harden(oidc): RFC 9207 iss URL parameter check on callback (MED-17)

Audit 2026-05-10 MED-17 closure.

WHAT.

When the matched IdP's discovery doc advertises
authorization_response_iss_parameter_supported=true (RFC 9207 §3),
HandleCallback now REQUIRES a non-empty `iss` query parameter on
/auth/oidc/callback and enforces a constant-time compare against the
configured provider's IssuerURL. Mismatch maps to two new sentinel
errors (ErrIssParamMissing / ErrIssParamMismatch) that the handler's
classifyOIDCFailure dispatches via errors.Is BEFORE the substring
fall-through, so the audit failure_category remains distinguishable
between the RFC 9207 leg (iss_param_missing / iss_param_mismatch) and
the in-token iss claim leg (id_token_iss_mismatch).

WHY.

The RFC 9207 iss URL parameter is the load-bearing mix-up-attack
defense for multi-tenant IdPs (Keycloak realms, Authentik tenants,
Auth0 tenants, public-trust CAs). Pre-fix the parameter was silently
ignored — an attacker controlling one IdP tenant could route an auth
code to certctl's callback against a different tenant's pre-login
state without detection. Modern Keycloak / Authentik / public-trust
CAs ship the discovery flag by default; legacy IdPs that don't
advertise are unaffected (back-compat preserved).

HOW.

- internal/auth/oidc/service.go
  - providerEntry gains issParamSupported bool.
  - getOrLoad extends the discovery-claims read to include
    authorization_response_iss_parameter_supported, alongside the
    existing id_token_signing_alg_values_supported defense.
  - HandleCallback's signature gains callbackIss string at position 5.
    Step 2.5 runs after the state compare + provider load: when
    issParamSupported is true, an empty callbackIss returns
    ErrIssParamMissing; a present-but-mismatched value returns
    ErrIssParamMismatch (constant-time compare).
  - Two new sentinels: ErrIssParamMissing, ErrIssParamMismatch.
    ErrIssuerMismatch's doc-string clarified to note it covers the
    in-token leg only.

- internal/api/handler/auth_session_oidc.go
  - OIDCAuthHandshaker.HandleCallback signature updated.
  - LoginCallback reads r.URL.Query().Get("iss") (no TrimSpace —
    byte-strict compare upstream) and threads it through.
  - classifyOIDCFailure: typed errors.Is dispatch for the three
    iss-family sentinels BEFORE the substring fall-through, so the
    three cases stay distinguishable in the audit row.

- internal/api/handler/auth_session_oidc_test.go
  - stubOIDCSvc.HandleCallback bumped to 7-arg signature.
  - TestClassifyOIDCFailure extended with 5 new cases pinning the
    iss-family dispatch + a wrapped-error round-trip.

- internal/auth/oidc/service_test.go
  - mockIdP gains advertiseIssParameterSupported bool; the
    /.well-known/openid-configuration handler emits the claim only
    when set (so existing tests stay back-compat).
  - 4 new regression tests:
    * MED17_NoSupport_AnyIssAccepted — provider doesn't advertise;
      arbitrary callbackIss is ignored (back-compat).
    * MED17_SupportButMissing — provider advertises; missing iss →
      ErrIssParamMissing.
    * MED17_SupportButMismatch — provider advertises; wrong iss →
      ErrIssParamMismatch (load-bearing mix-up defense).
    * MED17_SupportAndCorrect — provider advertises; matching iss →
      success path proves the gate isn't over-eager.

- internal/auth/oidc/bench_test.go,
  internal/auth/oidc/logging_test.go,
  internal/auth/oidc/integration_keycloak_test.go
  - Mechanical: all existing HandleCallback call sites updated to
    pass "" for callbackIss (matches pre-fix behavior for IdPs that
    don't advertise support — the Keycloak integration suite tests
    will be re-evaluated once the Keycloak fixture is run against a
    realm with the discovery flag enabled).

VERIFY.

- go vet ./internal/auth/oidc/... ./internal/api/handler/...   PASS
- go test -short -count=1 ./internal/auth/oidc/...              PASS (3.4s)
- go test -short -count=1 ./internal/api/handler/...            PASS (5.4s)
- 4 new MED-17 regression tests + extended TestClassifyOIDCFailure pass.

Refs: cowork/auth-bundles-audit-2026-05-10.md MED-17
      cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md item 7
      RFC 9207 — OAuth 2.0 Authorization Server Issuer Identification
