Skip to content

Latest commit

 

History

History
378 lines (299 loc) · 18.8 KB

endpoints.md

File metadata and controls

378 lines (299 loc) · 18.8 KB

Endpoints

This page is the HTTP reference. Paths assume the issuer https://auth.example.com and the default routePrefix of /oauth. Use ServerConfig::endpointUrl() to get the real URLs.

The JSON endpoints (metadata, token, register, revoke, introspect) answer OPTIONS preflights and add CORS headers: Access-Control-Allow-Origin: *, methods GET, POST, OPTIONS, headers Authorization, Content-Type, MCP-Protocol-Version, no credentials. Their responses are never cached (Cache-Control: no-store), except the metadata. The two page endpoints (authorize and connections) answer HTML or redirects and are not CORS enabled.

JSON errors always have this shape (see errors for every code):

{"error": "invalid_grant", "error_description": "The authorization code expired."}

A wrong method answers 405 with an Allow header and the error invalid_request.

GET /.well-known/oauth-authorization-server

Authorization server metadata (RFC 8414). Clients read it to find the other endpoints.

  • Method: GET (and OPTIONS).
  • Path: ServerConfig::metadataPath(). When the issuer has a path the path is added after the well-known segment.
  • Cache: Cache-Control: public, max-age=300.
curl -s https://auth.example.com/.well-known/oauth-authorization-server
{
  "issuer": "https://auth.example.com",
  "authorization_endpoint": "https://auth.example.com/oauth/authorize",
  "token_endpoint": "https://auth.example.com/oauth/token",
  "registration_endpoint": "https://auth.example.com/oauth/register",
  "revocation_endpoint": "https://auth.example.com/oauth/revoke",
  "introspection_endpoint": "https://auth.example.com/oauth/introspect",
  "scopes_supported": ["content:read", "content:write", "offline_access"],
  "response_types_supported": ["code"],
  "response_modes_supported": ["query"],
  "grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
  "token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"],
  "revocation_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"],
  "introspection_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post"],
  "code_challenge_methods_supported": ["S256"],
  "client_id_metadata_document_supported": true,
  "authorization_response_iss_parameter_supported": true
}

Members that depend on settings: registration_endpoint is missing when registration is closed; introspection_endpoint and its auth methods are missing when introspection is false; client_credentials is missing from grant_types_supported when clientCredentials is false; client_id_metadata_document_supported follows cimd.enabled.

GET and POST /oauth/authorize

The authorization endpoint. The browser of the person visits it, never the client's server.

GET: start

Query parameters. Each authorization parameter below may be sent at most once: a repeat of any of them is refused (client_id and redirect_uri repeats give the error page). Repeats of other, non-OAuth parameters (for example a tracking parameter that a proxy appends) are ignored. That leniency is deliberate: RFC 6749 section 3.1 says request parameters must not be repeated, which the server enforces for the parameters it reads, but a stray parameter it does not read cannot change the meaning of the request, so it is not worth breaking a login over.

Parameter Required Meaning
response_type yes Must be code
client_id yes The client's id (a URL for a metadata document client)
redirect_uri when the client has more than one Must match a registered URI exactly. Loopback http URIs match on any port
code_challenge yes PKCE challenge: base64url of the SHA-256 of the verifier (43 characters)
code_challenge_method yes Must be S256
resource no The resource the token is for. Default: the default resource
scope no Space separated scopes. Default: the resource's defaultScopes. May include offline_access
state recommended Returned unchanged. Only printable ASCII (%x20-7E) is accepted: anything else gets a redirected invalid_request that omits state

What happens:

  1. The request is validated. Unknown client, bad or unregistered redirect_uri, or a repeated client_id or redirect_uri: an HTML error page (the server cannot know where to send the person, so it never redirects).
  2. If nobody is signed in, UserContextInterface::loginResponse() is called, also when the request has any other error: the server never redirects to a client before the person is authenticated (RFC 9700 section 4.11.2). After the login the browser comes back to the same URL and the request is validated again.
  3. Any other error is then sent back to the client (error, error_description, state and iss), but automatically (a 302) only when the redirect URI is trusted: the client was registered by an administrator (ClientRegistrar::create()), or the signed-in person already approved this client (for any resource). For a dynamically registered or metadata document client the person never approved, the error page names the destination host and offers a "Continue to ..." link with the same error, which the person may follow or not. A client secret does not make a dynamic client trusted: anyone can register one, with any https redirect URI.
  4. UserProviderInterface::canAuthorize() and the scope rules decide whether the request may go on.
  5. If the person already consented to these scopes and the client's identity is assured, the code is issued at once (a 302 to the client). The identity is assured for a confidential client, or when the redirect URI is https on a host that is not this computer. A public client with a loopback or private-use redirect URI always gets the consent page, which then says the client was approved before: any program on the person's computer can open the authorization URL with its own port and PKCE challenge (RFC 8252 section 8.6). Otherwise the consent page is shown.

