@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
package/src/oidc-rp.ts ADDED
@@ -0,0 +1,2217 @@
1
+ import {
2
+ parseSuiteCatalogRevision,
3
+ parseSuiteFeatureId,
4
+ type SuiteFeatureId,
5
+ } from "./identity/catalog.js";
6
+ import {
7
+ parseSuiteAccountId,
8
+ type SuiteAccountId,
9
+ } from "./identity/identifiers.js";
10
+ import {
11
+ type ProductLinkProof,
12
+ type SuiteEntitlementReceipt,
13
+ type SuiteLinkReceipt,
14
+ validateProductLinkProof,
15
+ validateSuiteEntitlementReceipt,
16
+ validateSuiteLinkReceipt,
17
+ } from "./identity/links.js";
18
+ import { parseSuiteJwtClaims } from "./identity/principals.js";
19
+ import {
20
+ parseSuiteUsername,
21
+ type SuiteUsername,
22
+ } from "./identity/usernames.js";
23
+ import {
24
+ createLocalJWKSet,
25
+ decodeProtectedHeader,
26
+ jwtVerify,
27
+ type JSONWebKeySet,
28
+ type JWTPayload,
29
+ } from "jose";
30
+
31
+ import {
32
+ SUITE_ENTITLEMENT_MAX_PROVIDER_AGE_MS,
33
+ verifySuiteEntitlementToken,
34
+ type SuiteEntitlementsClaim,
35
+ type VerifiedSuiteEntitlements,
36
+ } from "./entitlements.js";
37
+ import {
38
+ getSuiteAccountsCurrentConsumerEnvironment,
39
+ isSuiteAccountsCurrentLinkedOidcConsumerId,
40
+ suiteAccountsCurrentConsumerRequiresEmailOtp,
41
+ type SuiteAccountsCurrentLinkedOidcConsumerId,
42
+ type SuiteAccountsCurrentOidcConsumerId,
43
+ type SuiteAccountsRemoteEnvironment,
44
+ } from "./registry.js";
45
+ import {
46
+ suiteAccountsCurrentOidcClientRegistration,
47
+ suiteAccountsOidcProviderConfiguration,
48
+ type SuiteAccountsOidcProviderConfiguration,
49
+ } from "./urls.js";
50
+ import { deepFreeze } from "./immutable.js";
51
+ import { createOidcContinuationResponse } from "./oidc-continuation.js";
52
+ import { SUITE_OIDC_EARLY_REFRESH_WINDOW_MS } from "./oidc-session-policy.js";
53
+
54
+ const TRANSACTION_TTL_MS = 10 * 60_000;
55
+ const SESSION_TTL_MS = 7 * 24 * 60 * 60_000;
56
+ const FETCH_TIMEOUT_MS = 8_000;
57
+ const MAX_PROVIDER_BODY_BYTES = 64 * 1_024;
58
+ const MAX_COOKIE_BYTES = 4_096;
59
+ const MAX_RETURN_PATH_BYTES = 1_024;
60
+ const MAX_CODE_BYTES = 2_048;
61
+ const ALLOWED_TOKEN_ALGORITHMS = ["ES256"] as const;
62
+ const REMOTE_TRANSACTION_COOKIE = "__Host-hraness-suite-oidc-transaction";
63
+ const REMOTE_SESSION_COOKIE = "__Host-hraness-suite-oidc-session";
64
+ const LOCAL_TRANSACTION_COOKIE = "hraness-suite-oidc-local-transaction";
65
+ const LOCAL_SESSION_COOKIE = "hraness-suite-oidc-local-session";
66
+
67
+ export type SuiteOidcConsumer = SuiteAccountsCurrentOidcConsumerId;
68
+ type SuiteOidcReceiptConsumer = SuiteAccountsCurrentLinkedOidcConsumerId;
69
+ type FetchImplementation = (
70
+ input: RequestInfo | URL,
71
+ init?: RequestInit,
72
+ ) => Promise<Response>;
73
+
74
+ function isReceiptConsumer(
75
+ consumer: SuiteOidcConsumer,
76
+ ): consumer is SuiteOidcReceiptConsumer {
77
+ return isSuiteAccountsCurrentLinkedOidcConsumerId(consumer);
78
+ }
79
+
80
+ type TransactionBase = Readonly<{
81
+ consumer: SuiteOidcConsumer;
82
+ environment: SuiteAccountsRemoteEnvironment;
83
+ expiresAtMs: number;
84
+ issuedAtMs: number;
85
+ nonce: string;
86
+ returnTo: string;
87
+ state: string;
88
+ verifier: string;
89
+ }>;
90
+
91
+ type Transaction = TransactionBase & (
92
+ | Readonly<{ version: 1 }>
93
+ | Readonly<{ version: 2; context: string }>
94
+ );
95
+
96
+ /** Opaque server-owned action context, never copied from browser parameters. */
97
+ export type SuiteOidcFreshAuthenticationInput = Readonly<{
98
+ context: string;
99
+ expiresAtMs: number;
100
+ /**
101
+ * Optional reuse window floor. When present, the authorization request sets
102
+ * a positive OIDC `max_age` so the provider may satisfy `prompt=login` with
103
+ * a live session whose authentication is at or after this instant instead
104
+ * of forcing another interactive sign-in. Omitted means always re-auth.
105
+ */
106
+ authenticationNotBeforeMs?: number;
107
+ }>;
108
+
109
+ /** Verified evidence only. The product must durably consume it before authorizing. */
110
+ export type SuiteOidcFreshAuthentication = Readonly<{
111
+ authenticatedAtMs: number;
112
+ context: string;
113
+ expiresAtMs: number;
114
+ startedAtMs: number;
115
+ suiteAccountId: SuiteAccountId;
116
+ }>;
117
+
118
+ export type SuiteOidcFreshAuthenticationResult =
119
+ | Readonly<{
120
+ kind: "authenticated";
121
+ authentication: SuiteOidcFreshAuthentication;
122
+ response: Response;
123
+ }>
124
+ | Readonly<{ kind: "rejected"; response: Response }>;
125
+
126
+ type AuthorizationResult = SuiteOidcFreshAuthenticationResult
127
+ | Readonly<{ kind: "ordinary"; response: Response }>;
128
+
129
+ type SuiteOidcProfile =
130
+ | Readonly<{
131
+ profileComplete: false;
132
+ profileRevision: "username-v1" | null;
133
+ username: null;
134
+ }>
135
+ | Readonly<{
136
+ profileComplete: true;
137
+ profileRevision: "username-v1";
138
+ username: SuiteUsername;
139
+ }>;
140
+
141
+ type StoredSession = Readonly<{
142
+ accessToken: string;
143
+ audience: string;
144
+ entitlements: VerifiedSuiteEntitlements;
145
+ environment: SuiteAccountsRemoteEnvironment;
146
+ expiresAtMs: number;
147
+ accessTokenExpiresAtMs: number;
148
+ issuer: string;
149
+ nonce: string;
150
+ pendingEntitlementReceipt: SuiteEntitlementReceipt | null;
151
+ refreshToken: string;
152
+ subject: string;
153
+ suiteAccountId: SuiteAccountId;
154
+ version: 2;
155
+ }> & SuiteOidcProfile;
156
+
157
+ type SuiteOidcSessionViewBase = Readonly<{
158
+ entitlementReceipt: SuiteEntitlementReceipt | null;
159
+ entitlements: Readonly<{
160
+ features: readonly SuiteFeatureId[];
161
+ kind: "fresh" | "legacy" | "stale";
162
+ }>;
163
+ suiteAccountId: SuiteAccountId;
164
+ }>;
165
+
166
+ export type SuiteOidcSessionView =
167
+ SuiteOidcSessionViewBase & SuiteOidcProfile;
168
+
169
+ /**
170
+ * Server-only identity projected from a verified, unexpired RP session.
171
+ *
172
+ * The access token remains inside this accessor and the encrypted HttpOnly
173
+ * cookie. It is never part of the public `/session` response.
174
+ */
175
+ export type SuiteOidcServerAccountSession = Readonly<{
176
+ accessToken: string;
177
+ accessTokenExpiresAtMs: number;
178
+ suiteAccountId: SuiteAccountId;
179
+ }>;
180
+
181
+ export type SuiteOidcServerSession = SuiteOidcServerAccountSession & Readonly<{
182
+ username: SuiteUsername;
183
+ }>;
184
+
185
+ /**
186
+ * Canonical account email projected only to trusted product server code.
187
+ *
188
+ * This value is fetched from the live Suite userinfo response. It is never
189
+ * stored in the relying-party cookie or included in the browser session view,
190
+ * and it does not require optional username onboarding to be complete.
191
+ */
192
+ export type SuiteOidcServerVerifiedAccountEmail = Readonly<{
193
+ accessTokenExpiresAtMs: number;
194
+ email: string;
195
+ suiteAccountId: SuiteAccountId;
196
+ }>;
197
+
198
+ /** Completed-profile email projection with the permanent Suite username. */
199
+ export type SuiteOidcServerVerifiedEmail =
200
+ SuiteOidcServerVerifiedAccountEmail & Readonly<{
201
+ username: SuiteUsername;
202
+ }>;
203
+
204
+ export type SuiteOidcRelyingPartyOptions = Readonly<{
205
+ consumer: SuiteOidcConsumer;
206
+ cookieSecret: string;
207
+ environment: SuiteAccountsRemoteEnvironment;
208
+ fetch?: FetchImplementation;
209
+ fetchTimeoutMs?: number;
210
+ now?: () => number;
211
+ randomBytes?: (length: number) => Uint8Array;
212
+ receiptKeyVersion: string;
213
+ }>;
214
+
215
+ export type SuiteOidcRelyingParty = Readonly<{
216
+ callback(request: Request): Promise<Response>;
217
+ completeFreshAuthentication(
218
+ request: Request,
219
+ ): Promise<SuiteOidcFreshAuthenticationResult>;
220
+ acknowledgeEntitlementReceipt(request: Request): Promise<Response>;
221
+ configuration: Readonly<{
222
+ callbackUrl: string;
223
+ clientId: string;
224
+ provider: SuiteAccountsOidcProviderConfiguration;
225
+ siteUrl: string;
226
+ }>;
227
+ currentSession(request: Request): Promise<Response>;
228
+ handle(request: Request): Promise<Response>;
229
+ linkReceipt(request: Request): Promise<Response>;
230
+ refreshSession(request: Request): Promise<Response>;
231
+ serverAccountSession(
232
+ request: Request,
233
+ ): Promise<SuiteOidcServerAccountSession | null>;
234
+ serverVerifiedAccountEmail(
235
+ request: Request,
236
+ ): Promise<SuiteOidcServerVerifiedAccountEmail | null>;
237
+ serverSession(request: Request): Promise<SuiteOidcServerSession | null>;
238
+ serverVerifiedEmail(
239
+ request: Request,
240
+ ): Promise<SuiteOidcServerVerifiedEmail | null>;
241
+ signOut(request: Request): Promise<Response>;
242
+ start(request: Request): Promise<Response>;
243
+ startFreshAuthentication(request: Request, input: unknown): Promise<Response>;
244
+ }>;
245
+
246
+ type TokenResponse = Readonly<{
247
+ accessToken: string;
248
+ idToken: string | null;
249
+ refreshToken: string;
250
+ tokenType: "Bearer";
251
+ }>;
252
+
253
+ type IdentityLinkReceiptRequest = ProductLinkProof & Readonly<{
254
+ proofSignature: string;
255
+ }>;
256
+
257
+ function isRecord(value: unknown): value is Record<string, unknown> {
258
+ return typeof value === "object" && value !== null && !Array.isArray(value);
259
+ }
260
+
261
+ function safeInteger(value: unknown): value is number {
262
+ return typeof value === "number"
263
+ && Number.isSafeInteger(value)
264
+ && value >= 0;
265
+ }
266
+
267
+ function boundedString(
268
+ value: unknown,
269
+ minimum: number,
270
+ maximum: number,
271
+ ): value is string {
272
+ return typeof value === "string"
273
+ && value.length >= minimum
274
+ && value.length <= maximum
275
+ && !value.includes("\u0000");
276
+ }
277
+
278
+ function encodeBase64Url(bytes: Uint8Array): string {
279
+ let binary = "";
280
+ for (const byte of bytes) binary += String.fromCharCode(byte);
281
+ return btoa(binary)
282
+ .replaceAll("+", "-")
283
+ .replaceAll("/", "_")
284
+ .replace(/=+$/u, "");
285
+ }
286
+
287
+ function decodeBase64Url(value: string): Uint8Array | null {
288
+ if (!/^[A-Za-z0-9_-]+$/u.test(value)) return null;
289
+ const padded = value.replaceAll("-", "+").replaceAll("_", "/")
290
+ + "=".repeat((4 - (value.length % 4)) % 4);
291
+ try {
292
+ const binary = atob(padded);
293
+ return Uint8Array.from(binary, character => character.charCodeAt(0));
294
+ } catch {
295
+ return null;
296
+ }
297
+ }
298
+
299
+ function ownedBytes(value: Uint8Array): Uint8Array<ArrayBuffer> {
300
+ const result = new Uint8Array(new ArrayBuffer(value.byteLength));
301
+ result.set(value);
302
+ return result;
303
+ }
304
+
305
+ function randomValue(
306
+ length: number,
307
+ source: (length: number) => Uint8Array,
308
+ ): string {
309
+ const bytes = source(length);
310
+ if (bytes.byteLength !== length) {
311
+ throw new Error("The OIDC random source returned the wrong byte length.");
312
+ }
313
+ return encodeBase64Url(bytes);
314
+ }
315
+
316
+ async function sha256Base64Url(value: string): Promise<string> {
317
+ const digest = await crypto.subtle.digest(
318
+ "SHA-256",
319
+ new TextEncoder().encode(value),
320
+ );
321
+ return encodeBase64Url(new Uint8Array(digest));
322
+ }
323
+
324
+ function cookieNames(siteUrl: string) {
325
+ const secure = new URL(siteUrl).protocol === "https:";
326
+ return {
327
+ secure,
328
+ session: secure ? REMOTE_SESSION_COOKIE : LOCAL_SESSION_COOKIE,
329
+ transaction: secure
330
+ ? REMOTE_TRANSACTION_COOKIE
331
+ : LOCAL_TRANSACTION_COOKIE,
332
+ };
333
+ }
334
+
335
+ function cookieAttributes(secure: boolean, maxAgeSeconds: number): string {
336
+ return [
337
+ `Max-Age=${maxAgeSeconds}`,
338
+ "Path=/",
339
+ "HttpOnly",
340
+ ...(secure ? ["Secure"] : []),
341
+ "SameSite=Lax",
342
+ ].join("; ");
343
+ }
344
+
345
+ function setCookie(
346
+ name: string,
347
+ value: string,
348
+ secure: boolean,
349
+ maxAgeSeconds: number,
350
+ ): string {
351
+ return `${name}=${value}; ${cookieAttributes(secure, maxAgeSeconds)}`;
352
+ }
353
+
354
+ function clearCookie(name: string, secure: boolean): string {
355
+ return [
356
+ `${name}=`,
357
+ "Max-Age=0",
358
+ "Expires=Thu, 01 Jan 1970 00:00:00 GMT",
359
+ "Path=/",
360
+ "HttpOnly",
361
+ ...(secure ? ["Secure"] : []),
362
+ "SameSite=Lax",
363
+ ].join("; ");
364
+ }
365
+
366
+ function requestCookie(request: Request, name: string): string | null {
367
+ const header = request.headers.get("cookie");
368
+ if (header === null || header.length > 16_384) return null;
369
+ const values: string[] = [];
370
+ for (const segment of header.split(";")) {
371
+ const trimmed = segment.trim();
372
+ const separator = trimmed.indexOf("=");
373
+ if (separator <= 0 || trimmed.slice(0, separator) !== name) continue;
374
+ values.push(trimmed.slice(separator + 1));
375
+ }
376
+ return values.length === 1 ? values[0]! : null;
377
+ }
378
+
379
+ async function deriveCookieKey(
380
+ secret: string,
381
+ consumer: SuiteOidcConsumer,
382
+ environment: SuiteAccountsRemoteEnvironment,
383
+ ): Promise<CryptoKey> {
384
+ const material = new TextEncoder().encode(
385
+ `hraness-suite-oidc-cookie-v1\u0000${consumer}\u0000${environment}\u0000${secret}`,
386
+ );
387
+ const digest = await crypto.subtle.digest("SHA-256", material);
388
+ return await crypto.subtle.importKey(
389
+ "raw",
390
+ digest,
391
+ { name: "AES-GCM" },
392
+ false,
393
+ ["decrypt", "encrypt"],
394
+ );
395
+ }
396
+
397
+ async function sealCookie(
398
+ value: unknown,
399
+ key: CryptoKey,
400
+ purpose: "session" | "transaction",
401
+ randomBytes: (length: number) => Uint8Array,
402
+ ): Promise<string> {
403
+ const iv = ownedBytes(randomBytes(12));
404
+ if (iv.byteLength !== 12) throw new Error("Invalid OIDC cookie IV.");
405
+ const plaintext = new TextEncoder().encode(JSON.stringify(value));
406
+ if (plaintext.byteLength > MAX_COOKIE_BYTES - 256) {
407
+ throw new Error("The OIDC cookie payload was too large.");
408
+ }
409
+ const aad = new TextEncoder().encode(`hraness-suite-oidc-${purpose}-v1`);
410
+ const encrypted = await crypto.subtle.encrypt(
411
+ { additionalData: aad, iv, name: "AES-GCM", tagLength: 128 },
412
+ key,
413
+ plaintext,
414
+ );
415
+ const sealed = `v1.${encodeBase64Url(iv)}.${encodeBase64Url(
416
+ new Uint8Array(encrypted),
417
+ )}`;
418
+ if (sealed.length > MAX_COOKIE_BYTES) {
419
+ throw new Error("The sealed OIDC cookie was too large.");
420
+ }
421
+ return sealed;
422
+ }
423
+
424
+ async function unsealCookie(
425
+ value: string,
426
+ key: CryptoKey,
427
+ purpose: "session" | "transaction",
428
+ ): Promise<unknown> {
429
+ if (value.length < 1 || value.length > MAX_COOKIE_BYTES) return null;
430
+ const parts = value.split(".");
431
+ if (parts.length !== 3 || parts[0] !== "v1") return null;
432
+ const iv = decodeBase64Url(parts[1]!);
433
+ const ciphertext = decodeBase64Url(parts[2]!);
434
+ if (iv?.byteLength !== 12 || ciphertext === null) return null;
435
+ try {
436
+ const plaintext = await crypto.subtle.decrypt(
437
+ {
438
+ additionalData: new TextEncoder().encode(
439
+ `hraness-suite-oidc-${purpose}-v1`,
440
+ ),
441
+ iv: ownedBytes(iv),
442
+ name: "AES-GCM",
443
+ tagLength: 128,
444
+ },
445
+ key,
446
+ ownedBytes(ciphertext),
447
+ );
448
+ if (plaintext.byteLength > MAX_COOKIE_BYTES) return null;
449
+ return JSON.parse(new TextDecoder().decode(plaintext)) as unknown;
450
+ } catch {
451
+ return null;
452
+ }
453
+ }
454
+
455
+ function parseTransaction(
456
+ value: unknown,
457
+ consumer: SuiteOidcConsumer,
458
+ environment: SuiteAccountsRemoteEnvironment,
459
+ nowMs: number,
460
+ ): Transaction | null {
461
+ if (
462
+ !isRecord(value)
463
+ || (value["version"] !== 1 && value["version"] !== 2)
464
+ || value["consumer"] !== consumer
465
+ || value["environment"] !== environment
466
+ || !safeInteger(value["issuedAtMs"])
467
+ || !safeInteger(value["expiresAtMs"])
468
+ || value["expiresAtMs"] <= nowMs
469
+ || value["issuedAtMs"] > nowMs + 30_000
470
+ || value["expiresAtMs"] - value["issuedAtMs"] > TRANSACTION_TTL_MS
471
+ || !boundedString(value["state"], 43, 128)
472
+ || !boundedString(value["nonce"], 43, 128)
473
+ || !boundedString(value["verifier"], 43, 128)
474
+ || parseReturnPath(value["returnTo"], "") === null
475
+ ) {
476
+ return null;
477
+ }
478
+ if (value["version"] === 2 && (
479
+ !safeInteger(nowMs)
480
+ || value["issuedAtMs"] > nowMs
481
+ || value["expiresAtMs"] <= value["issuedAtMs"]
482
+ || !validFreshContext(value["context"])
483
+ || Object.keys(value).length !== 10
484
+ )) return null;
485
+ return value as Transaction;
486
+ }
487
+
488
+ function validFreshContext(value: unknown): value is string {
489
+ return typeof value === "string" && /^[A-Za-z0-9_-]{32,256}$/u.test(value);
490
+ }
491
+
492
+ function parseFreshAuthenticationInput(
493
+ value: unknown,
494
+ nowMs: number,
495
+ ): SuiteOidcFreshAuthenticationInput | null {
496
+ if (!isRecord(value) || !safeInteger(nowMs)
497
+ || !safeInteger(nowMs + TRANSACTION_TTL_MS)) return null;
498
+ const descriptors = Object.getOwnPropertyDescriptors(value);
499
+ const ownKeys = Reflect.ownKeys(descriptors);
500
+ if ((ownKeys.length !== 2 && ownKeys.length !== 3)
501
+ || !("value" in (descriptors["context"] ?? {}))
502
+ || !("value" in (descriptors["expiresAtMs"] ?? {}))) return null;
503
+ if (ownKeys.length === 3 && !("value" in (descriptors["authenticationNotBeforeMs"] ?? {}))) return null;
504
+ const context: unknown = descriptors["context"]!.value;
505
+ const expiresAtMs: unknown = descriptors["expiresAtMs"]!.value;
506
+ const notBeforeMs: unknown = descriptors["authenticationNotBeforeMs"]?.value;
507
+ if (!validFreshContext(context) || !safeInteger(expiresAtMs)
508
+ || expiresAtMs <= nowMs || expiresAtMs > nowMs + TRANSACTION_TTL_MS
509
+ || (notBeforeMs !== undefined && (!safeInteger(notBeforeMs) || notBeforeMs < 0))) return null;
510
+ return notBeforeMs === undefined
511
+ ? { context, expiresAtMs }
512
+ : { context, expiresAtMs, authenticationNotBeforeMs: notBeforeMs };
513
+ }
514
+
515
+ function parseStoredEntitlements(value: unknown): VerifiedSuiteEntitlements | null {
516
+ if (!isRecord(value) || !Array.isArray(value["features"])) return null;
517
+ const features: SuiteFeatureId[] = [];
518
+ for (const rawFeature of value["features"]) {
519
+ const feature = parseSuiteFeatureId(rawFeature);
520
+ if (!feature.ok || features.includes(feature.value)) return null;
521
+ features.push(feature.value);
522
+ }
523
+ if (value["kind"] === "legacy") {
524
+ return value["claim"] === null && features.length === 0
525
+ ? { claim: null, features: [], kind: "legacy" }
526
+ : null;
527
+ }
528
+ if (value["kind"] !== "fresh" && value["kind"] !== "stale") return null;
529
+ const claim = value["claim"];
530
+ const catalogRevision = isRecord(claim)
531
+ ? parseSuiteCatalogRevision(claim["catalogRevision"])
532
+ : null;
533
+ if (
534
+ !isRecord(claim)
535
+ || claim["version"] !== "suite-entitlements-v1"
536
+ || catalogRevision === null
537
+ || !catalogRevision.ok
538
+ || !safeInteger(claim["observedAtMs"])
539
+ || !safeInteger(claim["expiresAtMs"])
540
+ || !safeInteger(claim["projectionRevision"])
541
+ || !Array.isArray(claim["features"])
542
+ ) {
543
+ return null;
544
+ }
545
+ const claimFeatures: SuiteFeatureId[] = [];
546
+ for (const rawFeature of claim["features"]) {
547
+ const feature = parseSuiteFeatureId(rawFeature);
548
+ if (!feature.ok || claimFeatures.includes(feature.value)) return null;
549
+ claimFeatures.push(feature.value);
550
+ }
551
+ if (
552
+ claim["expiresAtMs"] <= claim["observedAtMs"]
553
+ || (
554
+ value["kind"] === "fresh"
555
+ && (
556
+ features.length !== claimFeatures.length
557
+ || features.some(feature => !claimFeatures.includes(feature))
558
+ )
559
+ )
560
+ ) {
561
+ return null;
562
+ }
563
+ const parsedClaim: SuiteEntitlementsClaim = {
564
+ catalogRevision: catalogRevision.value,
565
+ expiresAtMs: claim["expiresAtMs"],
566
+ features: claimFeatures,
567
+ observedAtMs: claim["observedAtMs"],
568
+ projectionRevision: claim["projectionRevision"],
569
+ version: "suite-entitlements-v1",
570
+ };
571
+ return value["kind"] === "fresh"
572
+ ? { claim: parsedClaim, features, kind: "fresh" }
573
+ : { claim: parsedClaim, features: [], kind: "stale" };
574
+ }
575
+
576
+ function parseEntitlementReceipt(
577
+ value: unknown,
578
+ consumer: SuiteOidcConsumer,
579
+ environment: SuiteAccountsRemoteEnvironment,
580
+ keyVersion: string,
581
+ suiteAccountId: string,
582
+ nowMs: number,
583
+ ): SuiteEntitlementReceipt | null {
584
+ if (!isReceiptConsumer(consumer)) return null;
585
+ if (
586
+ !isRecord(value)
587
+ || !isRecord(value["entitlements"])
588
+ ) {
589
+ return null;
590
+ }
591
+ try {
592
+ const receipt = value as unknown as SuiteEntitlementReceipt;
593
+ return validateSuiteEntitlementReceipt(receipt, nowMs) === null
594
+ && receipt.product === consumer
595
+ && receipt.environment === environment
596
+ && receipt.keyVersion === keyVersion
597
+ && receipt.suiteAccountId === suiteAccountId
598
+ ? receipt
599
+ : null;
600
+ } catch {
601
+ return null;
602
+ }
603
+ }
604
+
605
+ function parseIdentityLinkReceiptRequest(
606
+ value: unknown,
607
+ consumer: SuiteOidcConsumer,
608
+ environment: SuiteAccountsRemoteEnvironment,
609
+ nowMs: number,
610
+ ): IdentityLinkReceiptRequest | null {
611
+ if (!isReceiptConsumer(consumer)) return null;
612
+ if (
613
+ !isRecord(value)
614
+ || Object.keys(value).length !== 8
615
+ || value["product"] !== consumer
616
+ || value["environment"] !== environment
617
+ || !boundedString(value["challengeId"], 22, 128)
618
+ || !boundedString(value["localSubject"], 1, 255)
619
+ || !boundedString(value["keyVersion"], 1, 32)
620
+ || !boundedString(value["proofSignature"], 43, 43)
621
+ || !safeInteger(value["issuedAtMs"])
622
+ || !safeInteger(value["expiresAtMs"])
623
+ ) {
624
+ return null;
625
+ }
626
+ const proof: ProductLinkProof = {
627
+ challengeId: value["challengeId"],
628
+ environment,
629
+ expiresAtMs: value["expiresAtMs"],
630
+ issuedAtMs: value["issuedAtMs"],
631
+ keyVersion: value["keyVersion"],
632
+ localSubject: value["localSubject"],
633
+ product: consumer,
634
+ };
635
+ return validateProductLinkProof(proof, nowMs) === null
636
+ && /^[A-Za-z0-9_-]{43}$/u.test(value["proofSignature"])
637
+ ? { ...proof, proofSignature: value["proofSignature"] }
638
+ : null;
639
+ }
640
+
641
+ function parseIdentityLinkReceipt(
642
+ value: unknown,
643
+ proof: IdentityLinkReceiptRequest,
644
+ suiteAccountId: string,
645
+ nowMs: number,
646
+ ): SuiteLinkReceipt | null {
647
+ if (!isRecord(value)) return null;
648
+ try {
649
+ const receipt = value as unknown as SuiteLinkReceipt;
650
+ return validateSuiteLinkReceipt(receipt, nowMs) === null
651
+ && receipt.challengeId === proof.challengeId
652
+ && receipt.environment === proof.environment
653
+ && receipt.expiresAtMs === proof.expiresAtMs
654
+ && receipt.issuedAtMs === proof.issuedAtMs
655
+ && receipt.keyVersion === proof.keyVersion
656
+ && receipt.localSubject === proof.localSubject
657
+ && receipt.product === proof.product
658
+ && receipt.suiteAccountId === suiteAccountId
659
+ ? receipt
660
+ : null;
661
+ } catch {
662
+ return null;
663
+ }
664
+ }
665
+
666
+ function parseStoredProfile(value: Record<string, unknown>): SuiteOidcProfile | null {
667
+ const profileRevision = value["profileRevision"];
668
+ const profileComplete = value["profileComplete"];
669
+ const rawUsername = value["username"];
670
+ if (
671
+ profileComplete === false
672
+ && (profileRevision === null || profileRevision === "username-v1")
673
+ && rawUsername === null
674
+ ) {
675
+ return {
676
+ profileComplete: false,
677
+ profileRevision,
678
+ username: null,
679
+ };
680
+ }
681
+ if (profileComplete !== true || profileRevision !== "username-v1") {
682
+ return null;
683
+ }
684
+ const username = parseSuiteUsername(rawUsername);
685
+ return username.ok
686
+ ? {
687
+ profileComplete: true,
688
+ profileRevision: "username-v1",
689
+ username: username.value,
690
+ }
691
+ : null;
692
+ }
693
+
694
+ function profileFromClaims(
695
+ claims: Readonly<{
696
+ profileComplete: boolean;
697
+ profileRevision: "username-v1" | null;
698
+ username: SuiteUsername | null;
699
+ }>,
700
+ ): SuiteOidcProfile | null {
701
+ if (
702
+ claims.profileComplete
703
+ && claims.profileRevision === "username-v1"
704
+ && claims.username !== null
705
+ ) {
706
+ return {
707
+ profileComplete: true,
708
+ profileRevision: "username-v1",
709
+ username: claims.username,
710
+ };
711
+ }
712
+ return !claims.profileComplete && claims.username === null
713
+ ? {
714
+ profileComplete: false,
715
+ profileRevision: claims.profileRevision,
716
+ username: null,
717
+ }
718
+ : null;
719
+ }
720
+
721
+ function parseSession(
722
+ value: unknown,
723
+ configuration: SuiteOidcRelyingParty["configuration"],
724
+ consumer: SuiteOidcConsumer,
725
+ environment: SuiteAccountsRemoteEnvironment,
726
+ receiptKeyVersion: string,
727
+ nowMs: number,
728
+ ): StoredSession | null {
729
+ if (
730
+ !isRecord(value)
731
+ || value["version"] !== 2
732
+ || value["environment"] !== environment
733
+ || value["issuer"] !== configuration.provider.issuer
734
+ || !boundedString(value["accessToken"], 16, 16_384)
735
+ || value["audience"] !== configuration.provider.resource
736
+ || !boundedString(value["subject"], 1, 255)
737
+ || !/^acct_[0-9a-f]{32}$/u.test(String(value["suiteAccountId"]))
738
+ || !boundedString(value["nonce"], 43, 128)
739
+ || !boundedString(value["refreshToken"], 16, 2_048)
740
+ || !safeInteger(value["accessTokenExpiresAtMs"])
741
+ || !safeInteger(value["expiresAtMs"])
742
+ || value["expiresAtMs"] <= nowMs
743
+ || value["expiresAtMs"] > nowMs + SESSION_TTL_MS + 30_000
744
+ ) {
745
+ return null;
746
+ }
747
+ const suiteAccountId = parseSuiteAccountId(value["suiteAccountId"]);
748
+ const entitlements = parseStoredEntitlements(value["entitlements"]);
749
+ const profile = parseStoredProfile(value);
750
+ const pendingEntitlementReceipt = value["pendingEntitlementReceipt"] === null
751
+ ? null
752
+ : parseEntitlementReceipt(
753
+ value["pendingEntitlementReceipt"],
754
+ consumer,
755
+ environment,
756
+ receiptKeyVersion,
757
+ String(value["suiteAccountId"]),
758
+ nowMs,
759
+ );
760
+ return entitlements === null || profile === null || !suiteAccountId.ok
761
+ ? null
762
+ : {
763
+ ...(value as Omit<
764
+ StoredSession,
765
+ | "entitlements"
766
+ | "pendingEntitlementReceipt"
767
+ | "profileComplete"
768
+ | "profileRevision"
769
+ | "suiteAccountId"
770
+ | "username"
771
+ >),
772
+ entitlements,
773
+ pendingEntitlementReceipt,
774
+ ...profile,
775
+ suiteAccountId: suiteAccountId.value,
776
+ };
777
+ }
778
+
779
+ function parseReturnPath(value: unknown, fallback = "/"): string | null {
780
+ if (value === undefined || value === null || value === "") return fallback;
781
+ if (
782
+ typeof value !== "string"
783
+ || value.length > MAX_RETURN_PATH_BYTES
784
+ || !value.startsWith("/")
785
+ || value.startsWith("//")
786
+ || value.includes("\\")
787
+ || value.includes("#")
788
+ ) {
789
+ return null;
790
+ }
791
+ let parsed: URL;
792
+ try {
793
+ parsed = new URL(value, "https://return.invalid");
794
+ } catch {
795
+ return null;
796
+ }
797
+ if (
798
+ parsed.origin !== "https://return.invalid"
799
+ || parsed.username !== ""
800
+ || parsed.password !== ""
801
+ || parsed.pathname.startsWith("/api/suite-auth/")
802
+ ) {
803
+ return null;
804
+ }
805
+ return `${parsed.pathname}${parsed.search}`;
806
+ }
807
+
808
+ function exactRequest(
809
+ request: Request,
810
+ siteUrl: string,
811
+ path: string,
812
+ method: "GET" | "POST",
813
+ ): boolean {
814
+ let url: URL;
815
+ try {
816
+ url = new URL(request.url);
817
+ } catch {
818
+ return false;
819
+ }
820
+ if (request.method !== method || url.origin !== siteUrl || url.pathname !== path) {
821
+ return false;
822
+ }
823
+ const fetchSite = request.headers.get("sec-fetch-site");
824
+ if (fetchSite !== null && fetchSite.toLowerCase() !== "same-origin") {
825
+ return false;
826
+ }
827
+ if (method === "POST" && request.headers.get("origin") !== siteUrl) {
828
+ return false;
829
+ }
830
+ return true;
831
+ }
832
+
833
+ function exactCallbackRequest(
834
+ request: Request,
835
+ siteUrl: string,
836
+ ): boolean {
837
+ let url: URL;
838
+ try {
839
+ url = new URL(request.url);
840
+ } catch {
841
+ return false;
842
+ }
843
+ if (
844
+ request.method !== "GET"
845
+ || url.origin !== siteUrl
846
+ || url.pathname !== "/api/suite-auth/callback"
847
+ ) {
848
+ return false;
849
+ }
850
+ const mode = request.headers.get("sec-fetch-mode");
851
+ const destination = request.headers.get("sec-fetch-dest");
852
+ const site = request.headers.get("sec-fetch-site");
853
+ return (mode === null || mode.toLowerCase() === "navigate")
854
+ && (destination === null || destination.toLowerCase() === "document")
855
+ && (
856
+ site === null
857
+ || ["cross-site", "same-origin", "same-site", "none"].includes(
858
+ site.toLowerCase(),
859
+ )
860
+ );
861
+ }
862
+
863
+ function jsonResponse(
864
+ body: unknown,
865
+ status: number,
866
+ cookies: readonly string[] = [],
867
+ ): Response {
868
+ const headers = new Headers({
869
+ "cache-control": "no-store",
870
+ "content-type": "application/json; charset=utf-8",
871
+ pragma: "no-cache",
872
+ });
873
+ for (const cookie of cookies) headers.append("set-cookie", cookie);
874
+ return new Response(JSON.stringify(body), { headers, status });
875
+ }
876
+
877
+ function failure(
878
+ code: string,
879
+ status: number,
880
+ cookies: readonly string[] = [],
881
+ ): Response {
882
+ return jsonResponse({
883
+ error: { code, retryable: status >= 500 },
884
+ schemaVersion: 1,
885
+ }, status, cookies);
886
+ }
887
+
888
+ async function readBoundedJson(
889
+ response: Response,
890
+ maximumBytes = MAX_PROVIDER_BODY_BYTES,
891
+ ): Promise<unknown> {
892
+ const declared = response.headers.get("content-length");
893
+ if (
894
+ declared !== null
895
+ && (!/^(?:0|[1-9]\d*)$/u.test(declared)
896
+ || Number(declared) > maximumBytes)
897
+ ) {
898
+ throw new Error("Provider response was too large.");
899
+ }
900
+ if (response.body === null) throw new Error("Provider response was empty.");
901
+ const reader = response.body.getReader();
902
+ const chunks: Uint8Array[] = [];
903
+ let length = 0;
904
+ while (true) {
905
+ const result = await reader.read();
906
+ if (result.done) break;
907
+ length += result.value.byteLength;
908
+ if (length > maximumBytes) {
909
+ await reader.cancel();
910
+ throw new Error("Provider response was too large.");
911
+ }
912
+ chunks.push(result.value);
913
+ }
914
+ const bytes = new Uint8Array(length);
915
+ let offset = 0;
916
+ for (const chunk of chunks) {
917
+ bytes.set(chunk, offset);
918
+ offset += chunk.byteLength;
919
+ }
920
+ return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
921
+ }
922
+
923
+ async function consumeBoundedBody(
924
+ response: Response,
925
+ maximumBytes: number,
926
+ ): Promise<void> {
927
+ const declared = response.headers.get("content-length");
928
+ if (
929
+ declared !== null
930
+ && (!/^(?:0|[1-9]\d*)$/u.test(declared)
931
+ || Number(declared) > maximumBytes)
932
+ ) {
933
+ throw new Error("Provider response was too large.");
934
+ }
935
+ if (response.body === null) return;
936
+ const reader = response.body.getReader();
937
+ let length = 0;
938
+ while (true) {
939
+ const result = await reader.read();
940
+ if (result.done) return;
941
+ length += result.value.byteLength;
942
+ if (length > maximumBytes) {
943
+ await reader.cancel();
944
+ throw new Error("Provider response was too large.");
945
+ }
946
+ }
947
+ }
948
+
949
+ async function readBoundedRequestJson(
950
+ request: Request,
951
+ maximumBytes: number,
952
+ ): Promise<unknown> {
953
+ const declared = request.headers.get("content-length");
954
+ if (
955
+ declared !== null
956
+ && (!/^(?:0|[1-9]\d*)$/u.test(declared)
957
+ || Number(declared) > maximumBytes)
958
+ ) {
959
+ throw new Error("Request body was too large.");
960
+ }
961
+ if (request.body === null) throw new Error("Request body was empty.");
962
+ const reader = request.body.getReader();
963
+ const chunks: Uint8Array[] = [];
964
+ let length = 0;
965
+ while (true) {
966
+ const result = await reader.read();
967
+ if (result.done) break;
968
+ if (result.value.byteLength > maximumBytes - length) {
969
+ await reader.cancel();
970
+ throw new Error("Request body was too large.");
971
+ }
972
+ length += result.value.byteLength;
973
+ chunks.push(result.value);
974
+ }
975
+ const bytes = new Uint8Array(length);
976
+ let offset = 0;
977
+ for (const chunk of chunks) {
978
+ bytes.set(chunk, offset);
979
+ offset += chunk.byteLength;
980
+ }
981
+ return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
982
+ }
983
+
984
+ async function providerJson(
985
+ url: string,
986
+ init: RequestInit,
987
+ fetchImplementation: FetchImplementation,
988
+ timeoutMs: number,
989
+ ): Promise<unknown> {
990
+ const controller = new AbortController();
991
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
992
+ try {
993
+ const response = await fetchImplementation(url, {
994
+ ...init,
995
+ redirect: "manual",
996
+ signal: controller.signal,
997
+ });
998
+ if (
999
+ response.status !== 200
1000
+ || response.headers.has("location")
1001
+ ) {
1002
+ throw new Error("Provider request failed.");
1003
+ }
1004
+ return await readBoundedJson(response);
1005
+ } finally {
1006
+ clearTimeout(timeout);
1007
+ }
1008
+ }
1009
+
1010
+ export function validateSuiteOidcDiscovery(
1011
+ value: unknown,
1012
+ expected: SuiteAccountsOidcProviderConfiguration,
1013
+ ): boolean {
1014
+ if (!isRecord(value)) return false;
1015
+ const exact = (
1016
+ value["issuer"] === expected.issuer
1017
+ && value["authorization_endpoint"] === expected.authorizationEndpoint
1018
+ && value["token_endpoint"] === expected.tokenEndpoint
1019
+ && value["jwks_uri"] === expected.jwksEndpoint
1020
+ && value["revocation_endpoint"] === expected.revocationEndpoint
1021
+ );
1022
+ const responseTypes = value["response_types_supported"];
1023
+ const grants = value["grant_types_supported"];
1024
+ const challenges = value["code_challenge_methods_supported"];
1025
+ const methods = value["token_endpoint_auth_methods_supported"];
1026
+ const algorithms = value["id_token_signing_alg_values_supported"];
1027
+ return exact
1028
+ && Array.isArray(responseTypes)
1029
+ && responseTypes.includes("code")
1030
+ && Array.isArray(grants)
1031
+ && grants.includes("authorization_code")
1032
+ && grants.includes("refresh_token")
1033
+ && Array.isArray(challenges)
1034
+ && challenges.includes("S256")
1035
+ && Array.isArray(methods)
1036
+ && methods.includes("none")
1037
+ && Array.isArray(algorithms)
1038
+ && algorithms.length === 1
1039
+ && algorithms[0] === "ES256"
1040
+ && !algorithms.includes("HS256");
1041
+ }
1042
+
1043
+ function parseTokenResponse(
1044
+ value: unknown,
1045
+ requireIdToken: boolean,
1046
+ ): TokenResponse | null {
1047
+ if (
1048
+ !isRecord(value)
1049
+ || value["token_type"] !== "Bearer"
1050
+ || !boundedString(value["access_token"], 16, 16_384)
1051
+ || !boundedString(value["refresh_token"], 16, 4_096)
1052
+ || (
1053
+ value["id_token"] !== undefined
1054
+ && !boundedString(value["id_token"], 64, 16_384)
1055
+ )
1056
+ || (requireIdToken && !boundedString(value["id_token"], 64, 16_384))
1057
+ ) {
1058
+ return null;
1059
+ }
1060
+ return {
1061
+ accessToken: value["access_token"],
1062
+ idToken: typeof value["id_token"] === "string"
1063
+ ? value["id_token"]
1064
+ : null,
1065
+ refreshToken: value["refresh_token"],
1066
+ tokenType: "Bearer",
1067
+ };
1068
+ }
1069
+
1070
+ function parseJwks(value: unknown): JSONWebKeySet | null {
1071
+ if (
1072
+ !isRecord(value)
1073
+ || !Array.isArray(value["keys"])
1074
+ || value["keys"].length < 1
1075
+ || value["keys"].length > 8
1076
+ ) {
1077
+ return null;
1078
+ }
1079
+ for (const key of value["keys"]) {
1080
+ if (
1081
+ !isRecord(key)
1082
+ || !boundedString(key["kid"], 1, 128)
1083
+ || (
1084
+ key["alg"] !== undefined
1085
+ && key["alg"] !== "ES256"
1086
+ )
1087
+ || key["kty"] !== "EC"
1088
+ || key["crv"] !== "P-256"
1089
+ || !boundedString(key["x"], 1, 256)
1090
+ || !boundedString(key["y"], 1, 256)
1091
+ || key["d"] !== undefined
1092
+ ) {
1093
+ return null;
1094
+ }
1095
+ }
1096
+ return value as unknown as JSONWebKeySet;
1097
+ }
1098
+
1099
+ type VerifiedIdToken = Readonly<{
1100
+ freshTimes: Readonly<{ authenticatedAtMs: number; expiresAtMs: number }> | null;
1101
+ profile: SuiteOidcProfile;
1102
+ subject: string;
1103
+ suiteAccountId: SuiteAccountId;
1104
+ }>;
1105
+
1106
+ async function verifiedIdToken(input: {
1107
+ configuration: SuiteOidcRelyingParty["configuration"];
1108
+ expectedNonce: string;
1109
+ idToken: string;
1110
+ jwks: JSONWebKeySet;
1111
+ nowMs: number;
1112
+ freshStartedAtMs?: number;
1113
+ }): Promise<VerifiedIdToken | null> {
1114
+ const header = decodeProtectedHeader(input.idToken);
1115
+ if (
1116
+ !boundedString(header.kid, 1, 128)
1117
+ || header.alg !== "ES256"
1118
+ ) {
1119
+ return null;
1120
+ }
1121
+ const verified = await jwtVerify(
1122
+ input.idToken,
1123
+ createLocalJWKSet(input.jwks),
1124
+ {
1125
+ algorithms: [...ALLOWED_TOKEN_ALGORITHMS],
1126
+ audience: input.configuration.clientId,
1127
+ clockTolerance: input.freshStartedAtMs === undefined ? 30 : 0,
1128
+ currentDate: new Date(input.nowMs),
1129
+ issuer: input.configuration.provider.issuer,
1130
+ maxTokenAge: "20m",
1131
+ },
1132
+ );
1133
+ if (
1134
+ verified.payload["nonce"] !== input.expectedNonce
1135
+ || !boundedString(verified.payload.sub, 1, 255)
1136
+ ) {
1137
+ return null;
1138
+ }
1139
+ const claims = parseSuiteJwtClaims(verified.payload);
1140
+ if (!claims.ok) return null;
1141
+ const profile = profileFromClaims(claims.value);
1142
+ if (profile === null) return null;
1143
+ let freshTimes: VerifiedIdToken["freshTimes"] = null;
1144
+ if (input.freshStartedAtMs !== undefined) {
1145
+ const authTime = verified.payload["auth_time"];
1146
+ const issuedAt = verified.payload.iat;
1147
+ const expiresAt = verified.payload.exp;
1148
+ if (!safeInteger(input.nowMs) || input.nowMs < input.freshStartedAtMs
1149
+ || !safeInteger(authTime) || !safeInteger(authTime * 1_000)
1150
+ || !safeInteger(issuedAt) || !safeInteger(expiresAt)
1151
+ || !safeInteger(expiresAt * 1_000)
1152
+ || authTime < Math.floor(input.freshStartedAtMs / 1_000)
1153
+ || authTime > Math.floor(input.nowMs / 1_000)
1154
+ || authTime > issuedAt || issuedAt > Math.floor(input.nowMs / 1_000)
1155
+ || expiresAt * 1_000 <= input.nowMs
1156
+ || verified.payload.aud !== input.configuration.clientId
1157
+ || (verified.payload["azp"] !== undefined
1158
+ && verified.payload["azp"] !== input.configuration.clientId)) return null;
1159
+ freshTimes = { authenticatedAtMs: authTime * 1_000, expiresAtMs: expiresAt * 1_000 };
1160
+ }
1161
+ return {
1162
+ freshTimes,
1163
+ profile,
1164
+ subject: String(claims.value.principal.subject),
1165
+ suiteAccountId: claims.value.suiteAccountId,
1166
+ };
1167
+ }
1168
+
1169
+ function exactAccessTokenAudience(
1170
+ value: unknown,
1171
+ provider: SuiteAccountsOidcProviderConfiguration,
1172
+ ): boolean {
1173
+ if (!Array.isArray(value) || value.length !== 2) return false;
1174
+ const audiences = new Set(value);
1175
+ return audiences.size === 2
1176
+ && audiences.has(provider.resource)
1177
+ && audiences.has(provider.userInfoAudience);
1178
+ }
1179
+
1180
+ async function verifiedSessionFromAccessToken(input: {
1181
+ accessToken: string;
1182
+ configuration: SuiteOidcRelyingParty["configuration"];
1183
+ environment: SuiteAccountsRemoteEnvironment;
1184
+ expectedAccountId?: string;
1185
+ expectedProfile?: SuiteOidcProfile;
1186
+ expectedSubject?: string;
1187
+ profileTransition: "exact" | "forward";
1188
+ jwks: JSONWebKeySet;
1189
+ nonce: string;
1190
+ nowMs: number;
1191
+ refreshToken: string;
1192
+ requireFresh?: boolean;
1193
+ }): Promise<StoredSession | null> {
1194
+ const header = decodeProtectedHeader(input.accessToken);
1195
+ if (!boundedString(header.kid, 1, 128) || header.alg !== "ES256") {
1196
+ return null;
1197
+ }
1198
+ let verifiedPayload: JWTPayload;
1199
+ try {
1200
+ const verified = await jwtVerify(
1201
+ input.accessToken,
1202
+ createLocalJWKSet(input.jwks),
1203
+ {
1204
+ algorithms: [...ALLOWED_TOKEN_ALGORITHMS],
1205
+ audience: input.configuration.provider.resource,
1206
+ clockTolerance: input.requireFresh === true ? 0 : 30,
1207
+ currentDate: new Date(input.nowMs),
1208
+ issuer: input.configuration.provider.issuer,
1209
+ maxTokenAge: "20m",
1210
+ },
1211
+ );
1212
+ verifiedPayload = verified.payload;
1213
+ } catch {
1214
+ return null;
1215
+ }
1216
+ const result = await verifySuiteEntitlementToken(input.accessToken, {
1217
+ ...(input.requireFresh === true ? { clockSkewMs: 0 } : {}),
1218
+ expectedAudience: input.configuration.provider.resource,
1219
+ expectedIssuer: input.configuration.provider.issuer,
1220
+ nowMs: input.nowMs,
1221
+ verify: () => Promise.resolve(verifiedPayload),
1222
+ });
1223
+ const profile = result.kind === "verified"
1224
+ ? profileFromClaims(result.claims)
1225
+ : null;
1226
+ const expectedProfileMatches = input.expectedProfile === undefined
1227
+ || (
1228
+ input.expectedProfile.profileComplete
1229
+ ? profile?.profileComplete === true
1230
+ && profile.username === input.expectedProfile.username
1231
+ : input.profileTransition === "forward"
1232
+ ? (
1233
+ profile?.profileComplete === true
1234
+ || (
1235
+ profile?.profileComplete === false
1236
+ && (
1237
+ input.expectedProfile.profileRevision === null
1238
+ || profile.profileRevision === input.expectedProfile.profileRevision
1239
+ )
1240
+ )
1241
+ )
1242
+ : profile?.profileComplete === false
1243
+ && profile.profileRevision === input.expectedProfile.profileRevision
1244
+ );
1245
+ if (
1246
+ result.kind !== "verified"
1247
+ || profile === null
1248
+ || !expectedProfileMatches
1249
+ || !exactAccessTokenAudience(
1250
+ verifiedPayload.aud,
1251
+ input.configuration.provider,
1252
+ )
1253
+ || verifiedPayload["azp"] !== input.configuration.clientId
1254
+ || verifiedPayload["suite_client_id"] !== input.configuration.clientId
1255
+ || (
1256
+ input.expectedSubject !== undefined
1257
+ && result.claims.principal.subject !== input.expectedSubject
1258
+ )
1259
+ || (
1260
+ input.expectedAccountId !== undefined
1261
+ && result.claims.suiteAccountId !== input.expectedAccountId
1262
+ )
1263
+ ) {
1264
+ return null;
1265
+ }
1266
+ return {
1267
+ accessToken: input.accessToken,
1268
+ audience: input.configuration.provider.resource,
1269
+ entitlements: result.entitlements,
1270
+ environment: input.environment,
1271
+ expiresAtMs: input.nowMs + SESSION_TTL_MS,
1272
+ accessTokenExpiresAtMs: result.claims.expiresAtSeconds * 1_000,
1273
+ issuer: input.configuration.provider.issuer,
1274
+ nonce: input.nonce,
1275
+ pendingEntitlementReceipt: null,
1276
+ ...profile,
1277
+ refreshToken: input.refreshToken,
1278
+ subject: String(result.claims.principal.subject),
1279
+ suiteAccountId: result.claims.suiteAccountId,
1280
+ version: 2,
1281
+ };
1282
+ }
1283
+
1284
+ function currentView(
1285
+ session: StoredSession,
1286
+ nowMs: number,
1287
+ ): SuiteOidcSessionView | null {
1288
+ if (session.accessTokenExpiresAtMs <= nowMs) return null;
1289
+ const entitlements = session.entitlements;
1290
+ const fresh = entitlements.kind === "fresh"
1291
+ && entitlements.claim.expiresAtMs > nowMs
1292
+ && nowMs - entitlements.claim.observedAtMs
1293
+ <= SUITE_ENTITLEMENT_MAX_PROVIDER_AGE_MS;
1294
+ const base: SuiteOidcSessionViewBase = {
1295
+ entitlementReceipt: session.pendingEntitlementReceipt,
1296
+ entitlements: {
1297
+ features: fresh ? entitlements.features : [],
1298
+ kind: entitlements.kind === "legacy"
1299
+ ? "legacy"
1300
+ : fresh ? "fresh" : "stale",
1301
+ },
1302
+ suiteAccountId: session.suiteAccountId,
1303
+ };
1304
+ return session.profileComplete
1305
+ ? {
1306
+ ...base,
1307
+ profileComplete: true,
1308
+ profileRevision: "username-v1",
1309
+ username: session.username,
1310
+ }
1311
+ : {
1312
+ ...base,
1313
+ profileComplete: false,
1314
+ profileRevision: session.profileRevision,
1315
+ username: null,
1316
+ };
1317
+ }
1318
+
1319
+ function liveUserInfoMatchesSession(
1320
+ value: unknown,
1321
+ configuration: SuiteOidcRelyingParty["configuration"],
1322
+ session: StoredSession,
1323
+ view: SuiteOidcSessionView,
1324
+ ): boolean {
1325
+ if (!isRecord(value)) return false;
1326
+ const account = parseSuiteAccountId(value["suite_account_id"]);
1327
+ if (
1328
+ !account.ok
1329
+ || account.value !== session.suiteAccountId
1330
+ || value["suite_client_id"] !== configuration.clientId
1331
+ || value["sub"] !== session.subject
1332
+ || value["profile_complete"] !== view.profileComplete
1333
+ || value["profile_revision"] !== view.profileRevision
1334
+ ) {
1335
+ return false;
1336
+ }
1337
+ if (!view.profileComplete) return value["username"] === null;
1338
+ const username = parseSuiteUsername(value["username"]);
1339
+ return username.ok && username.value === view.username;
1340
+ }
1341
+
1342
+ function verifiedEmailFromUserInfo(value: unknown): string | null {
1343
+ if (!isRecord(value) || value["email_verified"] !== true) return null;
1344
+ const rawEmail = value["email"];
1345
+ if (
1346
+ typeof rawEmail !== "string"
1347
+ || rawEmail.length === 0
1348
+ || rawEmail.length > 320
1349
+ || rawEmail.trim() !== rawEmail
1350
+ || [...rawEmail].some((character) => {
1351
+ const codePoint = character.codePointAt(0);
1352
+ return codePoint !== undefined && (codePoint <= 0x20 || codePoint === 0x7f);
1353
+ })
1354
+ || !/^[^@]+@[^@]+\.[^@]+$/u.test(rawEmail)
1355
+ ) return null;
1356
+ return rawEmail.toLocaleLowerCase("en-US");
1357
+ }
1358
+
1359
+ export function createSuiteOidcRelyingParty(
1360
+ options: SuiteOidcRelyingPartyOptions,
1361
+ ): SuiteOidcRelyingParty {
1362
+ const consumer = options.consumer;
1363
+ const cookieSecret = options.cookieSecret;
1364
+ const environment = options.environment;
1365
+ const receiptKeyVersion = options.receiptKeyVersion;
1366
+ const cookieSecretBytes = new TextEncoder().encode(cookieSecret);
1367
+ if (
1368
+ cookieSecretBytes.byteLength < 32
1369
+ || cookieSecretBytes.byteLength > 1_024
1370
+ ) {
1371
+ throw new Error("The suite OIDC cookie secret must be 32–1024 bytes.");
1372
+ }
1373
+ if (!/^[a-z0-9][a-z0-9._-]{0,31}$/u.test(receiptKeyVersion)) {
1374
+ throw new Error("The suite receipt key version was invalid.");
1375
+ }
1376
+ const registration = suiteAccountsCurrentOidcClientRegistration(
1377
+ consumer,
1378
+ environment,
1379
+ );
1380
+ if (registration === null) {
1381
+ throw new Error(
1382
+ "The suite consumer has no OIDC client in this environment.",
1383
+ );
1384
+ }
1385
+ const consumerEnvironment = getSuiteAccountsCurrentConsumerEnvironment(
1386
+ consumer,
1387
+ environment,
1388
+ );
1389
+ if (consumerEnvironment === null) {
1390
+ throw new Error(
1391
+ "The suite consumer has no registered origin in this environment.",
1392
+ );
1393
+ }
1394
+ const siteUrl = consumerEnvironment.siteUrl;
1395
+ const provider = suiteAccountsOidcProviderConfiguration(environment);
1396
+ const configuration = deepFreeze({
1397
+ callbackUrl: registration.callbackUrl,
1398
+ clientId: registration.clientId,
1399
+ provider,
1400
+ siteUrl,
1401
+ } as const);
1402
+ const names = cookieNames(siteUrl);
1403
+ const fetchImplementation = options.fetch ?? fetch;
1404
+ const timeoutMs = options.fetchTimeoutMs ?? FETCH_TIMEOUT_MS;
1405
+ if (!safeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 30_000) {
1406
+ throw new Error("The suite OIDC fetch timeout must be 100–30000ms.");
1407
+ }
1408
+ const now = options.now ?? Date.now;
1409
+ const randomBytes = options.randomBytes ?? ((length: number) => {
1410
+ const value = new Uint8Array(length);
1411
+ crypto.getRandomValues(value);
1412
+ return value;
1413
+ });
1414
+ const key = deriveCookieKey(
1415
+ cookieSecret,
1416
+ consumer,
1417
+ environment,
1418
+ );
1419
+
1420
+ async function discovery(): Promise<void> {
1421
+ const value = await providerJson(
1422
+ provider.discoveryEndpoint,
1423
+ { headers: { accept: "application/json" }, method: "GET" },
1424
+ fetchImplementation,
1425
+ timeoutMs,
1426
+ );
1427
+ if (!validateSuiteOidcDiscovery(value, provider)) {
1428
+ throw new Error("The OIDC provider metadata did not match the registry.");
1429
+ }
1430
+ }
1431
+
1432
+ async function jwks(): Promise<JSONWebKeySet> {
1433
+ const value = await providerJson(
1434
+ provider.jwksEndpoint,
1435
+ { headers: { accept: "application/json" }, method: "GET" },
1436
+ fetchImplementation,
1437
+ timeoutMs,
1438
+ );
1439
+ const parsed = parseJwks(value);
1440
+ if (parsed === null) throw new Error("The OIDC JWKS was invalid.");
1441
+ return parsed;
1442
+ }
1443
+
1444
+ async function tokenRequest(
1445
+ body: URLSearchParams,
1446
+ requireIdToken: boolean,
1447
+ ): Promise<TokenResponse> {
1448
+ const value = await providerJson(
1449
+ provider.tokenEndpoint,
1450
+ {
1451
+ body,
1452
+ headers: {
1453
+ accept: "application/json",
1454
+ "content-type": "application/x-www-form-urlencoded",
1455
+ origin: provider.issuer,
1456
+ },
1457
+ method: "POST",
1458
+ },
1459
+ fetchImplementation,
1460
+ timeoutMs,
1461
+ );
1462
+ const parsed = parseTokenResponse(value, requireIdToken);
1463
+ if (parsed === null) throw new Error("The OIDC token response was invalid.");
1464
+ return parsed;
1465
+ }
1466
+
1467
+ async function revokeRefreshToken(refreshToken: string): Promise<void> {
1468
+ const controller = new AbortController();
1469
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
1470
+ try {
1471
+ const response = await fetchImplementation(
1472
+ provider.revocationEndpoint,
1473
+ {
1474
+ body: new URLSearchParams({
1475
+ client_id: configuration.clientId,
1476
+ token: refreshToken,
1477
+ token_type_hint: "refresh_token",
1478
+ }),
1479
+ headers: {
1480
+ accept: "application/json",
1481
+ "content-type": "application/x-www-form-urlencoded",
1482
+ origin: provider.issuer,
1483
+ },
1484
+ method: "POST",
1485
+ redirect: "manual",
1486
+ signal: controller.signal,
1487
+ },
1488
+ );
1489
+ if (response.status !== 200 || response.headers.has("location")) {
1490
+ throw new Error("OIDC refresh-token revocation failed.");
1491
+ }
1492
+ await consumeBoundedBody(response, 1_024);
1493
+ } finally {
1494
+ clearTimeout(timeout);
1495
+ }
1496
+ }
1497
+
1498
+ async function entitlementReceipt(
1499
+ accessToken: string,
1500
+ suiteAccountId: string,
1501
+ ): Promise<SuiteEntitlementReceipt | null> {
1502
+ if (!isReceiptConsumer(consumer)) return null;
1503
+ const value = await providerJson(
1504
+ provider.entitlementReceiptEndpoint,
1505
+ {
1506
+ body: JSON.stringify({
1507
+ environment,
1508
+ keyVersion: receiptKeyVersion,
1509
+ product: consumer,
1510
+ }),
1511
+ headers: {
1512
+ accept: "application/json",
1513
+ authorization: `Bearer ${accessToken}`,
1514
+ "content-type": "application/json",
1515
+ },
1516
+ method: "POST",
1517
+ },
1518
+ fetchImplementation,
1519
+ timeoutMs,
1520
+ );
1521
+ const parsed = parseEntitlementReceipt(
1522
+ value,
1523
+ consumer,
1524
+ environment,
1525
+ receiptKeyVersion,
1526
+ suiteAccountId,
1527
+ now(),
1528
+ );
1529
+ if (parsed === null) {
1530
+ throw new Error("The suite entitlement receipt was invalid.");
1531
+ }
1532
+ return parsed;
1533
+ }
1534
+
1535
+ async function requestIdentityLinkReceipt(
1536
+ accessToken: string,
1537
+ proof: IdentityLinkReceiptRequest,
1538
+ suiteAccountId: string,
1539
+ ): Promise<
1540
+ | Readonly<{ kind: "receipt"; receipt: SuiteLinkReceipt }>
1541
+ | Readonly<{ kind: "rejected"; status: 400 | 401 | 403 | 409 | 410 | 413 }>
1542
+ > {
1543
+ const controller = new AbortController();
1544
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
1545
+ try {
1546
+ const response = await fetchImplementation(
1547
+ provider.identityLinkReceiptEndpoint,
1548
+ {
1549
+ body: JSON.stringify(proof),
1550
+ headers: {
1551
+ accept: "application/json",
1552
+ authorization: `Bearer ${accessToken}`,
1553
+ "content-type": "application/json",
1554
+ },
1555
+ method: "POST",
1556
+ redirect: "manual",
1557
+ signal: controller.signal,
1558
+ },
1559
+ );
1560
+ if (response.headers.has("location")) {
1561
+ throw new Error("Identity-link receipt endpoint redirected.");
1562
+ }
1563
+ if ([400, 401, 403, 409, 410, 413].includes(response.status)) {
1564
+ await consumeBoundedBody(response, 16_384);
1565
+ return {
1566
+ kind: "rejected",
1567
+ status: response.status as 400 | 401 | 403 | 409 | 410 | 413,
1568
+ };
1569
+ }
1570
+ if (response.status !== 200) {
1571
+ throw new Error("Identity-link receipt endpoint failed.");
1572
+ }
1573
+ const value = await readBoundedJson(response, 16_384);
1574
+ const receipt = parseIdentityLinkReceipt(
1575
+ value,
1576
+ proof,
1577
+ suiteAccountId,
1578
+ now(),
1579
+ );
1580
+ if (receipt === null) {
1581
+ throw new Error("Identity-link receipt was invalid.");
1582
+ }
1583
+ return { kind: "receipt", receipt };
1584
+ } finally {
1585
+ clearTimeout(timeout);
1586
+ }
1587
+ }
1588
+
1589
+ async function loadSession(request: Request): Promise<StoredSession | null> {
1590
+ const sealed = requestCookie(request, names.session);
1591
+ if (sealed === null) return null;
1592
+ return parseSession(
1593
+ await unsealCookie(sealed, await key, "session"),
1594
+ configuration,
1595
+ consumer,
1596
+ environment,
1597
+ receiptKeyVersion,
1598
+ now(),
1599
+ );
1600
+ }
1601
+
1602
+ async function startAuthorization(
1603
+ request: Request,
1604
+ freshInput?: unknown,
1605
+ requireFresh = false,
1606
+ ): Promise<Response> {
1607
+ if (!exactRequest(request, siteUrl, "/api/suite-auth/start", "GET")) {
1608
+ return failure("OIDC_START_REJECTED", 403);
1609
+ }
1610
+ const requestUrl = new URL(request.url);
1611
+ const returnTo = parseReturnPath(
1612
+ requestUrl.searchParams.get("return_to"),
1613
+ );
1614
+ if (returnTo === null) return failure("OIDC_RETURN_REJECTED", 400);
1615
+ const issuedAtMs = now();
1616
+ const fresh = requireFresh
1617
+ ? parseFreshAuthenticationInput(freshInput, issuedAtMs)
1618
+ : null;
1619
+ if (requireFresh && fresh === null) return failure("OIDC_FRESH_INPUT_INVALID", 400);
1620
+ const verifier = randomValue(48, randomBytes);
1621
+ const transaction: Transaction = {
1622
+ consumer,
1623
+ environment,
1624
+ expiresAtMs: fresh?.expiresAtMs ?? issuedAtMs + TRANSACTION_TTL_MS,
1625
+ issuedAtMs,
1626
+ nonce: randomValue(32, randomBytes),
1627
+ returnTo,
1628
+ state: randomValue(32, randomBytes),
1629
+ verifier,
1630
+ ...(fresh === null ? { version: 1 as const } : { version: 2 as const, context: fresh.context }),
1631
+ };
1632
+ const authorize = new URL(provider.authorizationEndpoint);
1633
+ authorize.searchParams.set("client_id", configuration.clientId);
1634
+ authorize.searchParams.set("code_challenge", await sha256Base64Url(verifier));
1635
+ authorize.searchParams.set("code_challenge_method", "S256");
1636
+ authorize.searchParams.set("nonce", transaction.nonce);
1637
+ // Ordinary authorization never forces a fresh sign-in: a live Accounts
1638
+ // session already satisfies the checked client. The provider-side hook
1639
+ // still forces `prompt=login` for a session that lacks the required
1640
+ // email-OTP method, so nothing here weakens the method boundary.
1641
+ if (requireFresh) {
1642
+ authorize.searchParams.set("prompt", "login");
1643
+ const notBefore = fresh?.authenticationNotBeforeMs;
1644
+ authorize.searchParams.set(
1645
+ "max_age",
1646
+ String(notBefore === undefined ? 0 : Math.max(0, Math.ceil((issuedAtMs - notBefore) / 1_000))),
1647
+ );
1648
+ }
1649
+ authorize.searchParams.set("redirect_uri", configuration.callbackUrl);
1650
+ authorize.searchParams.set("resource", provider.resource);
1651
+ authorize.searchParams.set("response_type", "code");
1652
+ authorize.searchParams.set(
1653
+ "scope",
1654
+ suiteAccountsCurrentConsumerRequiresEmailOtp(consumer)
1655
+ ? "openid profile email offline_access"
1656
+ : "openid offline_access",
1657
+ );
1658
+ authorize.searchParams.set("state", transaction.state);
1659
+ const sealed = await sealCookie(
1660
+ transaction,
1661
+ await key,
1662
+ "transaction",
1663
+ randomBytes,
1664
+ );
1665
+ return new Response(null, {
1666
+ headers: {
1667
+ "cache-control": "no-store",
1668
+ location: authorize.href,
1669
+ "set-cookie": setCookie(
1670
+ names.transaction,
1671
+ sealed,
1672
+ names.secure,
1673
+ Math.ceil((transaction.expiresAtMs - issuedAtMs) / 1_000),
1674
+ ),
1675
+ },
1676
+ status: 302,
1677
+ });
1678
+ }
1679
+
1680
+ async function start(request: Request): Promise<Response> {
1681
+ return await startAuthorization(request);
1682
+ }
1683
+
1684
+ async function startFreshAuthentication(request: Request, input: unknown): Promise<Response> {
1685
+ try {
1686
+ return await startAuthorization(request, input, true);
1687
+ } catch {
1688
+ return failure("OIDC_FRESH_START_FAILED", 400);
1689
+ }
1690
+ }
1691
+
1692
+ async function completeAuthorization(
1693
+ request: Request,
1694
+ requireFresh: boolean,
1695
+ ): Promise<AuthorizationResult> {
1696
+ const clear = clearCookie(names.transaction, names.secure);
1697
+ const reject = (code: string, status: number): AuthorizationResult => ({
1698
+ kind: "rejected", response: failure(code, status, [clear]),
1699
+ });
1700
+ if (
1701
+ !exactCallbackRequest(request, siteUrl)
1702
+ ) {
1703
+ return reject("OIDC_CALLBACK_REJECTED", 403);
1704
+ }
1705
+ const sealed = requestCookie(request, names.transaction);
1706
+ const transaction = sealed === null
1707
+ ? null
1708
+ : parseTransaction(
1709
+ await unsealCookie(sealed, await key, "transaction"),
1710
+ consumer,
1711
+ environment,
1712
+ now(),
1713
+ );
1714
+ if (transaction === null || (transaction.version === 2) !== requireFresh) {
1715
+ return reject("OIDC_TRANSACTION_INVALID", 400);
1716
+ }
1717
+ const url = new URL(request.url);
1718
+ const states = url.searchParams.getAll("state");
1719
+ const codes = url.searchParams.getAll("code");
1720
+ const errors = url.searchParams.getAll("error");
1721
+ if (
1722
+ states.length !== 1
1723
+ || states[0] !== transaction.state
1724
+ || errors.length > 0
1725
+ || codes.length !== 1
1726
+ || !boundedString(codes[0], 1, MAX_CODE_BYTES)
1727
+ ) {
1728
+ return reject("OIDC_CALLBACK_INVALID", 400);
1729
+ }
1730
+ try {
1731
+ await discovery();
1732
+ const tokens = await tokenRequest(new URLSearchParams({
1733
+ client_id: configuration.clientId,
1734
+ code: codes[0],
1735
+ code_verifier: transaction.verifier,
1736
+ grant_type: "authorization_code",
1737
+ redirect_uri: configuration.callbackUrl,
1738
+ resource: provider.resource,
1739
+ }), true);
1740
+ if (tokens.idToken === null) {
1741
+ return reject("OIDC_TOKEN_INVALID", 502);
1742
+ }
1743
+ const providerKeys = await jwks();
1744
+ const identity = await verifiedIdToken({
1745
+ configuration,
1746
+ expectedNonce: transaction.nonce,
1747
+ idToken: tokens.idToken,
1748
+ jwks: providerKeys,
1749
+ nowMs: now(),
1750
+ ...(transaction.version === 2 ? { freshStartedAtMs: transaction.issuedAtMs } : {}),
1751
+ });
1752
+ if (identity === null) {
1753
+ return reject("OIDC_TOKEN_INVALID", 502);
1754
+ }
1755
+ const verifiedSession = await verifiedSessionFromAccessToken({
1756
+ accessToken: tokens.accessToken,
1757
+ configuration,
1758
+ environment,
1759
+ expectedAccountId: identity.suiteAccountId,
1760
+ expectedProfile: identity.profile,
1761
+ expectedSubject: identity.subject,
1762
+ jwks: providerKeys,
1763
+ nonce: transaction.nonce,
1764
+ nowMs: now(),
1765
+ profileTransition: "exact",
1766
+ refreshToken: tokens.refreshToken,
1767
+ requireFresh,
1768
+ });
1769
+ if (verifiedSession === null) {
1770
+ return reject("OIDC_TOKEN_INVALID", 502);
1771
+ }
1772
+ const session: StoredSession = {
1773
+ ...verifiedSession,
1774
+ pendingEntitlementReceipt: await entitlementReceipt(
1775
+ tokens.accessToken,
1776
+ verifiedSession.suiteAccountId,
1777
+ ),
1778
+ };
1779
+ const sessionCookie = await sealCookie(
1780
+ session,
1781
+ await key,
1782
+ "session",
1783
+ randomBytes,
1784
+ );
1785
+ // Recheck after every provider call and cookie operation. Token clock skew
1786
+ // allowed for ordinary login never extends fresh-action validity.
1787
+ let authentication: SuiteOidcFreshAuthentication | null = null;
1788
+ if (transaction.version === 2) {
1789
+ const completedAtMs = now();
1790
+ const times = identity.freshTimes;
1791
+ const expiresAtMs = Math.min(
1792
+ transaction.expiresAtMs,
1793
+ times?.expiresAtMs ?? 0,
1794
+ session.accessTokenExpiresAtMs,
1795
+ );
1796
+ if (times === null || !safeInteger(completedAtMs)
1797
+ || completedAtMs < transaction.issuedAtMs
1798
+ || times.authenticatedAtMs > completedAtMs
1799
+ || !safeInteger(expiresAtMs) || expiresAtMs <= completedAtMs) {
1800
+ return reject("OIDC_FRESH_AUTHENTICATION_INVALID", 502);
1801
+ }
1802
+ authentication = Object.freeze({
1803
+ authenticatedAtMs: times.authenticatedAtMs,
1804
+ context: transaction.context,
1805
+ expiresAtMs,
1806
+ startedAtMs: transaction.issuedAtMs,
1807
+ suiteAccountId: identity.suiteAccountId,
1808
+ });
1809
+ }
1810
+ const response = createOidcContinuationResponse(
1811
+ transaction.returnTo,
1812
+ randomValue(24, randomBytes),
1813
+ [
1814
+ clear,
1815
+ setCookie(
1816
+ names.session,
1817
+ sessionCookie,
1818
+ names.secure,
1819
+ SESSION_TTL_MS / 1_000,
1820
+ ),
1821
+ ],
1822
+ );
1823
+ return authentication === null
1824
+ ? { kind: "ordinary", response }
1825
+ : Object.freeze({ kind: "authenticated", authentication, response });
1826
+ } catch {
1827
+ return reject("OIDC_UPSTREAM_FAILED", 502);
1828
+ }
1829
+ }
1830
+
1831
+ async function callback(request: Request): Promise<Response> {
1832
+ return (await completeAuthorization(request, false)).response;
1833
+ }
1834
+
1835
+ async function completeFreshAuthentication(request: Request): Promise<SuiteOidcFreshAuthenticationResult> {
1836
+ const result = await completeAuthorization(request, true);
1837
+ return result.kind === "ordinary"
1838
+ ? { kind: "rejected", response: failure("OIDC_TRANSACTION_INVALID", 400) }
1839
+ : result;
1840
+ }
1841
+
1842
+ async function currentSession(request: Request): Promise<Response> {
1843
+ if (!exactRequest(
1844
+ request,
1845
+ siteUrl,
1846
+ "/api/suite-auth/session",
1847
+ "GET",
1848
+ )) {
1849
+ return failure("OIDC_SESSION_REJECTED", 403);
1850
+ }
1851
+ const session = await loadSession(request);
1852
+ if (session === null) {
1853
+ return jsonResponse({ kind: "signed_out" }, 200, [
1854
+ clearCookie(names.session, names.secure),
1855
+ ]);
1856
+ }
1857
+ const nowMs = now();
1858
+ const view = currentView(session, nowMs);
1859
+ return view === null
1860
+ || session.accessTokenExpiresAtMs - nowMs
1861
+ < SUITE_OIDC_EARLY_REFRESH_WINDOW_MS
1862
+ ? jsonResponse({ kind: "refresh_required" }, 200)
1863
+ : jsonResponse({ kind: "signed_in", session: view }, 200);
1864
+ }
1865
+
1866
+ async function serverSessionState(
1867
+ request: Request,
1868
+ ): Promise<Readonly<{
1869
+ session: StoredSession;
1870
+ verifiedEmail: string | null;
1871
+ view: SuiteOidcSessionView;
1872
+ }> | null> {
1873
+ let requestOrigin: string;
1874
+ try {
1875
+ requestOrigin = new URL(request.url).origin;
1876
+ } catch {
1877
+ return null;
1878
+ }
1879
+ const fetchSite = request.headers.get("sec-fetch-site");
1880
+ if (
1881
+ requestOrigin !== siteUrl
1882
+ || (
1883
+ fetchSite !== null
1884
+ && fetchSite.toLowerCase() !== "same-origin"
1885
+ )
1886
+ ) {
1887
+ return null;
1888
+ }
1889
+ const session = await loadSession(request);
1890
+ const view = session === null ? null : currentView(session, now());
1891
+ if (session === null || view === null) return null;
1892
+ let verifiedEmail: string | null = null;
1893
+ if (
1894
+ suiteAccountsCurrentConsumerRequiresEmailOtp(consumer)
1895
+ ) {
1896
+ let userInfo: unknown;
1897
+ try {
1898
+ userInfo = await providerJson(
1899
+ provider.userInfoAudience,
1900
+ {
1901
+ headers: {
1902
+ accept: "application/json",
1903
+ authorization: `Bearer ${session.accessToken}`,
1904
+ },
1905
+ method: "GET",
1906
+ },
1907
+ fetchImplementation,
1908
+ timeoutMs,
1909
+ );
1910
+ } catch {
1911
+ return null;
1912
+ }
1913
+ if (!liveUserInfoMatchesSession(
1914
+ userInfo,
1915
+ configuration,
1916
+ session,
1917
+ view,
1918
+ )) return null;
1919
+ verifiedEmail = verifiedEmailFromUserInfo(userInfo);
1920
+ }
1921
+ return { session, verifiedEmail, view };
1922
+ }
1923
+
1924
+ async function serverAccountSession(
1925
+ request: Request,
1926
+ ): Promise<SuiteOidcServerAccountSession | null> {
1927
+ const state = await serverSessionState(request);
1928
+ return state === null
1929
+ ? null
1930
+ : {
1931
+ accessToken: state.session.accessToken,
1932
+ accessTokenExpiresAtMs: state.session.accessTokenExpiresAtMs,
1933
+ suiteAccountId: state.view.suiteAccountId,
1934
+ };
1935
+ }
1936
+
1937
+ async function serverVerifiedAccountEmail(
1938
+ request: Request,
1939
+ ): Promise<SuiteOidcServerVerifiedAccountEmail | null> {
1940
+ const state = await serverSessionState(request);
1941
+ return state === null || state.verifiedEmail === null
1942
+ ? null
1943
+ : {
1944
+ accessTokenExpiresAtMs: state.session.accessTokenExpiresAtMs,
1945
+ email: state.verifiedEmail,
1946
+ suiteAccountId: state.view.suiteAccountId,
1947
+ };
1948
+ }
1949
+
1950
+ async function serverSession(
1951
+ request: Request,
1952
+ ): Promise<SuiteOidcServerSession | null> {
1953
+ const state = await serverSessionState(request);
1954
+ return state?.view.profileComplete === true
1955
+ ? {
1956
+ accessToken: state.session.accessToken,
1957
+ accessTokenExpiresAtMs: state.session.accessTokenExpiresAtMs,
1958
+ suiteAccountId: state.view.suiteAccountId,
1959
+ username: state.view.username,
1960
+ }
1961
+ : null;
1962
+ }
1963
+
1964
+ async function serverVerifiedEmail(
1965
+ request: Request,
1966
+ ): Promise<SuiteOidcServerVerifiedEmail | null> {
1967
+ const state = await serverSessionState(request);
1968
+ return state === null
1969
+ || state.verifiedEmail === null
1970
+ || state.view.profileComplete !== true
1971
+ ? null
1972
+ : {
1973
+ accessTokenExpiresAtMs: state.session.accessTokenExpiresAtMs,
1974
+ email: state.verifiedEmail,
1975
+ suiteAccountId: state.view.suiteAccountId,
1976
+ username: state.view.username,
1977
+ };
1978
+ }
1979
+
1980
+ async function acknowledgeEntitlementReceipt(
1981
+ request: Request,
1982
+ ): Promise<Response> {
1983
+ const clear = clearCookie(names.session, names.secure);
1984
+ if (!exactRequest(
1985
+ request,
1986
+ siteUrl,
1987
+ "/api/suite-auth/entitlements/ack",
1988
+ "POST",
1989
+ )) {
1990
+ return failure("OIDC_ENTITLEMENT_ACK_REJECTED", 403);
1991
+ }
1992
+ const session = await loadSession(request);
1993
+ if (session === null) {
1994
+ return failure("OIDC_SESSION_INVALID", 401, [clear]);
1995
+ }
1996
+ let body: unknown;
1997
+ try {
1998
+ body = await readBoundedRequestJson(request, 512);
1999
+ } catch {
2000
+ return failure("OIDC_ENTITLEMENT_ACK_INVALID", 400);
2001
+ }
2002
+ if (
2003
+ !isRecord(body)
2004
+ || Object.keys(body).length !== 1
2005
+ || !boundedString(body["signature"], 43, 43)
2006
+ || session.pendingEntitlementReceipt?.signature !== body["signature"]
2007
+ ) {
2008
+ return failure("OIDC_ENTITLEMENT_ACK_INVALID", 400);
2009
+ }
2010
+ const updated: StoredSession = {
2011
+ ...session,
2012
+ pendingEntitlementReceipt: null,
2013
+ };
2014
+ const sealed = await sealCookie(
2015
+ updated,
2016
+ await key,
2017
+ "session",
2018
+ randomBytes,
2019
+ );
2020
+ return jsonResponse({ acknowledged: true }, 200, [
2021
+ setCookie(names.session, sealed, names.secure, SESSION_TTL_MS / 1_000),
2022
+ ]);
2023
+ }
2024
+
2025
+ async function linkReceipt(request: Request): Promise<Response> {
2026
+ if (!exactRequest(
2027
+ request,
2028
+ siteUrl,
2029
+ "/api/suite-auth/link-receipt",
2030
+ "POST",
2031
+ )) {
2032
+ return failure("OIDC_LINK_RECEIPT_REJECTED", 403);
2033
+ }
2034
+ const session = await loadSession(request);
2035
+ if (session === null || session.accessTokenExpiresAtMs <= now()) {
2036
+ return failure("OIDC_REFRESH_REQUIRED", 401);
2037
+ }
2038
+ let value: unknown;
2039
+ try {
2040
+ value = await readBoundedRequestJson(request, 16_384);
2041
+ } catch {
2042
+ return failure("OIDC_LINK_PROOF_INVALID", 400);
2043
+ }
2044
+ const proof = parseIdentityLinkReceiptRequest(
2045
+ value,
2046
+ consumer,
2047
+ environment,
2048
+ now(),
2049
+ );
2050
+ if (proof === null) {
2051
+ return failure("OIDC_LINK_PROOF_INVALID", 400);
2052
+ }
2053
+ try {
2054
+ const result = await requestIdentityLinkReceipt(
2055
+ session.accessToken,
2056
+ proof,
2057
+ session.suiteAccountId,
2058
+ );
2059
+ return result.kind === "receipt"
2060
+ ? jsonResponse({ receipt: result.receipt }, 200)
2061
+ : failure("OIDC_LINK_RECEIPT_REJECTED", result.status);
2062
+ } catch {
2063
+ return failure("OIDC_LINK_RECEIPT_FAILED", 502);
2064
+ }
2065
+ }
2066
+
2067
+ async function refreshSession(request: Request): Promise<Response> {
2068
+ const clear = clearCookie(names.session, names.secure);
2069
+ if (!exactRequest(
2070
+ request,
2071
+ siteUrl,
2072
+ "/api/suite-auth/refresh",
2073
+ "POST",
2074
+ )) {
2075
+ return failure("OIDC_REFRESH_REJECTED", 403);
2076
+ }
2077
+ const session = await loadSession(request);
2078
+ if (session === null) {
2079
+ return failure("OIDC_SESSION_INVALID", 401, [clear]);
2080
+ }
2081
+ try {
2082
+ await discovery();
2083
+ const tokens = await tokenRequest(new URLSearchParams({
2084
+ client_id: configuration.clientId,
2085
+ grant_type: "refresh_token",
2086
+ refresh_token: session.refreshToken,
2087
+ resource: provider.resource,
2088
+ }), false);
2089
+ if (tokens.refreshToken === session.refreshToken) {
2090
+ return failure("OIDC_REFRESH_ROTATION_INVALID", 502, [clear]);
2091
+ }
2092
+ const verifiedRefresh = await verifiedSessionFromAccessToken({
2093
+ accessToken: tokens.accessToken,
2094
+ configuration,
2095
+ environment,
2096
+ expectedAccountId: session.suiteAccountId,
2097
+ expectedProfile: session.profileComplete
2098
+ ? {
2099
+ profileComplete: true,
2100
+ profileRevision: "username-v1",
2101
+ username: session.username,
2102
+ }
2103
+ : {
2104
+ profileComplete: false,
2105
+ profileRevision: session.profileRevision,
2106
+ username: null,
2107
+ },
2108
+ expectedSubject: session.subject,
2109
+ jwks: await jwks(),
2110
+ nonce: session.nonce,
2111
+ nowMs: now(),
2112
+ profileTransition: "forward",
2113
+ refreshToken: tokens.refreshToken,
2114
+ });
2115
+ if (verifiedRefresh === null) {
2116
+ return failure("OIDC_REFRESH_TOKEN_INVALID", 502, [clear]);
2117
+ }
2118
+ const refreshed: StoredSession = {
2119
+ ...verifiedRefresh,
2120
+ pendingEntitlementReceipt: await entitlementReceipt(
2121
+ tokens.accessToken,
2122
+ verifiedRefresh.suiteAccountId,
2123
+ ),
2124
+ };
2125
+ const view = currentView(refreshed, now());
2126
+ if (view === null) {
2127
+ return failure("OIDC_REFRESH_TOKEN_EXPIRED", 502, [clear]);
2128
+ }
2129
+ const sealed = await sealCookie(
2130
+ refreshed,
2131
+ await key,
2132
+ "session",
2133
+ randomBytes,
2134
+ );
2135
+ return jsonResponse({ kind: "signed_in", session: view }, 200, [
2136
+ setCookie(
2137
+ names.session,
2138
+ sealed,
2139
+ names.secure,
2140
+ SESSION_TTL_MS / 1_000,
2141
+ ),
2142
+ ]);
2143
+ } catch {
2144
+ return failure("OIDC_REFRESH_FAILED", 502, [clear]);
2145
+ }
2146
+ }
2147
+
2148
+ async function signOut(request: Request): Promise<Response> {
2149
+ if (!exactRequest(
2150
+ request,
2151
+ siteUrl,
2152
+ "/api/suite-auth/sign-out",
2153
+ "POST",
2154
+ )) {
2155
+ return failure("OIDC_SIGN_OUT_REJECTED", 403);
2156
+ }
2157
+ const session = await loadSession(request);
2158
+ if (session !== null) {
2159
+ try {
2160
+ await revokeRefreshToken(session.refreshToken);
2161
+ } catch {
2162
+ // Local token custody still ends when the provider is unavailable.
2163
+ }
2164
+ }
2165
+ return jsonResponse({ kind: "signed_out" }, 200, [
2166
+ clearCookie(names.session, names.secure),
2167
+ clearCookie(names.transaction, names.secure),
2168
+ ]);
2169
+ }
2170
+
2171
+ async function handle(request: Request): Promise<Response> {
2172
+ let path: string;
2173
+ try {
2174
+ path = new URL(request.url).pathname;
2175
+ } catch {
2176
+ return failure("OIDC_ROUTE_NOT_FOUND", 404);
2177
+ }
2178
+ if (request.method === "GET") {
2179
+ if (path === "/api/suite-auth/start") return await start(request);
2180
+ if (path === "/api/suite-auth/callback") return await callback(request);
2181
+ if (path === "/api/suite-auth/session") {
2182
+ return await currentSession(request);
2183
+ }
2184
+ }
2185
+ if (request.method === "POST") {
2186
+ if (path === "/api/suite-auth/refresh") {
2187
+ return await refreshSession(request);
2188
+ }
2189
+ if (path === "/api/suite-auth/sign-out") return await signOut(request);
2190
+ if (path === "/api/suite-auth/link-receipt") {
2191
+ return await linkReceipt(request);
2192
+ }
2193
+ if (path === "/api/suite-auth/entitlements/ack") {
2194
+ return await acknowledgeEntitlementReceipt(request);
2195
+ }
2196
+ }
2197
+ return failure("OIDC_ROUTE_NOT_FOUND", 404);
2198
+ }
2199
+
2200
+ return Object.freeze({
2201
+ acknowledgeEntitlementReceipt,
2202
+ callback,
2203
+ completeFreshAuthentication,
2204
+ configuration,
2205
+ currentSession,
2206
+ handle,
2207
+ linkReceipt,
2208
+ refreshSession,
2209
+ serverAccountSession,
2210
+ serverVerifiedAccountEmail,
2211
+ serverSession,
2212
+ serverVerifiedEmail,
2213
+ signOut,
2214
+ start,
2215
+ startFreshAuthentication,
2216
+ });
2217
+ }