@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-auth",
3
3
  "license": "MIT",
4
- "version": "19.0.0",
4
+ "version": "20.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -24,11 +24,13 @@
24
24
  "dependencies": {
25
25
  "@fastify/cookie": "^11.0.2",
26
26
  "@node-rs/argon2": "^2.0.2",
27
+ "@temporalio/activity": "1.18.1",
28
+ "@temporalio/workflow": "1.18.1",
27
29
  "drizzle-orm": "^0.45.2",
28
30
  "zod": "^4.4.3",
29
31
  "@shipfox/api-common-dto": "15.0.0",
30
- "@shipfox/api-auth-context": "19.0.0",
31
- "@shipfox/api-auth-dto": "19.0.0",
32
+ "@shipfox/api-auth-context": "20.0.0",
33
+ "@shipfox/api-auth-dto": "20.0.0",
32
34
  "@shipfox/api-email-challenges": "1.1.12",
33
35
  "@shipfox/api-workspaces-dto": "15.0.0",
34
36
  "@shipfox/inter-module": "0.2.3",
@@ -44,6 +46,7 @@
44
46
  "@shipfox/node-outbox": "0.2.7",
45
47
  "@shipfox/node-postgres": "0.5.1",
46
48
  "@shipfox/node-rate-limit": "0.4.0",
49
+ "@shipfox/node-temporal": "0.4.6",
47
50
  "@shipfox/node-tokens": "1.1.0"
48
51
  },
49
52
  "imports": {
@@ -58,7 +61,7 @@
58
61
  }
59
62
  },
