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