Skip to content

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

Merged
igrigorik merged 32 commits intoUniversal-Commerce-Protocol:mainfrom
amithanda:feat/identity-linking-oauth2-foundation
May 6, 2026
Merged

feat!: identity linking OAuth 2.0 foundation with capability-driven scopes#354
igrigorik merged 32 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
Copy link
Copy Markdown
Contributor Author

We should define scope groups for other core capabilities as well? Catalog, Cart and Order.

Added scopes for other capabilities. PTAL

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.

Hmm, UCP-Identity-Token doesn't feel right. Let's step back...

In standard OAuth, client authentication happens at the /token endpoint (via client_secret_basic, private_key_jwt, mTLS, etc), not via a Bearer header at API time. Authorization at API time uses the access token that carries client_id claim. The user-authorized token IS the platform's presented credential to the resource server -- resource servers validate client_id/azp against expected values.

All that to say, we don't need to stack a "platform Bearer" and a "user Bearer." The collision Maxime flagged only appears because we listed OAuth Bearer as one of the platform auth options at API time -- which, in retrospect, I think is a mistake. OAuth Bearer at API time is the user-authorized token; it's not a separate platform credential.

I think we can unwind and fix this in this PR...

  1. Drop OAuth Bearer from the platform auth options in cart-rest.md (line 540) and checkout-rest.md (line 1408). Platform identity at API time = HTTP Message Signatures (RFC 9421), mTLS, or API key.
  2. Authorization: Bearer <user_token> unambiguously carries the user-authorized access token. Add a forward reference from those auth sections to identity-linking.md for scope vocabulary and validation requirements.
  3. Drop UCP-Identity-Token

@maximenajim please sanity check!

Comment thread docs/specification/identity-linking.md Outdated
@amithanda
Copy link
Copy Markdown
Contributor Author

Thanks for the feedback. @igrigorik - I agree that we should avoid stacking a 'platform Bearer' and a 'user Bearer' and eliminate the need for a custom UCP-Identity-Token header.

