OAuth 2.1 for MCP clients
Remote MCP clients — a Claude.ai custom connector, Claude Code, a ChatGPT app — do not hold an Orpheus API key. They sign a user in through the authorization server at the API's origin and get a bearer the API accepts like any other. The server is OAuth 2.1 as the MCP authorization specification asks for it: authorization code with PKCE (S256), dynamic client registration, refresh tokens, resource indicators, and the two well-known documents that let a client find all of this on its own.
Nothing here is specific to MCP. Any OAuth client can use it.
Discovery
The client learns where everything is from two documents at the API's origin.
| URL | Standard | Answers |
|---|---|---|
https://api.hireorpheus.com/.well-known/oauth-protected-resource/mcp (also without /mcp) |
RFC 9728 | which authorization server protects the MCP resource, and the scopes it understands |
https://api.hireorpheus.com/.well-known/oauth-authorization-server |
RFC 8414 | the endpoints, grant types, PKCE methods, client authentication methods and scopes |
A request to the MCP server without a valid bearer answers 401 with
WWW-Authenticate: Bearer resource_metadata="https://api.hireorpheus.com/.well-known/oauth-protected-resource/mcp"
which is the thread a client pulls to find the rest. The metadata names:
authorization_endpoint https://api.hireorpheus.com/oauth/authorize
token_endpoint https://api.hireorpheus.com/oauth/token
registration_endpoint https://api.hireorpheus.com/oauth/register
revocation_endpoint https://api.hireorpheus.com/oauth/revoke
1. Register the client
Registration is open and dynamic (RFC 7591): no account, no initial token. Claude and ChatGPT do this by themselves the first time a user adds the connector.
curl -X POST https://api.hireorpheus.com/oauth/register \
-H 'Content-Type: application/json' \
-d '{
"client_name": "Claude",
"redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
"token_endpoint_auth_method": "none"
}'
{
"client_id": "6c1f0f6e-…",
"client_id_issued_at": 1760340000,
"client_name": "Claude",
"redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
Redirect URIs must be https, http on localhost / 127.0.0.1 (any port, for native apps that bind an ephemeral one), or a private-use scheme such as com.example.app:/callback. Fragments are refused. A client that registers token_endpoint_auth_method client_secret_post or client_secret_basic is confidential and receives a client_secret, shown once. Registration is rate-limited per address.
2. Send the user to sign in
Build a PKCE pair — a random code_verifier of 43–128 characters and code_challenge = BASE64URL(SHA256(code_verifier)) — and open the authorization endpoint in the user's browser:
https://api.hireorpheus.com/oauth/authorize
?response_type=code
&client_id=6c1f0f6e-…
&redirect_uri=https://claude.ai/api/mcp/auth_callback
&scope=gigs%20catalog%20offline_access
&state=…
&code_challenge=…
&code_challenge_method=S256
&resource=https://api.hireorpheus.com/mcp
The page names the client and the scopes it asks for, and offers two ways in:
- Email. The user types the address on their Orpheus account and receives a single-use link, valid 15 minutes. The link lands on a confirmation page (so a mail scanner that opens it does not spend it); one click there finishes the sign-in and returns the browser to
redirect_uri. If the link was opened in another browser, the page shows a "Return to client" button with the same URL. - API key. The user pastes an Orpheus API key (from
POST /api/v1/api-keys). The key is checked exactly as the API checks it and the browser is redirected at once.
Either way the client receives redirect_uri?code=…&state=…&iss=https://api.hireorpheus.com. The code is good for 10 minutes, once.
An unknown client_id or a redirect_uri the client did not register renders an error page and never redirects. Every other problem — missing PKCE, an unknown scope, a response type other than code — comes back to redirect_uri as error and error_description with the state.
3. Exchange the code
curl -X POST https://api.hireorpheus.com/oauth/token \
-d grant_type=authorization_code \
-d code=… \
-d redirect_uri=https://claude.ai/api/mcp/auth_callback \
-d client_id=6c1f0f6e-… \
-d code_verifier=…
{
"access_token": "…",
"token_type": "Bearer",
"expires_in": 604800,
"refresh_token": "…",
"scope": "gigs catalog offline_access"
}
The token endpoint takes a form body or JSON. Confidential clients add client_secret to the body or send client_id:client_secret as HTTP Basic. A code presented twice is refused, and everything it minted the first time is revoked.
The access token lives 7 days; the refresh token 90. Send the access token as Authorization: Bearer … to the MCP server or to any /api/v1 endpoint.
4. Refresh
curl -X POST https://api.hireorpheus.com/oauth/token \
-d grant_type=refresh_token \
-d refresh_token=… \
-d client_id=6c1f0f6e-…
Refresh tokens rotate: the answer carries a new pair and the old refresh token is spent. A spent refresh token presented again is treated as a leak — the whole grant, access tokens included, is revoked, and the user signs in again.
Scopes
One scope per capability group, plus offline_access:
| Scope | Capabilities |
|---|---|
gigs |
gigs.view, gigs.edit |
catalog |
catalog.view, catalog.edit |
staffing |
staffing.view, staffing.edit |
production |
production.view, production.edit |
people |
people.view, people.edit |
pay |
pay.view, pay.edit |
offline_access |
a refresh token (one is issued regardless) |
A scope never grants more than the user's roles do: the token's ability is the user's ability intersected with the scopes. A request with no scope gets all six groups. GET /api/v1/me with an OAuth bearer reports token.kind oauth, token.client_id, the scopes, and a can map that already reflects the intersection.
Revocation
POST /oauth/revoke with token (and the client's credentials) per RFC 7009. Revoking a refresh token revokes the access tokens minted beside it; revoking an access token revokes only itself. The response is 200 either way.
Errors
Token, registration and revocation errors are RFC 6749 JSON — { "error": "invalid_grant", "error_description": "…" } — with 400, or 401 and invalid_client when the client could not be authenticated. Errors on the authorization endpoint go back to the client's redirect_uri as described above.