sugarcommerce / oauth-server
The authorization-server half of OAuth 2.1 for a remote MCP server: protected resource and authorization server metadata, Client ID Metadata Documents and dynamic registration, authorization code with S256 PKCE, resource-bound opaque tokens, rotating refresh tokens with reuse detection, and revocati
Requires
- php: >=8.5
- ext-mbstring: *
- sugarcommerce/generate: ^1.0
- sugarcommerce/http-client: ^1.0
- sugarcommerce/oauth: ^1.0
Requires (Dev)
- phpunit/phpunit: ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
The authorization-server half of OAuth 2.1, profiled for a remote MCP server that is its own authorization server: so somebody can add your server to Claude or ChatGPT by clicking "Connect", signing in, and approving โ no pasted token.
Follows the MCP authorization specification, revision 2026-07-28, which profiles OAuth 2.1 (draft-ietf-oauth-v2-1-13), RFC 8414, RFC 9728, RFC 8707, RFC 9207, RFC 7591, RFC 7009 and Client ID Metadata Documents (draft-ietf-oauth-client-id-metadata-document-00).
sugarcommerce/oauth is the other half โ the client's PKCE pair and browser-bound state.
Why
An MCP client meeting your server for the first time has no client id, no secret and no relationship with you. The spec's answer is a stack of RFCs, each of which has a classic mistake:
- The authorization endpoint becomes an open redirector when it redirects an error before the client and redirect URI are known good.
- Redirect URIs are matched by prefix, or
https://good.example@evil.example/is read as good. - PKCE is optional, or
plainis accepted โ which sends the verifier through the browser. - Tokens are not bound to an audience, so a token minted for one server is accepted by another.
- Refresh tokens are long-lived bearer values with no reuse detection, so a stolen one works for ever alongside the real one.
- The client's metadata document is fetched without an SSRF guard, and the client id is a URL a stranger chose.
What it does
- ๐ Metadata โ RFC 9728 protected resource metadata and RFC 8414 authorization server metadata from one
Settings, so what is advertised is what is enforced. - ๐ชช Clients โ Client ID Metadata Documents (fetched with the SSRF policy on, no redirects, 5 KB, cached within 5 minutes and a day) and RFC 7591 dynamic registration. Always public clients (
none), which is what Claude, ChatGPT and Claude Code use. - ๐ Authorization code + PKCE S256 only, exact redirect URIs (any port on loopback, OAuth 2.1 ยง8.4.2),
statepassed through,isson every response (RFC 9207). - ๐ฏ Resource indicators โ a request for another resource is
invalid_target; every token is bound to the resource andauthenticate()refuses one bound to another. - ๐ Rotating refresh tokens with reuse detection โ a spent refresh token, or a code, presented twice revokes the whole grant.
- ๐งพ Opaque tokens stored as SHA-256 digests, short-lived access tokens (an hour by default), prefixed so a leaked one is recognisable.
- ๐ซ Revocation (RFC 7009) โ a refresh token takes its grant with it; the answer never says whether a token existed.
- ๐ช Scope hierarchies โ
writeimpliesread, expanded once, at issue.
Requirements
PHP 8.5+, ext-mbstring, sugarcommerce/oauth (PKCE), sugarcommerce/generate (tokens), sugarcommerce/http-client (metadata documents).
Installation
composer require sugarcommerce/oauth-server
Usage
use SugarCommerce\OAuthServer\{AuthorizationRequest, AuthorizationServer, ClientDocuments, Settings}; $settings = new Settings( issuer: 'https://api.example.com', resource: 'https://api.example.com/mcp', authorization_endpoint: 'https://app.example.com/oauth/authorize', token_endpoint: 'https://api.example.com/oauth/token', revocation_endpoint: 'https://api.example.com/oauth/revoke', registration_endpoint: 'https://api.example.com/oauth/register', scopes: [ 'shop:read' => [], 'shop:write' => [ 'shop:read' ] ], resource_name: 'Example Shop', ); $server = new AuthorizationServer( $settings, new MyStore(), new ClientDocuments() );
The two discovery documents
// GET /.well-known/oauth-protected-resource/mcp (and /.well-known/oauth-protected-resource) json_encode( $settings->resource_metadata() ); // GET /.well-known/oauth-authorization-server json_encode( $settings->server_metadata() );
Send both with Access-Control-Allow-Origin: *: browser-based clients read them. An unauthenticated MCP request answers 401 with WWW-Authenticate: Bearer resource_metadata="<$settings->resource_metadata_url()>" โ sugarcommerce/mcp-server's Http::headers() writes it.
The consent screen
$request = $server->authorize( $_GET ); if ( $request instanceof OAuthError ) { $url = $server->error_url( $_GET, $request ); // null: show $request->description to the person. Never redirect it. } // Not signed in? Sign them in first, keeping the query. // Show: $request->client->name, $request->redirect_host(), $request->scopes, // and a warning when $request->client->local_only(). // The form posts $request->params back, plus a CSRF token. // On Approve (after checking CSRF, and re-running authorize() on the posted params): redirect( $server->approve( $request, $user_id, $approved_scopes, [ 'tenant' => $tenant ] ) ); // On Deny: redirect( $server->deny( $request ) );
Never approve without the person pressing a button.
Token, revocation and registration endpoints
$reply = $server->token( $_POST ); // application/x-www-form-urlencoded $reply = $server->revoke( $_POST ); $reply = $server->register( json_decode( $body, true ) ?? [] ); http_response_code( $reply->status ); foreach ( $reply->headers() as $name => $value ) header( "$name: $value" ); echo $reply->json();
Rate-limit all three, and the authorization endpoint (it may fetch a URL).
The resource server
$who = $server->authenticate( $bearer ); // null, or [ 'grant' => Grant, 'scopes' => [...] ]
Storage
Implement Store. Two methods must be atomic โ spend_code() and spend_refresh() โ because each is what stops a stolen credential being used twice by two racing requests: one conditional UPDATE โฆ WHERE spent_at IS NULL, and the affected-row count is the answer. A code presented again must reach the grant it became even when the replay lands before that grant is recorded: spend_code() marks a spent code as replayed, and create_grant() makes the grant and records it against the code in one transaction only if the code is not so marked โ returning null, and making nothing, when it is. revoke_grant() must end every token of the grant. tests/MemoryStore.php is the contract in arrays.
What it does not do
- Confidential clients, client secrets,
private_key_jwt. Every client is public. A metadata document namingprivate_key_jwtis treated asnone; one naming a shared secret is refused. - OpenID Connect. No id tokens, no
openid-configuration. - Sign the person in, or decide anything for them. That is your consent screen.
- Rate limits. Pair with
sugarcommerce/rate-limit.
Testing
composer install
composer test
102 tests โ canonical resource URIs, redirect URI rules and loopback ports, well-known paths, registration and metadata-document validation (Claude's and ChatGPT's real documents), the SSRF-guarded fetch and its cache, both metadata documents, the full code + PKCE flow, plain and missing PKCE, redirect and client mismatches never redirected, wrong verifier, expired and replayed codes, other-resource tokens refused, refresh rotation, reuse revoking the grant, scope narrowing, revocation, and registration.
License
GPL-2.0-or-later