@shipfox/api-auth 18.0.0 → 20.0.0

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 (151) hide show
  1. package/.turbo/turbo-build.log +17 -2
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +24 -9
  4. package/dist/core/administration.d.ts +15 -0
  5. package/dist/core/administration.d.ts.map +1 -1
  6. package/dist/core/administration.js +44 -2
  7. package/dist/core/administration.js.map +1 -1
  8. package/dist/core/auth.d.ts.map +1 -1
  9. package/dist/core/auth.js +5 -3
  10. package/dist/core/auth.js.map +1 -1
  11. package/dist/core/cimd.d.ts +39 -0
  12. package/dist/core/cimd.d.ts.map +1 -0
  13. package/dist/core/cimd.js +392 -0
  14. package/dist/core/cimd.js.map +1 -0
  15. package/dist/core/entities/agent-access.d.ts +75 -0
  16. package/dist/core/entities/agent-access.d.ts.map +1 -0
  17. package/dist/core/entities/agent-access.js +3 -0
  18. package/dist/core/entities/agent-access.js.map +1 -0
  19. package/dist/core/errors.d.ts +17 -0
  20. package/dist/core/errors.d.ts.map +1 -1
  21. package/dist/core/errors.js +32 -0
  22. package/dist/core/errors.js.map +1 -1
  23. package/dist/core/oauth-client-resolver.d.ts +54 -0
  24. package/dist/core/oauth-client-resolver.d.ts.map +1 -0
  25. package/dist/core/oauth-client-resolver.js +154 -0
  26. package/dist/core/oauth-client-resolver.js.map +1 -0
  27. package/dist/core/oauth-client.d.ts +39 -0
  28. package/dist/core/oauth-client.d.ts.map +1 -0
  29. package/dist/core/oauth-client.js +236 -0
  30. package/dist/core/oauth-client.js.map +1 -0
  31. package/dist/db/admin-user-moderation.d.ts.map +1 -1
  32. package/dist/db/admin-user-moderation.js +30 -26
  33. package/dist/db/admin-user-moderation.js.map +1 -1
  34. package/dist/db/admin-user-summary.d.ts +22 -0
  35. package/dist/db/admin-user-summary.d.ts.map +1 -1
  36. package/dist/db/admin-user-summary.js +75 -1
  37. package/dist/db/admin-user-summary.js.map +1 -1
  38. package/dist/db/admin-users.d.ts +11 -0
  39. package/dist/db/admin-users.d.ts.map +1 -1
  40. package/dist/db/admin-users.js +4 -1
  41. package/dist/db/admin-users.js.map +1 -1
  42. package/dist/db/agent-access-retention.d.ts +10 -0
  43. package/dist/db/agent-access-retention.d.ts.map +1 -0
  44. package/dist/db/agent-access-retention.js +11 -0
  45. package/dist/db/agent-access-retention.js.map +1 -0
  46. package/dist/db/agent-access.d.ts +255 -0
  47. package/dist/db/agent-access.d.ts.map +1 -0
  48. package/dist/db/agent-access.js +705 -0
  49. package/dist/db/agent-access.js.map +1 -0
  50. package/dist/db/db.d.ts +2604 -238
  51. package/dist/db/db.d.ts.map +1 -1
  52. package/dist/db/db.js +7 -0
  53. package/dist/db/db.js.map +1 -1
  54. package/dist/db/schema/agent-access.d.ts +1204 -0
  55. package/dist/db/schema/agent-access.d.ts.map +1 -0
  56. package/dist/db/schema/agent-access.js +267 -0
  57. package/dist/db/schema/agent-access.js.map +1 -0
  58. package/dist/db/schema/users.d.ts.map +1 -1
  59. package/dist/db/schema/users.js +8 -3
  60. package/dist/db/schema/users.js.map +1 -1
  61. package/dist/index.d.ts +11 -2
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +26 -2
  64. package/dist/index.js.map +1 -1
  65. package/dist/metrics/instance.d.ts +1 -1
  66. package/dist/metrics/instance.d.ts.map +1 -1
  67. package/dist/metrics/instance.js.map +1 -1
  68. package/dist/presentation/routes/administration.d.ts.map +1 -1
  69. package/dist/presentation/routes/administration.js +123 -20
  70. package/dist/presentation/routes/administration.js.map +1 -1
  71. package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -1
  72. package/dist/presentation/routes/email-verification/verify-email-resend.js +6 -1
  73. package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -1
  74. package/dist/presentation/routes/oauth.d.ts +9 -0
  75. package/dist/presentation/routes/oauth.d.ts.map +1 -0
  76. package/dist/presentation/routes/oauth.js +150 -0
  77. package/dist/presentation/routes/oauth.js.map +1 -0
  78. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  79. package/dist/presentation/routes/rate-limit.js +34 -0
  80. package/dist/presentation/routes/rate-limit.js.map +1 -1
  81. package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
  82. package/dist/presentation/routes/registration/signup.js +59 -50
  83. package/dist/presentation/routes/registration/signup.js.map +1 -1
  84. package/dist/temporal/activities/agent-access-retention.d.ts +13 -0
  85. package/dist/temporal/activities/agent-access-retention.d.ts.map +1 -0
  86. package/dist/temporal/activities/agent-access-retention.js +58 -0
  87. package/dist/temporal/activities/agent-access-retention.js.map +1 -0
  88. package/dist/temporal/activities/index.d.ts +5 -0
  89. package/dist/temporal/activities/index.d.ts.map +1 -0
  90. package/dist/temporal/activities/index.js +8 -0
  91. package/dist/temporal/activities/index.js.map +1 -0
  92. package/dist/temporal/constants.d.ts +2 -0
  93. package/dist/temporal/constants.d.ts.map +1 -0
  94. package/dist/temporal/constants.js +3 -0
  95. package/dist/temporal/constants.js.map +1 -0
  96. package/dist/temporal/workflows/agent-access-retention-cron.d.ts +2 -0
  97. package/dist/temporal/workflows/agent-access-retention-cron.d.ts.map +1 -0
  98. package/dist/temporal/workflows/agent-access-retention-cron.js +22 -0
  99. package/dist/temporal/workflows/agent-access-retention-cron.js.map +1 -0
  100. package/dist/temporal/workflows/index.bundle.js +25227 -0
  101. package/dist/temporal/workflows/index.bundle.meta.json +1 -0
  102. package/dist/temporal/workflows/index.d.ts +2 -0
  103. package/dist/temporal/workflows/index.d.ts.map +1 -0
  104. package/dist/temporal/workflows/index.js +3 -0
  105. package/dist/temporal/workflows/index.js.map +1 -0
  106. package/dist/tsconfig.test.tsbuildinfo +1 -1
  107. package/drizzle/0003_brainy_luckman.sql +6 -0
  108. package/drizzle/0004_yummy_runaways.sql +110 -0
  109. package/drizzle/meta/0003_snapshot.json +844 -0
  110. package/drizzle/meta/0004_snapshot.json +1751 -0
  111. package/drizzle/meta/_journal.json +14 -0
  112. package/package.json +11 -8
  113. package/src/core/admin-role.test.ts +70 -0
  114. package/src/core/administration.ts +78 -1
  115. package/src/core/auth.ts +3 -5
  116. package/src/core/cimd.test.ts +213 -0
  117. package/src/core/cimd.ts +532 -0
  118. package/src/core/entities/agent-access.ts +80 -0
  119. package/src/core/errors.ts +48 -0
  120. package/src/core/oauth-client-resolver.test.ts +198 -0
  121. package/src/core/oauth-client-resolver.ts +253 -0
  122. package/src/core/oauth-client.test.ts +157 -0
  123. package/src/core/oauth-client.ts +331 -0
  124. package/src/db/admin-user-moderation.ts +53 -45
  125. package/src/db/admin-user-summary.test.ts +228 -0
  126. package/src/db/admin-user-summary.ts +143 -1
  127. package/src/db/admin-users.ts +21 -1
  128. package/src/db/agent-access-retention.ts +10 -0
  129. package/src/db/agent-access.test.ts +1064 -0
  130. package/src/db/agent-access.ts +1491 -0
  131. package/src/db/db.ts +14 -0
  132. package/src/db/schema/agent-access.ts +273 -0
  133. package/src/db/schema/users.ts +9 -3
  134. package/src/index.test.ts +27 -0
  135. package/src/index.ts +82 -0
  136. package/src/metrics/instance.ts +6 -1
  137. package/src/presentation/routes/administration.test.ts +359 -2
  138. package/src/presentation/routes/administration.ts +142 -20
  139. package/src/presentation/routes/email-verification/verify-email-resend.ts +7 -1
  140. package/src/presentation/routes/oauth.test.ts +133 -0
  141. package/src/presentation/routes/oauth.ts +144 -0
  142. package/src/presentation/routes/rate-limit.ts +16 -0
  143. package/src/presentation/routes/registration/signup.ts +52 -41
  144. package/src/temporal/activities/agent-access-retention.ts +76 -0
  145. package/src/temporal/activities/index.ts +5 -0
  146. package/src/temporal/constants.ts +1 -0
  147. package/src/temporal/workflows/agent-access-retention-cron.ts +23 -0
  148. package/src/temporal/workflows/index.ts +1 -0
  149. package/test/globalSetup.ts +1 -1
  150. package/test/routes.ts +3 -1
  151. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,532 @@
