Authentication and authorization
Quiote’s security model has three moving parts: a user object that holds authentication state and credentials, actions that declare whether they are secure and what they require, and SecurityMiddleware, which decides — before the action runs — whether to allow it or forward the request elsewhere.
Establishing who the user is — checking a password, validating a bearer token, completing an OIDC login — is a separate concern from that authorization decision, and stays optional: you can still verify credentials yourself and call setAuthenticated(true) directly, or reach for the quioteframework/auth family of packages, which now ship real, tested implementations of the common mechanisms (form login, HTTP Basic, JWT bearer tokens, OIDC). Either path ends the same way: an ISecurityUser marked authenticated, which is all SecurityMiddleware and the rest of this page care about. The authentication packages are covered in Authenticating with the auth packages below; everything else on this page — the user hierarchy, securing actions, CSRF, rate limiting — applies regardless of which path establishes identity.
The user hierarchy
Section titled “The user hierarchy”Three classes, each adding capability, in Quiote\User:
| Class | Adds |
|---|---|
User | Base user, attribute storage, dirty tracking |
SecurityUser | Authentication state + credentials |
RbacSecurityUser | Role-based access control on top of credentials |
You choose which one your app uses by pointing the user factory role at it (see Configuration):
// Config/factories.php — the "user" role'user' => ['class' => \App\Security\AppUser::class, 'params' => []], // extends Quiote\User\RbacSecurityUseruser: class: App\Security\AppUser # extends Quiote\User\RbacSecurityUser params: []<!-- Config/factories.xml — inside <ae:configuration> --><user class="App\Security\AppUser" /> <!-- extends Quiote\User\RbacSecurityUser -->SecurityUser
Section titled “SecurityUser”SecurityUser implements ISecurityUser:
interface ISecurityUser{ public function addCredential($credential); public function clearCredentials(); public function hasCredentials($credential); public function isAuthenticated(); public function removeCredential($credential); public function setAuthenticated($authenticated);}Authentication state and credentials persist in the session bag. Three security-relevant behaviours are built in:
setAuthenticated(true)regenerates the session id on the unauthenticated-to-authenticated transition, defeating session fixation.setAuthenticated(false)discards the session and rotates the id, so a logged-out session id is neither replayable nor inheritable. Session data does not survive a logout — anything that must outlive one belongs elsewhere.isAuthenticated()reads the value loaded once at init and does not re-read the session mid-request, avoiding a class of fail-open bugs.
Two more hooks exist specifically for the token/service-identity path used by quioteframework/auth-jwt and auth-oauth (see below):
isTokenDerived()/markTokenDerived(bool)— a request-scoped marker meaning “this identity was re-derived from the token the caller presented on this request, not read back from the session.” Nothing about a token-derived user is written to the session: no authentication flag, no credentials, no roles, and no session-id regeneration (attributes still persist). It is never read from the session either —initialize()always starts a user out session-derived, and only the request’s own authenticator marks it otherwise. See Token identities and the session below.restoreIdentityFromStorage()plus theCORE_IDENTITY_KEYSclass constant — a hook for worker-mode cold starts, where a freshSecurityUseris built viarestoreContext()rather thaninitialize(). A subclass opts in by declaringprotected const CORE_IDENTITY_KEYS = ['legacy_user_id', ...];and calling the hook explicitly; nothing calls it automatically.
Subclassing: go through the mutators, or call markDirty()
Section titled “Subclassing: go through the mutators, or call markDirty()”The User hierarchy tracks whether a request actually changed anything and writes nothing when it did not — that is what keeps a read-only request from touching the session backend at all.
Dirty tracking lives in the mutators. A subclass that writes to $attributes, $credentials or $roles directly, or overrides a mutator without calling parent::, is invisible to it and will not persist — with no error:
// Invisible to dirty tracking; this write is silently dropped.$this->attributes[$ns]['userId'] = $id;
// Either go through the mutator:$this->setAttribute('userId', $id, $ns);
// or say so explicitly:$this->attributes[$ns]['userId'] = $id;$this->markDirty();markDirty() is public and exists for exactly this. isDirty() and markClean() round out the API. If you subclass User, SecurityUser or RbacSecurityUser, audit for direct writes to those three properties.
Credentials: AND / OR
Section titled “Credentials: AND / OR”hasCredentials() encodes boolean structure in the argument shape:
- A plain array of credentials means all are required (AND).
- A nested array means one or more of that group is required (OR).
// requires 'edit' AND ('admin' OR 'moderator')$user->hasCredentials(['edit', ['admin', 'moderator']]);RbacSecurityUser
Section titled “RbacSecurityUser”RbacSecurityUser adds roles on top of credentials. Granting a role walks its chain of roles up to their parents and adds each permission as a credential:
$user->grantRole('editor'); // adds every permission the editor role (and its parents) grant$user->hasRole('editor');$user->revokeRole('editor');Roles and their permissions are defined in the rbac_definitions config. So in an RBAC app you think in roles when granting, and the framework checks credentials (the permissions those roles imply) when authorizing.
Defining roles and permissions
Section titled “Defining roles and permissions”Roles and permissions live in Config/rbac_definitions.{xml,php,yaml,yml}. Role hierarchy is expressed by nesting in XML — a role declared inside another role’s <roles> is a child; the PHP/YAML canonical form flattens that into a parent key per role instead. A granted role receives its own permissions plus every ancestor’s:
return [ 'guest' => ['parent' => null, 'permissions' => ['photos.list']], 'member' => ['parent' => 'guest', 'permissions' => ['photos.rate']], 'photomoderator' => ['parent' => 'member', 'permissions' => ['photos.edit', 'photos.delete']],];guest: parent: null permissions: [photos.list]member: parent: guest permissions: [photos.rate]photomoderator: parent: member permissions: [photos.edit, photos.delete]<ae:configurations xmlns:ae="http://quiote.dev/quiote/config/global/envelope/1.1" xmlns="http://quiote.dev/quiote/config/parts/rbac_definitions/1.1"> <ae:configuration> <roles> <role name="guest"> <permissions> <permission>photos.list</permission> </permissions> <roles> <role name="member"> <permissions> <permission>photos.rate</permission> </permissions> <roles> <role name="photomoderator"> <permissions> <permission>photos.edit</permission> <permission>photos.delete</permission> </permissions> </role> </roles> </role> </roles> </role> </roles> </ae:configuration></ae:configurations>Here photomoderator inherits member’s photos.rate and guest’s photos.list on top of its own permissions. Granting photomoderator therefore adds all of them as credentials, so an action requiring photos.delete is allowed. A member — lacking photos.delete — is forwarded to the secure action; an unauthenticated user is forwarded to login.
rbac_definitions.* is compiled and cached like the rest of Quiote’s config; it is read once when the RbacSecurityUser initializes (from core.config_dir, overridable with the user’s definitions_file parameter).
Authenticating a user manually
Section titled “Authenticating a user manually”Authentication is your code — verify a password, validate a token, complete an OAuth flow — and then tell the user object the result. A login action, in outline:
public function __construct(private readonly RbacSecurityUser $user) {}
public function executeWrite(WebRequest $rd){ $user = $this->user;
if ($this->credentialsAreValid($rd)) { $user->setAuthenticated(true); // regenerates the session id $user->grantRole('member'); // if using RBAC return 'Success'; }
$this->setAttribute('error', 'Invalid credentials.'); return 'Input';}This path needs no packages at all — credentialsAreValid() is entirely yours, and the framework only provides the state model (setAuthenticated, grantRole) and the enforcement below. Reach for it when your credential check is simple or already exists. For the common mechanisms — password forms, HTTP Basic, JWT bearer tokens, OIDC — the quioteframework/auth packages below do the same job with tested, reusable code instead of a hand-rolled credentialsAreValid().
Authenticating with the auth packages
Section titled “Authenticating with the auth packages”Three packages — quioteframework/auth, auth-jwt, and auth-oauth — build a stateless authenticator model on top of framework-wide contracts in Quiote\Security\Auth\ (referenced by SecurityUser/RbacSecurityUser directly, so core carries the contracts without depending on any of the three packages):
| Contract | Purpose |
|---|---|
AuthenticatorInterface | One implementation per credential mechanism: supports(request): bool, authenticate(request): Passport (throws AuthenticationException on invalid/absent credentials), onFailure(exception): ?ResponseInterface. |
Passport | A resolved identity plus credentials/roles, a stateless flag, and an optional TokenClaims. |
TokenClaims | Validated token claims: subject, the raw claim array, and a ClientType. |
ClientType | Enum of User | Service. |
ClientTypeResolverInterface | Derives ClientType from raw claims (the RFC 9068 rule: service when sub equals client_id/azp). |
UserIdentity | Minimal identity contract: getIdentifier(), getRoles(). |
UserProviderInterface | loadByIdentifier(string) and loadByToken(TokenClaims). |
PasswordHasherInterface | hash(), verify(), needsRehash(). |
EntryPointInterface | start(request, exception): ResponseInterface — the failure response for a firewall (a 401 challenge, a login redirect, …). |
AuthenticationException | Thrown by an authenticator on invalid/absent credentials. |
The firewall (Firewall) is the unit all of this hangs off. The next section explains what it is and works through exactly how one matches a request, tries its authenticators, and applies the result — read it before wiring your first one, since the behavior that matters in practice (what counts as “no credential” vs. “invalid credential”, which of the two middleware even looks at a given firewall) isn’t guessable from the constructor signature alone.
The firewall model
Section titled “The firewall model”A firewall answers one question for a slice of your app’s URL space: “for requests to these paths, how does a caller prove who they are, and what happens if they can’t?” It is not a network firewall and it does not block anything by itself — the name is borrowed from the same concept in Symfony Security. Think of it as a labeled rule that says “requests under ^/api/ authenticate with a bearer token; if the token is bad, answer with a 401 challenge.” That’s the whole idea; everything below is the mechanics of that one sentence.
Concretely, a firewall bundles four decisions together:
- Which requests it governs — a path pattern (e.g. everything under
/api/). - How they authenticate — an ordered list of authenticators (bearer token, HTTP Basic, a login form, …), each knowing how to recognize and verify one kind of credential.
- What happens on failure — an entry point that turns a failed attempt into a response (a 401 challenge, a redirect to a login page).
- What kind of identity this is — the
stateless/sessionlessflags, which decide whether the credential is re-checked every request (an API token) or established once and carried in a session (a login form).
Your whole app’s authentication config is then just an ordered list of these firewalls — a FirewallMap. A request is matched to exactly one firewall (the first whose pattern fits), and that firewall alone decides how the request authenticates. A typical app has two: a stateless api firewall for token-authenticated endpoints and a session-based main firewall for the browser-facing site. A firewall never runs the action or makes the authorization decision (that’s still SecurityMiddleware, unchanged — see How enforcement works); it only establishes who the caller is so that later decision has something to work with.
With that model in mind, here’s how each piece actually behaves. A Firewall is a plain, immutable value object — five constructor arguments, no magic:
new Firewall( name: 'api', // diagnostic name only — shows up in logs, not matched against anything pattern: '^/api/', // a PCRE pattern, no delimiters, matched against the raw request path authenticators: [$basicAuth, $bearerAuth], // tried in declaration order entryPoint: new HttpChallengeEntryPoint(), stateless: true, // identity axis — see below sessionless: false, // session axis — see below);Matching. Firewall::matches($path) tests the raw request path (preg_match('#' . $pattern . '#', $path)), not the resolved route — deliberately, so a stateless firewall can be evaluated by StatelessAuthenticationMiddleware before RoutingMiddleware has even run. A FirewallMap holds an ordered list of firewalls and returns the first one whose pattern matches (FirewallMap::match()); there’s no “most specific pattern wins” logic, so list narrower patterns before broader ones — ^/api/ before ^/, not after, or every request ever falls into the catch-all main firewall and api never matches.
The authenticator chain. Given the matched firewall, AuthenticationManager::authenticate() walks getAuthenticators() in order and calls supports($request) on each, stopping at the first one that returns true — not the first one that succeeds. That authenticator’s authenticate($request) then either returns a Passport (success) or throws AuthenticationException (the credential it recognized was present but invalid — a bad password, an expired/malformed token). If none of the chain’s authenticators support the request at all — no Authorization header, no session cookie, whatever each one checks for — authenticate() returns null rather than throwing. This distinction matters:
null(no credential presented) is not itself a failure. The request continues unauthenticated, and it’s stillSecurityMiddleware’s existingSecurityService::decide()— the authZ path described in How enforcement works — that decides whether that’s actually a problem for the action being requested (LoginForward/SecureForward) or a non-issue (an open action).- A thrown
AuthenticationException(credential present but invalid) short-circuits immediately: the owning middleware catches it and calls$firewall->getEntryPoint()->start($request, $exception), returning that response straight away.SecurityMiddlewareand the action never run for that request.
So a firewall with [HttpBasicAuthenticator, BearerTokenAuthenticator] tries Basic first; a request with no Authorization header at all supports neither and falls through as unauthenticated, while a request with a garbled bearer token is recognized (supports() sees the header) and rejected outright via the entry point, even though a correct Basic credential would otherwise have been accepted by the second authenticator in the chain — chain order is a real behavioral choice, not just documentation order.
Applying a successful Passport. On success, AuthenticationManager::apply() does three things to the request’s SecurityUser/RbacSecurityUser (nothing happens if the configured user factory role isn’t a SecurityUser at all):
- If the firewall is
stateless, callsmarkTokenDerived(true)and stores the passport’s claims, then revokes whatever the session rehydrated —revokeAllRoles()on anRbacSecurityUser,clearCredentials()on anySecurityUser. The token is the whole identity here, so the roles and credentials a cookie sent alongside it may have carried are not part of it. - Calls
setAuthenticated(true). - Grants every credential the
Passportcarries — as roles viagrantRole()on aRbacSecurityUser, or as flat credentials viaaddCredential()otherwise.
A non-stateless (form-login) firewall skips step 1 entirely: it adds to the session identity rather than replacing it.
Token identities and the session
Section titled “Token identities and the session”A stateless: true firewall produces an identity that lasts exactly one request, and Quiote keeps it out of the session in both directions:
- Nothing is read in.
SecurityUser::initialize()always starts out session-derived; only the request’s own authenticator can mark the user token-derived. A session can therefore never be left in a state where it authenticates but carries no roles. - Nothing is written out. While the user is token-derived,
setAuthenticated(true)records no authentication flag and does not regenerate the session id, andshutdown()writes back no credentials and no roles. Attributes still persist.
This matters most for the mixed case — an SPA that authenticates with a bearer token while the browser still holds a session cookie for the classic pages. The token request neither inherits that session’s roles nor overwrites them, so the next ordinary page load on the same cookie is unaffected.
Turning a token into a browser session (a session-establishing endpoint the SPA calls once) is the deliberate exception, and it opts back in explicitly:
// In the action behind e.g. /auth/session, having already authenticated the JWT.$user = $this->getContext()->getUser();$user->markTokenDerived(false); // this identity is now the session's$user->setAuthenticated(true); // regenerates the id, writes the auth flag$user->grantRole('member'); // persisted on shutdownWithout the markTokenDerived(false), every write above applies to the request and nothing else.
Two independent flags, not one. stateless and sessionless answer different questions, and it’s easy to conflate them:
| Flag | Axis | What it controls |
|---|---|---|
stateless | Identity | Is the identity re-derived from the credential every request (true: HTTP Basic, bearer/JWT — the credential is the source of truth, sent again every time) or read back from the session as the source of truth between requests (false: form login — you authenticate once, a session cookie carries the result)? This is also the flag AuthenticationManager reads to decide whether to mark the user token-derived. |
sessionless | Session | Should a session be started at all for requests under this firewall (true: pure M2M surfaces with no cookie jar in sight) or not (false: default)? StatelessAuthenticationMiddleware sets the auth.sessionless request attribute when this is true or the resolved token turns out to be a service token (ClientType::Service) — so a human’s bearer token on a stateless: true, sessionless: false firewall does not set it, but a machine’s does, even on the same firewall. See the auth.sessionless wiring caveat below — this flag is set but not yet fully consumed. |
A firewall is almost always stateless: true when it’s session-optional (pure APIs) and stateless: false for form login, but nothing stops a stateless firewall from also wanting a session for other reasons (an SPA that authenticates via bearer token but still wants server-side session state for something unrelated) — that’s exactly the case sessionless: false with stateless: true expresses.
Which middleware actually runs a given firewall. Both StatelessAuthenticationMiddleware and SessionAuthenticationMiddleware are handed the same FirewallMap — but each ignores the firewalls it doesn’t own:
StatelessAuthenticationMiddlewarematches the path, then bails out ($handler->handle($request)with no authentication attempted) unless the matched firewall’sisStateless()istrue.SessionAuthenticationMiddlewaredoes the mirror check — it bails out unless the matched firewall’sisStateless()isfalse.
So for any single request, exactly one of the two middleware actually does anything, decided entirely by that one firewall’s stateless flag — a request under a stateless: true firewall is authenticated before SessionMiddleware even runs (and never touched by SessionAuthenticationMiddleware later in the pipeline), and a request under a stateless: false firewall is authenticated after routing, right before SecurityMiddleware, having been left alone by StatelessAuthenticationMiddleware earlier. One FirewallMap can freely mix both kinds of firewall — a typical app registers a stateless api firewall and a session-based main firewall side by side, as in the worked examples below.
Multiple authenticators, multiple firewalls, or both. Nothing requires one firewall per authenticator or one authenticator per firewall — pick whichever shape matches how your paths actually split:
// One firewall, two authenticators — /api/ accepts either scheme:$apiFirewall = new Firewall('api', '^/api/', [$basicAuth, $bearerAuth], new HttpChallengeEntryPoint(), stateless: true);
// Two firewalls, one authenticator each — different entry points per surface:$firewalls = new FirewallMap([ new Firewall('api', '^/api/', [$bearerAuth], new HttpChallengeEntryPoint(), stateless: true), new Firewall('main', '^/', [$formLoginAuth], new LoginRedirectEntryPoint('/login')),]);Which package for which role — a decision guide
Section titled “Which package for which role — a decision guide”The three packages answer three different questions, and it’s easy to reach for auth-oauth when what you actually need is auth-jwt, or vice versa. Between them, the two official packages cover two of the roles an app can play in an OAuth2/OIDC world:
- Resource server — Quiote is the thing an already-issued bearer/JWT token is presented to. This is
auth-jwt: something else (an IdP, a login service, another Quiote app) minted the token; you’re just verifying it and reading its claims. This applies equally to a human’s access token and an M2M client-credentials token — RFC 9068’ssub === client_id/azprule (ClientTypeResolverInterface) is exactly howauth-jwttells those two apart on the receiving end. - OAuth/OIDC client — Quiote is the thing that goes and gets a token from somewhere else. This is
auth-oauth, and it splits into two genuinely different flows that happen to share a package because they both wrapleague/oauth2-client:- Relying party (human SSO) —
OidcClient+OidcAuthenticator. A browser is involved: you redirect a human to an identity provider’s login page, they authenticate there (password, MFA, whatever the IdP does), and come back with a code you exchange for tokens. Use this for “log in with Entra ID / Google / Okta”. - Outbound M2M (client credentials) —
ClientCredentialsClient. No browser, no human, no redirect. Quiote’s own backend fetches a token for itself and presents it when calling another API. Use this when your app is the caller of someone else’s service, not the thing being called.
- Relying party (human SSO) —
What’s not shipped as a package: an authorization server — something that mints tokens for other clients to consume. As with everything else in Quiote, that’s not a rule, it’s just a scope decision — nobody has built quioteframework/auth-server (yet). Nothing about the framework prevents it: AuthenticatorInterface, Passport, and the rest of the contracts in this section are just PSR-7-level building blocks, and a token-issuing endpoint is an Action like any other. If you need to issue tokens today, the pragmatic move is a dedicated product like OpenIddict or Keycloak sitting in front of (or beside) your Quiote app — but porting something OpenIddict-shaped onto Quiote yourself is a perfectly reasonable thing to build, it’s just not something this project has done. auth-jwt and auth-oauth cover the two roles that do ship: validating tokens, and fetching/exchanging them as a client.
| Scenario | You are the… | Package |
|---|---|---|
Your Quiote app exposes an API that accepts Authorization: Bearer <jwt> from users and/or services | resource server | auth-jwt |
| Your Quiote app is the web app that redirects a human to Entra ID / Google / Okta / any OIDC IdP to log in | relying party (client) | auth-oauth (OidcClient + OidcAuthenticator) |
| Your Quiote app is the backend service that calls another API and needs its own access token first | M2M client | auth-oauth (ClientCredentialsClient) |
| Your Quiote app is the backend service that receives that M2M call | resource server | auth-jwt (same BearerTokenAuthenticator as the human case) |
| Your Quiote app should itself issue tokens for other apps to consume | authorization server | no official package ships this — build it on the same contracts, or run OpenIddict/Keycloak alongside your app |
quioteframework/auth — the foundation
Section titled “quioteframework/auth — the foundation”Form login, HTTP Basic, credential providers, and the firewall machinery above:
- Password hashing —
Hasher\DefaultPasswordHasher: argon2id by default, falling back to bcrypt if the PHP build lacks argon2 support. - Identity and providers —
Identity\InMemoryUserIdentity(a plain value object implementing the package’sPasswordProtectedUserIdentity extends UserIdentity, addinggetPasswordHash());Provider\InMemoryUserProvider(a static config array),Provider\PdoUserProvider(a single users table viaDatabaseManager),Provider\CallableUserProvider(app-supplied closures). - Authenticators —
Authenticator\HttpBasicAuthenticator(always stateless);Authenticator\FormLoginAuthenticator(session-backed, optionally taking aQuiote\Security\Csrf\CsrfManagerand/or aQuiote\Security\RateLimit\LoginThrottle— both soft dependencies, passnullto skip either). - Entry points —
EntryPoint\HttpChallengeEntryPoint(401 +WWW-Authenticate+ an RFC 9457 Problem Details body, matching the MCP server’s existing challenge shape);EntryPoint\LoginRedirectEntryPoint(302 back to the login path with?error=1) — this fires only when a login attempt itself fails, not on plain unauthenticated browsing, which is stillSecurityMiddleware’s existingLoginForward/SecureForwardpath from How enforcement works, unchanged.
HTTP Basic with in-memory users, wired in an app plugin:
$hasher = new DefaultPasswordHasher();$provider = new InMemoryUserProvider([ 'alice@example.com' => ['password_hash' => $hasher->hash('secret'), 'roles' => ['admin']],]);$firewall = new Firewall( 'api', '^/api/', [new HttpBasicAuthenticator($provider, $hasher)], new HttpChallengeEntryPoint(), stateless: true,);$registrar->service(FirewallMap::class, static fn() => new FirewallMap([$firewall]));Form login with CSRF and throttling:
$authenticator = new FormLoginAuthenticator( $provider, $hasher, checkPath: '/login', csrf: new CsrfManager($context), throttle: new LoginThrottle(new PdoRateLimiterStorage(...)),);$firewall = new Firewall('main', '^/', [$authenticator], new LoginRedirectEntryPoint('/login'));FormLoginAuthenticator runs its own CSRF check via the optional CsrfManager, even though CsrfValidationMiddleware already checks every unsafe request generically (see CSRF protection below). That’s deliberate redundancy, not an oversight: the authenticator’s own check stays correct even if the CSRF middleware is disabled or reordered for some other route.
quioteframework/auth-jwt — bearer/JWT resource server
Section titled “quioteframework/auth-jwt — bearer/JWT resource server”Adds firebase/php-jwt:^7.1. Validates bearer tokens rather than issuing them — this package is for an API that accepts JWTs, not one that mints them.
TokenValidatorInterface—validate(string $token): array(raw claims), throwingAuthenticationExceptionon an invalid token.JwtTokenValidator— verifies the JWS via either a singleKey(a shared HS256 secret) or a JWKS-backedCachedKeySet(RS256/ES256, with rotation); enforcesiss/auditself, since the underlying library only checksexp/nbf/iat.ClientTypeResolver— the defaultClientTypeResolverInterface(the RFC 9068 rule above).BearerTokenAuthenticator— always stateless; resolves identity viaUserProviderInterface::loadByToken().JwtAuthPluginregisters only the defaultClientTypeResolverInterface— there’s no safe default forTokenValidatorInterface/BearerTokenAuthenticator, since both need app-specific secrets or a JWKS URI.
$validator = new JwtTokenValidator( new CachedKeySet($jwksUri, $httpClient, $requestFactory, $cachePool), issuer: 'https://issuer.example.com', audience: 'my-api',);$authenticator = new BearerTokenAuthenticator($validator, new ClientTypeResolver(), $userProvider);$firewall = new Firewall('api', '^/api/', [$authenticator], new HttpChallengeEntryPoint(), stateless: true);quioteframework/auth-oauth — Quiote as an OAuth/OIDC client
Section titled “quioteframework/auth-oauth — Quiote as an OAuth/OIDC client”Adds league/oauth2-client:^2.9. No plugin ships with this package — nothing in it has a safe framework-wide default, since every piece needs app-specific secrets or endpoints. Everything here makes Quiote the client; validating an incoming token is auth-jwt’s job (see the decision guide above), and minting tokens for someone else isn’t something either package does — see the “not shipped as a package” note above if that’s what you’re actually after.
OIDC discovery — one issuer URL instead of four endpoints
Section titled “OIDC discovery — one issuer URL instead of four endpoints”OidcDiscoveryClient fetches a provider’s metadata from {issuer}/.well-known/openid-configuration (OpenID Connect Discovery 1.0 §4), so one issuer URL replaces the four-to-five endpoint strings you’d otherwise copy into config by hand and re-copy when the provider moves them.
It is PSR-18 + PSR-17 — discovery is a plain GET, so it doesn’t go through league/oauth2-client — and takes an optional PSR-6 pool, the same kind of pool CachedKeySet already needs for the JWKS in this same auth stack:
// Wiring from one issuer URL instead of four hand-copied endpoints:$discovery = new OidcDiscoveryClient($psr18Client, $psr17Factory, $cachePool, cacheTtl: 3600);$document = $discovery->discover("https://login.microsoftonline.com/{$tenant}/v2.0");
$client = OidcClient::fromDiscovery($document, $clientId, $clientSecret, $redirectUri, ['openid', 'profile']);
$idTokenValidator = new JwtTokenValidator( new CachedKeySet($document->getJwksUri(), $psr18Client, $psr17Factory, $cachePool), issuer: $document->getIssuer(), audience: $clientId,);Two checks are not optional. The document’s own issuer must match the one you asked for (Discovery §4.3 — without it, a redirect or a compromised well-known path could substitute another provider’s endpoints for the one you meant to trust), and the issuer must be HTTPS unless you pass requireHttps: false for a local test provider.
Missing endpoints fail loudly or return null, by category. Endpoints a flow cannot work without — authorization_endpoint, token_endpoint, jwks_uri — throw AuthenticationException when the provider doesn’t advertise them, rather than returning a null that becomes an empty endpoint URL inside GenericProvider one hop later. Genuinely optional ones (getUserinfoEndpoint(), getIntrospectionEndpoint(), getRevocationEndpoint(), getEndSessionEndpoint()) return null. Anything with no dedicated accessor is still reachable via get('member_name') / getMetadata().
Three fromDiscovery() factories consume the document:
| Factory | Wires | Refuses when |
|---|---|---|
OidcClient::fromDiscovery() | authorization + token endpoints | The provider advertises code_challenge_methods_supported without S256 |
ClientCredentialsClient::fromDiscovery() | token endpoint | No token_endpoint |
IntrospectionClient::fromDiscovery() | introspection endpoint | No introspection_endpoint |
OidcClient::fromDiscovery()’s S256 pre-flight check is worth understanding: PKCE S256 is hardcoded because OAuth 2.1 mandates it, so it’s better to fail at wiring time than to have the provider reject the authorization request later. A provider that doesn’t advertise the member at all is treated as unknown-but-allowed, since it’s OPTIONAL metadata. And since introspection_endpoint is RFC 8414 metadata that plenty of OIDC providers omit, that third factory tells you so instead of POSTing to an empty URL.
Relying party — sending a human to their identity provider
Section titled “Relying party — sending a human to their identity provider”Use this for “log in with Entra ID / Google / Okta” — a browser-based flow where a human authenticates at the IdP, not at Quiote.
Why hand login to someone else
Section titled “Why hand login to someone else”The short version: your application never sees the password. Someone types an email address into your login page, they end up typing their password on login.microsoftonline.com, and what comes back to you is a signed statement about who they are.
Concretely, from the user’s side:
- They hit
/loginon your app and enterada@contoso.com— or just click “Sign in with Microsoft” and enter nothing at all. - Your login action calls
OidcClient::buildAuthorizationRequest()and redirects the browser to Entra ID. - Everything hard happens over there. Password, MFA push, hardware key, “this device isn’t compliant”, “your password expired, change it now”, the organization’s conditional-access rules. None of it is your code, and none of it is on your servers.
- Entra ID sends the browser back to
/callback?code=…&state=…. OidcAuthenticatorverifies thestate, exchanges the code for tokens over a back channel, validates the ID token’s signature, issuer, audience andnonce, and hands the verified claims to yourUserProviderInterface::loadByToken().- Your provider maps those claims onto a local
SecurityUser— roles, permissions, whatever your app needs — and from there the rest of this page applies unchanged.
sequenceDiagram
participant B as Browser
participant Q as Your Quiote app
participant I as Entra ID / Google / Okta
B->>Q: GET /login
Q-->>B: 302 to the IdP (state + PKCE + nonce)
B->>I: authenticate (password, MFA, policy)
I-->>B: 302 to /callback?code=…&state=…
B->>Q: GET /callback
Q->>I: exchange code for tokens (back channel)
I-->>Q: ID token + access token
Q->>Q: verify signature, issuer, audience, nonce
Q-->>B: logged in
What you stop owning by doing this:
- Password storage and everything around it — hashing, rotation policy, breach lists, “forgot password” email flows, the support ticket when it doesn’t arrive.
- MFA and step-up. The IdP already has TOTP, push, WebAuthn and the policy engine deciding when to demand them. Building that yourself is a project, not a feature.
- Offboarding. Someone leaves; IT disables the account in the directory; access to your app dies with it. No “which of the fourteen internal apps still has an account for them?”
- The audit trail. Sign-in logs, risky-sign-in detection and conditional access live in one place the security team already watches.
- The procurement conversation. For anything sold into an enterprise, “does it do SSO with our IdP?” is a checkbox that decides whether the deal happens.
And what you keep: the authorization half. The IdP asserts identity; deciding that this identity may approve a purchase order is your app’s job, which is what the user hierarchy and securing an action are for.
When not to reach for this:
| Situation | Use instead |
|---|---|
| Consumer app where accounts are yours and there is no external directory | a password form — quioteframework/auth, or your own credentialsAreValid() |
| No browser involved — your backend calls another API | ClientCredentialsClient, in this same package |
| Someone else’s token arrives at your API and you only need to check it | auth-jwt |
OidcClientwrapsGenericProviderfor the Authorization Code flow. PKCE S256 is hardcoded, not app-configurable — OAuth 2.1 mandates it.buildAuthorizationRequest()generates the state/PKCE-verifier/nonce and the redirect URL;exchangeCode()performs the token exchange.OidcAuthorizationState/OidcAuthorizationRequestare value objects for the state round-trip;OidcStateStoragepersists a single in-flight state in the session bag, keyed by its ownstatevalue, andconsume()removes it on read (one-time use).OidcAuthenticatoris the callback leg only —supports()matches the callback path plus the presence ofcode/state. It verifiesstatein constant time, exchanges the code, validates the ID token via an injectedTokenValidatorInterface(reusingauth-jwt’s validator rather than a second JWT stack) plus its ownnoncecheck, then resolves identity viaUserProviderInterface::loadByToken(). It does not initiate the flow — building the authorization redirect withOidcClient::buildAuthorizationRequest()is left to your own login-initiation action.at_hashis deliberately not checked: per OIDC Core §3.1.3.6 it’s only required when an access token comes back from the authorization endpoint (implicit/hybrid flows), andOidcAuthenticatoronly implements the Authorization Code exchange at the token endpoint, where it’s optional — and computing it would need the ID token’s signing algorithm, which isn’t exposed throughTokenValidatorInterface’s return shape.
The full round trip — a login action that initiates, and a firewall whose authenticator handles the callback:
// Login action — redirects the browser to the IdP:$client = new OidcClient($clientId, $clientSecret, $redirectUri, $authorizeUrl, $tokenUrl);$request = $client->buildAuthorizationRequest();(new OidcStateStorage($context))->store($request->getState());return redirect($request->getAuthorizationUrl());
// Firewall registration, matched on the callback path:$idTokenValidator = new JwtTokenValidator( new CachedKeySet($jwksUri, $httpClient, $requestFactory, $cachePool), issuer: $issuer, audience: $clientId,);$authenticator = new OidcAuthenticator($client, $idTokenValidator, $userProvider, $stateStorage, '/callback');$firewall = new Firewall('sso', '^/callback$', [$authenticator], new LoginRedirectEntryPoint('/login'));$authorizeUrl, $tokenUrl, $jwksUri, and $issuer are the four values every provider’s /.well-known/openid-configuration document publishes as authorization_endpoint, token_endpoint, jwks_uri, and issuer — which is exactly what OidcDiscoveryClient fetches for you. If you’d rather hardcode them, here are three real providers so you don’t have to go find them yourself:
// {tenant} is your Entra tenant ID or domain (or "common" for multi-tenant apps).$authorizeUrl = "https://login.microsoftonline.com/{$tenant}/oauth2/v2.0/authorize";$tokenUrl = "https://login.microsoftonline.com/{$tenant}/oauth2/v2.0/token";$jwksUri = "https://login.microsoftonline.com/{$tenant}/discovery/v2.0/keys";$issuer = "https://login.microsoftonline.com/{$tenant}/v2.0";$authorizeUrl = 'https://accounts.google.com/o/oauth2/v2/auth';$tokenUrl = 'https://oauth2.googleapis.com/token';$jwksUri = 'https://www.googleapis.com/oauth2/v3/certs';$issuer = 'https://accounts.google.com';// {okta-domain} is your org's Okta domain, e.g. "dev-123456.okta.com".// "default" is the default authorization server; use your own server id if you created one.$authorizeUrl = "https://{$oktaDomain}/oauth2/default/v1/authorize";$tokenUrl = "https://{$oktaDomain}/oauth2/default/v1/token";$jwksUri = "https://{$oktaDomain}/oauth2/default/v1/keys";$issuer = "https://{$oktaDomain}/oauth2/default";Register the app itself in each provider’s console first — that’s where $clientId/$clientSecret and the allowed $redirectUri come from, and every provider requires the exact callback URL to be allow-listed before it will redirect back to it.
Outbound M2M — Quiote as the caller
Section titled “Outbound M2M — Quiote as the caller”Use this when Quiote’s own backend needs to call another API and must present its own access token to do so — no browser, no human, no redirect. This is the mirror image of the resource-server case: here Quiote is the one fetching a client-credentials token, not the one validating it.
ClientCredentialsClientwrapsGenericProvider’s Client Credentials grant.getAccessToken()returns the token; there’s no authorization redirect or callback involved at all.IntrospectionClientis an RFC 7662 token-introspection POST helper, for revocation-sensitive paths where you’d rather ask the authorization server “is this still valid?” than trust a cached JWT’sexp.
$client = new ClientCredentialsClient( $clientId, $clientSecret, tokenEndpoint: $tokenUrl, // the same per-provider token endpoint as above scopes: ['api://my-downstream-api/.default'],);$token = $client->getAccessToken();
$response = $httpClient->request('GET', 'https://downstream.example.com/orders', [ 'headers' => ['Authorization' => 'Bearer ' . $token->getToken()],]);On the receiving end, that downstream service validates the token with auth-jwt’s BearerTokenAuthenticator — the exact same authenticator a human’s bearer token goes through, since ClientTypeResolverInterface’s RFC 9068 rule (sub === client_id/azp) is what tells the two apart. There’s no separate “M2M authenticator” — the M2M-vs-human distinction lives entirely in how the token was obtained (client credentials vs. a user login), not in how it’s checked.
Configuring firewalls in a security config
Section titled “Configuring firewalls in a security config”Building FirewallMap by hand in a plugin’s register(), as in the examples above, needs no config file at all and is the simplest path for most apps. If you’d rather declare firewalls in config, Config\SecurityConfigHandler parses a security.{php,yaml,xml} file into a canonical array that Config\FirewallFactory turns into a live FirewallMap:
return [ 'password_hasher_algorithm' => 'argon2id', 'providers' => [ 'app' => [ 'type' => 'pdo', 'connection' => 'main', 'table' => 'users', 'identifier_column' => 'email', 'password_column' => 'password_hash', ], ], 'firewalls' => [ 'api' => [ 'pattern' => '^/api/', 'stateless' => true, 'sessionless' => false, 'entry_point' => 'challenge', 'provider' => null, 'authenticators' => ['http_basic'], ], 'main' => [ 'pattern' => '^/', 'stateless' => false, 'sessionless' => false, 'entry_point' => 'login', 'provider' => 'app', 'authenticators' => ['form_login'], ], ],];password_hasher_algorithm: argon2idproviders: app: type: pdo connection: main table: users identifier_column: email password_column: password_hashfirewalls: api: pattern: "^/api/" stateless: true sessionless: false entry_point: challenge provider: null authenticators: [http_basic] main: pattern: "^/" stateless: false sessionless: false entry_point: login provider: app authenticators: [form_login]<?xml version="1.0" encoding="UTF-8"?><ae:configurations xmlns:ae="http://quiote.dev/quiote/config/global/envelope/1.1" xmlns="http://quiote.dev/quiote/config/parts/security/1.1"> <ae:configuration> <password_hashers algorithm="argon2id"/> <providers> <provider name="app" type="pdo" connection="main" table="users" identifier-column="email" password-column="password_hash"/> </providers> <firewalls> <firewall name="api" pattern="^/api/" stateless="true" entry-point="challenge"> <authenticator ref="http_basic"/> </firewall> <firewall name="main" pattern="^/" provider="app" entry-point="login"> <authenticator ref="form_login"/> </firewall> </firewalls> </ae:configuration></ae:configurations>Order matters: firewalls are matched in declaration order, so the catch-all ^/ goes last.
The PHP and YAML forms are the handler’s canonical array — the same structure toCanonicalArray() produces from the XML — so they are written fully resolved. XML fills stateless, sessionless, pattern, entry-point and provider from attribute defaults when they are absent; executeArray() applies no defaults of its own, and the schema requires pattern, stateless, sessionless and authenticators on every firewall, plus type on every provider. Note the spelling difference the two formats inherit: XML attributes are hyphenated (identifier-column, entry-point), array keys are underscored (identifier_column, entry_point).
Middleware ordering
Section titled “Middleware ordering”StatelessAuthenticationMiddleware and SessionAuthenticationMiddleware are placed with explicit before:/after: anchors rather than phase/priority tuning: StatelessAuthenticationMiddleware is registered before: Quiote\Middleware\SessionMiddleware::class, and SessionAuthenticationMiddleware is registered after: RoutingMiddleware::class, before: SecurityMiddleware::class. See The middleware pipeline for the full default stack and Writing custom middleware for the general anchor mechanism — the reason both use explicit anchors rather than a phase is that phase alone can’t express “before SessionMiddleware” here: phase is the primary sort key, and bootstrap (where SessionMiddleware sits) is always ordered ahead of the later phases regardless of priority. An explicit before:/after: anchor is the only way to guarantee an order that crosses a phase boundary.
A wiring gap worth knowing about: auth.sessionless
Section titled “A wiring gap worth knowing about: auth.sessionless”StatelessAuthenticationMiddleware sets the auth.sessionless request attribute when a firewall is sessionless: true or the resolved ClientType is Service. As of this writing, SessionMiddleware only checks the older, JWT-specific jwt.skip_session attribute — not auth.sessionless — so setting it on a stateless/service-token request has no runtime effect yet beyond being available for your own code to read. It’s the one piece of this wiring that doesn’t fully connect end to end without a follow-up change to SessionMiddleware; don’t rely on auth.sessionless to actually skip session handling until that lands.
Securing an action
Section titled “Securing an action”An action declares its own security via two hooks:
class EditPostAction extends Action{ public function isSecure(): bool { return true; // this action requires authorization }
public function getCredentials() { return ['edit']; // ...and this credential }
public function executeWrite(WebRequest $rd) { // only runs if the user is authenticated and holds 'edit' return 'Success'; }}isSecure()— returntrueto require authorization. Default isfalse(open).getCredentials()— the credential(s) required, in the AND/OR shape above. Returnnull(the default) to require only authentication, not a specific credential.
How enforcement works
Section titled “How enforcement works”SecurityMiddleware runs before dispatch and asks SecurityService::decide() for a decision:
public function decide(Action $action): SecurityDecision{ if (!$action->isSecure()) { return SecurityDecision::Allow; } $user = $this->controller->getContext()->getContainer()->get(User::class); // A plain User carries no authentication or credential capability at all, so a // secure action guarded by one is treated as unauthenticated rather than fatalling. if (!$user instanceof ISecurityUser || !$user->isAuthenticated()) { return SecurityDecision::LoginForward; } $cred = $action->getCredentials(); if ($cred !== null && !$user->hasCredentials($cred)) { return SecurityDecision::SecureForward; } return SecurityDecision::Allow;}The three outcomes:
| Decision | When | Result |
|---|---|---|
Allow | Open action, or authenticated with required credentials | Action runs |
LoginForward | Secure action, not authenticated | Forward to the login system action |
SecureForward | Authenticated, missing a credential | Forward to the secure system action |
On a non-Allow decision, SecurityMiddleware swaps the ActionDescriptor to the login or secure action, preserving the original under quiote.original_action. Forwards are loop-guarded (more than five forwards returns HTTP 508). If security is disabled globally (core.use_security => false), everything is allowed. The design fails closed: when an action cannot be created and the user is not authenticated, it forwards to login rather than proceeding.
CSRF protection
Section titled “CSRF protection”CSRF protection ships in the quioteframework/csrf package (Quiote\Security\Csrf\CsrfPlugin) — a required kernel dependency that registers itself automatically, so it’s on by default. Turning it off is a conscious act (core.csrf.enabled => false). It’s enforced by two middlewares (see The middleware pipeline):
CsrfInjectionMiddlewareinjects a hidden token field into every non-GET HTML form, adds a<meta name="csrf-token">tag, and sets a readableXSRF-TOKENcookie for SPA clients.CsrfValidationMiddlewarerejects unsafe-method requests (non GET/HEAD/OPTIONS/TRACE) with 403 unless a valid token is present in the form field or theX-CSRF-Tokenheader.
Automatic exemptions
Section titled “Automatic exemptions”CSRF exists to stop an attacker site from riding a victim’s ambient, automatically-attached session cookie. CsrfValidationMiddleware exempts two classes of request from that check automatically, with no per-route opt-out needed:
- A request an authenticator already resolved from a caller-supplied credential — a JWT, an API key, an OAuth2 bearer token. Such a caller’s identity does not come from an ambient cookie, so it isn’t forgeable cross-site.
- A request with no session cookie and no foreign
Origin. With no ambient session-backed credential there’s nothing for an attacker to ride — but that reasoning only holds for the request itself, so the exemption additionally requires that this isn’t a browser request from another origin.
Both conditions are narrower than they look, and deliberately so:
The sessionless half needs the Origin condition because a login POST also arrives without a session. Exempting it on that basis made login CSRF work: an attacker’s page posts their own credentials to /login, the victim’s browser has no session to ride so the check is skipped, and the victim ends up authenticated as the attacker with everything they then do recorded in the attacker’s account. So a cross-origin browser request is never exempt. A non-browser caller sends no Origin and stays exempt; an Origin matching the request host, or one listed in core.csrf.trusted_origins, isn’t foreign.
The session cookie is recognised by asking the configured SessionManager for its cookie name (QSID by default, or your cookie_name slot parameter). ext/session’s session_name() is consulted only when no session factory slot is configured at all, which is the legacy native-$_SESSION path where it’s the right answer. A stray PHPSESSID from something else on the domain does not count as a session.
Forcing the check. A route that needs validating despite one of the exemptions can force it with a _csrf => true route default — the mirror image of the _csrf => false opt-out below.
Server-rendered forms get tokens automatically. Two things to remember:
- JS/XHR clients must send the token in the
X-CSRF-Tokenheader (read it from the meta tag or the cookie). - Stateless routes (webhooks, token-authenticated APIs) must opt out with an
_csrf => falseroute default.
$routes->add('webhook', new Route('/hooks/stripe', [ '_module' => 'Api', '_action' => 'StripeWebhook', '_csrf' => false,]));Login rate limiting
Section titled “Login rate limiting”The optional quioteframework/ratelimit package provides the Quiote\Security\RateLimit components — login throttling (LoginThrottle) with pluggable storage (e.g. PdoRateLimiterStorage), so repeated failed logins can be slowed or blocked. It’s a library rather than a plugin: composer require it, then wire the throttle into your login action to defend against credential-stuffing.