yahyaerturan / auth-psr15
PSR-15/PSR-7 HTTP integration for yahyaerturan/auth: request-scoped authentication context, session-token extraction and secure session-cookie handling.
Requires
- php: ^8.5
- psr/clock: ^1.0
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- yahyaerturan/auth: ^1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.90
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.2
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^13.3
- yahyaerturan/auth-testing: ^1.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-08 23:31:30 UTC
README
PSR-15 and PSR-7 HTTP integration for
yahyaerturan/auth.
Middleware that resolves the session credential once per request and attaches an immutable authentication context to it, a fail-closed set of token extractors, and a session-cookie writer that names and enforces every security attribute rather than defaulting it.
composer require yahyaerturan/auth-psr15
Requires PHP 8.5, yahyaerturan/auth, and the PSR interface packages
psr/http-message (PSR-7), psr/http-server-middleware and
psr/http-server-handler (PSR-15), plus psr/clock (PSR-20). It works with any
PSR-7 implementation and any PSR-15 dispatcher.
Why you would install it
yahyaerturan/auth is deliberately framework-agnostic: it knows about sessions
and tokens, and nothing about requests, responses, cookies or headers. That is
what makes it usable anywhere, and it means somebody has to bridge the two.
This package is that bridge, and it is small on purpose. It does four things:
- Extracts a session token from the request — cookie,
Authorization: Bearer, or both. - Resolves it to an
AuthenticationContextby calling the core'sAuthenticateSession. - Attaches that context to the request as an attribute, for handlers downstream.
- Writes the session cookie on login, and clears it on logout.
What it does not do is decide anything. There is no RequireAuthentication
middleware that issues a 401, and no permission check that issues a 403 —
because the right status code depends on whether a resource's existence is
itself a secret, and only your application knows that.
Minimal usage
<?php declare(strict_types=1); use YahyaErturan\Auth\Psr15\AuthenticationContextRequest; use YahyaErturan\Auth\Psr15\CookieSessionTokenExtractor; use YahyaErturan\Auth\Psr15\SessionAuthenticationMiddleware; use YahyaErturan\Auth\Psr15\SessionCookieConfiguration; $cookie = SessionCookieConfiguration::hostPrefixed(); // $authenticateSession is the core's AuthenticateSession use case, // composed from your repositories, clock and token hasher. $middleware = new SessionAuthenticationMiddleware( CookieSessionTokenExtractor::configuredBy($cookie), $authenticateSession, );
Add $middleware to your PSR-15 pipeline. Downstream, any handler reads the
context off the request:
$context = AuthenticationContextRequest::require($request); // throws if absent $context = AuthenticationContextRequest::find($request); // null if absent if ($context?->isAuthenticated() === true) { $userId = $context->userId(); }
require() is not an authorization check. It throws when the middleware did
not run — a wiring mistake — not when the visitor is anonymous. An anonymous
visitor gets a perfectly valid context that reports isAuthenticated() === false.
A complete framework-free composition lives in
examples/PlainPsr15Example.php, driven end to
end by Psr15ExampleTest so it cannot rot into pseudocode.
Extracting the credential
| Extractor | Reads |
|---|---|
CookieSessionTokenExtractor |
one named cookie |
AuthorizationHeaderSessionTokenExtractor |
Authorization: Bearer <token> |
CompositeSessionTokenExtractor |
several sources, fail-closed |
The composite is the interesting one:
$extractor = new CompositeSessionTokenExtractor( CookieSessionTokenExtractor::configuredBy($cookie), new AuthorizationHeaderSessionTokenExtractor(), );
If two sources both present a credential, the request is treated as unauthenticated — even if the two tokens are identical (ADR-055).
That is not fussiness. "Take the first one" is a choice about which credential authenticates, made by whichever extractor happens to be registered first, and an attacker who can set one of the two channels gets to pick. Refusing is the only answer that does not depend on registration order.
Malformed input — a bearer value that is not canonical, a cookie that is not a well-formed token — is refused rather than repaired. Nothing is trimmed and nothing is normalised, because a token that needed repair is not the token that was issued.
Cookies
SessionCookieConfiguration::hostPrefixed(); // recommended SessionCookieConfiguration::secureCookie($name, $sameSite, $path, $domain); SessionCookieConfiguration::forPlainHttpDevelopment($name); // local HTTP only
hostPrefixed() takes no arguments deliberately: every attribute a __Host-
cookie may vary is already at its safest value, and the two that could vary —
Path and Domain — are the two the prefix fixes.
The __Host- rules are enforced, not documented. A __Host- name with
Secure off, a Domain set, or a Path other than / is rejected at
construction rather than silently written and quietly ignored by the browser.
forPlainHttpDevelopment() is named at length on purpose. It is the only way
this library produces a cookie without Secure, and the call site says so.
Writing and clearing:
$writer = new SessionCookieWriter($cookie, $clock); $response = $writer->withIssuedSession($response, $createSessionResult); // login $response = $writer->withoutSession($response); // logout
The writer takes a PSR-20 clock rather than reading the wall clock, so a rendered
Max-Age is reproducible in a test. Expiry is truncated toward the past: a
cookie never outlives the session it carries.
See docs/COOKIES.md for every attribute, the __Host-
constraints in full, and the subdomain trade-off.
Composing the flows
Logout revokes first, then clears the cookie. In that order:
$logout->handle($context->sessionId()); // server-side revocation $response = $writer->withoutSession($response); // then the cookie
Clearing the cookie alone leaves a live session that anyone holding the token can still use. The cookie is a convenience for the browser; the session is the truth.
A password change rotates the session — the core issues a new session and invalidates the old, so the response must carry the new cookie or the user is logged out of their own password change.
Full compositions for login, logout, password change and password reset are in
docs/HTTP_INTEGRATION.md.
Security-relevant defaults
| Two credentials fail closed | never "first one wins" |
__Host- constraints enforced |
rejected at construction, not ignored at runtime |
Secure and HttpOnly by default |
opting out requires a method whose name says so |
SameSite=Lax by default |
None is available and requires Secure |
| Cookie expiry truncated toward the past | the cookie never outlives the session |
| The request attribute is a destination, not a source | a forged attribute cannot become an authenticated context (ADR-057) |
| No raw token is ever attached to the request | the context carries identity, never the credential |
No superglobals, no setcookie(), no header() |
every response is a returned object |
| No ambient clock | PSR-20 injected, so expiry is testable |
CSRF is your responsibility
This package does not provide CSRF protection, and cannot: the defence depends on your forms, your routing and your templating, none of which a transport adapter can see.
SameSite=Lax — the default here — blocks cross-site POST, which covers the
common case. It is not a complete defence: it does not protect same-site
subdomain attacks, and SameSite=None (needed for genuine cross-site flows)
removes it entirely. If you set None, you need a CSRF token, and you need it
from your framework.
Trusted proxies and client identity
This package never reads X-Forwarded-For, X-Real-IP or any other proxy
header, and never derives a client IP. Deriving one incorrectly is how a
rate-limit key becomes attacker-controlled — anyone who can set a header gets
their own bucket.
Client metadata is supplied, never derived (ADR-068). Your application decides which proxies it trusts and passes the result in.
Long-running processes
Safe under Swoole, RoadRunner, FrankenPHP and workers generally, and asserted rather than assumed:
- no static or global state — a test drives sequential requests through one middleware instance and checks nothing leaks between them;
- no superglobals — PSR-7 hands the request over already parsed, and a second reader would disagree with the first;
- no
setcookie()orheader()— both write to a process-global buffer a PSR-7 response cannot see; - no ambient clock — an injected PSR-20 clock, so a worker that has been up for a week has no stale notion of now.
Failure behaviour
Expected denials produce an anonymous context. An expired session, a revoked session, a token that matches nothing: the request continues, unauthenticated. These are ordinary outcomes, not errors.
Infrastructure failures propagate. A database that is unreachable throws, and the exception passes through the middleware untouched.
That distinction is the important one. Swallowing a repository failure into "not authenticated" would make an outage look exactly like a logged-out user — your monitoring sees a spike in anonymous traffic instead of an incident, and every protected page silently becomes a login redirect.
What this package does not own
- Authorization. No permission check, no 403, no policy. That is
yahyaerturan/auth-authorization, and the mapping from a denial to a status code is your application's — see ADR-062 and document 12 §Deny behavior. - Authentication logic. Session lookup, expiry, revocation and credential
binding are all
yahyaerturan/auth. This package calls one use case. - Persistence. It touches no storage at all.
- CSRF, routing, templating, sessions-as-in-
$_SESSION. None of these.
Relationship to the rest of the ecosystem
| Package | Repository | Relationship |
|---|---|---|
yahyaerturan/auth |
https://github.com/yahyaerturan/auth | required |
yahyaerturan/auth-psr15 |
https://github.com/yahyaerturan/auth-psr15 | this package |
yahyaerturan/auth-pdo |
https://github.com/yahyaerturan/auth-pdo | independent — supply any adapter |
yahyaerturan/auth-authorization |
https://github.com/yahyaerturan/auth-authorization | independent — this package never calls it |
yahyaerturan/auth-testing |
https://github.com/yahyaerturan/auth-testing | dev — in-memory adapters for the suite |
This package depends on yahyaerturan/auth and the PSR interfaces, and on no
sibling package at runtime.
Development
git clone https://github.com/yahyaerturan/auth-psr15 cd auth-psr15 composer install composer qa # platform, coding standard, PHPStan, PHPUnit
No database is needed. The suite composes the whole stack in memory, which is the point: document 19's Phase 7 exit criterion is that a framework-free stack authenticates, and composing it in memory is what makes that a claim about this adapter rather than about a driver.
PSR-7 conformance comes from a real implementation — nyholm/psr7 is a dev
dependency — rather than from a hand-written double, because a double would
agree with whatever this package assumed (ADR-058).
Working across several packages at once
Clone the repositories you need as siblings, then point Composer at the checkouts without committing anything:
cp composer.json composer.dev.json
composer config --file composer.dev.json repositories.siblings \
'{"type":"path","url":"../auth*","options":{"symlink":true}}'
composer config --file composer.dev.json minimum-stability dev
COMPOSER=composer.dev.json composer update
composer.dev.json and composer.dev.lock are git-ignored.
Documentation
docs/HTTP_INTEGRATION.md— composition, flows, failure mapping, framework bridgesdocs/COOKIES.md— every cookie attribute and the reasoning behind its defaultdocs/RELEASING.md— the release gate and sequence
Contributing
Issues and pull requests are welcome — see CONTRIBUTING.md
for the setup, the quality gate, and the conventions this project expects.
Participation is governed by the Code of Conduct.
Found a security vulnerability? Do not open an issue or a pull request.
Security
See SECURITY.md.
License
MIT — see LICENSE.