mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
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:
@@ -169,17 +169,27 @@ constant, router-level no-rbacGate-wraps-protocol-paths).
|
||||
|
||||
- **Algorithm allow-list, never `none`, never HMAC.** The service-
|
||||
layer pinning lives in `internal/auth/oidc/service.go::disallowedAlgs`
|
||||
and the IdP-downgrade-attack defense in
|
||||
`Service.guardAdvertisedAlgs`. At provider creation AND on every
|
||||
`RefreshKeys`, the IdP's advertised
|
||||
`id_token_signing_alg_values_supported` is intersected with the
|
||||
allow-list (RS256 / RS512 / ES256 / ES384 / EdDSA). If the IdP
|
||||
advertises HS256/HS384/HS512 or `none` AT ALL, provider creation
|
||||
is rejected - the IdP has not yet signed a single token, but the
|
||||
service refuses to trust an IdP that COULD sign one with a weak
|
||||
alg. coreos/go-oidc additionally enforces the allow-list per-token
|
||||
at verify time as defense-in-depth against an upstream library
|
||||
regression.
|
||||
+ `isDisallowedAlg`. The per-token alg check at sig-verify time
|
||||
(`isDisallowedAlg`, line ~1177) is the load-bearing defense — every
|
||||
ID token whose JWS header carries an alg outside the allow-list
|
||||
(RS256 / RS512 / ES256 / ES384 / EdDSA) is rejected with
|
||||
`ErrAlgRejected`. coreos/go-oidc additionally enforces the allow-list
|
||||
per-token at verify time as defense-in-depth against an upstream
|
||||
library regression. The IdP-downgrade-attack secondary defense at
|
||||
provider creation / `RefreshKeys` (v2.1.0-relaxed semantics)
|
||||
intersects the IdP's advertised `id_token_signing_alg_values_supported`
|
||||
with the allow-list and rejects only when the intersection is EMPTY
|
||||
— i.e., the IdP advertises NO acceptable alg. Pre-v2.1.0 the check
|
||||
strict-denied on ANY HS*/`none` advertisement; that broke against
|
||||
Keycloak 26.x (which lists every alg it's capable of in its discovery
|
||||
doc, including HS*, even when the realm only signs with RS256). The
|
||||
relaxation is safe because the per-token alg pin already prevents
|
||||
a real algorithm-confusion attack — a forged HS256 token using the
|
||||
IdP's RS256 pubkey as HMAC secret is rejected at sig-verify regardless
|
||||
of what the discovery doc advertises. Operators worried about a
|
||||
compromised IdP rotating to weak algs without rotating its certctl
|
||||
provider config get defense-in-depth from `JWKSStatus` + the alert
|
||||
hooks in the GUI panel.
|
||||
- **Exact `iss` match.** ID-token `iss` claim must equal the
|
||||
configured `OIDCProvider.IssuerURL` byte-for-byte (sentinel
|
||||
`ErrIssuerMismatch`). A token from a different IdP - even one
|
||||
|
||||
Reference in New Issue
Block a user