@tumbaland/backend-core 1.32.0 → 1.34.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.
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseScopes = exports.verifyAccessToken = exports.mintAccessToken = void 0;
7
+ const crypto_1 = require("crypto");
8
+ const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
9
+ const env_1 = require("../config/env");
10
+ const types_1 = require("../apiKeys/types");
11
+ /**
12
+ * Access tokens for the OAuth flow that lets an assistant connect to Tumbaland.
13
+ *
14
+ * These are JWTs rather than database rows so the MCP server can validate one
15
+ * without a query on every tool call, and because OAuth expects short-lived
16
+ * bearer tokens with a refresh path rather than the indefinite credentials an
17
+ * API key is. The trade is that a token cannot be revoked before it expires —
18
+ * hence the deliberately short life, with revocation applied at the refresh
19
+ * token, which is the thing that actually persists.
20
+ */
21
+ /** Marks a token as issued by the OAuth flow, for the MCP resource specifically. */
22
+ const TOKEN_TYPE = 'mcp_access';
23
+ /** Short enough that a leaked token is a small window, long enough to be usable. */
24
+ const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
25
+ const mintAccessToken = (input) => {
26
+ const scope = input.scopes.join(' ');
27
+ const accessToken = jsonwebtoken_1.default.sign({
28
+ sub: input.userId,
29
+ aud: input.resource,
30
+ iss: input.issuer,
31
+ scope,
32
+ groupId: input.groupId,
33
+ email: input.email,
34
+ name: input.name,
35
+ typ: TOKEN_TYPE,
36
+ jti: (0, crypto_1.randomUUID)()
37
+ }, (0, env_1.requireEnv)('JWT_SECRET'), { expiresIn: ACCESS_TOKEN_TTL_SECONDS });
38
+ return { accessToken, expiresIn: ACCESS_TOKEN_TTL_SECONDS, scope };
39
+ };
40
+ exports.mintAccessToken = mintAccessToken;
41
+ /**
42
+ * Check a bearer token presented to the MCP server.
43
+ *
44
+ * Two checks beyond the signature carry real weight. The audience must match
45
+ * this server: a token minted for one resource must not work against another,
46
+ * which is what RFC 8707 binding is for and what stops a token obtained for
47
+ * somewhere else being replayed here. And the type must be `mcp_access`, so an
48
+ * ordinary session JWT — same secret, same issuer, but no scopes and no tenant
49
+ * pin — cannot be presented as an access token and quietly get everything.
50
+ */
51
+ const verifyAccessToken = (token, expectedAudience) => {
52
+ let claims;
53
+ try {
54
+ claims = jsonwebtoken_1.default.verify(token, (0, env_1.requireEnv)('JWT_SECRET'));
55
+ }
56
+ catch (error) {
57
+ const expired = error?.name === 'TokenExpiredError';
58
+ return { ok: false, rejection: expired ? 'expired' : 'bad-signature' };
59
+ }
60
+ if (claims.typ !== TOKEN_TYPE)
61
+ return { ok: false, rejection: 'wrong-type' };
62
+ if (claims.aud !== expectedAudience)
63
+ return { ok: false, rejection: 'wrong-audience' };
64
+ if (!claims.sub)
65
+ return { ok: false, rejection: 'malformed' };
66
+ return {
67
+ ok: true,
68
+ userId: claims.sub,
69
+ email: claims.email ?? '',
70
+ name: claims.name ?? '',
71
+ scopes: (claims.scope ?? '').split(' ').filter(types_1.isApiKeyScope),
72
+ groupId: claims.groupId ?? null
73
+ };
74
+ };
75
+ exports.verifyAccessToken = verifyAccessToken;
76
+ /** Parse a space-separated `scope` parameter, dropping anything we do not define. */
77
+ const parseScopes = (scope) => typeof scope === 'string' ? scope.split(/\s+/).filter(types_1.isApiKeyScope) : [];
78
+ exports.parseScopes = parseScopes;
79
+ //# sourceMappingURL=tokens.js.map
@@ -0,0 +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"}
package/jest.config.js CHANGED
@@ -8,7 +8,17 @@ module.exports = {
8
8
  '^.+\\.ts$': ['ts-jest', { tsconfig: { types: ['jest', 'node'] } }]
9
9
  },
