@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.
- package/LICENSE +21 -0
- package/README.md +496 -0
- package/compiler-foundation.css +1 -0
- package/dist/auth-client.js +16 -0
- package/dist/auth-proxy.js +757 -0
- package/dist/bearer-verifier.js +1119 -0
- package/dist/browser-session.js +208 -0
- package/dist/client-configuration.js +470 -0
- package/dist/convex-browser-auth-browser.js +923 -0
- package/dist/convex-browser-auth-server.js +846 -0
- package/dist/convex-browser-auth.js +597 -0
- package/dist/entitlements.js +502 -0
- package/dist/identity/functions.js +44 -0
- package/dist/identity/index.js +1716 -0
- package/dist/identity/return-targets.js +33 -0
- package/dist/index.js +1133 -0
- package/dist/oidc-device-code.js +253 -0
- package/dist/oidc-rp.js +2259 -0
- package/dist/oidc-session-policy.js +5 -0
- package/dist/oidc-surface-server.js +2332 -0
- package/dist/profile-form.js +1934 -0
- package/dist/profile.js +17 -0
- package/dist/public-config.js +462 -0
- package/dist/react.js +137 -0
- package/dist/receipt-verifier.js +619 -0
- package/dist/registry.js +327 -0
- package/dist/stylex-manifest.json +1 -0
- package/dist/stylex.css +435 -0
- package/dist/urls.js +391 -0
- package/package.json +263 -0
- package/portfolio-inventory.json +37 -0
- package/src/auth-client.ts +37 -0
- package/src/auth-proxy.ts +473 -0
- package/src/bearer-verifier.ts +478 -0
- package/src/browser-session.ts +260 -0
- package/src/client-configuration.ts +172 -0
- package/src/convex-browser-auth-browser.ts +238 -0
- package/src/convex-browser-auth-server.ts +492 -0
- package/src/convex-browser-auth.ts +208 -0
- package/src/convex-url.ts +74 -0
- package/src/entitlements.ts +317 -0
- package/src/identity/catalog.ts +227 -0
- package/src/identity/consumers.ts +53 -0
- package/src/identity/functions.ts +70 -0
- package/src/identity/identifiers.ts +82 -0
- package/src/identity/index.ts +10 -0
- package/src/identity/links.ts +280 -0
- package/src/identity/membership.ts +156 -0
- package/src/identity/principals.ts +301 -0
- package/src/identity/profiles-v2.ts +330 -0
- package/src/identity/profiles.ts +524 -0
- package/src/identity/return-targets.ts +20 -0
- package/src/identity/usernames.ts +101 -0
- package/src/identity/views.ts +289 -0
- package/src/immutable.ts +25 -0
- package/src/index.ts +7 -0
- package/src/oidc-continuation.ts +59 -0
- package/src/oidc-device-code.ts +401 -0
- package/src/oidc-rp.ts +2217 -0
- package/src/oidc-session-policy.ts +6 -0
- package/src/oidc-surface-server.ts +186 -0
- package/src/profile-form.css +1 -0
- package/src/profile-form.stylex.ts +226 -0
- package/src/profile-form.tsx +314 -0
- package/src/profile.ts +33 -0
- package/src/public-config.ts +294 -0
- package/src/public-profile-form-result.ts +82 -0
- package/src/public-profile-form.tsx +319 -0
- package/src/public-profile-links.ts +100 -0
- package/src/react.tsx +196 -0
- package/src/receipt-verifier.ts +273 -0
- package/src/registry.ts +600 -0
- 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
|
+
}
|