@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
@@ -1,54 +1,87 @@
1
- import { FunctionReference, OptionalRestArgs } from "convex/server";
1
+ import { ConvexHttpClient } from "convex/browser";
2
2
  import { Value } from "convex/values";
3
- import type {
4
- SignInAction,
5
- SignOutAction,
6
- } from "../server/implementation/index.js";
7
-
8
- type AuthActionCaller = {
9
- authenticatedCall<Action extends FunctionReference<"action", "public">>(
10
- action: Action,
11
- ...args: OptionalRestArgs<Action>
12
- ): Promise<Action["_returnType"]>;
13
- unauthenticatedCall<Action extends FunctionReference<"action", "public">>(
14
- action: Action,
15
- ...args: OptionalRestArgs<Action>
16
- ): Promise<Action["_returnType"]>;
17
- verbose?: boolean;
18
- logger?: {
19
- logVerbose?: (message: string) => void;
20
- };
21
- };
22
3
 
23
- export interface TokenStorage {
24
- getItem(key: string): string | null | undefined | Promise<string | null | undefined>;
4
+ /**
5
+ * Structural interface for any Convex client.
6
+ * Satisfied by both `ConvexClient` (`convex/browser`) and
7
+ * `ConvexReactClient` (`convex/react`).
8
+ */
9
+ interface ConvexTransport {
10
+ action(action: any, args: any): Promise<any>;
11
+ setAuth(
12
+ fetchToken: (args: {
13
+ forceRefreshToken: boolean;
14
+ }) => Promise<string | null | undefined>,
15
+ onChange?: (isAuthenticated: boolean) => void,
16
+ ): void;
17
+ clearAuth(): void;
18
+ }
19
+
20
+ /** Pluggable key-value storage (defaults to `localStorage`). */
21
+ export interface Storage {
22
+ getItem(
23
+ key: string,
24
+ ): string | null | undefined | Promise<string | null | undefined>;
25
25
  setItem(key: string, value: string): void | Promise<void>;
26
26
  removeItem(key: string): void | Promise<void>;
27
27
  }
28
28
 
29
- export type AuthSession = {
29
+ type AuthSession = {
30
30
  token: string;
31
31
  refreshToken: string;
32
32
  };
33
33
 
34
- export type SignInResult = {
34
+ type SignInResult = {
35
35
  signingIn: boolean;
36
36
  redirect?: URL;
37
+ totpRequired?: boolean;
38
+ verifier?: string;
37
39
  };
38
40
 
39
- export type AuthSnapshot = {
41
+ /** Reactive auth state snapshot returned by `auth.state` and `auth.onChange`. */
42
+ export type AuthState = {
40
43
  isLoading: boolean;
41
44
  isAuthenticated: boolean;
42
45
  token: string | null;
43
46
  };
44
47
 
45
- export type AuthClientOptions = {
46
- transport: AuthActionCaller;
47
- storage?: TokenStorage | null;
48
- storageNamespace: string;
48
+ /** Options for {@link client}. */
49
+ export type ClientOptions = {
50
+ /** Any Convex client (`ConvexClient` or `ConvexReactClient`). */
51
+ convex: ConvexTransport;
52
+ /**
53
+ * Convex deployment URL. Derived automatically from the client internals
54
+ * when omitted — pass explicitly only if auto-detection fails.
55
+ */
56
+ url?: string;
57
+ /**
58
+ * Key-value storage for persisting tokens.
59
+ *
60
+ * - Defaults to `localStorage` in SPA mode.
61
+ * - Defaults to `null` (in-memory only) when `proxy` is set,
62
+ * since httpOnly cookies handle persistence.
63
+ */
64
+ storage?: Storage | null;
65
+ /** Override how the URL bar is updated after OAuth code exchange. */
49
66
  replaceURL?: (relativeUrl: string) => void | Promise<void>;
50
- shouldHandleCode?: (() => boolean) | boolean;
51
- onChange?: () => Promise<unknown>;
67
+ /**
68
+ * SSR proxy endpoint (e.g. `"/api/auth"`).
69
+ *
70
+ * When set, `signIn`/`signOut`/token refresh POST to this URL
71
+ * (with `credentials: "include"`) instead of calling Convex directly.
72
+ * The server handles httpOnly cookies for token persistence.
73
+ *
74
+ * Pair with {@link ClientOptions.token} for flash-free SSR hydration.
75
+ */
76
+ proxy?: string;
77
+ /**
78
+ * JWT from server-side hydration.
79
+ *
80
+ * In proxy mode the server reads the JWT from an httpOnly cookie
81
+ * and passes it to the client during SSR. This avoids a loading
82
+ * flash on first render — the client is immediately authenticated.
83
+ */
84
+ token?: string | null;
52
85
  };
53
86
 
54
87
  const VERIFIER_STORAGE_KEY = "__convexAuthOAuthVerifier";
@@ -58,61 +91,123 @@ const REFRESH_TOKEN_STORAGE_KEY = "__convexAuthRefreshToken";
58
91
  const RETRY_BACKOFF = [500, 2000];
59
92
  const RETRY_JITTER = 100;
60
93
 
61
- export function createAuthClient(options: AuthClientOptions) {
62
- const {
63
- transport,
64
- storage = typeof window === "undefined" ? null : window.localStorage,
65
- storageNamespace,
66
- replaceURL = (url: string) => {
94
+ /**
95
+ * Resolve the Convex deployment URL from the client.
96
+ *
97
+ * `ConvexReactClient` exposes `.url` directly.
98
+ * `ConvexClient` exposes `.client.url` via `BaseConvexClient`.
99
+ */
100
+ function resolveUrl(convex: ConvexTransport, explicit?: string): string {
101
+ if (explicit) return explicit;
102
+ const c = convex as any;
103
+ const url: unknown = c.url ?? c.client?.url;
104
+ if (typeof url === "string") return url;
105
+ throw new Error(
106
+ "Could not determine Convex deployment URL. Pass `url` explicitly.",
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Create a framework-agnostic auth client.
112
+ *
113
+ * ### SPA mode (default)
114
+ *
115
+ * ```ts
116
+ * import { ConvexClient } from 'convex/browser'
117
+ * import { client } from '\@robelest/convex-auth/client'
118
+ *
119
+ * const convex = new ConvexClient(CONVEX_URL)
120
+ * const auth = client({ convex })
121
+ * ```
122
+ *
123
+ * ### SSR / proxy mode
124
+ *
125
+ * ```ts
126
+ * const auth = client({
127
+ * convex,
128
+ * proxy: '/api/auth',
129
+ * initialToken: tokenFromServer, // read from httpOnly cookie during SSR
130
+ * })
131
+ * ```
132
+ *
133
+ * In proxy mode all auth operations go through the proxy URL.
134
+ * Tokens are stored in httpOnly cookies server-side — the client
135
+ * only holds the JWT in memory.
136
+ */
137
+ export function client(options: ClientOptions) {
138
+ const { convex, proxy } = options;
139
+
140
+ // In proxy mode, default storage to null (cookies handle persistence).
141
+ const storage =
142
+ options.storage !== undefined
143
+ ? options.storage
144
+ : proxy
145
+ ? null
146
+ : typeof window === "undefined"
147
+ ? null
148
+ : window.localStorage;
149
+
150
+ const replaceURL =
151
+ options.replaceURL ??
152
+ ((url: string) => {
67
153
  if (typeof window !== "undefined") {
68
154
  window.history.replaceState({}, "", url);
69
155
  }
70
- },
71
- shouldHandleCode,
72
- onChange,
73
- } = options;
156
+ });
74
157
 
75
- const escapedNamespace = storageNamespace.replace(/[^a-zA-Z0-9]/g, "");
158
+ const url = proxy ? undefined : resolveUrl(convex, options.url);
159
+ const escapedNamespace = proxy
160
+ ? proxy.replace(/[^a-zA-Z0-9]/g, "")
161
+ : url!.replace(/[^a-zA-Z0-9]/g, "");
76
162
  const key = (name: string) => `${name}_${escapedNamespace}`;
77
163
  const subscribers = new Set<() => void>();
78
164
 
79
- let token: string | null = null;
80
- let isLoading = true;
81
- let snapshot: AuthSnapshot = {
165
+ // Unauthenticated HTTP client for code verification & OAuth exchange.
166
+ // Only needed in SPA mode — proxy mode routes everything through the proxy.
167
+ const httpClient = proxy ? null : new ConvexHttpClient(url!);
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // State
171
+ // ---------------------------------------------------------------------------
172
+
173
+ // If a server-provided token was supplied (SSR hydration), start authenticated.
174
+ const serverToken = options.token ?? null;
175
+ const hasServerToken = serverToken !== null;
176
+
177
+ let token: string | null = serverToken;
178
+ let isLoading = !hasServerToken;
179
+ let snapshot: AuthState = {
82
180
  isLoading,
83
- isAuthenticated: false,
181
+ isAuthenticated: hasServerToken,
84
182
  token,
85
183
  };
86
184
  let handlingCodeFlow = false;
87
185
 
88
- const logVerbose = (message: string) => {
89
- if (transport.verbose) {
90
- transport.logger?.logVerbose?.(message);
91
- console.debug(`${new Date().toISOString()} ${message}`);
92
- }
93
- };
94
-
95
186
  const notify = () => {
96
187
  for (const cb of subscribers) cb();
97
188
  };
98
189
 
99
190
  const updateSnapshot = () => {
100
- const nextSnapshot: AuthSnapshot = {
191
+ const next: AuthState = {
101
192
  isLoading,
102
193
  isAuthenticated: token !== null,
103
194
  token,
104
195
  };
105
196
  if (
106
- snapshot.isLoading === nextSnapshot.isLoading &&
107
- snapshot.isAuthenticated === nextSnapshot.isAuthenticated &&
108
- snapshot.token === nextSnapshot.token
197
+ snapshot.isLoading === next.isLoading &&
198
+ snapshot.isAuthenticated === next.isAuthenticated &&
199
+ snapshot.token === next.token
109
200
  ) {
110
201
  return false;
111
202
  }
112
- snapshot = nextSnapshot;
203
+ snapshot = next;
113
204
  return true;
114
205
  };
115
206
 
207
+ // ---------------------------------------------------------------------------
208
+ // Storage helpers (SPA mode only)
209
+ // ---------------------------------------------------------------------------
210
+
116
211
  const storageGet = async (name: string) =>
117
212
  storage ? ((await storage.getItem(key(name))) ?? null) : null;
118
213
  const storageSet = async (name: string, value: string) => {
@@ -122,12 +217,15 @@ export function createAuthClient(options: AuthClientOptions) {
122
217
  if (storage) await storage.removeItem(key(name));
123
218
  };
124
219
 
220
+ // ---------------------------------------------------------------------------
221
+ // Token management
222
+ // ---------------------------------------------------------------------------
223
+
125
224
  const setToken = async (
126
225
  args:
127
226
  | { shouldStore: true; tokens: AuthSession | null }
128
227
  | { shouldStore: false; tokens: { token: string } | null },
129
228
  ) => {
130
- const wasAuthenticated = token !== null;
131
229
  if (args.tokens === null) {
132
230
  token = null;
133
231
  if (args.shouldStore) {
@@ -141,9 +239,6 @@ export function createAuthClient(options: AuthClientOptions) {
141
239
  await storageSet(REFRESH_TOKEN_STORAGE_KEY, args.tokens.refreshToken);
142
240
  }
143
241
  }
144
- if (wasAuthenticated !== (token !== null)) {
145
- await onChange?.();
146
- }
147
242
  const hadPendingLoad = isLoading;
148
243
  isLoading = false;
149
244
  const changed = updateSnapshot();
@@ -152,6 +247,30 @@ export function createAuthClient(options: AuthClientOptions) {
152
247
  }
153
248
  };
154
249
 
250
+ // ---------------------------------------------------------------------------
251
+ // Proxy fetch helper
252
+ // ---------------------------------------------------------------------------
253
+
254
+ const proxyFetch = async (body: Record<string, unknown>) => {
255
+ const response = await fetch(proxy!, {
256
+ method: "POST",
257
+ headers: { "Content-Type": "application/json" },
258
+ credentials: "include",
259
+ body: JSON.stringify(body),
260
+ });
261
+ if (!response.ok) {
262
+ const error = await response.json().catch(() => ({}));
263
+ throw new Error(
264
+ (error as any).error ?? `Proxy request failed: ${response.status}`,
265
+ );
266
+ }
267
+ return response.json();
268
+ };
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Code verification with retries (SPA mode only)
272
+ // ---------------------------------------------------------------------------
273
+
155
274
  const verifyCode = async (
156
275
  args: { code: string; verifier?: string } | { refreshToken: string },
157
276
  ) => {
@@ -159,8 +278,8 @@ export function createAuthClient(options: AuthClientOptions) {
159
278
  let retry = 0;
160
279
  while (retry < RETRY_BACKOFF.length) {
161
280
  try {
162
- return await transport.unauthenticatedCall(
163
- "auth:signIn" as unknown as SignInAction,
281
+ return await httpClient!.action(
282
+ "auth:signIn" as any,
164
283
  "code" in args
165
284
  ? { params: { code: args.code }, verifier: args.verifier }
166
285
  : args,
@@ -170,11 +289,8 @@ export function createAuthClient(options: AuthClientOptions) {
170
289
  const isNetworkError =
171
290
  e instanceof Error && /network/i.test(e.message || "");
172
291
  if (!isNetworkError) break;
173
- const wait = RETRY_BACKOFF[retry] + RETRY_JITTER * Math.random();
292
+ const wait = RETRY_BACKOFF[retry]! + RETRY_JITTER * Math.random();
174
293
  retry++;
175
- logVerbose(
176
- `verifyCode network retry ${retry}/${RETRY_BACKOFF.length} in ${wait}ms`,
177
- );
178
294
  await new Promise((resolve) => setTimeout(resolve, wait));
179
295
  }
180
296
  }
@@ -185,10 +301,17 @@ export function createAuthClient(options: AuthClientOptions) {
185
301
  args: { code: string; verifier?: string } | { refreshToken: string },
186
302
  ) => {
187
303
  const { tokens } = await verifyCode(args);
188
- await setToken({ shouldStore: true, tokens: (tokens as AuthSession | null) ?? null });
304
+ await setToken({
305
+ shouldStore: true,
306
+ tokens: (tokens as AuthSession | null) ?? null,
307
+ });
189
308
  return tokens !== null;
190
309
  };
191
310
 
311
+ // ---------------------------------------------------------------------------
312
+ // signIn
313
+ // ---------------------------------------------------------------------------
314
+
192
315
  const signIn = async (
193
316
  provider?: string,
194
317
  args?: FormData | Record<string, Value>,
@@ -204,19 +327,54 @@ export function createAuthClient(options: AuthClientOptions) {
204
327
  )
205
328
  : args ?? {};
206
329
 
330
+ if (proxy) {
331
+ // Proxy mode: POST to the proxy endpoint.
332
+ const result = await proxyFetch({
333
+ action: "auth:signIn",
334
+ args: { provider, params },
335
+ });
336
+ if (result.redirect !== undefined) {
337
+ const redirectUrl = new URL(result.redirect);
338
+ // Verifier is stored server-side in an httpOnly cookie.
339
+ if (typeof window !== "undefined") {
340
+ window.location.href = redirectUrl.toString();
341
+ }
342
+ return { signingIn: false, redirect: redirectUrl };
343
+ }
344
+ if (result.totpRequired) {
345
+ return { signingIn: false, totpRequired: true, verifier: result.verifier };
346
+ }
347
+ if (result.tokens !== undefined) {
348
+ // Proxy returns { token, refreshToken: "dummy" }.
349
+ // Store JWT in memory only — real refresh token is in httpOnly cookie.
350
+ await setToken({
351
+ shouldStore: false,
352
+ tokens:
353
+ result.tokens === null ? null : { token: result.tokens.token },
354
+ });
355
+ return { signingIn: result.tokens !== null };
356
+ }
357
+ return { signingIn: false };
358
+ }
359
+
360
+ // SPA mode: call Convex directly.
207
361
  const verifier = (await storageGet(VERIFIER_STORAGE_KEY)) ?? undefined;
208
362
  await storageRemove(VERIFIER_STORAGE_KEY);
209
- const result = await transport.authenticatedCall(
210
- "auth:signIn" as unknown as SignInAction,
211
- { provider, params, verifier },
212
- );
363
+ const result = await convex.action("auth:signIn" as any, {
364
+ provider,
365
+ params,
366
+ verifier,
367
+ });
213
368
  if (result.redirect !== undefined) {
214
- const url = new URL(result.redirect);
369
+ const redirectUrl = new URL(result.redirect);
215
370
  await storageSet(VERIFIER_STORAGE_KEY, result.verifier!);
216
371
  if (typeof window !== "undefined") {
217
- window.location.href = url.toString();
372
+ window.location.href = redirectUrl.toString();
218
373
  }
219
- return { signingIn: false, redirect: url };
374
+ return { signingIn: false, redirect: redirectUrl };
375
+ }
376
+ if (result.totpRequired) {
377
+ return { signingIn: false, totpRequired: true, verifier: result.verifier };
220
378
  }
221
379
  if (result.tokens !== undefined) {
222
380
  await setToken({
@@ -228,35 +386,78 @@ export function createAuthClient(options: AuthClientOptions) {
228
386
  return { signingIn: false };
229
387
  };
230
388
 
389
+ // ---------------------------------------------------------------------------
390
+ // signOut
391
+ // ---------------------------------------------------------------------------
392
+
231
393
  const signOut = async () => {
394
+ if (proxy) {
395
+ try {
396
+ await proxyFetch({ action: "auth:signOut", args: {} });
397
+ } catch {
398
+ // Already signed out is fine.
399
+ }
400
+ await setToken({ shouldStore: false, tokens: null });
401
+ return;
402
+ }
403
+
404
+ // SPA mode.
232
405
  try {
233
- await transport.authenticatedCall(
234
- "auth:signOut" as unknown as SignOutAction,
235
- );
406
+ await convex.action("auth:signOut" as any, {});
236
407
  } catch {
237
408
  // Already signed out is fine.
238
409
  }
239
410
  await setToken({ shouldStore: true, tokens: null });
240
411
  };
241
412
 
413
+ // ---------------------------------------------------------------------------
414
+ // fetchAccessToken — called by convex.setAuth()
415
+ // ---------------------------------------------------------------------------
416
+
242
417
  const fetchAccessToken = async ({
243
418
  forceRefreshToken,
244
419
  }: {
245
420
  forceRefreshToken: boolean;
246
421
  }): Promise<string | null> => {
247
422
  if (!forceRefreshToken) return token;
423
+
424
+ if (proxy) {
425
+ // Proxy mode: POST to the proxy to refresh.
426
+ // The proxy reads the real refresh token from the httpOnly cookie.
427
+ const tokenBeforeRefresh = token;
428
+ return await browserMutex("__convexAuthProxyRefresh", async () => {
429
+ // Another tab/call may have already refreshed.
430
+ if (token !== tokenBeforeRefresh) return token;
431
+ try {
432
+ const result = await proxyFetch({
433
+ action: "auth:signIn",
434
+ args: { refreshToken: true },
435
+ });
436
+ if (result.tokens) {
437
+ await setToken({
438
+ shouldStore: false,
439
+ tokens: { token: result.tokens.token },
440
+ });
441
+ } else {
442
+ await setToken({ shouldStore: false, tokens: null });
443
+ }
444
+ } catch {
445
+ await setToken({ shouldStore: false, tokens: null });
446
+ }
447
+ return token;
448
+ });
449
+ }
450
+
451
+ // SPA mode: refresh via localStorage + httpClient.
248
452
  const tokenBeforeLockAcquisition = token;
249
453
  return await browserMutex(REFRESH_TOKEN_STORAGE_KEY, async () => {
250
454
  const tokenAfterLockAcquisition = token;
251
455
  if (tokenAfterLockAcquisition !== tokenBeforeLockAcquisition) {
252
- logVerbose(
253
- `fetchAccessToken using synchronized token, is null: ${tokenAfterLockAcquisition === null}`,
254
- );
255
456
  return tokenAfterLockAcquisition;
256
457
  }
257
- const refreshToken = (await storageGet(REFRESH_TOKEN_STORAGE_KEY)) ?? null;
458
+ const refreshToken =
459
+ (await storageGet(REFRESH_TOKEN_STORAGE_KEY)) ?? null;
258
460
  if (!refreshToken) {
259
- logVerbose("fetchAccessToken found no refresh token");
260
461
  return null;
261
462
  }
262
463
  await verifyCodeAndSetToken({ refreshToken });
@@ -264,29 +465,30 @@ export function createAuthClient(options: AuthClientOptions) {
264
465
  });
265
466
  };
266
467
 
468
+ // ---------------------------------------------------------------------------
469
+ // OAuth code flow (SPA mode only — server handles this in proxy mode)
470
+ // ---------------------------------------------------------------------------
471
+
267
472
  const handleCodeFlow = async () => {
268
473
  if (typeof window === "undefined") return;
269
474
  if (handlingCodeFlow) return;
270
475
  const code = new URLSearchParams(window.location.search).get("code");
271
476
  if (!code) return;
272
- const shouldRun =
273
- shouldHandleCode === undefined
274
- ? true
275
- : typeof shouldHandleCode === "function"
276
- ? shouldHandleCode()
277
- : shouldHandleCode;
278
- if (!shouldRun) return;
279
477
  handlingCodeFlow = true;
280
- const url = new URL(window.location.href);
281
- url.searchParams.delete("code");
478
+ const codeUrl = new URL(window.location.href);
479
+ codeUrl.searchParams.delete("code");
282
480
  try {
283
- await replaceURL(url.pathname + url.search + url.hash);
481
+ await replaceURL(codeUrl.pathname + codeUrl.search + codeUrl.hash);
284
482
  await signIn(undefined, { code });
285
483
  } finally {
286
484
  handlingCodeFlow = false;
287
485
  }
288
486
  };
289
487
 
488
+ // ---------------------------------------------------------------------------
489
+ // Hydrate from storage (SPA mode only)
490
+ // ---------------------------------------------------------------------------
491
+
290
492
  const hydrateFromStorage = async () => {
291
493
  const storedToken = (await storageGet(JWT_STORAGE_KEY)) ?? null;
292
494
  await setToken({
@@ -295,40 +497,552 @@ export function createAuthClient(options: AuthClientOptions) {
295
497
  });
296
498
  };
297
499
 
298
- const getSnapshot = (): AuthSnapshot => snapshot;
500
+ // ---------------------------------------------------------------------------
501
+ // Subscribe
502
+ // ---------------------------------------------------------------------------
299
503
 
300
- const subscribe = (cb: () => void) => {
301
- subscribers.add(cb);
302
- return () => subscribers.delete(cb);
504
+ /**
505
+ * Subscribe to auth state changes. Immediately invokes the callback
506
+ * with the current state and returns an unsubscribe function.
507
+ *
508
+ * ```ts
509
+ * const unsub = auth.onChange(setState)
510
+ * ```
511
+ */
512
+ const onChange = (cb: (state: AuthState) => void): (() => void) => {
513
+ cb(snapshot);
514
+ const wrapped = () => cb(snapshot);
515
+ subscribers.add(wrapped);
516
+ return () => {
517
+ subscribers.delete(wrapped);
518
+ };
303
519
  };
304
520
 
305
- if (typeof window !== "undefined") {
521
+ // ---------------------------------------------------------------------------
522
+ // Initialization
523
+ // ---------------------------------------------------------------------------
524
+
525
+ // Cross-tab sync via storage events (SPA mode only).
526
+ if (!proxy && typeof window !== "undefined") {
306
527
  const onStorage = (event: StorageEvent) => {
307
528
  void (async () => {
308
- if (event.key !== key(JWT_STORAGE_KEY)) {
309
- return;
310
- }
311
- const value = event.newValue;
529
+ if (event.key !== key(JWT_STORAGE_KEY)) return;
312
530
  await setToken({
313
531
  shouldStore: false,
314
- tokens: value === null ? null : { token: value },
532
+ tokens:
533
+ event.newValue === null ? null : { token: event.newValue },
315
534
  });
316
535
  })();
317
536
  };
318
537
  window.addEventListener("storage", onStorage);
319
538
  }
320
539
 
540
+ // Auto-wire: feed our tokens into the Convex client so
541
+ // queries and mutations are automatically authenticated.
542
+ convex.setAuth(fetchAccessToken);
543
+
544
+ // Auto-hydrate and handle code flow.
545
+ if (typeof window !== "undefined") {
546
+ if (proxy) {
547
+ // Proxy mode: if no initialToken was provided, try a refresh
548
+ // to pick up any existing session from httpOnly cookies.
549
+ if (!hasServerToken) {
550
+ void fetchAccessToken({ forceRefreshToken: true });
551
+ } else {
552
+ // initialToken already set — mark loading as done.
553
+ isLoading = false;
554
+ updateSnapshot();
555
+ }
556
+ } else {
557
+ // SPA mode: hydrate from localStorage, then handle OAuth code flow.
558
+ void hydrateFromStorage().then(() => handleCodeFlow());
559
+ }
560
+ }
561
+
562
+ // ---------------------------------------------------------------------------
563
+ // Passkey helpers
564
+ // ---------------------------------------------------------------------------
565
+
566
+ /**
567
+ * Base64url encode/decode helpers for the WebAuthn credential API.
568
+ * These run client-side only (browser context).
569
+ */
570
+ const base64urlEncode = (buffer: ArrayBuffer): string => {
571
+ const bytes = new Uint8Array(buffer);
572
+ let binary = "";
573
+ for (let i = 0; i < bytes.byteLength; i++) {
574
+ binary += String.fromCharCode(bytes[i]!);
575
+ }
576
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
577
+ };
578
+
579
+ const base64urlDecode = (str: string): Uint8Array => {
580
+ const padded = str.replace(/-/g, "+").replace(/_/g, "/");
581
+ const binary = atob(padded);
582
+ const bytes = new Uint8Array(binary.length);
583
+ for (let i = 0; i < binary.length; i++) {
584
+ bytes[i] = binary.charCodeAt(i);
585
+ }
586
+ return bytes;
587
+ };
588
+
589
+ const passkey = {
590
+ /**
591
+ * Check if WebAuthn passkeys are supported in the current environment.
592
+ */
593
+ isSupported: (): boolean => {
594
+ return (
595
+ typeof window !== "undefined" &&
596
+ typeof window.PublicKeyCredential !== "undefined"
597
+ );
598
+ },
599
+
600
+ /**
601
+ * Check if conditional UI (autofill-assisted passkey sign-in) is supported.
602
+ *
603
+ * ```ts
604
+ * if (await auth.passkey.isAutofillSupported()) {
605
+ * auth.passkey.authenticate({ autofill: true });
606
+ * }
607
+ * ```
608
+ */
609
+ isAutofillSupported: async (): Promise<boolean> => {
610
+ if (typeof window === "undefined") return false;
611
+ if (typeof window.PublicKeyCredential === "undefined") return false;
612
+ if (
613
+ typeof (
614
+ window.PublicKeyCredential as any
615
+ ).isConditionalMediationAvailable !== "function"
616
+ ) {
617
+ return false;
618
+ }
619
+ return (
620
+ window.PublicKeyCredential as any
621
+ ).isConditionalMediationAvailable();
622
+ },
623
+
624
+ /**
625
+ * Register a new passkey for the current or new user.
626
+ *
627
+ * Performs the full two-round-trip WebAuthn registration ceremony:
628
+ * 1. Requests creation options from the server (challenge, RP info)
629
+ * 2. Calls `navigator.credentials.create()` with the options
630
+ * 3. Sends the attestation back to the server for verification
631
+ * 4. Server creates user + account + passkey records and returns tokens
632
+ *
633
+ * Works in both SPA and proxy (SSR) modes.
634
+ *
635
+ * ```ts
636
+ * await auth.passkey.register({ name: "MacBook Touch ID" });
637
+ * ```
638
+ *
639
+ * @param opts.name - Friendly name for this passkey
640
+ * @param opts.email - Email to associate with the new account
641
+ * @param opts.userName - Username for the credential (defaults to email)
642
+ * @param opts.userDisplayName - Display name for the credential
643
+ * @returns `{ signingIn: true }` on success
644
+ */
645
+ register: async (
646
+ opts?: {
647
+ name?: string;
648
+ email?: string;
649
+ userName?: string;
650
+ userDisplayName?: string;
651
+ },
652
+ ): Promise<SignInResult> => {
653
+ const phase1Params = {
654
+ flow: "register-options",
655
+ email: opts?.email,
656
+ userName: opts?.userName,
657
+ userDisplayName: opts?.userDisplayName,
658
+ };
659
+
660
+ // Phase 1: Get registration options from server
661
+ let phase1Result: any;
662
+ if (proxy) {
663
+ phase1Result = await proxyFetch({
664
+ action: "auth:signIn",
665
+ args: { provider: "passkey", params: phase1Params },
666
+ });
667
+ } else {
668
+ phase1Result = await convex.action("auth:signIn" as any, {
669
+ provider: "passkey",
670
+ params: phase1Params,
671
+ });
672
+ }
673
+
674
+ if (!phase1Result.options) {
675
+ throw new Error("Server did not return passkey registration options");
676
+ }
677
+
678
+ const options = phase1Result.options;
679
+
680
+ // Convert base64url strings to ArrayBuffers for the credential API
681
+ const createOptions: CredentialCreationOptions = {
682
+ publicKey: {
683
+ rp: options.rp,
684
+ user: {
685
+ id: base64urlDecode(options.user.id).buffer as ArrayBuffer,
686
+ name: options.user.name,
687
+ displayName: options.user.displayName,
688
+ },
689
+ challenge: base64urlDecode(options.challenge).buffer as ArrayBuffer,
690
+ pubKeyCredParams: options.pubKeyCredParams,
691
+ timeout: options.timeout,
692
+ attestation: options.attestation,
693
+ authenticatorSelection: options.authenticatorSelection,
694
+ excludeCredentials: (options.excludeCredentials ?? []).map(
695
+ (cred: any) => ({
696
+ type: cred.type ?? "public-key",
697
+ id: base64urlDecode(cred.id).buffer as ArrayBuffer,
698
+ transports: cred.transports,
699
+ }),
700
+ ),
701
+ },
702
+ };
703
+
704
+ // Phase 2: Create credential via browser API
705
+ const credential = (await navigator.credentials.create(
706
+ createOptions,
707
+ )) as PublicKeyCredential | null;
708
+ if (!credential) {
709
+ throw new Error("Passkey registration was cancelled");
710
+ }
711
+
712
+ const response =
713
+ credential.response as AuthenticatorAttestationResponse;
714
+
715
+ // Extract transports if available
716
+ const transports =
717
+ typeof response.getTransports === "function"
718
+ ? response.getTransports()
719
+ : undefined;
720
+
721
+ const phase2Params = {
722
+ flow: "register-verify",
723
+ clientDataJSON: base64urlEncode(response.clientDataJSON),
724
+ attestationObject: base64urlEncode(response.attestationObject),
725
+ transports,
726
+ passkeyName: opts?.name,
727
+ email: opts?.email,
728
+ };
729
+
730
+ // Phase 3: Send attestation to server for verification
731
+ let phase2Result: any;
732
+ if (proxy) {
733
+ // In proxy mode the verifier is stored in an httpOnly cookie by the proxy.
734
+ // We pass it back explicitly so the proxy can forward it to Convex.
735
+ phase2Result = await proxyFetch({
736
+ action: "auth:signIn",
737
+ args: {
738
+ provider: "passkey",
739
+ params: phase2Params,
740
+ verifier: phase1Result.verifier,
741
+ },
742
+ });
743
+ } else {
744
+ phase2Result = await convex.action("auth:signIn" as any, {
745
+ provider: "passkey",
746
+ params: phase2Params,
747
+ verifier: phase1Result.verifier,
748
+ });
749
+ }
750
+
751
+ if (phase2Result.tokens) {
752
+ if (proxy) {
753
+ await setToken({
754
+ shouldStore: false,
755
+ tokens:
756
+ phase2Result.tokens === null
757
+ ? null
758
+ : { token: phase2Result.tokens.token },
759
+ });
760
+ } else {
761
+ await setToken({
762
+ shouldStore: true,
763
+ tokens: phase2Result.tokens as AuthSession,
764
+ });
765
+ }
766
+ return { signingIn: true };
767
+ }
768
+ return { signingIn: false };
769
+ },
770
+
771
+ /**
772
+ * Authenticate with an existing passkey.
773
+ *
774
+ * Performs the full two-round-trip WebAuthn authentication ceremony:
775
+ * 1. Requests assertion options from the server (challenge, allowed credentials)
776
+ * 2. Calls `navigator.credentials.get()` with the options
777
+ * 3. Sends the assertion back to the server for signature verification
778
+ * 4. Server verifies signature, updates counter, creates session, returns tokens
779
+ *
780
+ * Works in both SPA and proxy (SSR) modes.
781
+ *
782
+ * ```ts
783
+ * // Discoverable credential (no email needed)
784
+ * await auth.passkey.authenticate();
785
+ *
786
+ * // Scoped to a specific user's credentials
787
+ * await auth.passkey.authenticate({ email: "user@example.com" });
788
+ *
789
+ * // Autofill-assisted (conditional UI)
790
+ * await auth.passkey.authenticate({ autofill: true });
791
+ * ```
792
+ *
793
+ * @param opts.email - Scope to credentials for this email's user
794
+ * @param opts.autofill - Use conditional mediation (autofill UI)
795
+ * @returns `{ signingIn: true }` on success
796
+ */
797
+ authenticate: async (
798
+ opts?: { email?: string; autofill?: boolean },
799
+ ): Promise<SignInResult> => {
800
+ const phase1Params = {
801
+ flow: "auth-options",
802
+ email: opts?.email,
803
+ };
804
+
805
+ // Phase 1: Get assertion options from server
806
+ let phase1Result: any;
807
+ if (proxy) {
808
+ phase1Result = await proxyFetch({
809
+ action: "auth:signIn",
810
+ args: { provider: "passkey", params: phase1Params },
811
+ });
812
+ } else {
813
+ phase1Result = await convex.action("auth:signIn" as any, {
814
+ provider: "passkey",
815
+ params: phase1Params,
816
+ });
817
+ }
818
+
819
+ if (!phase1Result.options) {
820
+ throw new Error("Server did not return passkey authentication options");
821
+ }
822
+
823
+ const options = phase1Result.options;
824
+
825
+ // Convert base64url strings to ArrayBuffers for the credential API
826
+ const getOptions: CredentialRequestOptions = {
827
+ publicKey: {
828
+ challenge: base64urlDecode(options.challenge).buffer as ArrayBuffer,
829
+ timeout: options.timeout,
830
+ rpId: options.rpId,
831
+ userVerification: options.userVerification,
832
+ allowCredentials: (options.allowCredentials ?? []).map(
833
+ (cred: any) => ({
834
+ type: cred.type ?? "public-key",
835
+ id: base64urlDecode(cred.id).buffer as ArrayBuffer,
836
+ transports: cred.transports,
837
+ }),
838
+ ),
839
+ },
840
+ ...(opts?.autofill ? { mediation: "conditional" as any } : {}),
841
+ };
842
+
843
+ // Phase 2: Get credential via browser API
844
+ const credential = (await navigator.credentials.get(
845
+ getOptions,
846
+ )) as PublicKeyCredential | null;
847
+ if (!credential) {
848
+ throw new Error("Passkey authentication was cancelled");
849
+ }
850
+
851
+ const response =
852
+ credential.response as AuthenticatorAssertionResponse;
853
+
854
+ const phase2Params = {
855
+ flow: "auth-verify",
856
+ credentialId: base64urlEncode(credential.rawId),
857
+ clientDataJSON: base64urlEncode(response.clientDataJSON),
858
+ authenticatorData: base64urlEncode(response.authenticatorData),
859
+ signature: base64urlEncode(response.signature),
860
+ };
861
+
862
+ // Phase 3: Send assertion to server for verification
863
+ let phase2Result: any;
864
+ if (proxy) {
865
+ phase2Result = await proxyFetch({
866
+ action: "auth:signIn",
867
+ args: {
868
+ provider: "passkey",
869
+ params: phase2Params,
870
+ verifier: phase1Result.verifier,
871
+ },
872
+ });
873
+ } else {
874
+ phase2Result = await convex.action("auth:signIn" as any, {
875
+ provider: "passkey",
876
+ params: phase2Params,
877
+ verifier: phase1Result.verifier,
878
+ });
879
+ }
880
+
881
+ if (phase2Result.tokens) {
882
+ if (proxy) {
883
+ await setToken({
884
+ shouldStore: false,
885
+ tokens:
886
+ phase2Result.tokens === null
887
+ ? null
888
+ : { token: phase2Result.tokens.token },
889
+ });
890
+ } else {
891
+ await setToken({
892
+ shouldStore: true,
893
+ tokens: phase2Result.tokens as AuthSession,
894
+ });
895
+ }
896
+ return { signingIn: true };
897
+ }
898
+ return { signingIn: false };
899
+ },
900
+ };
901
+
902
+ const totp = {
903
+ /**
904
+ * Start TOTP enrollment. Must be authenticated.
905
+ *
906
+ * Returns a URI for QR code display and a base32 secret for manual entry.
907
+ *
908
+ * ```ts
909
+ * const setup = await auth.totp.setup();
910
+ * // Display QR code from setup.uri
911
+ * // Or show setup.secret for manual entry
912
+ * ```
913
+ */
914
+ setup: async (
915
+ opts?: { name?: string; accountName?: string },
916
+ ): Promise<{ uri: string; secret: string; verifier: string; totpId: string }> => {
917
+ const params: Record<string, any> = { flow: "setup" };
918
+ if (opts?.name) params.name = opts.name;
919
+ if (opts?.accountName) params.accountName = opts.accountName;
920
+
921
+ if (proxy) {
922
+ const result = await proxyFetch({
923
+ action: "auth:signIn",
924
+ args: { provider: "totp", params },
925
+ });
926
+ return { uri: result.totpSetup.uri, secret: result.totpSetup.secret, verifier: result.verifier, totpId: result.totpSetup.totpId };
927
+ }
928
+
929
+ const result = await convex.action("auth:signIn" as any, {
930
+ provider: "totp",
931
+ params,
932
+ });
933
+ return { uri: result.totpSetup.uri, secret: result.totpSetup.secret, verifier: result.verifier, totpId: result.totpSetup.totpId };
934
+ },
935
+
936
+ /**
937
+ * Complete TOTP enrollment by verifying the first code from the authenticator app.
938
+ *
939
+ * ```ts
940
+ * await auth.totp.confirm({ code: "123456", verifier: setup.verifier, totpId: setup.totpId });
941
+ * ```
942
+ */
943
+ confirm: async (opts: {
944
+ code: string;
945
+ verifier: string;
946
+ totpId: string;
947
+ }): Promise<void> => {
948
+ const params: Record<string, any> = {
949
+ flow: "confirm",
950
+ code: opts.code,
951
+ totpId: opts.totpId,
952
+ };
953
+
954
+ if (proxy) {
955
+ const result = await proxyFetch({
956
+ action: "auth:signIn",
957
+ args: { provider: "totp", params, verifier: opts.verifier },
958
+ });
959
+ if (result.tokens) {
960
+ await setToken({
961
+ shouldStore: false,
962
+ tokens: result.tokens === null ? null : { token: result.tokens.token },
963
+ });
964
+ }
965
+ return;
966
+ }
967
+
968
+ const result = await convex.action("auth:signIn" as any, {
969
+ provider: "totp",
970
+ params,
971
+ verifier: opts.verifier,
972
+ });
973
+ if (result.tokens) {
974
+ await setToken({
975
+ shouldStore: true,
976
+ tokens: (result.tokens as AuthSession | null) ?? null,
977
+ });
978
+ }
979
+ },
980
+
981
+ /**
982
+ * Complete 2FA verification during sign-in.
983
+ *
984
+ * Called after a credentials sign-in returns `totpRequired: true`.
985
+ *
986
+ * ```ts
987
+ * const result = await auth.signIn("password", { email, password });
988
+ * if (result.totpRequired) {
989
+ * await auth.totp.verify({ code: "123456", verifier: result.verifier! });
990
+ * }
991
+ * ```
992
+ */
993
+ verify: async (opts: { code: string; verifier: string }): Promise<void> => {
994
+ const params: Record<string, any> = {
995
+ flow: "verify",
996
+ code: opts.code,
997
+ };
998
+
999
+ if (proxy) {
1000
+ const result = await proxyFetch({
1001
+ action: "auth:signIn",
1002
+ args: { provider: "totp", params, verifier: opts.verifier },
1003
+ });
1004
+ if (result.tokens) {
1005
+ await setToken({
1006
+ shouldStore: false,
1007
+ tokens: result.tokens === null ? null : { token: result.tokens.token },
1008
+ });
1009
+ }
1010
+ return;
1011
+ }
1012
+
1013
+ const result = await convex.action("auth:signIn" as any, {
1014
+ provider: "totp",
1015
+ params,
1016
+ verifier: opts.verifier,
1017
+ });
1018
+ if (result.tokens) {
1019
+ await setToken({
1020
+ shouldStore: true,
1021
+ tokens: (result.tokens as AuthSession | null) ?? null,
1022
+ });
1023
+ }
1024
+ },
1025
+ };
1026
+
321
1027
  return {
1028
+ /** Current auth state snapshot. */
1029
+ get state(): AuthState {
1030
+ return snapshot;
1031
+ },
322
1032
  signIn,
323
1033
  signOut,
324
- fetchAccessToken,
325
- handleCodeFlow,
326
- hydrateFromStorage,
327
- getSnapshot,
328
- subscribe,
1034
+ onChange,
1035
+ /** Passkey (WebAuthn) authentication helpers. */
1036
+ passkey,
1037
+ /** TOTP two-factor authentication helpers. */
1038
+ totp,
329
1039
  };
330
1040
  }
331
1041
 
1042
+ // ---------------------------------------------------------------------------
1043
+ // Browser mutex — ensures only one tab refreshes a token at a time.
1044
+ // ---------------------------------------------------------------------------
1045
+
332
1046
  async function browserMutex<T>(
333
1047
  key: string,
334
1048
  callback: () => Promise<T>,