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/LICENSE +21 -0
- package/README.md +49 -0
- package/dist/cli.js +771 -0
- package/dist/index.cjs +394 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +181 -0
- package/dist/index.d.ts +181 -0
- package/dist/index.js +381 -0
- package/dist/index.js.map +1 -0
- package/package.json +73 -0
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
|
+
}
|