Skip to content
faablecloudPublic

About

πŸ” An isomorphic JavaScript client for Faable Auth.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

131 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Faable

auth-js

An isomorphic JavaScript client for Faable Auth.

πŸ“š Full documentation at faable.com/docs

Features

  • 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

Install

npm install @faable/auth-js

Requires Node.js >=22.8 for development. The published bundle runs in any modern browser and in Node/SSR environments.

Quick start

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' })

Configuration

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.

Authentication flows

OAuth / social connection

// 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)

Username + password

await auth.signInWithUsernamePassword({
  username: 'user@example.com',
  password: 'β€’β€’β€’β€’β€’β€’β€’β€’',
  redirectTo: 'https://app.example.com/callback'
})

Passwordless (magic link or OTP)

// 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'
})

Password reset

await auth.changePassword({ email: 'user@example.com' })

Account: password, profile, linked accounts and sessions

With the signed-in user's session, no client_secret and no Management API:

// "Set a password" or "Change password"?
const { data } = await auth.getCredentials()
// data: { has_password: false, connections: [{ id, name, type: 'google_oauth2' }] }

// Change it (the current password is required)…
await auth.setPassword({ password: next, current_password: current })

// …or set the first one. That needs a verified email and a recent sign-in
// (with the second factor, if the user has one); otherwise `reauth_required`.
const { error } = await auth.setPassword({ password: next })
if (error?.code === 'reauth_required') {
  await auth.authorize({ queryParams: { max_age: '0' } })
}

// Name, company and country, and the app's own profile fields (in
// user_metadata["faable.com/fields"]; only keys declared in the client's or
// tenant's `profile_fields` are accepted). Keys left out are not touched.
await auth.updateProfile({
  name: 'Ada Lovelace',
  country_iso: 'GB',
  fields: [{ key: 'profile', value: 'teacher' }]
})

// Unlink a Google, GitHub or GitLab account (a connection id from
// getCredentials). Refused when it is the user's last way to sign in.
const { error: unlinkError } = await auth.unlinkIdentity(connection_id)
if (unlinkError?.code === 'last_sign_in_method') {
  // Ask them to set a password or link another account first.
}

// Where the user is signed in, and "sign out everywhere else".
const { data: sessions } = await auth.listSessions()
// sessions: { results: [{ id, user_agent, ip, last_seen_at, … }], current_session_id }
const { data: ended } = await auth.revokeOtherSessions() // { revoked: 2 }

// Delete the account. Permanent: confirm first. Clears the local session and
// emits SIGNED_OUT.
await auth.deleteAccount()

Setting or changing the password signs the user out of every other session and sends them a security email.

Sign out

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 nav

Scopes: 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' })
)

A token without a login screen

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.

Error handling

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, signupAndLogin, 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.

Prefer throw-style? Use unwrap

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 }))

Sessions and state changes

// 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.

Reading token claims

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: /me returns them as top-level properties, so session.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.

Roles and permissions

With your Api's token_dialect set to access_token_authz, the access token carries a permissions claim; with include_roles_in_access_token, a roles claim. Ask for the Api with audience and, when the Api has RBAC enabled, request the permissions you need as scopes: the token carries those the user's roles grant.

const auth = createClient({
  domain: 'https://my-tenant.auth.faable.link',
  clientId: '…',
  audience: 'https://api.example.com',
  scope: 'openid profile email invites:read invites:write'
})

if (await auth.hasPermission('invites:write')) showInviteButton()
const isStaff = await auth.hasRole('staff')

Both require all the values you pass. Like getClaims, they decode the token without verifying it: fine for showing UI, but your API must check the token itself (verifyAccessToken and requirePermissions in @faable/auth-sdk). The pure helpers permissionsOf, hasPermissions and hasRoles work on any decoded claims.

Storage adapters

Trade-offs

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 via BroadcastChannel, 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, and Domain. Note that this library writes cookies from JavaScript, so they cannot be marked HttpOnly; an XSS can still read them, but cookies make CSRF and same-site policies enforceable in a way localStorage does 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.

localStorage (default)

Used automatically in browsers. No configuration required.

Cookies

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' }
})

Custom adapter

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 })

Next.js / server-side

@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.handlers

Register 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).*)'] }

Permissions on the server are checked on the verified access token (JWKS, iss, aud, exp), so they can gate pages and actions. They need authorizationParams.audience set to your Api identifier (and, with RBAC on the Api, the permissions requested in authorizationParams.scope):

// Server Component / Server Action
if (!(await faableAuth.hasPermission('invites:write'))) notFound()

// middleware.ts β€” anonymous β†’ login, signed in without the permission β†’ 403
faableAuth.middleware(req, {
  permissions: req =>
    req.nextUrl.pathname.startsWith('/admin') ? ['admin:read'] : undefined
})

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.

Legacy: getSessionFromCookies (deprecated)

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.

Documentation

For the full guides, API reference, and dashboard setup walkthroughs visit faable.com/docs.

License

See LICENSE.md.

About

πŸ” An isomorphic JavaScript client for Faable Auth.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Contributors

Languages