feat: delegated identity providers and accelerated IdP flow#423
feat: delegated identity providers and accelerated IdP flow#423igrigorik wants to merge 3 commits into
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.
| The `config.providers` map declares the trusted identity providers a | ||
| business accepts for authentication. | ||
|
|
||
| * **When absent:** by advertising the `dev.ucp.common.identity_linking` |
There was a problem hiding this comment.
Is this for backwards compatibility? Why have this implicit behavior?
There was a problem hiding this comment.
Is this for backwards compatibility? Why have this implicit behavior?
Correct, the idea is to retain the same semantics for the regular identity linking flow as defined in the very first version of the specification.
|
|
||
| | Field | Type | Required | Description | | ||
| | :---- | :--- | :------- | :---------- | | ||
| | `type` | string | No (default: `oauth2`) | Provider type discriminator. | |
There was a problem hiding this comment.
Again, why a default here?
There was a problem hiding this comment.
Again, why a default here?
Purely convenience. Want to make clear that unless otherwise noted, OAuth is the protocol for identity linking.
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.
|
|
||
| Once the assertion is validated, the business resolves the user from `sub` | ||
| (auto-provisioning permitted) and issues an access token scoped to the | ||
| requested UCP scopes. If user interaction is required (terms acceptance, |
There was a problem hiding this comment.
"If user interaction is required... " should UCP require businesses to issue all advertised scopes regardless of which identity provider is used? e.g. would a business have any cause to require use of it's own OAuth endpoints to acquire certain scopes? ... I don't have a specific scenario in mind but possibly useful to cover this expectation in the docs?
| JWKS cannot be retrieved, the business **MUST** fail closed. | ||
|
|
||
| Once the assertion is validated, the business resolves the user from `sub` | ||
| (auto-provisioning permitted) and issues an access token scoped to the |
There was a problem hiding this comment.
Worth considering whether some guidance on identity claims would help implementers here — a couple of scenarios where opaque sub could be tricky:
-
Cross-IdP user resolution. A business that accepts both com.shopify.accounts and com.google receives sub=abc123 from one and sub=xyz789 from the other. If these correspond to the same end user, the business has no way to correlate them without a shared identifier like email. Without that, a user who links via Google and later links via the business oauth could end up with two disjoint accounts at the same business. This has happened to me a time or two in fact ;-)
-
Auto-provisioning a new account. A business receives a chained grant for a sub it has never seen. To create a useful account it likely needs at minimum a contact identifier (email, phone) — otherwise it provisions an empty record it can't communicate with or match to an existing customer.
OIDC Core §5.1 defines standard claims (email, email_verified, name, etc.) that seem like a natural fit here. Not necessarily as a MUST, but perhaps a SHOULD for IdPs to include email and email_verified when available, or a note in the spec acknowledging that businesses will likely need claims beyond sub for practical user resolution. The building blocks already exist via OIDC — it might just be a matter of connecting the reference.
| "title": "Identity Provider", | ||
| "description": "A trusted identity provider for delegated authentication. The 'type' discriminator defaults to 'oauth2' when absent; future versions may define additional types (e.g., 'wallet') without breaking the shape.", | ||
| "required": ["auth_url"], | ||
| "properties": { |
There was a problem hiding this comment.
Related to the comment about about other OIDC claims, I'm wondering if there's a use case for the business to include in the provider certain well-known claims it needs to perform identity transformation between the IdP domain and it's own domain e.g. to advertise I minimally need an email or phone claim in the JWT from the IdP to either resolve an existing account or create a new one
Platforms may need to understand this when acquiring tokens from the identity provider the first time
|
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?
| "pattern": "^[a-z][a-z0-9]*(?:\\.[a-z][a-z0-9_]*)+:[a-z][a-z0-9_]*$" | ||
| }, | ||
|
|
||
| "provider": { |
There was a problem hiding this comment.
This feels a lot like advertising services but we're doing it under a capability. I imagine that we will run into this a lot in the future; thinking about fulfillment providers, 3p catalog providers, etc.
I'm thinking we could instead extend the base service def for dev.ucp.identity_linking, such that you advertise:
"services": {
"dev.ucp.common.identity_linking": [
{
"id": "{{id}}",
"version": "{{version}}",
"spec": "https://ucp.dev/{{version}}/specification/identity",
"transport": "rest",
"endpoint": "{{3p_url}}",
"config": {
"type": "oauth2"
}
},
{
"id": "{{id}}",
"version": "{{version}}",
"spec": "https://ucp.dev/{{version}}/specification/identity",
"transport": "rest",
"endpoint": "{{buisness_url}}",
"config": {
"type": "oauth2"
}
}
]
}
The somewhat unique thing here is that right now it seems services have been described as defining verticals, but they can be used under specific product namespaces as well to represent a thinner slice of support, right? Such that you could advertise your checkout operations under a fundamentally unique url from cart, right?
There was a problem hiding this comment.
fwiw this is non-blocking, if we wanted to ship this and later deprecate to advertise through a more unified channel in a follow up PR, we could! Depends on if we see this as a small diff or a larger refactor that needs debates and structural reform. While squinting this looked like it didn't need a reform, but I might be missing something.
There was a problem hiding this comment.
While a unified vertical discovery channel is a valuable concept, I would not recommend moving IdPs into the global services registry here due to UCP's architectural separation:
- UCP-Compliant Interfaces vs. Generic Protocols:
In UCP, advertising a service with aresttransport implies that the target endpoint conforms to UCP-governed schemas and API contracts (as will be the case with future 3P integrations like Fulfillment or Catalog adapters). In contrast, external IdPs (like Google OIDC or Shop Pay) do not implement UCP-governed schemas; they conform to standard OIDC and OAuth 2.0 specifications. - Registry Pollution:
Listing generic OIDC auth endpoints in theservicesregistry would set a precedent of using the service registry for utility configurations, requiring the addition of auth-specific protocols (likeoauth2oroidc) to the service transport enum, which is otherwise reserved for UCP vertical API transport bindings. - Independent Negotiation:
Independent routing and negotiation are already supported under the capability model.dev.ucp.common.identity_linkingis negotiated as its own independent capability in the profile. Split-domain routing (direct OAuth vs. external IdP) is natively resolved by standard OAuth 2.0 endpoint configurations within the capability'sprovidersconfig map.
Keeping the provider map encapsulated inside the capability configuration remains the correct abstraction. It avoids polluting the service transport registry and keeps commerce operations separated from generic identity mechanics.
There was a problem hiding this comment.
Appreciate the pushback @amithanda. Let me pull at a couple of these though to make sure I'm reading the concern right, as my proposal might have been ambiguous.
For 1 - Is there a strong gain from enforcing that a REST API service speaks a UCP dictated schema? From my example, the service is advertised under a UCP capability with a UCP spec that explains how to speak to it over the provided transport and how to use its response to build UCP-owned schemas. I also want to challenge this a bit with a2a. The a2a service points at a metadata document published at a well-known path, whose contract is owned by an external standards body, which the client parses to discover the real endpoints. Understandably the transport then communicates UCP schemas over a2a (hence why its a transport and tied deeper into UCP), but I think of this akin to how after performing Oauth you speak UCP schemas back to our core capabilities to resolve the flow.
For 2 — I notice you started pushing in the direction of claiming oauth2 as a transport. In my initial thinking I saw REST remaining as the transport and communicating a distinction of the auth protocol as a config underneath a "dev.ucp.common.identity_linking" service. Is there a reason you tended in the direction of seeing oauth2 as a transport rather than letting the associated UCP spec inform that context? I agree that I don't want to see us proliferate N unique protocols we are not governing bodies for as transports in our spec.
For 3 - because I'm proposing to keep the services under the capability namespace ("https://ucp.dev/{{version}}/specification/identity"), if the capability is not to be used the services can be discarded. This is akin to leveraging UCP but choosing to not negotiate anything under the shopping vertical; we expect this to be valid and possible to navigate by pruning, correct? I think I see the same pollution problem here.
What I keep coming back to, and why I am proposing this, is because services are a great base schema to advertise services in a common way for all capabilities. What we're setting ourselves up for is N capabilities each over time advertising services in independent ways with fragmented schemas. If we standardize that service endpoints get advertised under services, associated to their capabilities, we can avoid this fragmentation of discovery processes.
| "type": "object", | ||
| "title": "Identity Provider", | ||
| "description": "A trusted identity provider for delegated authentication. The 'type' discriminator defaults to 'oauth2' when absent; future versions may define additional types (e.g., 'wallet') without breaking the shape.", | ||
| "required": ["auth_url"], |
There was a problem hiding this comment.
As @douglasborthwick-crypto highlighted in his suggestion - Should we relax the global required: ["auth_url"] constraint on the provider object to accommodate non-OAuth mechanisms (such as wallet attestation) that do not use authorization endpoints?
Instead of a single flat object, should we define provider using UCP's standard oneOf pattern, delegating to specific sub-schemas (e.g. #/$defs/oauth2_provider which requires auth_url, and #/$defs/wallet_provider which does not)? This is consistent with other polymorphic patterns in UCP (like fulfillment_destination.json) and allows clean extensibility for future provider types without breaking backwards compatibility.
There was a problem hiding this comment.
The oneOf split is the right call, and the fulfillment_destination.json precedent you cited is the one to follow — oneOf over $ref'd member schemas, as in that file, with these members told apart by the type field (the oauth2 default vs const: "wallet_attestation"). Most of the wallet arm is already drafted: #415 defines a wallet_attestation_provider $def in identity_linking.json with required: ["type", "provider_jwks"] and no auth_url (plus an optional attestation_endpoint). That's exactly the non-oauth2 member you're describing — it drops straight into #423's oneOf, with oauth2_provider keeping required: ["auth_url"] and the type default of oauth2 preserving every existing config (non-breaking on the OAuth path).
Small naming nit: I'd suggest wallet_attestation_provider over wallet_provider. The arm is specific to the signed-predicate mechanism, and a bare wallet_* could read as covering wallet-based OAuth too.
The one thing worth deciding is sequencing: do you want #423 to absorb the wallet arm into its oneOf now, or land providers here as oauth2-only and let #415 add the second arm on top once #423 merges? Either works on my end — #415 also carries the spec prose, the offline verification procedure, the wallet_state_required / wallet_state_optional info codes, and a provisional WalletAttestation WWW-Authenticate scheme, so if #423 takes the schema arm I'll trim #415 down to that prose layer.
Douglas
| `auth_url` using the same two-tier hierarchy as [Discovery](#discovery) | ||
| (RFC 8414 primary, OIDC fallback on 404 only). | ||
|
|
||
| ### Provider Selection |
There was a problem hiding this comment.
Should we introduce an explicit differentiator (such as a reserved provider key "self" or a boolean flag "self": true / "direct": true) in the provider schema to help platforms distinguish between self-listed direct OAuth endpoints and external delegated IdPs?
It could be argued that platforms can identify the direct endpoint by matching the storefront domain with the provider's auth_url, or by treating all listed providers uniformly (running Token Exchange for every provider). However, both the direct OAuth fallback and the proposed provider schema together are insufficient:
- Strict Issuer Validation Forces Self-Listing:
UCP mandates that: "Theissuervalue in the discovered metadata MUST match the discovery base URI exactly (per RFC 8414 §3.3). Platforms MUST NOT normalize (e.g., strip trailing slashes) before comparison — the value must be a byte-for-byte match."
If a merchant separates storefront hosting from customer identity (e.g., storefront onbrand.combut AS on Okta atbrand.okta.com), direct OAuth discovery on the storefront domain will fail this strict issuer check. To bypass this domain mismatch, the merchant is forced to self-list their AS domain in theprovidersmap. - Structural Ambiguity and Circular Requests:
Once self-listed, the merchant's endpoint is structurally identical to external IdPs (such as Google). Because the domains do not match the storefront, platforms cannot use domain-matching heuristics to identify the direct endpoint. Without a clear differentiator, the platform cannot determine which endpoint requires direct redirects and which enables back-channel token exchange. If the platform treats the self-listed endpoint as an external IdP, it will attempt a redundant, circular token exchange (RFC 8693) on the merchant's own AS, causing latency and request failures.
An explicit flag resolves this structural ambiguity and ensures platforms can execute direct OAuth redirects for self-hosted authorization and reserve token exchange logic solely for external federators.
| `auth_url` using the same two-tier hierarchy as [Discovery](#discovery) | ||
| (RFC 8414 primary, OIDC fallback on 404 only). | ||
|
|
||
| ### Provider Selection |
There was a problem hiding this comment.
Should we introduce an explicit differentiator (such as a reserved provider key "self" or a boolean flag "self": true / "direct": true) in the provider schema to help platforms distinguish between self-listed direct OAuth endpoints and external delegated IdPs?
It could be argued that platforms can identify the direct endpoint by matching the storefront domain with the provider's auth_url, or by treating all listed providers uniformly (running Token Exchange for every provider). However, both the direct OAuth fallback and the proposed provider schema together are insufficient:
- Strict Issuer Validation Forces Self-Listing:
UCP mandates that: "Theissuervalue in the discovered metadata MUST match the discovery base URI exactly (per RFC 8414 §3.3). Platforms MUST NOT normalize (e.g., strip trailing slashes) before comparison — the value must be a byte-for-byte match."
If a merchant separates storefront hosting from customer identity (e.g., storefront onbrand.combut AS on Okta atbrand.okta.com), direct OAuth discovery on the storefront domain will fail this strict issuer check. To bypass this domain mismatch, the merchant is forced to self-list their AS domain in theprovidersmap. - Structural Ambiguity and Circular Requests:
Once self-listed, the merchant's endpoint is structurally identical to external IdPs (such as Google). Because the domains do not match the storefront, platforms cannot use domain-matching heuristics to identify the direct endpoint. Without a clear differentiator, the platform cannot determine which endpoint requires direct redirects and which enables back-channel token exchange. If the platform treats the self-listed endpoint as an external IdP, it will attempt a redundant, circular token exchange (RFC 8693) on the merchant's own AS, causing latency and request failures.
An explicit flag resolves this structural ambiguity and ensures platforms can execute direct OAuth redirects for self-hosted authorization and reserve token exchange logic solely for external federators.
| * `grant_type`: `urn:ietf:params:oauth:grant-type:token-exchange` | ||
| * `subject_token`: the platform's existing IdP access token | ||
| * `subject_token_type`: `urn:ietf:params:oauth:token-type:access_token` | ||
| * `resource`: the business's authorization server issuer URI (the IdP |
There was a problem hiding this comment.
Should we relax the strict requirement to use the resource parameter and instead permit the use of audience, resource, or both (containing the business AS issuer URI or logical identifier) during token exchange?
Why:
Strictly mandating resource and prohibiting audience (lines 433-436) introduces a critical interoperability barrier with several widely deployed Identity Providers (IdPs) and Security Token Services (STS):
- Google Cloud STS: Google’s Workload Identity Federation STS endpoint strictly requires the
audienceparameter (mapped to the workload pool provider URI) and does not support or recognize theresourceparameter. - Keycloak: In Keycloak's RFC 8693 token exchange implementation, downscoping and client targets are resolved via the
audienceparameter (mapping to Keycloak client IDs). Keycloak historically does not natively resolve theresourceparameter out-of-the-box.
RFC 8693 Semantics:
While it may be argued that resource is defined in RFC 8693 as a URI and audience as a logical name, Section 2.1 of the RFC explicitly states that the audience value "MAY be a URI". Therefore, passing the merchant's AS issuer URI in the audience parameter is fully compliant with the RFC and ensures out-of-the-box interoperability with standard OIDC and STS implementations without compromising security.
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 closes the gap by letting a business advertise the 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 — no redirect, no extra prompt — and the business still issues its own access tokens under its own authority.
How a business advertises providers
Strict delegation: the business uses an external IdP and does not run its own oAuth infrastructure. Platforms authenticate via a listed provider (in this example accounts.shopify.com):
Hybrid: the business self-lists alongside an external IdP. Platforms pick either the IdP for accelerated flow, or the business directly (Account Linking Flow), based on what they already hold a token for or what the user prefers:
Checklist