1
+ import {promises as dnsPromises} from 'node:dns';
2
+ import type {IncomingMessage} from 'node:http';
3
+ import {request as httpsRequest} from 'node:https';
4
+ import {isIP} from 'node:net';
5
+ import {oauthClientMetadataDocumentSchema} from '@shipfox/api-auth-dto';
6
+ import {InvalidOAuthClientMetadataError, OAuthMetadataFetchError} from './errors.js';
7
+ import type {ValidatedOAuthClientMetadata} from './oauth-client.js';
8
+ import {
9
+ OAUTH_CIMD_CACHE_MAX_AGE_SECONDS,
10
+ validateOAuthClientId,
11
+ validateOAuthClientMetadataDocument,
12
+ } from './oauth-client.js';
13
+
14
+ export const OAUTH_CIMD_MAX_BODY_BYTES = 256 * 1024;
15
+ export const OAUTH_CIMD_FETCH_TIMEOUT_MS = 5_000;
16
+
17
+ const DECIMAL_RE = /^\d+$/u;
18
+ const IPV6_GROUP_RE = /^[0-9a-f]{1,4}$/u;
19
+
20
+ class CimdTimeoutError extends Error {
21
+ constructor() {
22
+ super('CIMD fetch timed out');
23
+ this.name = 'CimdTimeoutError';
24
+ }
25
+ }
26
+
27
+ export interface CimdAddress {
28
+ address: string;
29
+ family: 4 | 6;
30
+ }
31
+
32
+ export interface CimdHttpResponse {
33
+ statusCode: number;
34
+ headers: Record<string, string | string[] | undefined>;
35
+ body: Uint8Array;
36
+ }
37
+
38
+ export type CimdAddressResolver = (hostname: string) => Promise<readonly CimdAddress[]>;
39
+ export type CimdHttpRequester = (params: {
40
+ url: URL;
41
+ address: CimdAddress;
42
+ timeoutMs: number;
43
+ maxBodyBytes: number;
44
+ signal?: AbortSignal;
45
+ }) => Promise<CimdHttpResponse>;
46
+
47
+ export interface FetchedCimdMetadata {
48
+ metadata: ValidatedOAuthClientMetadata;
49
+ cacheMaxAgeSeconds: number;
50
+ }
51
+
52
+ function metadataFetchError(
53
+ reason: OAuthMetadataFetchError['reason'],
54
+ cause?: unknown,
55
+ ): OAuthMetadataFetchError {
56
+ return new OAuthMetadataFetchError(reason, cause);
57
+ }
58
+
59
+ function ipv4ToNumber(value: string): number | undefined {
60
+ const parts = value.split('.');
61
+ if (parts.length !== 4) return undefined;
62
+ let result = 0;
63
+ for (const part of parts) {
64
+ if (!DECIMAL_RE.test(part)) return undefined;
65
+ const octet = Number(part);
66
+ if (!Number.isInteger(octet) || octet > 255) return undefined;
67
+ result = result * 256 + octet;
68
+ }
69
+ return result >>> 0;
70
+ }
71
+
72
+ function inIpv4Range(value: number, start: number, end: number): boolean {
73
+ return value >= start && value <= end;
74
+ }
75
+
76
+ function isPublicIpv4(value: string): boolean {
77
+ const address = ipv4ToNumber(value);
78
+ if (address === undefined) return false;
79
+ return !(
80
+ (
81
+ inIpv4Range(address, 0x00000000, 0x00ffffff) || // 0.0.0.0/8
82
+ inIpv4Range(address, 0x0a000000, 0x0affffff) || // 10.0.0.0/8
83
+ inIpv4Range(address, 0x64400000, 0x647fffff) || // 100.64.0.0/10
84
+ inIpv4Range(address, 0x7f000000, 0x7fffffff) || // 127.0.0.0/8
85
+ inIpv4Range(address, 0xa9fe0000, 0xa9feffff) || // 169.254.0.0/16
86
+ inIpv4Range(address, 0xac100000, 0xac1fffff) || // 172.16.0.0/12
87
+ inIpv4Range(address, 0xc0000000, 0xc00000ff) || // 192.0.0.0/24
88
+ inIpv4Range(address, 0xc0000200, 0xc00002ff) || // 192.0.2.0/24
89
+ inIpv4Range(address, 0xc01fc400, 0xc01fc4ff) || // 192.31.196.0/24
90
+ inIpv4Range(address, 0xc034c100, 0xc034c1ff) || // 192.52.193.0/24
91
+ inIpv4Range(address, 0xc0586300, 0xc05863ff) || // 192.88.99.0/24
92
+ inIpv4Range(address, 0xc0a80000, 0xc0a8ffff) || // 192.168.0.0/16
93
+ inIpv4Range(address, 0xc0af3000, 0xc0af30ff) || // 192.175.48.0/24
94
+ inIpv4Range(address, 0xc6120000, 0xc613ffff) || // 198.18.0.0/15
95
+ inIpv4Range(address, 0xc6336400, 0xc63364ff) || // 198.51.100.0/24
96
+ inIpv4Range(address, 0xcb007100, 0xcb0071ff) || // 203.0.113.0/24
97
+ inIpv4Range(address, 0xe0000000, 0xffffffff)
98
+ ) // multicast, reserved, broadcast
99
+ );
100
+ }
101
+
102
+ function parseIpv6(value: string): bigint[] | undefined {
103
+ if (value.includes('%')) return undefined;
104
+ const input = expandEmbeddedIpv4(value.toLowerCase());
105
+ if (input === undefined) return undefined;
106
+
107
+ const parts = splitIpv6Groups(input);
108
+ if (parts === undefined) return undefined;
109
+ const {left, right, hasCompression} = parts;
110
+
111
+ const groups = hasCompression
112
+ ? [...left, ...Array(8 - left.length - right.length).fill('0'), ...right]
113
+ : [...left, ...right];
114
+ if (groups.length !== 8) return undefined;
115
+ return groups.map((part) => BigInt(`0x${part}`));
116
+ }
117
+
118
+ function expandEmbeddedIpv4(value: string): string | undefined {
119
+ if (!value.includes('.')) return value;
120
+ const lastColon = value.lastIndexOf(':');
121
+ if (lastColon < 0) return undefined;
122
+ const ipv4 = ipv4ToNumber(value.slice(lastColon + 1));
123
+ if (ipv4 === undefined) return undefined;
124
+ const high = ((ipv4 >>> 16) & 0xffff).toString(16);
125
+ const low = (ipv4 & 0xffff).toString(16);
126
+ return `${value.slice(0, lastColon)}:${high}:${low}`;
127
+ }
128
+
129
+ function splitIpv6Groups(value: string):
130
+ | {
131
+ hasCompression: boolean;
132
+ left: string[];
133
+ right: string[];
134
+ }
135
+ | undefined {
136
+ const compressionStart = value.indexOf('::');
137
+ if (compressionStart !== -1 && compressionStart !== value.lastIndexOf('::')) {
138
+ return undefined;
139
+ }
140
+
141
+ const hasCompression = compressionStart !== -1;
142
+ const [leftText, rightText = ''] = value.split('::');
143
+ const left = leftText ? leftText.split(':') : [];
144
+ const right = rightText ? rightText.split(':') : [];
145
+ if (left.some((part) => !IPV6_GROUP_RE.test(part))) return undefined;
146
+ if (right.some((part) => !IPV6_GROUP_RE.test(part))) return undefined;
147
+ if (!hasCompression && left.length + right.length !== 8) return undefined;
148
+ if (hasCompression && left.length + right.length >= 8) return undefined;
149
+ return {hasCompression, left, right};
150
+ }
151
+
152
+ function isPublicIpv6(value: string): boolean {
153
+ const groups = parseIpv6(value);
154
+ if (!groups) return false;
155
+ const first = Number(groups[0]);
156
+ if (first < 0x2000 || first > 0x3fff) return false;
157
+
158
+ // Keep documentation, benchmarking, transition, and other special-use
159
+ // prefixes out of a fetch path even when a resolver returns a routable one.
160
+ if (first === 0x2001) {
161
+ const second = Number(groups[1]);
162
+ if (
163
+ second === 0x0000 ||
164
+ second === 0x0002 ||
165
+ (second >= 0x0010 && second <= 0x002f) ||
166
+ (second >= 0x0db8 && second <= 0x0dbf)
167
+ ) {
168
+ return false;
169
+ }
170
+ }
171
+ if (first === 0x2002 || first === 0x3fff) return false;
172
+ return true;
173
+ }
174
+
175
+ /** Returns true only for a globally routable unicast address. */
176
+ export function isPublicUnicastAddress(address: string, family?: 4 | 6): boolean {
177
+ const normalized = address.replace(/^\[|\]$/gu, '');
178
+ const detectedFamily = family ?? (isIP(normalized) as 4 | 6 | 0);
179
+ if (detectedFamily === 4) return isPublicIpv4(normalized);
180
+ if (detectedFamily === 6) return isPublicIpv6(normalized);
181
+ return false;
182
+ }
183
+
184
+ function normalizeHostname(url: URL): string {
185
+ return url.hostname.replace(/^\[|\]$/gu, '');
186
+ }
187
+
188
+ async function resolvePublicAddress(hostname: string): Promise<readonly CimdAddress[]> {
189
+ if (isIP(hostname) === 4 || isIP(hostname) === 6) {
190
+ const family = isIP(hostname) as 4 | 6;
191
+ if (!isPublicUnicastAddress(hostname, family)) {
192
+ throw metadataFetchError('private-address');
193
+ }
194
+ return [{address: hostname, family}];
195
+ }
196
+
197
+ let resolved: Array<{address: string; family: number}>;
198
+ try {
199
+ resolved = await dnsPromises.lookup(hostname, {all: true, verbatim: true});
200
+ } catch (error) {
201
+ throw metadataFetchError('dns-failed', error);
202
+ }
203
+ if (resolved.length === 0) throw metadataFetchError('dns-failed');
204
+
205
+ const addresses = resolved.map((entry) => ({
206
+ address: entry.address,
207
+ family: entry.family as 4 | 6,
208
+ }));
209
+ if (
210
+ addresses.some(
211
+ (entry) =>
212
+ (entry.family !== 4 && entry.family !== 6) ||
213
+ !isPublicUnicastAddress(entry.address, entry.family),
214
+ )
215
+ ) {
216
+ throw metadataFetchError('private-address');
217
+ }
218
+ return addresses;
219
+ }
220
+
221
+ function headerValue(
222
+ headers: Record<string, string | string[] | undefined>,
223
+ name: string,
224
+ ): string | undefined {
225
+ const normalizedName = name.toLowerCase();
226
+ const headerName = Object.keys(headers).find((key) => key.toLowerCase() === normalizedName);
227
+ const value = headerName === undefined ? undefined : headers[headerName];
228
+ if (Array.isArray(value)) return value.join(', ');
229
+ return value;
230
+ }
231
+
232
+ function parseCacheMaxAge(headers: Record<string, string | string[] | undefined>): number {
233
+ const cacheControl = headerValue(headers, 'cache-control');
234
+ if (!cacheControl) return OAUTH_CIMD_CACHE_MAX_AGE_SECONDS;
235
+
236
+ let maxAge: number | undefined;
237
+ for (const directive of cacheControl.split(',')) {
238
+ const [rawName, rawValue] = directive.trim().split('=', 2);
239
+ const name = rawName?.trim().toLowerCase();
240
+ if (name === 'no-store' || name === 'no-cache') return 0;
241
+ if (name === 'max-age' && rawValue !== undefined && DECIMAL_RE.test(rawValue.trim())) {
242
+ maxAge = Number(rawValue.trim());
243
+ }
244
+ }
245
+ return Math.min(maxAge ?? OAUTH_CIMD_CACHE_MAX_AGE_SECONDS, OAUTH_CIMD_CACHE_MAX_AGE_SECONDS);
246
+ }
247
+
248
+ function withTimeout<T>(
249
+ promise: Promise<T>,
250
+ timeoutMs: number,
251
+ onTimeout?: () => void,
252
+ ): Promise<T> {
253
+ return new Promise<T>((resolve, reject) => {
254
+ let settled = false;
255
+ const timer = setTimeout(() => {
256
+ if (settled) return;
257
+ settled = true;
258
+ onTimeout?.();
259
+ reject(new CimdTimeoutError());
260
+ }, timeoutMs);
261
+ promise.then(
262
+ (value) => {
263
+ if (settled) return;
264
+ settled = true;
265
+ clearTimeout(timer);
266
+ resolve(value);
267
+ },
268
+ (error: unknown) => {
269
+ if (settled) return;
270
+ settled = true;
271
+ clearTimeout(timer);
272
+ reject(error);
273
+ },
274
+ );
275
+ });
276
+ }
277
+
278
+ function requestPinnedHttps(params: {
279
+ url: URL;
280
+ address: CimdAddress;
281
+ timeoutMs: number;
282
+ maxBodyBytes: number;
283
+ signal?: AbortSignal;
284
+ }): Promise<CimdHttpResponse> {
285
+ return new Promise((resolve, reject) => {
286
+ const hostname = normalizeHostname(params.url);
287
+ let settled = false;
288
+
289
+ const fail = (error: unknown) => {
290
+ if (settled) return;
291
+ settled = true;
292
+ reject(error);
293
+ };
294
+ const succeed = (response: CimdHttpResponse) => {
295
+ if (settled) return;
296
+ settled = true;
297
+ resolve(response);
298
+ };
299
+
300
+ if (params.url.protocol !== 'https:' || params.url.username || params.url.password) {
301
+ fail(metadataFetchError('invalid-url'));
302
+ return;
303
+ }
304
+
305
+ const request = httpsRequest(
306
+ {
307
+ hostname,
308
+ port: params.url.port || 443,
309
+ path: `${params.url.pathname || '/'}${params.url.search}`,
310
+ method: 'GET',
311
+ headers: {accept: 'application/json'},
312
+ rejectUnauthorized: true,
313
+ servername: hostname,
314
+ timeout: params.timeoutMs,
315
+ signal: params.signal,
316
+ // The address was resolved before this request and is the only value
317
+ // the TLS connection is allowed to use.
318
+ lookup: (_lookupHostname, _options, callback) => {
319
+ callback(null, params.address.address, params.address.family);
320
+ },
321
+ },
322
+ (response) => {
323
+ handleCimdResponse(response, params.maxBodyBytes, fail, succeed);
324
+ },
325
+ );
326
+ request.once('error', (error) => fail(error));
327
+ request.once('timeout', () => {
328
+ request.destroy();
329
+ fail(metadataFetchError('timeout'));
330
+ });
331
+ request.end();
332
+ });
333
+ }
334
+
335
+ function responseHeaderError(
336
+ statusCode: number | undefined,
337
+ headers: Record<string, string | string[] | undefined>,
338
+ maxBodyBytes: number,
339
+ ): OAuthMetadataFetchError | undefined {
340
+ if (statusCode !== undefined && statusCode >= 300 && statusCode < 400) {
341
+ return metadataFetchError('redirected');
342
+ }
343
+ const contentLength = headerValue(headers, 'content-length');
344
+ if (contentLength !== undefined) {
345
+ if (!DECIMAL_RE.test(contentLength.trim())) return metadataFetchError('invalid-response');
346
+ if (Number(contentLength) > maxBodyBytes) return metadataFetchError('response-too-large');
347
+ }
348
+ const contentEncoding = headerValue(headers, 'content-encoding');
349
+ if (contentEncoding && contentEncoding.toLowerCase() !== 'identity') {
350
+ return metadataFetchError('invalid-response');
351
+ }
352
+ return undefined;
353
+ }
354
+
355
+ function readCimdResponseBody(response: IncomingMessage, maxBodyBytes: number): Promise<Buffer> {
356
+ return new Promise((resolve, reject) => {
357
+ const chunks: Buffer[] = [];
358
+ let size = 0;
359
+ response.on('data', (chunk: Buffer | string) => {
360
+ const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
361
+ size += buffer.byteLength;
362
+ if (size > maxBodyBytes) {
363
+ response.destroy();
364
+ reject(metadataFetchError('response-too-large'));
365
+ return;
366
+ }
367
+ chunks.push(buffer);
368
+ });
369
+ response.once('error', reject);
370
+ response.once('end', () => resolve(Buffer.concat(chunks)));
371
+ });
372
+ }
373
+
374
+ function handleCimdResponse(
375
+ response: IncomingMessage,
376
+ maxBodyBytes: number,
377
+ fail: (error: unknown) => void,
378
+ succeed: (response: CimdHttpResponse) => void,
379
+ ): void {
380
+ const headers: Record<string, string | string[] | undefined> = response.headers;
381
+ const headerError = responseHeaderError(response.statusCode, headers, maxBodyBytes);
382
+ if (headerError) {
383
+ response.destroy();
384
+ fail(headerError);
385
+ return;
386
+ }
387
+ void readCimdResponseBody(response, maxBodyBytes).then(
388
+ (body) => succeed({statusCode: response.statusCode ?? 0, headers, body}),
389
+ fail,
390
+ );
391
+ }
392
+
393
+ function parseCimdDocument(body: Uint8Array, clientId: string): ValidatedOAuthClientMetadata {
394
+ let parsed: unknown;
395
+ try {
396
+ parsed = JSON.parse(Buffer.from(body).toString('utf8'));
397
+ } catch {
398
+ throw new InvalidOAuthClientMetadataError();
399
+ }
400
+
401
+ const result = oauthClientMetadataDocumentSchema.safeParse(parsed);
402
+ if (!result.success) throw new InvalidOAuthClientMetadataError();
403
+ return validateOAuthClientMetadataDocument(result.data, clientId);
404
+ }
405
+
406
+ async function resolvePinnedAddresses(
407
+ hostname: string,
408
+ resolveAddress: CimdAddressResolver,
409
+ timeoutMs: number,
410
+ ): Promise<readonly CimdAddress[]> {
411
+ let resolved: readonly CimdAddress[];
412
+ try {
413
+ resolved = await withTimeout(resolveAddress(hostname), timeoutMs);
414
+ } catch (error) {
415
+ if (error instanceof OAuthMetadataFetchError) throw error;
416
+ if (error instanceof CimdTimeoutError) throw metadataFetchError('timeout', error);
417
+ throw metadataFetchError('dns-failed', error);
418
+ }
419
+ if (resolved.length === 0) throw metadataFetchError('dns-failed');
420
+ if (
421
+ resolved.some(
422
+ (entry) =>
423
+ (entry.family !== 4 && entry.family !== 6) ||
424
+ !isPublicUnicastAddress(entry.address, entry.family),
425
+ )
426
+ ) {
427
+ throw metadataFetchError('private-address');
428
+ }
429
+ return resolved;
430
+ }
431
+
432
+ async function requestCimdResponse(params: {
433
+ url: URL;
434
+ address: CimdAddress;
435
+ request: CimdHttpRequester;
436
+ timeoutMs: number;
437
+ maxBodyBytes: number;
438
+ }): Promise<CimdHttpResponse> {
439
+ const abortController = new AbortController();
440
+ try {
441
+ return await withTimeout(
442
+ params.request({
443
+ url: params.url,
444
+ address: params.address,
445
+ timeoutMs: params.timeoutMs,
446
+ maxBodyBytes: params.maxBodyBytes,
447
+ signal: abortController.signal,
448
+ }),
449
+ params.timeoutMs,
450
+ () => abortController.abort(),
451
+ );
452
+ } catch (error) {
453
+ if (error instanceof OAuthMetadataFetchError) throw error;
454
+ if (error instanceof CimdTimeoutError) throw metadataFetchError('timeout', error);
455
+ throw metadataFetchError('connection-failed', error);
456
+ }
457
+ }
458
+
459
+ function remainingTimeout(deadlineMs: number): number {
460
+ const timeoutMs = deadlineMs - Date.now();
461
+ if (timeoutMs <= 0) throw metadataFetchError('timeout');
462
+ return timeoutMs;
463
+ }
464
+
465
+ function assertCimdResponse(response: CimdHttpResponse, maxBodyBytes: number): void {
466
+ const headerError = responseHeaderError(response.statusCode, response.headers, maxBodyBytes);
467
+ if (headerError) throw headerError;
468
+ if (response.statusCode !== 200) throw metadataFetchError('invalid-response');
469
+ if (response.body.byteLength > maxBodyBytes) {
470
+ throw metadataFetchError('response-too-large');
471
+ }
472
+ }
473
+
474
+ export interface FetchCimdMetadataOptions {
475
+ resolveAddress?: CimdAddressResolver;
476
+ request?: CimdHttpRequester;
477
+ timeoutMs?: number;
478
+ maxBodyBytes?: number;
479
+ }
480
+
481
+ /**
482
+ * Fetches a Client ID Metadata Document after resolving and pinning a public
483
+ * address. This deliberately does not share the general egress helper: CIMD
484
+ * has stricter public-only, no-redirect, and no-credential requirements.
485
+ */
486
+ export async function fetchClientIdMetadata(
487
+ clientId: string,
488
+ options: FetchCimdMetadataOptions = {},
489
+ ): Promise<FetchedCimdMetadata> {
490
+ const url = validateOAuthClientId(clientId);
491
+ const timeoutMs = options.timeoutMs ?? OAUTH_CIMD_FETCH_TIMEOUT_MS;
492
+ const maxBodyBytes = options.maxBodyBytes ?? OAUTH_CIMD_MAX_BODY_BYTES;
493
+ const resolveAddress = options.resolveAddress ?? resolvePublicAddress;
494
+ const request = options.request ?? requestPinnedHttps;
495
+ const hostname = normalizeHostname(url);
496
+ const deadlineMs = Date.now() + timeoutMs;
497
+
498
+ const addresses = await resolvePinnedAddresses(
499
+ hostname,
500
+ resolveAddress,
501
+ remainingTimeout(deadlineMs),
502
+ );
503
+ let lastConnectionError: OAuthMetadataFetchError | undefined;
504
+ for (const [index, address] of addresses.entries()) {
505
+ try {
506
+ const response = await requestCimdResponse({
507
+ url,
508
+ address,
509
+ request,
510
+ timeoutMs: remainingTimeout(deadlineMs),
511
+ maxBodyBytes,
512
+ });
513
+ assertCimdResponse(response, maxBodyBytes);
514
+
515
+ return {
516
+ metadata: parseCimdDocument(response.body, clientId),
517
+ cacheMaxAgeSeconds: parseCacheMaxAge(response.headers),
518
+ };
519
+ } catch (error) {
520
+ if (
521
+ error instanceof OAuthMetadataFetchError &&
522
+ error.reason === 'connection-failed' &&
523
+ index < addresses.length - 1
524
+ ) {
525
+ lastConnectionError = error;
526
+ continue;
527
+ }
528
+ throw error;
529
+ }
530
+ }
531
+ throw lastConnectionError ?? metadataFetchError('connection-failed');
532
+ }
@@ -0,0 +1,80 @@
1
+ export type AgentClientKind = 'registered' | 'cimd';
2
+
3
+ export interface AgentClient {
4
+ id: string;
5
+ clientId: string;
6
+ name: string;
7
+ redirectUris: string[];
8
+ kind: AgentClientKind;
9
+ lastSeenAt: Date | null;
10
+ unreferencedAt: Date | null;
11
+ createdAt: Date;
12
+ updatedAt: Date;
13
+ }
14
+
15
+ export interface AgentAuthorizationRequest {
16
+ id: string;
17
+ clientId: string;
18
+ redirectUri: string;
19
+ resource: string;
20
+ scopes: string[];
21
+ codeChallenge: string;
22
+ state: string | null;
23
+ expiresAt: Date;
24
+ consumedAt: Date | null;
25
+ createdAt: Date;
26
+ updatedAt: Date;
27
+ }
28
+
29
+ export interface AgentGrant {
30
+ id: string;
31
+ userId: string;
32
+ workspaceId: string;
33
+ clientId: string;
34
+ scopes: string[];
35
+ lastUsedAt: Date | null;
36
+ revokedAt: Date | null;
37
+ terminalAt: Date | null;
38
+ createdAt: Date;
39
+ updatedAt: Date;
40
+ }
41
+
42
+ export interface AgentAuthorizationCode {
43
+ id: string;
44
+ grantId: string;
45
+ hashedCode: string;
46
+ codeChallenge: string;
47
+ redirectUri: string;
48
+ resource: string;
49
+ expiresAt: Date;
50
+ consumedAt: Date | null;
51
+ createdAt: Date;
52
+ updatedAt: Date;
53
+ }
54
+
55
+ export interface AgentRefreshToken {
56
+ id: string;
57
+ grantId: string;
58
+ hashedToken: string;
59
+ expiresAt: Date;
60
+ rotatedAt: Date | null;
61
+ revokedAt: Date | null;
62
+ lastUsedAt: Date | null;
63
+ createdAt: Date;
64
+ updatedAt: Date;
65
+ }
66
+
67
+ export interface AgentPersonalAccessToken {
68
+ id: string;
69
+ userId: string;
70
+ workspaceId: string;
71
+ hashedToken: string;
72
+ prefix: string;
73
+ name: string;
74
+ scopes: string[];
75
+ expiresAt: Date;
76
+ lastUsedAt: Date | null;
77
+ revokedAt: Date | null;
78
+ createdAt: Date;
79
+ updatedAt: Date;
80
+ }
@@ -95,6 +95,13 @@ export class AdminRoleRequiredError extends Error {
95
95
  }
