@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tumbaland/backend-core",
3
- "version": "1.35.0",
3
+ "version": "1.37.0",
4
4
  "description": "Core shared functionality for Tumbaland backend services",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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 { ApiKeyScope } from './types';
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
- const user = jwt.verify(token, requireEnv('JWT_SECRET')) as UserPayload;
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
- } catch {
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
- const scopes = result.scopes ?? [];
128
- const missing = requiredScopes.filter((scope) => !scopes.includes(scope));
129
- if (missing.length > 0) {
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
- // Only the pinned group, never the owner's full membership: this is what
152
- // stops a key reaching a group it was not issued for through any handler
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: `API key is missing required scope: ${missing.join(', ')}`
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 API keys on a route that a session may still use.
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 user = jwt.verify(token, JWT_SECRET) as UserPayload;
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 user = jwt.verify(token, JWT_SECRET) as UserPayload;
50
- req.user = user;
51
- req.userGroups = user.groups ?? [];
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.
@@ -19,5 +19,11 @@ export type {
19
19
  CodeRejection,
20
20
  RefreshRedemption
21
21
  } from './service';
22
- export { mintAccessToken, verifyAccessToken, parseScopes } from './tokens';
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 = {
@@ -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 client = await OAuthClient.create({
34
- clientId: `tmb-client-${randomBytes(16).toString('hex')}`,
35
- clientName: input.clientName.slice(0, 200),
36
- redirectUris: input.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
- lastUsedAt: token.lastUsedAt?.toISOString() ?? null,
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
  };
@@ -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 TOKEN_TYPE = 'mcp_access';
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 TOKEN_TYPE;
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: TOKEN_TYPE,
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.typ !== TOKEN_TYPE) return { ok: false, rejection: 'wrong-type' };
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) : [];