diff --git a/SCOPE.md b/SCOPE.md index ab2522b..ef9506b 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -42,8 +42,10 @@ Keycloak interchangeability are not established. - Client registrations, service secrets and the signing key are loaded at startup. Login, authorization and handoff state are process-local. There is no shared - session store, general refresh-token flow, token introspection/revocation API, - or automatic signing-key/client-secret rotation service. Logout clears the + session store, general refresh-token flow, or automatic signing-key/client-secret + rotation service. The default profile remains expiry-only. Proposed KEY-WP-0042 + adds an explicitly enabled, singleton, hash-only online access-token status + profile; see `docs/online-token-status.md` for its scope and admission limits. Logout clears the local KeyCape session, not every upstream or downstream session/token. - The optional tenant-engine `tenant_roles` adapter is wired through a `tenantEngine` config block and is off unless `baseURL` is set (KEY-WP-0024). diff --git a/docs/online-token-status.md b/docs/online-token-status.md new file mode 100644 index 0000000..331e6df --- /dev/null +++ b/docs/online-token-status.md @@ -0,0 +1,119 @@ +# Optional online access-token status + +Source proposal under KEY-WP-0042, driven by CUST-WP-0080 and existing +NK-WP-0048-T34/T42/T43 and RAPPS-WP-0016-T08/T09. Independent issuer/consumer +contract review and native admission remain required. Existing deployments are +unchanged. This extends the bounded issuer; it does not introduce a broker, +approval store, PDP, or a second application-local token registry. + +The legacy profile authenticates self-contained JWTs until expiry. That does +not establish that an operation's initiating token is still unrevoked. The +optional profile adds issuer-owned current access-token status using +[RFC 7662](https://www.rfc-editor.org/rfc/rfc7662.html) introspection and +[RFC 7009](https://www.rfc-editor.org/rfc/rfc7009.html) revocation. It supports +access tokens only, without refresh tokens or token exchange. + +## Enablement and lifecycle + +Owner-admitted configuration opts in explicitly: + +```yaml +tokenStatus: + enabled: true + maxEntries: 10000 +``` + +The issuer must use HTTPS at its existing trusted ingress. The server's internal +HTTP listener and reverse-proxy topology are unchanged; direct HTTP exposure is +not admitted by an HTTPS issuer string. Omitted configuration keeps the existing +expiry-only token/UserInfo contract and advertises no new endpoints. Enabling +status registers `/introspect` and `/revoke`, advertises their implemented client +authentication methods, and connects the same store to issuance and UserInfo. + +Human PKCE and service grants add a cryptographically random `jti` to each access +token. ID-token claims and existing assurance/tenant provenance remain unchanged. +Distinct grants in the same second have distinct bearer tokens: a new grant can +never remint a revoked token. A successful response is emitted only after the +token hash, expiry and current registration commitment have been recorded. No +bearer, client secret or identity/group snapshot is retained in the status store. + +The store is bounded to the configured count (default 10000, maximum 1000000). +Live records and revocation tombstones are never evicted to admit another grant; +capacity exhaustion refuses issuance with a fixed 503. Expired entries are +collected when admitting a new token. Capacity and per-client ingress rate limits +need workload-representative admission: this is not a throughput/availability +claim or protection against an authorized client filling the bound. + +This profile uses KeyCape's existing **single-instance topology**. Status is +process-local. Restart, deployment, rollback or store loss makes every earlier +token inactive at online endpoints, even if its JWT signature/expiry still +validates offline. Consumers must reauthenticate; there is no recovery replay +or token-state import that can resurrect revoked authority. Multiple replicas +are unsupported. Native admission must include this interruption and the +measured reauthentication/recovery procedure. A persisted/shared store is not +introduced here. + +## Introspection and revocation + +Both endpoints accept POST form bodies only, bound to 64 KiB; token values are +bound to 32 KiB. Query parameters, duplicate form or authentication fields, +unsupported content types and malformed authentication refuse without echoing +input. Responses use `Cache-Control: no-store` and `Pragma: no-cache`. + +Introspection requires the existing registered confidential client's +`client_secret_basic` authentication. Only that client's own issued tokens can +be inspected. A separate resource audience is returned as `aud`, not confused +with the issuing `client_id`. Cross-client resource-server delegation needs a +separately reviewed authorization contract and is not implicitly granted by a +matching audience string. Public clients cannot enumerate token status. + +An active response means the exact access token was issued in this process, +has not expired or been revoked, still matches its current complete registration, +and passes current issuer/signature/purpose validation. For humans, directory +lookup must succeed and the current account must be enabled and not suspended. +Status is checked again after directory lookup. Service subjects remain bound +to their current registration. Active output contains only standard access-token +metadata; inactive output contains only `active: false`. Unknown tokens, ID +tokens, altered tokens, another client's tokens and expired/revoked tokens all +return that same inactive response to an authenticated introspector. + +Revocation authenticates confidential clients using the same method. A registered +public client may revoke its own token using `client_id` plus possession of that +bearer; public-client introspection remains unavailable. Revocation only affects +the exact token issued to that client. Unknown, other-client, expired and already +revoked tokens all produce the same empty 200. No token existence is disclosed. +Token-type hints do not bypass the actual access-token lookup. Removal or changes +to a registration invalidate online status; the commitment explicitly includes +the secret digest and internal token lifetime omitted from public Client JSON. + +When enabled, actual UserInfo consults the same store before lookup and again +after it. Revocation therefore affects that existing endpoint immediately on its +next check. Ordinary login/logout and upstream Authelia sessions are distinct: +neither browser logout nor deleting a local cookie is represented as global +JWT revocation. + +## Consumer contract and remaining acceptance + +Online status establishes current access-token status, not permission +for an application resource or privileged effect. Consumers still independently +validate the actual signed token, admitted issuer/key/audience/type, subject, +tenant, principal type, assurance and freshness. The authenticated introspection +response must refer to that exact token/client. Obtain a new current response +before each protected operation; unavailable, malformed, inactive or ambiguous +responses refuse. A cached positive is not a current-session check. + +Access Engine retains resource/action decisions, Approval Engine exact one-shot +approval, Warden admission and owner adapters execution. Bind their existing +descriptor, case, transaction and exact effect before dispatch. A separate new +operation/inverse token must be acquired and revoked on every terminal path; +renewed authentication cannot revive a consumed approval or replay a mutation. +This issuer capability alone implements none of those consumer bindings. + +Native completion still requires independent issuer/profile review, exact +image/config/trust admission, bounded retained-token custody, real human/service +issuance, private transport, current-token/revocation integration in each owner, +all exit-path cleanup, restart/expiry/unavailable/refusal checks, ongoing +HTTP/API/Git/SSH authorization and audit evidence. Offline consumers which never +consult the endpoint retain their existing JWT validity; no estate-wide immediate +revocation claim follows from source implementation. Disabling the feature is +not an acceptable fallback for a consumer admitted under the online profile. diff --git a/docs/operations.md b/docs/operations.md index 3092a16..c4d8381 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -20,7 +20,8 @@ is not required today. Consequences to plan for: - **A restart drops in-flight logins.** Anyone mid-login gets an error and must - start again. Issued tokens are unaffected — they are self-contained JWTs and + start again. In the default profile issued tokens are unaffected — they are + self-contained JWTs and stay valid until they expire. - **Rolling deployments are single-instance rollovers**, so expect a brief window where new logins fail. Draining (below) protects requests already in flight, not @@ -89,7 +90,8 @@ and reject freshly issued tokens until its cache expires. Plan rotation as a same-`kid` swap as a breaking change for anyone caching aggressively. **Removing a client registration does not revoke tokens already issued to it.** -There is no introspection or revocation endpoint, so an issued token stays valid +In the default expiry-only profile there is no introspection or revocation +endpoint, so an issued token stays valid until it expires — 15 minutes by default, or the client's `tokenLifetime`. To cut off a compromised client, remove the registration *and* wait out the lifetime, or rotate the signing key if you cannot. @@ -281,3 +283,12 @@ set with net-kingdom `sso-mfa/k8s/keycape/openbao-signin-diagnosis-rollout.py`; `--operation remove` plus a restart is the rollback. A redirect URI that is not http(s) skips the page. Every `auth_success` log line now carries `client_id`, `uid` and `assurance`, and no token, code, email or one-time password. + +## Proposed online token-status profile + +KEY-WP-0042 adds an explicitly enabled singleton profile. It makes prior tokens +inactive at online endpoints after restart and keeps revoked-token tombstones +until expiry. It does not revoke offline consumers or upstream/browser sessions. +Read [the complete lifecycle and admission contract](online-token-status.md) +before proposing activation; no image/config rollout or consumer admission is +established by source tests. Existing deployed configuration remains unchanged. diff --git a/src/cmd/keycape/main.go b/src/cmd/keycape/main.go index 16de34c..a30559b 100644 --- a/src/cmd/keycape/main.go +++ b/src/cmd/keycape/main.go @@ -149,6 +149,24 @@ func main() { // 12. Register HTTP handlers. // ----------------------------------------------------------------- mux := http.NewServeMux() + var tokenStatus *oidc.TokenStatusStore + var introspectionEndpoint, revocationEndpoint string + if cfg.TokenStatus.Enabled { + limit := cfg.TokenStatus.MaxEntries + if limit == 0 { + limit = 10000 + } + var err error + tokenStatus, err = oidc.NewTokenStatusStore(limit) + if err != nil { + log.Error().Msg("invalid token-status configuration") + os.Exit(1) + } + status := &oidc.TokenStatusHandler{Tokens: tokenStatus, ClientConfig: clients, Users: lldapAdapter, SigningKey: &privateKey.PublicKey, Issuer: issuer} + mux.HandleFunc("/introspect", status.Introspect) + mux.HandleFunc("/revoke", status.Revoke) + introspectionEndpoint, revocationEndpoint = issuer+"/introspect", issuer+"/revoke" + } // Discovery. mux.Handle("/.well-known/openid-configuration", oidc.NewDiscoveryHandler(oidc.DiscoveryConfig{ @@ -158,6 +176,8 @@ func main() { JWKSUri: issuer + "/jwks", UserinfoEndpoint: issuer + "/userinfo", EndSessionEndpoint: issuer + "/logout", + IntrospectionEndpoint: introspectionEndpoint, + RevocationEndpoint: revocationEndpoint, Clients: clients, })) @@ -218,15 +238,18 @@ func main() { // fails open, so a configured-but-unreachable cache omits the claim // rather than failing issuance. TenantEngine: buildTenantEngine(cfg.TenantEngine), + Tokens: tokenStatus, } mux.Handle("/token", enforcement.Middleware(tokenHandler)) // Userinfo handler. mux.Handle("/userinfo", &oidc.UserinfoHandler{ - Users: lldapAdapter, - SigningKey: &privateKey.PublicKey, - Issuer: issuer, - Emitter: emitter, + Users: lldapAdapter, + SigningKey: &privateKey.PublicKey, + Issuer: issuer, + Emitter: emitter, + Tokens: tokenStatus, + ClientConfig: clients, }) // Healthz. diff --git a/src/internal/config/config.go b/src/internal/config/config.go index 7895b73..6376708 100644 --- a/src/internal/config/config.go +++ b/src/internal/config/config.go @@ -30,6 +30,14 @@ type Config struct { Clients []ClientConfig `yaml:"clients"` Environment string `yaml:"environment"` TenantEngine TenantEngineConfig `yaml:"tenantEngine,omitempty"` + TokenStatus TokenStatusConfig `yaml:"tokenStatus,omitempty"` +} + +// TokenStatusConfig opts a singleton issuer into online access-token status. +// Existing deployment configuration remains expiry-only until owner admission. +type TokenStatusConfig struct { + Enabled bool `yaml:"enabled"` + MaxEntries int `yaml:"maxEntries,omitempty"` } // TenantEngineConfig configures the optional tenant_roles cache claim. diff --git a/src/internal/config/token_status_test.go b/src/internal/config/token_status_test.go new file mode 100644 index 0000000..0d1278d --- /dev/null +++ b/src/internal/config/token_status_test.go @@ -0,0 +1,41 @@ +package config_test + +import ( + "strings" + "testing" + + "keycape/internal/config" +) + +func TestTokenStatusConfigIsExplicitBoundedAndHTTPS(t *testing.T) { + for _, kind := range []string{"legacy", "enabled-default", "enabled-bounded", "http", "userinfo-url", "query", "too-large", "negative", "disabled-capacity"} { + t.Run(kind, func(t *testing.T) { + cfg := validConfig("unused-public-test-key-path") + cfg.TokenStatus.Enabled = kind != "legacy" && kind != "disabled-capacity" + switch kind { + case "enabled-bounded": + cfg.TokenStatus.MaxEntries = 128 + case "http": + cfg.Issuer = "http://auth.example.com" + case "userinfo-url": + cfg.Issuer = "https://name@auth.example.com" + case "query": + cfg.Issuer = "https://auth.example.com?query=present" + case "too-large": + cfg.TokenStatus.MaxEntries = 1000001 + case "negative": + cfg.TokenStatus.MaxEntries = -1 + case "disabled-capacity": + cfg.TokenStatus.MaxEntries = 128 + } + var statusError bool + for _, err := range config.ValidateConfig(cfg) { + statusError = statusError || strings.HasPrefix(err, "tokenStatus") + } + expected := kind != "legacy" && kind != "enabled-default" && kind != "enabled-bounded" + if statusError != expected { + t.Fatal("token-status configuration acceptance mismatch") + } + }) + } +} diff --git a/src/internal/config/validate.go b/src/internal/config/validate.go index c020e40..8df8151 100644 --- a/src/internal/config/validate.go +++ b/src/internal/config/validate.go @@ -12,6 +12,17 @@ import ( // Called at startup — the server must exit 1 if any errors are returned. func ValidateConfig(cfg *Config) []string { var errs []string + if cfg.TokenStatus.Enabled { + u, err := url.Parse(cfg.Issuer) + if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + errs = append(errs, "tokenStatus: requires an HTTPS issuer without credentials, query or fragment") + } + if cfg.TokenStatus.MaxEntries < 0 || cfg.TokenStatus.MaxEntries > 1000000 { + errs = append(errs, "tokenStatus.maxEntries: must be between 1 and 1000000, or omitted for 10000") + } + } else if cfg.TokenStatus.MaxEntries != 0 { + errs = append(errs, "tokenStatus.maxEntries: set while token status is disabled") + } for name, raw := range map[string]string{"accountPortalURL": cfg.AccountPortalURL, "browserLogoutURL": cfg.BrowserLogoutURL} { if raw == "" { continue diff --git a/src/internal/server/oidc/discovery.go b/src/internal/server/oidc/discovery.go index 4b0074b..6291c51 100644 --- a/src/internal/server/oidc/discovery.go +++ b/src/internal/server/oidc/discovery.go @@ -20,6 +20,8 @@ type DiscoveryConfig struct { JWKSUri string UserinfoEndpoint string // optional, empty = not advertised EndSessionEndpoint string // optional, empty = not advertised + IntrospectionEndpoint string // opt-in online access-token status + RevocationEndpoint string // opt-in access-token revocation // Clients supplies the registered clients so scopes_supported reflects the // scopes this deployment can actually grant, rather than a fixed list that // omits every configured resource scope (KEY-WP-0017-T03). Nil or empty @@ -31,22 +33,26 @@ type DiscoveryConfig struct { // Fields are ordered to match common OIDC implementations for readability. // registration_endpoint is intentionally absent — no dynamic client registration. type discoveryDocument struct { - Issuer string `json:"issuer"` - AuthorizationEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` - JWKSUri string `json:"jwks_uri"` - UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"` - EndSessionEndpoint string `json:"end_session_endpoint,omitempty"` - ResponseTypesSupported []string `json:"response_types_supported"` - GrantTypesSupported []string `json:"grant_types_supported"` - CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` - IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` - ScopesSupported []string `json:"scopes_supported"` - TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` - ClaimsSupported []string `json:"claims_supported"` - SubjectTypesSupported []string `json:"subject_types_supported"` - RequestParameterSupported bool `json:"request_parameter_supported"` - ClaimsParameterSupported bool `json:"claims_parameter_supported"` + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSUri string `json:"jwks_uri"` + UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"` + EndSessionEndpoint string `json:"end_session_endpoint,omitempty"` + IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"` + RevocationEndpoint string `json:"revocation_endpoint,omitempty"` + IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"` + RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"` + ResponseTypesSupported []string `json:"response_types_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` + ScopesSupported []string `json:"scopes_supported"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` + ClaimsSupported []string `json:"claims_supported"` + SubjectTypesSupported []string `json:"subject_types_supported"` + RequestParameterSupported bool `json:"request_parameter_supported"` + ClaimsParameterSupported bool `json:"claims_parameter_supported"` } // discoveryHandler implements http.Handler for GET /.well-known/openid-configuration. @@ -85,6 +91,15 @@ func NewDiscoveryHandler(cfg DiscoveryConfig) http.Handler { RequestParameterSupported: false, ClaimsParameterSupported: false, } + if cfg.IntrospectionEndpoint != "" { + d.IntrospectionEndpoint = cfg.IntrospectionEndpoint + d.IntrospectionEndpointAuthMethodsSupported = []string{"client_secret_basic"} + d.ClaimsSupported = append(d.ClaimsSupported, "jti") + } + if cfg.RevocationEndpoint != "" { + d.RevocationEndpoint = cfg.RevocationEndpoint + d.RevocationEndpointAuthMethodsSupported = []string{"client_secret_basic", "none"} + } b, err := json.Marshal(d) if err != nil { diff --git a/src/internal/server/oidc/token.go b/src/internal/server/oidc/token.go index b1d7f57..0ab2256 100644 --- a/src/internal/server/oidc/token.go +++ b/src/internal/server/oidc/token.go @@ -32,6 +32,9 @@ type TokenHandler struct { // TenantEngine sources the optional tenant_roles claim (KEY-WP-0005-T02). // Nil disables it entirely -- token issuance never depends on it. TenantEngine *tenantengine.Client + // Tokens enables issuer-owned online token status; nil retains the legacy + // expiry-only profile. Enabling it requires explicit deployment admission. + Tokens *TokenStatusStore } // tokenResponse is the JSON body returned on a successful token exchange. @@ -233,11 +236,25 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { claims["aud"] = audience } claims["scope"] = strings.Join(sess.Scopes, " ") + if h.Tokens != nil { + id := make([]byte, 32) + if _, err := rand.Read(id); err != nil { + http.Error(w, "token status unavailable", http.StatusServiceUnavailable) + return + } + claims["jti"] = base64.RawURLEncoding.EncodeToString(id) + } accessToken, err := buildJWT(claims, kid, h.SigningKey) if err != nil { http.Error(w, "failed to build JWT", http.StatusInternalServerError) return } + if h.Tokens != nil { + if err := h.Tokens.register(accessToken, client, time.Unix(exp.Unix(), 0), now); err != nil { + http.Error(w, "token status unavailable", http.StatusServiceUnavailable) + return + } + } // 8. Build response. The session was already consumed at lookup, so no // separate replay-prevention delete is needed here. @@ -357,11 +374,25 @@ func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Req if roles, ok := h.TenantEngine.Roles(ctx, client.Tenant); ok { claims["tenant_roles"] = roles } + if h.Tokens != nil { + id := make([]byte, 32) + if _, err := rand.Read(id); err != nil { + http.Error(w, "token status unavailable", http.StatusServiceUnavailable) + return + } + claims["jti"] = base64.RawURLEncoding.EncodeToString(id) + } jwtToken, err := buildJWT(claims, "key-1", h.SigningKey) if err != nil { http.Error(w, "failed to build JWT", http.StatusInternalServerError) return } + if h.Tokens != nil { + if err := h.Tokens.register(jwtToken, client, time.Unix(now.Add(tokenLifetime).Unix(), 0), now); err != nil { + http.Error(w, "token status unavailable", http.StatusServiceUnavailable) + return + } + } h.Emitter.Emit(ctx, telemetry.Event{ Timestamp: now, EventType: telemetry.EventTokenIssued, ClientID: clientID, Endpoint: "/token", Result: "success", Scopes: requestedScopes, diff --git a/src/internal/server/oidc/token_status.go b/src/internal/server/oidc/token_status.go new file mode 100644 index 0000000..34792f4 --- /dev/null +++ b/src/internal/server/oidc/token_status.go @@ -0,0 +1,97 @@ +package oidc + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "sync" + "time" + + "keycape/internal/domain" +) + +// TokenStatusStore is the optional single-process online access-token ledger. +// It holds only token hashes and registration commitments, never bearer values. +// Losing this store makes every previously issued token inactive to online +// checks. It is not a browser session, approval, or operation authority store. +type TokenStatusStore struct { + mu sync.Mutex + entries map[[32]byte]tokenStatus + limit int +} + +type tokenStatus struct { + clientID string + registration [32]byte + expires time.Time + revoked bool +} + +func NewTokenStatusStore(limit int) (*TokenStatusStore, error) { + if limit < 1 || limit > 1000000 { + return nil, errors.New("invalid token-status capacity") + } + return &TokenStatusStore{entries: make(map[[32]byte]tokenStatus), limit: limit}, nil +} + +func registrationCommitment(client *domain.Client) [32]byte { + // ClientSecret and TokenLifetime are deliberately omitted from Client JSON. + // Bind their commitments explicitly as well as all public registration data. + secret := sha256.Sum256([]byte(client.ClientSecret)) + raw, _ := json.Marshal(struct { + Client *domain.Client + Secret [32]byte + Lifetime time.Duration + }{client, secret, client.TokenLifetime}) + return sha256.Sum256(raw) +} + +func (s *TokenStatusStore) register(raw string, client *domain.Client, expires, now time.Time) error { + if s == nil || client == nil || raw == "" || len(raw) > 32768 || !now.Before(expires) { + return errors.New("token-status registration refused") + } + s.mu.Lock() + defer s.mu.Unlock() + for key, entry := range s.entries { + if !now.Before(entry.expires) { + delete(s.entries, key) + } + } + key := sha256.Sum256([]byte(raw)) + if _, exists := s.entries[key]; exists || len(s.entries) >= s.limit { + return errors.New("token-status registration refused") + } + s.entries[key] = tokenStatus{clientID: client.ClientID, registration: registrationCommitment(client), expires: expires} + return nil +} + +func (s *TokenStatusStore) active(raw string, clients map[string]*domain.Client, now time.Time) (string, bool) { + if s == nil { + return "", false + } + s.mu.Lock() + defer s.mu.Unlock() + entry, ok := s.entries[sha256.Sum256([]byte(raw))] + if !ok || entry.revoked || !now.Before(entry.expires) { + return "", false + } + client := clients[entry.clientID] + if client == nil || client.ClientID != entry.clientID || registrationCommitment(client) != entry.registration { + return "", false + } + return entry.clientID, true +} + +func (s *TokenStatusStore) revoke(raw, clientID string) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + key := sha256.Sum256([]byte(raw)) + if entry, ok := s.entries[key]; ok && entry.clientID == clientID { + // Retain the tombstone until expiry: a retry can never reactivate it. + entry.revoked = true + s.entries[key] = entry + } +} diff --git a/src/internal/server/oidc/token_status_discovery_test.go b/src/internal/server/oidc/token_status_discovery_test.go new file mode 100644 index 0000000..bf8ca62 --- /dev/null +++ b/src/internal/server/oidc/token_status_discovery_test.go @@ -0,0 +1,30 @@ +package oidc_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "keycape/internal/server/oidc" +) + +func TestTokenStatusDiscoveryOnlyAdvertisesEnabledProfile(t *testing.T) { + for _, enabled := range []bool{false, true} { + cfg := oidc.DiscoveryConfig{Issuer: "https://issuer.example"} + if enabled { + cfg.IntrospectionEndpoint, cfg.RevocationEndpoint = cfg.Issuer+"/introspect", cfg.Issuer+"/revoke" + } + w := httptest.NewRecorder() + oidc.NewDiscoveryHandler(cfg).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/.well-known/openid-configuration", nil)) + var doc map[string]interface{} + if json.Unmarshal(w.Body.Bytes(), &doc) != nil { + t.Fatal("discovery is invalid JSON") + } + for _, name := range []string{"introspection_endpoint", "revocation_endpoint", "introspection_endpoint_auth_methods_supported", "revocation_endpoint_auth_methods_supported"} { + if _, exists := doc[name]; exists != enabled { + t.Fatal("unimplemented token status advertised") + } + } + } +} diff --git a/src/internal/server/oidc/token_status_http.go b/src/internal/server/oidc/token_status_http.go new file mode 100644 index 0000000..5bd9161 --- /dev/null +++ b/src/internal/server/oidc/token_status_http.go @@ -0,0 +1,145 @@ +package oidc + +import ( + "crypto/rsa" + "encoding/json" + "mime" + "net/http" + "time" + + "keycape/internal/domain" +) + +// TokenStatusHandler serves the optional issuer-owned RFC 7662 introspection +// and RFC 7009 revocation endpoints. Introspection authenticates the issuing +// confidential client; this profile does not delegate cross-client token reads. +type TokenStatusHandler struct { + Tokens *TokenStatusStore + ClientConfig map[string]*domain.Client + Users domain.UserRepository + SigningKey *rsa.PublicKey + Issuer string +} + +func statusError(w http.ResponseWriter, status int, code string) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": code}) +} + +func (h *TokenStatusHandler) parse(w http.ResponseWriter, r *http.Request, introspection bool) (*domain.Client, string, bool) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + statusError(w, http.StatusMethodNotAllowed, "invalid_request") + return nil, "", false + } + if h.Tokens == nil || h.SigningKey == nil || h.Issuer == "" { + statusError(w, http.StatusServiceUnavailable, "temporarily_unavailable") + return nil, "", false + } + media, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || media != "application/x-www-form-urlencoded" || r.URL.RawQuery != "" || len(r.Header.Values("Authorization")) > 1 { + statusError(w, http.StatusBadRequest, "invalid_request") + return nil, "", false + } + r.Body = http.MaxBytesReader(w, r.Body, 65536) + if err := r.ParseForm(); err != nil { + statusError(w, http.StatusBadRequest, "invalid_request") + return nil, "", false + } + for _, values := range r.PostForm { + if len(values) != 1 { + statusError(w, http.StatusBadRequest, "invalid_request") + return nil, "", false + } + } + raw := r.PostForm.Get("token") + if raw == "" || len(raw) > 32768 { + statusError(w, http.StatusBadRequest, "invalid_request") + return nil, "", false + } + id, secret, basic := basicClientCredentials(r) + formID := r.PostForm.Get("client_id") + if !basic { + id = formID + } + client := h.ClientConfig[id] + valid := client != nil && client.ClientID == id && !r.PostForm.Has("client_secret") + if valid && client.ClientType == "confidential" { + valid = basic && (formID == "" || formID == id) && client.ClientSecret != "" && secretsEqual(secret, client.ClientSecret) + } else { + // Public-client revocation requires possession of its own bearer token. + // Public clients can never enumerate token status via introspection. + valid = valid && !introspection && client.ClientType == "public" && !basic && r.Header.Get("Authorization") == "" + } + if !valid { + w.Header().Set("WWW-Authenticate", `Basic realm="keycape-token-status"`) + statusError(w, http.StatusUnauthorized, "invalid_client") + return nil, "", false + } + return client, raw, true +} + +func (h *TokenStatusHandler) Introspect(w http.ResponseWriter, r *http.Request) { + client, raw, ok := h.parse(w, r, true) + if !ok { + return + } + response := map[string]interface{}{"active": false} + defer func() { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + }() + id, active := h.Tokens.active(raw, h.ClientConfig, time.Now()) + if !active || id != client.ClientID { + return + } + claims, err := validateAccessToken(raw, h.SigningKey, h.Issuer) + if err != nil { + return + } + sub, _ := claims["sub"].(string) + switch claims["principal_type"] { + case "human": + if h.Users == nil { + return + } + user, err := lookupUserBySubject(r.Context(), h.Users, claims, sub) + if err != nil || !user.Enabled || isSuspended(user) { + return + } + case "service": + if sub == "" || sub != client.ServiceSubject { + return + } + default: + return + } + // Directory lookup may block. Recheck revocation and registration after it. + if current, active := h.Tokens.active(raw, h.ClientConfig, time.Now()); !active || current != id { + return + } + response["active"] = true + response["client_id"] = id + response["token_type"] = "Bearer" + for _, field := range []string{"iss", "sub", "aud", "scope", "iat", "exp", "jti"} { + if value, present := claims[field]; present { + response[field] = value + } + } +} + +func (h *TokenStatusHandler) Revoke(w http.ResponseWriter, r *http.Request) { + client, raw, ok := h.parse(w, r, false) + if !ok { + return + } + // Unknown, expired, ID and other-client tokens receive the same empty + // success as an already-revoked token; no existence information leaks. + h.Tokens.revoke(raw, client.ClientID) + w.WriteHeader(http.StatusOK) +} diff --git a/src/internal/server/oidc/token_status_internal_test.go b/src/internal/server/oidc/token_status_internal_test.go new file mode 100644 index 0000000..3fe725c --- /dev/null +++ b/src/internal/server/oidc/token_status_internal_test.go @@ -0,0 +1,66 @@ +package oidc + +import ( + "fmt" + "sync" + "testing" + "time" + + "keycape/internal/domain" +) + +func TestTokenStatusExactExpiryCapacityAndTombstones(t *testing.T) { + client := &domain.Client{ClientID: "synthetic-client", ClientType: "public"} + clients := map[string]*domain.Client{client.ClientID: client} + store, _ := NewTokenStatusStore(1) + now := time.Unix(1700000000, 0) + end := now.Add(time.Second) + if store.register("synthetic-first", client, end, now) != nil { + t.Fatal("register refused") + } + if _, active := store.active("synthetic-first", clients, end); active { + t.Fatal("expiry equality accepted") + } + store.revoke("synthetic-first", client.ClientID) + if store.register("synthetic-first", client, end, now) == nil { + t.Fatal("revoked token reactivated") + } + if store.register("synthetic-second", client, end.Add(time.Second), end) != nil { + t.Fatal("expired tombstone not collected") + } + if _, active := store.active("synthetic-second", clients, end); !active { + t.Fatal("fresh token refused") + } + for _, capacity := range []int{-1, 0, 1000001} { + if _, err := NewTokenStatusStore(capacity); err == nil { + t.Fatal("unbounded capacity accepted") + } + } +} + +func TestTokenStatusConcurrentIssueReadAndRevoke(t *testing.T) { + store, _ := NewTokenStatusStore(128) + client := &domain.Client{ClientID: "synthetic-client", ClientType: "public"} + clients := map[string]*domain.Client{client.ClientID: client} + now := time.Now() + var wg sync.WaitGroup + for i := 0; i < 64; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + raw := fmt.Sprintf("synthetic-token-%d", i) + if store.register(raw, client, now.Add(time.Minute), now) != nil { + t.Error("concurrent registration failed") + return + } + if _, active := store.active(raw, clients, now); !active { + t.Error("concurrent current token refused") + } + store.revoke(raw, client.ClientID) + if _, active := store.active(raw, clients, now); active { + t.Error("concurrent revoked token accepted") + } + }(i) + } + wg.Wait() +} diff --git a/src/internal/server/oidc/token_status_test.go b/src/internal/server/oidc/token_status_test.go new file mode 100644 index 0000000..deb3f52 --- /dev/null +++ b/src/internal/server/oidc/token_status_test.go @@ -0,0 +1,379 @@ +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") + } +} diff --git a/src/internal/server/oidc/token_status_transport_test.go b/src/internal/server/oidc/token_status_transport_test.go new file mode 100644 index 0000000..8bd0a16 --- /dev/null +++ b/src/internal/server/oidc/token_status_transport_test.go @@ -0,0 +1,121 @@ +package oidc_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func TestTokenStatusActualHTTPSFlowAndAuthenticationEncoding(t *testing.T) { + f := newStatusFixture(t, 8) + f.token.ClientConfig["test-client"].ClientSecret = "synthetic + / colon: only" + mux := http.NewServeMux() + mux.Handle("/token", f.token) + mux.Handle("/userinfo", f.userinfo) + mux.HandleFunc("/introspect", f.status.Introspect) + mux.HandleFunc("/revoke", f.status.Revoke) + server := httptest.NewTLSServer(mux) + defer server.Close() + client := server.Client() + verifier, _ := makeVerifierAndChallenge() + code := seededSession(f.token.Sessions, verifier) + post := func(path string, form url.Values) (int, []byte) { + r, err := http.NewRequest(http.MethodPost, server.URL+path, strings.NewReader(form.Encode())) + if err != nil { + t.Fatal("request construction failed") + } + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.SetBasicAuth(url.QueryEscape("test-client"), url.QueryEscape(f.token.ClientConfig["test-client"].ClientSecret)) + resp, err := client.Do(r) + if err != nil { + t.Fatal("HTTPS fixture transport failed") + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 65537)) + if err != nil || len(body) > 65536 { + t.Fatal("response read failed") + } + return resp.StatusCode, body + } + status, body := post("/token", url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {verifier}, "redirect_uri": {seededRedirectURI}}) + var tokens struct { + Access string `json:"access_token"` + } + if status != http.StatusOK || json.Unmarshal(body, &tokens) != nil || tokens.Access == "" { + t.Fatal("actual HTTPS PKCE exchange failed") + } + // A misleading type hint must not hide a supported access token. + form := url.Values{"token": {tokens.Access}, "token_type_hint": {"refresh_token"}} + status, body = post("/introspect", form) + var metadata map[string]interface{} + if status != http.StatusOK || json.Unmarshal(body, &metadata) != nil || metadata["active"] != true { + t.Fatal("actual HTTPS introspection failed") + } + if status, body = post("/revoke", form); status != http.StatusOK || len(body) != 0 { + t.Fatal("actual HTTPS revocation failed") + } + status, body = post("/introspect", form) + if status != http.StatusOK || strings.TrimSpace(string(body)) != `{"active":false}` { + t.Fatal("revocation did not affect HTTPS introspection") + } + r, _ := http.NewRequest(http.MethodGet, server.URL+"/userinfo", nil) + r.Header.Set("Authorization", "Bearer "+tokens.Access) + resp, err := client.Do(r) + if err != nil { + t.Fatal("HTTPS UserInfo failed") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized || resp.Header.Get("Cache-Control") != "no-store" { + t.Fatal("real UserInfo accepted or cached revoked bearer") + } +} + +func TestTokenStatusRevocationDuringUserInfoLookupRefuses(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.userinfo.Users = users + done := make(chan int, 1) + go func() { done <- f.userinfoStatus(raw) }() + select { + case <-users.entered: + case <-time.After(3 * time.Second): + t.Fatal("UserInfo did not enter directory lookup") + } + f.request(t, raw, "test-client", "synthetic-status-only", true, nil) + close(users.release) + select { + case code := <-done: + if code != http.StatusUnauthorized { + t.Fatal("delayed UserInfo lookup resurrected revoked token") + } + case <-time.After(3 * time.Second): + t.Fatal("UserInfo did not finish") + } +} + +func TestTokenStatusLegacyGrantHasNoStatusClaim(t *testing.T) { + f := newStatusFixture(t, 8) + f.token.Tokens = nil + raw, id, code := f.issue(t) + if code != http.StatusOK { + t.Fatal("legacy issue failed") + } + for _, token := range []string{raw, id} { + if _, present := parseJWTPayload(t, token)["jti"]; present { + t.Fatal("disabled token contract changed") + } + } + if f.introspect(t, raw)["active"] != false || f.userinfoStatus(raw) != http.StatusUnauthorized { + t.Fatal("unregistered legacy token gained online authority") + } + f.userinfo.Tokens = nil + if f.userinfoStatus(raw) != http.StatusOK { + t.Fatal("legacy token no longer accepted in legacy mode") + } +} diff --git a/src/internal/server/oidc/userinfo.go b/src/internal/server/oidc/userinfo.go index c3d2014..4e46956 100644 --- a/src/internal/server/oidc/userinfo.go +++ b/src/internal/server/oidc/userinfo.go @@ -22,15 +22,21 @@ import ( // the user, and returns claims that are consistent with those in the ID token // for the same scope set. type UserinfoHandler struct { - Users domain.UserRepository - SigningKey *rsa.PublicKey // used to verify the incoming access token - Issuer string - Emitter telemetry.Emitter + Users domain.UserRepository + SigningKey *rsa.PublicKey // used to verify the incoming access token + Issuer string + Emitter telemetry.Emitter + Tokens *TokenStatusStore + ClientConfig map[string]*domain.Client } // ServeHTTP handles GET /userinfo. func (h *UserinfoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + if h.Tokens != nil { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + } // 1. Extract Bearer token. tokenStr, ok := bearerToken(r) @@ -46,6 +52,12 @@ func (h *UserinfoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"invalid_token","description":"token validation failed"}`, http.StatusUnauthorized) return } + if h.Tokens != nil { + if _, active := h.Tokens.active(tokenStr, h.ClientConfig, time.Now()); !active { + http.Error(w, `{"error":"invalid_token"}`, http.StatusUnauthorized) + return + } + } // 3. Extract sub claim (which is the username in our model). sub, _ := claims["sub"].(string) @@ -68,6 +80,12 @@ func (h *UserinfoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"invalid_token","description":"subject is suspended"}`, http.StatusUnauthorized) return } + if h.Tokens != nil { + if _, active := h.Tokens.active(tokenStr, h.ClientConfig, time.Now()); !user.Enabled || !active { + http.Error(w, `{"error":"invalid_token"}`, http.StatusUnauthorized) + return + } + } // 5. Build response claims filtered by the scopes embedded in the token. scopeStr, _ := claims["scope"].(string) diff --git a/workplans/ADHOC-2026-09-27.md b/workplans/ADHOC-2026-09-27.md index 0b75554..a206833 100644 --- a/workplans/ADHOC-2026-09-27.md +++ b/workplans/ADHOC-2026-09-27.md @@ -70,6 +70,7 @@ can approve after a fresh sign-in against this build. id: ADHOC-2026-09-27-T02 status: done priority: high +state_hub_task_id: "7fa0bfc7-0772-57c7-8d6f-641615cab82a" ``` Rolling out T01 (image digest `sha256:248e449a...`) broke every KeyCape login, diff --git a/workplans/KEY-WP-0042-online-access-token-status.md b/workplans/KEY-WP-0042-online-access-token-status.md new file mode 100644 index 0000000..66f9094 --- /dev/null +++ b/workplans/KEY-WP-0042-online-access-token-status.md @@ -0,0 +1,84 @@ +--- +id: KEY-WP-0042 +type: workplan +title: "Provide issuer-owned current access-token status and scoped revocation" +domain: infotech +repo: key-cape +status: proposed +owner: codex +topic_slug: custodian +created: "2026-10-18" +updated: "2026-10-18" +flavor: extension +depends_on: [] +state_hub_workstream_id: "e2eecd1c-45c7-5a7c-bd1c-d31c845e3d38" +--- + +CUST-WP-0080's current-operation/inverse requirement exposed a concrete missing +issuer facility. KEY-WP-0025-T03 truthfully documents the expiry-only limit; +KEY-WP-0038 owns Forgejo's existing client, not a token-liveness API. This is an +explicit proposed scope extension in the existing issuer, not a silent change +to that completed limit record. No new service or duplicate session authority. +Consumer binding/authorization and native case acceptance stay in +NK-WP-0048-T34/T42/T43 and RAPPS-WP-0016-T08/T09; they are not copied here. + +## Review the bounded issuer/profile extension + +```task +id: KEY-WP-0042-T01 +status: progress +priority: high +state_hub_task_id: "d8af8e77-f6e5-5d0d-8b44-794af3dee5cf" +``` + +The reviewable contract is `docs/online-token-status.md`: opt-in RFC 7662/7009 +access-token status; registered owning-client authentication; private hash-only +bounded state; unique access-token jti; no ID-token change; singleton store-loss +refusal; disabled legacy behavior explicit. Source preparation is authorized by +the full Custodian implementation goal. Independent issuer/profile/consumer +assent remains required before admission; no such assent is inferred here. + +## Implement and verify the actual issuer paths + +```task +id: KEY-WP-0042-T02 +status: done +priority: high +state_hub_task_id: "2cade51f-d25a-5719-96b4-b57d50c1b97d" +``` + +Wire the same issuer store to both actual grant types, UserInfo, introspection, +revocation, configuration validation and implemented discovery. Verify distinct +grants, exact expiry, replay/tombstones, capacity, malformed/unauthenticated and +cross-client refusals, current registration and human disposition, revocation +during directory lookup, process-store loss and absence of secret output. Keep +the complete existing source suite, disabled profile and build passing. + +## Admit and accept the issuer lifecycle at native scope + +```task +id: KEY-WP-0042-T03 +status: todo +priority: high +state_hub_task_id: "a5539bb0-2501-5774-a7d8-acddfb0938ab" +``` + +After independent contract review, admit exact issuer image/config/profile, +HTTPS ingress/client custody and measured capacity/headroom. Exercise real +human PKCE and service tokens, actual endpoint authentication, revoked-token +UserInfo refusal, expiry, unavailable dependency, restart/store loss and fresh +reauthentication. Preserve old offline-token semantics and document every +consumer's selected mode. Review disabling/rollback explicitly; never silently +fall back to offline acceptance for online-profile consumers. Provide owner +receipts to existing NK48/Apps consumer tasks and Audit source admission. + +2026-10-18 source verification: all 659 test/subtest results in the complete +race suite pass, with six preexisting native/external skips. The standalone +binary passes 26 isolated process checks, including actual main/router/config, +service issuance, capacity, revocation, process restart, fresh issuance and +disabled-profile behavior; fixture diagnostics omit synthetic private inputs. +The HTTPS human PKCE flow and revocation during directory lookups also pass. +An isolated control omitting the revoked-state predicate fails seven intended +checks. This closes source task T02 only. T01 independent issuer/profile review, +T03 native admission and all existing consumer operation/effect bindings remain +open. Evidence is retained by CUST-WP-0080 under keycape-token-status-r1.