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