@tumbaland/backend-core 1.32.0 → 1.34.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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/oauth/index.d.ts +7 -0
- package/dist/oauth/index.d.ts.map +1 -0
- package/dist/oauth/index.js +22 -0
- package/dist/oauth/index.js.map +1 -0
- package/dist/oauth/models.d.ts +93 -0
- package/dist/oauth/models.d.ts.map +1 -0
- package/dist/oauth/models.js +81 -0
- package/dist/oauth/models.js.map +1 -0
- package/dist/oauth/service.d.ts +111 -0
- package/dist/oauth/service.d.ts.map +1 -0
- package/dist/oauth/service.js +159 -0
- package/dist/oauth/service.js.map +1 -0
- package/dist/oauth/tokens.d.ts +69 -0
- package/dist/oauth/tokens.d.ts.map +1 -0
- package/dist/oauth/tokens.js +79 -0
- package/dist/oauth/tokens.js.map +1 -0
- package/jest.config.js +11 -1
- package/package.json +1 -1
- package/src/apiKeys/ApiKey.test.ts +118 -0
- package/src/apiKeys/index.test.ts +72 -0
- package/src/apiKeys/middleware.test.ts +100 -1
- package/src/apiKeys/types.test.ts +41 -0
- package/src/index.ts +3 -0
- package/src/oauth/index.ts +23 -0
- package/src/oauth/models.ts +134 -0
- package/src/oauth/service.test.ts +325 -0
- package/src/oauth/service.ts +245 -0
- package/src/oauth/tokens.test.ts +129 -0
- package/src/oauth/tokens.ts +127 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
|
|
3
|
+
jest.mock('./models', () => ({
|
|
4
|
+
OAuthClient: { create: jest.fn(), findOne: jest.fn(), find: jest.fn() },
|
|
5
|
+
AuthorizationCode: { create: jest.fn(), findOneAndUpdate: jest.fn(), exists: jest.fn() },
|
|
6
|
+
RefreshToken: { create: jest.fn(), findOne: jest.fn(), find: jest.fn(), updateMany: jest.fn() }
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import { AuthorizationCode, OAuthClient, RefreshToken } from './models';
|
|
10
|
+
import {
|
|
11
|
+
registerClient,
|
|
12
|
+
isRegisteredRedirect,
|
|
13
|
+
issueAuthorizationCode,
|
|
14
|
+
redeemAuthorizationCode,
|
|
15
|
+
issueRefreshToken,
|
|
16
|
+
redeemRefreshToken,
|
|
17
|
+
revokeRefreshTokensForUser
|
|
18
|
+
} from './service';
|
|
19
|
+
|
|
20
|
+
const mockedCodeUpdate = AuthorizationCode.findOneAndUpdate as unknown as jest.Mock;
|
|
21
|
+
const mockedCodeExists = AuthorizationCode.exists as unknown as jest.Mock;
|
|
22
|
+
const mockedCodeCreate = AuthorizationCode.create as unknown as jest.Mock;
|
|
23
|
+
const mockedClientCreate = OAuthClient.create as unknown as jest.Mock;
|
|
24
|
+
const mockedRefreshCreate = RefreshToken.create as unknown as jest.Mock;
|
|
25
|
+
const mockedRefreshFindOne = RefreshToken.findOne as unknown as jest.Mock;
|
|
26
|
+
const mockedRefreshUpdateMany = RefreshToken.updateMany as unknown as jest.Mock;
|
|
27
|
+
|
|
28
|
+
const VERIFIER = 'a-code-verifier-long-enough-to-be-real';
|
|
29
|
+
const CHALLENGE = createHash('sha256').update(VERIFIER).digest('base64url');
|
|
30
|
+
|
|
31
|
+
const storedCode = (over: Record<string, unknown> = {}) => ({
|
|
32
|
+
clientId: 'client-1',
|
|
33
|
+
redirectUri: 'https://claude.ai/callback',
|
|
34
|
+
codeChallenge: CHALLENGE,
|
|
35
|
+
expiresAt: new Date(Date.now() + 30_000),
|
|
36
|
+
...over
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
jest.clearAllMocks();
|
|
41
|
+
mockedClientCreate.mockImplementation(async (doc) => doc);
|
|
42
|
+
mockedCodeCreate.mockImplementation(async (doc) => doc);
|
|
43
|
+
mockedRefreshCreate.mockImplementation(async (doc) => doc);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('registerClient', () => {
|
|
47
|
+
it('issues a namespaced client id and keeps the redirect URIs', async () => {
|
|
48
|
+
const client = await registerClient({
|
|
49
|
+
clientName: 'Claude',
|
|
50
|
+
redirectUris: ['https://claude.ai/callback']
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(client.clientId).toMatch(/^tmb-client-[0-9a-f]{32}$/);
|
|
54
|
+
expect(client.redirectUris).toEqual(['https://claude.ai/callback']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('never issues the same id twice', async () => {
|
|
58
|
+
const a = await registerClient({ clientName: 'A', redirectUris: ['https://a/cb'] });
|
|
59
|
+
const b = await registerClient({ clientName: 'B', redirectUris: ['https://b/cb'] });
|
|
60
|
+
expect(a.clientId).not.toBe(b.clientId);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('isRegisteredRedirect', () => {
|
|
65
|
+
const client = { redirectUris: ['https://claude.ai/callback'] };
|
|
66
|
+
|
|
67
|
+
it('accepts a registered URI', () => {
|
|
68
|
+
expect(isRegisteredRedirect(client, 'https://claude.ai/callback')).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it.each([
|
|
72
|
+
['a different host', 'https://evil.example/callback'],
|
|
73
|
+
['a path suffix', 'https://claude.ai/callback/extra'],
|
|
74
|
+
['an added query', 'https://claude.ai/callback?next=https://evil.example'],
|
|
75
|
+
['a prefix', 'https://claude.ai/call']
|
|
76
|
+
])('rejects %s — matching is exact, never prefix', (_label, uri) => {
|
|
77
|
+
// Loose redirect matching is the classic way an authorization code gets
|
|
78
|
+
// delivered somewhere the client never controlled.
|
|
79
|
+
expect(isRegisteredRedirect(client, uri)).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('issueAuthorizationCode', () => {
|
|
84
|
+
it('stores what was consented to, and expires within a minute', async () => {
|
|
85
|
+
const before = Date.now();
|
|
86
|
+
await issueAuthorizationCode({
|
|
87
|
+
clientId: 'client-1',
|
|
88
|
+
userId: 'u1',
|
|
89
|
+
userEmail: 'u1@example.com',
|
|
90
|
+
userName: 'Tester',
|
|
91
|
+
redirectUri: 'https://claude.ai/callback',
|
|
92
|
+
scopes: ['relationship:read'],
|
|
93
|
+
groupId: 'g1',
|
|
94
|
+
resource: 'https://mcp.tumbaland.eu',
|
|
95
|
+
codeChallenge: CHALLENGE
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const [doc] = mockedCodeCreate.mock.calls[0];
|
|
99
|
+
expect(doc).toMatchObject({ userId: 'u1', groupId: 'g1', scopes: ['relationship:read'] });
|
|
100
|
+
expect(doc.expiresAt.getTime() - before).toBeLessThanOrEqual(60_000);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('redeemAuthorizationCode', () => {
|
|
105
|
+
it('accepts a correct exchange', async () => {
|
|
106
|
+
mockedCodeUpdate.mockResolvedValue(storedCode());
|
|
107
|
+
|
|
108
|
+
const result = await redeemAuthorizationCode(
|
|
109
|
+
'code',
|
|
110
|
+
'client-1',
|
|
111
|
+
'https://claude.ai/callback',
|
|
112
|
+
VERIFIER
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
expect(result.ok).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('marks the code used in the same operation that reads it', async () => {
|
|
119
|
+
mockedCodeUpdate.mockResolvedValue(storedCode());
|
|
120
|
+
|
|
121
|
+
await redeemAuthorizationCode('code', 'client-1', 'https://claude.ai/callback', VERIFIER);
|
|
122
|
+
|
|
123
|
+
// Atomic: two simultaneous exchanges cannot both win, which a
|
|
124
|
+
// read-then-check would allow.
|
|
125
|
+
expect(mockedCodeUpdate).toHaveBeenCalledWith(
|
|
126
|
+
{ code: 'code', usedAt: { $exists: false } },
|
|
127
|
+
{ $set: { usedAt: expect.any(Date) } },
|
|
128
|
+
{ new: true }
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('refuses a code that has already been exchanged', async () => {
|
|
133
|
+
mockedCodeUpdate.mockResolvedValue(null);
|
|
134
|
+
mockedCodeExists.mockResolvedValue(true);
|
|
135
|
+
|
|
136
|
+
await expect(
|
|
137
|
+
redeemAuthorizationCode('code', 'client-1', 'https://claude.ai/callback', VERIFIER)
|
|
138
|
+
).resolves.toEqual({ ok: false, rejection: 'already-used' });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('refuses a code that never existed', async () => {
|
|
142
|
+
mockedCodeUpdate.mockResolvedValue(null);
|
|
143
|
+
mockedCodeExists.mockResolvedValue(false);
|
|
144
|
+
|
|
145
|
+
await expect(
|
|
146
|
+
redeemAuthorizationCode('code', 'client-1', 'https://claude.ai/callback', VERIFIER)
|
|
147
|
+
).resolves.toEqual({ ok: false, rejection: 'unknown' });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('refuses the wrong PKCE verifier', async () => {
|
|
151
|
+
// Without this, an intercepted code could be exchanged by whoever stole it.
|
|
152
|
+
mockedCodeUpdate.mockResolvedValue(storedCode());
|
|
153
|
+
|
|
154
|
+
await expect(
|
|
155
|
+
redeemAuthorizationCode('code', 'client-1', 'https://claude.ai/callback', 'wrong-verifier')
|
|
156
|
+
).resolves.toEqual({ ok: false, rejection: 'pkce-failed' });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('refuses a different client redeeming the code', async () => {
|
|
160
|
+
mockedCodeUpdate.mockResolvedValue(storedCode());
|
|
161
|
+
|
|
162
|
+
await expect(
|
|
163
|
+
redeemAuthorizationCode('code', 'other-client', 'https://claude.ai/callback', VERIFIER)
|
|
164
|
+
).resolves.toEqual({ ok: false, rejection: 'client-mismatch' });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('refuses a redirect URI that differs from the one authorized', async () => {
|
|
168
|
+
mockedCodeUpdate.mockResolvedValue(storedCode());
|
|
169
|
+
|
|
170
|
+
await expect(
|
|
171
|
+
redeemAuthorizationCode('code', 'client-1', 'https://evil.example/cb', VERIFIER)
|
|
172
|
+
).resolves.toEqual({ ok: false, rejection: 'redirect-mismatch' });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('refuses an expired code', async () => {
|
|
176
|
+
mockedCodeUpdate.mockResolvedValue(storedCode({ expiresAt: new Date(Date.now() - 1000) }));
|
|
177
|
+
|
|
178
|
+
await expect(
|
|
179
|
+
redeemAuthorizationCode('code', 'client-1', 'https://claude.ai/callback', VERIFIER)
|
|
180
|
+
).resolves.toEqual({ ok: false, rejection: 'expired' });
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('refresh token rotation', () => {
|
|
185
|
+
it('retires the presented token and issues a replacement', async () => {
|
|
186
|
+
const record = {
|
|
187
|
+
userId: 'u1',
|
|
188
|
+
scopes: ['relationship:read'],
|
|
189
|
+
groupId: null,
|
|
190
|
+
resource: 'https://mcp.tumbaland.eu',
|
|
191
|
+
grantedAt: new Date('2026-03-01T00:00:00.000Z'),
|
|
192
|
+
createdAt: new Date('2026-09-01T00:00:00.000Z'),
|
|
193
|
+
save: jest.fn()
|
|
194
|
+
} as Record<string, unknown>;
|
|
195
|
+
mockedRefreshFindOne.mockResolvedValue(record);
|
|
196
|
+
|
|
197
|
+
const result = await redeemRefreshToken('old-token', 'client-1');
|
|
198
|
+
|
|
199
|
+
// The old one is dead the moment it is used: a stolen copy stops working as
|
|
200
|
+
// soon as the honest client refreshes.
|
|
201
|
+
expect(record.revokedAt).toBeInstanceOf(Date);
|
|
202
|
+
expect(result.rotatedToken).toBeTruthy();
|
|
203
|
+
expect(result.rotatedToken).not.toBe('old-token');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('carries the original grant date onto the replacement', async () => {
|
|
207
|
+
const grantedAt = new Date('2026-03-01T00:00:00.000Z');
|
|
208
|
+
mockedRefreshFindOne.mockResolvedValue({
|
|
209
|
+
userId: 'u1',
|
|
210
|
+
scopes: [],
|
|
211
|
+
groupId: null,
|
|
212
|
+
resource: 'r',
|
|
213
|
+
grantedAt,
|
|
214
|
+
save: jest.fn()
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await redeemRefreshToken('old-token', 'client-1');
|
|
218
|
+
|
|
219
|
+
// Otherwise the UI would say the user connected an hour ago when they
|
|
220
|
+
// connected in March.
|
|
221
|
+
expect(mockedRefreshCreate.mock.calls[0][0].grantedAt).toBe(grantedAt);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('treats reuse of a retired token as a compromise and cuts the whole grant', async () => {
|
|
225
|
+
// Honest client and thief now both hold tokens from one grant and there is
|
|
226
|
+
// no way to tell which just called, so everything goes.
|
|
227
|
+
mockedRefreshFindOne.mockResolvedValue({
|
|
228
|
+
userId: 'u1',
|
|
229
|
+
revokedAt: new Date(),
|
|
230
|
+
scopes: [],
|
|
231
|
+
groupId: null,
|
|
232
|
+
resource: 'r',
|
|
233
|
+
save: jest.fn()
|
|
234
|
+
});
|
|
235
|
+
mockedRefreshUpdateMany.mockResolvedValue({ modifiedCount: 1 });
|
|
236
|
+
|
|
237
|
+
const result = await redeemRefreshToken('retired-token', 'client-1');
|
|
238
|
+
|
|
239
|
+
expect(result).toMatchObject({ ok: false, reused: true });
|
|
240
|
+
expect(mockedRefreshUpdateMany).toHaveBeenCalledWith(
|
|
241
|
+
{ userId: 'u1', clientId: 'client-1', revokedAt: { $exists: false } },
|
|
242
|
+
{ $set: { revokedAt: expect.any(Date) } }
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('does not report an unknown token as reuse', async () => {
|
|
247
|
+
mockedRefreshFindOne.mockResolvedValue(null);
|
|
248
|
+
await expect(redeemRefreshToken('nope', 'client-1')).resolves.toEqual({ ok: false });
|
|
249
|
+
expect(mockedRefreshUpdateMany).not.toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe('refresh tokens', () => {
|
|
254
|
+
it('stores only a digest, never the token itself', async () => {
|
|
255
|
+
const { token } = await issueRefreshToken({
|
|
256
|
+
clientId: 'client-1',
|
|
257
|
+
userId: 'u1',
|
|
258
|
+
scopes: ['relationship:read'],
|
|
259
|
+
groupId: null,
|
|
260
|
+
resource: 'https://mcp.tumbaland.eu'
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const [doc] = mockedRefreshCreate.mock.calls[0];
|
|
264
|
+
expect(doc.tokenHash).toBe(createHash('sha256').update(token).digest('hex'));
|
|
265
|
+
expect(JSON.stringify(doc)).not.toContain(token);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('looks a token up by digest and returns what it may do', async () => {
|
|
269
|
+
mockedRefreshFindOne.mockResolvedValue({
|
|
270
|
+
userId: 'u1',
|
|
271
|
+
scopes: ['relationship:read'],
|
|
272
|
+
groupId: 'g1',
|
|
273
|
+
resource: 'https://mcp.tumbaland.eu',
|
|
274
|
+
grantedAt: new Date(),
|
|
275
|
+
save: jest.fn()
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
await expect(redeemRefreshToken('tok', 'client-1')).resolves.toMatchObject({
|
|
279
|
+
ok: true,
|
|
280
|
+
userId: 'u1',
|
|
281
|
+
groupId: 'g1'
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('refuses a revoked token, which is how an assistant is cut off', async () => {
|
|
286
|
+
mockedRefreshFindOne.mockResolvedValue({
|
|
287
|
+
userId: 'u1',
|
|
288
|
+
revokedAt: new Date(),
|
|
289
|
+
scopes: [],
|
|
290
|
+
groupId: null,
|
|
291
|
+
resource: 'r',
|
|
292
|
+
save: jest.fn()
|
|
293
|
+
});
|
|
294
|
+
mockedRefreshUpdateMany.mockResolvedValue({ modifiedCount: 0 });
|
|
295
|
+
|
|
296
|
+
await expect(redeemRefreshToken('tok', 'client-1')).resolves.toMatchObject({ ok: false });
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('refuses an unknown token', async () => {
|
|
300
|
+
mockedRefreshFindOne.mockResolvedValue(null);
|
|
301
|
+
await expect(redeemRefreshToken('tok', 'client-1')).resolves.toEqual({ ok: false });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('revokes only tokens that are still live', async () => {
|
|
305
|
+
mockedRefreshUpdateMany.mockResolvedValue({ modifiedCount: 2 });
|
|
306
|
+
|
|
307
|
+
await revokeRefreshTokensForUser('u1');
|
|
308
|
+
|
|
309
|
+
expect(mockedRefreshUpdateMany).toHaveBeenCalledWith(
|
|
310
|
+
{ userId: 'u1', revokedAt: { $exists: false } },
|
|
311
|
+
{ $set: { revokedAt: expect.any(Date) } }
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('can cut off one assistant without touching the others', async () => {
|
|
316
|
+
mockedRefreshUpdateMany.mockResolvedValue({ modifiedCount: 1 });
|
|
317
|
+
|
|
318
|
+
await revokeRefreshTokensForUser('u1', 'client-1');
|
|
319
|
+
|
|
320
|
+
expect(mockedRefreshUpdateMany).toHaveBeenCalledWith(
|
|
321
|
+
expect.objectContaining({ clientId: 'client-1' }),
|
|
322
|
+
expect.anything()
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
|
|
2
|
+
import { AuthorizationCode, IAuthorizationCode, OAuthClient, RefreshToken } from './models';
|
|
3
|
+
import type { ApiKeyScope } from '../apiKeys/types';
|
|
4
|
+
|
|
5
|
+
/** How long a user has between approving and the client exchanging the code. */
|
|
6
|
+
const CODE_TTL_MS = 60 * 1000;
|
|
7
|
+
|
|
8
|
+
const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex');
|
|
9
|
+
|
|
10
|
+
/** base64url of the SHA-256 digest, which is what PKCE `S256` specifies. */
|
|
11
|
+
const s256 = (verifier: string): string =>
|
|
12
|
+
createHash('sha256').update(verifier).digest('base64url');
|
|
13
|
+
|
|
14
|
+
const constantTimeEquals = (a: string, b: string): boolean => {
|
|
15
|
+
const left = Buffer.from(a);
|
|
16
|
+
const right = Buffer.from(b);
|
|
17
|
+
if (left.length !== right.length) return false;
|
|
18
|
+
return timingSafeEqual(left, right);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export interface RegisterClientInput {
|
|
22
|
+
clientName: string;
|
|
23
|
+
redirectUris: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RegisteredClient {
|
|
27
|
+
clientId: string;
|
|
28
|
+
clientName: string;
|
|
29
|
+
redirectUris: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const registerClient = async (input: RegisterClientInput): Promise<RegisteredClient> => {
|
|
33
|
+
const client = await OAuthClient.create({
|
|
34
|
+
clientId: `tmb-client-${randomBytes(16).toString('hex')}`,
|
|
35
|
+
clientName: input.clientName.slice(0, 200),
|
|
36
|
+
redirectUris: input.redirectUris
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
clientId: client.clientId,
|
|
41
|
+
clientName: client.clientName,
|
|
42
|
+
redirectUris: client.redirectUris
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const findClient = (clientId: string) => OAuthClient.findOne({ clientId });
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a client may be sent back to this URI.
|
|
50
|
+
*
|
|
51
|
+
* Exact string match, deliberately. Prefix or wildcard matching on redirect URIs
|
|
52
|
+
* is the classic way an authorization code ends up delivered to somewhere the
|
|
53
|
+
* client never controlled.
|
|
54
|
+
*/
|
|
55
|
+
export const isRegisteredRedirect = (client: { redirectUris: string[] }, uri: string): boolean =>
|
|
56
|
+
client.redirectUris.includes(uri);
|
|
57
|
+
|
|
58
|
+
export interface IssueCodeInput {
|
|
59
|
+
clientId: string;
|
|
60
|
+
userId: string;
|
|
61
|
+
userEmail: string;
|
|
62
|
+
userName: string;
|
|
63
|
+
redirectUri: string;
|
|
64
|
+
scopes: ApiKeyScope[];
|
|
65
|
+
groupId: string | null;
|
|
66
|
+
resource: string;
|
|
67
|
+
codeChallenge: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const issueAuthorizationCode = async (input: IssueCodeInput): Promise<string> => {
|
|
71
|
+
const code = randomBytes(32).toString('base64url');
|
|
72
|
+
|
|
73
|
+
await AuthorizationCode.create({
|
|
74
|
+
...input,
|
|
75
|
+
code,
|
|
76
|
+
expiresAt: new Date(Date.now() + CODE_TTL_MS)
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return code;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type CodeRejection =
|
|
83
|
+
| 'unknown'
|
|
84
|
+
| 'expired'
|
|
85
|
+
| 'already-used'
|
|
86
|
+
| 'client-mismatch'
|
|
87
|
+
| 'redirect-mismatch'
|
|
88
|
+
| 'pkce-failed';
|
|
89
|
+
|
|
90
|
+
export interface CodeRedemption {
|
|
91
|
+
ok: boolean;
|
|
92
|
+
rejection?: CodeRejection;
|
|
93
|
+
code?: IAuthorizationCode;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Exchange a code, once.
|
|
98
|
+
*
|
|
99
|
+
* The `usedAt` stamp is set by the same atomic update that fetches the code, so
|
|
100
|
+
* two simultaneous exchanges cannot both succeed — a replayed code loses the
|
|
101
|
+
* race rather than being caught by a check that ran a moment earlier.
|
|
102
|
+
*/
|
|
103
|
+
export const redeemAuthorizationCode = async (
|
|
104
|
+
code: string,
|
|
105
|
+
clientId: string,
|
|
106
|
+
redirectUri: string,
|
|
107
|
+
codeVerifier: string
|
|
108
|
+
): Promise<CodeRedemption> => {
|
|
109
|
+
const record = await AuthorizationCode.findOneAndUpdate(
|
|
110
|
+
{ code, usedAt: { $exists: false } },
|
|
111
|
+
{ $set: { usedAt: new Date() } },
|
|
112
|
+
{ new: true }
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (!record) {
|
|
116
|
+
// Either it never existed or it has already been redeemed; the client is
|
|
117
|
+
// told the same thing for both.
|
|
118
|
+
const existed = await AuthorizationCode.exists({ code });
|
|
119
|
+
return { ok: false, rejection: existed ? 'already-used' : 'unknown' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (record.expiresAt.getTime() <= Date.now()) return { ok: false, rejection: 'expired' };
|
|
123
|
+
if (record.clientId !== clientId) return { ok: false, rejection: 'client-mismatch' };
|
|
124
|
+
if (record.redirectUri !== redirectUri) return { ok: false, rejection: 'redirect-mismatch' };
|
|
125
|
+
if (!constantTimeEquals(s256(codeVerifier), record.codeChallenge)) {
|
|
126
|
+
return { ok: false, rejection: 'pkce-failed' };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { ok: true, code: record };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export interface IssuedRefreshToken {
|
|
133
|
+
token: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const issueRefreshToken = async (input: {
|
|
137
|
+
clientId: string;
|
|
138
|
+
userId: string;
|
|
139
|
+
scopes: ApiKeyScope[];
|
|
140
|
+
groupId: string | null;
|
|
141
|
+
resource: string;
|
|
142
|
+
}): Promise<IssuedRefreshToken> => {
|
|
143
|
+
const token = randomBytes(32).toString('base64url');
|
|
144
|
+
await RefreshToken.create({ ...input, tokenHash: sha256(token) });
|
|
145
|
+
return { token };
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export interface RefreshRedemption {
|
|
149
|
+
ok: boolean;
|
|
150
|
+
/** true when a retired token was presented again — treated as a compromise */
|
|
151
|
+
reused?: boolean;
|
|
152
|
+
userId?: string;
|
|
153
|
+
scopes?: ApiKeyScope[];
|
|
154
|
+
groupId?: string | null;
|
|
155
|
+
resource?: string;
|
|
156
|
+
/** the replacement the client must store; the presented one is now dead */
|
|
157
|
+
rotatedToken?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Exchange a refresh token for a new one, rotating as we go.
|
|
162
|
+
*
|
|
163
|
+
* Rotation matters because these are the long-lived half: an access token is
|
|
164
|
+
* gone within the hour, but a refresh token that never changes is a permanent
|
|
165
|
+
* credential for whoever obtains a copy. Each use retires the old token and
|
|
166
|
+
* issues a fresh one, so a stolen copy stops working as soon as the legitimate
|
|
167
|
+
* client refreshes.
|
|
168
|
+
*
|
|
169
|
+
* Reuse of an already-rotated token is treated as theft rather than as an
|
|
170
|
+
* ordinary failure: the honest client and the thief now both hold tokens
|
|
171
|
+
* descended from the same grant, and there is no way to tell which just called.
|
|
172
|
+
* So the whole chain is revoked and the user reconnects — noisy, but the
|
|
173
|
+
* alternative is leaving an attacker with working access.
|
|
174
|
+
*/
|
|
175
|
+
export const redeemRefreshToken = async (
|
|
176
|
+
token: string,
|
|
177
|
+
clientId: string
|
|
178
|
+
): Promise<RefreshRedemption> => {
|
|
179
|
+
const record = await RefreshToken.findOne({ tokenHash: sha256(token), clientId });
|
|
180
|
+
if (!record) return { ok: false };
|
|
181
|
+
|
|
182
|
+
if (record.revokedAt) {
|
|
183
|
+
// Already rotated or explicitly revoked. If something is still presenting
|
|
184
|
+
// it, a copy is loose — cut every token in this grant.
|
|
185
|
+
await RefreshToken.updateMany(
|
|
186
|
+
{ userId: record.userId, clientId, revokedAt: { $exists: false } },
|
|
187
|
+
{ $set: { revokedAt: new Date() } }
|
|
188
|
+
);
|
|
189
|
+
return { ok: false, reused: true };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
record.revokedAt = new Date();
|
|
193
|
+
record.lastUsedAt = new Date();
|
|
194
|
+
await record.save();
|
|
195
|
+
|
|
196
|
+
const rotated = randomBytes(32).toString('base64url');
|
|
197
|
+
await RefreshToken.create({
|
|
198
|
+
tokenHash: sha256(rotated),
|
|
199
|
+
clientId,
|
|
200
|
+
userId: record.userId,
|
|
201
|
+
scopes: record.scopes,
|
|
202
|
+
groupId: record.groupId ?? null,
|
|
203
|
+
resource: record.resource,
|
|
204
|
+
// Carried, not reset: this is still the grant the user approved.
|
|
205
|
+
grantedAt: record.grantedAt ?? record.createdAt
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
ok: true,
|
|
210
|
+
userId: record.userId,
|
|
211
|
+
scopes: record.scopes as ApiKeyScope[],
|
|
212
|
+
groupId: record.groupId ?? null,
|
|
213
|
+
resource: record.resource,
|
|
214
|
+
rotatedToken: rotated
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/** Cut off an assistant: without a refresh token it can obtain nothing new. */
|
|
219
|
+
export const revokeRefreshTokensForUser = async (
|
|
220
|
+
userId: string,
|
|
221
|
+
clientId?: string
|
|
222
|
+
): Promise<number> => {
|
|
223
|
+
const filter: Record<string, unknown> = { userId, revokedAt: { $exists: false } };
|
|
224
|
+
if (clientId) filter.clientId = clientId;
|
|
225
|
+
|
|
226
|
+
const result = await RefreshToken.updateMany(filter, { $set: { revokedAt: new Date() } });
|
|
227
|
+
return result.modifiedCount;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export const listConnections = async (userId: string) => {
|
|
231
|
+
const tokens = await RefreshToken.find({ userId }).sort({ createdAt: -1 });
|
|
232
|
+
const clients = await OAuthClient.find({ clientId: { $in: tokens.map((t) => t.clientId) } });
|
|
233
|
+
const nameById = new Map(clients.map((client) => [client.clientId, client.clientName]));
|
|
234
|
+
|
|
235
|
+
return tokens.map((token) => ({
|
|
236
|
+
id: String(token._id),
|
|
237
|
+
clientId: token.clientId,
|
|
238
|
+
clientName: nameById.get(token.clientId) ?? 'Unknown app',
|
|
239
|
+
scopes: token.scopes,
|
|
240
|
+
groupId: token.groupId ?? null,
|
|
241
|
+
createdAt: (token.grantedAt ?? token.createdAt).toISOString(),
|
|
242
|
+
lastUsedAt: token.lastUsedAt?.toISOString() ?? null,
|
|
243
|
+
revokedAt: token.revokedAt?.toISOString() ?? null
|
|
244
|
+
}));
|
|
245
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const ORIGINAL_ENV = process.env;
|
|
2
|
+
|
|
3
|
+
import jwt from 'jsonwebtoken';
|
|
4
|
+
import { mintAccessToken, verifyAccessToken, parseScopes } from './tokens';
|
|
5
|
+
|
|
6
|
+
const RESOURCE = 'https://mcp.tumbaland.eu';
|
|
7
|
+
const ISSUER = 'https://auth-api.tumbaland.eu';
|
|
8
|
+
|
|
9
|
+
const mint = (over: Partial<Parameters<typeof mintAccessToken>[0]> = {}) =>
|
|
10
|
+
mintAccessToken({
|
|
11
|
+
userId: 'u1',
|
|
12
|
+
email: 'u1@example.com',
|
|
13
|
+
name: 'Tester',
|
|
14
|
+
resource: RESOURCE,
|
|
15
|
+
issuer: ISSUER,
|
|
16
|
+
scopes: ['relationship:read', 'relationship:write'],
|
|
17
|
+
groupId: null,
|
|
18
|
+
...over
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-jwt-secret' };
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
process.env = ORIGINAL_ENV;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('mintAccessToken', () => {
|
|
30
|
+
it('binds the token to one resource and carries the granted scopes', () => {
|
|
31
|
+
const { accessToken, scope, expiresIn } = mint();
|
|
32
|
+
const claims = jwt.decode(accessToken) as Record<string, unknown>;
|
|
33
|
+
|
|
34
|
+
expect(claims.aud).toBe(RESOURCE);
|
|
35
|
+
expect(claims.iss).toBe(ISSUER);
|
|
36
|
+
expect(claims.sub).toBe('u1');
|
|
37
|
+
expect(scope).toBe('relationship:read relationship:write');
|
|
38
|
+
expect(expiresIn).toBe(3600);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('carries the tenant pin, so a token cannot wander between groups', () => {
|
|
42
|
+
const claims = jwt.decode(mint({ groupId: 'g1' }).accessToken) as Record<string, unknown>;
|
|
43
|
+
expect(claims.groupId).toBe('g1');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('gives every token a distinct id', () => {
|
|
47
|
+
const a = jwt.decode(mint().accessToken) as Record<string, unknown>;
|
|
48
|
+
const b = jwt.decode(mint().accessToken) as Record<string, unknown>;
|
|
49
|
+
expect(a.jti).not.toBe(b.jti);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('verifyAccessToken', () => {
|
|
54
|
+
it('accepts a token minted for this resource', () => {
|
|
55
|
+
const { accessToken } = mint();
|
|
56
|
+
|
|
57
|
+
expect(verifyAccessToken(accessToken, RESOURCE)).toMatchObject({
|
|
58
|
+
ok: true,
|
|
59
|
+
userId: 'u1',
|
|
60
|
+
email: 'u1@example.com',
|
|
61
|
+
scopes: ['relationship:read', 'relationship:write'],
|
|
62
|
+
groupId: null
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('refuses a token minted for a different resource', () => {
|
|
67
|
+
// RFC 8707 audience binding: a token obtained for somewhere else must not
|
|
68
|
+
// be replayable here, which is the whole point of the resource parameter.
|
|
69
|
+
const { accessToken } = mint({ resource: 'https://mcp.someone-else.example' });
|
|
70
|
+
|
|
71
|
+
expect(verifyAccessToken(accessToken, RESOURCE)).toEqual({
|
|
72
|
+
ok: false,
|
|
73
|
+
rejection: 'wrong-audience'
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('refuses an ordinary session JWT presented as an access token', () => {
|
|
78
|
+
// Same secret, same issuer — but no scopes and no tenant pin. Without the
|
|
79
|
+
// type check a user's session cookie would authenticate as an assistant
|
|
80
|
+
// holding every permission.
|
|
81
|
+
const session = jwt.sign(
|
|
82
|
+
{ id: 'u1', email: 'u1@example.com', name: 'Tester', groups: ['g1'] },
|
|
83
|
+
'test-jwt-secret'
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(verifyAccessToken(session, RESOURCE)).toEqual({ ok: false, rejection: 'wrong-type' });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('refuses a token signed with a different secret', () => {
|
|
90
|
+
const forged = jwt.sign({ sub: 'u1', aud: RESOURCE, typ: 'mcp_access' }, 'not-the-secret');
|
|
91
|
+
expect(verifyAccessToken(forged, RESOURCE)).toEqual({ ok: false, rejection: 'bad-signature' });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('reports an expired token distinctly, so the client knows to refresh', () => {
|
|
95
|
+
const expired = jwt.sign(
|
|
96
|
+
{ sub: 'u1', aud: RESOURCE, typ: 'mcp_access', scope: '' },
|
|
97
|
+
'test-jwt-secret',
|
|
98
|
+
{ expiresIn: -10 }
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(verifyAccessToken(expired, RESOURCE)).toEqual({ ok: false, rejection: 'expired' });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('refuses gibberish rather than throwing', () => {
|
|
105
|
+
expect(verifyAccessToken('not-a-token', RESOURCE).ok).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('drops scopes it does not define, so a forged claim grants nothing', () => {
|
|
109
|
+
const token = jwt.sign(
|
|
110
|
+
{ sub: 'u1', aud: RESOURCE, typ: 'mcp_access', scope: 'relationship:read admin:everything' },
|
|
111
|
+
'test-jwt-secret'
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(verifyAccessToken(token, RESOURCE).scopes).toEqual(['relationship:read']);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('parseScopes', () => {
|
|
119
|
+
it('keeps only scopes the system defines', () => {
|
|
120
|
+
expect(parseScopes('relationship:read admin:all finance:write')).toEqual([
|
|
121
|
+
'relationship:read',
|
|
122
|
+
'finance:write'
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it.each([[undefined], [null], [42], ['']])('returns nothing for %s', (value) => {
|
|
127
|
+
expect(parseScopes(value)).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
});
|