Success is a redirect to the client: redirect_uri?code=...&state=...&iss=https://auth.example.com.

POST: the decision

The consent form posts the same authorization parameters (as hidden fields) plus:

Field Meaning
decision approve or deny. Anything else counts as a denial
consent_token Signed token bound to this exact request, the shown scopes and the person. Expires after 15 minutes

The answer is a 303 redirect to the client with the code (approve) or error=access_denied (deny), for every client: the person saw the client and the host of its redirect URI on the consent page. 303 makes the browser use GET, so the form data is not posted to the client. A form whose token is missing, expired or changed answers invalid_request on an error page. A POST from someone who is no longer signed in gets the error page, never a redirect. Other errors of the POST follow the same trust rule as the GET.

Errors: invalid_request, invalid_client, unsupported_response_type, unauthorized_client, invalid_scope, invalid_target, access_denied, server_error, temporarily_unavailable (rate limit). See errors.

POST /oauth/token

Exchanges a grant for tokens. The body is application/x-www-form-urlencoded. Any other content type is refused. Unlike /authorize, there is no leniency here: any parameter sent more than once, whatever its name, is refused with invalid_request (/revoke follows the same rule; /introspect refuses a repeated token).

Client authentication

Client How it authenticates
Public (none) Sends client_id in the body, no secret
client_secret_basic Authorization: Basic base64(client_id:client_secret)
client_secret_post client_id and client_secret in the body

A client must use the method it registered with. Sending two methods at once is refused. Every authentication failure answers the same text, Client authentication failed., and a failed Basic attempt adds WWW-Authenticate: Basic realm="oauth". A request that names no client at all (no client_id, no Basic header) answers Client authentication is required.. The one exception is a metadata document client whose document cannot be used: that invalid_client keeps its own message (for example Clients from example.org are not accepted. or The metadata document needs a client_name.), so its developer can fix it. See troubleshooting.

Response

{
  "access_token": "oauth_at_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "content:read",
  "refresh_token": "oauth_rt_..."
}

refresh_token is present when the client may use refresh tokens and refreshTokens allows it. The response is Cache-Control: no-store.

grant_type=authorization_code

Parameter Required Meaning
code yes The code from the redirect
code_verifier yes The PKCE verifier (43 to 128 characters of A-Z a-z 0-9 - . _ ~)
redirect_uri when the client has several Must equal the one used at /authorize
resource no Must name the resource of the code
client_id / secret per client See above
curl -s https://auth.example.com/oauth/token \
  -d grant_type=authorization_code \
  -d code=oauth_ac_... \
  -d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk \
  -d redirect_uri=http://127.0.0.1:8123/callback \
  -d client_id=CLIENT_ID \
  -d resource=https://example.com/mcp

Checks, in this order: client authentication, the grant is allowed, the code exists and belongs to this client, the verifier matches, the code was not used before, it is not expired, redirect_uri, resource, the person still exists. A code used a second time revokes everything issued from it, but only when the right verifier is presented, so a thief with only the code cannot cause that.

Errors: invalid_request, invalid_client, invalid_grant, unauthorized_client, invalid_target.

grant_type=refresh_token

Parameter Required Meaning
refresh_token yes The refresh token
scope no A subset of the scopes of the refresh token. Narrows the new access token only. Default: all of them
resource no Must name the token's resource
curl -s https://auth.example.com/oauth/token \
  -d grant_type=refresh_token -d refresh_token=oauth_rt_... -d client_id=CLIENT_ID

Every refresh returns a new pair and retires the old refresh token. Presenting an old one again revokes the whole family (see security), except inside the grace window: refreshes sent at the same time with the same token, or a retry after a lost response, all get the same pair (concurrent refreshes).

The new refresh token keeps the scopes of the one presented (RFC 6749 section 6), minus scopes the resource no longer declares. A scope parameter narrows only the new access token, so a client that asks for content:read once can ask for content:write again at the next refresh. The client's registered scopes and the person's scopes of today narrow the new access token the same way (a client an administrator narrowed from content:write to content:read gets content:read); the refresh fails with invalid_scope only when the client may hold none of the scopes, and with invalid_grant when the person may hold none. The response's scope describes the access token.

Errors: invalid_request, invalid_client, invalid_grant, unauthorized_client, invalid_scope, invalid_target.

grant_type=client_credentials

For a confidential client acting for itself. See clients.

Parameter Required Meaning
scope no Scopes wanted. Default: the resource's defaultScopes
resource no Default: the default resource
curl -s https://auth.example.com/oauth/token -u CLIENT_ID:CLIENT_SECRET \
  -d grant_type=client_credentials -d resource=https://example.com/api -d scope=read

No refresh token is issued. Errors: invalid_client, unauthorized_client (public client, no permission, or forClient() returned null), unsupported_grant_type (disabled), invalid_target, invalid_scope.

Any other grant_type gives unsupported_grant_type; a missing one gives invalid_request.

POST /oauth/register

