@faable/auth-sdk 2.7.107 → 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/api/api-types.d.ts +25 -0
- package/dist/api/types.d.ts +218 -0
- 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/spec/openapi.json +817 -1
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
|
+
};
|