mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:41:30 +00:00
2a1a0b347c
Audit 2026-05-10 MED-16 closure.
WHAT.
Binds the OIDC pre-login row to the (clientIP, userAgent) tuple of
the /auth/oidc/login request, and enforces a constant-time compare
against the /auth/oidc/callback request at consume time. Defeats
replay of a stolen pre-login cookie by a different browser /
source — the secondary defense layer recommended by RFC 9700 §4.7.1
when the primary layer (HMAC integrity + Path=/ + SameSite=Lax on
the cookie) is bypassed via CSRF / XSS / TLS-termination leak.
WHY.
Pre-fix, the pre-login cookie's HMAC verified only that 'some'
caller of /auth/oidc/login was talking to /auth/oidc/callback; it
did not verify that the SAME browser / source was on both sides.
An attacker who exfiltrated the cookie value via any vector could
replay the bytes through their own user-agent and ride the victim's
authorization. RFC 9700 §4.7.1 calls out the gap explicitly and
recommends binding state to a user-agent fingerprint + source IP.
HOW.
Migration:
migrations/000044_prelogin_uaip.up.sql
ALTER TABLE oidc_pre_login_sessions
ADD COLUMN IF NOT EXISTS client_ip TEXT,
ADD COLUMN IF NOT EXISTS user_agent TEXT;
Both nullable for in-flight rolling-deploy compat — the consume-
side check only enforces when both row AND request carry non-empty
values for the leg in question.
Domain:
internal/repository/oidc.go (PreLoginSession) — adds ClientIP +
UserAgent fields.
Repository:
internal/repository/postgres/oidc_prelogin.go — Create persists
via sql.NullString (empty → NULL); LookupAndConsume reads back.
Re-uses package-local nullableString from discovery.go.
Service:
internal/auth/oidc/service.go
- PreLoginStore.CreatePreLogin signature takes (clientIP,
userAgent) as positions 5–6.
- PreLoginStore.LookupAndConsume returns (clientIP, userAgent)
as positions 5–6.
- HandleAuthRequest signature gains (clientIP, userAgent),
threaded to the store.
- HandleCallback adds Step 1.5 — UA / IP constant-time compare
between stored row and incoming request. Per-leg toggles via
preLoginRequireUA / preLoginRequireIP service fields. Empty
values on either side pass through (rolling-deploy + headless-
proxy compat).
- New sentinels ErrPreLoginUAMismatch, ErrPreLoginIPMismatch.
- SetPreLoginBindingRequirements(requireUA, requireIP) helper
for main.go config wiring.
Adapter:
internal/auth/oidc/prelogin.go — PreLoginAdapter passes the new
fields through to the repo row.
Handler:
internal/api/handler/auth_session_oidc.go
- OIDCAuthHandshaker.HandleAuthRequest signature updated.
- LoginInitiate captures clientIPFromRequest + r.UserAgent()
and passes to the service.
- classifyOIDCFailure adds errors.Is dispatch for the two new
sentinels → prelogin_ua_mismatch / prelogin_ip_mismatch
audit categories.
Config:
internal/config/config.go
+ AuthConfig.OIDCPreLoginRequireUA (default true)
env CERTCTL_OIDC_PRELOGIN_REQUIRE_UA
+ AuthConfig.OIDCPreLoginRequireIP (default true)
env CERTCTL_OIDC_PRELOGIN_REQUIRE_IP
cmd/server/main.go calls oidcService.SetPreLoginBindingRequirements
from cfg.Auth.OIDCPreLoginRequire{UA,IP}.
Tests (internal/auth/oidc/service_test.go):
- TestService_HandleCallback_MED16_UAMismatchRejected
- TestService_HandleCallback_MED16_IPMismatchRejected
- TestService_HandleCallback_MED16_BothMatch_Succeeds
- TestService_HandleCallback_MED16_LegacyRowEmptyValues (rolling-
deploy compat — empty stored values pass through)
- TestService_HandleCallback_MED16_RequireUAFalse_AllowsMismatch
(operator escape-hatch — UA mismatch silently allowed)
Mechanical fan-out:
- stubPreLogin / stubPreLoginRepo signatures updated.
- All existing call sites in service_test.go (~40), prelogin_test.go,
bench_test.go, logging_test.go, provider_enabled_test.go,
integration_keycloak_test.go, integration_okta_smoke_test.go,
auth_session_oidc_test.go updated to pass empty strings for the
new params — pre-existing tests do not exercise UA/IP binding
semantics.
VERIFY.
- go vet ./internal/auth/oidc/... ./internal/api/handler/...
./internal/config/... PASS
- go test -short -count=1 -run MED16 ./internal/auth/oidc/... PASS (5/5)
- go test -short -count=1 ./internal/auth/oidc/... PASS (4.6s)
- go test -short -count=1 ./internal/api/handler/... PASS (4.3s)
- go test -short -count=1 ./internal/config/... PASS
Refs: cowork/auth-bundles-audit-2026-05-10.md MED-16
cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md item 6
RFC 9700 §4.7.1 — OAuth 2.0 Security Best Current Practice
144 lines
5.1 KiB
Go
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
|
|
}
|