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

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

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

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

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