Files
certctl/internal/auth/oidc/bench_test.go
T
shankar0123 2cd2a5c52f 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
2026-05-10 23:05:52 +00:00

144 lines
5.1 KiB
Go

package oidc
import (
"context"
"sort"
"testing"
"time"
)
// =============================================================================
// Bundle 2 Phase 14 — OIDC token validation benchmark (steady state).
//
// Measures the warm-JWKS-cache OIDC HandleCallback path against an
// in-process mockIdP. The mockIdP runs as an httptest.Server on
// localhost so the "exchange code for tokens" round-trip + the
// JWKS-cache hit are both purely local; there is NO real network
// latency in this measurement.
//
// Phase 14 target: p99 < 5ms.
//
// What this benchmark covers:
// - parseCookie + pre-login row consume (in-memory stubPreLogin)
// - OAuth2 Exchange against the mockIdP /token endpoint
// (httptest.Server local-loopback, ~50-200 µs typical)
// - go-oidc's id_token verification (JWKS cache lookup + RSA-2048
// signature verify + alg pin)
// - certctl service-layer re-verification (iss / aud / azp /
// at_hash / exp / iat / nonce)
// - Group-claim resolution (groupclaim/resolver.go)
// - Group→role mapping (in-memory stubMappings)
// - User upsert (in-memory stubUsers)
// - Session mint via stubSessions
//
// What this benchmark does NOT cover:
// - JWKS network refetch (that's the Phase-14 ColdCache benchmark
// in bench_keycloak_test.go; build-tagged under integration).
// - Real-network IdP latency (steady state assumes JWKS cache is
// warm; the local-loopback /token call is the "control" for
// the production cost of a same-region IdP /token call).
//
// The cold-cache OIDC measurement runs against a live Keycloak
// container per the Phase 10 fixture; see bench_keycloak_test.go
// (//go:build integration).
//
// Run via:
// go test -bench BenchmarkOIDC_SteadyState -benchmem -run='^$' \
// ./internal/auth/oidc/
//
// The full Phase 14 result table lives at docs/operator/auth-benchmarks.md.
// =============================================================================
// reportOIDCPercentiles is identical in shape to the session
// benchmark's reportPercentiles, duplicated here so the two
// benchmark files don't share a helper across the package boundary.
func reportOIDCPercentiles(b *testing.B, samples []time.Duration) {
b.Helper()
if len(samples) == 0 {
return
}
sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] })
p := func(pct float64) time.Duration {
idx := int(float64(len(samples)) * pct / 100.0)
if idx >= len(samples) {
idx = len(samples) - 1
}
return samples[idx]
}
b.ReportMetric(float64(p(50).Microseconds()), "p50_us/op")
b.ReportMetric(float64(p(95).Microseconds()), "p95_us/op")
b.ReportMetric(float64(p(99).Microseconds()), "p99_us/op")
b.ReportMetric(float64(samples[len(samples)-1].Microseconds()), "max_us/op")
}
// BenchmarkOIDC_SteadyState measures the OIDC HandleCallback p99
// against an in-process mockIdP. Warm JWKS cache (the first iteration
// triggers the cache load via getOrLoad; subsequent iterations hit
// the cached entry).
//
// Phase 14 target: p99 < 5ms.
func BenchmarkOIDC_SteadyState(b *testing.B) {
idp := newMockIdPForBench(b)
svc, pl := newBenchServiceWithProviderAndPL(b, idp.URL(), "op-bench")
// Pre-warm the JWKS cache so the first iteration's measurement
// doesn't include the discovery + JWKS load.
if err := svc.RefreshKeys(context.Background(), "op-bench"); err != nil {
b.Fatalf("RefreshKeys (warm): %v", err)
}
ctx := context.Background()
samples := make([]time.Duration, 0, b.N)
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Each iteration needs a fresh pre-login row (HandleCallback
// consumes the row atomically + single-use). State + nonce +
// verifier are stable; the cookie value is unique per call.
cookie, _, err := pl.CreatePreLogin(ctx, "op-bench", "bench-state", "test-nonce-fixed", "verifier-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")
if err != nil {
b.Fatalf("CreatePreLogin: %v", err)
}
start := time.Now()
_, err = svc.HandleCallback(ctx, cookie, "bench-code", "bench-state", "", "10.0.0.1", "bench/1.0")
elapsed := time.Since(start)
if err != nil {
b.Fatalf("HandleCallback: %v", err)
}
samples = append(samples, elapsed)
}
b.StopTimer()
reportOIDCPercentiles(b, samples)
}
// ---------------------------------------------------------------------------
// Benchmark-local helpers (versions of the service_test.go helpers
// that take a *testing.B instead of *testing.T).
// ---------------------------------------------------------------------------
func newMockIdPForBench(b *testing.B) *mockIdP {
b.Helper()
// newMockIdP takes *testing.T; we pass an adapter via the public
// interface. Since *testing.T and *testing.B both satisfy
// testing.TB, we adapt by using a synthetic T wrapper.
return newMockIdPWithTB(b)
}
func newBenchServiceWithProviderAndPL(b *testing.B, idpURL, providerID string) (*Service, *stubPreLogin) {
b.Helper()
prov := makeProvider(idpURL, providerID)
pl := newStubPreLogin()
mappings := &stubMappings{roleIDs: []string{"r-operator"}}
users := newStubUsers()
sessions := &stubSessions{}
svc := NewService(
&stubProviderLookup{provider: prov},
mappings,
users,
sessions,
pl,
"",
)
return svc, pl
}