@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,478 @@
1
+ import {
2
+ base64url,
3
+ createLocalJWKSet,
4
+ decodeProtectedHeader,
5
+ importJWK,
6
+ jwtVerify,
7
+ type JSONWebKeySet,
8
+ type JWTPayload,
9
+ } from "jose";
10
+
11
+ import type { SuiteJwtClaims } from "./identity/principals.js";
12
+
13
+ import { deepFreeze } from "./immutable.js";
14
+ import {
15
+ verifySuiteEntitlementToken,
16
+ type VerifiedSuiteEntitlements,
17
+ } from "./entitlements.js";
18
+ import {
19
+ type SuiteAccountsCurrentOAuthConsumerId,
20
+ type SuiteAccountsRemoteEnvironment,
21
+ } from "./registry.js";
22
+ import {
23
+ suiteAccountsCurrentOidcClientRegistration,
24
+ suiteAccountsOidcProviderConfiguration,
25
+ } from "./urls.js";
26
+
27
+ const MAX_TOKEN_BYTES = 16_384;
28
+ const MAX_JWKS_BYTES = 64 * 1_024;
29
+ const DEFAULT_FETCH_TIMEOUT_MS = 5_000;
30
+ const DEFAULT_JWKS_CACHE_TTL_MS = 5 * 60_000;
31
+ const DEFAULT_JWKS_REFRESH_COOLDOWN_MS = 60_000;
32
+ const ALGORITHMS = ["ES256"] as const;
33
+
34
+ type FetchImplementation = (
35
+ input: RequestInfo | URL,
36
+ init?: RequestInit,
37
+ ) => Promise<Response>;
38
+
39
+ export type SuiteBearerVerifierConfiguration = Readonly<{
40
+ audiences: readonly [string, string];
41
+ clientId: string;
42
+ issuer: string;
43
+ jwksEndpoint: string;
44
+ }>;
45
+
46
+ export type VerifiedSuiteBearer = Readonly<{
47
+ claims: SuiteJwtClaims;
48
+ entitlements: VerifiedSuiteEntitlements;
49
+ kind: "verified";
50
+ }>;
51
+
52
+ export type SuiteBearerVerification =
53
+ | VerifiedSuiteBearer
54
+ | Readonly<{
55
+ kind: "invalid";
56
+ reason:
57
+ | "audience"
58
+ | "authorization"
59
+ | "claims"
60
+ | "client"
61
+ | "entitlements"
62
+ | "signature"
63
+ | "time";
64
+ }>
65
+ | Readonly<{
66
+ kind: "unavailable";
67
+ reason: "jwks";
68
+ }>;
69
+
70
+ export type SuiteBearerVerifier = Readonly<{
71
+ configuration: SuiteBearerVerifierConfiguration;
72
+ verify(bearerToken: string): Promise<SuiteBearerVerification>;
73
+ }>;
74
+
75
+ export type CreateSuiteBearerVerifierOptions = Readonly<{
76
+ consumer: SuiteAccountsCurrentOAuthConsumerId;
77
+ environment: SuiteAccountsRemoteEnvironment;
78
+ fetch?: FetchImplementation;
79
+ fetchTimeoutMs?: number;
80
+ jwksCacheTtlMs?: number;
81
+ jwksRefreshCooldownMs?: number;
82
+ now?: () => number;
83
+ }>;
84
+
85
+ export type SuiteBearerAuthorizationResult =
86
+ | Readonly<{ ok: true; value: string }>
87
+ | Readonly<{ error: "invalid-authorization"; ok: false }>;
88
+
89
+ type CachedJwks = Readonly<{
90
+ expiresAtMs: number;
91
+ value: JSONWebKeySet;
92
+ }>;
93
+
94
+ type SignatureResult =
95
+ | Readonly<{ kind: "verified"; payload: JWTPayload }>
96
+ | Readonly<{ kind: "invalid"; reason: "signature" | "time" }>
97
+ | Readonly<{ kind: "unavailable"; reason: "jwks" }>;
98
+
99
+ function isRecord(value: unknown): value is Record<string, unknown> {
100
+ return typeof value === "object" && value !== null && !Array.isArray(value);
101
+ }
102
+
103
+ function safeInteger(value: unknown): value is number {
104
+ return typeof value === "number"
105
+ && Number.isSafeInteger(value)
106
+ && value >= 0;
107
+ }
108
+
109
+ function containsAsciiControl(value: string): boolean {
110
+ for (let index = 0; index < value.length; index += 1) {
111
+ const code = value.charCodeAt(index);
112
+ if (code <= 31 || code === 127) return true;
113
+ }
114
+ return false;
115
+ }
116
+
117
+ function boundedString(
118
+ value: unknown,
119
+ minimum: number,
120
+ maximum: number,
121
+ ): value is string {
122
+ return typeof value === "string"
123
+ && value.length >= minimum
124
+ && value.length <= maximum
125
+ && value.trim() === value
126
+ && !containsAsciiControl(value);
127
+ }
128
+
129
+ function compactJwt(value: string): boolean {
130
+ return value.length >= 32
131
+ && value.length <= MAX_TOKEN_BYTES
132
+ && /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u.test(value);
133
+ }
134
+
135
+ /**
136
+ * Parse one exact HTTP Bearer credential without accepting lists, alternate
137
+ * schemes, leading/trailing whitespace, or control characters.
138
+ */
139
+ export function parseSuiteBearerAuthorization(
140
+ value: unknown,
141
+ ): SuiteBearerAuthorizationResult {
142
+ if (
143
+ typeof value !== "string"
144
+ || value.length > "Bearer ".length + MAX_TOKEN_BYTES
145
+ || !value.startsWith("Bearer ")
146
+ || value.includes(",")
147
+ ) {
148
+ return { error: "invalid-authorization", ok: false };
149
+ }
150
+ const token = value.slice("Bearer ".length);
151
+ return compactJwt(token)
152
+ ? { ok: true, value: token }
153
+ : { error: "invalid-authorization", ok: false };
154
+ }
155
+
156
+ function canonicalP256Coordinate(value: unknown): value is string {
157
+ if (
158
+ typeof value !== "string"
159
+ || !/^[A-Za-z0-9_-]{43}$/u.test(value)
160
+ ) {
161
+ return false;
162
+ }
163
+ try {
164
+ const decoded = base64url.decode(value);
165
+ return decoded.byteLength === 32 && base64url.encode(decoded) === value;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ async function validJwks(value: unknown): Promise<JSONWebKeySet | null> {
172
+ if (
173
+ !isRecord(value)
174
+ || !Array.isArray(value["keys"])
175
+ || value["keys"].length < 1
176
+ || value["keys"].length > 8
177
+ ) {
178
+ return null;
179
+ }
180
+ const kids = new Set<string>();
181
+ for (const key of value["keys"]) {
182
+ if (
183
+ !isRecord(key)
184
+ || !boundedString(key["kid"], 1, 128)
185
+ || kids.has(key["kid"])
186
+ || key["kty"] !== "EC"
187
+ || key["crv"] !== "P-256"
188
+ || (key["alg"] !== undefined && key["alg"] !== "ES256")
189
+ || (key["use"] !== undefined && key["use"] !== "sig")
190
+ || !canonicalP256Coordinate(key["x"])
191
+ || !canonicalP256Coordinate(key["y"])
192
+ || key["d"] !== undefined
193
+ ) {
194
+ return null;
195
+ }
196
+ try {
197
+ const imported = await importJWK(key, "ES256");
198
+ if (
199
+ imported instanceof Uint8Array
200
+ || imported.type !== "public"
201
+ || !imported.usages.includes("verify")
202
+ ) {
203
+ return null;
204
+ }
205
+ } catch {
206
+ return null;
207
+ }
208
+ kids.add(key["kid"]);
209
+ }
210
+ return value as unknown as JSONWebKeySet;
211
+ }
212
+
213
+ async function readBoundedJson(
214
+ response: Response,
215
+ maximumBytes: number,
216
+ ): Promise<unknown> {
217
+ const declared = response.headers.get("content-length");
218
+ if (
219
+ declared !== null
220
+ && (
221
+ !/^(?:0|[1-9]\d*)$/u.test(declared)
222
+ || Number(declared) > maximumBytes
223
+ )
224
+ ) {
225
+ throw new Error("The JWKS response was too large.");
226
+ }
227
+ if (response.body === null) throw new Error("The JWKS response was empty.");
228
+ const reader = response.body.getReader();
229
+ const chunks: Uint8Array[] = [];
230
+ let length = 0;
231
+ while (true) {
232
+ const result = await reader.read();
233
+ if (result.done) break;
234
+ if (result.value.byteLength > maximumBytes - length) {
235
+ await reader.cancel();
236
+ throw new Error("The JWKS response was too large.");
237
+ }
238
+ chunks.push(result.value);
239
+ length += result.value.byteLength;
240
+ }
241
+ const bytes = new Uint8Array(length);
242
+ let offset = 0;
243
+ for (const chunk of chunks) {
244
+ bytes.set(chunk, offset);
245
+ offset += chunk.byteLength;
246
+ }
247
+ return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
248
+ }
249
+
250
+ function exactAudience(
251
+ value: unknown,
252
+ audiences: readonly [string, string],
253
+ ): boolean {
254
+ if (!Array.isArray(value) || value.length !== 2) return false;
255
+ const actual = new Set(value);
256
+ return actual.size === 2
257
+ && actual.has(audiences[0])
258
+ && actual.has(audiences[1]);
259
+ }
260
+
261
+ function jwtFailureReason(error: unknown): "signature" | "time" {
262
+ if (!isRecord(error)) return "signature";
263
+ const code = error["code"];
264
+ if (code === "ERR_JWT_EXPIRED") return "time";
265
+ if (
266
+ code === "ERR_JWT_CLAIM_VALIDATION_FAILED"
267
+ && (
268
+ error["claim"] === "exp"
269
+ || error["claim"] === "iat"
270
+ || error["claim"] === "nbf"
271
+ )
272
+ ) {
273
+ return "time";
274
+ }
275
+ return "signature";
276
+ }
277
+
278
+ function missingJwksKey(error: unknown): boolean {
279
+ return isRecord(error) && error["code"] === "ERR_JWKS_NO_MATCHING_KEY";
280
+ }
281
+
282
+ /**
283
+ * Build a server-only verifier for access tokens minted to one exact suite
284
+ * public client. Registry authority, not caller input, selects every trust
285
+ * endpoint and client binding.
286
+ */
287
+ export function createSuiteBearerVerifier(
288
+ options: CreateSuiteBearerVerifierOptions,
289
+ ): SuiteBearerVerifier {
290
+ const client = suiteAccountsCurrentOidcClientRegistration(
291
+ options.consumer,
292
+ options.environment,
293
+ );
294
+ if (client === null) {
295
+ throw new Error("The suite bearer consumer has no OAuth client.");
296
+ }
297
+ const provider =
298
+ suiteAccountsOidcProviderConfiguration(options.environment);
299
+ const configuration: SuiteBearerVerifierConfiguration = deepFreeze({
300
+ audiences: [provider.resource, provider.userInfoAudience],
301
+ clientId: client.clientId,
302
+ issuer: provider.issuer,
303
+ jwksEndpoint: provider.jwksEndpoint,
304
+ });
305
+ const fetchImplementation = options.fetch ?? fetch;
306
+ const fetchTimeoutMs = options.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
307
+ const jwksCacheTtlMs =
308
+ options.jwksCacheTtlMs ?? DEFAULT_JWKS_CACHE_TTL_MS;
309
+ const jwksRefreshCooldownMs = options.jwksRefreshCooldownMs
310
+ ?? DEFAULT_JWKS_REFRESH_COOLDOWN_MS;
311
+ if (
312
+ !safeInteger(fetchTimeoutMs)
313
+ || fetchTimeoutMs < 100
314
+ || fetchTimeoutMs > 30_000
315
+ ) {
316
+ throw new Error("The suite JWKS fetch timeout must be 100–30000ms.");
317
+ }
318
+ if (
319
+ !safeInteger(jwksCacheTtlMs)
320
+ || jwksCacheTtlMs < 1_000
321
+ || jwksCacheTtlMs > 24 * 60 * 60_000
322
+ ) {
323
+ throw new Error("The suite JWKS cache TTL must be 1000–86400000ms.");
324
+ }
325
+ if (
326
+ !safeInteger(jwksRefreshCooldownMs)
327
+ || jwksRefreshCooldownMs < 1_000
328
+ || jwksRefreshCooldownMs > 5 * 60_000
329
+ ) {
330
+ throw new Error("The suite JWKS refresh cooldown must be 1000–300000ms.");
331
+ }
332
+ const now = options.now ?? Date.now;
333
+ let cache: CachedJwks | null = null;
334
+ let lastForcedRefreshAtMs: number | null = null;
335
+ let pending: Promise<JSONWebKeySet> | null = null;
336
+
337
+ async function fetchJwks(): Promise<JSONWebKeySet> {
338
+ const controller = new AbortController();
339
+ const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs);
340
+ try {
341
+ const response = await fetchImplementation(configuration.jwksEndpoint, {
342
+ cache: "no-store",
343
+ headers: { accept: "application/json" },
344
+ method: "GET",
345
+ redirect: "manual",
346
+ signal: controller.signal,
347
+ });
348
+ if (
349
+ response.status !== 200
350
+ || response.headers.has("location")
351
+ || response.headers.get("content-type")?.split(";", 1)[0]?.trim()
352
+ .toLowerCase() !== "application/json"
353
+ ) {
354
+ throw new Error("The suite JWKS endpoint was unavailable.");
355
+ }
356
+ const value = await validJwks(
357
+ await readBoundedJson(response, MAX_JWKS_BYTES),
358
+ );
359
+ if (value === null) throw new Error("The suite JWKS was invalid.");
360
+ cache = {
361
+ expiresAtMs: now() + jwksCacheTtlMs,
362
+ value,
363
+ };
364
+ return value;
365
+ } finally {
366
+ clearTimeout(timeout);
367
+ }
368
+ }
369
+
370
+ async function jwks(forceRefresh = false): Promise<JSONWebKeySet> {
371
+ const nowMs = now();
372
+ if (!forceRefresh && cache !== null && cache.expiresAtMs > nowMs) {
373
+ return cache.value;
374
+ }
375
+ if (pending !== null) return await pending;
376
+ // A forced rotation probe commits only after the replacement has passed
377
+ // every JWKS check, leaving the still-live known-good set available.
378
+ pending = fetchJwks();
379
+ try {
380
+ return await pending;
381
+ } finally {
382
+ pending = null;
383
+ }
384
+ }
385
+
386
+ async function verifySignature(
387
+ token: string,
388
+ nowMs: number,
389
+ forceRefresh = false,
390
+ ): Promise<SignatureResult> {
391
+ let keySet: JSONWebKeySet;
392
+ try {
393
+ keySet = await jwks(forceRefresh);
394
+ } catch {
395
+ return { kind: "unavailable", reason: "jwks" };
396
+ }
397
+ try {
398
+ const result = await jwtVerify(
399
+ token,
400
+ createLocalJWKSet(keySet),
401
+ {
402
+ algorithms: [...ALGORITHMS],
403
+ clockTolerance: 30,
404
+ currentDate: new Date(nowMs),
405
+ maxTokenAge: "20m",
406
+ },
407
+ );
408
+ return { kind: "verified", payload: result.payload };
409
+ } catch (error) {
410
+ if (!forceRefresh && missingJwksKey(error)) {
411
+ if (
412
+ lastForcedRefreshAtMs !== null
413
+ && nowMs - lastForcedRefreshAtMs < jwksRefreshCooldownMs
414
+ ) {
415
+ return { kind: "invalid", reason: "signature" };
416
+ }
417
+ lastForcedRefreshAtMs = nowMs;
418
+ return await verifySignature(token, nowMs, true);
419
+ }
420
+ return { kind: "invalid", reason: jwtFailureReason(error) };
421
+ }
422
+ }
423
+
424
+ async function verify(
425
+ bearerToken: string,
426
+ ): Promise<SuiteBearerVerification> {
427
+ if (!compactJwt(bearerToken)) {
428
+ return { kind: "invalid", reason: "authorization" };
429
+ }
430
+ let header: ReturnType<typeof decodeProtectedHeader>;
431
+ try {
432
+ header = decodeProtectedHeader(bearerToken);
433
+ } catch {
434
+ return { kind: "invalid", reason: "authorization" };
435
+ }
436
+ if (
437
+ header.alg !== "ES256"
438
+ || !boundedString(header.kid, 1, 128)
439
+ ) {
440
+ return { kind: "invalid", reason: "signature" };
441
+ }
442
+ const nowMs = now();
443
+ if (!safeInteger(nowMs)) return { kind: "invalid", reason: "time" };
444
+ const signed = await verifySignature(bearerToken, nowMs);
445
+ if (signed.kind !== "verified") return signed;
446
+
447
+ const parsed = await verifySuiteEntitlementToken(bearerToken, {
448
+ expectedAudience: provider.resource,
449
+ expectedIssuer: provider.issuer,
450
+ nowMs,
451
+ verify: () => Promise.resolve(signed.payload),
452
+ });
453
+ if (parsed.kind !== "verified") {
454
+ return {
455
+ kind: "invalid",
456
+ reason: parsed.reason === "issuer" || parsed.reason === "signature"
457
+ ? "claims"
458
+ : parsed.reason,
459
+ };
460
+ }
461
+ if (!exactAudience(signed.payload.aud, configuration.audiences)) {
462
+ return { kind: "invalid", reason: "audience" };
463
+ }
464
+ if (
465
+ signed.payload["azp"] !== configuration.clientId
466
+ || signed.payload["suite_client_id"] !== configuration.clientId
467
+ ) {
468
+ return { kind: "invalid", reason: "client" };
469
+ }
470
+ return {
471
+ claims: parsed.claims,
472
+ entitlements: parsed.entitlements,
473
+ kind: "verified",
474
+ };
475
+ }
476
+
477
+ return Object.freeze({ configuration, verify });
478
+ }