Let Another App Act for a User
A customer connects an AI assistant to the shop and asks it about their orders. A desktop client or an automation of a third party does the same. Such a program must act as that customer, with the customer’s consent, and without ever seeing their password. The gateway does this as an OAuth 2.1 authorization server: the customer agrees on a page of your web client, and the program receives a token of the customer’s identity, which can be limited to some roles and to one entry point.
The gateway serves everything except the consent page. That page needs a person in front of it, so it is yours to build.
§Turn it on
Add oauth to the exposition of the Context, beside the authority toa create wrote:
# context.toa.yaml
exposition:
authorities:
local: localhost:8000
local@production: api.store.example
oauth:
authorize: https://store.example/oauth/authorize
resources: ['/.mcp']
scopes: [store:orders]
registration: open $ npm run restart Only authorize, the absolute URL of your consent page, is required. Without oauth there is no
authorization server. local@production gives the authority its host in production.
| Key | Meaning | Default |
|---|---|---|
authorize | Required. The absolute URL of the consent page. | |
resources | Paths advertised as protected resources, for example ['/.mcp']. | none |
scopes | Scopes advertised as what a client may ask for. | none |
registration | open lets clients register themselves; closed does not advertise registration. | closed |
The server is addressed as https:// followed by the authority’s host, so the gateway must be
served over TLS.
§Decide which programs are known
A program identifies itself in one of two ways. Either way it is a public client: it proves itself with PKCE and has no secret.
It publishes what it is. Known products use an https URL as their client_id and publish a
metadata document there. The gateway reads such documents only from origins you list:
# context.toa.yaml
configuration:
identity.clients:
trust:
- https://claude.ai The list is empty by default, and then no such client is known.
It registers itself. With registration: open, any program may:
POST /identity/clients/ HTTP/1.1
host: api.store.example
content-type: application/json
{"client_name": "Claude", "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"]} 201 Created
cache-control: no-store
{
"client_id": "c8f3a1e4d8c9b2f6a0e5d7c3b1a9f8e2",
"client_name": "Claude",
"redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
} Accepted properties are client_name, client_uri, logo_uri, redirect_uris (1 to 8,
required), scope, grant_types, response_types and token_endpoint_auth_method.
- The same registration sent twice is the same client, with the same
client_id. A registration cannot be changed; different metadata is a different client. - A redirect URI must be
https, orhttpon a loopback address. Anything else is400witherror: invalid_redirect_uri. token_endpoint_auth_methodother thannoneis400witherror: invalid_client_metadata.- A registration that nothing has used is dropped after
identity.clients.ttl.
registration: closed only stops advertising the endpoint to programs. Choose by who you expect:
known products publish metadata; arbitrary tools register.
§Build the consent page
The program opens the URL of authorize in the user’s browser, with its request in the query:
https://store.example/oauth/authorize
?response_type=code
&client_id=https%3A%2F%2Fclaude.ai%2Foauth%2Fclaude-code-client-metadata
&redirect_uri=http%3A%2F%2Flocalhost%3A3118%2Fcallback
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&state=af0ifjsldkj
&resource=https%3A%2F%2Fapi.store.example%2F.mcp
&scope=store:orders The page is part of your web client. It does four things.
§1. Sign the user in
In whatever way the shop already offers. The page ends up with the user’s own Token and their
identity id.
§2. Show who is asking
GET /identity/clients/https%3A%2F%2Fclaude.ai%2Foauth%2Fclaude-code-client-metadata/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...
accept: application/yaml client_id: https://claude.ai/oauth/claude-code-client-metadata
client_name: Claude Code
client_uri: https://claude.ai
logo_uri: https://claude.ai/logo.png
redirect_uris: [http://localhost/callback, http://127.0.0.1/callback] The client_id goes into the path URL-encoded. Any signed-in identity may read a client. For a
client that is not known the reply is:
422 Unprocessable Entity
code: UNKNOWN_CLIENT Show an error then, and do not redirect.
Display client_name, the scopes asked for, and the host of redirect_uri. A redirect to localhost could be any program on the user’s machine, so the user should see where the code is
going.
§3. When the user agrees, ask for a code
POST /identity/grants/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...
content-type: application/json
{
"client": "https://claude.ai/oauth/claude-code-client-metadata",
"redirect": "http://localhost:3118/callback",
"challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
"method": "S256",
"scope": ["store:orders"],
"resource": ["https://api.store.example/.mcp"]
} 201 Created
cache-control: no-store
{"code": "SplxlOBeZQQYbYS6WxSbIA", "expires_in": 60} The path holds the id of the identity that agrees, which must be the one the page is signed in as. The body passes on what arrived in the query:
| Property | From the query | |
|---|---|---|
client | client_id | Required. |
redirect | redirect_uri | Required. |
challenge | code_challenge | Required. |
method | code_challenge_method | Required; must be S256. |
scope | scope, split on spaces into a list | Optional. |
resource | resource, as a list | Optional. |
A refusal is 400 with error and error_description:
error | When |
|---|---|
invalid_client | The client is unknown. |
invalid_request | The redirect is not one this client may receive a code at, or the method is not S256. |
After such a refusal the page must not redirect to the redirect_uri: it has just been told
that the address is not the client’s. It shows the error itself.
A redirect URI must equal one of the client’s exactly, except on a loopback address, where the port is ignored.
§4. Send the user back
Redirect to the client’s redirect_uri with the code, the client’s state untouched, and the
issuer:
http://localhost:3118/callback
?code=SplxlOBeZQQYbYS6WxSbIA
&state=af0ifjsldkj
&iss=https%3A%2F%2Fapi.store.example iss is required, and a client rejects a response without it. Its value is the issuer of /.well-known/oauth-authorization-server, which the page may fetch.
When the user declines, redirect all the same, saying so:
http://localhost:3118/callback?error=access_denied&state=af0ifjsldkj&iss=https%3A%2F%2Fapi.store.example A single-page application does either with location.assign.
The page is shown every time. The gateway does not skip it for a client the user allowed before; think twice before your page does.
§What the program gets
The program exchanges the code for an access token (its request is shown below) and presents the token as Bearer:
GET /store/orders/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
authorization: Bearer eyJhbGciOiJkaXIi... 200 OK The token is a token of the identity that agreed. It passes the same access directives as that identity would: Bob’s assistant reaches Bob’s orders
through auth:id and nobody else’s. It is never replaced by a newer one, and is valid for identity.grants.token seconds.
§Limit a token to some roles
Without scope, the token has the roles its identity holds. With scope, it has those roles in
place of the identity’s. Each must be covered by a role of the identity, or the exchange is refused with error: invalid_scope.
The scopes list of the annotation is what you advertise, not a filter: a scope outside the list
is still granted when the identity’s roles cover it.
§Limit a token to one entry
When the program asked for a resource and the user agreed, the token is admitted only there. A
token for https://api.store.example/.mcp works at /.mcp, and anywhere else:
GET /store/orders/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
authorization: Bearer eyJhbGciOiJkaXIi... 401 Unauthorized The same goes for /.rpc and /identity/…, and for the same path on another host.
This does not hide data: an operation published as an MCP tool returns what it returns over HTTP. It keeps the program away from what the entry does not publish: the rest of the tree, and changing the user’s password or roles.
§Connect an AI assistant
Let an AI Assistant Call the API publishes operations as MCP tools at /.mcp. What ties it to the consent page:
- Advertise the endpoint with
resources: ['/.mcp']. An MCP client that is refused there is told where the authorization server is, and the token it obtains is limited to/.mcp. - Where MCP is served on a host of its own, that host is a protected resource without being listed.
- Each tool is authorized as the route it is, for the identity that agreed or the roles it
allowed. A route that is
auth:anonymousis callable as a tool whatever the client presents. - A tool for a signed-in customer needs a directive that admits the customer, typically
auth:idwith the id passed as a tool argument. The assistant learns the id from a tool that answers a caller with their own account.
§Show the user what they allowed
One grant is kept per identity per client. A “connected apps” page lists them:
GET /identity/grants/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...
accept: application/yaml - id: 3f2e1d0c9b8a4f7e6d5c4b3a2f1e0d9c
client: c8f3a1e4d8c9b2f6a0e5d7c3b1a9f8e2
scope: [store:orders]
resource: [https://api.store.example/.mcp]
CREATED: 1759696400000 and lets the user take one back:
DELETE /identity/grants/2428c31ecb6e4a51a24ef52f0c4181b9/3f2e1d0c9b8a4f7e6d5c4b3a2f1e0d9c/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi... 204 No Content The token of a revoked grant stops working within identity.tokens.cache.ttl. Both routes are open to
the identity alone.
§Test it
A scenario plays both parts: the program registers and exchanges the code, and the two requests of the consent page are made with the customer’s credentials. The challenge and the verifier are a fixed pair.
# features/apps.feature
Feature: Connected applications
Scenario: An application reads the orders of the customer who allowed it
When the request is sent:
"""
POST /accounts/ HTTP/1.1
authorization: Basic #{{ basic }}
content-type: application/yaml
accept: application/yaml
name: Bob
"""
Then the response is received:
"""
201 Created
authorization: Token ${{ bob.token }}
id: ${{ bob.id }}
"""
When the request is sent:
"""
POST /identity/clients/ HTTP/1.1
content-type: application/yaml
accept: application/yaml
client_name: Assistant
redirect_uris:
- https://assistant.example.com/callback
"""
Then the response is received:
"""
201 Created
client_id: ${{ client }}
"""
When the request is sent:
"""
POST /identity/grants/${{ bob.id }}/ HTTP/1.1
authorization: Token ${{ bob.token }}
content-type: application/yaml
accept: application/yaml
client: ${{ client }}
redirect: https://assistant.example.com/callback
challenge: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
method: S256
"""
Then the response is received:
"""
201 Created
code: ${{ code }}
"""
When the request is sent:
"""
POST /identity/grants/ HTTP/1.1
content-type: application/x-www-form-urlencoded
accept: application/yaml
grant_type=authorization_code&code=${{ code }}&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk&redirect_uri=https%3A%2F%2Fassistant.example.com%2Fcallback&client_id=${{ client }}
"""
Then the response is received:
"""
200 OK
access_token: ${{ access }}
token_type: Bearer
"""
When the request is sent:
"""
GET /store/orders/${{ bob.id }}/ HTTP/1.1
authorization: Bearer ${{ access }}
"""
Then the response is received:
"""
200 OK
"""
# a code is spent once
When the request is sent:
"""
POST /identity/grants/ HTTP/1.1
content-type: application/x-www-form-urlencoded
accept: application/yaml
grant_type=authorization_code&code=${{ code }}&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk&redirect_uri=https%3A%2F%2Fassistant.example.com%2Fcallback&client_id=${{ client }}
"""
Then the response is received:
"""
400 Bad Request
error: invalid_grant
""" It needs the oauth annotation, and the host the tests call declared among the authorities,
as localhost:8000 is. The steps are the ones toa create wrote, which Write a Test explains; the customer signs up at the sign-up route and the orders are read at the customer’s routes.
$ npm run restart
$ npm run features §The requests a program makes
None of these is yours to implement; they are what you see in the gateway’s log and what a developer of a client asks about.
The program calls a protected entry, such as
/.mcp, without a token and is refused:401 Unauthorized www-authenticate: Bearer resource_metadata="https://api.store.example/.well-known/oauth-protected-resource/.mcp", scope="store:orders"With
oauthannotated, every401of the gateway carries this header.It reads the document that header names, then the server’s own:
GET /.well-known/oauth-protected-resource/.mcp HTTP/1.1 host: api.store.example accept: application/yamlresource: https://api.store.example/.mcp authorization_servers: [https://api.store.example] bearer_methods_supported: [header] scopes_supported: [store:orders]GET /.well-known/oauth-authorization-server HTTP/1.1 host: api.store.example accept: application/yamlissuer: https://api.store.example authorization_endpoint: https://store.example/oauth/authorize token_endpoint: https://api.store.example/identity/grants/ registration_endpoint: https://api.store.example/identity/clients/ response_types_supported: [code] grant_types_supported: [authorization_code] code_challenge_methods_supported: [S256] token_endpoint_auth_methods_supported: [none] client_id_metadata_document_supported: true authorization_response_iss_parameter_supported: true scopes_supported: [store:orders]It registers, unless it publishes its metadata.
It opens your consent page in the user’s browser and is redirected back with a code.
It exchanges the code:
POST /identity/grants/ HTTP/1.1 host: api.store.example content-type: application/x-www-form-urlencoded grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk&redirect_uri=http%3A%2F%2Flocalhost%3A3118%2Fcallback&client_id=https%3A%2F%2Fclaude.ai%2Foauth%2Fclaude-code-client-metadata200 OK cache-control: no-store {"access_token": "eyJhbGciOiJkaXIi...", "token_type": "Bearer", "expires_in": 2592000}It repeats step 1 with
authorization: Bearer <token>.
About the documents:
registration_endpointappears only withregistration: open, andscopes_supportedonly withscopes. The server’s document is also served at/.well-known/openid-configuration.- Each path of
resourceshas a document at/.well-known/oauth-protected-resourcefollowed by the path. Without a suffix, the document describes the origin as a whole. - A host that is not a declared authority has no documents: these paths answer
404there. - A host that serves MCP alone has its document at
/.well-known/oauth-protected-resourceof that host, naming the authority as the authorization server.
About the exchange:
code_verifieris required, and must be the one thechallengewas made from.- A code is redeemed once, and is spent by the attempt whether or not the verifier matches: after a failed exchange the program starts over from the consent page.
- A wrong verifier, a different
redirect_uri, a used or expired code are all400witherror: invalid_grant, with no hint which. - A scope that the identity’s roles do not cover is
400witherror: invalid_scope.
§Settings
Under configuration in context.toa.yaml:
| Setting | Meaning | Default |
|---|---|---|
identity.grants.lifetime | Seconds a code may be redeemed within; at most 600. | 60 |
identity.grants.token | Seconds an access token is valid; 0 is until revoked. | 2592000 |
identity.clients.trust | Origins whose client metadata documents are read. | [] |
identity.clients.lifetime | Seconds a fetched metadata document is kept before it is read again. | 3600 |
identity.clients.ttl | Seconds an unused registration is kept. | 2592000 |
identity.clients.size | Bytes of a metadata document that are read. | 16384 |
identity.clients.timeout | Milliseconds a metadata document has to arrive. | 3000 |
Registration accepts 10 requests an hour per client address, and the exchange 60 a minute. Both
count by the client address, so they limit
nothing until exposition.ip is set.