@faable/auth-sdk 2.7.108 → 2.8.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 +74 -16
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/version.js +2 -2
- package/dist/webhooks.d.ts +117 -0
- package/dist/webhooks.js +170 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,12 +20,12 @@ Programmatically manage FaableAuth users, teams, connections and clients through
|
|
|
20
20
|
> the whole tenant. A user's own bearer token or the browser's session cookie
|
|
21
21
|
> only grant what that user can do, and those are fine client-side.
|
|
22
22
|
|
|
23
|
-
| Strategy | Credential | Grants
|
|
24
|
-
| ----------------------- | --------------------------------- |
|
|
23
|
+
| Strategy | Credential | Grants | Where |
|
|
24
|
+
| ----------------------- | --------------------------------- | ------------------------------------------------------------------ | --------------------------------------- |
|
|
25
25
|
| `authClientCredentials` | `client_id` + **`client_secret`** | The management scopes of the client (full catalog unless narrowed) | **Server only** |
|
|
26
|
-
| `authApikey` | **API key** (`fak_…`) | The management scopes of the key
|
|
27
|
-
| `authBearer` | A token the caller already holds | Whatever that token's scopes and subject allow
|
|
28
|
-
| `authCookie` | The browser's session cookie | The signed-in user's own session (`/me`, MFA, passkeys…)
|
|
26
|
+
| `authApikey` | **API key** (`fak_…`) | The management scopes of the key | **Server only** |
|
|
27
|
+
| `authBearer` | A token the caller already holds | Whatever that token's scopes and subject allow | Anywhere the token itself may live |
|
|
28
|
+
| `authCookie` | The browser's session cookie | The signed-in user's own session (`/me`, MFA, passkeys…) | Browser, same-origin with the auth host |
|
|
29
29
|
| _(none)_ | — | Public endpoints only (login, passwordless start, device flow…) | Anywhere |
|
|
30
30
|
|
|
31
31
|
---
|
|
@@ -136,7 +136,9 @@ discovery). Those are not on the generated client; call them through the
|
|
|
136
136
|
fetcher:
|
|
137
137
|
|
|
138
138
|
```ts
|
|
139
|
-
const api = FaableAuthApi.create({
|
|
139
|
+
const api = FaableAuthApi.create({
|
|
140
|
+
domain: "https://<your-account>.auth.faable.link",
|
|
141
|
+
});
|
|
140
142
|
await api.fetcher.post("/passwordless/start", { email, client_id });
|
|
141
143
|
```
|
|
142
144
|
|
|
@@ -148,7 +150,10 @@ itself instead — the value you pass replaces the default, on data calls and on
|
|
|
148
150
|
the token request alike:
|
|
149
151
|
|
|
150
152
|
```ts
|
|
151
|
-
FaableAuthApi.create({
|
|
153
|
+
FaableAuthApi.create({
|
|
154
|
+
domain,
|
|
155
|
+
clientInfo: { client: "my-app/1.4.0", name: "my-app" },
|
|
156
|
+
});
|
|
152
157
|
```
|
|
153
158
|
|
|
154
159
|
### Per-call options
|
|
@@ -164,14 +169,14 @@ await api.fetcher.get(`/user/${id}`, { timeout: 800, retry: false });
|
|
|
164
169
|
|
|
165
170
|
### Constructor options
|
|
166
171
|
|
|
167
|
-
| Option | Type
|
|
168
|
-
| -------------- |
|
|
169
|
-
| `domain` | string
|
|
170
|
-
| `authStrategy` | strategy builder
|
|
171
|
-
| `auth` | config
|
|
172
|
-
| `debug` | boolean
|
|
173
|
-
| `headers` | `{ project_id?; account_id? }`
|
|
174
|
-
| `clientInfo` | `{ client?; name?; instance? }`
|
|
172
|
+
| Option | Type | Description |
|
|
173
|
+
| -------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
174
|
+
| `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. |
|
|
175
|
+
| `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. |
|
|
176
|
+
| `auth` | config | Config for the chosen strategy. Its shape is inferred from `authStrategy` (e.g. `{ client_id, client_secret }`). |
|
|
177
|
+
| `debug` | boolean | Enables verbose logging in the underlying fetcher. |
|
|
178
|
+
| `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`. |
|
|
179
|
+
| `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>`. |
|
|
175
180
|
|
|
176
181
|
---
|
|
177
182
|
|
|
@@ -273,12 +278,65 @@ for await (const page of paginator) {
|
|
|
273
278
|
}
|
|
274
279
|
```
|
|
275
280
|
|
|
281
|
+
## Webhooks
|
|
282
|
+
|
|
283
|
+
Faable Auth sends signed `POST`s 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:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
import {
|
|
287
|
+
constructWebhookEvent,
|
|
288
|
+
readRawBody,
|
|
289
|
+
WebhookVerificationError,
|
|
290
|
+
} from "@faable/auth-sdk";
|
|
291
|
+
|
|
292
|
+
// Next.js pages router: the signature is over the RAW body.
|
|
293
|
+
export const config = { api: { bodyParser: false } };
|
|
294
|
+
|
|
295
|
+
export default async function handler(req, res) {
|
|
296
|
+
let event;
|
|
297
|
+
try {
|
|
298
|
+
event = await constructWebhookEvent(
|
|
299
|
+
await readRawBody(req), // fetch-style handlers: await request.text()
|
|
300
|
+
req.headers,
|
|
301
|
+
process.env.FAABLEAUTH_WEBHOOK_SECRET!,
|
|
302
|
+
);
|
|
303
|
+
} catch (e) {
|
|
304
|
+
if (e instanceof WebhookVerificationError) return res.status(401).end();
|
|
305
|
+
throw e;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
switch (event.type) {
|
|
309
|
+
case "user.created":
|
|
310
|
+
await welcome(event.payload.email); // payload is the User
|
|
311
|
+
break;
|
|
312
|
+
case "team.member.added":
|
|
313
|
+
await sync(event.payload.team_id, event.payload.user_id);
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
res.status(200).end();
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
- **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.
|
|
321
|
+
- **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.
|
|
322
|
+
- **Rotation:** `faable auth webhooks rotate-secret` keeps 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])`.
|
|
323
|
+
- Uses Web Crypto, so it runs on Node ≥ 19, Deno, Bun and edge runtimes; that is why it is `async`.
|
|
324
|
+
- Deliveries older than 5 minutes are refused as replays (`toleranceMs` to change it).
|
|
325
|
+
|
|
326
|
+
`WebhookEvent`, `WebhookEventType`, `WebhookEventOf<"user.created">` and `WebhookPayload<"team.member.added">` are generated from the auth OpenAPI, like every other type.
|
|
327
|
+
|
|
276
328
|
## Types
|
|
277
329
|
|
|
278
330
|
All response shapes are generated from the OpenAPI spec and re-exported from the package root:
|
|
279
331
|
|
|
280
332
|
```ts
|
|
281
|
-
import type {
|
|
333
|
+
import type {
|
|
334
|
+
User,
|
|
335
|
+
Team,
|
|
336
|
+
TeamMember,
|
|
337
|
+
Connection,
|
|
338
|
+
Client,
|
|
339
|
+
} from "@faable/auth-sdk";
|
|
282
340
|
```
|
|
283
341
|
|
|
284
342
|
## License
|
package/dist/index.d.ts
CHANGED
|
@@ -3,5 +3,7 @@ export { SDK_CLIENT, commit, version } from "./version.js";
|
|
|
3
3
|
export * from "./api/generated-client.js";
|
|
4
4
|
export * from "./api/api-types.js";
|
|
5
5
|
export * from "./api/types.js";
|
|
6
|
+
export { WEBHOOK_HEADERS, DEFAULT_WEBHOOK_TOLERANCE_MS, WebhookVerificationError, verifyWebhookSignature, constructWebhookEvent, readRawBody, } from "./webhooks.js";
|
|
7
|
+
export type { WebhookEventOf, WebhookPayload, WebhookHeaders, WebhookVerificationFailure, VerifyWebhookOptions, VerifiedWebhookEvent, } from "./webhooks.js";
|
|
6
8
|
export { authClientCredentials, authApikey, authBearer, authCookie, FaableApiError, } from "@faable/sdk-base";
|
|
7
9
|
export type { ApiParams, ClientCredentialsConfig, ApikeyConfig, BearerConfig, RetryConfig, Fetcher, FetcherConfig, FetcherRequestParams, AuthStrategy, AuthStrategyBuilder, AuthStrategyContext, } from "@faable/sdk-base";
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,8 @@ export { SDK_CLIENT, commit, version } from "./version.js";
|
|
|
3
3
|
export * from "./api/generated-client.js";
|
|
4
4
|
export * from "./api/api-types.js";
|
|
5
5
|
export * from "./api/types.js";
|
|
6
|
+
// Receiving webhooks: verify the signature, get the event typed.
|
|
7
|
+
export { WEBHOOK_HEADERS, DEFAULT_WEBHOOK_TOLERANCE_MS, WebhookVerificationError, verifyWebhookSignature, constructWebhookEvent, readRawBody, } from "./webhooks.js";
|
|
6
8
|
// Re-export the auth strategies (and their config/types) from
|
|
7
9
|
// @faable/sdk-base so consumers depend on @faable/auth-sdk ONLY — sdk-base
|
|
8
10
|
// stays a transitive dependency and no app needs to import it directly.
|
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.8.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 = "861ed06";
|
|
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
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { WebhookEvent, WebhookEventType } from "./api/api-types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Receiving Faable Auth webhooks.
|
|
4
|
+
*
|
|
5
|
+
* Every delivery is a `POST` whose JSON body is a {@link WebhookEvent}, signed
|
|
6
|
+
* with the subscription's secret:
|
|
7
|
+
*
|
|
8
|
+
* - `x-faable-timestamp`: when it was sent, in Unix milliseconds.
|
|
9
|
+
* - `x-faable-signature`: `sha256=<hex>` — HMAC-SHA256 of
|
|
10
|
+
* `${timestamp}.${raw body}`. During a secret rotation it carries two,
|
|
11
|
+
* comma-separated (new first); one matching is enough.
|
|
12
|
+
* - `x-faable-delivery`: the event id, the same on every retry.
|
|
13
|
+
*
|
|
14
|
+
* Failed deliveries (timeout, network, 5xx, 429) are retried at 1 min, 5 min,
|
|
15
|
+
* 30 min, 2 h and 12 h, so a receiver must answer 2xx only once it has done
|
|
16
|
+
* its work, and must tolerate the same event twice.
|
|
17
|
+
*
|
|
18
|
+
* The event types come from the auth OpenAPI of the release this SDK was
|
|
19
|
+
* generated from: `type` narrows `payload`.
|
|
20
|
+
*/
|
|
21
|
+
export type { WebhookEvent, WebhookEventType };
|
|
22
|
+
/** The delivery of one event type, e.g. `WebhookEventOf<"user.created">`. */
|
|
23
|
+
export type WebhookEventOf<T extends WebhookEventType> = Extract<WebhookEvent, {
|
|
24
|
+
type: T;
|
|
25
|
+
}>;
|
|
26
|
+
/** The payload of one event type, e.g. the `User` for `"user.created"`. */
|
|
27
|
+
export type WebhookPayload<T extends WebhookEventType> = WebhookEventOf<T>["payload"];
|
|
28
|
+
export declare const WEBHOOK_HEADERS: {
|
|
29
|
+
readonly signature: "x-faable-signature";
|
|
30
|
+
readonly timestamp: "x-faable-timestamp";
|
|
31
|
+
readonly delivery: "x-faable-delivery";
|
|
32
|
+
readonly event: "x-faable-event";
|
|
33
|
+
};
|
|
34
|
+
/** How far a delivery's timestamp may be from now before it is a replay. */
|
|
35
|
+
export declare const DEFAULT_WEBHOOK_TOLERANCE_MS: number;
|
|
36
|
+
export type WebhookVerificationFailure = "missing_secret" | "missing_headers" | "timestamp_out_of_tolerance" | "signature_mismatch" | "invalid_body";
|
|
37
|
+
/**
|
|
38
|
+
* Thrown by {@link verifyWebhookSignature} and {@link constructWebhookEvent}.
|
|
39
|
+
* Answer the delivery with a 400/401 and do nothing else: it was not sent by
|
|
40
|
+
* Faable Auth with your secret, or it was tampered with or replayed.
|
|
41
|
+
*/
|
|
42
|
+
export declare class WebhookVerificationError extends Error {
|
|
43
|
+
readonly reason: WebhookVerificationFailure;
|
|
44
|
+
constructor(reason: WebhookVerificationFailure, message: string);
|
|
45
|
+
}
|
|
46
|
+
/** Fetch `Headers`, Node's `IncomingHttpHeaders`, or a plain object. */
|
|
47
|
+
export type WebhookHeaders = {
|
|
48
|
+
get(name: string): string | null;
|
|
49
|
+
} | Record<string, string | string[] | undefined>;
|
|
50
|
+
type RawBody = string | Uint8Array;
|
|
51
|
+
export type VerifyWebhookOptions = {
|
|
52
|
+
/** The raw request body, exactly as received — not re-serialised JSON. */
|
|
53
|
+
body: RawBody;
|
|
54
|
+
/** `x-faable-signature`. */
|
|
55
|
+
signature: string | undefined;
|
|
56
|
+
/** `x-faable-timestamp`. */
|
|
57
|
+
timestamp: string | undefined;
|
|
58
|
+
/**
|
|
59
|
+
* The subscription's signing secret. Pass several while you rotate on your
|
|
60
|
+
* side (old and new): any of them may verify the delivery.
|
|
61
|
+
*/
|
|
62
|
+
secret: string | string[];
|
|
63
|
+
/** Defaults to 5 minutes. */
|
|
64
|
+
toleranceMs?: number;
|
|
65
|
+
/** For tests. Defaults to `Date.now()`. */
|
|
66
|
+
now?: number;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Checks that a delivery was signed by Faable Auth with your secret, recently.
|
|
70
|
+
* Throws {@link WebhookVerificationError}; returns nothing when it is valid.
|
|
71
|
+
*
|
|
72
|
+
* Prefer {@link constructWebhookEvent}, which also parses and types the body.
|
|
73
|
+
*/
|
|
74
|
+
export declare function verifyWebhookSignature(options: VerifyWebhookOptions): Promise<void>;
|
|
75
|
+
/** A verified delivery: the typed event plus the delivery id to deduplicate on. */
|
|
76
|
+
export type VerifiedWebhookEvent = WebhookEvent & {
|
|
77
|
+
/** `x-faable-delivery`: the same on every retry of this event. */
|
|
78
|
+
delivery_id: string;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Verifies a delivery and returns its typed event.
|
|
82
|
+
*
|
|
83
|
+
* @example Next.js (pages router)
|
|
84
|
+
* ```ts
|
|
85
|
+
* import { constructWebhookEvent, readRawBody } from "@faable/auth-sdk";
|
|
86
|
+
*
|
|
87
|
+
* export const config = { api: { bodyParser: false } };
|
|
88
|
+
*
|
|
89
|
+
* export default async function handler(req, res) {
|
|
90
|
+
* let event;
|
|
91
|
+
* try {
|
|
92
|
+
* event = await constructWebhookEvent(
|
|
93
|
+
* await readRawBody(req),
|
|
94
|
+
* req.headers,
|
|
95
|
+
* process.env.FAABLEAUTH_WEBHOOK_SECRET!,
|
|
96
|
+
* );
|
|
97
|
+
* } catch {
|
|
98
|
+
* return res.status(401).end();
|
|
99
|
+
* }
|
|
100
|
+
* if (event.type === "user.created") {
|
|
101
|
+
* event.payload.email; // typed: the User
|
|
102
|
+
* }
|
|
103
|
+
* res.status(200).end();
|
|
104
|
+
* }
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
export declare function constructWebhookEvent(body: RawBody, headers: WebhookHeaders, secret: string | string[], options?: {
|
|
108
|
+
toleranceMs?: number;
|
|
109
|
+
now?: number;
|
|
110
|
+
}): Promise<VerifiedWebhookEvent>;
|
|
111
|
+
/**
|
|
112
|
+
* The raw body of a Node request (`IncomingMessage`, Next.js pages API with
|
|
113
|
+
* `bodyParser: false`, Express without `express.json()` on the route): what
|
|
114
|
+
* the signature is computed over. With fetch-style handlers (Next.js route
|
|
115
|
+
* handlers, Hono…) use `await request.text()` instead.
|
|
116
|
+
*/
|
|
117
|
+
export declare const readRawBody: (req: AsyncIterable<Uint8Array | string>) => Promise<string>;
|
package/dist/webhooks.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
export const WEBHOOK_HEADERS = {
|
|
2
|
+
signature: "x-faable-signature",
|
|
3
|
+
timestamp: "x-faable-timestamp",
|
|
4
|
+
delivery: "x-faable-delivery",
|
|
5
|
+
event: "x-faable-event",
|
|
6
|
+
};
|
|
7
|
+
/** How far a delivery's timestamp may be from now before it is a replay. */
|
|
8
|
+
export const DEFAULT_WEBHOOK_TOLERANCE_MS = 5 * 60 * 1000;
|
|
9
|
+
/**
|
|
10
|
+
* Thrown by {@link verifyWebhookSignature} and {@link constructWebhookEvent}.
|
|
11
|
+
* Answer the delivery with a 400/401 and do nothing else: it was not sent by
|
|
12
|
+
* Faable Auth with your secret, or it was tampered with or replayed.
|
|
13
|
+
*/
|
|
14
|
+
export class WebhookVerificationError extends Error {
|
|
15
|
+
reason;
|
|
16
|
+
constructor(reason, message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "WebhookVerificationError";
|
|
19
|
+
this.reason = reason;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const readHeader = (headers, name) => {
|
|
23
|
+
if (typeof headers.get === "function") {
|
|
24
|
+
return (headers.get(name) ?? undefined);
|
|
25
|
+
}
|
|
26
|
+
const record = headers;
|
|
27
|
+
const value = record[name] ??
|
|
28
|
+
Object.entries(record).find(([k]) => k.toLowerCase() === name)?.[1];
|
|
29
|
+
return Array.isArray(value) ? value[0] : value;
|
|
30
|
+
};
|
|
31
|
+
// Web Crypto and TextEncoder, not `node:crypto` / `Buffer`: this package also
|
|
32
|
+
// loads in browser bundles (the hosted login pages), and the same code then
|
|
33
|
+
// runs on Node ≥ 19, Deno, Bun and edge runtimes.
|
|
34
|
+
const encoder = new TextEncoder();
|
|
35
|
+
const decoder = new TextDecoder();
|
|
36
|
+
const bodyText = (body) => typeof body === "string" ? body : decoder.decode(body);
|
|
37
|
+
const fromHex = (hex) => {
|
|
38
|
+
if (hex.length % 2 !== 0 || !/^[0-9a-f]*$/i.test(hex))
|
|
39
|
+
return null;
|
|
40
|
+
const out = new Uint8Array(hex.length / 2);
|
|
41
|
+
for (let i = 0; i < out.length; i++) {
|
|
42
|
+
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
};
|
|
46
|
+
// Constant time for equal lengths: the comparison does not stop at the first
|
|
47
|
+
// differing byte, so its timing does not leak how much of a guess was right.
|
|
48
|
+
const sameBytes = (a, b) => {
|
|
49
|
+
if (a.length !== b.length)
|
|
50
|
+
return false;
|
|
51
|
+
let diff = 0;
|
|
52
|
+
for (let i = 0; i < a.length; i++)
|
|
53
|
+
diff |= a[i] ^ b[i];
|
|
54
|
+
return diff === 0;
|
|
55
|
+
};
|
|
56
|
+
const hmacSha256 = async (secret, data) => {
|
|
57
|
+
const subtle = globalThis.crypto?.subtle;
|
|
58
|
+
if (!subtle) {
|
|
59
|
+
throw new Error("Web Crypto (globalThis.crypto.subtle) is not available: verifying webhooks needs Node ≥ 19 or a runtime with Web Crypto.");
|
|
60
|
+
}
|
|
61
|
+
const key = await subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
62
|
+
return new Uint8Array(await subtle.sign("HMAC", key, encoder.encode(data)));
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Checks that a delivery was signed by Faable Auth with your secret, recently.
|
|
66
|
+
* Throws {@link WebhookVerificationError}; returns nothing when it is valid.
|
|
67
|
+
*
|
|
68
|
+
* Prefer {@link constructWebhookEvent}, which also parses and types the body.
|
|
69
|
+
*/
|
|
70
|
+
export async function verifyWebhookSignature(options) {
|
|
71
|
+
const secrets = (Array.isArray(options.secret) ? options.secret : [options.secret]).filter((s) => typeof s === "string" && s.length > 0);
|
|
72
|
+
if (secrets.length === 0) {
|
|
73
|
+
throw new WebhookVerificationError("missing_secret", "No webhook secret configured: refusing every delivery.");
|
|
74
|
+
}
|
|
75
|
+
const { signature, timestamp } = options;
|
|
76
|
+
if (!signature || !timestamp) {
|
|
77
|
+
throw new WebhookVerificationError("missing_headers", `Missing ${WEBHOOK_HEADERS.signature} or ${WEBHOOK_HEADERS.timestamp}.`);
|
|
78
|
+
}
|
|
79
|
+
const sent = Number(timestamp);
|
|
80
|
+
const tolerance = options.toleranceMs ?? DEFAULT_WEBHOOK_TOLERANCE_MS;
|
|
81
|
+
const now = options.now ?? Date.now();
|
|
82
|
+
if (!Number.isFinite(sent) || Math.abs(now - sent) > tolerance) {
|
|
83
|
+
throw new WebhookVerificationError("timestamp_out_of_tolerance", "The delivery timestamp is outside the tolerance: a replay, or a clock far off.");
|
|
84
|
+
}
|
|
85
|
+
const signed = `${timestamp}.${bodyText(options.body)}`;
|
|
86
|
+
const received = signature
|
|
87
|
+
.split(",")
|
|
88
|
+
.map((part) => part.trim())
|
|
89
|
+
.filter((part) => part.startsWith("sha256="))
|
|
90
|
+
.map((part) => fromHex(part.slice("sha256=".length)))
|
|
91
|
+
.filter((r) => r !== null);
|
|
92
|
+
let matches = false;
|
|
93
|
+
for (const secret of secrets) {
|
|
94
|
+
const expected = await hmacSha256(secret, signed);
|
|
95
|
+
if (received.some((r) => sameBytes(r, expected)))
|
|
96
|
+
matches = true;
|
|
97
|
+
}
|
|
98
|
+
if (!matches) {
|
|
99
|
+
throw new WebhookVerificationError("signature_mismatch", "The signature does not match: wrong secret, or the body was changed (verify the RAW body, not re-serialised JSON).");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Verifies a delivery and returns its typed event.
|
|
104
|
+
*
|
|
105
|
+
* @example Next.js (pages router)
|
|
106
|
+
* ```ts
|
|
107
|
+
* import { constructWebhookEvent, readRawBody } from "@faable/auth-sdk";
|
|
108
|
+
*
|
|
109
|
+
* export const config = { api: { bodyParser: false } };
|
|
110
|
+
*
|
|
111
|
+
* export default async function handler(req, res) {
|
|
112
|
+
* let event;
|
|
113
|
+
* try {
|
|
114
|
+
* event = await constructWebhookEvent(
|
|
115
|
+
* await readRawBody(req),
|
|
116
|
+
* req.headers,
|
|
117
|
+
* process.env.FAABLEAUTH_WEBHOOK_SECRET!,
|
|
118
|
+
* );
|
|
119
|
+
* } catch {
|
|
120
|
+
* return res.status(401).end();
|
|
121
|
+
* }
|
|
122
|
+
* if (event.type === "user.created") {
|
|
123
|
+
* event.payload.email; // typed: the User
|
|
124
|
+
* }
|
|
125
|
+
* res.status(200).end();
|
|
126
|
+
* }
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
export async function constructWebhookEvent(body, headers, secret, options = {}) {
|
|
130
|
+
await verifyWebhookSignature({
|
|
131
|
+
body,
|
|
132
|
+
signature: readHeader(headers, WEBHOOK_HEADERS.signature),
|
|
133
|
+
timestamp: readHeader(headers, WEBHOOK_HEADERS.timestamp),
|
|
134
|
+
secret,
|
|
135
|
+
...options,
|
|
136
|
+
});
|
|
137
|
+
let event;
|
|
138
|
+
try {
|
|
139
|
+
event = JSON.parse(bodyText(body));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new WebhookVerificationError("invalid_body", "The body is not JSON.");
|
|
143
|
+
}
|
|
144
|
+
if (!event || typeof event !== "object" || typeof event.type !== "string") {
|
|
145
|
+
throw new WebhookVerificationError("invalid_body", "The body is not a webhook event.");
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
...event,
|
|
149
|
+
delivery_id: readHeader(headers, WEBHOOK_HEADERS.delivery) ?? event.id,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The raw body of a Node request (`IncomingMessage`, Next.js pages API with
|
|
154
|
+
* `bodyParser: false`, Express without `express.json()` on the route): what
|
|
155
|
+
* the signature is computed over. With fetch-style handlers (Next.js route
|
|
156
|
+
* handlers, Hono…) use `await request.text()` instead.
|
|
157
|
+
*/
|
|
158
|
+
export const readRawBody = async (req) => {
|
|
159
|
+
const chunks = [];
|
|
160
|
+
for await (const chunk of req) {
|
|
161
|
+
chunks.push(typeof chunk === "string" ? encoder.encode(chunk) : chunk);
|
|
162
|
+
}
|
|
163
|
+
const all = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
|
|
164
|
+
let at = 0;
|
|
165
|
+
for (const c of chunks) {
|
|
166
|
+
all.set(c, at);
|
|
167
|
+
at += c.length;
|
|
168
|
+
}
|
|
169
|
+
return decoder.decode(all);
|
|
170
|
+
};
|