// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package auth import ( "context" "crypto" "crypto/rand" "crypto/rsa" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/coreos/go-oidc/v3/oidc" "github.com/go-jose/go-jose/v4" "github.com/spf13/viper" "golang.org/x/oauth2" "git.coopcloud.tech/wiki-cafe/member-console/internal/identity" "git.coopcloud.tech/wiki-cafe/member-console/internal/organization" ) // signingKey is a test RSA key, generated once per process: the verifier is // built over its public half, so a token signed with it is as verifiable // here as a provider's would be in production. var signingKey = func() *rsa.PrivateKey { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { panic(err) } return key }() // signIDToken produces a real, signed ID token carrying claims, the way the // identity provider's token endpoint would. func signIDToken(t *testing.T, claims map[string]any) string { t.Helper() signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: signingKey}, nil) if err != nil { t.Fatalf("signer: %v", err) } payload, err := json.Marshal(claims) if err != nil { t.Fatal(err) } jws, err := signer.Sign(payload) if err != nil { t.Fatalf("sign: %v", err) } raw, err := jws.CompactSerialize() if err != nil { t.Fatalf("serialize: %v", err) } return raw } // Returning-person fakes: the two queriers CallbackHandler consults when the // subject is already known. The interface is embedded so only the methods // this path calls need bodies; any other call is a nil dereference, which is // the failure a test should see. type fakeIdentity struct { identity.Querier user identity.User person identity.Person } func (f fakeIdentity) GetUserByOIDCSubject(context.Context, string) (identity.User, error) { return f.user, nil } func (f fakeIdentity) UpdateUserLogin(context.Context, identity.UpdateUserLoginParams) (identity.User, error) { return f.user, nil } func (f fakeIdentity) GetPersonByUserID(context.Context, string) (identity.Person, error) { return f.person, nil } func (f fakeIdentity) UpdatePerson(context.Context, identity.UpdatePersonParams) (identity.Person, error) { return f.person, nil } type fakeOrganization struct { organization.Querier } func (fakeOrganization) GetOrganizationsByOwner(context.Context, string) ([]organization.Organization, error) { return []organization.Organization{{OrgID: "org-1"}}, nil } func (fakeOrganization) GetWorkspacesByOrgID(context.Context, string) ([]organization.Workspace, error) { return []organization.Workspace{{WorkspaceID: "ws-1"}}, nil } // The refresh token the provider issues at sign-in is kept in the session, // because sign-out mints a fresh id_token_hint from it. This drives the whole // callback: a real signed ID token, verified against the signing key, with // the nonce the session holds, exchanged at a fake token endpoint. func TestCallbackStoresTheRefreshToken(t *testing.T) { const ( issuer = "https://idp.test/realms/main" clientID = "test-client" nonce = "the-nonce" state = "the-state" ) viper.Set("base-url", "http://console.test") t.Cleanup(viper.Reset) idToken := signIDToken(t, map[string]any{ "iss": issuer, "sub": "subject-1", "aud": clientID, "exp": time.Now().Add(5 * time.Minute).Unix(), "iat": time.Now().Unix(), "nonce": nonce, "email": "bob@example.test", "email_verified": true, "name": "Bob Builder", "preferred_username": "bob", "roles": []string{}, }) var exchanged struct{ grant, code string } tokenEndpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { t.Errorf("token endpoint: %v", err) } exchanged.grant = r.PostForm.Get("grant_type") exchanged.code = r.PostForm.Get("code") w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "access_token": "access-1", "token_type": "Bearer", "expires_in": 300, "refresh_token": "refresh-from-the-provider", "id_token": idToken, }) })) t.Cleanup(tokenEndpoint.Close) cfg := newTestConfig() cfg.OAuthConfig = &oauth2.Config{ ClientID: clientID, ClientSecret: "test-secret", RedirectURL: "http://console.test/callback", Endpoint: oauth2.Endpoint{AuthURL: issuer + "/auth", TokenURL: tokenEndpoint.URL}, } cfg.Verifier = oidc.NewVerifier(issuer, &oidc.StaticKeySet{PublicKeys: []crypto.PublicKey{&signingKey.PublicKey}}, &oidc.Config{ClientID: clientID}) cfg.IdentityQ = fakeIdentity{ user: identity.User{UserID: "user-1", OidcSubject: "subject-1"}, person: identity.Person{PersonID: "person-1", UserID: "user-1", DisplayName: "Bob Builder", PrimaryEmail: "bob@example.test"}, } cfg.OrgQ = fakeOrganization{} // The session /login would have left behind. seed := httptest.NewRequest(http.MethodGet, "/login", nil) token := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) { cfg.SessionManager.Put(r.Context(), sessionKeyState, state) cfg.SessionManager.Put(r.Context(), sessionKeyNonce, nonce) cfg.SessionManager.Put(r.Context(), sessionKeyCodeVerifier, "verifier-1") }, seed) if token == "" { t.Fatal("setup: no session token was issued") } req := httptest.NewRequest(http.MethodGet, "/callback?state="+state+"&code=code-1", nil) req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token}) rec := httptest.NewRecorder() cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.CallbackHandler)).ServeHTTP(rec, req) if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" { t.Fatalf("status %d Location %q body %q; want a 302 to /", rec.Code, rec.Header().Get("Location"), rec.Body.String()) } if exchanged.grant != "authorization_code" || exchanged.code != "code-1" { t.Errorf("token endpoint saw grant %q code %q", exchanged.grant, exchanged.code) } // Read the session back through the manager, as a later request would. var got struct { authenticated bool personID string refreshToken string idToken string } after := httptest.NewRequest(http.MethodGet, "/", nil) after.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: callbackSessionToken(t, rec, cfg)}) cfg.SessionManager.LoadAndSave(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got.authenticated = cfg.SessionManager.GetBool(r.Context(), sessionKeyAuthenticated) got.personID = cfg.SessionManager.GetString(r.Context(), sessionKeyPersonID) got.refreshToken = cfg.SessionManager.GetString(r.Context(), sessionKeyRefreshToken) got.idToken = cfg.SessionManager.GetString(r.Context(), sessionKeyIDToken) })).ServeHTTP(httptest.NewRecorder(), after) if !got.authenticated || got.personID != "person-1" { t.Errorf("session after sign-in: authenticated=%v person=%q", got.authenticated, got.personID) } if got.refreshToken != "refresh-from-the-provider" { t.Errorf("refresh token in session = %q, want the one the provider issued", got.refreshToken) } if got.idToken != idToken { t.Error("the ID token in the session is not the one the provider issued") } } // callbackSessionToken returns the session token the callback response set. func callbackSessionToken(t *testing.T, rec *httptest.ResponseRecorder, cfg *Config) string { t.Helper() for _, c := range rec.Result().Cookies() { if c.Name == cfg.SessionManager.Cookie.Name { return c.Value } } t.Fatal("the callback response set no session cookie") return "" } // newCallbackTestFixture wires the same happy-path callback // (TestCallbackStoresTheRefreshToken) needs, for the rotation and deadline // tests below, which care about the session token and its deadline rather // than the claims a successful sign-in stores. type callbackTestFixture struct { cfg *Config nonce string } func newCallbackTestFixture(t *testing.T) callbackTestFixture { t.Helper() const ( issuer = "https://idp.test/realms/main" clientID = "test-client" nonce = "the-nonce" ) viper.Set("base-url", "http://console.test") t.Cleanup(viper.Reset) idToken := signIDToken(t, map[string]any{ "iss": issuer, "sub": "subject-1", "aud": clientID, "exp": time.Now().Add(5 * time.Minute).Unix(), "iat": time.Now().Unix(), "nonce": nonce, "email": "bob@example.test", "email_verified": true, "name": "Bob Builder", "preferred_username": "bob", "roles": []string{}, }) tokenEndpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "access_token": "access-1", "token_type": "Bearer", "expires_in": 300, "refresh_token": "refresh-from-the-provider", "id_token": idToken, }) })) t.Cleanup(tokenEndpoint.Close) cfg := newTestConfig() cfg.OAuthConfig = &oauth2.Config{ ClientID: clientID, ClientSecret: "test-secret", RedirectURL: "http://console.test/callback", Endpoint: oauth2.Endpoint{AuthURL: issuer + "/auth", TokenURL: tokenEndpoint.URL}, } cfg.Verifier = oidc.NewVerifier(issuer, &oidc.StaticKeySet{PublicKeys: []crypto.PublicKey{&signingKey.PublicKey}}, &oidc.Config{ClientID: clientID}) cfg.IdentityQ = fakeIdentity{ user: identity.User{UserID: "user-1", OidcSubject: "subject-1"}, person: identity.Person{PersonID: "person-1", UserID: "user-1", DisplayName: "Bob Builder", PrimaryEmail: "bob@example.test"}, } cfg.OrgQ = fakeOrganization{} return callbackTestFixture{cfg: cfg, nonce: nonce} } // runSuccessfulCallback seeds the pending-flow session /login would have // left and drives it through /callback, exactly as TestCallbackStoresTheRefreshToken // does, returning the response and the session token the seed step issued // (the one the browser held while at the provider). func runSuccessfulCallback(t *testing.T, cfg *Config, nonce string) (rec *httptest.ResponseRecorder, seedToken string) { t.Helper() const state = "the-state" seed := httptest.NewRequest(http.MethodGet, "/login", nil) seedToken = tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) { cfg.SessionManager.Put(r.Context(), sessionKeyState, state) cfg.SessionManager.Put(r.Context(), sessionKeyNonce, nonce) cfg.SessionManager.Put(r.Context(), sessionKeyCodeVerifier, "verifier-1") }, seed) if seedToken == "" { t.Fatal("setup: no session token was issued") } req := httptest.NewRequest(http.MethodGet, "/callback?state="+state+"&code=code-1", nil) req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: seedToken}) rec = httptest.NewRecorder() cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.CallbackHandler)).ServeHTTP(rec, req) if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" { t.Fatalf("callback status %d Location %q body %q; want a 302 to /", rec.Code, rec.Header().Get("Location"), rec.Body.String()) } return rec, seedToken } // sessionAuthenticated reads the authenticated flag directly through the // session store for token, the way CallbackHandler's own tests read the // session back — not through Middleware, which needs an IdentityQ that // answers GetPersonByID and which the callback fixtures above do not wire. func sessionAuthenticated(t *testing.T, cfg *Config, token string) bool { t.Helper() var got bool req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token}) cfg.SessionManager.LoadAndSave(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got = cfg.SessionManager.GetBool(r.Context(), sessionKeyAuthenticated) })).ServeHTTP(httptest.NewRecorder(), req) return got } // The verdict behind design D2: the callback binds the identity into // whatever session presents the matching state, without a new token, so a // session id fixed between /login and /callback survives sign-in. RenewToken // after the checks pass and before the identity is written closes that. func TestCallbackRotatesTheSessionToken(t *testing.T) { fx := newCallbackTestFixture(t) rec, seedToken := runSuccessfulCallback(t, fx.cfg, fx.nonce) newToken := callbackSessionToken(t, rec, fx.cfg) if newToken == seedToken { t.Fatalf("session token was not rotated at the callback (%q); a session id fixed between /login and /callback would survive sign-in", newToken) } if sessionAuthenticated(t, fx.cfg, seedToken) { t.Error("the token the browser held while at the provider still resolves to the authenticated session") } if !sessionAuthenticated(t, fx.cfg, newToken) { t.Error("the rotated session token does not resolve to the authenticated session") } } // Design D3: a pre-authentication session lives fifteen minutes // (LoginHandler); a successful callback restores the full configured // lifetime, since the person is now signed in for the session's ordinary // life rather than mid pending flow. func TestCallbackRestoresTheFullSessionLifetime(t *testing.T) { fx := newCallbackTestFixture(t) rec, _ := runSuccessfulCallback(t, fx.cfg, fx.nonce) newToken := callbackSessionToken(t, rec, fx.cfg) var deadline time.Time req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(&http.Cookie{Name: fx.cfg.SessionManager.Cookie.Name, Value: newToken}) fx.cfg.SessionManager.LoadAndSave(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { deadline = fx.cfg.SessionManager.Deadline(r.Context()) })).ServeHTTP(httptest.NewRecorder(), req) want := time.Now().Add(fx.cfg.SessionManager.Lifetime) if diff := deadline.Sub(want); diff < -time.Minute || diff > time.Minute { t.Errorf("deadline = %v, want within a minute of %v (the full session lifetime from now)", deadline, want) } }