@robelest/convex-auth 0.0.2-preview.1 → 0.0.2

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 (145) hide show
  1. package/dist/bin.cjs +466 -63
  2. package/dist/client/index.d.ts +211 -30
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +673 -59
  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 +93 -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 -3
  15. package/dist/component/index.d.ts.map +1 -1
  16. package/dist/component/index.js +5 -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 +193 -9
  23. package/dist/component/public.d.ts.map +1 -1
  24. package/dist/component/public.js +204 -33
  25. package/dist/component/public.js.map +1 -1
  26. package/dist/component/schema.d.ts +89 -9
  27. package/dist/component/schema.d.ts.map +1 -1
  28. package/dist/component/schema.js +68 -7
  29. package/dist/component/schema.js.map +1 -1
  30. package/dist/providers/{Anonymous.d.ts → anonymous.d.ts} +8 -8
  31. package/dist/providers/{Anonymous.d.ts.map → anonymous.d.ts.map} +1 -1
  32. package/dist/providers/{Anonymous.js → anonymous.js} +9 -10
  33. package/dist/providers/anonymous.js.map +1 -0
  34. package/dist/providers/{ConvexCredentials.d.ts → credentials.d.ts} +11 -11
  35. package/dist/providers/credentials.d.ts.map +1 -0
  36. package/dist/providers/{ConvexCredentials.js → credentials.js} +8 -8
  37. package/dist/providers/credentials.js.map +1 -0
  38. package/dist/providers/{Email.d.ts → email.d.ts} +6 -6
  39. package/dist/providers/email.d.ts.map +1 -0
  40. package/dist/providers/{Email.js → email.js} +6 -6
  41. package/dist/providers/email.js.map +1 -0
  42. package/dist/providers/passkey.d.ts +20 -0
  43. package/dist/providers/passkey.d.ts.map +1 -0
  44. package/dist/providers/passkey.js +32 -0
  45. package/dist/providers/passkey.js.map +1 -0
  46. package/dist/providers/{Password.d.ts → password.d.ts} +10 -10
  47. package/dist/providers/{Password.d.ts.map → password.d.ts.map} +1 -1
  48. package/dist/providers/{Password.js → password.js} +19 -20
  49. package/dist/providers/password.js.map +1 -0
  50. package/dist/providers/{Phone.d.ts → phone.d.ts} +3 -3
  51. package/dist/providers/{Phone.d.ts.map → phone.d.ts.map} +1 -1
  52. package/dist/providers/{Phone.js → phone.js} +3 -3
  53. package/dist/providers/{Phone.js.map → phone.js.map} +1 -1
  54. package/dist/providers/totp.d.ts +14 -0
  55. package/dist/providers/totp.d.ts.map +1 -0
  56. package/dist/providers/totp.js +23 -0
  57. package/dist/providers/totp.js.map +1 -0
  58. package/dist/server/convex-auth.d.ts +243 -0
  59. package/dist/server/convex-auth.d.ts.map +1 -0
  60. package/dist/server/convex-auth.js +365 -0
  61. package/dist/server/convex-auth.js.map +1 -0
  62. package/dist/server/implementation/index.d.ts +153 -166
  63. package/dist/server/implementation/index.d.ts.map +1 -1
  64. package/dist/server/implementation/index.js +162 -105
  65. package/dist/server/implementation/index.js.map +1 -1
  66. package/dist/server/implementation/passkey.d.ts +33 -0
  67. package/dist/server/implementation/passkey.d.ts.map +1 -0
  68. package/dist/server/implementation/passkey.js +450 -0
  69. package/dist/server/implementation/passkey.js.map +1 -0
  70. package/dist/server/implementation/redirects.d.ts.map +1 -1
  71. package/dist/server/implementation/redirects.js +4 -9
  72. package/dist/server/implementation/redirects.js.map +1 -1
  73. package/dist/server/implementation/sessions.d.ts +2 -20
  74. package/dist/server/implementation/sessions.d.ts.map +1 -1
  75. package/dist/server/implementation/sessions.js +2 -20
  76. package/dist/server/implementation/sessions.js.map +1 -1
  77. package/dist/server/implementation/signIn.d.ts +13 -0
  78. package/dist/server/implementation/signIn.d.ts.map +1 -1
  79. package/dist/server/implementation/signIn.js +26 -1
  80. package/dist/server/implementation/signIn.js.map +1 -1
  81. package/dist/server/implementation/totp.d.ts +40 -0
  82. package/dist/server/implementation/totp.d.ts.map +1 -0
  83. package/dist/server/implementation/totp.js +211 -0
  84. package/dist/server/implementation/totp.js.map +1 -0
  85. package/dist/server/index.d.ts +18 -0
  86. package/dist/server/index.d.ts.map +1 -1
  87. package/dist/server/index.js +255 -0
  88. package/dist/server/index.js.map +1 -1
  89. package/dist/server/portal-email.d.ts +19 -0
  90. package/dist/server/portal-email.d.ts.map +1 -0
  91. package/dist/server/portal-email.js +89 -0
  92. package/dist/server/portal-email.js.map +1 -0
  93. package/dist/server/portal.d.ts +116 -0
  94. package/dist/server/portal.d.ts.map +1 -0
  95. package/dist/server/portal.js +294 -0
  96. package/dist/server/portal.js.map +1 -0
  97. package/dist/server/provider_utils.d.ts +1 -1
  98. package/dist/server/provider_utils.d.ts.map +1 -1
  99. package/dist/server/provider_utils.js +39 -1
  100. package/dist/server/provider_utils.js.map +1 -1
  101. package/dist/server/types.d.ts +128 -11
  102. package/dist/server/types.d.ts.map +1 -1
  103. package/package.json +7 -7
  104. package/src/cli/index.ts +48 -6
  105. package/src/cli/portal-link.ts +112 -0
  106. package/src/cli/portal-upload.ts +411 -0
  107. package/src/client/index.ts +823 -109
  108. package/src/component/_generated/api.ts +72 -1
  109. package/src/component/_generated/component.ts +180 -4
  110. package/src/component/convex.config.ts +3 -0
  111. package/src/component/index.ts +5 -10
  112. package/src/component/portalBridge.ts +116 -0
  113. package/src/component/public.ts +231 -37
  114. package/src/component/schema.ts +70 -7
  115. package/src/providers/{Anonymous.ts → anonymous.ts} +10 -11
  116. package/src/providers/{ConvexCredentials.ts → credentials.ts} +11 -11
  117. package/src/providers/{Email.ts → email.ts} +5 -5
  118. package/src/providers/passkey.ts +35 -0
  119. package/src/providers/{Password.ts → password.ts} +22 -27
  120. package/src/providers/{Phone.ts → phone.ts} +2 -2
  121. package/src/providers/totp.ts +26 -0
  122. package/src/server/convex-auth.ts +470 -0
  123. package/src/server/implementation/index.ts +228 -239
  124. package/src/server/implementation/passkey.ts +650 -0
  125. package/src/server/implementation/redirects.ts +4 -11
  126. package/src/server/implementation/sessions.ts +2 -20
  127. package/src/server/implementation/signIn.ts +39 -1
  128. package/src/server/implementation/totp.ts +366 -0
  129. package/src/server/index.ts +373 -0
  130. package/src/server/portal-email.ts +95 -0
  131. package/src/server/portal.ts +375 -0
  132. package/src/server/provider_utils.ts +42 -1
  133. package/src/server/types.ts +161 -10
  134. package/dist/providers/Anonymous.js.map +0 -1
  135. package/dist/providers/ConvexCredentials.d.ts.map +0 -1
  136. package/dist/providers/ConvexCredentials.js.map +0 -1
  137. package/dist/providers/Email.d.ts.map +0 -1
  138. package/dist/providers/Email.js.map +0 -1
  139. package/dist/providers/Password.js.map +0 -1
  140. package/providers/Anonymous/package.json +0 -6
  141. package/providers/ConvexCredentials/package.json +0 -6
  142. package/providers/Email/package.json +0 -6
  143. package/providers/Password/package.json +0 -6
  144. package/providers/Phone/package.json +0 -6
  145. package/server/package.json +0 -6
