@tumbaland/backend-core 1.36.0 → 1.38.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 (56) hide show
  1. package/dist/apiKeys/ApiKey.d.ts +12 -3
  2. package/dist/apiKeys/ApiKey.d.ts.map +1 -1
  3. package/dist/apiKeys/ApiKey.js +5 -1
  4. package/dist/apiKeys/ApiKey.js.map +1 -1
  5. package/dist/apiKeys/index.d.ts +2 -2
  6. package/dist/apiKeys/index.d.ts.map +1 -1
  7. package/dist/apiKeys/index.js +5 -1
  8. package/dist/apiKeys/index.js.map +1 -1
  9. package/dist/apiKeys/middleware.d.ts +9 -5
  10. package/dist/apiKeys/middleware.d.ts.map +1 -1
  11. package/dist/apiKeys/middleware.js +125 -59
  12. package/dist/apiKeys/middleware.js.map +1 -1
  13. package/dist/apiKeys/service.d.ts +5 -3
  14. package/dist/apiKeys/service.d.ts.map +1 -1
  15. package/dist/apiKeys/service.js +10 -3
  16. package/dist/apiKeys/service.js.map +1 -1
  17. package/dist/apiKeys/types.d.ts +41 -8
  18. package/dist/apiKeys/types.d.ts.map +1 -1
  19. package/dist/apiKeys/types.js +35 -1
  20. package/dist/apiKeys/types.js.map +1 -1
  21. package/dist/middleware/authMiddleware.d.ts +7 -0
  22. package/dist/middleware/authMiddleware.d.ts.map +1 -1
  23. package/dist/middleware/authMiddleware.js +25 -4
  24. package/dist/middleware/authMiddleware.js.map +1 -1
  25. package/dist/oauth/index.d.ts +1 -1
  26. package/dist/oauth/index.d.ts.map +1 -1
  27. package/dist/oauth/index.js +3 -1
  28. package/dist/oauth/index.js.map +1 -1
  29. package/dist/oauth/models.d.ts +13 -2
  30. package/dist/oauth/models.d.ts.map +1 -1
  31. package/dist/oauth/models.js +9 -0
  32. package/dist/oauth/models.js.map +1 -1
  33. package/dist/oauth/service.d.ts +15 -8
  34. package/dist/oauth/service.d.ts.map +1 -1
  35. package/dist/oauth/service.js +10 -3
  36. package/dist/oauth/service.js.map +1 -1
  37. package/dist/oauth/tokens.d.ts +34 -8
  38. package/dist/oauth/tokens.d.ts.map +1 -1
  39. package/dist/oauth/tokens.js +22 -6
  40. package/dist/oauth/tokens.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/apiKeys/ApiKey.ts +16 -4
  43. package/src/apiKeys/index.ts +16 -2
  44. package/src/apiKeys/middleware.test.ts +207 -16
  45. package/src/apiKeys/middleware.ts +159 -66
  46. package/src/apiKeys/service.test.ts +56 -9
  47. package/src/apiKeys/service.ts +24 -6
  48. package/src/apiKeys/types.ts +61 -8
  49. package/src/middleware/authMiddleware.test.ts +52 -0
  50. package/src/middleware/authMiddleware.ts +26 -4
  51. package/src/oauth/index.ts +7 -1
  52. package/src/oauth/models.ts +21 -2
  53. package/src/oauth/service.test.ts +58 -6
  54. package/src/oauth/service.ts +25 -7
  55. package/src/oauth/tokens.test.ts +20 -5
  56. package/src/oauth/tokens.ts +51 -11
@@ -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';
@@ -1,4 +1,5 @@
1
1
  import mongoose, { Document, Schema } from 'mongoose';
2
+ import { PERSONAL_TENANT, type Tenant } from '../apiKeys/types';
2
3
 
3
4
  /**
4
5
  * A client registered through RFC 7591 Dynamic Client Registration.
@@ -46,7 +47,13 @@ export interface IAuthorizationCode extends Document {
46
47
  userName: string;
47
48
  redirectUri: string;
48
49
  scopes: string[];
49
- groupId: string | null;
50
+ /** every tenant the user approved, and the one chosen as the default */
51
+ tenants: Tenant[];
52
+ defaultTenant: Tenant;
53
+ /** what each approved tenant is called, snapshotted at consent */
54
+ tenantNames: Record<Tenant, string>;
55
+ /** the single tenant an older grant was pinned to; read by `readTenants` */
56
+ groupId?: string | null;
50
57
  /** RFC 8707: the MCP server this will be minted for */
