@tumbaland/backend-core 1.33.0 → 1.35.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/middleware/corsMiddleware.d.ts +17 -6
- package/dist/middleware/corsMiddleware.d.ts.map +1 -1
- package/dist/middleware/corsMiddleware.js +9 -1
- package/dist/middleware/corsMiddleware.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/index.ts +3 -0
- package/src/middleware/corsMiddleware.test.ts +39 -0
- package/src/middleware/corsMiddleware.ts +27 -2
- 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,69 @@
|
|
|
1
|
+
import { type ApiKeyScope } from '../apiKeys/types';
|
|
2
|
+
/**
|
|
3
|
+
* Access tokens for the OAuth flow that lets an assistant connect to Tumbaland.
|
|
4
|
+
*
|
|
5
|
+
* These are JWTs rather than database rows so the MCP server can validate one
|
|
6
|
+
* without a query on every tool call, and because OAuth expects short-lived
|
|
7
|
+
* bearer tokens with a refresh path rather than the indefinite credentials an
|
|
8
|
+
* API key is. The trade is that a token cannot be revoked before it expires —
|
|
9
|
+
* hence the deliberately short life, with revocation applied at the refresh
|
|
10
|
+
* token, which is the thing that actually persists.
|
|
11
|
+
*/
|
|
12
|
+
/** Marks a token as issued by the OAuth flow, for the MCP resource specifically. */
|
|
13
|
+
declare const TOKEN_TYPE = "mcp_access";
|
|
14
|
+
export interface AccessTokenClaims {
|
|
15
|
+
/** the user the assistant is acting for */
|
|
16
|
+
sub: string;
|
|
17
|
+
/** the MCP server this token may be used against — RFC 8707 audience binding */
|
|
18
|
+
aud: string;
|
|
19
|
+
iss: string;
|
|
20
|
+
scope: string;
|
|
21
|
+
/** the tenant the token is pinned to; null means the user's personal data */
|
|
22
|
+
groupId: string | null;
|
|
23
|
+
email: string;
|
|
24
|
+
name: string;
|
|
25
|
+
typ: typeof TOKEN_TYPE;
|
|
26
|
+
jti: string;
|
|
27
|
+
exp: number;
|
|
28
|
+
iat: number;
|
|
29
|
+
}
|
|
30
|
+
export interface MintAccessTokenInput {
|
|
31
|
+
userId: string;
|
|
32
|
+
email: string;
|
|
33
|
+
name: string;
|
|
34
|
+
/** the canonical URI of the MCP server the token is for */
|
|
35
|
+
resource: string;
|
|
36
|
+
issuer: string;
|
|
37
|
+
scopes: ApiKeyScope[];
|
|
38
|
+
groupId: string | null;
|
|
39
|
+
}
|
|
40
|
+
export interface MintedAccessToken {
|
|
41
|
+
accessToken: string;
|
|
42
|
+
expiresIn: number;
|
|
43
|
+
scope: string;
|
|
44
|
+
}
|
|
45
|
+
export declare const mintAccessToken: (input: MintAccessTokenInput) => MintedAccessToken;
|
|
46
|
+
export interface AccessTokenVerification {
|
|
47
|
+
ok: boolean;
|
|
48
|
+
rejection?: 'malformed' | 'expired' | 'wrong-audience' | 'wrong-type' | 'bad-signature';
|
|
49
|
+
userId?: string;
|
|
50
|
+
email?: string;
|
|
51
|
+
name?: string;
|
|
52
|
+
scopes?: ApiKeyScope[];
|
|
53
|
+
groupId?: string | null;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Check a bearer token presented to the MCP server.
|
|
57
|
+
*
|
|
58
|
+
* Two checks beyond the signature carry real weight. The audience must match
|
|
59
|
+
* this server: a token minted for one resource must not work against another,
|
|
60
|
+
* which is what RFC 8707 binding is for and what stops a token obtained for
|
|
61
|
+
* somewhere else being replayed here. And the type must be `mcp_access`, so an
|
|
62
|
+
* ordinary session JWT — same secret, same issuer, but no scopes and no tenant
|
|
63
|
+
* pin — cannot be presented as an access token and quietly get everything.
|
|
64
|
+
*/
|
|
65
|
+
export declare const verifyAccessToken: (token: string, expectedAudience: string) => AccessTokenVerification;
|
|
66
|
+
/** Parse a space-separated `scope` parameter, dropping anything we do not define. */
|
|
67
|
+
export declare const parseScopes: (scope: unknown) => ApiKeyScope[];
|
|
68
|
+
export {};
|
|
69
|
+
//# sourceMappingURL=tokens.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/oauth/tokens.ts"],"names":[],"mappings":"AAGA,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEnE;;;;;;;;;GASG;AAEH,oFAAoF;AACpF,QAAA,MAAM,UAAU,eAAe,CAAC;AAKhC,MAAM,WAAW,iBAAiB;IAChC,2CAA2C;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,gFAAgF;IAChF,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,6EAA6E;IAC7E,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,OAAO,UAAU,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,WAAW,EAAE,CAAC;IACtB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,eAAO,MAAM,eAAe,GAAI,OAAO,oBAAoB,KAAG,iBAoB7D,CAAC;AAEF,MAAM,WAAW,uBAAuB;IACtC,EAAE,EAAE,OAAO,CAAC;IACZ,SAAS,CAAC,EAAE,WAAW,GAAG,SAAS,GAAG,gBAAgB,GAAG,YAAY,GAAG,eAAe,CAAC;IACxF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,WAAW,EAAE,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,iBAAiB,GAC5B,OAAO,MAAM,EACb,kBAAkB,MAAM,KACvB,uBAqBF,CAAC;AAEF,qFAAqF;AACrF,eAAO,MAAM,WAAW,GAAI,OAAO,OAAO,KAAG,WAAW,EACmB,CAAC"}
|
|
@@ -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
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 {
|
|
@@ -94,3 +94,42 @@ describe('createCorsMiddleware', () => {
|
|
|
94
94
|
process.env = ORIGINAL_ENV;
|
|
95
95
|
});
|
|
96
96
|
});
|
|
97
|
+
|
|
98
|
+
describe('bypassPaths', () => {
|
|
99
|
+
const run = (middleware: ReturnType<typeof createCorsMiddleware>, path: string, origin?: string) => {
|
|
100
|
+
const req = { path, method: 'POST', headers: origin ? { origin } : {} } as never;
|
|
101
|
+
const res = { setHeader: jest.fn(), getHeader: jest.fn(), end: jest.fn() } as never;
|
|
102
|
+
const next = jest.fn();
|
|
103
|
+
middleware(req, res, next);
|
|
104
|
+
return next;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
it('lets a bypassed path through with an origin that would otherwise be blocked', () => {
|
|
108
|
+
// Firefox sends a literal `Origin: null` on the consent form POST after the
|
|
109
|
+
// login redirect. It is truthy, matches no whitelist entry, and turned an
|
|
110
|
+
// approved consent into a 500.
|
|
111
|
+
const middleware = createCorsMiddleware({ origins: 'https://app.example', bypassPaths: ['/oauth'] });
|
|
112
|
+
|
|
113
|
+
const next = run(middleware, '/oauth/authorize', 'null');
|
|
114
|
+
|
|
115
|
+
expect(next).toHaveBeenCalledWith();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('still checks the origin on every other path', () => {
|
|
119
|
+
const middleware = createCorsMiddleware({ origins: 'https://app.example', bypassPaths: ['/oauth'] });
|
|
120
|
+
|
|
121
|
+
const next = run(middleware, '/auth/profile', 'null');
|
|
122
|
+
|
|
123
|
+
expect(next).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining('CORS') }));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('matches by prefix, so nested oauth routes are covered too', () => {
|
|
127
|
+
const middleware = createCorsMiddleware({ origins: 'https://app.example', bypassPaths: ['/oauth'] });
|
|
128
|
+
expect(run(middleware, '/oauth/token', 'null')).toHaveBeenCalledWith();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('changes nothing when no bypass is configured', () => {
|
|
132
|
+
const middleware = createCorsMiddleware({ origins: 'https://app.example' });
|
|
133
|
+
expect(run(middleware, '/oauth/authorize', 'null')).toHaveBeenCalledWith(expect.any(Error));
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import cors from 'cors';
|
|
2
|
+
import type { RequestHandler } from 'express';
|
|
2
3
|
import logger from '../logging/logger';
|
|
3
4
|
|
|
4
5
|
export interface CorsMiddlewareOptions {
|
|
@@ -8,6 +9,21 @@ export interface CorsMiddlewareOptions {
|
|
|
8
9
|
*/
|
|
9
10
|
origins?: string;
|
|
10
11
|
exposedHeaders?: string[];
|
|
12
|
+
/**
|
|
13
|
+
* Path prefixes that skip the origin check entirely.
|
|
14
|
+
*
|
|
15
|
+
* For endpoints the browser *navigates* to rather than fetches — an OAuth
|
|
16
|
+
* authorize page and the consent form it posts back. CORS governs
|
|
17
|
+
* script-initiated cross-origin reads and has nothing to say about a
|
|
18
|
+
* top-level form submission, but it still blocks one: Firefox sends a
|
|
19
|
+
* literal `Origin: null` after a cross-site redirect chain, which is truthy,
|
|
20
|
+
* matches no whitelist entry, and turns a legitimate consent into a 500.
|
|
21
|
+
*
|
|
22
|
+
* Nothing is weakened by skipping them. These routes carry no XHR-readable
|
|
23
|
+
* data, and what actually protects the consent POST is its signed consent
|
|
24
|
+
* token, not the origin header.
|
|
25
|
+
*/
|
|
26
|
+
bypassPaths?: string[];
|
|
11
27
|
}
|
|
12
28
|
|
|
13
29
|
/**
|
|
@@ -15,8 +31,10 @@ export interface CorsMiddlewareOptions {
|
|
|
15
31
|
* Requests without an Origin header (server-to-server, curl, health checks)
|
|
16
32
|
* are always allowed; browser requests must match the whitelist.
|
|
17
33
|
*/
|
|
18
|
-
export function createCorsMiddleware(options: CorsMiddlewareOptions = {}) {
|
|
19
|
-
|
|
34
|
+
export function createCorsMiddleware(options: CorsMiddlewareOptions = {}): RequestHandler {
|
|
35
|
+
const bypassPaths = options.bypassPaths ?? [];
|
|
36
|
+
|
|
37
|
+
const corsHandler = cors({
|
|
20
38
|
origin: (origin, callback) => {
|
|
21
39
|
const allowedOrigins = (options.origins ?? process.env.CORS_ORIGIN ?? '')
|
|
22
40
|
.split(',')
|
|
@@ -37,4 +55,11 @@ export function createCorsMiddleware(options: CorsMiddlewareOptions = {}) {
|
|
|
37
55
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'x-correlation-id', 'x-session-id'],
|
|
38
56
|
exposedHeaders: options.exposedHeaders
|
|
39
57
|
});
|
|
58
|
+
|
|
59
|
+
if (bypassPaths.length === 0) return corsHandler as RequestHandler;
|
|
60
|
+
|
|
61
|
+
return (req, res, next) => {
|
|
62
|
+
if (bypassPaths.some((prefix) => req.path.startsWith(prefix))) return next();
|
|
63
|
+
return (corsHandler as RequestHandler)(req, res, next);
|
|
64
|
+
};
|
|
40
65
|
}
|
|
@@ -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);
|