Skip to content

feat: direct OIDC IdP federation (spec-aligned preferred path for upstream IdP integration) #88

@rsharath

Description

@rsharath

Summary

Add direct OIDC / OAuth 2.1 IdP federation as the spec-aligned preferred path for ingesting upstream user identity (Okta, Entra ID, Auth0, Google Workspace, custom OIDC). The existing ExternalPrincipalExchange trusted-service broker becomes a fallback, not a complement - it loses IdP-level provenance and is strictly lossier than the spec-conformant direct-federation pattern.

Why this matters

The standards explicitly prefer direct IdP verification and IdP-level provenance:

  • RFC 8693 defines subject_token_type = urn:ietf:params:oauth:token-type:id_token specifically for this case: the receiver verifies the ID token. The broker path uses the generic :jwt type and skips verification.
  • OIDC Core expects the iss claim (and its propagation across exchanges) to trace to the IdP that authenticated the subject.
  • RFC 9068 (JWT Access Token Profile) specifies acr / amr / auth_time as authentication-context claims. These require knowing where authentication happened; they are not meaningful when an intermediate broker swallows the IdP identity.
  • NIST SP 800-63C §4.1 mandates that federation assertions name the IdP that authenticated the subject, not an aggregator.
  • CoSAI Agentic IAM "prove control on demand" is hollow without per-IdP provenance - "which IdP authenticated this user?" must be answerable from the token.

The broker pattern emits trusted_by: <service-name> - service-granular provenance. The consumer can see which service vouched but cannot see which IdP authenticated. For finance, healthcare, and any regulated deployment, this is insufficient.

Direct federation emits user_id_iss: <issuer URL> - IdP-granular provenance, reconstructable from the token alone.

Current state

ExternalPrincipalExchange (internal/service/oauth.go:499) implements only the broker pattern. A deployer-controlled service verifies the IdP's signature and calls ZeroID with pre-validated user claims. ZeroID validates the caller via TrustedServiceValidator but does not re-verify the external JWT.

This is a pragmatic design that keeps IdP-specific quirks out of ZeroID. It remains the right answer for a specific set of operational cases (see "When the broker is actually right" below) - but it is not spec-aligned and should not be the default recommendation for new deployments.

Proposed feature

A new path where ZeroID:

  1. Accepts subject_token_type = urn:ietf:params:oauth:token-type:id_token.
  2. Reads iss from the subject_token and looks it up against a deployer-configured allowlist of trusted external issuers.
  3. Fetches that issuer's JWKS (cached 5 min, same pattern as pkg/authjwt).
  4. Verifies signature, iss, aud, exp, nbf, iat (with reasonable age cap).
  5. Applies deployer-configured claim-mapping rules to extract user_id, email, etc.
  6. Issues a ZeroID-signed token carrying new provenance claims: user_id_iss, optionally auth_time, acr, amr when present on the upstream token.

API shape

Token endpoint request:

POST /oauth2/token
  grant_type           = urn:ietf:params:oauth:grant-type:token-exchange
  subject_token        = <external IdP's ID token>
  subject_token_type   = urn:ietf:params:oauth:token-type:id_token
  account_id           = <tenant>
  project_id           = <tenant>
  scope                = <optional>

ZeroID response:

{
  "access_token": "...",
  "token_type":   "Bearer",
  "expires_in":   3600
}

Issued JWT (new claims in bold):

{
  "iss":             "https://auth.highflame.ai",
  "sub":             "<resolved principal>",
  "user_id":         "alice@example.com",
  "user_id_iss":     "https://auth.example.okta.com",
  "auth_time":       1735600000,
  "acr":             "urn:okta:app:mfa:factor:push",
  "amr":             ["pwd", "mfa"],
  "scopes":          ["..."]
}

Configuration model

New top-level config section:

external_issuers:
  - issuer:           "https://auth.example.okta.com"
    jwks_uri:         "https://auth.example.okta.com/.well-known/jwks.json"
    audience:         "https://zeroid.example.com"
    algorithms:       ["RS256", "ES256"]
    max_token_age:    10m
    claim_mapping:
      user_id:        "sub"
      email:          "email"
    allowed_accounts: ["acct-prod", "acct-staging"]
    propagate_claims: ["auth_time", "acr", "amr"]   # optional passthrough