60
63
  "scripts": {
61
- "build": "shipfox-swc",
64
+ "build": "shipfox-swc && shipfox-temporal-bundle dist/temporal/workflows/index.js",
62
65
  "check": "shipfox-biome-check",
63
66
  "check:fix": "shipfox-biome-check --write",
64
67
  "depcruise": "shipfox-depcruise",
@@ -0,0 +1,213 @@
1
+ import {describe, expect, it, vi} from '@shipfox/vitest/vi';
2
+ import {
3
+ type CimdAddress,
4
+ type CimdHttpResponse,
5
+ fetchClientIdMetadata,
6
+ isPublicUnicastAddress,
7
+ } from './cimd.js';
8
+ import {InvalidOAuthClientMetadataError} from './errors.js';
9
+
10
+ const clientId = 'https://client.example/.well-known/oauth-client';
11
+ const publicAddress: CimdAddress = {address: '93.184.216.34', family: 4};
12
+ const validDocument = {
13
+ client_id: clientId,
14
+ client_name: 'Desktop agent',
15
+ redirect_uris: ['https://client.example/callback'],
16
+ token_endpoint_auth_method: 'none',
17
+ };
18
+
19
+ function response(
20
+ document: unknown,
21
+ headers: Record<string, string | string[] | undefined> = {},
22
+ statusCode = 200,
23
+ ) {
24
+ return {
25
+ statusCode,
26
+ headers,
27
+ body: Buffer.from(JSON.stringify(document)),
28
+ };
29
+ }
30
+
31
+ describe('CIMD fetch', () => {
32
+ it('allows global unicast addresses and rejects special-use ranges', () => {
33
+ expect(isPublicUnicastAddress('93.184.216.34', 4)).toBe(true);
34
+ for (const address of [
35
+ '10.0.0.1',
36
+ '127.0.0.1',
37
+ '169.254.1.1',
38
+ '172.16.0.1',
39
+ '192.168.1.1',
40
+ '192.88.99.1',
41
+ '224.0.0.1',
42
+ ]) {
43
+ expect(isPublicUnicastAddress(address, 4)).toBe(false);
44
+ }
45
+ expect(isPublicUnicastAddress('2001:4860:4860::8888', 6)).toBe(true);
46
+ expect(isPublicUnicastAddress('2001:db8::1', 6)).toBe(false);
47
+ expect(isPublicUnicastAddress('fe80::1', 6)).toBe(false);
48
+ for (const address of ['2002:0a00:0001::1', '::ffff:10.0.0.1', '::', '1::2::3']) {
49
+ expect(isPublicUnicastAddress(address, 6)).toBe(false);
50
+ }
51
+ expect(isPublicUnicastAddress('fe80::1%eth0', 6)).toBe(false);
52
+ });
53
+
54
+ it('resolves once and passes the pinned public address to the request', async () => {
55
+ const resolveAddress = vi.fn(async () => [publicAddress]);
56
+ const request = vi.fn((params: {address: CimdAddress}) => {
57
+ expect(params.address).toEqual(publicAddress);
58
+ return Promise.resolve(response(validDocument, {'cache-control': 'max-age=30'}));
59
+ });
60
+
61
+ const result = await fetchClientIdMetadata(clientId, {resolveAddress, request});
62
+
63
+ expect(resolveAddress).toHaveBeenCalledTimes(1);
64
+ expect(request).toHaveBeenCalledTimes(1);
65
+ expect(result.metadata).toMatchObject({
66
+ clientId,
67
+ clientName: 'Desktop agent',
68
+ redirectUris: ['https://client.example/callback'],
69
+ });
70
+ expect(result.cacheMaxAgeSeconds).toBe(30);
71
+ });
72
+
73
+ it('rejects a DNS answer containing a private or link-local address', async () => {
74
+ const request = vi.fn(async () => response(validDocument));
75
+ await expect(
76
+ fetchClientIdMetadata(clientId, {
77
+ resolveAddress: async () => [publicAddress, {address: '169.254.1.1', family: 4}],
78
+ request,
79
+ }),
80
+ ).rejects.toMatchObject({
81
+ name: 'OAuthMetadataFetchError',
82
+ reason: 'private-address',
83
+ });
84
+ expect(request).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it('rejects redirects, oversized bodies, malformed JSON, and identity mismatches', async () => {
88
+ await expect(
89
+ fetchClientIdMetadata(clientId, {
90
+ resolveAddress: async () => [publicAddress],
91
+ request: async () => ({
92
+ statusCode: 302,
93
+ headers: {location: 'https://other.example'},
94
+ body: new Uint8Array(),
95
+ }),
96
+ }),
97
+ ).rejects.toMatchObject({name: 'OAuthMetadataFetchError', reason: 'redirected'});
98
+
99
+ await expect(
100
+ fetchClientIdMetadata(clientId, {
101
+ resolveAddress: async () => [publicAddress],
102
+ maxBodyBytes: 8,
103
+ request: async () => response(validDocument),
104
+ }),
105
+ ).rejects.toMatchObject({name: 'OAuthMetadataFetchError', reason: 'response-too-large'});
106
+
107
+ await expect(
108
+ fetchClientIdMetadata(clientId, {
109
+ resolveAddress: async () => [publicAddress],
110
+ request: async () => ({statusCode: 200, headers: {}, body: Buffer.from('{bad json')}),
111
+ }),
112
+ ).rejects.toBeInstanceOf(InvalidOAuthClientMetadataError);
113
+
114
+ await expect(
115
+ fetchClientIdMetadata(clientId, {
116
+ resolveAddress: async () => [publicAddress],
117
+ request: async () =>
118
+ response({...validDocument, client_id: 'https://other.example/.well-known/client'}),
119
+ }),
120
+ ).rejects.toBeInstanceOf(InvalidOAuthClientMetadataError);
121
+ });
122
+
123
+ it('honors cache directives and rejects invalid response headers', async () => {
124
+ const cacheCases = [
125
+ ['no-store', 0],
126
+ ['no-cache', 0],
127
+ ['max-age = 0', 0],
128
+ ['max-age=30', 30],
129
+ ['max-age=999999', 900],
130
+ ['max-age=not-a-number', 900],
131
+ [undefined, 900],
132
+ ] as const;
133
+
134
+ for (const [cacheControl, expectedMaxAge] of cacheCases) {
135
+ const headers = cacheControl === undefined ? {} : {'cache-control': cacheControl};
136
+ const result = await fetchClientIdMetadata(clientId, {
137
+ resolveAddress: async () => [publicAddress],
138
+ request: async () => response(validDocument, headers),
139
+ });
140
+ expect(result.cacheMaxAgeSeconds).toBe(expectedMaxAge);
141
+ }
142
+
143
+ await expect(
144
+ fetchClientIdMetadata(clientId, {
145
+ resolveAddress: async () => [publicAddress],
146
+ request: async () => response(validDocument, {'content-length': '9'}),
147
+ maxBodyBytes: 8,
148
+ }),
149
+ ).rejects.toMatchObject({name: 'OAuthMetadataFetchError', reason: 'response-too-large'});
150
+
151
+ await expect(
152
+ fetchClientIdMetadata(clientId, {
153
+ resolveAddress: async () => [publicAddress],
154
+ request: async () => response(validDocument, {'content-encoding': 'gzip'}),
155
+ }),
156
+ ).rejects.toMatchObject({name: 'OAuthMetadataFetchError', reason: 'invalid-response'});
157
+
158
+ await expect(
159
+ fetchClientIdMetadata(clientId, {
160
+ resolveAddress: async () => [publicAddress],
161
+ request: async () => response(validDocument, {}, 500),
162
+ }),
163
+ ).rejects.toMatchObject({name: 'OAuthMetadataFetchError', reason: 'invalid-response'});
164
+ });
165
+
166
+ it('tries the next validated address after a connection failure', async () => {
167
+ const secondAddress: CimdAddress = {address: '93.184.216.35', family: 4};
168
+ const request = vi.fn(({address}: {address: CimdAddress}) => {
169
+ if (address === publicAddress) return Promise.reject(new Error('connection refused'));
170
+ return Promise.resolve(response(validDocument));
171
+ });
172
+
173
+ await expect(
174
+ fetchClientIdMetadata(clientId, {
175
+ resolveAddress: async () => [publicAddress, secondAddress],
176
+ request,
177
+ }),
178
+ ).resolves.toMatchObject({metadata: {clientId}});
179
+ expect(request).toHaveBeenCalledTimes(2);
180
+ expect(request).toHaveBeenLastCalledWith(expect.objectContaining({address: secondAddress}));
181
+ });
182
+
183
+ it('aborts an in-flight request when the overall timeout expires', async () => {
184
+ let signal: AbortSignal | undefined;
185
+ const request = vi.fn(
186
+ ({signal: requestSignal}: {signal?: AbortSignal}) =>
187
+ new Promise<CimdHttpResponse>((_resolve, reject) => {
188
+ signal = requestSignal;
189
+ requestSignal?.addEventListener('abort', () => reject(new Error('request aborted')), {
190
+ once: true,
191
+ });
192
+ }),
193
+ );
194
+
195
+ await expect(
196
+ fetchClientIdMetadata(clientId, {
197
+ resolveAddress: async () => [publicAddress],
198
+ request,
199
+ timeoutMs: 10,
200
+ }),
201
+ ).rejects.toMatchObject({name: 'OAuthMetadataFetchError', reason: 'timeout'});
202
+ expect(signal?.aborted).toBe(true);
203
+ });
204
+
205
+ it('does not fetch a credential-bearing client ID', async () => {
206
+ await expect(
207
+ fetchClientIdMetadata('https://user:password@client.example/.well-known/oauth-client', {
208
+ resolveAddress: async () => [publicAddress],
209
+ request: async () => response(validDocument),
210
+ }),
211
+ ).rejects.toBeInstanceOf(InvalidOAuthClientMetadataError);
212
+ });
213
+ });