package turnstile import ( "context" _ "embed" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/modules/caddyhttp" ) //go:embed settings.html var forgejoSettingsHTML []byte func TestSessionPassCookieRoundTrip(t *testing.T) { signer, err := newSessionPassSigner("test-secret-key-please-change", "__turnstile_session_pass", time.Hour) if err != nil { t.Fatal(err) } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "https://example.com/owner/repo/src/x", nil) if err := signer.set(rec, req, "forgejo-session-token", "remember-token", "alice"); err != nil { t.Fatal(err) } req2 := httptest.NewRequest(http.MethodGet, "https://example.com/owner/repo/src/x", nil) req2.AddCookie(rec.Result().Cookies()[0]) username, ok := signer.valid(req2, "forgejo-session-token", "") if !ok || username != "alice" { t.Fatalf("expected session pass cookie to validate with session alone, got %q ok=%v", username, ok) } username, ok = signer.valid(req2, "", "remember-token") if !ok || username != "alice" { t.Fatalf("expected session pass cookie to validate with remember-me alone, got %q ok=%v", username, ok) } } func TestSessionPassCookieRejectsTokenMismatch(t *testing.T) { signer, err := newSessionPassSigner("test-secret-key-please-change", "__turnstile_session_pass", time.Hour) if err != nil { t.Fatal(err) } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) if err := signer.set(rec, req, "old-session-token", "old-remember", "alice"); err != nil { t.Fatal(err) } req2 := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) req2.AddCookie(rec.Result().Cookies()[0]) if _, ok := signer.valid(req2, "new-session-token", "new-remember"); ok { t.Fatal("cookie for different credentials must not validate") } // Changed session must reject even when remember-me still matches. if _, ok := signer.valid(req2, "new-session-token", "old-remember"); ok { t.Fatal("mismatched session must not fall through to remember-me") } } func TestSessionPassCookieRejectsEmptyCredentials(t *testing.T) { signer, err := newSessionPassSigner("test-secret-key-please-change", "__turnstile_session_pass", time.Hour) if err != nil { t.Fatal(err) } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) if err := signer.set(rec, req, "", "", "alice"); err == nil { t.Fatal("expected set with empty credentials to fail") } if err := signer.set(rec, req, "session-token", "", "alice"); err != nil { t.Fatal(err) } req2 := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) req2.AddCookie(rec.Result().Cookies()[0]) if _, ok := signer.valid(req2, "", ""); ok { t.Fatal("empty credentials must not validate") } if _, ok := signer.valid(req2, "", "unrelated-remember"); ok { t.Fatal("empty stored remember binding must not match a present remember cookie") } } func TestParseForgejoSettingsUser(t *testing.T) { username, ok := parseForgejoSettingsUser(forgejoSettingsHTML) if !ok || username != "alice" { t.Fatalf("expected alice from settings.html, got %q ok=%v", username, ok) } } func TestForgejoAuthorizationHeaderToken(t *testing.T) { tests := []struct { header string want string ok bool }{ {"token abc123", "token abc123", true}, {"Bearer oauth-token", "Bearer oauth-token", true}, {"TOKEN abc123", "TOKEN abc123", true}, {"", "", false}, {"Basic dXNlcjpwYXNz", "", false}, {"token", "", false}, {"token ", "", false}, } for _, tc := range tests { req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) if tc.header != "" { req.Header.Set("Authorization", tc.header) } got, ok := forgejoAuthorizationHeaderToken(req) if ok != tc.ok || got != tc.want { t.Fatalf("forgejoAuthorizationHeaderToken(%q) = (%q, %v), want (%q, %v)", tc.header, got, ok, tc.want, tc.ok) } } } func TestParseForgejoAPIUser(t *testing.T) { body, err := json.Marshal(map[string]any{"username": "alice", "id": 1}) if err != nil { t.Fatal(err) } username, ok := parseForgejoAPIUser(body) if !ok || username != "alice" { t.Fatalf("expected alice, got %q ok=%v", username, ok) } if _, ok := parseForgejoAPIUser([]byte(`{"id":1}`)); ok { t.Fatal("expected missing username to fail") } } func TestVerifyForgejoAuthorizationToken(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != forgejoUserPath { http.NotFound(w, r) return } if r.Header.Get("Authorization") != "token valid-api-token" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } _ = json.NewEncoder(w).Encode(map[string]any{"username": "alice", "id": 1}) })) defer upstream.Close() m := Middleware{ ForgejoCookieName: "i_like_gitea", Upstream: upstream.URL, } req := httptest.NewRequest(http.MethodGet, "https://example.com/owner/repo/src/x", nil) req.Host = "example.com" req.Header.Set("Authorization", "token valid-api-token") username, ok, err := m.verifyForgejoAuthorizationToken(req, "token valid-api-token") if err != nil { t.Fatal(err) } if !ok || username != "alice" { t.Fatalf("expected valid authorization token with user alice, got %q ok=%v", username, ok) } req2 := httptest.NewRequest(http.MethodGet, "https://example.com/owner/repo/src/x", nil) username, ok, err = m.verifyForgejoAuthorizationToken(req2, "token invalid-api-token") if err != nil { t.Fatal(err) } if ok { t.Fatalf("expected invalid authorization token, got user %q", username) } } func TestEnsureSessionUserIDPrefersAuthorizationHeader(t *testing.T) { var seenPath string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seenPath = r.URL.Path switch r.URL.Path { case forgejoUserPath: if r.Header.Get("Authorization") != "token valid-api-token" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } _ = json.NewEncoder(w).Encode(map[string]any{"username": "bob", "id": 2}) case forgejoSettingsPath: t.Fatal("session cookie verification must not run when Authorization header is present") default: http.NotFound(w, r) } })) defer upstream.Close() signer, err := newSessionPassSigner("test-secret-key-please-change", "__turnstile_session_pass", time.Hour) if err != nil { t.Fatal(err) } m := Middleware{ ForgejoCookieName: "i_like_gitea", Upstream: upstream.URL, sessionPasses: signer, } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "https://example.com/owner/repo/src/x", nil) req.Header.Set("Authorization", "token valid-api-token") req.AddCookie(&http.Cookie{Name: "i_like_gitea", Value: "valid-session"}) vars := map[string]any{} ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars) repl := caddy.NewReplacer() ctx = context.WithValue(ctx, caddy.ReplacerCtxKey, repl) req = req.WithContext(ctx) if !m.ensureSessionUserID(rec, req) { t.Fatal("expected authorization header auth") } if seenPath != forgejoUserPath { t.Fatalf("expected %s, got %q", forgejoUserPath, seenPath) } if vars["user_id"] != "bob" { t.Fatalf("expected user_id bob in vars, got %#v", vars["user_id"]) } for _, c := range rec.Result().Cookies() { req.AddCookie(c) } seenPath = "" if !m.ensureSessionUserID(rec, req) { t.Fatal("expected cached authorization auth") } if seenPath != "" { t.Fatal("expected pass cookie to skip upstream verification") } } func TestEnsureSessionUserIDFromAuthorizationPassCookie(t *testing.T) { signer, err := newSessionPassSigner("test-secret-key-please-change", "__turnstile_session_pass", time.Hour) if err != nil { t.Fatal(err) } m := Middleware{ ForgejoCookieName: "i_like_gitea", sessionPasses: signer, } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) req.Header.Set("Authorization", "token api-token") if err := signer.set(rec, req, "token api-token", "", "bob"); err != nil { t.Fatal(err) } req.AddCookie(rec.Result().Cookies()[0]) vars := map[string]any{} ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars) repl := caddy.NewReplacer() ctx = context.WithValue(ctx, caddy.ReplacerCtxKey, repl) req = req.WithContext(ctx) if !m.ensureSessionUserID(rec, req) { t.Fatal("expected authorization pass cookie auth") } if vars["user_id"] != "bob" { t.Fatalf("expected user_id bob in vars, got %#v", vars["user_id"]) } } func TestEnsureSessionUserIDDisabledWithoutForgejoCookieName(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("forgejo upstream must not be called when forgejo auth is disabled") })) defer upstream.Close() signer, err := newSessionPassSigner("test-secret-key-please-change", "__turnstile_session_pass", time.Hour) if err != nil { t.Fatal(err) } m := Middleware{ Upstream: upstream.URL, sessionPasses: signer, } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) req.Header.Set("Authorization", "token valid-api-token") req.AddCookie(&http.Cookie{Name: "i_like_gitea", Value: "valid-session"}) if m.ensureSessionUserID(rec, req) { t.Fatal("expected forgejo auth to be disabled without forgejo_cookie_name") } } func TestNormalizeUpstream(t *testing.T) { tests := []struct { in string want string }{ {"127.0.0.1:3000", "http://127.0.0.1:3000"}, {"http://127.0.0.1:3000", "http://127.0.0.1:3000"}, {"http://forgejo.internal:3000/", "http://forgejo.internal:3000"}, } for _, tc := range tests { got, err := normalizeUpstream(tc.in) if err != nil { t.Fatalf("normalizeUpstream(%q): %v", tc.in, err) } if got != tc.want { t.Fatalf("normalizeUpstream(%q) = %q, want %q", tc.in, got, tc.want) } } if _, err := normalizeUpstream(""); err == nil { t.Fatal("expected empty upstream to be rejected") } if _, err := normalizeUpstream("http://127.0.0.1:3000/extra"); err == nil { t.Fatal("expected path in upstream to be rejected") } } func TestVerifyForgejoSession(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != forgejoSettingsPath { http.NotFound(w, r) return } c, err := r.Cookie("i_like_gitea") if err != nil || c.Value != "valid-session" { http.Redirect(w, r, "/user/login", http.StatusSeeOther) return } _, _ = w.Write(forgejoSettingsHTML) })) defer upstream.Close() m := Middleware{ ForgejoCookieName: "i_like_gitea", Upstream: upstream.URL, } req := httptest.NewRequest(http.MethodGet, "https://example.com/owner/repo/src/x", nil) req.Host = "example.com" req.AddCookie(&http.Cookie{Name: "i_like_gitea", Value: "valid-session"}) username, ok, err := m.verifyForgejoSession(req, "valid-session") if err != nil { t.Fatal(err) } if !ok || username != "alice" { t.Fatalf("expected valid forgejo session with user alice, got %q ok=%v", username, ok) } req2 := httptest.NewRequest(http.MethodGet, "https://example.com/owner/repo/src/x", nil) username, ok, err = m.verifyForgejoSession(req2, "invalid-session") if err != nil { t.Fatal(err) } if ok { t.Fatalf("expected invalid forgejo session, got user %q", username) } } func TestVerifyForgejoSessionRejectsRedirect(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/user/login", http.StatusSeeOther) })) defer upstream.Close() m := Middleware{ ForgejoCookieName: "i_like_gitea", Upstream: upstream.URL, } req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) _, ok, err := m.verifyForgejoSession(req, "valid-session") if err != nil { t.Fatal(err) } if ok { t.Fatal("expected redirect response to be treated as unauthenticated") } } func TestVerifyForgejoSessionForwardsHost(t *testing.T) { var seenHost string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seenHost = r.Host _, _ = w.Write(forgejoSettingsHTML) })) defer upstream.Close() m := Middleware{ ForgejoCookieName: "i_like_gitea", Upstream: upstream.URL, } req := httptest.NewRequest(http.MethodGet, "https://git.example.com/", nil) req.Host = "git.example.com" _, ok, err := m.verifyForgejoSession(req, "valid-session") if err != nil || !ok { t.Fatalf("verifyForgejoSession() = ok=%v err=%v", ok, err) } if seenHost != "git.example.com" { t.Fatalf("expected Host git.example.com, got %q", seenHost) } } func TestEnsureSessionUserIDSetsCookie(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != forgejoSettingsPath { http.NotFound(w, r) return } _, _ = w.Write(forgejoSettingsHTML) })) defer upstream.Close() signer, err := newSessionPassSigner("test-secret-key-please-change", "__turnstile_session_pass", time.Hour) if err != nil { t.Fatal(err) } m := Middleware{ ForgejoCookieName: "i_like_gitea", Upstream: upstream.URL, sessionPasses: signer, } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "https://example.com/owner/repo/src/x", nil) req.AddCookie(&http.Cookie{Name: "i_like_gitea", Value: "valid-session"}) if !m.ensureSessionUserID(rec, req) { t.Fatal("expected session auth") } cookies := rec.Result().Cookies() if len(cookies) != 1 || cookies[0].Name != "__turnstile_session_pass" { t.Fatalf("expected session pass cookie, got %#v", cookies) } } func TestEnsureSessionUserIDBeforeTurnstilePassCookie(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != forgejoSettingsPath { http.NotFound(w, r) return } _, _ = w.Write(forgejoSettingsHTML) })) defer upstream.Close() sessionSigner, err := newSessionPassSigner("test-secret-key-please-change", "__turnstile_session_pass", time.Hour) if err != nil { t.Fatal(err) } passSigner, err := newCookieSigner("turnstile-secret", "__turnstile_pass", time.Hour) if err != nil { t.Fatal(err) } m := Middleware{ ForgejoCookieName: "i_like_gitea", Upstream: upstream.URL, sessionPasses: sessionSigner, cookies: passSigner, } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) req.AddCookie(&http.Cookie{Name: "i_like_gitea", Value: "valid-session"}) if err := passSigner.set(rec, req); err != nil { t.Fatal(err) } req.AddCookie(rec.Result().Cookies()[0]) vars := map[string]any{} ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars) repl := caddy.NewReplacer() ctx = context.WithValue(ctx, caddy.ReplacerCtxKey, repl) req = req.WithContext(ctx) if !m.ensureSessionUserID(rec, req) { t.Fatal("expected session auth") } if vars["user_id"] != "alice" { t.Fatalf("expected user_id alice in vars, got %#v", vars["user_id"]) } if id, _ := repl.Get("http.auth.user.id"); id != "alice" { t.Fatalf("expected http.auth.user.id alice in replacer, got %#v", id) } } func TestEnsureSessionUserIDFromPassCookie(t *testing.T) { signer, err := newSessionPassSigner("test-secret-key-please-change", "__turnstile_session_pass", time.Hour) if err != nil { t.Fatal(err) } m := Middleware{ ForgejoCookieName: "i_like_gitea", sessionPasses: signer, } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) req.AddCookie(&http.Cookie{Name: "i_like_gitea", Value: "forgejo-session-token"}) if err := signer.set(rec, req, "forgejo-session-token", "", "alice"); err != nil { t.Fatal(err) } req.AddCookie(rec.Result().Cookies()[0]) vars := map[string]any{} ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars) repl := caddy.NewReplacer() ctx = context.WithValue(ctx, caddy.ReplacerCtxKey, repl) req = req.WithContext(ctx) m.ensureSessionUserID(rec, req) if vars["user_id"] != "alice" { t.Fatalf("expected user_id alice in vars, got %#v", vars["user_id"]) } if id, _ := repl.Get("http.auth.user.id"); id != "alice" { t.Fatalf("expected http.auth.user.id alice in replacer, got %#v", id) } } func TestEnsureSessionUserIDFromRememberPassCookie(t *testing.T) { signer, err := newSessionPassSigner("test-secret-key-please-change", "__turnstile_session_pass", time.Hour) if err != nil { t.Fatal(err) } m := Middleware{ ForgejoCookieName: "i_like_gitea", ForgejoRememberCookieName: "persistent", sessionPasses: signer, } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) if err := signer.set(rec, req, "forgejo-session-token", "remember-token", "alice"); err != nil { t.Fatal(err) } // Browser restart: session cookie gone, remember-me remains. req2 := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) req2.AddCookie(rec.Result().Cookies()[0]) req2.AddCookie(&http.Cookie{Name: "persistent", Value: "remember-token"}) vars := map[string]any{} ctx := context.WithValue(req2.Context(), caddyhttp.VarsCtxKey, vars) repl := caddy.NewReplacer() ctx = context.WithValue(ctx, caddy.ReplacerCtxKey, repl) req2 = req2.WithContext(ctx) if !m.ensureSessionUserID(httptest.NewRecorder(), req2) { t.Fatal("expected remember-me pass cookie auth") } if vars["user_id"] != "alice" { t.Fatalf("expected user_id alice in vars, got %#v", vars["user_id"]) } } func TestEnsureSessionUserIDReverifiesChangedSession(t *testing.T) { var verifiedSessions []string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != forgejoSettingsPath { http.NotFound(w, r) return } c, err := r.Cookie("i_like_gitea") if err != nil { http.Redirect(w, r, "/user/login", http.StatusSeeOther) return } verifiedSessions = append(verifiedSessions, c.Value) _, _ = w.Write(forgejoSettingsHTML) })) defer upstream.Close() signer, err := newSessionPassSigner("test-secret-key-please-change", "__turnstile_session_pass", time.Hour) if err != nil { t.Fatal(err) } m := Middleware{ ForgejoCookieName: "i_like_gitea", ForgejoRememberCookieName: "persistent", Upstream: upstream.URL, sessionPasses: signer, } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) if err := signer.set(rec, req, "old-session", "remember-token", "alice"); err != nil { t.Fatal(err) } req2 := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) req2.AddCookie(rec.Result().Cookies()[0]) req2.AddCookie(&http.Cookie{Name: "i_like_gitea", Value: "new-session"}) req2.AddCookie(&http.Cookie{Name: "persistent", Value: "remember-token"}) vars := map[string]any{} ctx := context.WithValue(req2.Context(), caddyhttp.VarsCtxKey, vars) repl := caddy.NewReplacer() ctx = context.WithValue(ctx, caddy.ReplacerCtxKey, repl) req2 = req2.WithContext(ctx) out := httptest.NewRecorder() if !m.ensureSessionUserID(out, req2) { t.Fatal("expected changed session to re-verify successfully") } if len(verifiedSessions) != 1 || verifiedSessions[0] != "new-session" { t.Fatalf("expected upstream verify of new-session, got %#v", verifiedSessions) } if vars["user_id"] != "alice" { t.Fatalf("expected user_id alice, got %#v", vars["user_id"]) } passCookies := out.Result().Cookies() if len(passCookies) != 1 || passCookies[0].Name != "__turnstile_session_pass" { t.Fatalf("expected refreshed session pass cookie, got %#v", passCookies) } req3 := httptest.NewRequest(http.MethodGet, "https://example.com/", nil) req3.AddCookie(passCookies[0]) if _, ok := signer.valid(req3, "new-session", "remember-token"); !ok { t.Fatal("refreshed pass cookie should validate with new session") } }