turnstile-caddy/session_test.go
Lukas Schaefer d38dff43a1
All checks were successful
/ resolve-go (push) Successful in 3s
/ test (push) Successful in 6s
/ cleanup (push) Successful in 1s
Implement remember me support to survive browser restarts
Signed-off-by: Lukas Schaefer <lukas@lschaefer.xyz>
2026-09-12 23:24:56 -04:00

618 lines
20 KiB
Go

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")
}
}