waynestate / sesame
Wayne State's protocol-neutral auth library: it returns a verified identity and leaves the session to the app.
Requires
- php: ^8.3
- ext-simplexml: *
- firebase/php-jwt: ^7.2
- lcobucci/jwt: ^5.6
- nyholm/psr7: ^1.8
- nyholm/psr7-server: ^1.1
- psr/clock: ^1.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^2.0
- psr/simple-cache: ^3.0
- symfony/http-foundation: ^6.4 || ^7.4
Requires (Dev)
- laravel/pint: ^1.32
- orchestra/testbench: ^10.0 || ^11.0
- phpstan/phpstan: ^2.1
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.5
- waynestate/pinstripe: ^0.1
Suggests
- guzzlehttp/guzzle: A PSR-18 client for the cas and oauth2 drivers
- laravel/framework: For the Laravel adapter
- symfony/http-client: A PSR-18 client for the cas and oauth2 drivers
Provides
None
Conflicts
None
Replaces
None
README
SESAME
The way in. One lock, any key. Single Entry, Sessions App-Managed Everywhere: keys cut while you wait, and no key ever gets to keep the door.
Checks: composer ci (the same command runs on GitLab and GitHub). Both pipelines run the same five checks (manifest, tests, Pint, PHPStan, audit), so a green build means the same thing on both sides.
Our CI is the same script on both sides, so when it fails, nobody gets to blame the other platform.
SESAME is Wayne State's protocol-neutral auth library. It returns a verified
identity and leaves the session to the app: every driver ends in the same
place, the app's findOrImportUser(Identity), and the protocol never holds
the session. Switching driver is a config change with no code change. Open
sesame.
Front page: site/index.html, a static page you can open in a browser or serve from site/.
On Citadel it is served by the raw-PHP deploy layer in deploy/: a SQUIRE kit whose front controller serves site/ behind HELMET's security headers. For local development, keep serving site/ directly.
This repository is published from Wayne State University's internal GitLab and mirrored here. Changes land there first; Wayne State developers open issues and merge requests on the internal project.
Install
composer require waynestate/sesame
Raw PHP
<?php declare(strict_types=1); require __DIR__.'/vendor/autoload.php'; use Waynestate\Sesame\Config; use Waynestate\Sesame\DriverFactory; use Waynestate\Sesame\Identity; use Waynestate\Sesame\Sesame; use Waynestate\Sesame\Session\NativeSession; use Waynestate\Sesame\UserResolver; session_start(); $config = Config::fromArray([ 'driver' => 'fake', 'environment' => 'local', 'fake' => [ 'username' => 'jdoe', 'attributes' => ['cn' => ['John Doe']], ], ]); $sesame = new Sesame( DriverFactory::make($config), new class implements UserResolver { public function findOrImportUser(Identity $identity): mixed { return $identity->username; } }, new NativeSession(), $config, ); sesame($sesame); // When the login has not completed, sends the redirect to the identity // provider and stops the script. When a ticket completes it, sends the // redirect to the driver's service URL and stops the script, so the ticket // never stays in the address bar. When the user is already signed in and // the request has no ticket, returns the user. $user = sesame_login();
The app owns the session: start it with session_start(), and let HELMET set
the cookie flags. SESAME regenerates the session ID on login and stores the
user and identity in it, but it never starts a session and never touches a
cookie. sesame_login() and sesame_logout() stop the script after they send
their redirect, so nothing after them runs in that request. A protected page
reads the user with sesame_user():
$user = sesame_user();
On logout, both paths send Cache-Control: no-store and
Clear-Site-Data: "cache", "storage" so a back button or a shared browser
cannot show a signed-in page; "cookies" is left out on purpose, because it
would clear every cookie on the host and sign the user out of every other app
on wayne.edu, not just this one. Single logout is not supported, the
identity provider cannot sign the user out of the app's other sessions, so
SESAME rejects a truthy slo (or single_logout) config key at boot.
Drivers
| Driver | Status |
|---|---|
fake |
Now. Local development and tests only. Refuses to boot in production. |
cas |
Now. CAS 2.0 against Academica by default. |
gateway |
Now. A signed JWT assertion from the auth gateway (on Citadel, that is auth.apps.wayne.edu, the first planned one), never a bare header. |
saml |
Later. Direct SP. |
oauth2 |
Now. A JWT bearer token verified against the issuer's key set (JWKS), RS256 only. Microsoft Entra ID first. |
The cas driver
The duplicator traces the cas key from the front page: five notches, one per sign-in step, and logout at the tip. The cut is the sign-in; the lock is still yours. With reduced motion on, you get the finished key.
The cas driver logs in against a CAS server, CAS 2.0 by default. The base
URL is required config with no default, and it must include the path the
CAS server lives under. For Academica, which serves CAS under /cas, it is
https://login.wayne.edu/cas.
<?php declare(strict_types=1); use Nyholm\Psr7\Factory\Psr17Factory; use Waynestate\Sesame\Config; use Waynestate\Sesame\DriverFactory; $config = Config::fromArray([ 'driver' => 'cas', 'environment' => 'production', 'cas' => [ 'base_url' => 'https://login.wayne.edu/cas', 'service_url' => 'https://app.apps.wayne.edu/login', ], ]); // Any PSR-18 client works, for example Guzzle. $http = new \GuzzleHttp\Client(); $driver = DriverFactory::make($config, $http, new Psr17Factory());
Then build the Sesame facade with the driver, as in the Raw PHP section above.
Config keys:
| Key | Required | Default | Meaning |
|---|---|---|---|
cas.base_url |
Yes | none | The CAS server, an absolute https:// URL, including the path the CAS server lives under (for Academica, /cas). |
cas.service_url |
Yes | none | Where the server sends the user back to. |
cas.protocol |
No | 2.0 |
3.0 or 2.0. |
cas.renew |
No | false |
Send renew on login and validation, so the CAS server requires a fresh, direct login. |
cas.assume_mfa |
No | false |
A successful validation counts as an MFA sign-in: the identity's amr includes mfa. On only when the CAS server enforces MFA for every sign-in. |
The service URL is the configured value only: the driver never reads the request's Host or X-Forwarded-* headers to build it.
Protocol 2.0 is the default because Academica is believed to redirect
cookieless /p3/serviceValidate calls to the login page. Set cas.protocol
to 3.0 when a CAS server releases attributes on /p3/serviceValidate.
Set cas.renew to true when the app needs a fresh, direct login rather
than an existing SSO session. The driver then sends renew on the login
redirect and on the validation request, and the CAS server refuses a ticket
that came from an SSO session with INVALID_TICKET. Leave it off against
Academica: it does not issue a ticket for renew=true and sends the user
back to the login page, so a renew login loops instead of signing in.
The sign-in time on the identity comes from the CAS authenticationDate
attribute when the server sends one. When it is missing, unparseable, or more
than five minutes ahead of the driver's clock, the driver uses its own clock
instead, so a bad value can never push the sign-in time into the future.
cas.assume_mfa is off by default, and it should stay off unless the CAS
server enforces MFA for every sign-in. A successful CAS validation then means
MFA happened for the user's current SSO session, and the driver says so on
the identity: its amr includes mfa. Ask your CAS server's owner before
turning it on; a server that enforces MFA only sometimes, or only for some
users, would make the MFA check pass for sign-ins that never did MFA.
The MFA age still needs a sign-in time, and the driver never invents one: it
comes from the CAS authenticationDate attribute, and when the server does
not send one, the MFA check fails because there is no sign-in time to age.
The gateway driver
The gateway driver signs the user in from a signed JWT assertion that the auth
gateway (DRAWBRIDGE) puts in one header. It verifies the assertion's signature
against the gateway's public key, then checks the issuer, audience and time
claims. It never reads a plain identity header: Remote-User,
X-Remote-User, or any spelling of them, because a plain header can come from
anywhere on the shared network. And the request must come from a trusted
proxy: the direct peer (REMOTE_ADDR) must match one of the configured CIDR
ranges. A valid assertion from an untrusted peer is still rejected.
The algorithm is pinned from config, so a token that claims a different
algorithm (or none) is refused before its signature is even looked at.
Every assertion is single-use. The driver requires a jti claim, refuses an
assertion that lives longer than gateway.max_lifetime seconds, and remembers
each jti it has seen in a replay guard, so a captured assertion cannot be
played back a second time. The guard is a PSR-16 cache: the Laravel adapter
wires one up for you, and a raw PHP app passes its own (below).
<?php declare(strict_types=1); use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Waynestate\Sesame\Config; use Waynestate\Sesame\DriverFactory; use Waynestate\Sesame\Drivers\Psr16ReplayGuard; $config = Config::fromArray([ 'driver' => 'gateway', 'environment' => 'production', 'gateway' => [ 'public_key' => file_get_contents('/etc/sesame/gateway.pub'), 'issuer' => 'https://drawbridge.wayne.edu', 'audience' => 'sesame', 'trusted_proxies' => ['10.0.0.0/8'], ], ]); // The gateway driver needs no HTTP client: it only reads the assertion from // the request. It does need a replay guard, so a captured assertion cannot be // played back a second time. Any PSR-16 cache works (symfony/cache is shown); // the Laravel adapter wires one up for you, so you only see this in raw PHP. $guard = new Psr16ReplayGuard(new FilesystemAdapter()); $driver = DriverFactory::make($config, null, null, null, $guard);
Then build the Sesame facade with the driver, as in the Raw PHP section above.
Config keys:
| Key | Required | Default | Meaning |
|---|---|---|---|
gateway.header |
No | X-Sesame-Assertion |
The one header that holds the signed assertion. |
gateway.algorithm |
No | ES256 |
ES256, RS256 or EdDSA. Pinned, so a token that claims another algorithm is refused. |
gateway.public_key |
Yes | none | The gateway's public key: a PEM string (or a path to one) for ES256 and RS256, or a base64-encoded raw key (or a path to one) for EdDSA. |
gateway.issuer |
Yes | none | The iss claim the assertion must carry. |
gateway.audience |
Yes | none | The aud claim the assertion must carry. |
gateway.leeway_seconds |
No | 30 |
Clock skew allowed on the iat, nbf and exp checks, in seconds. |
gateway.trusted_proxies |
Yes | none | A non-empty array of CIDR ranges (or single IPs) the gateway proxy sits behind. |
gateway.max_lifetime |
No | 300 |
The longest an assertion may live (exp minus iat), in seconds. Longer assertions are refused, so a stolen long-lived token is useless. |
gateway.replay_store |
No | the app's default cache store | The cache store the used jtis are kept in (Laravel only; raw PHP passes its own PSR-16 cache to the guard). |
gateway.allow_without_replay_guard |
No | false |
Skip the replay guard entirely. Local development only: a production app must keep replay protection on. |
The assertion must carry sub, iat, exp and jti; the sub claim becomes
the username. The optional email, name and affiliations claims become
read-only identity attributes. The sign-in time on the identity comes from the
driver's clock.
The optional auth_time and amr claims become the identity's MFA facts, for
requireMfa(). auth_time is the epoch seconds of the user's last MFA
sign-in; amr is the list of methods they used. A missing or malformed
auth_time leaves the MFA time unset, and a missing or malformed amr leaves
the method list empty, so a claim the gateway did not verify can never satisfy
the check.
oauth2 with Entra
The oauth2 driver signs the user in from a JWT bearer token in the
Authorization header, verified against the issuer's key set (a JWKS) fetched
from oauth2.jwks_url. Only RS256 tokens are accepted: a token that claims
none or an HMAC algorithm is refused before its signature is even looked at,
and the key used to verify is the one the issuer published under the token's
key id, never anything the token itself carries. After the signature, the
driver checks the issuer, audience, expiry and not-before claims (with a
leeway), then any required scopes and roles.
Microsoft Entra ID is the first planned issuer. Point issuer and jwks_url
at the tenant's v2.0 endpoints (the issuer and jwks_uri values in the
tenant's discovery document) and audience at the app's client id. Any other
RS256 issuer works with the same settings.
<?php declare(strict_types=1); use Nyholm\Psr7\Factory\Psr17Factory; use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Waynestate\Sesame\Config; use Waynestate\Sesame\DriverFactory; $config = Config::fromArray([ 'driver' => 'oauth2', 'environment' => 'production', 'oauth2' => [ 'issuer' => 'https://login.microsoftonline.com/<tenant>/v2.0', 'jwks_url' => 'https://login.microsoftonline.com/<tenant>/discovery/v2.0/keys', 'audience' => '<client id>', 'required_scopes' => ['openid'], ], ]); // Any PSR-18 client works, for example Guzzle. $http = new \GuzzleHttp\Client(); // Any PSR-16 cache works (symfony/cache is shown); the Laravel adapter wires // one up for you, so you only see this in raw PHP. $driver = DriverFactory::make($config, $http, new Psr17Factory(), null, null, new FilesystemAdapter());
Then build the Sesame facade with the driver, as in the Raw PHP section above.
Config keys:
| Key | Required | Default | Meaning |
|---|---|---|---|
oauth2.issuer |
Yes | none | The iss claim the token must carry. For Entra, the issuer value in the tenant's discovery document. |
oauth2.jwks_url |
Yes | none | The URL of the issuer's JSON Web Key Set. For Entra, the jwks_uri value in the tenant's discovery document. |
oauth2.audience |
Yes | none | The aud claim the token must carry. For Entra, the app's client id. |
oauth2.leeway_seconds |
No | 60 |
Clock skew allowed on the exp and nbf checks, in seconds. |
oauth2.required_scopes |
No | [] |
Scopes the token's scp claim must cover, all of them. An empty list checks nothing. |
oauth2.required_roles |
No | [] |
Roles the token's roles claim must include, all of them. An empty list checks nothing. |
oauth2.identity_claim |
No | preferred_username |
The claim the username comes from: preferred_username, or accessid (the UPN's mail prefix, Entra's user claim). |
oauth2.jwks_cache_ttl_seconds |
No | 3600 |
How long the fetched key set is kept before it is refetched, in seconds. |
oauth2.jwks_store |
No | the app's default cache store | The cache store the key set is kept in (Laravel only; raw PHP passes its own PSR-16 cache). |
The token must carry an exp claim; a token without one is refused. The
email and name claims become read-only identity attributes, and the
optional auth_time and amr claims become the identity's MFA facts, for
requireMfa(), exactly as in the gateway driver.
The key set is fetched once and kept for oauth2.jwks_cache_ttl_seconds. When
a token carries a key id the cached set does not know, the provider refetches
the set once, and at most once a minute, so a rotated key is picked up without
hammering the issuer. Everything about the fetch fails closed: an unreachable
issuer, a non-2xx answer, or an empty or malformed key set all refuse the
sign-in rather than let it through.
Session timeouts
Open sesame opens the door for a live session; a stale one finds it locked.
Every signed-in session has two clocks. The idle timeout is the quiet time
after which the session is dropped, and the absolute lifetime is the longest
a session may live, no matter how active it is. Both are in minutes, and both
are checked on every user() call, so a stale session is signed out on the
spot the first time anything asks who is signed in.
| Key | Default | Meaning |
|---|---|---|
session.idle_minutes |
120 |
Drop the session after this much quiet time. |
session.absolute_minutes |
720 |
End the session this long after sign-in, even if it is still active. |
On login the session stores the sign-in time and the last activity time, and
user() refreshes the last activity time on every call that finds a live
session. A session made before the timeouts existed (a user, no timestamps)
counts as expired.
In Laravel the keys are SESAME_SESSION_IDLE_MINUTES and
SESAME_SESSION_ABSOLUTE_MINUTES.
Requiring MFA
Open sesame opens the door; requiring MFA checks the key is still in the lock. A signed-in session is not the same as a recently-verified one, so a protected page can ask for proof that the user's MFA is still good, not just that they are signed in.
The check reads two facts from the identity: authTime, when the user last
completed MFA, and amr, the methods they used. It is satisfied when the
identity has an authTime that is not in the future, is not older than the
allowed age, and the amr includes an MFA method. It fails closed: no
authTime, no MFA method, or an age that has passed all deny the request.
| MFA method | Meaning |
|---|---|
mfa |
A generic MFA step. |
otp |
A one-time password. |
hwk |
A hardware key. |
swk |
A software key. |
fido |
A FIDO/WebAuthn assertion. |
sms |
A one-time code by SMS. |
pop |
A possession proof. |
The allowed age defaults to eight hours (28800 seconds). In raw PHP, a
protected page reads the user and the MFA proof in one call:
// Throws MfaNotSatisfied when the MFA check is not satisfied. $user = sesame_require_mfa();
Or, with a custom max age:
$user = sesame_require_mfa(3600);
The core method returns the result instead of throwing:
$result = $sesame->requireMfa(); if (! $result->satisfied) { // $result->reason is a stable code; $result->message() is the human text. }
Only the gateway driver reports MFA facts on its own: it reads them from the
signed assertion's auth_time and amr claims. The cas driver reports none
by default, because CAS carries no usable MFA signal, so a cas identity
fails the check. With cas.assume_mfa on, a cas identity carries mfa in
its amr and its MFA time from the CAS authenticationDate attribute, so
the check can pass when the CAS server enforces MFA for every sign-in.
Step-up (authentication context)
Open sesame opens the door; step-up checks the user actually walked through
the right one. When the gateway requires a step-up for a path, it says so in
the signed assertion's acrs claim, a list of authentication context ids
(c1 through c99). The gateway driver reads that claim and keeps only the
ids of the right shape; anything else is dropped, and a missing or malformed
claim is not an error, it is simply no step-up.
The contexts live on the identity, and the check is membership, never a
prefix match: an identity with c2 and c10 satisfies c2 and c10, but
not c1.
$identity->authContexts(); // list<string>, e.g. ['c1'] $identity->hasAuthContext('c1'); // bool
In raw PHP, a protected page reads the user and the context proof in one call:
// Throws AuthContextNotSatisfied when the identity lacks the context. $user = sesame_require_context('c1');
The core method returns a bool instead of throwing:
if (! $sesame->hasAuthContext('c1')) { // The signed-in identity does not have the required context. }
In Laravel the sesame.context middleware does the same check on a route:
Route::get('/vault', [VaultController::class, 'index']) ->middleware(['web', 'auth.sesame', 'sesame.context:c1']);
A request without the context gets a 403, or a JSON 403 with a message for
JSON requests. A route that names no context fails closed.
Only the gateway driver reports authentication contexts, because only the gateway performs step-up. A cas identity has none, so a cas identity always fails the check.
Laravel
The Laravel adapter is a service provider that binds the core into the
container, registers the auth.sesame and wayne.cas middleware aliases,
and, unless the app has taken the routes over, registers the login and logout
routes. It holds no rules of its own: every rule lives in the core.
The provider is discovered automatically. Publish the config with:
php artisan vendor:publish --tag=sesame-config
Config keys (in config/sesame.php):
| Key | Env | Default | Meaning |
|---|---|---|---|
driver |
SESAME_DRIVER |
cas |
cas, gateway, oauth2 or fake. |
cas.base_url |
SESAME_CAS_BASE_URL |
none | The CAS server, an absolute https:// URL. |
cas.service_url |
SESAME_CAS_SERVICE_URL |
the app's /login route |
Where the server sends the user back to. |
cas.protocol |
SESAME_CAS_PROTOCOL |
2.0 |
3.0 or 2.0. |
cas.assume_mfa |
SESAME_CAS_ASSUME_MFA |
false |
A successful validation counts as an MFA sign-in. On only when the CAS server enforces MFA for every sign-in. |
fake.username |
SESAME_FAKE_USERNAME |
none | The fake driver's username. |
gateway.header |
SESAME_GATEWAY_HEADER |
X-Sesame-Assertion |
The one header that holds the signed assertion. |
gateway.algorithm |
SESAME_GATEWAY_ALGORITHM |
ES256 |
ES256, RS256 or EdDSA. |
gateway.public_key |
SESAME_GATEWAY_PUBLIC_KEY |
none | The gateway's public key. Required for the gateway driver. |
gateway.issuer |
SESAME_GATEWAY_ISSUER |
none | The iss claim the assertion must carry. Required for the gateway driver. |
gateway.audience |
SESAME_GATEWAY_AUDIENCE |
none | The aud claim the assertion must carry. Required for the gateway driver. |
gateway.leeway_seconds |
SESAME_GATEWAY_LEEWAY_SECONDS |
30 |
Clock skew allowed on the time-claim checks, in seconds. |
gateway.trusted_proxies |
SESAME_GATEWAY_TRUSTED_PROXIES |
none | Comma-separated CIDR ranges (or single IPs) the gateway proxy sits behind. Required for the gateway driver. |
gateway.max_lifetime |
SESAME_GATEWAY_MAX_LIFETIME |
300 |
The longest an assertion may live, in seconds. |
gateway.replay_store |
SESAME_GATEWAY_REPLAY_STORE |
the app's default cache store | The cache store the used jtis are kept in (Laravel only). |
gateway.allow_without_replay_guard |
SESAME_GATEWAY_ALLOW_WITHOUT_REPLAY_GUARD |
false |
Skip the replay guard. Local development only. |
oauth2.issuer |
SESAME_OAUTH2_ISSUER |
none | The iss claim the token must carry. Required for the oauth2 driver. |
oauth2.jwks_url |
SESAME_OAUTH2_JWKS_URL |
none | The URL of the issuer's key set. Required for the oauth2 driver. |
oauth2.audience |
SESAME_OAUTH2_AUDIENCE |
none | The aud claim the token must carry. Required for the oauth2 driver. |
oauth2.leeway_seconds |
SESAME_OAUTH2_LEEWAY_SECONDS |
60 |
Clock skew allowed on the time-claim checks, in seconds. |
oauth2.identity_claim |
SESAME_OAUTH2_IDENTITY_CLAIM |
preferred_username |
The claim the username comes from: preferred_username or accessid. |
oauth2.jwks_cache_ttl_seconds |
SESAME_OAUTH2_JWKS_CACHE_TTL_SECONDS |
3600 |
How long the fetched key set is kept, in seconds. |
oauth2.jwks_store |
SESAME_OAUTH2_JWKS_STORE |
the app's default cache store | The cache store the key set is kept in (Laravel only). |
session.idle_minutes |
SESAME_SESSION_IDLE_MINUTES |
120 |
Drop the session after this much quiet time, in minutes. |
session.absolute_minutes |
SESAME_SESSION_ABSOLUTE_MINUTES |
720 |
End the session this long after sign-in, in minutes. |
mfa.max_age |
SESAME_MFA_MAX_AGE |
28800 |
The longest a user's last MFA sign-in may be, in seconds. |
mfa.on_failure |
SESAME_MFA_ON_FAILURE |
deny |
deny answers a 403; reauth redirects to the login route. |
routes |
SESAME_ROUTES |
true |
Register the login and logout routes. |
user_resolver |
SESAME_USER_RESOLVER |
none | The class that resolves a verified identity to the app's user. Required. |
The routes, when routes is true:
| Method | Path | Name |
|---|---|---|
GET |
/login |
sesame.login |
POST |
/logout |
sesame.logout |
Set SESAME_ROUTES=false when the app defines its own login and logout
routes. The auth.sesame middleware still points at the sesame.login route
name, so the app's login route must keep that name.
Protect a route with the auth.sesame middleware:
Route::get('/dashboard', fn () => view('dashboard'))->middleware('auth.sesame');
A guest is sent to the login route; a JSON guest gets a 401.
Require MFA on a route with the sesame.mfa middleware:
Route::get('/vault', fn () => view('vault'))->middleware(['auth.sesame', 'sesame.mfa']);
It lets the request through when the signed-in user's MFA is still good, and
otherwise stops it: a 403 by default, a JSON 403 for JSON requests, and a
redirect to the login route when mfa.on_failure is reauth. The allowed age
comes from the route parameter when there is one, and from mfa.max_age when
there is not:
Route::get('/vault', fn () => view('vault'))->middleware(['auth.sesame', 'sesame.mfa:3600']);
The app's user is available through the facade:
$user = app(\Waynestate\Sesame\Sesame::class)->user();
The cas and oauth2 drivers need a PSR-18 client. The adapter uses the app's
Psr\Http\Client\ClientInterface binding when there is one, Guzzle when it is
installed, and nothing when there is neither. Bind a client when the app has
neither:
app()->singleton( Psr\Http\Client\ClientInterface::class, fn () => new \Symfony\Component\HttpClient\Psr18Client(), );
The oauth2 driver also keeps its fetched key set in a PSR-16 cache. The
adapter wires one up from the app's cache, using the store named by
oauth2.jwks_store, or the default store when none is named.
Running off Citadel
Open sesame opens the door on any host, not just Citadel. SESAME takes its
config from the app (env or config/sesame.php), keeps no files of its own,
and has no platform address in code, so a move to OpenShift, Coolify or raw
Docker is a config change, not a code change. What differs per host:
- The
casdriver needs outbound HTTPS from the app to the CAS server, andservice_urlmust be the public URL users see, behind any proxy. - The
gatewaydriver trusts only a signed assertion, never a bare header. A source address is not enough on its own: on shared ingress (OpenShift's router, Coolify's Traefik) the same proxy carries routes with and without the auth step. The header name and the list of trusted auth hops are config. Its replay guard remembers usedjtis in a cache store, so that store must be shared too (Redis or a database), for the same reason as the session store: a local-file cache would let a replayed assertion pass on another replica. - The session store must be shared: Redis or a database, never local files.
Every host overlaps old and new containers during a deploy, and most can run
more than one replica, so a file session would drop users mid-login. In
Laravel, set
SESSION_DRIVERtoredisordatabase(Laravel 13 already defaults todatabase); in raw PHP, setsession.save_handlerto a shared handler, for example Redis.
Moving from wayne-cas
Apps that use wayne-cas can move to SESAME without rewriting their auth. The shim keeps the wayne-cas names and behavior, and it says once (in the log) that it is a deprecated bridge.
The mapping:
| wayne-cas | SESAME |
|---|---|
wayne-cas.root |
sesame.cas.base_url |
auth.providers.users.model |
auth.providers.users.model (unchanged) |
WayneCasable trait |
implements WayneCasable + use ReadOnlyAttributes |
WayneAuthenticate middleware |
the wayne.cas middleware alias |
auth()->login($user) |
app(Sesame::class)->user() |
use2FA |
not supported; the shim logs a warning when it is on |
The model. wayne-cas found the user through the model's static
findOrImportUser, with the verified username as the access ID. The shim
keeps that, and the model keeps its wayne-cas shape:
class User extends Model implements WayneCasable { use Notifiable, ReadOnlyAttributes; public static function findOrImportUser($accessId) { return static::firstOrCreate(['username' => $accessId]); } public function getReadOnlyAttributes($persistedAttributes = []): array { return [ 'cn' => $persistedAttributes['cn'] ?? null, ]; } }
The WayneCasable interface and the ReadOnlyAttributes trait are in the
Waynestate\Sesame\WayneCas namespace.
The middleware. wayne-cas's WayneAuthenticate middleware is kept as the
wayne.cas alias. It checks the SESAME session instead of the Laravel auth
guard, and it sends unauthenticated users to the sesame.login route. The
AJAX 403 is kept, because wayne-cas returned it.
The service URL. wayne-cas built the service URL from the current request.
SESAME takes sesame.cas.service_url from config; the Laravel adapter
defaults it to the app's /login route when it is not set.
use2FA. wayne-cas's use2FA runs a Duo challenge the shim does not carry.
When the app still has wayne-cas.use2FA switched on, the shim logs a warning
once and leaves the 2FA to the app.
Proof
75 sign-ins and 75 CAS answers run through SESAME on 2026-10-28: 149 passed, 1 tool error.
- 75 round trips. A raw PHP app on
php -S, no network, a cookie jar: protected page, login redirect, ticket back, one validation call, signed in, MFA reason, logout, signed out. Every option was crossed: the fake driver in five environments, and the cas driver over protocol 2.0 and 3.0,assume_mfa, fourauthenticationDatecases,renew, and a safe or foreign return-to. 75 of 75 ended where they should. MFA passed only for a recent sign-in withassume_mfaon (8 of 8); all 32 foreign return-to addresses were refused. - 75 CAS answers through
CasDriver::loginwith a stub HTTP client: successes in every shape, every failure code, DOCTYPE and XXE attempts, non-XML, HTTP errors, empty users and malformed tickets. 74 of 75 were right.
The one wrong answer: a response whose cas: prefix was bound to a different namespace was read as a success. It
could only come from the trusted CAS server over HTTPS, but the parser promises a namespace check, and the fix is
queued with a test for that exact response. No real account, password or ticket was used. Breakdown, command and
limits: site/proof.html.
No key under the mat. We checked 150 times.
Tests
Maintainers: how to build, test and release this project, and where its data comes from, is in MAINTAINING.md.
No PHP on the machine? Fine. Docker runs the suite:
docker run --rm -v "${PWD}:/app" -w /app composer:2 composer install --no-interaction
docker run --rm -v "${PWD}:/app" -w /app php:8.3-cli vendor/bin/phpunit
docker run --rm -v "${PWD}:/app" -w /app php:8.4-cli vendor/bin/phpunit
docker run --rm -v "${PWD}:/app" -w /app php:8.5-cli vendor/bin/phpunit
Static analysis is PHPStan at level 8 from Pinstripe's strict preset
(vendor/waynestate/pinstripe/phpstan/strict.neon, included through the
waynestate/pinstripe dev dependency): strict rules stay on, and a call to
a deprecated method fails the build. composer ci runs the whole gate,
validate through tests, Pint and PHPStan:
docker run --rm -v "${PWD}:/app" -w /app composer:2 composer ci
Strongly recommended: a git.wayne.edu instance runner
git.wayne.edu has no runners today, so CI for this repo runs on the test GitLab instance. Every product in the stack strongly recommends a git.wayne.edu instance runner.
Same door, every keyWayneUI and the stack. Each one gets in its own way.
scripts/readme-art. Open sesame.

