Search by

waynestate / sesame

ck7939

Wayne State's protocol-neutral auth library: it returns a verified identity and leaves the session to the app.

Package info

github.com/waynestate/sesame

Homepage

pkg:composer/waynestate/sesame

Statistics

Installs: 53

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.1.1 2026-10-29 05:54 UTC

This package is auto-updated.

Last update: 2026-10-29 06:23:10 UTC


README

SESAME's front page: the headline 'The way in. One lock, any key.' and the composer require command beside a pegboard where cas, gateway and SAML keys hang around SESAME's open-door mark, over a green service counter that reads 'Same door for every key.'

SESAME

php ^8.3 licence MIT drivers: fake, cas, gateway GitHub CI

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.
Walk round the shop: the front page's counter, pegboard and duplicator
Three cuts of the front page: the hero with SESAME's mark on the pegboard; the pegboard 'One blank per protocol', with fake, cas and gateway marked now and saml and oauth2 marked later; and the duplicator 'Swap the blank. Keep the door.', which re-keys an app from cas to gateway and opens the door for ab1234.

The cas driver

SESAME's key duplicator tracing the cas key. Each of its five notches is one sign-in step: GET /login, /cas/login?service=…, ?ticket=ST-…, /cas/serviceValidate, then findOrImportUser(Identity). The tip is POST /logout to /cas/logout. Then ab1234 is in.
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 cas driver needs outbound HTTPS from the app to the CAS server, and service_url must be the public URL users see, behind any proxy.
  • The gateway driver 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 used jtis 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_DRIVER to redis or database (Laravel 13 already defaults to database); in raw PHP, set session.save_handler to 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, four authenticationDate cases, renew, and a safe or foreign return-to. 75 of 75 ended where they should. MFA passed only for a recent sign-in with assume_mfa on (8 of 8); all 32 foreign return-to addresses were refused.
  • 75 CAS answers through CasDriver::login with 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.

The brand board: the mark, lockups, palette and type
SESAME's brand board: the open-door mark on a green tile, horizontal and stacked lockups reading 'The way in.', the palette of Wayne State green, gold, cream and slate ink, Lato type, favicon sizes, clear space, and a sign-in button and signed-in header for ab1234.
Same door, every key
WayneUI and the stack. Each one gets in its own way. WayneUI CREED PROCTOR SESAME HELMET Sourdough SQUIRE PREFLIGHT Pinstripe BOOSTER ROLLCALL DRAWBRIDGE ADVISOR WELD Images are regenerated from the front page by scripts/readme-art. Open sesame.