From feae2e19a14a91c997308b937a11db9716407ff7 Mon Sep 17 00:00:00 2001 From: agent-company Date: Sat, 28 Mar 2026 13:04:55 +0000 Subject: [PATCH] feat: wire GITEA_TOKEN env var as auth fallback for single-user deployments Update Auth middleware to accept a fallbackToken parameter. When no per-user cookie token is present and GITEA_TOKEN is set in the environment, the middleware uses the env token instead of redirecting to /settings. Cookie tokens still take precedence over the fallback. Add three new unit tests covering: fallback used when no cookie, cookie takes precedence over fallback, and redirect when neither is set. Closes leeworks-agents/gitea-mobile#125 Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/server/main.go | 2 +- internal/middleware/auth.go | 12 ++++- internal/middleware/auth_test.go | 77 ++++++++++++++++++++++++++++++-- 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index fbe511b..15676a4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -33,7 +33,7 @@ func main() { // Apply middleware chain: logging -> auth. var handler http.Handler = mux - handler = middleware.Auth(cfg.SessionSecret)(handler) + handler = middleware.Auth(cfg.SessionSecret, cfg.GiteaToken)(handler) handler = middleware.Logging()(handler) slog.Info("server starting", "addr", cfg.ListenAddr, "gitea_url", cfg.GiteaURL) diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 3d09f2c..36eefab 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -23,9 +23,12 @@ func TokenFromContext(ctx context.Context) string { } // Auth returns middleware that checks for a valid token cookie. +// If no cookie token is found and fallbackToken is non-empty, the fallback +// token is used instead (useful for single-user or service-account deployments +// where GITEA_TOKEN is set in the environment). // Unauthenticated requests are redirected to the settings page. // The /health, /settings, and /static/ paths are exempt from auth. -func Auth(sessionSecret string) func(http.Handler) http.Handler { +func Auth(sessionSecret, fallbackToken string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Skip auth for exempt paths. @@ -37,6 +40,13 @@ func Auth(sessionSecret string) func(http.Handler) http.Handler { token, err := auth.GetToken(r, sessionSecret) if err != nil || token == "" { + // Fall back to environment token if available. + if fallbackToken != "" { + slog.Debug("using fallback token from environment", "path", path) + ctx := context.WithValue(r.Context(), TokenContextKey, fallbackToken) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } slog.Debug("unauthenticated request, redirecting to settings", "path", path, "error", err) http.Redirect(w, r, "/settings", http.StatusSeeOther) return diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go index d4cb61b..0c61d2e 100644 --- a/internal/middleware/auth_test.go +++ b/internal/middleware/auth_test.go @@ -11,7 +11,7 @@ import ( const testSecret = "test-secret-that-is-at-least-32-chars-long" func TestAuth_HealthBypass(t *testing.T) { - handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) @@ -25,7 +25,7 @@ func TestAuth_HealthBypass(t *testing.T) { } func TestAuth_SettingsBypass(t *testing.T) { - handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) @@ -39,7 +39,7 @@ func TestAuth_SettingsBypass(t *testing.T) { } func TestAuth_RedirectWithoutToken(t *testing.T) { - handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) @@ -57,7 +57,7 @@ func TestAuth_RedirectWithoutToken(t *testing.T) { func TestAuth_PassWithToken(t *testing.T) { called := false - handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true token := TokenFromContext(r.Context()) if token != "my-token" { @@ -83,3 +83,72 @@ func TestAuth_PassWithToken(t *testing.T) { t.Errorf("status = %d, want %d", w.Code, http.StatusOK) } } + +func TestAuth_FallbackToken_UsedWhenNoCookie(t *testing.T) { + called := false + handler := Auth(testSecret, "env-fallback-token")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + token := TokenFromContext(r.Context()) + if token != "env-fallback-token" { + t.Errorf("token = %q, want %q", token, "env-fallback-token") + } + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if !called { + t.Error("next handler was not called with fallback token") + } + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } +} + +func TestAuth_FallbackToken_CookieTakesPrecedence(t *testing.T) { + called := false + handler := Auth(testSecret, "env-fallback-token")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + token := TokenFromContext(r.Context()) + if token != "cookie-token" { + t.Errorf("token = %q, want %q (cookie should take precedence over fallback)", token, "cookie-token") + } + w.WriteHeader(http.StatusOK) + })) + + // Set a cookie token. + cookieW := httptest.NewRecorder() + auth.SetTokenCookie(cookieW, "cookie-token", testSecret, false) + cookie := cookieW.Result().Cookies()[0] + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(cookie) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if !called { + t.Error("next handler was not called") + } + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } +} + +func TestAuth_NoFallbackToken_RedirectsWithoutCookie(t *testing.T) { + handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/issues", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want %d", w.Code, http.StatusSeeOther) + } + if loc := w.Header().Get("Location"); loc != "/settings" { + t.Errorf("Location = %q, want %q", loc, "/settings") + } +} -- 2.52.0