SSO bridge (Filament / session apps)
The rw3iss-jwt guard covers
token-model surfaces (mobile APIs, SPAs): the client holds a bearer
token, the backend validates it locally. Filament and classic Blade
apps don’t work that way — they’re session-based, with their own
Eloquent user the framework expects to exist. Making Filament
stateless fights the framework.
The bridge resolves that tension: the app federates authentication to the auth-server but keeps its native session + Eloquent user. The auth-server owns credentials, social login, 2FA, lockouts, rate limits, and the core user record (in the app’s configured user pools); the bridge hydrates a local shadow user keyed to the core user id and logs it into an ordinary Laravel session guard. One auth-server, one core user, two patterns by surface:
Mobile API / SPA → pure tokens (rw3iss-jwt guard, no bridge) Filament / Blade web → SSO bridge (session + shadow user)How a login flows
Password (the app’s own login form stays):
form POST → SessionBridge::loginWithPassword(email, pw) → auth-server POST /auth/login (app_code attached) → tokens returned; access token validated LOCALLY (HS256) → ResolvesShadowUser maps the UserPrincipal → local Eloquent user (link by ven_user_id → adopt by email → provision) → session regenerated, Auth::guard(...)->login(localUser)Social SSO — providers live centrally on the auth-server; Laravel stops
owning Socialite. Implemented + registered today: Google, Apple, Facebook,
LinkedIn (and generic OAuth2 custom). Microsoft / GitHub are config-recognized
but not yet implemented. A provider only works once it’s enabled on the
auth-server (SSO_<PROVIDER>_ENABLED=true + credentials) and listed in the
app’s allowed_auth_methods (when that policy is set). Apple verifies the
id_token against Apple’s JWKS and signs its client secret from the .p8 key;
Facebook tolerates a missing email (phone-registered accounts); LinkedIn is OIDC.
GET /auth/rw3iss/{google|apple|facebook|linkedin}/redirect ──► auth-server /auth/sso/url (PKCE challenge) ◄── 302 to providerprovider ──► GET /auth/rw3iss/callback?code&state ──► /auth/sso/callback → {auth_code} → /auth/sso/exchange (verifier) ──► same shadow-user tail as password loginPKCE state + the auth-server token pair live in the Laravel
session (Bridge\LaravelSessionStore), so they survive the redirect
round-trip and the app can keep calling the auth-server on the user’s
behalf afterwards (AuthClient::authenticatedRequest()).
Logout tears down both halves — the Laravel session and the auth-server refresh chain (best-effort; a dead auth-server never traps the user in a session).
Setup
1. Register the app on the auth-server (once, system_admin)
POST /admin/apps{ "code": "claimleo", "allowed_redirect_urls": ["https://claimleo.com/auth/rw3iss/callback"], "allowed_auth_methods": ["password", "google", "apple", "facebook", "linkedin"], "auto_grant_on_signup": true, // user pools — new users land in the `default` pool and get tagged // `claimleo` + `wristleo`; existing claimleo/wristleo users sign straight in "registration_namespace": "default", "read_namespaces": ["claimleo", "wristleo"],
// Auto-provisioning (optional). On first federated login the auth-server // grants the app + linked-app memberships and, when a default org is set, // an org membership with `default_role_code` (must be an org-scoped role). // e.g. a marketplace-seller app: every logged-in user becomes a `seller` // in rw3iss-marketplace and also gains that app. "default_organization_id": "<rw3iss-marketplace org id>", "default_role_code": "seller", "linked_app_codes": ["rw3iss-marketplace"]}
default_role_codeis validated at config time — it must be an existing org-scoped role and requires adefault_organization_id, otherwise the create/patch is rejected. A login/register request may override it per call withrole_code/linked_app_codes(the role is re-validated server-side, so a client can never escalate to a platform role). See App registration → auto-provisioning.
2. Enable the bridge
AUTH_SERVER_URL=https://auth.ryanweiss.netAUTH_APP_CODE=claimleoJWT_ACCESS_SECRET=<shared HS256 secret>
AUTH_BRIDGE_ENABLED=trueAUTH_BRIDGE_GUARD=web # session guard to log intoAUTH_BRIDGE_USER_MODEL=App\Models\UserAUTH_BRIDGE_REDIRECT=/admin # post-login landing3. Add the link column
Schema::table('users', function (Blueprint $table) { $table->string('ven_user_id')->nullable()->unique()->after('id');});That’s the entire schema footprint. Existing local users are
adopted on first federated login (matched by email, link stamped)
— no batch import required, though one can pre-link via the
auth-server’s POST /admin/users/lookup.
4. Routes (mounted automatically when enabled)
GET /auth/rw3iss/{provider}/redirect start social login (?next=/path)GET /auth/rw3iss/callback provider returns herePOST /auth/rw3iss/logout dual logoutPrefix configurable via vauth.bridge.route_prefix.
Filament integration
Filament’s login page is a Livewire component — override its
authenticate() to call the bridge instead of the local DB. The
session, panel guards, and everything downstream stay stock:
class VenLogin extends \Filament\Pages\Auth\Login{ public function authenticate(): ?LoginResponse { $data = $this->form->getState(); try { $user = app(SessionBridge::class)->loginWithPassword( email: $data['email'], password: $data['password'], remember: $data['remember'] ?? false, ); } catch (InvalidCredentialsException) { $this->throwFailureValidationException(); } if ($user === null) { // requires_2fa — surface a TOTP field and re-submit with // $twoFactorCode. (Or keep 2FA disabled per-app via the // auth-server's allowed_auth_methods.) $this->throwFailureValidationException(); } session()->regenerate(); return app(LoginResponse::class); }}
// panel provider->login(VenLogin::class)Social buttons are plain anchors on that page — render one per provider you’ve enabled on the auth-server:
@foreach (['google' => 'Google', 'apple' => 'Apple', 'facebook' => 'Facebook', 'linkedin' => 'LinkedIn'] as $id => $label) <a href="{{ route('vauth.bridge.redirect', ['provider' => $id, 'next' => '/admin']) }}"> Continue with {{ $label }} </a>@endforeachOnly render providers the auth-server actually has enabled. You can read the
live list at GET /auth/sso/providers (or just hard-code the ones you turned on).
Multiple user types
One bridge instance targets one guard + one model. The default
app(SessionBridge::class) is a single instance. For an app with
several session user types (e.g. staff User + customer Homeowner),
declare each under vauth.bridge.instances and pull them from
SessionBridgeFactory (v0.7.0+) instead of hand-constructing a
bridge. Each instance inherits any key it omits from the base
vauth.bridge config, and its guard defaults to the instance name:
'bridge' => [ 'enabled' => true, 'guard' => 'web', // base / default instance 'user_model' => App\Models\User::class, 'instances' => [ 'web' => [], // inherits everything above 'customer' => [ 'guard' => 'customer', 'user_model' => App\Models\Homeowner::class, 'resolver' => App\Auth\HomeownerResolver::class, // optional 'redirect_after_login' => '/home', ], ],],$bridge = app(SessionBridgeFactory::class)->for('customer');$user = $bridge->loginWithPassword($email, $password);// ->names() lists declared instances; ->has('customer') checks one.Bridges are cached per name; the single-bridge
app(SessionBridge::class) path is unchanged — the factory is purely
additive. You can still hand-construct a bridge when you’d rather not
put it in config:
$homeBridge = new SessionBridge( client: app(AuthClient::class), resolver: new EmailShadowUserResolver(model: Homeowner::class), auth: app('auth'), session: session()->driver(), options: ['guard' => 'customer', 'redirect_after_login' => '/home'],);(Or implement ResolvesShadowUser once and route on
$principal->roles — e.g. staff roles → User, everyone else →
Homeowner.)
Custom shadow-user mapping
The default EmailShadowUserResolver is link → adopt → provision.
When the app needs role mapping or denial rules, bind your own:
// config/vauth.php → 'bridge' => ['resolver' => App\Auth\MyResolver::class]final class MyResolver implements ResolvesShadowUser{ public function resolve(UserPrincipal $p, array $user): Authenticatable { $local = User::firstWhere('ven_user_id', $p->id) ?? User::whereRaw('LOWER(email) = ?', [strtolower($p->email)])->first(); if (!$local) { $local = new User([ 'email' => $p->email, 'name' => trim($p->firstName . ' ' . $p->lastName), // central roles → the app's local role enum 'role' => $p->hasRole('org_admin') ? 'admin' : 'member', ]); $local->password = Hash::make(Str::random(40)); } $local->ven_user_id = $p->id; $local->save(); return $local; }}Throw ShadowUserDenied to refuse entry (e.g. staff apps where
provisioning is admin-only — set bridge.create_missing=false to get
this behavior from the default resolver).
Configuration reference
All under vauth.bridge (publish with --tag=vauth-config):
| Key | Env | Default | Notes |
|---|---|---|---|
enabled | AUTH_BRIDGE_ENABLED | false | Master switch; mounts the routes. |
guard | AUTH_BRIDGE_GUARD | null | Session guard to log into (null = default guard). |
user_model | AUTH_BRIDGE_USER_MODEL | App\Models\User | Shadow-user Eloquent model. |
resolver | — | null | Class-string overriding EmailShadowUserResolver. |
id_column | AUTH_BRIDGE_ID_COLUMN | ven_user_id | Core-user link column. |
email_column / name_column | — | email / name | name_column: null = don’t touch. |
create_missing | AUTH_BRIDGE_CREATE_MISSING | true | false = adopt/link only; unknown identities are denied. |
set_random_password | — | true | Satisfy NOT NULL password schemas with an unusable hash. |
with_trashed | AUTH_BRIDGE_WITH_TRASHED | false | Include soft-deleted rows in link/adopt lookups (models using SoftDeletes) so a returning soft-deleted user is recognized instead of hitting a UNIQUE(email) collision. |
on_trashed | AUTH_BRIDGE_ON_TRASHED | deny | When a matched row is soft-deleted: deny (refuse — ShadowUserDenied with reason deactivated), restore (un-delete + sign in), or adopt (sign in as-is). |
route_prefix | AUTH_BRIDGE_ROUTE_PREFIX | auth/rw3iss | |
redirect_after_login | AUTH_BRIDGE_REDIRECT | / | |
redirect_on_failure | AUTH_BRIDGE_REDIRECT_FAILURE | /login | Failures flash vauth_error. |
remember | — | true | Allow remember-me on the session guard. |
instances | — | [] | v0.7.0+. Named multi-guard bridges, keyed by name. Each value is a partial vauth.bridge config (inherits omitted keys; guard defaults to the name). Resolve via app(SessionBridgeFactory::class)->for('<name>'). Empty = single-bridge mode. |
Since v0.6.0, the default EmailShadowUserResolver handles soft-deletes
itself via with_trashed / on_trashed — hosts using SoftDeletes no longer
need a custom resolver just for that. ShadowUserDenied now carries a
machine-readable reason() (no_local_account / deactivated) so you can
localize or route denials.
Getting the access token (call sibling rw3iss APIs)
The bridge stores the logged-in user’s auth-server access token in the session.
To forward it to another rw3iss service (e.g. rw3iss/auction-sdk-php),
read it via the facade — no extra network hop:
$token = \rw3iss\AuthServer\Laravel\Facades\VenAuth::accessToken(); // ?string(Backed by AuthClient::accessToken() → the SessionStore. Null in pure
bearer-token mode, where the token is on the request, not the session.)
Surfacing failures
On a federated-login failure the user gets a generic message, but the bridge
now logs the real auth-server error server-side (including the serverCode,
e.g. LEGACY_MIGRATION_CONFLICT) so operators can debug. ShadowUserDenied
messages are surfaced to the user as the flash error.
Service-to-service is NOT this
Machine credentials (one backend calling another) don’t cross this
bridge — that’s the OAuth2 client-credentials grant
(Flows::clientCredentialsGrant() against /oauth/token, minted via
/admin/m2m-clients). The bridge is strictly end-user identity
federation.
Security notes
- The access token is validated locally (shared HS256 secret) —
no network hop in
establish(). - Session id is regenerated on every federated login (fixation).
- SSO is PKCE end-to-end; the verifier never leaves the Laravel session, state payloads expire after 10 minutes and are consumed atomically.
- Provisioned shadow users get an unusable random password — local password login is dead for federated users by design. Keep the app’s local “forgot password” off (the auth-server owns resets).
?next=redirects are restricted to relative paths (open-redirect defense); the auth-server separately allowlists the callback URL.