@faable/auth-sdk 2.8.26 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -242,6 +242,26 @@ const members = await api.listTeamMembers({ query: "alice" }).first();
242
242
  const isMember = await api.isUserMemberOfTeam("user_xxx", "team_xxx");
243
243
  ```
244
244
 
245
+ ### Roles and permissions
246
+
247
+ 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).
248
+
249
+ ```ts
250
+ const role = await api.roleCreate({ name: "support" });
251
+ await api.roleAddPermissions(role.id, {
252
+ permissions: [{ api: "api_xxx", value: "tickets:write" }],
253
+ });
254
+
255
+ await api.userAssignRoles("user_xxx", { roles: [role.id] });
256
+ const roles = await api.userRoles("user_xxx");
257
+ const permissions = await api.userPermissions("user_xxx"); // with the role that grants each
258
+
259
+ await api.userRemoveRoles("user_xxx", { roles: [role.id] });
260
+ await api.roleRemovePermissions(role.id, {
261
+ permissions: [{ api: "api_xxx", value: "tickets:write" }],
262
+ });
263
+ ```
264
+
245
265
  ### Connections
246
266
 
247
267
  ```ts
@@ -325,6 +345,37 @@ export default async function handler(req, res) {
325
345
 
326
346
  `WebhookEvent`, `WebhookEventType`, `WebhookEventOf<"user.created">` and `WebhookPayload<"team.member.added">` are generated from the auth OpenAPI, like every other type.
327
347
 
348
+ ## Protecting your API
349
+
350
+ Verify the access tokens your API receives and check their permissions:
351
+
352
+ ```ts
353
+ import {
354
+ AccessTokenError,
355
+ bearerToken,
356
+ requirePermissions,
357
+ verifyAccessToken,
358
+ } from "@faable/auth-sdk";
359
+
360
+ export default async function handler(req, res) {
361
+ try {
362
+ const claims = await verifyAccessToken(bearerToken(req.headers.authorization), {
363
+ domain: process.env.FAABLEAUTH_DOMAIN!, // https://<tenant>.auth.faable.link
364
+ audience: "https://api.example.com", // your Api's identifier
365
+ });
366
+ requirePermissions(claims, "tickets:write");
367
+ // … claims.sub is the user (or the client, for machine tokens)
368
+ } catch (e) {
369
+ if (e instanceof AccessTokenError) return res.status(e.status).json({ error: e.reason });
370
+ throw e;
371
+ }
372
+ }
373
+ ```
374
+
375
+ - `verifyAccessToken` checks the signature against the tenant JWKS (fetched once and cached), `iss`, `aud` and `exp`. It throws `AccessTokenError` with `status` 401.
376
+ - `requirePermissions` throws `AccessTokenError` `insufficient_scope` with `status` 403 and `missing`; `hasPermissions` returns a boolean. Both require **all** the permissions passed.
377
+ - The `permissions` claim needs the Api's `token_dialect` to be `access_token_authz`. With `rbac` on, a user token carries the permissions the client **requested** that the user's roles grant, so request them as scopes.
378
+
328
379
  ## Types
329
380
 
330
381
  All response shapes are generated from the OpenAPI spec and re-exported from the package root:
@@ -8522,7 +8522,7 @@ export interface components {
8522
8522
  * - `invalid_query` (400): The `?query=` FaableQL expression could not be parsed.
8523
8523
  * - `search_not_supported` (400): This resource does not support `?search=`.
8524
8524
  * - `invalid_expand` (400): An `?expand=` path is not allowed on this resource.
8525
- * - `access_denied` (401): The caller may not access this row (ownership or machine-to-machine restriction).
8525
+ * - `access_denied` (403): The caller may not access this row (ownership or machine-to-machine restriction).
8526
8526
  * - `insufficient_scope` (403): The token lacks a scope this operation requires. `message` names it.
8527
8527
  * - `team_required` (400): An Auth Account must belong to a project (team).
8528
8528
  * - `account_limit_reached` (409): The project already has the maximum number of Auth Accounts.
package/dist/index.d.ts CHANGED
@@ -5,5 +5,7 @@ export * from "./api/api-types.js";
5
5
  export * from "./api/types.js";
6
6
  export { WEBHOOK_HEADERS, DEFAULT_WEBHOOK_TOLERANCE_MS, WebhookVerificationError, verifyWebhookSignature, constructWebhookEvent, readRawBody, } from "./webhooks.js";
7
7
  export type { WebhookEventOf, WebhookPayload, WebhookHeaders, WebhookVerificationFailure, VerifyWebhookOptions, VerifiedWebhookEvent, } from "./webhooks.js";
8
+ export { AccessTokenError, bearerToken, verifyAccessToken, permissionsOf, hasPermissions, requirePermissions, } from "./tokens.js";
9
+ export type { AccessTokenClaims, AccessTokenFailure, VerifyAccessTokenOptions, } from "./tokens.js";
8
10
  export { authClientCredentials, authApikey, authBearer, authCookie, FaableApiError, } from "@faable/sdk-base";
9
11
  export type { ApiParams, ClientCredentialsConfig, ApikeyConfig, BearerConfig, RetryConfig, Fetcher, FetcherConfig, FetcherRequestParams, AuthStrategy, AuthStrategyBuilder, AuthStrategyContext, } from "@faable/sdk-base";
package/dist/index.js CHANGED
@@ -5,6 +5,8 @@ export * from "./api/api-types.js";
5
5
  export * from "./api/types.js";
6
6
  // Receiving webhooks: verify the signature, get the event typed.
7
7
  export { WEBHOOK_HEADERS, DEFAULT_WEBHOOK_TOLERANCE_MS, WebhookVerificationError, verifyWebhookSignature, constructWebhookEvent, readRawBody, } from "./webhooks.js";
8
+ // Protecting your API: verify a Faable Auth access token, check permissions.
9
+ export { AccessTokenError, bearerToken, verifyAccessToken, permissionsOf, hasPermissions, requirePermissions, } from "./tokens.js";
8
10
  // Re-export the auth strategies (and their config/types) from
9
11
  // @faable/sdk-base so consumers depend on @faable/auth-sdk ONLY — sdk-base
10
12
  // stays a transitive dependency and no app needs to import it directly.
@@ -0,0 +1,82 @@
1
+ import { type JWTPayload } from "jose";
2
+ /**
3
+ * Verifying the access tokens Faable Auth issues for your API, and checking
4
+ * what they allow.
5
+ *
6
+ * An Api with `token_dialect: "access_token_authz"` puts a `permissions` claim
7
+ * in its tokens: a space-separated string. With `rbac` enabled on the Api,
8
+ * user tokens carry only the requested permissions that the user's roles
9
+ * grant; machine tokens (`client_credentials`) carry what the client asked
10
+ * for, or the whole catalog when it asked for nothing.
11
+ *
12
+ * ```ts
13
+ * const claims = await verifyAccessToken(bearerToken(req.headers.authorization), {
14
+ * domain: "https://my-tenant.auth.faable.link",
15
+ * audience: "https://api.example.com",
16
+ * });
17
+ * requirePermissions(claims, "invites:write"); // throws AccessTokenError (403)
18
+ * ```
19
+ */
20
+ /** The claims of a Faable Auth access token. */
21
+ export type AccessTokenClaims = JWTPayload & {
22
+ sub: string;
23
+ /** Space-separated granted scopes. */
24
+ scope?: string;
25
+ /** Space-separated permissions (`access_token_authz` dialect). */
26
+ permissions?: string;
27
+ /** Role names, when the Api has `include_roles_in_access_token`. */
28
+ roles?: string[];
29
+ /** Team slugs, when the Api has `include_teams_in_access_token`. */
30
+ teams?: string[];
31
+ client_id?: string;
32
+ };
33
+ export type AccessTokenFailure = "missing_token" | "invalid_token" | "insufficient_scope";
34
+ /**
35
+ * Thrown by {@link verifyAccessToken} (`invalid_token`, `missing_token`: answer
36
+ * 401) and {@link requirePermissions} (`insufficient_scope`: answer 403).
37
+ * `status` is the HTTP status to answer with.
38
+ */
39
+ export declare class AccessTokenError extends Error {
40
+ readonly reason: AccessTokenFailure;
41
+ readonly status: 401 | 403;
42
+ /** For `insufficient_scope`: the permissions the token lacks. */
43
+ readonly missing?: string[];
44
+ constructor(reason: AccessTokenFailure, message: string, opts?: {
45
+ missing?: string[];
46
+ cause?: unknown;
47
+ });
48
+ }
49
+ export type VerifyAccessTokenOptions = {
50
+ /** Your tenant host, e.g. `https://my-tenant.auth.faable.link`. */
51
+ domain: string;
52
+ /** The `identifier` of your Api: the token's `aud` must be it. */
53
+ audience: string | string[];
54
+ /**
55
+ * The expected `iss`, when the tenant has a custom domain whose host is not
56
+ * `domain`. Defaults to `domain`.
57
+ */
58
+ issuer?: string;
59
+ /** Seconds of clock skew to tolerate on `exp` / `nbf`. Default 60. */
60
+ clockTolerance?: number;
61
+ };
62
+ /**
63
+ * The token of an `Authorization: Bearer <token>` header, or `undefined`.
64
+ */
65
+ export declare const bearerToken: (header: string | string[] | null | undefined) => string | undefined;
66
+ /**
67
+ * Verifies an access token against the tenant's JWKS: signature (RS256),
68
+ * `iss`, `aud`, `exp`. Returns its claims, or throws {@link AccessTokenError}.
69
+ */
70
+ export declare const verifyAccessToken: (token: string | null | undefined, options: VerifyAccessTokenOptions) => Promise<AccessTokenClaims>;
71
+ /**
72
+ * The permissions of a token, as a list. Accepts the claim as Faable Auth
73
+ * issues it (a space-separated string) and as an array.
74
+ */
75
+ export declare const permissionsOf: (claims: Pick<AccessTokenClaims, "permissions"> | null | undefined) => string[];
76
+ /** True when the token carries EVERY permission in `required`. */
77
+ export declare const hasPermissions: (claims: Pick<AccessTokenClaims, "permissions"> | null | undefined, required: string | string[]) => boolean;
78
+ /**
79
+ * Throws {@link AccessTokenError} `insufficient_scope` (403) unless the token
80
+ * carries every permission in `required`. `missing` names the ones it lacks.
81
+ */
82
+ export declare const requirePermissions: (claims: Pick<AccessTokenClaims, "permissions"> | null | undefined, required: string | string[]) => void;
package/dist/tokens.js ADDED
@@ -0,0 +1,94 @@
1
+ import { createRemoteJWKSet, jwtVerify } from "jose";
2
+ import { getDomain } from "./helpers.js";
3
+ /**
4
+ * Thrown by {@link verifyAccessToken} (`invalid_token`, `missing_token`: answer
5
+ * 401) and {@link requirePermissions} (`insufficient_scope`: answer 403).
6
+ * `status` is the HTTP status to answer with.
7
+ */
8
+ export class AccessTokenError extends Error {
9
+ reason;
10
+ status;
11
+ /** For `insufficient_scope`: the permissions the token lacks. */
12
+ missing;
13
+ constructor(reason, message, opts = {}) {
14
+ super(message, opts.cause ? { cause: opts.cause } : undefined);
15
+ this.name = "AccessTokenError";
16
+ this.reason = reason;
17
+ this.status = reason === "insufficient_scope" ? 403 : 401;
18
+ if (opts.missing)
19
+ this.missing = opts.missing;
20
+ }
21
+ }
22
+ // One JWKS per issuer, so keys are fetched once and refreshed by jose on an
23
+ // unknown `kid` (a key rotation) instead of on every request.
24
+ const jwksByIssuer = new Map();
25
+ const jwksFor = (issuer) => {
26
+ let jwks = jwksByIssuer.get(issuer);
27
+ if (!jwks) {
28
+ jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
29
+ jwksByIssuer.set(issuer, jwks);
30
+ }
31
+ return jwks;
32
+ };
33
+ /**
34
+ * The token of an `Authorization: Bearer <token>` header, or `undefined`.
35
+ */
36
+ export const bearerToken = (header) => {
37
+ const value = Array.isArray(header) ? header[0] : header;
38
+ const match = value?.match(/^Bearer\s+(\S+)\s*$/i);
39
+ return match?.[1];
40
+ };
41
+ /**
42
+ * Verifies an access token against the tenant's JWKS: signature (RS256),
43
+ * `iss`, `aud`, `exp`. Returns its claims, or throws {@link AccessTokenError}.
44
+ */
45
+ export const verifyAccessToken = async (token, options) => {
46
+ if (!token) {
47
+ throw new AccessTokenError("missing_token", "No access token");
48
+ }
49
+ const issuer = getDomain(options.issuer ?? options.domain);
50
+ try {
51
+ const { payload } = await jwtVerify(token, jwksFor(issuer), {
52
+ issuer,
53
+ audience: options.audience,
54
+ algorithms: ["RS256"],
55
+ clockTolerance: options.clockTolerance ?? 60,
56
+ });
57
+ if (typeof payload.sub !== "string") {
58
+ throw new Error("access token has no sub");
59
+ }
60
+ return payload;
61
+ }
62
+ catch (err) {
63
+ throw new AccessTokenError("invalid_token", `Invalid access token: ${err?.message ?? err}`, { cause: err });
64
+ }
65
+ };
66
+ /**
67
+ * The permissions of a token, as a list. Accepts the claim as Faable Auth
68
+ * issues it (a space-separated string) and as an array.
69
+ */
70
+ export const permissionsOf = (claims) => {
71
+ const raw = claims?.permissions;
72
+ if (Array.isArray(raw))
73
+ return raw.filter((p) => typeof p === "string");
74
+ if (typeof raw === "string")
75
+ return raw.split(" ").filter(Boolean);
76
+ return [];
77
+ };
78
+ const asList = (required) => Array.isArray(required) ? required : [required];
79
+ /** True when the token carries EVERY permission in `required`. */
80
+ export const hasPermissions = (claims, required) => {
81
+ const held = new Set(permissionsOf(claims));
82
+ return asList(required).every((p) => held.has(p));
83
+ };
84
+ /**
85
+ * Throws {@link AccessTokenError} `insufficient_scope` (403) unless the token
86
+ * carries every permission in `required`. `missing` names the ones it lacks.
87
+ */
88
+ export const requirePermissions = (claims, required) => {
89
+ const held = new Set(permissionsOf(claims));
90
+ const missing = asList(required).filter((p) => !held.has(p));
91
+ if (missing.length > 0) {
92
+ throw new AccessTokenError("insufficient_scope", `Missing permission(s): ${missing.join(", ")}`, { missing });
93
+ }
94
+ };
package/dist/version.js CHANGED
@@ -9,13 +9,13 @@
9
9
  // login pages could not import the SDK at all. Same pattern as auth-js.
10
10
  //
11
11
  // The sentinels MUST stay byte-identical to the `from` values in `.releaserc`.
12
- export const version = "2.8.26";
12
+ export const version = "2.9.0";
13
13
  // Short git SHA of the released commit. The version dates a build; this names
14
14
  // the exact tree, so a canonical log line or an audit entry leads straight to
15
15
  // `git show <sha>`. Deliberately NOT hex: an unreleased build (dev, a local
16
16
  // link) cannot be mistaken for a real commit — auth only records values that
17
17
  // look like a SHA, and this one never will.
18
- export const commit = "80ea0ef";
18
+ export const commit = "ff21a5a";
19
19
  // What this SDK writes in `x-faable-client`. Exported for a consumer that
20
20
  // builds a strategy on its own (outside `FaableAuthApi`) and still wants the
21
21
  // token request attributed — a bare `authClientCredentials` stamps only what
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/auth-sdk",
3
- "version": "2.8.26",
3
+ "version": "2.9.0",
4
4
  "author": "Marc Pomar <marc@faable.com>",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -10,7 +10,8 @@
10
10
  },
11
11
  "type": "module",
12
12
  "dependencies": {
13
- "@faable/sdk-base": "^1.7.0"
13
+ "@faable/sdk-base": "^1.7.0",
14
+ "jose": "^6.2.13"
14
15
  },
15
16
  "devDependencies": {
16
17
  "@ava/typescript": "^5.0.0",
@@ -148,6 +148,16 @@ const emitMethod = ({ path, httpMethod, op }) => {
148
148
  // client/rotateSecret) send an empty object.
149
149
  const data = hasBody ? "data" : "{}";
150
150
  body = `return this.fetcher.post<OpResult<"${id}">>(${url}, ${data});`;
151
+ } else if (httpMethod === "delete" && hasBody) {
152
+ // fetcher.delete takes no body, so a DELETE with one (DELETE
153
+ // /role/{id}/permissions, /user/{id}/roles) goes through fetcher.request.
154
+ // Before this branch the `data` argument was declared and then dropped.
155
+ if (hasQuery) {
156
+ throw new Error(
157
+ `gen-client: DELETE with both a body and query params not supported yet (${id}).`,
158
+ );
159
+ }
160
+ body = `return this.fetcher.request<OpResult<"${id}">>({ method: "DELETE", url: ${url}, data });`;
151
161
  } else if (hasQuery) {
152
162
  // GET/DELETE with query params route through fetcher.request: its `params`
153
163
  // is untyped, sidestepping FetcherConfig's string-only constraint, and the
package/spec/openapi.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "info": {
4
4
  "title": "@faablecloud/auth",
5
5
  "description": "Auth Platform made by Faable. Manage Users and Roles",
6
- "version": "2.92.0",
6
+ "version": "2.92.1",
7
7
  "license": {
8
8
  "name": "private",
9
9
  "url": "https://faable.com/docs/platform/privacy-policy"
@@ -9657,7 +9657,7 @@
9657
9657
  },
9658
9658
  "ErrorCode": {
9659
9659
  "type": "string",
9660
- "description": "Machine-readable error code. One HTTP status per code.\n\n- `bad_request` (400): The request is malformed. `message` says what is wrong.\n- `validation_error` (400): The body, query or path failed schema validation. `details.issues` lists each failing field.\n- `unauthorized` (401): The request carries no valid credentials.\n- `payment_required` (402): The feature is not included in the current plan.\n- `forbidden` (403): The credentials are valid but do not allow this operation.\n- `not_found` (404): The resource does not exist in this account.\n- `method_not_allowed` (405): The HTTP method is not supported on this path.\n- `conflict` (409): The request conflicts with the current state of the resource.\n- `last_sign_in_method` (409): Unlinking this account would leave the user with no way to sign in: set a password, verify the email or link another account first.\n- `payload_too_large` (413): The request body exceeds the size limit.\n- `unsupported_media_type` (415): The `Content-Type` is not accepted by this endpoint.\n- `unprocessable_entity` (422): The request is well-formed but cannot be processed.\n- `too_many_requests` (429): Rate limit exceeded. Honour the `Retry-After` header.\n- `internal_error` (500): Unexpected server error. Retry later; the request id is logged.\n- `already_exists` (400): A resource with the same unique field already exists (for a user, usually `email`). `details.fields` names the field.\n- `invalid_id` (400): The id in the path is not a valid id for this resource.\n- `account_not_found` (404): No Auth Account matches the request (domain, header or token).\n- `invalid_query` (400): The `?query=` FaableQL expression could not be parsed.\n- `search_not_supported` (400): This resource does not support `?search=`.\n- `invalid_expand` (400): An `?expand=` path is not allowed on this resource.\n- `access_denied` (401): The caller may not access this row (ownership or machine-to-machine restriction).\n- `insufficient_scope` (403): The token lacks a scope this operation requires. `message` names it.\n- `team_required` (400): An Auth Account must belong to a project (team).\n- `account_limit_reached` (409): The project already has the maximum number of Auth Accounts.\n- `invalid_project_id` (400): The project id is not of the form `project_<hex>` or `team_<hex>`.\n- `account_transfer_refused` (409): The Auth Account cannot be moved to that project. `details.blockers` lists why.\n- `email_sender_domain_not_allowed` (400): The sender address is not in a domain this platform signs email for (AUTH_EMAIL_SENDER_DOMAINS).\n- `plan_required` (402): The setting requires a higher plan. `message` says which.\n- `invalid_canonical_host` (400): `canonical_host` must be this account's own platform host or one of its verified custom domains.\n- `canonical_host_breaks_passkeys` (400): `canonical_host` is outside the account's current WebAuthn RP ID — every passkey already enrolled would stop working. Set `webauthn_rp_id` in the same request to a suffix that covers both.\n- `not_logged_in` (401): There is no signed-in session for this request.\n- `session_already_revoked` (409): The session was already revoked.\n- `invalid_token` (401): The bearer token is missing, malformed, expired or has no subject.\n- `not_owner` (403): The caller may only perform this operation on their own user.\n- `user_suspended` (403): The user is suspended and cannot sign in or be modified.\n- `invalid_credentials` (400): The email/username or password is incorrect.\n- `login_denied` (401): A post-login Action denied the sign-in. `message` carries the reason it gave. On token grants it is a 403 with `error: invalid_grant`.\n- `signup_denied` (403): A pre-signup Action or an active block denied the sign-up. `message` carries the reason.\n- `client_credentials_denied` (401): An Action denied the client_credentials grant. `message` carries the reason.\n- `m2m_quota_exceeded` (402): The project used the machine-to-machine tokens its plan includes this month. Free is capped; Hobby and Pro are billed per token instead.\n- `signup_disabled` (403): Self-service sign-up is disabled on this connection.\n- `email_taken` (409): Another user in this account already uses that email.\n- `username_taken` (409): Another user of this database connection already uses that username.\n- `invalid_username` (400): The username is empty, or contains an `@` or whitespace. `message` says which.\n- `invalid_email` (400): The email address is not valid.\n- `invalid_name` (400): The name contains a link. `message` says which field.\n- `same_email` (400): The new email is the same as the current one.\n- `user_has_no_email` (400): The operation needs an email but the user has none.\n- `password_too_weak` (400): The password does not meet the connection policy. `message` lists each unmet rule.\n- `invalid_password_hash` (400): The imported password hash cannot be read, or its cost is above what a login can afford. `message` names the field.\n- `weak_password_hash` (400): The imported password hash uses a fast digest (MD5, SHA-1, SHA-2) or a cost below the minimum. `message` says which.\n- `invalid_code` (400): The verification code is wrong, expired or already used.\n- `invalid_phone` (400): The phone number is not E.164 and could not be resolved with the account default country. Include the country code.\n- `phone_changed` (400): The phone on the user changed after the code was sent. Start again.\n- `phone_already_verified` (409): The user already has a verified phone; the number cannot be swapped from the screen.\n- `sms_unavailable` (409): This account cannot send SMS right now. The suffix says why: `sms_unavailable:plan`, `:quota`, `:provider`.\n- `sms_failed` (409): The SMS provider rejected the message.\n- `invalid_state` (400): The `state` is missing, expired, or does not describe a resumable step.\n- `state_mismatch` (401): The `state` belongs to another account, session, client or ceremony.\n- `invalid_ticket` (400): The ticket does not exist or is not the kind this endpoint accepts.\n- `ticket_used` (400): The ticket was already consumed.\n- `ticket_expired` (400): The ticket is past its expiry.\n- `ticket_not_found` (404): No ticket with that id belongs to this user.\n- `credential_not_found` (404): The user has no password credential on this connection.\n- `invalid_link` (400): The magic link is malformed.\n- `expired_link` (400): The magic link token was not found or has expired.\n- `invalid_otp` (401): The one-time password is wrong or expired.\n- `invalid_client` (400): The `client_id` in the request does not name a client of this account.\n- `client_not_found` (404): No client with that `client_id`.\n- `client_mismatch` (401): The client belongs to a different account.\n- `invalid_client_metadata` (400): Dynamic client registration rejected a field (RFC 7591). `message` names it.\n- `invalid_connection` (400): The connection does not exist, is disabled, or is not of the type this flow needs.\n- `no_database_connection` (400): The tenant has no database connection, so there is nowhere to store a password. Create one and retry.\n- `connection_required` (400): No connection was given and the client has no `default_connection`.\n- `connection_misconfigured` (400): The connection is missing settings needed to talk to its provider.\n- `passwordless_unavailable` (400): No passwordless connection is configured or enabled for this client.\n- `ambiguous_connection` (400): Several connections match; pass `connection_id`.\n- `invalid_redirect_uri` (400): The redirect URI is not registered for the client.\n- `invalid_saml_request` (400): The SAMLRequest is missing, too large, not well-formed or not a SAML 2.0 AuthnRequest.\n- `saml_sp_mismatch` (400): The AuthnRequest does not match the client's SAML configuration: its Issuer is not the configured entity_id, or it asks for another Assertion Consumer Service URL or binding.\n- `saml_not_enabled` (404): This client does not have SAML enabled.\n- `invalid_saml_sp` (400): The `saml_sp` configuration is invalid: `acs_url` must be an absolute https URL without a fragment.\n- `origin_not_allowed` (403): The request `Origin` is not in the client's Allowed Web Origins.\n- `audience_not_found` (403): The `audience` does not name an API registered in this account.\n- `system_resource` (403): The resource is managed by Faable and cannot be modified or deleted.\n- `invalid_request` (400): A required OAuth parameter is missing or two of them are incompatible. `message` says which.\n- `invalid_device_code` (400): The device or user code is invalid or expired.\n- `provider_error` (400): The upstream identity provider returned an error. The suffix is the provider’s own code, when it sent one: `provider_error:bad_refresh_token` (the user must re-authorize), `provider_error:incorrect_client_credentials` (our configuration, the user can do nothing). `details.provider_error_description` carries the provider’s sentence.\n- `identity_already_linked` (409): That external identity is already linked to another user.\n- `identity_link_denied` (409): That external identity cannot be linked to this user.\n- `identity_conflict` (409): The user already has a linked identity for this connection.\n- `identity_orphaned` (400): The identity pointed at a user that no longer exists; it was removed. Sign in again.\n- `no_provider_token` (400): The identity holds no provider access token.\n- `no_refresh_token` (400): The identity holds no provider refresh token, so it cannot be refreshed.\n- `action_unavailable` (401): The Action that paused this login is disabled or belongs elsewhere.\n- `login_flow_misconfigured` (500): The login flow of this account cannot run. `message` carries the node error.\n- `mfa_required` (403): The login needs a second factor that this flow cannot collect, or one is still pending on the session.\n- `step_up_required` (403): Confirm an existing factor before changing your factors.\n- `reauth_required` (403): The operation needs a recent sign-in (and the second factor, when the user has one). Sign in again with `max_age=0` and retry with the new token.\n- `invalid_current_password` (400): The current password is wrong.\n- `email_not_verified` (403): The user's email is not verified, and the operation needs it to be.\n- `invalid_profile_field` (400): A profile field is not declared for this application, or its value does not match the declaration. `message` names the key.\n- `interaction_required` (403): An Action asked for a redirect on a non-interactive flow.\n- `passkey_login_disabled` (400): Sign in with a passkey is not enabled for this client.\n- `mfa_pending` (401): A second-factor challenge is pending; answer it before continuing.\n- `mfa_invalid_code` (401): The authenticator code did not verify.\n- `mfa_invalid_recovery_code` (401): The recovery code did not verify.\n- `too_many_attempts` (429): Too many wrong codes. Wait a few minutes and try again.\n- `too_many_login_attempts` (429): Too many failed sign-in attempts for this email or username from this network. Wait a few minutes and try again.\n- `totp_not_allowed` (401): This account does not accept authenticator apps.\n- `no_usable_factor` (400): The user has no confirmed second factor to challenge.\n- `factor_not_found` (400): No factor with that id belongs to this user.\n- `factor_already_confirmed` (400): The factor was already confirmed.\n- `invalid_factor` (400): The factor cannot be verified (no secret material).\n- `session_missing` (401): The session that started this login no longer exists.\n- `passkey_verification_failed` (400): The WebAuthn response could not be verified.\n- `no_passkey_enrolled` (400): The user has no passkey to sign in with.\n- `malformed_credential` (400): The WebAuthn credential is malformed.\n- `unknown_passkey` (401): The passkey is not enrolled for this user.\n- `invalid_passkey` (401): The passkey assertion did not verify.\n- `invalid_invite_url` (400): An invite's `accept_url` or `redirect_uri` is not an http(s) URL at an origin of this tenant's client callbacks or `callback_hostnames`, or it carries a fragment.\n- `invite_email_mismatch` (403): The invite is for another email address than the accepting user's. `details.invited_email` shows it masked.\n- `invite_email_unverified` (403): The accepting user's email matches the invite but is not verified yet.\n- `invite_not_found` (404): No invite with that id in this account.\n- `invite_used` (400): The invite was already accepted.\n- `already_member` (400): The user is already a member of the team.\n- `not_a_member` (404): The user is not a member of the team.\n- `consent_options_unavailable` (502): The consent screen could not load the choices from the API being authorized (its `consent_options.url`). Try again in a moment.\n- `consent_choice_invalid` (400): The choice sent with the consent decision is not one the API offered for this user.\n- `registration_disabled` (403): Dynamic client registration is turned off for this tenant. Create the client from the dashboard or the Management API, or turn on `dynamic_client_registration_enabled`.\n- `invalid_email_domain` (400): Not an email domain this organization can claim: it must be a plain domain name (`acme.com`), lowercase, without scheme, path or wildcard.\n- `public_email_domain` (400): A public email provider (gmail.com, outlook.com…) cannot belong to an organization: anyone can have an address there.\n- `organization_domain_not_found` (404): The organization has no such domain.\n- `organization_domain_taken` (409): Another organization of this tenant already verified this domain. A domain belongs to one organization.\n- `organization_domain_unverified` (400): The TXT record was not found or does not hold the expected value yet. `details.record` says what was expected and what resolves now; DNS can take a few minutes to propagate.\n- `plan_unavailable` (503): The plan could not be checked right now, and this setting is only for paid plans. Try again in a minute.\n- `organization_domain_mismatch` (403): This organization's identity provider can only sign in addresses of the organization's verified email domains.\n- `team_not_in_organization` (400): Auto-join can only put people in teams of the same organization. `details.teams` lists the others.\n- `organization_has_no_idp` (400): Requiring SSO needs an identity provider: bind an `oidc` connection to the organization first.\n- `organization_not_found` (404): No organization with that id in this account.\n- `invalid_user` (400): The user does not exist in this account.\n- `not_an_organization_admin` (404): The user is not an admin of the organization.\n- `last_organization_admin` (409): An organization keeps at least one admin. Add another admin before removing this one.\n- `team_id_unavailable` (409): The team id cannot be used in this tenant. Pick another id or let the API generate one.\n- `use_team_member_route` (400): This generic route cannot create memberships. Use `POST /team/{team_id}/member` for team members or `POST /role/{role_id}/users` for role grants.\n- `role_not_found` (404): No role with that id in this account.\n- `invalid_role` (400): The role belongs to another account.\n- `flow_not_found` (400): The login flow does not exist in this account.\n- `revision_not_found` (404): The revision is not in the flow history.\n- `invalid_webhook_event` (400): An event is not in the webhook event catalog. `message` and `details.valid_events` list the valid ones.\n- `invalid_webhook_url` (400): The webhook URL must be an absolute `https` URL.\n- `not_a_webhook` (400): The operation only applies to `webhook` subscriptions.\n- `delivery_not_found` (404): No webhook delivery with that id for this subscription.\n- `delivery_in_progress` (409): The delivery is being attempted right now; retry the redeliver in a minute.",
9660
+ "description": "Machine-readable error code. One HTTP status per code.\n\n- `bad_request` (400): The request is malformed. `message` says what is wrong.\n- `validation_error` (400): The body, query or path failed schema validation. `details.issues` lists each failing field.\n- `unauthorized` (401): The request carries no valid credentials.\n- `payment_required` (402): The feature is not included in the current plan.\n- `forbidden` (403): The credentials are valid but do not allow this operation.\n- `not_found` (404): The resource does not exist in this account.\n- `method_not_allowed` (405): The HTTP method is not supported on this path.\n- `conflict` (409): The request conflicts with the current state of the resource.\n- `last_sign_in_method` (409): Unlinking this account would leave the user with no way to sign in: set a password, verify the email or link another account first.\n- `payload_too_large` (413): The request body exceeds the size limit.\n- `unsupported_media_type` (415): The `Content-Type` is not accepted by this endpoint.\n- `unprocessable_entity` (422): The request is well-formed but cannot be processed.\n- `too_many_requests` (429): Rate limit exceeded. Honour the `Retry-After` header.\n- `internal_error` (500): Unexpected server error. Retry later; the request id is logged.\n- `already_exists` (400): A resource with the same unique field already exists (for a user, usually `email`). `details.fields` names the field.\n- `invalid_id` (400): The id in the path is not a valid id for this resource.\n- `account_not_found` (404): No Auth Account matches the request (domain, header or token).\n- `invalid_query` (400): The `?query=` FaableQL expression could not be parsed.\n- `search_not_supported` (400): This resource does not support `?search=`.\n- `invalid_expand` (400): An `?expand=` path is not allowed on this resource.\n- `access_denied` (403): The caller may not access this row (ownership or machine-to-machine restriction).\n- `insufficient_scope` (403): The token lacks a scope this operation requires. `message` names it.\n- `team_required` (400): An Auth Account must belong to a project (team).\n- `account_limit_reached` (409): The project already has the maximum number of Auth Accounts.\n- `invalid_project_id` (400): The project id is not of the form `project_<hex>` or `team_<hex>`.\n- `account_transfer_refused` (409): The Auth Account cannot be moved to that project. `details.blockers` lists why.\n- `email_sender_domain_not_allowed` (400): The sender address is not in a domain this platform signs email for (AUTH_EMAIL_SENDER_DOMAINS).\n- `plan_required` (402): The setting requires a higher plan. `message` says which.\n- `invalid_canonical_host` (400): `canonical_host` must be this account's own platform host or one of its verified custom domains.\n- `canonical_host_breaks_passkeys` (400): `canonical_host` is outside the account's current WebAuthn RP ID — every passkey already enrolled would stop working. Set `webauthn_rp_id` in the same request to a suffix that covers both.\n- `not_logged_in` (401): There is no signed-in session for this request.\n- `session_already_revoked` (409): The session was already revoked.\n- `invalid_token` (401): The bearer token is missing, malformed, expired or has no subject.\n- `not_owner` (403): The caller may only perform this operation on their own user.\n- `user_suspended` (403): The user is suspended and cannot sign in or be modified.\n- `invalid_credentials` (400): The email/username or password is incorrect.\n- `login_denied` (401): A post-login Action denied the sign-in. `message` carries the reason it gave. On token grants it is a 403 with `error: invalid_grant`.\n- `signup_denied` (403): A pre-signup Action or an active block denied the sign-up. `message` carries the reason.\n- `client_credentials_denied` (401): An Action denied the client_credentials grant. `message` carries the reason.\n- `m2m_quota_exceeded` (402): The project used the machine-to-machine tokens its plan includes this month. Free is capped; Hobby and Pro are billed per token instead.\n- `signup_disabled` (403): Self-service sign-up is disabled on this connection.\n- `email_taken` (409): Another user in this account already uses that email.\n- `username_taken` (409): Another user of this database connection already uses that username.\n- `invalid_username` (400): The username is empty, or contains an `@` or whitespace. `message` says which.\n- `invalid_email` (400): The email address is not valid.\n- `invalid_name` (400): The name contains a link. `message` says which field.\n- `same_email` (400): The new email is the same as the current one.\n- `user_has_no_email` (400): The operation needs an email but the user has none.\n- `password_too_weak` (400): The password does not meet the connection policy. `message` lists each unmet rule.\n- `invalid_password_hash` (400): The imported password hash cannot be read, or its cost is above what a login can afford. `message` names the field.\n- `weak_password_hash` (400): The imported password hash uses a fast digest (MD5, SHA-1, SHA-2) or a cost below the minimum. `message` says which.\n- `invalid_code` (400): The verification code is wrong, expired or already used.\n- `invalid_phone` (400): The phone number is not E.164 and could not be resolved with the account default country. Include the country code.\n- `phone_changed` (400): The phone on the user changed after the code was sent. Start again.\n- `phone_already_verified` (409): The user already has a verified phone; the number cannot be swapped from the screen.\n- `sms_unavailable` (409): This account cannot send SMS right now. The suffix says why: `sms_unavailable:plan`, `:quota`, `:provider`.\n- `sms_failed` (409): The SMS provider rejected the message.\n- `invalid_state` (400): The `state` is missing, expired, or does not describe a resumable step.\n- `state_mismatch` (401): The `state` belongs to another account, session, client or ceremony.\n- `invalid_ticket` (400): The ticket does not exist or is not the kind this endpoint accepts.\n- `ticket_used` (400): The ticket was already consumed.\n- `ticket_expired` (400): The ticket is past its expiry.\n- `ticket_not_found` (404): No ticket with that id belongs to this user.\n- `credential_not_found` (404): The user has no password credential on this connection.\n- `invalid_link` (400): The magic link is malformed.\n- `expired_link` (400): The magic link token was not found or has expired.\n- `invalid_otp` (401): The one-time password is wrong or expired.\n- `invalid_client` (400): The `client_id` in the request does not name a client of this account.\n- `client_not_found` (404): No client with that `client_id`.\n- `client_mismatch` (401): The client belongs to a different account.\n- `invalid_client_metadata` (400): Dynamic client registration rejected a field (RFC 7591). `message` names it.\n- `invalid_connection` (400): The connection does not exist, is disabled, or is not of the type this flow needs.\n- `no_database_connection` (400): The tenant has no database connection, so there is nowhere to store a password. Create one and retry.\n- `connection_required` (400): No connection was given and the client has no `default_connection`.\n- `connection_misconfigured` (400): The connection is missing settings needed to talk to its provider.\n- `passwordless_unavailable` (400): No passwordless connection is configured or enabled for this client.\n- `ambiguous_connection` (400): Several connections match; pass `connection_id`.\n- `invalid_redirect_uri` (400): The redirect URI is not registered for the client.\n- `invalid_saml_request` (400): The SAMLRequest is missing, too large, not well-formed or not a SAML 2.0 AuthnRequest.\n- `saml_sp_mismatch` (400): The AuthnRequest does not match the client's SAML configuration: its Issuer is not the configured entity_id, or it asks for another Assertion Consumer Service URL or binding.\n- `saml_not_enabled` (404): This client does not have SAML enabled.\n- `invalid_saml_sp` (400): The `saml_sp` configuration is invalid: `acs_url` must be an absolute https URL without a fragment.\n- `origin_not_allowed` (403): The request `Origin` is not in the client's Allowed Web Origins.\n- `audience_not_found` (403): The `audience` does not name an API registered in this account.\n- `system_resource` (403): The resource is managed by Faable and cannot be modified or deleted.\n- `invalid_request` (400): A required OAuth parameter is missing or two of them are incompatible. `message` says which.\n- `invalid_device_code` (400): The device or user code is invalid or expired.\n- `provider_error` (400): The upstream identity provider returned an error. The suffix is the provider’s own code, when it sent one: `provider_error:bad_refresh_token` (the user must re-authorize), `provider_error:incorrect_client_credentials` (our configuration, the user can do nothing). `details.provider_error_description` carries the provider’s sentence.\n- `identity_already_linked` (409): That external identity is already linked to another user.\n- `identity_link_denied` (409): That external identity cannot be linked to this user.\n- `identity_conflict` (409): The user already has a linked identity for this connection.\n- `identity_orphaned` (400): The identity pointed at a user that no longer exists; it was removed. Sign in again.\n- `no_provider_token` (400): The identity holds no provider access token.\n- `no_refresh_token` (400): The identity holds no provider refresh token, so it cannot be refreshed.\n- `action_unavailable` (401): The Action that paused this login is disabled or belongs elsewhere.\n- `login_flow_misconfigured` (500): The login flow of this account cannot run. `message` carries the node error.\n- `mfa_required` (403): The login needs a second factor that this flow cannot collect, or one is still pending on the session.\n- `step_up_required` (403): Confirm an existing factor before changing your factors.\n- `reauth_required` (403): The operation needs a recent sign-in (and the second factor, when the user has one). Sign in again with `max_age=0` and retry with the new token.\n- `invalid_current_password` (400): The current password is wrong.\n- `email_not_verified` (403): The user's email is not verified, and the operation needs it to be.\n- `invalid_profile_field` (400): A profile field is not declared for this application, or its value does not match the declaration. `message` names the key.\n- `interaction_required` (403): An Action asked for a redirect on a non-interactive flow.\n- `passkey_login_disabled` (400): Sign in with a passkey is not enabled for this client.\n- `mfa_pending` (401): A second-factor challenge is pending; answer it before continuing.\n- `mfa_invalid_code` (401): The authenticator code did not verify.\n- `mfa_invalid_recovery_code` (401): The recovery code did not verify.\n- `too_many_attempts` (429): Too many wrong codes. Wait a few minutes and try again.\n- `too_many_login_attempts` (429): Too many failed sign-in attempts for this email or username from this network. Wait a few minutes and try again.\n- `totp_not_allowed` (401): This account does not accept authenticator apps.\n- `no_usable_factor` (400): The user has no confirmed second factor to challenge.\n- `factor_not_found` (400): No factor with that id belongs to this user.\n- `factor_already_confirmed` (400): The factor was already confirmed.\n- `invalid_factor` (400): The factor cannot be verified (no secret material).\n- `session_missing` (401): The session that started this login no longer exists.\n- `passkey_verification_failed` (400): The WebAuthn response could not be verified.\n- `no_passkey_enrolled` (400): The user has no passkey to sign in with.\n- `malformed_credential` (400): The WebAuthn credential is malformed.\n- `unknown_passkey` (401): The passkey is not enrolled for this user.\n- `invalid_passkey` (401): The passkey assertion did not verify.\n- `invalid_invite_url` (400): An invite's `accept_url` or `redirect_uri` is not an http(s) URL at an origin of this tenant's client callbacks or `callback_hostnames`, or it carries a fragment.\n- `invite_email_mismatch` (403): The invite is for another email address than the accepting user's. `details.invited_email` shows it masked.\n- `invite_email_unverified` (403): The accepting user's email matches the invite but is not verified yet.\n- `invite_not_found` (404): No invite with that id in this account.\n- `invite_used` (400): The invite was already accepted.\n- `already_member` (400): The user is already a member of the team.\n- `not_a_member` (404): The user is not a member of the team.\n- `consent_options_unavailable` (502): The consent screen could not load the choices from the API being authorized (its `consent_options.url`). Try again in a moment.\n- `consent_choice_invalid` (400): The choice sent with the consent decision is not one the API offered for this user.\n- `registration_disabled` (403): Dynamic client registration is turned off for this tenant. Create the client from the dashboard or the Management API, or turn on `dynamic_client_registration_enabled`.\n- `invalid_email_domain` (400): Not an email domain this organization can claim: it must be a plain domain name (`acme.com`), lowercase, without scheme, path or wildcard.\n- `public_email_domain` (400): A public email provider (gmail.com, outlook.com…) cannot belong to an organization: anyone can have an address there.\n- `organization_domain_not_found` (404): The organization has no such domain.\n- `organization_domain_taken` (409): Another organization of this tenant already verified this domain. A domain belongs to one organization.\n- `organization_domain_unverified` (400): The TXT record was not found or does not hold the expected value yet. `details.record` says what was expected and what resolves now; DNS can take a few minutes to propagate.\n- `plan_unavailable` (503): The plan could not be checked right now, and this setting is only for paid plans. Try again in a minute.\n- `organization_domain_mismatch` (403): This organization's identity provider can only sign in addresses of the organization's verified email domains.\n- `team_not_in_organization` (400): Auto-join can only put people in teams of the same organization. `details.teams` lists the others.\n- `organization_has_no_idp` (400): Requiring SSO needs an identity provider: bind an `oidc` connection to the organization first.\n- `organization_not_found` (404): No organization with that id in this account.\n- `invalid_user` (400): The user does not exist in this account.\n- `not_an_organization_admin` (404): The user is not an admin of the organization.\n- `last_organization_admin` (409): An organization keeps at least one admin. Add another admin before removing this one.\n- `team_id_unavailable` (409): The team id cannot be used in this tenant. Pick another id or let the API generate one.\n- `use_team_member_route` (400): This generic route cannot create memberships. Use `POST /team/{team_id}/member` for team members or `POST /role/{role_id}/users` for role grants.\n- `role_not_found` (404): No role with that id in this account.\n- `invalid_role` (400): The role belongs to another account.\n- `flow_not_found` (400): The login flow does not exist in this account.\n- `revision_not_found` (404): The revision is not in the flow history.\n- `invalid_webhook_event` (400): An event is not in the webhook event catalog. `message` and `details.valid_events` list the valid ones.\n- `invalid_webhook_url` (400): The webhook URL must be an absolute `https` URL.\n- `not_a_webhook` (400): The operation only applies to `webhook` subscriptions.\n- `delivery_not_found` (404): No webhook delivery with that id for this subscription.\n- `delivery_in_progress` (409): The delivery is being attempted right now; retry the redeliver in a minute.",
9661
9661
  "enum": [
9662
9662
  "bad_request",
9663
9663
  "validation_error",