@hraness/suite-accounts 0.9.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +496 -0
  3. package/compiler-foundation.css +1 -0
  4. package/dist/auth-client.js +16 -0
  5. package/dist/auth-proxy.js +757 -0
  6. package/dist/bearer-verifier.js +1119 -0
  7. package/dist/browser-session.js +208 -0
  8. package/dist/client-configuration.js +470 -0
  9. package/dist/convex-browser-auth-browser.js +923 -0
  10. package/dist/convex-browser-auth-server.js +846 -0
  11. package/dist/convex-browser-auth.js +597 -0
  12. package/dist/entitlements.js +502 -0
  13. package/dist/identity/functions.js +44 -0
  14. package/dist/identity/index.js +1716 -0
  15. package/dist/identity/return-targets.js +33 -0
  16. package/dist/index.js +1133 -0
  17. package/dist/oidc-device-code.js +253 -0
  18. package/dist/oidc-rp.js +2259 -0
  19. package/dist/oidc-session-policy.js +5 -0
  20. package/dist/oidc-surface-server.js +2332 -0
  21. package/dist/profile-form.js +1934 -0
  22. package/dist/profile.js +17 -0
  23. package/dist/public-config.js +462 -0
  24. package/dist/react.js +137 -0
  25. package/dist/receipt-verifier.js +619 -0
  26. package/dist/registry.js +327 -0
  27. package/dist/stylex-manifest.json +1 -0
  28. package/dist/stylex.css +435 -0
  29. package/dist/urls.js +391 -0
  30. package/package.json +263 -0
  31. package/portfolio-inventory.json +37 -0
  32. package/src/auth-client.ts +37 -0
  33. package/src/auth-proxy.ts +473 -0
  34. package/src/bearer-verifier.ts +478 -0
  35. package/src/browser-session.ts +260 -0
  36. package/src/client-configuration.ts +172 -0
  37. package/src/convex-browser-auth-browser.ts +238 -0
  38. package/src/convex-browser-auth-server.ts +492 -0
  39. package/src/convex-browser-auth.ts +208 -0
  40. package/src/convex-url.ts +74 -0
  41. package/src/entitlements.ts +317 -0
  42. package/src/identity/catalog.ts +227 -0
  43. package/src/identity/consumers.ts +53 -0
  44. package/src/identity/functions.ts +70 -0
  45. package/src/identity/identifiers.ts +82 -0
  46. package/src/identity/index.ts +10 -0
  47. package/src/identity/links.ts +280 -0
  48. package/src/identity/membership.ts +156 -0
  49. package/src/identity/principals.ts +301 -0
  50. package/src/identity/profiles-v2.ts +330 -0
  51. package/src/identity/profiles.ts +524 -0
  52. package/src/identity/return-targets.ts +20 -0
  53. package/src/identity/usernames.ts +101 -0
  54. package/src/identity/views.ts +289 -0
  55. package/src/immutable.ts +25 -0
  56. package/src/index.ts +7 -0
  57. package/src/oidc-continuation.ts +59 -0
  58. package/src/oidc-device-code.ts +401 -0
  59. package/src/oidc-rp.ts +2217 -0
  60. package/src/oidc-session-policy.ts +6 -0
  61. package/src/oidc-surface-server.ts +186 -0
  62. package/src/profile-form.css +1 -0
  63. package/src/profile-form.stylex.ts +226 -0
  64. package/src/profile-form.tsx +314 -0
  65. package/src/profile.ts +33 -0
  66. package/src/public-config.ts +294 -0
  67. package/src/public-profile-form-result.ts +82 -0
  68. package/src/public-profile-form.tsx +319 -0
  69. package/src/public-profile-links.ts +100 -0
  70. package/src/react.tsx +196 -0
  71. package/src/receipt-verifier.ts +273 -0
  72. package/src/registry.ts +600 -0
  73. package/src/urls.ts +234 -0
