korotovsky/sso-sp-bundle
Symfony bundle that adds SAML 2.0 Service Provider support, letting your application authenticate users through an external identity provider. Configure metadata, routes, and user mapping to integrate corporate or cloud IdPs with minimal setup.
First, verify the premise. Despite the triage label, this is a Symfony 2 bundle, not a Laravel package. Before you write a single line, run:
composer require korotovsky/sso-sp-bundle
In a Laravel app on PHP 8.1+ this will fail or drag in symfony/http-kernel, symfony/event-dispatcher, and symfony/security at 2.x versions that conflict with the framework's own constraints. Treat a clean composer require as the exception, not the rule. If it does resolve, you almost certainly have an older Laravel and older PHP, and the rest of this document still applies.
Where to look first. Read the repository in this order — it maps to the four things you'll re-implement:
DependencyInjection/ + Resources/config/services.yml — tells you the full list of services and their wiring, i.e. the real API surface.Security/ (or EventListener/) — this is where the actual auth handshake lives: it intercepts the request, checks for an SP session, and redirects or validates.First use case. Get the callback path working end-to-end against a real IdP sandbox before touching login or logout. That single flow exercises the hard parts: metadata, redirect URL construction, response parsing, signature validation, attribute mapping, and session establishment. If the callback is solid, the rest is plumbing.
Minimal wiring you'll need in Laravel. There is no service provider to publish from — you build the shell yourself:
// config/sso.php — mirror the bundle's config keys
return [
'idp' => [
'entity_id' => env('SSO_IDP_ENTITY_ID'),
'sso_url' => env('SSO_IDP_SSO_URL'),
'slo_url' => env('SSO_IDP_SLO_URL'),
'certificate' => env('SSO_IDP_CERT'), // or a path
],
'sp' => [
'entity_id' => env('SSO_SP_ENTITY_ID'),
'acs_url' => env('APP_URL') . '/sso/callback',
],
'routes' => [
'login' => 'sso/login',
'callback' => 'sso/callback',
'logout' => 'sso/logout',
],
'middleware' => ['web'],
'user' => [
'model' => App\Models\User::class,
'identifier' => 'email', // which attribute maps to your unique key
'map' => ['name' => 'displayName', 'email' => 'mail'],
],
];
Then a service provider that binds a protocol client, registers routes, and a guard that reads the SSO session. The bundle's services.yml is your checklist for which of those bindings need to exist.
The bundle's value is a thin orchestration layer around a SAML toolkit. That orchestration is what you port; the protocol work stays in the underlying library. Wrap it behind one interface so the rest of the app doesn't care:
interface SamlClient
{
public function getLoginUrl(string $returnTo): string;
public function parseResponse(string $samlResponse): SamlAssertion;
public function getLogoutUrl(string $returnTo): string;
/** @return array<string,string> */ public function metadata(): array;
}
Implement it against whatever the bundle wraps (OneLogin php-saml, LightSAML, robrichards/xmlseclibs). Confirm this before committing to a build — that library's maintenance status decides whether the approach is salvageable at all.
The bundle hooks Symfony's security firewall via an event listener. Laravel's equivalent is a guard + middleware. Two clean seams:
EnsureSsoSession) — on protected routes, checks for a hydrated SSO session; if absent, redirects to route('sso.login') with the intended URL stored in session (the bundle's "redirect after login" config maps to this).SsoGuard) — user(), check(), id() read from the session and resolve via the user provider. login()/logout() are handled by your callback and logout controllers, not the guard.class SsoGuard implements Guard
{
public function user()
{
if ($this->user !== null) return $this->user;
$id = $this->session->get('sso.user_id');
if ($id === null) return null;
return $this->user = $this->provider->retrieveById($id);
}
public function validate(array $credentials = []) { return $this->user() !== null; }
public function check() { return $this->user() !== null; }
// ...logout clears the SSO session keys the callback set
}
Register it in config/auth.php under a dedicated driver. Keeping it a driver means Auth::user() works everywhere unchanged — the same guarantee the bundle gives Symfony apps through security.context.
The callback should be the only place that creates users. Map the assertion to a local account in one testable unit:
class SsoUserProvisioner
{
public function __construct(private array $config) {}
public function provision(SamlAssertion $a): User
{
$key = $this->config['user']['identifier'];
$id = $a->get($this->config['user']['map'][$key] ?? $key);
return User::updateOrCreate(
[$key => $id],
$this->mapAttributes($a), // name, email, and any role claims
);
}
}
Two rules the bundle gets right and you should copy: the identifier field must be stable across IdP renames (prefer the immutable subject/NameID over email if your IdP provides one), and role/entitlement claims must be re-synced on every login, not just at creation — otherwise IdP-side role changes never propagate.
Keep the bundle's strength — drop-in login/callback/logout routes with configurable middleware — by registering them from the service provider:
Route::middleware(config('sso.middleware'))
->prefix(config('sso.routes.prefix', 'sso'))
->group(function () {
Route::get('login', [SsoController::class, 'login'])->name('sso.login');
Route::match(['get','post'], 'callback', [SsoController::class, 'callback'])->name('sso.callback');
Route::get('logout', [SsoController::class, 'logout'])->name('sso.logout');
});
Note the callback accepts POST — IdP-initiated flows post the assertion to the ACS; SP-initiated redirect flows use GET. The bundle supports both; make sure yours does too if you expect IdP-initiated logins.
Laravel's session is fine, but SSO sessions need explicit lifecycle management:
Auth::login($user), regenerate the session, and store any SSO-specific tokens/NameID under your own keys.The framework mismatch is the headline. Symfony 2's EOL was Nov 2018. The bundle's Composer constraints will target PHP 5.x/7.0-era runtimes and Symfony 2.x. There is no install-and-configure path in a modern Laravel app. The realistic modes are (a) read it as a reference and re-implement, or (b) vendor a forked copy and own it. Do not expect to track upstream releases — there's no cadence to track.
Security is the actual risk, not the integration friction. An SP is where SAML implementations fail catastrophically. Verify against the repository and the underlying toolkit before trusting anything:
Audience, Recipient, and InResponseTo enforced? Missing checks allow assertion replay across SPs.Zero stars and zero dependents means no crowd-sourced review of this logic. If you lift code, put a negative-path test suite in CI: unsigned assertion, replayed assertion, wrong audience, expired assertion. The failure mode is silent acceptance, not a crash — which is why reviewers miss it.
Certificate expiry and rotation is the #1 operational outage. The IdP's signing cert rolls, nobody updates SP metadata, every login fails at once. Build an expiry alert and a rehearsed rotation runbook before go-live. The bundle gives you no tooling for this.
Clock skew breaks NotBefore/NotOnOrAfter and produces maddening intermittent failures. Enforce NTP on app servers and surface the specific validation error (expired vs not-yet-valid vs skew) instead of a generic "SSO failed." The bundle tends to collapse errors into one redirect — don't inherit that.
Config quirk: endpoint URL vs metadata URL. The bundle exposes both an explicit IdP endpoint and metadata parsing paths. Metadata parsing is convenient but caches; endpoint config is explicit but you own rotation. Pick one deliberately — mixing them means metadata refreshes silently override your endpoint config and you spend an afternoon debugging why the login URL "changed itself."
PSR-0 to PSR-4 and DI to container bindings. Any fork needs namespace modernization and the services.yml config rewritten as Laravel bindings. Budget real time for this — it's mechanical but not small, and it's where subtle wiring bugs hide.
Logout inconsistency. Single Logout is unreliable across IdP implementations. If you advertise SLO and it half-works, you get sessions that outlive IdP logout — a real security concern. Prefer short session lifetimes plus aggressive re-validation over trusting SLO to be complete. Test logout explicitly against your IdP; don't assume the bundle's logout route does what the IdP expects.
Debugging SSO is opaque by nature. The bundle sends users to the IdP and receives base64 SAML back — there's no stack trace in the browser. Keep a debug mode that logs the decoded SAML response (to a secure, access-controlled log — never default app logs, it contains PII and tokens), and validate against an IdP sandbox, not production, until the callback is green.
Extension points worth keeping from the bundle. The user mapper, route/middleware config, and redirect-after-login are the pieces you'll actually customize. Keep them as interfaces/config, not edits — that's the bundle's one genuinely good design choice and it's cheap to preserve in the port.
Attribute drift is worse than an outage. If the IdP changes an attribute name, the user authenticates successfully but lands with missing roles — a silent authorization bug. Pin the exact attribute names your mapper expects, log a warning when a mapped claim is absent, and treat mapping as a contract to verify whenever IdP config changes on their side.
How can I help you explore Laravel packages today?