@tumbaland/backend-core 1.36.0 → 1.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apiKeys/middleware.d.ts +5 -3
- package/dist/apiKeys/middleware.d.ts.map +1 -1
- package/dist/apiKeys/middleware.js +75 -34
- package/dist/apiKeys/middleware.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/tokens.d.ts +12 -3
- package/dist/oauth/tokens.d.ts.map +1 -1
- package/dist/oauth/tokens.js +17 -4
- package/dist/oauth/tokens.js.map +1 -1
- package/package.json +1 -1
- package/src/apiKeys/middleware.test.ts +108 -0
- package/src/apiKeys/middleware.ts +97 -37
- package/src/middleware/authMiddleware.test.ts +49 -0
- package/src/middleware/authMiddleware.ts +26 -4
- package/src/oauth/index.ts +7 -1
- package/src/oauth/tokens.ts +18 -4
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { RequestHandler } from 'express';
|
|
2
|
-
import type
|
|
2
|
+
import { type ApiKeyScope } from './types';
|
|
3
3
|
/**
|
|
4
4
|
* Request-scoped facts about the key a request arrived on. Absent on ordinary
|
|
5
5
|
* session requests, which is itself the signal a handler needs: `req.apiKey`
|
|
@@ -40,11 +40,13 @@ export declare const authenticateAgent: (...requiredScopes: ApiKeyScope[]) => Re
|
|
|
40
40
|
* door for lacking a scope half the router never uses.
|
|
41
41
|
*
|
|
42
42
|
* A session passes unconditionally: scopes narrow what software may do on a
|
|
43
|
-
* person's behalf, not what the person may do themselves.
|
|
43
|
+
* person's behalf, not what the person may do themselves. Everything that is
|
|
44
|
+
* not a session — an API key or an OAuth access token alike — arrives with
|
|
45
|
+
* `req.apiKey` set and is held to it.
|
|
44
46
|
*/
|
|
45
47
|
export declare const requireScope: (...requiredScopes: ApiKeyScope[]) => RequestHandler;
|
|
46
48
|
/**
|
|
47
|
-
* Refuse
|
|
49
|
+
* Refuse every non-session credential on a route that a session may still use.
|
|
48
50
|
*
|
|
49
51
|
* For the handful of operations that should stay a person's to perform — key
|
|
50
52
|
* management itself, most obviously, since a key that can mint keys is a key
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/apiKeys/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAyB,cAAc,EAAY,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/apiKeys/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAyB,cAAc,EAAY,MAAM,SAAS,CAAC;AAQ1E,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,SAAS,CAAC;AAE1D;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,6EAA6E;IAC7E,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,OAAO,CAAC;QAChB,UAAU,OAAO;YACf,MAAM,CAAC,EAAE,aAAa,CAAC;SACxB;KACF;CACF;AAgHD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB,GAAI,GAAG,gBAAgB,WAAW,EAAE,KAAG,cA+DlE,CAAC;AAEJ;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,YAAY,GAAI,GAAG,gBAAgB,WAAW,EAAE,KAAG,cAiB7D,CAAC;AAEJ;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,EAAE,cASzB,CAAC"}
|
|
@@ -7,8 +7,10 @@ exports.denyApiKeys = exports.requireScope = exports.authenticateAgent = void 0;
|
|
|
7
7
|
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
8
8
|
const env_1 = require("../config/env");
|
|
9
9
|
const logger_1 = __importDefault(require("../logging/logger"));
|
|
10
|
+
const tokens_1 = require("../oauth/tokens");
|
|
10
11
|
const crypto_1 = require("./crypto");
|
|
11
12
|
const service_1 = require("./service");
|
|
13
|
+
const types_1 = require("./types");
|
|
12
14
|
/** Both auth paths read the token from the same two places. */
|
|
13
15
|
const extractToken = (req) => req.cookies?.access_token || req.headers.authorization?.replace('Bearer ', '');
|
|
14
16
|
const unauthorized = (res) => {
|
|
@@ -58,6 +60,39 @@ const applyTenantPin = (req, groupId) => {
|
|
|
58
60
|
}
|
|
59
61
|
return true;
|
|
60
62
|
};
|
|
63
|
+
/**
|
|
64
|
+
* Admit software acting for a user, on the terms its credential carries.
|
|
65
|
+
*
|
|
66
|
+
* Shared by both non-session credentials on purpose. An API key and an OAuth
|
|
67
|
+
* access token differ entirely in how they are issued and verified, and not at
|
|
68
|
+
* all in what they mean once they are: a user, a set of scopes, and one tenant.
|
|
69
|
+
* Settling that in one place is what keeps the two from drifting into subtly
|
|
70
|
+
* different amounts of access.
|
|
71
|
+
*/
|
|
72
|
+
function admitAgent(req, res, next, requiredScopes, credential) {
|
|
73
|
+
const missing = requiredScopes.filter((scope) => !credential.scopes.includes(scope));
|
|
74
|
+
if (missing.length > 0) {
|
|
75
|
+
res.status(403).json({
|
|
76
|
+
success: false,
|
|
77
|
+
message: `Credential is missing required scope: ${missing.join(', ')}`
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (!applyTenantPin(req, credential.groupId)) {
|
|
82
|
+
res.status(403).json({
|
|
83
|
+
success: false,
|
|
84
|
+
message: 'Credential is not permitted to act in the requested group'
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
req.user = { id: credential.userId, email: credential.email, name: credential.name };
|
|
89
|
+
// Only the pinned group, never the owner's full membership: this is what
|
|
90
|
+
// stops a credential reaching a group it was not issued for through any
|
|
91
|
+
// handler that consults `userGroups` instead of the `groupId` parameter.
|
|
92
|
+
req.userGroups = credential.groupId ? [credential.groupId] : [];
|
|
93
|
+
req.apiKey = { keyId: credential.credentialId, scopes: credential.scopes, groupId: credential.groupId };
|
|
94
|
+
next();
|
|
95
|
+
}
|
|
61
96
|
/**
|
|
62
97
|
* Authenticate a request that software may legitimately be making.
|
|
63
98
|
*
|
|
@@ -77,15 +112,39 @@ const authenticateAgent = (...requiredScopes) => async function authenticateAgen
|
|
|
77
112
|
return;
|
|
78
113
|
}
|
|
79
114
|
if (!(0, crypto_1.looksLikeApiKey)(token)) {
|
|
115
|
+
let claims;
|
|
80
116
|
try {
|
|
81
|
-
|
|
82
|
-
req.user = user;
|
|
83
|
-
req.userGroups = user.groups ?? [];
|
|
84
|
-
next();
|
|
117
|
+
claims = jsonwebtoken_1.default.verify(token, (0, env_1.requireEnv)('JWT_SECRET'));
|
|
85
118
|
}
|
|
86
119
|
catch {
|
|
87
120
|
unauthorized(res);
|
|
121
|
+
return;
|
|
88
122
|
}
|
|
123
|
+
if (!(0, tokens_1.isAccessTokenClaims)(claims)) {
|
|
124
|
+
const user = claims;
|
|
125
|
+
req.user = user;
|
|
126
|
+
req.userGroups = user.groups ?? [];
|
|
127
|
+
next();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// An OAuth access token: signed with the same secret as a session and
|
|
131
|
+
// arriving in the same header, but nothing like one. It names its user in
|
|
132
|
+
// `sub`, carries scopes, and is pinned to a tenant — so it is admitted
|
|
133
|
+
// the way a key is, not the way a person is.
|
|
134
|
+
//
|
|
135
|
+
// Reading it as a session was the original bug and it failed quietly: the
|
|
136
|
+
// claims have no `id` and no `groups`, so `req.user.id` arrived as
|
|
137
|
+
// `undefined`, scoped writes were refused as inaccessible, and a read
|
|
138
|
+
// whose scope collapsed to nothing fell through to "no user context" and
|
|
139
|
+
// answered from the whole collection.
|
|
140
|
+
admitAgent(req, res, next, requiredScopes, {
|
|
141
|
+
credentialId: claims.jti,
|
|
142
|
+
userId: claims.sub,
|
|
143
|
+
email: claims.email ?? '',
|
|
144
|
+
name: claims.name ?? '',
|
|
145
|
+
scopes: (claims.scope ?? '').split(' ').filter(types_1.isApiKeyScope),
|
|
146
|
+
groupId: claims.groupId ?? null
|
|
147
|
+
});
|
|
89
148
|
return;
|
|
90
149
|
}
|
|
91
150
|
const result = await (0, service_1.verifyApiKey)(token);
|
|
@@ -96,34 +155,14 @@ const authenticateAgent = (...requiredScopes) => async function authenticateAgen
|
|
|
96
155
|
unauthorized(res);
|
|
97
156
|
return;
|
|
98
157
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
res.status(403).json({
|
|
103
|
-
success: false,
|
|
104
|
-
message: `API key is missing required scope: ${missing.join(', ')}`
|
|
105
|
-
});
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
const groupId = result.groupId ?? null;
|
|
109
|
-
if (!applyTenantPin(req, groupId)) {
|
|
110
|
-
res.status(403).json({
|
|
111
|
-
success: false,
|
|
112
|
-
message: 'API key is not permitted to act in the requested group'
|
|
113
|
-
});
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
req.user = {
|
|
117
|
-
id: result.userId,
|
|
158
|
+
admitAgent(req, res, next, requiredScopes, {
|
|
159
|
+
credentialId: result.keyId,
|
|
160
|
+
userId: result.userId,
|
|
118
161
|
email: result.userEmail ?? '',
|
|
119
|
-
name: result.userName ?? ''
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
// that consults `userGroups` instead of the `groupId` parameter.
|
|
124
|
-
req.userGroups = groupId ? [groupId] : [];
|
|
125
|
-
req.apiKey = { keyId: result.keyId, scopes, groupId };
|
|
126
|
-
next();
|
|
162
|
+
name: result.userName ?? '',
|
|
163
|
+
scopes: result.scopes ?? [],
|
|
164
|
+
groupId: result.groupId ?? null
|
|
165
|
+
});
|
|
127
166
|
};
|
|
128
167
|
exports.authenticateAgent = authenticateAgent;
|
|
129
168
|
/**
|
|
@@ -135,7 +174,9 @@ exports.authenticateAgent = authenticateAgent;
|
|
|
135
174
|
* door for lacking a scope half the router never uses.
|
|
136
175
|
*
|
|
137
176
|
* A session passes unconditionally: scopes narrow what software may do on a
|
|
138
|
-
* person's behalf, not what the person may do themselves.
|
|
177
|
+
* person's behalf, not what the person may do themselves. Everything that is
|
|
178
|
+
* not a session — an API key or an OAuth access token alike — arrives with
|
|
179
|
+
* `req.apiKey` set and is held to it.
|
|
139
180
|
*/
|
|
140
181
|
const requireScope = (...requiredScopes) => function requireScopeHandler(req, res, next) {
|
|
141
182
|
if (!req.apiKey) {
|
|
@@ -146,7 +187,7 @@ const requireScope = (...requiredScopes) => function requireScopeHandler(req, re
|
|
|
146
187
|
if (missing.length > 0) {
|
|
147
188
|
res.status(403).json({
|
|
148
189
|
success: false,
|
|
149
|
-
message: `
|
|
190
|
+
message: `Credential is missing required scope: ${missing.join(', ')}`
|
|
150
191
|
});
|
|
151
192
|
return;
|
|
152
193
|
}
|
|
@@ -154,7 +195,7 @@ const requireScope = (...requiredScopes) => function requireScopeHandler(req, re
|
|
|
154
195
|
};
|
|
155
196
|
exports.requireScope = requireScope;
|
|
156
197
|
/**
|
|
157
|
-
* Refuse
|
|
198
|
+
* Refuse every non-session credential on a route that a session may still use.
|
|
158
199
|
*
|
|
159
200
|
* For the handful of operations that should stay a person's to perform — key
|
|
160
201
|
* management itself, most obviously, since a key that can mint keys is a key
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.js","sourceRoot":"","sources":["../../src/apiKeys/middleware.ts"],"names":[],"mappings":";;;;;;AACA,gEAA+B;AAC/B,uCAA2C;AAC3C,+DAAuC;
|
|
1
|
+
{"version":3,"file":"middleware.js","sourceRoot":"","sources":["../../src/apiKeys/middleware.ts"],"names":[],"mappings":"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"}
|
|
@@ -4,6 +4,13 @@ import { Request, Response, NextFunction } from 'express';
|
|
|
4
4
|
* Verifies JWT token and attaches user info (and its embedded groups) to
|
|
5
5
|
* the request — typed via the global `Express.Request` augmentation in
|
|
6
6
|
* `../types/auth`, no `(req as any)` cast needed.
|
|
7
|
+
*
|
|
8
|
+
* OAuth access tokens are refused outright. They are signed with the same
|
|
9
|
+
* secret, so they verify here, but they mean something this middleware has no
|
|
10
|
+
* way to honour: an assistant acting within scopes, pinned to one tenant.
|
|
11
|
+
* Routes that software may legitimately reach use `authenticateAgent`, which
|
|
12
|
+
* enforces both — so anything still guarded by this one is a person's to do,
|
|
13
|
+
* and letting a token through would be handing an assistant the account.
|
|
7
14
|
*/
|
|
8
15
|
export declare function authenticateToken(req: Request, res: Response, next: NextFunction): void;
|
|
9
16
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"authMiddleware.d.ts","sourceRoot":"","sources":["../../src/middleware/authMiddleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"authMiddleware.d.ts","sourceRoot":"","sources":["../../src/middleware/authMiddleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAM1D;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CA0BvF;AACD;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CAmBnF"}
|
|
@@ -7,11 +7,19 @@ exports.authenticateToken = authenticateToken;
|
|
|
7
7
|
exports.optionalAuth = optionalAuth;
|
|
8
8
|
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
9
9
|
const env_1 = require("../config/env");
|
|
10
|
+
const tokens_1 = require("../oauth/tokens");
|
|
10
11
|
/**
|
|
11
12
|
* JWT authentication middleware
|
|
12
13
|
* Verifies JWT token and attaches user info (and its embedded groups) to
|
|
13
14
|
* the request — typed via the global `Express.Request` augmentation in
|
|
14
15
|
* `../types/auth`, no `(req as any)` cast needed.
|
|
16
|
+
*
|
|
17
|
+
* OAuth access tokens are refused outright. They are signed with the same
|
|
18
|
+
* secret, so they verify here, but they mean something this middleware has no
|
|
19
|
+
* way to honour: an assistant acting within scopes, pinned to one tenant.
|
|
20
|
+
* Routes that software may legitimately reach use `authenticateAgent`, which
|
|
21
|
+
* enforces both — so anything still guarded by this one is a person's to do,
|
|
22
|
+
* and letting a token through would be handing an assistant the account.
|
|
15
23
|
*/
|
|
16
24
|
function authenticateToken(req, res, next) {
|
|
17
25
|
try {
|
|
@@ -21,7 +29,15 @@ function authenticateToken(req, res, next) {
|
|
|
21
29
|
res.status(401).json({ success: false, message: 'Access token required' });
|
|
22
30
|
return;
|
|
23
31
|
}
|
|
24
|
-
const
|
|
32
|
+
const claims = jsonwebtoken_1.default.verify(token, JWT_SECRET);
|
|
33
|
+
if ((0, tokens_1.isAccessTokenClaims)(claims)) {
|
|
34
|
+
res.status(403).json({
|
|
35
|
+
success: false,
|
|
36
|
+
message: 'This operation requires an interactive session, not a connected assistant'
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const user = claims;
|
|
25
41
|
req.user = user;
|
|
26
42
|
req.userGroups = user.groups ?? [];
|
|
27
43
|
next();
|
|
@@ -48,9 +64,14 @@ function optionalAuth(req, _res, next) {
|
|
|
48
64
|
const JWT_SECRET = (0, env_1.requireEnv)('JWT_SECRET');
|
|
49
65
|
const token = req.cookies?.access_token || req.headers.authorization?.replace('Bearer ', '');
|
|
50
66
|
if (token) {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
67
|
+
const claims = jsonwebtoken_1.default.verify(token, JWT_SECRET);
|
|
68
|
+
// An access token is not a session; treated as none, exactly like an
|
|
69
|
+
// expired cookie, so the route still answers with its public form.
|
|
70
|
+
if (!(0, tokens_1.isAccessTokenClaims)(claims)) {
|
|
71
|
+
const user = claims;
|
|
72
|
+
req.user = user;
|
|
73
|
+
req.userGroups = user.groups ?? [];
|
|
74
|
+
}
|
|
54
75
|
}
|
|
55
76
|
}
|
|
56
77
|
catch {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"authMiddleware.js","sourceRoot":"","sources":["../../src/middleware/authMiddleware.ts"],"names":[],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"authMiddleware.js","sourceRoot":"","sources":["../../src/middleware/authMiddleware.ts"],"names":[],"mappings":";;;;;AAmBA,8CA0BC;AAcD,oCAmBC;AA7ED,gEAA+B;AAC/B,uCAA2C;AAC3C,4CAAsD;AAGtD;;;;;;;;;;;;GAYG;AACH,SAAgB,iBAAiB,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;IAC/E,IAAI,CAAC;QACH,MAAM,UAAU,GAAG,IAAA,gBAAU,EAAC,YAAY,CAAC,CAAC;QAC5C,MAAM,KAAK,GAAG,GAAG,CAAC,OAAO,EAAE,YAAY,IAAI,GAAG,CAAC,OAAO,CAAC,aAAa,EAAE,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;QAE7F,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,uBAAuB,EAAE,CAAC,CAAC;YAC3E,OAAO;QACT,CAAC;QAED,MAAM,MAAM,GAAG,sBAAG,CAAC,MAAM,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC;QAC7C,IAAI,IAAA,4BAAmB,EAAC,MAAM,CAAC,EAAE,CAAC;YAChC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;gBACnB,OAAO,EAAE,KAAK;gBACd,OAAO,EAAE,2EAA2E;aACrF,CAAC,CAAC;YACH,OAAO;QACT,CAAC;QAED,MAAM,IAAI,GAAG,MAAqB,CAAC;QACnC,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC;QAChB,GAAG,CAAC,UAAU,GAAG,IAAI,CAAC,MAAM,IAAI,EAAE,CAAC;QACnC,IAAI,EAAE,CAAC;IACT,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,0BAA0B,EAAE,CAAC,CAAC;IAChF,CAAC;AACH,CAAC;AACD;;;;;;;;;;;;GAYG;AACH,SAAgB,YAAY,CAAC,GAAY,EAAE,IAAc,EAAE,IAAkB;IAC3E,IAAI,CAAC;QACH,MAAM,UAAU,GAAG,IAAA,gBAAU,EAAC,YAAY,CAAC,CAAC;QAC5C,MAAM,KAAK,GAAG,GAAG,CAAC,OAAO,EAAE,YAAY,IAAI,GAAG,CAAC,OAAO,CAAC,aAAa,EAAE,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC;QAE7F,IAAI,KAAK,EAAE,CAAC;YACV,MAAM,MAAM,GAAG,sBAAG,CAAC,MAAM,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC;YAC7C,qEAAqE;YACrE,mEAAmE;YACnE,IAAI,CAAC,IAAA,4BAAmB,EAAC,MAAM,CAAC,EAAE,CAAC;gBACjC,MAAM,IAAI,GAAG,MAAqB,CAAC;gBACnC,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC;gBAChB,GAAG,CAAC,UAAU,GAAG,IAAI,CAAC,MAAM,IAAI,EAAE,CAAC;YACrC,CAAC;QACH,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,oCAAoC;IACtC,CAAC;IACD,IAAI,EAAE,CAAC;AACT,CAAC"}
|
package/dist/oauth/index.d.ts
CHANGED
|
@@ -2,6 +2,6 @@ export { OAuthClient, AuthorizationCode, RefreshToken } from './models';
|
|
|
2
2
|
export type { IOAuthClient, IAuthorizationCode, IRefreshToken } from './models';
|
|
3
3
|
export { registerClient, findClient, isRegisteredRedirect, issueAuthorizationCode, redeemAuthorizationCode, issueRefreshToken, redeemRefreshToken, revokeRefreshTokensForUser, listConnections } from './service';
|
|
4
4
|
export type { RegisterClientInput, RegisteredClient, IssueCodeInput, CodeRedemption, CodeRejection, RefreshRedemption } from './service';
|
|
5
|
-
export { mintAccessToken, verifyAccessToken, parseScopes } from './tokens';
|
|
5
|
+
export { mintAccessToken, verifyAccessToken, parseScopes, isAccessTokenClaims, ACCESS_TOKEN_TYPE } from './tokens';
|
|
6
6
|
export type { AccessTokenClaims, MintAccessTokenInput, MintedAccessToken, AccessTokenVerification } from './tokens';
|
|
7
7
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/oauth/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AACxE,YAAY,EAAE,YAAY,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAChF,OAAO,EACL,cAAc,EACd,UAAU,EACV,oBAAoB,EACpB,sBAAsB,EACtB,uBAAuB,EACvB,iBAAiB,EACjB,kBAAkB,EAClB,0BAA0B,EAC1B,eAAe,EAChB,MAAM,WAAW,CAAC;AACnB,YAAY,EACV,mBAAmB,EACnB,gBAAgB,EAChB,cAAc,EACd,cAAc,EACd,aAAa,EACb,iBAAiB,EAClB,MAAM,WAAW,CAAC;AACnB,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/oauth/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AACxE,YAAY,EAAE,YAAY,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAChF,OAAO,EACL,cAAc,EACd,UAAU,EACV,oBAAoB,EACpB,sBAAsB,EACtB,uBAAuB,EACvB,iBAAiB,EACjB,kBAAkB,EAClB,0BAA0B,EAC1B,eAAe,EAChB,MAAM,WAAW,CAAC;AACnB,YAAY,EACV,mBAAmB,EACnB,gBAAgB,EAChB,cAAc,EACd,cAAc,EACd,aAAa,EACb,iBAAiB,EAClB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,WAAW,EACX,mBAAmB,EACnB,iBAAiB,EAClB,MAAM,UAAU,CAAC;AAClB,YAAY,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,uBAAuB,EAAE,MAAM,UAAU,CAAC"}
|
package/dist/oauth/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.parseScopes = exports.verifyAccessToken = exports.mintAccessToken = exports.listConnections = exports.revokeRefreshTokensForUser = exports.redeemRefreshToken = exports.issueRefreshToken = exports.redeemAuthorizationCode = exports.issueAuthorizationCode = exports.isRegisteredRedirect = exports.findClient = exports.registerClient = exports.RefreshToken = exports.AuthorizationCode = exports.OAuthClient = void 0;
|
|
3
|
+
exports.ACCESS_TOKEN_TYPE = exports.isAccessTokenClaims = exports.parseScopes = exports.verifyAccessToken = exports.mintAccessToken = exports.listConnections = exports.revokeRefreshTokensForUser = exports.redeemRefreshToken = exports.issueRefreshToken = exports.redeemAuthorizationCode = exports.issueAuthorizationCode = exports.isRegisteredRedirect = exports.findClient = exports.registerClient = exports.RefreshToken = exports.AuthorizationCode = exports.OAuthClient = void 0;
|
|
4
4
|
var models_1 = require("./models");
|
|
5
5
|
Object.defineProperty(exports, "OAuthClient", { enumerable: true, get: function () { return models_1.OAuthClient; } });
|
|
6
6
|
Object.defineProperty(exports, "AuthorizationCode", { enumerable: true, get: function () { return models_1.AuthorizationCode; } });
|
|
@@ -19,4 +19,6 @@ var tokens_1 = require("./tokens");
|
|
|
19
19
|
Object.defineProperty(exports, "mintAccessToken", { enumerable: true, get: function () { return tokens_1.mintAccessToken; } });
|
|
20
20
|
Object.defineProperty(exports, "verifyAccessToken", { enumerable: true, get: function () { return tokens_1.verifyAccessToken; } });
|
|
21
21
|
Object.defineProperty(exports, "parseScopes", { enumerable: true, get: function () { return tokens_1.parseScopes; } });
|
|
22
|
+
Object.defineProperty(exports, "isAccessTokenClaims", { enumerable: true, get: function () { return tokens_1.isAccessTokenClaims; } });
|
|
23
|
+
Object.defineProperty(exports, "ACCESS_TOKEN_TYPE", { enumerable: true, get: function () { return tokens_1.ACCESS_TOKEN_TYPE; } });
|
|
22
24
|
//# sourceMappingURL=index.js.map
|
package/dist/oauth/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/oauth/index.ts"],"names":[],"mappings":";;;AAAA,mCAAwE;AAA/D,qGAAA,WAAW,OAAA;AAAE,2GAAA,iBAAiB,OAAA;AAAE,sGAAA,YAAY,OAAA;AAErD,qCAUmB;AATjB,yGAAA,cAAc,OAAA;AACd,qGAAA,UAAU,OAAA;AACV,+GAAA,oBAAoB,OAAA;AACpB,iHAAA,sBAAsB,OAAA;AACtB,kHAAA,uBAAuB,OAAA;AACvB,4GAAA,iBAAiB,OAAA;AACjB,6GAAA,kBAAkB,OAAA;AAClB,qHAAA,0BAA0B,OAAA;AAC1B,0GAAA,eAAe,OAAA;AAUjB,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/oauth/index.ts"],"names":[],"mappings":";;;AAAA,mCAAwE;AAA/D,qGAAA,WAAW,OAAA;AAAE,2GAAA,iBAAiB,OAAA;AAAE,sGAAA,YAAY,OAAA;AAErD,qCAUmB;AATjB,yGAAA,cAAc,OAAA;AACd,qGAAA,UAAU,OAAA;AACV,+GAAA,oBAAoB,OAAA;AACpB,iHAAA,sBAAsB,OAAA;AACtB,kHAAA,uBAAuB,OAAA;AACvB,4GAAA,iBAAiB,OAAA;AACjB,6GAAA,kBAAkB,OAAA;AAClB,qHAAA,0BAA0B,OAAA;AAC1B,0GAAA,eAAe,OAAA;AAUjB,mCAMkB;AALhB,yGAAA,eAAe,OAAA;AACf,2GAAA,iBAAiB,OAAA;AACjB,qGAAA,WAAW,OAAA;AACX,6GAAA,mBAAmB,OAAA;AACnB,2GAAA,iBAAiB,OAAA"}
|
package/dist/oauth/tokens.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { type ApiKeyScope } from '../apiKeys/types';
|
|
|
10
10
|
* token, which is the thing that actually persists.
|
|
11
11
|
*/
|
|
12
12
|
/** Marks a token as issued by the OAuth flow, for the MCP resource specifically. */
|
|
13
|
-
declare const
|
|
13
|
+
export declare const ACCESS_TOKEN_TYPE = "mcp_access";
|
|
14
14
|
export interface AccessTokenClaims {
|
|
15
15
|
/** the user the assistant is acting for */
|
|
16
16
|
sub: string;
|
|
@@ -22,7 +22,7 @@ export interface AccessTokenClaims {
|
|
|
22
22
|
groupId: string | null;
|
|
23
23
|
email: string;
|
|
24
24
|
name: string;
|
|
25
|
-
typ: typeof
|
|
25
|
+
typ: typeof ACCESS_TOKEN_TYPE;
|
|
26
26
|
jti: string;
|
|
27
27
|
exp: number;
|
|
28
28
|
iat: number;
|
|
@@ -63,7 +63,16 @@ export interface AccessTokenVerification {
|
|
|
63
63
|
* pin — cannot be presented as an access token and quietly get everything.
|
|
64
64
|
*/
|
|
65
65
|
export declare const verifyAccessToken: (token: string, expectedAudience: string) => AccessTokenVerification;
|
|
66
|
+
/**
|
|
67
|
+
* Tell an OAuth access token apart from an ordinary session JWT.
|
|
68
|
+
*
|
|
69
|
+
* They are signed with the same secret and arrive in the same header, so
|
|
70
|
+
* anything holding one has to ask which it got. Getting this wrong is not a
|
|
71
|
+
* subtle failure: read as a session, an access token has no `id` and no
|
|
72
|
+
* `groups`, so `req.user.id` lands as `undefined` and every scoped query goes
|
|
73
|
+
* out unbounded.
|
|
74
|
+
*/
|
|
75
|
+
export declare const isAccessTokenClaims: (claims: unknown) => claims is AccessTokenClaims;
|
|
66
76
|
/** Parse a space-separated `scope` parameter, dropping anything we do not define. */
|
|
67
77
|
export declare const parseScopes: (scope: unknown) => ApiKeyScope[];
|
|
68
|
-
export {};
|
|
69
78
|
//# sourceMappingURL=tokens.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/oauth/tokens.ts"],"names":[],"mappings":"AAGA,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEnE;;;;;;;;;GASG;AAEH,oFAAoF;AACpF,
|
|
1
|
+
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/oauth/tokens.ts"],"names":[],"mappings":"AAGA,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEnE;;;;;;;;;GASG;AAEH,oFAAoF;AACpF,eAAO,MAAM,iBAAiB,eAAe,CAAC;AAK9C,MAAM,WAAW,iBAAiB;IAChC,2CAA2C;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,gFAAgF;IAChF,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,6EAA6E;IAC7E,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,OAAO,iBAAiB,CAAC;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,eAAO,MAAM,eAAe,GAAI,OAAO,oBAAoB,KAAG,iBAoB7D,CAAC;AAEF,MAAM,WAAW,uBAAuB;IACtC,EAAE,EAAE,OAAO,CAAC;IACZ,SAAS,CAAC,EAAE,WAAW,GAAG,SAAS,GAAG,gBAAgB,GAAG,YAAY,GAAG,eAAe,CAAC;IACxF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,WAAW,EAAE,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,GAC5B,OAAO,MAAM,EACb,kBAAkB,MAAM,KACvB,uBAqBF,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,GAAI,QAAQ,OAAO,KAAG,MAAM,IAAI,iBAGP,CAAC;AAE1D,qFAAqF;AACrF,eAAO,MAAM,WAAW,GAAI,OAAO,OAAO,KAAG,WAAW,EACmB,CAAC"}
|
package/dist/oauth/tokens.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.parseScopes = exports.verifyAccessToken = exports.mintAccessToken = void 0;
|
|
6
|
+
exports.parseScopes = exports.isAccessTokenClaims = exports.verifyAccessToken = exports.mintAccessToken = exports.ACCESS_TOKEN_TYPE = void 0;
|
|
7
7
|
const crypto_1 = require("crypto");
|
|
8
8
|
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
9
9
|
const env_1 = require("../config/env");
|
|
@@ -19,7 +19,7 @@ const types_1 = require("../apiKeys/types");
|
|
|
19
19
|
* token, which is the thing that actually persists.
|
|
20
20
|
*/
|
|
21
21
|
/** Marks a token as issued by the OAuth flow, for the MCP resource specifically. */
|
|
22
|
-
|
|
22
|
+
exports.ACCESS_TOKEN_TYPE = 'mcp_access';
|
|
23
23
|
/** Short enough that a leaked token is a small window, long enough to be usable. */
|
|
24
24
|
const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
|
|
25
25
|
const mintAccessToken = (input) => {
|
|
@@ -32,7 +32,7 @@ const mintAccessToken = (input) => {
|
|
|
32
32
|
groupId: input.groupId,
|
|
33
33
|
email: input.email,
|
|
34
34
|
name: input.name,
|
|
35
|
-
typ:
|
|
35
|
+
typ: exports.ACCESS_TOKEN_TYPE,
|
|
36
36
|
jti: (0, crypto_1.randomUUID)()
|
|
37
37
|
}, (0, env_1.requireEnv)('JWT_SECRET'), { expiresIn: ACCESS_TOKEN_TTL_SECONDS });
|
|
38
38
|
return { accessToken, expiresIn: ACCESS_TOKEN_TTL_SECONDS, scope };
|
|
@@ -57,7 +57,7 @@ const verifyAccessToken = (token, expectedAudience) => {
|
|
|
57
57
|
const expired = error?.name === 'TokenExpiredError';
|
|
58
58
|
return { ok: false, rejection: expired ? 'expired' : 'bad-signature' };
|
|
59
59
|
}
|
|
60
|
-
if (
|
|
60
|
+
if (!(0, exports.isAccessTokenClaims)(claims))
|
|
61
61
|
return { ok: false, rejection: 'wrong-type' };
|
|
62
62
|
if (claims.aud !== expectedAudience)
|
|
63
63
|
return { ok: false, rejection: 'wrong-audience' };
|
|
@@ -73,6 +73,19 @@ const verifyAccessToken = (token, expectedAudience) => {
|
|
|
73
73
|
};
|
|
74
74
|
};
|
|
75
75
|
exports.verifyAccessToken = verifyAccessToken;
|
|
76
|
+
/**
|
|
77
|
+
* Tell an OAuth access token apart from an ordinary session JWT.
|
|
78
|
+
*
|
|
79
|
+
* They are signed with the same secret and arrive in the same header, so
|
|
80
|
+
* anything holding one has to ask which it got. Getting this wrong is not a
|
|
81
|
+
* subtle failure: read as a session, an access token has no `id` and no
|
|
82
|
+
* `groups`, so `req.user.id` lands as `undefined` and every scoped query goes
|
|
83
|
+
* out unbounded.
|
|
84
|
+
*/
|
|
85
|
+
const isAccessTokenClaims = (claims) => typeof claims === 'object' &&
|
|
86
|
+
claims !== null &&
|
|
87
|
+
claims.typ === exports.ACCESS_TOKEN_TYPE;
|
|
88
|
+
exports.isAccessTokenClaims = isAccessTokenClaims;
|
|
76
89
|
/** Parse a space-separated `scope` parameter, dropping anything we do not define. */
|
|
77
90
|
const parseScopes = (scope) => typeof scope === 'string' ? scope.split(/\s+/).filter(types_1.isApiKeyScope) : [];
|
|
78
91
|
exports.parseScopes = parseScopes;
|
package/dist/oauth/tokens.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../src/oauth/tokens.ts"],"names":[],"mappings":";;;;;;AAAA,mCAAoC;AACpC,gEAA+B;AAC/B,uCAA2C;AAC3C,4CAAmE;AAEnE;;;;;;;;;GASG;AAEH,oFAAoF;
|
|
1
|
+
{"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../src/oauth/tokens.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -8,6 +8,7 @@ jest.mock('../logging/logger', () => ({
|
|
|
8
8
|
|
|
9
9
|
import jwt from 'jsonwebtoken';
|
|
10
10
|
import { verifyApiKey } from './service';
|
|
11
|
+
import { mintAccessToken, type MintAccessTokenInput } from '../oauth/tokens';
|
|
11
12
|
import { authenticateAgent, requireScope, denyApiKeys } from './middleware';
|
|
12
13
|
|
|
13
14
|
const mockedVerify = verifyApiKey as jest.Mock;
|
|
@@ -351,3 +352,110 @@ describe('denyApiKeys', () => {
|
|
|
351
352
|
expect(next).not.toHaveBeenCalled();
|
|
352
353
|
});
|
|
353
354
|
});
|
|
355
|
+
|
|
356
|
+
describe('authenticateAgent — OAuth access tokens', () => {
|
|
357
|
+
/**
|
|
358
|
+
* Minted by the real thing rather than hand-signed. The bug this covers was
|
|
359
|
+
* that an access token verified as a session — same secret, same header — and
|
|
360
|
+
* its claims were then read as a session's: `sub` instead of `id`, no
|
|
361
|
+
* `groups`. Every one of those details has to come from the code that issues
|
|
362
|
+
* them, or the test simply re-encodes whatever the middleware happens to do.
|
|
363
|
+
*/
|
|
364
|
+
const mint = (over: Partial<MintAccessTokenInput> = {}) =>
|
|
365
|
+
mintAccessToken({
|
|
366
|
+
userId: 'u1',
|
|
367
|
+
email: 'u1@example.com',
|
|
368
|
+
name: 'Tester',
|
|
369
|
+
resource: 'https://mcp.example.com',
|
|
370
|
+
issuer: 'https://auth.example.com',
|
|
371
|
+
scopes: ['relationship:read', 'relationship:write'],
|
|
372
|
+
groupId: null,
|
|
373
|
+
...over
|
|
374
|
+
}).accessToken;
|
|
375
|
+
|
|
376
|
+
const withToken = (token: string, over: Partial<Request> = {}) =>
|
|
377
|
+
req({ headers: { authorization: `Bearer ${token}` }, ...over });
|
|
378
|
+
|
|
379
|
+
it('names the user, so scoped queries are bounded', async () => {
|
|
380
|
+
const request = withToken(mint());
|
|
381
|
+
const next = jest.fn();
|
|
382
|
+
|
|
383
|
+
await authenticateAgent('relationship:write')(request, mockRes(), next);
|
|
384
|
+
|
|
385
|
+
expect(next).toHaveBeenCalled();
|
|
386
|
+
// Read as a session this was `undefined`, which reached handlers as "no
|
|
387
|
+
// user" rather than as a refusal: writes were rejected as inaccessible and
|
|
388
|
+
// reads fell through to an unscoped query.
|
|
389
|
+
expect(request.user?.id).toBe('u1');
|
|
390
|
+
expect(request.user?.email).toBe('u1@example.com');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('is held to its scopes, exactly as a key is', async () => {
|
|
394
|
+
const res = mockRes();
|
|
395
|
+
const next = jest.fn();
|
|
396
|
+
const request = withToken(mint({ scopes: ['relationship:read'] }));
|
|
397
|
+
|
|
398
|
+
await authenticateAgent('relationship:write')(request, res, next);
|
|
399
|
+
|
|
400
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
401
|
+
expect(next).not.toHaveBeenCalled();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('carries an apiKey context, so requireScope applies downstream', async () => {
|
|
405
|
+
const request = withToken(mint({ scopes: ['relationship:read'] }));
|
|
406
|
+
|
|
407
|
+
await authenticateAgent()(request, mockRes(), jest.fn());
|
|
408
|
+
|
|
409
|
+
// Without this the token looked like a session and every requireScope on
|
|
410
|
+
// every route waved it through.
|
|
411
|
+
expect(request.apiKey?.scopes).toEqual(['relationship:read']);
|
|
412
|
+
|
|
413
|
+
const res = mockRes();
|
|
414
|
+
const next = jest.fn();
|
|
415
|
+
requireScope('relationship:write')(request, res, next);
|
|
416
|
+
|
|
417
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
418
|
+
expect(next).not.toHaveBeenCalled();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('is pinned to the tenant chosen at consent', async () => {
|
|
422
|
+
const request = withToken(mint({ groupId: 'g1' }));
|
|
423
|
+
const next = jest.fn();
|
|
424
|
+
|
|
425
|
+
await authenticateAgent()(request, mockRes(), next);
|
|
426
|
+
|
|
427
|
+
expect(next).toHaveBeenCalled();
|
|
428
|
+
expect(request.userGroups).toEqual(['g1']);
|
|
429
|
+
// Written in for a caller that named none, so a handler's group branch
|
|
430
|
+
// lands where the person who approved the connection intended.
|
|
431
|
+
expect(request.query.groupId).toBe('g1');
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('refuses a request naming a different tenant', async () => {
|
|
435
|
+
const res = mockRes();
|
|
436
|
+
const next = jest.fn();
|
|
437
|
+
const request = withToken(mint({ groupId: 'g1' }), { query: { groupId: 'g2' } });
|
|
438
|
+
|
|
439
|
+
await authenticateAgent()(request, res, next);
|
|
440
|
+
|
|
441
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
442
|
+
expect(next).not.toHaveBeenCalled();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('is refused by denyApiKeys, like any other credential software holds', async () => {
|
|
446
|
+
const request = withToken(mint());
|
|
447
|
+
await authenticateAgent()(request, mockRes(), jest.fn());
|
|
448
|
+
|
|
449
|
+
const res = mockRes();
|
|
450
|
+
const next = jest.fn();
|
|
451
|
+
denyApiKeys(request, res, next);
|
|
452
|
+
|
|
453
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
454
|
+
expect(next).not.toHaveBeenCalled();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('never consults the API key store', async () => {
|
|
458
|
+
await authenticateAgent()(withToken(mint()), mockRes(), jest.fn());
|
|
459
|
+
expect(mockedVerify).not.toHaveBeenCalled();
|
|
460
|
+
});
|
|
461
|
+
});
|
|
@@ -2,10 +2,11 @@ import { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
|
2
2
|
import jwt from 'jsonwebtoken';
|
|
3
3
|
import { requireEnv } from '../config/env';
|
|
4
4
|
import logger from '../logging/logger';
|
|
5
|
+
import { isAccessTokenClaims, type AccessTokenClaims } from '../oauth/tokens';
|
|
5
6
|
import type { UserPayload } from '../types/auth';
|
|
6
7
|
import { looksLikeApiKey } from './crypto';
|
|
7
8
|
import { verifyApiKey } from './service';
|
|
8
|
-
import type
|
|
9
|
+
import { isApiKeyScope, type ApiKeyScope } from './types';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Request-scoped facts about the key a request arrived on. Absent on ordinary
|
|
@@ -83,6 +84,60 @@ const applyTenantPin = (req: Request, groupId: string | null): boolean => {
|
|
|
83
84
|
return true;
|
|
84
85
|
};
|
|
85
86
|
|
|
87
|
+
/** A verified non-session credential, whichever kind it arrived as. */
|
|
88
|
+
interface AgentCredential {
|
|
89
|
+
/** the key id or token id, for the request-scoped context */
|
|
90
|
+
credentialId: string;
|
|
91
|
+
userId: string;
|
|
92
|
+
email: string;
|
|
93
|
+
name: string;
|
|
94
|
+
scopes: ApiKeyScope[];
|
|
95
|
+
groupId: string | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Admit software acting for a user, on the terms its credential carries.
|
|
100
|
+
*
|
|
101
|
+
* Shared by both non-session credentials on purpose. An API key and an OAuth
|
|
102
|
+
* access token differ entirely in how they are issued and verified, and not at
|
|
103
|
+
* all in what they mean once they are: a user, a set of scopes, and one tenant.
|
|
104
|
+
* Settling that in one place is what keeps the two from drifting into subtly
|
|
105
|
+
* different amounts of access.
|
|
106
|
+
*/
|
|
107
|
+
function admitAgent(
|
|
108
|
+
req: Request,
|
|
109
|
+
res: Response,
|
|
110
|
+
next: NextFunction,
|
|
111
|
+
requiredScopes: ApiKeyScope[],
|
|
112
|
+
credential: AgentCredential
|
|
113
|
+
): void {
|
|
114
|
+
const missing = requiredScopes.filter((scope) => !credential.scopes.includes(scope));
|
|
115
|
+
if (missing.length > 0) {
|
|
116
|
+
res.status(403).json({
|
|
117
|
+
success: false,
|
|
118
|
+
message: `Credential is missing required scope: ${missing.join(', ')}`
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!applyTenantPin(req, credential.groupId)) {
|
|
124
|
+
res.status(403).json({
|
|
125
|
+
success: false,
|
|
126
|
+
message: 'Credential is not permitted to act in the requested group'
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
req.user = { id: credential.userId, email: credential.email, name: credential.name };
|
|
132
|
+
// Only the pinned group, never the owner's full membership: this is what
|
|
133
|
+
// stops a credential reaching a group it was not issued for through any
|
|
134
|
+
// handler that consults `userGroups` instead of the `groupId` parameter.
|
|
135
|
+
req.userGroups = credential.groupId ? [credential.groupId] : [];
|
|
136
|
+
req.apiKey = { keyId: credential.credentialId, scopes: credential.scopes, groupId: credential.groupId };
|
|
137
|
+
|
|
138
|
+
next();
|
|
139
|
+
}
|
|
140
|
+
|
|
86
141
|
/**
|
|
87
142
|
* Authenticate a request that software may legitimately be making.
|
|
88
143
|
*
|
|
@@ -104,14 +159,40 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
|
|
|
104
159
|
}
|
|
105
160
|
|
|
106
161
|
if (!looksLikeApiKey(token)) {
|
|
162
|
+
let claims: UserPayload | AccessTokenClaims;
|
|
107
163
|
try {
|
|
108
|
-
|
|
164
|
+
claims = jwt.verify(token, requireEnv('JWT_SECRET')) as UserPayload | AccessTokenClaims;
|
|
165
|
+
} catch {
|
|
166
|
+
unauthorized(res);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!isAccessTokenClaims(claims)) {
|
|
171
|
+
const user = claims as UserPayload;
|
|
109
172
|
req.user = user;
|
|
110
173
|
req.userGroups = user.groups ?? [];
|
|
111
174
|
next();
|
|
112
|
-
|
|
113
|
-
unauthorized(res);
|
|
175
|
+
return;
|
|
114
176
|
}
|
|
177
|
+
|
|
178
|
+
// An OAuth access token: signed with the same secret as a session and
|
|
179
|
+
// arriving in the same header, but nothing like one. It names its user in
|
|
180
|
+
// `sub`, carries scopes, and is pinned to a tenant — so it is admitted
|
|
181
|
+
// the way a key is, not the way a person is.
|
|
182
|
+
//
|
|
183
|
+
// Reading it as a session was the original bug and it failed quietly: the
|
|
184
|
+
// claims have no `id` and no `groups`, so `req.user.id` arrived as
|
|
185
|
+
// `undefined`, scoped writes were refused as inaccessible, and a read
|
|
186
|
+
// whose scope collapsed to nothing fell through to "no user context" and
|
|
187
|
+
// answered from the whole collection.
|
|
188
|
+
admitAgent(req, res, next, requiredScopes, {
|
|
189
|
+
credentialId: claims.jti,
|
|
190
|
+
userId: claims.sub,
|
|
191
|
+
email: claims.email ?? '',
|
|
192
|
+
name: claims.name ?? '',
|
|
193
|
+
scopes: (claims.scope ?? '').split(' ').filter(isApiKeyScope),
|
|
194
|
+
groupId: claims.groupId ?? null
|
|
195
|
+
});
|
|
115
196
|
return;
|
|
116
197
|
}
|
|
117
198
|
|
|
@@ -124,37 +205,14 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
|
|
|
124
205
|
return;
|
|
125
206
|
}
|
|
126
207
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
res.status(403).json({
|
|
131
|
-
success: false,
|
|
132
|
-
message: `API key is missing required scope: ${missing.join(', ')}`
|
|
133
|
-
});
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const groupId = result.groupId ?? null;
|
|
138
|
-
if (!applyTenantPin(req, groupId)) {
|
|
139
|
-
res.status(403).json({
|
|
140
|
-
success: false,
|
|
141
|
-
message: 'API key is not permitted to act in the requested group'
|
|
142
|
-
});
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
req.user = {
|
|
147
|
-
id: result.userId!,
|
|
208
|
+
admitAgent(req, res, next, requiredScopes, {
|
|
209
|
+
credentialId: result.keyId!,
|
|
210
|
+
userId: result.userId!,
|
|
148
211
|
email: result.userEmail ?? '',
|
|
149
|
-
name: result.userName ?? ''
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
// that consults `userGroups` instead of the `groupId` parameter.
|
|
154
|
-
req.userGroups = groupId ? [groupId] : [];
|
|
155
|
-
req.apiKey = { keyId: result.keyId!, scopes, groupId };
|
|
156
|
-
|
|
157
|
-
next();
|
|
212
|
+
name: result.userName ?? '',
|
|
213
|
+
scopes: result.scopes ?? [],
|
|
214
|
+
groupId: result.groupId ?? null
|
|
215
|
+
});
|
|
158
216
|
};
|
|
159
217
|
|
|
160
218
|
/**
|
|
@@ -166,7 +224,9 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
|
|
|
166
224
|
* door for lacking a scope half the router never uses.
|
|
167
225
|
*
|
|
168
226
|
* A session passes unconditionally: scopes narrow what software may do on a
|
|
169
|
-
* person's behalf, not what the person may do themselves.
|
|
227
|
+
* person's behalf, not what the person may do themselves. Everything that is
|
|
228
|
+
* not a session — an API key or an OAuth access token alike — arrives with
|
|
229
|
+
* `req.apiKey` set and is held to it.
|
|
170
230
|
*/
|
|
171
231
|
export const requireScope = (...requiredScopes: ApiKeyScope[]): RequestHandler =>
|
|
172
232
|
function requireScopeHandler(req, res, next) {
|
|
@@ -179,7 +239,7 @@ export const requireScope = (...requiredScopes: ApiKeyScope[]): RequestHandler =
|
|
|
179
239
|
if (missing.length > 0) {
|
|
180
240
|
res.status(403).json({
|
|
181
241
|
success: false,
|
|
182
|
-
message: `
|
|
242
|
+
message: `Credential is missing required scope: ${missing.join(', ')}`
|
|
183
243
|
});
|
|
184
244
|
return;
|
|
185
245
|
}
|
|
@@ -188,7 +248,7 @@ export const requireScope = (...requiredScopes: ApiKeyScope[]): RequestHandler =
|
|
|
188
248
|
};
|
|
189
249
|
|
|
190
250
|
/**
|
|
191
|
-
* Refuse
|
|
251
|
+
* Refuse every non-session credential on a route that a session may still use.
|
|
192
252
|
*
|
|
193
253
|
* For the handful of operations that should stay a person's to perform — key
|
|
194
254
|
* management itself, most obviously, since a key that can mint keys is a key
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Request, Response } from 'express';
|
|
2
2
|
import jwt from 'jsonwebtoken';
|
|
3
3
|
import { authenticateToken, optionalAuth } from './authMiddleware';
|
|
4
|
+
import { mintAccessToken } from '../oauth/tokens';
|
|
4
5
|
import { UserPayload } from '../types/auth';
|
|
5
6
|
|
|
6
7
|
function mockReq(overrides: Partial<Request> = {}): Request {
|
|
@@ -171,3 +172,51 @@ describe('optionalAuth', () => {
|
|
|
171
172
|
expect(res.status).not.toHaveBeenCalled();
|
|
172
173
|
});
|
|
173
174
|
});
|
|
175
|
+
|
|
176
|
+
describe('OAuth access tokens are not sessions', () => {
|
|
177
|
+
const ORIGINAL_ENV = process.env;
|
|
178
|
+
|
|
179
|
+
beforeEach(() => {
|
|
180
|
+
process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-secret' };
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
afterEach(() => {
|
|
184
|
+
process.env = ORIGINAL_ENV;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const accessToken = () =>
|
|
188
|
+
mintAccessToken({
|
|
189
|
+
userId: 'u1',
|
|
190
|
+
email: 'u1@example.com',
|
|
191
|
+
name: 'Tester',
|
|
192
|
+
resource: 'https://mcp.example.com',
|
|
193
|
+
issuer: 'https://auth.example.com',
|
|
194
|
+
scopes: ['relationship:read'],
|
|
195
|
+
groupId: null
|
|
196
|
+
}).accessToken;
|
|
197
|
+
|
|
198
|
+
it('refuses one on a session-only route', () => {
|
|
199
|
+
const res = mockRes();
|
|
200
|
+
const next = jest.fn();
|
|
201
|
+
const req = mockReq({ headers: { authorization: `Bearer ${accessToken()}` } });
|
|
202
|
+
|
|
203
|
+
// It verifies — same secret — so nothing but the type check stands between
|
|
204
|
+
// an assistant and every route that has no scopes to be held to.
|
|
205
|
+
authenticateToken(req, res, next);
|
|
206
|
+
|
|
207
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
208
|
+
expect(next).not.toHaveBeenCalled();
|
|
209
|
+
expect(req.user).toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('treats one as no session at all on an optional-auth route', () => {
|
|
213
|
+
const next = jest.fn();
|
|
214
|
+
const req = mockReq({ headers: { authorization: `Bearer ${accessToken()}` } });
|
|
215
|
+
|
|
216
|
+
optionalAuth(req, mockRes(), next);
|
|
217
|
+
|
|
218
|
+
// The route still answers, in its public form — the same as a stale cookie.
|
|
219
|
+
expect(next).toHaveBeenCalled();
|
|
220
|
+
expect(req.user).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Request, Response, NextFunction } from 'express';
|
|
2
2
|
import jwt from 'jsonwebtoken';
|
|
3
3
|
import { requireEnv } from '../config/env';
|
|
4
|
+
import { isAccessTokenClaims } from '../oauth/tokens';
|
|
4
5
|
import { UserPayload } from '../types/auth';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -8,6 +9,13 @@ import { UserPayload } from '../types/auth';
|
|
|
8
9
|
* Verifies JWT token and attaches user info (and its embedded groups) to
|
|
9
10
|
* the request — typed via the global `Express.Request` augmentation in
|
|
10
11
|
* `../types/auth`, no `(req as any)` cast needed.
|
|
12
|
+
*
|
|
13
|
+
* OAuth access tokens are refused outright. They are signed with the same
|
|
14
|
+
* secret, so they verify here, but they mean something this middleware has no
|
|
15
|
+
* way to honour: an assistant acting within scopes, pinned to one tenant.
|
|
16
|
+
* Routes that software may legitimately reach use `authenticateAgent`, which
|
|
17
|
+
* enforces both — so anything still guarded by this one is a person's to do,
|
|
18
|
+
* and letting a token through would be handing an assistant the account.
|
|
11
19
|
*/
|
|
12
20
|
export function authenticateToken(req: Request, res: Response, next: NextFunction): void {
|
|
13
21
|
try {
|
|
@@ -19,7 +27,16 @@ export function authenticateToken(req: Request, res: Response, next: NextFunctio
|
|
|
19
27
|
return;
|
|
20
28
|
}
|
|
21
29
|
|
|
22
|
-
const
|
|
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/tokens.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { isApiKeyScope, type ApiKeyScope } from '../apiKeys/types';
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/** Marks a token as issued by the OAuth flow, for the MCP resource specifically. */
|
|
18
|
-
const
|
|
18
|
+
export const ACCESS_TOKEN_TYPE = 'mcp_access';
|
|
19
19
|
|
|
20
20
|
/** Short enough that a leaked token is a small window, long enough to be usable. */
|
|
21
21
|
const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
|
|
@@ -31,7 +31,7 @@ export interface AccessTokenClaims {
|
|
|
31
31
|
groupId: string | null;
|
|
32
32
|
email: string;
|
|
33
33
|
name: string;
|
|
34
|
-
typ: typeof
|
|
34
|
+
typ: typeof ACCESS_TOKEN_TYPE;
|
|
35
35
|
jti: string;
|
|
36
36
|
exp: number;
|
|
37
37
|
iat: number;
|
|
@@ -66,7 +66,7 @@ export const mintAccessToken = (input: MintAccessTokenInput): MintedAccessToken
|
|
|
66
66
|
groupId: input.groupId,
|
|
67
67
|
email: input.email,
|
|
68
68
|
name: input.name,
|
|
69
|
-
typ:
|
|
69
|
+
typ: ACCESS_TOKEN_TYPE,
|
|
70
70
|
jti: randomUUID()
|
|
71
71
|
},
|
|
72
72
|
requireEnv('JWT_SECRET'),
|
|
@@ -108,7 +108,7 @@ export const verifyAccessToken = (
|
|
|
108
108
|
return { ok: false, rejection: expired ? 'expired' : 'bad-signature' };
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
if (claims
|
|
111
|
+
if (!isAccessTokenClaims(claims)) return { ok: false, rejection: 'wrong-type' };
|
|
112
112
|
if (claims.aud !== expectedAudience) return { ok: false, rejection: 'wrong-audience' };
|
|
113
113
|
if (!claims.sub) return { ok: false, rejection: 'malformed' };
|
|
114
114
|
|
|
@@ -122,6 +122,20 @@ export const verifyAccessToken = (
|
|
|
122
122
|
};
|
|
123
123
|
};
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Tell an OAuth access token apart from an ordinary session JWT.
|
|
127
|
+
*
|
|
128
|
+
* They are signed with the same secret and arrive in the same header, so
|
|
129
|
+
* anything holding one has to ask which it got. Getting this wrong is not a
|
|
130
|
+
* subtle failure: read as a session, an access token has no `id` and no
|
|
131
|
+
* `groups`, so `req.user.id` lands as `undefined` and every scoped query goes
|
|
132
|
+
* out unbounded.
|
|
133
|
+
*/
|
|
134
|
+
export const isAccessTokenClaims = (claims: unknown): claims is AccessTokenClaims =>
|
|
135
|
+
typeof claims === 'object' &&
|
|
136
|
+
claims !== null &&
|
|
137
|
+
(claims as AccessTokenClaims).typ === ACCESS_TOKEN_TYPE;
|
|
138
|
+
|
|
125
139
|
/** Parse a space-separated `scope` parameter, dropping anything we do not define. */
|
|
126
140
|
export const parseScopes = (scope: unknown): ApiKeyScope[] =>
|
|
127
141
|
typeof scope === 'string' ? scope.split(/\s+/).filter(isApiKeyScope) : [];
|