Client for the FaableAuth REST API — management from a server, session and login flows from a browser or CLI.
Programmatically manage FaableAuth users, teams, connections and clients through the FaableAuth REST API — and, with the right strategy, call the tenant's session and login endpoints from a browser or a CLI.
⚠️ Where it can run depends on the credential, not on the package. The SDK itself has no Node-only dependencies and bundles for the browser. What you must never ship to a browser or any untrusted runtime is a
client_secretor an API key — those grant management scopes over the tenant. A user's own bearer token or the browser's session cookie only grant what that user can do, and those are fine client-side.
| Strategy | Credential | Grants | Where |
|---|---|---|---|
authClientCredentials | client_id + client_secret | The client's Management API scopes (its allow-list; none by default) | Server only |
authApikey | API key (fak_…) | The management scopes of the key | Server only |
authBearer | A token the caller already holds | Whatever that token's scopes and subject allow | Anywhere the token itself may live |
authCookie | The browser's session cookie | The signed-in user's own session (/me, MFA, passkeys…) | Browser, same-origin with the auth host |
| (none) | — | Public endpoints only (login, passwordless start, device flow…) | Anywhere |
Install
npm install @faable/auth-sdk
Authentication
Auth is explicit and pluggable: you pick an authStrategy and pass its
config in auth. The SDK never reads credentials from the environment —
read them yourself and pass them in. The strategy you choose drives the type of
auth, so the editor autocompletes the right fields. The strategies are
re-exported from @faable/auth-sdk, so you only ever import from this package.
Breaking change (v2): the old
createClientCredentials()/createApikeyAuth()helpers (which readFAABLE_*env vars automatically) were removed. Use theauthStrategy+authpair below.domainis now required — there is no hardcoded default host anymore.
Client credentials (recommended)
authClientCredentials is the default strategy, so you only pass auth
with { client_id, client_secret } — no need to set authStrategy:
import { FaableAuthApi } from "@faable/auth-sdk";
const api = FaableAuthApi.create({
domain: "https://<your-account>.auth.faable.link",
auth: {
client_id: process.env.FAABLEAUTH_CLIENT_ID!,
client_secret: process.env.FAABLEAUTH_CLIENT_SECRET!,
},
});
Allow the client its Management API scopes first. The token carries at most
the client's allow-list (management_scopes): with no scope in auth, the
whole list; with scope, the requested ones that are on it. A new client's list
is empty, so until you allow scopes (dashboard → your Auth tenant → Clients →
the client → OAuth → Management API scopes) every call fails with a
FaableApiError whose message names management_scopes_empty and says where to
fix it; asking only for scopes outside the list names
management_scope_not_allowed. See
Calling the Faable Management API.
By default the token is requested from <domain>/oauth/token. If your API host
is not the auth server, override it with auth.domain:
auth: {
client_id: "...",
client_secret: "...",
domain: "https://<your-account>.auth.faable.link", // where /oauth/token lives
}
API key
import { FaableAuthApi, authApikey } from "@faable/auth-sdk";
const api = FaableAuthApi.create({
domain: "https://<your-account>.auth.faable.link",
authStrategy: authApikey,
auth: { apikey: "fak_xxxxxxxxxxxxxxxx" },
});
Bearer token
For a token you already hold — a user's access token in a CLI, a token minted
elsewhere. The SDK does not mint or refresh it; whoever owns the token does.
token may be a getter (sync or async): it is consulted on every request,
so a getter that returns the current token keeps a long-lived client correct
when the token rotates.
import { FaableAuthApi, authBearer } from "@faable/auth-sdk";
const api = FaableAuthApi.create({
domain: "https://<your-account>.auth.faable.link",
authStrategy: authBearer,
auth: { token: () => session.accessToken },
});
What the calls may do is whatever the token's subject and scopes allow — a user token reaches the user's own resources, a management token reaches the management API.
Session cookie (browser)
For pages served under the tenant's auth host that authenticate with the
browser's session cookie (the hosted login pages: /me, MFA, passkeys). No
config: the browser attaches the cookie itself; the strategy makes the request
withCredentials so it also does cross-origin, where the auth server must
allow the page's origin for credentials.
import { FaableAuthApi, authCookie } from "@faable/auth-sdk";
const api = FaableAuthApi.create({
domain: "https://<your-account>.auth.faable.link",
authStrategy: authCookie,
});
No credentials
Omit auth and authStrategy and the client is anonymous — right for the
public endpoints (login, /passwordless/start, the device flow, OIDC
discovery). Those are not on the generated client; call them through the
fetcher:
const api = FaableAuthApi.create({
domain: "https://<your-account>.auth.faable.link",
});
await api.fetcher.post("/passwordless/start", { email, client_id });
Identifying your integration
Every request carries x-faable-client: auth-sdk/<version>+<sha> so the
auth server can attribute traffic. An app that wraps the SDK can report as
itself instead — the value you pass replaces the default, on data calls and on
the token request alike:
FaableAuthApi.create({
domain,
clientInfo: { client: "my-app/1.4.0", name: "my-app" },
});
Per-call options
Every fetcher method takes timeout, responseType and retry for that one
call. Retries only ever apply to GET (a replayed write could duplicate a
resource), and timeout and retry multiply — a short deadline on a hot path
wants retry: false next to it:
await api.fetcher.get(`/user/${id}`, { timeout: 800, retry: false });
Constructor options
| Option | Type | Description |
|---|---|---|
domain | string | Required. The account host to target, e.g. https://<your-account>.auth.faable.link. No default. The protocol is optional — <your-account>.auth.faable.link and https://<your-account>.auth.faable.link are equivalent. |
authStrategy | strategy builder | Auth strategy (authClientCredentials | authApikey | authBearer | authCookie), re-exported from @faable/auth-sdk. Defaults to authClientCredentials when auth is given; anonymous when neither is. |
auth | config | Config for the chosen strategy. Its shape is inferred from authStrategy (e.g. { client_id, client_secret }). |
debug | boolean | Enables verbose logging in the underlying fetcher. |
headers | { project_id?; account_id? } | Advanced / testing escape hatch — target domain but have the server resolve a different project/account (sent as x-faable-project / x-faableauth-account). team_id is a deprecated alias of project_id: a team_<hex> value is sent as project_<hex>, and passing both with different ids throws. The SDK no longer sends x-faable-team. |
clientInfo | { client?; name?; instance? } | How this integration identifies itself (x-faable-client / x-faable-instance). client replaces the SDK's own auth-sdk/<version>+<sha>. |
Usage
Users
// Get a user
const user = await api.getUser("user_xxx");
// Create a user
const created = await api.createUser({
email: "[email protected]",
password: "•••••••••",
});
// Update a user
const updated = await api.updateUser("user_xxx", { phone: "+34XXXXXXXXX" });
// List users (paginated) — first page of 30
const firstPage = await api.listUsers().first();
// Filter by email
const matches = await api.listUsers({ email: "[email protected]" }).first();
// Iterate every page
for await (const page of api.listUsers()) {
for (const user of page) {
// ...
}
}
User metadata
const metadata = await api.getUserMetadata("user_xxx");
await api.setUserMetadata("user_xxx", {
onboarded: true,
plan: "pro",
});
Teams
const team = await api.getTeam("team_xxx");
const newTeam = await api.createTeam({ name: "Acme Inc." });
await api.deleteTeam("team_xxx");
const myTeams = await api.listTeams({ user_id: "user_xxx" }).first();
Team members
const members = await api.listTeamMembers({ query: "alice" }).first();
const isMember = await api.isUserMemberOfTeam("user_xxx", "team_xxx");
Roles and permissions
A role grants permissions of your APIs. Assign it to a user at the account level and their tokens for an API with rbac enabled carry those permissions (team roles grant none).
const role = await api.roleCreate({ name: "support" });
await api.roleAddPermissions(role.id, {
permissions: [{ api: "api_xxx", value: "tickets:write" }],
});
await api.userAssignRoles("user_xxx", { roles: [role.id] });
const roles = await api.userRoles("user_xxx");
const permissions = await api.userPermissions("user_xxx"); // with the role that grants each
await api.userRemoveRoles("user_xxx", { roles: [role.id] });
await api.roleRemovePermissions(role.id, {
permissions: [{ api: "api_xxx", value: "tickets:write" }],
});
Connections
const connections = await api.listConections().first();
const connection = await api.getConection("conn_xxx");
Clients
const clients = await api.listClients().first();
const client = await api.getClient("client_xxx");
Current account
const account = await api.currentAccount();
Pagination
List methods return a paginator. Use .first() to grab the first page or iterate with for await ... of to walk all pages:
const paginator = api.listUsers();
const firstPage = await paginator.first();
for await (const page of paginator) {
// each `page` is an array of items
}
Webhooks
Faable Auth sends signed POSTs to your server when something happens in the tenant (user.created, team.member.added…). Create the subscription with faable auth webhooks create --url https://… -e user.created (it prints the signing secret once), then verify every delivery before trusting it:
import {
constructWebhookEvent,
readRawBody,
WebhookVerificationError,
} from "@faable/auth-sdk";
// Next.js pages router: the signature is over the RAW body.
export const config = { api: { bodyParser: false } };
export default async function handler(req, res) {
let event;
try {
event = await constructWebhookEvent(
await readRawBody(req), // fetch-style handlers: await request.text()
req.headers,
process.env.FAABLEAUTH_WEBHOOK_SECRET!,
);
} catch (e) {
if (e instanceof WebhookVerificationError) return res.status(401).end();
throw e;
}
switch (event.type) {
case "user.created":
await welcome(event.payload.email); // payload is the User
break;
case "team.member.added":
await sync(event.payload.team_id, event.payload.user_id);
break;
}
res.status(200).end();
}
- Answer 2xx only when the work is done. Timeouts, network errors, 5xx and 429 are retried at 1 min, 5 min, 30 min, 2 h and 12 h.
- The same event can arrive twice (a retry after a response that was lost).
event.delivery_id(x-faable-delivery) is the same on every retry: deduplicate by it, or make the work idempotent. - Rotation:
faable auth webhooks rotate-secretkeeps the old secret valid for 24 h and deliveries carry both signatures. To switch on your side without downtime, pass both:await constructWebhookEvent(body, headers, [oldSecret, newSecret]). - Uses Web Crypto, so it runs on Node ≥ 19, Deno, Bun and edge runtimes; that is why it is
async. - Deliveries older than 5 minutes are refused as replays (
toleranceMsto change it).
WebhookEvent, WebhookEventType, WebhookEventOf<"user.created"> and WebhookPayload<"team.member.added"> are generated from the auth OpenAPI, like every other type.
Protecting your API
Verify the access tokens your API receives and check their permissions:
import {
AccessTokenError,
bearerToken,
requirePermissions,
verifyAccessToken,
} from "@faable/auth-sdk";
export default async function handler(req, res) {
try {
const claims = await verifyAccessToken(bearerToken(req.headers.authorization), {
domain: process.env.FAABLEAUTH_DOMAIN!, // https://<tenant>.auth.faable.link
audience: "https://api.example.com", // your Api's identifier
});
requirePermissions(claims, "tickets:write");
// … claims.sub is the user (or the client, for machine tokens)
} catch (e) {
if (e instanceof AccessTokenError) return res.status(e.status).json({ error: e.reason });
throw e;
}
}
verifyAccessTokenchecks the signature against the tenant JWKS (fetched once and cached),iss,audandexp. It throwsAccessTokenErrorwithstatus401.requirePermissionsthrowsAccessTokenErrorinsufficient_scopewithstatus403 andmissing;hasPermissionsreturns a boolean. Both require all the permissions passed.- The
permissionsclaim needs the Api'stoken_dialectto beaccess_token_authz. Withrbacon, a user token carries the permissions the client requested that the user's roles grant, so request them as scopes.
Types
All response shapes are generated from the OpenAPI spec and re-exported from the package root:
import type {
User,
Team,
TeamMember,
Connection,
Client,
} from "@faable/auth-sdk";
License
MIT