feat(oidc): phase 3 — introspection, hybrid flows, JWKS multi-key, back-channel logout#593
Merged
lakhansamani merged 7 commits intomainfrom Apr 7, 2026
Merged
Conversation
New POST /oauth/introspect endpoint implementing OAuth 2.0 Token
Introspection per RFC 7662. Mirrors the auth and content-type
patterns of /oauth/revoke for consistency:
- client_secret_basic (HTTP Basic) OR client_secret_post (form body)
- application/x-www-form-urlencoded request body
- 'token' and 'token_type_hint' parameters (unknown hints ignored per
RFC 7662 §2.1, not rejected)
Response semantics (RFC 7662 §2.2):
- Active tokens return {active: true, scope, client_id, exp, iat,
sub, aud, iss, token_type}. Claims are copied from the parsed JWT
only if present on the source token; missing claims are omitted
rather than emitted as null.
- Inactive / invalid / expired / wrong-audience tokens return ONLY
{active: false}. Never any error, error_description, or details
about why the token is inactive (spec-mandated non-disclosure).
- Cache-Control: no-store and Pragma: no-cache on all responses.
Validation checks performed for active tokens: signature (via
ParseJWTToken), exp > now, iss matches current host, aud contains
our client_id.
Wiring:
- Route: POST /oauth/introspect in internal/server/http_routes.go
- Provider interface: IntrospectHandler() in provider.go
- CSRF exempt list: /oauth/introspect alongside /oauth/token and
/oauth/revoke (client-authenticated, not cookie-authenticated)
- Discovery: introspection_endpoint + introspection_endpoint_auth_
methods_supported in openid-configuration
New test file oidc_phase3_introspect_test.go with 9 tests covering
active access/ID tokens, inactive non-disclosure, missing token,
missing client_id, invalid client via form + Basic Auth, cache
headers, and discovery advertisement.
OIDC Core 1.0 §3.3 defines the hybrid flow with three response_type combinations that were previously rejected: 'code id_token', 'code token', and 'code id_token token'. This commit also adds the pure-implicit 'id_token token' combination for completeness. Changes to /authorize handler: - New supportedResponseTypeSet helper parses response_type as a space-delimited set, lowercases, dedupes, and sorts the tokens. Returns a canonical string and a validity bool. Order is now insignificant: 'token id_token code' → 'code id_token token'. - Unsupported combinations return OIDC error unsupported_response_type (HTTP 400). - Hybrid-specific guard: response_mode=query is rejected with invalid_request per OIDC Core §3.3.2.5. When the client did not supply a response_mode for a hybrid request, the default is fragment (not query, regardless of server's global default). - New hybrid dispatch branch that mints tokens AND a code in a single response. Sets cfg.Code on the AuthTokenConfig, which was already wired in Phase 1 to populate cfg.CodeHash and emit the c_hash claim on the ID token. - Response artifacts are assembled conditionally based on the requested combination: code is always present; access_token is present iff the combo includes 'token'; id_token is present iff the combo includes 'id_token'. What's already done (no change needed): - Phase 1's CreateIDToken already emits c_hash whenever cfg.CodeHash is populated. The hybrid branch just needs to set cfg.Code before calling CreateAuthToken — the hash computation and claim emission are already plumbed. New test file oidc_phase3_hybrid_test.go: - Unsupported response_type rejected - response_mode=query rejected for hybrid - All four new combinations accepted by the parser - Token order in response_type is insignificant Backward compatibility: single-value 'code', 'token', 'id_token' response_types still work identically. No client behavior change for non-hybrid flows.
Allow operators to configure a SECONDARY JWT key alongside the
primary. When configured, the JWKS endpoint publishes both public
keys with distinct kids so relying parties can verify tokens signed
by either. Enables zero-downtime key rotation.
New config fields (all default empty, opt-in):
- JWTSecondaryType (algorithm — e.g. RS256)
- JWTSecondarySecret (HMAC secret — never exposed via JWKS)
- JWTSecondaryPrivateKey
- JWTSecondaryPublicKey
New CLI flags mirroring existing --jwt-* pattern:
- --jwt-secondary-type
- --jwt-secondary-secret
- --jwt-secondary-private-key
- --jwt-secondary-public-key
JWKS handler changes (internal/http_handlers/jwks.go):
- Refactored into a pure generateJWKFromKey(algo, pub, kidSuffix,
clientID) helper used for both primary and secondary.
- Primary kid stays unchanged ('-' + clientID suffix dropped for
backward compat).
- Secondary kid appends '-secondary' so primary and secondary are
always distinguishable even when they use the same algorithm.
- HMAC secondaries are silently dropped (never exposed), consistent
with primary HMAC behavior.
- Errors on secondary key generation are logged and the secondary
is dropped — a malformed secondary config never breaks the JWKS
endpoint (primary keeps working).
Documented manual rotation workflow (commit message):
1. Operator adds new key as --jwt-secondary-*
2. Server publishes both keys in JWKS; both can verify new tokens
3. Operator swaps: new key becomes primary (--jwt-*), old becomes
secondary (--jwt-secondary-*)
4. Wait for outstanding tokens to expire (default 30 days for
refresh tokens)
5. Operator removes --jwt-secondary-* flags
Deliberately out of scope for this commit: token signature
verification fallback. The current verifier in internal/token only
tries the primary key; after step 3 above, outstanding tokens
signed with the now-secondary key would fail verification. This
gap will be addressed in a follow-up commit that adds a retry path
to ParseJWTToken. Documented as a known limitation in the
post-phase review.
No automation of key rotation in this commit — automated
time-based rotation is a Phase 4 roadmap item.
Backward compatibility: when no secondary is configured, behavior
is byte-identical to the existing single-key path.
New test file oidc_phase3_jwks_multi_key_test.go:
- Default (no secondary) publishes exactly one key
- Both keys published with distinct kids when secondary configured
- HMAC secondary is not exposed
Implements OIDC Back-Channel Logout 1.0. On successful /logout,
when the operator has configured --backchannel-logout-uri, fire a
signed logout_token JWT via HTTP POST to that URI. Fire-and-forget
from a goroutine so logout UX is never blocked by the receiver.
New config field (opt-in, default empty):
- BackchannelLogoutURI string
New CLI flag:
- --backchannel-logout-uri
New file: internal/token/backchannel_logout.go
- BackchannelLogoutConfig struct carrying HostName, Subject, SessionID
- NotifyBackchannelLogout() method on the token Provider. Added to
the token.Provider interface so callers can mock it. Method builds
a logout_token JWT per OIDC BCL 1.0 §2.4 with:
iss, aud, iat, exp (+5 min), jti (uuid), events map containing
the BCL event identifier, and sub + sid when available.
Deliberately omits nonce — prohibited by spec §2.4.
- Signs via existing SignJWTToken, POSTs as x-www-form-urlencoded
with 5-second HTTP timeout (context + client). Receiver response
is not inspected — logout completes regardless.
/logout handler change:
- After successful session termination + audit log, if
BackchannelLogoutURI is set, launch a goroutine that calls
NotifyBackchannelLogout with sessionData.Subject as sub and
sessionData.Nonce as sid. Errors logged at debug.
The receiver verifies the logout_token signature using the same
JWKS endpoint as ID token verification. No new secrets, no new
keys, no new endpoints to secure.
Intentional design choices:
- logout_token is signed with the SAME key as ID tokens so the RP
already has everything it needs to verify it (existing JWKS URI).
- Short exp (5 min) despite §2.4 saying SHOULD NOT include exp,
because leaving it open-ended creates replay risk if the token
is intercepted in transit. Single-use is enforced by the jti +
events combination.
- sid is populated from sessionData.Nonce, which is the closest
existing analogue of an OIDC session ID in our model.
New test file oidc_phase3_backchannel_logout_test.go:
- Spins up an httptest receiver, calls NotifyBackchannelLogout,
asserts the JWT carries iss/aud/sub/sid/iat/jti, asserts nonce
is absent, asserts the events map contains the BCL event key.
- Empty URI → error
- Missing both sub and sid → error
…n fallback
Two final pieces for Phase 3 that cross-cut all four groups:
Discovery wiring (openid_config.go):
- response_types_supported now advertises all 7 supported values
including the hybrid combinations: code, token, id_token,
'code id_token', 'code token', 'code id_token token',
'id_token token'.
- claims_supported extended with auth_time, amr, acr, at_hash, c_hash
(added by Phase 2 and Phase 1 respectively).
- backchannel_logout_supported and backchannel_logout_session_supported
set to true IFF BackchannelLogoutURI is configured. Avoids lying
to RPs that probe the discovery doc.
Secondary-key verification fallback (internal/token/jwt.go):
- ParseJWTToken now retries with the secondary key when the primary
verification fails, IFF JWTSecondaryType is configured. This
completes the Phase 3 manual rotation workflow end-to-end:
1. Operator adds new key as --jwt-secondary-*
2. JWKS publishes both keys (Group C)
3. Verification accepts tokens signed by either (this commit)
4. Operator swaps primary/secondary
5. Outstanding tokens signed by the now-secondary key keep
verifying — this was the gap Group C flagged as deferred
- Refactored the parse logic into a small parseJWTWithKey helper
that takes (token, algo, secret, publicKey) so the primary and
secondary paths share one implementation.
- New signing keys are always generated with the primary — the
secondary is verification-only.
New tests in oidc_phase3_jwks_multi_key_test.go:
- TestParseJWTTokenFallsBackToSecondaryKey: mints a token signed
with secondary RSA key material, asserts ParseJWTToken accepts
it via fallback.
- TestParseJWTTokenRejectsUnsignedGarbage: ensures the fallback
doesn't accept malformed tokens.
Updated TestOpenIDDiscoveryCompliance assertions already pass the
expanded response_types_supported list.
Code review follow-ups on Phase 3 (approved with nits): - I-1 (Important): Fix introspection client_id passthrough. Previously copied client_id from the aud claim, which could be a JSON array for multi-audience tokens and would produce client_id as an array in violation of RFC 7662 §2.2. Set resp['client_id'] = h.Config .ClientID directly — the audience check above already confirmed our client_id is in the audience set, and the RFC says client_id is a string. Latent today (single-string aud), active once Phase 4 M2M lands. - M-1 (Minor): Clarify --jwt-secondary-private-key / --jwt-secondary -public-key CLI help text to make it explicit that the private key is currently unused (verification-only) and only the public key is consumed by the verification fallback. Prevents operator confusion about what the secondary private key does during rotation.
With Phases 1-3 all merged, the 'phase' labels in filenames and comments no longer carry useful information. Rename the integration test files to semantic names that describe what they test, and scrub 'Phase 1'/'Phase 2'/'Phase 3' labels from comments and test fixture data. File renames (internal/integration_tests/): oidc_phase1_userinfo_test.go -> oidc_userinfo_scope_filtering_test.go oidc_phase2_id_token_claims_test.go -> oidc_id_token_claims_test.go oidc_phase2_logout_test.go -> oidc_rp_initiated_logout_test.go oidc_phase2_authorize_test.go -> oidc_authorize_params_test.go oidc_phase3_introspect_test.go -> oidc_introspect_test.go oidc_phase3_hybrid_test.go -> oidc_hybrid_flow_test.go oidc_phase3_jwks_multi_key_test.go -> oidc_jwks_multi_key_test.go oidc_phase3_backchannel_logout_test.go -> oidc_backchannel_logout_test.go Identifier renames: createAuthTokenForPhase2Test -> createAuthTokenForIDTokenClaimsTest Test fixture email prefixes: id_token_phase2_ -> id_token_claims_ userinfo_phase1_ -> userinfo_scope_ Comment updates drop 'Phase N' labels from: internal/token/auth_token.go (c_hash, acr comments) internal/token/jwt.go (ParseJWTToken fallback comment) internal/http_handlers/authorize.go (prompt=consent comment + log msg) internal/integration_tests/oidc_authorize_params_test.go internal/integration_tests/oidc_id_token_claims_test.go internal/integration_tests/oidc_jwks_multi_key_test.go Pure cosmetic change. No functional deltas. Full test suite green.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
OIDC Phase 3 — Extensions. Completes the three-phase OIDC conformance work with four optional-but-commonly-requested OIDC extensions plus a file/comment rename cleanup now that all three phases have landed.
All changes are additive and fully backward-compatible — no new config flag defaults behavior change, no existing client breaks.
Implemented via four parallel subagents working on disjoint files, followed by a single holistic end-of-phase review (approved with one Important and one Minor nit, both applied inline).
Changes
1. Token Introspection — RFC 7662
New
POST /oauth/introspectendpoint mirroring the/oauth/revokeclient auth pattern.client_secret_basic(HTTP Basic) orclient_secret_post(form body){active:true, scope, client_id, exp, iat, sub, aud, iss, token_type}{active:false}per RFC 7662 §2.2 non-disclosure (no error, no error_description, no claim leakage)client_idin the response is set directly from the server's configured ClientID (never passed through from theaudclaim — prevents array-shapedclient_idwhen multi-audience tokens are introduced)Cache-Control: no-store+Pragma: no-cacheon all responsesintrospection_endpoint+introspection_endpoint_auth_methods_supported2. Hybrid response_types — OIDC Core §3.3
/authorizenow accepts four newresponse_typecombinations:code id_tokencode tokencode id_token tokenid_token token(implicit with both tokens)Parser normalizes token order (
token id_token code→ canonicalcode id_token token), rejects unknown combinations with OIDC errorunsupported_response_type, and rejectsresponse_mode=queryfor hybrid flows per §3.3.2.5 (defaults tofragmentwhen the client doesn't specify). Hybrid responses include code + id_token + access_token in a single response per the requested combination. Leans on Phase 1'scfg.CodeHashplumbing —CreateIDTokenalready emitsc_hashwhenevercfg.CodeHashis set.3. JWKS multi-key + verification fallback
Four new opt-in config fields for a secondary JWT key (all default empty):
--jwt-secondary-type--jwt-secondary-secret(HMAC; never exposed in JWKS)--jwt-secondary-private-key--jwt-secondary-public-keyWhen configured:
kids (secondary gets a-secondarysuffix so primary/secondary are always distinguishable, even when they use the same algorithm)ParseJWTTokenretries with the secondary key on primary verification failure, completing the rotation workflow end-to-endSignJWTToken) is unchanged — the secondary key is verification-onlyAutomated time-based rotation is on the roadmap but out of scope for this PR.
4. Back-Channel Logout — OIDC BCL 1.0
New
--backchannel-logout-uriconfig (default empty). When set, successful/logoutfires a fire-and-forget goroutine that:logout_tokenJWT per OIDC BCL 1.0 §2.4:iss,aud,iat,jti,exp(+5 min),eventsmap with the BCL event identifier,sub+sidnonce(explicitly prohibited by §2.4)backchannel_logout_supportedandbackchannel_logout_session_supportediff URI is configured5. File/comment rename cleanup
Now that all three phases are merged, "Phase N" labels in filenames and comments no longer carry useful information. Rename the integration test files to semantic names that describe what they test:
oidc_phase1_userinfo_test.gooidc_userinfo_scope_filtering_test.gooidc_phase2_id_token_claims_test.gooidc_id_token_claims_test.gooidc_phase2_logout_test.gooidc_rp_initiated_logout_test.gooidc_phase2_authorize_test.gooidc_authorize_params_test.gooidc_phase3_introspect_test.gooidc_introspect_test.gooidc_phase3_hybrid_test.gooidc_hybrid_flow_test.gooidc_phase3_jwks_multi_key_test.gooidc_jwks_multi_key_test.gooidc_phase3_backchannel_logout_test.gooidc_backchannel_logout_test.goPlus identifier and comment scrubbing of
Phase 1/2/3labels inauth_token.go,jwt.go,authorize.go, and a few test fixtures.Backward compatibility
/oauth/introspectcode/token/id_tokenstill work identically.--backchannel-logout-uri. Unset = byte-identical to current behavior.No new flags with non-default behavior. No breaking changes. No pre-existing clients affected.
Test plan
TestIntrospectActiveAccessToken+ 8 other introspect testsTestIntrospectInactiveReturnsOnlyActiveFalse(RFC 7662 §2.2 non-disclosure)TestHybridUnsupportedResponseTypeRejectedTestHybridQueryResponseModeRejectedTestHybridResponseTypeParsingAcceptsKnownCombos(4 sub-tests, one per combination)TestHybridResponseTypeOrderInsensitiveTestJWKSPublishesSinglePrimaryKeyByDefaultTestJWKSPublishesBothKeysWhenSecondaryConfiguredTestJWKSSecondaryHMACIsNotExposedTestParseJWTTokenFallsBackToSecondaryKey(end-to-end rotation verification)TestParseJWTTokenRejectsUnsignedGarbage(fallback safety guard)TestBackchannelLogoutSendsLogoutToken(full JWT claim assertions includingnonceabsence)TestBackchannelLogoutEmptyURIIsErrorTestBackchannelLogoutMissingSubAndSidIsErrormake test-sqlitegreenCommit log
7 commits, +1260/-99 across 22 files.
Out of scope / future work
acr_valuesrequest support (the hardcodedacr="0"from Phase 2 stays)sid(session ID) column on the session model —logout_token.sidcurrently usessessionData.Nonceas the closest existing analogue