@@ -0,0 +1,492 @@
1
+ import {
2
+ createECDH,
3
+ createPrivateKey,
4
+ createPublicKey,
5
+ type JsonWebKey,
6
+ type KeyObject,
7
+ } from "node:crypto";
8
+
9
+ import {
10
+ parseSuiteAccountId,
11
+ type SuiteAccountId,
12
+ } from "./identity/identifiers.js";
13
+ import {
14
+ parseSuiteUsername,
15
+ type SuiteUsername,
16
+ } from "./identity/usernames.js";
17
+ import { SignJWT } from "jose";
18
+
19
+ import {
20
+ SUITE_CONVEX_BROWSER_PARENT_REFRESH_WINDOW_MS,
21
+ SUITE_CONVEX_BROWSER_TOKEN_TTL_MS,
22
+ SUITE_CONVEX_BROWSER_TOKEN_USE,
23
+ suiteConvexBrowserConfiguration,
24
+ type SuiteConvexBrowserConfiguration,
25
+ } from "./convex-browser-auth.js";
26
+ import { deepFreeze } from "./immutable.js";
27
+ import type { SuiteOidcServerSession } from "./oidc-rp.js";
28
+
29
+ const KEYRING_VERSION = "suite-convex-browser-keyring-v1" as const;
30
+ const TOKEN_RESPONSE_VERSION =
31
+ "suite-convex-browser-token-response-v1" as const;
32
+ const MAXIMUM_KEY_COUNT = 4;
33
+
34
+ export type SuiteConvexBrowserPublicJwk = Readonly<{
35
+ alg: "ES256";
36
+ crv: "P-256";
37
+ kid: string;
38
+ kty: "EC";
39
+ use: "sig";
40
+ x: string;
41
+ y: string;
42
+ }>;
43
+
44
+ export type SuiteConvexBrowserKeyring = Readonly<{
45
+ activeKid: string;
46
+ consumer: SuiteConvexBrowserConfiguration["consumer"];
47
+ environment: SuiteConvexBrowserConfiguration["environment"];
48
+ jwks: Readonly<{ keys: readonly SuiteConvexBrowserPublicJwk[] }>;
49
+ version: typeof KEYRING_VERSION;
50
+ }>;
51
+
52
+ export type SuiteConvexBrowserParentSession = Readonly<{
53
+ accessTokenExpiresAtMs: number;
54
+ suiteAccountId: SuiteAccountId;
55
+ username: SuiteUsername;
56
+ }>;
57
+
58
+ export type SuiteConvexBrowserTokenResult =
59
+ | Readonly<{ kind: "refresh_required" }>
60
+ | Readonly<{
61
+ expiresAtMs: number;
62
+ kind: "token";
63
+ token: string;
64
+ version: typeof TOKEN_RESPONSE_VERSION;
65
+ }>;
66
+
67
+ export type SuiteConvexBrowserTokenSigner = Readonly<{
68
+ sign(
69
+ session: SuiteConvexBrowserParentSession,
70
+ ): Promise<SuiteConvexBrowserTokenResult>;
71
+ }>;
72
+
73
+ export type SuiteConvexBrowserAuthHandlers = Readonly<{
74
+ jwks(request: Request): Promise<Response>;
75
+ token(request: Request): Promise<Response>;
76
+ }>;
77
+
78
+ export type CreateSuiteConvexBrowserAuthHandlersOptions = Readonly<{
79
+ configuration: SuiteConvexBrowserConfiguration;
80
+ keyring: SuiteConvexBrowserKeyring;
81
+ now?: () => number;
82
+ serverSession(request: Request): Promise<SuiteOidcServerSession | null>;
83
+ }>;
84
+
85
+ const activePrivateKeys = new WeakMap<SuiteConvexBrowserKeyring, KeyObject>();
86
+
87
+ const CONFIGURATION_KEYS = deepFreeze([
88
+ "audience",
89
+ "clientId",
90
+ "consumer",
91
+ "environment",
92
+ "issuer",
93
+ "jwksEndpoint",
94
+ "siteUrl",
95
+ "suiteIssuer",
96
+ "tokenEndpoint",
97
+ ] as const satisfies readonly (keyof SuiteConvexBrowserConfiguration)[]);
98
+
99
+ function authoritativeConfiguration(
100
+ configuration: SuiteConvexBrowserConfiguration,
101
+ ): SuiteConvexBrowserConfiguration {
102
+ const expected = suiteConvexBrowserConfiguration(
103
+ configuration.consumer,
104
+ configuration.environment,
105
+ );
106
+ if (
107
+ Object.keys(configuration).length !== CONFIGURATION_KEYS.length
108
+ || CONFIGURATION_KEYS.some(key => configuration[key] !== expected[key])
109
+ ) {
110
+ throw new Error("The Convex browser configuration does not match the registry.");
111
+ }
112
+ return expected;
113
+ }
114
+
115
+ function isRecord(value: unknown): value is Record<string, unknown> {
116
+ return typeof value === "object" && value !== null && !Array.isArray(value);
117
+ }
118
+
119
+ function exactKeys(
120
+ value: Record<string, unknown>,
121
+ required: readonly string[],
122
+ optional: readonly string[] = [],
123
+ ): boolean {
124
+ const keys = Object.keys(value);
125
+ const accepted = new Set([...required, ...optional]);
126
+ return required.every(key => Object.hasOwn(value, key))
127
+ && keys.every(key => accepted.has(key));
128
+ }
129
+
130
+ function canonicalCoordinate(value: unknown): value is string {
131
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/u.test(value)) {
132
+ return false;
133
+ }
134
+ try {
135
+ const bytes = Buffer.from(value, "base64url");
136
+ return bytes.byteLength === 32 && bytes.toString("base64url") === value;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ function canonicalKid(value: unknown): value is string {
143
+ return typeof value === "string"
144
+ && /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,63})$/u.test(value);
145
+ }
146
+
147
+ function publicJwk(
148
+ value: Record<string, unknown>,
149
+ ): SuiteConvexBrowserPublicJwk | null {
150
+ if (
151
+ !exactKeys(
152
+ value,
153
+ ["alg", "crv", "kid", "kty", "use", "x", "y"],
154
+ ["d"],
155
+ )
156
+ || value["alg"] !== "ES256"
157
+ || value["crv"] !== "P-256"
158
+ || !canonicalKid(value["kid"])
159
+ || value["kty"] !== "EC"
160
+ || value["use"] !== "sig"
161
+ || !canonicalCoordinate(value["x"])
162
+ || !canonicalCoordinate(value["y"])
163
+ || (value["d"] !== undefined && !canonicalCoordinate(value["d"]))
164
+ ) {
165
+ return null;
166
+ }
167
+ return {
168
+ alg: "ES256",
169
+ crv: "P-256",
170
+ kid: value["kid"],
171
+ kty: "EC",
172
+ use: "sig",
173
+ x: value["x"],
174
+ y: value["y"],
175
+ };
176
+ }
177
+
178
+ function nodeJwk(
179
+ key: SuiteConvexBrowserPublicJwk,
180
+ privateCoordinate?: string,
181
+ ): JsonWebKey {
182
+ return {
183
+ alg: key.alg,
184
+ crv: key.crv,
185
+ ...(privateCoordinate === undefined ? {} : { d: privateCoordinate }),
186
+ key_ops: privateCoordinate === undefined ? ["verify"] : ["sign"],
187
+ kid: key.kid,
188
+ kty: key.kty,
189
+ use: key.use,
190
+ x: key.x,
191
+ y: key.y,
192
+ };
193
+ }
194
+
195
+ function validPublicKey(key: SuiteConvexBrowserPublicJwk): boolean {
196
+ try {
197
+ const imported = createPublicKey({ format: "jwk", key: nodeJwk(key) });
198
+ return imported.asymmetricKeyType === "ec"
199
+ && imported.asymmetricKeyDetails?.namedCurve === "prime256v1";
200
+ } catch {
201
+ return false;
202
+ }
203
+ }
204
+
205
+ function activePrivateKey(
206
+ key: SuiteConvexBrowserPublicJwk,
207
+ privateCoordinate: string,
208
+ ): KeyObject | null {
209
+ try {
210
+ const ecdh = createECDH("prime256v1");
211
+ ecdh.setPrivateKey(Buffer.from(privateCoordinate, "base64url"));
212
+ const derivedPoint = ecdh.getPublicKey(undefined, "uncompressed");
213
+ if (
214
+ derivedPoint.byteLength !== 65
215
+ || derivedPoint[0] !== 4
216
+ || derivedPoint.subarray(1, 33).toString("base64url") !== key.x
217
+ || derivedPoint.subarray(33, 65).toString("base64url") !== key.y
218
+ ) {
219
+ return null;
220
+ }
221
+ const imported = createPrivateKey({
222
+ format: "jwk",
223
+ key: nodeJwk(key, privateCoordinate),
224
+ });
225
+ if (
226
+ imported.asymmetricKeyType !== "ec"
227
+ || imported.asymmetricKeyDetails?.namedCurve !== "prime256v1"
228
+ ) {
229
+ return null;
230
+ }
231
+ const derived = createPublicKey(imported).export({ format: "jwk" });
232
+ return derived.kty === "EC"
233
+ && derived.crv === "P-256"
234
+ && derived.x === key.x
235
+ && derived.y === key.y
236
+ ? imported
237
+ : null;
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Parse an environment-bound keyring without retaining private JWK material in
245
+ * the returned serializable object. Retiring entries may be public-only; the
246
+ * active entry must carry a matching private coordinate.
247
+ */
248
+ export function parseSuiteConvexBrowserKeyring(
249
+ value: unknown,
250
+ configuration: SuiteConvexBrowserConfiguration,
251
+ ): SuiteConvexBrowserKeyring | null {
252
+ const authoritative = authoritativeConfiguration(configuration);
253
+ if (
254
+ !isRecord(value)
255
+ || !exactKeys(
256
+ value,
257
+ ["activeKid", "consumer", "environment", "keys", "version"],
258
+ )
259
+ || value["version"] !== KEYRING_VERSION
260
+ || value["consumer"] !== authoritative.consumer
261
+ || value["environment"] !== authoritative.environment
262
+ || !canonicalKid(value["activeKid"])
263
+ || !Array.isArray(value["keys"])
264
+ || value["keys"].length < 1
265
+ || value["keys"].length > MAXIMUM_KEY_COUNT
266
+ ) {
267
+ return null;
268
+ }
269
+ const seen = new Set<string>();
270
+ const parsed: Array<Readonly<{
271
+ privateCoordinate: string | null;
272
+ publicKey: SuiteConvexBrowserPublicJwk;
273
+ }>> = [];
274
+ for (const candidate of value["keys"]) {
275
+ if (!isRecord(candidate)) return null;
276
+ const publicKey = publicJwk(candidate);
277
+ if (
278
+ publicKey === null
279
+ || seen.has(publicKey.kid)
280
+ || !validPublicKey(publicKey)
281
+ ) {
282
+ return null;
283
+ }
284
+ seen.add(publicKey.kid);
285
+ parsed.push({
286
+ privateCoordinate: typeof candidate["d"] === "string"
287
+ ? candidate["d"]
288
+ : null,
289
+ publicKey,
290
+ });
291
+ }
292
+ const active = parsed.find(key => key.publicKey.kid === value["activeKid"]);
293
+ if (active?.privateCoordinate === null || active === undefined) return null;
294
+ const privateKey = activePrivateKey(
295
+ active.publicKey,
296
+ active.privateCoordinate,
297
+ );
298
+ if (privateKey === null) return null;
299
+ const ordered = parsed
300
+ .map(key => key.publicKey)
301
+ .sort((left, right) => {
302
+ if (left.kid === value["activeKid"]) return -1;
303
+ if (right.kid === value["activeKid"]) return 1;
304
+ return left.kid.localeCompare(right.kid);
305
+ });
306
+ const keyring: SuiteConvexBrowserKeyring = deepFreeze({
307
+ activeKid: value["activeKid"],
308
+ consumer: authoritative.consumer,
309
+ environment: authoritative.environment,
310
+ jwks: { keys: ordered },
311
+ version: KEYRING_VERSION,
312
+ });
313
+ activePrivateKeys.set(keyring, privateKey);
314
+ return keyring;
315
+ }
316
+
317
+ function safeTimestamp(value: unknown): value is number {
318
+ return typeof value === "number"
319
+ && Number.isSafeInteger(value)
320
+ && value >= 0;
321
+ }
322
+
323
+ export function createSuiteConvexBrowserTokenSigner(
324
+ configuration: SuiteConvexBrowserConfiguration,
325
+ keyring: SuiteConvexBrowserKeyring,
326
+ now: () => number = Date.now,
327
+ ): SuiteConvexBrowserTokenSigner {
328
+ const authoritative = authoritativeConfiguration(configuration);
329
+ if (
330
+ keyring.consumer !== authoritative.consumer
331
+ || keyring.environment !== authoritative.environment
332
+ ) {
333
+ throw new Error("The Convex browser keyring environment does not match.");
334
+ }
335
+ const privateKey = activePrivateKeys.get(keyring);
336
+ if (privateKey === undefined) {
337
+ throw new Error("The Convex browser keyring was not parsed by this module.");
338
+ }
339
+ return Object.freeze({
340
+ async sign(session) {
341
+ const nowMs = now();
342
+ const suiteAccountId = parseSuiteAccountId(session.suiteAccountId);
343
+ const username = parseSuiteUsername(session.username);
344
+ if (
345
+ !safeTimestamp(nowMs)
346
+ || !safeTimestamp(session.accessTokenExpiresAtMs)
347
+ || !suiteAccountId.ok
348
+ || !username.ok
349
+ ) {
350
+ throw new Error("The parent suite session is invalid.");
351
+ }
352
+ if (
353
+ session.accessTokenExpiresAtMs - nowMs
354
+ < SUITE_CONVEX_BROWSER_PARENT_REFRESH_WINDOW_MS
355
+ ) {
356
+ return { kind: "refresh_required" };
357
+ }
358
+ const issuedAtSeconds = Math.floor(nowMs / 1_000);
359
+ const expiresAtSeconds = Math.min(
360
+ issuedAtSeconds + Math.floor(SUITE_CONVEX_BROWSER_TOKEN_TTL_MS / 1_000),
361
+ Math.floor(session.accessTokenExpiresAtMs / 1_000),
362
+ );
363
+ if (expiresAtSeconds <= issuedAtSeconds) {
364
+ return { kind: "refresh_required" };
365
+ }
366
+ const token = await new SignJWT({
367
+ profile_complete: true,
368
+ profile_revision: "username-v1",
369
+ suite_account_id: suiteAccountId.value,
370
+ suite_client_id: authoritative.clientId,
371
+ suite_issuer: authoritative.suiteIssuer,
372
+ token_use: SUITE_CONVEX_BROWSER_TOKEN_USE,
373
+ username: username.value,
374
+ })
375
+ .setProtectedHeader({ alg: "ES256", kid: keyring.activeKid, typ: "JWT" })
376
+ .setIssuer(authoritative.issuer)
377
+ .setAudience(authoritative.audience)
378
+ .setSubject(suiteAccountId.value)
379
+ .setIssuedAt(issuedAtSeconds)
380
+ .setNotBefore(issuedAtSeconds)
381
+ .setExpirationTime(expiresAtSeconds)
382
+ .sign(privateKey);
383
+ return {
384
+ expiresAtMs: expiresAtSeconds * 1_000,
385
+ kind: "token",
386
+ token,
387
+ version: TOKEN_RESPONSE_VERSION,
388
+ };
389
+ },
390
+ });
391
+ }
392
+
393
+ function noStoreJson(value: unknown, status = 200): Response {
394
+ return Response.json(value, {
395
+ headers: {
396
+ "cache-control": "no-store",
397
+ "content-security-policy": "default-src 'none'",
398
+ pragma: "no-cache",
399
+ "x-content-type-options": "nosniff",
400
+ },
401
+ status,
402
+ });
403
+ }
404
+
405
+ function exactEndpointRequest(
406
+ request: Request,
407
+ endpoint: string,
408
+ method: "GET" | "POST",
409
+ ): boolean {
410
+ let incoming: URL;
411
+ let expected: URL;
412
+ try {
413
+ incoming = new URL(request.url);
414
+ expected = new URL(endpoint);
415
+ } catch {
416
+ return false;
417
+ }
418
+ return request.method === method
419
+ && incoming.origin === expected.origin
420
+ && incoming.pathname === expected.pathname
421
+ && incoming.search === ""
422
+ && incoming.username === ""
423
+ && incoming.password === ""
424
+ && request.body === null;
425
+ }
426
+
427
+ function exactTokenRequest(
428
+ request: Request,
429
+ configuration: SuiteConvexBrowserConfiguration,
430
+ ): boolean {
431
+ if (!exactEndpointRequest(request, configuration.tokenEndpoint, "POST")) {
432
+ return false;
433
+ }
434
+ const contentLength = request.headers.get("content-length");
435
+ const fetchDestination = request.headers.get("sec-fetch-dest");
436
+ const fetchMode = request.headers.get("sec-fetch-mode");
437
+ const fetchSite = request.headers.get("sec-fetch-site");
438
+ return request.headers.get("origin") === configuration.siteUrl
439
+ && (contentLength === null || contentLength === "0")
440
+ && request.headers.get("content-type") === null
441
+ && (fetchDestination === null || fetchDestination === "empty")
442
+ && (fetchMode === null || fetchMode === "cors" || fetchMode === "same-origin")
443
+ && (fetchSite === null || fetchSite === "same-origin")
444
+ && request.headers.get("sec-fetch-user") === null;
445
+ }
446
+
447
+ /** Create exact product token and public-key route handlers. */
448
+ export function createSuiteConvexBrowserAuthHandlers(
449
+ options: CreateSuiteConvexBrowserAuthHandlersOptions,
450
+ ): SuiteConvexBrowserAuthHandlers {
451
+ const configuration = authoritativeConfiguration(options.configuration);
452
+ const keyring = options.keyring;
453
+ const serverSession = options.serverSession;
454
+ const signer = createSuiteConvexBrowserTokenSigner(
455
+ configuration,
456
+ keyring,
457
+ options.now,
458
+ );
459
+ return Object.freeze({
460
+ jwks: request => Promise.resolve(
461
+ exactEndpointRequest(
462
+ request,
463
+ configuration.jwksEndpoint,
464
+ "GET",
465
+ )
466
+ ? noStoreJson(keyring.jwks)
467
+ : noStoreJson({ kind: "request_rejected" }, 403),
468
+ ),
469
+ token: async request => {
470
+ if (!exactTokenRequest(request, configuration)) {
471
+ return noStoreJson({ kind: "request_rejected" }, 403);
472
+ }
473
+ const session = await serverSession(request);
474
+ if (session === null) {
475
+ return noStoreJson({ kind: "signed_out" }, 401);
476
+ }
477
+ let result: SuiteConvexBrowserTokenResult;
478
+ try {
479
+ result = await signer.sign({
480
+ accessTokenExpiresAtMs: session.accessTokenExpiresAtMs,
481
+ suiteAccountId: session.suiteAccountId,
482
+ username: session.username,
483
+ });
484
+ } catch {
485
+ return noStoreJson({ kind: "signed_out" }, 401);
486
+ }
487
+ return result.kind === "refresh_required"
488
+ ? noStoreJson(result, 409)
489
+ : noStoreJson(result);
490
+ },
491
+ });
492
+ }
@@ -0,0 +1,208 @@
1
+ import {
2
+ parseSuiteAccountId,
3
+ type SuiteAccountId,
4
+ } from "./identity/identifiers.js";
5
+ import {
6
+ parseSuiteUsername,
7
+ type SuiteUsername,
8
+ } from "./identity/usernames.js";
9
+ import type { AuthConfig } from "convex/server";
10
+
11
+ import { deepFreeze } from "./immutable.js";
12
+ import {
13
+ getSuiteAccountsCurrentConsumerEnvironment,
14
+ type SuiteAccountsCurrentOidcConsumerId,
15
+ type SuiteAccountsRemoteEnvironment,
16
+ } from "./registry.js";
17
+ import {
18
+ suiteAccountsCurrentOidcClientRegistration,
19
+ suiteAccountsOidcProviderConfiguration,
20
+ } from "./urls.js";
21
+ import { SUITE_OIDC_EARLY_REFRESH_WINDOW_MS } from "./oidc-session-policy.js";
22
+
23
+ export const SUITE_CONVEX_BROWSER_TOKEN_USE =
24
+ "suite-convex-browser-v1" as const;
25
+ export const SUITE_CONVEX_BROWSER_TOKEN_TTL_MS = 5 * 60_000;
26
+ export const SUITE_CONVEX_BROWSER_PARENT_REFRESH_WINDOW_MS =
27
+ SUITE_OIDC_EARLY_REFRESH_WINDOW_MS;
28
+ export const SUITE_CONVEX_BROWSER_TOKEN_PATH =
29
+ "/api/convex-auth/token" as const;
30
+ export const SUITE_CONVEX_BROWSER_JWKS_PATH =
31
+ "/api/convex-auth/jwks" as const;
32
+ export const SUITE_CONVEX_BROWSER_ISSUER_PATH =
33
+ "/api/convex-auth" as const;
34
+ export const SUITE_CONVEX_BROWSER_AUDIENCE_PATH = "/convex" as const;
35
+
36
+ /**
37
+ * Products admitted to the browser-to-Convex token exchange.
38
+ *
39
+ * Registration is deliberately separate from the broader OIDC consumer
40
+ * union. Adding a suite RP must not silently let it mint a browser bearer.
41
+ * Admission names a current OIDC consumer; every trust value below derives
42
+ * from the current Accounts authority, never the deprecated v1 registry.
43
+ */
44
+ export const SUITE_CONVEX_BROWSER_CONSUMER_IDS = deepFreeze([
45
+ "alt",
46
+ ] as const satisfies readonly SuiteAccountsCurrentOidcConsumerId[]);
47
+
48
+ export type SuiteConvexBrowserConsumerId =
49
+ (typeof SUITE_CONVEX_BROWSER_CONSUMER_IDS)[number];
50
+
51
+ export type SuiteConvexBrowserConfiguration = Readonly<{
52
+ audience: string;
53
+ clientId: string;
54
+ consumer: SuiteConvexBrowserConsumerId;
55
+ environment: SuiteAccountsRemoteEnvironment;
56
+ issuer: string;
57
+ jwksEndpoint: string;
58
+ siteUrl: string;
59
+ suiteIssuer: string;
60
+ tokenEndpoint: string;
61
+ }>;
62
+
63
+ export type SuiteConvexBrowserIdentity = Readonly<{
64
+ issuer: string;
65
+ subject: SuiteAccountId;
66
+ suiteAccountId: SuiteAccountId;
67
+ username: SuiteUsername;
68
+ }>;
69
+
70
+ export type SuiteConvexBrowserIdentityResult =
71
+ | Readonly<{ ok: true; value: SuiteConvexBrowserIdentity }>
72
+ | Readonly<{
73
+ error:
74
+ | "invalid-client"
75
+ | "invalid-identity"
76
+ | "invalid-issuer"
77
+ | "invalid-profile"
78
+ | "invalid-subject"
79
+ | "invalid-token-use";
80
+ ok: false;
81
+ }>;
82
+
83
+ function isRecord(value: unknown): value is Record<string, unknown> {
84
+ return typeof value === "object" && value !== null && !Array.isArray(value);
85
+ }
86
+
87
+ function enabledConsumer(
88
+ value: unknown,
89
+ ): value is SuiteConvexBrowserConsumerId {
90
+ return typeof value === "string"
91
+ && (SUITE_CONVEX_BROWSER_CONSUMER_IDS as readonly string[]).includes(value);
92
+ }
93
+
94
+ /** Resolve every product-token trust value from the current suite registry. */
95
+ export function suiteConvexBrowserConfiguration(
96
+ consumer: SuiteConvexBrowserConsumerId,
97
+ environment: SuiteAccountsRemoteEnvironment,
98
+ ): SuiteConvexBrowserConfiguration {
99
+ if (!enabledConsumer(consumer)) {
100
+ throw new Error("The suite consumer has no Convex browser-token grant.");
101
+ }
102
+ const consumerEnvironment = getSuiteAccountsCurrentConsumerEnvironment(
103
+ consumer,
104
+ environment,
105
+ );
106
+ if (consumerEnvironment === null) {
107
+ throw new Error("The suite consumer is unavailable in this environment.");
108
+ }
109
+ const siteUrl = consumerEnvironment.siteUrl;
110
+ const registration = suiteAccountsCurrentOidcClientRegistration(
111
+ consumer,
112
+ environment,
113
+ );
114
+ if (registration === null) {
115
+ throw new Error("The suite consumer has no OIDC client registration.");
116
+ }
117
+ const suiteProvider = suiteAccountsOidcProviderConfiguration(environment);
118
+ return deepFreeze({
119
+ audience: new URL(SUITE_CONVEX_BROWSER_AUDIENCE_PATH, siteUrl).href,
120
+ clientId: registration.clientId,
121
+ consumer,
122
+ environment,
123
+ issuer: new URL(SUITE_CONVEX_BROWSER_ISSUER_PATH, siteUrl).href,
124
+ jwksEndpoint: new URL(SUITE_CONVEX_BROWSER_JWKS_PATH, siteUrl).href,
125
+ siteUrl,
126
+ suiteIssuer: suiteProvider.issuer,
127
+ tokenEndpoint: new URL(SUITE_CONVEX_BROWSER_TOKEN_PATH, siteUrl).href,
128
+ });
129
+ }
130
+
131
+ /** Match an environment only through the registered exact consumer origin. */
132
+ export function suiteConvexBrowserEnvironmentForOrigin(
133
+ consumer: SuiteConvexBrowserConsumerId,
134
+ value: unknown,
135
+ ): SuiteAccountsRemoteEnvironment | null {
136
+ if (typeof value !== "string" || !enabledConsumer(consumer)) return null;
137
+ return getSuiteAccountsCurrentConsumerEnvironment(consumer, "production")
138
+ ?.siteUrl === value
139
+ ? "production"
140
+ : null;
141
+ }
142
+
143
+ /** Build the exact custom-JWT trust configuration for product Convex. */
144
+ export function suiteConvexBrowserAuthConfig(
145
+ consumer: SuiteConvexBrowserConsumerId,
146
+ environment: SuiteAccountsRemoteEnvironment,
147
+ ): AuthConfig {
148
+ const configuration = suiteConvexBrowserConfiguration(consumer, environment);
149
+ return deepFreeze({
150
+ providers: [
151
+ {
152
+ algorithm: "ES256",
153
+ applicationID: configuration.audience,
154
+ issuer: configuration.issuer,
155
+ jwks: configuration.jwksEndpoint,
156
+ type: "customJwt",
157
+ },
158
+ ],
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Parse the identity Convex exposes after its custom-JWT verifier succeeds.
164
+ *
165
+ * Convex verifies ES256, issuer, audience, and JWKS before this parser runs.
166
+ * The parser pins the remaining signed product, suite, token-use, account, and
167
+ * public-username claims. Product code derives ownership only from this value.
168
+ */
169
+ export function parseSuiteConvexBrowserIdentity(
170
+ value: unknown,
171
+ configuration: SuiteConvexBrowserConfiguration,
172
+ ): SuiteConvexBrowserIdentityResult {
173
+ if (!isRecord(value)) return { error: "invalid-identity", ok: false };
174
+ if (value["issuer"] !== configuration.issuer) {
175
+ return { error: "invalid-issuer", ok: false };
176
+ }
177
+ if (
178
+ value["suite_client_id"] !== configuration.clientId
179
+ || value["suite_issuer"] !== configuration.suiteIssuer
180
+ ) {
181
+ return { error: "invalid-client", ok: false };
182
+ }
183
+ if (value["token_use"] !== SUITE_CONVEX_BROWSER_TOKEN_USE) {
184
+ return { error: "invalid-token-use", ok: false };
185
+ }
186
+ if (
187
+ value["profile_complete"] !== true
188
+ || value["profile_revision"] !== "username-v1"
189
+ ) {
190
+ return { error: "invalid-profile", ok: false };
191
+ }
192
+ const account = parseSuiteAccountId(value["suite_account_id"]);
193
+ const subject = parseSuiteAccountId(value["subject"]);
194
+ if (!account.ok || !subject.ok || account.value !== subject.value) {
195
+ return { error: "invalid-subject", ok: false };
196
+ }
197
+ const username = parseSuiteUsername(value["username"]);
198
+ if (!username.ok) return { error: "invalid-profile", ok: false };
199
+ return deepFreeze({
200
+ ok: true,
201
+ value: {
202
+ issuer: configuration.issuer,
203
+ subject: subject.value,
204
+ suiteAccountId: account.value,
205
+ username: username.value,
206
+ },
207
+ });
208
+ }