Documentation menu
HomeDocsAuth & SecuritySSO

SSO (Single Sign-On) — ⭐ Martis differential

Pluggable single sign-on for Laravel admin panels. Azure AD, Google Workspace, GitHub, Okta — or any custom IdP — with role mapping, Spatie/laravel-permission integration, environment-aware config, and a generator command (php artisan martis:sso <provider>) that scaffolds everything.

Martis signs admin users in through an external identity provider. Azure AD ships built in; Google, GitHub, Okta or any other provider plugs in through MartisSso::extend(). Groups map to your roles (Spatie laravel-permission is detected) and martis:sso scaffolds the setup.

This document is the long-form reference. For a 30-second TL;DR see the Quick start below. For step-by-step Azure setup, jump to Azure AD — full step-by-step.


Table of contents

  1. What is the Martis SSO subsystem?
  2. Architecture
  3. Quick start (Azure, Spatie)
  4. Azure AD — full step-by-step
  5. Configuration reference
  6. The three orthogonal axes
  7. Spatie / laravel-permission integration
  8. Per-environment role mapping
  9. Host-app hooks
  10. REST surface and routes
  11. Generator command
  12. Migration from a custom OAuth controller
  13. Multi-provider login UI
  14. Testing your SSO integration
  15. Security considerations
  16. Troubleshooting
  17. Test coverage
  18. Signed token between two Martis apps (v2.21.0, v2.22.0)

1. What is the Martis SSO subsystem?

A configurable, layered SSO stack you wire up via config + 5 optional hooks. Replaces the 200-line custom AzureOauthController extends LoginController pattern that most admin apps end up writing by hand.

What it differentiates

FeatureMartisTypical Laravel admin
Multiple SSO providers active in parallel✅⚠ usually one
Role mapping in 3 strategies (column / config / callable)✅❌ DIY
Spatie/laravel-permission auto-detected✅⚠ DIY
Per-environment role mapping via env✅⚠ hard-coded
Generator command scaffolds the full provider✅ martis:sso <name>❌
Hook surface for app-side overrides✅ 5 hooks⚠ DIY
Login button auto-renders from config✅❌ DIY UI
auto_create_user opt-in✅⚠ DIY
on_no_role_match strategy (deny / guest / callable)✅⚠ DIY
Soft-deleted user restore via hook✅DIY

What it does NOT do

  • It is not an IdP. Martis consumes identity from Azure / Google / GitHub / Okta — it doesn't issue tokens.
  • It does not store passwords. When auto_create_user = true, new users get an unguessable random hash so the users.password column constraint is satisfied. Authentication always flows through the IdP.
  • It does not bypass the MFA configured at the IdP. Whatever Azure / Google / GitHub require still applies.

2. Architecture

HTTP entry points (registered when `auth.sso.enabled = true`):
GET /{martis}/sso/{provider}/redirect → SsoController::redirect
GET /{martis}/sso/{provider}/callback → SsoController::callback
 
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ SsoManager (singleton, exposes the MartisSso facade) │
│ • providers registry (azure → AzureProvider, ...) │
│ • adapter resolver (auto / spatie / native / callable) │
│ • host-app hook surface (5 closures) │
└────────┬────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ SsoProviderContract │
│ redirect(Request) : RedirectResponse │
│ resolveIdentity(Request) : SsoIdentity │
│ │
│ Built-in: AzureProvider │
│ Generator stubs (config + env only, no Provider class): │
│ `martis:sso google` and `martis:sso github` │
│ Custom: register via `MartisSso::extend('okta', OktaProvider)` │
└────────┬────────────────────────────────────────────────────────┘
│ returns SsoIdentity { provider, externalId, email, name,
│ externalRoles[], raw[], accessToken? }
▼
┌─────────────────────────────────────────────────────────────────┐
│ IdentityResolver — find-or-create local user │
│ • match by `email` (default) | by `external_id` │
│ • auto-create when missing (config gate) │
│ • sync `name` / `email` / `external_id` │
│ from `sync_user_attributes` │
│ • host-app override: `MartisSso::resolveUserUsing(...)` │
└────────┬────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ RoleMapper — translate external groups → local role models │
│ 3 strategies: │
│ 1. column — `Role::whereIn($column, $externalNames)->get()`│
│ 2. config — `role_map` array of `slug => env_value` │
│ 3. callable — provider-config closure │
│ Global override: `MartisSso::resolveRolesUsing(...)` │
└────────┬────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ PermissionAdapter — sync resolved roles onto the user │
│ SpatieAdapter — calls $user->syncRoles($collection) │
│ NativeAdapter — direct attach/detach against model_has_roles │
│ CallableAdapter — host-app closure │
│ Resolution: `auto` picks Spatie when available, Native otherwise│
└────────┬────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ MartisSso::afterLogin($user, $identity, $provider) │
│ Auth::guard()->login($user, remember=provider.remember (false)) │
│ redirect()->intended(provider.redirect_to ?? '/martis') │
└─────────────────────────────────────────────────────────────────┘

Every layer is overridable via a host-app hook — the layered design is opt-in. An app that just wants "the basics" hooks nothing; an app with quirky requirements hooks one or two specific layers.

The boxes above are the layers, not the run order inside the callback. Roles are decided before the local user is written (v2.21.0), so a refused identity never leaves a row behind: see Deny before provisioning. Every refusal ends the same way: one log line, one SsoFailed event and a redirect to the login page, see Failed sign-ins.


3. Quick start (Azure, Spatie)

php artisan martis:sso azure --with-spatie --with-migration

That's it. The command runs end-to-end:

  1. ✅ composer require laravel/socialite socialiteproviders/microsoft spatie/laravel-permission — only for packages not already installed.
  2. ✅ Inserts auth.sso.providers.azure block in config/martis.php.
  3. ✅ Stubs AZURE_* and MARTIS_SSO_* env vars in .env and .env.example.
  4. ✅ Auto-registers the Microsoft Socialite listener in AppServiceProvider::boot().
  5. ✅ Publishes Spatie's permission config + migrations (vendor:publish for the package), in the same run when step 1 just installed Spatie. If Spatie still cannot be loaded, the command stops with an error asking you to re-run it.
  6. ✅ Publishes the azure_group_name migration on the roles table.
  7. ✅ Runs php artisan migrate.
  8. ✅ Interactive prompt (one row at a time) to populate azure_group_name on each existing Spatie role.

Then complete the 2 manual steps the command prints (intrinsically Azure-portal-only):

  1. Register the Azure AD app in https://portal.azure.com (Redirect URI = /martis/sso/azure/callback, permissions, App Roles, user assignments).
  2. Fill AZURE_CLIENT_ID / AZURE_CLIENT_SECRET / AZURE_REDIRECT_URI / AZURE_RESOURCE_ID in .env.

Reload /martis/login — the Continue with Microsoft button is there.

Skipping individual steps

If your CI / production deploy already manages composer / migrations / role mapping independently, opt out:

php artisan martis:sso azure --with-spatie --with-migration \
--no-composer \ # skip composer require
--no-publish-spatie \ # skip Spatie vendor:publish
--no-listener \ # skip auto-registering the Socialite listener
--no-migrate \ # skip php artisan migrate
--no-map # skip the interactive role-mapping prompt

4. Azure AD — full step-by-step

The canonical recipe. Copy/paste straight through.

Step 1 — Run the Martis generator

php artisan martis:sso azure --with-spatie --with-migration

