Skip to content

feat!: identity linking OAuth 2.0 foundation with capability-driven scopes#354

Open
amithanda wants to merge 12 commits intoUniversal-Commerce-Protocol:mainfrom
amithanda:feat/identity-linking-oauth2-foundation
Open

feat!: identity linking OAuth 2.0 foundation with capability-driven scopes#354
amithanda wants to merge 12 commits intoUniversal-Commerce-Protocol:mainfrom
amithanda:feat/identity-linking-oauth2-foundation

Conversation

@amithanda
Copy link
Copy Markdown
Contributor

@amithanda amithanda commented Apr 12, 2026

Overview

This PR establishes the OAuth 2.0 foundation for the Identity Linking capability
(dev.ucp.common.identity_linking). It is an intentionally scoped first step —
business-hosted OAuth 2.0 only — designed so that delegated identity providers
and non-OAuth auth mechanisms can be added in future PRs as non-breaking
extensions.

Context: #265 introduced a mechanism registry and capability-driven scopes
but shipped a critical bug where the intersection algorithm's scope-dependency
pruning rule gated checkout behind identity linking, breaking guest checkout.
#329 reverted it. #330 proposed a full redesign including delegated IdP and
identity chaining. This PR carries forward the improvements from both without
the delegated IdP complexity, and lays explicit extensibility groundwork for
that work to land cleanly.


What Changed

New: source/schemas/common/identity_linking.json

Schema for the identity linking capability. Two context-specific views, nested
under "dev.ucp.common.identity_linking" in $defs per the project's
capability schema convention:

  • platform_schema: passthrough — platforms advertise support; no
    auth-specific config needed.
  • business_schema: requires config.capabilities — a map declaring
    which capabilities offer buyer-scoped features and whether buyer identity
    is required for each.

The config object uses additionalProperties: true with a $comment
naming one reserved extension point:

  • providers — map of trusted identity providers keyed by reverse-domain,
    with a type discriminator defaulting to oauth2. This single extension
    point covers delegated IdP, identity chaining, and future non-OAuth
    mechanisms such as wallet attestation (future PR).

New: identity_required error code (error_code.json)

Standard protocol signal for the case where a capability is configured with
auth_required: true and a request arrives without a buyer identity token.
Businesses MAY include a continue_url in the error body for buyer
onboarding flows.

Rewrite: docs/specification/identity-linking.md

Major rewrite of the spec. Key sections:

Access levels. Capabilities operate at three access levels — public,
agent-authenticated, buyer-authenticated. Identity linking upgrades capabilities
to buyer-authenticated access. Capabilities are never excluded from
capability negotiation based on identity linking. This directly addresses the
root cause of the #265 bug.

