@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,260 @@
1
+ import {
2
+ SUITE_OIDC_JUST_SIGNED_IN_STORAGE_KEY,
3
+ } from "./oidc-continuation.js";
4
+
5
+ const MAXIMUM_SESSION_RESPONSE_BYTES = 32_768;
6
+ const SESSION_PATH = "/api/suite-auth/session";
7
+ const REFRESH_PATH = "/api/suite-auth/refresh";
8
+ const SIGN_OUT_PATH = "/api/suite-auth/sign-out";
9
+
10
+ export const SUITE_OIDC_REFRESH_LOCK_NAME =
11
+ "jungle-suite-accounts:oidc-refresh:v1";
12
+ export const SUITE_OIDC_SESSION_CHANNEL_NAME =
13
+ "jungle-suite-accounts:oidc-session:v1";
14
+
15
+ const SIGNED_OUT_EVENT = Object.freeze({
16
+ kind: "signed_out",
17
+ version: "suite-oidc-session-event-v1",
18
+ });
19
+
20
+ type SuiteOidcFetch = (
21
+ input: RequestInfo | URL,
22
+ init?: RequestInit,
23
+ ) => Promise<Response>;
24
+
25
+ export type SuiteOidcExclusiveLock = (
26
+ name: string,
27
+ task: () => Promise<unknown>,
28
+ ) => Promise<unknown>;
29
+
30
+ export type SuiteOidcBrowserSessionDependencies = Readonly<{
31
+ fetch?: SuiteOidcFetch;
32
+ withExclusiveLock?: SuiteOidcExclusiveLock | null;
33
+ }>;
34
+
35
+ export type SuiteOidcBrowserSignOutDependencies =
36
+ SuiteOidcBrowserSessionDependencies & Readonly<{
37
+ notifySignedOut?: () => void;
38
+ }>;
39
+
40
+ let localRefresh: Promise<unknown> | null = null;
41
+
42
+ function isExactRefreshRequired(value: unknown): boolean {
43
+ return typeof value === "object"
44
+ && value !== null
45
+ && !Array.isArray(value)
46
+ && Object.keys(value).length === 1
47
+ && Reflect.get(value, "kind") === "refresh_required";
48
+ }
49
+
50
+ async function readBoundedJson(response: Response): Promise<unknown> {
51
+ const contentType = response.headers
52
+ .get("content-type")
53
+ ?.split(";", 1)[0]
54
+ ?.trim()
55
+ .toLowerCase();
56
+ if (contentType !== "application/json" || response.body === null) {
57
+ throw new Error("The suite session response was not JSON.");
58
+ }
59
+
60
+ const declaredLength = response.headers.get("content-length");
61
+ if (
62
+ declaredLength !== null
63
+ && (
64
+ !/^(?:0|[1-9]\d*)$/u.test(declaredLength)
65
+ || Number(declaredLength) > MAXIMUM_SESSION_RESPONSE_BYTES
66
+ )
67
+ ) {
68
+ await response.body.cancel().catch(() => undefined);
69
+ throw new Error("The suite session response was too large.");
70
+ }
71
+
72
+ const reader = response.body.getReader();
73
+ const chunks: Uint8Array[] = [];
74
+ let total = 0;
75
+ try {
76
+ while (true) {
77
+ const result = await reader.read();
78
+ if (result.done) break;
79
+ total += result.value.byteLength;
80
+ if (total > MAXIMUM_SESSION_RESPONSE_BYTES) {
81
+ await reader.cancel().catch(() => undefined);
82
+ throw new Error("The suite session response was too large.");
83
+ }
84
+ chunks.push(result.value);
85
+ }
86
+ } finally {
87
+ reader.releaseLock();
88
+ }
89
+
90
+ const bytes = new Uint8Array(total);
91
+ let offset = 0;
92
+ for (const chunk of chunks) {
93
+ bytes.set(chunk, offset);
94
+ offset += chunk.byteLength;
95
+ }
96
+ return JSON.parse(
97
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes),
98
+ ) as unknown;
99
+ }
100
+
101
+ async function requestJson(
102
+ fetcher: SuiteOidcFetch,
103
+ path: typeof REFRESH_PATH | typeof SESSION_PATH | typeof SIGN_OUT_PATH,
104
+ method: "GET" | "POST",
105
+ ): Promise<unknown> {
106
+ const response = await fetcher(path, {
107
+ cache: "no-store",
108
+ credentials: "same-origin",
109
+ headers: { accept: "application/json" },
110
+ method,
111
+ redirect: "error",
112
+ });
113
+ const value = await readBoundedJson(response);
114
+ if (!response.ok) {
115
+ throw new Error("The suite session request failed.");
116
+ }
117
+ return value;
118
+ }
119
+
120
+ function exactSignedOut(value: unknown): boolean {
121
+ return typeof value === "object"
122
+ && value !== null
123
+ && !Array.isArray(value)
124
+ && Object.keys(value).length === 1
125
+ && Reflect.get(value, "kind") === "signed_out";
126
+ }
127
+
128
+ /** Notify every open tab on this exact product origin that custody ended. */
129
+ export function broadcastSuiteOidcBrowserSignOut(): void {
130
+ if (typeof BroadcastChannel === "undefined") return;
131
+ const channel = new BroadcastChannel(SUITE_OIDC_SESSION_CHANNEL_NAME);
132
+ try {
133
+ channel.postMessage(SIGNED_OUT_EVENT);
134
+ } finally {
135
+ channel.close();
136
+ }
137
+ }
138
+
139
+ /** Subscribe to the bounded cross-tab suite-session event. */
140
+ export function subscribeSuiteOidcBrowserSignOut(
141
+ listener: () => void,
142
+ ): () => void {
143
+ if (typeof BroadcastChannel === "undefined") return () => undefined;
144
+ const channel = new BroadcastChannel(SUITE_OIDC_SESSION_CHANNEL_NAME);
145
+ const onMessage = (event: MessageEvent<unknown>): void => {
146
+ const value = event.data;
147
+ if (
148
+ typeof value === "object"
149
+ && value !== null
150
+ && !Array.isArray(value)
151
+ && Object.keys(value).length === 2
152
+ && Reflect.get(value, "kind") === SIGNED_OUT_EVENT.kind
153
+ && Reflect.get(value, "version") === SIGNED_OUT_EVENT.version
154
+ ) {
155
+ listener();
156
+ }
157
+ };
158
+ channel.addEventListener("message", onMessage);
159
+ return () => {
160
+ channel.removeEventListener("message", onMessage);
161
+ channel.close();
162
+ };
163
+ }
164
+
165
+ async function withLocalRefreshLock(
166
+ task: () => Promise<unknown>,
167
+ ): Promise<unknown> {
168
+ if (localRefresh !== null) return await localRefresh;
169
+ const pending = task();
170
+ localRefresh = pending;
171
+ try {
172
+ return await pending;
173
+ } finally {
174
+ if (localRefresh === pending) localRefresh = null;
175
+ }
176
+ }
177
+
178
+ async function withRefreshLock(
179
+ task: () => Promise<unknown>,
180
+ injectedLock: SuiteOidcExclusiveLock | null | undefined,
181
+ ): Promise<unknown> {
182
+ if (injectedLock !== undefined) {
183
+ return injectedLock === null
184
+ ? await withLocalRefreshLock(task)
185
+ : await injectedLock(SUITE_OIDC_REFRESH_LOCK_NAME, task);
186
+ }
187
+ if (typeof navigator !== "undefined" && navigator.locks !== undefined) {
188
+ return await navigator.locks.request(
189
+ SUITE_OIDC_REFRESH_LOCK_NAME,
190
+ { mode: "exclusive" },
191
+ task,
192
+ );
193
+ }
194
+ return await withLocalRefreshLock(task);
195
+ }
196
+
197
+ /**
198
+ * Reads the product's same-origin suite session and refreshes it when needed.
199
+ *
200
+ * Web Locks serialize refresh-token rotation across tabs on the current
201
+ * product origin. The session is re-read after acquiring the lock so a waiter
202
+ * observes the cookie rotated by the preceding tab instead of replaying the
203
+ * old refresh token.
204
+ */
205
+ export async function loadSuiteOidcBrowserSession(
206
+ dependencies: SuiteOidcBrowserSessionDependencies = {},
207
+ ): Promise<unknown> {
208
+ const fetcher = dependencies.fetch ?? globalThis.fetch;
209
+ const withExclusiveLock = dependencies.withExclusiveLock;
210
+ const current = await requestJson(fetcher, SESSION_PATH, "GET");
211
+ if (!isExactRefreshRequired(current)) return current;
212
+
213
+ return await withRefreshLock(async () => {
214
+ const latest = await requestJson(fetcher, SESSION_PATH, "GET");
215
+ return isExactRefreshRequired(latest)
216
+ ? await requestJson(fetcher, REFRESH_PATH, "POST")
217
+ : latest;
218
+ }, withExclusiveLock);
219
+ }
220
+
221
+ /**
222
+ * End the sealed relying-party session inside the same exclusive section used
223
+ * for refresh-token rotation, then notify sibling tabs only after the server
224
+ * confirms that local cookie custody ended.
225
+ */
226
+ export async function signOutSuiteOidcBrowserSession(
227
+ dependencies: SuiteOidcBrowserSignOutDependencies = {},
228
+ ): Promise<void> {
229
+ const fetcher = dependencies.fetch ?? globalThis.fetch;
230
+ const notifySignedOut = dependencies.notifySignedOut
231
+ ?? broadcastSuiteOidcBrowserSignOut;
232
+ const withExclusiveLock = dependencies.withExclusiveLock;
233
+ await withRefreshLock(async () => {
234
+ const value = await requestJson(fetcher, SIGN_OUT_PATH, "POST");
235
+ if (!exactSignedOut(value)) {
236
+ throw new Error("The suite sign-out response was invalid.");
237
+ }
238
+ }, withExclusiveLock);
239
+ notifySignedOut();
240
+ }
241
+
242
+ /**
243
+ * Consume the one-shot marker the relying-party continuation page plants on
244
+ * this product origin immediately before its replace navigation. Returns true
245
+ * exactly once per completed sign-in so a product can show post-auth feedback.
246
+ */
247
+ export function consumeSuiteOidcJustSignedIn(): boolean {
248
+ try {
249
+ if (
250
+ typeof sessionStorage === "undefined"
251
+ || sessionStorage.getItem(SUITE_OIDC_JUST_SIGNED_IN_STORAGE_KEY) === null
252
+ ) {
253
+ return false;
254
+ }
255
+ sessionStorage.removeItem(SUITE_OIDC_JUST_SIGNED_IN_STORAGE_KEY);
256
+ return true;
257
+ } catch {
258
+ return false;
259
+ }
260
+ }
@@ -0,0 +1,172 @@
1
+ import { err, isRecord, ok, type Result } from "@hraness/result";
2
+
3
+ import { deepFreeze } from "./immutable.js";
4
+ import {
5
+ getSuiteAccountsCurrentConsumerEnvironment,
6
+ getSuiteAccountsCurrentConsumer,
7
+ isSuiteAccountsCurrentConsumerId,
8
+ type SuiteAccountsAuthConfiguration,
9
+ type SuiteAccountsCurrentConsumerId,
10
+ type SuiteAccountsRemoteEnvironment,
11
+ } from "./registry.js";
12
+ import {
13
+ suiteAccountsCurrentOidcClientRegistration,
14
+ suiteAccountsOidcProviderConfiguration,
15
+ type SuiteAccountsOidcProviderConfiguration,
16
+ } from "./urls.js";
17
+
18
+ export const SUITE_ACCOUNTS_CLIENT_CONFIGURATION_VERSION =
19
+ "suite-accounts-client-configuration-v1" as const;
20
+ export const SUITE_ACCOUNTS_WIRE_VERSION = "v1" as const;
21
+
22
+ export type SuiteAccountsClientBinding = Readonly<{
23
+ authMode: SuiteAccountsAuthConfiguration["kind"];
24
+ callbackUrl: string | null;
25
+ clientId: string | null;
26
+ consumer: SuiteAccountsCurrentConsumerId;
27
+ environment: SuiteAccountsRemoteEnvironment;
28
+ origin: string;
29
+ }>;
30
+
31
+ export type SuiteAccountsClientConfiguration = Readonly<{
32
+ authBasePath: SuiteAccountsAuthConfiguration["basePath"];
33
+ binding: SuiteAccountsClientBinding;
34
+ configurationVersion: typeof SUITE_ACCOUNTS_CLIENT_CONFIGURATION_VERSION;
35
+ provider: SuiteAccountsOidcProviderConfiguration;
36
+ wireVersion: typeof SUITE_ACCOUNTS_WIRE_VERSION;
37
+ }>;
38
+
39
+ export type SuiteAccountsClientConfigurationIssue =
40
+ | "invalid-auth-mode"
41
+ | "invalid-binding"
42
+ | "invalid-callback-url"
43
+ | "invalid-client-id"
44
+ | "invalid-consumer"
45
+ | "invalid-environment"
46
+ | "invalid-origin";
47
+
48
+ const BINDING_KEYS = [
49
+ "authMode",
50
+ "callbackUrl",
51
+ "clientId",
52
+ "consumer",
53
+ "environment",
54
+ "origin",
55
+ ] as const satisfies readonly (keyof SuiteAccountsClientBinding)[];
56
+
57
+ type SuiteAccountsClientBindingInput = Readonly<{
58
+ [Key in (typeof BINDING_KEYS)[number]]: unknown;
59
+ }>;
60
+
61
+ function snapshotBinding(
62
+ input: unknown,
63
+ ): SuiteAccountsClientBindingInput | null {
64
+ if (!isRecord(input)) return null;
65
+
66
+ try {
67
+ const descriptors = Object.getOwnPropertyDescriptors(input);
68
+ const keys = Reflect.ownKeys(descriptors);
69
+ if (
70
+ keys.length !== BINDING_KEYS.length
71
+ || keys.some(key =>
72
+ typeof key !== "string"
73
+ || !(BINDING_KEYS as readonly string[]).includes(key)
74
+ )
75
+ ) {
76
+ return null;
77
+ }
78
+
79
+ for (const key of BINDING_KEYS) {
80
+ const descriptor = descriptors[key];
81
+ if (descriptor === undefined || !("value" in descriptor)) return null;
82
+ }
83
+
84
+ return {
85
+ authMode: descriptors.authMode!.value,
86
+ callbackUrl: descriptors.callbackUrl!.value,
87
+ clientId: descriptors.clientId!.value,
88
+ consumer: descriptors.consumer!.value,
89
+ environment: descriptors.environment!.value,
90
+ origin: descriptors.origin!.value,
91
+ };
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ function freezeConfiguration(
98
+ binding: SuiteAccountsClientBinding,
99
+ authBasePath: SuiteAccountsAuthConfiguration["basePath"],
100
+ provider: SuiteAccountsOidcProviderConfiguration,
101
+ ): SuiteAccountsClientConfiguration {
102
+ return deepFreeze({
103
+ authBasePath,
104
+ binding,
105
+ configurationVersion: SUITE_ACCOUNTS_CLIENT_CONFIGURATION_VERSION,
106
+ provider,
107
+ wireVersion: SUITE_ACCOUNTS_WIRE_VERSION,
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Bind one client to an exact Accounts registration.
113
+ *
114
+ * The caller can state only its public binding. Issuer, JWKS, resource,
115
+ * endpoints, protocol version, and client-ID format come from the frozen
116
+ * Accounts registry and cannot be supplied or overridden.
117
+ */
118
+ export function createSuiteAccountsClientConfiguration(
119
+ input: unknown,
120
+ ): Result<
121
+ SuiteAccountsClientConfiguration,
122
+ SuiteAccountsClientConfigurationIssue
123
+ > {
124
+ const binding = snapshotBinding(input);
125
+ if (binding === null) return err("invalid-binding");
126
+ if (!isSuiteAccountsCurrentConsumerId(binding.consumer)) {
127
+ return err("invalid-consumer");
128
+ }
129
+ if (binding.environment !== "production") {
130
+ return err("invalid-environment");
131
+ }
132
+
133
+ const consumer = binding.consumer;
134
+ const environment = binding.environment;
135
+ const registration = getSuiteAccountsCurrentConsumer(consumer);
136
+ const deployed = getSuiteAccountsCurrentConsumerEnvironment(
137
+ consumer,
138
+ environment,
139
+ );
140
+ if (deployed === null || binding.origin !== deployed.siteUrl) {
141
+ return err("invalid-origin");
142
+ }
143
+ if (binding.authMode !== registration.auth.kind) {
144
+ return err("invalid-auth-mode");
145
+ }
146
+
147
+ const oauth = suiteAccountsCurrentOidcClientRegistration(
148
+ consumer,
149
+ environment,
150
+ );
151
+ const expectedClientId = oauth?.clientId ?? null;
152
+ const expectedCallbackUrl = oauth?.callbackUrl ?? null;
153
+ if (binding.clientId !== expectedClientId) {
154
+ return err("invalid-client-id");
155
+ }
156
+ if (binding.callbackUrl !== expectedCallbackUrl) {
157
+ return err("invalid-callback-url");
158
+ }
159
+
160
+ return ok(freezeConfiguration(
161
+ {
162
+ authMode: registration.auth.kind,
163
+ callbackUrl: expectedCallbackUrl,
164
+ clientId: expectedClientId,
165
+ consumer,
166
+ environment,
167
+ origin: deployed.siteUrl,
168
+ },
169
+ registration.auth.basePath,
170
+ suiteAccountsOidcProviderConfiguration(environment),
171
+ ));
172
+ }
@@ -0,0 +1,238 @@
1
+ import {
2
+ loadSuiteOidcBrowserSession,
3
+ type SuiteOidcExclusiveLock,
4
+ } from "./browser-session.js";
5
+ import {
6
+ SUITE_CONVEX_BROWSER_PARENT_REFRESH_WINDOW_MS,
7
+ SUITE_CONVEX_BROWSER_TOKEN_PATH,
8
+ } from "./convex-browser-auth.js";
9
+
10
+ const MAXIMUM_TOKEN_RESPONSE_BYTES = 20 * 1_024;
11
+ const TOKEN_RESPONSE_VERSION =
12
+ "suite-convex-browser-token-response-v1" as const;
13
+
14
+ type FetchImplementation = (
15
+ input: RequestInfo | URL,
16
+ init?: RequestInit,
17
+ ) => Promise<Response>;
18
+
19
+ export type SuiteConvexBrowserTokenLoaderDependencies = Readonly<{
20
+ fetch?: FetchImplementation;
21
+ now?: () => number;
22
+ refreshSuiteSession?: () => Promise<unknown>;
23
+ withExclusiveLock?: SuiteOidcExclusiveLock | null;
24
+ }>;
25
+
26
+ export type SuiteConvexBrowserTokenLoader = Readonly<{
27
+ clear(): void;
28
+ fetchAccessToken(): Promise<string | null>;
29
+ }>;
30
+
31
+ type TokenEndpointResult =
32
+ | Readonly<{ kind: "refresh_required" }>
33
+ | Readonly<{ kind: "signed_out" }>
34
+ | Readonly<{ expiresAtMs: number; kind: "token"; token: string }>;
35
+
36
+ function isRecord(value: unknown): value is Record<string, unknown> {
37
+ return typeof value === "object" && value !== null && !Array.isArray(value);
38
+ }
39
+
40
+ function exactKeys(
41
+ value: Record<string, unknown>,
42
+ keys: readonly string[],
43
+ ): boolean {
44
+ const actual = Object.keys(value);
45
+ return actual.length === keys.length
46
+ && keys.every(key => Object.hasOwn(value, key));
47
+ }
48
+
49
+ function compactJwt(value: unknown): value is string {
50
+ return typeof value === "string"
51
+ && value.length >= 32
52
+ && value.length <= 16_384
53
+ && /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u.test(value);
54
+ }
55
+
56
+ async function readBoundedJson(response: Response): Promise<unknown> {
57
+ const contentType = response.headers
58
+ .get("content-type")
59
+ ?.split(";", 1)[0]
60
+ ?.trim()
61
+ .toLowerCase();
62
+ const cacheControl = response.headers.get("cache-control")?.toLowerCase();
63
+ if (
64
+ contentType !== "application/json"
65
+ || cacheControl === undefined
66
+ || !cacheControl.split(",").some(value => value.trim() === "no-store")
67
+ || response.body === null
68
+ ) {
69
+ throw new Error("The Convex token response was unsafe.");
70
+ }
71
+ const declaredLength = response.headers.get("content-length");
72
+ if (
73
+ declaredLength !== null
74
+ && (
75
+ !/^(?:0|[1-9]\d*)$/u.test(declaredLength)
76
+ || Number(declaredLength) > MAXIMUM_TOKEN_RESPONSE_BYTES
77
+ )
78
+ ) {
79
+ await response.body.cancel().catch(() => undefined);
80
+ throw new Error("The Convex token response was too large.");
81
+ }
82
+ const reader = response.body.getReader();
83
+ const chunks: Uint8Array[] = [];
84
+ let length = 0;
85
+ try {
86
+ while (true) {
87
+ const result = await reader.read();
88
+ if (result.done) break;
89
+ length += result.value.byteLength;
90
+ if (length > MAXIMUM_TOKEN_RESPONSE_BYTES) {
91
+ await reader.cancel().catch(() => undefined);
92
+ throw new Error("The Convex token response was too large.");
93
+ }
94
+ chunks.push(result.value);
95
+ }
96
+ } finally {
97
+ reader.releaseLock();
98
+ }
99
+ const bytes = new Uint8Array(length);
100
+ let offset = 0;
101
+ for (const chunk of chunks) {
102
+ bytes.set(chunk, offset);
103
+ offset += chunk.byteLength;
104
+ }
105
+ return JSON.parse(
106
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes),
107
+ ) as unknown;
108
+ }
109
+
110
+ function parseEndpointResult(
111
+ value: unknown,
112
+ status: number,
113
+ nowMs: number,
114
+ ): TokenEndpointResult {
115
+ if (!isRecord(value)) {
116
+ throw new Error("The Convex token response was invalid.");
117
+ }
118
+ if (
119
+ status === 401
120
+ && exactKeys(value, ["kind"])
121
+ && value["kind"] === "signed_out"
122
+ ) {
123
+ return { kind: "signed_out" };
124
+ }
125
+ if (
126
+ status === 409
127
+ && exactKeys(value, ["kind"])
128
+ && value["kind"] === "refresh_required"
129
+ ) {
130
+ return { kind: "refresh_required" };
131
+ }
132
+ if (
133
+ status !== 200
134
+ || !exactKeys(value, ["expiresAtMs", "kind", "token", "version"])
135
+ || value["kind"] !== "token"
136
+ || value["version"] !== TOKEN_RESPONSE_VERSION
137
+ || !compactJwt(value["token"])
138
+ || typeof value["expiresAtMs"] !== "number"
139
+ || !Number.isSafeInteger(value["expiresAtMs"])
140
+ || value["expiresAtMs"] <= nowMs
141
+ || value["expiresAtMs"] > nowMs + 5 * 60_000
142
+ ) {
143
+ throw new Error("The Convex token response was invalid.");
144
+ }
145
+ return {
146
+ expiresAtMs: value["expiresAtMs"],
147
+ kind: "token",
148
+ token: value["token"],
149
+ };
150
+ }
151
+
152
+ async function requestToken(
153
+ fetcher: FetchImplementation,
154
+ now: () => number,
155
+ ): Promise<TokenEndpointResult> {
156
+ const response = await fetcher(SUITE_CONVEX_BROWSER_TOKEN_PATH, {
157
+ cache: "no-store",
158
+ credentials: "same-origin",
159
+ headers: { accept: "application/json" },
160
+ method: "POST",
161
+ redirect: "error",
162
+ });
163
+ return parseEndpointResult(
164
+ await readBoundedJson(response),
165
+ response.status,
166
+ now(),
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Create a memory-only Convex bearer loader for `ConvexProviderWithAuth`.
172
+ *
173
+ * Concurrent calls share one in-flight exchange. A near-expiry parent session
174
+ * runs the existing origin-locked RP refresh path, then retries exactly once.
175
+ * No suite access or refresh token reaches this module or its return value.
176
+ */
177
+ export function createSuiteConvexBrowserTokenLoader(
178
+ dependencies: SuiteConvexBrowserTokenLoaderDependencies = {},
179
+ ): SuiteConvexBrowserTokenLoader {
180
+ const fetcher = dependencies.fetch ?? globalThis.fetch;
181
+ const now = dependencies.now ?? Date.now;
182
+ const withExclusiveLock = dependencies.withExclusiveLock;
183
+ const refreshSuiteSession = dependencies.refreshSuiteSession
184
+ ?? (() => loadSuiteOidcBrowserSession({
185
+ fetch: fetcher,
186
+ ...(withExclusiveLock === undefined
187
+ ? {}
188
+ : { withExclusiveLock }),
189
+ }));
190
+ let cached: Readonly<{ expiresAtMs: number; token: string }> | null = null;
191
+ let pending: Promise<string | null> | null = null;
192
+ let custodyGeneration = 0;
193
+
194
+ async function exchange(generation: number): Promise<string | null> {
195
+ const first = await requestToken(fetcher, now);
196
+ const result = first.kind === "refresh_required"
197
+ ? await (async () => {
198
+ await refreshSuiteSession();
199
+ return await requestToken(fetcher, now);
200
+ })()
201
+ : first;
202
+ if (result.kind === "refresh_required") {
203
+ throw new Error("The suite session still requires refresh.");
204
+ }
205
+ if (result.kind === "signed_out") {
206
+ if (custodyGeneration === generation) cached = null;
207
+ return null;
208
+ }
209
+ if (custodyGeneration !== generation) return null;
210
+ cached = { expiresAtMs: result.expiresAtMs, token: result.token };
211
+ return result.token;
212
+ }
213
+
214
+ return Object.freeze({
215
+ clear() {
216
+ custodyGeneration += 1;
217
+ cached = null;
218
+ },
219
+ async fetchAccessToken() {
220
+ const nowMs = now();
221
+ if (
222
+ cached !== null
223
+ && cached.expiresAtMs - nowMs
224
+ >= SUITE_CONVEX_BROWSER_PARENT_REFRESH_WINDOW_MS
225
+ ) {
226
+ return cached.token;
227
+ }
228
+ if (pending !== null) return await pending;
229
+ const request = exchange(custodyGeneration);
230
+ pending = request;
231
+ try {
232
+ return await request;
233
+ } finally {
234
+ if (pending === request) pending = null;
235
+ }
236
+ },
237
+ });
238
+ }