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:
- Accepts
subject_token_type = urn:ietf:params:oauth:token-type:id_token.
- Reads
iss from the subject_token and looks it up against a deployer-configured allowlist of trusted external issuers.
- Fetches that issuer's JWKS (cached 5 min, same pattern as
pkg/authjwt).
- Verifies signature,
iss, aud, exp, nbf, iat (with reasonable age cap).
- Applies deployer-configured claim-mapping rules to extract
user_id, email, etc.
- 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
- Domain:
domain/external_issuer.go - config type + validation.
- 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.
- JWKS fetching: reuse
lestrrat-go/jwx/v2/jwk (already a dep via authjwt). Keyed cache per iss.
- Config: extend
Config struct, add external_issuers koanf mapping.
- 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.
- 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
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".
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
ExternalPrincipalExchangetrusted-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:
subject_token_type = urn:ietf:params:oauth:token-type:id_tokenspecifically for this case: the receiver verifies the ID token. The broker path uses the generic:jwttype and skips verification.issclaim (and its propagation across exchanges) to trace to the IdP that authenticated the subject.acr/amr/auth_timeas authentication-context claims. These require knowing where authentication happened; they are not meaningful when an intermediate broker swallows the IdP identity.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 viaTrustedServiceValidatorbut 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:
subject_token_type = urn:ietf:params:oauth:token-type:id_token.issfrom the subject_token and looks it up against a deployer-configured allowlist of trusted external issuers.pkg/authjwt).iss,aud,exp,nbf,iat(with reasonable age cap).user_id,email, etc.user_id_iss, optionallyauth_time,acr,amrwhen present on the upstream token.API shape
Token endpoint request:
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:
Runtime defaults:
iat.Implementation sketch
domain/external_issuer.go- config type + validation.internal/service/oauth.gowithexternalIDTokenExchange()branch inToken()dispatch, conditional onsubject_token_type. Shares 80% of the existingExternalPrincipalExchangecode path for token minting; only the verification prelude is new.lestrrat-go/jwx/v2/jwk(already a dep viaauthjwt). Keyed cache periss.Configstruct, addexternal_issuerskoanf mapping."sub","email","preferred_username"). Consider JSONPath / expression language only if a concrete use case emerges.TrustedServiceValidator: when the request usessubject_token_type = id_tokenAND 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
user_id_iss/auth_time/acr/amrpropagation.Acceptance criteria
external_issuersconfig section parses and validates.subject_token_type = id_tokenand rejects with a clear error when no matching issuer is configured.kidtriggers refetch once).iss/aud/exp/nbfall enforced;iatage capped viamax_token_age.user_idcorrectly for Okta, Entra, and Google Workspace token shapes (one integration test each).user_id_isswith the real upstreamiss. Optional claims propagated per config.docs/identity-model.mdleads with direct federation as the default; broker described as fallback. (Already true on branchdocs-identity-model; this issue lands the implementation to match.)Choosing between direct federation and the broker
New deployments should default to direct federation unless one of the broker-justifying constraints applies.
Non-goals and honest limits
References
id_tokenas a validsubject_token_type.internal/service/oauth.go:ExternalPrincipalExchange.pkg/authjwt/verifier.go(JWKS fetch + verify logic to reuse).docs/identity-model.md→ "How ZeroID composes claims from upstream IdPs".