@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.
@@ -1,5 +1,5 @@
1
1
  import { RequestHandler } from 'express';
2
- import type { ApiKeyScope } from './types';
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 API keys on a route that a session may still use.
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;AAO1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3C;;;;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;AA0DD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB,GAAI,GAAG,gBAAgB,WAAW,EAAE,KAAG,cA4DlE,CAAC;AAEJ;;;;;;;;;;GAUG;AACH,eAAO,MAAM,YAAY,GAAI,GAAG,gBAAgB,WAAW,EAAE,KAAG,cAiB7D,CAAC;AAEJ;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,EAAE,cASzB,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
- const user = jsonwebtoken_1.default.verify(token, (0, env_1.requireEnv)('JWT_SECRET'));
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
- const scopes = result.scopes ?? [];
100
- const missing = requiredScopes.filter((scope) => !scopes.includes(scope));
101
- if (missing.length > 0) {
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
- // Only the pinned group, never the owner's full membership: this is what
122
- // stops a key reaching a group it was not issued for through any handler
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: `API key is missing required scope: ${missing.join(', ')}`
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 API keys on a route that a session may still use.
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":"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"}
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;AAK1D;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CAiBvF;AACD;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CAcnF"}
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 user = jsonwebtoken_1.default.verify(token, JWT_SECRET);
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 user = jsonwebtoken_1.default.verify(token, JWT_SECRET);
52
- req.user = user;
53
- req.userGroups = user.groups ?? [];
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":";;;;;AAWA,8CAiBC;AAcD,oCAcC;AAvDD,gEAA+B;AAC/B,uCAA2C;AAG3C;;;;;GAKG;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,IAAI,GAAG,sBAAG,CAAC,MAAM,CAAC,KAAK,EAAE,UAAU,CAAgB,CAAC;QAC1D,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,IAAI,GAAG,sBAAG,CAAC,MAAM,CAAC,KAAK,EAAE,UAAU,CAAgB,CAAC;YAC1D,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC;YAChB,GAAG,CAAC,UAAU,GAAG,IAAI,CAAC,MAAM,IAAI,EAAE,CAAC;QACrC,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,oCAAoC;IACtC,CAAC;IACD,IAAI,EAAE,CAAC;AACT,CAAC"}
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"}
@@ -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,EAAE,eAAe,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAC3E,YAAY,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,uBAAuB,EAAE,MAAM,UAAU,CAAC"}
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"}
@@ -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
@@ -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,mCAA2E;AAAlE,yGAAA,eAAe,OAAA;AAAE,2GAAA,iBAAiB,OAAA;AAAE,qGAAA,WAAW,OAAA"}
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"}
@@ -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 TOKEN_TYPE = "mcp_access";
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 TOKEN_TYPE;
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,QAAA,MAAM,UAAU,eAAe,CAAC;AAKhC,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,UAAU,CAAC;IACvB,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,qFAAqF;AACrF,eAAO,MAAM,WAAW,GAAI,OAAO,OAAO,KAAG,WAAW,EACmB,CAAC"}
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"}
@@ -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
- const TOKEN_TYPE = 'mcp_access';
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: TOKEN_TYPE,
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 (claims.typ !== TOKEN_TYPE)
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;
@@ -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;AACpF,MAAM,UAAU,GAAG,YAAY,CAAC;AAEhC,oFAAoF;AACpF,MAAM,wBAAwB,GAAG,EAAE,GAAG,EAAE,CAAC;AAoClC,MAAM,eAAe,GAAG,CAAC,KAA2B,EAAqB,EAAE;IAChF,MAAM,KAAK,GAAG,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAErC,MAAM,WAAW,GAAG,sBAAG,CAAC,IAAI,CAC1B;QACE,GAAG,EAAE,KAAK,CAAC,MAAM;QACjB,GAAG,EAAE,KAAK,CAAC,QAAQ;QACnB,GAAG,EAAE,KAAK,CAAC,MAAM;QACjB,KAAK;QACL,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,GAAG,EAAE,UAAU;QACf,GAAG,EAAE,IAAA,mBAAU,GAAE;KAClB,EACD,IAAA,gBAAU,EAAC,YAAY,CAAC,EACxB,EAAE,SAAS,EAAE,wBAAwB,EAAE,CACxC,CAAC;IAEF,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,wBAAwB,EAAE,KAAK,EAAE,CAAC;AACrE,CAAC,CAAC;AApBW,QAAA,eAAe,mBAoB1B;AAYF;;;;;;;;;GASG;AACI,MAAM,iBAAiB,GAAG,CAC/B,KAAa,EACb,gBAAwB,EACC,EAAE;IAC3B,IAAI,MAAyB,CAAC;IAC9B,IAAI,CAAC;QACH,MAAM,GAAG,sBAAG,CAAC,MAAM,CAAC,KAAK,EAAE,IAAA,gBAAU,EAAC,YAAY,CAAC,CAAsB,CAAC;IAC5E,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,OAAO,GAAI,KAAe,EAAE,IAAI,KAAK,mBAAmB,CAAC;QAC/D,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,eAAe,EAAE,CAAC;IACzE,CAAC;IAED,IAAI,MAAM,CAAC,GAAG,KAAK,UAAU;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,CAAC;IAC7E,IAAI,MAAM,CAAC,GAAG,KAAK,gBAAgB;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,gBAAgB,EAAE,CAAC;IACvF,IAAI,CAAC,MAAM,CAAC,GAAG;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,CAAC;IAE9D,OAAO;QACL,EAAE,EAAE,IAAI;QACR,MAAM,EAAE,MAAM,CAAC,GAAG;QAClB,KAAK,EAAE,MAAM,CAAC,KAAK,IAAI,EAAE;QACzB,IAAI,EAAE,MAAM,CAAC,IAAI,IAAI,EAAE;QACvB,MAAM,EAAE,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,qBAAa,CAAC;QAC7D,OAAO,EAAE,MAAM,CAAC,OAAO,IAAI,IAAI;KAChC,CAAC;AACJ,CAAC,CAAC;AAxBW,QAAA,iBAAiB,qBAwB5B;AAEF,qFAAqF;AAC9E,MAAM,WAAW,GAAG,CAAC,KAAc,EAAiB,EAAE,CAC3D,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,qBAAa,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;AAD/D,QAAA,WAAW,eACoD"}
1
+ {"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../src/oauth/tokens.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tumbaland/backend-core",
3
- "version": "1.36.0",
3
+ "version": "1.37.0",
4
4
  "description": "Core shared functionality for Tumbaland backend services",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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 { ApiKeyScope } from './types';
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
- const user = jwt.verify(token, requireEnv('JWT_SECRET')) as UserPayload;
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
- } catch {
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
- const scopes = result.scopes ?? [];
128
- const missing = requiredScopes.filter((scope) => !scopes.includes(scope));
129
- if (missing.length > 0) {
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
- // Only the pinned group, never the owner's full membership: this is what
152
- // stops a key reaching a group it was not issued for through any handler
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: `API key is missing required scope: ${missing.join(', ')}`
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 API keys on a route that a session may still use.
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 user = jwt.verify(token, JWT_SECRET) as UserPayload;
30
+ const claims = jwt.verify(token, JWT_SECRET);
31
+ if (isAccessTokenClaims(claims)) {
32
+ res.status(403).json({
33
+ success: false,
34
+ message: 'This operation requires an interactive session, not a connected assistant'
35
+ });
36
+ return;
37
+ }
38
+
39
+ const user = claims as UserPayload;
23
40
  req.user = user;
24
41
  req.userGroups = user.groups ?? [];
25
42
  next();
@@ -46,9 +63,14 @@ export function optionalAuth(req: Request, _res: Response, next: NextFunction):
46
63
  const token = req.cookies?.access_token || req.headers.authorization?.replace('Bearer ', '');
47
64
 
48
65
  if (token) {
49
- const user = jwt.verify(token, JWT_SECRET) as UserPayload;
50
- req.user = user;
51
- req.userGroups = user.groups ?? [];
66
+ const claims = jwt.verify(token, JWT_SECRET);
67
+ // An access token is not a session; treated as none, exactly like an
68
+ // expired cookie, so the route still answers with its public form.
69
+ if (!isAccessTokenClaims(claims)) {
70
+ const user = claims as UserPayload;
71
+ req.user = user;
72
+ req.userGroups = user.groups ?? [];
73
+ }
52
74
  }
53
75
  } catch {
54
76
  // Deliberately ignored — see above.
@@ -19,5 +19,11 @@ export type {
19
19
  CodeRejection,
20
20
  RefreshRedemption
21
21
  } from './service';
22
- export { mintAccessToken, verifyAccessToken, parseScopes } from './tokens';
22
+ export {
23
+ mintAccessToken,
24
+ verifyAccessToken,
25
+ parseScopes,
26
+ isAccessTokenClaims,
27
+ ACCESS_TOKEN_TYPE
28
+ } from './tokens';
23
29
  export type { AccessTokenClaims, MintAccessTokenInput, MintedAccessToken, AccessTokenVerification } from './tokens';
@@ -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 TOKEN_TYPE = 'mcp_access';
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 TOKEN_TYPE;
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: TOKEN_TYPE,
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.typ !== TOKEN_TYPE) return { ok: false, rejection: 'wrong-type' };
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) : [];