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