@tumbaland/backend-core 1.36.0 → 1.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apiKeys/ApiKey.d.ts +12 -3
- package/dist/apiKeys/ApiKey.d.ts.map +1 -1
- package/dist/apiKeys/ApiKey.js +5 -1
- package/dist/apiKeys/ApiKey.js.map +1 -1
- package/dist/apiKeys/index.d.ts +2 -2
- package/dist/apiKeys/index.d.ts.map +1 -1
- package/dist/apiKeys/index.js +5 -1
- package/dist/apiKeys/index.js.map +1 -1
- package/dist/apiKeys/middleware.d.ts +9 -5
- package/dist/apiKeys/middleware.d.ts.map +1 -1
- package/dist/apiKeys/middleware.js +125 -59
- package/dist/apiKeys/middleware.js.map +1 -1
- package/dist/apiKeys/service.d.ts +5 -3
- package/dist/apiKeys/service.d.ts.map +1 -1
- package/dist/apiKeys/service.js +10 -3
- package/dist/apiKeys/service.js.map +1 -1
- package/dist/apiKeys/types.d.ts +41 -8
- package/dist/apiKeys/types.d.ts.map +1 -1
- package/dist/apiKeys/types.js +35 -1
- package/dist/apiKeys/types.js.map +1 -1
- package/dist/middleware/authMiddleware.d.ts +7 -0
- package/dist/middleware/authMiddleware.d.ts.map +1 -1
- package/dist/middleware/authMiddleware.js +25 -4
- package/dist/middleware/authMiddleware.js.map +1 -1
- package/dist/oauth/index.d.ts +1 -1
- package/dist/oauth/index.d.ts.map +1 -1
- package/dist/oauth/index.js +3 -1
- package/dist/oauth/index.js.map +1 -1
- package/dist/oauth/models.d.ts +13 -2
- package/dist/oauth/models.d.ts.map +1 -1
- package/dist/oauth/models.js +9 -0
- package/dist/oauth/models.js.map +1 -1
- package/dist/oauth/service.d.ts +15 -8
- package/dist/oauth/service.d.ts.map +1 -1
- package/dist/oauth/service.js +10 -3
- package/dist/oauth/service.js.map +1 -1
- package/dist/oauth/tokens.d.ts +34 -8
- package/dist/oauth/tokens.d.ts.map +1 -1
- package/dist/oauth/tokens.js +22 -6
- package/dist/oauth/tokens.js.map +1 -1
- package/package.json +1 -1
- package/src/apiKeys/ApiKey.ts +16 -4
- package/src/apiKeys/index.ts +16 -2
- package/src/apiKeys/middleware.test.ts +207 -16
- package/src/apiKeys/middleware.ts +159 -66
- package/src/apiKeys/service.test.ts +56 -9
- package/src/apiKeys/service.ts +24 -6
- package/src/apiKeys/types.ts +61 -8
- package/src/middleware/authMiddleware.test.ts +52 -0
- package/src/middleware/authMiddleware.ts +26 -4
- package/src/oauth/index.ts +7 -1
- package/src/oauth/models.ts +21 -2
- package/src/oauth/service.test.ts +58 -6
- package/src/oauth/service.ts +25 -7
- package/src/oauth/tokens.test.ts +20 -5
- package/src/oauth/tokens.ts +51 -11
|
@@ -2,10 +2,19 @@ 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
|
|
9
|
+
import {
|
|
10
|
+
PERSONAL_TENANT,
|
|
11
|
+
groupIdsOf,
|
|
12
|
+
isApiKeyScope,
|
|
13
|
+
readTenants,
|
|
14
|
+
type ApiKeyScope,
|
|
15
|
+
type ApiKeyTenant,
|
|
16
|
+
type Tenant
|
|
17
|
+
} from './types';
|
|
9
18
|
|
|
10
19
|
/**
|
|
11
20
|
* Request-scoped facts about the key a request arrived on. Absent on ordinary
|
|
@@ -15,8 +24,10 @@ import type { ApiKeyScope } from './types';
|
|
|
15
24
|
export interface ApiKeyContext {
|
|
16
25
|
keyId: string;
|
|
17
26
|
scopes: ApiKeyScope[];
|
|
18
|
-
/**
|
|
19
|
-
|
|
27
|
+
/** every tenant this credential may act in, and the one it acts in by default */
|
|
28
|
+
tenants: ApiKeyTenant;
|
|
29
|
+
/** the tenant this particular request resolved to */
|
|
30
|
+
actingAs: Tenant;
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
declare global {
|
|
@@ -39,10 +50,12 @@ const unauthorized = (res: Response): void => {
|
|
|
39
50
|
* The tenant a request is asking to act in, wherever it named one.
|
|
40
51
|
*
|
|
41
52
|
* Handlers read `groupId` from either the query string or the body depending on
|
|
42
|
-
* the verb, so both are checked — a
|
|
43
|
-
* no
|
|
53
|
+
* the verb, so both are checked — a rule that only covered one of them would be
|
|
54
|
+
* no rule at all. A caller names the personal tenant with the literal
|
|
55
|
+
* `personal`, since "no group" is what the resolved default gets written into
|
|
56
|
+
* and would otherwise be indistinguishable from not having asked.
|
|
44
57
|
*/
|
|
45
|
-
const
|
|
58
|
+
const requestedTenant = (req: Request): Tenant | undefined => {
|
|
46
59
|
const fromQuery = req.query?.groupId;
|
|
47
60
|
if (typeof fromQuery === 'string' && fromQuery.length > 0) return fromQuery;
|
|
48
61
|
|
|
@@ -52,37 +65,112 @@ const requestedGroupId = (req: Request): string | undefined => {
|
|
|
52
65
|
return undefined;
|
|
53
66
|
};
|
|
54
67
|
|
|
68
|
+
/** Express 5 makes `req.query` a getter, so it is redefined rather than assigned. */
|
|
69
|
+
const setQueryGroupId = (req: Request, groupId: string | undefined): void => {
|
|
70
|
+
const query = { ...req.query } as Record<string, unknown>;
|
|
71
|
+
if (groupId === undefined) delete query.groupId;
|
|
72
|
+
else query.groupId = groupId;
|
|
73
|
+
|
|
74
|
+
Object.defineProperty(req, 'query', {
|
|
75
|
+
value: query,
|
|
76
|
+
writable: true,
|
|
77
|
+
configurable: true,
|
|
78
|
+
enumerable: true
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
55
82
|
/**
|
|
56
|
-
* Hold a
|
|
83
|
+
* Hold a credential to the tenants it was granted, and settle which one this
|
|
84
|
+
* request is acting in.
|
|
85
|
+
*
|
|
86
|
+
* A request naming a tenant outside the grant is refused outright — that
|
|
87
|
+
* boundary is the whole reason a credential is safe to hand to an agent, and it
|
|
88
|
+
* is unchanged by the grant being a set rather than one. What the set adds is a
|
|
89
|
+
* choice *inside* it, which is what lets one connection reach a shared journal
|
|
90
|
+
* and a private photo library without reconnecting.
|
|
57
91
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* none has the pinned tenant written in for it, so the agent never has to know a
|
|
62
|
-
* group id exists, and a handler's `if (groupId) ... else personal` branch lands
|
|
63
|
-
* where the key's owner intended.
|
|
92
|
+
* A request that names nothing gets the default written in for it, so an agent
|
|
93
|
+
* granted a single tenant never has to know a group id exists and a handler's
|
|
94
|
+
* `if (groupId) … else personal` branch lands where the owner intended.
|
|
64
95
|
*/
|
|
65
|
-
const
|
|
66
|
-
const requested =
|
|
67
|
-
|
|
68
|
-
if (requested
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (req.body && typeof req.body === 'object') {
|
|
79
|
-
(req.body as Record<string, unknown>).groupId = groupId;
|
|
80
|
-
}
|
|
96
|
+
const applyTenantGrant = (req: Request, tenants: ApiKeyTenant): Tenant | null => {
|
|
97
|
+
const requested = requestedTenant(req) ?? tenants.default;
|
|
98
|
+
|
|
99
|
+
if (!tenants.allowed.includes(requested)) return null;
|
|
100
|
+
|
|
101
|
+
// Resolved either way, including when the caller asked for exactly what the
|
|
102
|
+
// default already was: handlers read the personal tenant as an absent
|
|
103
|
+
// `groupId`, so `personal` has to be erased rather than passed through.
|
|
104
|
+
const groupId = requested === PERSONAL_TENANT ? undefined : requested;
|
|
105
|
+
setQueryGroupId(req, groupId);
|
|
106
|
+
if (req.body && typeof req.body === 'object') {
|
|
107
|
+
if (groupId === undefined) delete (req.body as Record<string, unknown>).groupId;
|
|
108
|
+
else (req.body as Record<string, unknown>).groupId = groupId;
|
|
81
109
|
}
|
|
82
110
|
|
|
83
|
-
return
|
|
111
|
+
return requested;
|
|
84
112
|
};
|
|
85
113
|
|
|
114
|
+
/** A verified non-session credential, whichever kind it arrived as. */
|
|
115
|
+
interface AgentCredential {
|
|
116
|
+
/** the key id or token id, for the request-scoped context */
|
|
117
|
+
credentialId: string;
|
|
118
|
+
userId: string;
|
|
119
|
+
email: string;
|
|
120
|
+
name: string;
|
|
121
|
+
scopes: ApiKeyScope[];
|
|
122
|
+
tenants: ApiKeyTenant;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Admit software acting for a user, on the terms its credential carries.
|
|
127
|
+
*
|
|
128
|
+
* Shared by both non-session credentials on purpose. An API key and an OAuth
|
|
129
|
+
* access token differ entirely in how they are issued and verified, and not at
|
|
130
|
+
* all in what they mean once they are: a user, a set of scopes, and one tenant.
|
|
131
|
+
* Settling that in one place is what keeps the two from drifting into subtly
|
|
132
|
+
* different amounts of access.
|
|
133
|
+
*/
|
|
134
|
+
function admitAgent(
|
|
135
|
+
req: Request,
|
|
136
|
+
res: Response,
|
|
137
|
+
next: NextFunction,
|
|
138
|
+
requiredScopes: ApiKeyScope[],
|
|
139
|
+
credential: AgentCredential
|
|
140
|
+
): void {
|
|
141
|
+
const missing = requiredScopes.filter((scope) => !credential.scopes.includes(scope));
|
|
142
|
+
if (missing.length > 0) {
|
|
143
|
+
res.status(403).json({
|
|
144
|
+
success: false,
|
|
145
|
+
message: `Credential is missing required scope: ${missing.join(', ')}`
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const actingAs = applyTenantGrant(req, credential.tenants);
|
|
151
|
+
if (actingAs === null) {
|
|
152
|
+
res.status(403).json({
|
|
153
|
+
success: false,
|
|
154
|
+
message: 'Credential is not permitted to act in the requested group'
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
req.user = { id: credential.userId, email: credential.email, name: credential.name };
|
|
160
|
+
// Only the granted groups, never the owner's full membership: this is what
|
|
161
|
+
// stops a credential reaching a group it was not issued for through any
|
|
162
|
+
// handler that consults `userGroups` instead of the `groupId` parameter.
|
|
163
|
+
req.userGroups = groupIdsOf(credential.tenants.allowed);
|
|
164
|
+
req.apiKey = {
|
|
165
|
+
keyId: credential.credentialId,
|
|
166
|
+
scopes: credential.scopes,
|
|
167
|
+
tenants: credential.tenants,
|
|
168
|
+
actingAs
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
next();
|
|
172
|
+
}
|
|
173
|
+
|
|
86
174
|
/**
|
|
87
175
|
* Authenticate a request that software may legitimately be making.
|
|
88
176
|
*
|
|
@@ -104,14 +192,40 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
|
|
|
104
192
|
}
|
|
105
193
|
|
|
106
194
|
if (!looksLikeApiKey(token)) {
|
|
195
|
+
let claims: UserPayload | AccessTokenClaims;
|
|
107
196
|
try {
|
|
108
|
-
|
|
197
|
+
claims = jwt.verify(token, requireEnv('JWT_SECRET')) as UserPayload | AccessTokenClaims;
|
|
198
|
+
} catch {
|
|
199
|
+
unauthorized(res);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!isAccessTokenClaims(claims)) {
|
|
204
|
+
const user = claims as UserPayload;
|
|
109
205
|
req.user = user;
|
|
110
206
|
req.userGroups = user.groups ?? [];
|
|
111
207
|
next();
|
|
112
|
-
|
|
113
|
-
unauthorized(res);
|
|
208
|
+
return;
|
|
114
209
|
}
|
|
210
|
+
|
|
211
|
+
// An OAuth access token: signed with the same secret as a session and
|
|
212
|
+
// arriving in the same header, but nothing like one. It names its user in
|
|
213
|
+
// `sub`, carries scopes, and is pinned to a tenant — so it is admitted
|
|
214
|
+
// the way a key is, not the way a person is.
|
|
215
|
+
//
|
|
216
|
+
// Reading it as a session was the original bug and it failed quietly: the
|
|
217
|
+
// claims have no `id` and no `groups`, so `req.user.id` arrived as
|
|
218
|
+
// `undefined`, scoped writes were refused as inaccessible, and a read
|
|
219
|
+
// whose scope collapsed to nothing fell through to "no user context" and
|
|
220
|
+
// answered from the whole collection.
|
|
221
|
+
admitAgent(req, res, next, requiredScopes, {
|
|
222
|
+
credentialId: claims.jti,
|
|
223
|
+
userId: claims.sub,
|
|
224
|
+
email: claims.email ?? '',
|
|
225
|
+
name: claims.name ?? '',
|
|
226
|
+
scopes: (claims.scope ?? '').split(' ').filter(isApiKeyScope),
|
|
227
|
+
tenants: readTenants(claims)
|
|
228
|
+
});
|
|
115
229
|
return;
|
|
116
230
|
}
|
|
117
231
|
|
|
@@ -124,37 +238,14 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
|
|
|
124
238
|
return;
|
|
125
239
|
}
|
|
126
240
|
|
|
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!,
|
|
241
|
+
admitAgent(req, res, next, requiredScopes, {
|
|
242
|
+
credentialId: result.keyId!,
|
|
243
|
+
userId: result.userId!,
|
|
148
244
|
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();
|
|
245
|
+
name: result.userName ?? '',
|
|
246
|
+
scopes: result.scopes ?? [],
|
|
247
|
+
tenants: result.tenants ?? readTenants({})
|
|
248
|
+
});
|
|
158
249
|
};
|
|
159
250
|
|
|
160
251
|
/**
|
|
@@ -166,7 +257,9 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
|
|
|
166
257
|
* door for lacking a scope half the router never uses.
|
|
167
258
|
*
|
|
168
259
|
* A session passes unconditionally: scopes narrow what software may do on a
|
|
169
|
-
* person's behalf, not what the person may do themselves.
|
|
260
|
+
* person's behalf, not what the person may do themselves. Everything that is
|
|
261
|
+
* not a session — an API key or an OAuth access token alike — arrives with
|
|
262
|
+
* `req.apiKey` set and is held to it.
|
|
170
263
|
*/
|
|
171
264
|
export const requireScope = (...requiredScopes: ApiKeyScope[]): RequestHandler =>
|
|
172
265
|
function requireScopeHandler(req, res, next) {
|
|
@@ -179,7 +272,7 @@ export const requireScope = (...requiredScopes: ApiKeyScope[]): RequestHandler =
|
|
|
179
272
|
if (missing.length > 0) {
|
|
180
273
|
res.status(403).json({
|
|
181
274
|
success: false,
|
|
182
|
-
message: `
|
|
275
|
+
message: `Credential is missing required scope: ${missing.join(', ')}`
|
|
183
276
|
});
|
|
184
277
|
return;
|
|
185
278
|
}
|
|
@@ -188,7 +281,7 @@ export const requireScope = (...requiredScopes: ApiKeyScope[]): RequestHandler =
|
|
|
188
281
|
};
|
|
189
282
|
|
|
190
283
|
/**
|
|
191
|
-
* Refuse
|
|
284
|
+
* Refuse every non-session credential on a route that a session may still use.
|
|
192
285
|
*
|
|
193
286
|
* For the handful of operations that should stay a person's to perform — key
|
|
194
287
|
* management itself, most obviously, since a key that can mint keys is a key
|
|
@@ -11,6 +11,7 @@ jest.mock('./ApiKey', () => ({
|
|
|
11
11
|
}));
|
|
12
12
|
|
|
13
13
|
import { ApiKey } from './ApiKey';
|
|
14
|
+
import { PERSONAL_TENANT } from './types';
|
|
14
15
|
import {
|
|
15
16
|
createApiKey,
|
|
16
17
|
listApiKeys,
|
|
@@ -43,7 +44,8 @@ function storedKey(over: Record<string, unknown> = {}) {
|
|
|
43
44
|
sealedIv: 'iv',
|
|
44
45
|
sealedTag: 'tag',
|
|
45
46
|
scopes: ['relationship:read'],
|
|
46
|
-
|
|
47
|
+
tenants: [PERSONAL_TENANT],
|
|
48
|
+
defaultTenant: PERSONAL_TENANT,
|
|
47
49
|
revealCount: 0,
|
|
48
50
|
lastRevealedAt: undefined as Date | undefined,
|
|
49
51
|
createdAt: CREATED_AT,
|
|
@@ -98,14 +100,30 @@ describe('createApiKey', () => {
|
|
|
98
100
|
expect(stored.sealedTag).toBeTruthy();
|
|
99
101
|
});
|
|
100
102
|
|
|
101
|
-
it('defaults
|
|
103
|
+
it('defaults a key with no stated tenants to the owner’s personal data', async () => {
|
|
102
104
|
await createApiKey(input);
|
|
103
|
-
expect(mockedCreate.mock.calls[0][0].
|
|
105
|
+
expect(mockedCreate.mock.calls[0][0].tenants).toEqual([PERSONAL_TENANT]);
|
|
106
|
+
expect(mockedCreate.mock.calls[0][0].defaultTenant).toBe(PERSONAL_TENANT);
|
|
104
107
|
});
|
|
105
108
|
|
|
106
|
-
it('
|
|
107
|
-
await createApiKey({ ...input,
|
|
108
|
-
|
|
109
|
+
it('stores every tenant a key was granted', async () => {
|
|
110
|
+
await createApiKey({ ...input, tenants: [PERSONAL_TENANT, 'g1'], defaultTenant: 'g1' });
|
|
111
|
+
|
|
112
|
+
expect(mockedCreate.mock.calls[0][0].tenants).toEqual([PERSONAL_TENANT, 'g1']);
|
|
113
|
+
expect(mockedCreate.mock.calls[0][0].defaultTenant).toBe('g1');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('refuses to store a default outside the grant', async () => {
|
|
117
|
+
// A key acting by default in something it cannot reach would authenticate
|
|
118
|
+
// and then fail every call that did not name a tenant explicitly.
|
|
119
|
+
await createApiKey({ ...input, tenants: ['g1'], defaultTenant: 'g2' });
|
|
120
|
+
|
|
121
|
+
expect(mockedCreate.mock.calls[0][0].defaultTenant).toBe('g1');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('reads an empty grant as the safest non-empty one', async () => {
|
|
125
|
+
await createApiKey({ ...input, tenants: [] });
|
|
126
|
+
expect(mockedCreate.mock.calls[0][0].tenants).toEqual([PERSONAL_TENANT]);
|
|
109
127
|
});
|
|
110
128
|
|
|
111
129
|
it('snapshots the owner so verification needs no second query', async () => {
|
|
@@ -311,10 +329,14 @@ describe('verifyApiKey', () => {
|
|
|
311
329
|
expect(mockedFindOne).toHaveBeenCalledWith({ keyId: parseKey(token)!.id });
|
|
312
330
|
});
|
|
313
331
|
|
|
314
|
-
it('returns the identity, scopes and
|
|
332
|
+
it('returns the identity, scopes and tenants a valid key carries', async () => {
|
|
315
333
|
const { token } = generateKey();
|
|
316
334
|
mockedFindOne.mockResolvedValue(
|
|
317
|
-
acceptingKey(token, {
|
|
335
|
+
acceptingKey(token, {
|
|
336
|
+
scopes: ['relationship:read', 'album:write'],
|
|
337
|
+
tenants: [PERSONAL_TENANT, 'g1'],
|
|
338
|
+
defaultTenant: 'g1'
|
|
339
|
+
})
|
|
318
340
|
);
|
|
319
341
|
|
|
320
342
|
await expect(verifyApiKey(token)).resolves.toEqual({
|
|
@@ -324,7 +346,32 @@ describe('verifyApiKey', () => {
|
|
|
324
346
|
userName: 'Tester',
|
|
325
347
|
keyId: 'k1',
|
|
326
348
|
scopes: ['relationship:read', 'album:write'],
|
|
327
|
-
|
|
349
|
+
tenants: { allowed: [PERSONAL_TENANT, 'g1'], default: 'g1' }
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('reads a key issued before tenants were a set', async () => {
|
|
354
|
+
// Keys stored under the single-tenant model carry `groupId` and no array.
|
|
355
|
+
// Without this fallback every connected assistant would break at once on
|
|
356
|
+
// the deploy that introduced the set.
|
|
357
|
+
const { token } = generateKey();
|
|
358
|
+
mockedFindOne.mockResolvedValue(
|
|
359
|
+
acceptingKey(token, { tenants: undefined, defaultTenant: undefined, groupId: 'g1' })
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
await expect(verifyApiKey(token)).resolves.toMatchObject({
|
|
363
|
+
tenants: { allowed: ['g1'], default: 'g1' }
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('reads an old personal key as the personal tenant, not as no tenant', async () => {
|
|
368
|
+
const { token } = generateKey();
|
|
369
|
+
mockedFindOne.mockResolvedValue(
|
|
370
|
+
acceptingKey(token, { tenants: undefined, defaultTenant: undefined, groupId: null })
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
await expect(verifyApiKey(token)).resolves.toMatchObject({
|
|
374
|
+
tenants: { allowed: [PERSONAL_TENANT], default: PERSONAL_TENANT }
|
|
328
375
|
});
|
|
329
376
|
});
|
|
330
377
|
|
package/src/apiKeys/service.ts
CHANGED
|
@@ -7,7 +7,14 @@ import {
|
|
|
7
7
|
parseKey,
|
|
8
8
|
secretMatches
|
|
9
9
|
} from './crypto';
|
|
10
|
-
import
|
|
10
|
+
import {
|
|
11
|
+
PERSONAL_TENANT,
|
|
12
|
+
readTenants,
|
|
13
|
+
type ApiKeyScope,
|
|
14
|
+
type ApiKeySummary,
|
|
15
|
+
type ApiKeyVerification,
|
|
16
|
+
type Tenant
|
|
17
|
+
} from './types';
|
|
11
18
|
|
|
12
19
|
export interface CreateApiKeyInput {
|
|
13
20
|
userId: string;
|
|
@@ -15,8 +22,10 @@ export interface CreateApiKeyInput {
|
|
|
15
22
|
userName: string;
|
|
16
23
|
name: string;
|
|
17
24
|
scopes: ApiKeyScope[];
|
|
18
|
-
/**
|
|
19
|
-
|
|
25
|
+
/** every tenant the key may act in; defaults to the user's own data alone */
|
|
26
|
+
tenants?: Tenant[];
|
|
27
|
+
/** which of them it acts in when a request names none */
|
|
28
|
+
defaultTenant?: Tenant;
|
|
20
29
|
expiresAt?: Date | null;
|
|
21
30
|
}
|
|
22
31
|
|
|
@@ -31,7 +40,9 @@ const toSummary = (key: IApiKey): ApiKeySummary => ({
|
|
|
31
40
|
name: key.name,
|
|
32
41
|
prefix: displayPrefix(key.keyId),
|
|
33
42
|
scopes: key.scopes,
|
|
34
|
-
|
|
43
|
+
...(({ allowed, default: fallback }) => ({ tenants: allowed, defaultTenant: fallback }))(
|
|
44
|
+
readTenants(key)
|
|
45
|
+
),
|
|
35
46
|
createdAt: key.createdAt.toISOString(),
|
|
36
47
|
lastUsedAt: key.lastUsedAt?.toISOString() ?? null,
|
|
37
48
|
expiresAt: key.expiresAt?.toISOString() ?? null,
|
|
@@ -39,6 +50,10 @@ const toSummary = (key: IApiKey): ApiKeySummary => ({
|
|
|
39
50
|
});
|
|
40
51
|
|
|
41
52
|
export const createApiKey = async (input: CreateApiKeyInput): Promise<CreatedApiKey> => {
|
|
53
|
+
// A key that reaches nowhere would authenticate and then fail every call, so
|
|
54
|
+
// an empty grant is read as the safest non-empty one rather than stored.
|
|
55
|
+
const tenants = input.tenants?.length ? input.tenants : [PERSONAL_TENANT];
|
|
56
|
+
|
|
42
57
|
const { token, id, hash } = generateKey();
|
|
43
58
|
const sealed = encryptSecret(token);
|
|
44
59
|
|
|
@@ -53,7 +68,10 @@ export const createApiKey = async (input: CreateApiKeyInput): Promise<CreatedApi
|
|
|
53
68
|
sealedIv: sealed.iv,
|
|
54
69
|
sealedTag: sealed.tag,
|
|
55
70
|
scopes: input.scopes,
|
|
56
|
-
|
|
71
|
+
tenants,
|
|
72
|
+
defaultTenant: tenants.includes(input.defaultTenant ?? '')
|
|
73
|
+
? input.defaultTenant
|
|
74
|
+
: (tenants[0] as Tenant),
|
|
57
75
|
expiresAt: input.expiresAt ?? undefined
|
|
58
76
|
});
|
|
59
77
|
|
|
@@ -144,6 +162,6 @@ export const verifyApiKey = async (token: unknown): Promise<ApiKeyVerification>
|
|
|
144
162
|
userName: key.userName,
|
|
145
163
|
keyId: String(key._id),
|
|
146
164
|
scopes: key.scopes,
|
|
147
|
-
|
|
165
|
+
tenants: readTenants(key)
|
|
148
166
|
};
|
|
149
167
|
};
|
package/src/apiKeys/types.ts
CHANGED
|
@@ -24,17 +24,69 @@ export const isApiKeyScope = (value: unknown): value is ApiKeyScope =>
|
|
|
24
24
|
typeof value === 'string' && (API_KEY_SCOPES as readonly string[]).includes(value);
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
27
|
+
* A tenant a credential may act in: a group id, or the user's own data.
|
|
28
28
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
* Personal is a named sentinel rather than `null` so that a tenant is always a
|
|
30
|
+
* plain string — storable in an array, sendable in a JWT claim, comparable
|
|
31
|
+
* without a special case at every layer. It cannot collide with a real tenant:
|
|
32
|
+
* group ids are 24-character hex.
|
|
33
|
+
*/
|
|
34
|
+
export const PERSONAL_TENANT = 'personal';
|
|
35
|
+
|
|
36
|
+
export type Tenant = string;
|
|
37
|
+
|
|
38
|
+
/** The `groupId` a tenant corresponds to, as handlers have always read it. */
|
|
39
|
+
export const groupIdOf = (tenant: Tenant): string | null =>
|
|
40
|
+
tenant === PERSONAL_TENANT ? null : tenant;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Which tenants a credential may act in, and which one it acts in by default.
|
|
44
|
+
*
|
|
45
|
+
* The web app picks a tenant per request from a selector; an agent has no such
|
|
46
|
+
* UI. So the *set* is chosen once, by a person, and the credential cannot escape
|
|
47
|
+
* it — but within that set the caller may name one per request, which is what
|
|
48
|
+
* lets a single connection reach both a shared journal and a private photo
|
|
49
|
+
* library without reconnecting.
|
|
50
|
+
*
|
|
51
|
+
* `allowed` is never empty and always contains `default`. A credential granted
|
|
52
|
+
* exactly one tenant behaves precisely as a pinned one did: nothing to name,
|
|
53
|
+
* nothing to get wrong.
|
|
33
54
|
*/
|
|
34
55
|
export interface ApiKeyTenant {
|
|
35
|
-
|
|
56
|
+
allowed: Tenant[];
|
|
57
|
+
default: Tenant;
|
|
36
58
|
}
|
|
37
59
|
|
|
60
|
+
/** Just the groups, for the `req.userGroups` every handler already reads. */
|
|
61
|
+
export const groupIdsOf = (tenants: Tenant[]): string[] =>
|
|
62
|
+
tenants.filter((tenant) => tenant !== PERSONAL_TENANT);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read a stored tenant grant, tolerating one written before tenants were a set.
|
|
66
|
+
*
|
|
67
|
+
* Keys and grants issued under the old single-tenant model carry `groupId`
|
|
68
|
+
* alone. Falling back to it here means they keep working across the deploy
|
|
69
|
+
* rather than every connected assistant breaking at once.
|
|
70
|
+
*/
|
|
71
|
+
export const readTenants = (stored: {
|
|
72
|
+
tenants?: Tenant[] | null;
|
|
73
|
+
defaultTenant?: Tenant | null;
|
|
74
|
+
groupId?: string | null;
|
|
75
|
+
}): ApiKeyTenant => {
|
|
76
|
+
const allowed =
|
|
77
|
+
stored.tenants && stored.tenants.length > 0
|
|
78
|
+
? stored.tenants
|
|
79
|
+
: [stored.groupId ?? PERSONAL_TENANT];
|
|
80
|
+
|
|
81
|
+
const fallbackDefault = allowed[0] as Tenant;
|
|
82
|
+
const preferred = stored.defaultTenant ?? fallbackDefault;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
allowed,
|
|
86
|
+
default: allowed.includes(preferred) ? preferred : fallbackDefault
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
38
90
|
/** A key as the API hands it back — never including the secret. */
|
|
39
91
|
export interface ApiKeySummary {
|
|
40
92
|
id: string;
|
|
@@ -42,7 +94,8 @@ export interface ApiKeySummary {
|
|
|
42
94
|
/** the public half, shown in listings so a key is identifiable at a glance */
|
|
43
95
|
prefix: string;
|
|
44
96
|
scopes: ApiKeyScope[];
|
|
45
|
-
|
|
97
|
+
tenants: Tenant[];
|
|
98
|
+
defaultTenant: Tenant;
|
|
46
99
|
createdAt: string;
|
|
47
100
|
lastUsedAt: string | null;
|
|
48
101
|
expiresAt: string | null;
|
|
@@ -65,5 +118,5 @@ export interface ApiKeyVerification {
|
|
|
65
118
|
userName?: string;
|
|
66
119
|
keyId?: string;
|
|
67
120
|
scopes?: ApiKeyScope[];
|
|
68
|
-
|
|
121
|
+
tenants?: ApiKeyTenant;
|
|
69
122
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Request, Response } from 'express';
|
|
2
2
|
import jwt from 'jsonwebtoken';
|
|
3
3
|
import { authenticateToken, optionalAuth } from './authMiddleware';
|
|
4
|
+
import { PERSONAL_TENANT } from '../apiKeys/types';
|
|
5
|
+
import { mintAccessToken } from '../oauth/tokens';
|
|
4
6
|
import { UserPayload } from '../types/auth';
|
|
5
7
|
|
|
6
8
|
function mockReq(overrides: Partial<Request> = {}): Request {
|
|
@@ -171,3 +173,53 @@ describe('optionalAuth', () => {
|
|
|
171
173
|
expect(res.status).not.toHaveBeenCalled();
|
|
172
174
|
});
|
|
173
175
|
});
|
|
176
|
+
|
|
177
|
+
describe('OAuth access tokens are not sessions', () => {
|
|
178
|
+
const ORIGINAL_ENV = process.env;
|
|
179
|
+
|
|
180
|
+
beforeEach(() => {
|
|
181
|
+
process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-secret' };
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
afterEach(() => {
|
|
185
|
+
process.env = ORIGINAL_ENV;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const accessToken = () =>
|
|
189
|
+
mintAccessToken({
|
|
190
|
+
userId: 'u1',
|
|
191
|
+
email: 'u1@example.com',
|
|
192
|
+
name: 'Tester',
|
|
193
|
+
resource: 'https://mcp.example.com',
|
|
194
|
+
issuer: 'https://auth.example.com',
|
|
195
|
+
scopes: ['relationship:read'],
|
|
196
|
+
tenants: [PERSONAL_TENANT],
|
|
197
|
+
defaultTenant: PERSONAL_TENANT,
|
|
198
|
+
tenantNames: {}
|
|
199
|
+
}).accessToken;
|
|
200
|
+
|
|
201
|
+
it('refuses one on a session-only route', () => {
|
|
202
|
+
const res = mockRes();
|
|
203
|
+
const next = jest.fn();
|
|
204
|
+
const req = mockReq({ headers: { authorization: `Bearer ${accessToken()}` } });
|
|
205
|
+
|
|
206
|
+
// It verifies — same secret — so nothing but the type check stands between
|
|
207
|
+
// an assistant and every route that has no scopes to be held to.
|
|
208
|
+
authenticateToken(req, res, next);
|
|
209
|
+
|
|
210
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
211
|
+
expect(next).not.toHaveBeenCalled();
|
|
212
|
+
expect(req.user).toBeUndefined();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('treats one as no session at all on an optional-auth route', () => {
|
|
216
|
+
const next = jest.fn();
|
|
217
|
+
const req = mockReq({ headers: { authorization: `Bearer ${accessToken()}` } });
|
|
218
|
+
|
|
219
|
+
optionalAuth(req, mockRes(), next);
|
|
220
|
+
|
|
221
|
+
// The route still answers, in its public form — the same as a stale cookie.
|
|
222
|
+
expect(next).toHaveBeenCalled();
|
|
223
|
+
expect(req.user).toBeUndefined();
|
|
224
|
+
});
|
|
225
|
+
});
|