96
96
  }
97
97
 
98
+ export class InvalidAdministratorUserDirectoryFilterError extends Error {
99
+ constructor(message: string) {
100
+ super(message);
101
+ this.name = 'InvalidAdministratorUserDirectoryFilterError';
102
+ }
103
+ }
104
+
98
105
  export class LastAdminOwnerError extends Error {
99
106
  constructor() {
100
107
  super('Cannot remove the final active administrator owner');
@@ -220,3 +227,44 @@ export class OpenInvitationExistsError extends Error {
220
227
  this.name = 'OpenInvitationExistsError';
221
228
  }
222
229
  }
230
+
231
+ export class InvalidOAuthClientMetadataError extends Error {
232
+ constructor() {
233
+ super('OAuth client metadata is invalid');
234
+ this.name = 'InvalidOAuthClientMetadataError';
235
+ }
236
+ }
237
+
238
+ export class OAuthRedirectUriNotRegisteredError extends Error {
239
+ constructor() {
240
+ super('OAuth redirect URI is not registered');
241
+ this.name = 'OAuthRedirectUriNotRegisteredError';
242
+ }
243
+ }
244
+
245
+ export class OAuthMetadataFetchError extends Error {
246
+ readonly reason:
247
+ | 'invalid-url'
248
+ | 'private-address'
249
+ | 'dns-failed'
250
+ | 'connection-failed'
251
+ | 'redirected'
252
+ | 'timeout'
253
+ | 'response-too-large'
254
+ | 'invalid-response';
255
+ override readonly cause: unknown;
256
+
257
+ constructor(reason: OAuthMetadataFetchError['reason'], cause?: unknown) {
258
+ super('OAuth client metadata could not be fetched');
259
+ this.name = 'OAuthMetadataFetchError';
260
+ this.reason = reason;
261
+ this.cause = cause;
262
+ }
263
+ }
264
+
265
+ export class InvalidOAuthConfigurationError extends Error {
266
+ constructor() {
267
+ super('OAuth public URL configuration is invalid');
268
+ this.name = 'InvalidOAuthConfigurationError';
269
+ }
270
+ }