The command is idempotent and self-sufficient. It:

  1. Composer — runs composer require for any missing packages: laravel/socialite, socialiteproviders/microsoft, and spatie/laravel-permission (with --with-spatie). Skips packages already declared in composer.json. The packages it installs are loaded in the same run (their classes, and Spatie's service provider so its config + migrations can be published); if Spatie still cannot be loaded, the command exits with an error asking you to re-run it.
  2. Config — inserts the auth.sso.providers.azure block in config/martis.php.
  3. Env — stubs the AZURE_* and MARTIS_SSO_* env vars in .env and .env.example.
  4. Listener — adds the MicrosoftExtendSocialite event listener at the top of AppServiceProvider::boot() (idempotent — checks if already there).
  5. Migration — publishes add_azure_group_name_to_roles_table (with --with-migration). Its timestamp is later than every migration already in database/migrations, Spatie's create_permission_tables included, so the column is added after the roles table exists. When there is neither a roles table nor Spatie's migration (no --with-spatie, Spatie not set up yet), the command stops with an error instead: the migration would do nothing, be recorded as run, and the column would never be added. Pass --with-spatie, or publish Spatie's migration first and re-run.
  6. Migrate — runs php artisan migrate (interactive prompt; non-interactive auto-runs unless --no-migrate).

Skip flags for CI / production deploys:

FlagWhat it skips
--no-composerComposer require step (deps must already be installed)
--no-publish-spatieSpatie's vendor:publish (config + migrations)
--no-listenerAuto-registering the Socialite listener in AppServiceProvider::boot()
--no-migrateThe php artisan migrate step
--no-mapThe interactive role-mapping prompt

Step 2 — Register the app in the Azure portal

  1. Go to https://portal.azure.com → Azure Active Directory → App registrations → New registration.

  2. Name: a friendly name (e.g. "Acme Admin Panel").

  3. Supported account types: pick what fits your tenant policy. For most B2B / single-tenant setups, choose Accounts in this organizational directory only.

  4. Redirect URI (Web): set it to the Martis callback URL:

    https://your-app.example/martis/sso/azure/callback

    (Replace your-app.example with your actual host, and martis with whatever you set as MARTIS_PATH.)

  5. After creation, copy the Application (client) ID. Paste it into .env:

    AZURE_CLIENT_ID=00000000-0000-0000-0000-000000000000
    AZURE_RESOURCE_ID=00000000-0000-0000-0000-000000000000

    AZURE_CLIENT_ID and AZURE_RESOURCE_ID are normally the same value (the Application ID).

  6. Certificates & secrets → New client secret:

    • Description: any label.
    • Expires: pick the longest your security policy allows.
    • Click Add, then immediately copy the Value (not the Secret ID — the value is shown only once).
    • Paste it into .env:
      AZURE_CLIENT_SECRET=...
  7. API permissions → Add a permission → Microsoft Graph → Delegated permissions:

    • openid
    • profile
    • email
    • GroupMember.Read.All
    • User.ReadBasic.All

    Then Grant admin consent for your tenant.

  8. (Recommended for app_role_assignments strategy) App roles → Create app role:

    • Display name: a human-readable label that you'll later store in roles.azure_group_name (e.g. Admin, Sales Rep, Backoffice).
    • Allowed member types: Users/Groups.
    • Value: any internal value (Martis doesn't read this).
    • Description: anything.

    Repeat for every role. Names are case-sensitive when matching against roles.azure_group_name.

  9. Enterprise applications → find your app → Users and groups → Add user/group → assign each user the right App Role. Users without an App Role assignment will be denied login (on_no_role_match = 'deny').

Step 3 — Run the published migration (only if you used --no-migrate)

php artisan migrate

This adds the nullable azure_group_name column to the existing roles table. (Spatie's migration must already have run — if roles doesn't exist, the Martis stub no-ops with a notice.)

Step 4 — Populate azure_group_name on each Spatie role

For every Spatie role that should map to an Azure App Role:

use Spatie\Permission\Models\Role;
 
Role::where('name', 'admin')->update(['azure_group_name' => 'Admin']);
Role::where('name', 'sales_rep')->update(['azure_group_name' => 'Sales Rep']);
Role::where('name', 'backoffice')->update(['azure_group_name' => 'Backoffice']);

The azure_group_name value must match the App Role display name in Azure exactly (case-sensitive). If you're using Groups instead (role_source = 'groups'), match against the group's displayName.

Step 5 — Confirm .env

MARTIS_SSO_ENABLED=true
MARTIS_SSO_AZURE_ENABLED=true
 
AZURE_CLIENT_ID=00000000-0000-0000-0000-000000000000
AZURE_CLIENT_SECRET=verysecretvalue
AZURE_REDIRECT_URI=https://your-app.example/martis/sso/azure/callback
AZURE_RESOURCE_ID=00000000-0000-0000-0000-000000000000

Step 6 — Reload /martis/login

The Continue with Microsoft button is there. Click → Azure consent screen → callback → Martis logs the user in and assigns the matching Spatie roles.

What happens on each click (full request flow)

1. User clicks "Continue with Microsoft"
2. Browser → /martis/sso/azure/redirect
3. SsoController::redirect → AzureProvider::redirect
4. Socialite::driver('microsoft')->scopes([...])->redirect()
5. Browser → https://login.microsoftonline.com/.../oauth2/v2.0/authorize?...
6. User authenticates against Azure tenant + grants consent (first time only)
7. Azure → /martis/sso/azure/callback?code=...&state=...
8. SsoController::callback → AzureProvider::resolveIdentity
9. Socialite exchanges code for access token
10. AzureProvider hits Microsoft Graph:
GET /v1.0/users/{id}/appRoleAssignments?$filter=resourceId eq {AZURE_RESOURCE_ID}
→ returns each assignment.principalDisplayName
11. RoleMapper::map($externalRoles, ...)
→ Role::whereIn('azure_group_name', $externalRoles)->get()
12. IdentityResolver::resolve($identity, 'azure')
→ User::where('email', $identity->email)->first() ?? create()
→ sync 'name' and 'email' from identity
13. SpatieAdapter::syncRoles($user, $roles)
→ $user->syncRoles($collection) ← Spatie's HasRoles method
14. SsoManager::fireAfterLogin($user, $identity, 'azure')
→ app's afterLogin hook runs (audit log, type derivation, etc.)
15. Auth::guard()->login($user, remember: provider.remember) ← default false (v2.4.0)
16. redirect()->intended('/martis')

5. Configuration reference

// config/martis.php
'auth' => [
'sso' => [
'enabled' => env('MARTIS_SSO_ENABLED', false),
 
// v2.22.0: the cache store of everything SSO keeps in a cache.
// Null = the default store. See "The cache store" below.
'cache_store' => env('MARTIS_SSO_CACHE_STORE'),
 
'providers' => [
'azure' => [
// Master gate per provider.
'enabled' => env('MARTIS_SSO_AZURE_ENABLED', false),
 
// Socialite driver name. The Microsoft Socialite extension
// registers as 'microsoft' by default — change if you renamed.
'driver' => 'azure',
 
// Login button label and Phosphor icon.
'label' => 'Continue with Microsoft',
'icon' => 'microsoft-outlook-logo',
 
// OAuth scopes requested at consent.
'scopes' => [
'openid', 'profile', 'email',
'GroupMember.Read.All',
'User.ReadBasic.All',
],
 
// Where the external role list comes from.
// 'app_role_assignments' → /users/{id}/appRoleAssignments
// 'groups' → /users/{id}/memberOf
// 'callable' → role_source_callable
'role_source' => 'app_role_assignments',
'resource_id' => env('AZURE_RESOURCE_ID'),
 
// The tenant the app registration is tied to (v2.4.0): a tenant
// id or a verified domain. An identity whose `tid` claim names
// another tenant is rejected, and so is one whose tenant cannot
// be read. Empty/common/organizations/consumers checks nothing:
// then match local accounts by 'external_id', not by email.
// Unset, it reads services.azure.tenant of the Socialite driver.
'tenant' => env('AZURE_TENANT_ID'),
 
// How to map external names to local roles.
// 'column' → Role::whereIn(role_column, $externalRoles)
// 'config' → role_map array (slug => env_value)
// 'callable' → role_callable
'role_strategy' => 'column',
'role_column' => 'azure_group_name',
'role_model' => null, // null = auto-detect Spatie/App\Models\Role
 
// Identity resolution.
'auto_create_user' => true,
'identity_match_attribute' => 'email', // 'email' | 'external_id'
'sync_user_attributes' => ['name', 'email'], // also 'external_id' (v2.21.0)
 
// Only with identity_match_attribute = 'external_id' (v2.21.0).
'identity_external_id_column' => 'azure_object_id', // default '{provider}_external_id'
'link_by_email' => false,
 
// true restores "write the user, then refuse" (v2.21.0).
'provision_on_deny' => false,
 
// Permission sync.
'sync_roles' => true,
'permission_adapter' => 'auto', // 'auto' | 'spatie' | 'native' | 'callable'
 
// What happens when no roles match.
'on_no_role_match' => 'deny', // 'deny' | 'guest' | 'callable'
 
// Remember-me after an SSO sign-in (v2.4.0). Off by default: the
// session follows SESSION_LIFETIME and a fresh IdP round-trip
// decides after it, so revoking access at the IdP ends panel
// access. On, the remember cookie signs the user back in for
// auth.guards.{guard}.remember minutes whatever the IdP says,
// and only MartisSso::revokeFor() ends it: it cannot be set
// together with the two limits below (v2.21.0, it throws).
'remember' => false,
 
// Absolute age and revalidation of an SSO-opened session
// (v2.21.0). Both null (off) by default. Not with 'remember' => true.
'max_session_minutes' => env('MARTIS_SSO_AZURE_MAX_SESSION_MINUTES'),
'revalidate_every_minutes' => env('MARTIS_SSO_AZURE_REVALIDATE_EVERY_MINUTES'),
 
// Federated logout: a URL with {post_logout_redirect_uri}, or
// (v2.21.0) the name of an invokable class.
'logout_url' => env('MARTIS_SSO_AZURE_LOGOUT_URL'),
 
// Optional post-login redirect override.
// Null = config('martis.path').
'redirect_to' => null,
],
],
],
],

The cache store (v2.22.0)

martis.auth.sso.cache_store (env MARTIS_SSO_CACHE_STORE) names the Laravel cache store, from cache.stores, that holds everything the SSO subsystem keeps in a cache: the signed token's one-time jti (section 18), the revocation time and the origin nonces (Ending an SSO session), the ended-session markers, and the Azure tenant lookup. Unset (null, the default) it is the application's default store, as before.

The store is security state, so it must be shared by every server of the panel (Redis, database or Memcached; file is per host), must not evict early, and must not be flushed on a deploy: a store that forgets a jti reopens a used token, and one that forgets a revocation reopens an ended session. Naming a store of its own keeps a cache:clear or a short-lived default store from touching them.

How the value is read, and what is refused (v2.22.0):

  • An empty or blank value is unset (what env() gives for MARTIS_SSO_CACHE_STORE=), so it is the default store.
  • With SSO on (martis.auth.sso.enabled), a name that is not a string or is not in cache.stores throws an InvalidArgumentException naming martis.auth.sso.cache_store, at the first use. That first use is not only the SSO callback: every sign-in (password, magic link, invitation) and sign-out drops the SSO origin of the session, and the plain 401 of an unauthenticated request reads the ended-session marker, so they throw too. A sign-in path checks the store before the guard signs anyone in (and before a magic link or an invitation is spent): it answers the error with nobody authenticated. Sign-out throws after the session has already ended. A typo is loud on purpose, instead of being read as a cache outage.
  • With SSO off the paths every panel runs (password sign-in, sign-out, the plain 401) never touch the SSO cache, so a mistake in this key cannot lock out a panel that does not use SSO.
  • With SSO on and outside the test suite, a store that uses the array driver (per process) or the null driver (holds nothing) is refused with an exception naming martis.auth.sso.cache_store (and cache.default when the key is unset and the default store is the weak one): replay protection would otherwise be void with no error. In the test suite (app()->runningUnitTests()) it is accepted, so an app's own tests keep using the array store.
  • Changing the store on a running panel orphans what the old one held: the revocation times of revokeFor() (a user revoked a moment ago can use an older session again), the origin nonces (remembered SSO sessions lose their SSO origin) and the one-time jti records (a token issued just before the change can be used once more, within its short life). Change it at a quiet moment, and run MartisSso::revokeFor() again for anyone revoked recently.

What reaches the browser (v2.21.0)

The login page is public, so window.MartisConfig.auth.sso carries only what the button strip needs: enabled, and for each enabled provider enabled, label and icon. A disabled provider is not published, and nothing else of a provider block is (role maps, tenant, resource_id, scopes, logout_url, columns). An app whose custom login component (see Overrides) needs more lists the keys in an optional public array of the block:

'azure' => [
'enabled' => env('MARTIS_SSO_AZURE_ENABLED', false),
'label' => 'Continue with Microsoft',
'icon' => 'microsoft-outlook-logo',
'tenant_label' => 'Acme Corp',
// Published next to enabled, label and icon. Listing a key makes it public:
// never list a secret, a tenant id or a role map.
'public' => ['tenant_label'],
],

public can add keys, never change enabled, label or icon, and is not published itself. A public that is not an array of key names throws an InvalidArgumentException naming martis.auth.sso.providers.{provider}.public. See Authentication → What reaches the browser for the whole allow-list. For a panel that signs in through SSO only, see Authentication → Password sign-in switch.

Identity binding and tenants (v2.4.0)

identity_match_attribute => 'email' adopts the local account that holds the IdP's address, and only when it can trust that row's address (v2.4.0): the row's email_verified_at is set (a user model without that column cannot prove a verified address, so with registration open it counts as unverified), or martis.auth.registration.enabled is false, so an administrator provisioned every row. With self-registration open and the row unverified, anyone could have registered the IdP user's address first and kept their own password, so the sign-in is refused (a logged warning and the translated sso_account_unverified message on the login page) instead of adopting the row and syncing the IdP's roles onto it. An address that the external_id strategy did not link, when auto_create_user would otherwise create a second row beside it, is refused too, with its own reason (unlinked_account, v2.21.0; see Failed sign-ins). Verify the address (email verification, or an administrator marks it), link the row by external id, or delete it. With registration enabled an app therefore needs an email_verified_at column (with verified rows) or the external_id strategy for SSO to adopt existing accounts. A closure registered through MartisSso::resolveUserUsing() replaces this logic and decides for itself. Two further guards close the pre-hijack route that used to exist there: the profile no longer lets a user write an address without the current password and a confirmation link sent to that address (see Authentication → Changing the email address), and the Azure provider rejects an identity whose tid claim differs from a configured concrete tenant. A multi-tenant registration (common, organizations, consumers, or no tenant) checks nothing: prefer identity_match_attribute => 'external_id' there.

Matching by external_id (v2.21.0)

identity_match_attribute => 'external_id' matches the id the IdP issued (the Azure object id, a Google subject) against a column of your users table: identity_external_id_column, {provider}_external_id when unset (azure_external_id). It survives an email change at the IdP and does not depend on the address being verified, which makes it the strategy for a multi-tenant app registration.

  • The id is stored. A user the sign-in creates gets the id written into that column, so the next sign-in finds the same row. (Before v2.21.0 the column was never written: the second sign-in of an auto-created user found no id match, saw the row holding the address and was refused.)
  • A missing id is refused. SsoIdentity::$externalId is never null, but a provider can hand over an empty string. Querying it would match every row whose column is empty, an arbitrary account, so the sign-in is refused with reason missing_external_id before anything is read or written.
  • Email at the IdP changed. The id still matches the same row. The new address is copied onto it when sync_user_attributes lists email; otherwise the row keeps its address.
  • link_by_email (per provider, default false). An existing account that holds the IdP's address but no id yet (the accounts you provisioned before switching strategy) is refused by default, as before, with reason unlinked_account (see Failed sign-ins). With link_by_email => true it is linked: the row must pass the same adoption rule as the email strategy (verified address, or registration.enabled false) and its id column must be empty, and then the id is written and the sign-in goes on. A row that already stores a different id is refused with reason external_id_mismatch and logged; the stored id is never overwritten. Linking applies even when auto_create_user is false: it does not create anything.
  • sync_user_attributes accepts external_id, on either strategy: the id is written into the column when it is empty (a user created by the email strategy gets it too), and a different non-empty value refuses the sign-in with external_id_mismatch, before the name or email of the row is touched.
'azure' => [
'identity_match_attribute' => 'external_id',
'identity_external_id_column' => 'azure_object_id',
'link_by_email' => true, // adopt the pre-provisioned rows once, by address
'auto_create_user' => true,
],

A identity_match_attribute value other than email or external_id, or an identity_external_id_column that is not a non-empty string, throws an InvalidArgumentException naming the key instead of matching nothing.

Deny before provisioning (v2.21.0)

A callback used to write the local user first (create it, rewrite the name and email of an existing one) and only then map the roles and refuse an identity without a role. A refused identity therefore left a row behind, or rewrote one. Roles now come first:

Role mappingWhat runsA refusal
role_strategy column or config, and no MartisSso::resolveRolesUsing closureThe roles are mapped with no user. When there is nothing to sign in as, the callback returns before IdentityResolver (and a resolveUserUsing closure) runs.Nothing was written.
role_strategy callable, or a resolveRolesUsing closure (they receive the user)The user is resolved, the roles are mapped and the decision is taken inside a database transaction on the user model's connection.The transaction is rolled back: the created row is gone and a rewritten name or email is restored. This includes a callable on_no_role_match hook that returns a redirect, a callable hook that returns nothing, and a resolveUserUsing closure that returns null.

on_no_role_match => 'guest' is not a refusal: the user is written and signed in without a role, in both rows of the table. The SsoFailed event is dispatched after the rollback, so a listener that writes to the same database is not rolled back with it.

provision_on_deny (per provider, default false) restores the old order, write first and refuse after, for an app that pre-provisions rows on purpose and wants them updated even when access is refused.

What a transaction cannot undo is yours to handle: a resolveUserUsing closure that writes outside the user model's connection (another database, a queue, an HTTP call), and the model events (created, updated observers) that a rolled-back row already fired. Keep those side effects in the afterLogin hooks or the SsoLoggedIn listener, which only run once the identity was admitted. An on_no_role_match value other than deny, guest or callable throws an InvalidArgumentException naming the key (it used to sign the user in without a role).

Because roles are decided first, an identity that has no role and no local user is now refused with no_role_match rather than not_provisioned.

Failed sign-ins: reason, log and event (v2.21.0)

Every refusal of the callback (the IdP call failed, the account may not be adopted, the user is not provisioned, no role matches) goes through one path: a warning log line, an SsoFailed event, and a redirect to the login page. Each refusal has a reason:

ReasonWhen
unknownAnything thrown that is not a Martis\Sso\SsoException: the provider (an unreachable IdP, a rejected code, a malformed token), the user step (a unique index on the email, the two concurrent first sign-ins of one identity, an IdP email change onto an address another row holds), the role sync or an afterLogin hook.
account_unverifiedA local row holds the IdP's address and the sign-in may not adopt it because the row's email is unverified while self-registration is open (SsoAdoptionRefusedException). Also the refusal of an address that differs from the row's only in letter case, on the email strategy.
unlinked_account (v2.21.0)identity_match_attribute => 'external_id', no row carries the IdP's id, and a local row holds the IdP's address while link_by_email is off: the sign-in neither links that row nor creates a second one beside it, whatever the row's verification. The log line names the cause and the link_by_email key (and the id column to fill); the login page shows the generic text, so it does not tell whoever holds an IdP account that a local row with that address exists. Before v2.21.0 this was reported as account_unverified. Fix: set link_by_email to true (the row must pass the adoption check below), or store the IdP's id in the id column of that account.
missing_external_id, external_id_mismatchSee Matching by external_id. Both show the generic text on the login page: a more specific one would tell whoever holds an IdP account that a local account with that address exists.
email_unverifiedThe provider says the identity's email is not verified (SsoIdentity::$emailVerified === false) and a local row holds that address: the row is not adopted, on the email strategy or with link_by_email. The signed-token provider sets it from the issuer's email_verified claim. A provider that makes no statement (null, the default and Azure's case) is unaffected. The login page shows the generic text.
not_provisionedNo local user, and auto_create_user is off (or the user resolver returned null).
no_role_matchNo role maps and on_no_role_match denies (also when a callable hook returned a redirect: the person is not signed in, the hook decides where they go).

The log line (SSO callback failed, on the default log channel) carries provider, reason, request_id (the request's X-Request-Id header, else a UUID generated for this line; since v2.22.0 anything but printable ASCII in the header becomes ? and at most 128 bytes are kept), exception (the class name) and error (the message). The message is redacted first: HTTP client exceptions embed the request URL and the response body, so the values of code, state, token, id_token, access_token, refresh_token, client_secret, code_verifier, client_assertion, assertion, SAMLResponse, SAMLRequest, password, id_token_hint and private_key are replaced with [redacted] whether they appear as query pairs or JSON pairs, and so is a Bearer credential.

A refusal never becomes a 500 page: whatever the user step, the role sync or an afterLogin hook throws (an InvalidArgumentException from a hook included) is logged through the redactor, dispatched as SsoFailed with reason unknown, and shown as the generic text; nobody is signed in. The one exception is a configuration error of the user or role step (an InvalidArgumentException naming the key, such as an unsupported on_no_role_match): that is thrown, so a typo is loud.

What stays written when a sign-in is refused after the user step depends on the order of Deny before provisioning. The role sync and the afterLogin hooks run right after the user is resolved. On the transaction order (a role mapping that needs the user) they run inside the transaction, so a failure of either rolls the user row back too. On the pre-check order (and with provision_on_deny) there is no transaction: a row the sign-in already created or updated stays, the person is not signed in, and the next attempt finds the row.

The login page shows the exception's messageKey text when the exception sets one and the key is translated, and the generic sso_callback_failed text otherwise (a plain exception never shows its message). The text reaches the page as an error toast on arrival: the callback flashes it as the sso error of the redirect, the SPA shell publishes that one string as window.MartisConfig.flash.sso (Martis\Auth\PublishedFlash, nothing else of the error bag, once: it is read from the session flash, so a reload shows nothing) and the login page toasts it, also when password sign-in is off and the SSO buttons are all there is. The redirects of a provider that is not enabled (sso_provider_disabled) take the same path. A provider or a resolveUserUsing closure can choose both by throwing Martis\Sso\SsoException:

use Martis\Sso\SsoException;
 
throw new SsoException(
'Tenant of the token is not allowed', // for the log
'tenant_not_allowed', // reason: a snake_case slug listeners can branch on
'messages.sso_tenant_not_allowed', // login page text, any translation key; null = generic text
);
use Illuminate\Support\Facades\Event;
use Martis\Events\SsoFailed;
 
Event::listen(SsoFailed::class, function (SsoFailed $event) {
// $event->provider, $event->reason, $event->exception (?Throwable, the RAW exception:
// its message and trace are not redacted, so do not log or ship it as it is)
AuditLog::record('sso.failed', ['provider' => $event->provider, 'reason' => $event->reason]);
});

Ending an SSO session: age, revocation, revalidation (v2.21.0)

Once a person is signed in through SSO, nothing asks the IdP again: the session lives until SESSION_LIFETIME, and with remember on, a cookie signs the user back in for up to 400 days (only revocation reaches a remembered session, see below). Three independent, off by default mechanisms end an SSO-opened session before that. They share one middleware, martis.sso.session, which sits right after martis.auth in the authenticated stack (Martis\Http\RouteMiddleware::authenticated()), ahead of every gate (2FA, email verification, forced password change). A session that SSO did not open (password, magic link, invitation) is never touched by any of them.

The middleware is also on the two routes that run martis.auth alone (GET /email/verify, POST /api/auth/email/verification-notification), and GET /api/auth/user, which is public and runs without it, answers as signed out (literal null) for a session the middleware would end, and ends it, using the same code (Martis\Sso\SsoSessionEnforcer). So a revoked or expired session cannot keep reading the current user or sending mail.

The session records, at sign-in, when the SSO sign-in happened (martis_sso_at, unix time) and the identity's external id (martis_sso_external_id). After a remember-me re-login, the new session gets the original sign-in time back from the SSO cookie (its issue time), never the time of the re-login, so that the revocation time of the next paragraph compares with the real sign-in.

remember and the two limits exclude each other. A provider with remember => true together with max_session_minutes or revalidate_every_minutes is a configuration error: the sign-in and every request of an SSO session throw an InvalidArgumentException naming the keys. The reason is that a remembered re-login (the session ended, Laravel's remember cookie signs the user in again) is recognised as an SSO one only through the martis_sso_provider cookie and its nonce in the cache. The server cannot rely on either: the person can delete the cookie, and a cache flush (a deploy, an eviction, a restart) drops every nonce. A re-login that is not recognised is a session no limit applies to, so the limits could not be enforced. Pick one: no remember cookie, and the session ends on its own and the limits apply, or a remember cookie and revocation (revokeFor()) as the way to end it. Revocation does not depend on the origin cookie: it cycles the user's remember token, so it still covers remembered sessions.

Absolute age: max_session_minutes

'azure' => [
// ...
'max_session_minutes' => 480, // 8 hours, counted from the SSO sign-in
],

A session this provider opened that is that old or older ends on its next request. A session opened before v2.21.0 carries the origin but no sign-in time and counts as expired. The value is a positive whole number of minutes (an environment string such as "480" is fine); anything else throws an InvalidArgumentException naming the key.

Ending a session does what a local sign-out does on this device (the guard's current-device logout, session invalidated, new CSRF token, the SSO origin and the remembered 2FA device dropped) without the federated logout: the IdP session is the one that is gone, or the person is about to sign in at it again. It also does not cycle the user's remember token, so an SSO session that reaches max_session_minutes on a laptop does not kill the remember-me cookie of the same user's password session on a phone; revokeFor() cycles it on purpose. The response then sends the person back through the provider:

  • a request that expects JSON gets 401 {"message": "...", "sso_reauth_url": "/martis/sso/azure/redirect"}. The SPA's global 401 handler navigates to that URL (a full page load) instead of the session-expired login page. It follows only a path under the Martis base path on its own origin: an absolute URL, a protocol-relative one, or a path that dot segments or a backslash would move out of the base path is ignored and the normal session-expired redirect happens;
  • any other request is redirected to /{martis}/sso/{name}/redirect.

A page fires several API requests at once, and only the first one meets the session; the rest are plain unauthenticated requests. So that they do not answer a bare 401 (which would send the SPA to the login page, past the provider), the session that ends leaves a marker for 60 seconds: the provider's name and the time, nothing of the user. It lives in the new anonymous session and, for the requests the browser sent before it learned the new session id, in the cache under a hash of the old id. While it holds, an unauthenticated JSON request of that browser (the martis.auth middleware answers it) gets the same 401 {"message", "sso_reauth_url"}. The marker is dropped when the provider's redirect route is visited, on any sign-in or sign-out, and after the minute; a browser that never had an SSO session gets the plain 401. If the cache cannot take the marker the failure is reported and only the session half answers.

On the SPA side the way back is followed once per page life: the 401s that come after it do nothing, and a 401 without the key waits 300 ms before it sends the page to the login page, so it can never override a way back that arrives a moment later. A page restored from the browser's back-forward cache (Back from the provider's page) follows it again.

Revocation: MartisSso::revokeFor()

use Martis\Sso\Facades\MartisSso;
 
// The IdP told you the person left (a webhook, a SCIM delete, an HR job):
MartisSso::revokeFor($user->getKey());

revokeFor(int|string $userId) ends every SSO session of the user, now:

  1. It stores a revocation time for the user in the cache (the key is scoped by the Martis guard, the TTL is the remember lifetime). The middleware ends any SSO-opened session whose sign-in is not later than that time. This works on every session driver, and with or without max_session_minutes.
  2. It drops every SSO-origin nonce of the user and cycles the user's remember token, so no remember cookie or SSO origin cookie signs them back in.
  3. On the database session driver it also deletes the user's rows of the sessions table. That ends every session of the user, including ones opened with a password, a magic link or an invitation, which the middleware cannot tell apart from the rest. The rows are matched by sessions.user_id; when the app's session guards sign in users of more than one table that column cannot be attributed to one person, so the rows are left alone (a warning is logged, naming the guards behind the tables) and only the SSO sessions end, through step 1. Laravel merges its own web guard into every app, so an admin-only app with its people in another table reads as ambiguous: list the guards that really write sessions.user_id in martis.auth.session_guards (the Martis guard is always counted; the list must be complete) and the rows are deleted. A set session_guards that is not a list of guard names of auth.guards that use the session driver makes revokeFor() throw, naming the key, after the revocation time is recorded and the remember token cycled.

The revocation time is a plain number in the cache. A store that keeps numbers as text (the Redis store returns "1700000000" for an integer it was given) is read correctly: the marker may come back as an integer or as a string of digits, and any other value (an array, a word, a signed or fractional number) is not a time Martis wrote and counts as revoked.

A sign-in after the revocation works normally: its sign-in time is later. That includes the one the middleware's redirect starts: while the person's session at the IdP (or, with the signed token, at the issuing panel) is alive they are signed in again at once, so a revocation that must hold is done at the IdP (or the issuing panel) as well, and revokeFor() on one panel never ends the sessions of another. The times are whole seconds, so a sign-in in the very second of the revocation counts as revoked.

A cache that cannot be read while the middleware looks for the revocation is reported and treated as "revoked" (fail closed: the session ends and the person signs in again). Because the revocation read runs on every SSO request, a cache outage ends every SSO session, fresh sign-ins included (sign in, the next request ends it, and so on) until the cache is back: that is the price of a revocation that cannot be skipped. A cache that cannot be written makes revokeFor() throw a RuntimeException, so a revocation that did not stick never looks as if it had. While an impersonation is active the session belongs to the operator who signed in through SSO, so the checks run against the operator.

Revalidation: revalidate_every_minutes

Ask the provider whether the person may still be in. The provider class implements Martis\Sso\Contracts\RevalidatesSessions, next to SsoProviderContract:

use Illuminate\Contracts\Auth\Authenticatable;
use Martis\Sso\Contracts\RevalidatesSessions;
use Martis\Sso\Contracts\SsoProviderContract;
 
final class OktaProvider implements SsoProviderContract, RevalidatesSessions
{
// name(), redirect(), resolveIdentity() ...
 
public function stillValid(Authenticatable $user, ?string $externalId): bool
{
// Ask the IdP (a user lookup, a token introspection). Keep it cheap: it runs inside a request.
return $this->idp->isActive($externalId);
}
}
'okta' => [
// ...
'revalidate_every_minutes' => 15,
],

The middleware calls stillValid() at most once per interval per session (the last check time is kept in the session; the SSO sign-in itself counts as the first check). externalId is the id the IdP issued at sign-in (null for a session opened before v2.21.0).

  • true: the session goes on.
  • false: the session ends as above.
  • a throwable: it is reported and the session is kept, and the check runs again no sooner than one minute later (kept in the session) instead of after a whole interval, and not on every request. An IdP outage must not sign every user out, nor turn every request of every SSO user into a synchronous IdP call; the hard bound is max_session_minutes, so set both when an outage must not stretch a session forever. Even so, stillValid() runs inside a request: give its HTTP client a short timeout (a few seconds), because every session waits that long once a minute while the IdP is down.

revalidate_every_minutes set on a provider whose class does not implement the contract throws an InvalidArgumentException naming the key.

Order of the checks

Revocation, then age, then revalidation; the first that applies ends the session. The config values are read (and validated) before any of them runs, so a bad value fails on every SSO request, not only when it would matter.


6. The three orthogonal axes

The three knobs (role_source, role_strategy, permission_adapter) compose freely. 3 × 3 × 4 = 36 combinations cover every realistic case without writing PHP. Anything outside that — use a callable in the relevant axis.

role_source — where external role names come from

ValueBehaviour
groupsCalls GET /v1.0/users/{id}/memberOf?$select=displayName on Microsoft Graph. Returns each group's displayName. Coarser-grained but doesn't require defining App Roles in the Azure portal.
app_role_assignmentsCalls GET /v1.0/users/{id}/appRoleAssignments?$filter=resourceId eq {resource_id}. Returns each assignment's principalDisplayName. Recommended — gives you per-app role granularity.
callableDefers to role_source_callable in the provider config, called with (string $externalId, string $accessToken) and returning the external role names (array<int, string>). Use for non-Microsoft IdPs or for custom Graph queries.

role_strategy — how to map external names to local roles

ValueBehaviour
column (default)Role::query()->whereIn($role_column, $externalRoles)->get(). The host app stores the IdP group/app-role name on the roles table. Most flexible — each role row decides whether and how it's mapped, without code changes.
configrole_map array in config maps local_slug => env_value. The mapper finds local roles whose name equals the slug for each env_value present in the external list. Useful when you can't add columns to roles.
callableDefers entirely to role_callable in the provider config, called with (array $externalRoles, ?User $user, string $provider). Returns a Collection of role models. Use for arbitrarily complex business rules.

role_source_callable and role_callable take the name of an invokable class (built through the container) or a [Class::class, 'staticMethod'] array. Both forms survive php artisan config:cache; a closure does not.

'role_source' => 'callable',
'role_source_callable' => \App\Martis\AzureRoles::class, // __invoke(string $externalId, string $accessToken): array
 
'role_strategy' => 'callable',
'role_callable' => [\App\Martis\SsoRoleMapper::class, 'map'], // public static function map(array $externalRoles, ?User $user, string $provider): Collection

A value that resolves to no callable throws an InvalidArgumentException naming the key, rather than reading no roles: with on_no_role_match => 'guest' and sync_roles, an empty role list logs the user in and syncs their local roles away. From role_source_callable the exception fails the callback (logged as SSO callback failed, and the login page shows the callback error); from role_callable it fails the request. See Config keys that take a callable.

permission_adapter — how resolved roles get written onto the user

ValueBehaviour
auto (default)Picks SpatieAdapter when spatie/laravel-permission is installed, NativeAdapter otherwise. It never picks callable: a MartisSso::syncRolesUsing() callback is ignored while this is auto.
spatieCalls $user->syncRoles($collection) (Spatie's HasRoles trait). The user model must use HasRoles.
nativeDirect attach/detach against model_has_roles. Pivot table name and column names are config-driven (auth.sso.native_pivot.*).
callableDefers to MartisSso::syncRolesUsing($user, $roles) registered in your service provider. Use when you have bespoke rules (custom main_role flag, dual tables, audit triggers).

7. Spatie / laravel-permission integration

Spatie integration is automatic. The default permission_adapter = 'auto' runs:

return class_exists(\Spatie\Permission\Models\Role::class)
? new SpatieAdapter
: new NativeAdapter;

The SpatieAdapter calls $user->syncRoles($collection). The user model must use Spatie's trait:

use Spatie\Permission\Traits\HasRoles;
 
class User extends Authenticatable
{
use HasRoles;
// ...
}

Multi-tenant Spatie schemas

When using Spatie's teams = true mode (Spatie Permission v6 with team scoping), set the team context before the SSO callback runs. The simplest way: a middleware that calls setPermissionsTeamId based on the subdomain or a session value, applied to the /sso/{provider}/callback route via the host app's route file.

Mixing Spatie and a custom adapter

When the app has Spatie roles BUT also a custom model_has_roles_extra table (like the CEO's case with main_role), use the callable adapter:

'permission_adapter' => 'callable',
MartisSso::syncRolesUsing(function ($user, $resolvedRoles) {
// 1. Spatie sync
$user->syncRoles($resolvedRoles);
 
// 2. Set the main_role flag (custom logic)
if (! $user->mainRole()) {
$user->updateMainRole($resolvedRoles->first(), $user);
}
});

8. Per-environment role mapping

Roles change between environments — QA's Azure tenant uses different group identifiers than production. Three patterns to handle this cleanly.

Pattern A — column strategy + per-env DB (preferred)

The roles.azure_group_name column carries the env-specific Azure name. Use seeders per environment:

// database/seeders/QaRoleSeeder.php (or via tinker / migrations)
Role::where('name', 'admin')->update(['azure_group_name' => 'PMI ES QA ADMIN']);
Role::where('name', 'sales_rep')->update(['azure_group_name' => 'PMI ES QA SALES REP']);
// database/seeders/ProdRoleSeeder.php
Role::where('name', 'admin')->update(['azure_group_name' => 'PMI ES PROD ADMIN']);
Role::where('name', 'sales_rep')->update(['azure_group_name' => 'PMI ES PROD SALES REP']);

Each environment seeds its own values. Code stays identical.

Pattern B — config strategy + per-env env vars

When you can't add a column to roles (legacy schema, third-party migrations), use the config strategy:

'azure' => [
'role_strategy' => 'config',
'role_map' => [
'admin' => env('AZURE_GROUP_ROLE_ADMIN'),
'sales_rep' => env('AZURE_GROUP_ROLE_SALES_REP'),
'backoffice' => env('AZURE_GROUP_ROLE_BACKOFFICE'),
'frontliner' => env('AZURE_GROUP_ROLE_FRONTLINER'),
],
],

.env.qa:

AZURE_GROUP_ROLE_ADMIN=PMI ES QA ADMIN
AZURE_GROUP_ROLE_SALES_REP=PMI ES QA SALES REP
AZURE_GROUP_ROLE_BACKOFFICE=PMI ES QA BACKOFFICE
AZURE_GROUP_ROLE_FRONTLINER=PMI ES QA FRONTLINER

.env.production:

AZURE_GROUP_ROLE_ADMIN=PMI ES PROD ADMIN
AZURE_GROUP_ROLE_SALES_REP=PMI ES PROD SALES REP
AZURE_GROUP_ROLE_BACKOFFICE=PMI ES PROD BACKOFFICE
AZURE_GROUP_ROLE_FRONTLINER=PMI ES PROD FRONTLINER

When a user logs in under QA, their Azure groups (PMI ES QA ADMIN etc.) match the env values, the mapper resolves the local slugs (admin etc.), and the lookup Role::whereIn('name', $slugs)->get() returns the local roles. Same code in both environments.

Pattern C — both layered

You can combine: per-env DB seeding for "real" mappings AND a config fallback for special-case overrides via env. The mapper runs column first, falls back to config only when the strategy says so — they're mutually exclusive within a single provider but you can declare two providers.


9. Host-app hooks

Five hook points exposed via the MartisSso facade. Wire them in app/Providers/MartisServiceProvider.php:

use Martis\Sso\Facades\MartisSso;
use Martis\Sso\SsoIdentity;

Hook 1 — resolveUserUsing

Replace the entire user-resolution flow. Receives the resolved external identity and the provider name. Return a User instance, or null to deny login.

MartisSso::resolveUserUsing(function (SsoIdentity $identity, string $provider): \App\Models\User {
return \App\Models\User::query()
->withoutGlobalScopes()
->updateOrCreate(
['email' => $identity->email],
[
'name' => $identity->name,
'auth_method' => 'azure',
'password' => bcrypt(bin2hex(random_bytes(32))),
],
)->tap(fn ($u) => $u->trashed() && $u->restore());
});

Hook 2 — resolveRolesUsing

Replace the entire role-resolution flow. Receives the external role list, the user (may be null when called before user creation), and the provider name. Return an Eloquent Collection of role models.

MartisSso::resolveRolesUsing(function (array $externalRoles, $user, string $provider) {
return \App\Models\Role::query()
->whereIn('azure_group_name', $externalRoles)
->where('active', true) // custom predicate
->get();
});

Hook 3 — syncRolesUsing

Replace the role-sync step. Auto-activates the CallableAdapter — no need to also flip permission_adapter.

MartisSso::syncRolesUsing(function ($user, $resolvedRoles) {
$user->syncRoles($resolvedRoles);
 
// Custom: set the main_role flag, dispatch a sync job, etc.
if (! $user->mainRole()) {
$user->updateMainRole($resolvedRoles->first(), $user);
}
 
\BroadcastUserSyncedJob::dispatch($user);
});

Hook 4 — afterLogin

Side-effect hook fired after the role sync and before the sign-in. Audit logs, welcome emails, derived columns.

MartisSso::afterLogin(function ($user, SsoIdentity $identity, string $provider) {
\App\Models\AuditEvent::record('sso.login', $user, ['provider' => $provider]);
 
$user->update([
'type' => $user->hasRole('frontliner') ? 'frontliner' : 'backoffice',
'last_sso_login_at' => now(),
]);
});

Registrations accumulate (v2.21.0): each call appends a callback and they run in registration order, so a package and the app can both register one without overwriting each other. Pass a provider name as the second argument to run a callback for that provider only; without it the callback runs for every provider.

MartisSso::afterLogin(fn ($user, $identity, $provider) => AuditLog::record($user)); // every provider
MartisSso::afterLogin(fn ($user, $identity, $provider) => SyncAzureGroups::dispatch($user), 'azure'); // azure only

An exception thrown by a callback stops the sign-in (the next callbacks do not run and the user is not logged in). To react after the user is signed in, listen to SsoLoggedIn instead, see Events.

Hook 5 — onNoRoleMatchUsing

Custom denial / guest flow when the role mapper returns an empty collection. Closure can return a redirect / response, or null to use the default deny page.

MartisSso::onNoRoleMatchUsing(function (SsoIdentity $identity, string $provider) {
\App\Notifications\AccessRequestedNotification::route('mail', 'admin@acme.com')
->notify(new \App\Notifications\SsoAccessRequested($identity));
 
return redirect('/login')->withErrors([
'sso' => __('Your account is not provisioned for :app yet. Admins have been notified.', [
'app' => config('app.name'),
]),
]);
});

Single-valued per scope (v2.21.0): one global slot and one slot per provider (MartisSso::onNoRoleMatchUsing($callback, 'azure')). The last registration for a scope replaces the earlier one; for a provider its own slot wins and the global one answers every provider without a slot.

Composing hooks

Hooks compose with each other AND with config — you can override resolveUserUsing while keeping the built-in RoleMapper (role_strategy = 'column'). Each layer is independent.

MartisSso::forgetHooks() (v2.21.0) clears every hook at once: the afterLogin and no-role-match callbacks (global and per provider) and the resolveUserUsing, resolveRolesUsing and syncRolesUsing overrides. Use it in tests and in an app that rebuilds its hooks per Octane worker; flushHooksForTesting() stays and calls it.

Events (v2.21.0)

Two events complement the hooks, and the issuing side of the signed token has two of its own. Listen with Event::listen() or a subscriber:

EventDispatchedProperties
Martis\Events\SsoLoggedInExactly once per successful sign-in, after Auth::login() and after the SSO session marker is set (the afterLogin hooks run before the sign-in).Authenticatable $user, SsoIdentity $identity, string $provider
Martis\Events\SsoFailedOnce per refused callback, after the log line and after any provisioning was rolled back. See Failed sign-ins.string $provider, string $reason, ?Throwable $exception
Martis\Events\SsoTokenIssued, Martis\Events\SsoIssueDenied (v2.22.0)The signed-token issuer signed a token, or refused to. See Issuer audit events.Authenticatable $user, string $audience, string $requestId (and string $reason on the denial; its user may be null)

10. REST surface and routes

Both routes register only when auth.sso.enabled = true. They're unauthenticated (the user isn't logged in yet) and rate-limited via the login_attempts / login_minutes config.

MethodPathDescription
GET/{martis}/sso/{provider}/redirectKicks off the OAuth flow. Returns 302 to the IdP authorize URL.
GET/{martis}/sso/{provider}/callbackHandles the IdP callback. Resolves identity → roles → user → login. Redirects to provider.redirect_to (or /martis by default) on success, /martis/login with errors on failure.

The provider segment is whatever the user registered — azure, google, github, okta, custom names. Routes only resolve if the provider is registered AND enabled. The segment must be the provider's lower-case name, exactly as its config key is spelled: /sso/AZURE/callback answers 404 (v2.21.0). The name used to be lowercased only to find the driver, so a path in another letter case ran the sign-in with every other setting of the provider (identity matching, role mapping, session limits, federated logout) read under a key that does not exist, that is, on the defaults.

A third route belongs to the issuing side of the signed token (v2.21.0). It is always registered, runs the full protected stack, and answers 404 while auth.sso.issuer.enabled is false:

MethodPathDescription
GET/{martis}/sso/issue/{audience}?state=...A signed-in user (2FA passed, viewMartis allowed) is redirected to the audience's configured callback_url with a signed token and the state. An anonymous visitor goes to the login page first.

The provider route driver => 'signed-token' uses the same two /{martis}/sso/{provider}/... routes as every provider.


11. Generator command

php artisan martis:sso <provider>
# Behaviour flags
[--with-spatie]
[--with-migration]
[--strategy=column|config|callable]
[--no-auto-create-user]
[--custom]
[--driver=signed-token] # v2.21.0: a block of any name that signs in through another Martis app
[--issuer] # v2.21.0: add an audience to the issuing block instead
# Skip flags (opt-out of automated steps)
[--no-composer]
[--no-publish-spatie]
[--no-listener]
[--no-migrate]
[--no-map]

Behaviour flags

FlagMeaning
--with-spatieInstalls spatie/laravel-permission (when missing), runs Spatie's vendor:publish, sets permission_adapter = 'spatie' in the generated config.
--with-migrationPublishes a migration adding {provider}_group_name to roles. Fails when neither the roles table nor Spatie's create_permission_tables migration exists.
--strategySets the default role_strategy (default column).
--no-auto-create-userSets auto_create_user = false in the generated config.
--customSkips the validation that the provider is one of azure / google / github. Use when registering a custom provider via MartisSso::extend().
--driver=signed-token(v2.21.0) The provider argument is any name (panel-a becomes panel_a); the block gets driver => 'signed-token', the MARTIS_SSO_{NAME}_* env stubs (provider disabled until filled) and identity_match_attribute = external_id. Nothing is installed: no Socialite, no listener.
--issuer(v2.21.0) The argument is an audience name (kept as typed, lowercased: panel-b). Adds 'panel-b' => ['callback_url' => env(...)] to auth.sso.issuer.audiences and the MARTIS_SSO_ISSUER_* env stubs. The private key stub is left empty: no command writes a key. Not combinable with --driver or --custom.

The key pair comes from its own command, php artisan martis:sso:keys (v2.21.0): it prints a new Ed25519 pair as base64 with where each half goes, and writes nothing.

Skip flags

FlagSkips
--no-composerThe composer require step (deps must already be installed).
--no-publish-spatieSpatie's vendor:publish (config + migrations).
--no-listenerAuto-registering the Socialite extension listener in AppServiceProvider::boot(). Azure-only — has no effect on other providers.
--no-migrateThe php artisan migrate step.
--no-mapThe interactive prompt that maps existing Spatie roles to provider display names.

Built-in scaffolding shapes for azure, google, github differ in:

  • Default scopes
  • Default role_source (Azure → app_role_assignments, others → callable)
  • Button label and icon

Idempotency

The generator is fully idempotent. Re-running with the same provider name on a fully-configured project is a no-op:

StepWhat "already done" looks like
Composer requireAll packages already declared in composer.json
Config block'azure' => [ already declared in config/martis.php (a commented example does not count)
Env varsEach MARTIS_SSO_* / AZURE_* line already in .env
ListenerMicrosoftExtendSocialite string already in AppServiceProvider.php
Spatie publishconfig/permission.php AND *_create_permission_tables.php already exist
Martis migrationA matching *_add_{provider}_group_name_to_roles_table.php already published
MigrateStandard Laravel — no pending migrations is a no-op
Role mappingEmpty input keeps current value; same input is no-op

12. Migration from a custom OAuth controller

Apps that already have a custom AzureOauthController (typically extending the host's LoginController) usually carry ~200 lines covering:

  1. Socialite driver + scope setup.
  2. Microsoft Graph appRoleAssignments fetch.
  3. Mapping Graph principalDisplayName to local roles.
  4. Find-or-create user with custom auth_method, type, soft-delete restore.
  5. Detach old roles, attach new ones.
  6. Custom main_role flag on a separate pivot.
  7. Type derivation (e.g. FRONTLINER vs BACKOFFICE based on role).
  8. Logging / audit.

The Martis subsystem replaces all of it with config + 2 hooks:

// app/Providers/MartisServiceProvider.php
use Martis\Sso\Facades\MartisSso;
use Martis\Sso\SsoIdentity;
use App\Enums\AuthMethodEnum;
use App\Enums\UserTypeEnum;
use App\Models\Role;
use App\Models\User;
 
protected function registerSso(): void
{
MartisSso::resolveUserUsing(function (SsoIdentity $identity, string $provider): User {
return User::query()
->withoutGlobalScopes()
->updateOrCreate(
['email' => $identity->email],
[
'name' => $identity->name,
'auth_method' => AuthMethodEnum::AZURE->value,
'password' => bcrypt(bin2hex(random_bytes(32))),
],
)->tap(fn ($u) => $u->trashed() && $u->restore());
});
 
MartisSso::afterLogin(function (User $user, SsoIdentity $identity, string $provider) {
$user->update([
'type' => $user->hasRole(Role::FRONTLINER)
? UserTypeEnum::FRONTLINER->value
: UserTypeEnum::BACKOFFICE->value,
]);
 
if (! $user->mainRole()) {
$user->updateMainRole($user->roles->first(), $user);
}
});
}

Plus the config block (~30 lines) and the azure_group_name migration (--with-migration flag). Total: ~30 lines vs 200.

The custom AzureOauthController and its routes can be deleted entirely.


13. Multi-provider login UI

The Login page reads auth.sso.providers and renders one button per enabled provider. Label and icon come from each provider's config:

'providers' => [
'azure' => [
'enabled' => true,
'label' => 'Continue with Microsoft',
'icon' => 'microsoft-outlook-logo',
],
'google' => [
'enabled' => true,
'label' => 'Continue with Google',
'icon' => 'google-logo',
],
'github' => [
'enabled' => true,
'label' => 'Sign in with GitHub',
'icon' => 'github-logo',
],
],

Result: three buttons stacked above the email/password form. Click → /sso/{name}/redirect.

A translated button label (v2.22.0)

label is a string, or a map of locale => string that the login page resolves against the active language the way it resolves auth.copy values: the exact locale (pt_PT), then its prefix (pt), then en, then the first non-empty entry. The button follows the language picker without a reload.

'azure' => [
'enabled' => true,
'label' => [
'en' => 'Continue with Microsoft',
'pt_PT' => 'Continuar com a Microsoft',
],
],

Unset (null), an empty string, an empty map or a map with no usable entry, the button reads "Continue with <provider name>" from the bundled translation. A label that is set but is neither a string nor a map of strings (a number, a list, a map with a non-string value) throws an exception naming martis.auth.sso.providers.{provider}.label when the login page is built; before v2.22.0 it was dropped without a word. The label of a provider that is not enabled is not read.

When auth.sso.enabled = false, no buttons render and no routes register.


14. Testing your SSO integration

Local development with Azure

For local dev, register a separate Azure app (or use the same one with multiple Redirect URIs):

  1. Azure portal → your app → Authentication → Add a platform → Web.
  2. Add http://localhost:8000/martis/sso/azure/callback (or whatever your local URL is).

Mocking the IdP for unit tests

For automated tests, bind a stub provider in the container:

$identity = new \Martis\Sso\SsoIdentity(
provider: 'azure',
externalId: 'azure-test-1',
email: 'test@example.com',
name: 'Test User',
externalRoles: ['Admin'],
);
 
$this->app->instance(\Martis\Sso\Providers\AzureProvider::class, new class($identity) extends \Martis\Sso\Providers\AzureProvider {
public function __construct(private SsoIdentity $stub) {}
public function resolveIdentity(\Illuminate\Http\Request $request): SsoIdentity { return $this->stub; }
public function redirect(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse { return redirect('/'); }
});
 
// Now hit the callback — it'll use your stub identity.
$response = $this->get('/martis/sso/azure/callback');
$response->assertRedirect('/martis');

Look at tests/Feature/SsoControllerTest.php for 8 working examples.


15. Security considerations

CSRF / state / nonce / PKCE

Socialite 5.x manages the OAuth state, nonce, and PKCE parameters automatically. ID-token signature validation is delegated to Socialite for OIDC providers. Don't disable the state check; don't drop the nonce; don't try to assemble the authorize URL by hand. The redirect() flow in AzureProvider defers entirely to Socialite::driver(...)->redirect().

Federated logout (Single Sign-Out)

By default POST /martis/api/auth/logout only clears the local session. The user stays signed in at the IdP, so re-clicking "Sign in with Microsoft" silently reuses the SSO session.

To opt in to federated logout, set the per-provider logout_url (since v1.8.8). When the user came in via SSO, logout redirects through the IdP's logout endpoint after invalidating the local session:

// config/martis.php
'azure' => [
// ... rest of the block
'logout_url' => env('MARTIS_SSO_AZURE_LOGOUT_URL'),
],
MARTIS_SSO_AZURE_LOGOUT_URL=https://login.microsoftonline.com/{tenant}/oauth2/v2.0/logout?post_logout_redirect_uri={post_logout_redirect_uri}

The placeholder {post_logout_redirect_uri} is replaced at logout time with the urlencoded Martis login URL — Azure (and most OIDC providers) need this to know where to bounce the browser after killing the IdP session.

The marker that says "this user came in via SSO" lives in the session under martis_sso_provider, and in a cookie of the same name (v2.3.0). The SSO callback signs in without remember-me unless the provider sets remember to true (v2.4.0; it was always on before, so panel access outlived the IdP session and IdP-side deprovisioning). For a provider that opts in, the session can end while the remember cookie signs the user back in, into a new session: the cookie carries the origin across that re-login, so the logout still goes through the IdP, and the forced password change gate still leaves the session alone. The cookie holds the user's id, the provider, the time it was issued, the identity's external id (v2.21.0) and a random nonce, encrypted by Martis (a browser cannot mint one), and is written only for a remembered sign-in. It counts only for the user it names, only while it is younger than the remember lifetime (auth.guards.{guard}.remember, else Laravel's 576000 minutes) and only while the server still holds its nonce for that user (cache, same lifetime; v2.4.0). A password, magic-link or invitation sign-in drops every nonce of the user, so a copy of an old cookie re-attached after a password sign-in no longer exempts the session from the forced password change; the sign-out drops the nonce of its own browser. A password, magic-link or invitation sign-in, and the sign-out, drop both, so a user who switches from SSO to password mid-stream does not get redirected to Azure on logout. Martis\Sso\SsoSession reads and writes them.

logout_url can also be the name of an invokable class (v2.21.0), for an IdP whose logout URL needs more than a placeholder, such as an id_token_hint or a signed request:

'azure' => [
'logout_url' => App\Martis\AzureLogoutUrl::class,
],
final class AzureLogoutUrl
{
public function __invoke(Request $request, ?Authenticatable $user, string $postLogoutRedirectUri): ?string
{
return 'https://idp.example/logout?'.http_build_query([
'id_token_hint' => $this->tokens->idTokenOf($user),
'post_logout_redirect_uri' => $postLogoutRedirectUri,
]);
}
}

It runs before the local logout (the user is still signed in), receives the login page URL as it is (encode it where your URL needs that: the {post_logout_redirect_uri} replacement is for plain URL strings only), and answers the URL, or null (or an empty string) for a local logout only. A plain URL string keeps today's behaviour. A string that names a class which is not invokable, or a class that answers something other than a string or null, throws an InvalidArgumentException naming martis.auth.sso.providers.{name}.logout_url. A class whose __invoke throws (it asked the IdP for its discovery document and the IdP is down) is reported and the sign-out completes locally, with no federated URL: the person is always signed out of the panel.

The JSON variant of the logout endpoint surfaces the IdP URL as logout_url, so a SPA can drive the redirect itself if it needs to add UI between local and federated logout.

Refresh tokens

Martis does not persist refresh tokens. Apps that need offline-token rotation (Graph queries fired from queued jobs, calendar sync, etc.) should capture both the access token and the refresh token in the afterLogin hook and run their own renewal flow. A first-class refresh-token store is on the roadmap, not in the current release.

Token storage

Martis does not persist the OAuth access token. If your app needs it (e.g. to make Graph API calls on behalf of the user later), capture it in the afterLogin hook:

MartisSso::afterLogin(function ($user, SsoIdentity $identity, string $provider) {
if ($identity->accessToken !== null) {
$user->update(['azure_access_token' => encrypt($identity->accessToken)]);
}
});

Always encrypt at rest.

Replay protection

The OAuth code is single-use — Azure invalidates it after the first exchange. The Socialite library handles this automatically.

Soft-deleted users

Auto-create + soft-delete = a user who left the company comes back. To deny re-login of soft-deleted users:

MartisSso::resolveUserUsing(function (SsoIdentity $identity) {
$user = User::query()->where('email', $identity->email)->first();
if ($user?->trashed()) {
return null; // denies login
}
return $user ?? User::create([...]);
});

To allow re-login (typical permissive behaviour):

->tap(fn ($u) => $u->trashed() && $u->restore());

Email forwarding / impersonation

The IdP guarantees the email belongs to the authenticated user. Martis trusts that. Don't add extra "verify the email" hops post-SSO.

Role escalation through groups strategy

When using role_source = 'groups', ANY group the user belongs to is considered. Make sure non-admin distribution lists, MS Teams groups, etc., are NOT named in your roles.azure_group_name column. Prefer app_role_assignments for tighter control.


16. Troubleshooting

People are sent through the IdP again after a while

Check the provider block for max_session_minutes and revalidate_every_minutes (see Ending an SSO session). A session opened before v2.21.0 has no sign-in time and counts as expired once max_session_minutes is set: each such user signs in once more and the session then carries its time. Separately, a RevalidatesSessions::stillValid() that throws never ends the session, but it is called again on every request until it answers, so a slow IdP call shows up as slow requests: keep it cheap and give it a short timeout.

Button doesn't appear on Login

Likely causeFix
auth.sso.enabled = falseSet MARTIS_SSO_ENABLED=true
Provider's enabled = falseSet MARTIS_SSO_AZURE_ENABLED=true
Stale config:cachephp artisan config:clear

Callback fails with "Class 'Laravel\Socialite\Facades\Socialite' not found"

Run composer require laravel/socialite.

Callback fails with Microsoft driver not registered

The socialiteproviders/microsoft package wasn't wired up. Add the listener in AppServiceProvider::boot():

Event::listen(SocialiteWasCalled::class, [
\SocialiteProviders\Microsoft\MicrosoftExtendSocialite::class.'@handle',
]);

Callback returns 302 to /martis/login and the login page shows a generic text

The reason is in the log, not on the page. Search the log for SSO callback failed: its reason (unknown, account_unverified, unlinked_account, email_unverified, missing_external_id, external_id_mismatch, not_provisioned, no_role_match), exception class and redacted error say what happened, and request_id ties the line to the request (send an X-Request-Id header from your proxy to correlate it with the access log). See Failed sign-ins.

Callback returns 302 to /martis/login with sso_no_role_match

The user has no Azure App Role assignment that matches a local role's azure_group_name. Verify:

  1. The user has been assigned an App Role in Azure portal → Enterprise applications → your app → Users and groups.
  2. The role display name in Azure exactly matches roles.azure_group_name (case-sensitive).
  3. The AZURE_RESOURCE_ID env var matches the application id in Azure (most apps: same value as AZURE_CLIENT_ID).
  4. The user actually has the assignment — query /me/appRoleAssignments directly in Graph Explorer to confirm.

User created but no Spatie roles assigned

Likely causeFix
azure_group_name column missing on rolesRun php artisan migrate
Column populated but values don't matchVerify exact case-sensitive match
User model missing HasRoles traituse Spatie\Permission\Traits\HasRoles;
permission_adapter = 'native' set explicitlySwitch to 'spatie' or 'auto'

Logs show "Failed to fetch appRoleAssignments"

Likely causeFix
Missing read permission on AppRoleAssignmentThe Azure app needs User.ReadBasic.All + GroupMember.Read.All granted; double-check by hitting /users/me/appRoleAssignments in Graph Explorer with the same delegated permissions. Martis only ever reads — the ReadWrite.All scope is never required.
AZURE_RESOURCE_ID mismatchSet it to the Application ID, not the Object ID or Tenant ID
Token expired / wrong scopesEnsure openid profile email are requested

"Cannot resolve user" / 500 on callback

Likely causeFix
auth.providers.users.model config wrongCheck config/auth.php — should point to App\Models\User::class
User table missing email columnHighly unusual — check users schema
auto_create_user = false and user doesn't existSet to true or pre-create the user

17. Test coverage

FileWhat it covers
tests/Unit/Sso/SsoIdentityTest.phpValue object contract (full + minimal payloads).
tests/Unit/Sso/RoleMapperTest.phpAll three strategies (column, config, callable) + global override + empty input + no-match.
tests/Unit/Sso/IdentityResolverTest.phpFind-or-create flow, attribute sync, soft-delete handling, host-app override hook.
tests/Unit/Sso/PermissionAdaptersTest.phpSpatie / Native / Callable / Auto adapter routing + role attach/detach semantics.
tests/Unit/Sso/SsoManagerTest.phpSingleton wiring, hook registration + dispatch, provider extension via extend().
tests/Feature/SsoControllerTest.phpEnd-to-end callback: user create / sync, role map, deny / guest paths, hook firing, disabled-provider redirect, route gating, federated logout, password-login wipe of the SSO marker.
tests/Unit/Sso/SsoLogRedactorTest.phpThe log redactor (query and JSON pairs, escaped JSON, bearer values, look-alike keys) and the SsoException / SsoAdoptionRefusedException contract.
tests/Unit/Sso/SsoManagerHooksTest.phpAccumulating afterLogin hooks, provider scoping, single-valued no-role-match slots, forgetHooks().
tests/Unit/Sso/IdentityResolverExternalIdTest.phpexternal_id matching: id stored on create, second sign-in, link_by_email, mismatch, unverified refusal, empty id, sync_user_attributes: external_id, config errors.
tests/Feature/SsoFailureReasonTest.phpThe reason, log context (request id, redaction) and SsoFailed event of every refusal, and the login page text.
tests/Feature/SsoLoggedInEventTest.phpSsoLoggedIn: once per sign-in, after the login and the SSO marker, never on a refusal.
tests/Feature/SsoDenyBeforeProvisioningTest.phpDeny before provisioning on the pre-check and the transaction path, guest, provision_on_deny, rollback of resolver writes, a refusal that is not an SsoException (unique index, throwing hook or role sync) is a refusal and not a 500.
tests/Feature/SsoSessionMarkerTest.phpThe SSO origin across a remember-me re-login: the forced password change gate and the federated logout still see it; a cookie for another user or not encrypted by Martis counts for nothing; every other sign-in and both sign-outs drop it.
tests/Feature/SsoSessionRevalidationTest.phpThe sign-in time and external id in the session (kept across a remember-me re-login), max_session_minutes (JSON 401 with sso_reauth_url, page redirect, boundary, sessions without a time), the refusal of remember together with a limit, the 404 for a provider name that is not lower case, MartisSso::revokeFor() (array and database drivers, remember cookie, nonces, later sign-in, impersonation, cache failures), RevalidatesSessions (false, true, throwing, interval, misconfiguration), password sessions untouched, invokable logout_url.
resources/js/lib/api.ssoReauth.test.tsThe SPA's 401 handler: follows a same-origin path under the Martis base path, ignores absolute, protocol-relative and base-escaping URLs; three concurrent 401s in every arrival order end in exactly one navigation; the way back is followed once and again after a back-forward cache restore.
tests/Feature/LoginSsoFlashTest.phpThe flashed sso refusal published once as MartisConfig.flash.sso (and nothing else of the error bag), the disabled-provider redirects, markup-safe encoding.
resources/js/pages/Login.ssoFlash.test.tsxThe login page toasts the flashed text once, also on an SSO-only page, and nothing for an empty or non-string value.
tests/Unit/Sso/SignedToken/UrlsTest.phpThe URL rule of authorize_url and callback_url: https, or http on localhost, *.localhost, *.test, 127.0.0.1 and [::1] only.
tests/Unit/Sso/SignedToken/PasetoV4PublicTest.phpThe PASETO v4.public codec against the official 4-S-* and 4-F-* test vectors, PAE, strict base64url, trailing data, canonical encoding.
tests/Unit/Sso/SignedToken/JsonObjectKeysTest.phpThe repeated-key scan of v2.23.0: a key repeated at any depth, in an escaped spelling or after a nested value is found, and a repeat inside a string value or across sibling objects is not.
tests/Feature/SsoSignedTokenProviderTest.phpDriver resolution by block name, redirect(), every refusal with its own reason, key rotation, one-time jti (kept past the last accepted second, used up before the state is checked), the state lifetime, the environment lock, email_verified, fail-closed cache, config errors.
tests/Feature/SsoSignedTokenIssuerTest.phpThe issue route (login, 2FA, gate, 404, state, redirect target, headers, Fetch Metadata, impersonation 403), the audit events and the denial log (v2.22.0), TokenIssuer claims (email_verified, reserved claims) and config errors.
tests/Feature/SsoSignedTokenEndToEndTest.phpOne app as both panels: issue route, callback, external_id linking, role mapping, SsoFailed, no token or key in logs, the jti as the second lock, a provider name in another letter case (404), an unverified email never adopting a row, the state-fixation replay refused.
tests/Feature/SsoCacheStoreTest.phpauth.sso.cache_store (v2.22.0): the jti, the revocation, the origin nonce, the ended marker and the Azure tenant lookup land in the configured store and not in the default one; an unknown or unusable name throws naming the key; the revocation marker is read back from a store that returns numbers as text (a real RedisStore over a text connection), and a marker of any other type counts as revoked.
tests/Feature/SsoSignedTokenCommandsTest.phpmartis:sso:keys (never writes), martis:sso --driver=signed-token and --issuer.
tests/Feature/SsoCheckCommandTest.phpmartis:sso:check (v2.23.0): a good issuer and consumer block pass, an enabled issuer with a missing, malformed or inconsistent private_key or another unusable key fails naming it, a bad consumer block fails, an unknown cache store fails, off blocks are skipped, exit codes, no key material in the output, nothing written.
tests/Feature/SsoMakeCommandTest.phpGenerator idempotency: composer skip, config-block insert, env stub, listener register, Spatie publish, migration publish, role-mapping prompt.

Run the SSO slice with:

vendor/bin/pest tests/Unit/Sso tests/Feature/SsoControllerTest.php tests/Feature/SsoFailureReasonTest.php tests/Feature/SsoLoggedInEventTest.php tests/Feature/SsoDenyBeforeProvisioningTest.php tests/Feature/SsoSessionMarkerTest.php tests/Feature/SsoSessionRevalidationTest.php tests/Feature/SsoMakeCommandTest.php

The signed-token slice (v2.21.0) on its own:

vendor/bin/pest tests/Unit/Sso/SignedToken tests/Feature/SsoSignedTokenProviderTest.php tests/Feature/SsoSignedTokenIssuerTest.php tests/Feature/SsoSignedTokenEndToEndTest.php tests/Feature/SsoSignedTokenCommandsTest.php

The counts grow with each release — see CI output for the live total.

Looking to swap the React LoginPage (multi-provider button strip + email/password form) for a custom shell? See Override System — the auth pages sit under registry keys auth:login, auth:register, auth:forgot-password, auth:reset-password, auth:email-verify-notice.


18. Signed token between two Martis apps (v2.21.0, v2.22.0)

Panel B signs its users in through panel A, when both are Martis apps, with one redirect and a short-lived, one-time token that panel A signs with a private key and panel B verifies with the matching public key. No OAuth server, no client secret, no new table.

It is not OIDC and not OAuth: there is no discovery document, no authorization code, no refresh token, and only a Martis panel issues the token. For a third-party identity provider (Microsoft, Google, GitHub, Okta) use the Socialite providers above.

The token is a PASETO v4.public token (Ed25519): v4.public.<base64url(payload || signature)>.<base64url(footer)>. There is no algorithm header and nothing to negotiate. The codec uses ext-sodium only, so no Composer package is added. Without the extension the feature throws an exception that says to enable it. The codec is tested against the official PASETO v4 test vectors.

The flow

browser panel B (consumer) panel A (issuer)
| click "Continue with Panel A" |
|--------------------->| GET /martis/sso/panel_a/redirect |
| | random state kept in B's session |
|<---------------------| 302 authorize_url?audience&state |
|------------------------------------------------------------->|
| GET /martis/sso/issue/panel-b?state=... |
| (signed in, 2FA passed, viewMartis) |
|<-------------------------------------------------------------|
| 302 callback_url?token&state |
|--------------------->| GET /martis/sso/panel_a/callback |
| | verify signature, claims, jti, state |
| | find or create the local user, sign in |

An anonymous visitor at panel A is sent to its login page first and the issue URL is kept as the session's intended URL. The Martis login screen signs in over JSON, and the response that completes the sign-in (after the 2FA challenge, the email verification or a forced password change, when one sits in between) carries that URL, so the screen opens it and the flow carries on to panel B without a second click. Only a same-origin path under panel A's Martis base path is followed; see Authentication.

1. Generate the key pair

php artisan martis:sso:keys

It prints a new Ed25519 pair as base64 and says where each half goes. It writes nothing: not to .env, a config file or a log. The secret key goes into the environment of panel A only (MARTIS_SSO_ISSUER_PRIVATE_KEY). The public key is not secret and goes to panel B. The output carries the kid too: the first 16 hex characters of the SHA-256 of the public key, which the token's footer names so a verifier can pick the key during a rotation.

2. Panel A: the issuer block

php artisan martis:sso panel-b --issuer # adds the audience and the env stubs, writes no key
// config/martis.php, auth.sso
'issuer' => [
'enabled' => env('MARTIS_SSO_ISSUER_ENABLED', false),
'issuer' => env('MARTIS_SSO_ISSUER'), // https://panel-a.example
'private_key' => env('MARTIS_SSO_ISSUER_PRIVATE_KEY'), // base64, from martis:sso:keys
'ttl_seconds' => 60,
'environment' => env('MARTIS_SSO_ISSUER_ENVIRONMENT'), // 'production' | 'staging' | null
'claims' => null, // App\Martis\SsoClaims::class
'audiences' => [
'panel-b' => [
'callback_url' => 'https://panel-b.example/martis/sso/panel_a/callback',
'ability' => 'sign-in-to-panel-b', // optional; null = any panel user
],
],
],
KeyMeaning
enabledMaster switch of the issuing side (default false). The issue route answers 404 while it is off.
issuerThis panel's name in the iss claim. Panel B's provider block must carry the same string.
private_keyBase64 Ed25519 secret key (64 bytes). Read from the environment: no command ever writes it to a file. A key that is not 64 bytes, or whose public half does not match its seed, throws naming this key.
ttl_secondsToken life, 1 to 600 (default 60).
environmentOptional. Added to the token as the claim env, so a verifier locked to production refuses a staging token. The lock is a pair (v2.22.0): panel B must set the same environment too, because a token that carries the claim is refused by a verifier with no environment.
role_claimOptional claim name for the roles (default roles). Keep it equal to panel B's role_claim.
environment_claimRemoved in v2.22.0. The claim that carries the environment is always env; a block that still sets the key throws, naming it. With a configurable name, a locked issuer and an unlocked verifier could read different claim names and disagree without any error.
claimsOptional invokable class, (Authenticatable $user, string $audience): array, that adds claims and replaces email, name and the roles. Use the name of a class or a [Class::class, 'method'] array, never a closure (config:cache). iss, aud, sub, iat, exp, jti, state, email_verified and env (the environment claim, always, whether or not environment is set) cannot be set: the class throws if it tries.
audiences'<audience>' => ['callback_url' => '...', 'ability' => null]. ability (optional, a non-empty string; anything else throws naming the key) limits who may obtain a token for that audience: the issue route checks Gate::forUser($user)->allows($ability), so an app gate or a Spatie permission name both work. A refusal answers 403 with a translated message, issues nothing and redirects nowhere. Unset, any user who can open the panel gets a token. The audience is the name panel B uses as its audience. A name is 1 to 64 letters, digits, ., - or _, and is not callback or redirect.

The claims of the token:

ClaimValue
ississuer
audthe audience of the request
subthe user's id at panel A, as a string
iat, expRFC 3339 UTC; exp is iat + ttl_seconds
jti128 random bits (hex), used once on the other side
statethe value panel B kept in the browser's session
email, namethe user's attributes (a user with no email cannot be signed for: panel B requires one)
email_verifiedboolean: true when the user model does not implement Illuminate\Contracts\Auth\MustVerifyEmail, else hasVerifiedEmail(). It describes the user's own address at panel A: a claims class that replaces email answers for the address it puts there.
rolesthe user's Spatie role names when the model has getRoleNames(), else an empty list
envonly when environment is set

Martis\Sso\SignedToken\TokenIssuer::fromConfig()->issue($user, $audience, $state) issues the same token from your own code.

The issue route runs the whole protected stack (signed in, 2FA passed, email verified when required, the viewMartis gate) and then the audience's ability, when it sets one. While an impersonation is active it answers 403 with a translated message and issues nothing: the guard then holds the impersonated user, and a token for them would open a full session at the other panel with no impersonation marker and none of its limits, which that panel's admins never granted the operator. It redirects only to the callback_url of the audience, never to a URL from the request, with token and state appended. A callback_url must be an absolute https URL without credentials or a fragment (http is accepted for localhost, *.localhost, *.test, 127.0.0.1 and [::1] hosts, so two apps on loopback ports work locally; no other loopback address, such as 127.0.0.2); anything else throws naming the key. The response carries Cache-Control: no-store and Referrer-Policy: no-referrer. When the browser sends Fetch Metadata the request must be a top-level navigation (Sec-Fetch-Mode: navigate and Sec-Fetch-Dest: document): an XHR, an iframe or an image is refused with 403, so another site cannot make a signed-in browser fetch a token. The state parameter is required (^[A-Za-z0-9_-]{16,128}$, else 422). One audit line is logged per token (audience and user id, never the token), and since v2.22.0 every token and every refusal is also an event: see Issuer audit events.

Issuer audit events (v2.22.0)

The issue route reports what it signs and what its controller refuses, so panel A can keep an audit trail of who got a token for which panel:

EventDispatchedProperties
Martis\Events\SsoTokenIssuedOnce per token signed, before the redirect. The log line SSO token issued (info) is unchanged and has the same request_id.Authenticatable $user, string $audience, string $requestId
Martis\Events\SsoIssueDeniedOnce per request the controller refuses (the six reasons below), after a warning log line SSO token issue denied (audience, user_id, reason, request_id). The HTTP answer is the one the route always gave.?Authenticatable $user, string $audience, string $reason, string $requestId

The reason of SsoIssueDenied is one of its six constants:

ReasonAnswerWhen
unknown_audience404The issuer is on and the audience in the URL is not one of audiences.
not_navigation403The browser's Fetch Metadata says the request is not a top-level navigation (an XHR, an iframe, an image).
invalid_state422The state parameter is missing or not ^[A-Za-z0-9_-]{16,128}$.
impersonating403An impersonation is active. user is the impersonated user, the one the guard holds.
forbidden403The audience's ability does not allow the user.
cannot_sign500Signing failed: a user with no email, a claims class that throws or answers the wrong shape, an unusable issuer block. The denial is recorded first and then the exception goes on, so the 500 stays and nothing is hidden.

Refusals by the route's middleware (not signed in, 2FA not passed, email not verified, the viewMartis gate, throttle) happen before the controller and raise no event. A disabled issuer stays a silent 404: no line, no event. An audience name the route's own shape constraint rejects (more than 64 characters, a character outside letters, digits, ., -, _) never matches the issue route, so it never reaches the controller: the panel's catch-all route answers it with the SPA shell (a 200 page, not a 404), and no event is raised. Only a name of a valid shape that is not configured is a 404 with unknown_audience. The audience of an event or a line is cut to 64 characters and anything but printable ASCII in it becomes ?: on the unknown-audience path it is whatever the caller put in the URL. requestId is the request's X-Request-Id, else a UUID, and is the request_id of the log line. The header is the caller's to write, so it is bounded: anything but printable ASCII becomes ? and at most 128 bytes are kept (the failed-callback line of SsoFailed uses the same bound). Listeners run inside the request: a throwing listener of SsoTokenIssued stops the sign-in (fail closed), and one of SsoIssueDenied turns the denial into a 500, so keep them short and let them catch their own failures. Nothing of the token or of the caller's state is in an event or a line. The same holds for a stack trace of the signed-token classes: the state, the token, the message and the secret key are #[\SensitiveParameter] parameters, so PHP prints SensitiveParameterValue in their place even with zend.exception_ignore_args off (the development default), including in the trace Laravel logs for a cannot_sign 500. That covers the package's own frames; the arguments of your own claims class, listener or middleware are yours to mark.

use Illuminate\Support\Facades\Event;
use Martis\Events\SsoIssueDenied;
use Martis\Events\SsoTokenIssued;
 
Event::listen(SsoTokenIssued::class, function (SsoTokenIssued $event) {
AuditLog::record('sso.issued', $event->user, ['audience' => $event->audience, 'request_id' => $event->requestId]);
});
 
Event::listen(SsoIssueDenied::class, function (SsoIssueDenied $event) {
AuditLog::record('sso.issue_denied', $event->user, [
'audience' => $event->audience,
'reason' => $event->reason,
'request_id' => $event->requestId,
]);
});

3. Panel B: the provider block

php artisan martis:sso panel-a --driver=signed-token
// config/martis.php, auth.sso.providers
'panel_a' => [
'enabled' => env('MARTIS_SSO_PANEL_A_ENABLED', false),
'driver' => 'signed-token',
'label' => 'Continue with Panel A',
'authorize_url' => 'https://panel-a.example/martis/sso/issue/panel-b',
'public_keys' => array_values(array_filter(array_map('trim', explode(',', (string) env('MARTIS_SSO_PANEL_A_PUBLIC_KEYS', ''))))),
'issuer' => 'https://panel-a.example',
'audience' => 'panel-b',
'max_age_seconds' => 60,
'clock_skew_seconds' => 30,
'role_claim' => 'roles',
// 'environment' => 'production', // optional second lock
'identity_match_attribute' => 'external_id',
// ...the usual role_strategy, auto_create_user, permission_adapter, on_no_role_match keys
],

MARTIS_SSO_<NAME>_PUBLIC_KEYS (the name in upper case, here MARTIS_SSO_PANEL_A_PUBLIC_KEYS) holds the public key, or several separated by commas during a rotation: that is the variable the generator writes into .env and the one martis:sso:keys names. Spaces around a key and empty entries are dropped.

The block may be named anything and there may be several: driver => 'signed-token' selects the built-in provider (MartisSso::extend() registrations keep precedence). The name must be lowercase, because routes and config keys are matched in lowercase.

KeyMeaning
authorize_urlPanel A's issue route for this panel's audience. The redirect appends audience and state to it and sends nothing else.
public_keysList of base64 Ed25519 public keys. Every one is accepted, which is how a key is rotated. At least one; a value that is not 32 bytes throws naming the key.
issuer, audienceThe iss and aud the token must carry.
max_age_secondsA token issued longer ago than this (plus the skew) is refused, whatever its exp says (default 60).
clock_skew_secondsClock difference tolerated for exp and iat (default 30).
role_claimThe claim that holds the role names (default roles). The roles feed the usual role_strategy mapping.
environmentOptional second lock: only tokens whose env claim equals environment are accepted. With no environment, a token that carries the env claim at all is refused (reason environment, the text says to set environment): the issuer is locked and this verifier is not (v2.22.0). environment_claim was removed: setting it throws.

The columns panel B needs

The generated block does not work until panel B's database has what it reads and writes. Panel B owns these columns, no flag creates the first one:

  • users.<name>_external_id (here panel_a_external_id), a nullable string, ideally unique. The external_id strategy stores the id of the token's sub there on the first sign-in and finds the user by it after that. A column of another name goes in identity_external_id_column. Add it in a migration of your own:

    Schema::table('users', function (Blueprint $table) \{
    $table->string('panel_a_external_id')->nullable()->unique();
    });
  • roles.<name>_group_name (here panel_a_group_name), for the generated 'role_strategy' => 'column' (the default of --strategy): the role whose column equals a role name in the token's roles claim is the one the user gets. php artisan martis:sso panel-a --driver=signed-token --with-migration publishes a migration that adds the column to an existing roles table (Spatie's, or yours); it refuses when there is none. The config and callable strategies read no column.

The command prints both in its list of next steps. A missing column is a refused sign-in (reason unknown, the database error in the log line); a roles column that exists but holds no role name the token sends refuses with no_role_match.

Which adapter writes the roles

permission_adapter => 'auto' (what the generator writes, or spatie with --with-spatie) picks the Spatie adapter when spatie/laravel-permission is installed and the native adapter (direct writes to model_has_roles) otherwise. It does not look at MartisSso::syncRolesUsing(): registering that callback changes nothing until the block says 'permission_adapter' => 'callable'. An app with its own role tables sets callable and registers the callback, or the native adapter writes to a pivot it does not have.

The provider sets externalId = sub, so set identity_match_attribute = external_id (see Matching by external_id): the id at panel A is the stable identity, an email is not. A block with a missing or unusable key throws an exception that names it.

Checking the configuration: martis:sso:check (v2.23.0)

php artisan martis:sso:check

Validates the signed-token configuration the way the runtime reads it, so a bad value shows up in a deploy script instead of at the first click (the issue route answers a 500 for an unusable issuer block, and the callback for an unusable provider block). It checks:

  • the issuer block (martis.auth.sso.issuer), when issuer.enabled is on: the same reader the issue route uses, so a missing, malformed or inconsistent private_key, a bad audience URL or an out-of-range ttl_seconds fails;
  • each enabled signed-token provider block (martis.auth.sso.providers.{name}): public_keys, authorize_url, issuer, audience, the numeric limits and the removed environment_claim;
  • the cache store (martis.auth.sso.cache_store) while SSO is on: an unknown store name fails, and so does an array or null store outside the test suite (The cache store).

It prints one line per block, ok, or FAIL with the key and the reason, and skip for what is off. A line never holds a key, or any part of one: the readers' messages name the config key and the type of the value. It exits 1 when any block fails and 0 when all pass or nothing is enabled, and it writes nothing. Put it in the deploy script after config:cache (it reads config() as the application runs, so it checks the cached config):

php artisan config:cache && php artisan martis:sso:check

It is not run at boot, and no route changes behaviour because of it: a bad env file must not break config:cache, package:discover or a queue worker, and a broken issuer still answers a 500 on the route (never a silent 404), with the failure in the log and in the SsoIssueDenied event.

Tying the environment lock to APP_ENV. environment is checked as a pair: a locked verifier refuses another environment, and an unlocked verifier refuses a token that carries one. To keep a staging issuer from signing in a production panel, give both sides the same expression, so each follows its own APP_ENV: 'environment' => env('APP_ENV') in auth.sso.issuer on panel A and in the provider block on panel B. Martis does not compare it with app()->environment() itself (prod, live and staging-eu are all valid names), and the check command only validates that the value is a non-empty string.

Rotating the key

  1. Run php artisan martis:sso:keys for a second pair.
  2. On panel B add the new public key to public_keys, next to the old one. Deploy.
  3. On panel A switch MARTIS_SSO_ISSUER_PRIVATE_KEY to the new secret key. New tokens are signed with it and still verify.
  4. After ttl_seconds plus the skew (a couple of minutes) no token signed by the old key is alive: remove the old public key from panel B.

A token signed by a key that is not in public_keys is refused as bad_signature; the footer's kid only picks which configured key to try first, it never adds one.

What the verifier checks

In this order, each refusal with its own reason (they reach the log line and the SsoFailed event of the callback):

ReasonRefused because
malformedNot a v4.public token: wrong prefix, bad or non-canonical base64url, trailing data, too long, a footer or payload that is not a JSON object, a footer or payload object that repeats a key at any depth (v2.23.0: json_decode() keeps the last of two equal keys, so a repeat would read differently to another parser; compared after decoding, so "sub" and "\u0073ub" collide; Martis's own tokens never repeat one), a claim of the wrong type or a time that is not RFC 3339.
bad_signatureNo configured public key verifies the signature (checked before any claim is read).
missing_claimA required claim is missing or empty: iss, aud, sub, iat, exp, jti, state, email, email_verified (a boolean: any other type is malformed).
issueriss is not the configured issuer.
audienceaud is not the configured audience.
expiredexp has passed beyond the skew, or iat is older than max_age_seconds plus the skew.
not_yet_validiat is in the future beyond the skew.
environmentThe verifier has an environment and the token's claim differs or is absent; or the verifier has none and the token carries the environment claim (the issuer is locked, this side is not: set environment).
replayedThe jti was already used.
replay_storeThe cache failed while recording the jti: the token is refused, never let through.
stateThe state in the browser's session (taken out of the session when the state is checked, once, whatever the outcome of the check: a callback refused earlier, for a token problem or a used jti, leaves it where it is; and no older than 10 minutes since the redirect: v2.22.0, a fixed limit with no config key, so a sign-in slower than that starts over), the state of the callback URL and the state claim are not all the same.

The jti is recorded (Cache::add, kept until the token could not be accepted any more) before the state is checked (v2.22.0), so a token that is validly signed, in time and meant for this audience is used up by its first arrival, whatever the state of that request. A token refused earlier in the list (bad signature, another audience or issuer, expired, another environment) is not recorded. Comparisons of the issuer, audience, environment and state are constant time.

Security notes

  • The token is in a URL. It travels in the query string of the callback URL, so it can reach browser history and any access log of that path. The mitigations are built in: it lives 60 seconds by default, is accepted once, is bound to the browser session that started the flow through state, and the issue response says Referrer-Policy: no-referrer and Cache-Control: no-store. Operators should keep query strings out of the access logs of /{martis}/sso/{provider}/callback (nginx: log $uri instead of $request_uri for that location; most load balancers can drop the query from the logged path).
  • A configuration error is a 500, not a refusal. An unusable key of the provider block (environment_claim set, a bad public_keys entry, an empty issuer), an unusable martis.auth.sso.cache_store or a missing ext-sodium throws on the callback, naming the key, the same whether the callback's middleware ran or the provider is used alone; only a token or state the verifier refuses (the reasons above) is a refusal with the generic text on the login page and an SsoFailed event. php artisan martis:sso:check finds these in a deploy script (Checking the configuration).
  • The first arrival of a valid token spends it. A request that reaches the callback with a signature-valid, in-time token uses it up, whatever it is and whether or not a session came with it. A gateway or a link scanner that fetches the callback URL with a GET ahead of the browser (corporate URL rewriting, an email-security sandbox that follows redirects) breaks that sign-in with replayed. A HEAD request on the callback of a signed-token provider is answered 405 before anything is read or recorded, so a scanner that probes with HEAD first does not spend the token.
  • The cache must be shared. The one-time jti lives in Laravel's default cache store, or in the one martis.auth.sso.cache_store names (The cache store). With several servers use a shared store (Redis, database, Memcached); the array and file stores are per process or per host, and the null store refuses every sign-in as replayed. A cache that is down refuses sign-ins.
  • Panel B trusts what panel A signs about the person. Two things, and neither is a flaw to patch: they are what a single sign-on is.
    • The email. Panel A signs the address its user row holds, and panel B may adopt a local row by it (identity_match_attribute => 'email', or external_id with link_by_email => true). So that an attacker cannot register at panel A with an address they do not own and be signed in as its holder at panel B, the token carries email_verified, and panel B refuses to adopt an existing local row by an email that says false (reason email_unverified; creating a new user and matching by external_id are not affected). Panel A needs a user model that implements MustVerifyEmail for the claim to mean anything: without it the claim is always true. The default scaffold matches by external_id without link_by_email, which does not rely on the address at all.
    • The roles. With role_strategy => 'column' panel B maps the role names panel A signs onto its own roles. Whoever can assign roles at panel A, or write the claims class, controls which roles that user gets at panel B. Treat the right to assign roles at the issuing panel as a right at every panel that trusts it.
  • State fixation: a victim's browser can be made to fetch a token for the attacker's state. The issue route is a GET the browser follows with its cookies, and a top-level navigation is what the legitimate flow is. So an attacker can start the flow at panel B in their own browser (state S in their session), then get a signed-in user of panel A to open /{martis}/sso/issue/{audience}?state=S (a link, a script redirect). Panel A signs for that user with S and redirects to the audience's callback_url; there the victim's browser holds no state, so the callback is refused as state, but the token is now in a URL that an access log, a proxy or a browser extension may hold, and whoever reads it could present it in the attacker's browser, where S matches, and be signed in as the victim.
    • What the order now guarantees (v2.22.0). For a callback that reaches the panel, the verifier uses up the jti before it looks at the state, so the first arrival of a validly signed, in-time token for this audience burns it, whatever the state of that request. The victim's own failed callback is that first arrival; a replay from a log finds it used (replayed). The one-time record does not wait for the callback's rate limit either: the callback route's middleware (SpendSignedToken, which then applies the rate limit itself) runs the verification up to and including the jti record first, so a victim whose throttle bucket was filled first (the callback answers 429) has still spent the token. Before v2.22.0 the jti was recorded last, so a token refused for its state, or throttled, stayed good for its lifetime.

    • What the lured callback still does. It takes the victim's in-progress state at panel B out of the session (the state is pulled when it is checked), which breaks a sign-in the victim had under way, and, because the attacker started panel B's flow in the victim's browser first, it can have the victim signed in to panel B as themselves without a click (login CSRF). It cannot sign anyone in as someone else.

    • What is left. The replay window is closed for a callback that reaches the panel. It stays open when the victim's request never arrives: the tab is closed in the middle of the redirect, a content blocker or a proxy drops the request, or a party on the path blocks it and reads the URL. Then the token is live for its lifetime in whatever log recorded the URL, and the attacker's browser (which holds the state) can use it. Keep ttl_seconds short (the default is 60), and keep query strings out of the access logs and of the Location header logs for /{martis}/sso/issue/* and /{martis}/sso/{provider}/callback (nginx: log $uri instead of $request_uri for those locations; most load balancers can drop the query from the logged path).

    • Why the package cannot require Sec-Fetch-Site. The legitimate flow goes from panel B to panel A, two hosts, so the navigation to the issue route is itself cross-site whenever the panels are on different sites; refusing cross-site would refuse every sign-in. An app whose two panels share a site (two subdomains of one registrable domain) may refuse Sec-Fetch-Site: cross-site on the issue route in a middleware of its own. An optional recipe for such an app (a global middleware: the route is not resolved yet when it runs, so match the path, not the route name):

      // app/Http/Middleware/RefuseCrossSiteSsoIssue.php
      public function handle(Request $request, Closure $next): Response
      \{
      if ($request->is(trim((string) config('martis.path'), '/').'/sso/issue/*')
      && $request->headers->get('Sec-Fetch-Site') === 'cross-site') \{
      abort(403);
      }
       
      return $next($request);
      }

      It is advisory and partial. A browser that sends no Sec-Fetch-Site (an old one, or a plain HTTP origin) is let through, none (a typed URL, a bookmark, a link in a mail client) and same-site (a page on a sibling subdomain) are let through too, so it stops a lure from a third site only; the jti burn before the state check and the single use remain the defence. Do not use it when the panels are on different sites, and test it with a real sign-in first: the value also depends on the redirect chain (a person who is not signed in at panel A arrives from its login page as same-origin), and a proxy that strips Fetch Metadata changes it. Refer to the Referer header instead and real people are refused, since it follows the other site's referrer policy.

    • Why PKCE or a state that panel B signs would not help. The attacker starts the flow, so they hold the PKCE verifier and they can produce a valid signed state for their own session. Both proofs would be in the attacker's hand when the token is replayed. The one-time jti is what ends the window.

    • If an audience should not be reachable this way at all, give it an ability the user must hold deliberately, or do not issue to it.

  • Revocation does not cross panels. MartisSso::revokeFor() on panel A ends A's SSO sessions; it does not end the sessions panel B opened from A's tokens (the signed-token provider does not implement RevalidatesSessions), and on panel B it ends B's sessions but the middleware then sends the person to /{martis}/sso/{name}/redirect, which signs them straight back in for as long as their session at panel A (or at the IdP, for Azure) lives. Revoke at every layer: the IdP or the issuing panel first (revokeFor() on A, which also drops A's sessions), then revokeFor() on each consuming panel; set max_session_minutes on B so a token cannot keep a session going longer than you accept.
  • The private key is the trust root. Whoever holds it can sign in to panel B as anyone panel B links by external_id. Keep it in the environment of panel A only, give it its own value per environment, and rotate it as above. Panel B holds public keys only. Use the environment lock on both sides so a staging key (or token) cannot sign in to production: the issuer adds the claim, the verifier refuses what does not match, and since v2.22.0 a pair where only the issuer is locked stops signing in until the verifier sets environment too, instead of silently ignoring the lock.
  • Use HTTPS. authorize_url and callback_url must be https, except localhost, *.localhost, *.test, 127.0.0.1 and [::1] for local development.
  • Clocks. Keep both servers on NTP; clock_skew_seconds is the tolerance and max_age_seconds the window.
  • One flow per browser session. Starting a second flow in another tab replaces the state of the first, which then fails as state.
  • Provider blocks, including public_keys, are server-side config and are not published to the browser.

Tests

The codec is checked against the official 4-S-* (sign and verify, byte for byte) and 4-F-* (must fail) vectors of the PASETO test-vector repository, kept in tests/Fixtures/Paseto/v4-public-vectors.json. See the test coverage table for the specs of the rest.