Security hardening (carried forward from #265 and #330):

  • PKCE S256: MUST for all authorization code flows; plain MUST NOT be used
  • iss validation: MUST (RFC 9207) — prevents Mix-Up Attacks; validation is
    unconditional, not gated on presence of the iss parameter
  • Exact redirect_uri matching: MUST — no partial/prefix matching
  • issuer byte-for-byte match: MUST — no normalization (trailing slash stripping
    is a known iss validation bypass)
  • scopes_supported: MUST in RFC 8414 metadata — enables early scope mismatch
    detection before consent screen

Strict discovery hierarchy (carried forward from #265 and #330):

  1. RFC 8414 (/.well-known/oauth-authorization-server) — 2xx: use it; 404:
    proceed to step 2; any other non-2xx response, network error, or timeout:
    MUST abort, MUST NOT fall through to step 2
  2. OIDC fallback (/.well-known/openid-configuration) — 2xx: use it; any
    non-2xx response, network error, or timeout: MUST abort

Capability-driven scope model (redesigned from #265, aligned with #330):
Scope declarations live in config.capabilities on the identity linking config,
not as identity_scopes annotations on individual capability schemas. This is
architecturally correct because whether a capability requires buyer auth is a
business decision — a B2B wholesaler gates catalog access, a B2C retailer
doesn't. Per-schema annotations can't express this variance.

Scope naming uses capability names directly (dev.ucp.shopping.checkout,
dev.ucp.shopping.order:read) — reusing UCP's existing reverse-DNS governance
and eliminating a separate scope namespace. Replaces the old
ucp:scopes:checkout_session format.

Future Extensibility section — explicit normative spec for how future
versions extend this capability without breaking v1 implementations (see below).

Updated: docs/specification/overview.md

Identity linking added back to both business and platform profile examples
using the new config shape, including the auth_required field naming.

Fixed: docs/index.md

Scope naming in the RFC 8414 metadata example updated from
ucp:scopes:checkout_session to dev.ucp.shopping.checkout.


Forward Compatibility Design

The schema and spec are explicitly designed for non-breaking extension via a
single config.providers extension point.

config.providers — Delegated Identity Providers and Mechanism Extensibility

A future PR will add a config.providers map (keyed by reverse-domain
identifier) allowing businesses to declare trusted external identity providers
alongside their own hosted OAuth server. Each provider entry carries a type
discriminator defaulting to oauth2, making the map extensible to non-OAuth
mechanisms (wallet attestation, verifiable credentials) without introducing a
separate mechanisms array.

This covers two use cases:

  • Delegated IdP: platforms with an existing IdP session can chain identity
    to new businesses without a browser redirect (per
    draft-ietf-oauth-identity-chaining-08), solving the N-merchant = N-OAuth-dances
    problem for agentic commerce.
  • Non-OAuth mechanisms: future entries with a non-oauth2 type value
    enable wallet attestation and similar schemes. Platforms select the first
    entry whose type they support — analogous to TLS cipher suite negotiation,
    preserving business-preference ordering.

This addition is non-breaking because:

  • config uses additionalProperties: true — the schema will not reject the
    new field
  • The spec defines a normative forward-compat rule: platforms that do not
    recognize config.providers MUST ignore it and fall back to RFC 8414
    discovery on the business domain

Type of Change

  • New feature (non-breaking addition of new capability schema)
  • Breaking change (new scope naming convention replaces ucp:scopes:* format)
  • Documentation update

Breaking Changes Justification

The scope naming convention change (ucp:scopes:checkout_session
dev.ucp.shopping.checkout) is breaking for any implementation that hardcoded
the old format. The old format was defined in the pre-#265 spec and was never
part of a stable release — the capability was at Working Draft status throughout.
The new format is consistent with UCP's reverse-DNS naming governance and
eliminates the need for a parallel scope namespace.


Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings

amithanda and others added 8 commits April 4, 2026 18:09
- Replace "consumer surfaces/platforms" with "consumer platforms" and "businesses" with "business platforms" for consistency.
- Enhance the definitions of consumer and business platforms, emphasizing their roles in capability consumption and exposure.
- Revise key goals and responsibilities to reflect updated terminology and clarify the interaction dynamics within the UCP framework.
- Introduce a new section on capabilities, detailing their structure and examples to improve understanding of UCP's functionality.
- Clarified the role of Payment & Credential Providers to emphasize the secure handling of sensitive user data.
- Enhanced the description of Agentic Commerce to include various modalities for AI agents.
- Revised terminology for distinct actors in the UCP framework to improve clarity.
- Updated capability negotiation process to specify version selection and mutual agreement.
- Improved examples and descriptions for capabilities and transport bindings to align with current standards.
- Updated terminology to replace "consumer platforms" with "clients" and "business platforms" with "providers" for consistency and clarity.
- Enhanced descriptions of the roles and responsibilities of clients and providers in the UCP framework.
- Revised key goals and capabilities to reflect the updated terminology and improve understanding of UCP's functionality.
- Replaced "Client" with "Platform" and "Provider" with "Business" for consistency.
- Updated the identity linking specification to clarify the role of platforms and businesses in buyer-authenticated commerce experiences.
- Introduced a new JSON schema for identity linking, detailing the configuration for capabilities that require buyer identity.
- Revised the overview and general guidelines sections to reflect the updated terminology and structure for identity linking capabilities.
- Added new error code for identity requirements in the shopping types schema.
Restores docs/documentation/core-concepts.md to match
Universal-Commerce-Protocol/ucp upstream main. The local changes
belong to a separate PR and should not be included here.
@douglasborthwick-crypto
Copy link
Copy Markdown

Thanks for laying the extensibility groundwork explicitly. The reserved config.mechanisms slot and the RFC 8414 fallback rule together make it straightforward for non-OAuth mechanisms to land as non-breaking additions — exactly the shape we were looking for when we opened #264.

Once this lands, we can follow up with a concrete config.mechanisms entry for wallet attestation — JWKS-discovered public key, ES256-signed boolean verdict per evaluated condition, deterministic offline verification. The type discriminator pattern (chosen inside the array, TLS-cipher-suite-style) works cleanly for wallet auth since the business declares the supported chains and condition types per entry.

Happy to wait for this PR to merge before opening the follow-up so we're not creating extension-point churn.

- Renamed the `required` field to `auth_required` in the identity linking specification and JSON schema to enhance clarity regarding buyer identity requirements.
igrigorik added a commit that referenced this pull request Apr 16, 2026
  Brings forward the delegated identity provider design from #330 into
  this PR's OAuth 2.0 foundation. The core capability-driven scope model
  and security posture from #354 are unchanged — this commit adds the
  multi-merchant identity layer on top.

  ## Added

  ### Delegated Identity Providers (`config.providers`)

  Businesses can declare trusted external OAuth identity providers in
  `config.providers`, keyed by reverse-domain identifier. A business MAY
  also list itself as a provider, unifying business-hosted OAuth and
  delegated IdP under the same discovery mechanism. When `config.providers`
  is absent, platforms fall back to RFC 8414 discovery on the business
  domain — preserving the baseline behavior already specified in this PR.

  ### Identity Chaining (Accelerated IdP Flow)

  When a platform already holds a valid IdP token and encounters a new
  business that trusts the same IdP, it can chain the buyer's identity
  without a browser redirect. Implements
  draft-ietf-oauth-identity-chaining-08:

  1. Platform obtains a JWT authorization grant from the IdP via token
     exchange (RFC 8693, `resource` parameter identifies target business)
  2. Platform presents the JWT grant to the business via JWT bearer
     assertion (RFC 7523)
  3. Business validates, resolves buyer identity, issues its own token

  This solves the N-merchant = N-OAuth-handoff problem for agentic
  commerce.

  ### Supporting sections

  - **Account Linking**: one-time OAuth flow between platform and IdP,
    reusable across businesses that trust the same provider
  - **Headless and Agentic Contexts**: RFC 8628 device authorization for
    CLI agents and voice assistants
  - **JWT Authorization Grant**: claims table (iss, sub, aud, exp, iat,
    jti), 60s lifetime recommendation, single-use enforcement
    fail-closed on JWKS retrieval failure
  - **Token Lifecycle**: dual-layer management — business tokens and IdP
    tokens have independent lifecycles and revocation; businesses SHOULD
    NOT issue refresh tokens on JWT bearer grants
  - **IdP Requirements**: metadata requirements (revocation_endpoint,
    jwks_uri, token-exchange grant type), token exchange processing rules
  - **Buyer Awareness**: provider choice UX, consent disclosure
  - **Chaining Error Handling**: error table mapping JWT validation
    failures to standard OAuth error responses

  ## Changed

  - **Overview**: removed "v1 auth mechanism" hedging; identity chaining
    is part of the spec, not a future extension
  - **Participants table**: added Identity Provider (IdP) role
  - **General Guidelines — Platforms**: added provider selection and
    chaining disclosure guidance
  - **General Guidelines — Businesses**: added JWT bearer assertion MUST
    when `config.providers` is present
  - **Scopes**: added "Scopes and External Identity Providers" subsection
    clarifying that UCP scopes are requested from the business, not the IdP
  - **Security Considerations**: added JWT grant lifetime, jti single-use,
    and grant replay items
  - **Future Extensibility**: removed `config.providers` subsection (now
    normative); only `config.mechanisms` remains as future work
  - **Auth server metadata example**: added jwt-bearer grant type,
    explanatory note
  - **Business Profile example**: added providers map, fixed `required` →
    `auth_required` to match schema field name
  - **overview.md**: added providers to business profile example

  ### Schema

  - Added `provider` $def (object with `auth_url` URI)
  - Added `providers` property to business config (optional, map keyed by
    reverse-domain)
  - Restructured $defs to nest platform_schema/business_schema under
    `dev.ucp.common.identity_linking`, required by the composition
    algorithm
  - Removed `"version": "Working Draft"` from schema top-level
  - Updated $comment to reflect providers as shipped (not reserved)
Copy link
Copy Markdown
Contributor

@igrigorik igrigorik left a comment

Choose a reason for hiding this comment

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

@amithanda nice work on this, lots of good improvements!

We need to land support for delegated IdPs. This is a key and painfully missing feature, which we proposed the shape for in #330. Comparing the two branches, we need to reconcile and merge, and I think it's easier to build on what you've scoped here.

I took a run at bringing over additional logic from #330 in 603f4c8. Because your PR is against your own fork I can't open a stacked PR. That said, if the commit looks good, I can push it into your branch directly and we can continue iterating against your current branch and close out #330. PTAL, any objections?


With above in place, I think we can collapse providers vs mechanisms into single primitive. They are both attempting to answer the same question: "who does the business trust to vouch for buyers, and what proof protocol do they speak?"

An OAuth IdP and a wallet attestor both have:

  • A reverse-domain trust anchor (com.google, com.example.attestor)
  • A discovery mechanism (auth_url → RFC 8414, provider_jwks → JWKS endpoint)
  • A proof format (OAuth tokens, ES256 signed payloads)
  • An explicit trust relationship (business lists them)

Keeping them as separate keys creates ambiguity: are mechanisms alternatives to OAuth (platform picks one) or complements (identity via OAuth + wallet proof for eligibility)? The TLS cipher-suite negotiation analogy on mechanisms implies the former, but your actual use case in #264/#280 looks more like the latter.

Proposed reconciliation

Unify under providers with a type discriminator. Default type is oauth2 when absent — zero wire-format change for existing entries:

"config": {
  "providers": {
    "com.google": {
      "auth_url": "https://accounts.google.com/"
    },
    "com.example.merchant": {
      "auth_url": "https://merchant.example.com/"
    },
    "com.example.attestor": {
      "type": "wallet_attestation",
      "provider_jwks": "https://attestor.example.com/.well-known/jwks.json",
      ...
    }
  },
  "capabilities": { ... }
}

What this gets us:

  • Single trust surface. Business declares all trusted identity/attestation sources in one map.
  • Clean forward-compat. Platforms skip provider entries whose type they don't recognize. No separate "ignore unknown config fields" rule for a sibling key.

What we drop: The config.mechanisms reserved extension point. It's replaced by new type values on provider entries, which is a strictly simpler extension model.

The relationship between providers then becomes clear from context: a platform that finds both type: "oauth2" and type: "wallet_attestation" entries knows these are different proof protocols. Whether they're alternatives or complements depends on how config.capabilities references them — that's the follow-up design question for when wallet attestation lands, not something we need to over-specify now.

  Four targeted fixes to prepare this PR for backport to 04/08 and
  clean stacking of the delegated IdP follow-up.

  ## 1. Nest $defs under capability name (convention alignment)

  Restructures the schema to match the established pattern required by
  the composition algorithm: `ext_schema["$defs"][root.name]`

  Before:
    $defs:

  After:
    $defs:
      capability_identity_config
      dev.ucp.common.identity_linking:
        platform_schema, business_schema

  Why: capability-scoped schemas live under the capability's reverse-domain
  name so future tooling can resolve them predictably as
  `schema#/$defs/{capability-name}/business_schema`.

  ## 2. Fix `required` → `auth_required` in overview.md

  The business profile example in `overview.md` used `"required": true`
  while the schema and spec text use `"auth_required"`. Anyone copying the
  overview example would hit a validation error.

  ## 3. Remove top-level `version` field from schema

  No other capability schema in the repo carries a top-level `version`
  field — version lives on the capability entry in the UCP profile, not
  on the schema file itself. Removed for consistency with `checkout.json`,
  `fulfillment.json`, `cart.json`, etc.

  ## 4. Tighten `iss` validation language

  Removed the "if present" hedge in two places (For Platforms bullet and
  Account Linking Flow step 3). Since the spec requires businesses to
  MUST return `iss` in every authorization response, the hedge was
  unnecessary and could be read as making `iss` validation conditional.

  ## 5. $comment updated to reflect unified providers model

  The schema-level `$comment` previously described `providers` and
  `mechanisms` as two separate reserved extension points. Updated to
  describe a single `providers` map with a `type` discriminator defaulting
  to `oauth2` — aligning with feedback on Universal-Commerce-Protocol#354 that these are the same
  concept (a trust-anchored identity source with a discovery mechanism
  and proof protocol), not separate keys. This is $comment-only — no
  schema behavior change — and gives the follow-up IdP PR a clean model
  to add `providers` onto without rewriting the Future Extensibility
  section.
@igrigorik
Copy link
Copy Markdown
Contributor

One gotcha that's bothering me with scopes as flat array of strings...

"dev.ucp.shopping.order": {
  "auth_required": true,
  "scopes": ["read", "manage"]
}

With above contract every scope inherits uniform requirements from a single auth_required flag at the capability level. Fine for simple cases, but I think we need more flexibility. Consider the following example...

B2C retailer

  • search does not require buyer auth — public catalog access
  • vip requires buyer authorization — unlocks premium/vip products and inventory

B2B wholesaler

  • search requires buyer auth
  • wholesale_pricing requires elevated auth context (e.g. hardware-backed auth)

Same capability (dev.ucp.shopping.catalog), overlapping but different scope vocabulary, and different access policies for different scopes. Array of strings can't capture this. Capability-level auth_required over-triggers for B2C (flags search as requiring auth) or under-triggers for B2B (can't signal scope step-up).

Rec: scope level policies + explicit scopes (no bare capability scope)

Each listed capability declares its scopes explicitly. Each scope is an object. Wire format is always capability:scope — there is no "bare capability name as scope". The resulting shape is:

"dev.ucp.common.identity_linking": [{
  "config": {
    "providers": { ... },
    "capabilities": {
      "dev.ucp.shopping.catalog": {
        "scopes": {
          "search": { "auth_required": false },
          "vip":    { "auth_required": true }
        }
      },
      "dev.ucp.shopping.order": {
        "scopes": {
          "read":   { "auth_required": true },
          "manage": { "auth_required": true, "min_acr": "urn:tl:TL3" }
        },
        "some_future_foo": "bar"
      }
    }
  }
}]

The contract is:

  • Each listed capability MUST declare a scopes map with at least one entry.
  • Scopes name always follow {capability}:{scope} convention.

What this unlocks

  • Per-scope configs — e.g, auth_required can be defined with scope granularity
  • Extensible scope configs — e.g., min_acr for step-up auth, max_token_age for freshness; ...
  • Convention alignment — providers, capabilities, payment_handlers are all keyed maps

Alt: flat scopes map keyed by wire-format

"config": {
  "providers": { ... },
  "scopes": {
    "dev.ucp.shopping.catalog:search": { "auth_required": false },
    "dev.ucp.shopping.catalog:vip":    { "auth_required": true },
    "dev.ucp.shopping.order:read":     { "auth_required": true },
    "dev.ucp.shopping.order:manage":   { "auth_required": true, "min_acr": "..." }
  }
}

The benefit of the above pattern is that key is the exact scope that you can copy-pase into Oauth scope=. The tradeoff is that we lose grouping and future extensibility / ability to provide capability-wide config values.

@amithanda amithanda added the TC review Ready for TC review label Apr 17, 2026
…t for both delegated identity providers and non-OAuth authentication mechanisms through a unified `config.providers` extension point.
@amithanda
Copy link
Copy Markdown
Contributor Author

amithanda commented Apr 17, 2026

How about an alternate option where we can extend at the capability entry level,
keep scopes as array as is and add scope_config in a future PR?

The per-scope flexibility concern is valid — capability-level auth_required
can't express cases where some scopes require buyer auth and others don't, or
where specific scopes need step-up auth (min_acr). However, rather than
changing scopes from an array to a map, can we get the same expressiveness by
opening the capability entry itself for future extension.

The change

capability_identity_config currently has "additionalProperties": false.
Changing that to true and naming a reserved extension point in $comment
is all that's needed. The current format stays valid and unchanged:

"dev.ucp.shopping.order": {
  "auth_required": true,
  "scopes": ["read", "manage"]
}

A future PR adds scope_config — a map keyed by scope name for per-scope
overrides — without any schema version bump or wire-format change:

"dev.ucp.shopping.order": {
  "auth_required": true,
  "scopes": ["read", "manage"],
  "scope_config": {
    "manage": { "min_acr": "urn:tl:TL3" }
  }
}

The B2C case (public catalog search, buyer-gated VIP access):

"dev.ucp.shopping.catalog": {
  "auth_required": false,
  "scopes": ["search", "vip"],
  "scope_config": {
    "vip": { "auth_required": true }
  }
}

auth_required at the capability level acts as the default for all scopes.
scope_config provides per-scope overrides only where needed — step-up auth,
scope-level auth exceptions, or future properties we haven't defined yet.

Why this over changing scopes to a map

The current scopes array covers the common case cleanly — most capabilities
have uniform auth requirements across all their scopes, and the simple array
format is easy to implement and read. Rather than redesigning the shape now to
handle the advanced cases, this approach lets us ship the current format as-is
and add scope_config as a follow-on PR once we have real implementation
experience with where per-scope policies are actually needed.

The extension point costs nothing to reserve now (one schema flag change) and
keeps the door open for min_acr, scope-level auth_required overrides, or
any other per-scope property we identify later — without touching anything that
has already shipped.

Mental model for identity linking discovery is - does this capability require identity linking, but adding
auth_required at the scope level adds complications as platform will have to
understand the semantic meaning of the scope and it becomes a scope level discovery
problem. Rather, we can do a step up if someone wants to get additional scope. If there is
any scope that doesn't require identity linking, the capability level auth_required if false
but we can do a step up if the agent requires operations with scopes that need buyer to be
authenticated.

Schema change (minimal)

In capability_identity_config:

- "additionalProperties": false
+ "additionalProperties": true,
+ "$comment": "Reserved extension point: 'scope_config' (map keyed by scope
+   name for per-scope overrides of auth_required and future properties such
+   as min_acr for step-up auth). Platforms MUST ignore unrecognized fields
+   and apply the capability-level auth_required as the default."

This is the same two-line pattern used on config itself for providers.
No spec text changes needed in v1 beyond naming the extension point in
the schema comment and the Future Extensibility section.

@mnaga
Copy link
Copy Markdown

mnaga commented Apr 17, 2026

@igrigorik , while I agree with you about the use-cases such as B2C or B2B, I feel some of those need to be modeled at a higher level of granularity (hierarchical or flat structure). with B2C or B2B, it not just access to capabilities but there could be variations to data returned as part of existing operations. For example, the price returned could be different, and with the below suggestion, if the agent chooses to shop as B2B, then it would enforce authorization.

"dev.ucp.common.identity_linking": [{
  "config": {
    "providers": { ... },
    "capabilities": {
      "dev.ucp.shopping" : {
        "scopes": {
          "b2c": { "auth_required": false, default: true },
          "b2b":    { "auth_required": true }
        }
      }
      "dev.ucp.shopping.catalog": {
        "scopes": {
        }
      },
      "dev.ucp.shopping.order": {
        "scopes": {
          "read":   { "auth_required": true },
          "manage": { "auth_required": true, "min_acr": "urn:tl:TL3" }
        },
      }
    }
  }
}]

BTW, I am split on whether "dev.ucp.shopping" treated as a capability or something else, but this should give an idea of how i see individual capability authorization and uber authorization are kinda orthogonal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

TC review Ready for TC review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants