@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.
- package/dist/bin.cjs +466 -63
- package/dist/client/index.d.ts +211 -30
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +673 -59
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +56 -1
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +93 -3
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/convex.config.d.ts.map +1 -1
- package/dist/component/convex.config.js +2 -0
- package/dist/component/convex.config.js.map +1 -1
- package/dist/component/index.d.ts +5 -3
- package/dist/component/index.d.ts.map +1 -1
- package/dist/component/index.js +5 -3
- package/dist/component/index.js.map +1 -1
- package/dist/component/portalBridge.d.ts +80 -0
- package/dist/component/portalBridge.d.ts.map +1 -0
- package/dist/component/portalBridge.js +102 -0
- package/dist/component/portalBridge.js.map +1 -0
- package/dist/component/public.d.ts +193 -9
- package/dist/component/public.d.ts.map +1 -1
- package/dist/component/public.js +204 -33
- package/dist/component/public.js.map +1 -1
- package/dist/component/schema.d.ts +89 -9
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +68 -7
- package/dist/component/schema.js.map +1 -1
- package/dist/providers/{Anonymous.d.ts → anonymous.d.ts} +8 -8
- package/dist/providers/{Anonymous.d.ts.map → anonymous.d.ts.map} +1 -1
- package/dist/providers/{Anonymous.js → anonymous.js} +9 -10
- package/dist/providers/anonymous.js.map +1 -0
- package/dist/providers/{ConvexCredentials.d.ts → credentials.d.ts} +11 -11
- package/dist/providers/credentials.d.ts.map +1 -0
- package/dist/providers/{ConvexCredentials.js → credentials.js} +8 -8
- package/dist/providers/credentials.js.map +1 -0
- package/dist/providers/{Email.d.ts → email.d.ts} +6 -6
- package/dist/providers/email.d.ts.map +1 -0
- package/dist/providers/{Email.js → email.js} +6 -6
- package/dist/providers/email.js.map +1 -0
- package/dist/providers/passkey.d.ts +20 -0
- package/dist/providers/passkey.d.ts.map +1 -0
- package/dist/providers/passkey.js +32 -0
- package/dist/providers/passkey.js.map +1 -0
- package/dist/providers/{Password.d.ts → password.d.ts} +10 -10
- package/dist/providers/{Password.d.ts.map → password.d.ts.map} +1 -1
- package/dist/providers/{Password.js → password.js} +19 -20
- package/dist/providers/password.js.map +1 -0
- package/dist/providers/{Phone.d.ts → phone.d.ts} +3 -3
- package/dist/providers/{Phone.d.ts.map → phone.d.ts.map} +1 -1
- package/dist/providers/{Phone.js → phone.js} +3 -3
- package/dist/providers/{Phone.js.map → phone.js.map} +1 -1
- package/dist/providers/totp.d.ts +14 -0
- package/dist/providers/totp.d.ts.map +1 -0
- package/dist/providers/totp.js +23 -0
- package/dist/providers/totp.js.map +1 -0
- package/dist/server/convex-auth.d.ts +243 -0
- package/dist/server/convex-auth.d.ts.map +1 -0
- package/dist/server/convex-auth.js +365 -0
- package/dist/server/convex-auth.js.map +1 -0
- package/dist/server/implementation/index.d.ts +153 -166
- package/dist/server/implementation/index.d.ts.map +1 -1
- package/dist/server/implementation/index.js +162 -105
- package/dist/server/implementation/index.js.map +1 -1
- package/dist/server/implementation/passkey.d.ts +33 -0
- package/dist/server/implementation/passkey.d.ts.map +1 -0
- package/dist/server/implementation/passkey.js +450 -0
- package/dist/server/implementation/passkey.js.map +1 -0
- package/dist/server/implementation/redirects.d.ts.map +1 -1
- package/dist/server/implementation/redirects.js +4 -9
- package/dist/server/implementation/redirects.js.map +1 -1
- package/dist/server/implementation/sessions.d.ts +2 -20
- package/dist/server/implementation/sessions.d.ts.map +1 -1
- package/dist/server/implementation/sessions.js +2 -20
- package/dist/server/implementation/sessions.js.map +1 -1
- package/dist/server/implementation/signIn.d.ts +13 -0
- package/dist/server/implementation/signIn.d.ts.map +1 -1
- package/dist/server/implementation/signIn.js +26 -1
- package/dist/server/implementation/signIn.js.map +1 -1
- package/dist/server/implementation/totp.d.ts +40 -0
- package/dist/server/implementation/totp.d.ts.map +1 -0
- package/dist/server/implementation/totp.js +211 -0
- package/dist/server/implementation/totp.js.map +1 -0
- package/dist/server/index.d.ts +18 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +255 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/portal-email.d.ts +19 -0
- package/dist/server/portal-email.d.ts.map +1 -0
- package/dist/server/portal-email.js +89 -0
- package/dist/server/portal-email.js.map +1 -0
- package/dist/server/portal.d.ts +116 -0
- package/dist/server/portal.d.ts.map +1 -0
- package/dist/server/portal.js +294 -0
- package/dist/server/portal.js.map +1 -0
- package/dist/server/provider_utils.d.ts +1 -1
- package/dist/server/provider_utils.d.ts.map +1 -1
- package/dist/server/provider_utils.js +39 -1
- package/dist/server/provider_utils.js.map +1 -1
- package/dist/server/types.d.ts +128 -11
- package/dist/server/types.d.ts.map +1 -1
- package/package.json +7 -7
- package/src/cli/index.ts +48 -6
- package/src/cli/portal-link.ts +112 -0
- package/src/cli/portal-upload.ts +411 -0
- package/src/client/index.ts +823 -109
- package/src/component/_generated/api.ts +72 -1
- package/src/component/_generated/component.ts +180 -4
- package/src/component/convex.config.ts +3 -0
- package/src/component/index.ts +5 -10
- package/src/component/portalBridge.ts +116 -0
- package/src/component/public.ts +231 -37
- package/src/component/schema.ts +70 -7
- package/src/providers/{Anonymous.ts → anonymous.ts} +10 -11
- package/src/providers/{ConvexCredentials.ts → credentials.ts} +11 -11
- package/src/providers/{Email.ts → email.ts} +5 -5
- package/src/providers/passkey.ts +35 -0
- package/src/providers/{Password.ts → password.ts} +22 -27
- package/src/providers/{Phone.ts → phone.ts} +2 -2
- package/src/providers/totp.ts +26 -0
- package/src/server/convex-auth.ts +470 -0
- package/src/server/implementation/index.ts +228 -239
- package/src/server/implementation/passkey.ts +650 -0
- package/src/server/implementation/redirects.ts +4 -11
- package/src/server/implementation/sessions.ts +2 -20
- package/src/server/implementation/signIn.ts +39 -1
- package/src/server/implementation/totp.ts +366 -0
- package/src/server/index.ts +373 -0
- package/src/server/portal-email.ts +95 -0
- package/src/server/portal.ts +375 -0
- package/src/server/provider_utils.ts +42 -1
- package/src/server/types.ts +161 -10
- package/dist/providers/Anonymous.js.map +0 -1
- package/dist/providers/ConvexCredentials.d.ts.map +0 -1
- package/dist/providers/ConvexCredentials.js.map +0 -1
- package/dist/providers/Email.d.ts.map +0 -1
- package/dist/providers/Email.js.map +0 -1
- package/dist/providers/Password.js.map +0 -1
- package/providers/Anonymous/package.json +0 -6
- package/providers/ConvexCredentials/package.json +0 -6
- package/providers/Email/package.json +0 -6
- package/providers/Password/package.json +0 -6
- package/providers/Phone/package.json +0 -6
- package/server/package.json +0 -6
package/dist/client/index.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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:
|
|
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
|
|
93
|
+
const next = {
|
|
35
94
|
isLoading,
|
|
36
95
|
isAuthenticated: token !== null,
|
|
37
96
|
token,
|
|
38
97
|
};
|
|
39
|
-
if (snapshot.isLoading ===
|
|
40
|
-
snapshot.isAuthenticated ===
|
|
41
|
-
snapshot.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 =
|
|
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
|
|
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({
|
|
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
|
|
232
|
+
const result = await convex.action("auth:signIn", {
|
|
233
|
+
provider,
|
|
234
|
+
params,
|
|
235
|
+
verifier,
|
|
236
|
+
});
|
|
119
237
|
if (result.redirect !== undefined) {
|
|
120
|
-
const
|
|
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 =
|
|
241
|
+
window.location.href = redirectUrl.toString();
|
|
124
242
|
}
|
|
125
|
-
return { signingIn: false, redirect:
|
|
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
|
|
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
|
|
181
|
-
|
|
342
|
+
const codeUrl = new URL(window.location.href);
|
|
343
|
+
codeUrl.searchParams.delete("code");
|
|
182
344
|
try {
|
|
183
|
-
await replaceURL(
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|