@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,37 @@
1
+ "use client";
2
+
3
+ import { convexClient } from "@convex-dev/better-auth/client/plugins";
4
+ import { emailOTPClient } from "better-auth/client/plugins";
5
+ import { createAuthClient } from "better-auth/react";
6
+
7
+ export type SuiteAccountsAuthClientOptions = Readonly<{
8
+ basePath?: "/api/auth" | "/api/suite-auth";
9
+ }>;
10
+
11
+ /**
12
+ * Construct the frozen Better Auth compatibility client used by the Accounts
13
+ * authority and its registered proxy.
14
+ *
15
+ * @deprecated New OAuth consumers use the validated client configuration and
16
+ * the OIDC relying-party boundary. Do not add another provider plugin here.
17
+ */
18
+ export function createSuiteAccountsAuthClient(
19
+ options: SuiteAccountsAuthClientOptions = {},
20
+ ) {
21
+ return createAuthClient({
22
+ basePath: options.basePath ?? "/api/auth",
23
+ plugins: [convexClient(), emailOTPClient()],
24
+ });
25
+ }
26
+
27
+ export type SuiteAccountsAuthClient =
28
+ ReturnType<typeof createSuiteAccountsAuthClient>;
29
+
30
+ /**
31
+ * The default same-origin `/api/auth` client used by Accounts and registered proxies.
32
+ *
33
+ * Products that retain a local `/api/auth` authority create one client with
34
+ * `createSuiteAccountsAuthClient({ basePath: "/api/suite-auth" })`.
35
+ */
36
+ /** @deprecated Retained for the frozen authority and proxy React surface. */
37
+ export const suiteAccountsAuthClient = createSuiteAccountsAuthClient();
@@ -0,0 +1,473 @@
1
+ import {
2
+ getSuiteAccountsCurrentConsumer,
3
+ getSuiteAccountsConsumer,
4
+ isSuiteAccountsCurrentConsumerId,
5
+ type SuiteAccountsCookieCapabilities,
6
+ type SuiteAccountsCookieName,
7
+ } from "./registry.js";
8
+ import {
9
+ suiteAccountsPublicConfigFromEnvironment,
10
+ type ReadySuiteAccountsPublicConfig,
11
+ type SuiteAccountsPublicConfig,
12
+ type SuiteAccountsPublicEnvironment,
13
+ } from "./public-config.js";
14
+ import { deepFreeze } from "./immutable.js";
15
+
16
+ const FORWARDED_REQUEST_HEADERS = [
17
+ "accept",
18
+ "accept-language",
19
+ "content-type",
20
+ "sec-fetch-dest",
21
+ "sec-fetch-mode",
22
+ "sec-fetch-site",
23
+ "sec-fetch-user",
24
+ ] as const;
25
+
26
+ const FORWARDED_RESPONSE_HEADERS = [
27
+ "content-language",
28
+ "content-type",
29
+ "vary",
30
+ ] as const;
31
+
32
+ const MAX_AUTH_REQUEST_BYTES = 1_048_576;
33
+
34
+ type BoundedRequestBody =
35
+ | Readonly<{ bytes: ArrayBuffer; kind: "ok" }>
36
+ | Readonly<{ kind: "too_large" }>;
37
+
38
+ type FetchImplementation = (
39
+ input: RequestInfo | URL,
40
+ init?: RequestInit,
41
+ ) => Promise<Response>;
42
+
43
+ function errorResponse(
44
+ code: string,
45
+ message: string,
46
+ status: number,
47
+ retryable = false,
48
+ ): Response {
49
+ return Response.json(
50
+ {
51
+ error: { code, message, retryable },
52
+ schemaVersion: 1,
53
+ },
54
+ {
55
+ headers: {
56
+ "cache-control": "no-store",
57
+ pragma: "no-cache",
58
+ },
59
+ status,
60
+ },
61
+ );
62
+ }
63
+
64
+ function unavailable(): Promise<Response> {
65
+ return Promise.resolve(errorResponse(
66
+ "SERVICE_NOT_CONFIGURED",
67
+ "Suite Accounts authentication is not configured.",
68
+ 503,
69
+ ));
70
+ }
71
+
72
+ function cookiePrefix(config: ReadySuiteAccountsPublicConfig): string {
73
+ return config.environment === "local"
74
+ ? "cclrte-local"
75
+ : "__Host-cclrte";
76
+ }
77
+
78
+ function cookieCapabilities(
79
+ config: ReadySuiteAccountsPublicConfig,
80
+ ): SuiteAccountsCookieCapabilities {
81
+ const auth = isSuiteAccountsCurrentConsumerId(config.consumer)
82
+ ? getSuiteAccountsCurrentConsumer(config.consumer).auth
83
+ : getSuiteAccountsConsumer(config.consumer).auth;
84
+ if (auth.kind !== "proxy") {
85
+ throw new Error("Only a registered suite auth proxy may forward cookies.");
86
+ }
87
+ return auth.cookies;
88
+ }
89
+
90
+ function cookieSuffix(
91
+ name: string,
92
+ config: ReadySuiteAccountsPublicConfig,
93
+ ): string | null {
94
+ const prefix = `${cookiePrefix(config)}.`;
95
+ return name.startsWith(prefix) ? name.slice(prefix.length) : null;
96
+ }
97
+
98
+ function enabledCookieName(
99
+ name: string,
100
+ config: ReadySuiteAccountsPublicConfig,
101
+ ): boolean {
102
+ const suffix = cookieSuffix(name, config);
103
+ if (suffix === null) return false;
104
+ const capabilities = cookieCapabilities(config);
105
+ if ((capabilities.names as readonly string[]).includes(suffix)) return true;
106
+ const separator = suffix.lastIndexOf(".");
107
+ if (separator < 1) return false;
108
+ const base = suffix.slice(0, separator) as SuiteAccountsCookieName;
109
+ const chunk = suffix.slice(separator + 1);
110
+ return (capabilities.chunked as readonly string[]).includes(base)
111
+ && /^(?:0|[1-9]\d?)$/u.test(chunk);
112
+ }
113
+
114
+ function matchingOwnedCookiePrefix(
115
+ name: string,
116
+ config: ReadySuiteAccountsPublicConfig,
117
+ ): boolean {
118
+ return cookieSuffix(name, config) !== null;
119
+ }
120
+
121
+ function containsCookieControl(value: string): boolean {
122
+ for (let index = 0; index < value.length; index += 1) {
123
+ const code = value.charCodeAt(index);
124
+ if (code <= 31 || code === 127) return true;
125
+ }
126
+ return false;
127
+ }
128
+
129
+ export function filteredSuiteAuthCookieHeader(
130
+ cookieHeader: string | null,
131
+ config: ReadySuiteAccountsPublicConfig,
132
+ ): string | null {
133
+ if (cookieHeader === null) return null;
134
+ const cookies: string[] = [];
135
+ for (const segment of cookieHeader.split(";")) {
136
+ const trimmed = segment.trim();
137
+ const separator = trimmed.indexOf("=");
138
+ if (separator <= 0) continue;
139
+ const name = trimmed.slice(0, separator).trim();
140
+ const value = trimmed.slice(separator + 1).trim();
141
+ if (
142
+ enabledCookieName(name, config)
143
+ && !containsCookieControl(value)
144
+ && !value.includes(";")
145
+ ) {
146
+ cookies.push(`${name}=${value}`);
147
+ }
148
+ }
149
+ return cookies.length === 0 ? null : cookies.join("; ");
150
+ }
151
+
152
+ function requestIsAllowed(
153
+ request: Request,
154
+ config: ReadySuiteAccountsPublicConfig,
155
+ ): boolean {
156
+ let incoming: URL;
157
+ try {
158
+ incoming = new URL(request.url);
159
+ } catch {
160
+ return false;
161
+ }
162
+ const onAuthPath = incoming.pathname === config.authBasePath
163
+ || incoming.pathname.startsWith(`${config.authBasePath}/`);
164
+ if (incoming.origin !== config.siteUrl || !onAuthPath) return false;
165
+
166
+ const origin = request.headers.get("origin");
167
+ if (
168
+ (origin !== null && origin !== config.siteUrl)
169
+ || (request.method === "POST" && origin !== config.siteUrl)
170
+ ) {
171
+ return false;
172
+ }
173
+ const fetchSite = request.headers.get("sec-fetch-site");
174
+ return fetchSite === null || fetchSite.toLowerCase() === "same-origin";
175
+ }
176
+
177
+ function forwardedRequestHeaders(
178
+ request: Request,
179
+ config: ReadySuiteAccountsPublicConfig,
180
+ ): Headers {
181
+ const headers = new Headers();
182
+ headers.set("origin", config.siteUrl);
183
+ for (const name of FORWARDED_REQUEST_HEADERS) {
184
+ const value = request.headers.get(name);
185
+ if (value !== null) headers.set(name, value);
186
+ }
187
+ const cookie = filteredSuiteAuthCookieHeader(
188
+ request.headers.get("cookie"),
189
+ config,
190
+ );
191
+ if (cookie !== null) headers.set("cookie", cookie);
192
+ return headers;
193
+ }
194
+
195
+ function splitSetCookieHeader(value: string): string[] {
196
+ return value
197
+ .split(/,(?=\s*[!#$%&'*+\-.^_`|~0-9A-Za-z]+=)/u)
198
+ .map(cookie => cookie.trim())
199
+ .filter(cookie => cookie !== "");
200
+ }
201
+
202
+ function responseCookies(headers: Headers): readonly string[] {
203
+ const cookieHeaders = headers as Headers & {
204
+ getSetCookie?: () => string[];
205
+ };
206
+ const discrete = cookieHeaders.getSetCookie?.();
207
+ if (discrete !== undefined) return discrete;
208
+ const combined = headers.get("set-cookie");
209
+ return combined === null ? [] : splitSetCookieHeader(combined);
210
+ }
211
+
212
+ function cookieName(cookie: string): string | null {
213
+ if (/[\r\n]/u.test(cookie)) return null;
214
+ const pair = cookie.split(";", 1)[0]?.trim();
215
+ const separator = pair?.indexOf("=") ?? -1;
216
+ if (pair === undefined || separator <= 0) return null;
217
+ const name = pair.slice(0, separator).trim();
218
+ return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u.test(name) ? name : null;
219
+ }
220
+
221
+ function validateOwnedCookie(
222
+ cookie: string,
223
+ config: ReadySuiteAccountsPublicConfig,
224
+ ): boolean {
225
+ const parts = cookie.split(";").map(value => value.trim());
226
+ parts.shift();
227
+ const attributes = new Map<string, string | null>();
228
+ for (const part of parts) {
229
+ if (part === "") continue;
230
+ const separator = part.indexOf("=");
231
+ const name = (separator === -1 ? part : part.slice(0, separator))
232
+ .trim()
233
+ .toLowerCase();
234
+ const value = separator === -1
235
+ ? null
236
+ : part.slice(separator + 1).trim();
237
+ if (name === "" || attributes.has(name)) return false;
238
+ attributes.set(name, value);
239
+ }
240
+ const secure = config.environment === "local"
241
+ ? !attributes.has("secure")
242
+ : attributes.has("secure") && attributes.get("secure") === null;
243
+ return (
244
+ attributes.has("httponly")
245
+ && attributes.get("httponly") === null
246
+ && attributes.get("path") === "/"
247
+ && attributes.get("samesite")?.toLowerCase() === "lax"
248
+ && !attributes.has("domain")
249
+ && secure
250
+ );
251
+ }
252
+
253
+ function isSafeCookieDeletion(
254
+ cookie: string,
255
+ config: ReadySuiteAccountsPublicConfig,
256
+ ): boolean {
257
+ const pair = cookie.split(";", 1)[0]?.trim();
258
+ const separator = pair?.indexOf("=") ?? -1;
259
+ if (
260
+ pair === undefined
261
+ || separator <= 0
262
+ || pair.slice(separator + 1) !== ""
263
+ || !validateOwnedCookie(cookie, config)
264
+ ) {
265
+ return false;
266
+ }
267
+ const maxAge = cookie.split(";").slice(1).find((attribute) => {
268
+ const [name] = attribute.trim().split("=", 1);
269
+ return name?.toLowerCase() === "max-age";
270
+ });
271
+ return maxAge?.trim().toLowerCase() === "max-age=0";
272
+ }
273
+
274
+ function validLocation(
275
+ location: string,
276
+ config: ReadySuiteAccountsPublicConfig,
277
+ ): boolean {
278
+ if (/[\r\n]/u.test(location) || location.startsWith("//")) return false;
279
+ let url: URL;
280
+ try {
281
+ url = new URL(location, config.siteUrl);
282
+ } catch {
283
+ return false;
284
+ }
285
+ return url.origin === config.siteUrl
286
+ && url.username === ""
287
+ && url.password === "";
288
+ }
289
+
290
+ function safeUpstreamResponse(
291
+ upstream: Response,
292
+ config: ReadySuiteAccountsPublicConfig,
293
+ ): Response {
294
+ const headers = new Headers();
295
+ for (const name of FORWARDED_RESPONSE_HEADERS) {
296
+ const value = upstream.headers.get(name);
297
+ if (value !== null) headers.set(name, value);
298
+ }
299
+ headers.set("cache-control", "no-store");
300
+ headers.set("pragma", "no-cache");
301
+
302
+ const location = upstream.headers.get("location");
303
+ if (location !== null) {
304
+ if (!validLocation(location, config)) {
305
+ return errorResponse(
306
+ "INVALID_AUTH_UPSTREAM_RESPONSE",
307
+ "Suite Accounts returned an unsafe redirect.",
308
+ 502,
309
+ true,
310
+ );
311
+ }
312
+ headers.set("location", location);
313
+ }
314
+ for (const cookie of responseCookies(upstream.headers)) {
315
+ const name = cookieName(cookie);
316
+ if (name === null) {
317
+ return errorResponse(
318
+ "INVALID_AUTH_UPSTREAM_RESPONSE",
319
+ "Suite Accounts returned a malformed cookie.",
320
+ 502,
321
+ true,
322
+ );
323
+ }
324
+ if (!matchingOwnedCookiePrefix(name, config)) continue;
325
+ if (!enabledCookieName(name, config)) {
326
+ if (isSafeCookieDeletion(cookie, config)) continue;
327
+ return errorResponse(
328
+ "INVALID_AUTH_UPSTREAM_RESPONSE",
329
+ "Suite Accounts returned an unsafe session cookie.",
330
+ 502,
331
+ true,
332
+ );
333
+ }
334
+ if (!validateOwnedCookie(cookie, config)) {
335
+ return errorResponse(
336
+ "INVALID_AUTH_UPSTREAM_RESPONSE",
337
+ "Suite Accounts returned an unsafe session cookie.",
338
+ 502,
339
+ true,
340
+ );
341
+ }
342
+ headers.append("set-cookie", cookie);
343
+ }
344
+ return new Response(upstream.body, {
345
+ headers,
346
+ status: upstream.status,
347
+ statusText: upstream.statusText,
348
+ });
349
+ }
350
+
351
+ function upstreamPath(
352
+ incoming: URL,
353
+ config: ReadySuiteAccountsPublicConfig,
354
+ ): string {
355
+ if (config.authBasePath === null) {
356
+ throw new Error("A CLI auth consumer has no proxy route.");
357
+ }
358
+ const suffix = incoming.pathname.slice(config.authBasePath.length);
359
+ return `/api/auth${suffix}${incoming.search}`;
360
+ }
361
+
362
+ async function readBoundedRequestBody(
363
+ request: Request,
364
+ maximumBytes: number,
365
+ ): Promise<BoundedRequestBody> {
366
+ if (request.body === null) {
367
+ return { bytes: new ArrayBuffer(0), kind: "ok" };
368
+ }
369
+ const reader = request.body.getReader();
370
+ const chunks: Uint8Array[] = [];
371
+ let length = 0;
372
+ while (true) {
373
+ const result = await reader.read();
374
+ if (result.done) break;
375
+ if (result.value.byteLength > maximumBytes - length) {
376
+ await reader.cancel();
377
+ return { kind: "too_large" };
378
+ }
379
+ length += result.value.byteLength;
380
+ chunks.push(result.value);
381
+ }
382
+ const bytes = new Uint8Array(length);
383
+ let offset = 0;
384
+ for (const chunk of chunks) {
385
+ bytes.set(chunk, offset);
386
+ offset += chunk.byteLength;
387
+ }
388
+ return { bytes: bytes.buffer, kind: "ok" };
389
+ }
390
+
391
+ async function proxyRequest(
392
+ request: Request,
393
+ config: ReadySuiteAccountsPublicConfig,
394
+ fetchImplementation: FetchImplementation,
395
+ ): Promise<Response> {
396
+ if (!requestIsAllowed(request, config)) {
397
+ return errorResponse(
398
+ "AUTH_PROXY_REQUEST_REJECTED",
399
+ "The authentication request did not come from this environment.",
400
+ 403,
401
+ );
402
+ }
403
+ const declaredLength = request.headers.get("content-length");
404
+ if (
405
+ declaredLength !== null
406
+ && (!/^(?:0|[1-9]\d*)$/u.test(declaredLength)
407
+ || Number(declaredLength) > MAX_AUTH_REQUEST_BYTES)
408
+ ) {
409
+ return errorResponse(
410
+ "AUTH_REQUEST_TOO_LARGE",
411
+ "The authentication request was too large.",
412
+ 413,
413
+ );
414
+ }
415
+ const incoming = new URL(request.url);
416
+ const init: RequestInit = {
417
+ headers: forwardedRequestHeaders(request, config),
418
+ method: request.method,
419
+ redirect: "manual",
420
+ };
421
+ if (request.method !== "GET" && request.method !== "HEAD") {
422
+ const body = await readBoundedRequestBody(request, MAX_AUTH_REQUEST_BYTES);
423
+ if (body.kind === "too_large") {
424
+ return errorResponse(
425
+ "AUTH_REQUEST_TOO_LARGE",
426
+ "The authentication request was too large.",
427
+ 413,
428
+ );
429
+ }
430
+ if (body.bytes.byteLength > 0) init.body = body.bytes;
431
+ }
432
+ try {
433
+ const upstream = await fetchImplementation(
434
+ new URL(upstreamPath(incoming, config), config.convexSiteUrl),
435
+ init,
436
+ );
437
+ return safeUpstreamResponse(upstream, config);
438
+ } catch {
439
+ return errorResponse(
440
+ "AUTH_UPSTREAM_UNAVAILABLE",
441
+ "Suite Accounts authentication is temporarily unavailable.",
442
+ 502,
443
+ true,
444
+ );
445
+ }
446
+ }
447
+
448
+ export function suiteAccountsAuthServerForConfig(
449
+ config: SuiteAccountsPublicConfig,
450
+ fetchImplementation: FetchImplementation = fetch,
451
+ ) {
452
+ if (config.kind !== "ready" || config.authMode !== "proxy") {
453
+ return deepFreeze({ handler: { GET: unavailable, POST: unavailable } });
454
+ }
455
+ const ownedConfig = deepFreeze({ ...config });
456
+ return deepFreeze({
457
+ handler: {
458
+ GET: async (request: Request) =>
459
+ await proxyRequest(request, ownedConfig, fetchImplementation),
460
+ POST: async (request: Request) =>
461
+ await proxyRequest(request, ownedConfig, fetchImplementation),
462
+ },
463
+ });
464
+ }
465
+
466
+ export function suiteAccountsAuthHandler(
467
+ consumer: ReadySuiteAccountsPublicConfig["consumer"],
468
+ environment?: SuiteAccountsPublicEnvironment,
469
+ ) {
470
+ return suiteAccountsAuthServerForConfig(
471
+ suiteAccountsPublicConfigFromEnvironment(consumer, environment),
472
+ ).handler;
473
+ }