An isomorphic JavaScript client for Faable Auth.
π Full documentation at faable.com/docs
- OAuth social connections (Google, GitHub, β¦) with PKCE and implicit flows
- Username + password login
- Passwordless: email magic link and OTP code
- Automatic token refresh with cross-tab synchronization via
BroadcastChannel - Pluggable storage adapters (
localStorage, cookies, or custom) - Server-side session helpers for Next.js
npm install @faable/auth-jsRequires Node.js >=22.8 for development. The published bundle runs in any
modern browser and in Node/SSR environments.
import { createClient } from '@faable/auth-js'
export const auth = createClient({
domain: '<faableauth_domain>',
clientId: '<client_id>',
redirectUri: window.location.origin
})
// Trigger a social login
await auth.signInWithOauthConnection({ connection: 'google' })createClient(config) accepts:
| Option | Type | Description |
|---|---|---|
domain |
string |
Required. Your Faable Auth tenant domain. The protocol is optional β tenant.auth.faable.link and https://tenant.auth.faable.link are equivalent. |
clientId |
string |
Required. Application client ID. |
redirectUri |
string |
Default callback URL. Falls back to window.location.origin. |
scope |
string |
Space-separated scopes. Defaults to openid profile email. |
storage |
SupportedStorage |
Custom storage adapter. Defaults to localStorage. |
storageKey |
string |
Prefix for the storage key. Final key is ${storageKey}-${clientId}. |
cookieOptions |
CookieOptions |
When set, switches storage to the cookie adapter. |
lock |
LockFunc |
Custom locking primitive for concurrent refreshes. |
debug |
boolean |
Enables verbose logging. |
// Use the default connection configured on the tenant
await auth.signInWithOauthConnection({})
// Or pick a specific provider (by name or connection_id)
await auth.signInWithOauthConnection({
connection_id: 'conn_01HXβ¦', // preferred when known; falls back to `connection` for legacy tenants
redirectTo: 'https://app.example.com/callback',
scopes: 'openid profile email',
queryParams: { prompt: 'select_account' }
})In browsers the SDK uses the PKCE flow by default and exchanges the code for a
session on the callback page. The first call to createClient automatically
processes the URL when the user lands back on the redirect target.
On the redirect success path the returned promise does not resolve β the
browser is already navigating away, so a loading state you bind to the await
stays on until the page unloads instead of flashing back to idle. Do not
re-enable UI after the await on this path.
To control the navigation yourself (e.g. custom timing, or a non-redirecting
runtime), pass skipBrowserRedirect: true. The call then resolves with the
authorization URL and leaves the navigation to you:
const { data, error } = await auth.signInWithOauthConnection({
connection: 'google',
skipBrowserRedirect: true
})
if (error) throw error
window.location.assign(data.url)await auth.signInWithUsernamePassword({
username: 'user@example.com',
password: 'β’β’β’β’β’β’β’β’',
redirectTo: 'https://app.example.com/callback'
})// Step 1 β request a code or link
await auth.signInWithPasswordless({
email: 'user@example.com',
type: 'code' // or "link"
})
// Step 2 β complete the login with the OTP the user received
const { data, error } = await auth.signInWithOtp({
username: 'user@example.com',
otp: '123456'
})await auth.changePassword({ email: 'user@example.com' })By default signOut() (global scope, in a browser) navigates the page to the
auth server's /logout to clear the SSO cookie, then returns to returnTo if
you pass one. This matters: the SSO cookie lives on the auth domain, so a
cross-origin fetch from your app can neither send nor clear it. Without the
navigation the SSO session survives and the next signInWith⦠silently re-logs
the previous user, ignoring the requested connection.
await auth.signOut() // clears local + redirects to /logout to clear the SSO cookie
await auth.signOut({ returnTo: 'https://app.example.com/bye' }) // + landing page
await auth.signOut({ scope: 'local' }) // only this device's storage, no redirect
await auth.signOut({ scope: 'others' }) // every OTHER device; this one stays signed in
await auth.signOut({ redirect: false }) // legacy: local + best-effort fetch, no navScopes: global (default) ends this browser's session at the auth server β
every refresh token issued in it is refused from then on β and, in a browser,
navigates to /logout. local only clears storage; the server is not told.
others calls POST /me/sessions/revoke-others: every other session of the
user ends, the local one stays, and no SIGNED_OUT fires.
returnTo maps to the OIDC post_logout_redirect_uri and must be registered
as a logout URL on the client, or the server responds 400.
On the redirect path the returned promise does not resolve (the browser is
navigating away) β do not re-enable UI after the await. To drive the
navigation yourself, build the URL with getLogoutUrl:
window.location.assign(
auth.getLogoutUrl({ returnTo: 'https://app.example.com' })
)getTokenSilently() returns a live access token or the reason there cannot be
one. It refreshes first (refresh_token grant, no navigation); when the refresh
is refused β no session, or the session was revoked by a sign-out elsewhere, an
admin, or a back-channel logout β it sends the browser through
/authorize?prompt=none as a top-level navigation (no iframe: third-party
cookie blocking makes one unreliable). The auth server signs the user back in
from its SSO cookie without a screen and lands on your redirectUri, with the
user's location in returnTo. On that path the promise never resolves.
const { data, error } = await auth.getTokenSilently()
if (error) {
// `login_required`: the SSO session is gone too β show a login.
if (error.code === 'login_required') auth.authorize({ response_type: 'code' })
return
}
fetch('/api/me', { headers: { Authorization: `Bearer ${data.access_token}` } })Pass { redirect: false } to get AuthLoginRequiredError instead of the
navigation. A page load that is itself the return of a refused silent attempt
(?error=login_required) never navigates again.
The client has one error contract, applied uniformly: every asynchronous
method resolves with { data, error } and never throws for an expected
failure (bad credentials, wrong OTP, missing session, network errorβ¦). On
success error is null; on failure data is null and error is an
AuthError. Always check error before reading data:
const { data, error } = await auth.signInWithOtp({ username, otp })
if (error) {
showError(error.message)
return
}
useSession(data.session)The only thing that throws is createClient itself, and only for a
misconfiguration (missing domain / clientId) β a programming error you fix
once, not a runtime condition to catch.
This applies to signInWithOauthConnection, signInWithUsernamePassword,
signUp, signInWithOtp, signInWithPasswordless, changePassword,
changeEmail, signOut, getSession, getClaims, setSession,
refreshSession, initialize and handleRedirectCallback. Their return types
(AuthResult<T>, AuthResponse, OAuthResponse) are all variants of the same
shape.
If your code path would rather let errors propagate (a server handler, a
try/catch, a wrapper that normalizes everything to throws), wrap the call in
unwrap instead of hand-writing if (error) throw error:
import { unwrap } from '@faable/auth-js'
// returns data on success, throws the AuthError on failure
const { session } = unwrap(await auth.signInWithOtp({ username, otp }))// Get the current session (refreshes if needed)
const {
data: { session }
} = await auth.getSession()
// Subscribe to auth events
const {
data: { subscription }
} = auth.onAuthStateChange((event, session) => {
// event: INITIAL_SESSION | SIGNED_IN | SIGNED_OUT | TOKEN_REFRESHED | PASSWORD_RECOVERY | USER_UPDATED
})
// Stop listening
subscription.unsubscribe()
// Force a refresh
await auth.refreshSession()Auth events are broadcast across tabs using BroadcastChannel, so a sign-in or
sign-out in one tab is reflected in every other tab using the same storageKey.
Custom claims your tenant puts on the access token β a connection's
claims_mapping or an Action's api.accessToken.setCustomClaim β are available
two ways:
- On the user:
/mereturns them as top-level properties, sosession.user['ciapol.com/station_id']just works. - Decoded from the token, without a request:
// Typed by namespace; refreshes an expired session first, like getSession()
const { data } = await auth.getClaims<{ 'ciapol.com/station_id': string }>()
data.claims?.['ciapol.com/station_id'] // 'station_123456789'
data.claims?.scope // standard claims are typed too
// One claim, or null when signed out / absent
const station = await auth.getClaim<string>('ciapol.com/station_id')The token is decoded, not signature-verified: use claims for UI and routing decisions, never as authorization β that is the resource server's job. Custom claims are frozen at login and survive refreshes; they change on the next sign-in.
Refresh tokens are sensitive: anyone who reads them can impersonate the user until the token is revoked. The storage you pick decides where they live:
localStorage(default) β simple and supports cross-tab sync viaBroadcastChannel, but any script running on the same origin can read it. A single XSS lets an attacker exfiltrate the refresh token. Acceptable for low-risk apps and prototypes; not recommended when the surface has third-party scripts, user-generated HTML, or strict compliance requirements.- Cookies β required for SSR (server reads them on every request) and the
only adapter that lets you scope storage with
Secure,SameSite, andDomain. Note that this library writes cookies from JavaScript, so they cannot be markedHttpOnly; an XSS can still read them, but cookies make CSRF and same-site policies enforceable in a waylocalStoragedoes not. - Custom adapter β use for in-memory storage (tokens lost on reload, safest against XSS), Web Workers, or platform-specific keychains.
If your app is exposed to untrusted content, prefer cookies with Secure: true
and SameSite: "Lax" (or "Strict"), and treat XSS prevention (CSP, escaping,
framework guarantees) as a hard requirement regardless of which adapter you
pick.
Used automatically in browsers. No configuration required.
Useful for SSR setups where the server must read the session from the request.
import { createClient } from '@faable/auth-js'
export const auth = createClient({
domain: '<faableauth_domain>',
clientId: '<client_id>',
storage: 'cookie'
})That's it. The adapter sets sensible defaults: Path=/, SameSite=Lax, auto
Secure on HTTPS, and a 30-day Max-Age so users stay signed in across browser
restarts.
Use cookieOptions only when you need to override something β e.g. share the
session across subdomains:
createClient({
domain: '<faableauth_domain>',
clientId: '<client_id>',
storage: 'cookie',
cookieOptions: { domain: '.example.com' }
})Provide any object that implements getItem, setItem, and removeItem (sync
or async). Set isServer: true if values may come from an untrusted source such
as request cookies.
const memoryStorage = {
store: new Map<string, string>(),
getItem: (k: string) => memoryStorage.store.get(k) ?? null,
setItem: (k: string, v: string) => void memoryStorage.store.set(k, v),
removeItem: (k: string) => void memoryStorage.store.delete(k)
}
createClient({ domain, clientId, storage: memoryStorage })@faable/auth-js/nextjs runs the login on the server: App Router handlers
for login, callback, logout and backchannel-logout, an HttpOnly
encrypted session cookie the browser cannot read, id_token and logout_token
verified against the tenant JWKS (jose), and a getAccessToken() that
refreshes on the server. Requires Node 20+ or the Edge runtime, and next 13+.
// lib/faable-auth.ts
import { createFaableAuth } from '@faable/auth-js/nextjs'
export const faableAuth = createFaableAuth({
domain: process.env.FAABLE_AUTH_DOMAIN!, // your-tenant.auth.faable.link
clientId: process.env.FAABLE_AUTH_CLIENT_ID!,
clientSecret: process.env.FAABLE_AUTH_CLIENT_SECRET, // confidential clients only
secret: process.env.FAABLE_AUTH_SECRET!, // β₯ 32 chars; encrypts the cookie
baseUrl: process.env.FAABLE_AUTH_BASE_URL! // https://app.example.com
})
// Every field also reads the FAABLE_AUTH_* environment variable of the same name.// app/auth/[...faable]/route.ts
import { faableAuth } from '@/lib/faable-auth'
export const { GET, POST } = faableAuth.handlersRegister https://app.example.com/auth/callback as an allowed callback URL,
https://app.example.com/ (or your returnTo page) as a logout URL, and
https://app.example.com/auth/backchannel-logout as the client's
backchannel_logout_uri. Then link to /auth/login?returnTo=/dashboard and
/auth/logout.
// app/dashboard/page.tsx β Server Component
import { redirect } from 'next/navigation'
import { faableAuth } from '@/lib/faable-auth'
export default async function Dashboard() {
const session = await faableAuth.getSession()
if (!session) redirect('/auth/login?returnTo=/dashboard')
return <h1>Hello {session.user.email}</h1>
}// app/api/orders/route.ts β call an API on the user's behalf
export async function GET() {
const token = await faableAuth.getAccessToken() // refreshes when needed
if (!token) return new Response(null, { status: 401 })
return fetch('https://api.example.com/orders', {
headers: { Authorization: `Bearer ${token.accessToken}` }
})
}// middleware.ts β keep the session fresh, gate a section
import type { NextRequest } from 'next/server'
import { faableAuth } from '@/lib/faable-auth'
export const middleware = (req: NextRequest) =>
faableAuth.middleware(req, {
protect: req => req.nextUrl.pathname.startsWith('/dashboard')
})
export const config = { matcher: ['/((?!_next|favicon.ico).*)'] }When the user signs out of another app in the same SSO session, the auth server
POSTs a logout_token to /auth/backchannel-logout; the helper verifies it
and, from then on, getSession() returns null for that session even though
the browser still sends the cookie. The record lives in a SessionStore β in
memory by default (one process); pass your own over Redis when the app runs on
several instances.
The browser SDK with storage: 'cookie' writes a cookie the server can parse
with getSessionFromCookies. It is not HttpOnly and not verified β any
script on the page, or a forged request, can put an arbitrary session in it β so
use it for convenience only, never as authorization. New apps use the
@faable/auth-js/nextjs entry above.
For the full guides, API reference, and dashboard setup walkthroughs visit faable.com/docs.
See LICENSE.md.