However, rather than removing OAuth 2.0 completely from the list of options (which might imply UCP REST bindings don't support standard OAuth at all), I have updated the language in both cart-rest.md and checkout-rest.md to be more targeted based on OAuth 2.0 fundamentals:

OAuth 2.0: Via Authorization: Bearer {token} header. Identifies the platform for agent-authenticated access, or both platform and user for user-authenticated access (see Identity Linking).

Why this modified language is better than removing the option:

  1. Aligns with Standard OAuth2: Per RFC 9068, a user-authorized access token already carries claims (like client_id or azp) identifying the client application it was issued to. Therefore, the user token is the platform's credential at API time.
  2. Clarifies Both Access Levels: It explains that the same standard Authorization: Bearer header is used for both Agent-authenticated access (client credentials) and User-authenticated access (auth code flow).
  3. Prevents Collision: By clarifying that the token represents both parties in the user flow, it makes it clear that a separate platform credential is not sent at API time, directly solving the collision problem without falsely implying that OAuth is unsupported.

Let me know what you think?

igrigorik added 2 commits May 4, 2026 08:54
  The MUST on client_secret_basic excluded native, desktop, and on-device
  agent runtimes (RFC 8252 §8.5 — public clients cannot keep a client_secret)
  and also blocked stronger asymmetric methods (private_key_jwt RFC 7523,
  tls_client_auth RFC 8705). The IdP support we want to land next requires
  asymmetric crypto for JWT bearer assertions.

  Replaces the single-method MUST with RFC-8414-driven negotiation:
    - Confidential clients SHOULD prefer asymmetric methods; MAY use
      client_secret_basic.
    - Public clients (RFC 8252 §8.5) MUST use 'none' and rely on PKCE
      with S256 as proof-of-possession; MUST NOT embed a client_secret.
    - Businesses declare methods in token_endpoint_auth_methods_supported;
      SHOULD support an asymmetric method; MAY support 'none' for public
      clients. PKCE S256 required when 'none' is advertised.
    - Distinct error codes: invalid_client for auth-method failures,
      invalid_grant for PKCE failures.
  Per TC discussion default access is a merchant policy decision, not a
  spec mandate. UCP defines well-known scopes; merchants decide what auth
  is required for non-scoped operations.

    - Remove default-access framing from capability specs. Each section
      now states only the well-known scopes the capability defines.
    - Tighten scope descriptions to a consistent shape:
      "<operations gated> — <data or behavior unlocked>".
    - Hoist protocol-level rules (declaration, derivation, well-known vs
      custom extension) into identity-linking.md, where they live once.
      Capability specs link to that section instead of duplicating.
    - Switch the identity-linking.md B2B walkthrough from
      dev.ucp.shopping.checkout:create to :manage. The well-known scope
      fits the "no guest checkout" narrative more cleanly (gates all
      checkout ops, not just the entry point).
@igrigorik
Copy link
Copy Markdown
Contributor

Per the TC sync, we converged on a split of responsibilities between UCP and OAuth (RFC8414) for expressing auth requirements vs availability vs runtime hints. As a think-out-loud sanity-check exercise, this means...

  1. UCP config.scopes declare:
    • hard gates: scopes that require user auth for the operations they cover
    • optional description string on the scope that provides human-readable explanation of the required scope.
  2. OAuth scopes_supported metadata (RFC 8414) declare the accepted scope vocabulary:
    • i.e. list every scope the AS will honor if requested
  3. OAuth scopes_supported - UCP config.scopes is the optional layer: scopes offered but not gating operation.
  4. UCP messages[] can be used as runtime contextual hints: per-request advisories ("sign in for member pricing") with a well-known info code and description (e.g. "sign-in to unlock member-pricing")

Example walkthrough

Step 1: discover capabilities

Agent fetches the UCP profile:

GET https://merchant.example.com/.well-known/ucp
{
  "ucp": {
    "capabilities": {
      "dev.ucp.shopping.catalog.search":   [{ ... }],
      "dev.ucp.shopping.checkout":         [{ ... }],
      "dev.ucp.common.identity_linking":   [{
        "config": {
          "scopes": {
            "dev.ucp.shopping.checkout:manage": {}
          }
        }
      }]
    }
  }
}

Agent learns:

  • Catalog search and checkout are offered.
  • Checkout :manage is a required gate — guest cannot complete checkout.
  • Catalog has no required scope — guest search works.
  • Identity linking is supported.

Step 2: discover auth machinery

The agent fetches the OAuth Authorization Server metadata:

GET https://merchant.example.com/.well-known/oauth-authorization-server
{
  "issuer": "https://merchant.example.com",
  "authorization_endpoint": "...",
  "token_endpoint": "...",
  "jwks_uri": "...",
  "scopes_supported": [
    "dev.ucp.shopping.checkout:manage",
    "dev.ucp.shopping.catalog.search:read"
  ],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["private_key_jwt", "none"],
  ...
}

Agent learns:

  • Endpoints, JWKS, flow parameters, PKCE/client-auth requirements.
  • The full vocabulary of scopes the AS will honor: :manage and .search:read.

Step 3: reason about the auth landscape

required = config.scopes              = { checkout:manage }
accepted = scopes_supported           = { checkout:manage, catalog.search:read }
optional = accepted - required        = { catalog.search:read }

Three sets, distinct semantics:

  • Required: auth needed to access these operations.
  • Accepted: the AS honors these scopes if the agent decides to request them.
  • Optional (the diff): merchant offers but doesn't gate. The exact semantics of why and what benefits might be unlocked if the user signs in are dependent on the merchant and are left to the merchant to express -- e.g. by emitting a message on the response indicating benefits of sign-in.

Step N: runtime contextual hints

Static metadata answers "is auth available?" but not "is auth valuable in this request?" That's contextual to the merchant and their implementation (persisted carts and wishlists; loyalty tiers; saved address and prefs). The merchant signals this via an informational message in the operation response, using a well-known code:

{
  ...
  "messages": [
    {
      "type": "info",
      "code": "identity_optional",
      "content": "Sign in for member pricing and personalized results."
    }
  ]
}

The agent can present this as an opportunity to the user, and if accepted, it uses the defined machinery and dance of identity linking to negotiate appropriate scopes by introspecting the two discovery documents and relevant descriptions. As a reminder, info messages are non-blocking for related operations (can be ignored by agent).

p.s. identity_optional as counterpart to identity_required for gated operations.

@igrigorik
Copy link
Copy Markdown
Contributor

@amithanda separate from above, pushed two updates, please sanity check:

  • 4f20c58: support public clients via PKCE
  • a1ef95e: normalize capability scope sections

@amithanda
Copy link
Copy Markdown
Contributor Author

amithanda commented May 5, 2026

@igrigorik - thank you - both your updates look good. I added the change to add optional description for required scopes. Please feel free to approve, if everything looks good.

igrigorik added 4 commits May 5, 2026 10:02
  * `scope_policy.description`: $ref shared `description.json` type for
    multi-format text (plain/markdown/html). Cross-domain ref to
    shopping/types is the minimal change; promoting the type to
    common/types is a follow-up.

  * New `## Optional Authentication` section + `identity_optional`
    info-severity code. Decoupled from per-scope `description` by
    design: identity_optional is a runtime per-request notice;
    description is static per-scope context for OAuth consent.

  * `insufficient_scope` example fixed: response lists the FULL required
    scope set, not the delta (per Amit's TC restatement). Platform
    computes the diff and uses incremental authorization to avoid
    redundant consent prompts. continue_url wording aligned.

  * Schema descriptions: drop "public or agent-authenticated access"
    framing. TC consensus is that UCP does not prescribe a default;
    merchants decide access policy for non-scoped operations.

  * `message_info.json` `code`: register known info codes via JSON
    Schema `examples` array AND inline names in the description.
    Tooling (autocomplete, codegen) and humans both served.
From the boyscout 'leave it better than you found it' rulebook...

 - Copy _error pattern as standalone file for _info and _warning
 - Include info and warning codes in reference docs
Replace the pre-baked OAuth `continue_url` pattern with RFC 6750 §3
WWW-Authenticate challenges, plus RFC 9728 Protected Resource Metadata
pointers. Resolves David's TC flag.

Why: pre-baking an authorization URL forces the merchant to own
parameters it can't sensibly own — PKCE code_challenge, state,
redirect_uri, client_id are all client-side concerns. Native/agent
clients per RFC 8252 construct their own authorization request anyway,
so the pre-baked URL was either ignored or rewritten. Standard OAuth
client libraries parse WWW-Authenticate Bearer challenges automatically;
custom continue_url parsing was UCP-specific dead weight.

* For Platforms: MUST process WWW-Authenticate Bearer challenges per
  RFC 6750 §3 on 401/403; extract scope parameter; SHOULD follow
  resource_metadata pointer per RFC 9728. Bumped Bearer Authorization
  bullet with RFC 6750 §2.1 reference.
* For Businesses: MUST emit Bearer challenge on identity_required
  (401) and insufficient_scope (403). RFC 9728 SHOULD bullet upgraded
  to reference /.well-known/oauth-protected-resource and integration
  with WWW-Authenticate.
* identity_required: full normative restructure (status code, header,
  body). realm MUST be issuer URI; error="invalid_token" when token
  present-but-bad; error SHOULD be omitted when no token (RFC 6750
  §3.1). resource_metadata SHOULD. continue_url retained for non-OAuth
  onboarding flows ONLY; explicit MUST NOT for pre-baked OAuth URLs.
* insufficient_scope: full normative restructure. realm + error +
  scope (full required set, not delta) MUST. resource_metadata SHOULD.
  Pre-baked OAuth continue_url removed entirely.
* Security Considerations: new "Authentication challenges" bullet.
  Platforms MUST drive flow from structured scope/error params;
  error_description is hint-only and MUST NOT control flow. realm MUST
  match issuer URI for cross-protection-space correlation.
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.

FYI, stacked a few more changes to document identity_optional and adopt WWW-Authenticate. Please sanity check, but otherwise I believe this is now good to land.

  Document the architectural split between UCP and OAuth (RFC 8414)
  responsibilities that the rest of the spec relies on but never
  articulates. Anchors the four moving parts:

  * UCP config.scopes — hard gates (required auth)
  * OAuth scopes_supported — accepted scope vocabulary
  * Diff (scopes_supported ∖ config.scopes) — optional layer
  * UCP messages[] — runtime contextual hints (e.g., identity_optional)
Comment thread docs/specification/identity-linking.md Outdated
…Authenticate Bearer changes:

1. Add no-token identity_required example

The existing example only showed the token-present-but-expired case (error="invalid_token"). RFC 6750 §3.1 says error SHOULD be omitted when no token was presented — the more common case for a first request to a gated operation. Added a second labeled example for the no-token case so implementers aren't led to emit error="invalid_token" unconditionally.

2. Fix identity_optional section direction

The section intro described identity_optional as "a mechanism for the platform to inform the buyer." The direction was reversed — the business emits this code in its response; the platform receives it and may present it to the user. Corrected to accurately describe the emitter and receiver.

3. Simplify identity_optional to remove misleading description coupling

The section stated that per-scope description fields convey context for optional authentication, and that businesses SHOULD populate them when emitting identity_optional. This is incorrect: description is a field on scope_policy objects in config.scopes, which is the hard-gate (required) layer. Scopes relevant to identity_optional are by definition in the optional layer (scopes_supported ∖ config.scopes) and have no corresponding UCP schema field for descriptions. Removed the two-mechanisms paragraph and the SHOULD guidance on populating descriptions, as both described a mechanism that doesn't exist for optional-layer scopes. Upgraded identity_optional emission from MAY to SHOULD, since content on the message is the only available value prompt mechanism.
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.

👍

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.

10 participants