@korajs/auth 1.0.0-beta.11 → 1.0.0-beta.13
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 +52 -47
- package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
- package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
- package/dist/index.cjs +645 -150
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -9
- package/dist/index.d.ts +27 -9
- package/dist/index.js +644 -150
- package/dist/index.js.map +1 -1
- package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
- package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
- package/dist/react.d.cts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/server.cjs +2852 -1675
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +779 -168
- package/dist/server.d.ts +779 -168
- package/dist/server.js +2831 -1665
- package/dist/server.js.map +1 -1
- package/dist/svelte.cjs +2 -2
- package/dist/svelte.cjs.map +1 -1
- package/dist/svelte.d.cts +2 -2
- package/dist/svelte.d.ts +2 -2
- package/dist/svelte.js +2 -2
- package/dist/svelte.js.map +1 -1
- package/dist/vue.d.cts +1 -1
- package/dist/vue.d.ts +1 -1
- package/package.json +7 -7
- package/src/admin/admin-api.ts +327 -0
- package/src/admin/audit-log.ts +324 -0
- package/src/admin/webhooks.ts +576 -0
- package/src/bindings/create-auth-session.ts +184 -0
- package/src/bindings/create-org-session.ts +130 -0
- package/src/client/auth-client.ts +1592 -0
- package/src/client/auth-sync.ts +213 -0
- package/src/client/device-session.ts +104 -0
- package/src/client/org-client.ts +399 -0
- package/src/client/quickstart.ts +108 -0
- package/src/client/storage.ts +94 -0
- package/src/device/device-identity.ts +330 -0
- package/src/device/device-store.ts +379 -0
- package/src/encryption/auto-lock.ts +170 -0
- package/src/encryption/database-encryption.ts +265 -0
- package/src/encryption/key-derivation.ts +149 -0
- package/src/encryption/operation-encryptor.ts +361 -0
- package/src/index.ts +132 -0
- package/src/mfa/totp.ts +826 -0
- package/src/org/org-routes.ts +758 -0
- package/src/org/org-store.ts +490 -0
- package/src/org/org-types.ts +230 -0
- package/src/passkey/passkey-client.ts +597 -0
- package/src/passkey/passkey-server.ts +779 -0
- package/src/postgres/ensure-schema.ts +65 -0
- package/src/provider/adapter.ts +246 -0
- package/src/provider/built-in/auth-routes.ts +1313 -0
- package/src/provider/built-in/email-verification.ts +303 -0
- package/src/provider/built-in/password-hash.ts +118 -0
- package/src/provider/built-in/password-reset.ts +416 -0
- package/src/provider/built-in/postgres-user-store.ts +328 -0
- package/src/provider/built-in/quickstart-server.ts +760 -0
- package/src/provider/built-in/sqlite-user-store.ts +322 -0
- package/src/provider/built-in/sync-scopes.ts +85 -0
- package/src/provider/built-in/user-store.ts +465 -0
- package/src/provider/external/clerk-adapter.ts +157 -0
- package/src/provider/external/external-jwt-provider.ts +491 -0
- package/src/provider/external/supabase-adapter.ts +163 -0
- package/src/provider/oauth/linked-identity-store.ts +108 -0
- package/src/provider/oauth/oauth-flow.ts +550 -0
- package/src/provider/oauth/oauth-types.ts +184 -0
- package/src/provider/oauth/postgres-oauth-store.ts +296 -0
- package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
- package/src/rbac/rbac-engine.ts +323 -0
- package/src/rbac/rbac-types.ts +210 -0
- package/src/rbac/scope-resolver.ts +140 -0
- package/src/react/AuthProvider.tsx +97 -0
- package/src/react/OrgProvider.tsx +41 -0
- package/src/react/auth-context.ts +26 -0
- package/src/react/hooks.ts +110 -0
- package/src/react/org-hooks.ts +214 -0
- package/src/react.ts +26 -0
- package/src/server.ts +334 -0
- package/src/session/session.ts +401 -0
- package/src/svelte/auth-context.ts +50 -0
- package/src/svelte/org-context.ts +32 -0
- package/src/svelte/org-hooks.ts +201 -0
- package/src/svelte/use-auth.ts +115 -0
- package/src/svelte.ts +25 -0
- package/src/tokens/encrypted-token-store.ts +360 -0
- package/src/tokens/jwt.ts +236 -0
- package/src/tokens/postgres-token-revocation-store.ts +140 -0
- package/src/tokens/sqlite-token-revocation-store.ts +121 -0
- package/src/tokens/token-manager.ts +821 -0
- package/src/tokens/token-store.ts +192 -0
- package/src/types.ts +394 -0
- package/src/vue/auth-context.ts +10 -0
- package/src/vue/auth-provider-types.ts +5 -0
- package/src/vue/auth-provider.ts +76 -0
- package/src/vue/org-hooks.ts +193 -0
- package/src/vue/org-provider.ts +49 -0
- package/src/vue/use-auth.ts +139 -0
- package/src/vue.ts +10 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { KoraError } from '@korajs/core'
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Webhook Types
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Events that can trigger webhooks.
|
|
9
|
+
*/
|
|
10
|
+
const WEBHOOK_EVENTS = [
|
|
11
|
+
'user.created',
|
|
12
|
+
'user.updated',
|
|
13
|
+
'user.deleted',
|
|
14
|
+
'user.signin',
|
|
15
|
+
'user.signout',
|
|
16
|
+
'user.password_changed',
|
|
17
|
+
'user.email_verified',
|
|
18
|
+
'mfa.enabled',
|
|
19
|
+
'mfa.disabled',
|
|
20
|
+
'session.created',
|
|
21
|
+
'session.revoked',
|
|
22
|
+
'org.created',
|
|
23
|
+
'org.updated',
|
|
24
|
+
'org.deleted',
|
|
25
|
+
'org.member_added',
|
|
26
|
+
'org.member_removed',
|
|
27
|
+
'org.invitation_created',
|
|
28
|
+
'org.invitation_accepted',
|
|
29
|
+
] as const
|
|
30
|
+
|
|
31
|
+
export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A webhook endpoint configuration.
|
|
35
|
+
*/
|
|
36
|
+
export interface WebhookEndpoint {
|
|
37
|
+
/** Unique endpoint ID */
|
|
38
|
+
id: string
|
|
39
|
+
/** Destination URL */
|
|
40
|
+
url: string
|
|
41
|
+
/** Events this endpoint subscribes to */
|
|
42
|
+
events: WebhookEvent[]
|
|
43
|
+
/** Signing secret for HMAC verification */
|
|
44
|
+
secret: string
|
|
45
|
+
/** Whether this endpoint is active */
|
|
46
|
+
active: boolean
|
|
47
|
+
/** When this endpoint was created */
|
|
48
|
+
createdAt: number
|
|
49
|
+
/** Custom metadata */
|
|
50
|
+
metadata?: Record<string, unknown>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A webhook delivery attempt.
|
|
55
|
+
*/
|
|
56
|
+
export interface WebhookDelivery {
|
|
57
|
+
/** Unique delivery ID */
|
|
58
|
+
id: string
|
|
59
|
+
/** Endpoint ID */
|
|
60
|
+
endpointId: string
|
|
61
|
+
/** Event that triggered this delivery */
|
|
62
|
+
event: WebhookEvent
|
|
63
|
+
/** Request payload (JSON) */
|
|
64
|
+
payload: string
|
|
65
|
+
/** HTTP response status (null if network error) */
|
|
66
|
+
responseStatus: number | null
|
|
67
|
+
/** Whether the delivery was successful (2xx response) */
|
|
68
|
+
success: boolean
|
|
69
|
+
/** Error message if delivery failed */
|
|
70
|
+
error: string | null
|
|
71
|
+
/** Number of attempts made */
|
|
72
|
+
attempts: number
|
|
73
|
+
/** When the delivery was first attempted */
|
|
74
|
+
createdAt: number
|
|
75
|
+
/** When the delivery was last attempted */
|
|
76
|
+
lastAttemptAt: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Payload sent to webhook endpoints.
|
|
81
|
+
*/
|
|
82
|
+
export interface WebhookPayload {
|
|
83
|
+
/** Unique event ID */
|
|
84
|
+
id: string
|
|
85
|
+
/** Event type */
|
|
86
|
+
event: WebhookEvent
|
|
87
|
+
/** When the event occurred */
|
|
88
|
+
timestamp: number
|
|
89
|
+
/** Event data */
|
|
90
|
+
data: Record<string, unknown>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Store for webhook endpoints and deliveries.
|
|
95
|
+
*/
|
|
96
|
+
export interface WebhookStore {
|
|
97
|
+
/** Save a webhook endpoint */
|
|
98
|
+
saveEndpoint(endpoint: WebhookEndpoint): Promise<void>
|
|
99
|
+
/** Get an endpoint by ID */
|
|
100
|
+
getEndpoint(id: string): Promise<WebhookEndpoint | null>
|
|
101
|
+
/** List all endpoints */
|
|
102
|
+
listEndpoints(): Promise<WebhookEndpoint[]>
|
|
103
|
+
/** Delete an endpoint */
|
|
104
|
+
deleteEndpoint(id: string): Promise<void>
|
|
105
|
+
/** Save a delivery record */
|
|
106
|
+
saveDelivery(delivery: WebhookDelivery): Promise<void>
|
|
107
|
+
/** List recent deliveries for an endpoint */
|
|
108
|
+
listDeliveries(endpointId: string, limit?: number): Promise<WebhookDelivery[]>
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ============================================================================
|
|
112
|
+
// Errors
|
|
113
|
+
// ============================================================================
|
|
114
|
+
|
|
115
|
+
export class WebhookError extends KoraError {
|
|
116
|
+
constructor(message: string, code: string, context?: Record<string, unknown>) {
|
|
117
|
+
super(message, code, context)
|
|
118
|
+
this.name = 'WebhookError'
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export class WebhookEndpointNotFoundError extends WebhookError {
|
|
123
|
+
constructor(id: string) {
|
|
124
|
+
super(`Webhook endpoint "${id}" not found.`, 'WEBHOOK_ENDPOINT_NOT_FOUND', { id })
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ============================================================================
|
|
129
|
+
// InMemoryWebhookStore
|
|
130
|
+
// ============================================================================
|
|
131
|
+
|
|
132
|
+
export class InMemoryWebhookStore implements WebhookStore {
|
|
133
|
+
private readonly endpoints = new Map<string, WebhookEndpoint>()
|
|
134
|
+
private readonly deliveries: WebhookDelivery[] = []
|
|
135
|
+
|
|
136
|
+
async saveEndpoint(endpoint: WebhookEndpoint): Promise<void> {
|
|
137
|
+
this.endpoints.set(endpoint.id, { ...endpoint })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async getEndpoint(id: string): Promise<WebhookEndpoint | null> {
|
|
141
|
+
const ep = this.endpoints.get(id)
|
|
142
|
+
return ep ? { ...ep } : null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async listEndpoints(): Promise<WebhookEndpoint[]> {
|
|
146
|
+
return [...this.endpoints.values()].map((e) => ({ ...e }))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async deleteEndpoint(id: string): Promise<void> {
|
|
150
|
+
this.endpoints.delete(id)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async saveDelivery(delivery: WebhookDelivery): Promise<void> {
|
|
154
|
+
const existing = this.deliveries.findIndex((d) => d.id === delivery.id)
|
|
155
|
+
if (existing >= 0) {
|
|
156
|
+
this.deliveries[existing] = { ...delivery }
|
|
157
|
+
} else {
|
|
158
|
+
this.deliveries.push({ ...delivery })
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async listDeliveries(endpointId: string, limit = 50): Promise<WebhookDelivery[]> {
|
|
163
|
+
return this.deliveries
|
|
164
|
+
.filter((d) => d.endpointId === endpointId)
|
|
165
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
166
|
+
.slice(0, limit)
|
|
167
|
+
.map((d) => ({ ...d }))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ============================================================================
|
|
172
|
+
// WebhookManager
|
|
173
|
+
// ============================================================================
|
|
174
|
+
|
|
175
|
+
const MAX_RETRIES = 3
|
|
176
|
+
const RETRY_DELAYS_MS = [1000, 5000, 30000] // 1s, 5s, 30s
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Manages webhook registrations and event delivery.
|
|
180
|
+
*
|
|
181
|
+
* Sends HTTP POST requests to registered endpoints when auth events occur.
|
|
182
|
+
* Includes HMAC-SHA256 signature verification and retry logic.
|
|
183
|
+
*
|
|
184
|
+
* @example
|
|
185
|
+
* ```typescript
|
|
186
|
+
* const webhooks = new WebhookManager({
|
|
187
|
+
* store: new InMemoryWebhookStore(),
|
|
188
|
+
* })
|
|
189
|
+
*
|
|
190
|
+
* // Register an endpoint
|
|
191
|
+
* const endpoint = await webhooks.register({
|
|
192
|
+
* url: 'https://myapp.com/webhooks/auth',
|
|
193
|
+
* events: ['user.created', 'user.signin'],
|
|
194
|
+
* })
|
|
195
|
+
*
|
|
196
|
+
* // Dispatch an event (usually called internally by auth system)
|
|
197
|
+
* await webhooks.dispatch('user.created', { userId: 'user-123', email: 'alice@example.com' })
|
|
198
|
+
* ```
|
|
199
|
+
*/
|
|
200
|
+
export class WebhookManager {
|
|
201
|
+
private readonly store: WebhookStore
|
|
202
|
+
private readonly fetchFn: typeof globalThis.fetch
|
|
203
|
+
private readonly allowPrivateTargets: boolean
|
|
204
|
+
private readonly resolveHost: ((host: string) => Promise<string[]>) | null
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param config.store - Endpoint and delivery storage
|
|
208
|
+
* @param config.fetch - Custom fetch. A custom fetch (for example through an
|
|
209
|
+
* egress proxy) owns its own network policy, so Kora skips its delivery-time
|
|
210
|
+
* DNS check unless `resolveHost` is also given.
|
|
211
|
+
* @param config.allowPrivateTargets - Development only: allow http:// and
|
|
212
|
+
* loopback/private/link-local targets. Default false.
|
|
213
|
+
* @param config.resolveHost - Resolve a hostname to addresses at delivery time
|
|
214
|
+
* (defaults to node:dns when no custom fetch is given), so a public name that
|
|
215
|
+
* points at a private address is refused (SSRF, DNS rebinding).
|
|
216
|
+
*/
|
|
217
|
+
constructor(config: {
|
|
218
|
+
store: WebhookStore
|
|
219
|
+
fetch?: typeof globalThis.fetch
|
|
220
|
+
allowPrivateTargets?: boolean
|
|
221
|
+
resolveHost?: (host: string) => Promise<string[]>
|
|
222
|
+
}) {
|
|
223
|
+
this.store = config.store
|
|
224
|
+
this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis)
|
|
225
|
+
this.allowPrivateTargets = config.allowPrivateTargets === true
|
|
226
|
+
this.resolveHost = config.resolveHost ?? (config.fetch ? null : resolveWithNodeDns)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Register a new webhook endpoint.
|
|
231
|
+
*/
|
|
232
|
+
async register(params: {
|
|
233
|
+
url: string
|
|
234
|
+
events: WebhookEvent[]
|
|
235
|
+
metadata?: Record<string, unknown>
|
|
236
|
+
}): Promise<WebhookEndpoint> {
|
|
237
|
+
assertSafeWebhookUrl(params.url, this.allowPrivateTargets)
|
|
238
|
+
const endpoint: WebhookEndpoint = {
|
|
239
|
+
id: generateId(),
|
|
240
|
+
url: params.url,
|
|
241
|
+
events: [...params.events],
|
|
242
|
+
secret: generateSecret(),
|
|
243
|
+
active: true,
|
|
244
|
+
createdAt: Date.now(),
|
|
245
|
+
metadata: params.metadata,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await this.store.saveEndpoint(endpoint)
|
|
249
|
+
return endpoint
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Update a webhook endpoint.
|
|
254
|
+
*/
|
|
255
|
+
async update(
|
|
256
|
+
id: string,
|
|
257
|
+
updates: { url?: string; events?: WebhookEvent[]; active?: boolean },
|
|
258
|
+
): Promise<WebhookEndpoint> {
|
|
259
|
+
const endpoint = await this.store.getEndpoint(id)
|
|
260
|
+
if (!endpoint) {
|
|
261
|
+
throw new WebhookEndpointNotFoundError(id)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (updates.url !== undefined) {
|
|
265
|
+
assertSafeWebhookUrl(updates.url, this.allowPrivateTargets)
|
|
266
|
+
endpoint.url = updates.url
|
|
267
|
+
}
|
|
268
|
+
if (updates.events !== undefined) endpoint.events = [...updates.events]
|
|
269
|
+
if (updates.active !== undefined) endpoint.active = updates.active
|
|
270
|
+
|
|
271
|
+
await this.store.saveEndpoint(endpoint)
|
|
272
|
+
return endpoint
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Delete a webhook endpoint.
|
|
277
|
+
*/
|
|
278
|
+
async remove(id: string): Promise<void> {
|
|
279
|
+
await this.store.deleteEndpoint(id)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* List all webhook endpoints.
|
|
284
|
+
*/
|
|
285
|
+
async list(): Promise<WebhookEndpoint[]> {
|
|
286
|
+
return this.store.listEndpoints()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Get a specific endpoint.
|
|
291
|
+
*/
|
|
292
|
+
async get(id: string): Promise<WebhookEndpoint> {
|
|
293
|
+
const endpoint = await this.store.getEndpoint(id)
|
|
294
|
+
if (!endpoint) {
|
|
295
|
+
throw new WebhookEndpointNotFoundError(id)
|
|
296
|
+
}
|
|
297
|
+
return endpoint
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Get recent deliveries for an endpoint.
|
|
302
|
+
*/
|
|
303
|
+
async getDeliveries(endpointId: string, limit?: number): Promise<WebhookDelivery[]> {
|
|
304
|
+
return this.store.listDeliveries(endpointId, limit)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Dispatch an event to all matching webhook endpoints.
|
|
309
|
+
* Delivery is best-effort with retries.
|
|
310
|
+
*/
|
|
311
|
+
async dispatch(event: WebhookEvent, data: Record<string, unknown>): Promise<void> {
|
|
312
|
+
const endpoints = await this.store.listEndpoints()
|
|
313
|
+
const matching = endpoints.filter((ep) => ep.active && ep.events.includes(event))
|
|
314
|
+
|
|
315
|
+
const payload: WebhookPayload = {
|
|
316
|
+
id: generateId(),
|
|
317
|
+
event,
|
|
318
|
+
timestamp: Date.now(),
|
|
319
|
+
data,
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const payloadJson = JSON.stringify(payload)
|
|
323
|
+
|
|
324
|
+
await Promise.allSettled(matching.map((ep) => this.deliverToEndpoint(ep, payloadJson, event)))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// --- Private ---
|
|
328
|
+
|
|
329
|
+
private async deliverToEndpoint(
|
|
330
|
+
endpoint: WebhookEndpoint,
|
|
331
|
+
payloadJson: string,
|
|
332
|
+
event: WebhookEvent,
|
|
333
|
+
): Promise<void> {
|
|
334
|
+
const delivery: WebhookDelivery = {
|
|
335
|
+
id: generateId(),
|
|
336
|
+
endpointId: endpoint.id,
|
|
337
|
+
event,
|
|
338
|
+
payload: payloadJson,
|
|
339
|
+
responseStatus: null,
|
|
340
|
+
success: false,
|
|
341
|
+
error: null,
|
|
342
|
+
attempts: 0,
|
|
343
|
+
createdAt: Date.now(),
|
|
344
|
+
lastAttemptAt: Date.now(),
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
348
|
+
delivery.attempts = attempt + 1
|
|
349
|
+
delivery.lastAttemptAt = Date.now()
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
await this.assertDeliverable(endpoint.url)
|
|
353
|
+
// Sign the timestamp together with the body, so a captured delivery
|
|
354
|
+
// cannot be replayed later (AUTH-14).
|
|
355
|
+
const timestamp = Math.floor(Date.now() / 1000)
|
|
356
|
+
const signature = await signPayload(`${timestamp}.${payloadJson}`, endpoint.secret)
|
|
357
|
+
const response = await this.fetchFn(endpoint.url, {
|
|
358
|
+
method: 'POST',
|
|
359
|
+
headers: {
|
|
360
|
+
'Content-Type': 'application/json',
|
|
361
|
+
'X-Webhook-Signature': `t=${timestamp},v1=${signature}`,
|
|
362
|
+
'X-Webhook-Timestamp': String(timestamp),
|
|
363
|
+
'X-Webhook-Event': event,
|
|
364
|
+
'X-Webhook-Delivery': delivery.id,
|
|
365
|
+
},
|
|
366
|
+
body: payloadJson,
|
|
367
|
+
redirect: 'manual',
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
delivery.responseStatus = response.status
|
|
371
|
+
delivery.success = response.ok
|
|
372
|
+
|
|
373
|
+
if (response.ok) {
|
|
374
|
+
await this.store.saveDelivery(delivery)
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
delivery.error = `HTTP ${response.status}`
|
|
379
|
+
} catch (err) {
|
|
380
|
+
delivery.error = err instanceof Error ? err.message : 'Network error'
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Wait before retrying (skip wait on last attempt)
|
|
384
|
+
if (attempt < MAX_RETRIES - 1) {
|
|
385
|
+
await delay(RETRY_DELAYS_MS[attempt] ?? 1000)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// All retries exhausted
|
|
390
|
+
await this.store.saveDelivery(delivery)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private async assertDeliverable(url: string): Promise<void> {
|
|
394
|
+
assertSafeWebhookUrl(url, this.allowPrivateTargets)
|
|
395
|
+
if (this.allowPrivateTargets || !this.resolveHost) return
|
|
396
|
+
const host = new URL(url).hostname
|
|
397
|
+
const addresses = await this.resolveHost(host)
|
|
398
|
+
if (addresses.length === 0 || addresses.some(isPrivateAddress)) {
|
|
399
|
+
throw new WebhookTargetError(url)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Thrown when a webhook URL points at a non-public target. */
|
|
405
|
+
export class WebhookTargetError extends WebhookError {
|
|
406
|
+
constructor(url: string) {
|
|
407
|
+
super('Webhook URLs must be https and resolve to public addresses.', 'WEBHOOK_TARGET_REFUSED', {
|
|
408
|
+
url,
|
|
409
|
+
})
|
|
410
|
+
this.name = 'WebhookTargetError'
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function assertSafeWebhookUrl(url: string, allowPrivate: boolean): void {
|
|
415
|
+
let parsed: URL
|
|
416
|
+
try {
|
|
417
|
+
parsed = new URL(url)
|
|
418
|
+
} catch {
|
|
419
|
+
throw new WebhookTargetError(url)
|
|
420
|
+
}
|
|
421
|
+
if (allowPrivate) {
|
|
422
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
|
|
423
|
+
throw new WebhookTargetError(url)
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
if (parsed.protocol !== 'https:') throw new WebhookTargetError(url)
|
|
427
|
+
const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
|
428
|
+
if (host === 'localhost' || host.endsWith('.localhost') || isPrivateAddress(host)) {
|
|
429
|
+
throw new WebhookTargetError(url)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* True for loopback, private (RFC 1918 / ULA), link-local (incl. cloud metadata
|
|
435
|
+
* 169.254.169.254), CGNAT, unspecified and IPv4-mapped private addresses.
|
|
436
|
+
* Non-IP strings return false (hostnames are checked after resolution).
|
|
437
|
+
*/
|
|
438
|
+
function isPrivateAddress(address: string): boolean {
|
|
439
|
+
let host = address.replace(/^\[|\]$/g, '').toLowerCase()
|
|
440
|
+
// URL() normalizes IPv4-mapped IPv6 to hex groups (::ffff:c0a8:1).
|
|
441
|
+
const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host)
|
|
442
|
+
if (mapped) {
|
|
443
|
+
const hi = Number.parseInt(mapped[1] as string, 16)
|
|
444
|
+
const lo = Number.parseInt(mapped[2] as string, 16)
|
|
445
|
+
host = `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`
|
|
446
|
+
}
|
|
447
|
+
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(
|
|
448
|
+
host.startsWith('::ffff:') ? host.slice(7) : host,
|
|
449
|
+
)
|
|
450
|
+
if (v4) {
|
|
451
|
+
const [a, b] = [Number(v4[1]), Number(v4[2])]
|
|
452
|
+
return (
|
|
453
|
+
a === 0 ||
|
|
454
|
+
a === 10 ||
|
|
455
|
+
a === 127 ||
|
|
456
|
+
(a === 169 && b === 254) ||
|
|
457
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
458
|
+
(a === 192 && b === 168) ||
|
|
459
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
460
|
+
a >= 224
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
if (host.includes(':')) {
|
|
464
|
+
return (
|
|
465
|
+
host === '::' ||
|
|
466
|
+
host === '::1' ||
|
|
467
|
+
host.startsWith('fe8') ||
|
|
468
|
+
host.startsWith('fe9') ||
|
|
469
|
+
host.startsWith('fea') ||
|
|
470
|
+
host.startsWith('feb') ||
|
|
471
|
+
host.startsWith('fc') ||
|
|
472
|
+
host.startsWith('fd') ||
|
|
473
|
+
host.startsWith('ff')
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
return false
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function resolveWithNodeDns(host: string): Promise<string[]> {
|
|
480
|
+
if (isPrivateAddress(host) || /^[\d.]+$/.test(host) || host.includes(':')) return [host]
|
|
481
|
+
const dns = (await import('node:dns/promises')) as {
|
|
482
|
+
lookup(h: string, o: { all: true }): Promise<Array<{ address: string }>>
|
|
483
|
+
}
|
|
484
|
+
const results = await dns.lookup(host, { all: true })
|
|
485
|
+
return results.map((r) => r.address)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ============================================================================
|
|
489
|
+
// Helpers
|
|
490
|
+
// ============================================================================
|
|
491
|
+
|
|
492
|
+
function generateId(): string {
|
|
493
|
+
const bytes = new Uint8Array(16)
|
|
494
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
495
|
+
let hex = ''
|
|
496
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
497
|
+
hex += bytes[i]?.toString(16).padStart(2, '0')
|
|
498
|
+
}
|
|
499
|
+
return hex
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function generateSecret(): string {
|
|
503
|
+
const bytes = new Uint8Array(32)
|
|
504
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
505
|
+
let hex = ''
|
|
506
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
507
|
+
hex += bytes[i]?.toString(16).padStart(2, '0')
|
|
508
|
+
}
|
|
509
|
+
return `whsec_${hex}`
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Sign a payload with HMAC-SHA256 for webhook verification.
|
|
514
|
+
*/
|
|
515
|
+
async function signPayload(payload: string, secret: string): Promise<string> {
|
|
516
|
+
const encoder = new TextEncoder()
|
|
517
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
518
|
+
'raw',
|
|
519
|
+
encoder.encode(secret),
|
|
520
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
521
|
+
false,
|
|
522
|
+
['sign'],
|
|
523
|
+
)
|
|
524
|
+
const signature = await globalThis.crypto.subtle.sign('HMAC', key, encoder.encode(payload))
|
|
525
|
+
const bytes = new Uint8Array(signature)
|
|
526
|
+
let hex = ''
|
|
527
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
528
|
+
hex += bytes[i]?.toString(16).padStart(2, '0')
|
|
529
|
+
}
|
|
530
|
+
return hex
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Default accepted age of a webhook delivery: 5 minutes. */
|
|
534
|
+
const DEFAULT_SIGNATURE_TOLERANCE_SECONDS = 300
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Verify a webhook payload signature (`X-Webhook-Signature: t=<unix>,v1=<hex>`).
|
|
538
|
+
*
|
|
539
|
+
* The HMAC covers `${t}.${payload}`, and a delivery older (or newer) than
|
|
540
|
+
* `toleranceSeconds` is rejected, so a captured delivery cannot be replayed.
|
|
541
|
+
*
|
|
542
|
+
* @param payload - The raw request body
|
|
543
|
+
* @param signature - The `X-Webhook-Signature` header
|
|
544
|
+
* @param secret - The endpoint secret
|
|
545
|
+
* @param options - `toleranceSeconds` (default 300)
|
|
546
|
+
* @returns True when the signature is valid and fresh
|
|
547
|
+
*/
|
|
548
|
+
export async function verifyWebhookSignature(
|
|
549
|
+
payload: string,
|
|
550
|
+
signature: string,
|
|
551
|
+
secret: string,
|
|
552
|
+
options: { toleranceSeconds?: number } = {},
|
|
553
|
+
): Promise<boolean> {
|
|
554
|
+
const parts = new Map<string, string>()
|
|
555
|
+
for (const piece of signature.split(',')) {
|
|
556
|
+
const index = piece.indexOf('=')
|
|
557
|
+
if (index > 0) parts.set(piece.slice(0, index).trim(), piece.slice(index + 1).trim())
|
|
558
|
+
}
|
|
559
|
+
const timestamp = Number(parts.get('t'))
|
|
560
|
+
const provided = parts.get('v1')
|
|
561
|
+
if (!Number.isInteger(timestamp) || !provided) return false
|
|
562
|
+
const tolerance = options.toleranceSeconds ?? DEFAULT_SIGNATURE_TOLERANCE_SECONDS
|
|
563
|
+
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > tolerance) return false
|
|
564
|
+
const expected = await signPayload(`${timestamp}.${payload}`, secret)
|
|
565
|
+
// Timing-safe comparison
|
|
566
|
+
if (expected.length !== provided.length) return false
|
|
567
|
+
let result = 0
|
|
568
|
+
for (let i = 0; i < expected.length; i++) {
|
|
569
|
+
result |= expected.charCodeAt(i) ^ provided.charCodeAt(i)
|
|
570
|
+
}
|
|
571
|
+
return result === 0
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function delay(ms: number): Promise<void> {
|
|
575
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
576
|
+
}
|