@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,164 +0,0 @@
1
- import mongoose, { Document, Schema } from 'mongoose';
2
- import { auditPlugin } from '../audit/plugin';
3
- import { PERSONAL_TENANT, type Tenant } from '../apiKeys/types';
4
-
5
- /**
6
- * A client registered through RFC 7591 Dynamic Client Registration.
7
- *
8
- * Assistants register themselves — nobody is going to pre-provision Claude,
9
- * ChatGPT and Gemini by hand, and the MCP spec expects a server to accept
10
- * registrations. Clients are public (no secret): the caller is a browser-driven
11
- * flow that cannot keep one, which is exactly why PKCE is mandatory here.
12
- */
13
- export interface IOAuthClient extends Document {
14
- clientId: string;
15
- clientName: string;
16
- redirectUris: string[];
17
- createdAt: Date;
18
- updatedAt: Date;
19
- }
20
-
21
- const OAuthClientSchema = new Schema<IOAuthClient>(
22
- {
23
- clientId: { type: String, required: true, unique: true, index: true },
24
- clientName: { type: String, required: true, maxlength: 200 },
25
- redirectUris: { type: [String], required: true }
26
- },
27
- { timestamps: true, collection: 'oauth_clients' }
28
- );
29
-
30
- export const OAuthClient = mongoose.models.OAuthClient
31
- ? (mongoose.models.OAuthClient as mongoose.Model<IOAuthClient>)
32
- : mongoose.model<IOAuthClient>('OAuthClient', OAuthClientSchema);
33
-
34
- /**
35
- * A one-time authorization code, alive for the seconds between the user
36
- * approving and the client exchanging it.
37
- *
38
- * Everything the token will assert is fixed here, at the moment consent is
39
- * given: which scopes, which tenant, which resource. The exchange cannot widen
40
- * any of it, so a client that asks for more at the token endpoint than the user
41
- * agreed to gets what the user agreed to.
42
- */
43
- export interface IAuthorizationCode extends Document {
44
- code: string;
45
- clientId: string;
46
- userId: string;
47
- userEmail: string;
48
- userName: string;
49
- redirectUri: string;
50
- scopes: string[];
51
- /** every tenant the user approved, and the one chosen as the default */
52
- tenants: Tenant[];
53
- defaultTenant: Tenant;
54
- /** what each approved tenant is called, snapshotted at consent */
55
- tenantNames: Record<Tenant, string>;
56
- /** the single tenant an older grant was pinned to; read by `readTenants` */
57
- groupId?: string | null;
58
- /** RFC 8707: the MCP server this will be minted for */
59
- resource: string;
60
- /** PKCE, always S256 — the spec does not allow `plain` */
61
- codeChallenge: string;
62
- /** set once the code is exchanged, so a replay is detectable rather than silent */
63
- usedAt?: Date;
64
- expiresAt: Date;
65
- createdAt: Date;
66
- }
67
-
68
- const AuthorizationCodeSchema = new Schema<IAuthorizationCode>(
69
- {
70
- code: { type: String, required: true, unique: true, index: true },
71
- clientId: { type: String, required: true },
72
- userId: { type: String, required: true },
73
- userEmail: { type: String, required: true },
74
- userName: { type: String, default: '' },
75
- redirectUri: { type: String, required: true },
76
- scopes: { type: [String], default: [] },
77
- tenants: { type: [String], default: () => [PERSONAL_TENANT] },
78
- defaultTenant: { type: String, default: PERSONAL_TENANT },
79
- tenantNames: { type: Schema.Types.Mixed, default: () => ({}) },
80
- // Written by the single-tenant model this replaced; read-only now.
81
- groupId: { type: String, default: null },
82
- resource: { type: String, required: true },
83
- codeChallenge: { type: String, required: true },
84
- usedAt: { type: Date },
85
- expiresAt: { type: Date, required: true }
86
- },
87
- { timestamps: { createdAt: true, updatedAt: false }, collection: 'oauth_authorization_codes' }
88
- );
89
-
90
- // Codes are short-lived and never read after expiry; let Mongo sweep them so a
91
- // replay cannot be attempted against a record nobody is cleaning up.
92
- AuthorizationCodeSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
93
-
94
- export const AuthorizationCode = mongoose.models.AuthorizationCode
95
- ? (mongoose.models.AuthorizationCode as mongoose.Model<IAuthorizationCode>)
96
- : mongoose.model<IAuthorizationCode>('AuthorizationCode', AuthorizationCodeSchema);
97
-
98
- /**
99
- * A refresh token: the part that actually persists, and therefore the part
100
- * revocation acts on.
101
- *
102
- * Access tokens are unrevokable JWTs by design, kept short so that matters
103
- * little. Cutting off an assistant means deleting its refresh token, after
104
- * which it can obtain nothing new.
105
- */
106
- export interface IRefreshToken extends Document {
107
- tokenHash: string;
108
- clientId: string;
109
- userId: string;
110
- scopes: string[];
111
- tenants: Tenant[];
112
- defaultTenant: Tenant;
113
- tenantNames: Record<Tenant, string>;
114
- /** the single tenant an older grant was pinned to; read by `readTenants` */
115
- groupId?: string | null;
116
- resource: string;
117
- /**
118
- * When the user actually connected this assistant.
119
- *
120
- * Distinct from `createdAt`, which rotation resets on every refresh — showing
121
- * that in the UI would tell someone they connected Claude an hour ago when
122
- * they did it in March.
123
- */
124
- grantedAt: Date;
125
- revokedAt?: Date;
126
- lastUsedAt?: Date;
127
- createdAt: Date;
128
- updatedAt: Date;
129
- }
130
-
131
- const RefreshTokenSchema = new Schema<IRefreshToken>(
132
- {
133
- tokenHash: { type: String, required: true, unique: true, index: true },
134
- clientId: { type: String, required: true },
135
- userId: { type: String, required: true, index: true },
136
- scopes: { type: [String], default: [] },
137
- tenants: { type: [String], default: () => [PERSONAL_TENANT] },
138
- defaultTenant: { type: String, default: PERSONAL_TENANT },
139
- tenantNames: { type: Schema.Types.Mixed, default: () => ({}) },
140
- // Written by the single-tenant model this replaced; read-only now.
141
- groupId: { type: String, default: null },
142
- resource: { type: String, required: true },
143
- grantedAt: { type: Date, default: Date.now },
144
- revokedAt: { type: Date },
145
- lastUsedAt: { type: Date }
146
- },
147
- { timestamps: true, collection: 'oauth_refresh_tokens' }
148
- );
149
-
150
- RefreshTokenSchema.index({ userId: 1, createdAt: -1 });
151
-
152
- /**
153
- * A grant appearing and being revoked is the shape of "who connected Claude,
154
- * and when did it stop" — the question the connections page answers for the
155
- * present and this answers for the past.
156
- *
157
- * `tokenHash` is redacted: it is the only thing standing between a copy of this
158
- * collection and a working refresh token.
159
- */
160
- RefreshTokenSchema.plugin(auditPlugin, { resource: 'connection', redact: ['tokenHash'] });
161
-
162
- export const RefreshToken = mongoose.models.RefreshToken
163
- ? (mongoose.models.RefreshToken as mongoose.Model<IRefreshToken>)
164
- : mongoose.model<IRefreshToken>('RefreshToken', RefreshTokenSchema);
@@ -1,432 +0,0 @@
1
- import { createHash } from 'crypto';
2
- import { PERSONAL_TENANT } from '../apiKeys/types';
3
-
4
- jest.mock('./models', () => ({
5
- OAuthClient: { create: jest.fn(), findOne: jest.fn(), find: jest.fn() },
6
- AuthorizationCode: { create: jest.fn(), findOneAndUpdate: jest.fn(), exists: jest.fn() },
7
- RefreshToken: { create: jest.fn(), findOne: jest.fn(), find: jest.fn(), updateMany: jest.fn() }
8
- }));
9
-
10
- import { AuthorizationCode, OAuthClient, RefreshToken } from './models';
11
- import {
12
- registerClient,
13
- isRegisteredRedirect,
14
- issueAuthorizationCode,
15
- redeemAuthorizationCode,
16
- issueRefreshToken,
17
- redeemRefreshToken,
18
- revokeRefreshTokensForUser
19
- } from './service';
20
-
21
- const mockedCodeUpdate = AuthorizationCode.findOneAndUpdate as unknown as jest.Mock;
22
- const mockedCodeExists = AuthorizationCode.exists as unknown as jest.Mock;
23
- const mockedCodeCreate = AuthorizationCode.create as unknown as jest.Mock;
24
- const mockedClientCreate = OAuthClient.create as unknown as jest.Mock;
25
- const mockedRefreshCreate = RefreshToken.create as unknown as jest.Mock;
26
- const mockedRefreshFindOne = RefreshToken.findOne as unknown as jest.Mock;
27
- const mockedRefreshUpdateMany = RefreshToken.updateMany as unknown as jest.Mock;
28
-
29
- const VERIFIER = 'a-code-verifier-long-enough-to-be-real';
30
- const CHALLENGE = createHash('sha256').update(VERIFIER).digest('base64url');
31
-
32
- const storedCode = (over: Record<string, unknown> = {}) => ({
33
- clientId: 'client-1',
34
- redirectUri: 'https://claude.ai/callback',
35
- codeChallenge: CHALLENGE,
36
- expiresAt: new Date(Date.now() + 30_000),
37
- ...over
38
- });
39
-
40
- beforeEach(() => {
41
- jest.clearAllMocks();
42
- mockedClientCreate.mockImplementation(async (doc) => doc);
43
- (OAuthClient.findOne as jest.Mock).mockResolvedValue(null);
44
- mockedCodeCreate.mockImplementation(async (doc) => doc);
45
- mockedRefreshCreate.mockImplementation(async (doc) => doc);
46
- });
47
-
48
- describe('registerClient', () => {
49
- it('returns the existing client when the same callback re-registers', async () => {
50
- // The Claude app re-registers on every connect attempt. Minting a fresh id
51
- // each time left three identical "Claude" rows in connected apps, only one
52
- // of which any given disconnect would cut off.
53
- (OAuthClient.findOne as jest.Mock).mockResolvedValue({
54
- clientId: 'tmb-client-existing',
55
- clientName: 'Claude',
56
- redirectUris: ['https://claude.ai/api/mcp/auth_callback']
57
- });
58
-
59
- const client = await registerClient({
60
- clientName: 'Claude',
61
- redirectUris: ['https://claude.ai/api/mcp/auth_callback']
62
- });
63
-
64
- expect(client.clientId).toBe('tmb-client-existing');
65
- expect(mockedClientCreate).not.toHaveBeenCalled();
66
- });
67
-
68
- it('matches on the exact set of callbacks, which is what identifies the app', async () => {
69
- (OAuthClient.findOne as jest.Mock).mockResolvedValue(null);
70
-
71
- await registerClient({ clientName: 'Claude', redirectUris: ['https://b/cb', 'https://a/cb'] });
72
-
73
- // Sorted and size-constrained: a client registering a superset is a
74
- // different client, and an authorization code is delivered to that URL.
75
- expect((OAuthClient.findOne as jest.Mock).mock.calls[0][0]).toEqual({
76
- redirectUris: { $size: 2, $all: ['https://a/cb', 'https://b/cb'] }
77
- });
78
- });
79
-
80
- it('issues a namespaced client id and keeps the redirect URIs', async () => {
81
- const client = await registerClient({
82
- clientName: 'Claude',
83
- redirectUris: ['https://claude.ai/callback']
84
- });
85
-
86
- expect(client.clientId).toMatch(/^tmb-client-[0-9a-f]{32}$/);
87
- expect(client.redirectUris).toEqual(['https://claude.ai/callback']);
88
- });
89
-
90
- it('never issues the same id twice', async () => {
91
- const a = await registerClient({ clientName: 'A', redirectUris: ['https://a/cb'] });
92
- const b = await registerClient({ clientName: 'B', redirectUris: ['https://b/cb'] });
93
- expect(a.clientId).not.toBe(b.clientId);
94
- });
95
- });
96
-
97
- describe('isRegisteredRedirect', () => {
98
- const client = { redirectUris: ['https://claude.ai/callback'] };
99
-
100
- it('accepts a registered URI', () => {
101
- expect(isRegisteredRedirect(client, 'https://claude.ai/callback')).toBe(true);
102
- });
103
-
104
- it.each([
105
- ['a different host', 'https://evil.example/callback'],
106
- ['a path suffix', 'https://claude.ai/callback/extra'],
107
- ['an added query', 'https://claude.ai/callback?next=https://evil.example'],
108
- ['a prefix', 'https://claude.ai/call']
109
- ])('rejects %s — matching is exact, never prefix', (_label, uri) => {
110
- // Loose redirect matching is the classic way an authorization code gets
111
- // delivered somewhere the client never controlled.
112
- expect(isRegisteredRedirect(client, uri)).toBe(false);
113
- });
114
- });
115
-
116
- describe('issueAuthorizationCode', () => {
117
- it('stores what was consented to, and expires within a minute', async () => {
118
- const before = Date.now();
119
- await issueAuthorizationCode({
120
- clientId: 'client-1',
121
- userId: 'u1',
122
- userEmail: 'u1@example.com',
123
- userName: 'Tester',
124
- redirectUri: 'https://claude.ai/callback',
125
- scopes: ['relationship:read'],
126
- tenants: ['g1'],
127
- defaultTenant: 'g1',
128
- tenantNames: { g1: 'Irja & Tom' },
129
- resource: 'https://mcp.tumbaland.eu',
130
- codeChallenge: CHALLENGE
131
- });
132
-
133
- const [doc] = mockedCodeCreate.mock.calls[0];
134
- expect(doc).toMatchObject({
135
- userId: 'u1',
136
- tenants: ['g1'],
137
- defaultTenant: 'g1',
138
- tenantNames: { g1: 'Irja & Tom' },
139
- scopes: ['relationship:read']
140
- });
141
- expect(doc.expiresAt.getTime() - before).toBeLessThanOrEqual(60_000);
142
- });
143
- });
144
-
145
- describe('redeemAuthorizationCode', () => {
146
- it('accepts a correct exchange', async () => {
147
- mockedCodeUpdate.mockResolvedValue(storedCode());
148
-
149
- const result = await redeemAuthorizationCode(
150
- 'code',
151
- 'client-1',
152
- 'https://claude.ai/callback',
153
- VERIFIER
154
- );
155
-
156
- expect(result.ok).toBe(true);
157
- });
158
-
159
- it('marks the code used in the same operation that reads it', async () => {
160
- mockedCodeUpdate.mockResolvedValue(storedCode());
161
-
162
- await redeemAuthorizationCode('code', 'client-1', 'https://claude.ai/callback', VERIFIER);
163
-
164
- // Atomic: two simultaneous exchanges cannot both win, which a
165
- // read-then-check would allow.
166
- expect(mockedCodeUpdate).toHaveBeenCalledWith(
167
- { code: 'code', usedAt: { $exists: false } },
168
- { $set: { usedAt: expect.any(Date) } },
169
- { new: true }
170
- );
171
- });
172
-
173
- it('refuses a code that has already been exchanged', async () => {
174
- mockedCodeUpdate.mockResolvedValue(null);
175
- mockedCodeExists.mockResolvedValue(true);
176
-
177
- await expect(
178
- redeemAuthorizationCode('code', 'client-1', 'https://claude.ai/callback', VERIFIER)
179
- ).resolves.toEqual({ ok: false, rejection: 'already-used' });
180
- });
181
-
182
- it('refuses a code that never existed', async () => {
183
- mockedCodeUpdate.mockResolvedValue(null);
184
- mockedCodeExists.mockResolvedValue(false);
185
-
186
- await expect(
187
- redeemAuthorizationCode('code', 'client-1', 'https://claude.ai/callback', VERIFIER)
188
- ).resolves.toEqual({ ok: false, rejection: 'unknown' });
189
- });
190
-
191
- it('refuses the wrong PKCE verifier', async () => {
192
- // Without this, an intercepted code could be exchanged by whoever stole it.
193
- mockedCodeUpdate.mockResolvedValue(storedCode());
194
-
195
- await expect(
196
- redeemAuthorizationCode('code', 'client-1', 'https://claude.ai/callback', 'wrong-verifier')
197
- ).resolves.toEqual({ ok: false, rejection: 'pkce-failed' });
198
- });
199
-
200
- it('refuses a different client redeeming the code', async () => {
201
- mockedCodeUpdate.mockResolvedValue(storedCode());
202
-
203
- await expect(
204
- redeemAuthorizationCode('code', 'other-client', 'https://claude.ai/callback', VERIFIER)
205
- ).resolves.toEqual({ ok: false, rejection: 'client-mismatch' });
206
- });
207
-
208
- it('refuses a redirect URI that differs from the one authorized', async () => {
209
- mockedCodeUpdate.mockResolvedValue(storedCode());
210
-
211
- await expect(
212
- redeemAuthorizationCode('code', 'client-1', 'https://evil.example/cb', VERIFIER)
213
- ).resolves.toEqual({ ok: false, rejection: 'redirect-mismatch' });
214
- });
215
-
216
- it('refuses an expired code', async () => {
217
- mockedCodeUpdate.mockResolvedValue(storedCode({ expiresAt: new Date(Date.now() - 1000) }));
218
-
219
- await expect(
220
- redeemAuthorizationCode('code', 'client-1', 'https://claude.ai/callback', VERIFIER)
221
- ).resolves.toEqual({ ok: false, rejection: 'expired' });
222
- });
223
- });
224
-
225
- describe('issueRefreshToken', () => {
226
- it('retires any grant the same client already held for this user', async () => {
227
- // Reconnecting is a re-authorization, not a second connection: two live
228
- // grants would leave stale scopes beside the new ones and a disconnect
229
- // that only cut one of them.
230
- mockedRefreshUpdateMany.mockResolvedValue({ modifiedCount: 1 });
231
-
232
- await issueRefreshToken({
233
- clientId: 'client-1',
234
- userId: 'u1',
235
- scopes: ['relationship:read'],
236
- tenants: [PERSONAL_TENANT],
237
- defaultTenant: PERSONAL_TENANT,
238
- tenantNames: {},
239
- resource: 'https://mcp.tumbaland.eu'
240
- });
241
-
242
- expect(mockedRefreshUpdateMany).toHaveBeenCalledWith(
243
- { userId: 'u1', clientId: 'client-1', revokedAt: { $exists: false } },
244
- { $set: { revokedAt: expect.any(Date) } }
245
- );
246
- expect(mockedRefreshCreate).toHaveBeenCalled();
247
- });
248
- });
249
-
250
- describe('refresh token rotation', () => {
251
- it('retires the presented token and issues a replacement', async () => {
252
- const record = {
253
- userId: 'u1',
254
- scopes: ['relationship:read'],
255
- groupId: null,
256
- resource: 'https://mcp.tumbaland.eu',
257
- grantedAt: new Date('2026-03-01T00:00:00.000Z'),
258
- createdAt: new Date('2026-09-01T00:00:00.000Z'),
259
- save: jest.fn()
260
- } as Record<string, unknown>;
261
- mockedRefreshFindOne.mockResolvedValue(record);
262
-
263
- const result = await redeemRefreshToken('old-token', 'client-1');
264
-
265
- // The old one is dead the moment it is used: a stolen copy stops working as
266
- // soon as the honest client refreshes.
267
- expect(record.revokedAt).toBeInstanceOf(Date);
268
- expect(result.rotatedToken).toBeTruthy();
269
- expect(result.rotatedToken).not.toBe('old-token');
270
- });
271
-
272
- it('carries the original grant date onto the replacement', async () => {
273
- const grantedAt = new Date('2026-03-01T00:00:00.000Z');
274
- mockedRefreshFindOne.mockResolvedValue({
275
- userId: 'u1',
276
- scopes: [],
277
- groupId: null,
278
- resource: 'r',
279
- grantedAt,
280
- save: jest.fn()
281
- });
282
-
283
- await redeemRefreshToken('old-token', 'client-1');
284
-
285
- // Otherwise the UI would say the user connected an hour ago when they
286
- // connected in March.
287
- expect(mockedRefreshCreate.mock.calls[0][0].grantedAt).toBe(grantedAt);
288
- });
289
-
290
- it('treats reuse of a retired token as a compromise and cuts the whole grant', async () => {
291
- // Honest client and thief now both hold tokens from one grant and there is
292
- // no way to tell which just called, so everything goes.
293
- mockedRefreshFindOne.mockResolvedValue({
294
- userId: 'u1',
295
- revokedAt: new Date(),
296
- scopes: [],
297
- groupId: null,
298
- resource: 'r',
299
- save: jest.fn()
300
- });
301
- mockedRefreshUpdateMany.mockResolvedValue({ modifiedCount: 1 });
302
-
303
- const result = await redeemRefreshToken('retired-token', 'client-1');
304
-
305
- expect(result).toMatchObject({ ok: false, reused: true });
306
- expect(mockedRefreshUpdateMany).toHaveBeenCalledWith(
307
- { userId: 'u1', clientId: 'client-1', revokedAt: { $exists: false } },
308
- { $set: { revokedAt: expect.any(Date) } }
309
- );
310
- });
311
-
312
- it('does not report an unknown token as reuse', async () => {
313
- mockedRefreshFindOne.mockResolvedValue(null);
314
- await expect(redeemRefreshToken('nope', 'client-1')).resolves.toEqual({ ok: false });
315
- expect(mockedRefreshUpdateMany).not.toHaveBeenCalled();
316
- });
317
- });
318
-
319
- describe('refresh tokens', () => {
320
- it('stores only a digest, never the token itself', async () => {
321
- const { token } = await issueRefreshToken({
322
- clientId: 'client-1',
323
- userId: 'u1',
324
- scopes: ['relationship:read'],
325
- tenants: [PERSONAL_TENANT],
326
- defaultTenant: PERSONAL_TENANT,
327
- tenantNames: {},
328
- resource: 'https://mcp.tumbaland.eu'
329
- });
330
-
331
- const [doc] = mockedRefreshCreate.mock.calls[0];
332
- expect(doc.tokenHash).toBe(createHash('sha256').update(token).digest('hex'));
333
- expect(JSON.stringify(doc)).not.toContain(token);
334
- });
335
-
336
- it('looks a token up by digest and returns what it may do', async () => {
337
- mockedRefreshFindOne.mockResolvedValue({
338
- userId: 'u1',
339
- scopes: ['relationship:read'],
340
- tenants: [PERSONAL_TENANT, 'g1'],
341
- defaultTenant: 'g1',
342
- tenantNames: { g1: 'Irja & Tom' },
343
- resource: 'https://mcp.tumbaland.eu',
344
- grantedAt: new Date(),
345
- save: jest.fn()
346
- });
347
-
348
- await expect(redeemRefreshToken('tok', 'client-1')).resolves.toMatchObject({
349
- ok: true,
350
- userId: 'u1',
351
- tenants: { allowed: [PERSONAL_TENANT, 'g1'], default: 'g1' }
352
- });
353
- });
354
-
355
- it('carries the tenants through rotation rather than re-deriving them', async () => {
356
- mockedRefreshFindOne.mockResolvedValue({
357
- userId: 'u1',
358
- scopes: ['relationship:read'],
359
- tenants: [PERSONAL_TENANT, 'g1'],
360
- defaultTenant: 'g1',
361
- tenantNames: { g1: 'Irja & Tom' },
362
- resource: 'https://mcp.tumbaland.eu',
363
- grantedAt: new Date(),
364
- save: jest.fn()
365
- });
366
-
367
- await redeemRefreshToken('tok', 'client-1');
368
-
369
- // Rotation reissues the same grant; re-deriving would quietly narrow a
370
- // connection every time it refreshed.
371
- const [doc] = mockedRefreshCreate.mock.calls.at(-1)!;
372
- expect(doc.tenants).toEqual([PERSONAL_TENANT, 'g1']);
373
- expect(doc.defaultTenant).toBe('g1');
374
- expect(doc.tenantNames).toEqual({ g1: 'Irja & Tom' });
375
- });
376
-
377
- it('reads a grant issued before tenants were a set', async () => {
378
- mockedRefreshFindOne.mockResolvedValue({
379
- userId: 'u1',
380
- scopes: ['relationship:read'],
381
- groupId: 'g1',
382
- resource: 'https://mcp.tumbaland.eu',
383
- grantedAt: new Date(),
384
- save: jest.fn()
385
- });
386
-
387
- await expect(redeemRefreshToken('tok', 'client-1')).resolves.toMatchObject({
388
- tenants: { allowed: ['g1'], default: 'g1' }
389
- });
390
- });
391
-
392
- it('refuses a revoked token, which is how an assistant is cut off', async () => {
393
- mockedRefreshFindOne.mockResolvedValue({
394
- userId: 'u1',
395
- revokedAt: new Date(),
396
- scopes: [],
397
- groupId: null,
398
- resource: 'r',
399
- save: jest.fn()
400
- });
401
- mockedRefreshUpdateMany.mockResolvedValue({ modifiedCount: 0 });
402
-
403
- await expect(redeemRefreshToken('tok', 'client-1')).resolves.toMatchObject({ ok: false });
404
- });
405
-
406
- it('refuses an unknown token', async () => {
407
- mockedRefreshFindOne.mockResolvedValue(null);
408
- await expect(redeemRefreshToken('tok', 'client-1')).resolves.toEqual({ ok: false });
409
- });
410
-
411
- it('revokes only tokens that are still live', async () => {
412
- mockedRefreshUpdateMany.mockResolvedValue({ modifiedCount: 2 });
413
-
414
- await revokeRefreshTokensForUser('u1');
415
-
416
- expect(mockedRefreshUpdateMany).toHaveBeenCalledWith(
417
- { userId: 'u1', revokedAt: { $exists: false } },
418
- { $set: { revokedAt: expect.any(Date) } }
419
- );
420
- });
421
-
422
- it('can cut off one assistant without touching the others', async () => {
423
- mockedRefreshUpdateMany.mockResolvedValue({ modifiedCount: 1 });
424
-
425
- await revokeRefreshTokensForUser('u1', 'client-1');
426
-
427
- expect(mockedRefreshUpdateMany).toHaveBeenCalledWith(
428
- expect.objectContaining({ clientId: 'client-1' }),
429
- expect.anything()
430
- );
431
- });
432
- });