@tumbaland/backend-core 1.44.0 → 1.46.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 (123) hide show
  1. package/dist/fileService/client.d.ts +54 -0
  2. package/dist/fileService/client.d.ts.map +1 -0
  3. package/dist/fileService/client.js +137 -0
  4. package/dist/fileService/client.js.map +1 -0
  5. package/dist/fileService/index.d.ts +4 -0
  6. package/dist/fileService/index.d.ts.map +1 -0
  7. package/dist/fileService/index.js +40 -0
  8. package/dist/fileService/index.js.map +1 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +6 -2
  12. package/dist/index.js.map +1 -1
  13. package/dist/middleware/corsMiddleware.d.ts.map +1 -1
  14. package/dist/middleware/corsMiddleware.js +4 -1
  15. package/dist/middleware/corsMiddleware.js.map +1 -1
  16. package/dist/middleware/errorHandler.d.ts.map +1 -1
  17. package/dist/middleware/errorHandler.js +5 -8
  18. package/dist/middleware/errorHandler.js.map +1 -1
  19. package/dist/utils/escapeRegex.d.ts +8 -0
  20. package/dist/utils/escapeRegex.d.ts.map +1 -0
  21. package/dist/utils/escapeRegex.js +12 -0
  22. package/dist/utils/escapeRegex.js.map +1 -0
  23. package/package.json +5 -1
  24. package/.versionrc.json +0 -7
  25. package/__mocks__/uuid.js +0 -8
  26. package/jest.config.js +0 -24
  27. package/src/apiKeys/ApiKey.test.ts +0 -142
  28. package/src/apiKeys/ApiKey.ts +0 -102
  29. package/src/apiKeys/crypto.test.ts +0 -161
  30. package/src/apiKeys/crypto.ts +0 -163
  31. package/src/apiKeys/index.test.ts +0 -72
  32. package/src/apiKeys/index.ts +0 -35
  33. package/src/apiKeys/middleware.test.ts +0 -651
  34. package/src/apiKeys/middleware.ts +0 -341
  35. package/src/apiKeys/service.test.ts +0 -401
  36. package/src/apiKeys/service.ts +0 -168
  37. package/src/apiKeys/types.test.ts +0 -41
  38. package/src/apiKeys/types.ts +0 -124
  39. package/src/app/createBaseApp.test.ts +0 -109
  40. package/src/app/createBaseApp.ts +0 -102
  41. package/src/app/shutdown.test.ts +0 -129
  42. package/src/app/shutdown.ts +0 -81
  43. package/src/audit/AuditEvent.ts +0 -123
  44. package/src/audit/actor.test.ts +0 -95
  45. package/src/audit/actor.ts +0 -68
  46. package/src/audit/context.test.ts +0 -91
  47. package/src/audit/context.ts +0 -83
  48. package/src/audit/index.ts +0 -11
  49. package/src/audit/plugin.test.ts +0 -258
  50. package/src/audit/plugin.ts +0 -254
  51. package/src/audit/reads.test.ts +0 -164
  52. package/src/audit/reads.ts +0 -88
  53. package/src/audit/service.test.ts +0 -115
  54. package/src/audit/service.ts +0 -95
  55. package/src/auth/session.test.ts +0 -89
  56. package/src/auth/session.ts +0 -75
  57. package/src/config/env.test.ts +0 -28
  58. package/src/config/env.ts +0 -13
  59. package/src/database/connection.test.ts +0 -188
  60. package/src/database/connection.ts +0 -90
  61. package/src/entitlements/UsageMeter.ts +0 -49
  62. package/src/entitlements/client.test.ts +0 -200
  63. package/src/entitlements/client.ts +0 -179
  64. package/src/entitlements/definitions.test.ts +0 -161
  65. package/src/entitlements/definitions.ts +0 -268
  66. package/src/entitlements/index.ts +0 -42
  67. package/src/entitlements/middleware.test.ts +0 -196
  68. package/src/entitlements/middleware.ts +0 -150
  69. package/src/entitlements/reconcile.test.ts +0 -333
  70. package/src/entitlements/reconcile.ts +0 -384
  71. package/src/entitlements/types.ts +0 -21
  72. package/src/entitlements/usage.test.ts +0 -314
  73. package/src/entitlements/usage.ts +0 -223
  74. package/src/errors/HttpError.test.ts +0 -76
  75. package/src/errors/HttpError.ts +0 -91
  76. package/src/groups/client.test.ts +0 -215
  77. package/src/groups/client.ts +0 -182
  78. package/src/groups/index.ts +0 -7
  79. package/src/groups/membership.test.ts +0 -84
  80. package/src/groups/membership.ts +0 -133
  81. package/src/groups/subject.test.ts +0 -85
  82. package/src/groups/subject.ts +0 -50
  83. package/src/health/createHealthCheck.test.ts +0 -89
  84. package/src/health/createHealthCheck.ts +0 -67
  85. package/src/health/healthController.test.ts +0 -113
  86. package/src/health/healthController.ts +0 -56
  87. package/src/index.ts +0 -88
  88. package/src/logging/logger.test.ts +0 -91
  89. package/src/logging/logger.ts +0 -103
  90. package/src/metrics/index.test.ts +0 -116
  91. package/src/metrics/index.ts +0 -111
  92. package/src/middleware/authMiddleware.test.ts +0 -275
  93. package/src/middleware/authMiddleware.ts +0 -91
  94. package/src/middleware/corsMiddleware.test.ts +0 -135
  95. package/src/middleware/corsMiddleware.ts +0 -65
  96. package/src/middleware/errorHandler.test.ts +0 -188
  97. package/src/middleware/errorHandler.ts +0 -103
  98. package/src/middleware/internalServiceAuth.test.ts +0 -173
  99. package/src/middleware/internalServiceAuth.ts +0 -96
  100. package/src/middleware/requestLogger.test.ts +0 -81
  101. package/src/middleware/requestLogger.ts +0 -48
  102. package/src/middleware/security.test.ts +0 -45
  103. package/src/middleware/security.ts +0 -43
  104. package/src/middleware/validate.test.ts +0 -72
  105. package/src/middleware/validate.ts +0 -23
  106. package/src/oauth/index.ts +0 -29
  107. package/src/oauth/models.ts +0 -164
  108. package/src/oauth/service.test.ts +0 -432
  109. package/src/oauth/service.ts +0 -299
  110. package/src/oauth/tokens.test.ts +0 -146
  111. package/src/oauth/tokens.ts +0 -186
  112. package/src/testing/serviceTestSetup.ts +0 -17
  113. package/src/tracing/index.test.ts +0 -272
  114. package/src/tracing/index.ts +0 -110
  115. package/src/types/auth.ts +0 -45
  116. package/src/utils/correlation.test.ts +0 -47
  117. package/src/utils/correlation.ts +0 -22
  118. package/src/utils/permissionUtils.test.ts +0 -47
  119. package/src/utils/permissionUtils.ts +0 -68
  120. package/src/utils/response.test.ts +0 -64
  121. package/src/utils/response.ts +0 -60
  122. package/tsconfig.build.json +0 -7
  123. package/tsconfig.json +0 -23
