mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-13 00:18:52 +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:
@@ -576,32 +576,40 @@ func TestService_ATHash_UnknownAlgReturnsFalse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test 11: IdP downgrade-attack defense. A provider whose discovery doc
|
||||
// advertises HS256 in id_token_signing_alg_values_supported is REJECTED
|
||||
// by the cache load with ErrIdPDowngradeAdvertised.
|
||||
func TestService_IdPDowngradeDefense_RejectsHSAdvertised(t *testing.T) {
|
||||
// Test 11: IdP downgrade-attack defense (v2.1.0-relaxed semantics).
|
||||
// Pre-v2.1.0 ANY HS* advertisement refused. Real IdPs like Keycloak
|
||||
// 26.x advertise the full alg list (HS+RS+ES+PS) but actually sign
|
||||
// with RS256 — they failed the old check + caused legitimate-IdP
|
||||
// integration tests to red. New contract: bind when the intersection
|
||||
// of advertised vs DefaultAllowedAlgs is non-empty; reject only when
|
||||
// the IdP advertises NO acceptable alg. Per-token alg pin at sig-verify
|
||||
// (isDisallowedAlg) still catches an actual algorithm-confusion attack.
|
||||
func TestService_IdPDowngradeDefense_RS256PlusHS256_BindsSuccessfully(t *testing.T) {
|
||||
idp := newMockIdP(t)
|
||||
idp.advertisedAlgs = []string{"RS256", "HS256"} // HS256 is the downgrade vector
|
||||
idp.advertisedAlgs = []string{"RS256", "HS256"} // Keycloak-shape — both advertised
|
||||
|
||||
svc, _ := newServiceWithProvider(t, idp.URL(), "op-bad-idp")
|
||||
svc, _ := newServiceWithProvider(t, idp.URL(), "op-keycloak-shape")
|
||||
|
||||
_, err := svc.getOrLoad(context.Background(), "op-bad-idp")
|
||||
if !errors.Is(err, ErrIdPDowngradeAdvertised) {
|
||||
t.Errorf("err = %v; want ErrIdPDowngradeAdvertised", err)
|
||||
entry, err := svc.getOrLoad(context.Background(), "op-keycloak-shape")
|
||||
if err != nil {
|
||||
t.Fatalf("RS256+HS256 advertisement must bind successfully (v2.1.0 relaxation); got %v", err)
|
||||
}
|
||||
if entry.provider == nil || entry.verifier == nil {
|
||||
t.Errorf("expected non-nil provider+verifier on successful bind")
|
||||
}
|
||||
}
|
||||
|
||||
// Test 12: IdP downgrade-attack defense — `none` advertisement also
|
||||
// triggers rejection.
|
||||
func TestService_IdPDowngradeDefense_RejectsNoneAdvertised(t *testing.T) {
|
||||
// Test 12: IdP downgrade-attack defense — HS-only advertisement is
|
||||
// still rejected (the pathological case the defense protects against).
|
||||
func TestService_IdPDowngradeDefense_RejectsHSOnlyAdvertised(t *testing.T) {
|
||||
idp := newMockIdP(t)
|
||||
idp.advertisedAlgs = []string{"RS256", "none"}
|
||||
idp.advertisedAlgs = []string{"HS256", "HS384", "HS512"} // no RS/ES — pathological
|
||||
|
||||
svc, _ := newServiceWithProvider(t, idp.URL(), "op-none-idp")
|
||||
svc, _ := newServiceWithProvider(t, idp.URL(), "op-hs-only-idp")
|
||||
|
||||
_, err := svc.getOrLoad(context.Background(), "op-none-idp")
|
||||
_, err := svc.getOrLoad(context.Background(), "op-hs-only-idp")
|
||||
if !errors.Is(err, ErrIdPDowngradeAdvertised) {
|
||||
t.Errorf("err = %v; want ErrIdPDowngradeAdvertised", err)
|
||||
t.Errorf("err = %v; want ErrIdPDowngradeAdvertised (intersection is empty)", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,7 +632,9 @@ func TestService_GetOrLoad_AcceptsCleanIdP(t *testing.T) {
|
||||
|
||||
// Test 14: RefreshKeys evicts the cache + re-fetches discovery, which
|
||||
// re-runs the downgrade defense. If the IdP rotated to advertising
|
||||
// HS256 between loads, RefreshKeys catches it.
|
||||
// ONLY weak algs between loads (intersection-empty case), RefreshKeys
|
||||
// catches it. Pre-v2.1.0 this test asserted the strict-deny "any HS*"
|
||||
// behavior; v2.1.0-relaxed asserts the intersection-empty behavior.
|
||||
func TestService_RefreshKeys_CatchesPostLoadDowngrade(t *testing.T) {
|
||||
idp := newMockIdP(t)
|
||||
svc, _ := newServiceWithProvider(t, idp.URL(), "op-rotate")
|
||||
@@ -633,11 +643,11 @@ func TestService_RefreshKeys_CatchesPostLoadDowngrade(t *testing.T) {
|
||||
t.Fatalf("initial load: %v", err)
|
||||
}
|
||||
|
||||
// IdP rotates to advertising HS256.
|
||||
idp.advertisedAlgs = []string{"RS256", "HS256"}
|
||||
// IdP rotates to advertising ONLY HS algs — pathological case.
|
||||
idp.advertisedAlgs = []string{"HS256", "HS384"}
|
||||
err := svc.RefreshKeys(context.Background(), "op-rotate")
|
||||
if !errors.Is(err, ErrIdPDowngradeAdvertised) {
|
||||
t.Errorf("RefreshKeys err = %v; want ErrIdPDowngradeAdvertised", err)
|
||||
t.Errorf("RefreshKeys err = %v; want ErrIdPDowngradeAdvertised (intersection-empty)", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user