@nominalso/vibe-auth
v0.4.0
Published
Silent Supabase OIDC login for Nominal Vibe Apps — a configurable, cross-document-safe auth gate (Web-Lock-serialised silent SSO, identity-switch rebinding, popup fallback) that replaces a hand-copied per-app implementation.
Readme
@nominalso/vibe-auth
Silent Supabase OIDC login for Nominal Vibe Apps — a configurable, cross-document-safe
auth gate that replaces a hand-copied per-app implementation of the
silent-supabase-oidc-login
skill. If your app authenticates through Supabase federated with the Nominal host's IdP
(Descope), this package IS that flow — written once, fixed once, and configurable per app.
For AI agents / Lovable: see
AGENTS.mdfor the condensed integration guide (including the SSR root-wiring recipe). To replace a hand-writtensilentAuth.ts/AuthGate.tsxpair, follow thesilent-supabase-oidc-loginskill Step 1b.
Why this exists
Six generated apps each carried a hand-copied version of this login flow. Five rounds of bug fixes had to be re-applied per app, and the copies drifted: one app grew a React hydration crash in its root route, three ended up mounting bridge providers inside the auth callback document, and all of them still carried a production bug where a stale session's failed boot-time refresh briefly flashed the sign-in screen. This package is that logic, fixed and written once.
Install
npm install @nominalso/vibe-authPeer dependencies: react >=18, @supabase/supabase-js ^2.
Quickstart
// src/lib/auth.ts — called synchronously, at module scope, imported eagerly.
import { createVibeAuth } from '@nominalso/vibe-auth'
import { supabase } from './supabaseClient' // YOUR existing client — this package never creates one
export const auth = createVibeAuth({
supabase,
provider: 'custom:supabase-fedapp', // from your Supabase Auth → Providers config
})// the callback route — mount ONLY this, ungated, at the callback path
// (default '/silent-callback'; see AGENTS.md for the SSR-safe recipe)
import { auth } from '@/lib/auth'
export default auth.SilentCallback// wrap the whole app — it never renders until authenticated
import { auth } from '@/lib/auth'
function Root() {
return (
<auth.AuthGate>
<App />
</auth.AuthGate>
)
}// Start the Nominal host handshake at module scope, before React mounts.
import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
import { auth } from '@/lib/auth'
export const bridge = new VibeAppBridge()
export const { hostContext, hostOutcome, getHostContext, subscribeHostContext } =
auth.wireVibeApp<ContextPayload>(bridge)One call does the whole handshake, in the one correct order: context subscriber
and host auth registered before connect(), the host principal seeded before
context is published, the handshake skipped in the callback document, and null
rather than a rejection when there is no host. Pass onContextChange,
onDataReset and onSubrouteRequest as options in the same call.
Telling "not embedded" from "the host broke". hostContext resolves null
for both, which is why apps either showed an error banner on every standalone
dev load or showed none at all. hostOutcome carries the distinction:
const { hostContext, hostOutcome } = auth.wireVibeApp<ContextPayload>(bridge)
const outcome = await hostOutcome // never rejects, settles with hostContext
// { kind: 'connected' }
// { kind: 'standalone' } — nothing was ever going to answer; expected
// { kind: 'failed', error } — embedded, and the host stayed silentSee AGENTS.md for SSR root wiring and the full ordering rules.
Timeouts live on VibeAuthTimeouts in the package .d.ts.
Why a client you own, not one this package creates
Your client.ts is often Lovable-generated (marked "do not edit"), carries your generated
Database types, and may have its own storage adapter (e.g. for Lovable preview brokering).
This package only requires it: at createVibeAuth(), it best-effort-checks
auth.flowType === 'pkce' and logs a loud console.error if it isn't — PKCE is a security
control, not a preference (the .d.ts on VibeAuthConfig explains why).
What you get
ensureSession()/rebindSession({ userId, tenant })— the cross-document-safe core (Web Lock serialised, terminal-capped, sibling-adopting). Most apps never call these directly; the gate andwireVibeAppdo.AuthGate/DefaultSignInScreen/SilentCallback— the three React pieces. OverridesignInScreen/loaderprops onAuthGatefor branding.wireVibeApp(bridge, options?)— the Nominal-host integration in one call (handshake, host context store, logout and identity/tenant switch). Call it at module scope from a parent ofAuthGate. The pre-0.2.3 hand-wiring (wireHostAuth/seedLastUserId) was removed in 0.4.0; an app still calling it fails to typecheck until it moves towireVibeApp.- Fully configurable timeouts (
VibeAuthTimeoutsin the.d.ts) — every value defaults to what shipped after this flow's production incidents.
Security boundary
signOut() calls made by this package (on host logout, on a failed identity-switch rebind)
are client-side, best-effort UX: they stop rendering the app and drop the local session,
but a persisted Supabase access token stays technically valid until it expires, and only this
browser is affected. Enforced logout must happen server-side — OIDC back-channel logout
revoking the Supabase refresh tokens (Supabase Admin API, per project). That back-channel
receiver is infrastructure outside this package.
