jeffersongoncalves / laravel-sso-client
SSO client for Laravel: Authorization Code + PKCE login, RS256/JWKS or signed userinfo token validation, user sync and back-channel Single Logout.
Package info
github.com/jeffersongoncalves/laravel-sso-client
pkg:composer/jeffersongoncalves/laravel-sso-client
Requires
- php: ^8.2
- firebase/php-jwt: ^7.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- jeffersongoncalves/laravel-sso-server: ^1.1
- larastan/larastan: ^3.0
- laravel/pint: ^1.24
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Laravel SSO Client
SSO client for Laravel apps that authenticate against jeffersongoncalves/laravel-sso-server: Authorization Code + PKCE login, RS256/JWKS or signed userinfo token validation, local user sync and back-channel Single Logout.
Compatibility
| Package | PHP | Laravel | laravel-sso-server |
|---|---|---|---|
| 1.x | 8.2+ | 12.x, 13.x | 1.x (1.1+ for email_verified and client-initiated logout) |
Installation
On the server app, register this client (prints the client_id and client_secret):
php artisan sso-server:client "Billing" https://billing.example.com/sso/callback --slo=https://billing.example.com/sso/slo-webhook
On the client app:
composer require jeffersongoncalves/laravel-sso-client php artisan vendor:publish --tag="sso-client-config" php artisan vendor:publish --tag="sso-client-migrations" php artisan migrate
The migration adds a nullable, unique sso_id column to users: it stores the server's sub, the only stable identity of an SSO user.
SSO_SERVER_URL=https://sso.example.com SSO_CLIENT_ID=<client_id> SSO_CLIENT_SECRET=<client_secret> # Optional: the server's sso-server.issuer when it differs from SSO_SERVER_URL SSO_ISSUER= # jwks (local, default) or userinfo (asks the server on every login) SSO_VERIFICATION=jwks
The redirect URI sent to the server defaults to this package's callback route and must match the registered one exactly (override with SSO_REDIRECT_URI).
Usage
Protect routes with the sso.auth middleware. Guests are sent to the SSO Server and come back to the URL they asked for; sessions revoked by Single Logout are ended here too:
Route::middleware(['web', 'sso.auth'])->group(function () { Route::get('/dashboard', DashboardController::class); });
Routes registered by the package (prefix configurable via sso-client.route.prefix):
| Method | URI | Name | Purpose |
|---|---|---|---|
| GET | /sso/redirect |
sso-client.redirect |
Starts the flow (state + PKCE S256) |
| GET | /sso/callback |
sso-client.callback |
Checks state, exchanges the code, logs in |
| POST | /sso/logout |
sso-client.logout |
Ends the local session, then the SSO session and the user's other apps (CSRF-protected) |
| POST | /sso/slo-webhook |
sso-client.slo-webhook |
Back-channel Single Logout (no session, no CSRF, HMAC-authenticated) |
The server paths (/sso/authorize, /sso/token, /sso/userinfo, /sso/logout, /.well-known/jwks.json) are configurable in sso-client.endpoints to follow the server's route prefix.
Logging out
Point the app's logout button at sso-client.logout:
<form method="POST" action="{{ route('sso-client.logout') }}"> @csrf <button type="submit">Log out</button> </form>
The local session is destroyed first; then the browser goes to the server (laravel-sso-server 1.1+), which ends the SSO session, sends the Single Logout webhook to the user's other client apps and returns to sso-client.post_logout_redirect_uri (default: the home URL; it must share the origin of the redirect URI). Users who did not sign in through SSO are only logged out locally.
Custom user synchronization
The default synchronizer links local users to the server's sub (sso-client.user.sso_id_column) and keeps the columns in sso-client.user.attributes in sync (name and email with the server's default serializer). New users get a random, unusable password.
| Situation on login | Result |
|---|---|
A user with this sub exists |
Updated and logged in (even if the email changed on the server) |
No user with this sub or this email |
Created and linked |
| A local user with this email exists, not linked | Rejected (AccountLinkingException, 401) unless link_existing_users_by_email is true and the email_verified claim is true |
A local user with this email is linked to another sub |
Always rejected |
Emails are mutable, so trusting an unverified one to take over an existing account would let anyone who registers that address on the server sign in as the local user. laravel-sso-server 1.1+ sends an email_verified claim, and email linking is refused unless it is exactly true; older servers do not send it, and then link_existing_users_by_email => true is your statement that the server verifies every email. Enable the flag only when needed (e.g. to adopt pre-SSO accounts). Setting sso_id_column => null matches users by email only (legacy mode); existing users are still refused when email_verified is false.
To keep users in memory only or map roles, implement the contract and set sso-client.synchronizer:
use Illuminate\Contracts\Auth\Authenticatable; use JeffersonGoncalves\SsoClient\Contracts\SsoUserSynchronizerContract; class RoleAwareSynchronizer implements SsoUserSynchronizerContract { public function synchronize(array $ssoPayload): Authenticatable { $user = User::updateOrCreate( ['sso_id' => $ssoPayload['sub']], ['name' => $ssoPayload['name'], 'email' => $ssoPayload['email']], ); $user->syncRoles($ssoPayload['roles'] ?? []); return $user; } }
Events
| Event | When |
|---|---|
UserSynchronizedEvent |
After the synchronizer returns, before login ($user, $ssoPayload) |
SsoLoginFailedEvent |
Any callback failure ($exception); the browser only gets a generic 401 |
SsoRemoteLogoutReceivedEvent |
A new, valid Single Logout webhook ($sub: the server user id) |
Failures are subclasses of SsoClientException: InvalidStateException, InvalidSignatureException, TokenExpiredException, SsoClientMismatchException (wrong iss/aud, or invalid_client), TokenReplayedException, SsoServerUnreachableException, AccountLinkingException.
How it works
- Authorize.
/sso/redirectstores a randomstate(40 chars) andcode_verifier(64 chars) in the session and redirects to{server}/sso/authorizewithcode_challenge = base64url(sha256(verifier))andcode_challenge_method=S256. - Callback. The
stateis pulled from the session (single use) and compared withhash_equals. The code is exchanged right away (it lives 60 s and is burned on any attempt) with a formPOST {server}/sso/tokencarryingclient_id,client_secret,code,redirect_uriandcode_verifier. Only network failures are retried (3 attempts, 100 ms apart);invalid_request/invalid_grant/invalid_clientfail immediately. - Verification.
jwks: theaccess_tokenis verified locally against{server}/.well-known/jwks.json(cached forjwks_cache_ttl, refetched once on an unknownkid, so key rotation just works). OnlyRS256is accepted;iss,aud,exp,nbf(withleeway) are checked and eachjtiis accepted once.userinfo:GET {server}/sso/userinfowith the Bearer token. The response must carry a validX-SSO-Signatureover the raw body and a freshX-SSO-Timestamp. Costs a round-trip, but the server rejects users who already logged out.
- Login. The synchronizer resolves the local user by
sub(see the table above), which is logged into the configured guard; the session is regenerated and remembers the serversuband the access token (thetoken_hintfor logout). - Client-initiated logout.
POST /sso/logoutdestroys the local session and redirects to{server}/sso/logout?client_id=...&token_hint=...&post_logout_redirect_uri=.... The server revokes all of the user's SSO sessions and notifies every other client through the webhook below (not this one). - Single Logout. When the user logs out on the server, it POSTs
{"event":"logout","sub","aud","iat","jti"}to the webhook. The client checksX-SSO-Signature = HMAC-SHA256("{X-SSO-Timestamp}.{raw body}", client_secret)and a timestamp withinsignature_tolerance(300 s), theneventandaud, and answers204. A replayedjtiis acknowledged but ignored, so the server stops retrying. Every local session of thatsubstarted before the webhook is ended bysso.authon its next request.
Production notes
- Use a shared, atomic cache store (Redis, Memcached, database): the
jtianti-replay markers and the Single Logout markers live there, and every app server must see them. - Revocation is enforced by
sso.auth: routes without it do not see a remote logout. - In
jwksmode a token stays valid until it expires; logout reaches the client only through the webhook. Useuserinfoif you need the server to confirm every login. - The access token is kept in the session for logout: use a server-side session driver or keep the (encrypted) cookie driver's default encryption on.
Testing
composer test
The suite includes interop tests that run the real laravel-sso-server (dev dependency) against this client: login in both verification modes and Single Logout.
Changelog
Please see CHANGELOG for more information on what has changed recently.
Security
If you discover any security related issues, please email the author instead of using the issue tracker.
Credits
License
The MIT License (MIT). Please see License File for more information.
