OAuth 2.1 / MCP Authorization
OAuth 2.1 / MCP Authorization
Hazaar's OAuth component implements the subset of the MCP Authorization specification (protocol revision 2026-07-28) needed to turn an application into an authorization server (AS), a protected resource server (RS), or both — without hand-rolling OAuth.
It does not duplicate existing framework machinery:
- Access tokens are signed and verified with the same
Hazaar\Auth\Session\Backend\JWTclass used for session tokens (RS256 by default — the AS signs with a private key, the RS verifies with the matching public key). - Persistence goes through
Hazaar\DBI, with ready-to-use default table-backed implementations. - Endpoints are ordinary controller actions; the RS guard is ordinary middleware.
- Login is not reimplemented. The AS delegates "is there a logged-in user?" to your existing
Hazaar\Auth\Adapter— however your app already authenticates users (password, SSO, whatever) is what runs during/authorize.
Authorization server
Extend Hazaar\Controller\OAuth (which extends the protocol engine, Hazaar\OAuth\Server) and implement one abstract method, consent():
namespace App\Controllers;
use Hazaar\OAuth\Client;
use Hazaar\OAuth\ConsentDecision;
class AuthServer extends \Hazaar\Controller\OAuth
{
protected function consent(Client $client, array $scope, string $userIdentity): ConsentDecision
{
// First-party clients can auto-approve; anything else should render a consent page and
// return ConsentDecision::pending($response) until the user has responded.
return ConsentDecision::approve($scope);
}
}Mount its four actions in route.php:
use App\Controllers\AuthServer;
use Hazaar\Application\Router;
Router::get('/.well-known/oauth-authorization-server', [AuthServer::class, 'metadata']);
Router::get('/authorize', [AuthServer::class, 'authorize']);
Router::post('/authorize', [AuthServer::class, 'authorize']); // consent form resubmission
Router::post('/token', [AuthServer::class, 'token']);
Router::post('/register', [AuthServer::class, 'register']);That's the whole server:
metadata()— RFC 8414 discovery document.authorize()— validatesclient_id,redirect_uri(exact match against the registered client), PKCE (code_challenge+S256— required, OAuth 2.1 style),scope, and the RFC 8707resourceparameter; requires an authenticated user (see below); calls yourconsent()hook; and on approval mints a short-lived, single-use authorization code and redirects back withcode,state, andiss(RFC 9207).token()— theauthorization_codegrant (verifies the code, PKCE verifier, redirect URI and client) and therefresh_tokengrant (rotates the refresh token on every use). Issues a signed JWT access token withaudset to the requestedresource.register()— RFC 7591 dynamic client registration.
Who's logged in?
authorize() needs to know if there's an authenticated user before it can ask for consent. By default it checks the request's session attribute (set by Hazaar\Auth\Middleware\Session, if that's in front of the route) and falls back to Hazaar\Auth\Adapter::getInstance(). If nobody's logged in, it redirects to oauth.server.login_path (default /login) with a return_to parameter carrying the original /authorize request, so your login page can bounce the user straight back once they've authenticated:
// App\Controllers\Login, roughly
public function index(): Response
{
// ... validate credentials, call $this->auth->authenticate(...) ...
return new Response\HTTP\Redirect($this->request->get('return_to') ?? '/');
}Override authenticatedUser(): ?string if your app resolves identity some other way.
Consent
ConsentDecision has three outcomes:
ConsentDecision::approve(array $scope)— grant the given scopes (may be a subset of what was requested).ConsentDecision::deny()— the flow ends with anaccess_deniedredirect.ConsentDecision::pending(Response $response)— the app is rendering its own consent UI;$responseis returned to the browser as-is. Your consent page should re-submit to the same/authorizeaction (GET or POST both route there) once the user responds, at which pointconsent()runs again and can read the decision from the request.
Storage
Three interfaces, all under Hazaar\OAuth\Interface, cover persistence. Each follows the same shape as Hazaar\Auth\Interface\SessionBackend — a __construct(array $config) plus the methods needed for that concern — so you can bind a custom implementation the same way you'd bind a custom auth backend:
interface ClientRepository
{
public function __construct(array $config);
public function find(string $clientId): ?Client;
public function save(Client $client): void;
}
interface AuthorizationCodeStore
{
public function __construct(array $config);
public function save(AuthorizationCode $code): void;
// Atomically fetches and invalidates the code — codes are single-use.
public function consume(string $code): ?AuthorizationCode;
}
interface RefreshTokenStore
{
public function __construct(array $config);
public function save(RefreshToken $token): void;
// Validates and revokes in one call — refresh tokens rotate on every use.
public function consume(string $token): ?RefreshToken;
public function revoke(string $token): void;
}Hazaar\OAuth\Store\DBI\{ClientRepository,AuthorizationCodeStore,RefreshTokenStore} are the default implementations, bound automatically unless overridden. They read/write plain tables (oauth_clients, oauth_authorization_codes, oauth_refresh_tokens by default) via Hazaar\DBI — there is no bundled migration, since any text/varchar/json column types work and the columns are listed in each class's docblock. Refresh tokens are hashed (SHA-256) before storage, the same way a password would be.
Access vs. refresh tokens
Access tokens are JWTs — self-verifying, so a resource server never needs to call back to the AS or a database. Refresh tokens are deliberately not JWTs: they're opaque and store-backed, because they're only ever presented back to this AS's own /token endpoint and need to be revocable and rotated, which OAuth 2.1 requires for public clients.
Device authorization grant (RFC 8628)
Some clients can't receive a browser redirect at all: a CLI, a sandboxed editor extension, a TV. For those, the AS can also run the device authorization grant. It's off by default; set oauth.server.device_authorization_enabled to true and mount two more actions:
Router::post('/device_authorization', [MyAuthServer::class, 'deviceAuthorization']);
Router::match(['GET', 'POST'], '/device/verify', [MyAuthServer::class, 'deviceVerify']);The flow:
- The client registers with
grant_types: ["urn:ietf:params:oauth:grant-type:device_code"](plusrefresh_tokenif it wants one). It needs noredirect_uris. - It
POSTsclient_id(plus optionalscopeandresource) to/device_authorizationand gets back adevice_code, a shortuser_codelikeBCDF-GHJK, theverification_uriandverification_uri_complete,expires_inand a pollinginterval. - It shows the user the code and opens the verification URI, then polls
/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_codeand itsdevice_code. Until the user decides it getsauthorization_pending; polling faster thanintervalgetsslow_downand adds 5 seconds to the interval. - The user, logged in to your app, confirms the code on your verification page, which submits
user_codetodeviceVerify(). That runs thedeviceConsent()hook (defaulting toconsent()) and responds with JSON —{"status": "approved"|"denied", "client_name": ...}— rather than a redirect. An unauthenticated user is sent tologin_pathfirst; codes are only ever looked up for a logged-in user. - The next poll returns the usual access token (and refresh token), or
access_denied, orexpired_tokenoncedevice_code_ttlhas passed.
User codes are 8 letters from RFC 8628's consonant-only alphabet and are matched case-insensitively, ignoring the separator. Device authorizations live in a fourth store, DeviceAuthorizationStore, resolved only when the device grant is used:
interface DeviceAuthorizationStore
{
public function __construct(array $config);
public function save(DeviceAuthorization $authorization): void;
// Keyed on the user code: a lookup by user code needn't carry the plaintext device code.
public function update(DeviceAuthorization $authorization): void;
public function findByDeviceCode(string $deviceCode): ?DeviceAuthorization;
public function findByUserCode(string $userCode): ?DeviceAuthorization;
// Atomically fetches and removes an approved/denied authorization; leaves pending ones alone.
public function consume(string $deviceCode): ?DeviceAuthorization;
}The default Hazaar\OAuth\Store\DBI\DeviceAuthorizationStore uses an oauth_device_codes table and, like the refresh token store, keeps only a SHA-256 hash of each device code.
Resource server
Protect an endpoint with Hazaar\OAuth\Middleware\ResourceGuard, backed by a Hazaar\OAuth\ResourceServer:
// route.php
use Hazaar\Application\Router;
use Hazaar\OAuth\Middleware\ResourceGuard;
use Hazaar\OAuth\ResourceServer;
$resourceServer = new ResourceServer(); // reads config from `oauth.resource`
Router::get('/.well-known/oauth-protected-resource', fn () => $resourceServer->metadata());
Router::post('/mcp', [Tools::class, 'index'])->middleware(new ResourceGuard($resourceServer, ['mcp']));ResourceGuard extracts the bearer token, verifies its signature/expiry/audience/scope, and on success sets an oauth request attribute with the resolved Hazaar\OAuth\AccessTokenClaims (subject, scope, audience, clientId) — read it from a controller via $this->request->getAttribute('oauth'). On failure it returns the response directly:
401withWWW-Authenticate: Bearer error="invalid_token", resource_metadata="..."when the token is missing, malformed, expired, or not audience-bound to this resource.403withWWW-Authenticate: Bearer error="insufficient_scope", scope="...", ...when the token is valid but missing a required scope.
Both follow RFC 6750/RFC 9728, so a compliant MCP client knows to re-discover metadata and re-run the authorization flow.
Configuration
Everything lives under the oauth key in configs/app.php.
oauth.server (authorization server)
| Key | Default | Purpose |
|---|---|---|
issuer | app base URL | This AS's canonical identifier; sent as iss and embedded in access tokens. |
authorization_path | /authorize | Advertised in metadata — must match where you mounted it. |
token_path | /token | Same, for the token endpoint. |
registration_path | /register | Same, for dynamic client registration. |
scopes_supported | [] | Advertised scopes. |
token_endpoint_auth_methods_supported | none, client_secret_basic, client_secret_post | Accepted client auth methods. |
code_ttl | 120 | Authorization code lifetime, seconds. |
access_token_ttl | 3600 | Access token lifetime, seconds. |
refresh_token_ttl | 1209600 | Refresh token lifetime, seconds (14 days). |
login_path | /login | Where authorize() redirects unauthenticated users. |
clients | Hazaar\OAuth\Store\DBI\ClientRepository | Class name or instance implementing ClientRepository. |
codes | Hazaar\OAuth\Store\DBI\AuthorizationCodeStore | Class name or instance implementing AuthorizationCodeStore. |
refresh_tokens | Hazaar\OAuth\Store\DBI\RefreshTokenStore | Class name or instance implementing RefreshTokenStore. |
clients_options / codes_options / refresh_tokens_options | [] | Config passed to the store's constructor (e.g. table, database) when bound by class name. |
device_authorization_enabled | false | Turns on the RFC 8628 device grant (deviceAuthorization(), deviceVerify() and the device_code grant at /token). |
device_authorization_path | /device_authorization | Advertised in metadata as device_authorization_endpoint. |
device_verification_uri | /device | Your verification page (absolute URL, or a path relative to issuer), returned to devices as verification_uri. |
device_code_ttl | 600 | Device/user code lifetime, seconds. |
device_poll_interval | 5 | Minimum seconds between polls before slow_down. |
device_codes / device_codes_options | Hazaar\OAuth\Store\DBI\DeviceAuthorizationStore / [] | Class name or instance implementing DeviceAuthorizationStore, and its constructor config. |
jwt | [] | Passed straight to the JWT session backend used to mint access tokens — same options as auth.jwt (alg, privateKey/privateKeyFile, publicKey/publicKeyFile, passphrase, ...). Defaults to RS256. |
Access tokens are portable, not fingerprint-bound
The AS forces jwt.fingerprintKeys to [] when minting access tokens, unlike session cookies. A bearer token may be minted by one HTTP client and used later by an entirely different process (an MCP client library, a server-to-server call) — binding it to the User-Agent/Accept headers seen at mint time would break that legitimate case. Configure oauth.resource.jwt.fingerprintKeys to [] to match.
oauth.resource (resource server)
| Key | Default | Purpose |
|---|---|---|
resource | (required) | This resource's canonical URI (RFC 8707) — must exactly match the resource parameter clients request and the aud claim on tokens. |
authorization_servers | [] | Issuer URLs of trusted authorization servers; the first entry is also used as the JWT issuer to verify against unless jwt.issuer is set explicitly. |
scopes_supported | [] | Advertised scopes. |
metadata_url | derived from resource | Override if metadata isn't served from the well-known path RFC 9728 §3.1 would derive. |
jwt | [] | Passed to the verify-only JWT backend — typically just publicKey/publicKeyFile and issuer. |
Example: DABOM
DABOM's user-service acts as the authorization server; mcp-service protects its /mcp endpoint as a resource server, with access tokens audience-bound to https://www.dabom.io/mcp.
// user-service configs/app.php
'oauth' => [
'server' => [
'issuer' => 'https://accounts.dabom.io',
'scopes_supported' => ['mcp'],
'jwt' => [
'alg' => 'RS256',
'privateKeyFile' => 'dabom_oauth_private.pem',
'publicKeyFile' => 'dabom_oauth_public.pem',
],
],
],// mcp-service configs/app.php
'oauth' => [
'resource' => [
'resource' => 'https://www.dabom.io/mcp',
'authorization_servers' => ['https://accounts.dabom.io'],
'scopes_supported' => ['mcp'],
'jwt' => [
'alg' => 'RS256',
'publicKeyFile' => 'dabom_oauth_public.pem', // same key pair, public half only
],
],
],mcp-service never needs the private key or a database connection to user-service's client/code/ token tables — token verification is entirely local, via the public key.
Where to next
- MCP Server — protect an MCP tool endpoint with
ResourceGuard. - Authentication — the
Auth\Adapter/SessionBackend/SessionTransportmachinery this component builds on.