@sneekin/sdk 0.3.2 → 1.1.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.
@@ -0,0 +1,471 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/verify/index.ts
21
+ var verify_exports = {};
22
+ __export(verify_exports, {
23
+ SneekApiError: () => SneekApiError,
24
+ SneekClient: () => SneekClient,
25
+ SneekVerifyHandler: () => SneekVerifyHandler,
26
+ deriveKeys: () => deriveKeys
27
+ });
28
+ module.exports = __toCommonJS(verify_exports);
29
+
30
+ // src/core/client.ts
31
+ var SneekApiError = class extends Error {
32
+ constructor(status, type, title, detail) {
33
+ super(`${title}${detail ? `: ${detail}` : ""} [${status}]`);
34
+ this.status = status;
35
+ this.type = type;
36
+ this.title = title;
37
+ this.detail = detail;
38
+ this.name = "SneekApiError";
39
+ }
40
+ status;
41
+ type;
42
+ title;
43
+ detail;
44
+ };
45
+ var SneekClient = class {
46
+ apiUrl;
47
+ apiKey;
48
+ fetchImpl;
49
+ maxRetries;
50
+ retryBaseMs;
51
+ constructor(options) {
52
+ if (!options.apiUrl) throw new Error("apiUrl is required");
53
+ if (!options.apiKey) throw new Error("apiKey is required");
54
+ this.apiUrl = options.apiUrl.replace(/\/+$/, "");
55
+ this.apiKey = options.apiKey;
56
+ this.fetchImpl = options.fetch ?? fetch;
57
+ this.maxRetries = options.maxRetries ?? 2;
58
+ this.retryBaseMs = options.retryBaseMs ?? 200;
59
+ }
60
+ async request(method, path, body, extraHeaders) {
61
+ const raw = body === void 0 ? void 0 : JSON.stringify(body);
62
+ let attempt = 0;
63
+ let lastError;
64
+ for (; ; ) {
65
+ try {
66
+ const response = await this.fetchImpl(`${this.apiUrl}${path}`, {
67
+ method,
68
+ headers: {
69
+ "content-type": "application/json",
70
+ authorization: `Bearer ${this.apiKey}`,
71
+ ...extraHeaders ?? {}
72
+ },
73
+ body: raw
74
+ });
75
+ const text = await response.text();
76
+ const parsed = text ? parseJson(text) : void 0;
77
+ if (!response.ok) {
78
+ const problem = asProblem(parsed);
79
+ const error = new SneekApiError(
80
+ response.status,
81
+ problem.type ?? "about:blank",
82
+ problem.title ?? response.statusText,
83
+ problem.detail
84
+ );
85
+ if (response.status >= 500 && attempt < this.maxRetries) {
86
+ lastError = error;
87
+ await this.backoff(attempt++);
88
+ continue;
89
+ }
90
+ throw error;
91
+ }
92
+ return parsed;
93
+ } catch (error) {
94
+ if (error instanceof SneekApiError) throw error;
95
+ if (attempt < this.maxRetries) {
96
+ lastError = error;
97
+ await this.backoff(attempt++);
98
+ continue;
99
+ }
100
+ throw lastError ?? error;
101
+ }
102
+ }
103
+ }
104
+ backoff(attempt) {
105
+ return new Promise((resolve) => setTimeout(resolve, this.retryBaseMs * 2 ** attempt));
106
+ }
107
+ };
108
+ function parseJson(text) {
109
+ try {
110
+ return JSON.parse(text);
111
+ } catch {
112
+ return void 0;
113
+ }
114
+ }
115
+ function asProblem(value) {
116
+ if (typeof value !== "object" || value === null) return {};
117
+ const problem = value;
118
+ return {
119
+ type: typeof problem.type === "string" ? problem.type : void 0,
120
+ title: typeof problem.title === "string" ? problem.title : void 0,
121
+ detail: typeof problem.detail === "string" ? problem.detail : void 0
122
+ };
123
+ }
124
+
125
+ // src/core/derive-keys.ts
126
+ var import_node_crypto = require("crypto");
127
+ var KEY_LABELS = {
128
+ qrSigningKey: "sneek-qr-v1",
129
+ webhookSigningKey: "sneek-webhook-v1",
130
+ proofKey: "sneek-proof-v1"
131
+ };
132
+ function deriveKeys(apiKey) {
133
+ if (typeof apiKey !== "string" || apiKey.length === 0) {
134
+ throw new TypeError("apiKey must be a nonempty string");
135
+ }
136
+ const inputKeyMaterial = Buffer.from(apiKey, "utf8");
137
+ const derive = (label) => Buffer.from(
138
+ (0, import_node_crypto.hkdfSync)("sha256", inputKeyMaterial, Buffer.alloc(0), label, 32)
139
+ );
140
+ return {
141
+ qrSigningKey: derive(KEY_LABELS.qrSigningKey),
142
+ webhookSigningKey: derive(KEY_LABELS.webhookSigningKey),
143
+ proofKey: derive(KEY_LABELS.proofKey)
144
+ };
145
+ }
146
+
147
+ // src/verify/handler.ts
148
+ var import_node_crypto2 = require("crypto");
149
+ var import_jose = require("jose");
150
+
151
+ // src/verify/auth-schema.ts
152
+ var import_zod = require("zod");
153
+ var BrowserProof = import_zod.z.string().min(1).max(4096);
154
+ var VerificationId = import_zod.z.string().uuid();
155
+ var QrReference = import_zod.z.string().regex(/^[0-9a-f]{64}$/);
156
+ var ConfigActionSchema = import_zod.z.object({ action: import_zod.z.literal("config") }).strict();
157
+ var RequestActionSchema = import_zod.z.object({
158
+ action: import_zod.z.literal("request"),
159
+ identifier: import_zod.z.string().min(3).max(254)
160
+ }).strict();
161
+ var SwitchActionSchema = import_zod.z.object({
162
+ action: import_zod.z.literal("switch"),
163
+ requestId: VerificationId,
164
+ channel: import_zod.z.enum(["sms", "whatsapp", "email", "push"]),
165
+ proof: BrowserProof
166
+ }).strict();
167
+ var VerifyCodeActionSchema = import_zod.z.object({
168
+ action: import_zod.z.literal("verify"),
169
+ requestId: VerificationId,
170
+ code: import_zod.z.string().min(4).max(12).optional(),
171
+ proof: BrowserProof
172
+ }).strict();
173
+ var VerifyQrActionSchema = import_zod.z.object({
174
+ action: import_zod.z.literal("verify"),
175
+ requestId: VerificationId,
176
+ ref: QrReference,
177
+ bindingProof: BrowserProof
178
+ }).strict();
179
+ var CreateQrActionSchema = import_zod.z.object({ action: import_zod.z.literal("create") }).strict();
180
+ var QrStatusActionSchema = import_zod.z.object({
181
+ action: import_zod.z.literal("status"),
182
+ ref: QrReference,
183
+ bindingProof: BrowserProof
184
+ }).strict();
185
+ var QrWebhookEventSchema = import_zod.z.object({
186
+ type: import_zod.z.literal("qr.approved"),
187
+ applicationId: import_zod.z.string().uuid(),
188
+ ref: QrReference,
189
+ requestId: VerificationId
190
+ }).strict();
191
+ var SneekVerifyActionSchema = import_zod.z.union([
192
+ ConfigActionSchema,
193
+ RequestActionSchema,
194
+ SwitchActionSchema,
195
+ VerifyCodeActionSchema,
196
+ VerifyQrActionSchema,
197
+ CreateQrActionSchema,
198
+ QrStatusActionSchema
199
+ ]);
200
+
201
+ // src/verify/handler.ts
202
+ var SneekVerifyHandler = class {
203
+ client;
204
+ allowedOrigins;
205
+ keys;
206
+ approvalStore;
207
+ onVerified;
208
+ jwksByIssuer = /* @__PURE__ */ new Map();
209
+ constructor(options) {
210
+ this.client = new SneekClient({
211
+ apiUrl: options.apiUrl,
212
+ apiKey: options.apiKey,
213
+ fetch: options.fetch
214
+ });
215
+ this.allowedOrigins = new Set(options.allowedOrigins.map((origin) => new URL(origin).origin));
216
+ this.keys = deriveKeys(options.apiKey);
217
+ this.approvalStore = options.approvalStore;
218
+ this.onVerified = options.onVerified;
219
+ }
220
+ async handle(request) {
221
+ const origin = this.getAllowedOrigin(request);
222
+ if (request.method !== "POST" || !origin) {
223
+ return jsonResponse({ error: "Forbidden" }, 403);
224
+ }
225
+ let body;
226
+ try {
227
+ body = await request.json();
228
+ } catch {
229
+ return jsonResponse({ error: "Invalid JSON body" }, 400);
230
+ }
231
+ const action = SneekVerifyActionSchema.safeParse(body);
232
+ if (!action.success) return jsonResponse({ error: "Invalid auth action" }, 400);
233
+ switch (action.data.action) {
234
+ case "config":
235
+ return this.config(origin);
236
+ case "request":
237
+ return this.startVerification(action.data.identifier, origin);
238
+ case "switch":
239
+ return this.switchVerification(action.data, origin);
240
+ case "verify":
241
+ return this.verify(action.data, origin);
242
+ case "create":
243
+ return this.createQr(origin);
244
+ case "status":
245
+ return this.qrStatus(action.data.ref, action.data.bindingProof, origin);
246
+ }
247
+ }
248
+ async handleWebhook(request) {
249
+ if (request.method !== "POST") return jsonResponse({ error: "Invalid webhook" }, 405);
250
+ const contentLength = Number(request.headers.get("content-length") ?? 0);
251
+ if (contentLength > 16384) return jsonResponse({ error: "Invalid webhook" }, 413);
252
+ const signature = request.headers.get("sneek-signature") ?? "";
253
+ const match = /^t=(\d{1,12}),v1=([A-Za-z0-9_-]{43})$/.exec(signature);
254
+ if (!match) return jsonResponse({ error: "Invalid webhook signature" }, 401);
255
+ const timestamp = Number(match[1]);
256
+ const now = Math.floor(Date.now() / 1e3);
257
+ if (!Number.isSafeInteger(timestamp) || Math.abs(now - timestamp) > 300) {
258
+ return jsonResponse({ error: "Expired webhook signature" }, 401);
259
+ }
260
+ let body;
261
+ try {
262
+ const text = await request.text();
263
+ if (Buffer.byteLength(text, "utf8") > 16384) return jsonResponse({ error: "Invalid webhook" }, 413);
264
+ body = JSON.parse(text);
265
+ } catch {
266
+ return jsonResponse({ error: "Invalid webhook body" }, 400);
267
+ }
268
+ const event = QrWebhookEventSchema.safeParse(body);
269
+ if (!event.success || !this.isValidWebhookSignature(timestamp, event.data, match[2])) {
270
+ return jsonResponse({ error: "Invalid webhook signature" }, 401);
271
+ }
272
+ const session = await this.approvalStore.readQrSession(event.data.ref);
273
+ if (!session || session.applicationId !== event.data.applicationId || session.expiresAt <= Date.now()) {
274
+ return jsonResponse({ error: "Unknown or expired QR approval" }, 401);
275
+ }
276
+ const recorded = await this.approvalStore.recordQrApproval(event.data, Date.now());
277
+ if (recorded === "missing") return jsonResponse({ error: "Unknown or expired QR approval" }, 401);
278
+ return new Response(null, { status: 204 });
279
+ }
280
+ getAllowedOrigin(request) {
281
+ const origin = request.headers.get("origin");
282
+ if (!origin || origin === "null") return null;
283
+ try {
284
+ const parsed = new URL(origin);
285
+ return parsed.origin === origin && this.allowedOrigins.has(parsed.origin) && new URL(request.url).origin === parsed.origin ? parsed.origin : null;
286
+ } catch {
287
+ return null;
288
+ }
289
+ }
290
+ async config(origin) {
291
+ const partner = await this.client.request("GET", "/api/verify/qr/partner-config");
292
+ const enabled = partner.origins.includes(origin);
293
+ return jsonResponse({
294
+ origin,
295
+ enabled,
296
+ ...enabled ? { applicationId: partner.applicationId } : {}
297
+ }, 200);
298
+ }
299
+ async startVerification(identifier, origin) {
300
+ const result = await this.client.request("POST", "/api/verify", { identifier });
301
+ return this.startResponse(result, origin);
302
+ }
303
+ async switchVerification(action, origin) {
304
+ if (!this.isValidProof(action.proof, action.requestId, origin)) {
305
+ return jsonResponse({ error: "Invalid or expired browser proof" }, 403);
306
+ }
307
+ const result = await this.client.request(
308
+ "POST",
309
+ `/api/verify/${action.requestId}/switch`,
310
+ { channel: action.channel }
311
+ );
312
+ return this.startResponse(result, origin);
313
+ }
314
+ async verify(action, origin) {
315
+ if ("proof" in action) {
316
+ if (!this.isValidProof(action.proof, action.requestId, origin)) {
317
+ return jsonResponse({ error: "Invalid or expired browser proof" }, 403);
318
+ }
319
+ } else {
320
+ const session = await this.qrSession(action.ref, action.bindingProof, origin);
321
+ if (!session || session.requestId !== action.requestId) {
322
+ return jsonResponse({ error: "QR approval is not bound to this browser" }, 403);
323
+ }
324
+ }
325
+ const body = "proof" in action && action.code !== void 0 ? { code: action.code } : {};
326
+ const result = await this.client.request(
327
+ "POST",
328
+ `/api/verify/${action.requestId}/check`,
329
+ body
330
+ );
331
+ if (result.status !== "verified") {
332
+ return jsonResponse({ error: "This verification requires additional account setup" }, 403);
333
+ }
334
+ const partner = await this.client.request("GET", "/api/verify/qr/partner-config");
335
+ let payload;
336
+ try {
337
+ const jwks = this.jwksByIssuer.get(partner.issuer) ?? (0, import_jose.createRemoteJWKSet)(new URL("/oauth/jwks", partner.issuer));
338
+ this.jwksByIssuer.set(partner.issuer, jwks);
339
+ ({ payload } = await (0, import_jose.jwtVerify)(result.idToken, jwks, {
340
+ issuer: partner.issuer,
341
+ audience: partner.clientId
342
+ }));
343
+ } catch {
344
+ return jsonResponse({ error: "Sneek verification token is invalid" }, 502);
345
+ }
346
+ const claims = this.verifiedClaims(payload, action.requestId);
347
+ if (!claims) return jsonResponse({ error: "Sneek verification token is invalid" }, 502);
348
+ if (!await this.approvalStore.claimJti(claims.jti, claims.expiresAt * 1e3)) {
349
+ return jsonResponse({ error: "Sneek verification token was already used" }, 401);
350
+ }
351
+ const verified = {
352
+ identifier: claims.identifier,
353
+ kind: claims.kind,
354
+ method: claims.method,
355
+ verifiedAt: new Date(claims.verifiedAt * 1e3).toISOString(),
356
+ requestId: action.requestId
357
+ };
358
+ await this.onVerified(verified);
359
+ return jsonResponse({ status: "verified", ...verified }, 200);
360
+ }
361
+ verifiedClaims(payload, requestId) {
362
+ const requestClaim = payload.verification_request_id;
363
+ const issuedAt = payload.auth_time ?? payload.iat;
364
+ const methods = payload.amr;
365
+ const email = payload.email;
366
+ const mobile = payload.phone_number;
367
+ const hasEmail = typeof email === "string" && payload.email_verified === true;
368
+ const hasMobile = typeof mobile === "string" && payload.phone_number_verified === true;
369
+ if (payload.sub !== requestId || requestClaim !== requestId || typeof payload.exp !== "number" || typeof issuedAt !== "number" || typeof payload.jti !== "string" || !payload.jti || !Array.isArray(methods) || methods.length === 0 || methods.some((method) => typeof method !== "string" || !method) || hasEmail === hasMobile) return null;
370
+ return {
371
+ identifier: hasEmail ? email : mobile,
372
+ kind: hasEmail ? "email" : "mobile",
373
+ method: methods.join(" "),
374
+ verifiedAt: issuedAt,
375
+ expiresAt: payload.exp,
376
+ jti: payload.jti
377
+ };
378
+ }
379
+ startResponse(result, origin) {
380
+ const expiresAt = Date.parse(result.expiresAt);
381
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
382
+ return jsonResponse({ error: "Verification response is invalid or expired" }, 502);
383
+ }
384
+ return jsonResponse({
385
+ requestId: result.id,
386
+ channel: result.channel,
387
+ maskedIdentifier: result.maskedIdentifier,
388
+ expiresAt: result.expiresAt,
389
+ ...result.instruction ? { instruction: result.instruction } : {},
390
+ ...result.alternatives ? { alternatives: result.alternatives } : {},
391
+ ...result.challengeNumber ? { challengeNumber: result.challengeNumber } : {},
392
+ proof: this.createProof(result.id, origin, expiresAt)
393
+ }, 200);
394
+ }
395
+ createProof(requestId, origin, expiresAt) {
396
+ const payload = Buffer.from(JSON.stringify({
397
+ requestId,
398
+ origin,
399
+ expiresAt,
400
+ nonce: (0, import_node_crypto2.randomBytes)(16).toString("base64url")
401
+ })).toString("base64url");
402
+ const signature = (0, import_node_crypto2.createHmac)("sha256", this.keys.proofKey).update(payload).digest("base64url");
403
+ return `${payload}.${signature}`;
404
+ }
405
+ isValidProof(proof, requestId, origin) {
406
+ const [payload, signature, ...extra] = proof.split(".");
407
+ if (!payload || !signature || extra.length > 0) return false;
408
+ const expected = (0, import_node_crypto2.createHmac)("sha256", this.keys.proofKey).update(payload).digest();
409
+ const actual = Buffer.from(signature, "base64url");
410
+ if (actual.length !== expected.length || !(0, import_node_crypto2.timingSafeEqual)(expected, actual)) return false;
411
+ try {
412
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
413
+ return decoded.requestId === requestId && decoded.origin === origin && typeof decoded.expiresAt === "number" && decoded.expiresAt > Date.now() && typeof decoded.nonce === "string";
414
+ } catch {
415
+ return false;
416
+ }
417
+ }
418
+ async createQr(origin) {
419
+ const partner = await this.partnerConfig();
420
+ if (!partner.origins.includes(origin)) return jsonResponse({ error: "Origin is not registered for QR verification" }, 403);
421
+ const nonce = (0, import_node_crypto2.randomBytes)(32).toString("hex");
422
+ const ref = (0, import_node_crypto2.createHash)("sha256").update(nonce).digest("hex");
423
+ const issuedAt = Math.floor(Date.now() / 1e3);
424
+ const expiresAt = (issuedAt + partner.qrTtlSeconds) * 1e3;
425
+ const bindingProof = (0, import_node_crypto2.randomBytes)(32).toString("base64url");
426
+ const signature = (0, import_node_crypto2.createHmac)("sha256", this.keys.qrSigningKey).update(["sneek-qr-v1", partner.applicationId, nonce, origin, String(issuedAt)].join("\n")).digest().subarray(0, 16).toString("base64url");
427
+ await this.approvalStore.createQrSession({
428
+ ref,
429
+ applicationId: partner.applicationId,
430
+ origin,
431
+ bindingProofHash: this.hash(bindingProof),
432
+ expiresAt
433
+ });
434
+ return jsonResponse({ ref, nonce, issuedAt, expiresAt, signature, bindingProof }, 200);
435
+ }
436
+ async qrStatus(ref, bindingProof, origin) {
437
+ const session = await this.qrSession(ref, bindingProof, origin);
438
+ if (!session) return jsonResponse({ error: "QR approval is not bound to this browser" }, 403);
439
+ if (!session.requestId) return jsonResponse({ status: "pending" }, 200);
440
+ return jsonResponse({ status: "approved", requestId: session.requestId }, 200);
441
+ }
442
+ async qrSession(ref, bindingProof, origin) {
443
+ const session = await this.approvalStore.readQrSession(ref);
444
+ if (!session || session.origin !== origin || session.expiresAt <= Date.now()) return null;
445
+ const expected = Buffer.from(session.bindingProofHash, "hex");
446
+ const actual = Buffer.from(this.hash(bindingProof), "hex");
447
+ if (expected.length !== actual.length || !(0, import_node_crypto2.timingSafeEqual)(expected, actual)) return null;
448
+ return session;
449
+ }
450
+ async partnerConfig() {
451
+ return this.client.request("GET", "/api/verify/qr/partner-config");
452
+ }
453
+ isValidWebhookSignature(timestamp, event, signature) {
454
+ const expected = (0, import_node_crypto2.createHmac)("sha256", this.keys.webhookSigningKey).update(["sneek-webhook-v1", String(timestamp), event.type, event.applicationId, event.ref, event.requestId].join("\n")).digest();
455
+ const actual = Buffer.from(signature, "base64url");
456
+ return actual.length === expected.length && (0, import_node_crypto2.timingSafeEqual)(expected, actual);
457
+ }
458
+ hash(value) {
459
+ return (0, import_node_crypto2.createHash)("sha256").update(value).digest("hex");
460
+ }
461
+ };
462
+ function jsonResponse(body, status) {
463
+ return Response.json(body, { status });
464
+ }
465
+ // Annotate the CommonJS export names for ESM import in node:
466
+ 0 && (module.exports = {
467
+ SneekApiError,
468
+ SneekClient,
469
+ SneekVerifyHandler,
470
+ deriveKeys
471
+ });
@@ -0,0 +1,3 @@
1
+ export * from '../core/index.js';
2
+ export * from './approval-store.js';
3
+ export * from './handler.js';