package auth import ( "errors" "net/http" "net/http/httptest" "testing" "time" "github.com/golang-jwt/jwt/v5" ) // testSecret mirrors the format of a Supabase JWT signing key. var testSecret = []byte("test-secret-for-hs256-verification-123") func sign(t *testing.T, secret []byte, claims jwt.MapClaims) string { t.Helper() tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) s, err := tok.SignedString(secret) if err != nil { t.Fatalf("sign: %v", err) } return s } func TestVerifyToken_Valid(t *testing.T) { c := &Client{JWTSecret: testSecret} token := sign(t, testSecret, jwt.MapClaims{ "sub": "11111111-1111-1111-1111-111111111111", "exp": time.Now().Add(time.Hour).Unix(), }) got, err := c.VerifyToken(token) if err != nil { t.Fatalf("unexpected error: %v", err) } if got.Sub != "11111111-1111-1111-1111-111111111111" { t.Errorf("sub: got %q", got.Sub) } } func TestVerifyToken_WrongSecret(t *testing.T) { c := &Client{JWTSecret: testSecret} token := sign(t, []byte("attacker-guessed-wrong"), jwt.MapClaims{ "sub": "11111111-1111-1111-1111-111111111111", "exp": time.Now().Add(time.Hour).Unix(), }) if _, err := c.VerifyToken(token); err == nil { t.Fatal("expected error for wrong-signature token, got nil (auth bypass)") } } func TestVerifyToken_Expired(t *testing.T) { c := &Client{JWTSecret: testSecret} token := sign(t, testSecret, jwt.MapClaims{ "sub": "11111111-1111-1111-1111-111111111111", "exp": time.Now().Add(-time.Hour).Unix(), }) _, err := c.VerifyToken(token) if err == nil { t.Fatal("expected error for expired token") } if !errors.Is(err, jwt.ErrTokenExpired) { t.Errorf("expected ErrTokenExpired, got %v", err) } } func TestVerifyToken_AlgNone(t *testing.T) { c := &Client{JWTSecret: testSecret} // An attacker might try alg=none to bypass signature checks. tok := jwt.NewWithClaims(jwt.SigningMethodNone, jwt.MapClaims{ "sub": "22222222-2222-2222-2222-222222222222", "exp": time.Now().Add(time.Hour).Unix(), }) token, err := tok.SignedString(jwt.UnsafeAllowNoneSignatureType) if err != nil { t.Fatalf("sign none: %v", err) } if _, err := c.VerifyToken(token); err == nil { t.Fatal("expected error for alg=none, got nil (critical bypass)") } } func TestVerifyToken_MissingSub(t *testing.T) { c := &Client{JWTSecret: testSecret} token := sign(t, testSecret, jwt.MapClaims{ "exp": time.Now().Add(time.Hour).Unix(), }) if _, err := c.VerifyToken(token); err == nil { t.Fatal("expected error for missing sub claim") } } func TestVerifyToken_Garbage(t *testing.T) { c := &Client{JWTSecret: testSecret} if _, err := c.VerifyToken("not.a.jwt"); err == nil { t.Fatal("expected error for garbage token") } } // TestMiddleware_LegacyCookieAccepted covers the patholo_session → paliad_session // rename grace period: a user whose browser still holds the legacy cookie must // stay authenticated and receive the new cookie in the response. func TestMiddleware_LegacyCookieAccepted(t *testing.T) { c := &Client{JWTSecret: testSecret} token := sign(t, testSecret, jwt.MapClaims{ "sub": "11111111-1111-1111-1111-111111111111", "exp": time.Now().Add(time.Hour).Unix(), }) var nextHit bool next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { nextHit = true if _, ok := verifiedClaimsFromContext(r.Context()); !ok { t.Error("handler reached without verified claims in ctx") } w.WriteHeader(http.StatusOK) }) req := httptest.NewRequest("GET", "/api/anything", nil) req.AddCookie(&http.Cookie{Name: LegacySessionCookieName, Value: token}) rec := httptest.NewRecorder() c.Middleware(next).ServeHTTP(rec, req) if !nextHit { t.Fatalf("legacy cookie should authenticate; got status %d", rec.Code) } // Response should upgrade the caller to paliad_session and expire the legacy one. var sawNew, sawLegacyExpiry bool for _, cookie := range rec.Result().Cookies() { if cookie.Name == SessionCookieName && cookie.Value == token && cookie.MaxAge > 0 { sawNew = true } if cookie.Name == LegacySessionCookieName && cookie.MaxAge < 0 { sawLegacyExpiry = true } } if !sawNew { t.Error("expected paliad_session to be set on upgrade path") } if !sawLegacyExpiry { t.Error("expected patholo_session to be expired on upgrade path") } } func TestMiddleware_NewCookiePreferredOverLegacy(t *testing.T) { c := &Client{JWTSecret: testSecret} good := sign(t, testSecret, jwt.MapClaims{ "sub": "aaaa", "exp": time.Now().Add(time.Hour).Unix(), }) bad := "garbage" next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) req := httptest.NewRequest("GET", "/api/anything", nil) req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: good}) req.AddCookie(&http.Cookie{Name: LegacySessionCookieName, Value: bad}) rec := httptest.NewRecorder() c.Middleware(next).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 using paliad_session; got %d", rec.Code) } // When the new cookie authenticated the request, the legacy cookie must // not be re-copied on top of it (would clobber a valid session with a // stale one). for _, cookie := range rec.Result().Cookies() { if cookie.Name == SessionCookieName && cookie.Value == bad { t.Fatal("legacy cookie value was copied over the valid paliad_session — rename upgrade is unsafe") } } } func TestMiddleware_NoCookieRejected(t *testing.T) { c := &Client{JWTSecret: testSecret} next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("next should not run without auth") }) req := httptest.NewRequest("GET", "/api/anything", nil) rec := httptest.NewRecorder() c.Middleware(next).ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("expected 401 on API without cookies, got %d", rec.Code) } }