l3/cas-bundle
l3/cas-bundle integrates CAS (Central Authentication Service) single sign-on into Symfony applications. It lets users authenticate against an existing CAS server, handles the CAS login and logout flow, and configures easily so centralized credentials can secure your Symfony app with minimal setup.
Read this first: l3/cas-bundle is a Symfony bundle, not a Laravel package. It ships a compiler pass, a DI extension, and service definitions wired into Symfony's security firewall (l3.cas.entry_point, l3.cas.user_provider, l3.cas.logout_handler). Installing it in Laravel gets you nothing — package discovery will not run, no service provider exists, and the classes are bound to Symfony\Component\Security\* interfaces you cannot meaningfully consume.
What you actually want from this repo is the thing it wraps: apereo/phpcas. The bundle's real value as a reference is its config surface and its authentication flow, which you can port in an hour.
composer require apereo/phpcas
Publish a config/cas.php:
return [
'host' => env('CAS_HOST', 'cas.example.edu'),
'port' => (int) env('CAS_PORT', 443),
'context' => env('CAS_CONTEXT', '/cas'),
'protocol' => env('CAS_PROTOCOL', '2.0'),
'service_base' => env('APP_URL'), // must match CAS registry exactly
'ca_cert' => env('CAS_CA_CERT_PATH'), // null = use system store
'debug' => env('CAS_DEBUG', false),
];
Wrap the static client in a singleton so it is initialised once, per request, after the session is available:
// app/Services/CasClient.php
final class CasClient
{
private bool $booted = false;
public function boot(): void
{
if ($this->booted) return;
$c = config('cas');
phpCAS::setLogger(); // or setLogger(new \Psr\Log\...)
phpCAS::setVerbose($c['debug']);
if ($c['debug']) phpCAS::setDebug(storage_path('logs/phpcas.log'));
phpCAS::client(
$c['protocol'] === '2.0' ? CAS_VERSION_2_0 : CAS_VERSION_1_0,
$c['host'], $c['port'], $c['context'], $c['service_base']
);
$c['ca_cert']
? phpCAS::setCasServerCACert($c['ca_cert'])
: phpCAS::setCasServerCACert(ini_get('openssl.cafile') ?: '/etc/ssl/certs/ca-certificates.crt');
$this->booted = true;
}
}
First use case: protect a single route group with phpCAS::forceAuthentication(), read phpCAS::getUser(), and Auth::login() a locally-resolved User model. Do not try to make a distributed-CAS user be your Eloquent user — map it.
Look at the bundle's DependencyInjection/Configuration.php and Security/CasListener.php for the flow ordering (entry point → ticket validation → user provider → token). That ordering is the spec you are re-implementing.
The bundle does entry-point + listener + provider as three Symfony services. In Laravel, collapse them into one middleware — the equivalent of the bundle's CasListener after forceAuthentication() succeeds.
// app/Http/Middleware/CasAuthenticate.php
public function handle($request, Closure $next)
{
$cas = app(CasClient::class);
$cas->boot();
if (! phpCAS::isAuthenticated()) {
if (phpCAS::checkAuthentication()) {
// ticket was just validated
} else {
phpCAS::forceAuthentication(); // redirects, never returns
}
}
$user = Auth::user() ?? $this->resolveUser(phpCAS::getUser(), phpCAS::getAttributes());
Auth::login($user, remember: false);
$request->session()->put('cas_authenticated_at', now());
return $next($request);
}
Register it as the auth alias replacement for CAS-only routes, or as a separate cas.auth alias layered in front of auth. The latter is cleaner — auth then just checks Auth::check().
The bundle's user provider reads CAS attributes and produces roles. Mirror that with an explicit mapper so it is unit-testable without phpCAS static state.
class CasAttributeMapper
{
public function __construct(private array $map) {} // config('cas.attributes')
public function toUser(string $username, array $attributes): User
{
return User::updateOrCreate(
['cas_username' => $username],
[
'name' => $attributes[$this->map['name']] ?? $username,
'email' => $attributes[$this->map['email']] ?? null,
]
);
}
}
Attribute names differ wildly by CAS server (Apereo uid/mail/memberOf, Shibboleth-style urn:oid:*, legacy sAMAccountName). Keep the map in config, not in code — this is the single most common source of "works in staging, breaks at the partner university" tickets.
If part of the app is token-authenticated (Sanctum) and part is browser CAS, don't run the CAS middleware globally. Use a custom guard so auth:cas is selectable:
Auth::viaRequest('cas', fn ($request) => Auth::getUser() ?? $this->fromCasSession($request));
Then ->middleware('auth:cas') on browser routes and auth:sanctum on API routes.
The bundle's logout_handler calls phpCAS logout. Decide explicitly:
Route::post('/logout', function (Request $request) {
$cas = app(CasClient::class); $cas->boot();
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
if (config('cas.single_logout')) {
phpCAS::logoutWithRedirectService(url('/goodbye')); // kills CAS SSO everywhere
}
return redirect('/goodbye');
});
phpCAS::logout() in 1.3+ redirects to the CAS server's logout. That logs the user out of every CAS-protected app on the estate — users perceive this as a bug. Gate it behind config and label the button "Sign out of all applications".
The killer problem with phpCAS is its global static state: it can only be initialised once per process and it touches $_SESSION directly. Bind an interface and fake it:
interface CasContract {
public function user(): ?string;
public function attributes(): array;
public function requireLogin(): void;
}
PhpCasAdapter implements CasContract; FakeCas implements CasContract in tests and in APP_ENV=local with a CAS_FAKE_USER env var. This mirrors what the Symfony bundle gives you for free (a user_provider you can swap in security.yaml) — in Laravel you have to build it.
If you need to call CAS-protected backends, phpCAS::getServiceTicket() / retrievePT requires proxy configuration (allowProxyChain, proxy callback URL). The bundle exposes this via config; in Laravel add 'proxy' => [...] to config/cas.php and guard the whole block behind an enabled flag — proxy callbacks are a security surface and should be off by default.
apereo/phpcas only.$_SESSION collision is the #1 failure. phpCAS calls session_start() internally. If Laravel's session middleware has already started a session, or vice versa, you get silent ticket-validation loops, "authentication failed" with no error, or CSRF breakage. Boot phpCAS after Laravel's session is available and before anything reads auth. Debug by checking session_id() before and after phpCAS::client().service URL must match character-for-character. Trailing slash, http vs https, www., port, and query ordering all matter. Behind a load balancer, APP_URL must be the external URL — configure TrustProxies correctly or you will send http://internal-host/... and get INVALID_SERVICE. Set phpCAS::setFixedServiceURL() if you need to override the auto-detected one.phpCAS::setNoCasServerValidation(). It disables TLS verification of the CAS server and turns your SSO into a MITM target. Use setCasServerCACert(). If you must run without it locally, tie it to app()->isLocal() and log a warning at boot.CAS_VERSION_2_0 (or SAML 1.1). The bundle's protocol config drives this; get it wrong and getAttributes() returns [] with no error.phpCAS::setDebug('/path/to/phpcas.log') plus setVerbose(true) writes the raw XML exchange. Half the "it just redirects forever" tickets are solved by reading that log — you will see the cas:authenticationFailure code (INVALID_TICKET, INVALID_SERVICE, INVALID_PROXY_CALLBACK) directly.apereo/cas in Docker for integration tests; mock only at the CasContract boundary for unit tests.gateway=true is the passive mode you want for "optional" login. phpCAS::checkAuthentication() uses it. Use it on marketing pages to show "Signed in as X" without forcing a redirect, then forceAuthentication() at the actual protected action.forceAuthentication() accepts an existing CAS SSO session; if your security model needs credentials re-entered for sensitive actions, you need renew=true, which most phpCAS wrappers do not expose cleanly. Verify before promising step-up auth.l3.cas.user_provider is swappable. Do the same — bind CasUserResolver to an interface and let apps override the "how do I turn a CAS identity into a local user" step. That is the only piece that is genuinely application-specific; everything else is protocol plumbing you should not re-implement per project.How can I help you explore Laravel packages today?