Authentication#
Session-based authentication where the UserProvider and password hashing are replaceable.
1. Implement a UserProvider#
<?php
use Polidog\Relayer\Auth\{Credentials, Identity, UserProvider};
final class PdoUserProvider implements UserProvider
{
public function __construct(private readonly \PDO $pdo) {}
public function findByIdentifier(string $identifier): ?Credentials
{
$stmt = $this->pdo->prepare(
'SELECT id, name, password_hash, roles FROM users WHERE email = ?'
);
$stmt->execute([\strtolower(\trim($identifier))]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (false === $row) {
return null;
}
return new Credentials(
identity: new Identity(
id: (int) $row['id'],
displayName: (string) $row['name'],
roles: \json_decode((string) $row['roles'], true) ?: [],
),
passwordHash: (string) $row['password_hash'],
);
}
}2. Bind the provider#
services:
App\Auth\PdoUserProvider: ~
Polidog\Relayer\Auth\UserProvider:
alias: App\Auth\PdoUserProviderRegistering a UserProvider automatically registers Authenticator in the DI container, so you can receive it by type.
3. Log in#
<?php
use Polidog\Relayer\Auth\Authenticator;
use Polidog\Relayer\Router\Component\PageContext;
return function (PageContext $ctx, Authenticator $auth): Closure {
$error = null;
$login = $ctx->action('login', function (array $form) use ($auth, $ctx, &$error): void {
$identity = $auth->attempt(
(string) ($form['email'] ?? ''),
(string) ($form['password'] ?? ''),
);
if (null === $identity) {
$error = 'The email address or password is incorrect.';
return;
}
$ctx->redirect('/dashboard');
});
return fn () => (<form action={$login}>{/* ... */}</form>);
};4. Protect a page#
Class style uses an attribute:
#[Auth]
final class DashboardPage extends PageComponent {}
#[Auth(roles: ['admin'])]
final class AdminPage extends PageComponent {}Function style uses PageContext:
return function (PageContext $ctx): Closure {
$user = $ctx->requireAuth(); // unauthenticated throws -> redirect/401
// $ctx->requireAuth(['admin']) requires a role
return fn () => <h1>Welcome {$user->displayName}</h1>;
};For conditional display, use $ctx->user() (null when not logged in). A page that takes a non-null Identity as an argument means "authentication required".
5. Token authentication (Firebase / Cognito)#
This is for setups where a client SDK (Firebase JS SDK, AWS Amplify, Cognito Hosted UI) issues a signed ID token. The framework does not hold the OAuth redirect / code exchange; it verifies the signature of the token that the client sends as Authorization: Bearer <jwt> against the IdP's JWKS, and validates the registered claims (iss, aud, exp/nbf/iat, and token_use for Cognito).
Bind a TokenVerifier#
TokenVerifier is the token version of UserProvider. It is created by the Firebase / Cognito factory and is complete with configuration alone (see the factory syntax in Services & DI; JWKS fetching goes through the HTTP client).
# config/services.yaml
services:
_defaults: { autowire: true, autoconfigure: true, public: true }
# Firebase
Polidog\Relayer\Auth\Token\TokenVerifier:
factory: ['Polidog\Relayer\Auth\Token\Firebase', 'verifier']
arguments:
$http: '@Polidog\Relayer\Http\Client\HttpClient'
$projectId: '%env(FIREBASE_PROJECT_ID)%'
$cacheDir: '%app.project_root%/var/cache/jwks'
# …or Cognito
# Polidog\Relayer\Auth\Token\TokenVerifier:
# factory: ['Polidog\Relayer\Auth\Token\Cognito', 'verifier']
# arguments:
# $http: '@Polidog\Relayer\Http\Client\HttpClient'
# $region: '%env(COGNITO_REGION)%'
# $userPoolId: '%env(COGNITO_USER_POOL_ID)%'
# $appClientId: '%env(COGNITO_APP_CLIENT_ID)%'
# $cacheDir: '%app.project_root%/var/cache/jwks'| Environment variable | Use | Example |
|---|---|---|
FIREBASE_PROJECT_ID | Firebase | my-app |
COGNITO_REGION | Cognito | ap-northeast-1 |
COGNITO_USER_POOL_ID | Cognito | ap-northeast-1_AbCdEf |
COGNITO_APP_CLIENT_ID | Cognito | 7f3k… (app client id) |
JWKS is cached to disk per URL and honors the response's Cache-Control: max-age. Key rotation is automatic: when a token arrives with a kid not in the cache, it refreshes exactly once (with rate limiting), so a flood of forged kid values is not amplified into a JWKS fetch storm. An unreachable JWKS endpoint is surfaced as an operational failure and does not silently log out all users.
Mode A — stateless API (Bearer per request)#
If you bind only TokenVerifier (no UserProvider), AuthenticatorInterface resolves to the stateless TokenAuthenticator, and the same #[Auth] / requireAuth() from section 4 work unchanged. The principal is re-derived from the Bearer header on every request, and nothing is persisted.
#[Auth(redirectTo: '')] // invalid/absent token -> 401 (no redirect)
final class ApiEndpoint extends PageComponent { /* ... */ }
#[Auth(roles: ['admin'])] // roles come from the token claims
final class AdminApi extends PageComponent { /* ... */ }Mode B — session login (verify once, then a cookie session)#
Verify the token on a login route and pass the resulting Identity to Authenticator::login(). The session Authenticator no longer requires a UserProvider, so even a Firebase/Cognito app that holds no local password can use a normal cookie session after the first request (see route.php in API Routes).
<?php
// src/Pages/auth/token/route.php — POST with Authorization: Bearer <jwt>
declare(strict_types=1);
use Polidog\Relayer\Auth\Authenticator;
use Polidog\Relayer\Auth\Token\BearerToken;
use Polidog\Relayer\Auth\Token\TokenVerifier;
use Polidog\Relayer\Auth\Token\AuthorizationHeader;
use Polidog\Relayer\Http\Response;
return [
'POST' => static function (
TokenVerifier $verifier,
Authenticator $auth,
AuthorizationHeader $header,
): Response {
$identity = $verifier->verify(
BearerToken::parse($header->value()) ?? '',
);
if (null === $identity) {
return Response::json(['error' => 'invalid token'], 401);
}
$auth->login($identity); // regenerate the session ID
return Response::json(['user' => $identity->toArray()]);
},
];Precedence (one rule, no hybrid layer)#
| Bound service | AuthenticatorInterface is… | Notes |
|---|---|---|
UserProvider | session Authenticator | Password app (traditional behavior). |
TokenVerifier only | TokenAuthenticator | Token-first API. #[Auth] enforces Bearer. |
| Both | session Authenticator | Session-first. TokenAuthenticator can be injected by type on specific API routes. |
Notes#
- Bearer only. The token is read from
Authorization: Bearer ….
On Apache/CGI, the Authorization header can be dropped without a re-injection rule (the REDIRECT_HTTP_AUTHORIZATION fallback is read, but that rewrite rule itself is required on the host in question).
- Claim mapping is replaceable. Both factories accept an optional
identityMapper (fn(\stdClass $claims): ?Identity). The default uses sub as the id, the display name from name → email → sub (Cognito also cognito:username), and roles from cognito:groups (Cognito) / a custom roles array (Firebase).
attempt()/login()/logout()onTokenAuthenticatorraise a
LogicException. Stateless Bearer has no password handshake and no session, so it fails explicitly rather than silently becoming a no-op.
Change history (1)
- Created