Dynamic client registration (RFC 7591). The body is JSON (Content-Type: application/json, at most 16 KB).

Member Meaning
redirect_uris Required. List of 1 to 10 URIs. See clients
client_name Display name, 1 to 255 characters, with a letter, number, punctuation or symbol, and no control, bidirectional or other invisible characters (see security). The consent page names a self-registered client by the host of its redirect URI and shows this name only as a quoted, unverified line, so a name such as claude.ai is accepted but cannot pass for the real client. Default: the host of the first redirect URI
application_type web or native. When omitted: native if no redirect URI is https, otherwise web. A client that says web may not register loopback http
token_endpoint_auth_method none, client_secret_basic (default) or client_secret_post
grant_types Default ["authorization_code"]. May add refresh_token. client_credentials is refused
response_types Must be ["code"]
scope Space separated scopes the client may ask for. offline_access is accepted but not stored. A scope of only offline_access stores an empty list, so the client may then get no resource scope at all (invalid_scope at /authorize). Leave scope out to allow any
client_uri, logo_uri https URLs

client_id and client_secret sent by the caller are ignored. In token mode the request needs Authorization: Bearer <initialAccessToken>.

curl -s https://auth.example.com/oauth/register -H 'Content-Type: application/json' -d '{
  "client_name": "My CLI",
  "application_type": "native",
  "redirect_uris": ["http://127.0.0.1/callback"],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"]
}'

The answer is 201:

{
  "client_id": "5f2b...e9",
  "client_id_issued_at": 1767225600,
  "client_name": "My CLI",
  "redirect_uris": ["http://127.0.0.1/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "application_type": "native"
}

Confidential clients also get client_secret and client_secret_expires_at (0, never). The secret is shown only here. The request is rate limited (register).

Errors: invalid_client_metadata, invalid_redirect_uri, access_denied (closed), invalid_token (token mode), temporarily_unavailable.

POST /oauth/revoke

Token revocation (RFC 7009). Form encoded. The client authenticates like at /token.

Parameter Required Meaning
token yes An access or refresh token of this client
curl -s https://auth.example.com/oauth/revoke -d token=oauth_rt_... -d client_id=CLIENT_ID

Revoking a refresh token revokes its whole family. Revoking an access token revokes only that token. The answer is 200 with {}, also for unknown tokens and tokens of other clients, so the endpoint cannot be used to find out which tokens exist. A TokenRevoked event with the reason client is dispatched when something was revoked. This endpoint uses the token rate limit.

Errors: invalid_request, invalid_client.

POST /oauth/introspect

Token introspection (RFC 7662), for another service that needs to check this server's tokens. Disabled with 'introspection' => false (answers access_denied).

The caller must be a confidential client created with allowIntrospection: true. A public client, or one without the permission, gets invalid_client. That permission is a high privilege: such a client can introspect any token, including the refresh tokens of other clients, and learns the person's subject, username and scopes. Give it only to your own trusted resource servers. A repeated token parameter is refused with invalid_request, as at /revoke.

curl -s https://auth.example.com/oauth/introspect -u RS_ID:RS_SECRET -d token=oauth_at_...

An active access token:

{
  "active": true,
  "scope": "content:read",
  "client_id": "5f2b...e9",
  "sub": "42",
  "username": "Ada Lovelace",
  "aud": "https://example.com/mcp",
  "exp": 1767229200,
  "iat": 1767225600,
  "token_type": "Bearer"
}

An active refresh token answers the same, without username, and with token_type refresh_token. Its scope is what an access token refreshed from it would hold today, not the grant it stores: a refresh keeps the grant on the new refresh token (security), so after a demotion the stored scopes can be wider than what the person has. A refresh token that could not be refreshed any more (its owner, its resource or every one of its scopes gone, for the client or for the person) is inactive. Anything else (unknown, revoked, expired, used, owner gone, client revoked) answers {"active": false}. Introspecting a token does not count as using it: last_used_at does not change. This endpoint has its own rate limit, introspect (600 a minute per address by default), so a resource server that introspects on every request neither runs out of the token budget nor has to loosen it.

A resource server that introspects must accept only "token_type": "Bearer", the value of every access token and personal token. A refresh token is never a bearer token: refuse it like an invalid token whatever its scope says, as TokenValidator and BearerTokenMiddleware do.

Errors: invalid_request, invalid_client, access_denied (disabled).

GET and POST /oauth/connections

The page where a signed-in person sees the applications they connected and manages personal access tokens. See pages and personal tokens.

  • GET: anonymous visitors go to loginResponse(). Signed-in people get the page.
  • POST: form encoded, needs form_token. The action field selects what to do:
action Other fields
revoke_connection family_id
revoke_token token_id (a personal token)
create_token name, resource, days, and scope:<name>=1 for each chosen scope

After a revocation the browser is redirected back (303). After create_token the page is shown with the new token, once. An invalid creation shows the page again with the message and status 422. A disabled page ('connections' => false) answers 404. A missing or expired form_token answers invalid_request.