@shipfox/api-auth 19.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 (86) hide show
  1. package/.turbo/turbo-build.log +17 -2
  2. package/CHANGELOG.md +13 -0
  3. package/dist/core/cimd.d.ts +39 -0
  4. package/dist/core/cimd.d.ts.map +1 -0
  5. package/dist/core/cimd.js +392 -0
  6. package/dist/core/cimd.js.map +1 -0
  7. package/dist/core/errors.d.ts +14 -0
  8. package/dist/core/errors.d.ts.map +1 -1
  9. package/dist/core/errors.js +26 -0
  10. package/dist/core/errors.js.map +1 -1
  11. package/dist/core/oauth-client-resolver.d.ts +54 -0
  12. package/dist/core/oauth-client-resolver.d.ts.map +1 -0
  13. package/dist/core/oauth-client-resolver.js +154 -0
  14. package/dist/core/oauth-client-resolver.js.map +1 -0
  15. package/dist/core/oauth-client.d.ts +39 -0
  16. package/dist/core/oauth-client.d.ts.map +1 -0
  17. package/dist/core/oauth-client.js +236 -0
  18. package/dist/core/oauth-client.js.map +1 -0
  19. package/dist/db/agent-access-retention.d.ts +10 -0
  20. package/dist/db/agent-access-retention.d.ts.map +1 -0
  21. package/dist/db/agent-access-retention.js +11 -0
  22. package/dist/db/agent-access-retention.js.map +1 -0
  23. package/dist/db/agent-access.d.ts +100 -4
  24. package/dist/db/agent-access.d.ts.map +1 -1
  25. package/dist/db/agent-access.js +483 -45
  26. package/dist/db/agent-access.js.map +1 -1
  27. package/dist/index.d.ts +9 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +25 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/metrics/instance.d.ts +1 -1
  32. package/dist/metrics/instance.d.ts.map +1 -1
  33. package/dist/metrics/instance.js.map +1 -1
  34. package/dist/presentation/routes/oauth.d.ts +9 -0
  35. package/dist/presentation/routes/oauth.d.ts.map +1 -0
  36. package/dist/presentation/routes/oauth.js +150 -0
  37. package/dist/presentation/routes/oauth.js.map +1 -0
  38. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  39. package/dist/presentation/routes/rate-limit.js +24 -0
  40. package/dist/presentation/routes/rate-limit.js.map +1 -1
  41. package/dist/temporal/activities/agent-access-retention.d.ts +13 -0
  42. package/dist/temporal/activities/agent-access-retention.d.ts.map +1 -0
  43. package/dist/temporal/activities/agent-access-retention.js +58 -0
  44. package/dist/temporal/activities/agent-access-retention.js.map +1 -0
  45. package/dist/temporal/activities/index.d.ts +5 -0
  46. package/dist/temporal/activities/index.d.ts.map +1 -0
  47. package/dist/temporal/activities/index.js +8 -0
  48. package/dist/temporal/activities/index.js.map +1 -0
  49. package/dist/temporal/constants.d.ts +2 -0
  50. package/dist/temporal/constants.d.ts.map +1 -0
  51. package/dist/temporal/constants.js +3 -0
  52. package/dist/temporal/constants.js.map +1 -0
  53. package/dist/temporal/workflows/agent-access-retention-cron.d.ts +2 -0
  54. package/dist/temporal/workflows/agent-access-retention-cron.d.ts.map +1 -0
  55. package/dist/temporal/workflows/agent-access-retention-cron.js +22 -0
  56. package/dist/temporal/workflows/agent-access-retention-cron.js.map +1 -0
  57. package/dist/temporal/workflows/index.bundle.js +25227 -0
  58. package/dist/temporal/workflows/index.bundle.meta.json +1 -0
  59. package/dist/temporal/workflows/index.d.ts +2 -0
  60. package/dist/temporal/workflows/index.d.ts.map +1 -0
  61. package/dist/temporal/workflows/index.js +3 -0
  62. package/dist/temporal/workflows/index.js.map +1 -0
  63. package/dist/tsconfig.test.tsbuildinfo +1 -1
  64. package/package.json +7 -4
  65. package/src/core/cimd.test.ts +213 -0
  66. package/src/core/cimd.ts +532 -0
  67. package/src/core/errors.ts +41 -0
  68. package/src/core/oauth-client-resolver.test.ts +198 -0
  69. package/src/core/oauth-client-resolver.ts +253 -0
  70. package/src/core/oauth-client.test.ts +157 -0
  71. package/src/core/oauth-client.ts +331 -0
  72. package/src/db/agent-access-retention.ts +10 -0
  73. package/src/db/agent-access.test.ts +697 -3
  74. package/src/db/agent-access.ts +953 -91
  75. package/src/index.test.ts +27 -0
  76. package/src/index.ts +76 -0
  77. package/src/metrics/instance.ts +5 -1
  78. package/src/presentation/routes/oauth.test.ts +133 -0
  79. package/src/presentation/routes/oauth.ts +144 -0
  80. package/src/presentation/routes/rate-limit.ts +12 -0
  81. package/src/temporal/activities/agent-access-retention.ts +76 -0
  82. package/src/temporal/activities/index.ts +5 -0
  83. package/src/temporal/constants.ts +1 -0
  84. package/src/temporal/workflows/agent-access-retention-cron.ts +23 -0
  85. package/src/temporal/workflows/index.ts +1 -0
  86. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,198 @@
