Skip to content
rw3iss Auth

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 provider
provider ──► GET /auth/rw3iss/callback?code&state
──► /auth/sso/callback → {auth_code} → /auth/sso/exchange (verifier)
──► same shadow-user tail as password login

PKCE 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_code is validated at config time — it must be an existing org-scoped role and requires a default_organization_id, otherwise the create/patch is rejected. A login/register request may override it per call with role_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.net
AUTH_APP_CODE=claimleo
JWT_ACCESS_SECRET=<shared HS256 secret>
AUTH_BRIDGE_ENABLED=true
AUTH_BRIDGE_GUARD=web # session guard to log into
AUTH_BRIDGE_USER_MODEL=App\Models\User
AUTH_BRIDGE_REDIRECT=/admin # post-login landing
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 here
POST /auth/rw3iss/logout dual logout

Prefix 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:

app/Filament/Pages/Auth/VenLogin.php
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>
@endforeach

Only 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:

config/vauth.php
'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):

KeyEnvDefaultNotes
enabledAUTH_BRIDGE_ENABLEDfalseMaster switch; mounts the routes.
guardAUTH_BRIDGE_GUARDnullSession guard to log into (null = default guard).
user_modelAUTH_BRIDGE_USER_MODELApp\Models\UserShadow-user Eloquent model.
resolvernullClass-string overriding EmailShadowUserResolver.
id_columnAUTH_BRIDGE_ID_COLUMNven_user_idCore-user link column.
email_column / name_columnemail / namename_column: null = don’t touch.
create_missingAUTH_BRIDGE_CREATE_MISSINGtruefalse = adopt/link only; unknown identities are denied.
set_random_passwordtrueSatisfy NOT NULL password schemas with an unusable hash.
with_trashedAUTH_BRIDGE_WITH_TRASHEDfalseInclude 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_trashedAUTH_BRIDGE_ON_TRASHEDdenyWhen a matched row is soft-deleted: deny (refuse — ShadowUserDenied with reason deactivated), restore (un-delete + sign in), or adopt (sign in as-is).
route_prefixAUTH_BRIDGE_ROUTE_PREFIXauth/rw3iss
redirect_after_loginAUTH_BRIDGE_REDIRECT/
redirect_on_failureAUTH_BRIDGE_REDIRECT_FAILURE/loginFailures flash vauth_error.
remembertrueAllow 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.