Skip to content

Validate subject tokens from external OIDC issuers#5995

Open
jhrozek wants to merge 4 commits into
mainfrom
token-delegation-3-multi-issuer-validator
Open

Validate subject tokens from external OIDC issuers#5995
jhrozek wants to merge 4 commits into
mainfrom
token-delegation-3-multi-issuer-validator

Conversation

@jhrozek

@jhrozek jhrozek commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Token exchange (#5812, #5813) currently only accepts subject tokens that the embedded authorization server issued itself. To let agents act on behalf of users authenticated by an external IdP (Keycloak, Entra, Okta), the AS needs to validate subject tokens from trusted external OIDC issuers — the trust-model groundwork for the federated token-exchange path in #5194.

This PR adds that validation, but deliberately does not enable it in production: factory.go still constructs the self-issued validator, so external tokens remain unreachable until the delegation-consent gate (#5989) lands. A TODO(#5989) on the type documents the constraint.

  • Extract a SubjectTokenValidator interface; rename the concrete self-issued type to SelfIssuedTokenValidator (accept interfaces, return structs).
  • Add MultiIssuerTokenValidator: routes self-issued tokens to the existing validator, and validates external-issuer tokens by resolving the issuer's JWKS.
  • JWKS caching with a 5m TTL; OIDC-discovery fallback when no JWKSURL is configured.
  • SSRF-safe fetching: HTTPS-only, per-hop redirect scheme checks, resolved-IP checks that reject private/loopback/link-local addresses, response-body size caps, and a key-count cap.
  • Fail loudly if a TrustedIssuer is configured without an ExpectedAudience.

Closes #5814

Type of change

  • New feature

Test plan

  • Unit tests (task test) — not run locally; run in CI
  • Manual testing (build + vet)

New multi_issuer_validator_test.go covers issuer routing, JWKS caching/expiry, OIDC discovery fallback, SSRF rejection (private/loopback IPs, non-HTTPS), oversized responses, and the empty-ExpectedAudience guard. Locally go build ./... and go vet ./pkg/authserver/... are green.

Does this introduce a user-facing change?

No. The validator is not wired into the token endpoint in this PR — external subject tokens are not accepted until #5989 adds the consent model and enables it in factory.go.

Special notes for reviewers

🤖 Generated with Claude Code

jhrozek and others added 4 commits July 23, 2026 19:55
Introduce a SubjectTokenValidator interface with a single Validate method,
preparing for multi-issuer token validation where external OIDC tokens
(e.g., Keycloak) need different JWKS resolution than self-issued tokens.

Rename the existing concrete struct to SelfIssuedTokenValidator to clarify
its role. The handler now depends on the interface, not the concrete type,
following Go's "accept interfaces, return structs" pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implement MultiIssuerTokenValidator that routes JWT validation based on
the token's issuer claim. Self-issued tokens delegate to the existing
SelfIssuedTokenValidator. External tokens (e.g., Keycloak) are validated
against the issuer's JWKS, fetched via OIDC discovery with caching.

Security: JWKS URLs from OIDC discovery are validated to require HTTPS
and reject private/loopback addresses (SSRF prevention). Discovered URLs
are re-resolved when the JWKS cache expires.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address panel review feedback on the multi-issuer validator:
NewMultiIssuerTokenValidator silently accepted a TrustedIssuer with no
ExpectedAudience instead of rejecting it, and subject-token validation
failures returned invalid_grant instead of invalid_request. Also drain
response bodies before closing on non-200 discovery/JWKS fetches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Address code-review findings on the multi-issuer subject-token
validator. Fix the self-issued routing test, which handed the private
JWKS to the validator instead of the public set. Require an "exp" claim
on external tokens so the delegated token can be bounded. Validate that
the OIDC discovery document's issuer matches the expected issuer.

Harden JWKS fetching against SSRF: validate redirects and resolved IPs
at dial time (blocking loopback, private, link-local and unspecified
addresses), not just the pre-request URL string. Reject empty key sets
and cap the accepted key count.

Drop dead test helpers and route JWKS handlers through the existing
public-JWKS helper. Add tests for the empty-audience constructor error,
discovery failures, and kid mismatch.

External-token delegation consent, error-code mapping and clock-skew
leeway are deferred to #5989 and flagged with TODOs, since the validator
is not yet wired into Factory.
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Jul 25, 2026

@JAORMX JAORMX left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed with a focus on the SSRF surface and token-validation correctness. The security-critical design is sound and — importantly — I confirmed the feature is genuinely inert: factory.go still constructs only NewSelfIssuedTokenValidator, and NewMultiIssuerTokenValidator is referenced nowhere in non-test code, so the external-issuer path is unreachable until the #5989 wiring lands. TODO(#5989) points at a tracked open issue. Approving on that basis.

Verified correct:

  • DNS-rebinding defense is real — DialContext resolves, rejects if any resolved IP is disallowed, then dials the vetted IP directly (no re-resolve/TOCTOU). isDisallowedIP covers loopback/link-local/private/unspecified across IPv4+IPv6, so 169.254.169.254 is blocked.
  • Redirects re-check HTTPS per hop and re-run the IP check via DialContext; body reads are io.LimitReader-capped on both discovery and JWKS; maxJWKSKeys cap + empty-set rejection.
  • Token path: signature verified against issuer JWKS before any claim is trusted; issuer-of-record from config (not the token body); audience enforced; exp/nbf checked; allowedSignatureAlgorithms excludes none and HMAC (no alg-confusion). Fail-closed on empty ExpectedAudience, unknown issuer, and all error paths.

Two should-fix items before this is wired up in #5989 (safe to land here or there — not exploitable while inert):

  1. The SSRF controls have no test coverage. Every test sets insecureSkipJWKSURLValidation = true, so validateJWKSURL, isDisallowedIP, the dial-time IP check, and the redirect scheme re-check are never exercised. Please add tests (flag left false) asserting refusal of: an http:// JWKS URL, a discovery doc whose jwks_uri resolves to loopback/link-local/IPv6, and a 302 redirect to a private IP. This is security-critical code that can currently regress silently.
  2. isDisallowedIP misses CGNAT 100.64.0.0/10. net.IP.IsPrivate() doesn't include it, so a hostile trusted-issuer discovery doc could point jwks_uri at e.g. Alibaba metadata 100.100.100.200. Add an explicit 100.64.0.0/10 check (consider 192.0.0.0/24, 198.18.0.0/15 too).

Minor nits (optional): the initial hop (configured IssuerURL/preconfigured JWKSURL) isn't HTTPS-checked — only discovered URLs are — so an http:// issuer config downgrades to cleartext on the first request; and ips[0] at the end of DialContext would panic on the (practically unreachable) empty-non-error LookupIPAddr result — a len(ips)==0 guard is cheap.

Heads-up unrelated to this PR: main is currently red from a nil-panic in pkg/vmcp/client (a #5990 struct-literal test hitting the new #5980 resolveAuthStrategy path), so your Test Go Code gate may fail on that until main is fixed — it's not from anything here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Large PR: 600-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support validating subject/actor tokens from external OIDC issuers

2 participants