@tumbaland/backend-core 1.44.0 → 1.45.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 (101) hide show
  1. package/package.json +5 -1
  2. package/.versionrc.json +0 -7
  3. package/__mocks__/uuid.js +0 -8
  4. package/jest.config.js +0 -24
  5. package/src/apiKeys/ApiKey.test.ts +0 -142
  6. package/src/apiKeys/ApiKey.ts +0 -102
  7. package/src/apiKeys/crypto.test.ts +0 -161
  8. package/src/apiKeys/crypto.ts +0 -163
  9. package/src/apiKeys/index.test.ts +0 -72
  10. package/src/apiKeys/index.ts +0 -35
  11. package/src/apiKeys/middleware.test.ts +0 -651
  12. package/src/apiKeys/middleware.ts +0 -341
  13. package/src/apiKeys/service.test.ts +0 -401
  14. package/src/apiKeys/service.ts +0 -168
  15. package/src/apiKeys/types.test.ts +0 -41
  16. package/src/apiKeys/types.ts +0 -124
  17. package/src/app/createBaseApp.test.ts +0 -109
  18. package/src/app/createBaseApp.ts +0 -102
  19. package/src/app/shutdown.test.ts +0 -129
  20. package/src/app/shutdown.ts +0 -81
  21. package/src/audit/AuditEvent.ts +0 -123
  22. package/src/audit/actor.test.ts +0 -95
  23. package/src/audit/actor.ts +0 -68
  24. package/src/audit/context.test.ts +0 -91
  25. package/src/audit/context.ts +0 -83
  26. package/src/audit/index.ts +0 -11
  27. package/src/audit/plugin.test.ts +0 -258
  28. package/src/audit/plugin.ts +0 -254
  29. package/src/audit/reads.test.ts +0 -164
  30. package/src/audit/reads.ts +0 -88
  31. package/src/audit/service.test.ts +0 -115
  32. package/src/audit/service.ts +0 -95
  33. package/src/auth/session.test.ts +0 -89
  34. package/src/auth/session.ts +0 -75
  35. package/src/config/env.test.ts +0 -28
  36. package/src/config/env.ts +0 -13
  37. package/src/database/connection.test.ts +0 -188
  38. package/src/database/connection.ts +0 -90
  39. package/src/entitlements/UsageMeter.ts +0 -49
  40. package/src/entitlements/client.test.ts +0 -200
  41. package/src/entitlements/client.ts +0 -179
  42. package/src/entitlements/definitions.test.ts +0 -161
  43. package/src/entitlements/definitions.ts +0 -268
  44. package/src/entitlements/index.ts +0 -42
  45. package/src/entitlements/middleware.test.ts +0 -196
  46. package/src/entitlements/middleware.ts +0 -150
  47. package/src/entitlements/reconcile.test.ts +0 -333
  48. package/src/entitlements/reconcile.ts +0 -384
  49. package/src/entitlements/types.ts +0 -21
  50. package/src/entitlements/usage.test.ts +0 -314
  51. package/src/entitlements/usage.ts +0 -223
  52. package/src/errors/HttpError.test.ts +0 -76
  53. package/src/errors/HttpError.ts +0 -91
  54. package/src/groups/client.test.ts +0 -215
  55. package/src/groups/client.ts +0 -182
  56. package/src/groups/index.ts +0 -7
  57. package/src/groups/membership.test.ts +0 -84
  58. package/src/groups/membership.ts +0 -133
  59. package/src/groups/subject.test.ts +0 -85
  60. package/src/groups/subject.ts +0 -50
  61. package/src/health/createHealthCheck.test.ts +0 -89
  62. package/src/health/createHealthCheck.ts +0 -67
  63. package/src/health/healthController.test.ts +0 -113
  64. package/src/health/healthController.ts +0 -56
  65. package/src/index.ts +0 -88
  66. package/src/logging/logger.test.ts +0 -91
  67. package/src/logging/logger.ts +0 -103
  68. package/src/metrics/index.test.ts +0 -116
  69. package/src/metrics/index.ts +0 -111
  70. package/src/middleware/authMiddleware.test.ts +0 -275
  71. package/src/middleware/authMiddleware.ts +0 -91
  72. package/src/middleware/corsMiddleware.test.ts +0 -135
  73. package/src/middleware/corsMiddleware.ts +0 -65
  74. package/src/middleware/errorHandler.test.ts +0 -188
  75. package/src/middleware/errorHandler.ts +0 -103
  76. package/src/middleware/internalServiceAuth.test.ts +0 -173
  77. package/src/middleware/internalServiceAuth.ts +0 -96
  78. package/src/middleware/requestLogger.test.ts +0 -81
  79. package/src/middleware/requestLogger.ts +0 -48
  80. package/src/middleware/security.test.ts +0 -45
  81. package/src/middleware/security.ts +0 -43
  82. package/src/middleware/validate.test.ts +0 -72
  83. package/src/middleware/validate.ts +0 -23
  84. package/src/oauth/index.ts +0 -29
  85. package/src/oauth/models.ts +0 -164
  86. package/src/oauth/service.test.ts +0 -432
  87. package/src/oauth/service.ts +0 -299
  88. package/src/oauth/tokens.test.ts +0 -146
  89. package/src/oauth/tokens.ts +0 -186
  90. package/src/testing/serviceTestSetup.ts +0 -17
  91. package/src/tracing/index.test.ts +0 -272
  92. package/src/tracing/index.ts +0 -110
  93. package/src/types/auth.ts +0 -45
  94. package/src/utils/correlation.test.ts +0 -47
  95. package/src/utils/correlation.ts +0 -22
  96. package/src/utils/permissionUtils.test.ts +0 -47
  97. package/src/utils/permissionUtils.ts +0 -68
  98. package/src/utils/response.test.ts +0 -64
  99. package/src/utils/response.ts +0 -60
  100. package/tsconfig.build.json +0 -7
  101. 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 ?? []);