package oidc_test import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "keycape/internal/domain" "keycape/internal/server/oidc" "keycape/internal/server/telemetry" ) type statusFixture struct { token *oidc.TokenHandler status *oidc.TokenStatusHandler userinfo *oidc.UserinfoHandler users *mockUserRepo } func newStatusFixture(t *testing.T, capacity int) *statusFixture { t.Helper() users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}} tokens, err := oidc.NewTokenStatusStore(capacity) if err != nil { t.Fatal("store creation failed") } h, key := newTokenHandler(t, oidc.NewSessionStore(), users) h.Tokens = tokens h.ClientConfig["test-client"].ClientType = "confidential" h.ClientConfig["test-client"].ClientSecret = "synthetic-status-only" h.ClientConfig["other"] = &domain.Client{ClientID: "other", ClientType: "confidential", ClientSecret: "synthetic-other-only"} return &statusFixture{token: h, users: users, status: &oidc.TokenStatusHandler{Tokens: tokens, ClientConfig: h.ClientConfig, Users: users, SigningKey: &key.PublicKey, Issuer: h.Issuer}, userinfo: &oidc.UserinfoHandler{Tokens: tokens, ClientConfig: h.ClientConfig, Users: users, SigningKey: &key.PublicKey, Issuer: h.Issuer, Emitter: telemetry.NoopEmitter{}}, } } func (f *statusFixture) issue(t *testing.T) (string, string, int) { t.Helper() verifier, _ := makeVerifierAndChallenge() code := seededSession(f.token.Sessions, verifier) req := tokenRequest(url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {verifier}}) if f.token.ClientConfig["test-client"].ClientType == "confidential" { req.SetBasicAuth(url.QueryEscape("test-client"), url.QueryEscape(f.token.ClientConfig["test-client"].ClientSecret)) } w := httptest.NewRecorder() f.token.ServeHTTP(w, req) if w.Code != http.StatusOK { return "", "", w.Code } var body struct { Access string `json:"access_token"` ID string `json:"id_token"` } if json.Unmarshal(w.Body.Bytes(), &body) != nil || body.Access == "" || body.ID == "" { t.Fatal("invalid token response") } return body.Access, body.ID, w.Code } func (f *statusFixture) request(t *testing.T, raw, client, secret string, revoke bool, mutate func(*http.Request)) *httptest.ResponseRecorder { t.Helper() form := url.Values{"token": {raw}} if secret == "" { form.Set("client_id", client) } r := httptest.NewRequest(http.MethodPost, "/introspect", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") if secret != "" { r.SetBasicAuth(url.QueryEscape(client), url.QueryEscape(secret)) } if mutate != nil { mutate(r) } w := httptest.NewRecorder() if revoke { f.status.Revoke(w, r) } else { f.status.Introspect(w, r) } if w.Header().Get("Cache-Control") != "no-store" || w.Header().Get("Pragma") != "no-cache" { t.Fatal("missing private response controls") } return w } func (f *statusFixture) introspect(t *testing.T, raw string) map[string]interface{} { t.Helper() w := f.request(t, raw, "test-client", f.token.ClientConfig["test-client"].ClientSecret, false, nil) if w.Code != http.StatusOK { t.Fatalf("introspection status %d", w.Code) } var body map[string]interface{} if json.Unmarshal(w.Body.Bytes(), &body) != nil { t.Fatal("invalid introspection response") } return body } func (f *statusFixture) userinfoStatus(raw string) int { w := httptest.NewRecorder() f.userinfo.ServeHTTP(w, userinfoRequest(raw)) return w.Code } func TestTokenStatusActualIssueRevokeAndFreshGrant(t *testing.T) { f := newStatusFixture(t, 8) first, id, code := f.issue(t) if code != http.StatusOK { t.Fatalf("issue status %d", code) } active := f.introspect(t, first) if active["active"] != true || active["sub"] != "user-alice" || active["client_id"] != "test-client" || active["token_type"] != "Bearer" { t.Fatal("wrong active-token metadata") } if _, present := parseJWTPayload(t, id)["jti"]; present { t.Fatal("access-token lifecycle changed ID token") } if f.userinfoStatus(first) != http.StatusOK { t.Fatal("issued token refused") } second, _, code := f.issue(t) if code != http.StatusOK || first == second || active["jti"] == f.introspect(t, second)["jti"] { t.Fatal("fresh grants must be distinct") } for i := 0; i < 2; i++ { w := f.request(t, first, "test-client", "synthetic-status-only", true, nil) if w.Code != http.StatusOK || w.Body.Len() != 0 { t.Fatal("revocation must be empty idempotent success") } } if got := f.introspect(t, first); got["active"] != false || len(got) != 1 { t.Fatal("revoked metadata leaked or remained active") } if f.userinfoStatus(first) != http.StatusUnauthorized { t.Fatal("revoked token still accepted by real UserInfo") } if f.introspect(t, second)["active"] != true || f.userinfoStatus(second) != http.StatusOK { t.Fatal("revoking one grant affected another") } third, _, code := f.issue(t) if code != http.StatusOK || f.introspect(t, third)["active"] != true || f.introspect(t, first)["active"] != false { t.Fatal("fresh issuance reactivated a revoked grant") } } func TestTokenStatusNoCrossClientOrIDTokenAuthority(t *testing.T) { f := newStatusFixture(t, 8) raw, id, _ := f.issue(t) for _, invalid := range []string{id, "unknown.synthetic.token", raw + "changed"} { got := f.introspect(t, invalid) if len(got) != 1 || got["active"] != false { t.Fatal("non-access token disclosed metadata") } w := f.request(t, invalid, "test-client", "synthetic-status-only", true, nil) if w.Code != http.StatusOK || w.Body.Len() != 0 { t.Fatal("unknown revocation disclosed state") } } w := f.request(t, raw, "other", "synthetic-other-only", false, nil) if w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != `{"active":false}` { t.Fatal("other client read token") } w = f.request(t, raw, "other", "synthetic-other-only", true, nil) if w.Code != http.StatusOK || f.introspect(t, raw)["active"] != true { t.Fatal("other client revoked token") } } func TestTokenStatusCapacityFailsIssuanceWithoutEvictingLiveToken(t *testing.T) { f := newStatusFixture(t, 1) raw, _, code := f.issue(t) if code != http.StatusOK { t.Fatal("first issuance failed") } if _, _, code = f.issue(t); code != http.StatusServiceUnavailable { t.Fatalf("full store issue status %d", code) } if f.introspect(t, raw)["active"] != true { t.Fatal("capacity evicted live authority") } f.request(t, raw, "test-client", "synthetic-status-only", true, nil) if _, _, code = f.issue(t); code != http.StatusServiceUnavailable { t.Fatal("revocation tombstone was evicted before expiry") } } func TestTokenStatusRestartAndUnavailableStoreRefuse(t *testing.T) { f := newStatusFixture(t, 8) raw, _, _ := f.issue(t) empty, err := oidc.NewTokenStatusStore(8) if err != nil { t.Fatal(err) } f.status.Tokens, f.userinfo.Tokens = empty, empty if f.introspect(t, raw)["active"] != false || f.userinfoStatus(raw) != http.StatusUnauthorized { t.Fatal("restart resurrected unknown token") } f.status.Tokens = nil if w := f.request(t, raw, "test-client", "synthetic-status-only", false, nil); w.Code != http.StatusServiceUnavailable { t.Fatal("missing store allowed online status") } // Offline JWT validity remains a separate legacy contract. Do not claim // that adding an endpoint revokes tokens in consumers that never consult it. f.userinfo.Tokens = nil if f.userinfoStatus(raw) != http.StatusOK { t.Fatal("legacy profile changed") } } func TestTokenStatusRegistrationDriftAndDirectoryDisposition(t *testing.T) { for _, change := range []string{"removed", "audience", "secret", "scopes", "lifetime", "suspended", "disabled", "user-removed"} { t.Run(change, func(t *testing.T) { f := newStatusFixture(t, 8) raw, _, _ := f.issue(t) switch change { case "removed": delete(f.token.ClientConfig, "test-client") case "audience": f.token.ClientConfig["test-client"].Audience = "different-resource" case "secret": f.token.ClientConfig["test-client"].ClientSecret = "replacement-synthetic-only" case "scopes": f.token.ClientConfig["test-client"].AllowedScopes = []string{"openid"} case "lifetime": f.token.ClientConfig["test-client"].TokenLifetime = time.Minute case "suspended": f.users.users["alice"].Groups = []string{"netkingdom-suspended"} case "disabled": f.users.users["alice"].Enabled = false case "user-removed": delete(f.users.users, "alice") } if f.userinfoStatus(raw) != http.StatusUnauthorized { t.Fatal("UserInfo accepted changed authority") } if change != "removed" && f.introspect(t, raw)["active"] != false { t.Fatal("introspection accepted changed authority") } }) } } func TestTokenStatusPublicClientMayRevokeButNotIntrospect(t *testing.T) { f := newStatusFixture(t, 8) f.token.ClientConfig["test-client"].ClientType = "public" f.token.ClientConfig["test-client"].ClientSecret = "" raw, _, code := f.issue(t) if code != http.StatusOK { t.Fatal("public PKCE issue failed") } if w := f.request(t, raw, "test-client", "", false, nil); w.Code != http.StatusUnauthorized { t.Fatal("public client enumerated status") } if w := f.request(t, raw, "test-client", "", true, nil); w.Code != http.StatusOK { t.Fatal("public bearer revocation failed") } if f.userinfoStatus(raw) != http.StatusUnauthorized { t.Fatal("public revocation not enforced") } } func TestTokenStatusServiceGrantIsCurrentAndDistinct(t *testing.T) { f := newStatusFixture(t, 8) client := f.token.ClientConfig["test-client"] client.GrantTypes, client.ServiceSubject, client.Tenant = []string{"client_credentials"}, "service:status-fixture", "tenant:fixture" issue := func() string { r := tokenRequest(url.Values{"grant_type": {"client_credentials"}}) r.SetBasicAuth(url.QueryEscape(client.ClientID), url.QueryEscape(client.ClientSecret)) w := httptest.NewRecorder() f.token.ServeHTTP(w, r) if w.Code != http.StatusOK { t.Fatalf("service issue status %d", w.Code) } var body struct { Access string `json:"access_token"` } if json.Unmarshal(w.Body.Bytes(), &body) != nil || body.Access == "" { t.Fatal("service response invalid") } return body.Access } first, second := issue(), issue() if first == second || f.introspect(t, first)["active"] != true { t.Fatal("service grants not distinct/current") } f.request(t, first, client.ClientID, client.ClientSecret, true, nil) if f.introspect(t, first)["active"] != false || f.introspect(t, second)["active"] != true { t.Fatal("service revocation not token-scoped") } } func TestTokenStatusRejectsMalformedOrUnauthenticatedRequests(t *testing.T) { for _, kind := range []string{"get", "query", "json", "duplicate-token", "oversize", "wrong-secret", "missing-auth", "duplicate-auth", "body-secret", "mismatched-client"} { t.Run(kind, func(t *testing.T) { f := newStatusFixture(t, 8) raw, _, _ := f.issue(t) mutate := func(r *http.Request) { body := func(form url.Values) { r.Body = io.NopCloser(strings.NewReader(form.Encode())) } switch kind { case "get": r.Method = http.MethodGet case "query": r.URL.RawQuery = "token=must-never-use-url" case "json": r.Header.Set("Content-Type", "application/json") case "duplicate-token": body(url.Values{"token": {raw, raw}}) case "oversize": body(url.Values{"token": {strings.Repeat("x", 65537)}}) case "wrong-secret": r.SetBasicAuth("test-client", "wrong-synthetic-secret") case "missing-auth": r.Header.Del("Authorization") case "duplicate-auth": r.Header.Add("Authorization", "Basic extra") case "body-secret": body(url.Values{"token": {raw}, "client_secret": {"unsupported"}}) case "mismatched-client": body(url.Values{"token": {raw}, "client_id": {"other"}}) } } for _, revoke := range []bool{false, true} { w := f.request(t, raw, "test-client", "synthetic-status-only", revoke, mutate) if w.Code < 400 { t.Fatal("malformed request accepted") } } if f.introspect(t, raw)["active"] != true { t.Fatal("refused request changed token") } }) } } type blockingStatusUsers struct { *mockUserRepo entered, release chan struct{} } func (u *blockingStatusUsers) LookupUser(ctx context.Context, name string) (*domain.User, error) { close(u.entered) select { case <-u.release: return u.mockUserRepo.LookupUser(ctx, name) case <-ctx.Done(): return nil, ctx.Err() } } func TestTokenStatusRevocationDuringDirectoryLookupRefuses(t *testing.T) { f := newStatusFixture(t, 8) raw, _, _ := f.issue(t) users := &blockingStatusUsers{mockUserRepo: f.users, entered: make(chan struct{}), release: make(chan struct{})} f.status.Users = users done := make(chan *httptest.ResponseRecorder, 1) go func() { done <- f.request(t, raw, "test-client", "synthetic-status-only", false, nil) }() select { case <-users.entered: case <-time.After(3 * time.Second): t.Fatal("directory lookup not entered") } f.request(t, raw, "test-client", "synthetic-status-only", true, nil) close(users.release) select { case w := <-done: if strings.TrimSpace(w.Body.String()) != `{"active":false}` { t.Fatal("delayed lookup resurrected revoked token") } case <-time.After(3 * time.Second): t.Fatal("introspection did not finish") } }