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