@tumbaland/backend-core 1.35.0 → 1.37.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/apiKeys/middleware.d.ts +5 -3
- package/dist/apiKeys/middleware.d.ts.map +1 -1
- package/dist/apiKeys/middleware.js +75 -34
- package/dist/apiKeys/middleware.js.map +1 -1
- package/dist/middleware/authMiddleware.d.ts +7 -0
- package/dist/middleware/authMiddleware.d.ts.map +1 -1
- package/dist/middleware/authMiddleware.js +25 -4
- package/dist/middleware/authMiddleware.js.map +1 -1
- package/dist/oauth/index.d.ts +1 -1
- package/dist/oauth/index.d.ts.map +1 -1
- package/dist/oauth/index.js +3 -1
- package/dist/oauth/index.js.map +1 -1
- package/dist/oauth/service.d.ts +22 -1
- package/dist/oauth/service.d.ts.map +1 -1
- package/dist/oauth/service.js +34 -5
- package/dist/oauth/service.js.map +1 -1
- package/dist/oauth/tokens.d.ts +12 -3
- package/dist/oauth/tokens.d.ts.map +1 -1
- package/dist/oauth/tokens.js +17 -4
- package/dist/oauth/tokens.js.map +1 -1
- package/package.json +1 -1
- package/src/apiKeys/middleware.test.ts +108 -0
- package/src/apiKeys/middleware.ts +97 -37
- package/src/middleware/authMiddleware.test.ts +49 -0
- package/src/middleware/authMiddleware.ts +26 -4
- package/src/oauth/index.ts +7 -1
- package/src/oauth/service.test.ts +55 -0
- package/src/oauth/service.ts +41 -5
- package/src/oauth/tokens.ts +18 -4
package/package.json
CHANGED
|
@@ -8,6 +8,7 @@ jest.mock('../logging/logger', () => ({
|
|
|
8
8
|
|
|
9
9
|
import jwt from 'jsonwebtoken';
|
|
10
10
|
import { verifyApiKey } from './service';
|
|
11
|
+
import { mintAccessToken, type MintAccessTokenInput } from '../oauth/tokens';
|
|
11
12
|
import { authenticateAgent, requireScope, denyApiKeys } from './middleware';
|
|
12
13
|
|
|
13
14
|
const mockedVerify = verifyApiKey as jest.Mock;
|
|
@@ -351,3 +352,110 @@ describe('denyApiKeys', () => {
|
|
|
351
352
|
expect(next).not.toHaveBeenCalled();
|
|
352
353
|
});
|
|
353
354
|
});
|
|
355
|
+
|
|
356
|
+
describe('authenticateAgent — OAuth access tokens', () => {
|
|
357
|
+
/**
|
|
358
|
+
* Minted by the real thing rather than hand-signed. The bug this covers was
|
|
359
|
+
* that an access token verified as a session — same secret, same header — and
|
|
360
|
+
* its claims were then read as a session's: `sub` instead of `id`, no
|
|
361
|
+
* `groups`. Every one of those details has to come from the code that issues
|
|
362
|
+
* them, or the test simply re-encodes whatever the middleware happens to do.
|
|
363
|
+
*/
|
|
364
|
+
const mint = (over: Partial<MintAccessTokenInput> = {}) =>
|
|
365
|
+
mintAccessToken({
|
|
366
|
+
userId: 'u1',
|
|
367
|
+
email: 'u1@example.com',
|
|
368
|
+
name: 'Tester',
|
|
369
|
+
resource: 'https://mcp.example.com',
|
|
370
|
+
issuer: 'https://auth.example.com',
|
|
371
|
+
scopes: ['relationship:read', 'relationship:write'],
|
|
372
|
+
groupId: null,
|
|
373
|
+
...over
|
|
374
|
+
}).accessToken;
|
|
375
|
+
|
|
376
|
+
const withToken = (token: string, over: Partial<Request> = {}) =>
|
|
377
|
+
req({ headers: { authorization: `Bearer ${token}` }, ...over });
|
|
378
|
+
|
|
379
|
+
it('names the user, so scoped queries are bounded', async () => {
|
|
380
|
+
const request = withToken(mint());
|
|
381
|
+
const next = jest.fn();
|
|
382
|
+
|
|
383
|
+
await authenticateAgent('relationship:write')(request, mockRes(), next);
|
|
384
|
+
|
|
385
|
+
expect(next).toHaveBeenCalled();
|
|
386
|
+
// Read as a session this was `undefined`, which reached handlers as "no
|
|
387
|
+
// user" rather than as a refusal: writes were rejected as inaccessible and
|
|
388
|
+
// reads fell through to an unscoped query.
|
|
389
|
+
expect(request.user?.id).toBe('u1');
|
|
390
|
+
expect(request.user?.email).toBe('u1@example.com');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('is held to its scopes, exactly as a key is', async () => {
|
|
394
|
+
const res = mockRes();
|
|
395
|
+
const next = jest.fn();
|
|
396
|
+
const request = withToken(mint({ scopes: ['relationship:read'] }));
|
|
397
|
+
|
|
398
|
+
await authenticateAgent('relationship:write')(request, res, next);
|
|
399
|
+
|
|
400
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
401
|
+
expect(next).not.toHaveBeenCalled();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('carries an apiKey context, so requireScope applies downstream', async () => {
|
|
405
|
+
const request = withToken(mint({ scopes: ['relationship:read'] }));
|
|
406
|
+
|
|
407
|
+
await authenticateAgent()(request, mockRes(), jest.fn());
|
|
408
|
+
|
|
409
|
+
// Without this the token looked like a session and every requireScope on
|
|
410
|
+
// every route waved it through.
|
|
411
|
+
expect(request.apiKey?.scopes).toEqual(['relationship:read']);
|
|
412
|
+
|
|
413
|
+
const res = mockRes();
|
|
414
|
+
const next = jest.fn();
|
|
415
|
+
requireScope('relationship:write')(request, res, next);
|
|
416
|
+
|
|
417
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
418
|
+
expect(next).not.toHaveBeenCalled();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('is pinned to the tenant chosen at consent', async () => {
|
|
422
|
+
const request = withToken(mint({ groupId: 'g1' }));
|
|
423
|
+
const next = jest.fn();
|
|
424
|
+
|
|
425
|
+
await authenticateAgent()(request, mockRes(), next);
|
|
426
|
+
|
|
427
|
+
expect(next).toHaveBeenCalled();
|
|
428
|
+
expect(request.userGroups).toEqual(['g1']);
|
|
429
|
+
// Written in for a caller that named none, so a handler's group branch
|
|
430
|
+
// lands where the person who approved the connection intended.
|
|
431
|
+
expect(request.query.groupId).toBe('g1');
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('refuses a request naming a different tenant', async () => {
|
|
435
|
+
const res = mockRes();
|
|
436
|
+
const next = jest.fn();
|
|
437
|
+
const request = withToken(mint({ groupId: 'g1' }), { query: { groupId: 'g2' } });
|
|
438
|
+
|
|
439
|
+
await authenticateAgent()(request, res, next);
|
|
440
|
+
|
|
441
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
442
|
+
expect(next).not.toHaveBeenCalled();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('is refused by denyApiKeys, like any other credential software holds', async () => {
|
|
446
|
+
const request = withToken(mint());
|
|
447
|
+
await authenticateAgent()(request, mockRes(), jest.fn());
|
|
448
|
+
|
|
449
|
+
const res = mockRes();
|
|
450
|
+
const next = jest.fn();
|
|
451
|
+
denyApiKeys(request, res, next);
|
|
452
|
+
|
|
453
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
454
|
+
expect(next).not.toHaveBeenCalled();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('never consults the API key store', async () => {
|
|
458
|
+
await authenticateAgent()(withToken(mint()), mockRes(), jest.fn());
|
|
459
|
+
expect(mockedVerify).not.toHaveBeenCalled();
|
|
460
|
+
});
|
|
461
|
+
});
|
|
@@ -2,10 +2,11 @@ import { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
|
2
2
|
import jwt from 'jsonwebtoken';
|
|
3
3
|
import { requireEnv } from '../config/env';
|
|
4
4
|
import logger from '../logging/logger';
|
|
5
|
+
import { isAccessTokenClaims, type AccessTokenClaims } from '../oauth/tokens';
|
|
5
6
|
import type { UserPayload } from '../types/auth';
|
|
6
7
|
import { looksLikeApiKey } from './crypto';
|
|
7
8
|
import { verifyApiKey } from './service';
|
|
8
|
-
import type
|
|
9
|
+
import { isApiKeyScope, type ApiKeyScope } from './types';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Request-scoped facts about the key a request arrived on. Absent on ordinary
|
|
@@ -83,6 +84,60 @@ const applyTenantPin = (req: Request, groupId: string | null): boolean => {
|
|
|
83
84
|
return true;
|
|
84
85
|
};
|
|
85
86
|
|
|
87
|
+
/** A verified non-session credential, whichever kind it arrived as. */
|
|
88
|
+
interface AgentCredential {
|
|
89
|
+
/** the key id or token id, for the request-scoped context */
|
|
90
|
+
credentialId: string;
|
|
91
|
+
userId: string;
|
|
92
|
+
email: string;
|
|
93
|
+
name: string;
|
|
94
|
+
scopes: ApiKeyScope[];
|
|
95
|
+
groupId: string | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Admit software acting for a user, on the terms its credential carries.
|
|
100
|
+
*
|
|
101
|
+
* Shared by both non-session credentials on purpose. An API key and an OAuth
|
|
102
|
+
* access token differ entirely in how they are issued and verified, and not at
|
|
103
|
+
* all in what they mean once they are: a user, a set of scopes, and one tenant.
|
|
104
|
+
* Settling that in one place is what keeps the two from drifting into subtly
|
|
105
|
+
* different amounts of access.
|
|
106
|
+
*/
|
|
107
|
+
function admitAgent(
|
|
108
|
+
req: Request,
|
|
109
|
+
res: Response,
|
|
110
|
+
next: NextFunction,
|
|
111
|
+
requiredScopes: ApiKeyScope[],
|
|
112
|
+
credential: AgentCredential
|
|
113
|
+
): void {
|
|
114
|
+
const missing = requiredScopes.filter((scope) => !credential.scopes.includes(scope));
|
|
115
|
+
if (missing.length > 0) {
|
|
116
|
+
res.status(403).json({
|
|
117
|
+
success: false,
|
|
118
|
+
message: `Credential is missing required scope: ${missing.join(', ')}`
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!applyTenantPin(req, credential.groupId)) {
|
|
124
|
+
res.status(403).json({
|
|
125
|
+
success: false,
|
|
126
|
+
message: 'Credential is not permitted to act in the requested group'
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
req.user = { id: credential.userId, email: credential.email, name: credential.name };
|
|
132
|
+
// Only the pinned group, never the owner's full membership: this is what
|
|
133
|
+
// stops a credential reaching a group it was not issued for through any
|
|
134
|
+
// handler that consults `userGroups` instead of the `groupId` parameter.
|
|
135
|
+
req.userGroups = credential.groupId ? [credential.groupId] : [];
|
|
136
|
+
req.apiKey = { keyId: credential.credentialId, scopes: credential.scopes, groupId: credential.groupId };
|
|
137
|
+
|
|
138
|
+
next();
|
|
139
|
+
}
|
|
140
|
+
|
|
86
141
|
/**
|
|
87
142
|
* Authenticate a request that software may legitimately be making.
|
|
88
143
|
*
|
|
@@ -104,14 +159,40 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
|
|
|
104
159
|
}
|
|
105
160
|
|
|
106
161
|
if (!looksLikeApiKey(token)) {
|
|
162
|
+
let claims: UserPayload | AccessTokenClaims;
|
|
107
163
|
try {
|
|
108
|
-
|
|
164
|
+
claims = jwt.verify(token, requireEnv('JWT_SECRET')) as UserPayload | AccessTokenClaims;
|
|
165
|
+
} catch {
|
|
166
|
+
unauthorized(res);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!isAccessTokenClaims(claims)) {
|
|
171
|
+
const user = claims as UserPayload;
|
|
109
172
|
req.user = user;
|
|
110
173
|
req.userGroups = user.groups ?? [];
|
|
111
174
|
next();
|
|
112
|
-
|
|
113
|
-
unauthorized(res);
|
|
175
|
+
return;
|
|
114
176
|
}
|
|
177
|
+
|
|
178
|
+
// An OAuth access token: signed with the same secret as a session and
|
|
179
|
+
// arriving in the same header, but nothing like one. It names its user in
|
|
180
|
+
// `sub`, carries scopes, and is pinned to a tenant — so it is admitted
|
|
181
|
+
// the way a key is, not the way a person is.
|
|
182
|
+
//
|
|
183
|
+
// Reading it as a session was the original bug and it failed quietly: the
|
|
184
|
+
// claims have no `id` and no `groups`, so `req.user.id` arrived as
|
|
185
|
+
// `undefined`, scoped writes were refused as inaccessible, and a read
|
|
186
|
+
// whose scope collapsed to nothing fell through to "no user context" and
|
|
187
|
+
// answered from the whole collection.
|
|
188
|
+
admitAgent(req, res, next, requiredScopes, {
|
|
189
|
+
credentialId: claims.jti,
|
|
190
|
+
userId: claims.sub,
|
|
191
|
+
email: claims.email ?? '',
|
|
192
|
+
name: claims.name ?? '',
|
|
193
|
+
scopes: (claims.scope ?? '').split(' ').filter(isApiKeyScope),
|
|
194
|
+
groupId: claims.groupId ?? null
|
|
195
|
+
});
|
|
115
196
|
return;
|
|
116
197
|
}
|
|
117
198
|
|
|
@@ -124,37 +205,14 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
|
|
|
124
205
|
return;
|
|
125
206
|
}
|
|
126
207
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
res.status(403).json({
|
|
131
|
-
success: false,
|
|
132
|
-
message: `API key is missing required scope: ${missing.join(', ')}`
|
|
133
|
-
});
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const groupId = result.groupId ?? null;
|
|
138
|
-
if (!applyTenantPin(req, groupId)) {
|
|
139
|
-
res.status(403).json({
|
|
140
|
-
success: false,
|
|
141
|
-
message: 'API key is not permitted to act in the requested group'
|
|
142
|
-
});
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
req.user = {
|
|
147
|
-
id: result.userId!,
|
|
208
|
+
admitAgent(req, res, next, requiredScopes, {
|
|
209
|
+
credentialId: result.keyId!,
|
|
210
|
+
userId: result.userId!,
|
|
148
211
|
email: result.userEmail ?? '',
|
|
149
|
-
name: result.userName ?? ''
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
// that consults `userGroups` instead of the `groupId` parameter.
|
|
154
|
-
req.userGroups = groupId ? [groupId] : [];
|
|
155
|
-
req.apiKey = { keyId: result.keyId!, scopes, groupId };
|
|
156
|
-
|
|
157
|
-
next();
|
|
212
|
+
name: result.userName ?? '',
|
|
213
|
+
scopes: result.scopes ?? [],
|
|
214
|
+
groupId: result.groupId ?? null
|
|
215
|
+
});
|
|
158
216
|
};
|
|
159
217
|
|
|
160
218
|
/**
|
|
@@ -166,7 +224,9 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
|
|
|
166
224
|
* door for lacking a scope half the router never uses.
|
|
167
225
|
*
|
|
168
226
|
* A session passes unconditionally: scopes narrow what software may do on a
|
|
169
|
-
* person's behalf, not what the person may do themselves.
|
|
227
|
+
* person's behalf, not what the person may do themselves. Everything that is
|
|
228
|
+
* not a session — an API key or an OAuth access token alike — arrives with
|
|
229
|
+
* `req.apiKey` set and is held to it.
|
|
170
230
|
*/
|
|
171
231
|
export const requireScope = (...requiredScopes: ApiKeyScope[]): RequestHandler =>
|
|
172
232
|
function requireScopeHandler(req, res, next) {
|
|
@@ -179,7 +239,7 @@ export const requireScope = (...requiredScopes: ApiKeyScope[]): RequestHandler =
|
|
|
179
239
|
if (missing.length > 0) {
|
|
180
240
|
res.status(403).json({
|
|
181
241
|
success: false,
|
|
182
|
-
message: `
|
|
242
|
+
message: `Credential is missing required scope: ${missing.join(', ')}`
|
|
183
243
|
});
|
|
184
244
|
return;
|
|
185
245
|
}
|
|
@@ -188,7 +248,7 @@ export const requireScope = (...requiredScopes: ApiKeyScope[]): RequestHandler =
|
|
|
188
248
|
};
|
|
189
249
|
|
|
190
250
|
/**
|
|
191
|
-
* Refuse
|
|
251
|
+
* Refuse every non-session credential on a route that a session may still use.
|
|
192
252
|
*
|
|
193
253
|
* For the handful of operations that should stay a person's to perform — key
|
|
194
254
|
* management itself, most obviously, since a key that can mint keys is a key
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Request, Response } from 'express';
|
|
2
2
|
import jwt from 'jsonwebtoken';
|
|
3
3
|
import { authenticateToken, optionalAuth } from './authMiddleware';
|
|
4
|
+
import { mintAccessToken } from '../oauth/tokens';
|
|
4
5
|
import { UserPayload } from '../types/auth';
|
|
5
6
|
|
|
6
7
|
function mockReq(overrides: Partial<Request> = {}): Request {
|
|
@@ -171,3 +172,51 @@ describe('optionalAuth', () => {
|
|
|
171
172
|
expect(res.status).not.toHaveBeenCalled();
|
|
172
173
|
});
|
|
173
174
|
});
|
|
175
|
+
|
|
176
|
+
describe('OAuth access tokens are not sessions', () => {
|
|
177
|
+
const ORIGINAL_ENV = process.env;
|
|
178
|
+
|
|
179
|
+
beforeEach(() => {
|
|
180
|
+
process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-secret' };
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
afterEach(() => {
|
|
184
|
+
process.env = ORIGINAL_ENV;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const accessToken = () =>
|
|
188
|
+
mintAccessToken({
|
|
189
|
+
userId: 'u1',
|
|
190
|
+
email: 'u1@example.com',
|
|
191
|
+
name: 'Tester',
|
|
192
|
+
resource: 'https://mcp.example.com',
|
|
193
|
+
issuer: 'https://auth.example.com',
|
|
194
|
+
scopes: ['relationship:read'],
|
|
195
|
+
groupId: null
|
|
196
|
+
}).accessToken;
|
|
197
|
+
|
|
198
|
+
it('refuses one on a session-only route', () => {
|
|
199
|
+
const res = mockRes();
|
|
200
|
+
const next = jest.fn();
|
|
201
|
+
const req = mockReq({ headers: { authorization: `Bearer ${accessToken()}` } });
|
|
202
|
+
|
|
203
|
+
// It verifies — same secret — so nothing but the type check stands between
|
|
204
|
+
// an assistant and every route that has no scopes to be held to.
|
|
205
|
+
authenticateToken(req, res, next);
|
|
206
|
+
|
|
207
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
208
|
+
expect(next).not.toHaveBeenCalled();
|
|
209
|
+
expect(req.user).toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('treats one as no session at all on an optional-auth route', () => {
|
|
213
|
+
const next = jest.fn();
|
|
214
|
+
const req = mockReq({ headers: { authorization: `Bearer ${accessToken()}` } });
|
|
215
|
+
|
|
216
|
+
optionalAuth(req, mockRes(), next);
|
|
217
|
+
|
|
218
|
+
// The route still answers, in its public form — the same as a stale cookie.
|
|
219
|
+
expect(next).toHaveBeenCalled();
|
|
220
|
+
expect(req.user).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Request, Response, NextFunction } from 'express';
|
|
2
2
|
import jwt from 'jsonwebtoken';
|
|
3
3
|
import { requireEnv } from '../config/env';
|
|
4
|
+
import { isAccessTokenClaims } from '../oauth/tokens';
|
|
4
5
|
import { UserPayload } from '../types/auth';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -8,6 +9,13 @@ import { UserPayload } from '../types/auth';
|
|
|
8
9
|
* Verifies JWT token and attaches user info (and its embedded groups) to
|
|
9
10
|
* the request — typed via the global `Express.Request` augmentation in
|
|
10
11
|
* `../types/auth`, no `(req as any)` cast needed.
|
|
12
|
+
*
|
|
13
|
+
* OAuth access tokens are refused outright. They are signed with the same
|
|
14
|
+
* secret, so they verify here, but they mean something this middleware has no
|
|
15
|
+
* way to honour: an assistant acting within scopes, pinned to one tenant.
|
|
16
|
+
* Routes that software may legitimately reach use `authenticateAgent`, which
|
|
17
|
+
* enforces both — so anything still guarded by this one is a person's to do,
|
|
18
|
+
* and letting a token through would be handing an assistant the account.
|
|
11
19
|
*/
|
|
12
20
|
export function authenticateToken(req: Request, res: Response, next: NextFunction): void {
|
|
13
21
|
try {
|
|
@@ -19,7 +27,16 @@ export function authenticateToken(req: Request, res: Response, next: NextFunctio
|
|
|
19
27
|
return;
|
|
20
28
|
}
|
|
21
29
|
|
|
22
|
-
const
|
|
30
|
+
const claims = jwt.verify(token, JWT_SECRET);
|
|
31
|
+
if (isAccessTokenClaims(claims)) {
|
|
32
|
+
res.status(403).json({
|
|
33
|
+
success: false,
|
|
34
|
+
message: 'This operation requires an interactive session, not a connected assistant'
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const user = claims as UserPayload;
|
|
23
40
|
req.user = user;
|
|
24
41
|
req.userGroups = user.groups ?? [];
|
|
25
42
|
next();
|
|
@@ -46,9 +63,14 @@ export function optionalAuth(req: Request, _res: Response, next: NextFunction):
|
|
|
46
63
|
const token = req.cookies?.access_token || req.headers.authorization?.replace('Bearer ', '');
|
|
47
64
|
|
|
48
65
|
if (token) {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
66
|
+
const claims = jwt.verify(token, JWT_SECRET);
|
|
67
|
+
// An access token is not a session; treated as none, exactly like an
|
|
68
|
+
// expired cookie, so the route still answers with its public form.
|
|
69
|
+
if (!isAccessTokenClaims(claims)) {
|
|
70
|
+
const user = claims as UserPayload;
|
|
71
|
+
req.user = user;
|
|
72
|
+
req.userGroups = user.groups ?? [];
|
|
73
|
+
}
|
|
52
74
|
}
|
|
53
75
|
} catch {
|
|
54
76
|
// Deliberately ignored — see above.
|
package/src/oauth/index.ts
CHANGED
|
@@ -19,5 +19,11 @@ export type {
|
|
|
19
19
|
CodeRejection,
|
|
20
20
|
RefreshRedemption
|
|
21
21
|
} from './service';
|
|
22
|
-
export {
|
|
22
|
+
export {
|
|
23
|
+
mintAccessToken,
|
|
24
|
+
verifyAccessToken,
|
|
25
|
+
parseScopes,
|
|
26
|
+
isAccessTokenClaims,
|
|
27
|
+
ACCESS_TOKEN_TYPE
|
|
28
|
+
} from './tokens';
|
|
23
29
|
export type { AccessTokenClaims, MintAccessTokenInput, MintedAccessToken, AccessTokenVerification } from './tokens';
|
|
@@ -39,11 +39,43 @@ const storedCode = (over: Record<string, unknown> = {}) => ({
|
|
|
39
39
|
beforeEach(() => {
|
|
40
40
|
jest.clearAllMocks();
|
|
41
41
|
mockedClientCreate.mockImplementation(async (doc) => doc);
|
|
42
|
+
(OAuthClient.findOne as jest.Mock).mockResolvedValue(null);
|
|
42
43
|
mockedCodeCreate.mockImplementation(async (doc) => doc);
|
|
43
44
|
mockedRefreshCreate.mockImplementation(async (doc) => doc);
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
describe('registerClient', () => {
|
|
48
|
+
it('returns the existing client when the same callback re-registers', async () => {
|
|
49
|
+
// The Claude app re-registers on every connect attempt. Minting a fresh id
|
|
50
|
+
// each time left three identical "Claude" rows in connected apps, only one
|
|
51
|
+
// of which any given disconnect would cut off.
|
|
52
|
+
(OAuthClient.findOne as jest.Mock).mockResolvedValue({
|
|
53
|
+
clientId: 'tmb-client-existing',
|
|
54
|
+
clientName: 'Claude',
|
|
55
|
+
redirectUris: ['https://claude.ai/api/mcp/auth_callback']
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const client = await registerClient({
|
|
59
|
+
clientName: 'Claude',
|
|
60
|
+
redirectUris: ['https://claude.ai/api/mcp/auth_callback']
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(client.clientId).toBe('tmb-client-existing');
|
|
64
|
+
expect(mockedClientCreate).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('matches on the exact set of callbacks, which is what identifies the app', async () => {
|
|
68
|
+
(OAuthClient.findOne as jest.Mock).mockResolvedValue(null);
|
|
69
|
+
|
|
70
|
+
await registerClient({ clientName: 'Claude', redirectUris: ['https://b/cb', 'https://a/cb'] });
|
|
71
|
+
|
|
72
|
+
// Sorted and size-constrained: a client registering a superset is a
|
|
73
|
+
// different client, and an authorization code is delivered to that URL.
|
|
74
|
+
expect((OAuthClient.findOne as jest.Mock).mock.calls[0][0]).toEqual({
|
|
75
|
+
redirectUris: { $size: 2, $all: ['https://a/cb', 'https://b/cb'] }
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
47
79
|
it('issues a namespaced client id and keeps the redirect URIs', async () => {
|
|
48
80
|
const client = await registerClient({
|
|
49
81
|
clientName: 'Claude',
|
|
@@ -181,6 +213,29 @@ describe('redeemAuthorizationCode', () => {
|
|
|
181
213
|
});
|
|
182
214
|
});
|
|
183
215
|
|
|
216
|
+
describe('issueRefreshToken', () => {
|
|
217
|
+
it('retires any grant the same client already held for this user', async () => {
|
|
218
|
+
// Reconnecting is a re-authorization, not a second connection: two live
|
|
219
|
+
// grants would leave stale scopes beside the new ones and a disconnect
|
|
220
|
+
// that only cut one of them.
|
|
221
|
+
mockedRefreshUpdateMany.mockResolvedValue({ modifiedCount: 1 });
|
|
222
|
+
|
|
223
|
+
await issueRefreshToken({
|
|
224
|
+
clientId: 'client-1',
|
|
225
|
+
userId: 'u1',
|
|
226
|
+
scopes: ['relationship:read'],
|
|
227
|
+
groupId: null,
|
|
228
|
+
resource: 'https://mcp.tumbaland.eu'
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(mockedRefreshUpdateMany).toHaveBeenCalledWith(
|
|
232
|
+
{ userId: 'u1', clientId: 'client-1', revokedAt: { $exists: false } },
|
|
233
|
+
{ $set: { revokedAt: expect.any(Date) } }
|
|
234
|
+
);
|
|
235
|
+
expect(mockedRefreshCreate).toHaveBeenCalled();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
184
239
|
describe('refresh token rotation', () => {
|
|
185
240
|
it('retires the presented token and issues a replacement', async () => {
|
|
186
241
|
const record = {
|
package/src/oauth/service.ts
CHANGED
|
@@ -29,13 +29,35 @@ export interface RegisteredClient {
|
|
|
29
29
|
redirectUris: string[];
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Register a client, or hand back the one that is already registered.
|
|
34
|
+
*
|
|
35
|
+
* Clients re-register freely — the Claude app does it on every connect attempt
|
|
36
|
+
* — and minting a fresh id each time left a user staring at three identical
|
|
37
|
+
* "Claude" entries in their connected apps, only one of which any given
|
|
38
|
+
* disconnect would cut off.
|
|
39
|
+
*
|
|
40
|
+
* Matching on the exact redirect URIs is what makes this safe. These are public
|
|
41
|
+
* clients with no secret, so a `client_id` is an identifier rather than a
|
|
42
|
+
* credential; anything presenting the same callback URL *is* the same
|
|
43
|
+
* application, because that URL is what an authorization code gets delivered
|
|
44
|
+
* to. A different app cannot claim it without already controlling it.
|
|
45
|
+
*/
|
|
32
46
|
export const registerClient = async (input: RegisterClientInput): Promise<RegisteredClient> => {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
redirectUris:
|
|
47
|
+
const redirectUris = [...input.redirectUris].sort();
|
|
48
|
+
|
|
49
|
+
const existing = await OAuthClient.findOne({
|
|
50
|
+
redirectUris: { $size: redirectUris.length, $all: redirectUris }
|
|
37
51
|
});
|
|
38
52
|
|
|
53
|
+
const client =
|
|
54
|
+
existing ??
|
|
55
|
+
(await OAuthClient.create({
|
|
56
|
+
clientId: `tmb-client-${randomBytes(16).toString('hex')}`,
|
|
57
|
+
clientName: input.clientName.slice(0, 200),
|
|
58
|
+
redirectUris
|
|
59
|
+
}));
|
|
60
|
+
|
|
39
61
|
return {
|
|
40
62
|
clientId: client.clientId,
|
|
41
63
|
clientName: client.clientName,
|
|
@@ -133,6 +155,13 @@ export interface IssuedRefreshToken {
|
|
|
133
155
|
token: string;
|
|
134
156
|
}
|
|
135
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Start a grant, replacing any the same client already held for this user.
|
|
160
|
+
*
|
|
161
|
+
* Reconnecting is a re-authorization, not a second connection: without this,
|
|
162
|
+
* approving twice leaves two live grants, the newer scopes sitting beside the
|
|
163
|
+
* older ones, and a disconnect that revokes only one of them.
|
|
164
|
+
*/
|
|
136
165
|
export const issueRefreshToken = async (input: {
|
|
137
166
|
clientId: string;
|
|
138
167
|
userId: string;
|
|
@@ -140,6 +169,11 @@ export const issueRefreshToken = async (input: {
|
|
|
140
169
|
groupId: string | null;
|
|
141
170
|
resource: string;
|
|
142
171
|
}): Promise<IssuedRefreshToken> => {
|
|
172
|
+
await RefreshToken.updateMany(
|
|
173
|
+
{ userId: input.userId, clientId: input.clientId, revokedAt: { $exists: false } },
|
|
174
|
+
{ $set: { revokedAt: new Date() } }
|
|
175
|
+
);
|
|
176
|
+
|
|
143
177
|
const token = randomBytes(32).toString('base64url');
|
|
144
178
|
await RefreshToken.create({ ...input, tokenHash: sha256(token) });
|
|
145
179
|
return { token };
|
|
@@ -239,7 +273,9 @@ export const listConnections = async (userId: string) => {
|
|
|
239
273
|
scopes: token.scopes,
|
|
240
274
|
groupId: token.groupId ?? null,
|
|
241
275
|
createdAt: (token.grantedAt ?? token.createdAt).toISOString(),
|
|
242
|
-
|
|
276
|
+
// Set when the refresh token is exchanged, not when a tool runs — access
|
|
277
|
+
// tokens are validated statelessly, so the server never sees ordinary use.
|
|
278
|
+
lastRenewedAt: token.lastUsedAt?.toISOString() ?? null,
|
|
243
279
|
revokedAt: token.revokedAt?.toISOString() ?? null
|
|
244
280
|
}));
|
|
245
281
|
};
|
package/src/oauth/tokens.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { isApiKeyScope, type ApiKeyScope } from '../apiKeys/types';
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/** Marks a token as issued by the OAuth flow, for the MCP resource specifically. */
|
|
18
|
-
const
|
|
18
|
+
export const ACCESS_TOKEN_TYPE = 'mcp_access';
|
|
19
19
|
|
|
20
20
|
/** Short enough that a leaked token is a small window, long enough to be usable. */
|
|
21
21
|
const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
|
|
@@ -31,7 +31,7 @@ export interface AccessTokenClaims {
|
|
|
31
31
|
groupId: string | null;
|
|
32
32
|
email: string;
|
|
33
33
|
name: string;
|
|
34
|
-
typ: typeof
|
|
34
|
+
typ: typeof ACCESS_TOKEN_TYPE;
|
|
35
35
|
jti: string;
|
|
36
36
|
exp: number;
|
|
37
37
|
iat: number;
|
|
@@ -66,7 +66,7 @@ export const mintAccessToken = (input: MintAccessTokenInput): MintedAccessToken
|
|
|
66
66
|
groupId: input.groupId,
|
|
67
67
|
email: input.email,
|
|
68
68
|
name: input.name,
|
|
69
|
-
typ:
|
|
69
|
+
typ: ACCESS_TOKEN_TYPE,
|
|
70
70
|
jti: randomUUID()
|
|
71
71
|
},
|
|
72
72
|
requireEnv('JWT_SECRET'),
|
|
@@ -108,7 +108,7 @@ export const verifyAccessToken = (
|
|
|
108
108
|
return { ok: false, rejection: expired ? 'expired' : 'bad-signature' };
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
if (claims
|
|
111
|
+
if (!isAccessTokenClaims(claims)) return { ok: false, rejection: 'wrong-type' };
|
|
112
112
|
if (claims.aud !== expectedAudience) return { ok: false, rejection: 'wrong-audience' };
|
|
113
113
|
if (!claims.sub) return { ok: false, rejection: 'malformed' };
|
|
114
114
|
|
|
@@ -122,6 +122,20 @@ export const verifyAccessToken = (
|
|
|
122
122
|
};
|
|
123
123
|
};
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Tell an OAuth access token apart from an ordinary session JWT.
|
|
127
|
+
*
|
|
128
|
+
* They are signed with the same secret and arrive in the same header, so
|
|
129
|
+
* anything holding one has to ask which it got. Getting this wrong is not a
|
|
130
|
+
* subtle failure: read as a session, an access token has no `id` and no
|
|
131
|
+
* `groups`, so `req.user.id` lands as `undefined` and every scoped query goes
|
|
132
|
+
* out unbounded.
|
|
133
|
+
*/
|
|
134
|
+
export const isAccessTokenClaims = (claims: unknown): claims is AccessTokenClaims =>
|
|
135
|
+
typeof claims === 'object' &&
|
|
136
|
+
claims !== null &&
|
|
137
|
+
(claims as AccessTokenClaims).typ === ACCESS_TOKEN_TYPE;
|
|
138
|
+
|
|
125
139
|
/** Parse a space-separated `scope` parameter, dropping anything we do not define. */
|
|
126
140
|
export const parseScopes = (scope: unknown): ApiKeyScope[] =>
|
|
127
141
|
typeof scope === 'string' ? scope.split(/\s+/).filter(isApiKeyScope) : [];
|