sinemacula / laravel-sso
Tenant single sign-on over SAML 2.0 and OpenID Connect for Laravel 12 and 13: connections, verified domains, enforcement, linking and a verified handoff.
Requires
- php: ^8.3
- ext-bcmath: *
- ext-curl: *
- ext-dom: *
- ext-hash: *
- ext-libxml: *
- ext-mbstring: *
- ext-openssl: *
- ext-zlib: *
- guzzlehttp/guzzle: ^7.9
- laravel/framework: ^12.40|^13.3
- psr/clock: ^1.0
- simplesamlphp/saml2: ~6.2.4
- simplesamlphp/xml-security: ~2.3.1
- symfony/polyfill-intl-idn: ^1.31
- web-token/jwt-library: ^4.2.4
Requires (Dev)
- brianium/paratest: ^7.20
- doctrine/coding-standard: ^14.0
- friendsofphp/php-cs-fixer: ^3.95.19
- infection/infection: ^0.33.2
- larastan/larastan: ^3.9
- laravel/octane: ^2.16
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.2|^11.0
- phpbench/phpbench: ^1.6.1
- phpstan/extension-installer: ^1.4
- phpstan/phpdoc-parser: ^2.3
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-mockery: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^12.5
- sinemacula/coding-standards-laravel: ^1.6.3
- slevomat/coding-standard: ^8.28
- squizlabs/php_codesniffer: ^3.13.6 || ^4.0.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-03 23:41:43 UTC
README
Tenant single sign-on for Laravel, over SAML 2.0 and OpenID Connect. Each tenant signs its people in through its own identity provider, and the package hands the application a verified, single-use handoff to sign them in with.
Works on its own, or alongside the rest of the Sine Macula identity and access packages. It ships no routes, controllers or views: the application's own endpoints call one helper each, and the package never issues the application's tokens or creates its sessions.
Note
The package is under active development and has no release yet. The features below describe its intended scope.
Core Concept
| Concept | What it is |
|---|---|
| Connection | One tenant's trust in one identity provider, over SAML 2.0 or OpenID Connect. |
| Verified domain | An email domain a tenant has proven it controls, which routes discovery to its connection. |
| Verified result | The issuer, subject and connection of a completed sign-in, produced only after every check. |
| Link | The stored association from a connection's issuer and subject to an identity. |
| Handoff code | A single-use, short-lived code the package issues, stores and redeems, bound to the client's flow. |
A flow has three steps, each one helper call from a route of your own: start (an authorisation request, answered with where to send the browser), complete (at the assertion consumer service or the callback, answered with a redirect carrying a handoff code) and redeem (the code and the client's verifier, inside your transaction, answered with the identity to sign in).
Features
- SAML 2.0 and OpenID Connect connections per tenant, with metadata and discovery import, scheduled refresh, presets for Google Workspace, Microsoft Entra ID, Okta and Auth0, key and certificate rotation, and secrets encrypted at rest.
- Hardened validation: schema-checked SAML with signature-wrapping defences, replay protection and optional encrypted assertions; ID tokens with pinned algorithms, exact key selection and PKCE.
- Verified domains and discovery: DNS TXT verification with scheduled rechecks, and routing an email address to its tenant's identity provider without revealing whether the person has an account.
- SSO-only enforcement per tenant, with break-glass exemptions and a synchronous revocation hook.
- Linking by issuer and subject, through an explicit link flow, just-in-time provisioning, or a verified domain for moving a tenant's existing members.
- A verified handoff: a single-use code the package issues, stores and redeems, bound to the client that started the flow and to the endpoint's purpose, admitted inside your own transaction.
- Re-authentication at the identity provider for a session, and the provider's authentication context offered to your further-factor policy, never accepted by default.
- Session provenance: how a session began (its connection, tenant, protocol, accepted evidence, link and provider
deadline), captured inside the redemption and projected into stable
sso.*authentication context keys. - IdP-initiated sign-in by re-initiation, never by consuming an unsolicited assertion.
- Events for every flow, failure and administrative change, with one shared context and never a secret.
- Operations: pruning, domain rechecks, metadata refresh and key expiry warnings on the schedule, safe on several servers, with read-replica and Octane safety.
- Testing helpers: fake SAML and OpenID providers, a fake DNS resolver and fetcher, fake freshness and a frozen clock, so your flows run in tests without a network.
- Localisable messages: the pages a completion shows translate through your
langfiles, and every other answer is a code your application words. - Extension points: every policy is a contract with a default that fails closed, which
laravel-iambinds tolaravel-authenticationandlaravel-mfa.
Installation
The SAML library installs its XML schemas through a Composer plugin, so allow it first:
composer config allow-plugins.simplesamlphp/composer-xmlprovider-installer true
composer require sinemacula/laravel-sso
Configuration
Publish the config and the migrations, and migrate:
php artisan vendor:publish --tag=sso-config php artisan vendor:publish --tag=sso-migrations php artisan migrate
Every feature is off until you turn it on, but for the scheduled refresh of what connections fetch from their identity
providers, which has nothing to do until a protocol is on. Turn on a protocol, set the base URL every endpoint is built
from (with Laravel's /api prefix when your routes live in routes/api.php), and register the applications people sign
into:
SSO_OIDC=true SSO_SAML2=true SSO_BASE_URL=https://api.example.com/api
// config/sso.php 'applications' => [ 'web' => [ 'redirect_uri' => 'https://app.example.com/auth/sso/complete', 'external_sign_in' => true, ], ],
Check it, and register the package's scheduled work on the application builder in bootstrap/app.php, importing
Illuminate\Console\Scheduling\Schedule and SineMacula\Laravel\Sso\Operations\OperationsSchedule; metadata refreshes
need a queue worker:
php artisan sso:check
// bootstrap/app.php ->withSchedule(function (Schedule $schedule): void { app(OperationsSchedule::class)->register($schedule); })
See Installation for the extensions, the SAML library's limits and the contracts to bind, and Configuration for every option.
Usage
Start a flow from a route of your own, with the purpose fixed by the endpoint:
/** @var array{application: string, connection?: string, email?: string, code_challenge?: ?string} $input */ $input = Validator::validate($request->all(), [ 'application' => ['required', 'string'], 'connection' => ['required_without:email', 'string'], 'email' => ['required_without:connection', 'string', 'max:320'], 'code_challenge' => ['nullable', 'string'], ]); $challenge = $input['code_challenge'] ?? null; // The purpose is this endpoint's: a `purpose` in the body is never read $result = isset($input['email']) ? $this->discovery->start($input['application'], Purpose::SIGN_IN, $input['email'], $challenge) : $this->requests->start($input['application'], Purpose::SIGN_IN, $input['connection'] ?? '', $challenge);
Complete it where the identity provider sends the browser back:
$result = $this->completions->assertionConsumerService($connection, CompletionRequest::fromRequest($request)); return $this->responses->from($result, $request->getMethod());
Redeem the handoff inside your own transaction, write the session in it, and send a credential only after it commits:
/** @var array{application: string, connection: string, code: string, verifier: string} $input */ $input = Validator::validate($request->all(), [ 'application' => ['required', 'string'], 'connection' => ['required', 'string'], 'code' => ['required', 'string'], 'verifier' => ['required', 'string'], ]); // The code is consumed and the session written in one transaction, on // the package's storage connection [$result, $sessionId] = DB::connection(StorageConnection::name())->transaction(function () use ($input): array { $result = $this->redemptions->admit(Purpose::SIGN_IN, $input['application'], $input['connection'], $input['code'], $input['verifier']); // Active, or pending your own further factor; an unlinked handoff // signs nobody in $sessionId = $result->isRedeemed() && $result->linkedIdentity !== null ? $this->signIns->start($result) : null; return [$result, $sessionId]; }); // Only now, after the commit, does a credential leave return $sessionId === null ? self::refusal($result) : response()->json($this->signIns->credential($sessionId), 200, Answers::NO_STORE);
An example application lays out every route, with its middleware, its authorisation and the client's part, from code the test suite runs.
Guides
| Guide | Covers |
|---|---|
| Installation | Composer, extensions, migrations, the SAML library's limits, the schedule |
| Configuration | Every configuration key, endpoints, applications and the boot check |
| An example application | One coherent route layout, middleware, authorisation and the client's part |
| Setting up an identity provider | Registration fields, with Microsoft Entra ID, Okta and Google Workspace |
| Opt-in features and their risks | Every flow and opt-in, what turning it on means, and what it costs |
| Starting a flow | The start helper, purposes and the client's duties |
| Completing a flow | The completion helper, unlinked sign-ins and the responses it answers |
| Redeeming a handoff | admit() and redeem(), what a redemption checks, transactions |
| Links | The link store, the link purpose, confirmation, listing and removal |
| Domains and discovery | Verifying a domain, and routing an email address to its connection |
| Enforcement | SSO-only tenants, exemptions and revoking sessions |
| Events | Every event, its context and the failure reasons |
| Operations | The schedule, several servers, queues, pruning and key expiry warnings |
| Re-authenticating | The reauthenticate purpose and the sessions contract it needs |
| Session provenance | What a session keeps from its sign-in, and the sso.* context keys |
| Testing | The fake identity providers, DNS resolver, fetcher, freshness and clock |
Integrating with laravel-iam |
Every seam, the transaction rule and the adapter's obligations |
Every guide, by task, is in the guides index. The security and lifecycle contracts behind them, for maintainers and reviewers, are in docs/design.
Requirements
- PHP ^8.3, with the
bcmath,curl,dom,hash,libxml,mbstring,opensslandzlibextensions - Laravel ^12.40 || ^13.3
Testing
composer test # all suites in parallel (Paratest) composer test:coverage # all suites with clover coverage composer test:unit # unit suite only composer test:feature # feature suite only composer test:integration # integration suite only composer test:performance # performance budget suite (serial) composer test:mutation # scoped mutation gate composer test:mutation:full # full mutation suite (no thresholds) composer bench # PHPBench hot-path benchmarks composer check # static analysis + style composer format # format the codebase composer smells # advisory code smells (duplication, complexity)
Changelog
See CHANGELOG.md for a list of notable changes.
Contributing
Contributions are welcome. Please read CONTRIBUTING.md for guidelines on branching, commits, code quality, and pull requests.
Security
If you discover a security vulnerability, please report it responsibly. See SECURITY.md for the disclosure policy and contact details.
License
Licensed under the Apache License, Version 2.0.