OAuth authorization server¶
naf/oauth-server provides an OAuth 2.0 authorization server and OpenID Connect provider
using existing NAF Auth accounts. Other applications can obtain access tokens and, with the
openid scope, verify user identities.
This guide requires naf/oauth-server 0.2.6+ and PHP 8.3+.
Deployment requires a configured issuer, database migrations, registered clients and scopes. User grants also need local login; OpenID Connect needs signing keys. The sections below describe those steps and the guarantees required from the database.
Required packages
In an existing NAF application:
composer require naf/oauth-server
Also installs these transitive dependencies: naf/auth, naf/form, naf/session.
Supported grants and protocols¶
The server uses NAF Auth identities and access checks. It does not create a separate user model or account registration system.
| Grant | Purpose |
|---|---|
| Authorization Code | with PKCE/S256, mandatory for every client, confidential ones included |
| Refresh Token | with strict rotation and replay detection |
| Client Credentials | for an application acting as itself |
OpenID Connect comes with it: ID tokens, UserInfo, discovery and a published key set. A client
opts into it per request by asking for the openid scope — it is not a mode the server is in.
Deliberately not offered: the Implicit and Password grants, wildcard redirect URIs, and the
max_age and prompt parameters.
The authorization code flow with PKCE, as a client application uses it. Step 2 sends
client_id, redirect_uri, scope, state and the code_challenge; step 8 authenticates
confidential clients and sends the original code_verifier. The ID token is returned only
when the client asked for the openid scope.
sequenceDiagram
autonumber
participant U as Browser
participant C as Client app
participant S as OAuth server
C->>C: code_verifier, S256 challenge
C->>U: redirect to /oauth/authorize
U->>S: GET /oauth/authorize
S->>U: login, then consent screen
U->>S: POST /oauth/authorize
S->>U: redirect with code, state
U->>C: GET redirect_uri?code=…
C->>S: POST /oauth/token (code, verifier)
S-->>C: access, refresh, ID token
C->>S: API call with Bearer token
Configuration¶
// app/config.php
return [
'public_url' => 'https://id.example.com',
'oauth_server' => [
'name' => 'Acme ID',
'scopes' => [
'posts.read' => ['label' => 'Read your posts', 'permission' => 'posts.view'],
'posts.write' => ['label' => 'Write your posts', 'permission' => 'posts.edit'],
],
],
];
Add this configuration to app/config.php. The issuer and display name are required;
application scopes define the API operations clients may request.
audience stays empty for the ordinary case — one server, one API, tokens that carry no
target. Set it only when this installation serves an API that clients name explicitly with the
resource parameter:
'oauth_server' => ['audience' => 'https://reports.example.com', …],
When audience validation is configured, a token granted for another resource is rejected. The grant's resource persists through refresh; changing client registration does not retarget it.
oauth_server:name is a required display label. It does not change the issuer, client IDs,
redirect URIs or subject identifiers. Relying parties configure their own display labels.
Scopes describe requested API access; permissions describe the account's authority. A scope
can map to a permission. If permission is omitted, the scope name is used; a string scope
definition supplies its label. Explicit permission => null means no additional account
permission and is appropriate only for scopes you deliberately allow that way. Boolean,
numeric, array, object, empty-string and whitespace-only values are configuration errors;
they never mean null. OAuth server 0.2.5+ validates the whole policy when it is constructed
and before the built-in token endpoint can issue a grant. Alexa setup and diagnostics
use the same policy. Correct the configuration before serving requests; this error is a server
failure, not a client's invalid_scope.
The built-in openid, profile and email scopes expose the consenting account's identity
and allowed profile data. They do not require an additional application permission.
The sign-in page is found on its own when a route is named login; otherwise name it in
oauth_server:login_route. PKCE and the protocol errors are already set.
Lifetimes¶
All lifetimes are integers in seconds:
| Key | Default | Effect |
|---|---|---|
oauth_server:access_token_ttl |
3600 |
Access-token lifetime: one hour. |
oauth_server:refresh_token_ttl |
2592000 |
Refresh-token lifetime: 30 days. Local browser-session lifetime is configured separately. |
oauth_server:id_token_ttl |
3600 |
ID-token lifetime: one hour; minimum retired-key retention. |
oauth_server:code_ttl |
60 |
Authorization-code lifetime: one minute. |
oauth_server:consent_ttl |
600 |
Pending consent-request lifetime: ten minutes. |
A shorter access-token lifetime reduces the time a leaked token can be used, but requires more frequent renewal. A refresh token permits renewal for its own lifetime and needs separate protection and revocation handling; it is not a longer browser login session. Choose lifetimes for the client and data being accessed, rather than increasing every TTL to avoid renewal. Expiry and explicit revocation are separate checks.
Profile claims¶
UserInterface::getProfile() supplies the standard profile data. Configure a claims mapper
only when the application needs additional mapping.
Configure oauth_server:claims only for something the profile does not cover:
'oauth_server' => ['claims' => static fn(User $user): array => ['locale' => $user->locale]],
The mapper adds to the profile. Output is filtered by granted scopes: email releases
email claims and profile releases profile claims. Without those scopes, their associated
claims are omitted. ID tokens still include the required protocol and subject claims.
Database and key setup¶
Install naf/cli for the setup and management commands, and configure a PDO connection
(usually through naf/database). Existing local users and a login route are prerequisites;
see Authentication.
composer require naf/cli naf/database
vendor/bin/naf oauth:server:setup
The installation command creates a signing key when absent and reports remaining setup requirements. Run it after configuring the database and local login.
vendor/bin/naf oauth:server:doctor answers the same question later, when something has
stopped working and you want to know which part.
Register a client¶
vendor/bin/naf oauth:client:create "Acme Intranet" \
--redirect=https://intranet.example.com/auth/callback \
--scope=posts.read,posts.write
Acme Intranet
Client ID 9f2c…
Client secret 7b41…
Copy the secret now. It is stored only as a hash and cannot be shown again.
Add --public for an application that cannot keep a secret — a mobile or desktop client. It is
then identified rather than authenticated, and PKCE is what protects its exchange.
vendor/bin/naf oauth:client:list shows what is registered.
A public client cannot be given --grant=introspection. A client id is not a secret — it
travels in every authorize URL — so a public client with that right would let anyone who has
ever seen a login link ask about anybody's tokens. The registry refuses the combination, and
the endpoint refuses it again.
Rotating a client secret¶
vendor/bin/naf oauth:client:rotate-secret 9f2c… # old one keeps working for a day
vendor/bin/naf oauth:client:rotate-secret 9f2c… --overlap=3600
vendor/bin/naf oauth:client:rotate-secret 9f2c… --now # after a leak
Secret rotation preserves the client ID and existing authorizations. Deploy the new secret to the client application during the configured overlap.
During the overlap, the current and previous secrets are accepted. Rotating again replaces
the previous secret; only two are retained. --now invalidates the previous secret immediately.
Use immediate rotation when the previous secret must no longer authenticate. Coordinate client deployment because its old configuration will fail.
The previous secret expires automatically. oauth:client:list and oauth:server:doctor
report open rotation windows for operational verification.
Protecting an API¶
use function Naf\OAuth\Server\token;
public function store(): ResponseInterface
{
token()->requireScope('posts.write');
$user = token()->user(); // your own model, or null for an application
…
}
requireScope() raises 401 when no usable token was presented and 403 when the one that was
does not reach. can() is the same check as a plain bool.
Token scopes and current permissions¶
- the token carries the scope — what the person agreed this application may do for them;
- the person still holds the permission behind it — what they may do at all.
User-token authorization checks the token scope and the account's current permission. A permission removed from the account is denied on a subsequent check.
For example, a token issued with posts.read cannot write posts even when its owner is an
administrator with posts.edit. Conversely, a token carrying posts.write cannot keep
writing after that account loses the mapped posts.edit permission. Check the scope at
each protected operation; signing in or recognizing a token alone does not authorize it.
A client-credentials token represents an application. user() returns null; authorization
uses the client's registered grants and token scopes.
Bearer authentication and sessions¶
There is no path from token() to a session. An expired, revoked or invented bearer token
cannot quietly fall back to whoever happens to be signed in with a cookie — a request with no
usable token has no usable token, whatever else it carries.
Request lifetime¶
OAuth Server 0.2.6 resolves the active request when token() is called. The default
ResourceServer reuses a TokenContext only for the same immutable PSR-7 request instance.
Successive App::handle() calls evaluate their own Authorization headers: a missing,
invalid or different token cannot inherit the preceding caller's identity or scopes.
Do not retain a TokenContext across requests.
For an explicit integration, pass the PSR-7 RequestInterface being authorized. This fragment
assumes $request is that request and the configured OAuth Server plugin has booted:
use Naf\OAuth\Server\Core\ResourceServer;
use function Naf\app;
$context = app()->container()->get(ResourceServer::class)->context($request);
$context->requireScope('posts.read');
Custom ResourceServer construction can use the optional currentRequest closure to resolve
the active request, or supply each request to context($request). Existing positional and
named constructor calls using request remain compatible; a fixed constructor request is
appropriate only for a service used for that one request. Calling context() without a
request or resolver raises LogicException.
The front controller boots a fresh application for each HTTP request. A custom persistent adapter must also manage the lifetime of other request-bound services: this bearer-context guarantee does not refresh dependencies that captured a previous request or session.
Endpoints¶
| Route | Purpose |
|---|---|
GET /oauth/authorize |
ask the person |
POST /oauth/authorize |
their answer — CSRF-protected like any other form |
POST /oauth/token |
the three grants |
POST /oauth/revoke |
RFC 7009 |
POST /oauth/introspect |
RFC 7662, for a resource server elsewhere |
GET/POST /oauth/userinfo |
claims about whoever a token was issued for |
GET /.well-known/openid-configuration |
OpenID Connect discovery |
GET /.well-known/oauth-authorization-server |
OAuth authorization-server metadata (0.2.4+) |
GET /.well-known/jwks.json |
the keys to check its signatures with |
Off with 'oauth_server' => ['routes' => false]. The endpoints stay reachable through the
container.
Programmatic token, revocation and introspection endpoints use protocol authentication
instead of the session-wide CSRF listener. Exemptions use exact route names in
csrf_exempt_routes. The consent route uses a separate request-bound CSRF check.
Introspection requires a confidential client explicitly registered with
--grant=introspection. Ordinary client registration does not grant token inspection.
OAuth-only discovery needs no signing key. It advertises authorization code, refresh,
client credentials and PKCE S256. Set public_url to the stable public HTTPS origin used in
the returned endpoint URLs.
Resource-bound code exchange¶
Since 0.2.4, authorization-code token requests can supply resource. The PDO store compares
it with the audience recorded at authorization, inside the atomic code claim. A mismatch
returns invalid_grant, consumes the attempted code and issues no tokens. Empty or non-string
resources return invalid_request before claiming the code. Omitting the parameter preserves
existing behavior. Refresh keeps the original audience without requiring resource again.
Custom stores can implement ResourceTokenStoreInterface, an additive extension of
TokenStoreInterface with redeemForResource(...). The existing redeem() signature is
unchanged. Supplying a resource to an adapter without the new contract returns invalid_target.
Verify the binding while atomically claiming the code, rather than in separate transactions.
For MCP, configure the canonical /mcp URI as the audience and register that exact URI for
each client. Use separate service and account-linking clients; Alexa+ automates
these registrations.
Redirect and reauthentication requirements¶
redirect_uri is required — in the authorization request and again at the token endpoint,
even for a client with exactly one registered URI. PKCE binds the exchange to the browser that
started it; this binds it to where the code was sent. They answer different questions, and
RFC 6749 §4.1.3 asks both.
Consent is always interactive and the server does not record the last authentication time.
Requests using prompt or max_age therefore have these results:
| Request | Result |
|---|---|
prompt=none |
interaction_required |
prompt=login |
invalid_request — re-authentication cannot be forced |
max_age=… |
invalid_request — the last authentication time is not recorded |
prompt=consent |
satisfied, because consent is always asked for |
These errors prevent clients from treating an ordinary session as proof of forced or recent reauthentication.
Consent screen¶
Shipped, and overridden by putting your own oauth/consent.phtml in the application's view
directory. Nothing in the central configuration has to change for that.
Its CSRF token belongs to the one request it was rendered for, derived from that request and keyed by the session. A single session-wide token cannot do that job: issuing the second one invalidates the first, so two consent screens open at once would break each other. This route is therefore exempt from the session-wide check and carries its own — it is not unprotected.
The browser is handed an opaque request id and nothing else — no client, no scope, no redirect URI. Consent is given for the request the server validated, and what comes back is only ever a key to look it up by. It is bound to the browser it was asked in and to the person who was asked: if somebody else signs in between the question and the answer, the request is void.
Signing keys¶
One key signs; every key stays published until you say otherwise. That is what makes rotating safe:
vendor/bin/naf oauth:keys:generate # the new key signs from now on
vendor/bin/naf oauth:keys:prune # remove the old ones, once their tokens have expired
prune measures retirement age, not creation age. Retain retired public keys until their
issued ID tokens have expired. The active key cannot be pruned, and retention shorter than
the configured ID-token lifetime is rejected.
They live in storage/oauth/keys unless oauth_server:key_path says otherwise — which it has
to, when several servers share one set: they are files, and nothing replicates them for you.
Keys are files, not rows, and are never generated on demand. A key that appears when a request needs one is a key that differs on the second server, and every token signed by the one that went away stops verifying.
Identity and account mapping¶
The server uses the application's Auth identity model, including UserInterface,
isActive() and getProfile(). Account activity and permission checks apply when the server
reloads the identity; there is no separate OAuth user table.
Protocol boundaries¶
Access tokens are opaque credentials stored as hashes. Local validation checks their current state; a remote resource server uses authenticated introspection. ID tokens are signed JWTs for OpenID Connect clients.
Refresh tokens rotate strictly. Reusing a spent token revokes its family; there is no grace window for duplicate refreshes. Clients should serialize refresh requests. The family row coordinates issuance with revocation so a concurrent successor cannot bypass replay detection.
An authorization code submitted with the wrong client, redirect URI, PKCE verifier or resource is consumed. An internal failure rolls the exchange back. Codes and bearer tokens are stored as SHA-256 hashes; client secrets use Auth's password hasher.
Account identity consists of provider and account ID. The public OIDC subject is a separate stable opaque identifier. Activity checks use the same Auth identity contract as local login.
Database concurrency¶
SQLite serializes writes and is useful for local examples. PostgreSQL and MySQL exercise concurrent row-locking behavior that SQLite alone cannot validate. Correct code redemption, refresh rotation and family revocation depend on transactions and the database's locking.
For server changes or a deployment-specific database validation, use disposable databases and the package's concurrency checks. They check simultaneous redemption and revocation/refresh races on SQLite, PostgreSQL and MySQL. Unit tests alone do not establish these guarantees.
Upgrading¶
No database migration is needed when upgrading from 0.2.3, 0.2.4 or 0.2.5 to 0.2.6.
Review custom shared ResourceServer services and follow the request-lifetime rules.
Review custom scope definitions: explicit permission values must be null or nonempty
strings. Ensure account-linking clients generate valid PKCE S256 verifiers and challenges
as described below.
For databases predating the current 0.2 schema, the schema is not backward compatible: authorizations carry their target API, families have their own rows, and clients carry the secret they last replaced. For a disposable development database created from an earlier schema, rebuild the tables after backing up anything you need. For a deployed database, write and test a forward migration; do not drop its tables:
vendor/bin/naf db:migrate up # after dropping the oauth_* tables of an earlier run
On MySQL the identifier columns are given a binary collation, because the default one is
case-insensitive and these hold values where aB and Ab are different identifiers.
PKCE format and consent responses¶
OAuth server 0.2.5+ requires a verifier of 43–128 unreserved ASCII characters:
letters, digits, -, ., _ and ~. The S256 challenge is exactly 43 unpadded base64url
characters. Missing or malformed values and unsupported plain return invalid_request;
a well-formed proof that does not match the code returns invalid_grant. Generate a fresh
cryptographically random verifier for every authorization. Format validation cannot establish
how a client generated its randomness. These requirements follow RFC 7636.
Consent HTML, protocol-error pages and redirects sent by the authorization controller have
Cache-Control: no-store, Pragma: no-cache, Referrer-Policy: no-referrer,
X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none'. Keep these
headers when overriding consent views or composing responses. Apply suitable protection to
the host's own login screen too.
The consent POST uses its request-bound HMAC CSRF check. Its exact-route exemption from
naf/form is intentional: replacing it with the session-wide token would invalidate parallel
consent forms. Stored consent binds the session, provider and user; the submitted form cannot
replace the validated client, redirect, resource or scopes.
The PDO store does not automatically delete old OAuth rows. Plan database retention and monitor growth. In particular, used codes and refresh-token tombstones support replay detection: deleting them while their authorization family can still issue tokens weakens that guarantee. Implement and test retention against family lifetimes and concurrent refresh/revocation before scheduling it. Keep subject mappings stable while their accounts remain linked. Key pruning is a separate operation and does not prune token or consent tables.