@@ -1,299 +0,0 @@
1
- import { createHash, randomBytes, timingSafeEqual } from 'crypto';
2
- import { AuthorizationCode, IAuthorizationCode, OAuthClient, RefreshToken } from './models';
3
- import {
4
- readTenants,
5
- type ApiKeyScope,
6
- type ApiKeyTenant,
7
- type Tenant
8
- } from '../apiKeys/types';
9
-
10
- /** How long a user has between approving and the client exchanging the code. */
11
- const CODE_TTL_MS = 60 * 1000;
12
-
13
- const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex');
14
-
15
- /** base64url of the SHA-256 digest, which is what PKCE `S256` specifies. */
16
- const s256 = (verifier: string): string =>
17
- createHash('sha256').update(verifier).digest('base64url');
18
-
19
- const constantTimeEquals = (a: string, b: string): boolean => {
20
- const left = Buffer.from(a);
21
- const right = Buffer.from(b);
22
- if (left.length !== right.length) return false;
23
- return timingSafeEqual(left, right);
24
- };
25
-
26
- export interface RegisterClientInput {
27
- clientName: string;
28
- redirectUris: string[];
29
- }
30
-
31
- export interface RegisteredClient {
32
- clientId: string;
33
- clientName: string;
34
- redirectUris: string[];
35
- }
36
-
37
- /**
38
- * Register a client, or hand back the one that is already registered.
39
- *
40
- * Clients re-register freely — the Claude app does it on every connect attempt
41
- * — and minting a fresh id each time left a user staring at three identical
42
- * "Claude" entries in their connected apps, only one of which any given
43
- * disconnect would cut off.
44
- *
45
- * Matching on the exact redirect URIs is what makes this safe. These are public
46
- * clients with no secret, so a `client_id` is an identifier rather than a
47
- * credential; anything presenting the same callback URL *is* the same
48
- * application, because that URL is what an authorization code gets delivered
49
- * to. A different app cannot claim it without already controlling it.
50
- */
51
- export const registerClient = async (input: RegisterClientInput): Promise<RegisteredClient> => {
52
- const redirectUris = [...input.redirectUris].sort();
53
-
54
- const existing = await OAuthClient.findOne({
55
- redirectUris: { $size: redirectUris.length, $all: redirectUris }
56
- });
57
-
58
- const client =
59
- existing ??
60
- (await OAuthClient.create({
61
- clientId: `tmb-client-${randomBytes(16).toString('hex')}`,
62
- clientName: input.clientName.slice(0, 200),
63
- redirectUris
64
- }));
65
-
66
- return {
67
- clientId: client.clientId,
68
- clientName: client.clientName,
69
- redirectUris: client.redirectUris
70
- };
71
- };
72
-
73
- export const findClient = (clientId: string) => OAuthClient.findOne({ clientId });
74
-
75
- /**
76
- * Whether a client may be sent back to this URI.
77
- *
78
- * Exact string match, deliberately. Prefix or wildcard matching on redirect URIs
79
- * is the classic way an authorization code ends up delivered to somewhere the
80
- * client never controlled.
81
- */
82
- export const isRegisteredRedirect = (client: { redirectUris: string[] }, uri: string): boolean =>
83
- client.redirectUris.includes(uri);
84
-
85
- export interface IssueCodeInput {
86
- clientId: string;
87
- userId: string;
88
- userEmail: string;
89
- userName: string;
90
- redirectUri: string;
91
- scopes: ApiKeyScope[];
92
- tenants: Tenant[];
93
- defaultTenant: Tenant;
94
- tenantNames: Record<Tenant, string>;
95
- resource: string;
96
- codeChallenge: string;
97
- }
98
-
99
- export const issueAuthorizationCode = async (input: IssueCodeInput): Promise<string> => {
100
- const code = randomBytes(32).toString('base64url');
101
-
102
- await AuthorizationCode.create({
103
- ...input,
104
- code,
105
- expiresAt: new Date(Date.now() + CODE_TTL_MS)
106
- });
107
-
108
- return code;
109
- };
110
-
111
- export type CodeRejection =
112
- | 'unknown'
113
- | 'expired'
114
- | 'already-used'
115
- | 'client-mismatch'
116
- | 'redirect-mismatch'
117
- | 'pkce-failed';
118
-
119
- export interface CodeRedemption {
120
- ok: boolean;
121
- rejection?: CodeRejection;
122
- code?: IAuthorizationCode;
123
- }
124
-
125
- /**
126
- * Exchange a code, once.
127
- *
128
- * The `usedAt` stamp is set by the same atomic update that fetches the code, so
129
- * two simultaneous exchanges cannot both succeed — a replayed code loses the
130
- * race rather than being caught by a check that ran a moment earlier.
131
- */
132
- export const redeemAuthorizationCode = async (
133
- code: string,
134
- clientId: string,
135
- redirectUri: string,
136
- codeVerifier: string
137
- ): Promise<CodeRedemption> => {
138
- const record = await AuthorizationCode.findOneAndUpdate(
139
- { code, usedAt: { $exists: false } },
140
- { $set: { usedAt: new Date() } },
141
- { new: true }
142
- );
143
-
144
- if (!record) {
145
- // Either it never existed or it has already been redeemed; the client is
146
- // told the same thing for both.
147
- const existed = await AuthorizationCode.exists({ code });
148
- return { ok: false, rejection: existed ? 'already-used' : 'unknown' };
149
- }
150
-
151
- if (record.expiresAt.getTime() <= Date.now()) return { ok: false, rejection: 'expired' };
152
- if (record.clientId !== clientId) return { ok: false, rejection: 'client-mismatch' };
153
- if (record.redirectUri !== redirectUri) return { ok: false, rejection: 'redirect-mismatch' };
154
- if (!constantTimeEquals(s256(codeVerifier), record.codeChallenge)) {
155
- return { ok: false, rejection: 'pkce-failed' };
156
- }
157
-
158
- return { ok: true, code: record };
159
- };
160
-
161
- export interface IssuedRefreshToken {
162
- token: string;
163
- }
164
-
165
- /**
166
- * Start a grant, replacing any the same client already held for this user.
167
- *
168
- * Reconnecting is a re-authorization, not a second connection: without this,
169
- * approving twice leaves two live grants, the newer scopes sitting beside the
170
- * older ones, and a disconnect that revokes only one of them.
171
- */
172
- export const issueRefreshToken = async (input: {
173
- clientId: string;
174
- userId: string;
175
- scopes: ApiKeyScope[];
176
- tenants: Tenant[];
177
- defaultTenant: Tenant;
178
- tenantNames: Record<Tenant, string>;
179
- resource: string;
180
- }): Promise<IssuedRefreshToken> => {
181
- await RefreshToken.updateMany(
182
- { userId: input.userId, clientId: input.clientId, revokedAt: { $exists: false } },
183
- { $set: { revokedAt: new Date() } }
184
- );
185
-
186
- const token = randomBytes(32).toString('base64url');
187
- await RefreshToken.create({ ...input, tokenHash: sha256(token) });
188
- return { token };
189
- };
190
-
191
- export interface RefreshRedemption {
192
- ok: boolean;
193
- /** true when a retired token was presented again — treated as a compromise */
194
- reused?: boolean;
195
- userId?: string;
196
- scopes?: ApiKeyScope[];
197
- tenants?: ApiKeyTenant;
198
- tenantNames?: Record<Tenant, string>;
199
- resource?: string;
200
- /** the replacement the client must store; the presented one is now dead */
201
- rotatedToken?: string;
202
- }
203
-
204
- /**
205
- * Exchange a refresh token for a new one, rotating as we go.
206
- *
207
- * Rotation matters because these are the long-lived half: an access token is
208
- * gone within the hour, but a refresh token that never changes is a permanent
209
- * credential for whoever obtains a copy. Each use retires the old token and
210
- * issues a fresh one, so a stolen copy stops working as soon as the legitimate
211
- * client refreshes.
212
- *
213
- * Reuse of an already-rotated token is treated as theft rather than as an
214
- * ordinary failure: the honest client and the thief now both hold tokens
215
- * descended from the same grant, and there is no way to tell which just called.
216
- * So the whole chain is revoked and the user reconnects — noisy, but the
217
- * alternative is leaving an attacker with working access.
218
- */
219
- export const redeemRefreshToken = async (
220
- token: string,
221
- clientId: string
222
- ): Promise<RefreshRedemption> => {
223
- const record = await RefreshToken.findOne({ tokenHash: sha256(token), clientId });
224
- if (!record) return { ok: false };
225
-
226
- if (record.revokedAt) {
227
- // Already rotated or explicitly revoked. If something is still presenting
228
- // it, a copy is loose — cut every token in this grant.
229
- await RefreshToken.updateMany(
230
- { userId: record.userId, clientId, revokedAt: { $exists: false } },
231
- { $set: { revokedAt: new Date() } }
232
- );
233
- return { ok: false, reused: true };
234
- }
235
-
236
- record.revokedAt = new Date();
237
- record.lastUsedAt = new Date();
238
- await record.save();
239
-
240
- const rotated = randomBytes(32).toString('base64url');
241
- await RefreshToken.create({
242
- tokenHash: sha256(rotated),
243
- clientId,
244
- userId: record.userId,
245
- scopes: record.scopes,
246
- // Carried, not reset: rotation reissues the same grant, and re-deriving the
247
- // tenants would quietly narrow a connection every time it refreshed.
248
- tenants: record.tenants,
249
- defaultTenant: record.defaultTenant,
250
- tenantNames: record.tenantNames,
251
- resource: record.resource,
252
- // Carried, not reset: this is still the grant the user approved.
253
- grantedAt: record.grantedAt ?? record.createdAt
254
- });
255
-
256
- return {
257
- ok: true,
258
- userId: record.userId,
259
- scopes: record.scopes as ApiKeyScope[],
260
- tenants: readTenants(record),
261
- tenantNames: record.tenantNames ?? {},
262
- resource: record.resource,
263
- rotatedToken: rotated
264
- };
265
- };
266
-
267
- /** Cut off an assistant: without a refresh token it can obtain nothing new. */
268
- export const revokeRefreshTokensForUser = async (
269
- userId: string,
270
- clientId?: string
271
- ): Promise<number> => {
272
- const filter: Record<string, unknown> = { userId, revokedAt: { $exists: false } };
273
- if (clientId) filter.clientId = clientId;
274
-
275
- const result = await RefreshToken.updateMany(filter, { $set: { revokedAt: new Date() } });
276
- return result.modifiedCount;
277
- };
278
-
279
- export const listConnections = async (userId: string) => {
280
- const tokens = await RefreshToken.find({ userId }).sort({ createdAt: -1 });
281
- const clients = await OAuthClient.find({ clientId: { $in: tokens.map((t) => t.clientId) } });
282
- const nameById = new Map(clients.map((client) => [client.clientId, client.clientName]));
283
-
284
- return tokens.map((token) => ({
285
- id: String(token._id),
286
- clientId: token.clientId,
287
- clientName: nameById.get(token.clientId) ?? 'Unknown app',
288
- scopes: token.scopes,
289
- ...(({ allowed, default: fallback }) => ({ tenants: allowed, defaultTenant: fallback }))(
290
- readTenants(token)
291
- ),
292
- tenantNames: token.tenantNames ?? {},
293
- createdAt: (token.grantedAt ?? token.createdAt).toISOString(),
294
- // Set when the refresh token is exchanged, not when a tool runs — access
295
- // tokens are validated statelessly, so the server never sees ordinary use.
296
- lastRenewedAt: token.lastUsedAt?.toISOString() ?? null,
297
- revokedAt: token.revokedAt?.toISOString() ?? null
298
- }));
299
- };
@@ -1,146 +0,0 @@
1
- const ORIGINAL_ENV = process.env;
2
-
3
- import jwt from 'jsonwebtoken';
4
- import { PERSONAL_TENANT } from '../apiKeys/types';
5
- import { mintAccessToken, verifyAccessToken, parseScopes } from './tokens';
6
-
7
- const RESOURCE = 'https://mcp.tumbaland.eu';
8
- const ISSUER = 'https://auth-api.tumbaland.eu';
9
-
10
- const mint = (over: Partial<Parameters<typeof mintAccessToken>[0]> = {}) =>
11
- mintAccessToken({
12
- userId: 'u1',
13
- email: 'u1@example.com',
14
- name: 'Tester',
15
- resource: RESOURCE,
16
- issuer: ISSUER,
17
- scopes: ['relationship:read', 'relationship:write'],
18
- tenants: [PERSONAL_TENANT],
19
- defaultTenant: PERSONAL_TENANT,
20
- tenantNames: { [PERSONAL_TENANT]: 'My own data' },
21
- clientId: 'client-1',
22
- clientName: 'Claude',
23
- ...over
24
- });
25
-
26
- beforeEach(() => {
27
- process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-jwt-secret' };
28
- });
29
-
30
- afterEach(() => {
31
- process.env = ORIGINAL_ENV;
32
- });
33
-
34
- describe('mintAccessToken', () => {
35
- it('binds the token to one resource and carries the granted scopes', () => {
36
- const { accessToken, scope, expiresIn } = mint();
37
- const claims = jwt.decode(accessToken) as Record<string, unknown>;
38
-
39
- expect(claims.aud).toBe(RESOURCE);
40
- expect(claims.iss).toBe(ISSUER);
41
- expect(claims.sub).toBe('u1');
42
- expect(scope).toBe('relationship:read relationship:write');
43
- expect(expiresIn).toBe(3600);
44
- });
45
-
46
- it('carries the granted tenants, so a token cannot wander outside them', () => {
47
- const claims = jwt.decode(
48
- mint({ tenants: [PERSONAL_TENANT, 'g1'], defaultTenant: 'g1' }).accessToken
49
- ) as Record<string, unknown>;
50
-
51
- expect(claims.tenants).toEqual([PERSONAL_TENANT, 'g1']);
52
- expect(claims.defaultTenant).toBe('g1');
53
- });
54
-
55
- it('carries what each tenant is called, since nothing downstream can look it up', () => {
56
- const claims = jwt.decode(
57
- mint({ tenants: ['g1'], defaultTenant: 'g1', tenantNames: { g1: 'Irja & Tom' } }).accessToken
58
- ) as Record<string, unknown>;
59
-
60
- expect(claims.tenantNames).toEqual({ g1: 'Irja & Tom' });
61
- });
62
-
63
- it('gives every token a distinct id', () => {
64
- const a = jwt.decode(mint().accessToken) as Record<string, unknown>;
65
- const b = jwt.decode(mint().accessToken) as Record<string, unknown>;
66
- expect(a.jti).not.toBe(b.jti);
67
- });
68
- });
69
-
70
- describe('verifyAccessToken', () => {
71
- it('accepts a token minted for this resource', () => {
72
- const { accessToken } = mint();
73
-
74
- expect(verifyAccessToken(accessToken, RESOURCE)).toMatchObject({
75
- ok: true,
76
- userId: 'u1',
77
- email: 'u1@example.com',
78
- scopes: ['relationship:read', 'relationship:write'],
79
- tenants: { allowed: [PERSONAL_TENANT], default: PERSONAL_TENANT }
80
- });
81
- });
82
-
83
- it('refuses a token minted for a different resource', () => {
84
- // RFC 8707 audience binding: a token obtained for somewhere else must not
85
- // be replayable here, which is the whole point of the resource parameter.
86
- const { accessToken } = mint({ resource: 'https://mcp.someone-else.example' });
87
-
88
- expect(verifyAccessToken(accessToken, RESOURCE)).toEqual({
89
- ok: false,
90
- rejection: 'wrong-audience'
91
- });
92
- });
93
-
94
- it('refuses an ordinary session JWT presented as an access token', () => {
95
- // Same secret, same issuer — but no scopes and no tenant pin. Without the
96
- // type check a user's session cookie would authenticate as an assistant
97
- // holding every permission.
98
- const session = jwt.sign(
99
- { id: 'u1', email: 'u1@example.com', name: 'Tester', groups: ['g1'] },
100
- 'test-jwt-secret'
101
- );
102
-
103
- expect(verifyAccessToken(session, RESOURCE)).toEqual({ ok: false, rejection: 'wrong-type' });
104
- });
105
-
106
- it('refuses a token signed with a different secret', () => {
107
- const forged = jwt.sign({ sub: 'u1', aud: RESOURCE, typ: 'mcp_access' }, 'not-the-secret');
108
- expect(verifyAccessToken(forged, RESOURCE)).toEqual({ ok: false, rejection: 'bad-signature' });
109
- });
110
-
111
- it('reports an expired token distinctly, so the client knows to refresh', () => {
112
- const expired = jwt.sign(
113
- { sub: 'u1', aud: RESOURCE, typ: 'mcp_access', scope: '' },
114
- 'test-jwt-secret',
115
- { expiresIn: -10 }
116
- );
117
-
118
- expect(verifyAccessToken(expired, RESOURCE)).toEqual({ ok: false, rejection: 'expired' });
119
- });
120
-
121
- it('refuses gibberish rather than throwing', () => {
122
- expect(verifyAccessToken('not-a-token', RESOURCE).ok).toBe(false);
123
- });
124
-
125
- it('drops scopes it does not define, so a forged claim grants nothing', () => {
126
- const token = jwt.sign(
127
- { sub: 'u1', aud: RESOURCE, typ: 'mcp_access', scope: 'relationship:read admin:everything' },
128
- 'test-jwt-secret'
129
- );
130
-
131
- expect(verifyAccessToken(token, RESOURCE).scopes).toEqual(['relationship:read']);
132
- });
133
- });
134
-
135
- describe('parseScopes', () => {
136
- it('keeps only scopes the system defines', () => {
137
- expect(parseScopes('relationship:read admin:all finance:write')).toEqual([
138
- 'relationship:read',
139
- 'finance:write'
140
- ]);
141
- });
142
-
143
- it.each([[undefined], [null], [42], ['']])('returns nothing for %s', (value) => {
144
- expect(parseScopes(value)).toEqual([]);
145
- });
146
- });
@@ -1,186 +0,0 @@
1
- import { randomUUID } from 'crypto';
2
- import jwt from 'jsonwebtoken';
3
- import { requireEnv } from '../config/env';
4
- import {
5
- isApiKeyScope,
6
- readTenants,
7
- type ApiKeyScope,
8
- type ApiKeyTenant,
9
- type Tenant
10
- } from '../apiKeys/types';
11
-
12
- /**
13
- * Access tokens for the OAuth flow that lets an assistant connect to Tumbaland.
14
- *
15
- * These are JWTs rather than database rows so the MCP server can validate one
16
- * without a query on every tool call, and because OAuth expects short-lived
17
- * bearer tokens with a refresh path rather than the indefinite credentials an
18
- * API key is. The trade is that a token cannot be revoked before it expires —
19
- * hence the deliberately short life, with revocation applied at the refresh
20
- * token, which is the thing that actually persists.
21
- */
22
-
23
- /** Marks a token as issued by the OAuth flow, for the MCP resource specifically. */
24
- export const ACCESS_TOKEN_TYPE = 'mcp_access';
25
-
26
- /** Short enough that a leaked token is a small window, long enough to be usable. */
27
- const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
28
-
29
- export interface AccessTokenClaims {
30
- /** the user the assistant is acting for */
31
- sub: string;
32
- /** the MCP server this token may be used against — RFC 8707 audience binding */
33
- aud: string;
34
- iss: string;
35
- scope: string;
36
- /** every tenant this token may act in */
37
- tenants: Tenant[];
38
- /** the one it acts in when a call names none */
39
- defaultTenant: Tenant;
40
- /**
41
- * What each tenant is called, for a client that has to offer the choice.
42
- *
43
- * Carried on the token because the only place these names are known is the
44
- * consent screen that rendered them, and the MCP server — which has to name
45
- * the choice to a model — cannot reach group-service. A snapshot: a group
46
- * renamed after the connection was made shows its old name until reconnect,
47
- * which is the same trade the owner's name on an API key already makes.
48
- */
49
- tenantNames: Record<Tenant, string>;
50
- /** the single tenant an older token was pinned to; read by `readTenants` */
51
- groupId?: string | null;
52
- /**
53
- * Which app this token was issued to.
54
- *
55
- * `jti` identifies the token and rotates every hour, so it cannot stand in for
56
- * the connection — an audit trail built on it would show a different actor
57
- * each time the assistant refreshed. The client id is stable for the life of
58
- * the grant, and the name is snapshotted alongside it for the same reason the
59
- * tenant names are: nothing downstream can look it up.
60
- */
61
- clientId: string;
62
- clientName: string;
63
- email: string;
64
- name: string;
65
- typ: typeof ACCESS_TOKEN_TYPE;
66
- jti: string;
67
- exp: number;
68
- iat: number;
69
- }
70
-
71
- export interface MintAccessTokenInput {
72
- userId: string;
73
- email: string;
74
- name: string;
75
- /** the canonical URI of the MCP server the token is for */
76
- resource: string;
77
- issuer: string;
78
- scopes: ApiKeyScope[];
79
- tenants: Tenant[];
80
- defaultTenant: Tenant;
81
- tenantNames: Record<Tenant, string>;
82
- clientId: string;
83
- clientName: string;
84
- }
85
-
86
- export interface MintedAccessToken {
87
- accessToken: string;
88
- expiresIn: number;
89
- scope: string;
90
- }
91
-
92
- export const mintAccessToken = (input: MintAccessTokenInput): MintedAccessToken => {
93
- const scope = input.scopes.join(' ');
94
-
95
- const accessToken = jwt.sign(
96
- {
97
- sub: input.userId,
98
- aud: input.resource,
99
- iss: input.issuer,
100
- scope,
101
- tenants: input.tenants,
102
- defaultTenant: input.defaultTenant,
103
- tenantNames: input.tenantNames,
104
- clientId: input.clientId,
105
- clientName: input.clientName,
106
- email: input.email,
107
- name: input.name,
108
- typ: ACCESS_TOKEN_TYPE,
109
- jti: randomUUID()
110
- },
111
- requireEnv('JWT_SECRET'),
112
- { expiresIn: ACCESS_TOKEN_TTL_SECONDS }
113
- );
114
-
115
- return { accessToken, expiresIn: ACCESS_TOKEN_TTL_SECONDS, scope };
116
- };
117
-
118
- export interface AccessTokenVerification {
119
- ok: boolean;
120
- rejection?: 'malformed' | 'expired' | 'wrong-audience' | 'wrong-type' | 'bad-signature';
121
- userId?: string;
122
- email?: string;
123
- name?: string;
124
- scopes?: ApiKeyScope[];
125
- tenants?: ApiKeyTenant;
126
- tenantNames?: Record<Tenant, string>;
127
- clientId?: string;
128
- clientName?: string;
129
- }
130
-
131
- /**
132
- * Check a bearer token presented to the MCP server.
133
- *
134
- * Two checks beyond the signature carry real weight. The audience must match
135
- * this server: a token minted for one resource must not work against another,
136
- * which is what RFC 8707 binding is for and what stops a token obtained for
137
- * somewhere else being replayed here. And the type must be `mcp_access`, so an
138
- * ordinary session JWT — same secret, same issuer, but no scopes and no tenant
139
- * pin — cannot be presented as an access token and quietly get everything.
140
- */
141
- export const verifyAccessToken = (
142
- token: string,
143
- expectedAudience: string
144
- ): AccessTokenVerification => {
145
- let claims: AccessTokenClaims;
146
- try {
147
- claims = jwt.verify(token, requireEnv('JWT_SECRET')) as AccessTokenClaims;
148
- } catch (error) {
149
- const expired = (error as Error)?.name === 'TokenExpiredError';
150
- return { ok: false, rejection: expired ? 'expired' : 'bad-signature' };
151
- }
152
-
153
- if (!isAccessTokenClaims(claims)) return { ok: false, rejection: 'wrong-type' };
154
- if (claims.aud !== expectedAudience) return { ok: false, rejection: 'wrong-audience' };
155
- if (!claims.sub) return { ok: false, rejection: 'malformed' };
156
-
157
- return {
158
- ok: true,
159
- userId: claims.sub,
160
- email: claims.email ?? '',
161
- name: claims.name ?? '',
162
- scopes: (claims.scope ?? '').split(' ').filter(isApiKeyScope),
163
- tenants: readTenants(claims),
164
- tenantNames: claims.tenantNames ?? {},
165
- clientId: claims.clientId,
166
- clientName: claims.clientName
167
- };
168
- };
169
-
170
- /**
171
- * Tell an OAuth access token apart from an ordinary session JWT.
172
- *
173
- * They are signed with the same secret and arrive in the same header, so
174
- * anything holding one has to ask which it got. Getting this wrong is not a
175
- * subtle failure: read as a session, an access token has no `id` and no
176
- * `groups`, so `req.user.id` lands as `undefined` and every scoped query goes
177
- * out unbounded.
178
- */
179
- export const isAccessTokenClaims = (claims: unknown): claims is AccessTokenClaims =>
180
- typeof claims === 'object' &&
181
- claims !== null &&
182
- (claims as AccessTokenClaims).typ === ACCESS_TOKEN_TYPE;
183
-
184
- /** Parse a space-separated `scope` parameter, dropping anything we do not define. */
185
- export const parseScopes = (scope: unknown): ApiKeyScope[] =>
186
- typeof scope === 'string' ? scope.split(/\s+/).filter(isApiKeyScope) : [];
@@ -1,17 +0,0 @@
1
- import { overrideSessionCheckForTests } from '../auth/session';
2
- import { overrideMembershipLookupForTests } from '../groups/membership';
3
-
4
- /**
5
- * Jest setup for service test suites: they sign session tokens without a
6
- * session and run with no database or group-service, so every token's session
7
- * counts as live and its `groups` claim stands in for group membership.
8
- * The session check itself is tested in backend-core (auth/session.test.ts).
9
- *
10
- * Loaded through the package's test-only entry point:
11
- * setupFiles: ['@tumbaland/backend-core/testing']
12
- */
13
- overrideSessionCheckForTests(async () => true);
14
-
15
- // No group-service either: a test token's own `groups` claim is taken as the
16
- // person's membership. The live lookup is tested in groups/membership.test.ts.
17
- overrideMembershipLookupForTests(async (user) => user.groups ?? []);