# The OAuth flow

> The agent authorization endpoints: client metadata documents or DCR, authorize with PKCE, consent, token, refresh, revoke, plus JWKS and RFC 8414 discovery.

Source: https://docs.customdomain.ai/docs/agents/oauth-flow

> **Live on customdomain.ai.** Every endpoint below is serving in production.
> Verified 2026-08-02: the metadata document returns 200, `/.well-known/jwks.json`
> publishes the ES256 signing key, and dynamic client registration issues a real
> `client_id`.

Agent access uses OAuth 2.0 authorization-code with &#x2A;*mandatory PKCE (S256)**,
against the control plane at `https://api.customdomain.ai`. All of it is
discoverable: a compliant client can bootstrap from the metadata document alone.

## Discovery

Two well-known documents describe the whole surface:

```bash
# RFC 8414 Authorization Server Metadata
curl https://api.customdomain.ai/.well-known/oauth-authorization-server
# → { "issuer", "authorization_endpoint", "registration_endpoint",
#     "token_endpoint", "revocation_endpoint", "jwks_uri",
#     "scopes_supported": ["domains:read","domains:connect","domains:disconnect","domains:purchase"],
#     "code_challenge_methods_supported": ["S256"],
#     "token_endpoint_auth_methods_supported": ["none"],
#     "client_id_metadata_document_supported": true, … }

# RFC 7517 JWKS: the ES256 public keys resource servers verify tokens against
curl https://api.customdomain.ai/.well-known/jwks.json
```

A client that starts at the MCP endpoint needs no other address: the `401` from
`https://mcp.customdomain.ai/mcp` points at its protected resource metadata
(RFC 9728), which names this server first as the authorization server.

## 1. Identify your agent

Agents are **public** clients (PKCE, no secret). There are two ways to get a
`client_id`, and both lead to the same authorize, consent and token steps.

### Option A: a Client ID Metadata Document (recommended)

The MCP authorization spec prefers this. Publish a JSON document about your
agent at an `https` URL with a path, and use that URL itself as your
`client_id`. Nothing is registered ahead of time, and one document serves every
workspace that connects your agent.

```json
{
  "client_id": "https://atlas.example/oauth/client.json",
  "client_name": "Atlas",
  "redirect_uris": ["https://atlas.example/cb", "http://127.0.0.1/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}
```

CustomDomain™ fetches the document when your agent first sends someone to the
authorize endpoint, and uses it only when:

* `client_id` in the document is exactly the URL it was fetched from;
* it names the agent (`client_name`) and lists at least one redirect URI
  (`https`, or `http` on `localhost`, `127.0.0.1` or `[::1]`);
* it asks for public-client authentication (`none`, or no method at all) and
  carries no client secret;
* if it lists `grant_types` or `response_types`, they include
  `authorization_code` and `code` (other values are ignored);
* it is served as `application/json`, is at most 5 KB, and answers `200`
  directly from a public host (redirects are not followed).

Documents are cached as their HTTP caching headers allow (5 minutes when they
give none, never more than 24 hours), so a change reaches CustomDomain™ within
that time.

### Option B: Dynamic Client Registration

Register with RFC 7591. The standard metadata MCP clients send
(`grant_types`, `response_types`, `token_endpoint_auth_method`, and fields such
as `client_uri` or `application_type`, which are ignored) is accepted. Redirect
URIs must be `https` (or `http` on a loopback host for native apps); at most 10
per client.

```bash
curl -X POST https://api.customdomain.ai/oauth/agent/register \
  -H "Content-Type: application/json" \
  -d '{"client_name":"Atlas","redirect_uris":["https://atlas.example/cb"]}'
# → { "client_id":"agc_…", "client_id_issued_at":1790000000,
#     "token_endpoint_auth_method":"none",
#     "grant_types":["authorization_code","refresh_token"],
#     "response_types":["code"] }
```

Registration is open, so it is bounded: each address may register 20 clients
per hour (ten times that for registrations whose redirect URIs are all a known
assistant's own callback, since hosted assistants register for every user from
a few addresses), and a client nobody approves into a workspace is removed
after 30 days; register again if yours was. Grant and response types beyond
`authorization_code` and `code` are ignored. Errors use the RFC 7591 shape, for
example `{"error":"invalid_redirect_uri","error_description":"…"}`.

**Names.** Your agent's name is shown to the person approving it, so it may not
claim a brand that its redirect addresses do not belong to. A client named
after Claude, Anthropic, ChatGPT, OpenAI, Devin or CustomDomain™ is refused
unless every redirect URI is on that brand's own domains or on a loopback
address. Names are at most 100 characters, may not contain control or
invisible characters, and may not mix Latin letters with look-alike letters
from another alphabet (Cyrillic, Greek, Armenian, Cherokee, Lisu or Coptic).

## 2. Authorize

Send the human to the authorize endpoint with a PKCE challenge. The control plane
validates the client, the exact redirect URI, and the requested scopes, then
redirects the browser to your console's consent screen.

```
GET https://api.customdomain.ai/oauth/agent/authorize
  ?client_id=agc_…
  &redirect_uri=https://atlas.example/cb
  &response_type=code
  &code_challenge=<BASE64URL(SHA256(verifier))>
  &code_challenge_method=S256
  &scope=domains:read domains:connect
  &state=<opaque>
```

The redirect URI must **exactly** match one registered in step 1 (or listed in
your metadata document), and `code_challenge_method` must be `S256`; plain PKCE
is rejected. The optional `resource` parameter (RFC 8707) names the server the
token is for: the MCP endpoint, which is the default, or the API origin. Any other
value is refused with `invalid_target`. Send the same `resource` on the token
request. A loopback redirect URI such as `http://127.0.0.1/callback`
matches on any port, so a native app can bind a new port each session
(RFC 8252).