Runtime defaults:

  • JWKS cache TTL: 5 min.
  • Clock-skew tolerance: 60s.
  • Max external token age: 10 min from iat.

Implementation sketch

  1. Domain: domain/external_issuer.go - config type + validation.
  2. Service: extend internal/service/oauth.go with externalIDTokenExchange() branch in Token() dispatch, conditional on subject_token_type. Shares 80% of the existing ExternalPrincipalExchange code path for token minting; only the verification prelude is new.
  3. JWKS fetching: reuse lestrrat-go/jwx/v2/jwk (already a dep via authjwt). Keyed cache per iss.
  4. Config: extend Config struct, add external_issuers koanf mapping.
  5. Claim mapping: single-level path selectors in v1 (e.g., "sub", "email", "preferred_username"). Consider JSONPath / expression language only if a concrete use case emerges.
  6. Bypass path for TrustedServiceValidator: when the request uses subject_token_type = id_token AND the issuer matches a configured external_issuer, the validator is skipped in favor of issuer allowlist + JWKS signature check. This is the point of the new path.

Scope

  • In: direct verification of external OIDC ID tokens, config-driven issuer allowlist, claim mapping, user_id_iss / auth_time / acr / amr propagation.
  • Out: interactive OIDC redirect flow (still the deployer's job - ZeroID is a token exchange endpoint, not a browser-facing OIDC RP).
  • Out: SAML. Separate conversation.
  • Out: automatic client registration with upstream IdPs. Config is explicit by design.
  • Out: OpenID Federation (OIDF) automatic trust chains. That's a larger piece; this issue stays within "deployer explicitly configures trusted issuers."

Acceptance criteria

  • New external_issuers config section parses and validates.
  • Token endpoint accepts subject_token_type = id_token and rejects with a clear error when no matching issuer is configured.
  • Signature verification hits the external JWKS, caches, and tolerates rotation (unknown kid triggers refetch once).
  • iss / aud / exp / nbf all enforced; iat age capped via max_token_age.
  • Claim mapping produces user_id correctly for Okta, Entra, and Google Workspace token shapes (one integration test each).
  • Issued token carries user_id_iss with the real upstream iss. Optional claims propagated per config.
  • docs/identity-model.md leads with direct federation as the default; broker described as fallback. (Already true on branch docs-identity-model; this issue lands the implementation to match.)
  • Integration tests: happy path + each rejection case (unknown issuer, bad signature, wrong audience, expired, stale).

Choosing between direct federation and the broker

Deployer constraint Use direct federation Use broker (fallback)
New deployment, no existing identity tier ✅ default
Simpler topology preferred
Per-IdP provenance required (finance, healthcare, regulated) ✅ required ❌ insufficient
Existing gateway already does OIDC for many services ✅ avoids duplication
Complex internal claim-normalization logic ✅ keep out of ZeroID
Air-gapped / egress-restricted ZeroID ✅ broker does the IdP call

New deployments should default to direct federation unless one of the broker-justifying constraints applies.

Non-goals and honest limits

  • Not a replacement for the broker path. The broker remains for the narrow operational cases above.
  • The v1 claim-mapping language is deliberately minimal. Single-level selectors only. Expression languages only if there's a real need.
  • OIDF is not in scope here. Larger piece of work for open cross-org trust.

References

  • RFC 8693: OAuth 2.0 Token Exchange, defines id_token as a valid subject_token_type.
  • RFC 9068: JWT Profile for OAuth Access Tokens.
  • NIST SP 800-63C: Federation & assertions.
  • CoSAI Agentic IAM: agentic provenance requirements.
  • Existing code: internal/service/oauth.go:ExternalPrincipalExchange.
  • Existing code: pkg/authjwt/verifier.go (JWKS fetch + verify logic to reuse).
  • Docs: docs/identity-model.md → "How ZeroID composes claims from upstream IdPs".

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions