@robelest/convex-auth 0.0.2-preview.2 → 0.0.3-preview

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.
Files changed (114) hide show
  1. package/dist/bin.cjs +467 -64
  2. package/dist/client/index.d.ts +127 -0
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +424 -1
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/api.d.ts +56 -1
  7. package/dist/component/_generated/api.d.ts.map +1 -1
  8. package/dist/component/_generated/api.js.map +1 -1
  9. package/dist/component/_generated/component.d.ts +141 -3
  10. package/dist/component/_generated/component.d.ts.map +1 -1
  11. package/dist/component/convex.config.d.ts.map +1 -1
  12. package/dist/component/convex.config.js +2 -0
  13. package/dist/component/convex.config.js.map +1 -1
  14. package/dist/component/index.d.ts +5 -4
  15. package/dist/component/index.d.ts.map +1 -1
  16. package/dist/component/index.js +4 -3
  17. package/dist/component/index.js.map +1 -1
  18. package/dist/component/portalBridge.d.ts +80 -0
  19. package/dist/component/portalBridge.d.ts.map +1 -0
  20. package/dist/component/portalBridge.js +102 -0
  21. package/dist/component/portalBridge.js.map +1 -0
  22. package/dist/component/public.d.ts +353 -9
  23. package/dist/component/public.d.ts.map +1 -1
  24. package/dist/component/public.js +328 -33
  25. package/dist/component/public.js.map +1 -1
  26. package/dist/component/schema.d.ts +168 -9
  27. package/dist/component/schema.d.ts.map +1 -1
  28. package/dist/component/schema.js +113 -7
  29. package/dist/component/schema.js.map +1 -1
  30. package/dist/providers/passkey.d.ts +20 -0
  31. package/dist/providers/passkey.d.ts.map +1 -0
  32. package/dist/providers/passkey.js +32 -0
  33. package/dist/providers/passkey.js.map +1 -0
  34. package/dist/providers/totp.d.ts +14 -0
  35. package/dist/providers/totp.d.ts.map +1 -0
  36. package/dist/providers/totp.js +23 -0
  37. package/dist/providers/totp.js.map +1 -0
  38. package/dist/server/convex-auth.d.ts +296 -0
  39. package/dist/server/convex-auth.d.ts.map +1 -0
  40. package/dist/server/convex-auth.js +480 -0
  41. package/dist/server/convex-auth.js.map +1 -0
  42. package/dist/server/email-templates.d.ts +18 -0
  43. package/dist/server/email-templates.d.ts.map +1 -0
  44. package/dist/server/email-templates.js +74 -0
  45. package/dist/server/email-templates.js.map +1 -0
  46. package/dist/server/implementation/apiKey.d.ts +74 -0
  47. package/dist/server/implementation/apiKey.d.ts.map +1 -0
  48. package/dist/server/implementation/apiKey.js +140 -0
  49. package/dist/server/implementation/apiKey.js.map +1 -0
  50. package/dist/server/implementation/index.d.ts +169 -7
  51. package/dist/server/implementation/index.d.ts.map +1 -1
  52. package/dist/server/implementation/index.js +220 -5
  53. package/dist/server/implementation/index.js.map +1 -1
  54. package/dist/server/implementation/passkey.d.ts +33 -0
  55. package/dist/server/implementation/passkey.d.ts.map +1 -0
  56. package/dist/server/implementation/passkey.js +450 -0
  57. package/dist/server/implementation/passkey.js.map +1 -0
  58. package/dist/server/implementation/redirects.d.ts.map +1 -1
  59. package/dist/server/implementation/redirects.js +4 -9
  60. package/dist/server/implementation/redirects.js.map +1 -1
  61. package/dist/server/implementation/signIn.d.ts +13 -0
  62. package/dist/server/implementation/signIn.d.ts.map +1 -1
  63. package/dist/server/implementation/signIn.js +29 -15
  64. package/dist/server/implementation/signIn.js.map +1 -1
  65. package/dist/server/implementation/totp.d.ts +40 -0
  66. package/dist/server/implementation/totp.d.ts.map +1 -0
  67. package/dist/server/implementation/totp.js +211 -0
  68. package/dist/server/implementation/totp.js.map +1 -0
  69. package/dist/server/index.d.ts +26 -2
  70. package/dist/server/index.d.ts.map +1 -1
  71. package/dist/server/index.js +63 -16
  72. package/dist/server/index.js.map +1 -1
  73. package/dist/server/portal-email.d.ts +19 -0
  74. package/dist/server/portal-email.d.ts.map +1 -0
  75. package/dist/server/portal-email.js +89 -0
  76. package/dist/server/portal-email.js.map +1 -0
  77. package/dist/server/provider_utils.d.ts +3 -1
  78. package/dist/server/provider_utils.d.ts.map +1 -1
  79. package/dist/server/provider_utils.js +39 -1
  80. package/dist/server/provider_utils.js.map +1 -1
  81. package/dist/server/types.d.ts +263 -4
  82. package/dist/server/types.d.ts.map +1 -1
  83. package/dist/server/version.d.ts +2 -0
  84. package/dist/server/version.d.ts.map +1 -0
  85. package/dist/server/version.js +3 -0
  86. package/dist/server/version.js.map +1 -0
  87. package/package.json +7 -3
  88. package/src/cli/index.ts +49 -7
  89. package/src/cli/portal-link.ts +112 -0
  90. package/src/cli/portal-upload.ts +411 -0
  91. package/src/cli/utils.ts +248 -0
  92. package/src/client/index.ts +489 -1
  93. package/src/component/_generated/api.ts +72 -1
  94. package/src/component/_generated/component.ts +241 -4
  95. package/src/component/convex.config.ts +3 -0
  96. package/src/component/index.ts +8 -3
  97. package/src/component/portalBridge.ts +116 -0
  98. package/src/component/public.ts +373 -37
  99. package/src/component/schema.ts +122 -7
  100. package/src/providers/passkey.ts +35 -0
  101. package/src/providers/totp.ts +26 -0
  102. package/src/server/convex-auth.ts +602 -0
  103. package/src/server/email-templates.ts +77 -0
  104. package/src/server/implementation/apiKey.ts +185 -0
  105. package/src/server/implementation/index.ts +301 -8
  106. package/src/server/implementation/passkey.ts +650 -0
  107. package/src/server/implementation/redirects.ts +4 -11
  108. package/src/server/implementation/signIn.ts +41 -13
  109. package/src/server/implementation/totp.ts +366 -0
  110. package/src/server/index.ts +98 -34
  111. package/src/server/portal-email.ts +95 -0
  112. package/src/server/provider_utils.ts +42 -1
  113. package/src/server/types.ts +285 -4
  114. package/src/server/version.ts +2 -0
@@ -0,0 +1,650 @@
1
+ /**
2
+ * Server-side WebAuthn ceremony logic for passkey authentication.
3
+ *
4
+ * Handles the four phases of the WebAuthn flow:
5
+ * 1. register-options — generate PublicKeyCredentialCreationOptions
6
+ * 2. register-verify — verify attestation and store credential
7
+ * 3. auth-options — generate PublicKeyCredentialRequestOptions
8
+ * 4. auth-verify — verify assertion signature and sign in
9
+ *
10
+ * Uses `@oslojs/webauthn` for attestation/assertion parsing and
11
+ * `@oslojs/crypto` for signature verification.
12
+ */
13
+
14
+ import { GenericId } from "convex/values";
15
+ import {
16
+ parseAttestationObject,
17
+ parseClientDataJSON,
18
+ parseAuthenticatorData,
19
+ createAssertionSignatureMessage,
20
+ ClientDataType,
21
+ coseAlgorithmES256,
22
+ coseAlgorithmRS256,
23
+ COSEKeyType,
24
+ } from "@oslojs/webauthn";
25
+ import type { AuthenticatorData } from "@oslojs/webauthn";
26
+ import {
27
+ ECDSAPublicKey,
28
+ p256,
29
+ verifyECDSASignature,
30
+ decodeSEC1PublicKey,
31
+ decodePKIXECDSASignature,
32
+ } from "@oslojs/crypto/ecdsa";
33
+ import {
34
+ RSAPublicKey,
35
+ sha256ObjectIdentifier,
36
+ verifyRSASSAPKCS1v15Signature,
37
+ } from "@oslojs/crypto/rsa";
38
+ import { sha256 } from "@oslojs/crypto/sha2";
39
+ import {
40
+ encodeBase64urlNoPadding,
41
+ decodeBase64urlIgnorePadding,
42
+ } from "@oslojs/encoding";
43
+ import {
44
+ PasskeyProviderConfig,
45
+ GenericActionCtxWithAuthConfig,
46
+ } from "../types.js";
47
+ import { AuthDataModel, SessionInfo } from "./types.js";
48
+ import { callSignIn, callVerifier } from "./mutations/index.js";
49
+ import { callVerifierSignature } from "./mutations/verifierSignature.js";
50
+ import { authDb } from "./db.js";
51
+
52
+
53
+ type EnrichedActionCtx = GenericActionCtxWithAuthConfig<AuthDataModel>;
54
+
55
+ /**
56
+ * Resolve passkey relying party options from provider config and environment.
57
+ */
58
+ function resolveRpOptions(provider: PasskeyProviderConfig) {
59
+ // WebAuthn RP ID and origin must match the *frontend* domain, not the
60
+ // Convex backend. SITE_URL is the canonical frontend URL
61
+ // (e.g. "http://localhost:3000" in dev, "https://myapp.com" in prod).
62
+ // CONVEX_SITE_URL points to the Convex cloud HTTP actions endpoint and
63
+ // must NOT be used here — the browser would reject the credential
64
+ // because the RP ID wouldn't match the page origin.
65
+ const siteUrl = process.env.SITE_URL;
66
+ if (!siteUrl && !provider.options.rpId) {
67
+ throw new Error(
68
+ "Passkey provider requires SITE_URL env var (your frontend URL) " +
69
+ "or explicit rpId / origin in the provider config. " +
70
+ "CONVEX_SITE_URL cannot be used because WebAuthn RP ID must match the frontend domain.",
71
+ );
72
+ }
73
+ const siteHostname = siteUrl ? new URL(siteUrl).hostname : undefined;
74
+
75
+ return {
76
+ rpName: provider.options.rpName ?? siteHostname ?? "localhost",
77
+ rpId: provider.options.rpId ?? siteHostname ?? "localhost",
78
+ origin: provider.options.origin ?? siteUrl ?? "http://localhost",
79
+ attestation: provider.options.attestation ?? "none",
80
+ userVerification: provider.options.userVerification ?? "required",
81
+ residentKey: provider.options.residentKey ?? "preferred",
82
+ authenticatorAttachment: provider.options.authenticatorAttachment,
83
+ algorithms: provider.options.algorithms ?? [coseAlgorithmES256, coseAlgorithmRS256],
84
+ challengeExpirationMs: provider.options.challengeExpirationMs ?? 300_000,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Generate a cryptographically random challenge.
90
+ */
91
+ function generateChallenge(): Uint8Array {
92
+ const challenge = new Uint8Array(32);
93
+ crypto.getRandomValues(challenge);
94
+ return challenge;
95
+ }
96
+
97
+ /**
98
+ * Hash a challenge for storage in the verifier table's `signature` field.
99
+ */
100
+ function hashChallenge(challenge: Uint8Array): string {
101
+ return encodeBase64urlNoPadding(new Uint8Array(sha256(challenge)));
102
+ }
103
+
104
+ // ============================================================================
105
+ // Registration flow
106
+ // ============================================================================
107
+
108
+ /**
109
+ * Phase 1: Generate registration options.
110
+ *
111
+ * Requires an authenticated user — passkey registration always adds a
112
+ * credential to an existing account. The userId is taken from the
113
+ * current session identity.
114
+ */
115
+ async function handleRegisterOptions(
116
+ ctx: EnrichedActionCtx,
117
+ provider: PasskeyProviderConfig,
118
+ params: Record<string, any>,
119
+ ): Promise<{
120
+ kind: "passkeyOptions";
121
+ options: Record<string, any>;
122
+ verifier: string;
123
+ }> {
124
+ // Passkey registration requires an authenticated user
125
+ const identity = await ctx.auth.getUserIdentity();
126
+ if (identity === null) {
127
+ throw new Error(
128
+ "Passkey registration requires an authenticated user. " +
129
+ "Sign in first, then add a passkey to your account.",
130
+ );
131
+ }
132
+ const [userId] = identity.subject.split("|");
133
+
134
+ const rp = resolveRpOptions(provider);
135
+ const challenge = generateChallenge();
136
+ const challengeHash = hashChallenge(challenge);
137
+
138
+ // Store the challenge hash in the verifier table
139
+ const verifier = await callVerifier(ctx);
140
+ await callVerifierSignature(ctx, {
141
+ verifier,
142
+ signature: challengeHash,
143
+ });
144
+
145
+ // Get the user's profile for credential metadata
146
+ const user = await ctx.runQuery(
147
+ ctx.auth.config.component.public.userGetById,
148
+ { userId: userId! },
149
+ );
150
+ const userName = params.userName ?? (user as any)?.email ?? "user";
151
+ const userDisplayName = params.userDisplayName ?? (user as any)?.name ?? userName;
152
+
153
+ // Collect existing credentials to prevent re-registration
154
+ let excludeCredentials: Array<{ id: string; transports?: string[] }> = [];
155
+ const existing = await ctx.runQuery(
156
+ ctx.auth.config.component.public.passkeyListByUserId,
157
+ { userId: userId! },
158
+ );
159
+ if (existing) {
160
+ excludeCredentials = (existing as any[]).map((pk: any) => ({
161
+ id: pk.credentialId,
162
+ transports: pk.transports,
163
+ }));
164
+ }
165
+
166
+ // User handle is derived from the Convex userId
167
+ const userHandle = encodeBase64urlNoPadding(
168
+ new TextEncoder().encode(userId!),
169
+ );
170
+
171
+ const options = {
172
+ rp: {
173
+ name: rp.rpName,
174
+ id: rp.rpId,
175
+ },
176
+ user: {
177
+ id: userHandle,
178
+ name: userName,
179
+ displayName: userDisplayName,
180
+ },
181
+ challenge: encodeBase64urlNoPadding(challenge),
182
+ pubKeyCredParams: rp.algorithms.map((alg) => ({
183
+ type: "public-key" as const,
184
+ alg,
185
+ })),
186
+ timeout: rp.challengeExpirationMs,
187
+ attestation: rp.attestation,
188
+ authenticatorSelection: {
189
+ residentKey: rp.residentKey,
190
+ requireResidentKey: rp.residentKey === "required",
191
+ userVerification: rp.userVerification,
192
+ ...(rp.authenticatorAttachment
193
+ ? { authenticatorAttachment: rp.authenticatorAttachment }
194
+ : {}),
195
+ },
196
+ excludeCredentials,
197
+ };
198
+
199
+ return { kind: "passkeyOptions", options, verifier };
200
+ }
201
+
202
+ /**
203
+ * Phase 2: Verify registration attestation and store the credential.
204
+ *
205
+ * Requires an authenticated user. Parses the attestation, verifies the
206
+ * challenge, extracts the public key, creates an account + passkey record
207
+ * linked to the current user, and returns auth tokens.
208
+ */
209
+ async function handleRegisterVerify(
210
+ ctx: EnrichedActionCtx,
211
+ provider: PasskeyProviderConfig,
212
+ params: Record<string, any>,
213
+ verifierValue: string | undefined,
214
+ ): Promise<{ kind: "signedIn"; signedIn: SessionInfo | null }> {
215
+ // Passkey registration requires an authenticated user
216
+ const identity = await ctx.auth.getUserIdentity();
217
+ if (identity === null) {
218
+ throw new Error(
219
+ "Passkey registration requires an authenticated user. " +
220
+ "Sign in first, then add a passkey to your account.",
221
+ );
222
+ }
223
+ const [userId] = identity.subject.split("|");
224
+
225
+ const rp = resolveRpOptions(provider);
226
+
227
+ if (!verifierValue) {
228
+ throw new Error("Missing verifier for passkey registration");
229
+ }
230
+
231
+ // Decode client data
232
+ const clientDataJSON = decodeBase64urlIgnorePadding(params.clientDataJSON);
233
+ const clientData = parseClientDataJSON(clientDataJSON);
234
+
235
+ // Verify client data type is "webauthn.create"
236
+ if (clientData.type !== ClientDataType.Create) {
237
+ throw new Error("Invalid client data type: expected webauthn.create");
238
+ }
239
+
240
+ // Verify origin
241
+ const allowedOrigins = Array.isArray(rp.origin) ? rp.origin : [rp.origin];
242
+ if (!allowedOrigins.includes(clientData.origin)) {
243
+ throw new Error(
244
+ `Invalid origin: ${clientData.origin}, expected one of: ${allowedOrigins.join(", ")}`,
245
+ );
246
+ }
247
+
248
+ // Verify challenge matches the stored verifier
249
+ const challengeHash = encodeBase64urlNoPadding(
250
+ new Uint8Array(sha256(clientData.challenge)),
251
+ );
252
+ const verifierDoc = await ctx.runQuery(
253
+ ctx.auth.config.component.public.verifierGetById,
254
+ { verifierId: verifierValue },
255
+ );
256
+ if (!verifierDoc || (verifierDoc as any).signature !== challengeHash) {
257
+ throw new Error("Invalid or expired challenge");
258
+ }
259
+
260
+ // Clean up the verifier
261
+ await ctx.runMutation(
262
+ ctx.auth.config.component.public.verifierDelete,
263
+ { verifierId: verifierValue },
264
+ );
265
+
266
+ // Parse attestation object
267
+ const attestationObjectBytes = decodeBase64urlIgnorePadding(params.attestationObject);
268
+ const attestation = parseAttestationObject(attestationObjectBytes);
269
+ const authenticatorData = attestation.authenticatorData;
270
+
271
+ // Verify RP ID hash
272
+ if (!authenticatorData.verifyRelyingPartyIdHash(rp.rpId)) {
273
+ throw new Error("Relying party ID mismatch");
274
+ }
275
+
276
+ // Verify user presence and verification flags
277
+ if (!authenticatorData.userPresent) {
278
+ throw new Error("User presence flag not set");
279
+ }
280
+ if (rp.userVerification === "required" && !authenticatorData.userVerified) {
281
+ throw new Error("User verification required but not performed");
282
+ }
283
+
284
+ // Extract credential
285
+ const credential = authenticatorData.credential;
286
+ if (!credential) {
287
+ throw new Error("No credential in attestation");
288
+ }
289
+
290
+ const credentialId = encodeBase64urlNoPadding(credential.id);
291
+ const publicKey = credential.publicKey;
292
+
293
+ // Determine algorithm and encode the public key for storage
294
+ let algorithm: number;
295
+ let publicKeyBytes: Uint8Array;
296
+
297
+ if (publicKey.isAlgorithmDefined()) {
298
+ algorithm = publicKey.algorithm();
299
+ } else {
300
+ const keyType = publicKey.type();
301
+ algorithm =
302
+ keyType === COSEKeyType.EC2
303
+ ? coseAlgorithmES256
304
+ : keyType === COSEKeyType.RSA
305
+ ? coseAlgorithmRS256
306
+ : coseAlgorithmES256;
307
+ }
308
+
309
+ if (algorithm === coseAlgorithmES256) {
310
+ const ec2 = publicKey.ec2();
311
+ // Encode as SEC1 uncompressed point (0x04 || x || y)
312
+ const xBytes = bigintToBytes(ec2.x, 32);
313
+ const yBytes = bigintToBytes(ec2.y, 32);
314
+ publicKeyBytes = new Uint8Array(65);
315
+ publicKeyBytes[0] = 0x04;
316
+ publicKeyBytes.set(xBytes, 1);
317
+ publicKeyBytes.set(yBytes, 33);
318
+ } else if (algorithm === coseAlgorithmRS256) {
319
+ const rsa = publicKey.rsa();
320
+ const rsaPubKey = new RSAPublicKey(rsa.n, rsa.e);
321
+ publicKeyBytes = rsaPubKey.encodePKCS1();
322
+ } else {
323
+ throw new Error(`Unsupported algorithm: ${algorithm}`);
324
+ }
325
+
326
+ const deviceType = params.deviceType ?? "single-device";
327
+ const backedUp = params.backedUp ?? false;
328
+
329
+ // Create an account record linking the passkey to the current user.
330
+ // Unlike unauthenticated flows, we don't create a new user — we
331
+ // attach the passkey credential to the existing authenticated user.
332
+ const db = authDb(ctx, ctx.auth.config);
333
+ await db.accounts.create({
334
+ userId: userId!,
335
+ provider: provider.id,
336
+ providerAccountId: credentialId,
337
+ });
338
+
339
+ // Store the passkey credential
340
+ await ctx.runMutation(
341
+ ctx.auth.config.component.public.passkeyInsert,
342
+ {
343
+ userId: userId!,
344
+ credentialId,
345
+ publicKey: publicKeyBytes.buffer.slice(
346
+ publicKeyBytes.byteOffset,
347
+ publicKeyBytes.byteOffset + publicKeyBytes.byteLength,
348
+ ),
349
+ algorithm,
350
+ counter: authenticatorData.signatureCounter,
351
+ transports: params.transports,
352
+ deviceType,
353
+ backedUp,
354
+ name: params.passkeyName,
355
+ createdAt: Date.now(),
356
+ },
357
+ );
358
+
359
+ // Return tokens for the existing session
360
+ const signInResult = await callSignIn(ctx, {
361
+ userId: userId!,
362
+ generateTokens: true,
363
+ });
364
+
365
+ return { kind: "signedIn", signedIn: signInResult };
366
+ }
367
+
368
+ // ============================================================================
369
+ // Authentication flow
370
+ // ============================================================================
371
+
372
+ /**
373
+ * Phase 3: Generate authentication options.
374
+ *
375
+ * Creates a challenge and returns PublicKeyCredentialRequestOptions.
376
+ * If an email is provided, scopes allowCredentials to that user's passkeys.
377
+ */
378
+ async function handleAuthOptions(
379
+ ctx: EnrichedActionCtx,
380
+ provider: PasskeyProviderConfig,
381
+ params: Record<string, any>,
382
+ ): Promise<{
383
+ kind: "passkeyOptions";
384
+ options: Record<string, any>;
385
+ verifier: string;
386
+ }> {
387
+ const rp = resolveRpOptions(provider);
388
+ const challenge = generateChallenge();
389
+ const challengeHash = hashChallenge(challenge);
390
+
391
+ // Store the challenge hash in the verifier table
392
+ const verifier = await callVerifier(ctx);
393
+ await callVerifierSignature(ctx, {
394
+ verifier,
395
+ signature: challengeHash,
396
+ });
397
+
398
+ // Build allowCredentials if email is provided
399
+ let allowCredentials: Array<{ type: string; id: string; transports?: string[] }> | undefined;
400
+ if (params.email) {
401
+ // Look up user by email, then find their passkeys
402
+ const user = await ctx.runQuery(
403
+ ctx.auth.config.component.public.userFindByVerifiedEmail,
404
+ { email: params.email },
405
+ );
406
+ if (user) {
407
+ const passkeys = await ctx.runQuery(
408
+ ctx.auth.config.component.public.passkeyListByUserId,
409
+ { userId: (user as any)._id },
410
+ );
411
+ if (passkeys && (passkeys as any[]).length > 0) {
412
+ allowCredentials = (passkeys as any[]).map((pk: any) => ({
413
+ type: "public-key",
414
+ id: pk.credentialId,
415
+ transports: pk.transports,
416
+ }));
417
+ }
418
+ }
419
+ }
420
+
421
+ const options: Record<string, any> = {
422
+ challenge: encodeBase64urlNoPadding(challenge),
423
+ timeout: rp.challengeExpirationMs,
424
+ rpId: rp.rpId,
425
+ userVerification: rp.userVerification,
426
+ };
427
+
428
+ if (allowCredentials) {
429
+ options.allowCredentials = allowCredentials;
430
+ }
431
+
432
+ return { kind: "passkeyOptions", options, verifier };
433
+ }
434
+
435
+ /**
436
+ * Phase 4: Verify authentication assertion and sign in.
437
+ *
438
+ * Verifies the signature against the stored public key, checks the counter,
439
+ * and creates a session.
440
+ */
441
+ async function handleAuthVerify(
442
+ ctx: EnrichedActionCtx,
443
+ provider: PasskeyProviderConfig,
444
+ params: Record<string, any>,
445
+ verifierValue: string | undefined,
446
+ ): Promise<{ kind: "signedIn"; signedIn: SessionInfo | null }> {
447
+ const rp = resolveRpOptions(provider);
448
+
449
+ if (!verifierValue) {
450
+ throw new Error("Missing verifier for passkey authentication");
451
+ }
452
+
453
+ // Decode client data
454
+ const clientDataJSON = decodeBase64urlIgnorePadding(params.clientDataJSON);
455
+ const clientData = parseClientDataJSON(clientDataJSON);
456
+
457
+ // Verify client data type is "webauthn.get"
458
+ if (clientData.type !== ClientDataType.Get) {
459
+ throw new Error("Invalid client data type: expected webauthn.get");
460
+ }
461
+
462
+ // Verify origin
463
+ const allowedOrigins = Array.isArray(rp.origin) ? rp.origin : [rp.origin];
464
+ if (!allowedOrigins.includes(clientData.origin)) {
465
+ throw new Error(
466
+ `Invalid origin: ${clientData.origin}, expected one of: ${allowedOrigins.join(", ")}`,
467
+ );
468
+ }
469
+
470
+ // Verify challenge matches the stored verifier
471
+ const challengeHash = encodeBase64urlNoPadding(
472
+ new Uint8Array(sha256(clientData.challenge)),
473
+ );
474
+ const verifierDoc = await ctx.runQuery(
475
+ ctx.auth.config.component.public.verifierGetById,
476
+ { verifierId: verifierValue },
477
+ );
478
+ if (!verifierDoc || (verifierDoc as any).signature !== challengeHash) {
479
+ throw new Error("Invalid or expired challenge");
480
+ }
481
+
482
+ // Clean up the verifier
483
+ await ctx.runMutation(
484
+ ctx.auth.config.component.public.verifierDelete,
485
+ { verifierId: verifierValue },
486
+ );
487
+
488
+ // Look up the credential
489
+ const credentialId = params.credentialId;
490
+ if (!credentialId) {
491
+ throw new Error("Missing credential ID");
492
+ }
493
+
494
+ const passkeyDoc = await ctx.runQuery(
495
+ ctx.auth.config.component.public.passkeyGetByCredentialId,
496
+ { credentialId },
497
+ );
498
+ if (!passkeyDoc) {
499
+ throw new Error("Unknown credential");
500
+ }
501
+ const passkey = passkeyDoc as any;
502
+
503
+ // Parse authenticator data
504
+ const authenticatorDataBytes = decodeBase64urlIgnorePadding(params.authenticatorData);
505
+ const authenticatorData = parseAuthenticatorData(authenticatorDataBytes);
506
+
507
+ // Verify RP ID hash
508
+ if (!authenticatorData.verifyRelyingPartyIdHash(rp.rpId)) {
509
+ throw new Error("Relying party ID mismatch");
510
+ }
511
+
512
+ // Verify user presence
513
+ if (!authenticatorData.userPresent) {
514
+ throw new Error("User presence flag not set");
515
+ }
516
+ if (rp.userVerification === "required" && !authenticatorData.userVerified) {
517
+ throw new Error("User verification required but not performed");
518
+ }
519
+
520
+ // Verify signature
521
+ const signature = decodeBase64urlIgnorePadding(params.signature);
522
+ const signatureMessage = createAssertionSignatureMessage(
523
+ authenticatorDataBytes,
524
+ clientDataJSON,
525
+ );
526
+ const messageHash = sha256(signatureMessage);
527
+
528
+ const storedPublicKeyBytes = new Uint8Array(passkey.publicKey);
529
+
530
+ if (passkey.algorithm === coseAlgorithmES256) {
531
+ // EC P-256 verification
532
+ const ecPublicKey = decodeSEC1PublicKey(p256, storedPublicKeyBytes);
533
+ // WebAuthn signatures for EC keys are DER/ASN.1 (PKIX) encoded
534
+ const ecdsaSignature = decodePKIXECDSASignature(signature);
535
+ const valid = verifyECDSASignature(
536
+ ecPublicKey,
537
+ messageHash,
538
+ ecdsaSignature,
539
+ );
540
+ if (!valid) {
541
+ throw new Error("Invalid signature");
542
+ }
543
+ } else if (passkey.algorithm === coseAlgorithmRS256) {
544
+ // RSA PKCS#1 v1.5 with SHA-256 verification
545
+ // Decode the stored PKCS#1 public key
546
+ const { decodePKCS1RSAPublicKey } = await import("@oslojs/crypto/rsa");
547
+ const rsaPublicKey = decodePKCS1RSAPublicKey(storedPublicKeyBytes);
548
+ const valid = verifyRSASSAPKCS1v15Signature(
549
+ rsaPublicKey,
550
+ sha256ObjectIdentifier,
551
+ messageHash,
552
+ signature,
553
+ );
554
+ if (!valid) {
555
+ throw new Error("Invalid signature");
556
+ }
557
+ } else {
558
+ throw new Error(`Unsupported algorithm: ${passkey.algorithm}`);
559
+ }
560
+
561
+ // Verify counter (clone detection)
562
+ // Counter of 0 means the authenticator doesn't support counters
563
+ if (
564
+ passkey.counter !== 0 &&
565
+ authenticatorData.signatureCounter !== 0 &&
566
+ authenticatorData.signatureCounter <= passkey.counter
567
+ ) {
568
+ throw new Error(
569
+ "Authenticator counter did not increase — possible credential cloning detected",
570
+ );
571
+ }
572
+
573
+ // Update counter and last used timestamp
574
+ await ctx.runMutation(
575
+ ctx.auth.config.component.public.passkeyUpdateCounter,
576
+ {
577
+ passkeyId: passkey._id,
578
+ counter: authenticatorData.signatureCounter,
579
+ lastUsedAt: Date.now(),
580
+ },
581
+ );
582
+
583
+ // Sign in the user
584
+ const signInResult = await callSignIn(ctx, {
585
+ userId: passkey.userId,
586
+ generateTokens: true,
587
+ });
588
+
589
+ return { kind: "signedIn", signedIn: signInResult };
590
+ }
591
+
592
+ // ============================================================================
593
+ // Main dispatch
594
+ // ============================================================================
595
+
596
+ /**
597
+ * Main passkey handler dispatched from signIn.ts.
598
+ *
599
+ * Routes to the appropriate phase based on `params.flow`.
600
+ */
601
+ export async function handlePasskey(
602
+ ctx: EnrichedActionCtx,
603
+ provider: PasskeyProviderConfig,
604
+ args: {
605
+ params?: Record<string, any>;
606
+ verifier?: string;
607
+ },
608
+ ): Promise<
609
+ | { kind: "signedIn"; signedIn: SessionInfo | null }
610
+ | { kind: "passkeyOptions"; options: Record<string, any>; verifier: string }
611
+ > {
612
+ const flow = args.params?.flow;
613
+ if (!flow) {
614
+ throw new Error(
615
+ "Missing `flow` parameter. Expected one of: register-options, register-verify, auth-options, auth-verify",
616
+ );
617
+ }
618
+
619
+ switch (flow) {
620
+ case "register-options":
621
+ return handleRegisterOptions(ctx, provider, args.params ?? {});
622
+ case "register-verify":
623
+ return handleRegisterVerify(ctx, provider, args.params ?? {}, args.verifier);
624
+ case "auth-options":
625
+ return handleAuthOptions(ctx, provider, args.params ?? {});
626
+ case "auth-verify":
627
+ return handleAuthVerify(ctx, provider, args.params ?? {}, args.verifier);
628
+ default:
629
+ throw new Error(
630
+ `Unknown passkey flow: ${flow}. Expected one of: register-options, register-verify, auth-options, auth-verify`,
631
+ );
632
+ }
633
+ }
634
+
635
+ // ============================================================================
636
+ // Helpers
637
+ // ============================================================================
638
+
639
+ /**
640
+ * Convert a bigint to a fixed-size big-endian byte array.
641
+ */
642
+ function bigintToBytes(value: bigint, length: number): Uint8Array {
643
+ const bytes = new Uint8Array(length);
644
+ let v = value;
645
+ for (let i = length - 1; i >= 0; i--) {
646
+ bytes[i] = Number(v & 0xffn);
647
+ v >>= 8n;
648
+ }
649
+ return bytes;
650
+ }
@@ -19,19 +19,12 @@ export async function redirectAbsoluteUrl(
19
19
  }
20
20
 
21
21
  async function defaultRedirectCallback({ redirectTo }: { redirectTo: string }) {
22
- const baseUrl = siteUrl();
22
+ // Resolve relative paths against SITE_URL; absolute URLs are passed through
23
+ // as-is. The developer is trusted to provide valid redirect targets.
23
24
  if (redirectTo.startsWith("?") || redirectTo.startsWith("/")) {
24
- return `${baseUrl}${redirectTo}`;
25
+ return `${siteUrl()}${redirectTo}`;
25
26
  }
26
- if (redirectTo.startsWith(baseUrl)) {
27
- const after = redirectTo[baseUrl.length];
28
- if (after === undefined || after === "?" || after === "/") {
29
- return redirectTo;
30
- }
31
- }
32
- throw new Error(
33
- `Invalid \`redirectTo\` ${redirectTo} for configured SITE_URL: ${baseUrl.toString()}`,
34
- );
27
+ return redirectTo;
35
28
  }
36
29
 
37
30
  // Temporary work-around because Convex doesn't support