mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
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:
@@ -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"):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user