@faable/auth-sdk 2.8.27 → 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 +51 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/tokens.d.ts +82 -0
- package/dist/tokens.js +94 -0
- package/dist/version.js +2 -2
- package/package.json +3 -2
- package/scripts/gen-client.mjs +10 -0
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:
|
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.
|
package/dist/tokens.d.ts
ADDED
|
@@ -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.
|
|
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 = "
|
|
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.
|
|
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",
|
package/scripts/gen-client.mjs
CHANGED
|
@@ -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
|