Skip to content

feat(oidc): phase 3 — introspection, hybrid flows, JWKS multi-key, back-channel logout#593

Merged
lakhansamani merged 7 commits intomainfrom
feat/oidc-phase3-extensions
Apr 7, 2026
Merged

feat(oidc): phase 3 — introspection, hybrid flows, JWKS multi-key, back-channel logout#593
lakhansamani merged 7 commits intomainfrom
feat/oidc-phase3-extensions

Conversation

@lakhansamani
Copy link
Copy Markdown
Contributor

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/introspect endpoint mirroring the /oauth/revoke client auth pattern.

  • client_secret_basic (HTTP Basic) or client_secret_post (form body)
  • Active tokens return {active:true, scope, client_id, exp, iat, sub, aud, iss, token_type}
  • Inactive/invalid tokens return only {active:false} per RFC 7662 §2.2 non-disclosure (no error, no error_description, no claim leakage)
  • client_id in the response is set directly from the server's configured ClientID (never passed through from the aud claim — prevents array-shaped client_id when multi-audience tokens are introduced)
  • Cache-Control: no-store + Pragma: no-cache on all responses
  • Registered in discovery: introspection_endpoint + introspection_endpoint_auth_methods_supported
  • CSRF exempt (client-authenticated, not cookie-authenticated)

2. Hybrid response_types — OIDC Core §3.3

/authorize now accepts four new response_type combinations:

  • code id_token
  • code token
  • code id_token token
  • id_token token (implicit with both tokens)

Parser normalizes token order (token id_token code → canonical code id_token token), rejects unknown combinations with OIDC error unsupported_response_type, and rejects response_mode=query for hybrid flows per §3.3.2.5 (defaults to fragment when 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's cfg.CodeHash plumbing — CreateIDToken already emits c_hash whenever cfg.CodeHash is 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-key

When configured:

  • JWKS publishes both the primary and secondary public keys with distinct kids (secondary gets a -secondary suffix so primary/secondary are always distinguishable, even when they use the same algorithm)
  • ParseJWTToken retries with the secondary key on primary verification failure, completing the rotation workflow end-to-end
  • The signing path (SignJWTToken) is unchanged — the secondary key is verification-only
  • Rotation workflow: add secondary → swap primary/secondary → wait for outstanding tokens to expire → remove secondary

Automated 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-uri config (default empty). When set, successful /logout fires a fire-and-forget goroutine that:

  • Builds a logout_token JWT per OIDC BCL 1.0 §2.4: iss, aud, iat, jti, exp (+5 min), events map with the BCL event identifier, sub + sid
  • Deliberately does not include nonce (explicitly prohibited by §2.4)
  • Signs with the same key as ID tokens (receivers verify via the existing JWKS URI)
  • POSTs with 5-second dual timeout (context + HTTP client); logout UX is never blocked
  • Discovery advertises backchannel_logout_supported and backchannel_logout_session_supported iff URI is configured

5. 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:

Old New
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

Plus identifier and comment scrubbing of Phase 1/2/3 labels in auth_token.go, jwt.go, authorize.go, and a few test fixtures.

Backward compatibility

Area Impact
/oauth/introspect Net-new endpoint. Old clients unaffected.
Hybrid response_types Net-new values. Single-value code/token/id_token still work identically.
JWKS multi-key Secondary key opt-in via config. Unset = byte-identical to current behavior.
Back-channel logout Opt-in via --backchannel-logout-uri. Unset = byte-identical to current behavior.
File renames Pure cosmetic. Only test files moved; git preserves history.

No new flags with non-default behavior. No breaking changes. No pre-existing clients affected.

Test plan

  • TestIntrospectActiveAccessToken + 8 other introspect tests
  • TestIntrospectInactiveReturnsOnlyActiveFalse (RFC 7662 §2.2 non-disclosure)
  • TestHybridUnsupportedResponseTypeRejected
  • TestHybridQueryResponseModeRejected
  • TestHybridResponseTypeParsingAcceptsKnownCombos (4 sub-tests, one per combination)
  • TestHybridResponseTypeOrderInsensitive
  • TestJWKSPublishesSinglePrimaryKeyByDefault
  • TestJWKSPublishesBothKeysWhenSecondaryConfigured
  • TestJWKSSecondaryHMACIsNotExposed
  • TestParseJWTTokenFallsBackToSecondaryKey (end-to-end rotation verification)
  • TestParseJWTTokenRejectsUnsignedGarbage (fallback safety guard)
  • TestBackchannelLogoutSendsLogoutToken (full JWT claim assertions including nonce absence)
  • TestBackchannelLogoutEmptyURIIsError
  • TestBackchannelLogoutMissingSubAndSidIsError
  • Full Phase 1 + Phase 2 regression still green
  • Full make test-sqlite green

Commit log

9ed855a7 chore(oidc): rename phase-prefixed files and scrub phase references
6beb8466 refactor(oidc): phase 3 review follow-ups
36b11852 feat(oidc): wire phase 3 discovery fields + secondary-key verification fallback
799cbf3c feat(oidc): OIDC Back-Channel Logout 1.0 notification
64aca794 feat(oidc): JWKS multi-key support for manual key rotation
f79b8126 feat(oidc): support hybrid response_type combinations
ef4115f8 feat(oidc): add RFC 7662 token introspection endpoint

7 commits, +1260/-99 across 22 files.

Out of scope / future work

  • Automated JWKS key rotation with time-based intervals (manual rotation lands in this PR)
  • MFA-aware acr_values request support (the hardcoded acr="0" from Phase 2 stays)
  • RFC 7591 dynamic client registration
  • RFC 9101 JAR / Request Object
  • OIDC Session Management iframe + front-channel logout
  • Hybrid end-to-end tests require a session-minting test helper that was deliberately deferred from previous phases
  • Dedicated sid (session ID) column on the session model — logout_token.sid currently uses sessionData.Nonce as the closest existing analogue

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.
@lakhansamani lakhansamani merged commit 22c2efe into main Apr 7, 2026
@lakhansamani lakhansamani deleted the feat/oidc-phase3-extensions branch April 7, 2026 16:56
@lakhansamani lakhansamani restored the feat/oidc-phase3-extensions branch April 8, 2026 04:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant