Add opt-in issuer-owned access-token status and scoped revocation
All checks were successful
Authentication acceptance / acceptance (push) Successful in 59s
Authentication acceptance / provider-contract (push) Successful in 13s
Build and Publish Container Image / build-and-push (push) Successful in 32s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7
This commit is contained in:
tegwick 2026-10-18 10:10:38 +02:00
parent d803e9282a
commit 67a0869c1e
18 changed files with 1230 additions and 28 deletions

View file

@ -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).

119
docs/online-token-status.md Normal file
View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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")
}
})
}
}

View file

@ -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

View file

@ -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 {

View file

@ -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,

View file

@ -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
}
}

View file

@ -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")
}
}
}
}

View file

@ -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)
}

View file

@ -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()
}

View file

@ -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")
}
}

View file

@ -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")
}
}

View file

@ -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)

View file

@ -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,

View file

@ -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.