@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
package/src/index.test.ts CHANGED
@@ -1,12 +1,21 @@
1
+ import {dirname, resolve} from 'node:path';
2
+ import {fileURLToPath} from 'node:url';
1
3
  import {
2
4
  AUTH_PASSWORD_RESET_SEND_REQUESTED,
3
5
  AUTH_USER_SIGNED_UP,
4
6
  authEventSchemas,
5
7
  } from '@shipfox/api-auth-dto';
6
8
  import {ADMINISTRATION_ACTION_PERFORMED} from '@shipfox/api-common-dto';
9
+ import {createAuthMaintenanceActivities} from '#temporal/activities/index.js';
7
10
  import {createAuthModule} from './index.js';
8
11
  import {passwordLoginMethods} from './login-methods.js';
9
12
 
13
+ const expectedWorkflowsPath = resolve(
14
+ dirname(fileURLToPath(import.meta.url)),
15
+ '..',
16
+ 'dist/temporal/workflows/index.js',
17
+ );
18
+
10
19
  type SignupPolicyLike = {
11
20
  isSignupAllowed(params: {
12
21
  email: string;
@@ -110,4 +119,22 @@ describe('authModule', () => {
110
119
  ]);
111
120
  expect(events).toEqual(expect.arrayContaining([AUTH_PASSWORD_RESET_SEND_REQUESTED]));
112
121
  });
122
+
123
+ test('registers the agent-access retention worker', () => {
124
+ const worker = authModule.workers?.[0];
125
+ expect(worker).toEqual(
126
+ expect.objectContaining({
127
+ taskQueue: 'auth-agent-access-maintenance',
128
+ workflowsPath: expectedWorkflowsPath,
129
+ workflows: [
130
+ {
131
+ name: 'agentAccessRetentionCron',
132
+ id: 'auth-agent-access-retention',
133
+ cronSchedule: '10 * * * *',
134
+ },
135
+ ],
136
+ }),
137
+ );
138
+ expect(worker?.activities).toBe(createAuthMaintenanceActivities);
139
+ });
113
140
  });
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import {dirname, resolve} from 'node:path';
2
+ import {fileURLToPath} from 'node:url';
1
3
  import {
2
4
  AUTH_PASSWORD_RESET_SEND_REQUESTED,
3
5
  type AuthEventMap,
@@ -24,9 +26,13 @@ import {
24
26
  } from '#presentation/routes/administration.js';
25
27
  import {buildAuthRoutes} from '#presentation/routes/index.js';
26
28
  import {onPasswordResetSendRequested} from '#presentation/subscribers/index.js';
29
+ import {createAuthMaintenanceActivities} from '#temporal/activities/index.js';
30
+ import {AUTH_AGENT_ACCESS_MAINTENANCE_TASK_QUEUE} from '#temporal/constants.js';
27
31
  import {passwordLoginMethods} from './login-methods.js';
28
32
 
29
33
  const authPublisherEventSchemas = {...authEventSchemas, ...administrationActionEventSchemas};
34
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
35
+ const workflowsPath = resolve(packageRoot, 'dist/temporal/workflows/index.js');
30
36
 
31
37
  export type {AdminRole, JobLeaseTokenClaims, RunnerSessionTokenClaims} from '@shipfox/api-auth-dto';
32
38
  export {
@@ -60,6 +66,20 @@ export type {
60
66
  ProvisionUserParams,
61
67
  } from '#core/auth.js';
62
68
  export {createImpersonatedSessionToken, createSessionForUser, provisionUser} from '#core/auth.js';
69
+ export type {
70
+ CimdAddress,
71
+ CimdAddressResolver,
72
+ CimdHttpRequester,
73
+ CimdHttpResponse,
74
+ FetchCimdMetadataOptions,
75
+ FetchedCimdMetadata,
76
+ } from '#core/cimd.js';
77
+ export {
78
+ fetchClientIdMetadata,
79
+ isPublicUnicastAddress,
80
+ OAUTH_CIMD_FETCH_TIMEOUT_MS,
81
+ OAUTH_CIMD_MAX_BODY_BYTES,
82
+ } from '#core/cimd.js';
63
83
  export type {EmailOwner, FindUserByEmailParams} from '#core/email-owner.js';
64
84
  export {findUserByEmail} from '#core/email-owner.js';
65
85
  export type {AdminGrant} from '#core/entities/admin-grant.js';
@@ -80,7 +100,11 @@ export {
80
100
  InvalidAdminBootstrapTokenError,
81
101
  InvalidAdministratorUserDirectoryFilterError,
82
102
  InvalidCredentialsError,
103
+ InvalidOAuthClientMetadataError,
104
+ InvalidOAuthConfigurationError,
83
105
  LastAdminOwnerError,
106
+ OAuthMetadataFetchError,
107
+ OAuthRedirectUriNotRegisteredError,
84
108
  SignupNotAllowedError,
85
109
  UserNotFoundError,
86
110
  } from '#core/errors.js';
@@ -89,6 +113,38 @@ export {
89
113
  jobLeaseParamsFrom,
90
114
  verifyJobLeaseToken,
91
115
  } from '#core/job-lease-token.js';
116
+ export type {OAuthGrantType, ValidatedOAuthClientMetadata} from '#core/oauth-client.js';
117
+ export {
118
+ assertOAuthClientMetadataMatchesRequest,
119
+ assertOAuthRedirectUriRegistered,
120
+ isOAuthLoopbackRedirectUri,
121
+ metadataForAgentClient,
122
+ OAUTH_CIMD_CACHE_MAX_AGE_SECONDS,
123
+ OAUTH_CLIENT_ID_MAX_BYTES,
124
+ OAUTH_CLIENT_NAME_MAX_BYTES,
125
+ OAUTH_REDIRECT_URI_MAX_BYTES,
126
+ OAUTH_REDIRECT_URI_MAX_COUNT,
127
+ oauthRedirectUriMatches,
128
+ validateOAuthClientId,
129
+ validateOAuthClientMetadataDocument,
130
+ validateOAuthDynamicClientRegistration,
131
+ validateOAuthPublicOrigin,
132
+ validateOAuthRedirectUri,
133
+ } from '#core/oauth-client.js';
134
+ export type {
135
+ OAuthClientResolver,
136
+ OAuthClientResolverOptions,
137
+ RegisteredOAuthClient,
138
+ RegisterOAuthClientParams,
139
+ ResolvedOAuthClient,
140
+ ResolveOAuthClientParams,
141
+ } from '#core/oauth-client-resolver.js';
142
+ export {
143
+ createOAuthClientResolver,
144
+ OAUTH_CIMD_CACHE_MAX_ENTRIES,
145
+ registerOAuthClient,
146
+ resolveOAuthClient,
147
+ } from '#core/oauth-client-resolver.js';
92
148
  export type {SignupDenialMessageFormat, SignupPolicy} from '#core/ports.js';
93
149
  export {
94
150
  issueRunnerSessionToken,
@@ -114,6 +170,12 @@ export {
114
170
  setRefreshTokenCookie,
115
171
  } from '#presentation/auth/refresh-cookie.js';
116
172
  export {createRunnerSessionAuthMethod} from '#presentation/auth/runner-session-auth.js';
173
+ export type {CreateOAuthRoutesOptions} from '#presentation/routes/oauth.js';
174
+ export {
175
+ createOAuthClientIdentificationRoutes,
176
+ createOAuthMetadataRoutes,
177
+ createOAuthRoutes,
178
+ } from '#presentation/routes/oauth.js';
117
179
 
118
180
  const subscriber = subscriberFactory<AuthEventMap>();
119
181
 
@@ -140,6 +202,20 @@ export function createAuthModule({
140
202
  e2eRoutes: [createAuthE2eRoutes(workspaces)],
141
203
  publishers: [{name: 'auth', table: authOutbox, db, eventSchemas: authPublisherEventSchemas}],
142
204
  subscribers: [subscriber(AUTH_PASSWORD_RESET_SEND_REQUESTED, onPasswordResetSendRequested)],
205
+ workers: [
206
+ {
207
+ taskQueue: AUTH_AGENT_ACCESS_MAINTENANCE_TASK_QUEUE,
208
+ workflowsPath,
209
+ activities: createAuthMaintenanceActivities,
210
+ workflows: [
211
+ {
212
+ name: 'agentAccessRetentionCron',
213
+ id: 'auth-agent-access-retention',
214
+ cronSchedule: '10 * * * *',
215
+ },
216
+ ],
217
+ },
218
+ ],
143
219
  interModulePresentations: [createAuthInterModulePresentation()],
144
220
  };
145
221
  }
@@ -10,7 +10,11 @@ export type AuthRateLimitAction =
10
10
  | 'bootstrap-state'
11
11
  | 'lookup'
12
12
  | 'directory'
13
- | 'impersonate';
13
+ | 'impersonate'
14
+ | 'oauth-register'
15
+ | 'oauth-cimd'
16
+ | 'oauth-authorize'
17
+ | 'oauth-token';
14
18
  export type AuthRateLimitScope = 'ip' | 'email' | 'actor';
15
19
  export type AuthRateLimitOutcome = 'allowed' | 'blocked' | 'unavailable';
16
20
  export type AuthImpersonationOutcome = 'succeeded' | 'failed';
@@ -0,0 +1,133 @@
1
+ import {createApp, type FastifyInstance} from '@shipfox/node-fastify';
2
+ import {describe, expect, it} from '@shipfox/vitest/vi';
3
+ import {InvalidOAuthConfigurationError} from '#core/errors.js';
4
+ import {createOAuthRoutes} from './oauth.js';
5
+
6
+ let ipCounter = 1;
7
+ const CLIENT_ID_PATTERN = /^client_[0-9a-f-]{36}$/u;
8
+
9
+ function uniqueIp(): string {
10
+ ipCounter += 1;
11
+ return `198.51.100.${ipCounter}`;
12
+ }
13
+
14
+ describe('OAuth route factories', () => {
15
+ let app: FastifyInstance;
16
+
17
+ beforeEach(async () => {
18
+ app = await createApp({
19
+ fastifyOptions: {trustProxy: true},
20
+ routes: [createOAuthRoutes({apiPublicUrl: 'https://api.example.test/'})],
21
+ swagger: false,
22
+ });
23
+ });
24
+
25
+ afterEach(async () => {
26
+ await app.close();
27
+ });
28
+
29
+ it('serves protected-resource and authorization-server metadata', async () => {
30
+ const resource = await app.inject({
31
+ method: 'GET',
32
+ url: '/.well-known/oauth-protected-resource',
33
+ });
34
+ expect(resource.statusCode).toBe(200);
35
+ expect(resource.json()).toEqual({
36
+ resource: 'https://api.example.test/mcp',
37
+ authorization_servers: ['https://api.example.test'],
38
+ scopes_supported: ['read'],
39
+ });
40
+
41
+ const server = await app.inject({
42
+ method: 'GET',
43
+ url: '/.well-known/oauth-authorization-server',
44
+ });
45
+ expect(server.statusCode).toBe(200);
46
+ expect(server.json()).toEqual({
47
+ issuer: 'https://api.example.test',
48
+ authorization_endpoint: 'https://api.example.test/oauth/authorize',
49
+ token_endpoint: 'https://api.example.test/oauth/token',
50
+ registration_endpoint: 'https://api.example.test/oauth/register',
51
+ response_types_supported: ['code'],
52
+ grant_types_supported: ['authorization_code', 'refresh_token'],
53
+ code_challenge_methods_supported: ['S256'],
54
+ token_endpoint_auth_methods_supported: ['none'],
55
+ scopes_supported: ['read'],
56
+ client_id_metadata_document_supported: true,
57
+ });
58
+ });
59
+
60
+ it('creates a public registration with no client secret', async () => {
61
+ const result = await app.inject({
62
+ method: 'POST',
63
+ url: '/oauth/register',
64
+ headers: {'x-forwarded-for': uniqueIp()},
65
+ payload: {
66
+ client_name: 'Desktop agent',
67
+ redirect_uris: ['http://127.0.0.1:43123/callback'],
68
+ },
69
+ });
70
+
71
+ expect(result.statusCode).toBe(201);
72
+ expect(result.json()).toEqual({
73
+ client_id: expect.stringMatching(CLIENT_ID_PATTERN),
74
+ client_name: 'Desktop agent',
75
+ redirect_uris: ['http://127.0.0.1:43123/callback'],
76
+ grant_types: ['authorization_code'],
77
+ response_types: ['code'],
78
+ token_endpoint_auth_method: 'none',
79
+ scope: 'read',
80
+ });
81
+ });
82
+
83
+ it('rejects non-loopback HTTP redirects', async () => {
84
+ const result = await app.inject({
85
+ method: 'POST',
86
+ url: '/oauth/register',
87
+ headers: {'x-forwarded-for': uniqueIp()},
88
+ payload: {
89
+ client_name: 'Desktop agent',
90
+ redirect_uris: ['http://client.example/callback'],
91
+ },
92
+ });
93
+
94
+ expect(result.statusCode).toBe(400);
95
+ });
96
+
97
+ it('rejects an invalid injected public URL before mounting routes', () => {
98
+ expect(() => createOAuthRoutes({apiPublicUrl: 'http://api.example.test'})).toThrow(
99
+ InvalidOAuthConfigurationError,
100
+ );
101
+ });
102
+
103
+ it('returns 429 and Retry-After after the registration IP budget is exhausted', async () => {
104
+ const ip = uniqueIp();
105
+ const payload = {
106
+ client_name: 'Desktop agent',
107
+ redirect_uris: ['https://client.example/callback'],
108
+ };
109
+
110
+ for (let attempt = 0; attempt < 10; attempt += 1) {
111
+ const result = await app.inject({
112
+ method: 'POST',
113
+ url: '/oauth/register',
114
+ headers: {'x-forwarded-for': ip},
115
+ payload,
116
+ });
117
+ expect(result.statusCode).toBe(201);
118
+ }
119
+
120
+ const blocked = await app.inject({
121
+ method: 'POST',
122
+ url: '/oauth/register',
123
+ headers: {'x-forwarded-for': ip},
124
+ payload,
125
+ });
126
+ expect(blocked.statusCode).toBe(429);
127
+ expect(blocked.headers['retry-after']).toEqual(expect.any(String));
128
+ expect(blocked.json()).toEqual({
129
+ code: 'rate-limited',
130
+ details: {retry_after_seconds: expect.any(Number)},
131
+ });
132
+ });
133
+ });
@@ -0,0 +1,144 @@
1
+ import {
2
+ type OAuthDynamicClientRegistrationResponseDto,
3
+ oauthAuthorizationServerMetadataSchema,
4
+ oauthDynamicClientRegistrationRequestSchema,
5
+ oauthDynamicClientRegistrationResponseSchema,
6
+ oauthProtectedResourceMetadataSchema,
7
+ } from '@shipfox/api-auth-dto';
8
+ import {ClientError, defineRoute, type RouteGroup} from '@shipfox/node-fastify';
9
+ import {
10
+ InvalidOAuthClientMetadataError,
11
+ InvalidOAuthConfigurationError,
12
+ OAuthMetadataFetchError,
13
+ OAuthRedirectUriNotRegisteredError,
14
+ } from '#core/errors.js';
15
+ import {validateOAuthPublicOrigin} from '#core/oauth-client.js';
16
+ import {registerOAuthClient} from '#core/oauth-client-resolver.js';
17
+ import {createAuthIpRateLimitPreHandler} from './rate-limit.js';
18
+
19
+ export interface CreateOAuthRoutesOptions {
20
+ /** Validated here and deliberately injected until production composition. */
21
+ apiPublicUrl: string;
22
+ }
23
+
24
+ function apiPublicOrigin(options: CreateOAuthRoutesOptions): string {
25
+ return validateOAuthPublicOrigin(options.apiPublicUrl);
26
+ }
27
+
28
+ function translateOAuthError(error: unknown): never {
29
+ if (
30
+ error instanceof InvalidOAuthClientMetadataError ||
31
+ error instanceof OAuthMetadataFetchError ||
32
+ error instanceof OAuthRedirectUriNotRegisteredError
33
+ ) {
34
+ throw new ClientError('OAuth client metadata is invalid', 'invalid-client-metadata', {
35
+ status: 400,
36
+ });
37
+ }
38
+ if (error instanceof InvalidOAuthConfigurationError) {
39
+ throw new ClientError('OAuth configuration is invalid', 'oauth-configuration-invalid', {
40
+ status: 500,
41
+ });
42
+ }
43
+ throw error;
44
+ }
45
+
46
+ function registrationResponse(
47
+ result: Awaited<ReturnType<typeof registerOAuthClient>>,
48
+ ): OAuthDynamicClientRegistrationResponseDto {
49
+ return {
50
+ client_id: result.metadata.clientId,
51
+ client_name: result.metadata.clientName,
52
+ redirect_uris: result.metadata.redirectUris,
53
+ grant_types: result.metadata.grantTypes,
54
+ response_types: result.metadata.responseTypes,
55
+ token_endpoint_auth_method: result.metadata.tokenEndpointAuthMethod,
56
+ scope: result.metadata.scope,
57
+ };
58
+ }
59
+
60
+ function createProtectedResourceMetadataRoute(origin: string) {
61
+ return defineRoute({
62
+ method: 'GET',
63
+ path: '/.well-known/oauth-protected-resource',
64
+ description: 'Describe the protected MCP resource and its authorization server.',
65
+ schema: {response: {200: oauthProtectedResourceMetadataSchema}},
66
+ handler: () => ({
67
+ resource: `${origin}/mcp`,
68
+ authorization_servers: [origin],
69
+ scopes_supported: ['read'],
70
+ }),
71
+ });
72
+ }
73
+
74
+ function createAuthorizationServerMetadataRoute(origin: string) {
75
+ return defineRoute({
76
+ method: 'GET',
77
+ path: '/.well-known/oauth-authorization-server',
78
+ description: 'Describe the MCP OAuth authorization server.',
79
+ schema: {response: {200: oauthAuthorizationServerMetadataSchema}},
80
+ handler: () => ({
81
+ issuer: origin,
82
+ authorization_endpoint: `${origin}/oauth/authorize`,
83
+ token_endpoint: `${origin}/oauth/token`,
84
+ registration_endpoint: `${origin}/oauth/register`,
85
+ response_types_supported: ['code'],
86
+ grant_types_supported: ['authorization_code', 'refresh_token'],
87
+ code_challenge_methods_supported: ['S256'],
88
+ token_endpoint_auth_methods_supported: ['none'],
89
+ scopes_supported: ['read'],
90
+ client_id_metadata_document_supported: true,
91
+ }),
92
+ });
93
+ }
94
+
95
+ function createDynamicRegistrationRoute() {
96
+ return defineRoute({
97
+ method: 'POST',
98
+ path: '/oauth/register',
99
+ description: 'Register a public OAuth client for MCP access.',
100
+ options: {bodyLimit: 32 * 1024},
101
+ schema: {
102
+ body: oauthDynamicClientRegistrationRequestSchema,
103
+ response: {201: oauthDynamicClientRegistrationResponseSchema},
104
+ },
105
+ preHandler: createAuthIpRateLimitPreHandler('oauth-register'),
106
+ errorHandler: translateOAuthError,
107
+ handler: async (request, reply) => {
108
+ const result = await registerOAuthClient({
109
+ clientName: request.body.client_name,
110
+ redirectUris: request.body.redirect_uris,
111
+ grantTypes: request.body.grant_types,
112
+ responseTypes: request.body.response_types,
113
+ tokenEndpointAuthMethod: request.body.token_endpoint_auth_method,
114
+ scope: request.body.scope,
115
+ });
116
+ reply.code(201);
117
+ return registrationResponse(result);
118
+ },
119
+ });
120
+ }
121
+
122
+ export function createOAuthMetadataRoutes(options: CreateOAuthRoutesOptions): RouteGroup {
123
+ const origin = apiPublicOrigin(options);
124
+ return {
125
+ prefix: '',
126
+ routes: [
127
+ createProtectedResourceMetadataRoute(origin),
128
+ createAuthorizationServerMetadataRoute(origin),
129
+ ],
130
+ };
131
+ }
132
+
133
+ export function createOAuthClientIdentificationRoutes(): RouteGroup {
134
+ return {prefix: '', routes: [createDynamicRegistrationRoute()]};
135
+ }
136
+
137
+ export function createOAuthRoutes(options: CreateOAuthRoutesOptions): RouteGroup {
138
+ const metadataRoutes = createOAuthMetadataRoutes(options);
139
+ const clientIdentificationRoutes = createOAuthClientIdentificationRoutes();
140
+ return {
141
+ prefix: '',
142
+ routes: [...metadataRoutes.routes, ...clientIdentificationRoutes.routes],
143
+ };
144
+ }
@@ -43,6 +43,18 @@ const policies: Record<
43
43
  ip: {limit: 20, windowSeconds: 15 * 60},
44
44
  actor: {limit: 20, windowSeconds: 15 * 60},
45
45
  },
46
+ 'oauth-register': {
47
+ ip: {limit: 10, windowSeconds: 60 * 60},
48
+ },
49
+ 'oauth-cimd': {
50
+ ip: {limit: 30, windowSeconds: 60 * 60},
51
+ },
52
+ 'oauth-authorize': {
53
+ ip: {limit: 30, windowSeconds: 5 * 60},
54
+ },
55
+ 'oauth-token': {
56
+ ip: {limit: 60, windowSeconds: 5 * 60},
57
+ },
46
58
  };
47
59
 
48
60
  interface EmailBody {
@@ -0,0 +1,76 @@
1
+ import {Context} from '@temporalio/activity';
2
+ import {pruneAgentAccessBatch} from '#db/agent-access.js';
3
+ import {
4
+ AGENT_ACCESS_RETENTION_BATCH_LIMIT,
5
+ AGENT_ACCESS_RETENTION_MAX_ITERATIONS,
6
+ AGENT_ACCESS_RETENTION_TIME_BUDGET_MS,
7
+ } from '#db/agent-access-retention.js';
8
+
9
+ export interface AgentAccessRetentionResult {
10
+ deleted: number;
11
+ transitioned: number;
12
+ iterations: number;
13
+ timedOut: boolean;
14
+ }
15
+
16
+ function isAgentAccessRetentionTimeout(error: unknown): boolean {
17
+ if (typeof error !== 'object' || error === null) return false;
18
+ const details = error as {cause?: unknown; code?: unknown};
19
+ if (details.code === '55P03' || details.code === '57014') return true;
20
+ return details.cause !== undefined && details.cause !== error
21
+ ? isAgentAccessRetentionTimeout(details.cause)
22
+ : false;
23
+ }
24
+
25
+ /**
26
+ * Drains bounded retention batches for the current tick. Heartbeats continue
27
+ * while a database batch waits on a lock, and the wall-clock budget keeps the
28
+ * activity inside its five-minute Temporal start-to-close timeout.
29
+ */
30
+ export async function agentAccessRetentionActivity(): Promise<AgentAccessRetentionResult> {
31
+ const ctx = Context.current();
32
+ const startedAt = Date.now();
33
+ const deadline = startedAt + AGENT_ACCESS_RETENTION_TIME_BUDGET_MS;
34
+ const result: AgentAccessRetentionResult = {
35
+ deleted: 0,
36
+ transitioned: 0,
37
+ iterations: 0,
38
+ timedOut: false,
39
+ };
40
+ const heartbeat = () => ctx.heartbeat(result);
41
+ const heartbeatTimer = setInterval(heartbeat, 15_000);
42
+
43
+ try {
44
+ while (result.iterations < AGENT_ACCESS_RETENTION_MAX_ITERATIONS) {
45
+ if (Date.now() >= deadline) {
46
+ result.timedOut = true;
47
+ break;
48
+ }
49
+
50
+ try {
51
+ const batch = await pruneAgentAccessBatch({
52
+ limit: AGENT_ACCESS_RETENTION_BATCH_LIMIT,
53
+ deadlineMs: deadline,
54
+ });
55
+ result.deleted += batch.deleted;
56
+ result.transitioned += batch.transitioned;
57
+ result.iterations += 1;
58
+ heartbeat();
59
+
60
+ if (batch.deleted === 0 && batch.transitioned === 0) break;
61
+ } catch (error) {
62
+ if (!isAgentAccessRetentionTimeout(error)) throw error;
63
+ result.timedOut = true;
64
+ break;
65
+ }
66
+ }
67
+
68
+ if (result.iterations >= AGENT_ACCESS_RETENTION_MAX_ITERATIONS) {
69
+ result.timedOut = true;
70
+ }
71
+ heartbeat();
72
+ return result;
73
+ } finally {
74
+ clearInterval(heartbeatTimer);
75
+ }
76
+ }
@@ -0,0 +1,5 @@
1
+ import {agentAccessRetentionActivity} from './agent-access-retention.js';
2
+
3
+ export function createAuthMaintenanceActivities() {
4
+ return {agentAccessRetentionActivity};
5
+ }
@@ -0,0 +1 @@
1
+ export const AUTH_AGENT_ACCESS_MAINTENANCE_TASK_QUEUE = 'auth-agent-access-maintenance';
@@ -0,0 +1,23 @@
1
+ import {log, proxyActivities} from '@temporalio/workflow';
2
+ import type {createAuthMaintenanceActivities} from '../activities/index.js';
3
+
4
+ const {agentAccessRetentionActivity} = proxyActivities<
5
+ ReturnType<typeof createAuthMaintenanceActivities>
6
+ >({
7
+ startToCloseTimeout: '5 minutes',
8
+ heartbeatTimeout: '1 minute',
9
+ // Retry on the next cron tick so a timed-out attempt cannot overlap its own sweep.
10
+ retry: {maximumAttempts: 1},
11
+ });
12
+
13
+ export async function agentAccessRetentionCron(): Promise<void> {
14
+ const result = await agentAccessRetentionActivity();
15
+ if (result.deleted > 0 || result.transitioned > 0 || result.timedOut) {
16
+ log.info('Agent-access retention sweep complete', {
17
+ deleted: result.deleted,
18
+ transitioned: result.transitioned,
19
+ iterations: result.iterations,
20
+ timedOut: result.timedOut,
21
+ });
22
+ }
23
+ }
@@ -0,0 +1 @@
1
+ export {agentAccessRetentionCron} from './agent-access-retention-cron.js';