RRelayer
Home/Features

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\PdoUserProvider

Registering 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 variableUseExample
FIREBASE_PROJECT_IDFirebasemy-app
COGNITO_REGIONCognitoap-northeast-1
COGNITO_USER_POOL_IDCognitoap-northeast-1_AbCdEf
COGNITO_APP_CLIENT_IDCognito7f3k… (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 { /* ... */ }

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 serviceAuthenticatorInterface is…Notes
UserProvidersession AuthenticatorPassword app (traditional behavior).
TokenVerifier onlyTokenAuthenticatorToken-first API. #[Auth] enforces Bearer.
Bothsession AuthenticatorSession-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() on TokenAuthenticator raise a

LogicException. Stateless Bearer has no password handshake and no session, so it fails explicitly rather than silently becoming a no-op.

Last updated: 2026-05-19
Change history (1)
  • Created