51
58
  resource: string;
52
59
  /** PKCE, always S256 — the spec does not allow `plain` */
@@ -66,6 +73,10 @@ const AuthorizationCodeSchema = new Schema<IAuthorizationCode>(
66
73
  userName: { type: String, default: '' },
67
74
  redirectUri: { type: String, required: true },
68
75
  scopes: { type: [String], default: [] },
76
+ tenants: { type: [String], default: () => [PERSONAL_TENANT] },
77
+ defaultTenant: { type: String, default: PERSONAL_TENANT },
78
+ tenantNames: { type: Schema.Types.Mixed, default: () => ({}) },
79
+ // Written by the single-tenant model this replaced; read-only now.
69
80
  groupId: { type: String, default: null },
70
81
  resource: { type: String, required: true },
71
82
  codeChallenge: { type: String, required: true },
@@ -96,7 +107,11 @@ export interface IRefreshToken extends Document {
96
107
  clientId: string;
97
108
  userId: string;
98
109
  scopes: string[];
99
- groupId: string | null;
110
+ tenants: Tenant[];
111
+ defaultTenant: Tenant;
112
+ tenantNames: Record<Tenant, string>;
113
+ /** the single tenant an older grant was pinned to; read by `readTenants` */
114
+ groupId?: string | null;
100
115
  resource: string;
101
116
  /**
102
117
  * When the user actually connected this assistant.
@@ -118,6 +133,10 @@ const RefreshTokenSchema = new Schema<IRefreshToken>(
118
133
  clientId: { type: String, required: true },
119
134
  userId: { type: String, required: true, index: true },
120
135
  scopes: { type: [String], default: [] },
136
+ tenants: { type: [String], default: () => [PERSONAL_TENANT] },
137
+ defaultTenant: { type: String, default: PERSONAL_TENANT },
138
+ tenantNames: { type: Schema.Types.Mixed, default: () => ({}) },
139
+ // Written by the single-tenant model this replaced; read-only now.
121
140
  groupId: { type: String, default: null },
122
141
  resource: { type: String, required: true },
123
142
  grantedAt: { type: Date, default: Date.now },
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'crypto';
2
+ import { PERSONAL_TENANT } from '../apiKeys/types';
2
3
 
3
4
  jest.mock('./models', () => ({
4
5
  OAuthClient: { create: jest.fn(), findOne: jest.fn(), find: jest.fn() },
@@ -122,13 +123,21 @@ describe('issueAuthorizationCode', () => {
122
123
  userName: 'Tester',
123
124
  redirectUri: 'https://claude.ai/callback',
124
125
  scopes: ['relationship:read'],
125
- groupId: 'g1',
126
+ tenants: ['g1'],
127
+ defaultTenant: 'g1',
128
+ tenantNames: { g1: 'Irja & Tom' },
126
129
  resource: 'https://mcp.tumbaland.eu',
127
130
  codeChallenge: CHALLENGE
128
131
  });
129
132
 
130
133
  const [doc] = mockedCodeCreate.mock.calls[0];
131
- expect(doc).toMatchObject({ userId: 'u1', groupId: 'g1', scopes: ['relationship:read'] });
134
+ expect(doc).toMatchObject({
135
+ userId: 'u1',
136
+ tenants: ['g1'],
137
+ defaultTenant: 'g1',
138
+ tenantNames: { g1: 'Irja & Tom' },
139
+ scopes: ['relationship:read']
140
+ });
132
141
  expect(doc.expiresAt.getTime() - before).toBeLessThanOrEqual(60_000);
133
142
  });
134
143
  });
@@ -224,7 +233,9 @@ describe('issueRefreshToken', () => {
224
233
  clientId: 'client-1',
225
234
  userId: 'u1',
226
235
  scopes: ['relationship:read'],
227
- groupId: null,
236
+ tenants: [PERSONAL_TENANT],
237
+ defaultTenant: PERSONAL_TENANT,
238
+ tenantNames: {},
228
239
  resource: 'https://mcp.tumbaland.eu'
229
240
  });
230
241
 
@@ -311,7 +322,9 @@ describe('refresh tokens', () => {
311
322
  clientId: 'client-1',
312
323
  userId: 'u1',
313
324
  scopes: ['relationship:read'],
314
- groupId: null,
325
+ tenants: [PERSONAL_TENANT],
326
+ defaultTenant: PERSONAL_TENANT,
327
+ tenantNames: {},
315
328
  resource: 'https://mcp.tumbaland.eu'
316
329
  });
317
330
 
@@ -324,7 +337,9 @@ describe('refresh tokens', () => {
324
337
  mockedRefreshFindOne.mockResolvedValue({
325
338
  userId: 'u1',
326
339
  scopes: ['relationship:read'],
327
- groupId: 'g1',
340
+ tenants: [PERSONAL_TENANT, 'g1'],
341
+ defaultTenant: 'g1',
342
+ tenantNames: { g1: 'Irja & Tom' },
328
343
  resource: 'https://mcp.tumbaland.eu',
329
344
  grantedAt: new Date(),
330
345
  save: jest.fn()
@@ -333,7 +348,44 @@ describe('refresh tokens', () => {
333
348
  await expect(redeemRefreshToken('tok', 'client-1')).resolves.toMatchObject({
334
349
  ok: true,
335
350
  userId: 'u1',
336
- groupId: 'g1'
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' }
337
389
  });
338
390
  });
339
391
 
@@ -1,6 +1,11 @@
1
1
  import { createHash, randomBytes, timingSafeEqual } from 'crypto';
2
2
  import { AuthorizationCode, IAuthorizationCode, OAuthClient, RefreshToken } from './models';
3
- import type { ApiKeyScope } from '../apiKeys/types';
3
+ import {
4
+ readTenants,
5
+ type ApiKeyScope,
6
+ type ApiKeyTenant,
7
+ type Tenant
8
+ } from '../apiKeys/types';
4
9
 
5
10
  /** How long a user has between approving and the client exchanging the code. */
6
11
  const CODE_TTL_MS = 60 * 1000;
@@ -84,7 +89,9 @@ export interface IssueCodeInput {
84
89
  userName: string;
85
90
  redirectUri: string;
86
91
  scopes: ApiKeyScope[];
87
- groupId: string | null;
92
+ tenants: Tenant[];
93
+ defaultTenant: Tenant;
94
+ tenantNames: Record<Tenant, string>;
88
95
  resource: string;
89
96
  codeChallenge: string;
90
97
  }
@@ -166,7 +173,9 @@ export const issueRefreshToken = async (input: {
166
173
  clientId: string;
167
174
  userId: string;
168
175
  scopes: ApiKeyScope[];
169
- groupId: string | null;
176
+ tenants: Tenant[];
177
+ defaultTenant: Tenant;
178
+ tenantNames: Record<Tenant, string>;
170
179
  resource: string;
171
180
  }): Promise<IssuedRefreshToken> => {
172
181
  await RefreshToken.updateMany(
@@ -185,7 +194,8 @@ export interface RefreshRedemption {
185
194
  reused?: boolean;
186
195
  userId?: string;
187
196
  scopes?: ApiKeyScope[];
188
- groupId?: string | null;
197
+ tenants?: ApiKeyTenant;
198
+ tenantNames?: Record<Tenant, string>;
189
199
  resource?: string;
190
200
  /** the replacement the client must store; the presented one is now dead */
191
201
  rotatedToken?: string;
@@ -233,7 +243,11 @@ export const redeemRefreshToken = async (
233
243
  clientId,
234
244
  userId: record.userId,
235
245
  scopes: record.scopes,
236
- groupId: record.groupId ?? null,
246
+ // Carried, not reset: rotation reissues the same grant, and re-deriving the
247
+ // tenants would quietly narrow a connection every time it refreshed.
248
+ tenants: record.tenants,
249
+ defaultTenant: record.defaultTenant,
250
+ tenantNames: record.tenantNames,
237
251
  resource: record.resource,
238
252
  // Carried, not reset: this is still the grant the user approved.
239
253
  grantedAt: record.grantedAt ?? record.createdAt
@@ -243,7 +257,8 @@ export const redeemRefreshToken = async (
243
257
  ok: true,
244
258
  userId: record.userId,
245
259
  scopes: record.scopes as ApiKeyScope[],
246
- groupId: record.groupId ?? null,
260
+ tenants: readTenants(record),
261
+ tenantNames: record.tenantNames ?? {},
247
262
  resource: record.resource,
248
263
  rotatedToken: rotated
249
264
  };
@@ -271,7 +286,10 @@ export const listConnections = async (userId: string) => {
271
286
  clientId: token.clientId,
272
287
  clientName: nameById.get(token.clientId) ?? 'Unknown app',
273
288
  scopes: token.scopes,
274
- groupId: token.groupId ?? null,
289
+ ...(({ allowed, default: fallback }) => ({ tenants: allowed, defaultTenant: fallback }))(
290
+ readTenants(token)
291
+ ),
292
+ tenantNames: token.tenantNames ?? {},
275
293
  createdAt: (token.grantedAt ?? token.createdAt).toISOString(),
276
294
  // Set when the refresh token is exchanged, not when a tool runs — access
277
295
  // tokens are validated statelessly, so the server never sees ordinary use.
@@ -1,6 +1,7 @@
1
1
  const ORIGINAL_ENV = process.env;
2
2
 
3
3
  import jwt from 'jsonwebtoken';
4
+ import { PERSONAL_TENANT } from '../apiKeys/types';
4
5
  import { mintAccessToken, verifyAccessToken, parseScopes } from './tokens';
5
6
 
6
7
  const RESOURCE = 'https://mcp.tumbaland.eu';
@@ -14,7 +15,9 @@ const mint = (over: Partial<Parameters<typeof mintAccessToken>[0]> = {}) =>
14
15
  resource: RESOURCE,
15
16
  issuer: ISSUER,
16
17
  scopes: ['relationship:read', 'relationship:write'],
17
- groupId: null,
18
+ tenants: [PERSONAL_TENANT],
19
+ defaultTenant: PERSONAL_TENANT,
20
+ tenantNames: { [PERSONAL_TENANT]: 'My own data' },
18
21
  ...over
19
22
  });
20
23
 
@@ -38,9 +41,21 @@ describe('mintAccessToken', () => {
38
41
  expect(expiresIn).toBe(3600);
39
42
  });
40
43
 
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
+ it('carries the granted tenants, so a token cannot wander outside them', () => {
45
+ const claims = jwt.decode(
46
+ mint({ tenants: [PERSONAL_TENANT, 'g1'], defaultTenant: 'g1' }).accessToken
47
+ ) as Record<string, unknown>;
48
+
49
+ expect(claims.tenants).toEqual([PERSONAL_TENANT, 'g1']);
50
+ expect(claims.defaultTenant).toBe('g1');
51
+ });
52
+
53
+ it('carries what each tenant is called, since nothing downstream can look it up', () => {
54
+ const claims = jwt.decode(
55
+ mint({ tenants: ['g1'], defaultTenant: 'g1', tenantNames: { g1: 'Irja & Tom' } }).accessToken
56
+ ) as Record<string, unknown>;
57
+
58
+ expect(claims.tenantNames).toEqual({ g1: 'Irja & Tom' });
44
59
  });
45
60
 
46
61
  it('gives every token a distinct id', () => {
@@ -59,7 +74,7 @@ describe('verifyAccessToken', () => {
59
74
  userId: 'u1',
60
75
  email: 'u1@example.com',
61
76
  scopes: ['relationship:read', 'relationship:write'],
62
- groupId: null
77
+ tenants: { allowed: [PERSONAL_TENANT], default: PERSONAL_TENANT }
63
78
  });
64
79
  });
65
80
 
@@ -1,7 +1,13 @@
1
1
  import { randomUUID } from 'crypto';
2
2
  import jwt from 'jsonwebtoken';
3
3
  import { requireEnv } from '../config/env';
4
- import { isApiKeyScope, type ApiKeyScope } from '../apiKeys/types';
4
+ import {
5
+ isApiKeyScope,
6
+ readTenants,
7
+ type ApiKeyScope,
8
+ type ApiKeyTenant,
9
+ type Tenant
10
+ } from '../apiKeys/types';
5
11
 
6
12
  /**
7
13
  * Access tokens for the OAuth flow that lets an assistant connect to Tumbaland.
@@ -15,7 +21,7 @@ import { isApiKeyScope, type ApiKeyScope } from '../apiKeys/types';
15
21
  */
16
22
 
17
23
  /** Marks a token as issued by the OAuth flow, for the MCP resource specifically. */
18
- const TOKEN_TYPE = 'mcp_access';
24
+ export const ACCESS_TOKEN_TYPE = 'mcp_access';
19
25
 
20
26
  /** Short enough that a leaked token is a small window, long enough to be usable. */
21
27
  const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
@@ -27,11 +33,25 @@ export interface AccessTokenClaims {
27
33
  aud: string;
28
34
  iss: string;
29
35
  scope: string;
30
- /** the tenant the token is pinned to; null means the user's personal data */
31
- groupId: string | null;
36
+ /** every tenant this token may act in */
37
+ tenants: Tenant[];
38
+ /** the one it acts in when a call names none */
39
+ defaultTenant: Tenant;
40
+ /**
41
+ * What each tenant is called, for a client that has to offer the choice.
42
+ *
43
+ * Carried on the token because the only place these names are known is the
44
+ * consent screen that rendered them, and the MCP server — which has to name
45
+ * the choice to a model — cannot reach group-service. A snapshot: a group
46
+ * renamed after the connection was made shows its old name until reconnect,
47
+ * which is the same trade the owner's name on an API key already makes.
48
+ */
49
+ tenantNames: Record<Tenant, string>;
50
+ /** the single tenant an older token was pinned to; read by `readTenants` */
51
+ groupId?: string | null;
32
52
  email: string;
33
53
  name: string;
34
- typ: typeof TOKEN_TYPE;
54
+ typ: typeof ACCESS_TOKEN_TYPE;
35
55
  jti: string;
36
56
  exp: number;
37
57
  iat: number;
@@ -45,7 +65,9 @@ export interface MintAccessTokenInput {
45
65
  resource: string;
46
66
  issuer: string;
47
67
  scopes: ApiKeyScope[];
48
- groupId: string | null;
68
+ tenants: Tenant[];
69
+ defaultTenant: Tenant;
70
+ tenantNames: Record<Tenant, string>;
49
71
  }
50
72
 
51
73
  export interface MintedAccessToken {
@@ -63,10 +85,12 @@ export const mintAccessToken = (input: MintAccessTokenInput): MintedAccessToken
63
85
  aud: input.resource,
64
86
  iss: input.issuer,
65
87
  scope,
66
- groupId: input.groupId,
88
+ tenants: input.tenants,
89
+ defaultTenant: input.defaultTenant,
90
+ tenantNames: input.tenantNames,
67
91
  email: input.email,
68
92
  name: input.name,
69
- typ: TOKEN_TYPE,
93
+ typ: ACCESS_TOKEN_TYPE,
70
94
  jti: randomUUID()
71
95
  },
72
96
  requireEnv('JWT_SECRET'),
@@ -83,7 +107,8 @@ export interface AccessTokenVerification {
83
107
  email?: string;
84
108
  name?: string;
85
109
  scopes?: ApiKeyScope[];
86
- groupId?: string | null;
110
+ tenants?: ApiKeyTenant;
111
+ tenantNames?: Record<Tenant, string>;
87
112
  }
88
113
 
89
114
  /**
@@ -108,7 +133,7 @@ export const verifyAccessToken = (
108
133
  return { ok: false, rejection: expired ? 'expired' : 'bad-signature' };
109
134
  }
110
135
 
111
- if (claims.typ !== TOKEN_TYPE) return { ok: false, rejection: 'wrong-type' };
136
+ if (!isAccessTokenClaims(claims)) return { ok: false, rejection: 'wrong-type' };
112
137
  if (claims.aud !== expectedAudience) return { ok: false, rejection: 'wrong-audience' };
113
138
  if (!claims.sub) return { ok: false, rejection: 'malformed' };
114
139
 
@@ -118,10 +143,25 @@ export const verifyAccessToken = (
118
143
  email: claims.email ?? '',
119
144
  name: claims.name ?? '',
120
145
  scopes: (claims.scope ?? '').split(' ').filter(isApiKeyScope),
121
- groupId: claims.groupId ?? null
146
+ tenants: readTenants(claims),
147
+ tenantNames: claims.tenantNames ?? {}
122
148
  };
123
149
  };
124
150
 
151
+ /**
152
+ * Tell an OAuth access token apart from an ordinary session JWT.
153
+ *
154
+ * They are signed with the same secret and arrive in the same header, so
155
+ * anything holding one has to ask which it got. Getting this wrong is not a
156
+ * subtle failure: read as a session, an access token has no `id` and no
157
+ * `groups`, so `req.user.id` lands as `undefined` and every scoped query goes
158
+ * out unbounded.
159
+ */
160
+ export const isAccessTokenClaims = (claims: unknown): claims is AccessTokenClaims =>
161
+ typeof claims === 'object' &&
162
+ claims !== null &&
163
+ (claims as AccessTokenClaims).typ === ACCESS_TOKEN_TYPE;
164
+
125
165
  /** Parse a space-separated `scope` parameter, dropping anything we do not define. */
126
166
  export const parseScopes = (scope: unknown): ApiKeyScope[] =>
127
167
  typeof scope === 'string' ? scope.split(/\s+/).filter(isApiKeyScope) : [];