@tumbaland/backend-core 1.44.0 → 1.46.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/fileService/client.d.ts +54 -0
- package/dist/fileService/client.d.ts.map +1 -0
- package/dist/fileService/client.js +137 -0
- package/dist/fileService/client.js.map +1 -0
- package/dist/fileService/index.d.ts +4 -0
- package/dist/fileService/index.d.ts.map +1 -0
- package/dist/fileService/index.js +40 -0
- package/dist/fileService/index.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/corsMiddleware.d.ts.map +1 -1
- package/dist/middleware/corsMiddleware.js +4 -1
- package/dist/middleware/corsMiddleware.js.map +1 -1
- package/dist/middleware/errorHandler.d.ts.map +1 -1
- package/dist/middleware/errorHandler.js +5 -8
- package/dist/middleware/errorHandler.js.map +1 -1
- package/dist/utils/escapeRegex.d.ts +8 -0
- package/dist/utils/escapeRegex.d.ts.map +1 -0
- package/dist/utils/escapeRegex.js +12 -0
- package/dist/utils/escapeRegex.js.map +1 -0
- package/package.json +5 -1
- package/.versionrc.json +0 -7
- package/__mocks__/uuid.js +0 -8
- package/jest.config.js +0 -24
- package/src/apiKeys/ApiKey.test.ts +0 -142
- package/src/apiKeys/ApiKey.ts +0 -102
- package/src/apiKeys/crypto.test.ts +0 -161
- package/src/apiKeys/crypto.ts +0 -163
- package/src/apiKeys/index.test.ts +0 -72
- package/src/apiKeys/index.ts +0 -35
- package/src/apiKeys/middleware.test.ts +0 -651
- package/src/apiKeys/middleware.ts +0 -341
- package/src/apiKeys/service.test.ts +0 -401
- package/src/apiKeys/service.ts +0 -168
- package/src/apiKeys/types.test.ts +0 -41
- package/src/apiKeys/types.ts +0 -124
- package/src/app/createBaseApp.test.ts +0 -109
- package/src/app/createBaseApp.ts +0 -102
- package/src/app/shutdown.test.ts +0 -129
- package/src/app/shutdown.ts +0 -81
- package/src/audit/AuditEvent.ts +0 -123
- package/src/audit/actor.test.ts +0 -95
- package/src/audit/actor.ts +0 -68
- package/src/audit/context.test.ts +0 -91
- package/src/audit/context.ts +0 -83
- package/src/audit/index.ts +0 -11
- package/src/audit/plugin.test.ts +0 -258
- package/src/audit/plugin.ts +0 -254
- package/src/audit/reads.test.ts +0 -164
- package/src/audit/reads.ts +0 -88
- package/src/audit/service.test.ts +0 -115
- package/src/audit/service.ts +0 -95
- package/src/auth/session.test.ts +0 -89
- package/src/auth/session.ts +0 -75
- package/src/config/env.test.ts +0 -28
- package/src/config/env.ts +0 -13
- package/src/database/connection.test.ts +0 -188
- package/src/database/connection.ts +0 -90
- package/src/entitlements/UsageMeter.ts +0 -49
- package/src/entitlements/client.test.ts +0 -200
- package/src/entitlements/client.ts +0 -179
- package/src/entitlements/definitions.test.ts +0 -161
- package/src/entitlements/definitions.ts +0 -268
- package/src/entitlements/index.ts +0 -42
- package/src/entitlements/middleware.test.ts +0 -196
- package/src/entitlements/middleware.ts +0 -150
- package/src/entitlements/reconcile.test.ts +0 -333
- package/src/entitlements/reconcile.ts +0 -384
- package/src/entitlements/types.ts +0 -21
- package/src/entitlements/usage.test.ts +0 -314
- package/src/entitlements/usage.ts +0 -223
- package/src/errors/HttpError.test.ts +0 -76
- package/src/errors/HttpError.ts +0 -91
- package/src/groups/client.test.ts +0 -215
- package/src/groups/client.ts +0 -182
- package/src/groups/index.ts +0 -7
- package/src/groups/membership.test.ts +0 -84
- package/src/groups/membership.ts +0 -133
- package/src/groups/subject.test.ts +0 -85
- package/src/groups/subject.ts +0 -50
- package/src/health/createHealthCheck.test.ts +0 -89
- package/src/health/createHealthCheck.ts +0 -67
- package/src/health/healthController.test.ts +0 -113
- package/src/health/healthController.ts +0 -56
- package/src/index.ts +0 -88
- package/src/logging/logger.test.ts +0 -91
- package/src/logging/logger.ts +0 -103
- package/src/metrics/index.test.ts +0 -116
- package/src/metrics/index.ts +0 -111
- package/src/middleware/authMiddleware.test.ts +0 -275
- package/src/middleware/authMiddleware.ts +0 -91
- package/src/middleware/corsMiddleware.test.ts +0 -135
- package/src/middleware/corsMiddleware.ts +0 -65
- package/src/middleware/errorHandler.test.ts +0 -188
- package/src/middleware/errorHandler.ts +0 -103
- package/src/middleware/internalServiceAuth.test.ts +0 -173
- package/src/middleware/internalServiceAuth.ts +0 -96
- package/src/middleware/requestLogger.test.ts +0 -81
- package/src/middleware/requestLogger.ts +0 -48
- package/src/middleware/security.test.ts +0 -45
- package/src/middleware/security.ts +0 -43
- package/src/middleware/validate.test.ts +0 -72
- package/src/middleware/validate.ts +0 -23
- package/src/oauth/index.ts +0 -29
- package/src/oauth/models.ts +0 -164
- package/src/oauth/service.test.ts +0 -432
- package/src/oauth/service.ts +0 -299
- package/src/oauth/tokens.test.ts +0 -146
- package/src/oauth/tokens.ts +0 -186
- package/src/testing/serviceTestSetup.ts +0 -17
- package/src/tracing/index.test.ts +0 -272
- package/src/tracing/index.ts +0 -110
- package/src/types/auth.ts +0 -45
- package/src/utils/correlation.test.ts +0 -47
- package/src/utils/correlation.ts +0 -22
- package/src/utils/permissionUtils.test.ts +0 -47
- package/src/utils/permissionUtils.ts +0 -68
- package/src/utils/response.test.ts +0 -64
- package/src/utils/response.ts +0 -60
- package/tsconfig.build.json +0 -7
- package/tsconfig.json +0 -23
package/src/oauth/service.ts
DELETED
|
@@ -1,299 +0,0 @@
|
|
|
1
|
-
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
|
|
2
|
-
import { AuthorizationCode, IAuthorizationCode, OAuthClient, RefreshToken } from './models';
|
|
3
|
-
import {
|
|
4
|
-
readTenants,
|
|
5
|
-
type ApiKeyScope,
|
|
6
|
-
type ApiKeyTenant,
|
|
7
|
-
type Tenant
|
|
8
|
-
} from '../apiKeys/types';
|
|
9
|
-
|
|
10
|
-
/** How long a user has between approving and the client exchanging the code. */
|
|
11
|
-
const CODE_TTL_MS = 60 * 1000;
|
|
12
|
-
|
|
13
|
-
const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex');
|
|
14
|
-
|
|
15
|
-
/** base64url of the SHA-256 digest, which is what PKCE `S256` specifies. */
|
|
16
|
-
const s256 = (verifier: string): string =>
|
|
17
|
-
createHash('sha256').update(verifier).digest('base64url');
|
|
18
|
-
|
|
19
|
-
const constantTimeEquals = (a: string, b: string): boolean => {
|
|
20
|
-
const left = Buffer.from(a);
|
|
21
|
-
const right = Buffer.from(b);
|
|
22
|
-
if (left.length !== right.length) return false;
|
|
23
|
-
return timingSafeEqual(left, right);
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export interface RegisterClientInput {
|
|
27
|
-
clientName: string;
|
|
28
|
-
redirectUris: string[];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface RegisteredClient {
|
|
32
|
-
clientId: string;
|
|
33
|
-
clientName: string;
|
|
34
|
-
redirectUris: string[];
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Register a client, or hand back the one that is already registered.
|
|
39
|
-
*
|
|
40
|
-
* Clients re-register freely — the Claude app does it on every connect attempt
|
|
41
|
-
* — and minting a fresh id each time left a user staring at three identical
|
|
42
|
-
* "Claude" entries in their connected apps, only one of which any given
|
|
43
|
-
* disconnect would cut off.
|
|
44
|
-
*
|
|
45
|
-
* Matching on the exact redirect URIs is what makes this safe. These are public
|
|
46
|
-
* clients with no secret, so a `client_id` is an identifier rather than a
|
|
47
|
-
* credential; anything presenting the same callback URL *is* the same
|
|
48
|
-
* application, because that URL is what an authorization code gets delivered
|
|
49
|
-
* to. A different app cannot claim it without already controlling it.
|
|
50
|
-
*/
|
|
51
|
-
export const registerClient = async (input: RegisterClientInput): Promise<RegisteredClient> => {
|
|
52
|
-
const redirectUris = [...input.redirectUris].sort();
|
|
53
|
-
|
|
54
|
-
const existing = await OAuthClient.findOne({
|
|
55
|
-
redirectUris: { $size: redirectUris.length, $all: redirectUris }
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
const client =
|
|
59
|
-
existing ??
|
|
60
|
-
(await OAuthClient.create({
|
|
61
|
-
clientId: `tmb-client-${randomBytes(16).toString('hex')}`,
|
|
62
|
-
clientName: input.clientName.slice(0, 200),
|
|
63
|
-
redirectUris
|
|
64
|
-
}));
|
|
65
|
-
|
|
66
|
-
return {
|
|
67
|
-
clientId: client.clientId,
|
|
68
|
-
clientName: client.clientName,
|
|
69
|
-
redirectUris: client.redirectUris
|
|
70
|
-
};
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
export const findClient = (clientId: string) => OAuthClient.findOne({ clientId });
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Whether a client may be sent back to this URI.
|
|
77
|
-
*
|
|
78
|
-
* Exact string match, deliberately. Prefix or wildcard matching on redirect URIs
|
|
79
|
-
* is the classic way an authorization code ends up delivered to somewhere the
|
|
80
|
-
* client never controlled.
|
|
81
|
-
*/
|
|
82
|
-
export const isRegisteredRedirect = (client: { redirectUris: string[] }, uri: string): boolean =>
|
|
83
|
-
client.redirectUris.includes(uri);
|
|
84
|
-
|
|
85
|
-
export interface IssueCodeInput {
|
|
86
|
-
clientId: string;
|
|
87
|
-
userId: string;
|
|
88
|
-
userEmail: string;
|
|
89
|
-
userName: string;
|
|
90
|
-
redirectUri: string;
|
|
91
|
-
scopes: ApiKeyScope[];
|
|
92
|
-
tenants: Tenant[];
|
|
93
|
-
defaultTenant: Tenant;
|
|
94
|
-
tenantNames: Record<Tenant, string>;
|
|
95
|
-
resource: string;
|
|
96
|
-
codeChallenge: string;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export const issueAuthorizationCode = async (input: IssueCodeInput): Promise<string> => {
|
|
100
|
-
const code = randomBytes(32).toString('base64url');
|
|
101
|
-
|
|
102
|
-
await AuthorizationCode.create({
|
|
103
|
-
...input,
|
|
104
|
-
code,
|
|
105
|
-
expiresAt: new Date(Date.now() + CODE_TTL_MS)
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
return code;
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
export type CodeRejection =
|
|
112
|
-
| 'unknown'
|
|
113
|
-
| 'expired'
|
|
114
|
-
| 'already-used'
|
|
115
|
-
| 'client-mismatch'
|
|
116
|
-
| 'redirect-mismatch'
|
|
117
|
-
| 'pkce-failed';
|
|
118
|
-
|
|
119
|
-
export interface CodeRedemption {
|
|
120
|
-
ok: boolean;
|
|
121
|
-
rejection?: CodeRejection;
|
|
122
|
-
code?: IAuthorizationCode;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Exchange a code, once.
|
|
127
|
-
*
|
|
128
|
-
* The `usedAt` stamp is set by the same atomic update that fetches the code, so
|
|
129
|
-
* two simultaneous exchanges cannot both succeed — a replayed code loses the
|
|
130
|
-
* race rather than being caught by a check that ran a moment earlier.
|
|
131
|
-
*/
|
|
132
|
-
export const redeemAuthorizationCode = async (
|
|
133
|
-
code: string,
|
|
134
|
-
clientId: string,
|
|
135
|
-
redirectUri: string,
|
|
136
|
-
codeVerifier: string
|
|
137
|
-
): Promise<CodeRedemption> => {
|
|
138
|
-
const record = await AuthorizationCode.findOneAndUpdate(
|
|
139
|
-
{ code, usedAt: { $exists: false } },
|
|
140
|
-
{ $set: { usedAt: new Date() } },
|
|
141
|
-
{ new: true }
|
|
142
|
-
);
|
|
143
|
-
|
|
144
|
-
if (!record) {
|
|
145
|
-
// Either it never existed or it has already been redeemed; the client is
|
|
146
|
-
// told the same thing for both.
|
|
147
|
-
const existed = await AuthorizationCode.exists({ code });
|
|
148
|
-
return { ok: false, rejection: existed ? 'already-used' : 'unknown' };
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (record.expiresAt.getTime() <= Date.now()) return { ok: false, rejection: 'expired' };
|
|
152
|
-
if (record.clientId !== clientId) return { ok: false, rejection: 'client-mismatch' };
|
|
153
|
-
if (record.redirectUri !== redirectUri) return { ok: false, rejection: 'redirect-mismatch' };
|
|
154
|
-
if (!constantTimeEquals(s256(codeVerifier), record.codeChallenge)) {
|
|
155
|
-
return { ok: false, rejection: 'pkce-failed' };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return { ok: true, code: record };
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
export interface IssuedRefreshToken {
|
|
162
|
-
token: string;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Start a grant, replacing any the same client already held for this user.
|
|
167
|
-
*
|
|
168
|
-
* Reconnecting is a re-authorization, not a second connection: without this,
|
|
169
|
-
* approving twice leaves two live grants, the newer scopes sitting beside the
|
|
170
|
-
* older ones, and a disconnect that revokes only one of them.
|
|
171
|
-
*/
|
|
172
|
-
export const issueRefreshToken = async (input: {
|
|
173
|
-
clientId: string;
|
|
174
|
-
userId: string;
|
|
175
|
-
scopes: ApiKeyScope[];
|
|
176
|
-
tenants: Tenant[];
|
|
177
|
-
defaultTenant: Tenant;
|
|
178
|
-
tenantNames: Record<Tenant, string>;
|
|
179
|
-
resource: string;
|
|
180
|
-
}): Promise<IssuedRefreshToken> => {
|
|
181
|
-
await RefreshToken.updateMany(
|
|
182
|
-
{ userId: input.userId, clientId: input.clientId, revokedAt: { $exists: false } },
|
|
183
|
-
{ $set: { revokedAt: new Date() } }
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
const token = randomBytes(32).toString('base64url');
|
|
187
|
-
await RefreshToken.create({ ...input, tokenHash: sha256(token) });
|
|
188
|
-
return { token };
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
export interface RefreshRedemption {
|
|
192
|
-
ok: boolean;
|
|
193
|
-
/** true when a retired token was presented again — treated as a compromise */
|
|
194
|
-
reused?: boolean;
|
|
195
|
-
userId?: string;
|
|
196
|
-
scopes?: ApiKeyScope[];
|
|
197
|
-
tenants?: ApiKeyTenant;
|
|
198
|
-
tenantNames?: Record<Tenant, string>;
|
|
199
|
-
resource?: string;
|
|
200
|
-
/** the replacement the client must store; the presented one is now dead */
|
|
201
|
-
rotatedToken?: string;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Exchange a refresh token for a new one, rotating as we go.
|
|
206
|
-
*
|
|
207
|
-
* Rotation matters because these are the long-lived half: an access token is
|
|
208
|
-
* gone within the hour, but a refresh token that never changes is a permanent
|
|
209
|
-
* credential for whoever obtains a copy. Each use retires the old token and
|
|
210
|
-
* issues a fresh one, so a stolen copy stops working as soon as the legitimate
|
|
211
|
-
* client refreshes.
|
|
212
|
-
*
|
|
213
|
-
* Reuse of an already-rotated token is treated as theft rather than as an
|
|
214
|
-
* ordinary failure: the honest client and the thief now both hold tokens
|
|
215
|
-
* descended from the same grant, and there is no way to tell which just called.
|
|
216
|
-
* So the whole chain is revoked and the user reconnects — noisy, but the
|
|
217
|
-
* alternative is leaving an attacker with working access.
|
|
218
|
-
*/
|
|
219
|
-
export const redeemRefreshToken = async (
|
|
220
|
-
token: string,
|
|
221
|
-
clientId: string
|
|
222
|
-
): Promise<RefreshRedemption> => {
|
|
223
|
-
const record = await RefreshToken.findOne({ tokenHash: sha256(token), clientId });
|
|
224
|
-
if (!record) return { ok: false };
|
|
225
|
-
|
|
226
|
-
if (record.revokedAt) {
|
|
227
|
-
// Already rotated or explicitly revoked. If something is still presenting
|
|
228
|
-
// it, a copy is loose — cut every token in this grant.
|
|
229
|
-
await RefreshToken.updateMany(
|
|
230
|
-
{ userId: record.userId, clientId, revokedAt: { $exists: false } },
|
|
231
|
-
{ $set: { revokedAt: new Date() } }
|
|
232
|
-
);
|
|
233
|
-
return { ok: false, reused: true };
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
record.revokedAt = new Date();
|
|
237
|
-
record.lastUsedAt = new Date();
|
|
238
|
-
await record.save();
|
|
239
|
-
|
|
240
|
-
const rotated = randomBytes(32).toString('base64url');
|
|
241
|
-
await RefreshToken.create({
|
|
242
|
-
tokenHash: sha256(rotated),
|
|
243
|
-
clientId,
|
|
244
|
-
userId: record.userId,
|
|
245
|
-
scopes: record.scopes,
|
|
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,
|
|
251
|
-
resource: record.resource,
|
|
252
|
-
// Carried, not reset: this is still the grant the user approved.
|
|
253
|
-
grantedAt: record.grantedAt ?? record.createdAt
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
return {
|
|
257
|
-
ok: true,
|
|
258
|
-
userId: record.userId,
|
|
259
|
-
scopes: record.scopes as ApiKeyScope[],
|
|
260
|
-
tenants: readTenants(record),
|
|
261
|
-
tenantNames: record.tenantNames ?? {},
|
|
262
|
-
resource: record.resource,
|
|
263
|
-
rotatedToken: rotated
|
|
264
|
-
};
|
|
265
|
-
};
|
|
266
|
-
|
|
267
|
-
/** Cut off an assistant: without a refresh token it can obtain nothing new. */
|
|
268
|
-
export const revokeRefreshTokensForUser = async (
|
|
269
|
-
userId: string,
|
|
270
|
-
clientId?: string
|
|
271
|
-
): Promise<number> => {
|
|
272
|
-
const filter: Record<string, unknown> = { userId, revokedAt: { $exists: false } };
|
|
273
|
-
if (clientId) filter.clientId = clientId;
|
|
274
|
-
|
|
275
|
-
const result = await RefreshToken.updateMany(filter, { $set: { revokedAt: new Date() } });
|
|
276
|
-
return result.modifiedCount;
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
export const listConnections = async (userId: string) => {
|
|
280
|
-
const tokens = await RefreshToken.find({ userId }).sort({ createdAt: -1 });
|
|
281
|
-
const clients = await OAuthClient.find({ clientId: { $in: tokens.map((t) => t.clientId) } });
|
|
282
|
-
const nameById = new Map(clients.map((client) => [client.clientId, client.clientName]));
|
|
283
|
-
|
|
284
|
-
return tokens.map((token) => ({
|
|
285
|
-
id: String(token._id),
|
|
286
|
-
clientId: token.clientId,
|
|
287
|
-
clientName: nameById.get(token.clientId) ?? 'Unknown app',
|
|
288
|
-
scopes: token.scopes,
|
|
289
|
-
...(({ allowed, default: fallback }) => ({ tenants: allowed, defaultTenant: fallback }))(
|
|
290
|
-
readTenants(token)
|
|
291
|
-
),
|
|
292
|
-
tenantNames: token.tenantNames ?? {},
|
|
293
|
-
createdAt: (token.grantedAt ?? token.createdAt).toISOString(),
|
|
294
|
-
// Set when the refresh token is exchanged, not when a tool runs — access
|
|
295
|
-
// tokens are validated statelessly, so the server never sees ordinary use.
|
|
296
|
-
lastRenewedAt: token.lastUsedAt?.toISOString() ?? null,
|
|
297
|
-
revokedAt: token.revokedAt?.toISOString() ?? null
|
|
298
|
-
}));
|
|
299
|
-
};
|
package/src/oauth/tokens.test.ts
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
const ORIGINAL_ENV = process.env;
|
|
2
|
-
|
|
3
|
-
import jwt from 'jsonwebtoken';
|
|
4
|
-
import { PERSONAL_TENANT } from '../apiKeys/types';
|
|
5
|
-
import { mintAccessToken, verifyAccessToken, parseScopes } from './tokens';
|
|
6
|
-
|
|
7
|
-
const RESOURCE = 'https://mcp.tumbaland.eu';
|
|
8
|
-
const ISSUER = 'https://auth-api.tumbaland.eu';
|
|
9
|
-
|
|
10
|
-
const mint = (over: Partial<Parameters<typeof mintAccessToken>[0]> = {}) =>
|
|
11
|
-
mintAccessToken({
|
|
12
|
-
userId: 'u1',
|
|
13
|
-
email: 'u1@example.com',
|
|
14
|
-
name: 'Tester',
|
|
15
|
-
resource: RESOURCE,
|
|
16
|
-
issuer: ISSUER,
|
|
17
|
-
scopes: ['relationship:read', 'relationship:write'],
|
|
18
|
-
tenants: [PERSONAL_TENANT],
|
|
19
|
-
defaultTenant: PERSONAL_TENANT,
|
|
20
|
-
tenantNames: { [PERSONAL_TENANT]: 'My own data' },
|
|
21
|
-
clientId: 'client-1',
|
|
22
|
-
clientName: 'Claude',
|
|
23
|
-
...over
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
beforeEach(() => {
|
|
27
|
-
process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-jwt-secret' };
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
afterEach(() => {
|
|
31
|
-
process.env = ORIGINAL_ENV;
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
describe('mintAccessToken', () => {
|
|
35
|
-
it('binds the token to one resource and carries the granted scopes', () => {
|
|
36
|
-
const { accessToken, scope, expiresIn } = mint();
|
|
37
|
-
const claims = jwt.decode(accessToken) as Record<string, unknown>;
|
|
38
|
-
|
|
39
|
-
expect(claims.aud).toBe(RESOURCE);
|
|
40
|
-
expect(claims.iss).toBe(ISSUER);
|
|
41
|
-
expect(claims.sub).toBe('u1');
|
|
42
|
-
expect(scope).toBe('relationship:read relationship:write');
|
|
43
|
-
expect(expiresIn).toBe(3600);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('carries the granted tenants, so a token cannot wander outside them', () => {
|
|
47
|
-
const claims = jwt.decode(
|
|
48
|
-
mint({ tenants: [PERSONAL_TENANT, 'g1'], defaultTenant: 'g1' }).accessToken
|
|
49
|
-
) as Record<string, unknown>;
|
|
50
|
-
|
|
51
|
-
expect(claims.tenants).toEqual([PERSONAL_TENANT, 'g1']);
|
|
52
|
-
expect(claims.defaultTenant).toBe('g1');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('carries what each tenant is called, since nothing downstream can look it up', () => {
|
|
56
|
-
const claims = jwt.decode(
|
|
57
|
-
mint({ tenants: ['g1'], defaultTenant: 'g1', tenantNames: { g1: 'Irja & Tom' } }).accessToken
|
|
58
|
-
) as Record<string, unknown>;
|
|
59
|
-
|
|
60
|
-
expect(claims.tenantNames).toEqual({ g1: 'Irja & Tom' });
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('gives every token a distinct id', () => {
|
|
64
|
-
const a = jwt.decode(mint().accessToken) as Record<string, unknown>;
|
|
65
|
-
const b = jwt.decode(mint().accessToken) as Record<string, unknown>;
|
|
66
|
-
expect(a.jti).not.toBe(b.jti);
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
describe('verifyAccessToken', () => {
|
|
71
|
-
it('accepts a token minted for this resource', () => {
|
|
72
|
-
const { accessToken } = mint();
|
|
73
|
-
|
|
74
|
-
expect(verifyAccessToken(accessToken, RESOURCE)).toMatchObject({
|
|
75
|
-
ok: true,
|
|
76
|
-
userId: 'u1',
|
|
77
|
-
email: 'u1@example.com',
|
|
78
|
-
scopes: ['relationship:read', 'relationship:write'],
|
|
79
|
-
tenants: { allowed: [PERSONAL_TENANT], default: PERSONAL_TENANT }
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('refuses a token minted for a different resource', () => {
|
|
84
|
-
// RFC 8707 audience binding: a token obtained for somewhere else must not
|
|
85
|
-
// be replayable here, which is the whole point of the resource parameter.
|
|
86
|
-
const { accessToken } = mint({ resource: 'https://mcp.someone-else.example' });
|
|
87
|
-
|
|
88
|
-
expect(verifyAccessToken(accessToken, RESOURCE)).toEqual({
|
|
89
|
-
ok: false,
|
|
90
|
-
rejection: 'wrong-audience'
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('refuses an ordinary session JWT presented as an access token', () => {
|
|
95
|
-
// Same secret, same issuer — but no scopes and no tenant pin. Without the
|
|
96
|
-
// type check a user's session cookie would authenticate as an assistant
|
|
97
|
-
// holding every permission.
|
|
98
|
-
const session = jwt.sign(
|
|
99
|
-
{ id: 'u1', email: 'u1@example.com', name: 'Tester', groups: ['g1'] },
|
|
100
|
-
'test-jwt-secret'
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
expect(verifyAccessToken(session, RESOURCE)).toEqual({ ok: false, rejection: 'wrong-type' });
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('refuses a token signed with a different secret', () => {
|
|
107
|
-
const forged = jwt.sign({ sub: 'u1', aud: RESOURCE, typ: 'mcp_access' }, 'not-the-secret');
|
|
108
|
-
expect(verifyAccessToken(forged, RESOURCE)).toEqual({ ok: false, rejection: 'bad-signature' });
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('reports an expired token distinctly, so the client knows to refresh', () => {
|
|
112
|
-
const expired = jwt.sign(
|
|
113
|
-
{ sub: 'u1', aud: RESOURCE, typ: 'mcp_access', scope: '' },
|
|
114
|
-
'test-jwt-secret',
|
|
115
|
-
{ expiresIn: -10 }
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
expect(verifyAccessToken(expired, RESOURCE)).toEqual({ ok: false, rejection: 'expired' });
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('refuses gibberish rather than throwing', () => {
|
|
122
|
-
expect(verifyAccessToken('not-a-token', RESOURCE).ok).toBe(false);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it('drops scopes it does not define, so a forged claim grants nothing', () => {
|
|
126
|
-
const token = jwt.sign(
|
|
127
|
-
{ sub: 'u1', aud: RESOURCE, typ: 'mcp_access', scope: 'relationship:read admin:everything' },
|
|
128
|
-
'test-jwt-secret'
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
expect(verifyAccessToken(token, RESOURCE).scopes).toEqual(['relationship:read']);
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
describe('parseScopes', () => {
|
|
136
|
-
it('keeps only scopes the system defines', () => {
|
|
137
|
-
expect(parseScopes('relationship:read admin:all finance:write')).toEqual([
|
|
138
|
-
'relationship:read',
|
|
139
|
-
'finance:write'
|
|
140
|
-
]);
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it.each([[undefined], [null], [42], ['']])('returns nothing for %s', (value) => {
|
|
144
|
-
expect(parseScopes(value)).toEqual([]);
|
|
145
|
-
});
|
|
146
|
-
});
|
package/src/oauth/tokens.ts
DELETED
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'crypto';
|
|
2
|
-
import jwt from 'jsonwebtoken';
|
|
3
|
-
import { requireEnv } from '../config/env';
|
|
4
|
-
import {
|
|
5
|
-
isApiKeyScope,
|
|
6
|
-
readTenants,
|
|
7
|
-
type ApiKeyScope,
|
|
8
|
-
type ApiKeyTenant,
|
|
9
|
-
type Tenant
|
|
10
|
-
} from '../apiKeys/types';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Access tokens for the OAuth flow that lets an assistant connect to Tumbaland.
|
|
14
|
-
*
|
|
15
|
-
* These are JWTs rather than database rows so the MCP server can validate one
|
|
16
|
-
* without a query on every tool call, and because OAuth expects short-lived
|
|
17
|
-
* bearer tokens with a refresh path rather than the indefinite credentials an
|
|
18
|
-
* API key is. The trade is that a token cannot be revoked before it expires —
|
|
19
|
-
* hence the deliberately short life, with revocation applied at the refresh
|
|
20
|
-
* token, which is the thing that actually persists.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
/** Marks a token as issued by the OAuth flow, for the MCP resource specifically. */
|
|
24
|
-
export const ACCESS_TOKEN_TYPE = 'mcp_access';
|
|
25
|
-
|
|
26
|
-
/** Short enough that a leaked token is a small window, long enough to be usable. */
|
|
27
|
-
const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
|
|
28
|
-
|
|
29
|
-
export interface AccessTokenClaims {
|
|
30
|
-
/** the user the assistant is acting for */
|
|
31
|
-
sub: string;
|
|
32
|
-
/** the MCP server this token may be used against — RFC 8707 audience binding */
|
|
33
|
-
aud: string;
|
|
34
|
-
iss: string;
|
|
35
|
-
scope: string;
|
|
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;
|
|
52
|
-
/**
|
|
53
|
-
* Which app this token was issued to.
|
|
54
|
-
*
|
|
55
|
-
* `jti` identifies the token and rotates every hour, so it cannot stand in for
|
|
56
|
-
* the connection — an audit trail built on it would show a different actor
|
|
57
|
-
* each time the assistant refreshed. The client id is stable for the life of
|
|
58
|
-
* the grant, and the name is snapshotted alongside it for the same reason the
|
|
59
|
-
* tenant names are: nothing downstream can look it up.
|
|
60
|
-
*/
|
|
61
|
-
clientId: string;
|
|
62
|
-
clientName: string;
|
|
63
|
-
email: string;
|
|
64
|
-
name: string;
|
|
65
|
-
typ: typeof ACCESS_TOKEN_TYPE;
|
|
66
|
-
jti: string;
|
|
67
|
-
exp: number;
|
|
68
|
-
iat: number;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export interface MintAccessTokenInput {
|
|
72
|
-
userId: string;
|
|
73
|
-
email: string;
|
|
74
|
-
name: string;
|
|
75
|
-
/** the canonical URI of the MCP server the token is for */
|
|
76
|
-
resource: string;
|
|
77
|
-
issuer: string;
|
|
78
|
-
scopes: ApiKeyScope[];
|
|
79
|
-
tenants: Tenant[];
|
|
80
|
-
defaultTenant: Tenant;
|
|
81
|
-
tenantNames: Record<Tenant, string>;
|
|
82
|
-
clientId: string;
|
|
83
|
-
clientName: string;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export interface MintedAccessToken {
|
|
87
|
-
accessToken: string;
|
|
88
|
-
expiresIn: number;
|
|
89
|
-
scope: string;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export const mintAccessToken = (input: MintAccessTokenInput): MintedAccessToken => {
|
|
93
|
-
const scope = input.scopes.join(' ');
|
|
94
|
-
|
|
95
|
-
const accessToken = jwt.sign(
|
|
96
|
-
{
|
|
97
|
-
sub: input.userId,
|
|
98
|
-
aud: input.resource,
|
|
99
|
-
iss: input.issuer,
|
|
100
|
-
scope,
|
|
101
|
-
tenants: input.tenants,
|
|
102
|
-
defaultTenant: input.defaultTenant,
|
|
103
|
-
tenantNames: input.tenantNames,
|
|
104
|
-
clientId: input.clientId,
|
|
105
|
-
clientName: input.clientName,
|
|
106
|
-
email: input.email,
|
|
107
|
-
name: input.name,
|
|
108
|
-
typ: ACCESS_TOKEN_TYPE,
|
|
109
|
-
jti: randomUUID()
|
|
110
|
-
},
|
|
111
|
-
requireEnv('JWT_SECRET'),
|
|
112
|
-
{ expiresIn: ACCESS_TOKEN_TTL_SECONDS }
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
return { accessToken, expiresIn: ACCESS_TOKEN_TTL_SECONDS, scope };
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
export interface AccessTokenVerification {
|
|
119
|
-
ok: boolean;
|
|
120
|
-
rejection?: 'malformed' | 'expired' | 'wrong-audience' | 'wrong-type' | 'bad-signature';
|
|
121
|
-
userId?: string;
|
|
122
|
-
email?: string;
|
|
123
|
-
name?: string;
|
|
124
|
-
scopes?: ApiKeyScope[];
|
|
125
|
-
tenants?: ApiKeyTenant;
|
|
126
|
-
tenantNames?: Record<Tenant, string>;
|
|
127
|
-
clientId?: string;
|
|
128
|
-
clientName?: string;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Check a bearer token presented to the MCP server.
|
|
133
|
-
*
|
|
134
|
-
* Two checks beyond the signature carry real weight. The audience must match
|
|
135
|
-
* this server: a token minted for one resource must not work against another,
|
|
136
|
-
* which is what RFC 8707 binding is for and what stops a token obtained for
|
|
137
|
-
* somewhere else being replayed here. And the type must be `mcp_access`, so an
|
|
138
|
-
* ordinary session JWT — same secret, same issuer, but no scopes and no tenant
|
|
139
|
-
* pin — cannot be presented as an access token and quietly get everything.
|
|
140
|
-
*/
|
|
141
|
-
export const verifyAccessToken = (
|
|
142
|
-
token: string,
|
|
143
|
-
expectedAudience: string
|
|
144
|
-
): AccessTokenVerification => {
|
|
145
|
-
let claims: AccessTokenClaims;
|
|
146
|
-
try {
|
|
147
|
-
claims = jwt.verify(token, requireEnv('JWT_SECRET')) as AccessTokenClaims;
|
|
148
|
-
} catch (error) {
|
|
149
|
-
const expired = (error as Error)?.name === 'TokenExpiredError';
|
|
150
|
-
return { ok: false, rejection: expired ? 'expired' : 'bad-signature' };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (!isAccessTokenClaims(claims)) return { ok: false, rejection: 'wrong-type' };
|
|
154
|
-
if (claims.aud !== expectedAudience) return { ok: false, rejection: 'wrong-audience' };
|
|
155
|
-
if (!claims.sub) return { ok: false, rejection: 'malformed' };
|
|
156
|
-
|
|
157
|
-
return {
|
|
158
|
-
ok: true,
|
|
159
|
-
userId: claims.sub,
|
|
160
|
-
email: claims.email ?? '',
|
|
161
|
-
name: claims.name ?? '',
|
|
162
|
-
scopes: (claims.scope ?? '').split(' ').filter(isApiKeyScope),
|
|
163
|
-
tenants: readTenants(claims),
|
|
164
|
-
tenantNames: claims.tenantNames ?? {},
|
|
165
|
-
clientId: claims.clientId,
|
|
166
|
-
clientName: claims.clientName
|
|
167
|
-
};
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Tell an OAuth access token apart from an ordinary session JWT.
|
|
172
|
-
*
|
|
173
|
-
* They are signed with the same secret and arrive in the same header, so
|
|
174
|
-
* anything holding one has to ask which it got. Getting this wrong is not a
|
|
175
|
-
* subtle failure: read as a session, an access token has no `id` and no
|
|
176
|
-
* `groups`, so `req.user.id` lands as `undefined` and every scoped query goes
|
|
177
|
-
* out unbounded.
|
|
178
|
-
*/
|
|
179
|
-
export const isAccessTokenClaims = (claims: unknown): claims is AccessTokenClaims =>
|
|
180
|
-
typeof claims === 'object' &&
|
|
181
|
-
claims !== null &&
|
|
182
|
-
(claims as AccessTokenClaims).typ === ACCESS_TOKEN_TYPE;
|
|
183
|
-
|
|
184
|
-
/** Parse a space-separated `scope` parameter, dropping anything we do not define. */
|
|
185
|
-
export const parseScopes = (scope: unknown): ApiKeyScope[] =>
|
|
186
|
-
typeof scope === 'string' ? scope.split(/\s+/).filter(isApiKeyScope) : [];
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { overrideSessionCheckForTests } from '../auth/session';
|
|
2
|
-
import { overrideMembershipLookupForTests } from '../groups/membership';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Jest setup for service test suites: they sign session tokens without a
|
|
6
|
-
* session and run with no database or group-service, so every token's session
|
|
7
|
-
* counts as live and its `groups` claim stands in for group membership.
|
|
8
|
-
* The session check itself is tested in backend-core (auth/session.test.ts).
|
|
9
|
-
*
|
|
10
|
-
* Loaded through the package's test-only entry point:
|
|
11
|
-
* setupFiles: ['@tumbaland/backend-core/testing']
|
|
12
|
-
*/
|
|
13
|
-
overrideSessionCheckForTests(async () => true);
|
|
14
|
-
|
|
15
|
-
// No group-service either: a test token's own `groups` claim is taken as the
|
|
16
|
-
// person's membership. The live lookup is tested in groups/membership.test.ts.
|
|
17
|
-
overrideMembershipLookupForTests(async (user) => user.groups ?? []);
|