10
10
  coverageProvider: 'v8',
11
- collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts', '!src/index.ts', '!src/types/**'],
11
+ // Barrels are excluded, not just the root one: a file of `export { x } from
12
+ // './y'` has no logic to test, but Jest counts every re-exported function as
13
+ // an uncovered one — which is what held global function coverage at ~78%
14
+ // while every module underneath was thoroughly tested.
15
+ collectCoverageFrom: [
16
+ 'src/**/*.ts',
17
+ '!src/**/*.test.ts',
18
+ '!src/index.ts',
19
+ '!src/**/index.ts',
20
+ '!src/types/**'
21
+ ],
12
22
  coverageReporters: ['text', 'json-summary'],
13
23
  coverageThreshold: { global: { statements: 90, branches: 80, functions: 90, lines: 90 } }
14
24
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tumbaland/backend-core",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "description": "Core shared functionality for Tumbaland backend services",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,118 @@
1
+ import { ApiKey } from './ApiKey';
2
+
3
+ /** The fields the schema insists on, so each test only varies what it is about. */
4
+ const complete = {
5
+ userId: 'u1',
6
+ userEmail: 'u1@example.com',
7
+ userName: 'Tester',
8
+ name: 'Claude Code',
9
+ keyId: 'abcdef123456',
10
+ hash: 'a'.repeat(64),
11
+ sealedCiphertext: 'ct',
12
+ sealedIv: 'iv',
13
+ sealedTag: 'tag'
14
+ };
15
+
16
+ describe('required fields', () => {
17
+ it('accepts a complete document', () => {
18
+ expect(new ApiKey(complete).validateSync()).toBeUndefined();
19
+ });
20
+
21
+ it.each([
22
+ 'userId',
23
+ 'userEmail',
24
+ 'name',
25
+ 'keyId',
26
+ 'hash',
27
+ 'sealedCiphertext',
28
+ 'sealedIv',
29
+ 'sealedTag'
30
+ ])('refuses a document with no %s', (field) => {
31
+ const doc = new ApiKey({ ...complete, [field]: undefined });
32
+ expect(doc.validateSync()?.errors[field]).toBeDefined();
33
+ });
34
+
35
+ it('will not store a key without the material needed to verify it', () => {
36
+ // A record missing its digest could never authenticate anything, and one
37
+ // missing the sealed copy could never be revealed — both are corruption,
38
+ // not a valid state.
39
+ const doc = new ApiKey({ ...complete, hash: undefined, sealedCiphertext: undefined });
40
+ const err = doc.validateSync();
41
+
42
+ expect(err?.errors.hash).toBeDefined();
43
+ expect(err?.errors.sealedCiphertext).toBeDefined();
44
+ });
45
+ });
46
+
47
+ describe('defaults', () => {
48
+ it('treats an unpinned key as personal rather than as unset', () => {
49
+ // `null` and "nobody set this" must not be distinguishable downstream: the
50
+ // middleware reads a null groupId as the owner's personal scope.
51
+ expect(new ApiKey(complete).groupId).toBeNull();
52
+ });
53
+
54
+ it('starts with no scopes, so a key grants nothing until asked', () => {
55
+ expect(new ApiKey(complete).scopes).toEqual([]);
56
+ });
57
+
58
+ it('starts the reveal counter at zero', () => {
59
+ expect(new ApiKey(complete).revealCount).toBe(0);
60
+ });
61
+
62
+ it('leaves the lifecycle timestamps unset', () => {
63
+ const doc = new ApiKey(complete);
64
+
65
+ expect(doc.lastUsedAt).toBeUndefined();
66
+ expect(doc.expiresAt).toBeUndefined();
67
+ expect(doc.revokedAt).toBeUndefined();
68
+ expect(doc.lastRevealedAt).toBeUndefined();
69
+ });
70
+ });
71
+
72
+ describe('name', () => {
73
+ it('trims surrounding whitespace', () => {
74
+ expect(new ApiKey({ ...complete, name: ' Claude ' }).name).toBe('Claude');
75
+ });
76
+
77
+ it('caps the length so a listing stays readable', () => {
78
+ const doc = new ApiKey({ ...complete, name: 'x'.repeat(61) });
79
+ expect(doc.validateSync()?.errors.name).toBeDefined();
80
+ });
81
+
82
+ it('accepts a name at the cap', () => {
83
+ const doc = new ApiKey({ ...complete, name: 'x'.repeat(60) });
84
+ expect(doc.validateSync()?.errors.name).toBeUndefined();
85
+ });
86
+ });
87
+
88
+ describe('indexes', () => {
89
+ const indexes = ApiKey.schema.indexes().map(([fields]) => fields);
90
+
91
+ it('indexes keyId, which every verification looks a key up by', () => {
92
+ const path = ApiKey.schema.path('keyId') as unknown as { options: Record<string, unknown> };
93
+ expect(path.options.index).toBe(true);
94
+ expect(path.options.unique).toBe(true);
95
+ });
96
+
97
+ it('indexes a user’s keys newest first, matching how the listing reads them', () => {
98
+ expect(indexes).toContainEqual({ userId: 1, createdAt: -1 });
99
+ });
100
+ });
101
+
102
+ describe('collection', () => {
103
+ it('lives in api_keys', () => {
104
+ // Named explicitly rather than pluralised by Mongoose, so the collection a
105
+ // migration or a manual query targets is not a guess.
106
+ expect(ApiKey.collection.collectionName).toBe('api_keys');
107
+ });
108
+ });
109
+
110
+ describe('model registration', () => {
111
+ it('reuses the compiled model instead of redefining it', () => {
112
+ // backend-core is imported by every service, and some import it more than
113
+ // once through different paths; recompiling would throw OverwriteModelError.
114
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
115
+ const again = require('./ApiKey').ApiKey;
116
+ expect(again).toBe(ApiKey);
117
+ });
118
+ });
@@ -0,0 +1,72 @@
1
+ import * as core from '../index';
2
+ import * as apiKeys from './index';
3
+
4
+ /**
5
+ * The package's public surface, as the services actually consume it.
6
+ *
7
+ * Services install this lib from npm, so an export dropped in a refactor does
8
+ * not fail here — it fails inside a Docker build as "has no exported member X",
9
+ * against a registry that genuinely has the code. That is a slow and confusing
10
+ * loop, and it is entirely preventable by naming the surface once.
11
+ */
12
+ const PUBLIC_SURFACE = [
13
+ // middleware the services mount
14
+ 'authenticateAgent',
15
+ 'requireScope',
16
+ 'denyApiKeys',
17
+ // key lifecycle, used by auth-service
18
+ 'createApiKey',
19
+ 'listApiKeys',
20
+ 'revealApiKey',
21
+ 'revokeApiKey',
22
+ 'deleteApiKey',
23
+ 'verifyApiKey',
24
+ // configuration and vocabulary
25
+ 'isEncryptionConfigured',
26
+ 'API_KEY_SCOPES',
27
+ 'isApiKeyScope',
28
+ 'ApiKey',
29
+ // helpers
30
+ 'looksLikeApiKey',
31
+ 'displayPrefix',
32
+ 'resetEncryptionKeyCache'
33
+ ] as const;
34
+
35
+ describe('the apiKeys barrel', () => {
36
+ it.each(PUBLIC_SURFACE)('exports %s', (name) => {
37
+ expect(apiKeys).toHaveProperty(name);
38
+ expect((apiKeys as Record<string, unknown>)[name]).toBeDefined();
39
+ });
40
+ });
41
+
42
+ describe('the package root', () => {
43
+ it.each(PUBLIC_SURFACE)('re-exports %s, which is how services import it', (name) => {
44
+ expect((core as Record<string, unknown>)[name]).toBeDefined();
45
+ });
46
+
47
+ it('exposes the same binding through both paths', () => {
48
+ for (const name of PUBLIC_SURFACE) {
49
+ expect((core as Record<string, unknown>)[name]).toBe((apiKeys as Record<string, unknown>)[name]);
50
+ }
51
+ });
52
+
53
+ it('still exports the session middleware the key work sits alongside', () => {
54
+ // `authenticateToken` stays JWT-only and is what auth-service and
55
+ // payment-service rely on to refuse keys outright.
56
+ expect(core.authenticateToken).toBeDefined();
57
+ expect(core.optionalAuth).toBeDefined();
58
+ });
59
+ });
60
+
61
+ describe('the scope vocabulary', () => {
62
+ it('is the same list the services and the UI are written against', () => {
63
+ expect([...core.API_KEY_SCOPES]).toEqual([
64
+ 'relationship:read',
65
+ 'relationship:write',
66
+ 'album:read',
67
+ 'album:write',
68
+ 'finance:read',
69
+ 'finance:write'
70
+ ]);
71
+ });
72
+ });
@@ -8,7 +8,7 @@ jest.mock('../logging/logger', () => ({
8
8
 
9
9
  import jwt from 'jsonwebtoken';
10
10
  import { verifyApiKey } from './service';
11
- import { authenticateAgent, denyApiKeys } from './middleware';
11
+ import { authenticateAgent, requireScope, denyApiKeys } from './middleware';
12
12
 
13
13
  const mockedVerify = verifyApiKey as jest.Mock;
14
14
  const ORIGINAL_ENV = process.env;
@@ -157,6 +157,37 @@ describe('authenticateAgent — API keys', () => {
157
157
  });
158
158
  });
159
159
 
160
+ it('authenticates a key stored before the owner snapshot existed', async () => {
161
+ // Records written by an earlier version carry no userEmail/userName. Such a
162
+ // key must still work — the id is what every handler actually reads — rather
163
+ // than 500 on a missing field.
164
+ mockedVerify.mockResolvedValue({
165
+ ok: true,
166
+ userId: 'u1',
167
+ keyId: 'k1',
168
+ scopes: ['relationship:read'],
169
+ groupId: null
170
+ });
171
+ const request = withKey();
172
+ const next = jest.fn();
173
+
174
+ await authenticateAgent('relationship:read')(request, mockRes(), next);
175
+
176
+ expect(next).toHaveBeenCalled();
177
+ expect(request.user).toEqual({ id: 'u1', email: '', name: '' });
178
+ });
179
+
180
+ it('treats a verification result with no scopes as granting nothing', async () => {
181
+ mockedVerify.mockResolvedValue({ ok: true, userId: 'u1', keyId: 'k1', groupId: null });
182
+ const res = mockRes();
183
+ const next = jest.fn();
184
+
185
+ await authenticateAgent('relationship:read')(withKey(), res, next);
186
+
187
+ expect(res.status).toHaveBeenCalledWith(403);
188
+ expect(next).not.toHaveBeenCalled();
189
+ });
190
+
160
191
  describe('authenticateAgent — tenant pinning', () => {
161
192
  it('grants a personal key no groups at all', async () => {
162
193
  mockedVerify.mockResolvedValue(okVerification({ groupId: null }));
@@ -231,6 +262,74 @@ describe('authenticateAgent — tenant pinning', () => {
231
262
  });
232
263
  });
233
264
 
265
+ describe('requireScope', () => {
266
+ const keyReq = (scopes: string[]) =>
267
+ req({ apiKey: { keyId: 'k1', scopes, groupId: null } } as Partial<Request>);
268
+
269
+ it('lets a session through unconditionally', () => {
270
+ // Scopes narrow what software may do on a person's behalf, not what the
271
+ // person may do themselves — a session carries no apiKey context at all.
272
+ const next = jest.fn();
273
+ requireScope('relationship:write')(req(), mockRes(), next);
274
+ expect(next).toHaveBeenCalled();
275
+ });
276
+
277
+ it('lets a key with the required scope through', () => {
278
+ const next = jest.fn();
279
+ requireScope('relationship:read')(keyReq(['relationship:read']), mockRes(), next);
280
+ expect(next).toHaveBeenCalled();
281
+ });
282
+
283
+ it('refuses a key without it, naming what is missing', () => {
284
+ const res = mockRes();
285
+ const next = jest.fn();
286
+
287
+ requireScope('relationship:write')(keyReq(['relationship:read']), res, next);
288
+
289
+ expect(res.status).toHaveBeenCalledWith(403);
290
+ expect(res.json).toHaveBeenCalledWith(
291
+ expect.objectContaining({ message: expect.stringContaining('relationship:write') })
292
+ );
293
+ expect(next).not.toHaveBeenCalled();
294
+ });
295
+
296
+ it('does not let a read scope stand in for a write', () => {
297
+ const next = jest.fn();
298
+ requireScope('album:write')(keyReq(['album:read']), mockRes(), next);
299
+ expect(next).not.toHaveBeenCalled();
300
+ });
301
+
302
+ it('does not let one area’s scope stand in for another’s', () => {
303
+ const next = jest.fn();
304
+ requireScope('finance:read')(keyReq(['relationship:read', 'album:read']), mockRes(), next);
305
+ expect(next).not.toHaveBeenCalled();
306
+ });
307
+
308
+ it('requires every named scope, not just one of them', () => {
309
+ const res = mockRes();
310
+ const next = jest.fn();
311
+
312
+ requireScope('relationship:read', 'album:write')(keyReq(['relationship:read']), res, next);
313
+
314
+ expect(next).not.toHaveBeenCalled();
315
+ expect(res.json).toHaveBeenCalledWith(
316
+ expect.objectContaining({ message: expect.stringContaining('album:write') })
317
+ );
318
+ });
319
+
320
+ it('refuses a key with no scopes at all', () => {
321
+ const next = jest.fn();
322
+ requireScope('relationship:read')(keyReq([]), mockRes(), next);
323
+ expect(next).not.toHaveBeenCalled();
324
+ });
325
+
326
+ it('lets any authenticated caller through when no scope is named', () => {
327
+ const next = jest.fn();
328
+ requireScope()(keyReq([]), mockRes(), next);
329
+ expect(next).toHaveBeenCalled();
330
+ });
331
+ });
332
+
234
333
  describe('denyApiKeys', () => {
235
334
  it('lets a session through', () => {
236
335
  const next = jest.fn();
@@ -0,0 +1,41 @@
1
+ import { API_KEY_SCOPES, isApiKeyScope } from './types';
2
+
3
+ describe('API_KEY_SCOPES', () => {
4
+ it('has no duplicates', () => {
5
+ expect(new Set(API_KEY_SCOPES).size).toBe(API_KEY_SCOPES.length);
6
+ });
7
+
8
+ it('offers a read and a write for every area it covers', () => {
9
+ for (const area of ['relationship', 'album', 'finance']) {
10
+ expect(API_KEY_SCOPES).toContain(`${area}:read`);
11
+ expect(API_KEY_SCOPES).toContain(`${area}:write`);
12
+ }
13
+ });
14
+
15
+ it('grants nothing over auth or payment, which stay a person’s to do', () => {
16
+ // A key that could mint keys could not be revoked, and one that could change
17
+ // a subscription has no ceiling on what it can cost.
18
+ for (const scope of API_KEY_SCOPES) {
19
+ expect(scope.startsWith('auth:')).toBe(false);
20
+ expect(scope.startsWith('payment:')).toBe(false);
21
+ }
22
+ });
23
+ });
24
+
25
+ describe('isApiKeyScope', () => {
26
+ it.each([...API_KEY_SCOPES])('accepts %s', (scope) => {
27
+ expect(isApiKeyScope(scope)).toBe(true);
28
+ });
29
+
30
+ it.each([
31
+ ['an invented area', 'admin:everything'],
32
+ ['an invented verb', 'relationship:delete'],
33
+ ['a bare area', 'relationship'],
34
+ ['an empty string', ''],
35
+ ['a non-string', 42],
36
+ ['null', null],
37
+ ['undefined', undefined]
38
+ ])('rejects %s', (_label, value) => {
39
+ expect(isApiKeyScope(value)).toBe(false);
40
+ });
41
+ });
package/src/index.ts CHANGED
@@ -33,6 +33,9 @@ export * from './groups';
33
33
  // API keys (non-browser clients: MCP servers, scripts, agents)
34
34
  export * from './apiKeys';
35
35
 
36
+ // OAuth 2.1 authorization server, for assistants connecting over MCP
37
+ export * from './oauth';
38
+
36
39
  // Middleware
37
40
  export { authenticateToken, optionalAuth } from './middleware/authMiddleware';
38
41
  export {
@@ -0,0 +1,23 @@
1
+ export { OAuthClient, AuthorizationCode, RefreshToken } from './models';
2
+ export type { IOAuthClient, IAuthorizationCode, IRefreshToken } from './models';
3
+ export {
4
+ registerClient,
5
+ findClient,
6
+ isRegisteredRedirect,
7
+ issueAuthorizationCode,
8
+ redeemAuthorizationCode,
9
+ issueRefreshToken,
10
+ redeemRefreshToken,
11
+ revokeRefreshTokensForUser,
12
+ listConnections
13
+ } from './service';
14
+ export type {
15
+ RegisterClientInput,
16
+ RegisteredClient,
17
+ IssueCodeInput,
18
+ CodeRedemption,
19
+ CodeRejection,
20
+ RefreshRedemption
21
+ } from './service';
22
+ export { mintAccessToken, verifyAccessToken, parseScopes } from './tokens';
23
+ export type { AccessTokenClaims, MintAccessTokenInput, MintedAccessToken, AccessTokenVerification } from './tokens';
@@ -0,0 +1,134 @@
1
+ import mongoose, { Document, Schema } from 'mongoose';
2
+
3
+ /**
4
+ * A client registered through RFC 7591 Dynamic Client Registration.
5
+ *
6
+ * Assistants register themselves — nobody is going to pre-provision Claude,
7
+ * ChatGPT and Gemini by hand, and the MCP spec expects a server to accept
8
+ * registrations. Clients are public (no secret): the caller is a browser-driven
9
+ * flow that cannot keep one, which is exactly why PKCE is mandatory here.
10
+ */
11
+ export interface IOAuthClient extends Document {
12
+ clientId: string;
13
+ clientName: string;
14
+ redirectUris: string[];
15
+ createdAt: Date;
16
+ updatedAt: Date;
17
+ }
18
+
19
+ const OAuthClientSchema = new Schema<IOAuthClient>(
20
+ {
21
+ clientId: { type: String, required: true, unique: true, index: true },
22
+ clientName: { type: String, required: true, maxlength: 200 },
23
+ redirectUris: { type: [String], required: true }
24
+ },
25
+ { timestamps: true, collection: 'oauth_clients' }
26
+ );
27
+
28
+ export const OAuthClient = mongoose.models.OAuthClient
29
+ ? (mongoose.models.OAuthClient as mongoose.Model<IOAuthClient>)
30
+ : mongoose.model<IOAuthClient>('OAuthClient', OAuthClientSchema);
31
+
32
+ /**
33
+ * A one-time authorization code, alive for the seconds between the user
34
+ * approving and the client exchanging it.
35
+ *
36
+ * Everything the token will assert is fixed here, at the moment consent is
37
+ * given: which scopes, which tenant, which resource. The exchange cannot widen
38
+ * any of it, so a client that asks for more at the token endpoint than the user
39
+ * agreed to gets what the user agreed to.
40
+ */
41
+ export interface IAuthorizationCode extends Document {
42
+ code: string;
43
+ clientId: string;
44
+ userId: string;
45
+ userEmail: string;
46
+ userName: string;
47
+ redirectUri: string;
48
+ scopes: string[];
49
+ groupId: string | null;
50
+ /** RFC 8707: the MCP server this will be minted for */
51
+ resource: string;
52
+ /** PKCE, always S256 — the spec does not allow `plain` */
53
+ codeChallenge: string;
54
+ /** set once the code is exchanged, so a replay is detectable rather than silent */
55
+ usedAt?: Date;
56
+ expiresAt: Date;
57
+ createdAt: Date;
58
+ }
59
+
60
+ const AuthorizationCodeSchema = new Schema<IAuthorizationCode>(
61
+ {
62
+ code: { type: String, required: true, unique: true, index: true },
63
+ clientId: { type: String, required: true },
64
+ userId: { type: String, required: true },
65
+ userEmail: { type: String, required: true },
66
+ userName: { type: String, default: '' },
67
+ redirectUri: { type: String, required: true },
68
+ scopes: { type: [String], default: [] },
69
+ groupId: { type: String, default: null },
70
+ resource: { type: String, required: true },
71
+ codeChallenge: { type: String, required: true },
72
+ usedAt: { type: Date },
73
+ expiresAt: { type: Date, required: true }
74
+ },
75
+ { timestamps: { createdAt: true, updatedAt: false }, collection: 'oauth_authorization_codes' }
76
+ );
77
+
78
+ // Codes are short-lived and never read after expiry; let Mongo sweep them so a
79
+ // replay cannot be attempted against a record nobody is cleaning up.
80
+ AuthorizationCodeSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
81
+
82
+ export const AuthorizationCode = mongoose.models.AuthorizationCode
83
+ ? (mongoose.models.AuthorizationCode as mongoose.Model<IAuthorizationCode>)
84
+ : mongoose.model<IAuthorizationCode>('AuthorizationCode', AuthorizationCodeSchema);
85
+
86
+ /**
87
+ * A refresh token: the part that actually persists, and therefore the part
88
+ * revocation acts on.
89
+ *
90
+ * Access tokens are unrevokable JWTs by design, kept short so that matters
91
+ * little. Cutting off an assistant means deleting its refresh token, after
92
+ * which it can obtain nothing new.
93
+ */
94
+ export interface IRefreshToken extends Document {
95
+ tokenHash: string;
96
+ clientId: string;
97
+ userId: string;
98
+ scopes: string[];
99
+ groupId: string | null;
100
+ resource: string;
101
+ /**
102
+ * When the user actually connected this assistant.
103
+ *
104
+ * Distinct from `createdAt`, which rotation resets on every refresh — showing
105
+ * that in the UI would tell someone they connected Claude an hour ago when
106
+ * they did it in March.
107
+ */
108
+ grantedAt: Date;
109
+ revokedAt?: Date;
110
+ lastUsedAt?: Date;
111
+ createdAt: Date;
112
+ updatedAt: Date;
113
+ }
114
+
115
+ const RefreshTokenSchema = new Schema<IRefreshToken>(
116
+ {
117
+ tokenHash: { type: String, required: true, unique: true, index: true },
118
+ clientId: { type: String, required: true },
119
+ userId: { type: String, required: true, index: true },
120
+ scopes: { type: [String], default: [] },
121
+ groupId: { type: String, default: null },
122
+ resource: { type: String, required: true },
123
+ grantedAt: { type: Date, default: Date.now },
124
+ revokedAt: { type: Date },
125
+ lastUsedAt: { type: Date }
126
+ },
127
+ { timestamps: true, collection: 'oauth_refresh_tokens' }
128
+ );
129
+
130
+ RefreshTokenSchema.index({ userId: 1, createdAt: -1 });
131
+
132
+ export const RefreshToken = mongoose.models.RefreshToken
133
+ ? (mongoose.models.RefreshToken as mongoose.Model<IRefreshToken>)
134
+ : mongoose.model<IRefreshToken>('RefreshToken', RefreshTokenSchema);