@@ -4,7 +4,9 @@ import {
4
4
  ConvexCredentialsConfig,
5
5
  EmailConfig,
6
6
  GenericActionCtxWithAuthConfig,
7
+ PasskeyProviderConfig,
7
8
  PhoneConfig,
9
+ TotpProviderConfig,
8
10
  } from "../types.js";
9
11
  import {
10
12
  AuthDataModel,
@@ -17,12 +19,15 @@ import {
17
19
  callRefreshSession,
18
20
  callSignIn,
19
21
  callVerifier,
22
+ callVerifierSignature,
20
23
  callVerifyCodeAndSignIn,
21
24
  } from "./mutations/index.js";
22
25
  import { redirectAbsoluteUrl, setURLSearchParam } from "./redirects.js";
23
26
  import { requireEnv } from "../utils.js";
24
27
  import { OAuth2Config, OIDCConfig } from "@auth/core/providers/oauth.js";
25
28
  import { generateRandomString } from "./utils.js";
29
+ import { handlePasskey } from "./passkey.js";
30
+ import { handleTotp, checkTotpRequired } from "./totp.js";
26
31
 
27
32
  const DEFAULT_EMAIL_VERIFICATION_CODE_DURATION_S = 60 * 60 * 24; // 24 hours
28
33
 
@@ -50,6 +55,12 @@ export async function signInImpl(
50
55
  | { kind: "started"; started: true }
51
56
  // OAuth2 and OIDC flows
52
57
  | { kind: "redirect"; redirect: string; verifier: string }
58
+ // Passkey options (challenge + credential options)
59
+ | { kind: "passkeyOptions"; options: Record<string, any>; verifier: string }
60
+ // TOTP 2FA required after credentials sign-in
61
+ | { kind: "totpRequired"; verifier: string }
62
+ // TOTP setup response (enrollment)
63
+ | { kind: "totpSetup"; uri: string; secret: string; verifier: string; totpId: string }
53
64
  > {
54
65
  if (provider === null && args.refreshToken) {
55
66
  const tokens: Tokens = (await callRefreshSession(ctx, {
@@ -84,6 +95,12 @@ export async function signInImpl(
84
95
  if (provider.type === "oauth" || provider.type === "oidc") {
85
96
  return handleOAuthProvider(ctx, provider, args, options);
86
97
  }
98
+ if (provider.type === "passkey") {
99
+ return handlePasskey(ctx, provider, args);
100
+ }
101
+ if (provider.type === "totp") {
102
+ return handleTotp(ctx, provider, args);
103
+ }
87
104
  const _typecheck: never = provider;
88
105
  throw new Error(
89
106
  `Provider type ${(provider as any).type} is not supported yet`,
@@ -187,11 +204,32 @@ async function handleCredentials(
187
204
  options: {
188
205
  generateTokens: boolean;
189
206
  },
190
- ): Promise<{ kind: "signedIn"; signedIn: SessionInfo | null }> {
207
+ ): Promise<
208
+ | { kind: "signedIn"; signedIn: SessionInfo | null }
209
+ | { kind: "totpRequired"; verifier: string }
210
+ > {
191
211
  const result = await provider.authorize(args.params ?? {}, ctx);
192
212
  if (result === null) {
193
213
  return { kind: "signedIn", signedIn: null };
194
214
  }
215
+ // Check if user has TOTP 2FA enrolled before issuing tokens
216
+ const hasTotpEnrolled = await checkTotpRequired(ctx, result.userId);
217
+ if (hasTotpEnrolled) {
218
+ // Create session but withhold tokens — TOTP verification needed
219
+ const idsWithoutTokens = await callSignIn(ctx, {
220
+ userId: result.userId,
221
+ sessionId: result.sessionId,
222
+ generateTokens: false,
223
+ });
224
+ // Store userId in verifier so the TOTP verify flow can complete sign-in
225
+ const verifier = await callVerifier(ctx);
226
+ await callVerifierSignature(ctx, {
227
+ verifier,
228
+ signature: JSON.stringify({ userId: result.userId }),
229
+ });
230
+ return { kind: "totpRequired", verifier };
231
+ }
232
+
195
233
  const idsAndTokens = await callSignIn(ctx, {
196
234
  userId: result.userId,
197
235
  sessionId: result.sessionId,
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Server-side TOTP ceremony logic for two-factor authentication.
3
+ *
4
+ * Handles the three phases of the TOTP flow:
5
+ * 1. setup — generate a TOTP secret and `otpauth://` URI for enrollment
6
+ * 2. confirm — verify the first code from the authenticator app and mark
7
+ * the enrollment as verified
8
+ * 3. verify — verify a TOTP code during sign-in (2FA challenge)
9
+ *
10
+ * Uses `@oslojs/otp` for TOTP generation / verification and
11
+ * `@oslojs/encoding` for base-32 secret encoding.
12
+ */
13
+
14
+ import { GenericId } from "convex/values";
15
+ import {
16
+ generateTOTP,
17
+ verifyTOTPWithGracePeriod,
18
+ createTOTPKeyURI,
19
+ } from "@oslojs/otp";
20
+ import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding";
21
+ import {
22
+ TotpProviderConfig,
23
+ GenericActionCtxWithAuthConfig,
24
+ } from "../types.js";
25
+ import { AuthDataModel, SessionInfo } from "./types.js";
26
+ import { callSignIn, callVerifier } from "./mutations/index.js";
27
+ import { callVerifierSignature } from "./mutations/verifierSignature.js";
28
+
29
+ type EnrichedActionCtx = GenericActionCtxWithAuthConfig<AuthDataModel>;
30
+
31
+ // ============================================================================
32
+ // Setup flow
33
+ // ============================================================================
34
+
35
+ /**
36
+ * Phase 1: Generate a TOTP secret and enrollment URI.
37
+ *
38
+ * Requires an authenticated user — TOTP enrollment always adds a second
39
+ * factor to an existing account. The userId is taken from the current
40
+ * session identity.
41
+ */
42
+ async function handleSetup(
43
+ ctx: EnrichedActionCtx,
44
+ provider: TotpProviderConfig,
45
+ params: Record<string, any>,
46
+ ): Promise<{
47
+ kind: "totpSetup";
48
+ uri: string;
49
+ secret: string;
50
+ verifier: string;
51
+ totpId: string;
52
+ }> {
53
+ // TOTP enrollment requires an authenticated user
54
+ const identity = await ctx.auth.getUserIdentity();
55
+ if (identity === null) {
56
+ throw new Error(
57
+ "TOTP enrollment requires an authenticated user. " +
58
+ "Sign in first, then add TOTP to your account.",
59
+ );
60
+ }
61
+ const [userId] = identity.subject.split("|");
62
+
63
+ // Generate a 20-byte random secret (160 bits, per RFC 4226 recommendation)
64
+ const secret = new Uint8Array(20);
65
+ crypto.getRandomValues(secret);
66
+
67
+ // Resolve the account name for the otpauth:// URI
68
+ let accountName: string = params.accountName as string;
69
+ if (!accountName) {
70
+ const user = await ctx.runQuery(
71
+ ctx.auth.config.component.public.userGetById,
72
+ { userId: userId! },
73
+ );
74
+ accountName = (user as any)?.email ?? "user";
75
+ }
76
+
77
+ // Build the otpauth:// URI for QR code scanning
78
+ const uri = createTOTPKeyURI(
79
+ provider.options.issuer,
80
+ accountName,
81
+ secret,
82
+ provider.options.period,
83
+ provider.options.digits,
84
+ );
85
+
86
+ // Encode the secret as base-32 for manual entry
87
+ const base32Secret = encodeBase32LowerCaseNoPadding(secret);
88
+
89
+ // Store enrolment metadata in a verifier so we can correlate the confirm step
90
+ const verifier = await callVerifier(ctx);
91
+ await callVerifierSignature(ctx, {
92
+ verifier,
93
+ signature: JSON.stringify({
94
+ secret: Array.from(secret),
95
+ userId,
96
+ digits: provider.options.digits,
97
+ period: provider.options.period,
98
+ }),
99
+ });
100
+
101
+ // Insert an UNVERIFIED TOTP record in the DB
102
+ const totpId = await ctx.runMutation(
103
+ ctx.auth.config.component.public.totpInsert,
104
+ {
105
+ userId: userId as any,
106
+ secret: secret.buffer.slice(
107
+ secret.byteOffset,
108
+ secret.byteOffset + secret.byteLength,
109
+ ),
110
+ digits: provider.options.digits,
111
+ period: provider.options.period,
112
+ verified: false,
113
+ name: params.name,
114
+ createdAt: Date.now(),
115
+ },
116
+ );
117
+
118
+ return {
119
+ kind: "totpSetup" as const,
120
+ uri,
121
+ secret: base32Secret,
122
+ verifier,
123
+ totpId: totpId as string,
124
+ };
125
+ }
126
+
127
+ // ============================================================================
128
+ // Confirm flow
129
+ // ============================================================================
130
+
131
+ /**
132
+ * Phase 2: Verify the first code from the authenticator app.
133
+ *
134
+ * Requires an authenticated user. Marks the TOTP enrollment as verified
135
+ * after confirming the code is correct.
136
+ */
137
+ async function handleConfirm(
138
+ ctx: EnrichedActionCtx,
139
+ provider: TotpProviderConfig,
140
+ params: Record<string, any>,
141
+ verifierValue: string | undefined,
142
+ ): Promise<{ kind: "signedIn"; signedIn: SessionInfo | null }> {
143
+ // TOTP confirmation requires an authenticated user
144
+ const identity = await ctx.auth.getUserIdentity();
145
+ if (identity === null) {
146
+ throw new Error(
147
+ "TOTP confirmation requires an authenticated user. " +
148
+ "Sign in first, then confirm your TOTP enrollment.",
149
+ );
150
+ }
151
+ const [userId] = identity.subject.split("|");
152
+
153
+ if (!verifierValue) {
154
+ throw new Error("Missing verifier");
155
+ }
156
+ if (!params.code) {
157
+ throw new Error("Missing `code` parameter");
158
+ }
159
+ if (!params.totpId) {
160
+ throw new Error("Missing `totpId` parameter");
161
+ }
162
+
163
+ // Look up the TOTP record
164
+ const totpDoc = await ctx.runQuery(
165
+ ctx.auth.config.component.public.totpGetById,
166
+ { totpId: params.totpId },
167
+ );
168
+ if (!totpDoc) {
169
+ throw new Error("TOTP enrollment not found");
170
+ }
171
+ if ((totpDoc as any).verified) {
172
+ throw new Error("TOTP enrollment is already verified");
173
+ }
174
+
175
+ // Extract the secret from the TOTP record
176
+ const secret = new Uint8Array((totpDoc as any).secret);
177
+
178
+ // Verify the code with a 30-second grace period
179
+ const valid = verifyTOTPWithGracePeriod(
180
+ secret,
181
+ provider.options.period,
182
+ provider.options.digits,
183
+ params.code,
184
+ 30,
185
+ );
186
+ if (!valid) {
187
+ throw new Error("Invalid TOTP code");
188
+ }
189
+
190
+ // Mark the enrollment as verified
191
+ await ctx.runMutation(
192
+ ctx.auth.config.component.public.totpMarkVerified,
193
+ { totpId: params.totpId as any, lastUsedAt: Date.now() },
194
+ );
195
+
196
+ // Clean up the verifier
197
+ await ctx.runMutation(
198
+ ctx.auth.config.component.public.verifierDelete,
199
+ { verifierId: verifierValue },
200
+ );
201
+
202
+ // Return tokens for the existing session
203
+ const signInResult = await callSignIn(ctx, {
204
+ userId: userId!,
205
+ generateTokens: true,
206
+ });
207
+
208
+ return { kind: "signedIn", signedIn: signInResult };
209
+ }
210
+
211
+ // ============================================================================
212
+ // Verify flow (2FA during sign-in)
213
+ // ============================================================================
214
+
215
+ /**
216
+ * Phase 3: Verify a TOTP code during sign-in.
217
+ *
218
+ * Does NOT require an authenticated user — this runs mid-sign-in as a
219
+ * second-factor challenge. The userId is retrieved from the stored verifier.
220
+ */
221
+ async function handleVerify(
222
+ ctx: EnrichedActionCtx,
223
+ provider: TotpProviderConfig,
224
+ params: Record<string, any>,
225
+ verifierValue: string | undefined,
226
+ ): Promise<{ kind: "signedIn"; signedIn: SessionInfo | null }> {
227
+ if (!verifierValue) {
228
+ throw new Error("Missing verifier");
229
+ }
230
+ if (!params.code) {
231
+ throw new Error("Missing `code` parameter");
232
+ }
233
+
234
+ // Look up the verifier to retrieve the stored userId
235
+ const verifierDoc = await ctx.runQuery(
236
+ ctx.auth.config.component.public.verifierGetById,
237
+ { verifierId: verifierValue },
238
+ );
239
+ if (!verifierDoc) {
240
+ throw new Error("Invalid or expired verifier");
241
+ }
242
+
243
+ // Parse the signature to extract userId
244
+ const signatureData = JSON.parse((verifierDoc as any).signature);
245
+ const userId = signatureData.userId as string;
246
+
247
+ // Look up the user's verified TOTP enrollment
248
+ const totpDoc = await ctx.runQuery(
249
+ ctx.auth.config.component.public.totpGetVerifiedByUserId,
250
+ { userId: userId as any },
251
+ );
252
+ if (!totpDoc) {
253
+ throw new Error("No TOTP enrollment found");
254
+ }
255
+
256
+ // Extract the secret from the TOTP record
257
+ const secret = new Uint8Array((totpDoc as any).secret);
258
+
259
+ // Verify the code with a 30-second grace period
260
+ const valid = verifyTOTPWithGracePeriod(
261
+ secret,
262
+ (totpDoc as any).period,
263
+ (totpDoc as any).digits,
264
+ params.code,
265
+ 30,
266
+ );
267
+ if (!valid) {
268
+ throw new Error("Invalid TOTP code");
269
+ }
270
+
271
+ // Update last used timestamp
272
+ await ctx.runMutation(
273
+ ctx.auth.config.component.public.totpUpdateLastUsed,
274
+ { totpId: (totpDoc as any)._id, lastUsedAt: Date.now() },
275
+ );
276
+
277
+ // Clean up the verifier
278
+ await ctx.runMutation(
279
+ ctx.auth.config.component.public.verifierDelete,
280
+ { verifierId: verifierValue },
281
+ );
282
+
283
+ // Sign in the user with tokens
284
+ const signInResult = await callSignIn(ctx, {
285
+ userId,
286
+ generateTokens: true,
287
+ });
288
+
289
+ return { kind: "signedIn", signedIn: signInResult };
290
+ }
291
+
292
+ // ============================================================================
293
+ // Main dispatch
294
+ // ============================================================================
295
+
296
+ /**
297
+ * Main TOTP handler dispatched from signIn.ts.
298
+ *
299
+ * Routes to the appropriate phase based on `params.flow`.
300
+ */
301
+ export async function handleTotp(
302
+ ctx: EnrichedActionCtx,
303
+ provider: TotpProviderConfig,
304
+ args: {
305
+ params?: Record<string, any>;
306
+ verifier?: string;
307
+ },
308
+ ): Promise<
309
+ | { kind: "signedIn"; signedIn: SessionInfo | null }
310
+ | {
311
+ kind: "totpSetup";
312
+ uri: string;
313
+ secret: string;
314
+ verifier: string;
315
+ totpId: string;
316
+ }
317
+ > {
318
+ const flow = args.params?.flow;
319
+ if (!flow) {
320
+ throw new Error(
321
+ "Missing `flow` parameter. Expected one of: setup, confirm, verify",
322
+ );
323
+ }
324
+
325
+ switch (flow) {
326
+ case "setup":
327
+ return handleSetup(ctx, provider, args.params ?? {});
328
+ case "confirm":
329
+ return handleConfirm(
330
+ ctx,
331
+ provider,
332
+ args.params ?? {},
333
+ args.verifier,
334
+ );
335
+ case "verify":
336
+ return handleVerify(
337
+ ctx,
338
+ provider,
339
+ args.params ?? {},
340
+ args.verifier,
341
+ );
342
+ default:
343
+ throw new Error(
344
+ `Unknown TOTP flow: ${flow}. Expected one of: setup, confirm, verify`,
345
+ );
346
+ }
347
+ }
348
+
349
+ // ============================================================================
350
+ // Helpers
351
+ // ============================================================================
352
+
353
+ /**
354
+ * Check if a user has a verified TOTP enrollment.
355
+ * Called after credentials sign-in to determine if 2FA is needed.
356
+ */
357
+ export async function checkTotpRequired(
358
+ ctx: EnrichedActionCtx,
359
+ userId: string,
360
+ ): Promise<boolean> {
361
+ const totpDoc = await ctx.runQuery(
362
+ ctx.auth.config.component.public.totpGetVerifiedByUserId,
363
+ { userId: userId as any },
364
+ );
365
+ return totpDoc !== null;
366
+ }