misakstvanu / laravel-zsso-client
Laravel client for zSSO: Socialite driver, login routes, token storage, integration calls and webhooks.
Package info
github.com/misakstvanu/laravel-zsso-client
pkg:composer/misakstvanu/laravel-zsso-client
Requires
- php: ^8.3
- guzzlehttp/guzzle: ^7.9
- laravel/framework: ^12.0|^13.0
- laravel/socialite: ^5.18
- lcobucci/jwt: ^5.4
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0|^5.0
- skautis/skautis: ^2.3
Suggests
- skautis/skautis: Hydrate a SkautIS SOAP client from the session zSSO holds (SkautisSessionData::applyTo(), contract C-5).
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-21 20:56:58 UTC
README
Turns a Laravel application into a zSSO client: single sign-on through the shared login server, plus the app-to-app integration calls and webhooks the zSSO apps use to talk to each other.
The goal is that an app needs nothing beyond composer require misakstvanu/laravel-zsso-client, four env
values and one class that turns an SSO user into a local one.
Adding a new app, in order
Every step has its own section below; this is the order they have to happen in.
- Register the app on zSSO —
php artisan zsso:app:registeron the server prints the client id and the secret once (Configuration). - Install the package and publish its config and migrations (Installation).
- Fill the four env keys —
ZSSO_SERVER_URL,ZSSO_CLIENT_ID,ZSSO_CLIENT_SECRET,ZSSO_APP_SLUG(Configuration). - Bind
ProvisionsSsoUser— the one class the app writes itself (Logging in). - Point the app's login at
zsso.redirect, and its logout atzsso.logout(From a SPA). - Put
zsso.silenton the routes a visitor may land on (Silent login). - Expose
/api/integration/v1behindzsso.integrationif other apps read this app's data (Receiving integration calls). - Register webhook handlers for what this app has to react to, and emit its own
(Webhooks). Webhook delivery is queued, so the app needs
php artisan queue:work. - Run
php artisan zsso:doctorand get a ✔ on every line (Configuration).
Steps 7 and 8 are optional: an app that only signs people in stops after step 6.
Requirements
- PHP 8.3+
- Laravel 12 or 13
Installation
The package is developed inside the zSSO repository (sso/packages/laravel-zsso-client) and consumed as a
local path repository until it is published. Clone it under the app's packages/ and add it to the app's
composer.json:
{
"repositories": [
{ "type": "path", "url": "packages/laravel-zsso-client", "options": { "symlink": true } }
],
"require": {
"misakstvanu/laravel-zsso-client": "@dev"
}
}
The service provider is auto-discovered. Publish the config and run the migrations:
php artisan vendor:publish --tag=zsso-config php artisan vendor:publish --tag=zsso-migrations php artisan migrate
Configuration
Register the app on the zSSO server first — php artisan zsso:app:register {slug} {name} {base_url} --redirect=https://<app>/auth/zsso/callback — and copy the client id and secret it prints once into the
app's .env. Add --requires-skautis when only accounts linked to SkautIS may use the app: zSSO then
keeps an unlinked user on its own "link SkautIS" page instead of sending them back with a code. A
prompt=none silent login answers interaction_required instead, which the package treats like
login_required: the visitor carries on as a guest until they log in deliberately.
ZSSO_SERVER_URL=https://zsso.localhost ZSSO_CLIENT_ID= ZSSO_CLIENT_SECRET= ZSSO_APP_SLUG=zirafa
config/zsso.php holds everything else:
| key | default | meaning |
|---|---|---|
server_url |
ZSSO_SERVER_URL |
the zSSO server, no trailing slash; every endpoint is read from its discovery document |
client_id / client_secret |
env | this app's OAuth client |
app_slug |
ZSSO_APP_SLUG |
this app's slug on the server, and the app field of webhook envelopes |
scopes |
profile email |
scopes requested on login, space separated |
redirect_path |
/auth/zsso/callback |
must match a redirect URI registered for client_id |
post_login_path |
/ |
where a login lands when no intended URL was stored |
post_logout_path |
/login |
where the server returns the visitor after a logout |
login_path |
/login |
this app's own login page |
silent_login |
true |
try prompt=none once per visitor |
apps |
[] |
base URL overrides per app slug, for local development |
webhook_path |
/api/integration/v1/webhooks |
where this app receives webhooks |
cache_ttl |
3600 |
seconds to cache discovery, JWKS and the app list |
Check the result with:
$ php artisan zsso:doctor
✔ zsso.server_url
✔ zsso.client_id
✔ zsso.client_secret
✔ zsso.app_slug
✔ discovery document
✔ signing keys
✔ client-credentials token
The last three lines each ask the server itself — the cached answers are dropped first, so an outage cannot hide behind them — and say what went wrong when they fail:
| line | what it proves | ✘ means |
|---|---|---|
discovery document |
GET <server_url>/.well-known/openid-configuration answers (contract C-1) |
the server is unreachable or not a zSSO server; the login still works off the conventional paths |
signing keys |
the document's jwks_uri publishes a usable RSA key |
incoming integration tokens (contract C-6) cannot be verified |
client-credentials token |
the token endpoint mints this app a token with the integration scope |
client_id/client_secret are wrong, or the client is not allowed the client_credentials grant — no app-to-app call and no webhook can go out |
The command exits 1 as soon as one check fails, so CI or a deploy step can run it.
Logging in
The package ships the whole redirect / callback / logout dance on three routes in the web group:
| route | name | what it does |
|---|---|---|
GET /auth/zsso/redirect |
zsso.redirect |
off to zSSO; a same-origin ?redirect= is stored as the intended URL, ?prompt=none asks for a silent login |
GET <redirect_path> |
zsso.callback |
exchanges the code, provisions, signs the user in and follows the intended URL (else post_login_path) |
POST /auth/zsso/logout |
zsso.logout |
ends the local session and hands the visitor to the server's front-channel logout |
The one class the app writes itself says how an SSO user becomes a local one. Bind it in a service provider:
use Illuminate\Contracts\Auth\Authenticatable; use Misakstvanu\ZssoClient\Contracts\ProvisionsSsoUser; use Misakstvanu\ZssoClient\Exceptions\ProvisioningDeniedException; use Misakstvanu\ZssoClient\SsoUser; class ProvisionUser implements ProvisionsSsoUser { public function provision(SsoUser $user): Authenticatable { if (! $user->emailVerified) { // Back to `login_path` with the message flashed as `error`. throw new ProvisioningDeniedException('Nejdřív si ověř e-mail v zSSO.'); } return User::updateOrCreate( ['zsso_sub' => $user->sub], ['name' => $user->name, 'email' => $user->email], ); } } // AppServiceProvider::register() $this->app->bind(ProvisionsSsoUser::class, ProvisionUser::class);
The callback signs the returned user in with Auth::login($user, remember: true), regenerates the
session and stores the tokens in zsso_tokens (one row per user, both tokens encrypted). An
error= callback ends on login_path the same way a denied provisioning does, except
login_required — the answer to a silent login — which simply carries on as a guest.
Once the user is signed in the callback fires Misakstvanu\ZssoClient\Events\SsoLoginCompleted
with the local user and the SsoUser, which is where an app hangs everything its login needs beyond
the account itself:
use Misakstvanu\ZssoClient\Events\SsoLoginCompleted; use Misakstvanu\ZssoClient\Facades\Zsso; class HydrateSkautisSession { public function handle(SsoLoginCompleted $event): void { if ($event->ssoUser->skautis === null) { return; } Zsso::skautis($event->user)->session()?->applyTo(app(\Skautis\Skautis::class)); } }
The event is fired inside the callback request, after the session was regenerated, so a listener that takes long should queue itself.
POST /auth/zsso/logout deletes the token row, ends the local session and answers
{"redirect": "<server>/logout?post_logout_redirect_uri=…&client_id=…"} for a SPA to follow (a
non-JSON request is redirected there directly). The server ends the session it holds and tells every
other app.
From a SPA
Only three URLs matter to a frontend, and none of them is an XHR the SPA has to interpret: two are
full-page navigations, one is a POST whose answer says where to navigate.
| what the visitor does | what the SPA does |
|---|---|
| clicks "Sign in with zSSO" | window.location.href = '/auth/zsso/redirect?redirect=' + encodeURIComponent(currentPath) |
| comes back from zSSO | nothing — zsso.callback signs them in server-side and redirects to ?redirect=, else post_login_path |
| clicks "Sign out" | POST /auth/zsso/logout with the CSRF token, then window.location.href = response.redirect |
// resources/js/auth.ts export function login(next = window.location.pathname) { window.location.href = `/auth/zsso/redirect?redirect=${encodeURIComponent(next)}` } export async function logout() { const response = await fetch('/auth/zsso/logout', { method: 'POST', headers: { Accept: 'application/json', 'X-CSRF-TOKEN': document.querySelector<HTMLMetaElement>('meta[name=csrf-token]')!.content, }, }) // Off to the server's front-channel logout, which ends the session every app shares. window.location.href = (await response.json()).redirect }
Both navigations have to be real navigations: the login is an OAuth redirect chain to another origin, and
the logout ends on the zSSO server, which returns the visitor to post_logout_path. ?redirect= is only
followed when it is same-origin, so an open redirect cannot be smuggled through it.
Silent login
A visitor already signed in on zSSO should not see this app's login page at all. The zsso.silent
middleware arranges that: the first time a guest opens a page it sets the zsso_silent cookie (5
minutes) and sends them to GET /auth/zsso/redirect?redirect=<current url>&prompt=none. If a session
exists on the server they come back signed in, on the page they asked for. If none exists the server
answers login_required, the callback carries on as a guest and lands them on the same URL — where this
app's own auth middleware shows the login page as usual. The cookie is never cleared, so that happens
once per visitor, not on every request.
Put it on the routes a visitor may land on with a bookmark — the SPA shell, the dashboard — and not
globally: every request it fires on costs a round trip to the server, and a guest hitting a page inside
auth is redirected to the login page anyway.
// routes/web.php Route::middleware(['zsso.silent'])->get('/{any?}', SpaController::class)->where('any', '.*');
The middleware stays out of the way of requests it cannot help: it only fires on a guest GET that
accepts HTML, never under login_path, /auth/zsso/* or /api/*, and never when the cookie is already
there. Set silent_login to false in config/zsso.php to turn it off everywhere it is registered.
Acting for a user
use Misakstvanu\ZssoClient\Facades\Zsso; $token = Zsso::tokenFor($user); // a live access token
tokenFor() returns the stored access token while it is live and spends the refresh token on a new one
when it is not (a minute before expires_at already counts as spent). When this app holds no token for
the user, or zSSO refuses the refresh token, it throws
Misakstvanu\ZssoClient\Exceptions\SessionLostException — the user has to sign in again.
The user's SkautIS session
zSSO logs the user in to SkautIS, holds the SOAP token and keeps it alive. An app reads that login instead of running one of its own (contract C-5):
use Misakstvanu\ZssoClient\Facades\Zsso; $session = Zsso::skautis($user)->session(); // ?SkautisSessionData $session?->applyTo($skautis); // hydrate a \Skautis\Skautis client Zsso::skautis($user)->refresh(); // SkautIS keep-alive Zsso::skautis($user)->switchRole(679); // activate another role Zsso::skautis($user)->logout(); // end the SkautIS login
SkautisSessionData carries loginId, roleId, unitId, logoutAt and userId (the SkautIS user id,
not the zSSO sub), plus isActive(). applyTo() needs skautis/skautis, which is a suggested — not a
required — dependency: only an app that talks to SkautIS itself installs it.
Every call goes out with the user's own access token (tokenFor(), refreshed when spent), so the app has
to have been granted the skautis:session scope. A user with no SkautIS session is not an error:
session(), refresh() and switchRole() answer null for them and logout() answers false. SkautIS
not answering zSSO raises Exceptions\SkautisUnavailableException (worth retrying); any other error
status raises its parent, Exceptions\SkautisRequestException, with the server's answer on $response.
Calling another app
use Misakstvanu\ZssoClient\Facades\Zsso; $events = Zsso::integration('zirafa')->get('events')->json('data'); Zsso::integration('zirafa') ->actingAs($user->zsso_sub) // X-Zsso-Acting-User ->post('attendees', ['event_id' => 42]);
Zsso::integration('<slug>') hands back Laravel's HTTP client with everything an app-to-app call
(contract C-6) needs already on it: the receiving app's integration_url as the base URL, this app's
client-credentials bearer (scope integration) and Accept: application/json. Every other
PendingRequest method — withHeaders(), timeout(), withQueryParameters(), … — works and keeps the
chain going.
The token is never the caller's business. It is minted once and cached until a minute before it expires,
so a hundred calls share one; a receiver that answers 401 (a revoked token, a rotated signing key) makes
the client drop the cached one, mint a fresh one and send the same call again, once. actingAs($sub)
names the zSSO user the call is made for — the receiver filters what it returns by their rights.
Any other error status raises Misakstvanu\ZssoClient\Exceptions\IntegrationRequestException, whose
$response is the receiver's answer ($exception->response?->json('message') is what it said) and whose
$app is the slug that was called. The same exception says that zSSO lists no such app, or would not mint
a token; a receiver that cannot be reached at all raises Laravel's own
Illuminate\Http\Client\ConnectionException.
Where an app lives is read from the server's app list (contract C-4), cached for cache_ttl seconds — a
slug that is not in the cached list is looked up once on a freshly fetched one, so an app registered a
minute ago is reachable. For local development, zsso.apps overrides that per slug:
// config/zsso.php 'apps' => [ 'zirafa' => 'https://zirafa.localhost/api/integration/v1', ],
Receiving integration calls
The other zSSO apps reach this app's /api/integration/v1/* routes with a client-credentials token
(contract C-6). zsso.integration verifies it and names the caller:
// routes/api.php Route::middleware('zsso.integration')->prefix('integration/v1')->group(function () { Route::get('events', [EventController::class, 'index']); // 403 unless the caller sends X-Zsso-Acting-User Route::middleware('zsso.integration:acting-user')->get('my-events', [EventController::class, 'mine']); });
Nothing in that path calls zSSO per request: the token is an RS256 JWT verified against the server's
published keys (the jwks_uri of contract C-1) and both those keys and the app list are cached for
cache_ttl seconds. A token naming a kid the cached set does not hold — a rotated signing key — and a
client id the cached app list does not hold — a freshly registered app — each re-fetch once. The
middleware answers 401 {"message": …} for a missing, unreadable, unsigned, expired or integration-less
token and for one issued to an app zSSO does not list.
Inside the route, the caller is on the request:
use Misakstvanu\ZssoClient\IntegrationContext; $context = IntegrationContext::current(); $context->callerSlug; // 'zirafa' $context->actingUserSub; // the zSSO uuid from X-Zsso-Acting-User, or null $context->actsForUser(); // whether the caller named one
callerSlug is the caller's app slug, resolved from the token's aud (its OAuth client id) through the
server's app list (contract C-4). When actingUserSub is set, the endpoint MUST filter what it returns by
that user's rights; zsso.integration:acting-user is for endpoints that make no sense without one.
Webhooks
Apps tell each other what happened with webhooks (contract C-7). Every envelope looks the same:
{
"id": "0192a3f4-…",
"type": "zirafa.attendee.upserted",
"app": "zirafa",
"occurred_at": "2026-09-08T12:00:00+02:00",
"data": { "attendee_id": 42 }
}
Emitting
use Misakstvanu\ZssoClient\Facades\Zsso; Zsso::webhook('zirafa.attendee.upserted', ['attendee_id' => $attendee->id])->to('zskauting'); // or to every app that receives webhooks, this one excluded Zsso::webhook('zirafa.attendee.upserted', ['attendee_id' => $attendee->id])->toAll();
Naming the receivers queues one DeliverWebhook job per receiver, all with the same envelope id, so a
receiver that is down only holds up its own delivery. The job posts the envelope to that app's
webhook_url (contract C-4, overridable per slug in zsso.apps) with this app's client-credentials
bearer — the same auth as any other app-to-app call.
Anything but a 2xx is retried on the contract's backoff, 1, 5, 30, 120, 600 seconds. When the last
attempt is spent the job logs an error and fires WebhookDeadLettered, which is where an app hooks its
own alerting:
use Misakstvanu\ZssoClient\Events\WebhookDeadLettered; Event::listen(function (WebhookDeadLettered $event) { report(new WebhookLost($event->app, $event->envelope->type, $event->envelope->id)); });
The jobs are queued, so the app needs a worker (php artisan queue:work).
Receiving
The package answers POST on zsso.webhook_path (/api/integration/v1/webhooks by default) behind
zsso.integration, so a sender is an app zSSO knows before anything else happens. Register the handlers
from a service provider's boot():
use Misakstvanu\ZssoClient\Facades\Zsso; use Misakstvanu\ZssoClient\Webhooks\WebhookEnvelope; Zsso::onWebhook('zirafa.attendee.upserted', UpsertAttendee::class); Zsso::onWebhook('zsso.session.logout', function (WebhookEnvelope $envelope) { // $envelope->get('sub'), $envelope->app, $envelope->occurredAt });
zsso.session.logout is the one webhook every app gets without another app sending it: zSSO emits it
itself, from the slug zsso, whenever a user signs out — through the SPA or through the front-channel
end_session_endpoint — with data: { sub } naming them. Handle it by ending that user's local session,
and the browser they left behind stops being signed in here too.
A handler class is resolved from the container and called through its handle() method (or __invoke())
with the envelope as its only argument, inline with the request; a handler that may take its time
dispatches its own queued job. An app that wants every webhook regardless of type listens for
WebhookReceived instead.
What the sender is told:
| answer | when |
|---|---|
200 |
the envelope was stored and a registered handler ran |
200 |
the envelope id was already received — the duplicate does nothing |
202 |
the envelope was stored, but no handler is registered for its type (logged at debug) |
403 |
the envelope's app is not the app the bearer token belongs to |
422 |
the envelope is not the C-7 shape |
De-duplication is the zsso_webhook_receipts table (php artisan vendor:publish --tag=zsso-migrations),
keyed by the envelope id. A handler that throws gives the id back before the 500 reaches the sender, so
the retry that follows is processed rather than answered as a duplicate.
The Socialite driver
The routes above are built on a Socialite driver the package registers, which an app can also drive itself:
use Laravel\Socialite\Facades\Socialite; return Socialite::driver('zsso')->redirect(); $user = Socialite::driver('zsso')->user(); // Misakstvanu\ZssoClient\SsoUser
The driver reads its endpoints from the server's discovery document (contract C-1), cached for
cache_ttl seconds; when the server cannot be reached it falls back to the conventional paths under
server_url, so a discovery outage does not take the login with it. Every request uses PKCE (S256) and
the OAuth state, both kept in the session, and asks for the configured scopes.
->with(['prompt' => 'none']) asks the server not to prompt: if nobody is signed in there, the callback
raises Misakstvanu\ZssoClient\Exceptions\LoginRequiredException instead of returning a user. Any other
error= in the callback raises its parent, AuthorizationFailedException, whose $error property
carries the OAuth code (access_denied and friends).
SsoUser is a readonly DTO of the userinfo shape (contract C-3) plus the tokens the login exchanged:
| property | notes |
|---|---|
sub |
the user's uuid on zSSO, the only value that is always there |
email, emailVerified |
with the email scope |
name, nickname, birthday, street, city, zip, avatarUrl |
with the profile scope; birthday is a Y-m-d string. name and nickname are the profile the user edits on zSSO |
skautisName, skautisNickname |
with the profile scope; the name and nickname SkautIS holds, refreshed on every SkautIS login and null until the account is linked. Use these when the app wants the SkautIS identity rather than the user's own |
skautisSex, skautisBirthday |
with the profile scope; muz / zena / null and a Y-m-d string, both straight from SkautIS and refreshed on every SkautIS login. Unlike the pair above they have no user-edited counterpart — zSSO's own profile form refuses to set a birthday once SkautIS supplies one — so these are the ones to gate an age on or to write grammatically gendered copy with. null until the account is linked, and whenever SkautIS holds nothing |
skautis |
a SkautisInfo (userId, roleId, unitId, logoutAt, roles, loginId) with the skautis scope, null without a live SkautIS session |
skautisUnit, skautisStredisko |
with the skautis scope; a SkautisUnit (id, name, registrationNumber, type) and a SkautisStredisko (id, name, registrationNumber) — the unit the last SkautIS login happened in and its středisko, kept by zSSO on the user and refreshed on every SkautIS login, so unlike skautis they need no live session. Match your own units against the ids or registration numbers. null until the account has been through a SkautIS login (and skautisStredisko also when zSSO could not find one); inside them name/registrationNumber/type are null when the SkautIS lookup failed, id is always there |
raw |
the untouched userinfo response |
token, refreshToken, expiresIn, scopes |
from the token response |
Contracts
The shapes this package speaks are the numbered contracts in the
zSSO PRD (sso/tasks/prd-zsso-server.md). They are shared by
every app: changing one means changing all of them, so treat this table as the map between a contract and
the part of the package that speaks it.
| contract | what it is | where the package uses it |
|---|---|---|
| C-1 | discovery — GET /.well-known/openid-configuration |
Discovery; every endpoint URL the package calls comes from it, with the conventional path as the fallback |
| C-2 | the scope registry | zsso.scopes on login; integration is the client-credentials scope of every app-to-app call |
| C-3 | userinfo — GET /api/userinfo |
SsoUser, what Socialite::driver('zsso')->user() returns and what ProvisionsSsoUser is handed |
| C-4 | the app list — GET /api/apps |
AppDirectory: a slug's integration_url and webhook_url, and the aud → slug lookup a receiver names its caller with |
| C-5 | the SkautIS session endpoints | Zsso::skautis($user) and SkautisSessionData |
| C-6 | app-to-app calls | Zsso::integration($slug) outbound, zsso.integration + IntegrationContext inbound |
| C-7 | the webhook envelope | Zsso::webhook() / WebhookEnvelope outbound, Zsso::onWebhook() inbound |
| C-8 | the user export/import format | not the package's business — it is what each app's <app>:export-users writes for the server's zsso:import-users |
Development
composer install vendor/bin/pest
The tests run under Orchestra Testbench, so no application and no running zSSO server are needed.