@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.
- package/dist/messages/client.d.ts +15 -0
- package/dist/messages/index.cjs +20 -0
- package/dist/messages/index.d.ts +1 -0
- package/dist/messages/index.js +19 -0
- package/dist/verify/approval-store.d.ts +22 -0
- package/dist/verify/auth-schema.d.ts +97 -0
- package/dist/verify/handler.d.ts +41 -0
- package/dist/verify/index.cjs +321 -0
- package/dist/verify/index.d.ts +2 -0
- package/dist/verify/index.js +320 -0
- package/package.json +1 -1
|
@@ -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;
|
package/dist/messages/index.cjs
CHANGED
|
@@ -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
|
});
|
package/dist/messages/index.d.ts
CHANGED
package/dist/messages/index.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/verify/index.cjs
CHANGED
|
@@ -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
|
});
|
package/dist/verify/index.d.ts
CHANGED
package/dist/verify/index.js
CHANGED
|
@@ -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
|
};
|