diff --git a/api-reference/oauth-openapi.json b/api-reference/oauth-openapi.json index c318e6a8..3f45230f 100644 --- a/api-reference/oauth-openapi.json +++ b/api-reference/oauth-openapi.json @@ -165,7 +165,7 @@ "summary": "Authorization Server Metadata (RFC 8414)", "description": "Advertises the authorization, token, and device_authorization endpoints, the supported grants (including urn:ietf:params:oauth:grant-type:device_code), and the JWKS URI. scopes_supported is deliberately absent (#2397).", "responses": { - "200": { "description": "RFC 8414 metadata", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true, "example": { "issuer": "https://api.sandbox.nevermined.app", "authorization_endpoint": "https://api.sandbox.nevermined.app/oauth/authorize", "token_endpoint": "https://api.sandbox.nevermined.app/oauth/token", "device_authorization_endpoint": "https://api.sandbox.nevermined.app/oauth/device_authorization", "jwks_uri": "https://api.sandbox.nevermined.app/.well-known/jwks.json", "grant_types_supported": ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"], "code_challenge_methods_supported": ["S256"] } } } } } + "200": { "description": "RFC 8414 metadata", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true, "example": { "issuer": "https://api.sandbox.nevermined.app", "authorization_endpoint": "https://nevermined.app/oauth/authorize?network=sandbox", "token_endpoint": "https://api.sandbox.nevermined.app/oauth/token", "device_authorization_endpoint": "https://api.sandbox.nevermined.app/oauth/device_authorization", "jwks_uri": "https://api.sandbox.nevermined.app/.well-known/jwks.json", "grant_types_supported": ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"], "code_challenge_methods_supported": ["S256"] } } } } } } } }, @@ -270,8 +270,8 @@ "properties": { "device_code": { "type": "string", "description": "Machine secret the agent polls with (opaque)." }, "user_code": { "type": "string", "example": "BCDF-GHJK" }, - "verification_uri": { "type": "string", "example": "https://nevermined.app/oauth/device" }, - "verification_uri_complete": { "type": "string", "example": "https://nevermined.app/oauth/device?user_code=BCDF-GHJK" }, + "verification_uri": { "type": "string", "example": "https://nevermined.app/oauth/device?network=sandbox", "description": "Where the human approves. Carries the API tier (`network=sandbox|live`) — hand it over unchanged." }, + "verification_uri_complete": { "type": "string", "example": "https://nevermined.app/oauth/device?network=sandbox&user_code=BCDF-GHJK", "description": "RFC 8628 §3.2: verification_uri with the user_code pre-filled (one query string)." }, "expires_in": { "type": "integer", "example": 600 }, "interval": { "type": "integer", "example": 5, "description": "Minimum seconds between token polls." } } diff --git a/integrate/authentication/device-flow.mdx b/integrate/authentication/device-flow.mdx index fd9bf523..446f53b8 100644 --- a/integrate/authentication/device-flow.mdx +++ b/integrate/authentication/device-flow.mdx @@ -37,14 +37,14 @@ This is the "claim ceremony" an agent can use on its own. Both the `device_autho { "device_code": "a1b2c3…", "user_code": "BCDF-GHJK", - "verification_uri": "https://nevermined.app/oauth/device", - "verification_uri_complete": "https://nevermined.app/oauth/device?user_code=BCDF-GHJK", + "verification_uri": "https://nevermined.app/oauth/device?network=sandbox", + "verification_uri_complete": "https://nevermined.app/oauth/device?network=sandbox&user_code=BCDF-GHJK", "expires_in": 600, "interval": 5 } ``` - `device_code` is your machine secret (opaque — poll with it). `user_code` is what the human types. `interval` is the minimum seconds between polls. + `device_code` is your machine secret (opaque — poll with it). `user_code` is what the human types. `interval` is the minimum seconds between polls. Both verification URIs carry the tier you called (`?network=sandbox|live`) — pass them on unchanged, so the human's browser opens the verification page on the tier your ceremony is pending on ([why](/integrate/authentication/overview#each-tier-is-its-own-issuer-and-its-authorize-url-says-so)). diff --git a/integrate/authentication/for-agents.mdx b/integrate/authentication/for-agents.mdx index c85568bb..ac13b578 100644 --- a/integrate/authentication/for-agents.mdx +++ b/integrate/authentication/for-agents.mdx @@ -30,7 +30,7 @@ Everything the ceremony needs is advertised in standards-based metadata (see [Di | Protected Resource Metadata | `/.well-known/oauth-protected-resource` | RFC 9728 | | JWKS (ES256K / secp256k1) | `/.well-known/jwks.json` | RFC 7517 (ES256K curve: RFC 8812) | -Resolve these against the environment you're targeting — `https://api.sandbox.nevermined.app` (test) or `https://api.live.nevermined.app` (real money). +Resolve these against the environment you're targeting — `https://api.sandbox.nevermined.app` (test) or `https://api.live.nevermined.app` (real money). The human-facing URLs each document advertises — the `authorization_endpoint`, and the `verification_uri` the device flow returns — carry that tier as `?network=sandbox|live`. Hand them to the human as-is and keep the query string when you add parameters of your own; the consent screen reads it to open on the right tier ([why](/integrate/authentication/overview#each-tier-is-its-own-issuer-and-its-authorize-url-says-so)). ## The short version diff --git a/integrate/authentication/oauth-authorization-code.mdx b/integrate/authentication/oauth-authorization-code.mdx index 510004b0..e0fbb805 100644 --- a/integrate/authentication/oauth-authorization-code.mdx +++ b/integrate/authentication/oauth-authorization-code.mdx @@ -20,6 +20,7 @@ Use the **OAuth 2.1 Authorization Code flow with PKCE** when a browser-based MCP - **An authenticated user.** `POST /oauth/authorize` runs in the signed-in user's context — the browser client calls it with the user's Nevermined API key. Without an authenticated user it fails with `BCK.OAUTH.0007`. - **A pre-registered `client_id`.** Same as the device flow — connectors are onboarded out of band (no dynamic client registration). An unregistered `client_id` is rejected with `BCK.OAUTH.0016`. - **PKCE is mandatory.** Generate a `code_verifier` and its `code_challenge` (`S256`). There is no non-PKCE path. +- **The right tier's authorize URL.** The browser step starts at the `authorization_endpoint` of the tier you are integrating with — `https://nevermined.app/oauth/authorize?network=sandbox` or `…?network=live` — as advertised by that tier's [RFC 8414 document](/integrate/authentication/overview#each-tier-is-its-own-issuer-and-its-authorize-url-says-so). This is the **web app's consent page**, the URL a connector sends the user's browser to with `client_id`, `redirect_uri`, `code_challenge` and the rest in its **query string** — keep the `network` parameter when you add them; it is what makes the consent screen open on the tier your `client_id` is registered on. It is not the API's `POST /oauth/authorize` in Step 1 below, which the signed-in browser client calls on the API host with those same fields in a **JSON body** (and no `network` — by then the tier is fixed). The consent URL is the only one of the two an integrator ever configures by hand; if you do, paste it with its query string. ## The flow @@ -91,5 +92,5 @@ Use the **OAuth 2.1 Authorization Code flow with PKCE** when a browser-based MCP The connection the user approves is recorded as an **AgentBinding** — the consent receipt. The `client_id` is the *connector* fronting the connection (Cursor, Claude, LangSmith Fleet), which is distinct from the *agent* the consent is for. Users can review and revoke these at any time from the [Connected agents](/integrate/authentication/connections) surface. -Both the `authorization_endpoint` and the `authorization_code` grant are advertised in the [RFC 8414 metadata](/integrate/authentication/overview#discovery). As with the device flow, discovery doesn't substitute for a pre-registered `client_id`. +Both the `authorization_endpoint` and the `authorization_code` grant are advertised in the [RFC 8414 metadata](/integrate/authentication/overview#discovery) — per tier, with the tier on the endpoint (`?network=sandbox|live`). As with the device flow, discovery doesn't substitute for a pre-registered `client_id`; and a `client_id` is registered per tier, so a connector known to Sandbox is "not authorized" on Live and vice versa. diff --git a/integrate/authentication/overview.mdx b/integrate/authentication/overview.mdx index 7288a767..8c517da0 100644 --- a/integrate/authentication/overview.mdx +++ b/integrate/authentication/overview.mdx @@ -82,6 +82,20 @@ Everything the ceremony needs is advertised in standards-based metadata. If a ca Because a transport now mints a credential this API accepts, discovery reflects it: the Protected Resource Metadata names this issuer in `authorization_servers`, and a genuine authentication `401` carries an RFC 6750 `WWW-Authenticate: Bearer resource_metadata="…"` challenge pointing at it. +### Each tier is its own issuer, and its authorize URL says so + +Sandbox and Live are separate authorization servers with separate discovery documents (`https://api.sandbox.nevermined.app/.well-known/oauth-authorization-server` and `https://api.live.nevermined.app/…`), but both send the human to the same web app for consent. That app cannot tell from a bare `/oauth/authorize` link which issuer it is acting for, so **the human-facing endpoints carry the tier as a query parameter**: + +```json +{ + "issuer": "https://api.sandbox.nevermined.app", + "authorization_endpoint": "https://nevermined.app/oauth/authorize?network=sandbox", + "token_endpoint": "https://api.sandbox.nevermined.app/oauth/token" +} +``` + +`network` is `sandbox` or `live` — the same parameter the embeddable widgets take, and distinct from the x402 request body's `network` field, which names the settlement rail (`eip155:`, or `stripe` / `braintree` / `visa` — [details](/development-guide/nevermined-x402#paymentrequired-response-402)), never a tier. The device flow's `verification_uri` carries it too. **Keep the query string** when you append your own parameters (RFC 6749 §3.1 requires it; standard clients do this already — one that appends with a second `?` is rejected outright with an error naming the mistake, rather than silently landing on the wrong tier). A discovery-driven client gets the right value for free; if you configure an OAuth provider **by hand** — a connector's "authorization URL" form field, say — copy the endpoint from the tier's discovery document, query string included. **The endpoint must end up as `…/oauth/authorize?network=sandbox` or `…?network=live`; if the document you fetch carries no `network`, append it yourself.** An authorize link without `network` is served on whichever tier the user's browser last used (Live by default), and a connector registered only on Sandbox is then refused as *"Connector not authorized"* until the user switches manually. + ### Reading a 401 A `401` is *usually* a missing, malformed, or invalid key — not a hint to go discover something. It isn't *always* an authentication problem, though: a few endpoints return `401` when you're authenticated but lack access to a specific resource. Distinguish by the error body's `category` field: diff --git a/integrations/mcp.mdx b/integrations/mcp.mdx index 4fbbcc71..e2c1b618 100644 --- a/integrations/mcp.mdx +++ b/integrations/mcp.mdx @@ -624,6 +624,12 @@ When a client connects to your MCP server: **You don't need to handle any of this manually** - `payments.mcp.start()` configures everything automatically! + +**The consent screen is per API tier.** The human approves at `https://nevermined.app/oauth/authorize`, and the Nevermined API advertises that endpoint **with the tier on it** — `?network=sandbox` from `api.sandbox.nevermined.app`, `?network=live` from `api.live.nevermined.app` — because the web app otherwise opens on whichever tier the user's browser last used (Live by default). If you wire an MCP client or an OAuth provider to Nevermined **by hand** instead of through discovery, use the tier-qualified endpoint from the tier's [`/.well-known/oauth-authorization-server`](/integrate/authentication/overview#each-tier-is-its-own-issuer-and-its-authorize-url-says-so), query string included — it must end up as `…/oauth/authorize?network=sandbox` or `…?network=live`; if the document you fetch carries no `network`, append it yourself. + +The discovery document your **own** MCP server serves (step 1 above) comes from the SDK, not from the API — and until [payments#447](https://github.com/nevermined-io/payments/issues/447) / [payments-py#277](https://github.com/nevermined-io/payments-py/issues/277) ship, it still advertises a tier-blind `authorization_endpoint`. Until then, override **just that field** with the API tier's endpoint above and keep the rest of your server's document — its `token_endpoint` already names the right tier, and its `registration_endpoint` (`/register`, step 2 above) exists only there. + + **For production servers**, replace `http://localhost:3000` with your public domain (e.g., `https://weather-mcp.yourdomain.com`).