@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.
- package/dist/apiKeys/ApiKey.d.ts +12 -3
- package/dist/apiKeys/ApiKey.d.ts.map +1 -1
- package/dist/apiKeys/ApiKey.js +5 -1
- package/dist/apiKeys/ApiKey.js.map +1 -1
- package/dist/apiKeys/index.d.ts +2 -2
- package/dist/apiKeys/index.d.ts.map +1 -1
- package/dist/apiKeys/index.js +5 -1
- package/dist/apiKeys/index.js.map +1 -1
- package/dist/apiKeys/middleware.d.ts +9 -5
- package/dist/apiKeys/middleware.d.ts.map +1 -1
- package/dist/apiKeys/middleware.js +125 -59
- package/dist/apiKeys/middleware.js.map +1 -1
- package/dist/apiKeys/service.d.ts +5 -3
- package/dist/apiKeys/service.d.ts.map +1 -1
- package/dist/apiKeys/service.js +10 -3
- package/dist/apiKeys/service.js.map +1 -1
- package/dist/apiKeys/types.d.ts +41 -8
- package/dist/apiKeys/types.d.ts.map +1 -1
- package/dist/apiKeys/types.js +35 -1
- package/dist/apiKeys/types.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/models.d.ts +13 -2
- package/dist/oauth/models.d.ts.map +1 -1
- package/dist/oauth/models.js +9 -0
- package/dist/oauth/models.js.map +1 -1
- package/dist/oauth/service.d.ts +15 -8
- package/dist/oauth/service.d.ts.map +1 -1
- package/dist/oauth/service.js +10 -3
- package/dist/oauth/service.js.map +1 -1
- package/dist/oauth/tokens.d.ts +34 -8
- package/dist/oauth/tokens.d.ts.map +1 -1
- package/dist/oauth/tokens.js +22 -6
- package/dist/oauth/tokens.js.map +1 -1
- package/package.json +1 -1
- package/src/apiKeys/ApiKey.ts +16 -4
- package/src/apiKeys/index.ts +16 -2
- package/src/apiKeys/middleware.test.ts +207 -16
- package/src/apiKeys/middleware.ts +159 -66
- package/src/apiKeys/service.test.ts +56 -9
- package/src/apiKeys/service.ts +24 -6
- package/src/apiKeys/types.ts +61 -8
- package/src/middleware/authMiddleware.test.ts +52 -0
- package/src/middleware/authMiddleware.ts +26 -4
- package/src/oauth/index.ts +7 -1
- package/src/oauth/models.ts +21 -2
- package/src/oauth/service.test.ts +58 -6
- package/src/oauth/service.ts +25 -7
- package/src/oauth/tokens.test.ts +20 -5
- 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
|
|
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';
|
package/src/oauth/models.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/oauth/service.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
|
|
2
2
|
import { AuthorizationCode, IAuthorizationCode, OAuthClient, RefreshToken } from './models';
|
|
3
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/src/oauth/tokens.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
42
|
-
const claims = jwt.decode(
|
|
43
|
-
|
|
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
|
-
|
|
77
|
+
tenants: { allowed: [PERSONAL_TENANT], default: PERSONAL_TENANT }
|
|
63
78
|
});
|
|
64
79
|
});
|
|
65
80
|
|
package/src/oauth/tokens.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
/**
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
88
|
+
tenants: input.tenants,
|
|
89
|
+
defaultTenant: input.defaultTenant,
|
|
90
|
+
tenantNames: input.tenantNames,
|
|
67
91
|
email: input.email,
|
|
68
92
|
name: input.name,
|
|
69
|
-
typ:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) : [];
|