@robelest/convex-auth 0.0.2-preview.1 → 0.0.2-preview.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 (67) hide show
  1. package/dist/client/index.d.ts +84 -30
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +259 -59
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/component/index.d.ts +2 -2
  6. package/dist/component/index.d.ts.map +1 -1
  7. package/dist/component/index.js +2 -2
  8. package/dist/component/index.js.map +1 -1
  9. package/dist/providers/{Anonymous.d.ts → anonymous.d.ts} +8 -8
  10. package/dist/providers/{Anonymous.d.ts.map → anonymous.d.ts.map} +1 -1
  11. package/dist/providers/{Anonymous.js → anonymous.js} +9 -10
  12. package/dist/providers/anonymous.js.map +1 -0
  13. package/dist/providers/{ConvexCredentials.d.ts → credentials.d.ts} +11 -11
  14. package/dist/providers/credentials.d.ts.map +1 -0
  15. package/dist/providers/{ConvexCredentials.js → credentials.js} +8 -8
  16. package/dist/providers/credentials.js.map +1 -0
  17. package/dist/providers/{Email.d.ts → email.d.ts} +6 -6
  18. package/dist/providers/email.d.ts.map +1 -0
  19. package/dist/providers/{Email.js → email.js} +6 -6
  20. package/dist/providers/email.js.map +1 -0
  21. package/dist/providers/{Password.d.ts → password.d.ts} +10 -10
  22. package/dist/providers/{Password.d.ts.map → password.d.ts.map} +1 -1
  23. package/dist/providers/{Password.js → password.js} +19 -20
  24. package/dist/providers/password.js.map +1 -0
  25. package/dist/providers/{Phone.d.ts → phone.d.ts} +3 -3
  26. package/dist/providers/{Phone.d.ts.map → phone.d.ts.map} +1 -1
  27. package/dist/providers/{Phone.js → phone.js} +3 -3
  28. package/dist/providers/{Phone.js.map → phone.js.map} +1 -1
  29. package/dist/server/implementation/index.d.ts +73 -159
  30. package/dist/server/implementation/index.d.ts.map +1 -1
  31. package/dist/server/implementation/index.js +74 -100
  32. package/dist/server/implementation/index.js.map +1 -1
  33. package/dist/server/implementation/sessions.d.ts +2 -20
  34. package/dist/server/implementation/sessions.d.ts.map +1 -1
  35. package/dist/server/implementation/sessions.js +2 -20
  36. package/dist/server/implementation/sessions.js.map +1 -1
  37. package/dist/server/index.d.ts +18 -0
  38. package/dist/server/index.d.ts.map +1 -1
  39. package/dist/server/index.js +255 -0
  40. package/dist/server/index.js.map +1 -1
  41. package/dist/server/provider_utils.d.ts.map +1 -1
  42. package/dist/server/types.d.ts +70 -9
  43. package/dist/server/types.d.ts.map +1 -1
  44. package/package.json +3 -6
  45. package/src/client/index.ts +347 -110
  46. package/src/component/index.ts +1 -8
  47. package/src/providers/{Anonymous.ts → anonymous.ts} +10 -11
  48. package/src/providers/{ConvexCredentials.ts → credentials.ts} +11 -11
  49. package/src/providers/{Email.ts → email.ts} +5 -5
  50. package/src/providers/{Password.ts → password.ts} +22 -27
  51. package/src/providers/{Phone.ts → phone.ts} +2 -2
  52. package/src/server/implementation/index.ts +119 -231
  53. package/src/server/implementation/sessions.ts +2 -20
  54. package/src/server/index.ts +373 -0
  55. package/src/server/types.ts +95 -8
  56. package/dist/providers/Anonymous.js.map +0 -1
  57. package/dist/providers/ConvexCredentials.d.ts.map +0 -1
  58. package/dist/providers/ConvexCredentials.js.map +0 -1
  59. package/dist/providers/Email.d.ts.map +0 -1
  60. package/dist/providers/Email.js.map +0 -1
  61. package/dist/providers/Password.js.map +0 -1
  62. package/providers/Anonymous/package.json +0 -6
  63. package/providers/ConvexCredentials/package.json +0 -6
  64. package/providers/Email/package.json +0 -6
  65. package/providers/Password/package.json +0 -6
  66. package/providers/Phone/package.json +0 -6
  67. package/server/package.json +0 -6
