package oidc_test import ( "context" "crypto/rand" "crypto/rsa" "encoding/json" "net/http" "net/http/httptest" "net/url" "testing" "time" josev4 "github.com/go-jose/go-jose/v4" "github.com/stretchr/testify/require" "gitea.d-ma.be/mathias/tapir/internal/web" "gitea.d-ma.be/mathias/tapir/internal/web/oidc" ) const ( testClientID = "tapir-web" testSubject = "dex-subject-123" ) // fakeIssuer is an httptest-backed OIDC provider: it serves a discovery // document, a JWKS, and a token endpoint that mints an RS256-signed ID token // from its mutable sub/email/nonce fields. No live Dex. type fakeIssuer struct { server *httptest.Server key *rsa.PrivateKey clientID string sub string email string nonce string } const testKID = "test-key" func newFakeIssuer(t *testing.T) *fakeIssuer { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) require.NoError(t, err) f := &fakeIssuer{key: key, clientID: testClientID} mux := http.NewServeMux() mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { writeJSON(t, w, map[string]any{ "issuer": f.server.URL, "authorization_endpoint": f.server.URL + "/authorize", "token_endpoint": f.server.URL + "/token", "jwks_uri": f.server.URL + "/jwks", "id_token_signing_alg_values_supported": []string{"RS256"}, "response_types_supported": []string{"code"}, "subject_types_supported": []string{"public"}, }) }) mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) { writeJSON(t, w, josev4.JSONWebKeySet{Keys: []josev4.JSONWebKey{{ Key: &f.key.PublicKey, KeyID: testKID, Algorithm: "RS256", Use: "sig", }}}) }) mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { writeJSON(t, w, map[string]any{ "access_token": "fake-access-token", "token_type": "Bearer", "expires_in": 3600, "id_token": f.signIDToken(t), }) }) f.server = httptest.NewServer(mux) t.Cleanup(f.server.Close) return f } func (f *fakeIssuer) signIDToken(t *testing.T) string { t.Helper() signer, err := josev4.NewSigner( josev4.SigningKey{Algorithm: josev4.RS256, Key: f.key}, (&josev4.SignerOptions{}).WithType("JWT").WithHeader("kid", testKID), ) require.NoError(t, err) now := time.Now() payload, err := json.Marshal(map[string]any{ "iss": f.server.URL, "sub": f.sub, "aud": f.clientID, "exp": now.Add(time.Hour).Unix(), "iat": now.Unix(), "nonce": f.nonce, "email": f.email, }) require.NoError(t, err) obj, err := signer.Sign(payload) require.NoError(t, err) s, err := obj.CompactSerialize() require.NoError(t, err) return s } func writeJSON(t *testing.T, w http.ResponseWriter, v any) { t.Helper() w.Header().Set("Content-Type", "application/json") require.NoError(t, json.NewEncoder(w).Encode(v)) } func newAuth(t *testing.T, f *fakeIssuer) *oidc.DexAuth { t.Helper() auth, err := oidc.New(context.Background(), oidc.Config{ Issuer: f.server.URL, ClientID: testClientID, ClientSecret: "test-client-secret", RedirectURL: "http://tapir.test/auth/callback", SessionSecret: "test-session-secret-please-change", }, oidc.WithInsecureCookies()) require.NoError(t, err) return auth } // login drives /auth/login and returns the state and nonce from the authorize // redirect, as a real browser hop would surface them. func login(t *testing.T, auth *oidc.DexAuth) (state, nonce string) { t.Helper() rec := httptest.NewRecorder() auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil)) require.Equal(t, http.StatusFound, rec.Code) loc, err := url.Parse(rec.Header().Get("Location")) require.NoError(t, err) q := loc.Query() return q.Get("state"), q.Get("nonce") } // authenticate completes a full login+callback for the test subject and returns // the resulting session cookie. func authenticate(t *testing.T, auth *oidc.DexAuth, f *fakeIssuer) *http.Cookie { t.Helper() state, nonce := login(t, auth) f.sub, f.email, f.nonce = testSubject, "maintainer@d-ma.be", nonce rec := httptest.NewRecorder() auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/callback?code=valid-code&state="+state, nil)) require.Equal(t, http.StatusFound, rec.Code) require.Equal(t, "/", rec.Header().Get("Location")) c := sessionCookie(t, rec.Result()) require.NotEmpty(t, c.Value) return c } func sessionCookie(t *testing.T, resp *http.Response) *http.Cookie { t.Helper() for _, c := range resp.Cookies() { if c.Name == "tapir_session" { return c } } t.Fatal("no tapir_session cookie set") return nil } func TestLoginRedirectsToAuthorize(t *testing.T) { f := newFakeIssuer(t) auth := newAuth(t, f) rec := httptest.NewRecorder() auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil)) require.Equal(t, http.StatusFound, rec.Code) loc, err := url.Parse(rec.Header().Get("Location")) require.NoError(t, err) require.Equal(t, f.server.URL+"/authorize", loc.Scheme+"://"+loc.Host+loc.Path) q := loc.Query() require.Equal(t, "code", q.Get("response_type")) require.Equal(t, testClientID, q.Get("client_id")) require.NotEmpty(t, q.Get("state")) require.NotEmpty(t, q.Get("nonce")) require.Contains(t, q.Get("scope"), "openid") } func TestCallbackSetsSession(t *testing.T) { f := newFakeIssuer(t) auth := newAuth(t, f) cookie := authenticate(t, auth, f) req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(cookie) user, ok := auth.CurrentUser(req) require.True(t, ok) require.Equal(t, testSubject, user.Subject) require.Equal(t, "maintainer@d-ma.be", user.Email) require.True(t, cookie.HttpOnly) require.Equal(t, http.SameSiteLaxMode, cookie.SameSite) } // TestCallbackAnySubjectAuthenticates proves the single-subject allowlist is gone // (ADR-012): a subject other than any prior allowlist still gets a session. func TestCallbackAnySubjectAuthenticates(t *testing.T) { f := newFakeIssuer(t) auth := newAuth(t, f) state, nonce := login(t, auth) f.sub, f.email, f.nonce = "some-other-subject-999", "other@elsewhere.test", nonce rec := httptest.NewRecorder() auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/callback?code=valid-code&state="+state, nil)) require.Equal(t, http.StatusFound, rec.Code) require.Equal(t, "/", rec.Header().Get("Location")) cookie := sessionCookie(t, rec.Result()) req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(cookie) user, ok := auth.CurrentUser(req) require.True(t, ok) require.Equal(t, "some-other-subject-999", user.Subject) } func TestCallbackUnknownStateRejected(t *testing.T) { f := newFakeIssuer(t) auth := newAuth(t, f) rec := httptest.NewRecorder() auth.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/callback?code=valid-code&state=forged-state", nil)) require.Equal(t, http.StatusBadRequest, rec.Code) } func TestMiddlewareRedirectsUnauthenticated(t *testing.T) { f := newFakeIssuer(t) auth := newAuth(t, f) guarded := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) // The bare root sends an unauthenticated visitor to the public landing page. rec := httptest.NewRecorder() guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) require.Equal(t, http.StatusFound, rec.Code) require.Equal(t, "/welcome", rec.Header().Get("Location")) // A deeper guarded path goes to login so the post-login round-trip returns there. rec = httptest.NewRecorder() guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v/some-id", nil)) require.Equal(t, http.StatusFound, rec.Code) require.Equal(t, "/auth/login", rec.Header().Get("Location")) } func TestMiddlewarePassesAuthenticated(t *testing.T) { f := newFakeIssuer(t) auth := newAuth(t, f) cookie := authenticate(t, auth, f) guarded := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusTeapot) // a marker the handler actually ran })) req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() guarded.ServeHTTP(rec, req) require.Equal(t, http.StatusTeapot, rec.Code) } func TestMiddlewarePublicPathsBypassAuth(t *testing.T) { f := newFakeIssuer(t) auth := newAuth(t, f) guarded := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) for _, path := range []string{"/healthz", "/welcome", "/auth/login"} { rec := httptest.NewRecorder() guarded.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) require.Equal(t, http.StatusOK, rec.Code, "expected %s to bypass auth", path) } } func TestLogoutClearsSession(t *testing.T) { f := newFakeIssuer(t) auth := newAuth(t, f) cookie := authenticate(t, auth, f) req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil) req.AddCookie(cookie) rec := httptest.NewRecorder() auth.Routes().ServeHTTP(rec, req) require.Equal(t, http.StatusFound, rec.Code) require.Equal(t, "/welcome", rec.Header().Get("Location"), "logout lands on the public page") cleared := sessionCookie(t, rec.Result()) require.Less(t, cleared.MaxAge, 0, "logout expires the cookie so the browser drops it") require.Empty(t, cleared.Value, "logout blanks the cookie value") // Sessions are stateless (ADR-029): logout clears the cookie client-side, so a // request carrying the cleared (empty) cookie is unauthenticated. The original // signed cookie remains technically valid until its expiry — the accepted // trade for no server-side store; the browser no longer holds it. check := httptest.NewRequest(http.MethodGet, "/", nil) check.AddCookie(cleared) _, ok := auth.CurrentUser(check) require.False(t, ok, "the cleared cookie does not authenticate") } // TestSessionSurvivesRestart is the core of ADR-029: a cookie issued by one // process is accepted by a FRESH instance with the same session secret — so a // deploy/pod-restart no longer logs users out (the old in-memory store did). func TestSessionSurvivesRestart(t *testing.T) { f := newFakeIssuer(t) auth1 := newAuth(t, f) cookie := authenticate(t, auth1, f) auth2 := newAuth(t, f) // simulate a redeploy: new process, same SessionSecret req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(cookie) user, ok := auth2.CurrentUser(req) require.True(t, ok, "a session must survive a restart (stateless signed cookie)") require.Equal(t, testSubject, user.Subject) } // TestSessionCookieIsPersistent: the cookie carries a positive Max-Age so it // survives the browser/app being closed (a session cookie was dropped on iOS). func TestSessionCookieIsPersistent(t *testing.T) { f := newFakeIssuer(t) auth := newAuth(t, f) cookie := authenticate(t, auth, f) require.Greater(t, cookie.MaxAge, 0, "session cookie must be persistent (Max-Age set)") } func TestExpiredSessionRejected(t *testing.T) { f := newFakeIssuer(t) clock := time.Now() auth, err := oidc.New(context.Background(), oidc.Config{ Issuer: f.server.URL, ClientID: testClientID, ClientSecret: "test-client-secret", RedirectURL: "http://tapir.test/auth/callback", SessionSecret: "test-session-secret-please-change", }, oidc.WithInsecureCookies(), oidc.WithSessionTTL(time.Minute), oidc.WithClock(func() time.Time { return clock })) require.NoError(t, err) cookie := authenticate(t, auth, f) clock = clock.Add(2 * time.Minute) // push past the TTL req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(cookie) _, ok := auth.CurrentUser(req) require.False(t, ok) } func TestTamperedCookieRejected(t *testing.T) { f := newFakeIssuer(t) auth := newAuth(t, f) cookie := authenticate(t, auth, f) tampered := &http.Cookie{Name: cookie.Name, Value: cookie.Value + "x"} req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(tampered) _, ok := auth.CurrentUser(req) require.False(t, ok) } var _ web.Auth = (*oidc.DexAuth)(nil)