## 3. Consent

The console authenticates the owner and shows what the agent is requesting,
led by **where its access would go**: the host of your redirect URI. That host
is labelled verified (a known assistant's own address, such as `claude.ai` or
`chatgpt.com`), local tool (a loopback address, with a warning), or
unverified; an unverified agent can only be approved after the owner confirms
they recognize the host. The screen also names the workspace and the application
the agent will act in, which the owner picks from the workspace's live
applications (test mode applications are not offered). On approval the console
hands the browser back to the agent's `redirect_uri` with an authorization
`code` and the original `state`. If the owner denies, a verified or local agent
gets `error=access_denied` and the `state`; an unverified one is not redirected
to. (The consent record itself is written server-side with your workspace
credential, `POST /v1/agent/consent`. The control plane checks the approval
again: an unverified host needs `acknowledged_unverified: true`, the owner's
confirmation, or the approval is refused with `400`; a name that imitates a
brand is refused with `403`; and a member's own key records only that member as
the approver. See [Managing access](/docs/agents/managing-access).)

## 4. Token

Exchange the code and the PKCE **verifier** for tokens. `client_id` and
`redirect_uri` must match the authorization request.

```bash
curl -X POST https://api.customdomain.ai/oauth/agent/token \
  -d "grant_type=authorization_code" \
  -d "code=cod_…" \
  -d "code_verifier=<VERIFIER>" \
  -d "client_id=agc_…" \
  -d "redirect_uri=https://atlas.example/cb"
# → { "access_token":"eyJ…", "token_type":"Bearer", "expires_in":900,
#     "refresh_token":"art_…", "scope":"domains:read domains:connect" }
```

The **access token** is an ES256 JWT valid for 15 minutes; it carries the tenant,
the bound application, the granted scopes, its issuer (`iss`), its audience
(`aud`, the resource it was issued for) and the consenting member. The
**refresh token** is long-lived and stored hashed at rest.

The code is good for one exchange within 5 minutes. Presenting a code that
already issued tokens is refused with `invalid_grant` **and revokes the grant**
those tokens came from (RFC 6749 section 4.1.2): someone else may hold a copy of
the code, and their tokens cannot be told apart from yours. The workspace is
told, as for a reused refresh token. A replay is caught for 5 minutes after the
exchange.

### Refresh

```bash
curl -X POST https://api.customdomain.ai/oauth/agent/token \
  -d "grant_type=refresh_token" \
  -d "refresh_token=art_…" \
  -d "client_id=agc_…"
# → { "access_token":"eyJ…", "refresh_token":"art_…(a new one)", … }
```

`client_id` is required and must be the client the grant belongs to. A refresh
without `resource` gets the default audience, the MCP endpoint. &#x2A;*Refresh
tokens rotate:** every refresh returns a new refresh token and the one you sent
stops working, so store the new one each time. Presenting a refresh token that
was already used is treated as theft: the whole grant is revoked and the owner
is told why, and the agent has to be approved again. The one exception is your
own client sending the same token twice within a few seconds (two requests
refreshing at once): the second is refused with `invalid_grant` and the grant
stands, so keep the token from the response that succeeded. Refresh keeps working
until the grant is revoked or expires. Revocation takes effect at once: the
control plane checks the grant on every call, so the next tool call and the next
refresh are refused.

## 5. Use the token

Call MCP tools with the access token. The MCP **verifies it locally** against the
JWKS (no introspection), checks that its audience is the MCP endpoint, and
enforces the tool's required scope:

```bash
curl -X POST https://mcp.customdomain.ai/mcp \
  -H "Authorization: Bearer eyJ…" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"connect-domain","arguments":{"domain":"app.customer.com"}}}'
```

A token whose grant lacks the tool's scope is refused with `insufficient_scope`,
and one issued for another audience with `401 invalid_token`. The control plane
verifies the token again on every call the tool makes: it accepts it only when
its issuer (`iss`) is the control plane itself and its audience (`aud`) is the
MCP endpoint or the API origin, and only while the grant is active. A token
whose grant was revoked still lists tools, but its first tool call is answered
HTTP `401` with a `WWW-Authenticate` challenge, so the client refreshes or signs
in again.

## 6. Revoke

```bash
curl -X POST https://api.customdomain.ai/oauth/agent/revoke -d "token=art_…"
```

Per RFC 7009 this always returns `200`. Revoking kills the grant and all its
refresh tokens; the owner can also revoke from the console.

## Security notes

* **PKCE S256 is mandatory** and verified with a constant-time comparison.
* **Exact redirect-URI match**: an unregistered URI is refused without
  redirecting (no open redirect).
* **Authorization codes are single-use** and live 5 minutes; a replayed code is
  refused and revokes the grant it issued tokens for.
* **Refresh tokens rotate**, and a reused one revokes the grant.
* **The consent screen shows where access goes** (the redirect host) and
  whether it is verified, and refuses agents whose name imitates a brand their
  host does not belong to. The control plane holds the approval to the same
  rules: an unverified host needs the owner's acknowledgement
  (`acknowledged_unverified`), whoever calls it.
* **Tokens are checked at both ends**: the MCP verifies the signature and the
  audience, and the control plane checks the issuer, the audience and the grant
  on every call.
* Tokens are **ES256** with an alg-confusion guard: an `alg:none` or HS256
  forgery is never accepted.
