Skip to content

feat: direct OIDC IdP federation (closes #88, partial)#124

Open
safayavatsal wants to merge 4 commits intohighflame-ai:mainfrom
safayavatsal:feat/direct-oidc-idp-federation-88
Open

feat: direct OIDC IdP federation (closes #88, partial)#124
safayavatsal wants to merge 4 commits intohighflame-ai:mainfrom
safayavatsal:feat/direct-oidc-idp-federation-88

Conversation

@safayavatsal
Copy link
Copy Markdown
Contributor

@safayavatsal safayavatsal commented May 7, 2026

Summary

  • Implements direct OIDC IdP federation per issue feat: direct OIDC IdP federation (spec-aligned preferred path for upstream IdP integration) #88 — spec-aligned preferred path for upstream user identity.
  • Adds external_issuers config; subject_token_type=id_token token-exchange requests now route to a new verifier instead of the broker.
  • Issued token carries user_id_iss (= upstream iss) for IdP-granular provenance; optional auth_time/acr/amr propagation when configured AND present upstream.

Acceptance criteria status

  • external_issuers config section parses and validates
  • Token endpoint accepts subject_token_type = id_token; rejects with invalid_request when no matching issuer is configured
  • JWKS cached + on-demand refresh on unknown kid
  • iss / aud / exp / nbf enforced; iat capped via max_token_age
  • user_id_iss carried on issued tokens; auth_time/acr/amr propagated per config
  • docs/identity-model.md leads with direct federation
  • End-to-end test asserting user_id_iss / acr / amr / auth_time / sub / user_email flow through (Okta-shaped fixture)
  • End-to-end test asserting broker dispatch is unchanged when subject_token_type is omitted

Test plan

  • make test passes (all four packages green)
  • Federation happy path: configured external issuer + Okta-shaped ID token → issued token carries user_id_iss = https://upstream.idp.test (automated as TestExternalIDTokenFederation_EndToEnd/federation_happy_path_emits_user_id_iss)
  • Broker path still works unchanged when subject_token_type is omitted (automated as TestExternalIDTokenFederation_EndToEnd/broker_dispatch_unchanged_when_subject_token_type_is_omitted)

…ee PR)

Spec-aligned alternative to the trusted-service broker path for ingesting
upstream user identity. RFC 8693 token-exchange with
subject_token_type=urn:ietf:params:oauth:token-type:id_token now routes to
a new code path that verifies the upstream IdP's signature against a
configured JWKS, then mints a ZeroID token carrying user_id_iss as
IdP-granular provenance.

Implementation:
- domain/external_issuer.go: ExternalIssuerConfig with Validate/Defaults
- internal/service/external_issuer_registry.go: per-issuer JWKS clients,
  fail-fast on startup, lifecycle hooks into Server.Shutdown
- internal/service/oauth_external_idp.go: externalIDTokenExchange — alg
  gate, signature verify, iss/aud/exp/nbf checks, iat staleness cap,
  claim mapping, auth_time/acr/amr opt-in propagation
- internal/service/oauth.go: dispatch for subject_token_type=id_token
  before the actor_token check; bypasses TrustedServiceValidator
- server.go: registry construction, lifecycle close
- config.go: ExternalIssuers section + per-entry validation loop
- docs/identity-model.md: direct-federation-first guide
- zeroid.yaml: example external_issuers stanza

Tests: domain config validation, registry lifecycle (incl. fail-fast on
unreachable JWKS), claim-mapping evaluator, algorithm allow-list gate.
Full HTTP-stack rejection matrix deferred to follow-up — needs the
shared-test-server helper to support a second federation-configured
server alongside the existing broker-configured one.
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements direct OIDC IdP federation, enabling ZeroID to verify upstream ID tokens directly via RFC 8693 token exchange. Key additions include an ExternalIssuerRegistry for managing JWKS clients, updated configuration validation, and comprehensive documentation on the identity model. Review feedback suggests aligning error codes with RFC 6749 by using invalid_grant for unknown issuers, simplifying reserved claim logic, and using strconv.FormatFloat instead of fmt.Sprintf to prevent scientific notation when processing numeric subject identifiers.

Comment thread internal/service/oauth_external_idp.go Outdated
Comment thread internal/service/oauth_external_idp.go Outdated
Comment thread internal/service/oauth_external_idp.go Outdated
Resolve conflicts and migrate the new federation code to jwx v4 (per main
PR highflame-ai#114, which upgraded the rest of the codebase).

- config.go: keep both validation additions — wimse_domain (from main)
  runs before the external_issuers loop (this PR).
- zeroid.yaml: keep both new top-level sections — attestation (from main)
  followed by the commented external_issuers example.
- internal/service/oauth_external_idp.go: migrate from jwx/v2 to jwx/v4.
  Token.Issuer/IssuedAt are now (T, bool) tuples; AsMap dropped, use
  Claims() iter.Seq2. Replace jws.Parse-based alg gate with
  internal/jwtalg.Validate (also covers JWT-SVID alg allow-list) plus a
  small base64 header decoder for kid/alg lookup.
- internal/service/external_issuer_registry_test.go: jwk.FromRaw →
  jwk.Import[jwk.Key]; jwa.ES256 → jwa.ES256().
@safayavatsal
Copy link
Copy Markdown
Contributor Author

Changes since last commit:

Merged main into the branch and resolved conflicts. Two AC-relevant deltas:

Migrated the new federation code from jwx v2 to jwx v4 to match main #114:

  • Token.Issuer and Token.IssuedAt are now (T, bool) tuples — call sites updated.
  • v4 dropped Token.AsMap; switched to Claims() (an iter.Seq2) wrapped in a small tokenClaimsAsMap helper.
  • Replaced the jws.Parse-based alg gate with internal/jwtalg.Validate (also covers JWT-SVID alg allow-list) plus a base64 header decoder for kid/alg lookup — independent of jwx major version.
  • Test file: jwk.FromRawjwk.Import[jwk.Key]; jwa.ES256jwa.ES256().

PR is now MERGEABLE against main. All service-level tests still pass under GOEXPERIMENT=jsonv2 go test ./domain/... ./internal/service/....

- Add ErrUnknownExternalIssuer sentinel; wrap onto *OAuthError so callers
  can errors.Is while the handler still picks up the OAuth error code via
  errors.As. Kept invalid_request rather than invalid_grant — see review
  reply for the RFC 6749 §5.2 reasoning.
- Add user_id_iss to the global reservedClaims map in oauth.go;
  federation path now uses the single-sourced check.
- extractMappedClaimString: switch float64 path from fmt.Sprintf("%v")
  to strconv.FormatFloat(tv, 'f', -1, 64); large numeric subjects no
  longer render in scientific notation. int64 path moved to FormatInt
  for symmetry. Regression test for 16-digit subject.
- Tests for the dual-signaling unknown-issuer error path.
@safayavatsal
Copy link
Copy Markdown
Contributor Author

Changes since last commit: addressed three Gemini review comments.

  • #discussion_r3200157252 — added ErrUnknownExternalIssuer sentinel; wrapped onto the existing *OAuthError cause so callers can errors.Is while the handler still maps the OAuth code via errors.As. Kept invalid_request (declined the invalid_grant swap — see inline reply for the RFC 6749 §5.2 reasoning).
  • #discussion_r3200157263 — added user_id_iss to the global reservedClaims map in oauth.go; federation path now uses the single-sourced reservedClaims[k] check.
  • #discussion_r3200157267 — switched the float64 branch of extractMappedClaimString from fmt.Sprintf("%v") to strconv.FormatFloat(tv, 'f', -1, 64) so large numeric subjects (e.g., 16-digit Entra oid) no longer render in scientific notation. Moved the int64 branch to FormatInt for symmetry. Regression test added.

New tests:

  • Dual-signaling unknown-issuer error path (errors.As for the OAuth code, errors.Is for the sentinel).
  • 16-digit numeric subject avoids scientific notation.

…n items 2 & 3

- New zeroid.WithExternalIssuerJWKSOption build-time option threads an
  authjwt.JWKSOption into the external-issuer registry. Lets tests inject
  an insecure HTTP client when pointing the registry at a fake TLS JWKS.
- tests/integration/external_idp_test.go: end-to-end
  TestExternalIDTokenFederation_EndToEnd with two subtests:
  * federation_happy_path_emits_user_id_iss — Okta-shaped ID token ->
    issued JWT carries user_id_iss=upstream iss, plus acr/amr/auth_time
    propagation and token_exchange=external_id_token.
  * broker_dispatch_unchanged_when_subject_token_type_is_omitted —
    request without subject_token_type still routes to the broker
    (proven by the broker-only "external principal exchange is not
    configured" error fingerprint).
- tests/integration/external_idp_helpers_test.go: fake upstream IdP key
  material, jws header builder, payload-segment decoder.
- helpers_test.go: capture sharedDBURL so federation tests can build a
  second NewServer pointed at the same Postgres.

All four packages PASS under \`make test\`.
@safayavatsal
Copy link
Copy Markdown
Contributor Author

Changes since last commit: test-plan items 2 and 3 are now automated.

  • New zeroid.WithExternalIssuerJWKSOption build-time option threads a JWKS option into the external-issuer registry — so tests can inject an insecure HTTP client when pointing the registry at a fake TLS JWKS server.
  • tests/integration/external_idp_test.goTestExternalIDTokenFederation_EndToEnd with two subtests:
    • federation_happy_path_emits_user_id_iss: spins up a fake upstream IdP, mints an Okta-shaped ID token (sub, email, auth_time, amr, acr), posts to a federation-configured zeroid.NewServer instance, and asserts user_id_iss = https://upstream.idp.test, plus acr/amr/auth_time propagation, token_exchange = external_id_token, mapped sub, and user_email.
    • broker_dispatch_unchanged_when_subject_token_type_is_omitted: same federation server, request without subject_token_type — verifies the broker fingerprint (external principal exchange is not configured) appears and the federation-only error string does not.

make test: all four packages (zeroid, domain, internal/service, tests/integration) green.

PR description updated — test-plan checkboxes ticked.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant