harden(oidc): relax alg-downgrade IdP-bind check to intersection-empty (Keycloak compat)

Phase-10 live-IdP smoke (Keycloak 26.x via testcontainers-go) revealed
the IdP-bind alg-downgrade check was too strict for real-world IdPs.
6 of the integration tests in internal/auth/oidc/integration_keycloak*_test.go
were failing with:

  oidc: IdP advertises weak signing algorithms (HS*/none);
  refusing to use as defense against downgrade attacks: HS256

Keycloak 26.x (and several other real-world IdPs — Auth0 when HS-mode is
enabled, some Authentik configs) advertise EVERY alg they're capable of
in the discovery doc's id_token_signing_alg_values_supported field, even
when the realm only signs with RS256 in practice. Pre-fix the IdP-bind
check refused on ANY HS* or 'none' advertisement → no real Keycloak deploy
could ever bind a provider row, hence the integration-test failures.

The strict-deny check was defense-in-depth on top of the load-bearing
per-token alg-pin at sig-verify time (isDisallowedAlg, service.go L1177):
that check rejects every ID token whose JWS header carries an alg outside
DefaultAllowedAlgs, regardless of what the discovery doc advertises.
A forged HS256 token signed with the IdP's RS256 pubkey as HMAC secret
is rejected at sig-verify time → the actual algorithm-confusion attack
is closed by the per-token pin, NOT by the discovery-doc check.

Fix: relax the IdP-bind check to refuse only when the intersection of
advertised vs DefaultAllowedAlgs is EMPTY (the pathological all-weak-alg
IdP case). Keycloak (RS256 + HS256 advertised) now binds successfully;
an HS-only IdP still fails closed.

Changes:
- internal/auth/oidc/service.go: rewrite the alg-check loop at L1067 in
  getOrLoad / RefreshKeys to compute the intersection set; refuse only
  when no acceptable alg is advertised. ErrIdPDowngradeAdvertised
  docstring updated to reflect new contract. DefaultAllowedAlgs
  docstring + the package-level design-comment block at L40-72 updated
  with v2.1.0-relaxed semantics callouts.
- internal/auth/oidc/test_discovery.go: TestDiscovery dry-run validator
  rewritten to surface HS*/none alongside RS* as an informational note
  ('note: IdP advertises weak algorithms %v alongside acceptable ones')
  rather than a hard-fail error. HS-only / none-only still hard-fails.
- internal/auth/oidc/service_test.go: TestService_IdPDowngradeDefense_*
  tests updated. Renamed:
  - RejectsHSAdvertised → RS256PlusHS256_BindsSuccessfully (positive)
  - RejectsNoneAdvertised → RejectsHSOnlyAdvertised (intersection-empty)
  - RefreshKeys_CatchesPostLoadDowngrade rotated to HS-only post-load
- internal/auth/oidc/coverage_fill_test.go: TestTestDiscovery_AlgDowngradeDetected
  split into _HS256AlongsideRS256_BindsWithNote (positive, asserts note
  but no hard-fail) + _HSOnly_StillTrips_HardFail (intersection-empty).
- docs/operator/auth-threat-model.md: OIDC token-validation alg-allow-list
  section rewritten to call out the load-bearing-defense hierarchy
  (per-token pin first, IdP-bind check defense-in-depth) and document
  the v2.1.0 relaxation rationale.
- CHANGELOG.md: ### Security entry under Unreleased.

Verify: go test ./internal/auth/oidc/ -short PASS; gofmt clean; go vet
clean. The Keycloak integration tests should now pass when the operator
re-runs 'make keycloak-integration-test'.
This commit is contained in:
shankar0123
2026-05-11 15:34:59 +00:00
parent 1cfa9f2e2a
commit fefeccfa59
6 changed files with 220 additions and 55 deletions
+29 -6
View File
@@ -76,14 +76,37 @@ func (s *Service) TestDiscovery(ctx context.Context, issuerURL string) (*TestDis
res.JWKSURI = advertised.JWKSURI
res.UserInfoEndpoint = advertised.UserInfoEndpoint
// Step 3 — alg-downgrade defense. The IdP MUST NOT advertise HS*
// or none in the signing-alg list (operators that bind certctl to
// an IdP advertising these are at risk of a forged-token attack).
// Same check applied in getOrLoad's production path.
// Step 3 — alg-downgrade defense (v2.1.0-relaxed semantics).
// Pre-v2.1.0 this loop appended an error for ANY HS*/none in the
// IdP's advertised list. That was strict-deny but incompatible with
// real IdPs like Keycloak 26.x which list every alg they're capable
// of, even though the realm only signs with RS256.
// New semantics: only flag the IdP if the intersection of advertised
// vs DefaultAllowedAlgs is empty (a pathological all-weak IdP). Each
// HS*/none advertisement is still surfaced as an informational note
// so operators can ask their IdP team to tighten the list, but it's
// no longer a hard fail. The per-token alg check at sig-verify time
// (isDisallowedAlg in service.go ~L1177) is the load-bearing defense.
allowedSet := make(map[string]struct{}, len(DefaultAllowedAlgs))
for _, a := range DefaultAllowedAlgs {
allowedSet[a] = struct{}{}
}
hasAcceptable := false
weak := []string{}
for _, a := range advertised.IDTokenSigningAlgValuesSupported {
if _, deny := disallowedAlgs[a]; deny {
res.Errors = append(res.Errors, fmt.Sprintf("alg-downgrade defense tripped: IdP advertises %s in id_token_signing_alg_values_supported", a))
if _, ok := allowedSet[a]; ok {
hasAcceptable = true
}
if _, deny := disallowedAlgs[a]; deny {
weak = append(weak, a)
}
}
if len(advertised.IDTokenSigningAlgValuesSupported) > 0 && !hasAcceptable {
res.Errors = append(res.Errors, fmt.Sprintf("alg-downgrade defense tripped: IdP advertises only weak algorithms (%v) — no acceptable alg from %v present", advertised.IDTokenSigningAlgValuesSupported, DefaultAllowedAlgs))
} else if len(weak) > 0 {
// Informational only — RS/ES present alongside HS, so the
// IdP binds successfully but the operator should know.
res.Errors = append(res.Errors, fmt.Sprintf("note: IdP advertises weak algorithms %v alongside acceptable ones — verifier-side alg pin prevents downgrade, but tightening the IdP's advertised list is recommended", weak))
}
// Step 4 — JWKS reachability. The go-oidc Verifier defers JWKS