@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/oauth/index.d.ts +7 -0
- package/dist/oauth/index.d.ts.map +1 -0
- package/dist/oauth/index.js +22 -0
- package/dist/oauth/index.js.map +1 -0
- package/dist/oauth/models.d.ts +93 -0
- package/dist/oauth/models.d.ts.map +1 -0
- package/dist/oauth/models.js +81 -0
- package/dist/oauth/models.js.map +1 -0
- package/dist/oauth/service.d.ts +111 -0
- package/dist/oauth/service.d.ts.map +1 -0
- package/dist/oauth/service.js +159 -0
- package/dist/oauth/service.js.map +1 -0
- package/dist/oauth/tokens.d.ts +69 -0
- package/dist/oauth/tokens.d.ts.map +1 -0
- package/dist/oauth/tokens.js +79 -0
- package/dist/oauth/tokens.js.map +1 -0
- package/jest.config.js +11 -1
- package/package.json +1 -1
- package/src/apiKeys/ApiKey.test.ts +118 -0
- package/src/apiKeys/index.test.ts +72 -0
- package/src/apiKeys/middleware.test.ts +100 -1
- package/src/apiKeys/types.test.ts +41 -0
- package/src/index.ts +3 -0
- package/src/oauth/index.ts +23 -0
- package/src/oauth/models.ts +134 -0
- package/src/oauth/service.test.ts +325 -0
- package/src/oauth/service.ts +245 -0
- package/src/oauth/tokens.test.ts +129 -0
- package/src/oauth/tokens.ts +127 -0
|
@@ -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
|
-
|
|
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
|
@@ -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);
|