crystoline/xpsr-auth-jwt

JSON Web Tokens for XPSR APIs, on lcobucci/jwt: the jwt guard driver, which the skeleton's api guard uses by default, an issuer for sign-in endpoints, and a secret generator.

composer require crystoline/xpsr-auth-jwt
php xpsr auth:jwt-secret --write

Add the provider to app.registry in config/app.php, after AuthServiceRegistry:

Crystoline\Xpsr\Registry\AuthServiceRegistry::class,
Crystoline\Xpsr\Auth\Jwt\JwtServiceRegistry::class,

Issuing tokens

use Crystoline\Xpsr\Auth\Jwt\JwtIssuer;
use Crystoline\Xpsr\Auth\UnauthenticatedException;

$user = $auth->verify($email, $password) ?? throw new UnauthenticatedException();
$token = $jwt->issue($user, ['tasks:read', 'tasks:write']);          // scopes; ['*'] by default
$token = $jwt->issue($user, ['tasks:read'], ttl: '15 minutes');      // another lifetime

$token->token;       // send to the client
$token->expiresAt;   // DateTimeImmutable, UTC
$token->id;          // the jti claim

Clients send it as Authorization: Bearer <token> to routes behind Authenticate::guard('api'). The token carries iss, aud, sub (the user's ID), jti, iat, nbf, exp, scope (space-separated, as in OAuth), and guard: the guard it is for. Only that guard accepts it, so with users and admins in separate tables, $jwt->issue($admin, guard: 'admin-api') gives a token that never counts on the users' api guard. Without guard, the first guard with the jwt driver is used.

A request's token counts only with a valid signature from the configured algorithm and key, the configured issuer and audience, and the current time between nbf and exp, give or take the leeway. Tokens signed with another algorithm, including unsigned alg: none tokens, are refused. The user is looked up by ID on every request, so a deleted user's tokens stop working.

A JWT cannot be revoked before it expires. Keep the lifetime short and let clients sign in again, or use the token driver of crystoline/xpsr-database for revocable tokens. Changing the secret ends every token at once.

Configuration

auth.jwt in config/auth.php:

Option Default Meaning
algorithm HS256 HS256, HS384, or HS512 with secret; RS256 or ES256 with key files.
secret AUTH_JWT_SECRET For HS*: at least 32 bytes (48 for HS384, 64 for HS512). base64:... is decoded.
private_key, public_key For RS256 and ES256: PEM files, relative to the project root. Another service that only verifies tokens needs only the public key.
ttl 1 hour How long a token is valid.
issuer app.url The iss claim; tokens from another issuer are refused.
audience the issuer The aud claim.
leeway 30 Seconds of clock difference tolerated, 0 to 300.

For RS256:

openssl genrsa -out var/jwt-private.pem 2048
openssl rsa -in var/jwt-private.pem -pubout -out var/jwt-public.pem
'jwt' => ['algorithm' => 'RS256', 'private_key' => 'var/jwt-private.pem', 'public_key' => 'var/jwt-public.pem'],

Keep the private key and the secret out of version control.

Commands

php xpsr auth:jwt-secret                  # print a new secret (64 random bytes, base64)
php xpsr auth:jwt-secret --write          # set AUTH_JWT_SECRET in .env; --force replaces one
php xpsr auth:jwt ada@example.com --scope=tasks:read --ttl="15 minutes"
php xpsr auth:jwt root@example.com --guard=admin-api   # a user from that guard's provider

Services

Service Provides
Crystoline\Xpsr\Auth\Jwt\JwtIssuer issue($user, $scopes, $ttl).
auth.driver.jwt The guard factory AuthManager uses for 'driver' => 'jwt'.

Requires PHP 8.4+ with the openssl and sodium extensions.