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/log: ^3.0
- psr/simple-cache: ^3.0
- symfony/http-foundation: ^6.4 || ^7.4
Requires (Dev)
- guzzlehttp/guzzle: ^7.9 || ^8.0
- laravel/pint: ^1.32
- league/oauth2-client: ^2.9.1
- 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, oauth2 and oidc drivers
- laravel/framework: For the Laravel adapter
- league/oauth2-client: Required by the oidc driver (browser sign-in to Entra): the authorization code flow with PKCE and the token request
- symfony/http-client: A PSR-18 client for the cas, oauth2 and oidc drivers
- waynestate/playbook: WSU's shared values (CAS, Entra, gateway); private, git.wayne.edu
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.
Documentation: the opening of this README is the Overview page of the docs site. Squire's docs builder renders docs/ into site/docs/; composer docs rebuilds it, and composer docs:check fails when the build or the generated environment reference has drifted.
Where the code lives
The canonical repository is on nougat: enterprise-apps/sesame. git.wayne.edu and GitHub (waynestate/sesame) are read-only mirrors of it. Open issues and merge requests on nougat, not on the mirrors. This moves to git.wayne.edu once it has CI runners. Open sesame: the cave is nougat.
Install
composer require waynestate/sesame
Which driver for my app?
| Situation | Driver | Entra registration |
|---|---|---|
| App on Citadel today | cas |
None. |
| App on Citadel once Drawbridge is live | gateway |
None per app (Drawbridge holds it once). |
| App off Citadel or an API with its own audience | oidc or oauth2 |
Its own registration. |
| Local development | fake |
None. |
The recommended path on Citadel is the gateway driver behind Drawbridge. Drawbridge is Wayne State's auth gateway: the browser signs in once at the gateway, which passes the app a signed assertion that the gateway driver verifies. Drawbridge is not live yet, so there is no production gateway to point at today. Until it is, CAS (the cas driver) is today's option for a Citadel app, and it needs no per-app Entra registration.
WSU values (Playbook)
Open sesame, minus the incantation. Four settings carry Wayne State's shared values: where the CAS server lives, and who signs the gateway and oauth2 assertions.
| Setting | Config key | Meaning |
|---|---|---|
SESAME_CAS_BASE_URL |
cas.base_url |
The CAS server, an absolute https:// URL. |
SESAME_GATEWAY_ISSUER |
gateway.issuer |
The iss claim the gateway assertion must carry. |
SESAME_OAUTH2_ISSUER |
oauth2.issuer |
The iss claim the bearer token must carry. |
SESAME_OAUTH2_JWKS_URL |
oauth2.jwks_url |
The URL of the issuer's key set. |
Set them in the environment, as with every other setting. When the
environment is empty, the private
waynestate/playbook package supplies the
university's values, so a fresh app boots against the real services without
a .env full of addresses. The environment always wins: a set variable
beats the playbook, and an empty one counts as unset.
The playbook is never required. It sits in suggest, not require, and
SESAME runs fine without it, reading these four from the environment alone.
Get it through Composer, fetch the values into a .env.playbook file, or
clone the repository:
Composer. Add the repository to your composer.json:
{
"repositories": [
{ "type": "vcs", "url": "https://git.wayne.edu/enterprise-apps/playbook.git" }
]
}
then require it:
composer require waynestate/playbook
Composer keeps the git.wayne.edu credentials it asks for in auth.json as
an http-basic entry, so later installs do not ask again.
curl (Linux, macOS).
curl --fail -H "PRIVATE-TOKEN: $GITLAB_TOKEN" "https://git.wayne.edu/api/v4/projects/enterprise-apps%2Fplaybook/repository/files/playbook.env/raw?ref=main" -o .env.playbook
PowerShell (Windows).
Invoke-WebRequest -Headers @{ 'PRIVATE-TOKEN' = $env:GITLAB_TOKEN } -Uri "https://git.wayne.edu/api/v4/projects/enterprise-apps%2Fplaybook/repository/files/playbook.env/raw?ref=main" -OutFile .env.playbook
A clone. When the app keeps its private dependencies by hand:
git clone https://git.wayne.edu/enterprise-apps/playbook.git
A fetched .env.playbook is plain KEY=value lines: copy the ones you need into your app's .env (or your
Citadel environment). SESAME does not read .env.playbook itself.
The token is your own: read_api for curl, read_repository for clone and Composer.
Playbook is private on git.wayne.edu. If you get a 404, ask the Enterprise Applications team for read access.
Raw-PHP apps that build their config by hand can resolve a value the same way the Laravel config does:
use Waynestate\Sesame\Support\Setting; $baseUrl = Setting::get('SESAME_CAS_BASE_URL'); // env, then playbook, else null
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();
Which key to store. Your findOrImportUser looks the person up in the
app's own store, so key that store on the driver plus the identity provider's
own stable identifier, never on an email or a display name, which can change
or be shared between providers. For the cas driver that identifier is the
username (the Access ID), so the key is $identity->driver plus
$identity->username. Other drivers name their stable identifier differently:
the oidc section keys on iss plus sub, or tid plus oid, never the
username alone.
Refusing a sign-in
A verified identity is not a permission to use your app. To refuse the
person, return null from findOrImportUser. When you do, SESAME stores
nothing, does not regenerate the session, returns null from login(), and
logs sesame.login_refused (with the username made safe) when a logger is
set. Your app then shows one generic "you do not have access" page, never the
reason, and must not start its own session for that person. Do not throw for
a normal deny: throwing is for errors, and a refused sign-in is not one.
// A resolver that refuses: null when the account is not found or disabled. public function findOrImportUser(Identity $identity): mixed { $account = Account::findByKey($identity->driver, $identity->username); return $account !== null && $account->enabled ? $account : null; }
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();
Set
session.gc_maxlifetimeto at least SESAME's idle timeout. PHP's default is 1440 seconds (24 minutes), and PHP's garbage collector deletes session data older than that, so a raw-PHP app on the defaults signs people out after 24 quiet minutes, long before SESAME's 120-minute idle timeout. SESAME never starts the session (S2), so it cannot change this for you: setsession.gc_maxlifetime = 7200(yoursession.idle_minutestimes 60) inphp.inior the FPM pool, or callini_set('session.gc_maxlifetime', '7200')beforesession_start(). SESAME's timeouts stay the authority; this only stops PHP from ending the session first. Laravel apps setSESSION_LIFETIME(minutes) instead.
The session cookie (name and SameSite). SESAME never sets a cookie, so two
choices belong to the session setup. In raw PHP HELMET makes both for you
(SessionHardening::apply(), called by helmet_boot()); in Laravel they are
settings in config/session.php:
- Name it with the
__Host-prefix (S4). The browser then accepts the cookie only if it isSecure, hasPath=/and has noDomain, so a sibling subdomain under*.apps.wayne.educannot overwrite it. HELMET names the cookie__Host-WSUSESSIDon HTTPS (it keeps a name your app chose). In Laravel, setSESSION_COOKIE=__Host-wsu-session,SESSION_SECURE_COOKIE=true,SESSION_PATH=/and leaveSESSION_DOMAINempty inconfig/session.php. - Keep it
SameSite=Lax, neverStrict(S41). The identity provider sends the browser back to your app with a cross-site top-levelGET, and aStrictcookie is not sent on that request: it would be missing, so the app would see no session on the return request and any login binding (S18) would fail for every user.Laxis what HELMET sets and what Laravel does by default. A cross-sitePOSTcarries noLaxcookie at all, which is the defense you want; a futuresamldriver would need its own short-lived cookie for that reason (S35). - Leave the session ID length alone (S45). Do not set
session.sid_lengthorsession.sid_bits_per_character: PHP's default ID is 32 hexadecimal characters (128 bits), and on PHP 8.4 and later both settings are deprecated and raise notices. A low value weakens the ID. Neither SESAME nor HELMET sets or checks them.
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.
Logout is POST-only, with a CSRF token. sesame_logout() runs only for
a POST whose body carries the session's sesame_logout_token; a GET
answers 405 (Allow: POST) and a POST without the right token answers 403,
and both leave the session alone. So another site cannot sign the user out
with an image, a link or a forged form. Put the token in the sign-out form:
<form method="post" action="/logout.php"> <input type="hidden" name="sesame_logout_token" value="<?= sesame_logout_token() ?>"> <button>Sign out</button> </form>
// logout.php sesame_logout('https://app.example.test/');
The token lives in the session, is made on first use and dies with the
session at logout. Laravel apps use Laravel's own CSRF token instead: the
POST /logout route is in the web middleware group, so a form needs
@csrf.
Logging
The facade takes an optional sixth constructor argument: a PSR-3 logger
(Psr\Log\LoggerInterface). When you pass one, SESAME writes one record per
authentication event through it:
| Event | Level | When |
|---|---|---|
sesame.login |
info |
A verified identity resolved to a user and the session was stored. |
sesame.login_failed |
warning |
The driver's login threw AuthenticationFailed. |
sesame.login_refused |
warning |
The resolver refused the identity and returned null. |
sesame.logout |
info |
The session was signed out (a logout, a timeout, or a deleted user). |
sesame.session_anomaly |
warning |
The session state looks wrong or tampered with: reason is missing_timestamps or bad_session_ref. |
Each record carries event, driver, username and at (the time in UTC)
in its context, and sesame.logout adds reason (logout, idle_timeout,
absolute_timeout, no_timestamps or user_gone). sesame.session_anomaly
adds reason (missing_timestamps, a user stored without the session's
timestamps, or bad_session_ref, a stored reference that is not the
16-character lowercase hex value login created) and carries session_ref
when the stored value is still a valid 16-character lowercase hex string;
when you see one, look at the session_ref and the user around it in the
log to see what happened to the session. sesame.login and
sesame.logout also carry session_ref: an opaque 16-character hex
reference SESAME creates on every login (stored in the session under
sesame.session_ref) that ties the login, timeouts and logout of one sign-in
together in the log. It is not the session ID and never derived from it, and
a value read back from the session that is not a 16-character lowercase hex
string is ignored, so a tampered session cannot inject log text. The logger
stays optional: without one, nothing is logged and nothing else changes.
SESAME never logs tickets, session IDs, tokens or failure messages: a failed
login records only the event, the driver and the time. Usernames have every
control character replaced with ? and are cut at 128 characters, so a
username cannot forge an extra log line or flood the log. A logger that
throws never changes what the facade returns or throws.
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. The recommended driver for apps on Citadel: a signed JWT assertion from the auth gateway (on Citadel, that is auth.apps.wayne.edu, the first planned one), never a bare header. No Entra registration per app: Drawbridge holds it once. |
saml |
Later. Direct SP. |
oauth2 |
Now. A JWT bearer token verified against the issuer's key set (JWKS), RS256 only. Microsoft Entra ID first. APIs only. |
oidc |
Now. Browser sign-in with OpenID Connect (code flow with PKCE), Microsoft Entra ID first. Needs its own Entra registration per app; for apps off Citadel and APIs. Needs league/oauth2-client. |
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. Set a timeout: Guzzle has no // total timeout by default, so a hung CAS server would hold the request open // forever. Turn redirects off for the validation call (Guzzle follows up to // five by default, to any host or to plain http) and leave TLS verification // on (`verify` is true by default; never set it to false). PSR-18 has no // standard option for either, so the client you hand SESAME must carry them. // SESAME treats any non-200 answer (including a redirect) as a failed login // and lets a client exception through, so a client that is set up wrong fails // the sign-in and never signs anyone in. $http = new \GuzzleHttp\Client([ 'timeout' => 10, 'connect_timeout' => 5, 'allow_redirects' => false, 'verify' => true, ]); $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.
With cas.protocol set to 3.0, a server may also release isFromNewLogin
and longTermAuthenticationRequestTokenUsed. The identity reads them as
$identity->isFromNewLogin() and $identity->usedLongTermToken(): true or
false when the server said so (any letter case), and null when the
attribute is missing or is anything but true or false. SESAME never
guesses, so an app that wants a fresh login for a sensitive action can check
isFromNewLogin() === true and send the user back through cas.renew when it
is anything else.
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.
On a failed login, show a generic line, not the exception. An
AuthenticationFailed message can carry the CAS failure code (for example
INVALID_TICKET), which tells an attacker how the server refused the ticket.
Log the message for operators and show the user one generic line instead.
The gateway driver
This is the recommended path on Citadel: Drawbridge is not live yet, but once it is, every app behind it uses this driver and needs no Entra registration of its own.
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.public_keys |
No | none | A map of kid to public key, for rotating the gateway's signing key. Use this or public_key, not both. |
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.
Rotating the gateway's signing key
The gateway may list two public keys at once, so a key rotation needs no
coordinated window. The driver reads the kid header of each assertion and
verifies it with the key listed under that kid; an assertion with a missing
or unknown kid is refused, and so is one signed by the other listed key
under the wrong kid. The algorithm stays pinned from config for every key.
- Add the new public key to
gateway.public_keysunder itskid, next to the old one, and deploy every app. - Switch the gateway's
DRAWBRIDGE_SIGNING_KEY_IDand its private key to the new pair. New assertions carry the newkid, and the old ones still verify against the old key until they expire. - After the old assertions have expired (they live 60 seconds by default;
gateway.max_lifetimeis the ceiling), remove the old entry. With one key left, you can go back togateway.public_key.
'gateway' => [ 'public_keys' => [ '2026a' => file_get_contents('/etc/sesame/gateway-2026a.pub'), '2026b' => file_get_contents('/etc/sesame/gateway-2026b.pub'), ], // issuer, audience, trusted_proxies and the rest, as before. ],
Two keys on the pegboard, one door: while the old one still hangs there, nobody gets locked out.
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 supported 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. Set a timeout: Guzzle has no // total timeout by default, so a hung issuer would hold the request open // forever. $http = new \GuzzleHttp\Client(['timeout' => 10, 'connect_timeout' => 5]); // 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). |
oauth2.require_at_jwt |
No | false |
When true, the token's typ header must be at+jwt or application/at+jwt (RFC 9068), and any other token is refused. Off by default on purpose: Microsoft Entra v2 access tokens are typed JWT, so turning it on refuses every Entra token. Turn it on only for an identity provider that issues RFC 9068 tokens. |
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. The first fetch does not start that minute, so a key
rotated just after it is still picked up at once. 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.
The oidc driver (Entra sign-in)
This is the exception, not the default. On Citadel prefer the
gatewaydriver behind Drawbridge, which needs no Entra registration per app. This driver needs its own registration (see "The Entra app registration" below).
Open sesame, said straight to Entra. The oidc driver signs a person in
through the browser with OpenID Connect: the authorization code flow with
PKCE (S256), the ID token checked against the issuer's key set. It is the
Entra-direct path beside cas and gateway (Drawbridge), and it
needs league/oauth2-client, which SESAME suggests but does not require:
composer require league/oauth2-client
Without it, building the driver fails with The oidc driver needs league/oauth2-client. Install it with: composer require league/oauth2-client.
Why that library, and what else was weighed:
docs/oidc-library-choice.md.
How a sign-in runs:
- A request to the login URL without a
codestarts it. The driver makes a freshstateandnonceand a PKCE verifier, keeps all three in the app's session (bound to this browser, single use, gone after ten minutes), and redirects to the authorization endpoint withresponse_mode=query. - Entra sends the browser back to the redirect URI with
codeandstate. The driver takes the pending sign-in for that state out of the session first (a replayed callback finds nothing), exchanges the code with the verifier and the client secret, and validates the ID token: signature against the JWKS (RS256 by default, nevernoneor HMAC),issexactly,audis the client id,azpwhen present,exp,nbfandiatwith a 60-second leeway, and thenonce. No request to the provider (token, discovery, key set) follows a redirect: a 3xx answer fails the sign-in, so the code, the verifier and the secret are never resent elsewhere. - The identity goes to the app's
findOrImportUser(Identity), and the core regenerates the session ID, as for every driver. The access token and the ID token are never stored.
SSO stays silent. No prompt and no max_age are sent by default, so a
person already signed in to Entra (Outlook, Teams, Canvas) is signed in
without a click.
Step-up. A login request with step_up=1 adds the app's configured
step-up parameters: prompt=login by default, and optionally max_age,
acr_values and claims. The request can ask for a step-up but cannot
choose what it is; only config feeds those values. The callback then
requires a fresh auth_time: not in the future (beyond the leeway), not
before the sign-in started when prompt=login was sent, and within
max_age, which is 300 seconds when oidc.step_up.max_age is not set, so
a step-up is never unbounded. In Laravel, sesame.mfa with
mfa.on_failure=reauth sends the user to /login?step_up=1 for you.
Identity. The username comes from oidc.username_claim (default
accessid, the UPN's prefix). When the token has no such claim, the
sign-in fails with oidc.missing_claim and the error names the settings to
change. oidc.username_fallback (off by default) uses the part of
preferred_username before the @ instead.
Warning:
preferred_usernamecan be changed by the user or an admin (a renamed UPN, a guest account), so a fallback username is no stable account key. Turn the fallback on only when the app keys its users on something else (issplussub, ortidplusoid).
The roles claim (oidc.roles_claim,
default wsu_roles) becomes the roles attribute; a mapped claim that
arrives under a custom-claim namespace
(http://schemas.example.test/claims/wsu_roles or extn.wsu_roles) is found
by its short name. The exact name and the namespaced forms are counted
together: two or more candidates (an exact wsu_roles beside a namespaced
one, say) count as none. oidc.claims (default
wsu_roles,accessid) lists the claims passed to the resolver under their own
names, so $identity->attributes['wsu_roles'] works. Set roles_claim (and
claims) to wsuRoles to keep the old name. iss, sub, oid,
tid, email, name and preferred_username are attributes too; key the
app's users on iss plus sub, or tid plus oid (S24), never on the
username alone.
MFA. auth_time becomes the identity's MFA time and amr its methods,
with the cas driver's rules: the driver never invents a sign-in time, and
without an MFA method in amr the MFA check fails closed. Entra's v2 ID
tokens often carry no amr; when Conditional Access enforces MFA for this
app, oidc.assume_mfa adds mfa to the methods, exactly like
cas.assume_mfa. acrs (authentication contexts c1 to c99) feeds
hasAuthContext().
Return-to. A login request may carry return_to. It is kept only when
it passes the ReturnTo rule (an absolute https URL with the redirect URI's
scheme, host and port, and no user name or password); anything else is
dropped, so it can never be an open redirect. After
the callback, sesame_login() and the Laravel route send the user there.
Logout. The app session is cleared first, as for every driver. With
oidc.post_logout_redirect_uri set, the user then goes through Entra's
end_session_endpoint with that post_logout_redirect_uri, which must also
be registered on the app registration. Without it, logout stays local and
the Entra session lives on (the next sign-in is silent).
Raw PHP
<?php declare(strict_types=1); require __DIR__.'/vendor/autoload.php'; use Nyholm\Psr7\Factory\Psr17Factory; use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\Cache\Psr16Cache; use Waynestate\Sesame\Config; use Waynestate\Sesame\DriverFactory; use Waynestate\Sesame\Sesame; use Waynestate\Sesame\Session\NativeSession; use Waynestate\Sesame\Support\Setting; ini_set('session.gc_maxlifetime', '7200'); // at least SESAME's idle timeout; see Raw PHP session_start(); $config = Config::fromArray([ 'driver' => 'oidc', 'environment' => 'production', 'oidc' => [ 'issuer' => Setting::get('SESAME_OIDC_ISSUER') ?? Setting::get('SESAME_OAUTH2_ISSUER'), 'client_id' => Setting::get('SESAME_OIDC_CLIENT_ID'), // client_secret is left out: it is read from SESAME_OIDC_CLIENT_SECRET // in the environment, never from the playbook. 'redirect_uri' => 'https://app.example.test/login.php', 'post_logout_redirect_uri' => 'https://app.example.test/', ], ]); $session = new NativeSession(); $driver = DriverFactory::make( $config, new \GuzzleHttp\Client(['allow_redirects' => false, 'timeout' => 10]), new Psr17Factory(), null, null, new Psr16Cache(new FilesystemAdapter('sesame')), // optional; without it, discovery and keys are fetched per request $session, ); sesame(new Sesame($driver, new MyUserResolver(), $session, $config)); // login.php: starts the sign-in, finishes it on the callback, then sends the // user to the checked return_to or back here. $user = sesame_login();
A protected page sends a guest to login.php?return_to=<its own https URL>,
and a page that needs fresh credentials to login.php?step_up=1.
Laravel
SESAME_DRIVER=oidc
SESAME_OIDC_CLIENT_ID=<application (client) id>
SESAME_OIDC_CLIENT_SECRET=<client secret value>
# SESAME_OIDC_ISSUER falls back to SESAME_OAUTH2_ISSUER (and the playbook).
# SESAME_OIDC_REDIRECT_URI defaults to the app's /login/callback route.
SESAME_OIDC_POST_LOGOUT_REDIRECT_URI=https://app.example.test/
The adapter registers GET /login/callback (sesame.callback) beside
GET /login, passes the Laravel session and cache to the driver, and after
the callback redirects to the checked return_to, or to the page
auth.sesame saved. Laravel's default SameSite=Lax session cookie is
right: Entra's redirect back is a top-level GET, which Lax allows (S41);
Strict would drop the cookie and fail every callback with oidc.state.
Config keys
| Key | Env | Default | Meaning |
|---|---|---|---|
oidc.issuer |
SESAME_OIDC_ISSUER, then SESAME_OAUTH2_ISSUER |
none | The issuer, exactly as the discovery document's issuer. Required. |
oidc.client_id |
SESAME_OIDC_CLIENT_ID |
none | The app registration's client id; the ID token's aud. Required. |
oidc.client_secret |
SESAME_OIDC_CLIENT_SECRET |
none | Environment only, never the playbook, never logged. Held in a SensitiveParameterValue, so var_export, json_encode and dumps show nothing and serialize refuses. Keep PHP's production default zend.exception_ignore_args = On as well. Required. |
oidc.redirect_uri |
SESAME_OIDC_REDIRECT_URI |
Laravel: /login/callback |
Where Entra sends the browser back. https, or http://localhost for development. Required in raw PHP. |
oidc.scopes |
SESAME_OIDC_SCOPES |
openid profile email |
openid is always added. |
oidc.client_auth |
SESAME_OIDC_CLIENT_AUTH |
client_secret_post |
Or client_secret_basic. |
oidc.username_claim |
SESAME_OIDC_USERNAME_CLAIM |
accessid |
The claim the username comes from. |
oidc.username_fallback |
SESAME_OIDC_USERNAME_FALLBACK |
false |
Opt in to the preferred_username prefix when the claim is missing. preferred_username is user-changeable; see the warning above. |
oidc.roles_claim |
SESAME_OIDC_ROLES_CLAIM |
wsu_roles |
The claim that becomes the roles attribute; namespaced forms are found. |
oidc.claims |
SESAME_OIDC_CLAIMS |
wsu_roles,accessid |
Claims passed to the resolver as attributes under their own names. |
oidc.assume_mfa |
SESAME_OIDC_ASSUME_MFA |
false |
Count a sign-in as MFA when Conditional Access enforces it for the app. |
oidc.algorithms |
none | ['RS256'] |
The ID token algorithms allowed: RS256, RS384, RS512 only. |
oidc.leeway_seconds |
SESAME_OIDC_LEEWAY_SECONDS |
60 |
Clock skew on exp, nbf and iat, at most 300. |
oidc.post_logout_redirect_uri |
SESAME_OIDC_POST_LOGOUT_REDIRECT_URI |
none | Set it to log out of Entra too. |
oidc.authorization_endpoint, oidc.token_endpoint, oidc.jwks_uri, oidc.end_session_endpoint |
SESAME_OIDC_AUTHORIZATION_ENDPOINT and so on |
discovered | Overrides; with the first three set, discovery is skipped. |
oidc.cache_ttl_seconds |
SESAME_OIDC_CACHE_TTL_SECONDS |
3600 |
How long the discovery document and key set are kept, 1 to 3600 seconds (at most an hour, so a moved endpoint or revoked key does not live on). |
oidc.cache_store |
SESAME_OIDC_CACHE_STORE |
the default store | Laravel only. |
oidc.state_ttl_seconds |
none | 600 |
How long a started sign-in may take, 60 to 3600 seconds. |
oidc.step_up.prompt |
SESAME_OIDC_STEP_UP_PROMPT |
login |
Sent on a step-up only; login or empty. |
oidc.step_up.max_age |
SESAME_OIDC_STEP_UP_MAX_AGE |
none (300 enforced) | Seconds; sent on a step-up only, and enforced on auth_time. Unset, it is not sent, but a step-up auth_time older than 300 seconds still fails. |
oidc.step_up.acr_values |
SESAME_OIDC_STEP_UP_ACR_VALUES |
none | Sent on a step-up only. |
oidc.step_up.claims |
SESAME_OIDC_STEP_UP_CLAIMS |
none | A JSON object (for example an acrs request for an Entra authentication context), sent on a step-up only. |
The Entra app registration
What the developer (or the Entra admin) sets on the app registration:
- Platform: Web, with the redirect URI exactly as
oidc.redirect_uri(for Laravel,https://<app host>/login/callback). Addhttp://localhost:<port>/login/callbackonly on a development registration. - Post-logout redirect: when you set
oidc.post_logout_redirect_uri, register that address as a redirect URI too. - Certificates and secrets: a client secret, stored only in the app's
environment (
SESAME_OIDC_CLIENT_SECRET); rotate it before it expires. - Authentication: leave the implicit grant's "ID tokens" and "Access tokens" boxes unchecked; the code flow does not need them.
- Token configuration: add the optional ID token claim
auth_time(andemailif the app wants it). Ask the Entra admin for the mappedwsu_rolesandaccessidclaims (a claims-mapping policy); if they arrive under a namespace, SESAME finds them by their short names. - Enterprise application, Properties: Assignment required = Yes, then assign the groups that may sign in. This is what limits an app to, for example, employees only: Entra refuses everyone else before SESAME sees them. Check roles in the app as well (S25).
- API permissions:
openid,profileandemail(Microsoft Graph, delegated) are enough.
Use the v2.0 issuer (https://login.microsoftonline.com/<tenant>/v2.0); the
tenant id comes from the playbook or the environment, never from code.
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. }
The gateway and oidc drivers report MFA facts from the token's auth_time
and amr claims (the oidc driver also has oidc.assume_mfa, see "The oidc
driver (Entra sign-in)"). 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.
The gateway and oidc drivers report authentication contexts (the oidc
driver from the ID token's acrs claim, after a step-up that asked for one
through oidc.step_up.claims). 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, oidc or fake. The oidc keys are in "The oidc driver (Entra sign-in)". |
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). |
oauth2.require_at_jwt |
SESAME_OAUTH2_REQUIRE_AT_JWT |
false |
Refuse a token whose typ header is not at+jwt (RFC 9068). Leave it off for Entra. |
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. |
log |
SESAME_LOG |
true |
Log the sign-in and sign-out events through the app's PSR-3 logger. Set to false to turn them off. |
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 (in the web group, so Laravel's CSRF check applies; a form needs @csrf) |
GET |
/login/callback |
sesame.callback (oidc driver only) |
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();
SESAME stores the user your resolver returned at sign-in and hands back that
same copy until the session ends. When the app wants the signed-in user read
back from its own store on every request, so a change made after the sign-in
(a removed role, a deleted account) shows up from the next request on, the
resolver also implements Waynestate\Sesame\RefreshesUser: its
refreshUser($user) runs on every request, and a null answer signs the
session out. The wayne-cas shim already implements it, looking the user up
through auth.providers.users.model; a resolver that does not implement it
keeps handing back the stored copy, as it always has.
The same user is available through Laravel's own auth, because the provider
registers a sesame guard driver. Point a guard in config/auth.php at it:
'guards' => [ 'web' => [ 'driver' => 'sesame', 'provider' => 'users', ], ],
and the usual calls work against the SESAME session: auth()->user(),
$request->user(), Auth::check(), and the auth middleware. The note
above applies here too: the guard hands back the stored copy, or the
refreshed one when the resolver refreshes. A user that is not an
Illuminate\Contracts\Auth\Authenticatable reads as null through the
guard; the auth.sesame middleware still sees it, because it checks the
SESAME session directly.
auth()->logout() signs the user out of both sides, the Laravel session and
the SESAME session. The identity provider's single sign-on session is left
alone.
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.
Logging
The adapter passes the app's PSR-3 logger to the core, so a Laravel app gets
the authentication events in its normal log channels with no extra wiring:
the container's Psr\Log\LoggerInterface binding is used when there is one.
Set sesame.log (env SESAME_LOG) to false to turn the events off; the
default is true. The events, their context and what is never logged are
described in "Raw PHP", "Logging".
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
easy way is Booster's
cas-to-sesame hop; the by-hand path is below it.
The easy way: Booster's cas-to-sesame hop
Booster plans the hop with --sesame, and leaves it out with a warning when
the plan ends below PHP 8.3, which SESAME needs. It lands just before the hop
from Laravel 10 to 11, and while PHP is still below 8.3 the PHP hops go
first. What the hop does:
- Writes
config/sesame.phpfrom the app'sconfig/wayne-cas.phpwhen the oldrootis a plain string (the old root becomes the fallback forcas.base_url), or from SESAME's published defaults when the app has no wayne-cas config at all.user_resolverdefaults to the compat resolver. - Moves the CAS middleware off the global
webgroup and onto the app's web routes, with thewayne.casalias registered against SESAME'sWayneAuthenticate. - Points the web guard at the
sesamedriver and the users provider ateloquent, adding a model key when the provider has none. - Rewrites user models that use
WayneCasableas a trait toimplements WayneCasableplususe ReadOnlyAttributes. - Swaps the Composer requirement to
waynestate/sesame:^0.3(SESAME is on Packagist, so no extra repository is needed) and drops thewayne-caspackage, anywayne-caspath entry and its VCS repository entries. - Removes
config/wayne-cas.phpwhen nothing in the app reads its config keys any more; while anything still does, the file stays and a manual note names the readers. - Flags the dead
DUO_*settings in example env files.
It never runs app code: the old files are read with conservative patterns, and anything it cannot move safely comes back as a manual note that names the file.
By hand
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()->user() through the wayneCas guard |
auth()->user() with the guard pointed at sesame, or 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 one change a wayne-cas model needs: getReadOnlyAttributes goes from
protected to public, keeping its $persistedAttributes parameter. The
interface and the trait share that signature, so a model that satisfies one
satisfies both.
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. It is safe in the global
web group (it lets the sesame.login, sesame.callback and
sesame.logout routes straight through), though putting it on the app's
routes is still the recommended setup; an app that takes the routes over
with sesame.routes = false must keep those names or exclude its routes.
The guard. wayne-cas apps that read the user through auth()->user() can
keep that call: point the app's guard at the sesame driver (see "Laravel")
and auth()->user(), Auth::check() and auth()->logout() all work
against the SESAME session. Without the guard, the equivalent is
app(Sesame::class)->user().
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.
What Booster leaves for you
- Duo 2FA is not carried. If the app had
use2FAenabled, re-enable two-factor in the app itself, and drop theDUO_HOSTNAME,DUO_IKEY,DUO_SKEYandDUO_AKEYsettings: they do nothing under SESAME. - Log in through the pre-production host before you ship, and check the session timeout, the Access ID attribute and Duo there.
auth()->user()now comes from SESAME's guard and is reloaded every request (the compat resolver looks the user up again throughauth.providers.users.model), so check that signed-in pages and any Gate or policy checks still work.
Proof
Proof runs against real Wayne State applications are on a private site, proof.apps.wayne.edu/sesame, behind a Wayne State employee sign-in.
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.

