✨ Add auth/ and config/ — composition layer and mode presets (Phase 2)#311
Conversation
Extract reusable auth building blocks from go-processor into a standalone Go module at authbridge/authlib/. Six packages with 34 unit tests: - validation/ — JWKS-backed JWT verifier (lestrrat-go/jwx) - exchange/ — RFC 8693 token exchange + client credentials - cache/ — SHA-256 keyed token cache with TTL eviction - bypass/ — Path pattern matcher for public endpoints - spiffe/ — File-based JWT-SVID source - routing/ — Host-to-audience router with glob matching No Envoy/gRPC dependencies — pure Go library. Phase 2 will add the auth composition layer and config; Phase 3 wires up listeners. Ref: rossoctl#279 Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
- exchange: Use http.NewRequestWithContext instead of PostForm so callers can cancel/timeout requests via context - exchange: Limit response body to 1MB via io.LimitReader - exchange: Add tests for connection failure, malformed JSON, and context cancellation - cache: Remove TOCTOU race in Get() — skip expired entry cleanup, rely on Set() eviction instead - routing: Add Matched field to ResolvedRoute so callers can distinguish explicit route match from default action fallback - bypass: Document path.Match limitation (* does not cross /) Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
Transitive dependency golang.org/x/crypto v0.32.0 has a known DoS vulnerability flagged by the dependency review CI check. Bump to v0.48.0 to match the existing authproxy module. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
Add the application layer that wires Phase 1 authlib building blocks
into two functions all listeners will call:
auth/:
- HandleInbound: bypass → extract bearer → validate JWT → allow/deny
- HandleOutbound: resolve route → cache check → exchange → cache result
- Per-request audience override for waypoint mode
- Actor token source for RFC 8693 act claim chaining
- Per-route token endpoint override
- Fail-closed: nil verifier denies (not allows)
- Case-insensitive bearer token extraction (RFC 7235)
config/:
- YAML config with ${ENV_VAR} expansion (preserves undefined refs)
- Three mode presets: envoy-sidecar, waypoint, proxy-sidecar
- Startup validation rejects invalid mode+listener combos
- Resolve() wires all authlib packages from config
- RouteConfig backwards compat (passthrough: true → action: passthrough)
Also updates exchange.Client to support per-request TokenEndpoint override
and ExchangeRequest.TokenEndpoint field.
56 tests across 8 packages, all passing.
Ref: rossoctl#279
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Close 4 feature parity gaps between old go-processor and new authbridge: - Derive TOKEN_URL and ISSUER from keycloak_url + keycloak_realm (operator sets these, not explicit URLs) - Derive JWKS URL from TOKEN_URL using Keycloak /token -> /certs convention - Support client_id_file and client_secret_file with startup polling (handles race with client-registration and spiffe-helper) - Add CachedActorTokenSource for actor token caching with 30s pre-expiry refresh (matches old actorTokenCache behavior) 7 new tests for derivation logic and file-based credentials. 63 total tests passing. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
Two feature parity findings from comprehensive review: 1. ClientCredentials grant now passes audience to Keycloak (matching old go-processor behavior). Without this, Keycloak may issue tokens with wrong audience that target services reject. 2. Waypoint mode now auto-derives audience from destination hostname via AudienceDeriver callback (serviceNameFromHost). This eliminates the need for explicit routes in waypoint mode, matching the old waypoint behavior where audience = first DNS label of host. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
pdettori
left a comment
There was a problem hiding this comment.
Review Summary
Well-structured Phase 2 adding the auth composition layer (auth/, config/) on top of Phase 1 building blocks. The auth flow logic (HandleInbound, HandleOutbound) is clean and follows fail-closed principles for inbound validation. One correctness bug in CachedActorTokenSource where the cache degrades after the first TTL expiry. Test helpers use t.Fatal from handler goroutines which is technically undefined behavior in Go.
Areas reviewed: Go (auth, config, exchange, routing, bypass, cache, spiffe, validation), Tests
Commits: 6 commits, all signed-off ✅
CI status: All 15 checks passing ✅
Highlights
- Clean six-package decomposition with single-responsibility packages and well-defined interfaces
- Strong security defaults: fail-closed inbound (nil verifier denies), path normalization in bypass, SHA-256 cache keys, 1MB response body limit, 30s pre-expiry cache buffer
- 63 tests with good edge case coverage (case-insensitive bearer, path traversal, context cancellation, cache eviction)
- Good backwards compatibility (
passthrough: true→action: passthrough)
| // The actual TTL is set via SetTTL after the client-credentials grant. | ||
| if c.expiresAt.IsZero() { | ||
| c.expiresAt = time.Now().Add(5 * time.Minute) | ||
| } |
There was a problem hiding this comment.
Bug: if c.expiresAt.IsZero() only matches on the very first call. After the initial 5-minute default TTL expires, the refresh path stores a new token (line 50) but does NOT update expiresAt — it's no longer zero, so this guard is skipped.
Result: every subsequent FetchToken call sees the stale expiresAt, treats the token as expired, and calls source(ctx) on every request. The cache degrades to no-caching after the first TTL.
Fix: always reset expiresAt after refresh:
c.token = token
c.expiresAt = time.Now().Add(5 * time.Minute) // default, SetTTL overridesThis type isn't wired up in Phase 2 but will be in Phase 3 — better to fix now before it ships.
There was a problem hiding this comment.
Good catch. Fixed in 8cf07a1 — expiresAt is now always reset after refresh:
c.token = token
c.expiresAt = time.Now().Add(5 * time.Minute)The IsZero() guard was wrong — after the first default TTL expired, subsequent refreshes never updated expiresAt, degrading the cache to no-caching.
| Identity: IdentityConfig{Audience: "my-agent"}, | ||
| }) | ||
| result := a.HandleInbound(context.Background(), "Bearer valid-token", "/api/test", "") | ||
| if result.Action != ActionAllow { |
There was a problem hiding this comment.
Suggestion: t.Fatal() called from an httptest.Server handler goroutine has undefined behavior in Go — it calls runtime.Goexit() on the wrong goroutine, which can cause test panics or hangs.
This pattern appears here and in several handlers in exchange/client_test.go.
Fix: use t.Error(err); http.Error(w, err.Error(), 500); return instead of t.Fatal(err) in all httptest handlers.
There was a problem hiding this comment.
Fixed in 8cf07a1. Replaced all t.Fatal(err) in httptest handlers with http.Error(w, err.Error(), http.StatusBadRequest); return. Applied to both auth/auth_test.go and exchange/client_test.go.
| jwksURL := strings.Replace(cfg.Outbound.TokenURL, "/token", "/certs", 1) | ||
| if jwksURL != cfg.Outbound.TokenURL { | ||
| cfg.Inbound.JWKSURL = jwksURL | ||
| slog.Info("derived jwks_url from token_url", "jwks_url", jwksURL) |
There was a problem hiding this comment.
Suggestion: strings.Replace(..., "/token", "/certs", 1) replaces the first occurrence. A URL like http://token-service.example.com/.../token would incorrectly modify the hostname portion.
In practice this is safe because deriveKeycloakURLs builds clean URLs, but a suffix-based approach would be more robust:
if strings.HasSuffix(cfg.Outbound.TokenURL, "/token") {
cfg.Inbound.JWKSURL = strings.TrimSuffix(cfg.Outbound.TokenURL, "/token") + "/certs"
}There was a problem hiding this comment.
Fixed in 8cf07a1. Switched to suffix-based approach:
if strings.HasSuffix(cfg.Outbound.TokenURL, "/token") {
cfg.Inbound.JWKSURL = strings.TrimSuffix(cfg.Outbound.TokenURL, "/token") + "/certs"
}This prevents accidentally modifying hostnames like http://token-service.example.com/....
…ation Address review from @pdettori on PR rossoctl#311: 1. actor_cache.go: Always reset expiresAt after token refresh. The IsZero() guard only triggered on the first call; subsequent refreshes left expiresAt stale, degrading the cache to no-caching. 2. auth_test.go, client_test.go: Replace t.Fatal(err) with http.Error() + return in httptest handlers. t.Fatal calls runtime.Goexit on the wrong goroutine, causing undefined behavior. 3. resolve.go: Use suffix-based HasSuffix + TrimSuffix for JWKS URL derivation instead of strings.Replace. Prevents accidentally modifying hostnames containing "token". Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
Address review from @pdettori: - Extract ServiceNameFromHost to routing/hostutil.go (shared by config/resolve.go and extauthz listener, avoids duplication) - Add TODO comment on SetTTL (unused, clarifies intent) Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
JWT validation errors can reveal issuer URL, audience, or algorithm details. Return 'token validation failed' to the client and log the full error server-side at INFO level. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
Address review from @pdettori on PR rossoctl#313: - Add ReadHeaderTimeout: 10s to HTTP servers (slowloris defense) - Add pipefail to entrypoint.sh set flags Findings 1 (JWT error leakage) fixed in PR rossoctl#311. Findings 2 (GracefulStop) and 4 (Envoy SHA pin) already fixed in PR rossoctl#312 review round. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
…ation Address review from @pdettori on PR rossoctl#311: 1. actor_cache.go: Always reset expiresAt after token refresh. The IsZero() guard only triggered on the first call; subsequent refreshes left expiresAt stale, degrading the cache to no-caching. 2. auth_test.go, client_test.go: Replace t.Fatal(err) with http.Error() + return in httptest handlers. t.Fatal calls runtime.Goexit on the wrong goroutine, causing undefined behavior. 3. resolve.go: Use suffix-based HasSuffix + TrimSuffix for JWKS URL derivation instead of strings.Replace. Prevents accidentally modifying hostnames containing "token". Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
Address review from @pdettori on PR rossoctl#313: - Add ReadHeaderTimeout: 10s to HTTP servers (slowloris defense) - Add pipefail to entrypoint.sh set flags Findings 1 (JWT error leakage) fixed in PR rossoctl#311. Findings 2 (GracefulStop) and 4 (Envoy SHA pin) already fixed in PR rossoctl#312 review round. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
…JWKS tests Address review from @pdettori on PR rossoctl#310: 1. MUST-FIX: Empty audience now returns error in JWKSVerifier.Verify() instead of silently skipping the check. Enforces the confused deputy protection invariant stated in the interface doc. 2. Remove http.Header param from ClientAuth.Apply() interface — callers always passed nil, risking NPE for future header-based implementations. Simplified to form-only. Add header support later if needed. 3. Add 6 JWKSVerifier unit tests with in-memory httptest JWKS server: valid token, expired, wrong issuer, wrong audience, empty audience, invalid signature. Covers the core security path. 4. Validate SubjectToken != empty in Exchange() for clear early error. 5. Fix TOCTOU in LoadRoutes — call ReadFile directly, check IsNotExist. 6. Add TODO for cache eviction strategy (thundering herd on full clear). Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
Address review from @pdettori on PR rossoctl#313: - Add ReadHeaderTimeout: 10s to HTTP servers (slowloris defense) - Add pipefail to entrypoint.sh set flags Findings 1 (JWT error leakage) fixed in PR rossoctl#311. Findings 2 (GracefulStop) and 4 (Envoy SHA pin) already fixed in PR rossoctl#312 review round. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
…ation Address review from @pdettori on PR rossoctl#311: 1. actor_cache.go: Always reset expiresAt after token refresh. The IsZero() guard only triggered on the first call; subsequent refreshes left expiresAt stale, degrading the cache to no-caching. 2. auth_test.go, client_test.go: Replace t.Fatal(err) with http.Error() + return in httptest handlers. t.Fatal calls runtime.Goexit on the wrong goroutine, causing undefined behavior. 3. resolve.go: Use suffix-based HasSuffix + TrimSuffix for JWKS URL derivation instead of strings.Replace. Prevents accidentally modifying hostnames containing "token". Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
Address review from @pdettori on PR rossoctl#313: - Add ReadHeaderTimeout: 10s to HTTP servers (slowloris defense) - Add pipefail to entrypoint.sh set flags Findings 1 (JWT error leakage) fixed in PR rossoctl#311. Findings 2 (GracefulStop) and 4 (Envoy SHA pin) already fixed in PR rossoctl#312 review round. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
pdettori
left a comment
There was a problem hiding this comment.
Approved
All 3 review findings addressed in 8cf07a1:
- actor_cache.go —
expiresAtnow always reset after refresh (was stale after first TTL) ✅ - auth_test.go / client_test.go —
t.Fatalin httptest handlers replaced withhttp.Error+return✅ - resolve.go — JWKS URL derivation uses
HasSuffix/TrimSuffixinstead ofstrings.Replace✅
PR #310 fixes also landed via b2ca966: audience enforcement, header param removal, 6 JWKSVerifier tests, TOCTOU fix, cache eviction TODO, SubjectToken validation.
Additional proactive improvements: ServiceNameFromHost extracted to shared package, generic JWT errors returned to clients (detail logged server-side).
10 commits, all signed-off. 15 CI checks passing. Clean Phase 2.
…ation Address review from @pdettori on PR rossoctl#311: 1. actor_cache.go: Always reset expiresAt after token refresh. The IsZero() guard only triggered on the first call; subsequent refreshes left expiresAt stale, degrading the cache to no-caching. 2. auth_test.go, client_test.go: Replace t.Fatal(err) with http.Error() + return in httptest handlers. t.Fatal calls runtime.Goexit on the wrong goroutine, causing undefined behavior. 3. resolve.go: Use suffix-based HasSuffix + TrimSuffix for JWKS URL derivation instead of strings.Replace. Prevents accidentally modifying hostnames containing "token". Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Hai Huang <huang195@gmail.com>
Summary
Phase 2 of the modular token exchange library (#279). Adds the application layer that composes Phase 1 authlib building blocks into two functions all listeners will call.
Depends on: #310 (Phase 1)
auth/— Composition layerHandleInboundHandleOutboundKey features:
client-credentials(envoy-sidecar),allow(waypoint),deny(proxy-sidecar)config/— Mode presets and validation${ENV_VAR}expansion (preserves undefined refs as-is)Resolve()wires all authlib packages from a single config filepassthrough: truetoaction: passthrough)Phase 1 changes
exchange.ExchangeRequest.TokenEndpoint— per-request token URL overrideRelated issue(s)
Ref #279
Test plan
go test ./...— 56 tests passing across 8 packagesgo vet ./...— no issuesAssisted-By: Claude (Anthropic AI) noreply@anthropic.com