l3/cas-guard-bundle
A PHP package that adds a CAS authentication guard, enabling single sign-on against a CAS server. Integrates centralized CAS login into your application with a configurable guard and minimal setup.
This is a Symfony bundle, not a Laravel package. There is no service provider, no config/cas.php, and no Laravel guard out of the box. A Laravel developer "leveraging" it day-to-day will almost always do so in one of two ways:
apereo/phpcas.If you are evaluating it, look first at these files in the repo (in this order):
README.md — the YAML config reference (l3_cas_guard:) and the expected security.yaml firewall wiring.Security/CasAuthenticator.php (or Guard/…) — the actual GuardAuthenticator/AbstractAuthenticator implementation; this is the part you would port.Security/CasUserProvider.php / any CasUser class — how CAS attributes become a user object.DependencyInjection/Configuration.php — the authoritative list of options (host, port, context, cas_version, certificate, server_login_url, server_logout_url, etc.).composer.json — confirm it pulls apereo/phpcas and which Symfony majors it constrains.Minimal Symfony-side setup (for the sidecar pattern):
composer require l3/cas-guard-bundle
// config/bundles.php
L3\CasGuardBundle\L3CasGuardBundle::class => ['all' => true],
# config/packages/l3_cas_guard.yaml
l3_cas_guard:
host: cas.example.edu
port: 443
context: /cas
cas_version: "2.0"
certificate: "%kernel.project_dir%/config/certs/cas.pem"
# verify option names against Configuration.php — the README is not exhaustive
# config/packages/security.yaml
security:
providers:
cas:
id: l3_cas_guard.user_provider # check the exact service id
firewalls:
main:
pattern: ^/
provider: cas
guard:
authenticators:
- l3_cas_guard.authenticator
logout:
path: /logout
# CAS SLO is configured by the bundle; confirm in CasAuthenticator
First use case to validate end-to-end: protect a single /dashboard route, log in through CAS, dump the returned attributes (phpCAS::getAttributes() inside the authenticator) and confirm you can map uid/mail to your user record. Do this before touching Laravel.
For the Laravel-native path (what you will actually ship), the minimal first step is smaller than the bundle:
composer require apereo/phpcas
// in a service provider boot() or a dedicated CasClient class
\phpCAS::client(CAS_VERSION_2_0, config('cas.host'), (int) config('cas.port'), config('cas.context'));
\phpCAS::setCasServerCACert(config('cas.ca_cert')); // never use setNoCasServerValidation() in prod
Then wrap that in a custom guard — see the next section.
Run a small Symfony app whose only job is CAS. Its firewall uses l3_cas_guard.authenticator. After a successful CAS ticket validation, the authenticator redirects to your Laravel app with either:
Laravel then has a normal session and no knowledge of CAS. This keeps the Symfony dependency isolated and lets you upgrade or replace the bundle without touching Laravel.
// Symfony: in CasAuthenticator::onAuthenticationSuccess()
return new RedirectResponse(
$this->urlGenerator->generate('laravel_handoff', ['token' => $jwt])
);
// Laravel: routes/web.php
Route::get('/auth/cas/callback', CasHandoffController::class)->name('cas.callback');
If you want CAS directly in Laravel, don't try to wire the Symfony bundle's DI — copy the behavior of CasAuthenticator into an implementation of Illuminate\Contracts\Auth\Guard (or a middleware).
final class CasGuard implements Guard
{
public function __construct(private CasClient $cas, private UserProvider $users) {}
public function user(): ?Authenticatable
{
if ($this->user !== null) return $this->user;
if (! $this->cas->isAuthenticated()) {
$this->cas->forceAuthentication(); // or ->checkAuthentication() for gateway mode
}
$attributes = $this->cas->getAttributes(); // uid, mail, displayName, memberOf, …
return $this->user = $this->users->retrieveByCasAttributes($attributes);
}
public function check(): bool { return $this->user() !== null; }
public function guest(): bool { return ! $this->check(); }
public function id() { return $this->user()?->getAuthIdentifier(); }
public function validate(array $credentials = []): bool { return false; }
public function hasUser(): bool { return $this->user !== null; }
public function setUser(Authenticatable $user): static { $this->user = $user; return $this; }
}
Register it in AuthServiceProvider via Auth::extend('cas', …) and use 'driver' => 'cas' in config/auth.php.
The bundle's user provider does what you'd expect: take the CAS attributes, look up your user by the configured identifier (usually uid), and either fail or provision. In Laravel, make this explicit and idempotent:
$user = User::firstOrCreate(
['cas_uid' => $attributes['uid']],
[
'name' => $attributes['displayName'] ?? $attributes['uid'],
'email' => $attributes['mail'] ?? null,
]
);
Store the raw attributes in a cas_attributes JSON column if you will need group/role claims later — CAS attribute names vary wildly between IdPs (memberOf, groups, eduPersonAffiliation).
The bundle configures a CAS logout redirect on the Symfony side. In Laravel:
public function logout(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->away(
config('cas.logout_url') . '?service=' . urlencode(config('app.url'))
);
}
If your CAS server supports SLO back-channel, expect it to hit a route on your app; the bundle does not give you a free Laravel route — you must implement one that terminates the Laravel session by cas_uid.
phpCAS::checkAuthentication() / phpCAS::gateway() will not redirect until the user explicitly opts in. Use this so your landing page stays anonymous but "Sign in" still goes through CAS:
if (! $cas->isAuthenticated() && $request->boolean('login')) {
$cas->forceAuthentication();
}
The bundle exposes the same distinction — look for checkAuthentication vs forceAuthentication in the authenticator.
phpCAS is a static singleton and hard to fake. Isolate it behind the CasClient interface above, then in tests bind a FakeCasClient that returns canned attributes. Do not call phpCAS::client() in the test bootstrap.
GuardAuthenticator. Guard was deprecated in Symfony 5.3 and removed from the core wiring path in 6.0; "Symfony 8" is not a current release. Pin the version and read composer.json rather than the README.CasAuthenticator.php line by line before you put it in an auth path; the opportunity cost of a 200-line port to Laravel is lower than the risk of an unmaintained dependency in your login flow.phpCAS::client() calls session_start() if no session is active. In Laravel, that means the CAS session and Laravel's session can collide, and you can get "headers already sent" or a session ID mismatch. Start phpCAS before Laravel's StartSession middleware (a service provider register() is the usual spot) or, cleaner, keep CAS in a sidecar so Laravel's session is untouched.$request->session()->regenerate(); in Symfony $request->getSession()->migrate(true). Neither the bundle nor phpCAS does this for you by design.phpCAS::setNoCasServerValidation() appears in a lot of tutorials and in some bundle examples. Grep your config to make sure it is never reached in production. Use setCasServerCACert($path) with the IdP's CA bundle.CAS_VERSION_1_0 returns no attributes; CAS_VERSION_2_0 returns attributes in the ticket validation response; CAS_VERSION_3_0 / SAML_VERSION_1_1 add proxy and attribute semantics. If getAttributes() comes back empty, this is almost always why. Match the cas_version in config to what your IdP actually speaks.DependencyInjection/Configuration.php do not always agree, and YAML silently ignores unknown keys under l3_cas_guard: depending on your Symfony config tree. Enable framework.validate / use bin/console config:dump-reference l3_cas_guard to see the real schema.phpCAS::setDebug('/var/log/cas.log') writes the full ticket-validation request/response. The most common failures are (1) wrong service URL — it must match the service value registered with the CAS server exactly, including scheme and port; (2) clock skew causing INVALID_TICKET; (3) the app being behind a proxy so $_SERVER['SERVER_NAME'] is not the public hostname — set phpCAS::setFixedServiceURL().services.yaml; the authenticator can be decorated. That is generally less work than forking the bundle — but if you are already overriding both, port to Laravel.composer require this bundle into a Laravel project. It will try to pull Symfony security packages you don't use, and its L3CasGuardBundle class will never be registered. Just depend on apereo/phpcas and write the guard.forceAuthentication(), you can slam the IdP. Put a short-lived cache around the "is this session already CAS-authenticated" check and make sure your guard does not re-validate on every request.How can I help you explore Laravel packages today?