OAuth 2.0
Delegated access with OAuth 2.0, from registering a client to running the authorization-code flow with PKCE and holding a grant the user can revoke.
OAuth 2.0 is how a third-party app or an agent acts on a Pear user's behalf without ever holding that user's credentials. The user signs in and approves access on a Pear-hosted page. Your app receives an access token bound to a grant, and the user can cut that grant off at any time.
This is the transport the Orchard MCP uses to connect agents.
When to use it
Pick the transport that matches whose account you act on:
| Transport | Whose account | Whose credentials | Use it for |
|---|---|---|---|
| API key | Yours | Yours | Your own server, your own bot. No end user. |
| Session login | Your user's | Your user's, held by you | An app where your users sign in to Pear through you. |
| OAuth 2.0 | Someone else's | Never yours | A client, agent, or integration other people connect to their own Pear account. |
Use OAuth when the person granting access is not you and does not sign in through you. If you are writing a bot that trades your own account, an API key is simpler and needs no browser. See Authentication for both alternatives.
The flow is the standard authorization code grant with PKCE. Two facts shape every integration:
- PKCE with
S256is mandatory. An authorize request without a code challenge is refused. - Clients are public. There is no client secret.
token_endpoint_auth_methods_supportedisnone, and nothing at the token endpoint proves who you are except the PKCE verifier.
The protocol endpoints
None of these five appear in the API Specification. The generated reference is built from the annotated gateway surface, and the OAuth protocol endpoints are excluded from it deliberately: the discovery document is the contract, and a client is expected to read the endpoints from there at runtime rather than from a page. This page documents them by hand.
All five sit on the same host as the rest of the gateway:
https://pro-gateway.pearprotocol.io| Endpoint | Purpose | Auth |
|---|---|---|
GET /.well-known/oauth-authorization-server | Discovery. | None |
POST /register | Dynamic client registration (RFC 7591). | None |
GET /authorize | Starts a flow. Redirects the browser to login and consent. | None |
POST /token | Exchanges a code for tokens, and refreshes them. | None |
POST /revoke | Revokes one grant, by its refresh token (RFC 7009). | The token itself |
None of them takes a session, an API key, or a bearer token.
Discovery
curl "https://pro-gateway.pearprotocol.io/.well-known/oauth-authorization-server"The document is static and advertises exactly ten fields:
{
"issuer": "https://pro-gateway.pearprotocol.io",
"authorization_endpoint": "https://pro-gateway.pearprotocol.io/authorize",
"token_endpoint": "https://pro-gateway.pearprotocol.io/token",
"registration_endpoint": "https://pro-gateway.pearprotocol.io/register",
"revocation_endpoint": "https://pro-gateway.pearprotocol.io/revoke",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"scopes_supported": ["mcp:read", "mcp:write"]
}| Field | What it tells you |
|---|---|
issuer | The authorization server's identity. Every other URL is built from it, so all five endpoints share this origin. |
authorization_endpoint | Where to send the browser. |
token_endpoint | Where to exchange a code and where to refresh. |
registration_endpoint | Where to register a client. Its presence is what tells you dynamic registration is open. |
revocation_endpoint | Where a client hands its own access back. |
response_types_supported | code only. There is no implicit flow and no hybrid flow. |
grant_types_supported | authorization_code and refresh_token only. No client credentials, no password grant, no device code. |
code_challenge_methods_supported | S256 only. plain is refused. |
token_endpoint_auth_methods_supported | none. Clients are public; there is no secret to send. |
scopes_supported | The scopes this deployment recognizes. See Scopes. |
Read the endpoints from this document rather than hardcoding them. There is no introspection_endpoint: access tokens are signed JWTs that each resource server verifies locally.
Register a client
Registration is open and unauthenticated. Anyone can register a client, so registration proves nothing on its own; the user's consent is what grants access.
curl -X POST "https://pro-gateway.pearprotocol.io/register" \
-H "content-type: application/json" \
-d '{
"redirect_uris": ["https://client.example.com/oauth/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"client_name": "Example Agent"
}'The fields are snake_case, because RFC 7591 is the wire contract here rather than Pear's usual convention:
| Field | Rule |
|---|---|
redirect_uris | Required. At least one. Each must be https, or http only for a loopback host: localhost, 127.0.0.1, or ::1. Anything else is a 400. |
token_endpoint_auth_method | none is the only accepted value, and the default. |
grant_types | Any subset of authorization_code and refresh_token. Defaults to both. A client that omits refresh_token here cannot refresh later. |
response_types | ["code"], and the default. |
client_name | Optional. The name the user sees on the consent screen and in their connected-agents list. Omit it and both fall back to the client id. |
The response is the registered client:
{
"client_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"redirect_uris": ["https://client.example.com/oauth/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"client_name": "Example Agent",
"client_id_issued_at": 1767225600
}client_id_issued_at is a Unix timestamp in seconds. There is no client_secret, and no client_secret_expires_at.
A registration cannot be read, changed, or deleted afterwards. No registration access token is issued and no management endpoints exist. To change a redirect URI, register a new client.
redirect_uris are matched as exact strings when a flow runs. A URI that differs from the registered one by a port, a trailing slash, or a query string is a different URI and is refused. Register every callback you will use.
Authorize
Send the browser to /authorize with the query parameters below. This is a top-level navigation, not a fetch.
| Parameter | Required | Rule |
|---|---|---|
response_type | Yes | code. |
client_id | Yes | From registration. |
redirect_uri | Yes | An exact match of one registered URI. |
code_challenge | Yes | Base64url SHA-256 of your verifier. |
code_challenge_method | Yes | S256. Omitting it means plain, which is refused. |
scope | No | Space-delimited. Omitted means no scope, which yields a grant that cannot write. |
state | No | Opaque. Echoed back on the callback. Send one and check it. |
On success the response is a 302 to a Pear-hosted login and consent page. Your app plays no part until the user finishes there.
On failure /authorize answers with JSON, and never redirects to your callback. RFC 6749 allows an authorization server to report some failures by redirecting to the client with error parameters. Pear does not, because the failure does not say which validation stage threw, and redirecting on the wrong one would send a code-flow error to a URI that was never properly validated. The trade is deliberate:
{
"status": 400,
"message": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed: The authorization server requires public clients to use PKCE RFC-7636",
"error": "invalid_request",
"error_description": "The authorization server requires public clients to use PKCE RFC-7636"
}Every OAuth failure on /authorize and /token uses this shape. Read error for the machine-readable code and error_description for the specific cause; message is the two joined.
Two consequences for your client:
- A broken authorize request strands the user on a JSON page. They never return to your callback, and your
statenever comes back. Time out the pending authorization transaction on your side; do not wait for a callback that will not arrive. - The only error your callback ever receives is a denial. If the user presses cancel, the consent step redirects to your
redirect_uriwitherror=access_denied. Every other failure is JSON on Pear's origin.
| Failure | Status | error |
|---|---|---|
Unknown client_id | 401 | invalid_client |
redirect_uri not registered, or not an exact match | 401 | invalid_client |
code_challenge missing | 400 | invalid_request |
code_challenge_method not S256 | 400 | invalid_request |
A scope outside scopes_supported | 400 | invalid_scope |
Token
POST /token serves both grant types. Send the parameters as application/x-www-form-urlencoded.
Authorization code:
| Parameter | Rule |
|---|---|
grant_type | authorization_code. |
code | From the callback. Single use, and valid for 60 seconds. |
redirect_uri | The same value you sent to /authorize. |
client_id | Your client id. There is no secret. |
code_verifier | The verifier whose challenge started the flow. 43 to 128 characters from A–Z a–z 0–9 - . _ ~. |
Refresh:
| Parameter | Rule |
|---|---|
grant_type | refresh_token. |
refresh_token | The current refresh token. |
client_id | The client the grant was issued to. |
scope | Optional. May only narrow the granted set; a scope outside it is refused. Omit it to keep what was granted. |
Both return the same body:
{
"token_type": "Bearer",
"expires_in": 900,
"access_token": "eyJhbGciOi…",
"refresh_token": "…",
"scope": "mcp:read mcp:write"
}The access token is an ordinary Pear access token. Send it as Authorization: Bearer <access-token> on gateway requests, exactly like a session token, together with x-trade-account-id on account-scoped routes.
expires_in is seconds and is deployment configuration. Read it; do not assume a number.
Refreshing rotates the refresh token in place. The old one stops working the moment the call succeeds, and the new one carries a fresh expiry. The grant keeps its identity across the whole chain, so the id the user sees in their connected-agents list never changes and one revocation kills every token the chain ever issued.
| Failure | Status | error | error_description |
|---|---|---|---|
Unknown client_id | 401 | invalid_client | — |
code unknown, expired, or already redeemed | 400 | invalid_grant | The authorization code is invalid or has expired |
| Two requests race for one code | 400 | invalid_grant | The authorization code has already been redeemed |
code_verifier does not match the challenge | 400 | invalid_grant | Failed to verify code challenge. |
code_verifier outside the RFC 7636 character set or length | 400 | invalid_request | Code verifier must follow the specifications of RFC-7636 |
redirect_uri different from the authorize one | 400 | invalid_request | Invalid redirect URI |
redirect_uri missing | 400 | invalid_request | Check the `redirect_uri` parameter |
refresh_token unknown, or already rotated away | 400 | invalid_grant | The refresh token is invalid or has expired |
refresh_token presented by a different client | 400 | invalid_request | Token is not linked to client |
| The grant's refresh window has closed | 400 | invalid_request | Token has expired |
| The grant was revoked | 400 | invalid_request | Token has been revoked |
A wrong verifier does not burn the code: the check runs before the code is claimed, so a client that retries with the right verifier inside the 60-second window still succeeds.
Revoke
POST /revoke is how a client hands its own access back — on disconnect, on uninstall, or when a user removes the integration from your side.
curl -X POST "https://pro-gateway.pearprotocol.io/revoke" \
-H "content-type: application/json" \
-d '{ "token": "<the refresh token>" }'| Field | Rule |
|---|---|
token | Required. The refresh token. |
token_type_hint | Optional, and ignored. The token is always resolved as a refresh token. |
It returns 200 with an empty body.
Only a refresh token revokes anything. The endpoint resolves the presented value against stored refresh tokens and nothing else. Send an access token and you get the same 200, having revoked nothing. RFC 7009 makes an unknown token a success, so the status code cannot tell you which happened.
A successful revoke kills the whole grant at once: the refresh token can never mint again, and the current access token is refused on its next request rather than lingering until it expires.
A worked flow, end to end
This is the whole authorization-code-with-PKCE flow, once, with real values.
1. Mint a verifier and derive its challenge. Keep the verifier server-side, or in memory, alongside the state for this one attempt.
const bytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = base64url(bytes); // 43 chars
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(digest)); // 43 chars32 random bytes encode to 43 base64url characters, which satisfies the 43-to-128 rule. Hash the ASCII text of the verifier, not the raw bytes it was encoded from.
For the rest of this walkthrough:
verifier = M2Rk8vQ1yZ7pLxNfA0bC5tHwJ9sYnE3uKoPrTgVdWiU
challenge = Oe2KG8_VRFJ90ROiM0MHMOzNfOlBkzX6te9dyL1-6BE2. Send the browser to /authorize. URL-encode the scope string; : and the separating space both need it.
https://pro-gateway.pearprotocol.io/authorize
?response_type=code
&client_id=3fa85f64-5717-4562-b3fc-2c963f66afa6
&redirect_uri=https%3A%2F%2Fclient.example.com%2Foauth%2Fcallback
&scope=mcp%3Aread%20mcp%3Awrite
&state=8f14e45fceea167a
&code_challenge=Oe2KG8_VRFJ90ROiM0MHMOzNfOlBkzX6te9dyL1-6BE
&code_challenge_method=S2563. The user signs in and approves. Pear serves this. Your app is not involved, and never sees the wallet signature or the password.
4. Your callback receives the code.
https://client.example.com/oauth/callback?code=eXaMpLe…&state=8f14e45fceea167aCompare state against the value you stored. Stop if they differ. The code is valid for 60 seconds, so exchange it immediately.
If the user pressed cancel instead:
https://client.example.com/oauth/callback?error=access_denied&error_description=User+denied+the+authorization+request&state=8f14e45fceea167a5. Exchange the code.
curl -X POST "https://pro-gateway.pearprotocol.io/token" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=eXaMpLe…" \
--data-urlencode "redirect_uri=https://client.example.com/oauth/callback" \
--data-urlencode "client_id=3fa85f64-5717-4562-b3fc-2c963f66afa6" \
--data-urlencode "code_verifier=M2Rk8vQ1yZ7pLxNfA0bC5tHwJ9sYnE3uKoPrTgVdWiU"{
"token_type": "Bearer",
"expires_in": 900,
"access_token": "eyJhbGciOi…",
"refresh_token": "…",
"scope": "mcp:read mcp:write"
}6. Call the gateway.
curl "https://pro-gateway.pearprotocol.io/positions" \
-H "Authorization: Bearer eyJhbGciOi…" \
-H "x-trade-account-id: $TRADE_ACCOUNT_ID"Store the refresh token as a secret. It is the whole grant: anyone holding it can mint access tokens until the user revokes.
Hosting the consent screen
Skip this section if you are writing a client. A client sends the browser to /authorize and waits for its callback. Everything below is the API behind the login-and-consent page, and it exists so that page can be built. Pear hosts one already.
/authorize stores the validated request under an opaque uid and redirects to /interaction/{uid} on the consent page. The uid lives for 10 minutes and is what the four endpoints below act on.
| Endpoint | SDK | Purpose |
|---|---|---|
GET /interaction/{uid} | sdk.auth.getInteractionDetails | Read what the page must render. |
POST /interaction/{uid}/login | sdk.auth.submitInteractionLogin | Sign the user in and bind them to this interaction. |
POST /interaction/{uid}/confirm | sdk.auth.confirmInteraction | Approve, and mint the code. |
POST /interaction/{uid}/abort | sdk.auth.abortInteraction | Deny. |
Reading the interaction
{
"uid": "Xr9tKm2QpLvNbC4hFwYs3A",
"prompt": { "name": "login", "details": {} },
"params": {
"client_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"redirect_uri": "https://client.example.com/oauth/callback",
"state": "8f14e45fceea167a",
"scope": "mcp:read mcp:write"
},
"client": {
"clientId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"clientName": "Example Agent",
"redirectUris": ["https://client.example.com/oauth/callback"]
}
}prompt.name drives the page: login before anyone has signed in, consent after. params.scope is what the client asked for, and is what the consent screen offers the user.
GET is readable without a session while prompt.name is login — the page has to render the sign-in step before a session can exist. The moment a login binds an account, the interaction belongs to that account and only its own first-party session may read it. A leaked uid therefore cannot observe someone else's signed-in interaction. A uid that expired or was already finished is a 404 Interaction not found or expired.
Login, confirm, abort
POST /interaction/{uid}/login takes the same body as POST /auth/login — method: "wallet" with a signed nonce, or method: "email" with a password — and verifies the credential for real. The account comes from that verification and never from the request, so this step cannot bind an arbitrary account to the interaction.
It does not return a token pair. A normal login hands back accessToken and refreshToken; this one returns returnTo and nothing else, and delivers the session as Set-Cookie. Send the following requests with credentials so the cookie rides along.
confirm and abort both require the first-party session that logged in. Two checks run:
- The request must carry a first-party access token — in practice the session cookie the login step set, though a bearer token works. A missing or invalid one is a
401. An OAuth grant token is a403— see What a grant cannot do. - The signed-in account must be the account bound to this interaction, or the answer is
403 Session does not match this interaction.
So a leaked uid cannot mint a code for a victim's account, and cannot cancel a victim's in-flight authorization either.
confirm takes the scopes the user actually ticked:
{ "scopes": ["mcp:read"] }The granted set can only narrow what the client requested. Submitted scopes are intersected with the requested ones, so a consent screen can never widen a grant beyond the authorize request, and an empty or fully mismatched array yields a grant with no scopes at all rather than a wide one.
All three return { returnTo } as JSON, not a 303.
{ "returnTo": "https://client.example.com/oauth/callback?code=eXaMpLe…&state=8f14e45fceea167a" }The reason is the browser, not the protocol: a cross-origin fetch cannot read the Location header of a redirect it receives, so a consent page on a different origin from the gateway would get an opaque response it can do nothing with. Returning the URL in the body lets the page perform the top-level navigation itself. Set window.location to returnTo and do not try to follow the response.
Each step returns a different target:
| Step | returnTo points at |
|---|---|
login | The same interaction page. Re-read the interaction; prompt.name is now consent. |
confirm | Your redirect_uri, carrying code and state. |
abort | Your redirect_uri, carrying error=access_denied, error_description, and state. |
| Failure | Status | Message |
|---|---|---|
confirm before any login | 400 | Interaction is not awaiting consent |
| A different account confirms or aborts | 403 | Session does not match this interaction |
The uid expired or was already finished | 404 | Interaction not found or expired |
| The user is at the grant cap | 403 | You have reached the maximum of 10 connected agents. Revoke one before connecting another. |
Scopes
Two scopes exist, and a client normally asks for both:
| Scope | What it means at the gateway |
|---|---|
mcp:read | The read half of a grant. |
mcp:write | Required for every mutating request. |
The authorization server treats scopes as opaque strings. It validates a requested scope against the recognized list and assigns it no meaning. Each resource server interprets them, so what a scope grants depends on what you are calling: the gateway and the Orchard MCP apply the same two names to their own surfaces.
At the gateway the rule is one line: GET, HEAD, and OPTIONS pass for any grant; every other method needs mcp:write, or the request is 403 OAuth grant does not have write access. This is the same boundary a read API key sits behind.
Consent decides the set once, and it never grows. A refresh re-mints exactly the scopes persisted on the grant, so a grant that was consented read-only stays read-only for its whole life. To widen it, the user runs the flow again and approves the wider set.
Limits
A user may hold 10 active grants. Active means not revoked and not expired. The cap is checked at consent, so the eleventh approval is refused with a 403 and no code is issued. The user frees a slot by revoking a connected agent.
A grant expires when its refresh window closes. That window slides forward on every refresh, so a client that refreshes stays connected and one that goes quiet eventually drops off the user's list on its own.
What a grant cannot do
An OAuth grant token is not a session. Three places refuse it outright:
| Surface | What happens |
|---|---|
Account and session routes under /auth | 403 OAuth grant tokens cannot access first-party account endpoints |
GET and DELETE /oauth2/grants | The same 403 |
| The realtime WebSocket | The handshake succeeds but the connection stays anonymous. Public channels work; the private user-event stream is never delivered. |
The second one is the important one. An agent cannot list or revoke its own grant, because grant management refuses grant tokens by design. Only the user, from a first-party sign-in, can revoke. An agent that wants to hand its own access back uses POST /revoke with its refresh token instead.
One capability points the other way, and is deliberate:
A grant with mcp:write can read decrypted venue credentials. POST /trade-accounts/{id}/credentials returns the connected exchange account's own keys, and a delegated agent may call it. Some venue endpoints — funding payment history among them — will not answer without the account's own credentials, so an agent that cannot read them cannot do the job the user connected it for.
What narrows this is the method. The route is a POST, so the write-scope rule applies and a read-only grant is turned away. Treat write scope as equivalent to full control of the connected exchange account, and ask for it only when your integration trades.
Reviewing and revoking grants
This is the user's side, and it needs a first-party sign-in.
| Endpoint | SDK | Purpose |
|---|---|---|
GET /oauth2/grants | sdk.auth.listOauthGrants | List the clients this user has authorized. |
DELETE /oauth2/grants/{id} | sdk.auth.revokeOauthGrant | Revoke one grant. Returns 204. |
{
"grants": [
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"name": "Example Agent",
"scopes": ["mcp:read", "mcp:write"],
"connectedAt": "2026-01-02T10:00:00.000Z"
}
]
}Only active grants are listed, newest first. name is the client_name from registration, falling back to the client id when the client registered without one. connectedAt is when consent happened, and it survives every refresh, so it always reads as the date the user connected the agent.
id is the grant, not the client. A user who connected the same agent twice sees two rows and revokes them independently.
Revocation takes effect on the agent's next request, not when its access token expires. A grant id that is unknown, already revoked, or belongs to another user all answer 404 OAuth grant not found — the same answer either way, so the endpoint never confirms that another user holds an id.