feat: delegated identity providers and accelerated IdP flow#423
Conversation
Adds delegated authentication to the identity linking capability so businesses can outsource user authentication to trusted external IdPs and let platforms chain identity to new businesses without a fresh browser-based OAuth flow. Schema (source/schemas/common/identity_linking.json): * New `provider` $def: `auth_url` (URI, required) + `type` discriminator (default `oauth2`, reserved for future non-OAuth mechanisms like wallet attestation). * New `config.providers` map keyed by reverse-domain identifier in the business schema. Spec (docs/specification/identity-linking.md): * `config.providers` semantics: absent = business is implicitly its own (and only) IdP; present = strict whitelist, platforms MUST NOT fall back to direct OAuth on the business domain; businesses self-list to opt back in alongside externals. * New "Accelerated IdP Flow" section: RFC 8693 token exchange at the IdP -> RFC 7523 JWT bearer assertion grant at the business token endpoint. Profiles draft-ietf-oauth-identity-chaining-08 with two UCP-specific tightenings: token exchange MUST use `resource` (not `audience`); JWT grant MUST carry single-valued `aud` and unique `jti`. * JWT grant validation defers to RFC 7523 §3 with UCP-specific constraints (iss in `config.providers`, fail-closed JWKS, jti single-use). * Token Lifecycle: SHOULD NOT issue refresh tokens on JWT bearer grants (per draft §5.4); dual-layer revocation at IdP + business on unlink. * New "IdP Requirements" section: AS metadata must advertise `revocation_endpoint`, `jwks_uri`, and the token-exchange grant type.
jamesandersen
left a comment
There was a problem hiding this comment.
This is a great step forward in helping remove end user friction in the commerce journey; left a few comments for discussion.
|
Strong +1 on lifting It's worth stress-testing the extensibility model against a concrete non- A wallet attestation provider issues no token. The platform presents the business a signed boolean ("wallet satisfies predicate P at block N"), the business verifies it offline against the provider's published JWKS, and nothing is minted — no access token, no session, and it contributes no entries to the scopes map under
Happy to rebase #415 onto this branch's selection semantics once it merges, and to fold these clarifications into that PR if you'd rather keep #423 focused on the OAuth-chaining path. And agreed on the agentic-flow framing — that's the strongest case for keeping the registry mechanism-plural. Douglas |
raginpirate
left a comment
There was a problem hiding this comment.
high level thought about restructuring where we advertise this set of providers, wdyt?
Reshape the `provider` definition in the identity-linking capability from a flat object with `required: ["auth_url"]` and a defaulted `type` into a required, open-string `type` discriminator whose per-type fields are enforced with conditional `if/then` branches. Addresses review on #423 from @amithanda and @douglasborthwick-crypto. - `provider.type` is now REQUIRED and an OPEN string (no `default`, no `enum`). - `auth_url` is required only for `type: "oauth2"`, via an `allOf` `if/then` branch -- not unconditionally on every provider. - Unrecognized `type` values validate as bare objects (tolerated), to be filtered by platforms at runtime per the existing forward-compatibility rule. - Docs updated to match (see below). The `provider`/`providers` shape is net-new here: on `main` it existed only as a reserved `$comment` extension point with no validated schema. There are no on-the-wire provider configs to break, so there is no backwards-compat reason to keep the `oauth2` default or to tolerate an omitted `type`. A required, explicit `type` is self-describing and removes discriminator ambiguity at zero migration cost. The value stays an open string (not a closed `enum`) so future mechanisms remain valid against older schemas. WHY if/then AND NOT oneOf (the decision we do not want to relitigate) 1. A closed `oneOf` is exclusive selection: an entry whose `type` is unknown to the schema doing the validation matches zero arms and FAILS. That directly contradicts this spec's own forward-compatibility rule ("platforms MUST treat provider entries with an unsupported `type` as filtered out"), which only makes sense if unknown types are tolerated, not rejected. 2. `anyOf` does not help: it still requires >=1 match, so an unknown type fails just like `oneOf`. Adding a permissive base arm to tolerate unknowns would also stop enforcing the known-type fields. Wrong tool. 3. There is no "open `oneOf`". Faking it with a catch-all arm is a maintenance footgun: each new known type must also be excluded from the catch-all, or it matches two arms and `oneOf` fails. 4. `if/then` is conditional, not selective: it enforces a known type's fields when that type is present and is vacuously true for types it does not recognize -- precisely the "validate what you know, tolerate what you don't" behavior the forward-compat rule promises. New mechanisms drop in as additive sibling branches with no churn to the oauth2 path.
…Auth
review flagged that when a business hosts its own AS at an
external-looking domain (e.g., brand.okta.com), self-listing it in
config.providers leaves platforms unable to tell self from external —
they'd apply the chaining protocol (RFC 8693 + 7523) to what is really
the business's own AS.
Root cause was the closed allowlist rule: "when providers is present,
MUST NOT fall back to direct OAuth." That forced businesses to enumerate
their own AS to keep direct OAuth available, which is what created the
ambiguity. Standard RFC 8414 discovery on the business domain already
handles the "business uses Okta as own AS" case cleanly — issuer must
be the business domain per §3.3, endpoints can resolve anywhere.
New contract:
- Direct OAuth via RFC 8414 discovery on the business domain is the
always-available baseline, regardless of providers content.
- config.providers is **additive** metadata declaring trusted external
IdPs for the Accelerated IdP Flow.
- Businesses MUST NOT list their own authorization server.
- Platforms MUST ignore any entry whose auth_url matches the business
issuer URI (defense-in-depth).
- When no listed provider is supported or suitable, platforms MUST
fall back to direct OAuth — never abort.
Advertised scopes apply uniformly whether the platform obtained identity via direct OAuth or the Accelerated IdP Flow. Per-scope policy (min_acr, max_token_age, require_mfa, etc.) gates which assertions satisfy a scope; the scope itself is always declared in config.scopes regardless of path.
The stable per-IdP identity key is (iss, sub). Other claims are advisory. §Business Token Issuance: oauth2 IdPs SHOULD include OIDC Core §5.1 standard claims (particularly email and email_verified) in JWT authorization grants when available and the user has consented. Businesses SHOULD NOT auto-link accounts across IdPs by matching email or any other claim; SHOULD require user-mediated linking before merging identities. §Security Considerations: new "Cross-IdP account linking" item. A provider asserting email_verified=true for an email the user does not control can hijack any account at the business sharing that email. Federation collapses trust boundaries; auto-linking by any IdP-asserted claim extends each provider's verification process into account- takeover risk. Businesses SHOULD require user-mediated linking. Aligned with industry default behavior (Auth0, Okta, Firebase, Cognito) and OpenID Foundation guidance on account linking. No schema change.
When the JWT authorization grant lacks a claim the business requires (e.g., email for account resolution), the business MUST reject with invalid_grant. error_description MAY name the missing claim for human diagnosis but platforms MUST NOT parse it; recovery is falling back to direct OAuth. Businesses with claim requirements beyond OIDC Core §5.1 standard claims SHOULD document them in their developer-facing materials.
Token exchange targets the business's authorization server issuer URI. The original spec mandated the `resource` parameter and prohibited `audience` on the grounds that the target is always a concrete URI. RFC 8693 §2.1 explicitly permits URI values in either parameter, and IdP implementations diverge in which they accept. The MUST-use-`resource` rule was an aesthetic preference, not a security constraint — both parameters resolve to the same `aud` claim in the resulting grant.
- source/schemas/common/identity_linking.json:84: $ref pointed to
../shopping/types/reverse_domain_name.json, which doesn't exist.
- docs/specification/identity-linking.md §Profile Example: capability
fragment expanded to a full profile and annotated
schema=profile def=business_schema. Now validates end-to-end.
- Removed three $comment blocks from identity_linking.json (top-level,
$defs.provider, $defs.provider.allOf[0]). identity_linking was the
only schema in source/schemas/ carrying $comment usage; all content
was duplicated in the spec doc.
|
@amithanda @jamesandersen @douglasborthwick-crypto ty for thorough review and comments. Pushed a set of commits that should address all the flags and clarifications, ptal. |
There was a problem hiding this comment.
Overall looks good. Suggest revamping the discovery section to explicitly use PRM as the mechanism for discovering where the authorization server is located. This also closely aligns us with the MCP approach and makes off-business domain authorization servers possible.
Also suggest updating the PR body to reflect the latest changes as to not confuse anyone in the future.
Co-authored-by: David Pettersson <david.pettersson@shopify.com>
Co-authored-by: David Pettersson <david.pettersson@shopify.com>
Co-authored-by: David Pettersson <david.pettersson@shopify.com>
Co-authored-by: David Pettersson <david.pettersson@shopify.com>
Restructure §Discovery as a three-step pipeline anchored on RFC 9728
protected-resource metadata, supporting authorization servers that
live on a different origin than the business domain. Resolves the
off-domain AS topology David raised on PR #423 :279, and aligns with
MCP's authorization discovery chain (PRM → RFC 8414 → OAuth).
Step 1 (resolve AS issuer): platforms fetch PRM and select an entry
from authorization_servers; AS issuer MAY be off-domain; defaults to
the business domain when PRM is absent (single-host).
Step 2 (fetch AS metadata): strict 8414-primary / OIDC-on-404-only
ordering — unchanged from the prior two-tier hierarchy. Adds an
explicit pointer at RFC 8414 §3.1 path-insertion semantics.
Step 3 (validate issuer): byte-for-byte match against the Step 1 AS
issuer, no normalization. Match target shifts from "business domain"
to "AS issuer" — a consequence of Step 1, not a separate change.
Defers RFC 9728 mechanics (PRM URL, resource match, authorization_
servers selection) and RFC 8414 mechanics (URL templates, issuer
validation) to the cited RFCs rather than restating. Net +13 lines.
Adds `required_claims` (array of OIDC Core §5.1 claim names) to the
`oauth2` provider branch. Optional hint that lets businesses advertise
which claims they need from a given provider's JWT authorization
grant, so platforms can skip provider entries whose upstream identity
cannot satisfy the requirement — saving the round-trip into an
invalid_grant rejection at the token endpoint.
Restructure `config.providers` from `{key: object}` to
`{key: array<object>}` — each IdP namespace now maps to an array of
mechanism entries instead of a single mechanism object.
|
@raginpirate sharp eyes and great catch! Agree on the nudge, updated -- ptal. |
| * **JWT grant lifetime.** JWT authorization grants **MUST** be short-lived; | ||
| the `exp` claim **SHOULD** be no more than 60 seconds after `iat`. Short | ||
| lifetimes limit the window for grant theft and replay. | ||
| * **JWT grant single-use.** Businesses **MUST** enforce single-use JWT; |
There was a problem hiding this comment.
I think the "JWT grant single-use" bullet is broken mid-clause:
Businesses MUST enforce single-use JWT; a short exp narrows the replay window, but only jti tracking closes it. authorization grants by tracking the
jticlaim within the grant's validity window.
This is probably a merge artifact from @shopdave's suggestion. I think, two fragments got spliced.
| * **JWT grant single-use.** Businesses **MUST** enforce single-use JWT; | |
| * **JWT grant single-use.** Businesses **MUST** enforce single-use of JWT | |
| authorization grants by tracking the `jti` claim within the grant's | |
| validity window. A short `exp` narrows the replay window, but only | |
| `jti` tracking closes it. |
Approving — great work on this 🚀Thanks for the thorough iteration here, @igrigorik. My earlier suggestions are all well addressed:
One small open item before merge - the "JWT grant single-use" bullet in Security Considerations is currently garbled (the original sentence's tail got left dangling when @shopdave's This is a meaningful step forward for UCP identity linking capability. Nicely done. Approving with the one nit above. |
The v1 design (#354) only supports direct OAuth between platform and business: every business requires a fresh redirect, fresh consent, fresh credentials. In practice, many businesses already offer delegated IdPs (Sign in with Google, Shop, Apple, etc.) to give their users accelerated sign-in and account creation+linking. Platforms that already hold an identity at one of those IdPs benefit too: they can complete the link without re-prompting the user, which is essential for agentic flows where redirects and per-merchant consent screens are prohibitive.
This PR lets a business advertise the delegated IdPs it trusts. Platforms that already hold a token at one of those IdPs chain identity to the business via a back-channel token exchange (RFC 8693 + RFC 7523) — no redirect, no extra prompt — and the business still issues its own access tokens under its own authority. Direct OAuth against the business's own AS remains always available;
providersis an additive allowlist for chaining only, and businesses MUST NOT self-list (chaining-to-self is degenerate).How a business advertises providers
Single delegation: business trusts one external IdP. Platforms chain identity through that IdP; direct OAuth on the business domain is implicitly available as a fallback.
Multiple IdPs with claim pre-filter: business accepts several IdPs and declares required OIDC §5.1 claims so platforms can skip providers whose upstream tokens can't satisfy them (reactive enforcement at the token endpoint remains mandatory):
The
typediscriminator is future-extensible (e.g., wallet attestation) as a non-breaking extension. AS discovery anchors on RFC 9728 protected-resource metadata, so the business's AS can live on a different origin than its API domain.Checklist