<p align="center">
  <a href="https://faable.com">
    <img src="https://www.faable.com/assets/logo/Emblem.png" height="96">
    <h3 align="center">Faable</h3>
  </a>
</p>

<p align="center">
  <a href="https://faable.com">
    <h1 align="center">auth-js</h1>
  </a>
  <p align="center">An isomorphic JavaScript client for Faable Auth.</p>
</p>

<p align="center">
  <a aria-label="NPM version" href="https://www.npmjs.com/package/@faable/auth-js">
    <img alt="" src="https://img.shields.io/npm/v/@faable/auth-js.svg?style=for-the-badge&labelColor=000000">
  </a>
</p>

<p align="center">
  📚 Full documentation at <a href="https://faable.com/docs">faable.com/docs</a>
</p>

## 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

```bash
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

```ts
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

```ts
// The tenant's hosted login (signs in silently with a live SSO session)
await auth.signIn()

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

```ts
const { data, error } = await auth.signInWithOauthConnection({
  connection: 'google',
  skipBrowserRedirect: true
})
if (error) throw error
window.location.assign(data.url)
```

#### When the round trip comes back with an error

A denied sign-in or link (an action deny, an identity already linked to another
user…) comes back to your redirect target as `?error=…`, and the promise of the
call that left died with the navigation. Read it once per page load with
`getRedirectError()`. It carries what the browser remembers of the round trip
that failed: the connection, whether it was a `linkOauthConnection`, and the
`appState` it was started with.

```ts
await auth.linkOauthConnection({
  connection_id: GOOGLE,
  appState: { provider: 'google' }
})

// Back on the page:
const err = await auth.getRedirectError()
if (err?.error_code === 'identity_already_linked') {
  toast(`That ${err.appState?.provider} account belongs to another user`)
}
```

It is one error per page for every client with the same `storageKey`, and only
one caller gets it: a second call — another client instance, or React StrictMode
running an effect twice — gets `null`.

### Username + password

```ts
await auth.signInWithUsernamePassword({
  username: 'user@example.com',
  password: '••••••••',
  redirectTo: 'https://app.example.com/callback'
})
```

### Passwordless (magic link or OTP)

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

A sent code is remembered until it is spent, so the screen that asks for it can
pick up where it was after a reload, from another tab, or when the in-app
browser the login was opened in closed while the user read the email. Any
sign-in or sign-out on the client forgets it.

```ts
const pending = await auth.getPendingPasswordless()
if (pending) showCodeStep(pending.email, pending.sent_at)

// "Change email"
await auth.clearPendingPasswordless()
```

`signInWithPasswordless` also takes `authParams` (the profile of an account the
code creates), `resumeUrl` (a page of yours the code email links back to, on one
of the client's registered origins) and `appState` (kept with the pending code,
never sent). `resumePasswordless({ email, sent_at })` records a code this
browser did not ask for, such as one whose email linked back to `resumeUrl`.

### Password reset

```ts
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:

```ts
// "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' }]
})

// One field at a time (null removes it), and reading it back. The session's
// user is updated on the spot, so getSession() sees the new value too.
await auth.updateProfileField('profile', 'teacher')
const profile = await auth.getProfileField('profile') // '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()` signs the user out of **this app only**: its refresh
token is revoked at the auth server and its storage cleared, without leaving the
page. The tenant's SSO session stays, so the user's other apps on the same
tenant (your dashboard, your docs…) keep them signed in, and the next sign-in
here comes back without asking.

To end the SSO session as well, pass `scope: 'global'`. In a browser that
**navigates the page to the auth server's `/logout`**, because the SSO cookie
lives on the auth domain and a cross-origin `fetch` can neither send nor clear
it.

```ts
await auth.signOut() // this app only; other apps of the tenant stay signed in
await auth.signOut({ scope: 'global' }) // + the SSO session: redirects to /logout
await auth.signOut({ scope: 'global', returnTo: 'https://app.example.com/bye' })
await auth.signOut({ scope: 'local' }) // only this device's storage, server not told
await auth.signOut({ scope: 'others' }) // every OTHER device; this one stays signed in
await auth.signOut({ scope: 'global', redirect: false }) // legacy: best-effort fetch
```

Scopes: `app` (default) revokes this app's refresh token and clears its storage.
`global` ends **this browser's** session at the auth server, so every refresh
token issued in it is refused from then on, in every app, 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.

> **Upgrading from 2.x:** `signOut()` used to mean `scope: 'global'`. If your
> sign-out button should end the SSO session, pass `{ scope: 'global' }`.

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

```ts
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.

```ts
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`:

```ts
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`:

```ts
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

```ts
// 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:

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

```ts
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.

```ts
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:

```ts
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.

```ts
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+.

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

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

```tsx
// 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>
}
```

```ts
// 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}` }
  })
}
```

```ts
// 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`):

```ts
// 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](https://faable.com/docs).

## License

See [LICENSE.md](LICENSE.md).