This commit is contained in:
shankar0123
2026-05-10 23:05:52 +00:00
parent 874419989d
commit 2cd2a5c52f
8 changed files with 274 additions and 50 deletions
+32 -2
View File
@@ -56,7 +56,11 @@ import (
// consumes. Phase 3's *oidc.Service satisfies this directly.
type OIDCAuthHandshaker interface {
HandleAuthRequest(ctx context.Context, providerID string) (authURL, cookieValue, preLoginID string, err error)
HandleCallback(ctx context.Context, preLoginCookie, code, callbackState, ip, userAgent string) (*oidcsvc.CallbackResult, error)
// Audit 2026-05-10 MED-17 — callbackIss carries the value of the
// RFC 9207 `iss` query parameter on /auth/oidc/callback (empty
// string when the IdP doesn't send it). The service enforces the
// check only when the provider's discovery doc advertised support.
HandleCallback(ctx context.Context, preLoginCookie, code, callbackState, callbackIss, ip, userAgent string) (*oidcsvc.CallbackResult, error)
RefreshKeys(ctx context.Context, providerID string) error
}
@@ -272,6 +276,12 @@ func (h *AuthSessionOIDCHandler) LoginCallback(w http.ResponseWriter, r *http.Re
q := r.URL.Query()
code := strings.TrimSpace(q.Get("code"))
state := strings.TrimSpace(q.Get("state"))
// Audit 2026-05-10 MED-17 — RFC 9207 iss URL parameter. NOT
// trimmed; preserved exactly as sent so the service-layer compare
// against the matched provider's IssuerURL is byte-strict. The IdP
// emits this only when advertised in its discovery doc; the
// service-layer check is a no-op otherwise.
callbackIss := q.Get("iss")
if code == "" || state == "" {
Error(w, http.StatusBadRequest, "missing code or state query parameter")
return
@@ -286,7 +296,7 @@ func (h *AuthSessionOIDCHandler) LoginCallback(w http.ResponseWriter, r *http.Re
clientIP := clientIPFromRequest(r)
userAgent := r.UserAgent()
res, err := h.oidcSvc.HandleCallback(r.Context(), preLoginCookie.Value, code, state, clientIP, userAgent)
res, err := h.oidcSvc.HandleCallback(r.Context(), preLoginCookie.Value, code, state, callbackIss, clientIP, userAgent)
if err != nil {
// Audit 2026-05-10 HIGH-7 — instead of a blank 400, redirect
// to /login?error=oidc_failed&reason=<category>. The LoginPage
@@ -1152,10 +1162,30 @@ func clientIPFromRequest(r *http.Request) string {
// classifyOIDCFailure maps an OIDC service error to a stable audit
// category string. Used for the failure_category audit detail; the
// wire stays uniform 400.
//
// Audit 2026-05-10 MED-17 — the three iss-related sentinel errors are
// dispatched via errors.Is BEFORE the substring fall-through so they
// stay distinguishable in the audit row:
// - ErrIssParamMissing → iss_param_missing
// - ErrIssParamMismatch → iss_param_mismatch
// - ErrIssuerMismatch → id_token_iss_mismatch
//
// errors.Is is used for the iss family because all three error
// strings contain "iss" and substring matching would either collapse
// them or order-dependently mis-classify.
func classifyOIDCFailure(err error) string {
if err == nil {
return "ok"
}
// Audit 2026-05-10 MED-17 — typed dispatch for the iss family.
switch {
case errors.Is(err, oidcsvc.ErrIssParamMissing):
return "iss_param_missing"
case errors.Is(err, oidcsvc.ErrIssParamMismatch):
return "iss_param_mismatch"
case errors.Is(err, oidcsvc.ErrIssuerMismatch):
return "id_token_iss_mismatch"
}
msg := strings.ToLower(err.Error())
switch {
case strings.Contains(msg, "pre-login"):
+10 -1
View File
@@ -46,7 +46,7 @@ type stubOIDCSvc struct {
func (s *stubOIDCSvc) HandleAuthRequest(_ context.Context, _ string) (string, string, string, error) {
return s.authURL, s.cookie, s.preLoginID, s.authReqErr
}
func (s *stubOIDCSvc) HandleCallback(_ context.Context, _, _, _, _, _ string) (*oidcsvc.CallbackResult, error) {
func (s *stubOIDCSvc) HandleCallback(_ context.Context, _, _, _, _, _, _ string) (*oidcsvc.CallbackResult, error) {
return s.callbackRes, s.callbackErr
}
func (s *stubOIDCSvc) RefreshKeys(_ context.Context, _ string) error { return s.refreshErr }
@@ -1197,6 +1197,15 @@ func TestClassifyOIDCFailure(t *testing.T) {
{errors.New("oidc: groups did not match any configured mapping"), "unmapped_groups"},
{errors.New("oidc: configured groups claim missing or malformed"), "groups_missing"},
{errors.New("oidc: jwks unreachable"), "jwks_unreachable"},
// Audit 2026-05-10 MED-17 — typed dispatch beats the substring
// fallthrough because all three iss-family sentinels contain
// "iss" in their message and would otherwise mis-classify.
{oidcsvc.ErrIssParamMissing, "iss_param_missing"},
{oidcsvc.ErrIssParamMismatch, "iss_param_mismatch"},
{oidcsvc.ErrIssuerMismatch, "id_token_iss_mismatch"},
// Wrapped variants must round-trip through errors.Is.
{fmt.Errorf("upstream: %w", oidcsvc.ErrIssParamMissing), "iss_param_missing"},
{fmt.Errorf("upstream: %w", oidcsvc.ErrIssParamMismatch), "iss_param_mismatch"},
{errors.New("some other error"), "unspecified"},
}
for _, tc := range cases {