Skip to content

Commit 22c2efe

Browse files
authored
feat(oidc): phase 3 — introspection, hybrid flows, JWKS multi-key, back-channel logout (#593)
* feat(oidc): add RFC 7662 token introspection endpoint 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. * feat(oidc): support hybrid response_type combinations 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. * feat(oidc): JWKS multi-key support for manual key rotation 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 * feat(oidc): OIDC Back-Channel Logout 1.0 notification 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 * feat(oidc): wire phase 3 discovery fields + secondary-key verification 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. * refactor(oidc): phase 3 review follow-ups 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. * chore(oidc): rename phase-prefixed files and scrub phase references 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.
1 parent 12d3348 commit 22c2efe

22 files changed

Lines changed: 1260 additions & 99 deletions

cmd/root.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,11 @@ func init() {
181181
f.StringVar(&rootArgs.config.JWTSecret, "jwt-secret", "", "Secret for the JWT")
182182
f.StringVar(&rootArgs.config.JWTPrivateKey, "jwt-private-key", "", "Private key for the JWT")
183183
f.StringVar(&rootArgs.config.JWTPublicKey, "jwt-public-key", "", "Public key for the JWT")
184+
// JWT secondary key flags (for manual key rotation)
185+
f.StringVar(&rootArgs.config.JWTSecondaryType, "jwt-secondary-type", "", "Algorithm of the optional secondary JWT key used for manual rotation. When set, JWKS publishes both keys and token validation accepts either. New tokens are always signed with the primary (--jwt-type) key.")
186+
f.StringVar(&rootArgs.config.JWTSecondarySecret, "jwt-secondary-secret", "", "Secret for the secondary JWT key (HMAC only; never exposed via JWKS)")
187+
f.StringVar(&rootArgs.config.JWTSecondaryPrivateKey, "jwt-secondary-private-key", "", "Private key for the secondary JWT key. Currently unused — verification only uses the public key; kept for symmetry with --jwt-private-key and for future primary/secondary swap automation.")
188+
f.StringVar(&rootArgs.config.JWTSecondaryPublicKey, "jwt-secondary-public-key", "", "Public key for the secondary JWT key. Used to verify tokens signed with the secondary key during rotation.")
184189
f.StringVar(&rootArgs.config.JWTRoleClaim, "jwt-role-claim", defaultJWTRoleClaim, "Role claim for the JWT")
185190
f.StringVar(&rootArgs.config.CustomAccessTokenScript, "custom-access-token-script", "", "Custom access token script")
186191

@@ -226,6 +231,9 @@ func init() {
226231
// URLs
227232
f.StringVar(&rootArgs.config.ResetPasswordURL, "reset-password-url", "", "URL for reset password")
228233

234+
// Back-channel logout (OIDC BCL 1.0)
235+
f.StringVar(&rootArgs.config.BackchannelLogoutURI, "backchannel-logout-uri", "", "URL to POST a signed logout_token to when users log out successfully. Leave empty (default) to disable back-channel logout notifications. See OIDC Back-Channel Logout 1.0.")
236+
229237
// Deprecated flags
230238
f.MarkDeprecated("database_url", "use --database-url instead")
231239
f.MarkDeprecated("database_type", "use --database-type instead")

internal/config/config.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,25 @@ type Config struct {
166166
JWTPublicKey string
167167
// JWTPrivateKey is the private key for the JWT
168168
JWTPrivateKey string
169+
// JWTSecondaryType is the algorithm of an optional secondary JWT
170+
// key used for manual key rotation. When set along with the other
171+
// JWT secondary fields, the JWKS endpoint will publish both keys
172+
// and token validation will accept tokens signed with either key.
173+
// New tokens are always signed with the primary key (JWTType).
174+
// Leave empty to disable multi-key mode (default).
175+
JWTSecondaryType string
176+
// JWTSecondarySecret is the secret for the secondary JWT key.
177+
// Used only when JWTSecondaryType is an HMAC algorithm. HMAC keys
178+
// are never exposed via the JWKS endpoint.
179+
JWTSecondarySecret string
180+
// JWTSecondaryPublicKey is the public key for the secondary JWT
181+
// key. Used when JWTSecondaryType is RSA or ECDSA.
182+
JWTSecondaryPublicKey string
183+
// JWTSecondaryPrivateKey is the private key for the secondary JWT
184+
// key. Currently unused at the signing stage (the primary key is
185+
// always used to sign); kept for symmetry and for future
186+
// primary/secondary swap automation.
187+
JWTSecondaryPrivateKey string
169188
// JWTRoleClaim is the role claim for the JWT
170189
JWTRoleClaim string
171190
// RefreshTokenExpiresIn is the refresh token lifetime in seconds.
@@ -286,4 +305,10 @@ type Config struct {
286305
// reverse proxy MUST set this explicitly or rate limiting and audit
287306
// logs will key on the proxy IP, not the real client IP.
288307
TrustedProxies []string
308+
309+
// BackchannelLogoutURI is the URL to which the server POSTs a
310+
// signed logout_token when a user logs out successfully. When
311+
// empty (default), back-channel logout notifications are disabled.
312+
// See OIDC Back-Channel Logout 1.0 §2.5 for the protocol.
313+
BackchannelLogoutURI string
289314
}

internal/http_handlers/authorize.go

Lines changed: 187 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
"fmt"
3838
"net/http"
3939
"net/url"
40+
"sort"
4041
"strconv"
4142
"strings"
4243
"time"
@@ -80,6 +81,7 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc {
8081
scopeString := strings.TrimSpace(gc.Query("scope"))
8182
clientID := strings.TrimSpace(gc.Query("client_id"))
8283
responseMode := strings.TrimSpace(gc.Query("response_mode"))
84+
rawResponseMode := responseMode
8385
nonce := strings.TrimSpace(gc.Query("nonce"))
8486
screenHint := strings.TrimSpace(gc.Query("screen_hint"))
8587

@@ -112,10 +114,10 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc {
112114
log.Debug().Msg("id_token_hint provided but invalid — ignoring per OIDC Core §3.1.2.1")
113115
}
114116

115-
// prompt=consent / prompt=select_account are accepted but not
116-
// implemented in Phase 2.
117+
// prompt=consent / prompt=select_account are accepted but
118+
// not yet implemented — proceed normally.
117119
if prompt == "consent" || prompt == "select_account" {
118-
log.Debug().Str("prompt", prompt).Msg("prompt value accepted but not implemented in Phase 2 — proceeding normally")
120+
log.Debug().Str("prompt", prompt).Msg("prompt value accepted but not implemented — proceeding normally")
119121
}
120122

121123
var scope []string
@@ -161,6 +163,38 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc {
161163
return
162164
}
163165

166+
canonical, ok := supportedResponseTypeSet(responseType)
167+
if !ok {
168+
log.Debug().Str("response_type", responseType).Msg("unsupported response_type")
169+
gc.JSON(http.StatusBadRequest, gin.H{
170+
"error": "unsupported_response_type",
171+
"error_description": "response_type is not supported",
172+
})
173+
return
174+
}
175+
responseType = canonical
176+
177+
// OIDC Core §3.3 hybrid response_type combinations.
178+
isHybrid := responseType == "code id_token" ||
179+
responseType == "code token" ||
180+
responseType == "code id_token token" ||
181+
responseType == "id_token token"
182+
if isHybrid {
183+
// OIDC Core §3.3.2.5: hybrid flow MUST NOT use query response_mode.
184+
if rawResponseMode == constants.ResponseModeQuery {
185+
gc.JSON(http.StatusBadRequest, gin.H{
186+
"error": "invalid_request",
187+
"error_description": "response_mode=query is not allowed for hybrid response_type",
188+
})
189+
return
190+
}
191+
// Default to fragment when the client did not explicitly
192+
// specify one (the global default may be query).
193+
if rawResponseMode == "" {
194+
responseMode = constants.ResponseModeFragment
195+
}
196+
}
197+
164198
if err := h.validateAuthorizeRequest(responseType, responseMode, clientID, state, codeChallenge); err != nil {
165199
log.Debug().Err(err).Msg("Invalid request")
166200
gc.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -355,6 +389,112 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc {
355389

356390
// rollover the session for security
357391
go h.MemoryStoreProvider.DeleteUserSession(sessionKey, claims.Nonce)
392+
393+
if isHybrid {
394+
hostname := parsers.GetHost(gc)
395+
// For hybrid flows we mint tokens AND a code. Setting Code
396+
// on the AuthTokenConfig causes CreateAuthToken to populate
397+
// cfg.CodeHash, which in turn causes CreateIDToken to emit
398+
// the c_hash claim per OIDC Core §3.3.2.11.
399+
authToken, err := h.TokenProvider.CreateAuthToken(gc, &token.AuthTokenConfig{
400+
User: user,
401+
Nonce: nonce,
402+
Code: code,
403+
Roles: claims.Roles,
404+
Scope: scope,
405+
LoginMethod: claims.LoginMethod,
406+
HostName: hostname,
407+
})
408+
if err != nil {
409+
log.Debug().Err(err).Msg("Error creating auth token for hybrid response")
410+
handleResponse(gc, responseMode, authURL, redirectURI, loginError, http.StatusOK)
411+
return
412+
}
413+
414+
// Stash the code so /oauth/token can later exchange it.
415+
if err := h.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+authToken.FingerPrint); err != nil {
416+
log.Debug().Err(err).Msg("Error setting temp code for hybrid")
417+
handleResponse(gc, responseMode, authURL, redirectURI, loginError, http.StatusOK)
418+
return
419+
}
420+
if err := h.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeSessionToken+"_"+authToken.FingerPrint, authToken.FingerPrintHash, authToken.SessionTokenExpiresAt); err != nil {
421+
log.Debug().Err(err).Msg("Error persisting session for hybrid")
422+
handleResponse(gc, responseMode, authURL, redirectURI, loginError, http.StatusOK)
423+
return
424+
}
425+
if err := h.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeAccessToken+"_"+authToken.FingerPrint, authToken.AccessToken.Token, authToken.AccessToken.ExpiresAt); err != nil {
426+
log.Debug().Err(err).Msg("Error persisting access token for hybrid")
427+
handleResponse(gc, responseMode, authURL, redirectURI, loginError, http.StatusOK)
428+
return
429+
}
430+
cookie.SetSession(gc, authToken.FingerPrintHash, h.Config.AppCookieSecure)
431+
expiresIn := authToken.AccessToken.ExpiresAt - time.Now().Unix()
432+
if expiresIn <= 0 {
433+
expiresIn = 1
434+
}
435+
436+
hasAccessToken := responseType == "code token" ||
437+
responseType == "code id_token token" ||
438+
responseType == "id_token token"
439+
hasIDToken := responseType == "code id_token" ||
440+
responseType == "code id_token token" ||
441+
responseType == "id_token token"
442+
443+
// Build the response map. Always include code + state for
444+
// hybrid combos containing "code"; include id_token /
445+
// access_token based on the requested set.
446+
res := map[string]interface{}{
447+
"state": state,
448+
"token_type": "Bearer",
449+
"scope": strings.Join(scope, " "),
450+
"expires_in": expiresIn,
451+
}
452+
hasCode := strings.Contains(responseType, "code")
453+
if hasCode {
454+
res["code"] = code
455+
}
456+
if hasAccessToken {
457+
res["access_token"] = authToken.AccessToken.Token
458+
}
459+
if hasIDToken {
460+
res["id_token"] = authToken.IDToken.Token
461+
}
462+
if nonce != "" {
463+
res["nonce"] = nonce
464+
}
465+
466+
// Build the fragment params string for redirect modes.
467+
params := "state=" + state + "&token_type=bearer&expires_in=" + strconv.FormatInt(expiresIn, 10)
468+
if hasCode {
469+
params += "&code=" + code
470+
}
471+
if hasAccessToken {
472+
params += "&access_token=" + authToken.AccessToken.Token
473+
}
474+
if hasIDToken {
475+
params += "&id_token=" + authToken.IDToken.Token
476+
}
477+
if nonce != "" {
478+
params += "&nonce=" + nonce
479+
}
480+
481+
// Hybrid is fragment-default; the pre-check above ensured
482+
// responseMode != "query".
483+
if responseMode == constants.ResponseModeFragment {
484+
if strings.Contains(redirectURI, "#") {
485+
redirectURI = redirectURI + "&" + params
486+
} else {
487+
redirectURI = redirectURI + "#" + params
488+
}
489+
}
490+
491+
handleResponse(gc, responseMode, authURL, redirectURI, map[string]interface{}{
492+
"type": "authorization_response",
493+
"response": res,
494+
}, http.StatusOK)
495+
return
496+
}
497+
358498
if responseType == constants.ResponseTypeCode {
359499
newSessionTokenData, newSessionToken, newSessionExpiresAt, err := h.TokenProvider.CreateSessionToken(&token.AuthTokenConfig{
360500
User: user,
@@ -520,12 +660,54 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc {
520660
}
521661
}
522662

663+
// supportedResponseTypeSet normalizes a space-delimited response_type
664+
// string into a canonical sorted form and returns whether it is one of
665+
// the supported OIDC Core combinations. Returns the canonical form and
666+
// true on success; empty string and false on unsupported.
667+
func supportedResponseTypeSet(raw string) (string, bool) {
668+
fields := strings.Fields(raw)
669+
if len(fields) == 0 {
670+
return "", false
671+
}
672+
// Dedupe + sort.
673+
seen := map[string]bool{}
674+
for _, f := range fields {
675+
f = strings.ToLower(strings.TrimSpace(f))
676+
if f != "" {
677+
seen[f] = true
678+
}
679+
}
680+
tokens := make([]string, 0, len(seen))
681+
for k := range seen {
682+
tokens = append(tokens, k)
683+
}
684+
sort.Strings(tokens)
685+
canonical := strings.Join(tokens, " ")
686+
687+
switch canonical {
688+
// Existing single-value types.
689+
case "code", "token", "id_token":
690+
return canonical, true
691+
// Hybrid combinations (OIDC Core §3.3).
692+
case "code id_token":
693+
return canonical, true
694+
case "code token":
695+
return canonical, true
696+
case "code id_token token":
697+
return canonical, true
698+
// Implicit with both.
699+
case "id_token token":
700+
return canonical, true
701+
}
702+
return "", false
703+
}
704+
523705
func (h *httpProvider) validateAuthorizeRequest(responseType, responseMode, clientID, state, codeChallenge string) error {
524706
if strings.TrimSpace(state) == "" {
525707
return fmt.Errorf("invalid state. state is required to prevent csrf attack")
526708
}
527-
if responseType != constants.ResponseTypeCode && responseType != constants.ResponseTypeToken && responseType != constants.ResponseTypeIDToken {
528-
return fmt.Errorf("invalid response type %s. 'code' & 'token' are valid response_type", responseType)
709+
if _, ok := supportedResponseTypeSet(responseType); !ok {
710+
return fmt.Errorf("invalid response type %s", responseType)
529711
}
530712

531713
if responseMode != constants.ResponseModeQuery && responseMode != constants.ResponseModeWebMessage && responseMode != constants.ResponseModeFragment && responseMode != constants.ResponseModeFormPost {

internal/http_handlers/csrf.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func (h *httpProvider) CSRFMiddleware() gin.HandlerFunc {
4545

4646
// Exempt /oauth/token and /oauth/revoke (client credentials flow,
4747
// authenticated via bearer or client_secret, not cookies)
48-
if c.Request.URL.Path == "/oauth/token" || c.Request.URL.Path == "/oauth/revoke" {
48+
if c.Request.URL.Path == "/oauth/token" || c.Request.URL.Path == "/oauth/revoke" || c.Request.URL.Path == "/oauth/introspect" {
4949
c.Next()
5050
return
5151
}

0 commit comments

Comments
 (0)