gijsbos / apiserver-oauth2
API Server OAuth2
Requires
- php: >=8.4
- gijsbos/apiserver: ^1.13
- gijsbos/http: ^1.2
- psr/clock: ^1.0
- web-token/jwt-framework: ^4.2
Requires (Dev)
- phpunit/phpunit: ^10.0
Suggests
- ext-apcu: Caches fetched JWKS keys across requests
Provides
None
Conflicts
None
Replaces
None
README
Introduction
OAuth2 / JWT access-token verification for gijsbos/apiserver.
Verifies Bearer tokens as JWTs (RFC 7519) against a JSON Web Key Set — supplied directly, fetched from a
URL, or discovered via OpenID Connect (.well-known/openid-configuration) — and provides #[HasScope] /
#[HasRole] route attributes for scope- and role-gated authorization, built on apiserver's generic
#[RequiresAuthority] mechanism.
apiserver itself stays OAuth2-ignorant: SecurityContext only decides whether a path needs some
credential, and RequiresAuthority only knows how to run a pluggable AuthorityCheckInterface. This
package supplies the OAuth2-specific pieces on top of that: token verification, key resolution, and two
ready-made checks (HasScope, HasRole).
Requirements
- PHP:
>= 8.4 - gijsbos/apiserver:
^1.13 - gijsbos/http:
^1.2 - web-token/jwt-framework:
^4.2 - psr/clock:
^1.0 - ext-apcu (optional, recommended) — used automatically to cache fetched JWKS keys across requests when available
Installation
composer require gijsbos/apiserver-oauth2
Setup
1. Define a verification policy
use gijsbos\ApiServer\OAuth2\Components\AccessTokenVerificationPolicy; $policy = new AccessTokenVerificationPolicy( issuerUri: "https://issuer.example.com", // used for OIDC discovery and to validate the "iss" claim keysUri: "https://issuer.example.com/keys", // or supply "keys" directly, or omit and rely on issuerUri alone audience: "your-api-audience", // optional - validates the "aud" claim kidRequired: true, // optional - require a "kid" in the token header );
At least one of keys, keysUri, or issuerUri must be set, and allowedAlgorithms (default: RS256) must
not be empty. See the class docblock for the full precedence order between the key sources
(keys > keysUri > issuerUri) and what each one does to iss validation.
2. Use OAuth2Server instead of Server in your entrypoint
use gijsbos\ApiServer\OAuth2\OAuth2Server; $server = new OAuth2Server($policy, [ // same options as gijsbos\ApiServer\Server ]); $server->listen();
OAuth2Server takes the policy as its first argument and wires AuthenticationVerifier::$viaBearer from it,
so any #[RequiresAuthority]-based check — including SecurityContext-gated paths and HasScope /
HasRole — can verify Bearer tokens with no further setup. Only the Bearer scheme is supported; Basic
credentials are rejected with schemeNotSupported.
3. Gate paths broadly with SecurityContext
SecurityContext lives in gijsbos/apiserver, not this package, but it's how you decide which paths
need a token at all before anything OAuth2-specific runs:
use gijsbos\ApiServer\Server; use gijsbos\ApiServer\SecurityContext; Server::$securityContext = new SecurityContext() ->permitAll("/health", "/.well-known/**") ->requireAuth("/api/**");
4. Gate individual routes by scope or role
class UserController extends RouteController { #[GetRoute('/user/{id}/')] #[HasScope('user:read')] public function getUser(/* ... */) { /* ... */ } #[DeleteRoute('/user/{id}/')] #[HasScope('user:delete,admin:all')] // comma-separated - any one match is enough public function deleteUser(/* ... */) { /* ... */ } #[PostRoute('/admin/settings')] #[HasRole('admin')] public function updateSettings(/* ... */) { /* ... */ } }
HasScope checks the token's scp / scopes / scope claim and HasRole checks roles / role. In both
cases the first claim present wins (there is no fallback to a later one), and the claim may be either a
delimited string (space, or comma) or a JSON array of strings. A claim of any other type, or an array holding
anything but strings, is treated as malformed and denied. Matching is exact: case-sensitive, no prefixes.
Both attributes accept a comma-separated string or an array; multiple values are OR'd together. Empty
entries are ignored, so an attribute without any usable value can never be satisfied and always denies. When
a route carries both #[HasScope] and #[HasRole], both must pass.
Denials are 403 responses with these error codes:
| Attribute | Error code |
|---|---|
HasScope |
insufficientScope — the camelCase form of RFC 6750 §3.1's insufficient_scope (all error codes in this package are camelCase) |
HasRole |
insufficientRole — roles aren't part of the OAuth2 spec, so there's no RFC code for that case |
Token problems are 401 responses: authorizationRequired, authorizationHeaderInvalid, schemeNotSupported,
tokenInvalid, tokenHeaderInvalid, tokenKeyNotFound, tokenKeyInvalid, tokenPayloadInvalid,
tokenKeysUnavailable and tokenIssuerUnavailable.
Custom authority checks
For anything beyond scope/role, implement AuthorityCheckInterface yourself and use apiserver's
#[RequiresAuthority] directly:
use gijsbos\ApiServer\Attributes\Route; use gijsbos\ApiServer\Attributes\RequiresAuthority; use gijsbos\ApiServer\Interfaces\AuthorityCheckInterface; class IsAccountOwnerCheck implements AuthorityCheckInterface { public function execute(Route $route, array $authority) { // Inspect $route->getData() (or look anything else up yourself), // throw to deny, return normally to allow. $authority is whatever // was passed as the attribute's second argument. } } #[RequiresAuthority(IsAccountOwnerCheck::class, [])]
HasScope and HasRole are themselves just RequiresAuthority subclasses that supply ScopeVerifier /
RoleVerifier as the check — following the same pattern is the intended way to extend authorization
beyond what this package ships.
Components
| Class | Purpose |
|---|---|
OAuth2Server |
Extends Server; wires OAuth2 bearer-token verification from a registered policy. |
AccessTokenVerificationPolicy |
Configures issuer, key source, audience, allowed algorithms, kid requirement. |
AccessTokenVerifier |
Verifies a JWT's header, signature, and standard claims (exp, nbf, iss, aud) against a policy. |
JwksResolver |
Fetches public keys directly, from a URL, or via OpenID Connect discovery; caches in APCu when available. |
SystemClock |
Default PSR-20 clock for exp/nbf checks. AccessTokenVerifier accepts any Psr\Clock\ClockInterface, e.g. a frozen clock in tests. |
HasScope / ScopeVerifier |
Route attribute + backing check for scope-gated authorization. |
HasRole / RoleVerifier |
Route attribute + backing check for role-gated authorization. |
Testing
vendor/bin/phpunit
The route tests send real HTTP requests to index.php, so the package must be served (e.g. by MAMP/Apache) and
BASE_URL in .env must point at it, e.g. BASE_URL=http://localhost/apiserver-oauth2.
Contributions
Contributions are welcome! Please open an issue or submit a pull request following our contribution guidelines.