Skip to content

feat: delegated identity providers and accelerated IdP flow#423

Merged
igrigorik merged 19 commits into
mainfrom
feat/identity-with-idp
Jun 12, 2026
Merged

feat: delegated identity providers and accelerated IdP flow#423
igrigorik merged 19 commits into
mainfrom
feat/identity-with-idp

Conversation

@igrigorik

@igrigorik igrigorik commented May 8, 2026

Copy link
Copy Markdown
Contributor

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; providers is 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.

"config": {
  "providers": {
    "com.shopify.accounts": {
      "type": "oauth2",
      "auth_url": "https://accounts.shopify.com/"
    }
  },
  "scopes": { "dev.ucp.shopping.order:read": {} }
}

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):

"config": {
  "providers": {
    "com.shopify.accounts": {
      "type": "oauth2",
      "auth_url": "https://accounts.shopify.com/",
      "required_claims": ["email"]
    },
    "com.google": {
      "type": "oauth2",
      "auth_url": "https://accounts.google.com/"
    }
  },
  "scopes": { "dev.ucp.shopping.order:read": {} }
}

The type discriminator 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

  • Core Protocol: Changes to the base communication layer, global context, or breaking refactors.
  • Capability: New schemas (Discovery, Cart, etc.) or extensions.
  • Documentation: Updates to README, or documentations regarding schema or capabilities.
  • I have followed the Contributing Guide.
  • I have updated the documentation (if applicable).
  • My changes pass all local linting and formatting checks.
  • (For Core/Capability) I have included/updated the relevant JSON schemas.

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.
@igrigorik igrigorik added this to the Working Draft milestone May 8, 2026
@igrigorik igrigorik self-assigned this May 8, 2026
@igrigorik igrigorik requested review from a team as code owners May 8, 2026 04:53
@igrigorik igrigorik added the TC review Ready for TC review label May 8, 2026
@igrigorik igrigorik requested review from a team as code owners May 8, 2026 04:53
Comment thread docs/specification/identity-linking.md Outdated
Comment thread docs/specification/identity-linking.md Outdated
@pemamian pemamian removed their request for review May 21, 2026 14:35

@jamesandersen jamesandersen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a great step forward in helping remove end user friction in the commerce journey; left a few comments for discussion.

Comment thread docs/specification/identity-linking.md Outdated
Comment thread docs/specification/identity-linking.md Outdated
Comment thread source/schemas/common/identity_linking.json
@douglasborthwick-crypto

Copy link
Copy Markdown

Strong +1 on lifting provider.type into the schema $def with the oauth2 default — that turns providers from an OAuth-only list into a genuine mechanism registry, and the "filter unsupported type, apply the whitelist, abort if nothing remains" selection rule is the right shape for letting a business offer more than one mechanism class side by side.

It's worth stress-testing the extensibility model against a concrete non-oauth2 consumer, since that's where the abstraction either holds or leaks. The wallet attestation mechanism I proposed in #415 is the natural first one, and it surfaces a few assumptions worth loosening now while this section is being written.

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 config. That doesn't fit the RFC 8693 → RFC 7523 grant-exchange chain, which assumes the flow terminates in a business-issued bearer token. Three additive adjustments would keep verification-only mechanisms non-breaking:

  1. Generalize the deferred-mechanics phrase from "grant exchange mechanics" to something like "discovery and proof-presentation mechanics," so a type that presents a verifiable assertion (rather than exchanging a grant for a token) is in scope by construction.
  2. Note that a provider entry's type MAY contribute no scopes — presence in providers doesn't imply participation in the scope/token model. The selection and abort rules work unchanged; they turn on whether the platform supports the type, independent of whether that type issues a token.
  3. Scope auth_url to the mechanism that needs it. The provider $def currently sets required: ["auth_url"], but the $def description already promises "additional types (e.g., 'wallet') without breaking the shape" — and a verification-only type discovers via provider_jwks, not an authorization-server URL, so it has no auth_url to supply. As written, any non-oauth2 entry fails validation against the $def. Suggest either an if/then on the discriminator (auth_url required only when type is oauth2), or relaxing required at the $def level and letting each type declare its own required fields. This is the one structural change that lets feat: identity linking — mechanism extensibility with wallet attestation #415 land as a pure extension rather than a retrofit.

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 raginpirate left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high level thought about restructuring where we advertise this set of providers, wdyt?

Comment thread source/schemas/common/identity_linking.json
Comment thread source/schemas/common/identity_linking.json Outdated
Comment thread docs/specification/identity-linking.md
Comment thread docs/specification/identity-linking.md
Comment thread docs/specification/identity-linking.md Outdated
igrigorik added 5 commits May 29, 2026 22:20
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.
igrigorik added 3 commits May 29, 2026 22:20
   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.
@igrigorik

Copy link
Copy Markdown
Contributor Author

@amithanda @jamesandersen @douglasborthwick-crypto ty for thorough review and comments. Pushed a set of commits that should address all the flags and clarifications, ptal.

Comment thread docs/specification/identity-linking.md
Comment thread source/schemas/common/identity_linking.json

@shopdave shopdave left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread docs/specification/identity-linking.md Outdated
Comment thread docs/specification/identity-linking.md Outdated
Comment thread docs/specification/identity-linking.md
Comment thread docs/specification/identity-linking.md Outdated
Comment thread docs/specification/identity-linking.md Outdated
Comment thread docs/specification/identity-linking.md Outdated
igrigorik and others added 6 commits June 4, 2026 21:40
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.
Comment thread source/schemas/common/identity_linking.json
  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.
@igrigorik

Copy link
Copy Markdown
Contributor Author

@raginpirate sharp eyes and great catch! Agree on the nudge, updated -- ptal.

@raginpirate raginpirate left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚀!

* **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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 jti claim within the grant's validity window.

This is probably a merge artifact from @shopdave's suggestion. I think, two fragments got spliced.

Suggested change
* **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.

@amithanda

amithanda commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Approving — great work on this 🚀

Thanks for the thorough iteration here, @igrigorik. My earlier suggestions are all well addressed:

  • resource/audience interop - the move to "resource and/or audience, MUST include at least one" cleanly unblocks existing implementations like Google STS and Keycloak without weakening the binding. 👍
  • Self-listed vs. external IdP ambiguity - flipping providers to a delegated-only closed allowlist (plus the "ignore an entry whose auth_url matches the business's own issuer" rule) resolves the circular-token-exchange concern far more elegantly than a self flag would have.
  • Non-OAuth extensibility - using if/then on the type discriminator keeps auth_url required only for oauth2 while leaving the door open for wallet attestation and future mechanisms as non-breaking additions.

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 jti suggestion was applied). Left an inline suggestion to restore it to a single coherent MUST. Purely editorial.

This is a meaningful step forward for UCP identity linking capability. Nicely done. Approving with the one nit above.

@igrigorik igrigorik merged commit 5d10ff7 into main Jun 12, 2026
15 checks passed
@igrigorik igrigorik deleted the feat/identity-with-idp branch June 12, 2026 14:30
@github-actions github-actions Bot added the enhancement New feature or request label Jun 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request payments TC review Ready for TC review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants