IV Users and Access

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.

KeyMeaningDefault
authorizeRequired. The absolute URL of the consent page.
resourcesPaths advertised as protected resources, for example ['/.mcp'].none
scopesScopes advertised as what a client may ask for.none
registrationopen 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, or http on a loopback address. Anything else is 400 with error: invalid_redirect_uri.
  • token_endpoint_auth_method other than none is 400 with error: 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.

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:

PropertyFrom the query
clientclient_idRequired.
redirectredirect_uriRequired.
challengecode_challengeRequired.
methodcode_challenge_methodRequired; must be S256.
scopescope, split on spaces into a listOptional.
resourceresource, as a listOptional.

A refusal is 400 with error and error_description:

errorWhen
invalid_clientThe client is unknown.
invalid_requestThe 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:anonymous is callable as a tool whatever the client presents.
  • A tool for a signed-in customer needs a directive that admits the customer, typically auth:id with 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.

  1. 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 oauth annotated, every 401 of the gateway carries this header.

  2. 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/yaml
    resource: 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/yaml
    issuer: 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]
  3. It registers, unless it publishes its metadata.

  4. It opens your consent page in the user’s browser and is redirected back with a code.

  5. 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-metadata
    200 OK
    cache-control: no-store
    
    {"access_token": "eyJhbGciOiJkaXIi...", "token_type": "Bearer", "expires_in": 2592000}
  6. It repeats step 1 with authorization: Bearer <token>.

About the documents:

  • registration_endpoint appears only with registration: open, and scopes_supported only with scopes. The server’s document is also served at /.well-known/openid-configuration.
  • Each path of resources has a document at /.well-known/oauth-protected-resource followed 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 404 there.
  • A host that serves MCP alone has its document at /.well-known/oauth-protected-resource of that host, naming the authority as the authorization server.

About the exchange:

  • code_verifier is required, and must be the one the challenge was 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 all 400 with error: invalid_grant, with no hint which.
  • A scope that the identity’s roles do not cover is 400 with error: invalid_scope.

§Settings

Under configuration in context.toa.yaml:

SettingMeaningDefault
identity.grants.lifetimeSeconds a code may be redeemed within; at most 600.60
identity.grants.tokenSeconds an access token is valid; 0 is until revoked.2592000
identity.clients.trustOrigins whose client metadata documents are read.[]
identity.clients.lifetimeSeconds a fetched metadata document is kept before it is read again.3600
identity.clients.ttlSeconds an unused registration is kept.2592000
identity.clients.sizeBytes of a metadata document that are read.16384
identity.clients.timeoutMilliseconds 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.

Search
Search the documentation