@tumbaland/backend-core 1.37.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 +5 -3
- package/dist/apiKeys/middleware.d.ts.map +1 -1
- package/dist/apiKeys/middleware.js +56 -31
- 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/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 +22 -5
- package/dist/oauth/tokens.d.ts.map +1 -1
- package/dist/oauth/tokens.js +5 -2
- 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 +103 -20
- package/src/apiKeys/middleware.ts +68 -35
- 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 +4 -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 +33 -7
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.
|
|
@@ -27,8 +33,22 @@ 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
54
|
typ: typeof ACCESS_TOKEN_TYPE;
|
|
@@ -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,7 +85,9 @@ 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
93
|
typ: ACCESS_TOKEN_TYPE,
|
|
@@ -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
|
/**
|
|
@@ -118,7 +143,8 @@ 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
|
|