1
+ import {describe, expect, it, vi} from '@shipfox/vitest/vi';
2
+ import type {AgentClient} from './entities/agent-access.js';
3
+ import {InvalidOAuthClientMetadataError} from './errors.js';
4
+ import {OAUTH_CIMD_CACHE_MAX_AGE_SECONDS} from './oauth-client.js';
5
+ import {
6
+ createOAuthClientResolver,
7
+ OAUTH_CIMD_CACHE_MAX_ENTRIES,
8
+ type ResolvedOAuthClient,
9
+ registerOAuthClient,
10
+ } from './oauth-client-resolver.js';
11
+
12
+ const clientId = 'https://client.example/.well-known/oauth-client';
13
+ const redirectUri = 'https://client.example/callback';
14
+
15
+ function client(overrides: Partial<AgentClient> = {}): AgentClient {
16
+ const now = new Date('2026-09-01T00:00:00.000Z');
17
+ return {
18
+ id: crypto.randomUUID(),
19
+ clientId,
20
+ name: 'Desktop agent',
21
+ redirectUris: [redirectUri],
22
+ kind: 'cimd',
23
+ lastSeenAt: now,
24
+ unreferencedAt: null,
25
+ createdAt: now,
26
+ updatedAt: now,
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ function fetched(): ResolvedOAuthClient['metadata'] {
32
+ return {
33
+ clientId,
34
+ clientName: 'Desktop agent',
35
+ redirectUris: [redirectUri],
36
+ grantTypes: ['authorization_code'],
37
+ responseTypes: ['code'],
38
+ tokenEndpointAuthMethod: 'none',
39
+ scope: 'read',
40
+ };
41
+ }
42
+
43
+ describe('OAuth client resolver', () => {
44
+ it('caches successful CIMD resolution while rate limiting every request', async () => {
45
+ let nowMs = Date.parse('2026-09-01T00:00:00.000Z');
46
+ const findClient = vi.fn(async () => undefined);
47
+ const fetchMetadata = vi.fn(async () => ({metadata: fetched(), cacheMaxAgeSeconds: 60}));
48
+ const upsertCimdClient = vi.fn(async () => client());
49
+ const checkCimdRateLimit = vi.fn(async () => undefined);
50
+ const resolver = createOAuthClientResolver({
51
+ findClient,
52
+ fetchMetadata,
53
+ upsertCimdClient,
54
+ checkCimdRateLimit,
55
+ now: () => new Date(nowMs),
56
+ });
57
+
58
+ const first = await resolver.resolve({clientId, requestIp: '198.51.100.10', redirectUri});
59
+ nowMs += 30_000;
60
+ const second = await resolver.resolve({clientId, requestIp: '198.51.100.10', redirectUri});
61
+ nowMs += 31_000;
62
+ const third = await resolver.resolve({clientId, requestIp: '198.51.100.10', redirectUri});
63
+
64
+ expect(first).toEqual(second);
65
+ expect(third).not.toEqual(first);
66
+ expect(fetchMetadata).toHaveBeenCalledTimes(2);
67
+ expect(upsertCimdClient).toHaveBeenCalledTimes(2);
68
+ expect(findClient).toHaveBeenCalledTimes(2);
69
+ expect(checkCimdRateLimit).toHaveBeenCalledTimes(3);
70
+ });
71
+
72
+ it('does not cache a no-store CIMD response', async () => {
73
+ const findClient = vi.fn(async () => undefined);
74
+ const fetchMetadata = vi.fn(async () => ({metadata: fetched(), cacheMaxAgeSeconds: 0}));
75
+ const resolver = createOAuthClientResolver({
76
+ findClient,
77
+ fetchMetadata,
78
+ upsertCimdClient: async () => client(),
79
+ checkCimdRateLimit: async () => undefined,
80
+ });
81
+
82
+ await resolver.resolve({clientId, requestIp: '198.51.100.10'});
83
+ await resolver.resolve({clientId, requestIp: '198.51.100.10'});
84
+
85
+ expect(fetchMetadata).toHaveBeenCalledTimes(2);
86
+ });
87
+
88
+ it('clamps CIMD cache freshness to the process maximum', async () => {
89
+ let nowMs = Date.parse('2026-09-01T00:00:00.000Z');
90
+ const fetchMetadata = vi.fn(async () => ({metadata: fetched(), cacheMaxAgeSeconds: 999_999}));
91
+ const resolver = createOAuthClientResolver({
92
+ findClient: async () => undefined,
93
+ fetchMetadata,
94
+ upsertCimdClient: async () => client(),
95
+ checkCimdRateLimit: async () => undefined,
96
+ now: () => new Date(nowMs),
97
+ });
98
+
99
+ await resolver.resolve({clientId, requestIp: '198.51.100.10'});
100
+ nowMs += (OAUTH_CIMD_CACHE_MAX_AGE_SECONDS + 1) * 1000;
101
+ await resolver.resolve({clientId, requestIp: '198.51.100.10'});
102
+
103
+ expect(fetchMetadata).toHaveBeenCalledTimes(2);
104
+ });
105
+
106
+ it('bounds the number of cached CIMD clients', async () => {
107
+ const clientIds = Array.from(
108
+ {length: OAUTH_CIMD_CACHE_MAX_ENTRIES + 1},
109
+ (_, index) => `https://client.example/.well-known/oauth-client-${index}`,
110
+ );
111
+ const fetchMetadata = vi.fn(async (resolvedClientId: string) => ({
112
+ metadata: {...fetched(), clientId: resolvedClientId},
113
+ cacheMaxAgeSeconds: 60,
114
+ }));
115
+ const resolver = createOAuthClientResolver({
116
+ findClient: async () => undefined,
117
+ fetchMetadata,
118
+ upsertCimdClient: async (params) => client({clientId: params.clientId}),
119
+ checkCimdRateLimit: async () => undefined,
120
+ });
121
+
122
+ for (const resolvedClientId of clientIds) {
123
+ await resolver.resolve({clientId: resolvedClientId, requestIp: '198.51.100.10'});
124
+ }
125
+ const firstClientId = clientIds[0];
126
+ if (firstClientId === undefined) throw new Error('Expected a non-empty client ID list');
127
+ await resolver.resolve({clientId: firstClientId, requestIp: '198.51.100.10'});
128
+
129
+ expect(fetchMetadata).toHaveBeenCalledTimes(clientIds.length + 1);
130
+ });
131
+
132
+ it('requires a source IP before resolving CIMD metadata', async () => {
133
+ const fetchMetadata = vi.fn();
134
+ const checkCimdRateLimit = vi.fn(async () => undefined);
135
+ const resolver = createOAuthClientResolver({
136
+ findClient: async () => undefined,
137
+ fetchMetadata,
138
+ checkCimdRateLimit,
139
+ });
140
+
141
+ await expect(resolver.resolve({clientId})).rejects.toBeInstanceOf(
142
+ InvalidOAuthClientMetadataError,
143
+ );
144
+ expect(fetchMetadata).not.toHaveBeenCalled();
145
+ expect(checkCimdRateLimit).not.toHaveBeenCalled();
146
+ });
147
+
148
+ it('returns registered clients without treating opaque IDs as CIMD URLs', async () => {
149
+ const registered = client({clientId: 'client_opaque', kind: 'registered'});
150
+ const fetchMetadata = vi.fn();
151
+ const checkCimdRateLimit = vi.fn(async () => undefined);
152
+ const resolver = createOAuthClientResolver({
153
+ findClient: async () => registered,
154
+ fetchMetadata,
155
+ checkCimdRateLimit,
156
+ });
157
+
158
+ const result = await resolver.resolve({
159
+ clientId: registered.clientId,
160
+ requestIp: '198.51.100.10',
161
+ redirectUri,
162
+ });
163
+
164
+ expect(result.kind).toBe('registered');
165
+ expect(result.client).toBe(registered);
166
+ expect(fetchMetadata).not.toHaveBeenCalled();
167
+ expect(checkCimdRateLimit).not.toHaveBeenCalled();
168
+ });
169
+
170
+ it('rejects a redirect before persisting a fetched client', async () => {
171
+ const upsertCimdClient = vi.fn(async () => client());
172
+ const resolver = createOAuthClientResolver({
173
+ findClient: async () => undefined,
174
+ fetchMetadata: async () => ({metadata: fetched(), cacheMaxAgeSeconds: 60}),
175
+ upsertCimdClient,
176
+ checkCimdRateLimit: async () => undefined,
177
+ });
178
+
179
+ await expect(
180
+ resolver.resolve({
181
+ clientId,
182
+ requestIp: '198.51.100.10',
183
+ redirectUri: 'https://client.example/other',
184
+ }),
185
+ ).rejects.toMatchObject({name: 'OAuthRedirectUriNotRegisteredError'});
186
+ expect(upsertCimdClient).not.toHaveBeenCalled();
187
+ });
188
+
189
+ it('does not drop explicitly supplied empty registration values', async () => {
190
+ await expect(
191
+ registerOAuthClient({
192
+ clientName: 'Desktop agent',
193
+ redirectUris: [redirectUri],
194
+ scope: '',
195
+ }),
196
+ ).rejects.toBeInstanceOf(InvalidOAuthClientMetadataError);
197
+ });
198
+ });
@@ -0,0 +1,253 @@
1
+ import {
2
+ createAgentClient,
3
+ findAgentClientByClientId,
4
+ upsertCimdAgentClient,
5
+ } from '#db/agent-access.js';
6
+ import {type FetchedCimdMetadata, fetchClientIdMetadata} from './cimd.js';
7
+ import type {AgentClient} from './entities/agent-access.js';
8
+ import {InvalidOAuthClientMetadataError} from './errors.js';
9
+ import {
10
+ assertOAuthClientMetadataMatchesRequest,
11
+ metadataForAgentClient,
12
+ OAUTH_CIMD_CACHE_MAX_AGE_SECONDS,
13
+ OAUTH_CLIENT_ID_MAX_BYTES,
14
+ type OAuthGrantType,
15
+ type ValidatedOAuthClientMetadata,
16
+ validateOAuthClientId,
17
+ validateOAuthDynamicClientRegistration,
18
+ } from './oauth-client.js';
19
+ import {checkAuthRateLimit} from './rate-limit.js';
20
+
21
+ export interface ResolvedOAuthClient {
22
+ client: AgentClient;
23
+ metadata: ValidatedOAuthClientMetadata;
24
+ kind: AgentClient['kind'];
25
+ }
26
+
27
+ export interface ResolveOAuthClientParams {
28
+ clientId: string;
29
+ requestIp?: string | undefined;
30
+ redirectUri?: string | undefined;
31
+ tokenEndpointAuthMethod?: string | undefined;
32
+ }
33
+
34
+ type FetchMetadata = (clientId: string) => Promise<FetchedCimdMetadata>;
35
+ type FindClient = (params: {clientId: string}) => Promise<AgentClient | undefined>;
36
+ type UpsertCimdClient = (params: {
37
+ clientId: string;
38
+ name: string;
39
+ redirectUris: string[];
40
+ }) => Promise<AgentClient>;
41
+ type CheckCimdRateLimit = (ip: string) => Promise<void>;
42
+
43
+ interface CachedCimdClient {
44
+ expiresAt: number;
45
+ resolved: ResolvedOAuthClient;
46
+ }
47
+
48
+ export interface OAuthClientResolver {
49
+ resolve(params: ResolveOAuthClientParams): Promise<ResolvedOAuthClient>;
50
+ clearCache(): void;
51
+ }
52
+
53
+ export interface OAuthClientResolverOptions {
54
+ fetchMetadata?: FetchMetadata;
55
+ findClient?: FindClient;
56
+ upsertCimdClient?: UpsertCimdClient;
57
+ checkCimdRateLimit?: CheckCimdRateLimit;
58
+ now?: () => Date;
59
+ }
60
+
61
+ /** Bounds process-wide CIMD metadata retained between authorization requests. */
62
+ export const OAUTH_CIMD_CACHE_MAX_ENTRIES = 1024;
63
+
64
+ function assertClientIdBounded(clientId: string): void {
65
+ if (
66
+ clientId.length === 0 ||
67
+ clientId.trim() !== clientId ||
68
+ Buffer.byteLength(clientId, 'utf8') > OAUTH_CLIENT_ID_MAX_BYTES
69
+ ) {
70
+ throw new InvalidOAuthClientMetadataError();
71
+ }
72
+ }
73
+
74
+ function normalizedCacheAge(seconds: number): number {
75
+ if (!Number.isFinite(seconds) || seconds <= 0) return 0;
76
+ return Math.min(Math.floor(seconds), OAUTH_CIMD_CACHE_MAX_AGE_SECONDS);
77
+ }
78
+
79
+ function pruneExpiredCache(cache: Map<string, CachedCimdClient>, nowMs: number): void {
80
+ for (const [clientId, cached] of cache) {
81
+ if (cached.expiresAt <= nowMs) cache.delete(clientId);
82
+ }
83
+ }
84
+
85
+ function cacheCimdClient(
86
+ cache: Map<string, CachedCimdClient>,
87
+ clientId: string,
88
+ cached: CachedCimdClient,
89
+ nowMs: number,
90
+ ): void {
91
+ pruneExpiredCache(cache, nowMs);
92
+ if (!cache.has(clientId) && cache.size >= OAUTH_CIMD_CACHE_MAX_ENTRIES) {
93
+ const oldest = cache.keys().next();
94
+ if (!oldest.done) cache.delete(oldest.value);
95
+ }
96
+ cache.set(clientId, cached);
97
+ }
98
+
99
+ function registeredMetadata(client: AgentClient): ResolvedOAuthClient {
100
+ return {client, metadata: metadataForAgentClient(client), kind: client.kind};
101
+ }
102
+
103
+ function assertResolvedClientMatchesRequest(
104
+ resolved: ResolvedOAuthClient,
105
+ params: ResolveOAuthClientParams,
106
+ ): void {
107
+ assertOAuthClientMetadataMatchesRequest({
108
+ metadata: resolved.metadata,
109
+ redirectUri: params.redirectUri,
110
+ tokenEndpointAuthMethod: params.tokenEndpointAuthMethod,
111
+ });
112
+ }
113
+
114
+ async function resolveCachedClient(
115
+ params: ResolveOAuthClientParams,
116
+ cached: CachedCimdClient,
117
+ checkCimdRateLimit: CheckCimdRateLimit,
118
+ ): Promise<ResolvedOAuthClient> {
119
+ if (params.requestIp === undefined) throw new InvalidOAuthClientMetadataError();
120
+ await checkCimdRateLimit(params.requestIp);
121
+ assertResolvedClientMatchesRequest(cached.resolved, params);
122
+ return cached.resolved;
123
+ }
124
+
125
+ export function createOAuthClientResolver(
126
+ options: OAuthClientResolverOptions = {},
127
+ ): OAuthClientResolver {
128
+ const cache = new Map<string, CachedCimdClient>();
129
+ const now = options.now ?? (() => new Date());
130
+ const findClient = options.findClient ?? findAgentClientByClientId;
131
+ const fetchMetadata = options.fetchMetadata ?? ((clientId) => fetchClientIdMetadata(clientId));
132
+ const upsertCimdClient = options.upsertCimdClient ?? upsertCimdAgentClient;
133
+ const checkCimdRateLimit =
134
+ options.checkCimdRateLimit ??
135
+ (async (requestIp: string) => {
136
+ await checkAuthRateLimit({
137
+ action: 'oauth-cimd',
138
+ scope: 'ip',
139
+ identifier: requestIp,
140
+ limit: 30,
141
+ windowSeconds: 60 * 60,
142
+ });
143
+ });
144
+
145
+ const resolve = async (params: ResolveOAuthClientParams): Promise<ResolvedOAuthClient> => {
146
+ assertClientIdBounded(params.clientId);
147
+
148
+ const cached = cache.get(params.clientId);
149
+ if (cached) {
150
+ if (cached.expiresAt > now().getTime()) {
151
+ return await resolveCachedClient(params, cached, checkCimdRateLimit);
152
+ }
153
+ cache.delete(params.clientId);
154
+ }
155
+
156
+ const existing = await findClient({clientId: params.clientId});
157
+ if (existing?.kind === 'registered') {
158
+ const resolved = registeredMetadata(existing);
159
+ assertResolvedClientMatchesRequest(resolved, params);
160
+ return resolved;
161
+ }
162
+
163
+ if (params.requestIp === undefined) throw new InvalidOAuthClientMetadataError();
164
+ await checkCimdRateLimit(params.requestIp);
165
+ validateOAuthClientId(params.clientId);
166
+ const fetched = await fetchMetadata(params.clientId);
167
+ if (fetched.metadata.clientId !== params.clientId) {
168
+ throw new InvalidOAuthClientMetadataError();
169
+ }
170
+ assertOAuthClientMetadataMatchesRequest({
171
+ metadata: fetched.metadata,
172
+ redirectUri: params.redirectUri,
173
+ tokenEndpointAuthMethod: params.tokenEndpointAuthMethod,
174
+ });
175
+ const client = await upsertCimdClient({
176
+ clientId: fetched.metadata.clientId,
177
+ name: fetched.metadata.clientName,
178
+ redirectUris: fetched.metadata.redirectUris,
179
+ });
180
+ const resolved: ResolvedOAuthClient = {
181
+ client,
182
+ metadata: fetched.metadata,
183
+ kind: 'cimd',
184
+ };
185
+ const cacheAge = normalizedCacheAge(fetched.cacheMaxAgeSeconds);
186
+ if (cacheAge > 0) {
187
+ const nowMs = now().getTime();
188
+ cacheCimdClient(
189
+ cache,
190
+ params.clientId,
191
+ {expiresAt: nowMs + cacheAge * 1000, resolved},
192
+ nowMs,
193
+ );
194
+ }
195
+ return resolved;
196
+ };
197
+
198
+ return {
199
+ resolve,
200
+ clearCache: () => cache.clear(),
201
+ };
202
+ }
203
+
204
+ export interface RegisterOAuthClientParams {
205
+ clientName: string;
206
+ redirectUris: string[];
207
+ grantTypes?: OAuthGrantType[] | undefined;
208
+ responseTypes?: 'code'[] | undefined;
209
+ tokenEndpointAuthMethod?: 'none' | undefined;
210
+ scope?: string | undefined;
211
+ }
212
+
213
+ export interface RegisteredOAuthClient {
214
+ client: AgentClient;
215
+ metadata: ValidatedOAuthClientMetadata;
216
+ }
217
+
218
+ export async function registerOAuthClient(
219
+ input: RegisterOAuthClientParams,
220
+ ): Promise<RegisteredOAuthClient> {
221
+ const validated = validateOAuthDynamicClientRegistration({
222
+ client_name: input.clientName,
223
+ redirect_uris: input.redirectUris,
224
+ ...(input.grantTypes !== undefined ? {grant_types: input.grantTypes} : {}),
225
+ ...(input.responseTypes !== undefined ? {response_types: input.responseTypes} : {}),
226
+ ...(input.tokenEndpointAuthMethod !== undefined
227
+ ? {token_endpoint_auth_method: input.tokenEndpointAuthMethod}
228
+ : {}),
229
+ ...(input.scope !== undefined ? {scope: input.scope} : {}),
230
+ });
231
+ const client = await createAgentClient({
232
+ clientId: `client_${crypto.randomUUID()}`,
233
+ name: validated.clientName,
234
+ redirectUris: validated.redirectUris,
235
+ kind: 'registered',
236
+ });
237
+ return {
238
+ client,
239
+ metadata: {
240
+ clientId: client.clientId,
241
+ ...validated,
242
+ },
243
+ };
244
+ }
245
+
246
+ const defaultOAuthClientResolver = createOAuthClientResolver();
247
+
248
+ export async function resolveOAuthClient(
249
+ params: ResolveOAuthClientParams,
250
+ resolver: OAuthClientResolver = defaultOAuthClientResolver,
251
+ ): Promise<ResolvedOAuthClient> {
252
+ return await resolver.resolve(params);
253
+ }
@@ -0,0 +1,157 @@
1
+ import {describe, expect, it} from '@shipfox/vitest/vi';
2
+ import {
3
+ InvalidOAuthClientMetadataError,
4
+ InvalidOAuthConfigurationError,
5
+ OAuthRedirectUriNotRegisteredError,
6
+ } from './errors.js';
7
+ import {
8
+ assertOAuthClientMetadataMatchesRequest,
9
+ assertOAuthRedirectUriRegistered,
10
+ oauthRedirectUriMatches,
11
+ validateOAuthClientId,
12
+ validateOAuthClientMetadataDocument,
13
+ validateOAuthDynamicClientRegistration,
14
+ validateOAuthPublicOrigin,
15
+ validateOAuthRedirectUri,
16
+ } from './oauth-client.js';
17
+
18
+ describe('OAuth client validation', () => {
19
+ it('accepts HTTPS redirects and loopback HTTP redirects', () => {
20
+ expect(validateOAuthRedirectUri('https://client.example/callback').protocol).toBe('https:');
21
+ expect(validateOAuthRedirectUri('http://localhost:43123/callback').hostname).toBe('localhost');
22
+ expect(validateOAuthRedirectUri('http://127.0.0.1/callback').hostname).toBe('127.0.0.1');
23
+ expect(validateOAuthRedirectUri('http://[::1]:43123/callback').hostname).toBe('[::1]');
24
+ });
25
+
26
+ it('allows only a loopback port variation during redirect matching', () => {
27
+ expect(
28
+ oauthRedirectUriMatches('http://127.0.0.1:43123/callback', 'http://127.0.0.1:51999/callback'),
29
+ ).toBe(true);
30
+ expect(
31
+ oauthRedirectUriMatches('http://[::1]:43123/callback', 'http://[::1]:51999/callback'),
32
+ ).toBe(true);
33
+ expect(
34
+ oauthRedirectUriMatches('http://127.0.0.1:43123/callback', 'http://127.0.0.1:51999/other'),
35
+ ).toBe(false);
36
+ expect(
37
+ oauthRedirectUriMatches(
38
+ 'http://localhost:43123/callback',
39
+ 'http://localhost:51999/./callback',
40
+ ),
41
+ ).toBe(false);
42
+ expect(
43
+ oauthRedirectUriMatches(
44
+ 'https://client.example/callback',
45
+ 'https://client.example:443/callback',
46
+ ),
47
+ ).toBe(false);
48
+ });
49
+
50
+ it('rejects private-use and non-loopback HTTP redirect schemes', () => {
51
+ for (const value of [
52
+ 'http://client.example/callback',
53
+ 'com.example.app:/oauth/callback',
54
+ 'https://client.example/callback#fragment',
55
+ 'https://user:password@client.example/callback',
56
+ ]) {
57
+ expect(() => validateOAuthRedirectUri(value)).toThrow(InvalidOAuthClientMetadataError);
58
+ }
59
+ });
60
+
61
+ it('requires CIMD client IDs to be HTTPS URLs with a path', () => {
62
+ expect(validateOAuthClientId('https://client.example/.well-known/oauth-client').pathname).toBe(
63
+ '/.well-known/oauth-client',
64
+ );
65
+ for (const value of ['https://client.example', 'http://client.example/oauth-client']) {
66
+ expect(() => validateOAuthClientId(value)).toThrow(InvalidOAuthClientMetadataError);
67
+ }
68
+ });
69
+
70
+ it('normalizes and validates the injected API origin', () => {
71
+ expect(validateOAuthPublicOrigin('https://api.example.test/')).toBe('https://api.example.test');
72
+ expect(validateOAuthPublicOrigin('http://localhost:3000/')).toBe('http://localhost:3000');
73
+ expect(() => validateOAuthPublicOrigin('https://api.example.test/v1')).toThrow(
74
+ InvalidOAuthConfigurationError,
75
+ );
76
+ });
77
+
78
+ it('validates registration bounds and the CIMD identity contract', () => {
79
+ const registration = validateOAuthDynamicClientRegistration({
80
+ client_name: 'Desktop agent',
81
+ redirect_uris: ['https://client.example/callback'],
82
+ token_endpoint_auth_method: 'none',
83
+ scope: 'read',
84
+ });
85
+ expect(registration).toMatchObject({
86
+ clientName: 'Desktop agent',
87
+ responseTypes: ['code'],
88
+ tokenEndpointAuthMethod: 'none',
89
+ scope: 'read',
90
+ });
91
+
92
+ expect(() =>
93
+ validateOAuthDynamicClientRegistration({
94
+ client_name: 'Desktop agent',
95
+ redirect_uris: ['https://client.example/callback'],
96
+ grant_types: ['authorization_code', 'refresh_token'],
97
+ }),
98
+ ).toThrow(InvalidOAuthClientMetadataError);
99
+
100
+ expect(() =>
101
+ validateOAuthDynamicClientRegistration({
102
+ client_name: 'é'.repeat(200),
103
+ redirect_uris: ['https://client.example/callback'],
104
+ }),
105
+ ).toThrow(InvalidOAuthClientMetadataError);
106
+
107
+ expect(() =>
108
+ validateOAuthClientMetadataDocument(
109
+ {
110
+ client_id: 'https://client.example/.well-known/oauth-client',
111
+ client_name: 'Desktop agent',
112
+ redirect_uris: ['https://client.example/callback'],
113
+ token_endpoint_auth_method: 'client_secret_basic',
114
+ },
115
+ 'https://client.example/.well-known/oauth-client',
116
+ ),
117
+ ).toThrow(InvalidOAuthClientMetadataError);
118
+
119
+ expect(() =>
120
+ validateOAuthClientMetadataDocument(
121
+ {
122
+ client_id: 'https://other.example/.well-known/oauth-client',
123
+ client_name: 'Desktop agent',
124
+ redirect_uris: ['https://client.example/callback'],
125
+ },
126
+ 'https://client.example/.well-known/oauth-client',
127
+ ),
128
+ ).toThrow(InvalidOAuthClientMetadataError);
129
+ });
130
+
131
+ it('checks redirect membership and public-client authentication', () => {
132
+ const metadata = validateOAuthDynamicClientRegistration({
133
+ client_name: 'Desktop agent',
134
+ redirect_uris: ['http://127.0.0.1:43123/callback'],
135
+ });
136
+ const clientMetadata = {
137
+ clientId: 'client_123',
138
+ ...metadata,
139
+ };
140
+
141
+ expect(() =>
142
+ assertOAuthClientMetadataMatchesRequest({
143
+ metadata: clientMetadata,
144
+ redirectUri: 'http://127.0.0.1:43124/callback',
145
+ }),
146
+ ).not.toThrow();
147
+ expect(() =>
148
+ assertOAuthRedirectUriRegistered(metadata.redirectUris, 'https://other.example/callback'),
149
+ ).toThrow(OAuthRedirectUriNotRegisteredError);
150
+ expect(() =>
151
+ assertOAuthClientMetadataMatchesRequest({
152
+ metadata: clientMetadata,
153
+ tokenEndpointAuthMethod: 'client_secret_basic',
154
+ }),
155
+ ).toThrow(InvalidOAuthClientMetadataError);
156
+ });
157
+ });