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.
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. |
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.