@tumbaland/backend-core 1.37.0 → 1.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apiKeys/ApiKey.d.ts +12 -3
- package/dist/apiKeys/ApiKey.d.ts.map +1 -1
- package/dist/apiKeys/ApiKey.js +5 -1
- package/dist/apiKeys/ApiKey.js.map +1 -1
- package/dist/apiKeys/index.d.ts +2 -2
- package/dist/apiKeys/index.d.ts.map +1 -1
- package/dist/apiKeys/index.js +5 -1
- package/dist/apiKeys/index.js.map +1 -1
- package/dist/apiKeys/middleware.d.ts +5 -3
- package/dist/apiKeys/middleware.d.ts.map +1 -1
- package/dist/apiKeys/middleware.js +56 -31
- package/dist/apiKeys/middleware.js.map +1 -1
- package/dist/apiKeys/service.d.ts +5 -3
- package/dist/apiKeys/service.d.ts.map +1 -1
- package/dist/apiKeys/service.js +10 -3
- package/dist/apiKeys/service.js.map +1 -1
- package/dist/apiKeys/types.d.ts +41 -8
- package/dist/apiKeys/types.d.ts.map +1 -1
- package/dist/apiKeys/types.js +35 -1
- package/dist/apiKeys/types.js.map +1 -1
- package/dist/oauth/models.d.ts +13 -2
- package/dist/oauth/models.d.ts.map +1 -1
- package/dist/oauth/models.js +9 -0
- package/dist/oauth/models.js.map +1 -1
- package/dist/oauth/service.d.ts +15 -8
- package/dist/oauth/service.d.ts.map +1 -1
- package/dist/oauth/service.js +10 -3
- package/dist/oauth/service.js.map +1 -1
- package/dist/oauth/tokens.d.ts +22 -5
- package/dist/oauth/tokens.d.ts.map +1 -1
- package/dist/oauth/tokens.js +5 -2
- package/dist/oauth/tokens.js.map +1 -1
- package/package.json +1 -1
- package/src/apiKeys/ApiKey.ts +16 -4
- package/src/apiKeys/index.ts +16 -2
- package/src/apiKeys/middleware.test.ts +103 -20
- package/src/apiKeys/middleware.ts +68 -35
- package/src/apiKeys/service.test.ts +56 -9
- package/src/apiKeys/service.ts +24 -6
- package/src/apiKeys/types.ts +61 -8
- package/src/middleware/authMiddleware.test.ts +4 -1
- package/src/oauth/models.ts +21 -2
- package/src/oauth/service.test.ts +58 -6
- package/src/oauth/service.ts +25 -7
- package/src/oauth/tokens.test.ts +20 -5
- package/src/oauth/tokens.ts +33 -7
|
@@ -6,7 +6,15 @@ import { isAccessTokenClaims, type AccessTokenClaims } from '../oauth/tokens';
|
|
|
6
6
|
import type { UserPayload } from '../types/auth';
|
|
7
7
|
import { looksLikeApiKey } from './crypto';
|
|
8
8
|
import { verifyApiKey } from './service';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
PERSONAL_TENANT,
|
|
11
|
+
groupIdsOf,
|
|
12
|
+
isApiKeyScope,
|
|
13
|
+
readTenants,
|
|
14
|
+
type ApiKeyScope,
|
|
15
|
+
type ApiKeyTenant,
|
|
16
|
+
type Tenant
|
|
17
|
+
} from './types';
|
|
10
18
|
|
|
11
19
|
/**
|
|
12
20
|
* Request-scoped facts about the key a request arrived on. Absent on ordinary
|
|
@@ -16,8 +24,10 @@ import { isApiKeyScope, type ApiKeyScope } from './types';
|
|
|
16
24
|
export interface ApiKeyContext {
|
|
17
25
|
keyId: string;
|
|
18
26
|
scopes: ApiKeyScope[];
|
|
19
|
-
/**
|
|
20
|
-
|
|
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;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
declare global {
|
|
@@ -40,10 +50,12 @@ const unauthorized = (res: Response): void => {
|
|
|
40
50
|
* The tenant a request is asking to act in, wherever it named one.
|
|
41
51
|
*
|
|
42
52
|
* Handlers read `groupId` from either the query string or the body depending on
|
|
43
|
-
* the verb, so both are checked — a
|
|
44
|
-
* 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.
|
|
45
57
|
*/
|
|
46
|
-
const
|
|
58
|
+
const requestedTenant = (req: Request): Tenant | undefined => {
|
|
47
59
|
const fromQuery = req.query?.groupId;
|
|
48
60
|
if (typeof fromQuery === 'string' && fromQuery.length > 0) return fromQuery;
|
|
49
61
|
|
|
@@ -53,35 +65,50 @@ const requestedGroupId = (req: Request): string | undefined => {
|
|
|
53
65
|
return undefined;
|
|
54
66
|
};
|
|
55
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
|
+
|
|
56
82
|
/**
|
|
57
|
-
* 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.
|
|
58
91
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* none has the pinned tenant written in for it, so the agent never has to know a
|
|
63
|
-
* group id exists, and a handler's `if (groupId) ... else personal` branch lands
|
|
64
|
-
* 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.
|
|
65
95
|
*/
|
|
66
|
-
const
|
|
67
|
-
const requested =
|
|
96
|
+
const applyTenantGrant = (req: Request, tenants: ApiKeyTenant): Tenant | null => {
|
|
97
|
+
const requested = requestedTenant(req) ?? tenants.default;
|
|
68
98
|
|
|
69
|
-
if (requested
|
|
99
|
+
if (!tenants.allowed.includes(requested)) return null;
|
|
70
100
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (req.body && typeof req.body === 'object') {
|
|
80
|
-
(req.body as Record<string, unknown>).groupId = groupId;
|
|
81
|
-
}
|
|
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;
|
|
82
109
|
}
|
|
83
110
|
|
|
84
|
-
return
|
|
111
|
+
return requested;
|
|
85
112
|
};
|
|
86
113
|
|
|
87
114
|
/** A verified non-session credential, whichever kind it arrived as. */
|
|
@@ -92,7 +119,7 @@ interface AgentCredential {
|
|
|
92
119
|
email: string;
|
|
93
120
|
name: string;
|
|
94
121
|
scopes: ApiKeyScope[];
|
|
95
|
-
|
|
122
|
+
tenants: ApiKeyTenant;
|
|
96
123
|
}
|
|
97
124
|
|
|
98
125
|
/**
|
|
@@ -120,7 +147,8 @@ function admitAgent(
|
|
|
120
147
|
return;
|
|
121
148
|
}
|
|
122
149
|
|
|
123
|
-
|
|
150
|
+
const actingAs = applyTenantGrant(req, credential.tenants);
|
|
151
|
+
if (actingAs === null) {
|
|
124
152
|
res.status(403).json({
|
|
125
153
|
success: false,
|
|
126
154
|
message: 'Credential is not permitted to act in the requested group'
|
|
@@ -129,11 +157,16 @@ function admitAgent(
|
|
|
129
157
|
}
|
|
130
158
|
|
|
131
159
|
req.user = { id: credential.userId, email: credential.email, name: credential.name };
|
|
132
|
-
// Only the
|
|
160
|
+
// Only the granted groups, never the owner's full membership: this is what
|
|
133
161
|
// stops a credential reaching a group it was not issued for through any
|
|
134
162
|
// handler that consults `userGroups` instead of the `groupId` parameter.
|
|
135
|
-
req.userGroups = credential.
|
|
136
|
-
req.apiKey = {
|
|
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
|
+
};
|
|
137
170
|
|
|
138
171
|
next();
|
|
139
172
|
}
|
|
@@ -191,7 +224,7 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
|
|
|
191
224
|
email: claims.email ?? '',
|
|
192
225
|
name: claims.name ?? '',
|
|
193
226
|
scopes: (claims.scope ?? '').split(' ').filter(isApiKeyScope),
|
|
194
|
-
|
|
227
|
+
tenants: readTenants(claims)
|
|
195
228
|
});
|
|
196
229
|
return;
|
|
197
230
|
}
|
|
@@ -211,7 +244,7 @@ export const authenticateAgent = (...requiredScopes: ApiKeyScope[]): RequestHand
|
|
|
211
244
|
email: result.userEmail ?? '',
|
|
212
245
|
name: result.userName ?? '',
|
|
213
246
|
scopes: result.scopes ?? [],
|
|
214
|
-
|
|
247
|
+
tenants: result.tenants ?? readTenants({})
|
|
215
248
|
});
|
|
216
249
|
};
|
|
217
250
|
|
|
@@ -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,7 @@
|
|
|
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';
|
|
4
5
|
import { mintAccessToken } from '../oauth/tokens';
|
|
5
6
|
import { UserPayload } from '../types/auth';
|
|
6
7
|
|
|
@@ -192,7 +193,9 @@ describe('OAuth access tokens are not sessions', () => {
|
|
|
192
193
|
resource: 'https://mcp.example.com',
|
|
193
194
|
issuer: 'https://auth.example.com',
|
|
194
195
|
scopes: ['relationship:read'],
|
|
195
|
-
|
|
196
|
+
tenants: [PERSONAL_TENANT],
|
|
197
|
+
defaultTenant: PERSONAL_TENANT,
|
|
198
|
+
tenantNames: {}
|
|
196
199
|
}).accessToken;
|
|
197
200
|
|
|
198
201
|
it('refuses one on a session-only route', () => {
|
package/src/oauth/models.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import mongoose, { Document, Schema } from 'mongoose';
|
|
2
|
+
import { PERSONAL_TENANT, type Tenant } from '../apiKeys/types';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* A client registered through RFC 7591 Dynamic Client Registration.
|
|
@@ -46,7 +47,13 @@ export interface IAuthorizationCode extends Document {
|
|
|
46
47
|
userName: string;
|
|
47
48
|
redirectUri: string;
|
|
48
49
|
scopes: string[];
|
|
49
|
-
|
|
50
|
+
/** every tenant the user approved, and the one chosen as the default */
|
|
51
|
+
tenants: Tenant[];
|
|
52
|
+
defaultTenant: Tenant;
|
|
53
|
+
/** what each approved tenant is called, snapshotted at consent */
|
|
54
|
+
tenantNames: Record<Tenant, string>;
|
|
55
|
+
/** the single tenant an older grant was pinned to; read by `readTenants` */
|
|
56
|
+
groupId?: string | null;
|
|
50
57
|
/** RFC 8707: the MCP server this will be minted for */
|
|
51
58
|
resource: string;
|
|
52
59
|
/** PKCE, always S256 — the spec does not allow `plain` */
|
|
@@ -66,6 +73,10 @@ const AuthorizationCodeSchema = new Schema<IAuthorizationCode>(
|
|
|
66
73
|
userName: { type: String, default: '' },
|
|
67
74
|
redirectUri: { type: String, required: true },
|
|
68
75
|
scopes: { type: [String], default: [] },
|
|
76
|
+
tenants: { type: [String], default: () => [PERSONAL_TENANT] },
|
|
77
|
+
defaultTenant: { type: String, default: PERSONAL_TENANT },
|
|
78
|
+
tenantNames: { type: Schema.Types.Mixed, default: () => ({}) },
|
|
79
|
+
// Written by the single-tenant model this replaced; read-only now.
|
|
69
80
|
groupId: { type: String, default: null },
|
|
70
81
|
resource: { type: String, required: true },
|
|
71
82
|
codeChallenge: { type: String, required: true },
|
|
@@ -96,7 +107,11 @@ export interface IRefreshToken extends Document {
|
|
|
96
107
|
clientId: string;
|
|
97
108
|
userId: string;
|
|
98
109
|
scopes: string[];
|
|
99
|
-
|
|
110
|
+
tenants: Tenant[];
|
|
111
|
+
defaultTenant: Tenant;
|
|
112
|
+
tenantNames: Record<Tenant, string>;
|
|
113
|
+
/** the single tenant an older grant was pinned to; read by `readTenants` */
|
|
114
|
+
groupId?: string | null;
|
|
100
115
|
resource: string;
|
|
101
116
|
/**
|
|
102
117
|
* When the user actually connected this assistant.
|
|
@@ -118,6 +133,10 @@ const RefreshTokenSchema = new Schema<IRefreshToken>(
|
|
|
118
133
|
clientId: { type: String, required: true },
|
|
119
134
|
userId: { type: String, required: true, index: true },
|
|
120
135
|
scopes: { type: [String], default: [] },
|
|
136
|
+
tenants: { type: [String], default: () => [PERSONAL_TENANT] },
|
|
137
|
+
defaultTenant: { type: String, default: PERSONAL_TENANT },
|
|
138
|
+
tenantNames: { type: Schema.Types.Mixed, default: () => ({}) },
|
|
139
|
+
// Written by the single-tenant model this replaced; read-only now.
|
|
121
140
|
groupId: { type: String, default: null },
|
|
122
141
|
resource: { type: String, required: true },
|
|
123
142
|
grantedAt: { type: Date, default: Date.now },
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
|
+
import { PERSONAL_TENANT } from '../apiKeys/types';
|
|
2
3
|
|
|
3
4
|
jest.mock('./models', () => ({
|
|
4
5
|
OAuthClient: { create: jest.fn(), findOne: jest.fn(), find: jest.fn() },
|
|
@@ -122,13 +123,21 @@ describe('issueAuthorizationCode', () => {
|
|
|
122
123
|
userName: 'Tester',
|
|
123
124
|
redirectUri: 'https://claude.ai/callback',
|
|
124
125
|
scopes: ['relationship:read'],
|
|
125
|
-
|
|
126
|
+
tenants: ['g1'],
|
|
127
|
+
defaultTenant: 'g1',
|
|
128
|
+
tenantNames: { g1: 'Irja & Tom' },
|
|
126
129
|
resource: 'https://mcp.tumbaland.eu',
|
|
127
130
|
codeChallenge: CHALLENGE
|
|
128
131
|
});
|
|
129
132
|
|
|
130
133
|
const [doc] = mockedCodeCreate.mock.calls[0];
|
|
131
|
-
expect(doc).toMatchObject({
|
|
134
|
+
expect(doc).toMatchObject({
|
|
135
|
+
userId: 'u1',
|
|
136
|
+
tenants: ['g1'],
|
|
137
|
+
defaultTenant: 'g1',
|
|
138
|
+
tenantNames: { g1: 'Irja & Tom' },
|
|
139
|
+
scopes: ['relationship:read']
|
|
140
|
+
});
|
|
132
141
|
expect(doc.expiresAt.getTime() - before).toBeLessThanOrEqual(60_000);
|
|
133
142
|
});
|
|
134
143
|
});
|
|
@@ -224,7 +233,9 @@ describe('issueRefreshToken', () => {
|
|
|
224
233
|
clientId: 'client-1',
|
|
225
234
|
userId: 'u1',
|
|
226
235
|
scopes: ['relationship:read'],
|
|
227
|
-
|
|
236
|
+
tenants: [PERSONAL_TENANT],
|
|
237
|
+
defaultTenant: PERSONAL_TENANT,
|
|
238
|
+
tenantNames: {},
|
|
228
239
|
resource: 'https://mcp.tumbaland.eu'
|
|
229
240
|
});
|
|
230
241
|
|
|
@@ -311,7 +322,9 @@ describe('refresh tokens', () => {
|
|
|
311
322
|
clientId: 'client-1',
|
|
312
323
|
userId: 'u1',
|
|
313
324
|
scopes: ['relationship:read'],
|
|
314
|
-
|
|
325
|
+
tenants: [PERSONAL_TENANT],
|
|
326
|
+
defaultTenant: PERSONAL_TENANT,
|
|
327
|
+
tenantNames: {},
|
|
315
328
|
resource: 'https://mcp.tumbaland.eu'
|
|
316
329
|
});
|
|
317
330
|
|
|
@@ -324,7 +337,9 @@ describe('refresh tokens', () => {
|
|
|
324
337
|
mockedRefreshFindOne.mockResolvedValue({
|
|
325
338
|
userId: 'u1',
|
|
326
339
|
scopes: ['relationship:read'],
|
|
327
|
-
|
|
340
|
+
tenants: [PERSONAL_TENANT, 'g1'],
|
|
341
|
+
defaultTenant: 'g1',
|
|
342
|
+
tenantNames: { g1: 'Irja & Tom' },
|
|
328
343
|
resource: 'https://mcp.tumbaland.eu',
|
|
329
344
|
grantedAt: new Date(),
|
|
330
345
|
save: jest.fn()
|
|
@@ -333,7 +348,44 @@ describe('refresh tokens', () => {
|
|
|
333
348
|
await expect(redeemRefreshToken('tok', 'client-1')).resolves.toMatchObject({
|
|
334
349
|
ok: true,
|
|
335
350
|
userId: 'u1',
|
|
336
|
-
|
|
351
|
+
tenants: { allowed: [PERSONAL_TENANT, 'g1'], default: 'g1' }
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('carries the tenants through rotation rather than re-deriving them', async () => {
|
|
356
|
+
mockedRefreshFindOne.mockResolvedValue({
|
|
357
|
+
userId: 'u1',
|
|
358
|
+
scopes: ['relationship:read'],
|
|
359
|
+
tenants: [PERSONAL_TENANT, 'g1'],
|
|
360
|
+
defaultTenant: 'g1',
|
|
361
|
+
tenantNames: { g1: 'Irja & Tom' },
|
|
362
|
+
resource: 'https://mcp.tumbaland.eu',
|
|
363
|
+
grantedAt: new Date(),
|
|
364
|
+
save: jest.fn()
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
await redeemRefreshToken('tok', 'client-1');
|
|
368
|
+
|
|
369
|
+
// Rotation reissues the same grant; re-deriving would quietly narrow a
|
|
370
|
+
// connection every time it refreshed.
|
|
371
|
+
const [doc] = mockedRefreshCreate.mock.calls.at(-1)!;
|
|
372
|
+
expect(doc.tenants).toEqual([PERSONAL_TENANT, 'g1']);
|
|
373
|
+
expect(doc.defaultTenant).toBe('g1');
|
|
374
|
+
expect(doc.tenantNames).toEqual({ g1: 'Irja & Tom' });
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('reads a grant issued before tenants were a set', async () => {
|
|
378
|
+
mockedRefreshFindOne.mockResolvedValue({
|
|
379
|
+
userId: 'u1',
|
|
380
|
+
scopes: ['relationship:read'],
|
|
381
|
+
groupId: 'g1',
|
|
382
|
+
resource: 'https://mcp.tumbaland.eu',
|
|
383
|
+
grantedAt: new Date(),
|
|
384
|
+
save: jest.fn()
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await expect(redeemRefreshToken('tok', 'client-1')).resolves.toMatchObject({
|
|
388
|
+
tenants: { allowed: ['g1'], default: 'g1' }
|
|
337
389
|
});
|
|
338
390
|
});
|
|
339
391
|
|