supakeys 0.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/cli.js ADDED
@@ -0,0 +1,771 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import chalk from 'chalk';
4
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
5
+ import { join } from 'path';
6
+ import ora from 'ora';
7
+ import enquirer from 'enquirer';
8
+
9
+ var { prompt } = enquirer;
10
+ function detectFramework() {
11
+ const frameworks = [
12
+ { name: "Next.js", files: ["next.config.js", "next.config.mjs", "next.config.ts"] },
13
+ { name: "Remix", files: ["remix.config.js", "remix.config.ts"] },
14
+ { name: "SvelteKit", files: ["svelte.config.js"] },
15
+ { name: "Nuxt", files: ["nuxt.config.js", "nuxt.config.ts"] },
16
+ { name: "Astro", files: ["astro.config.mjs", "astro.config.js"] }
17
+ ];
18
+ for (const fw of frameworks) {
19
+ for (const file of fw.files) {
20
+ if (existsSync(file)) {
21
+ return { name: fw.name, detected: true, configFile: file };
22
+ }
23
+ }
24
+ }
25
+ if (existsSync("package.json")) {
26
+ try {
27
+ const pkg = JSON.parse(readFileSync("package.json", "utf-8"));
28
+ if (pkg.dependencies?.react || pkg.devDependencies?.react) {
29
+ return { name: "React", detected: true };
30
+ }
31
+ if (pkg.dependencies?.vue || pkg.devDependencies?.vue) {
32
+ return { name: "Vue", detected: true };
33
+ }
34
+ } catch {
35
+ }
36
+ }
37
+ return { name: "Unknown", detected: false };
38
+ }
39
+ function checkSupabaseProject(dir) {
40
+ return existsSync(join(dir, "config.toml"));
41
+ }
42
+ async function initCommand(options) {
43
+ console.log(chalk.cyan("\n\u{1F510} Initializing passkey authentication...\n"));
44
+ const spinner = ora();
45
+ const framework = detectFramework();
46
+ if (framework.detected) {
47
+ console.log(chalk.dim(` Detected: ${framework.name}
48
+ `));
49
+ }
50
+ try {
51
+ const isSupabaseProject = checkSupabaseProject(options.dir);
52
+ if (!isSupabaseProject && !existsSync(options.dir)) {
53
+ const { createDir } = await prompt({
54
+ type: "confirm",
55
+ name: "createDir",
56
+ message: `Supabase directory not found at ${options.dir}. Create it?`,
57
+ initial: true
58
+ });
59
+ if (!createDir) {
60
+ console.log(chalk.yellow("\nAborted. Run `supabase init` first to set up Supabase."));
61
+ process.exit(1);
62
+ }
63
+ if (!options.dryRun) {
64
+ mkdirSync(options.dir, { recursive: true });
65
+ }
66
+ }
67
+ const config = await prompt([
68
+ {
69
+ type: "input",
70
+ name: "rpId",
71
+ message: "Relying Party ID (your domain):",
72
+ initial: "localhost"
73
+ },
74
+ {
75
+ type: "input",
76
+ name: "rpName",
77
+ message: "Application name:",
78
+ initial: framework.detected ? framework.name + " App" : "My App"
79
+ }
80
+ ]);
81
+ if (options.dryRun) {
82
+ console.log(chalk.yellow("\n[Dry Run] Would create the following:\n"));
83
+ }
84
+ if (!options.skipMigration) {
85
+ spinner.start("Setting up database migrations...");
86
+ const migrationsDir = join(options.dir, "migrations");
87
+ if (!options.dryRun && !existsSync(migrationsDir)) {
88
+ mkdirSync(migrationsDir, { recursive: true });
89
+ }
90
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").split(".")[0];
91
+ const migrationFile = join(migrationsDir, `${timestamp}_passkey_auth.sql`);
92
+ if (options.dryRun) {
93
+ spinner.info(`Would create: ${chalk.dim(migrationFile)}`);
94
+ } else {
95
+ writeFileSync(migrationFile, getMigrationSQL());
96
+ spinner.succeed(`Created migration: ${chalk.dim(migrationFile)}`);
97
+ }
98
+ }
99
+ if (!options.skipFunction) {
100
+ spinner.start("Setting up edge function...");
101
+ const functionsDir = join(options.dir, "functions", "passkey-auth");
102
+ if (!options.dryRun && !existsSync(functionsDir)) {
103
+ mkdirSync(functionsDir, { recursive: true });
104
+ }
105
+ const functionFile = join(functionsDir, "index.ts");
106
+ if (options.dryRun) {
107
+ spinner.info(`Would create: ${chalk.dim(functionFile)}`);
108
+ } else {
109
+ writeFileSync(functionFile, getEdgeFunctionCode());
110
+ spinner.succeed(`Created edge function: ${chalk.dim(functionFile)}`);
111
+ }
112
+ }
113
+ if (options.dryRun) {
114
+ console.log(chalk.yellow("\n[Dry Run] No files were created.\n"));
115
+ return;
116
+ }
117
+ console.log(chalk.green("\n\u2705 Passkey authentication initialized!\n"));
118
+ console.log(chalk.bold("Next steps:\n"));
119
+ console.log(` ${chalk.cyan("1.")} Apply database migrations:`);
120
+ console.log(chalk.dim(` supabase db push
121
+ `));
122
+ console.log(` ${chalk.cyan("2.")} Deploy the edge function:`);
123
+ console.log(chalk.dim(` supabase functions deploy passkey-auth
124
+ `));
125
+ console.log(` ${chalk.cyan("3.")} Initialize in your app:`);
126
+ console.log(
127
+ chalk.dim(`
128
+ import { createPasskeyAuth } from 'supakeys';
129
+ import { createClient } from '@supabase/supabase-js';
130
+
131
+ const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
132
+
133
+ const passkeys = createPasskeyAuth(supabase, {
134
+ rpId: '${config.rpId}',
135
+ rpName: '${config.rpName}',
136
+ });
137
+
138
+ // Register a new passkey
139
+ const { success, error } = await passkeys.register({ email: 'user@example.com' });
140
+
141
+ // Sign in with passkey
142
+ const { success, session } = await passkeys.signIn();
143
+ `)
144
+ );
145
+ console.log(chalk.dim("\u{1F4DA} Docs: https://supakeys.dev\n"));
146
+ } catch (error) {
147
+ spinner.fail("Failed to initialize");
148
+ if (error instanceof Error && error.message !== "") {
149
+ console.error(chalk.red(error.message));
150
+ }
151
+ process.exit(1);
152
+ }
153
+ }
154
+ function getMigrationSQL() {
155
+ return `CREATE TABLE IF NOT EXISTS public.passkey_credentials (
156
+ id TEXT PRIMARY KEY,
157
+ user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
158
+ webauthn_user_id TEXT NOT NULL,
159
+ public_key BYTEA NOT NULL,
160
+ counter BIGINT NOT NULL DEFAULT 0,
161
+ device_type VARCHAR(32) NOT NULL CHECK (device_type IN ('singleDevice', 'multiDevice')),
162
+ backed_up BOOLEAN NOT NULL DEFAULT false,
163
+ transports TEXT[],
164
+ authenticator_name VARCHAR(255),
165
+ aaguid VARCHAR(36),
166
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
167
+ last_used_at TIMESTAMPTZ,
168
+ CONSTRAINT unique_credential_per_user UNIQUE (id, user_id)
169
+ );
170
+
171
+ CREATE INDEX IF NOT EXISTS idx_passkey_credentials_user_id
172
+ ON public.passkey_credentials(user_id);
173
+ CREATE INDEX IF NOT EXISTS idx_passkey_credentials_webauthn_user_id
174
+ ON public.passkey_credentials(webauthn_user_id);
175
+
176
+ CREATE TABLE IF NOT EXISTS public.passkey_challenges (
177
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
178
+ challenge TEXT NOT NULL UNIQUE,
179
+ user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
180
+ email TEXT,
181
+ webauthn_user_id TEXT,
182
+ type VARCHAR(20) NOT NULL CHECK (type IN ('registration', 'authentication')),
183
+ expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '5 minutes'),
184
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
185
+ );
186
+
187
+ CREATE INDEX IF NOT EXISTS idx_passkey_challenges_expires_at
188
+ ON public.passkey_challenges(expires_at);
189
+ CREATE INDEX IF NOT EXISTS idx_passkey_challenges_challenge
190
+ ON public.passkey_challenges(challenge);
191
+
192
+ CREATE TABLE IF NOT EXISTS public.passkey_rate_limits (
193
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
194
+ identifier TEXT NOT NULL,
195
+ identifier_type VARCHAR(10) NOT NULL CHECK (identifier_type IN ('ip', 'email')),
196
+ endpoint VARCHAR(50) NOT NULL,
197
+ attempt_count INTEGER NOT NULL DEFAULT 1,
198
+ window_start TIMESTAMPTZ NOT NULL DEFAULT NOW(),
199
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
200
+ CONSTRAINT unique_rate_limit_window UNIQUE (identifier, identifier_type, endpoint, window_start)
201
+ );
202
+
203
+ CREATE INDEX IF NOT EXISTS idx_rate_limits_lookup
204
+ ON public.passkey_rate_limits(identifier, identifier_type, endpoint, window_start);
205
+
206
+ CREATE TYPE public.passkey_audit_event AS ENUM (
207
+ 'registration_started',
208
+ 'registration_completed',
209
+ 'registration_failed',
210
+ 'authentication_started',
211
+ 'authentication_completed',
212
+ 'authentication_failed',
213
+ 'passkey_removed',
214
+ 'passkey_updated',
215
+ 'rate_limit_exceeded',
216
+ 'challenge_expired',
217
+ 'counter_mismatch'
218
+ );
219
+
220
+ CREATE TABLE IF NOT EXISTS public.passkey_audit_log (
221
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
222
+ event_type public.passkey_audit_event NOT NULL,
223
+ user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
224
+ credential_id TEXT,
225
+ email TEXT,
226
+ ip_address INET,
227
+ user_agent TEXT,
228
+ origin TEXT,
229
+ metadata JSONB,
230
+ error_code TEXT,
231
+ error_message TEXT,
232
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
233
+ );
234
+
235
+ CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON public.passkey_audit_log(user_id);
236
+ CREATE INDEX IF NOT EXISTS idx_audit_log_event_type ON public.passkey_audit_log(event_type);
237
+ CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON public.passkey_audit_log(created_at);
238
+
239
+ ALTER TABLE public.passkey_credentials ENABLE ROW LEVEL SECURITY;
240
+ ALTER TABLE public.passkey_challenges ENABLE ROW LEVEL SECURITY;
241
+ ALTER TABLE public.passkey_rate_limits ENABLE ROW LEVEL SECURITY;
242
+ ALTER TABLE public.passkey_audit_log ENABLE ROW LEVEL SECURITY;
243
+
244
+ CREATE POLICY "Users can view their own passkeys"
245
+ ON public.passkey_credentials FOR SELECT USING (auth.uid() = user_id);
246
+
247
+ CREATE POLICY "Users can delete their own passkeys"
248
+ ON public.passkey_credentials FOR DELETE USING (auth.uid() = user_id);
249
+
250
+ CREATE POLICY "Users can view their own audit logs"
251
+ ON public.passkey_audit_log FOR SELECT USING (auth.uid() = user_id);
252
+
253
+ CREATE OR REPLACE FUNCTION public.cleanup_expired_passkey_challenges()
254
+ RETURNS INTEGER
255
+ LANGUAGE plpgsql
256
+ SECURITY DEFINER
257
+ AS $$
258
+ DECLARE
259
+ deleted_count INTEGER;
260
+ BEGIN
261
+ DELETE FROM public.passkey_challenges WHERE expires_at < NOW();
262
+ GET DIAGNOSTICS deleted_count = ROW_COUNT;
263
+ RETURN deleted_count;
264
+ END;
265
+ $$;
266
+
267
+ CREATE OR REPLACE FUNCTION public.check_passkey_rate_limit(
268
+ p_identifier TEXT,
269
+ p_identifier_type VARCHAR(10),
270
+ p_endpoint VARCHAR(50),
271
+ p_max_attempts INTEGER DEFAULT 10,
272
+ p_window_minutes INTEGER DEFAULT 1
273
+ )
274
+ RETURNS BOOLEAN
275
+ LANGUAGE plpgsql
276
+ SECURITY DEFINER
277
+ AS $$
278
+ DECLARE
279
+ v_window_start TIMESTAMPTZ;
280
+ v_current_count INTEGER;
281
+ BEGIN
282
+ v_window_start := date_trunc('minute', NOW());
283
+ INSERT INTO public.passkey_rate_limits (identifier, identifier_type, endpoint, window_start, attempt_count)
284
+ VALUES (p_identifier, p_identifier_type, p_endpoint, v_window_start, 1)
285
+ ON CONFLICT (identifier, identifier_type, endpoint, window_start)
286
+ DO UPDATE SET attempt_count = public.passkey_rate_limits.attempt_count + 1
287
+ RETURNING attempt_count INTO v_current_count;
288
+ RETURN v_current_count > p_max_attempts;
289
+ END;
290
+ $$;
291
+
292
+ CREATE OR REPLACE FUNCTION public.log_passkey_audit_event(
293
+ p_event_type public.passkey_audit_event,
294
+ p_user_id UUID DEFAULT NULL,
295
+ p_credential_id TEXT DEFAULT NULL,
296
+ p_email TEXT DEFAULT NULL,
297
+ p_ip_address INET DEFAULT NULL,
298
+ p_user_agent TEXT DEFAULT NULL,
299
+ p_origin TEXT DEFAULT NULL,
300
+ p_metadata JSONB DEFAULT NULL,
301
+ p_error_code TEXT DEFAULT NULL,
302
+ p_error_message TEXT DEFAULT NULL
303
+ )
304
+ RETURNS UUID
305
+ LANGUAGE plpgsql
306
+ SECURITY DEFINER
307
+ AS $$
308
+ DECLARE
309
+ v_log_id UUID;
310
+ BEGIN
311
+ INSERT INTO public.passkey_audit_log (
312
+ event_type, user_id, credential_id, email, ip_address, user_agent, origin, metadata, error_code, error_message
313
+ ) VALUES (
314
+ p_event_type, p_user_id, p_credential_id, p_email, p_ip_address, p_user_agent, p_origin, p_metadata, p_error_code, p_error_message
315
+ ) RETURNING id INTO v_log_id;
316
+ RETURN v_log_id;
317
+ END;
318
+ $$;
319
+
320
+ REVOKE ALL ON FUNCTION public.cleanup_expired_passkey_challenges() FROM PUBLIC;
321
+ REVOKE ALL ON FUNCTION public.check_passkey_rate_limit(TEXT, VARCHAR, VARCHAR, INTEGER, INTEGER) FROM PUBLIC;
322
+ REVOKE ALL ON FUNCTION public.log_passkey_audit_event(public.passkey_audit_event, UUID, TEXT, TEXT, INET, TEXT, TEXT, JSONB, TEXT, TEXT) FROM PUBLIC;
323
+ GRANT EXECUTE ON FUNCTION public.cleanup_expired_passkey_challenges() TO service_role;
324
+ GRANT EXECUTE ON FUNCTION public.check_passkey_rate_limit(TEXT, VARCHAR, VARCHAR, INTEGER, INTEGER) TO service_role;
325
+ GRANT EXECUTE ON FUNCTION public.log_passkey_audit_event(public.passkey_audit_event, UUID, TEXT, TEXT, INET, TEXT, TEXT, JSONB, TEXT, TEXT) TO service_role;
326
+ `;
327
+ }
328
+ function getEdgeFunctionCode() {
329
+ return `import { serve } from 'https://deno.land/std@0.208.0/http/server.ts';
330
+ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.39.0';
331
+ import {
332
+ generateAuthenticationOptions,
333
+ generateRegistrationOptions,
334
+ verifyAuthenticationResponse,
335
+ verifyRegistrationResponse,
336
+ } from 'npm:@simplewebauthn/server@11.0.0';
337
+
338
+ interface RequestBody {
339
+ endpoint: string;
340
+ data: Record<string, unknown>;
341
+ }
342
+
343
+ interface ApiResponse<T = unknown> {
344
+ success: boolean;
345
+ data?: T;
346
+ error?: { code: string; message: string };
347
+ }
348
+
349
+ const CHALLENGE_TTL_MINUTES = 5;
350
+ const SUPPORTED_ALGORITHMS = [-7, -257];
351
+ const RATE_LIMITS = { ip: { maxAttempts: 5, windowMinutes: 1 }, email: { maxAttempts: 10, windowMinutes: 1 } };
352
+
353
+ function success<T>(data: T): ApiResponse<T> {
354
+ return { success: true, data };
355
+ }
356
+
357
+ function error(code: string, message: string): ApiResponse {
358
+ return { success: false, error: { code, message } };
359
+ }
360
+
361
+ function getOrigin(request: Request): string {
362
+ const origin = request.headers.get('origin');
363
+ if (origin) return origin;
364
+ const url = new URL(request.url);
365
+ return \`\${url.protocol}//\${url.host}\`;
366
+ }
367
+
368
+ function getClientIP(request: Request): string {
369
+ return request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
370
+ request.headers.get('x-real-ip') ||
371
+ request.headers.get('cf-connecting-ip') ||
372
+ '0.0.0.0';
373
+ }
374
+
375
+ function uint8ArrayToBase64Url(bytes: Uint8Array): string {
376
+ const base64 = btoa(String.fromCharCode(...bytes));
377
+ return base64.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
378
+ }
379
+
380
+ function uint8ArrayToHex(bytes: Uint8Array): string {
381
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
382
+ }
383
+
384
+ function hexToUint8Array(hex: string): Uint8Array {
385
+ const bytes = new Uint8Array(hex.length / 2);
386
+ for (let i = 0; i < hex.length; i += 2) {
387
+ bytes[i / 2] = Number.parseInt(hex.substring(i, i + 2), 16);
388
+ }
389
+ return bytes;
390
+ }
391
+
392
+ function generateWebAuthnUserId(): string {
393
+ const bytes = new Uint8Array(32);
394
+ crypto.getRandomValues(bytes);
395
+ return uint8ArrayToBase64Url(bytes);
396
+ }
397
+
398
+ serve(async (req: Request) => {
399
+ if (req.method === 'OPTIONS') {
400
+ return new Response(null, {
401
+ headers: {
402
+ 'Access-Control-Allow-Origin': '*',
403
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
404
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, apikey',
405
+ },
406
+ });
407
+ }
408
+
409
+ const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
410
+ const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
411
+ const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
412
+ const origin = getOrigin(req);
413
+ const clientIP = getClientIP(req);
414
+
415
+ try {
416
+ const { endpoint, data }: RequestBody = await req.json();
417
+ const { rpId, rpName, email, challengeId, response: authResponse } = data as Record<string, unknown>;
418
+
419
+ let result: ApiResponse;
420
+
421
+ switch (endpoint) {
422
+ case '/register/start': {
423
+ const ipBlocked = await supabaseAdmin.rpc('check_passkey_rate_limit', {
424
+ p_identifier: clientIP, p_identifier_type: 'ip', p_endpoint: endpoint, p_max_attempts: RATE_LIMITS.ip.maxAttempts
425
+ });
426
+ if (ipBlocked.data) {
427
+ result = error('RATE_LIMITED', 'Too many requests');
428
+ break;
429
+ }
430
+
431
+ const webauthnUserId = generateWebAuthnUserId();
432
+ const { data: existingUser } = await supabaseAdmin.from('passkey_credentials')
433
+ .select('id').eq('webauthn_user_id', webauthnUserId).limit(1);
434
+
435
+ const excludeCredentials = existingUser?.map((c: { id: string }) => ({
436
+ id: c.id, type: 'public-key' as const
437
+ })) || [];
438
+
439
+ const options = await generateRegistrationOptions({
440
+ rpName: rpName as string,
441
+ rpID: rpId as string,
442
+ userName: email as string,
443
+ userDisplayName: email as string,
444
+ userID: new TextEncoder().encode(webauthnUserId),
445
+ attestationType: 'none',
446
+ excludeCredentials,
447
+ authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
448
+ supportedAlgorithmIDs: SUPPORTED_ALGORITHMS,
449
+ });
450
+
451
+ const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MINUTES * 60 * 1000);
452
+ const { data: challenge } = await supabaseAdmin.from('passkey_challenges').insert({
453
+ challenge: options.challenge, email, type: 'registration', expires_at: expiresAt.toISOString(), webauthn_user_id: webauthnUserId
454
+ }).select().single();
455
+
456
+ await supabaseAdmin.rpc('log_passkey_audit_event', {
457
+ p_event_type: 'registration_started', p_email: email, p_ip_address: clientIP, p_origin: origin
458
+ });
459
+
460
+ result = success({ options, challengeId: challenge.id });
461
+ break;
462
+ }
463
+
464
+ case '/register/finish': {
465
+ const { data: challenge } = await supabaseAdmin.from('passkey_challenges')
466
+ .select('*').eq('id', challengeId).eq('type', 'registration').single();
467
+
468
+ await supabaseAdmin.from('passkey_challenges').delete().eq('id', challengeId);
469
+
470
+ if (!challenge) {
471
+ result = error('CHALLENGE_MISMATCH', 'Invalid or expired challenge');
472
+ break;
473
+ }
474
+
475
+ if (new Date(challenge.expires_at) < new Date()) {
476
+ await supabaseAdmin.rpc('log_passkey_audit_event', {
477
+ p_event_type: 'challenge_expired', p_email: challenge.email, p_ip_address: clientIP
478
+ });
479
+ result = error('CHALLENGE_EXPIRED', 'Challenge has expired');
480
+ break;
481
+ }
482
+
483
+ try {
484
+ const verification = await verifyRegistrationResponse({
485
+ response: authResponse as Parameters<typeof verifyRegistrationResponse>[0]['response'],
486
+ expectedChallenge: challenge.challenge,
487
+ expectedOrigin: origin,
488
+ expectedRPID: rpId as string,
489
+ supportedAlgorithmIDs: SUPPORTED_ALGORITHMS,
490
+ });
491
+
492
+ if (!verification.verified || !verification.registrationInfo) {
493
+ throw new Error('Verification failed');
494
+ }
495
+
496
+ const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
497
+ const publicKeyBytes = credential.publicKey;
498
+ const publicKeyHex = '\\\\x' + uint8ArrayToHex(publicKeyBytes);
499
+
500
+ let userId: string;
501
+ const { data: existingUser } = await supabaseAdmin.auth.admin.getUserByEmail(challenge.email);
502
+
503
+ if (existingUser?.user) {
504
+ userId = existingUser.user.id;
505
+ } else {
506
+ const { data: newUser } = await supabaseAdmin.auth.admin.createUser({
507
+ email: challenge.email, email_confirm: true
508
+ });
509
+ userId = newUser.user!.id;
510
+ }
511
+
512
+ await supabaseAdmin.from('passkey_credentials').insert({
513
+ id: credential.id,
514
+ user_id: userId,
515
+ webauthn_user_id: challenge.webauthn_user_id,
516
+ public_key: publicKeyHex,
517
+ counter: credential.counter,
518
+ device_type: credentialDeviceType,
519
+ backed_up: credentialBackedUp,
520
+ transports: credential.transports,
521
+ });
522
+
523
+ const { data: linkData } = await supabaseAdmin.auth.admin.generateLink({
524
+ type: 'magiclink', email: challenge.email
525
+ });
526
+
527
+ await supabaseAdmin.rpc('log_passkey_audit_event', {
528
+ p_event_type: 'registration_completed', p_user_id: userId, p_credential_id: credential.id, p_email: challenge.email, p_ip_address: clientIP
529
+ });
530
+
531
+ result = success({ verified: true, tokenHash: linkData.properties?.hashed_token });
532
+ } catch (e) {
533
+ await supabaseAdmin.rpc('log_passkey_audit_event', {
534
+ p_event_type: 'registration_failed', p_email: challenge.email, p_ip_address: clientIP, p_error_message: e instanceof Error ? e.message : 'Unknown'
535
+ });
536
+ result = error('VERIFICATION_FAILED', 'Registration verification failed');
537
+ }
538
+ break;
539
+ }
540
+
541
+ case '/login/start': {
542
+ const ipBlocked = await supabaseAdmin.rpc('check_passkey_rate_limit', {
543
+ p_identifier: clientIP, p_identifier_type: 'ip', p_endpoint: endpoint, p_max_attempts: RATE_LIMITS.ip.maxAttempts
544
+ });
545
+ if (ipBlocked.data) {
546
+ result = error('RATE_LIMITED', 'Too many requests');
547
+ break;
548
+ }
549
+
550
+ let allowCredentials: { id: string; type: 'public-key' }[] | undefined;
551
+ let userEmail = email as string | undefined;
552
+
553
+ if (email) {
554
+ const { data: user } = await supabaseAdmin.auth.admin.getUserByEmail(email as string);
555
+ if (!user?.user) {
556
+ result = error('CREDENTIAL_NOT_FOUND', 'No passkey found for this email');
557
+ break;
558
+ }
559
+
560
+ const { data: credentials } = await supabaseAdmin.from('passkey_credentials')
561
+ .select('id, transports').eq('user_id', user.user.id);
562
+
563
+ if (!credentials?.length) {
564
+ result = error('CREDENTIAL_NOT_FOUND', 'No passkey found for this email');
565
+ break;
566
+ }
567
+
568
+ allowCredentials = credentials.map((c) => ({ id: c.id, type: 'public-key' as const }));
569
+ }
570
+
571
+ const options = await generateAuthenticationOptions({
572
+ rpID: rpId as string,
573
+ userVerification: 'preferred',
574
+ allowCredentials,
575
+ });
576
+
577
+ const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MINUTES * 60 * 1000);
578
+ const { data: challenge } = await supabaseAdmin.from('passkey_challenges').insert({
579
+ challenge: options.challenge, email: userEmail, type: 'authentication', expires_at: expiresAt.toISOString()
580
+ }).select().single();
581
+
582
+ await supabaseAdmin.rpc('log_passkey_audit_event', {
583
+ p_event_type: 'authentication_started', p_email: userEmail, p_ip_address: clientIP
584
+ });
585
+
586
+ result = success({ options, challengeId: challenge.id });
587
+ break;
588
+ }
589
+
590
+ case '/login/finish': {
591
+ const { data: challenge } = await supabaseAdmin.from('passkey_challenges')
592
+ .select('*').eq('id', challengeId).eq('type', 'authentication').single();
593
+
594
+ await supabaseAdmin.from('passkey_challenges').delete().eq('id', challengeId);
595
+
596
+ if (!challenge) {
597
+ result = error('CHALLENGE_MISMATCH', 'Invalid or expired challenge');
598
+ break;
599
+ }
600
+
601
+ if (new Date(challenge.expires_at) < new Date()) {
602
+ result = error('CHALLENGE_EXPIRED', 'Challenge has expired');
603
+ break;
604
+ }
605
+
606
+ const credentialId = (authResponse as { id: string }).id;
607
+ const { data: credential } = await supabaseAdmin.from('passkey_credentials')
608
+ .select('*, user:auth.users(email)').eq('id', credentialId).single();
609
+
610
+ if (!credential) {
611
+ result = error('CREDENTIAL_NOT_FOUND', 'Credential not found');
612
+ break;
613
+ }
614
+
615
+ try {
616
+ const publicKeyHex = credential.public_key.replace('\\\\x', '');
617
+ const publicKeyBytes = hexToUint8Array(publicKeyHex);
618
+
619
+ const verification = await verifyAuthenticationResponse({
620
+ response: authResponse as Parameters<typeof verifyAuthenticationResponse>[0]['response'],
621
+ expectedChallenge: challenge.challenge,
622
+ expectedOrigin: origin,
623
+ expectedRPID: rpId as string,
624
+ credential: {
625
+ id: credential.id,
626
+ publicKey: publicKeyBytes,
627
+ counter: credential.counter,
628
+ },
629
+ });
630
+
631
+ if (!verification.verified) {
632
+ throw new Error('Verification failed');
633
+ }
634
+
635
+ await supabaseAdmin.from('passkey_credentials').update({
636
+ counter: verification.authenticationInfo.newCounter, last_used_at: new Date().toISOString()
637
+ }).eq('id', credentialId);
638
+
639
+ const userEmail = credential.user?.email || challenge.email;
640
+ const { data: linkData } = await supabaseAdmin.auth.admin.generateLink({
641
+ type: 'magiclink', email: userEmail
642
+ });
643
+
644
+ await supabaseAdmin.rpc('log_passkey_audit_event', {
645
+ p_event_type: 'authentication_completed', p_user_id: credential.user_id, p_credential_id: credentialId, p_ip_address: clientIP
646
+ });
647
+
648
+ result = success({ verified: true, tokenHash: linkData.properties?.hashed_token });
649
+ } catch (e) {
650
+ await supabaseAdmin.rpc('log_passkey_audit_event', {
651
+ p_event_type: 'authentication_failed', p_credential_id: credentialId, p_ip_address: clientIP, p_error_message: e instanceof Error ? e.message : 'Unknown'
652
+ });
653
+ result = error('VERIFICATION_FAILED', 'Authentication verification failed');
654
+ }
655
+ break;
656
+ }
657
+
658
+ case '/passkeys/list': {
659
+ const authHeader = req.headers.get('Authorization');
660
+ const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY')!;
661
+ const userClient = createClient(supabaseUrl, supabaseAnonKey, {
662
+ global: { headers: { Authorization: authHeader || '' } }
663
+ });
664
+ const { data: { user } } = await userClient.auth.getUser();
665
+
666
+ if (!user) {
667
+ result = error('UNAUTHORIZED', 'Authentication required');
668
+ break;
669
+ }
670
+
671
+ const { data: credentials } = await supabaseAdmin.from('passkey_credentials')
672
+ .select('*').eq('user_id', user.id).order('created_at', { ascending: false });
673
+
674
+ result = success({
675
+ passkeys: credentials?.map((c) => ({
676
+ id: c.id, authenticatorName: c.authenticator_name, deviceType: c.device_type,
677
+ backedUp: c.backed_up, createdAt: c.created_at, lastUsedAt: c.last_used_at
678
+ })) || []
679
+ });
680
+ break;
681
+ }
682
+
683
+ case '/passkeys/remove': {
684
+ const authHeader = req.headers.get('Authorization');
685
+ const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY')!;
686
+ const userClient = createClient(supabaseUrl, supabaseAnonKey, {
687
+ global: { headers: { Authorization: authHeader || '' } }
688
+ });
689
+ const { data: { user } } = await userClient.auth.getUser();
690
+
691
+ if (!user) {
692
+ result = error('UNAUTHORIZED', 'Authentication required');
693
+ break;
694
+ }
695
+
696
+ const { credentialId: removeCredId } = data as { credentialId: string };
697
+ await supabaseAdmin.from('passkey_credentials').delete().eq('id', removeCredId).eq('user_id', user.id);
698
+
699
+ await supabaseAdmin.rpc('log_passkey_audit_event', {
700
+ p_event_type: 'passkey_removed', p_user_id: user.id, p_credential_id: removeCredId, p_ip_address: clientIP
701
+ });
702
+
703
+ result = success({ removed: true });
704
+ break;
705
+ }
706
+
707
+ case '/passkeys/update': {
708
+ const authHeader = req.headers.get('Authorization');
709
+ const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY')!;
710
+ const userClient = createClient(supabaseUrl, supabaseAnonKey, {
711
+ global: { headers: { Authorization: authHeader || '' } }
712
+ });
713
+ const { data: { user } } = await userClient.auth.getUser();
714
+
715
+ if (!user) {
716
+ result = error('UNAUTHORIZED', 'Authentication required');
717
+ break;
718
+ }
719
+
720
+ const { credentialId: updateCredId, authenticatorName } = data as { credentialId: string; authenticatorName: string };
721
+ const { data: updated } = await supabaseAdmin.from('passkey_credentials')
722
+ .update({ authenticator_name: authenticatorName }).eq('id', updateCredId).eq('user_id', user.id).select().single();
723
+
724
+ if (!updated) {
725
+ result = error('CREDENTIAL_NOT_FOUND', 'Passkey not found');
726
+ break;
727
+ }
728
+
729
+ await supabaseAdmin.rpc('log_passkey_audit_event', {
730
+ p_event_type: 'passkey_updated', p_user_id: user.id, p_credential_id: updateCredId, p_ip_address: clientIP
731
+ });
732
+
733
+ result = success({ passkey: updated });
734
+ break;
735
+ }
736
+
737
+ default:
738
+ result = error('NOT_FOUND', \`Unknown endpoint: \${endpoint}\`);
739
+ }
740
+
741
+ return new Response(JSON.stringify(result), {
742
+ headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }
743
+ });
744
+ } catch (e) {
745
+ return new Response(JSON.stringify(error('UNKNOWN_ERROR', 'Internal server error')), {
746
+ status: 500,
747
+ headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }
748
+ });
749
+ }
750
+ });
751
+ `;
752
+ }
753
+
754
+ // src/cli.ts
755
+ var program = new Command();
756
+ program.name("supakeys").description("CLI for setting up passkey authentication with Supabase").version("0.1.0");
757
+ program.command("init").description("Initialize passkey authentication in your Supabase project").option("-d, --dir <directory>", "Supabase directory", "./supabase").option("--skip-migration", "Skip database migration setup").option("--skip-function", "Skip edge function setup").option("--dry-run", "Show what would be created without writing files").action(initCommand);
758
+ program.parse();
759
+ if (!process.argv.slice(2).length) {
760
+ console.log(
761
+ chalk.cyan(`
762
+ \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
763
+ \u2551 \u2551
764
+ \u2551 ${chalk.bold("\u{1F510} supakeys")} \u2551
765
+ \u2551 ${chalk.dim("Passkey authentication for Supabase")} \u2551
766
+ \u2551 \u2551
767
+ \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
768
+ `)
769
+ );
770
+ program.help();
771
+ }