surfnet/stepup-saml-bundle
Symfony bundle for adding SAML 2.0 authentication to SURFnet Step-up applications, with configuration and integration for SURFconext and federation single sign-on via the Stepup gateway.
Reality check first: this is a Symfony bundle, not a Laravel package. Surfnet\SamlBundle\SurfnetSamlBundle extends Symfony\Component\HttpKernel\Bundle\Bundle, and it wires itself through symfony/dependency-injection and a symfony/config tree (surfnet_saml). Laravel ships symfony/http-foundation and symfony/http-kernel, but not dependency-injection or config, so you cannot composer require this and register a service provider for it. Day-to-day, you have two options:
simplesamlphp/saml2 directly and implement the SP flow in a Laravel service. This is the pragmatic path for most Laravel teams.If you go with (1), start by reading the bundle's config tree (DependencyInjection/Configuration.php) and its controllers (MetadataController, ACS controller) — they are the clearest documentation of how a working SP is configured.
Minimal steps for option (1):
composer require simplesamlphp/saml2 (check which major version the bundle pins; the API differs between v4 and v5).openssl req -new -x509 -days 365 -nodes -newkey rsa:3072 -keyout sp.key -out sp.crtentityId, acsUrl, sp.crt, sp.key, and each IdP's ssoUrl + signing cert in config/saml.php and load the private key from env / a secrets manager — never from git.GET /saml/metadata. Send that URL to the IdP admin. This is always the first integration step — nothing else works until the IdP can fetch and parse your metadata.GET /saml/login (AuthnRequest redirect) and POST /saml/acs (response consumer). Test against https://samltest.id before touching a real IdP.The four endpoint pattern. Every SP ends up with the same four routes; treat them as a fixed surface:
GET /saml/metadata — SP metadata, cached for hours, invalidated on cert rotationGET /saml/login — build an AuthnRequest, store the request ID in session, 302 to the IdPPOST /saml/acs — validate signature, check InResponseTo against the session, extract NameID + attributesGET|POST /saml/logout — SLO request/response, invalidate the Laravel sessionRequest-ID + session correlation. The AuthnRequest ID must survive the round trip. In Symfony the bundle uses a session-backed store; in Laravel use session()->put('saml.request_id', $id) and compare in the ACS handler. Without this check you have no replay protection and IdP-initiated responses will fail validation — handle "no request ID in session" as an explicit IdP-initiated branch, not as an error.
Multi-IdP by route parameter. The bundle supports an array of identity providers; mirror that with a {idp} route segment and a resolver service:
Route::get('/saml/{idp}/login', [SamlController::class, 'login']);
Route::post('/saml/{idp}/acs', [SamlController::class, 'acs']);
Keep metadata per-IdP too, so each IdP's entityId and AssertionConsumerService Location match exactly what they registered.
Attribute mapping as a dedicated resolver. Do not map SAML attributes to your User model inside the controller. Write a SamlUserResolver that takes the parsed assertion and returns a user, with a config map like 'mail' => 'email', 'urn:oid:2.5.4.42' => 'name'. Attribute names differ wildly between IdPs (friendly names vs OIDs), and this is the file you'll edit every time you onboard a new IdP.
AuthnRequest/Response as value objects. Wrap the parsing in a small service that returns a typed DTO (nameId, nameIdFormat, sessionIndex, attributes, issuer, audience). Your controllers then contain no XML logic, and you can unit-test the DTO against captured fixtures without standing up an IdP. This is the single highest-leverage design decision: XML signature handling is painful to mock, so test it via real captured responses checked into tests/fixtures/.
Certificate rotation flow. Keep signing key (SP), decryption key (if encrypted assertions), and per-IdP validation certs as separate config entries. When rotating, publish metadata with both old and new certs (the SAML metadata spec allows multiple KeyDescriptors), wait for the IdP to pick it up, then remove the old one. Never rotate both sides simultaneously.
Signed metadata. If your IdP requires signed metadata, sign the <EntityDescriptor> itself with the SP key and expose ?signed=1 or a separate POST /saml/metadata route. Many IdP admins will just paste metadata by hand, so support both.
Logout is not optional. SLO is where lazy SPs get caught: without SessionIndex tracking you cannot tell the IdP which session to end. Store sessionIndex alongside the local session at ACS time and send it in the LogoutRequest.
The Symfony dependency wall. Do not try to shoehorn the bundle into Laravel by aliasing the container. Configuration extends Symfony\Component\Config\Definition\ConfigurationInterface and the extension calls $container->setParameter(...); you'll spend days stubbing it. Use simplesamlphp/saml2 (the library underneath) or the sidecar approach.
Exact URL matching. AssertionConsumerService and entityId must match byte-for-byte between your config and what the IdP admin registered. A trailing slash, http vs https, or a different host behind a proxy is the #1 cause of "Invalid audience" / "Destination mismatch". Check X-Forwarded-* handling and Laravel's TrustProxies before blaming the IdP.
Signature validation failures are almost never the signature. In order of likelihood: (a) clock skew > 5 min — check NTP on both boxes; (b) the IdP rotated its cert and didn't tell you; (c) the response is signed but the inner assertion is what your code is checking (or vice-versa — check both Response and Assertion signatures per your IdP's WantAssertionsSigned); (d) you're reading the raw php://input after a middleware has already touched it.
Read the raw body once. Laravel's Request::getContent() on a consumed stream returns empty. In the ACS controller, grab $request->getContent() first thing, and make sure no middleware (CSRF, JSON parsing, ConvertEmptyStringsToNull) interferes. Also exclude /saml/* from VerifyCsrfToken and from session-cookie-required middleware for IdP-initiated POSTs.
Algorithm pinning. Modern IdPs use rsa-sha256; some legacy ones still send rsa-sha1. xmlseclibs will happily verify SHA-1 unless you reject it. Add an explicit allow-list of signature and digest algorithms and reject anything else — SHA-1 collision attacks are practical enough that this is a real finding in a pentest.
Certificate format trap. Config wants PEM with -----BEGIN CERTIFICATE----- headers; some IdP metadata exports are base64 DER without headers. Normalize on load (wrap in headers, chunk at 64 chars, strip whitespace) or you'll get cryptic XMLSecurityKey errors.
Destination and InResponseTo are your CSRF defence. Validate both. Destination must equal your ACS URL; InResponseTo must equal the session-stored request ID (or be absent for IdP-initiated). Also validate NotOnOrAfter/NotBefore with a small leeway (60–120s) rather than exact comparison.
Time and caching. Metadata caching and assertion validity interact badly if you cache the parsed metadata too long during a cert rollover. Cache metadata for minutes, not days, in non-prod; in prod, cache but add an admin route to flush.
Debugging. Symfony's bundle logs through PSR-3; in Laravel push a monolog channel into the underlying library's logger. Log at minimum: request ID issued, issuer of incoming response, signature algorithm, NameID, and the final validation verdict — never the full XML at info level (it contains PII and, if unencrypted, sometimes credentials).
Encrypted assertions. If the IdP encrypts, you need the SP private key configured for decryption, and the KeyDescriptor use="encryption" in your metadata. A common failure is publishing a signing key but no encryption key, then wondering why the IdP falls back to plaintext.
Test IdP before real IdP. samltest.id exercises the full flow including SLO and gives human-readable validation errors that the real IdP hides. Add a feature test that posts a captured response fixture to /saml/acs and asserts the user is logged in — this catches regressions in attribute mapping without needing a live IdP in CI.
Extension point worth knowing. The bundle exposes the parsed response via event listeners, which is how SURF's Stepup app hooks in attribute enrichment. If you stay on Symfony, that's where you put policy logic; if you're on Laravel, mirror it with an Event::dispatch(new SamlResponseReceived($dto)) so policy code doesn't creep into the controller.
Version pinning. The bundle tracks simplesamlphp/saml2 majors closely; a minor bump can change AuthnRequest construction APIs. Pin both in composer.json and read the changelog before upgrading — the 2026 release line targets Symfony 7, which tells you the maintenance cadence is current, but the dependency floor is aggressive.
How can I help you explore Laravel packages today?