@@ -1,54 +1,85 @@
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
37
  };
38
38
 
39
- export type AuthSnapshot = {
39
+ /** Reactive auth state snapshot returned by `auth.state` and `auth.onChange`. */
40
+ export type AuthState = {
40
41
  isLoading: boolean;
41
42
  isAuthenticated: boolean;
42
43
  token: string | null;
43
44
  };
44
45
 
45
- export type AuthClientOptions = {
46
- transport: AuthActionCaller;
47
- storage?: TokenStorage | null;
48
- storageNamespace: string;
46
+ /** Options for {@link client}. */
47
+ export type ClientOptions = {
48
+ /** Any Convex client (`ConvexClient` or `ConvexReactClient`). */
49
+ convex: ConvexTransport;
50
+ /**
51
+ * Convex deployment URL. Derived automatically from the client internals
52
+ * when omitted — pass explicitly only if auto-detection fails.
53
+ */
54
+ url?: string;
55
+ /**
56
+ * Key-value storage for persisting tokens.
57
+ *
58
+ * - Defaults to `localStorage` in SPA mode.
59
+ * - Defaults to `null` (in-memory only) when `proxy` is set,
60
+ * since httpOnly cookies handle persistence.
61
+ */
62
+ storage?: Storage | null;
63
+ /** Override how the URL bar is updated after OAuth code exchange. */
49
64
  replaceURL?: (relativeUrl: string) => void | Promise<void>;
50
- shouldHandleCode?: (() => boolean) | boolean;
51
- onChange?: () => Promise<unknown>;
65
+ /**
66
+ * SSR proxy endpoint (e.g. `"/api/auth"`).
67
+ *
68
+ * When set, `signIn`/`signOut`/token refresh POST to this URL
69
+ * (with `credentials: "include"`) instead of calling Convex directly.
70
+ * The server handles httpOnly cookies for token persistence.
71
+ *
72
+ * Pair with {@link ClientOptions.token} for flash-free SSR hydration.
73
+ */
74
+ proxy?: string;
75
+ /**
76
+ * JWT from server-side hydration.
77
+ *
78
+ * In proxy mode the server reads the JWT from an httpOnly cookie
79
+ * and passes it to the client during SSR. This avoids a loading
80
+ * flash on first render — the client is immediately authenticated.
81
+ */
82
+ token?: string | null;
52
83
  };
53
84
 
54
85
  const VERIFIER_STORAGE_KEY = "__convexAuthOAuthVerifier";
@@ -58,61 +89,123 @@ const REFRESH_TOKEN_STORAGE_KEY = "__convexAuthRefreshToken";
58
89
  const RETRY_BACKOFF = [500, 2000];
59
90
  const RETRY_JITTER = 100;
60
91
 
61
- export function createAuthClient(options: AuthClientOptions) {
62
- const {
63
- transport,
64
- storage = typeof window === "undefined" ? null : window.localStorage,
65
- storageNamespace,
66
- replaceURL = (url: string) => {
92
+ /**
93
+ * Resolve the Convex deployment URL from the client.
94
+ *
95
+ * `ConvexReactClient` exposes `.url` directly.
96
+ * `ConvexClient` exposes `.client.url` via `BaseConvexClient`.
97
+ */
98
+ function resolveUrl(convex: ConvexTransport, explicit?: string): string {
99
+ if (explicit) return explicit;
100
+ const c = convex as any;
101
+ const url: unknown = c.url ?? c.client?.url;
102
+ if (typeof url === "string") return url;
103
+ throw new Error(
104
+ "Could not determine Convex deployment URL. Pass `url` explicitly.",
105
+ );
106
+ }
107
+
108
+ /**
109
+ * Create a framework-agnostic auth client.
110
+ *
111
+ * ### SPA mode (default)
112
+ *
113
+ * ```ts
114
+ * import { ConvexClient } from 'convex/browser'
115
+ * import { client } from '\@robelest/convex-auth/client'
116
+ *
117
+ * const convex = new ConvexClient(CONVEX_URL)
118
+ * const auth = client({ convex })
119
+ * ```
120
+ *
121
+ * ### SSR / proxy mode
122
+ *
123
+ * ```ts
124
+ * const auth = client({
125
+ * convex,
126
+ * proxy: '/api/auth',
127
+ * initialToken: tokenFromServer, // read from httpOnly cookie during SSR
128
+ * })
129
+ * ```
130
+ *
131
+ * In proxy mode all auth operations go through the proxy URL.
132
+ * Tokens are stored in httpOnly cookies server-side — the client
133
+ * only holds the JWT in memory.
134
+ */
135
+ export function client(options: ClientOptions) {
136
+ const { convex, proxy } = options;
137
+
138
+ // In proxy mode, default storage to null (cookies handle persistence).
139
+ const storage =
140
+ options.storage !== undefined
141
+ ? options.storage
142
+ : proxy
143
+ ? null
144
+ : typeof window === "undefined"
145
+ ? null
146
+ : window.localStorage;
147
+
148
+ const replaceURL =
149
+ options.replaceURL ??
150
+ ((url: string) => {
67
151
  if (typeof window !== "undefined") {
68
152
  window.history.replaceState({}, "", url);
69
153
  }
70
- },
71
- shouldHandleCode,
72
- onChange,
73
- } = options;
154
+ });
74
155
 
75
- const escapedNamespace = storageNamespace.replace(/[^a-zA-Z0-9]/g, "");
156
+ const url = proxy ? undefined : resolveUrl(convex, options.url);
157
+ const escapedNamespace = proxy
158
+ ? proxy.replace(/[^a-zA-Z0-9]/g, "")
159
+ : url!.replace(/[^a-zA-Z0-9]/g, "");
76
160
  const key = (name: string) => `${name}_${escapedNamespace}`;
77
161
  const subscribers = new Set<() => void>();
78
162
 
79
- let token: string | null = null;
80
- let isLoading = true;
81
- let snapshot: AuthSnapshot = {
163
+ // Unauthenticated HTTP client for code verification & OAuth exchange.
164
+ // Only needed in SPA mode — proxy mode routes everything through the proxy.
165
+ const httpClient = proxy ? null : new ConvexHttpClient(url!);
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // State
169
+ // ---------------------------------------------------------------------------
170
+
171
+ // If a server-provided token was supplied (SSR hydration), start authenticated.
172
+ const serverToken = options.token ?? null;
173
+ const hasServerToken = serverToken !== null;
174
+
175
+ let token: string | null = serverToken;
176
+ let isLoading = !hasServerToken;
177
+ let snapshot: AuthState = {
82
178
  isLoading,
83
- isAuthenticated: false,
179
+ isAuthenticated: hasServerToken,
84
180
  token,
85
181
  };
86
182
  let handlingCodeFlow = false;
87
183
 
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
184
  const notify = () => {
96
185
  for (const cb of subscribers) cb();
97
186
  };
98
187
 
99
188
  const updateSnapshot = () => {
100
- const nextSnapshot: AuthSnapshot = {
189
+ const next: AuthState = {
101
190
  isLoading,
102
191
  isAuthenticated: token !== null,
103
192
  token,
104
193
  };
105
194
  if (
106
- snapshot.isLoading === nextSnapshot.isLoading &&
107
- snapshot.isAuthenticated === nextSnapshot.isAuthenticated &&
108
- snapshot.token === nextSnapshot.token
195
+ snapshot.isLoading === next.isLoading &&
196
+ snapshot.isAuthenticated === next.isAuthenticated &&
197
+ snapshot.token === next.token
109
198
  ) {
110
199
  return false;
111
200
  }
112
- snapshot = nextSnapshot;
201
+ snapshot = next;
113
202
  return true;
114
203
  };
115
204
 
205
+ // ---------------------------------------------------------------------------
206
+ // Storage helpers (SPA mode only)
207
+ // ---------------------------------------------------------------------------
208
+
116
209
  const storageGet = async (name: string) =>
117
210
  storage ? ((await storage.getItem(key(name))) ?? null) : null;
118
211
  const storageSet = async (name: string, value: string) => {
@@ -122,12 +215,15 @@ export function createAuthClient(options: AuthClientOptions) {
122
215
  if (storage) await storage.removeItem(key(name));
123
216
  };
124
217
 
218
+ // ---------------------------------------------------------------------------
219
+ // Token management
220
+ // ---------------------------------------------------------------------------
221
+
125
222
  const setToken = async (
126
223
  args:
127
224
  | { shouldStore: true; tokens: AuthSession | null }
128
225
  | { shouldStore: false; tokens: { token: string } | null },
129
226
  ) => {
130
- const wasAuthenticated = token !== null;
131
227
  if (args.tokens === null) {
132
228
  token = null;
133
229
  if (args.shouldStore) {
@@ -141,9 +237,6 @@ export function createAuthClient(options: AuthClientOptions) {
141
237
  await storageSet(REFRESH_TOKEN_STORAGE_KEY, args.tokens.refreshToken);
142
238
  }
143
239
  }
144
- if (wasAuthenticated !== (token !== null)) {
145
- await onChange?.();
146
- }
147
240
  const hadPendingLoad = isLoading;
148
241
  isLoading = false;
149
242
  const changed = updateSnapshot();
@@ -152,6 +245,30 @@ export function createAuthClient(options: AuthClientOptions) {
152
245
  }
153
246
  };
154
247
 
248
+ // ---------------------------------------------------------------------------
249
+ // Proxy fetch helper
250
+ // ---------------------------------------------------------------------------
251
+
252
+ const proxyFetch = async (body: Record<string, unknown>) => {
253
+ const response = await fetch(proxy!, {
254
+ method: "POST",
255
+ headers: { "Content-Type": "application/json" },
256
+ credentials: "include",
257
+ body: JSON.stringify(body),
258
+ });
259
+ if (!response.ok) {
260
+ const error = await response.json().catch(() => ({}));
261
+ throw new Error(
262
+ (error as any).error ?? `Proxy request failed: ${response.status}`,
263
+ );
264
+ }
265
+ return response.json();
266
+ };
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Code verification with retries (SPA mode only)
270
+ // ---------------------------------------------------------------------------
271
+
155
272
  const verifyCode = async (
156
273
  args: { code: string; verifier?: string } | { refreshToken: string },
157
274
  ) => {
@@ -159,8 +276,8 @@ export function createAuthClient(options: AuthClientOptions) {
159
276
  let retry = 0;
160
277
  while (retry < RETRY_BACKOFF.length) {
161
278
  try {
162
- return await transport.unauthenticatedCall(
163
- "auth:signIn" as unknown as SignInAction,
279
+ return await httpClient!.action(
280
+ "auth:signIn" as any,
164
281
  "code" in args
165
282
  ? { params: { code: args.code }, verifier: args.verifier }
166
283
  : args,
@@ -170,11 +287,8 @@ export function createAuthClient(options: AuthClientOptions) {
170
287
  const isNetworkError =
171
288
  e instanceof Error && /network/i.test(e.message || "");
172
289
  if (!isNetworkError) break;
173
- const wait = RETRY_BACKOFF[retry] + RETRY_JITTER * Math.random();
290
+ const wait = RETRY_BACKOFF[retry]! + RETRY_JITTER * Math.random();
174
291
  retry++;
175
- logVerbose(
176
- `verifyCode network retry ${retry}/${RETRY_BACKOFF.length} in ${wait}ms`,
177
- );
178
292
  await new Promise((resolve) => setTimeout(resolve, wait));
179
293
  }
180
294
  }
@@ -185,10 +299,17 @@ export function createAuthClient(options: AuthClientOptions) {
185
299
  args: { code: string; verifier?: string } | { refreshToken: string },
186
300
  ) => {
187
301
  const { tokens } = await verifyCode(args);
188
- await setToken({ shouldStore: true, tokens: (tokens as AuthSession | null) ?? null });
302
+ await setToken({
303
+ shouldStore: true,
304
+ tokens: (tokens as AuthSession | null) ?? null,
305
+ });
189
306
  return tokens !== null;
190
307
  };
191
308
 
309
+ // ---------------------------------------------------------------------------
310
+ // signIn
311
+ // ---------------------------------------------------------------------------
312
+
192
313
  const signIn = async (
193
314
  provider?: string,
194
315
  args?: FormData | Record<string, Value>,
@@ -204,19 +325,48 @@ export function createAuthClient(options: AuthClientOptions) {
204
325
  )
205
326
  : args ?? {};
206
327
 
328
+ if (proxy) {
329
+ // Proxy mode: POST to the proxy endpoint.
330
+ const result = await proxyFetch({
331
+ action: "auth:signIn",
332
+ args: { provider, params },
333
+ });
334
+ if (result.redirect !== undefined) {
335
+ const redirectUrl = new URL(result.redirect);
336
+ // Verifier is stored server-side in an httpOnly cookie.
337
+ if (typeof window !== "undefined") {
338
+ window.location.href = redirectUrl.toString();
339
+ }
340
+ return { signingIn: false, redirect: redirectUrl };
341
+ }
342
+ if (result.tokens !== undefined) {
343
+ // Proxy returns { token, refreshToken: "dummy" }.
344
+ // Store JWT in memory only — real refresh token is in httpOnly cookie.
345
+ await setToken({
346
+ shouldStore: false,
347
+ tokens:
348
+ result.tokens === null ? null : { token: result.tokens.token },
349
+ });
350
+ return { signingIn: result.tokens !== null };
351
+ }
352
+ return { signingIn: false };
353
+ }
354
+
355
+ // SPA mode: call Convex directly.
207
356
  const verifier = (await storageGet(VERIFIER_STORAGE_KEY)) ?? undefined;
208
357
  await storageRemove(VERIFIER_STORAGE_KEY);
209
- const result = await transport.authenticatedCall(
210
- "auth:signIn" as unknown as SignInAction,
211
- { provider, params, verifier },
212
- );
358
+ const result = await convex.action("auth:signIn" as any, {
359
+ provider,
360
+ params,
361
+ verifier,
362
+ });
213
363
  if (result.redirect !== undefined) {
214
- const url = new URL(result.redirect);
364
+ const redirectUrl = new URL(result.redirect);
215
365
  await storageSet(VERIFIER_STORAGE_KEY, result.verifier!);
216
366
  if (typeof window !== "undefined") {
217
- window.location.href = url.toString();
367
+ window.location.href = redirectUrl.toString();
218
368
  }
219
- return { signingIn: false, redirect: url };
369
+ return { signingIn: false, redirect: redirectUrl };
220
370
  }
221
371
  if (result.tokens !== undefined) {
222
372
  await setToken({
@@ -228,35 +378,78 @@ export function createAuthClient(options: AuthClientOptions) {
228
378
  return { signingIn: false };
229
379
  };
230
380
 
381
+ // ---------------------------------------------------------------------------
382
+ // signOut
383
+ // ---------------------------------------------------------------------------
384
+
231
385
  const signOut = async () => {
386
+ if (proxy) {
387
+ try {
388
+ await proxyFetch({ action: "auth:signOut", args: {} });
389
+ } catch {
390
+ // Already signed out is fine.
391
+ }
392
+ await setToken({ shouldStore: false, tokens: null });
393
+ return;
394
+ }
395
+
396
+ // SPA mode.
232
397
  try {
233
- await transport.authenticatedCall(
234
- "auth:signOut" as unknown as SignOutAction,
235
- );
398
+ await convex.action("auth:signOut" as any, {});
236
399
  } catch {
237
400
  // Already signed out is fine.
238
401
  }
239
402
  await setToken({ shouldStore: true, tokens: null });
240
403
  };
241
404
 
405
+ // ---------------------------------------------------------------------------
406
+ // fetchAccessToken — called by convex.setAuth()
407
+ // ---------------------------------------------------------------------------
408
+
242
409
  const fetchAccessToken = async ({
243
410
  forceRefreshToken,
244
411
  }: {
245
412
  forceRefreshToken: boolean;
246
413
  }): Promise<string | null> => {
247
414
  if (!forceRefreshToken) return token;
415
+
416
+ if (proxy) {
417
+ // Proxy mode: POST to the proxy to refresh.
418
+ // The proxy reads the real refresh token from the httpOnly cookie.
419
+ const tokenBeforeRefresh = token;
420
+ return await browserMutex("__convexAuthProxyRefresh", async () => {
421
+ // Another tab/call may have already refreshed.
422
+ if (token !== tokenBeforeRefresh) return token;
423
+ try {
424
+ const result = await proxyFetch({
425
+ action: "auth:signIn",
426
+ args: { refreshToken: true },
427
+ });
428
+ if (result.tokens) {
429
+ await setToken({
430
+ shouldStore: false,
431
+ tokens: { token: result.tokens.token },
432
+ });
433
+ } else {
434
+ await setToken({ shouldStore: false, tokens: null });
435
+ }
436
+ } catch {
437
+ await setToken({ shouldStore: false, tokens: null });
438
+ }
439
+ return token;
440
+ });
441
+ }
442
+
443
+ // SPA mode: refresh via localStorage + httpClient.
248
444
  const tokenBeforeLockAcquisition = token;
249
445
  return await browserMutex(REFRESH_TOKEN_STORAGE_KEY, async () => {
250
446
  const tokenAfterLockAcquisition = token;
251
447
  if (tokenAfterLockAcquisition !== tokenBeforeLockAcquisition) {
252
- logVerbose(
253
- `fetchAccessToken using synchronized token, is null: ${tokenAfterLockAcquisition === null}`,
254
- );
255
448
  return tokenAfterLockAcquisition;
256
449
  }
257
- const refreshToken = (await storageGet(REFRESH_TOKEN_STORAGE_KEY)) ?? null;
450
+ const refreshToken =
451
+ (await storageGet(REFRESH_TOKEN_STORAGE_KEY)) ?? null;
258
452
  if (!refreshToken) {
259
- logVerbose("fetchAccessToken found no refresh token");
260
453
  return null;
261
454
  }
262
455
  await verifyCodeAndSetToken({ refreshToken });
@@ -264,29 +457,30 @@ export function createAuthClient(options: AuthClientOptions) {
264
457
  });
265
458
  };
266
459
 
460
+ // ---------------------------------------------------------------------------
461
+ // OAuth code flow (SPA mode only — server handles this in proxy mode)
462
+ // ---------------------------------------------------------------------------
463
+
267
464
  const handleCodeFlow = async () => {
268
465
  if (typeof window === "undefined") return;
269
466
  if (handlingCodeFlow) return;
270
467
  const code = new URLSearchParams(window.location.search).get("code");
271
468
  if (!code) return;
272
- const shouldRun =
273
- shouldHandleCode === undefined
274
- ? true
275
- : typeof shouldHandleCode === "function"
276
- ? shouldHandleCode()
277
- : shouldHandleCode;
278
- if (!shouldRun) return;
279
469
  handlingCodeFlow = true;
280
- const url = new URL(window.location.href);
281
- url.searchParams.delete("code");
470
+ const codeUrl = new URL(window.location.href);
471
+ codeUrl.searchParams.delete("code");
282
472
  try {
283
- await replaceURL(url.pathname + url.search + url.hash);
473
+ await replaceURL(codeUrl.pathname + codeUrl.search + codeUrl.hash);
284
474
  await signIn(undefined, { code });
285
475
  } finally {
286
476
  handlingCodeFlow = false;
287
477
  }
288
478
  };
289
479
 
480
+ // ---------------------------------------------------------------------------
481
+ // Hydrate from storage (SPA mode only)
482
+ // ---------------------------------------------------------------------------
483
+
290
484
  const hydrateFromStorage = async () => {
291
485
  const storedToken = (await storageGet(JWT_STORAGE_KEY)) ?? null;
292
486
  await setToken({
@@ -295,40 +489,83 @@ export function createAuthClient(options: AuthClientOptions) {
295
489
  });
296
490
  };
297
491
 
298
- const getSnapshot = (): AuthSnapshot => snapshot;
299
-
300
- const subscribe = (cb: () => void) => {
301
- subscribers.add(cb);
302
- return () => subscribers.delete(cb);
492
+ // ---------------------------------------------------------------------------
493
+ // Subscribe
494
+ // ---------------------------------------------------------------------------
495
+
496
+ /**
497
+ * Subscribe to auth state changes. Immediately invokes the callback
498
+ * with the current state and returns an unsubscribe function.
499
+ *
500
+ * ```ts
501
+ * const unsub = auth.onChange(setState)
502
+ * ```
503
+ */
504
+ const onChange = (cb: (state: AuthState) => void): (() => void) => {
505
+ cb(snapshot);
506
+ const wrapped = () => cb(snapshot);
507
+ subscribers.add(wrapped);
508
+ return () => {
509
+ subscribers.delete(wrapped);
510
+ };
303
511
  };
304
512
 
305
- if (typeof window !== "undefined") {
513
+ // ---------------------------------------------------------------------------
514
+ // Initialization
515
+ // ---------------------------------------------------------------------------
516
+
517
+ // Cross-tab sync via storage events (SPA mode only).
518
+ if (!proxy && typeof window !== "undefined") {
306
519
  const onStorage = (event: StorageEvent) => {
307
520
  void (async () => {
308
- if (event.key !== key(JWT_STORAGE_KEY)) {
309
- return;
310
- }
311
- const value = event.newValue;
521
+ if (event.key !== key(JWT_STORAGE_KEY)) return;
312
522
  await setToken({
313
523
  shouldStore: false,
314
- tokens: value === null ? null : { token: value },
524
+ tokens:
525
+ event.newValue === null ? null : { token: event.newValue },
315
526
  });
316
527
  })();
317
528
  };
318
529
  window.addEventListener("storage", onStorage);
319
530
  }
320
531
 
532
+ // Auto-wire: feed our tokens into the Convex client so
533
+ // queries and mutations are automatically authenticated.
534
+ convex.setAuth(fetchAccessToken);
535
+
536
+ // Auto-hydrate and handle code flow.
537
+ if (typeof window !== "undefined") {
538
+ if (proxy) {
539
+ // Proxy mode: if no initialToken was provided, try a refresh
540
+ // to pick up any existing session from httpOnly cookies.
541
+ if (!hasServerToken) {
542
+ void fetchAccessToken({ forceRefreshToken: true });
543
+ } else {
544
+ // initialToken already set — mark loading as done.
545
+ isLoading = false;
546
+ updateSnapshot();
547
+ }
548
+ } else {
549
+ // SPA mode: hydrate from localStorage, then handle OAuth code flow.
550
+ void hydrateFromStorage().then(() => handleCodeFlow());
551
+ }
552
+ }
553
+
321
554
  return {
555
+ /** Current auth state snapshot. */
556
+ get state(): AuthState {
557
+ return snapshot;
558
+ },
322
559
  signIn,
323
560
  signOut,
324
- fetchAccessToken,
325
- handleCodeFlow,
326
- hydrateFromStorage,
327
- getSnapshot,
328
- subscribe,
561
+ onChange,
329
562
  };
330
563
  }
331
564
 
565
+ // ---------------------------------------------------------------------------
566
+ // Browser mutex — ensures only one tab refreshes a token at a time.
567
+ // ---------------------------------------------------------------------------
568
+
332
569
  async function browserMutex<T>(
333
570
  key: string,
334
571
  callback: () => Promise<T>,
@@ -2,7 +2,7 @@
2
2
  * Configuration and helpers for using Convex Auth on your Convex
3
3
  * backend.
4
4
  *
5
- * Call {@link convexAuth} to configure your authentication methods
5
+ * Call {@link Auth} to configure your authentication methods
6
6
  * and use the helpers it returns.
7
7
  *
8
8
  * @module
@@ -10,13 +10,6 @@
10
10
 
11
11
  export {
12
12
  Auth,
13
- getAuthUserId,
14
- getAuthSessionId,
15
- createAccount,
16
- retrieveAccount,
17
- signInViaProvider,
18
- invalidateSessions,
19
- modifyAccountCredentials,
20
13
  Tokens,
21
14
  Doc,
22
15
  SignInAction,