Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions api-reference/oauth-openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"] } } } } }
}
}
},
Expand Down Expand Up @@ -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." }
}
Expand Down
6 changes: 3 additions & 3 deletions integrate/authentication/device-flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
r-marques marked this conversation as resolved.
"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)).
</Step>

<Step title="Send the human to approve">
Expand Down
2 changes: 1 addition & 1 deletion integrate/authentication/for-agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion integrate/authentication/oauth-authorization-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

<Note>
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.
</Note>
14 changes: 14 additions & 0 deletions integrate/authentication/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Comment thread
r-marques marked this conversation as resolved.

```json
{
"issuer": "https://api.sandbox.nevermined.app",
"authorization_endpoint": "https://nevermined.app/oauth/authorize?network=sandbox",
Comment thread
r-marques marked this conversation as resolved.
"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:<chainId>`, 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:
Expand Down
6 changes: 6 additions & 0 deletions integrations/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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!

<Note>
**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.
</Note>

<Note>
**For production servers**, replace `http://localhost:3000` with your public domain (e.g., `https://weather-mcp.yourdomain.com`).
</Note>
Expand Down