@tumbaland/backend-core 1.34.0 → 1.36.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/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/service.d.ts +22 -1
- package/dist/oauth/service.d.ts.map +1 -1
- package/dist/oauth/service.js +34 -5
- package/dist/oauth/service.js.map +1 -1
- package/package.json +1 -1
- package/src/middleware/corsMiddleware.test.ts +39 -0
- package/src/middleware/corsMiddleware.ts +27 -2
- package/src/oauth/service.test.ts +55 -0
- package/src/oauth/service.ts +41 -5
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { RequestHandler } from 'express';
|
|
2
2
|
export interface CorsMiddlewareOptions {
|
|
3
3
|
/**
|
|
4
4
|
* Comma-separated origin whitelist. Defaults to process.env.CORS_ORIGIN,
|
|
@@ -6,15 +6,26 @@ export interface CorsMiddlewareOptions {
|
|
|
6
6
|
*/
|
|
7
7
|
origins?: string;
|
|
8
8
|
exposedHeaders?: string[];
|
|
9
|
+
/**
|
|
10
|
+
* Path prefixes that skip the origin check entirely.
|
|
11
|
+
*
|
|
12
|
+
* For endpoints the browser *navigates* to rather than fetches — an OAuth
|
|
13
|
+
* authorize page and the consent form it posts back. CORS governs
|
|
14
|
+
* script-initiated cross-origin reads and has nothing to say about a
|
|
15
|
+
* top-level form submission, but it still blocks one: Firefox sends a
|
|
16
|
+
* literal `Origin: null` after a cross-site redirect chain, which is truthy,
|
|
17
|
+
* matches no whitelist entry, and turns a legitimate consent into a 500.
|
|
18
|
+
*
|
|
19
|
+
* Nothing is weakened by skipping them. These routes carry no XHR-readable
|
|
20
|
+
* data, and what actually protects the consent POST is its signed consent
|
|
21
|
+
* token, not the origin header.
|
|
22
|
+
*/
|
|
23
|
+
bypassPaths?: string[];
|
|
9
24
|
}
|
|
10
25
|
/**
|
|
11
26
|
* Shared CORS middleware with an explicit origin whitelist.
|
|
12
27
|
* Requests without an Origin header (server-to-server, curl, health checks)
|
|
13
28
|
* are always allowed; browser requests must match the whitelist.
|
|
14
29
|
*/
|
|
15
|
-
export declare function createCorsMiddleware(options?: CorsMiddlewareOptions):
|
|
16
|
-
statusCode?: number | undefined;
|
|
17
|
-
setHeader(key: string, value: string): any;
|
|
18
|
-
end(): any;
|
|
19
|
-
}, next: (err?: any) => any) => void;
|
|
30
|
+
export declare function createCorsMiddleware(options?: CorsMiddlewareOptions): RequestHandler;
|
|
20
31
|
//# sourceMappingURL=corsMiddleware.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"corsMiddleware.d.ts","sourceRoot":"","sources":["../../src/middleware/corsMiddleware.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"corsMiddleware.d.ts","sourceRoot":"","sources":["../../src/middleware/corsMiddleware.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAG9C,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B;;;;;;;;;;;;;OAaG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,GAAE,qBAA0B,GAAG,cAAc,CA+BxF"}
|
|
@@ -12,7 +12,8 @@ const logger_1 = __importDefault(require("../logging/logger"));
|
|
|
12
12
|
* are always allowed; browser requests must match the whitelist.
|
|
13
13
|
*/
|
|
14
14
|
function createCorsMiddleware(options = {}) {
|
|
15
|
-
|
|
15
|
+
const bypassPaths = options.bypassPaths ?? [];
|
|
16
|
+
const corsHandler = (0, cors_1.default)({
|
|
16
17
|
origin: (origin, callback) => {
|
|
17
18
|
const allowedOrigins = (options.origins ?? process.env.CORS_ORIGIN ?? '')
|
|
18
19
|
.split(',')
|
|
@@ -33,5 +34,12 @@ function createCorsMiddleware(options = {}) {
|
|
|
33
34
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'x-correlation-id', 'x-session-id'],
|
|
34
35
|
exposedHeaders: options.exposedHeaders
|
|
35
36
|
});
|
|
37
|
+
if (bypassPaths.length === 0)
|
|
38
|
+
return corsHandler;
|
|
39
|
+
return (req, res, next) => {
|
|
40
|
+
if (bypassPaths.some((prefix) => req.path.startsWith(prefix)))
|
|
41
|
+
return next();
|
|
42
|
+
return corsHandler(req, res, next);
|
|
43
|
+
};
|
|
36
44
|
}
|
|
37
45
|
//# sourceMappingURL=corsMiddleware.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"corsMiddleware.js","sourceRoot":"","sources":["../../src/middleware/corsMiddleware.ts"],"names":[],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"corsMiddleware.js","sourceRoot":"","sources":["../../src/middleware/corsMiddleware.ts"],"names":[],"mappings":";;;;;AAiCA,oDA+BC;AAhED,gDAAwB;AAExB,+DAAuC;AA0BvC;;;;GAIG;AACH,SAAgB,oBAAoB,CAAC,UAAiC,EAAE;IACtE,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,IAAI,EAAE,CAAC;IAE9C,MAAM,WAAW,GAAG,IAAA,cAAI,EAAC;QACvB,MAAM,EAAE,CAAC,MAAM,EAAE,QAAQ,EAAE,EAAE;YAC3B,MAAM,cAAc,GAAG,CAAC,OAAO,CAAC,OAAO,IAAI,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,EAAE,CAAC;iBACtE,KAAK,CAAC,GAAG,CAAC;iBACV,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;iBAClB,MAAM,CAAC,OAAO,CAAC,CAAC;YACnB,IAAI,CAAC,MAAM,IAAI,cAAc,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;gBAC/C,OAAO,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;YAC9B,CAAC;YACD,gBAAM,CAAC,IAAI,CAAC,qBAAqB,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC;YAC/C,OAAO,QAAQ,CAAC,IAAI,KAAK,CAAC,uBAAuB,GAAG,MAAM,CAAC,CAAC,CAAC;QAC/D,CAAC;QACD,WAAW,EAAE,IAAI;QACjB,uEAAuE;QACvE,uEAAuE;QACvE,wEAAwE;QACxE,kDAAkD;QAClD,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,SAAS,CAAC;QAC7D,cAAc,EAAE,CAAC,cAAc,EAAE,eAAe,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,cAAc,CAAC;QACzG,cAAc,EAAE,OAAO,CAAC,cAAc;KACvC,CAAC,CAAC;IAEH,IAAI,WAAW,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,WAA6B,CAAC;IAEnE,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QACxB,IAAI,WAAW,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;YAAE,OAAO,IAAI,EAAE,CAAC;QAC7E,OAAQ,WAA8B,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC;IACzD,CAAC,CAAC;AACJ,CAAC"}
|
package/dist/oauth/service.d.ts
CHANGED
|
@@ -9,6 +9,20 @@ export interface RegisteredClient {
|
|
|
9
9
|
clientName: string;
|
|
10
10
|
redirectUris: string[];
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Register a client, or hand back the one that is already registered.
|
|
14
|
+
*
|
|
15
|
+
* Clients re-register freely — the Claude app does it on every connect attempt
|
|
16
|
+
* — and minting a fresh id each time left a user staring at three identical
|
|
17
|
+
* "Claude" entries in their connected apps, only one of which any given
|
|
18
|
+
* disconnect would cut off.
|
|
19
|
+
*
|
|
20
|
+
* Matching on the exact redirect URIs is what makes this safe. These are public
|
|
21
|
+
* clients with no secret, so a `client_id` is an identifier rather than a
|
|
22
|
+
* credential; anything presenting the same callback URL *is* the same
|
|
23
|
+
* application, because that URL is what an authorization code gets delivered
|
|
24
|
+
* to. A different app cannot claim it without already controlling it.
|
|
25
|
+
*/
|
|
12
26
|
export declare const registerClient: (input: RegisterClientInput) => Promise<RegisteredClient>;
|
|
13
27
|
export declare const findClient: (clientId: string) => import("mongoose").Query<(import("mongoose").Document<unknown, {}, import("./models").IOAuthClient, {}, import("mongoose").DefaultSchemaOptions> & import("./models").IOAuthClient & Required<{
|
|
14
28
|
_id: import("mongoose").Types.ObjectId;
|
|
@@ -62,6 +76,13 @@ export declare const redeemAuthorizationCode: (code: string, clientId: string, r
|
|
|
62
76
|
export interface IssuedRefreshToken {
|
|
63
77
|
token: string;
|
|
64
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Start a grant, replacing any the same client already held for this user.
|
|
81
|
+
*
|
|
82
|
+
* Reconnecting is a re-authorization, not a second connection: without this,
|
|
83
|
+
* approving twice leaves two live grants, the newer scopes sitting beside the
|
|
84
|
+
* older ones, and a disconnect that revokes only one of them.
|
|
85
|
+
*/
|
|
65
86
|
export declare const issueRefreshToken: (input: {
|
|
66
87
|
clientId: string;
|
|
67
88
|
userId: string;
|
|
@@ -105,7 +126,7 @@ export declare const listConnections: (userId: string) => Promise<{
|
|
|
105
126
|
scopes: string[];
|
|
106
127
|
groupId: string | null;
|
|
107
128
|
createdAt: string;
|
|
108
|
-
|
|
129
|
+
lastRenewedAt: string | null;
|
|
109
130
|
revokedAt: string | null;
|
|
110
131
|
}[]>;
|
|
111
132
|
//# sourceMappingURL=service.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/oauth/service.ts"],"names":[],"mappings":"AACA,OAAO,EAAqB,kBAAkB,EAA6B,MAAM,UAAU,CAAC;AAC5F,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAkBpD,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,EAAE,CAAC;CACxB;AAED,eAAO,MAAM,cAAc,GAAU,OAAO,mBAAmB,KAAG,OAAO,CAAC,gBAAgB,
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/oauth/service.ts"],"names":[],"mappings":"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"}
|
package/dist/oauth/service.js
CHANGED
|
@@ -15,12 +15,31 @@ const constantTimeEquals = (a, b) => {
|
|
|
15
15
|
return false;
|
|
16
16
|
return (0, crypto_1.timingSafeEqual)(left, right);
|
|
17
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* Register a client, or hand back the one that is already registered.
|
|
20
|
+
*
|
|
21
|
+
* Clients re-register freely — the Claude app does it on every connect attempt
|
|
22
|
+
* — and minting a fresh id each time left a user staring at three identical
|
|
23
|
+
* "Claude" entries in their connected apps, only one of which any given
|
|
24
|
+
* disconnect would cut off.
|
|
25
|
+
*
|
|
26
|
+
* Matching on the exact redirect URIs is what makes this safe. These are public
|
|
27
|
+
* clients with no secret, so a `client_id` is an identifier rather than a
|
|
28
|
+
* credential; anything presenting the same callback URL *is* the same
|
|
29
|
+
* application, because that URL is what an authorization code gets delivered
|
|
30
|
+
* to. A different app cannot claim it without already controlling it.
|
|
31
|
+
*/
|
|
18
32
|
const registerClient = async (input) => {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
redirectUris: input.redirectUris
|
|
33
|
+
const redirectUris = [...input.redirectUris].sort();
|
|
34
|
+
const existing = await models_1.OAuthClient.findOne({
|
|
35
|
+
redirectUris: { $size: redirectUris.length, $all: redirectUris }
|
|
23
36
|
});
|
|
37
|
+
const client = existing ??
|
|
38
|
+
(await models_1.OAuthClient.create({
|
|
39
|
+
clientId: `tmb-client-${(0, crypto_1.randomBytes)(16).toString('hex')}`,
|
|
40
|
+
clientName: input.clientName.slice(0, 200),
|
|
41
|
+
redirectUris
|
|
42
|
+
}));
|
|
24
43
|
return {
|
|
25
44
|
clientId: client.clientId,
|
|
26
45
|
clientName: client.clientName,
|
|
@@ -76,7 +95,15 @@ const redeemAuthorizationCode = async (code, clientId, redirectUri, codeVerifier
|
|
|
76
95
|
return { ok: true, code: record };
|
|
77
96
|
};
|
|
78
97
|
exports.redeemAuthorizationCode = redeemAuthorizationCode;
|
|
98
|
+
/**
|
|
99
|
+
* Start a grant, replacing any the same client already held for this user.
|
|
100
|
+
*
|
|
101
|
+
* Reconnecting is a re-authorization, not a second connection: without this,
|
|
102
|
+
* approving twice leaves two live grants, the newer scopes sitting beside the
|
|
103
|
+
* older ones, and a disconnect that revokes only one of them.
|
|
104
|
+
*/
|
|
79
105
|
const issueRefreshToken = async (input) => {
|
|
106
|
+
await models_1.RefreshToken.updateMany({ userId: input.userId, clientId: input.clientId, revokedAt: { $exists: false } }, { $set: { revokedAt: new Date() } });
|
|
80
107
|
const token = (0, crypto_1.randomBytes)(32).toString('base64url');
|
|
81
108
|
await models_1.RefreshToken.create({ ...input, tokenHash: sha256(token) });
|
|
82
109
|
return { token };
|
|
@@ -151,7 +178,9 @@ const listConnections = async (userId) => {
|
|
|
151
178
|
scopes: token.scopes,
|
|
152
179
|
groupId: token.groupId ?? null,
|
|
153
180
|
createdAt: (token.grantedAt ?? token.createdAt).toISOString(),
|
|
154
|
-
|
|
181
|
+
// Set when the refresh token is exchanged, not when a tool runs — access
|
|
182
|
+
// tokens are validated statelessly, so the server never sees ordinary use.
|
|
183
|
+
lastRenewedAt: token.lastUsedAt?.toISOString() ?? null,
|
|
155
184
|
revokedAt: token.revokedAt?.toISOString() ?? null
|
|
156
185
|
}));
|
|
157
186
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.js","sourceRoot":"","sources":["../../src/oauth/service.ts"],"names":[],"mappings":";;;AAAA,mCAAkE;AAClE,qCAA4F;AAG5F,gFAAgF;AAChF,MAAM,WAAW,GAAG,EAAE,GAAG,IAAI,CAAC;AAE9B,MAAM,MAAM,GAAG,CAAC,KAAa,EAAU,EAAE,CAAC,IAAA,mBAAU,EAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAE3F,4EAA4E;AAC5E,MAAM,IAAI,GAAG,CAAC,QAAgB,EAAU,EAAE,CACxC,IAAA,mBAAU,EAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;AAE5D,MAAM,kBAAkB,GAAG,CAAC,CAAS,EAAE,CAAS,EAAW,EAAE;IAC3D,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC5B,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC7B,IAAI,IAAI,CAAC,MAAM,KAAK,KAAK,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IAC/C,OAAO,IAAA,wBAAe,EAAC,IAAI,EAAE,KAAK,CAAC,CAAC;AACtC,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"service.js","sourceRoot":"","sources":["../../src/oauth/service.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -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
|
}
|
|
@@ -39,11 +39,43 @@ const storedCode = (over: Record<string, unknown> = {}) => ({
|
|
|
39
39
|
beforeEach(() => {
|
|
40
40
|
jest.clearAllMocks();
|
|
41
41
|
mockedClientCreate.mockImplementation(async (doc) => doc);
|
|
42
|
+
(OAuthClient.findOne as jest.Mock).mockResolvedValue(null);
|
|
42
43
|
mockedCodeCreate.mockImplementation(async (doc) => doc);
|
|
43
44
|
mockedRefreshCreate.mockImplementation(async (doc) => doc);
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
describe('registerClient', () => {
|
|
48
|
+
it('returns the existing client when the same callback re-registers', async () => {
|
|
49
|
+
// The Claude app re-registers on every connect attempt. Minting a fresh id
|
|
50
|
+
// each time left three identical "Claude" rows in connected apps, only one
|
|
51
|
+
// of which any given disconnect would cut off.
|
|
52
|
+
(OAuthClient.findOne as jest.Mock).mockResolvedValue({
|
|
53
|
+
clientId: 'tmb-client-existing',
|
|
54
|
+
clientName: 'Claude',
|
|
55
|
+
redirectUris: ['https://claude.ai/api/mcp/auth_callback']
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const client = await registerClient({
|
|
59
|
+
clientName: 'Claude',
|
|
60
|
+
redirectUris: ['https://claude.ai/api/mcp/auth_callback']
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(client.clientId).toBe('tmb-client-existing');
|
|
64
|
+
expect(mockedClientCreate).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('matches on the exact set of callbacks, which is what identifies the app', async () => {
|
|
68
|
+
(OAuthClient.findOne as jest.Mock).mockResolvedValue(null);
|
|
69
|
+
|
|
70
|
+
await registerClient({ clientName: 'Claude', redirectUris: ['https://b/cb', 'https://a/cb'] });
|
|
71
|
+
|
|
72
|
+
// Sorted and size-constrained: a client registering a superset is a
|
|
73
|
+
// different client, and an authorization code is delivered to that URL.
|
|
74
|
+
expect((OAuthClient.findOne as jest.Mock).mock.calls[0][0]).toEqual({
|
|
75
|
+
redirectUris: { $size: 2, $all: ['https://a/cb', 'https://b/cb'] }
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
47
79
|
it('issues a namespaced client id and keeps the redirect URIs', async () => {
|
|
48
80
|
const client = await registerClient({
|
|
49
81
|
clientName: 'Claude',
|
|
@@ -181,6 +213,29 @@ describe('redeemAuthorizationCode', () => {
|
|
|
181
213
|
});
|
|
182
214
|
});
|
|
183
215
|
|
|
216
|
+
describe('issueRefreshToken', () => {
|
|
217
|
+
it('retires any grant the same client already held for this user', async () => {
|
|
218
|
+
// Reconnecting is a re-authorization, not a second connection: two live
|
|
219
|
+
// grants would leave stale scopes beside the new ones and a disconnect
|
|
220
|
+
// that only cut one of them.
|
|
221
|
+
mockedRefreshUpdateMany.mockResolvedValue({ modifiedCount: 1 });
|
|
222
|
+
|
|
223
|
+
await issueRefreshToken({
|
|
224
|
+
clientId: 'client-1',
|
|
225
|
+
userId: 'u1',
|
|
226
|
+
scopes: ['relationship:read'],
|
|
227
|
+
groupId: null,
|
|
228
|
+
resource: 'https://mcp.tumbaland.eu'
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(mockedRefreshUpdateMany).toHaveBeenCalledWith(
|
|
232
|
+
{ userId: 'u1', clientId: 'client-1', revokedAt: { $exists: false } },
|
|
233
|
+
{ $set: { revokedAt: expect.any(Date) } }
|
|
234
|
+
);
|
|
235
|
+
expect(mockedRefreshCreate).toHaveBeenCalled();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
184
239
|
describe('refresh token rotation', () => {
|
|
185
240
|
it('retires the presented token and issues a replacement', async () => {
|
|
186
241
|
const record = {
|
package/src/oauth/service.ts
CHANGED
|
@@ -29,13 +29,35 @@ export interface RegisteredClient {
|
|
|
29
29
|
redirectUris: string[];
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Register a client, or hand back the one that is already registered.
|
|
34
|
+
*
|
|
35
|
+
* Clients re-register freely — the Claude app does it on every connect attempt
|
|
36
|
+
* — and minting a fresh id each time left a user staring at three identical
|
|
37
|
+
* "Claude" entries in their connected apps, only one of which any given
|
|
38
|
+
* disconnect would cut off.
|
|
39
|
+
*
|
|
40
|
+
* Matching on the exact redirect URIs is what makes this safe. These are public
|
|
41
|
+
* clients with no secret, so a `client_id` is an identifier rather than a
|
|
42
|
+
* credential; anything presenting the same callback URL *is* the same
|
|
43
|
+
* application, because that URL is what an authorization code gets delivered
|
|
44
|
+
* to. A different app cannot claim it without already controlling it.
|
|
45
|
+
*/
|
|
32
46
|
export const registerClient = async (input: RegisterClientInput): Promise<RegisteredClient> => {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
redirectUris:
|
|
47
|
+
const redirectUris = [...input.redirectUris].sort();
|
|
48
|
+
|
|
49
|
+
const existing = await OAuthClient.findOne({
|
|
50
|
+
redirectUris: { $size: redirectUris.length, $all: redirectUris }
|
|
37
51
|
});
|
|
38
52
|
|
|
53
|
+
const client =
|
|
54
|
+
existing ??
|
|
55
|
+
(await OAuthClient.create({
|
|
56
|
+
clientId: `tmb-client-${randomBytes(16).toString('hex')}`,
|
|
57
|
+
clientName: input.clientName.slice(0, 200),
|
|
58
|
+
redirectUris
|
|
59
|
+
}));
|
|
60
|
+
|
|
39
61
|
return {
|
|
40
62
|
clientId: client.clientId,
|
|
41
63
|
clientName: client.clientName,
|
|
@@ -133,6 +155,13 @@ export interface IssuedRefreshToken {
|
|
|
133
155
|
token: string;
|
|
134
156
|
}
|
|
135
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Start a grant, replacing any the same client already held for this user.
|
|
160
|
+
*
|
|
161
|
+
* Reconnecting is a re-authorization, not a second connection: without this,
|
|
162
|
+
* approving twice leaves two live grants, the newer scopes sitting beside the
|
|
163
|
+
* older ones, and a disconnect that revokes only one of them.
|
|
164
|
+
*/
|
|
136
165
|
export const issueRefreshToken = async (input: {
|
|
137
166
|
clientId: string;
|
|
138
167
|
userId: string;
|
|
@@ -140,6 +169,11 @@ export const issueRefreshToken = async (input: {
|
|
|
140
169
|
groupId: string | null;
|
|
141
170
|
resource: string;
|
|
142
171
|
}): Promise<IssuedRefreshToken> => {
|
|
172
|
+
await RefreshToken.updateMany(
|
|
173
|
+
{ userId: input.userId, clientId: input.clientId, revokedAt: { $exists: false } },
|
|
174
|
+
{ $set: { revokedAt: new Date() } }
|
|
175
|
+
);
|
|
176
|
+
|
|
143
177
|
const token = randomBytes(32).toString('base64url');
|
|
144
178
|
await RefreshToken.create({ ...input, tokenHash: sha256(token) });
|
|
145
179
|
return { token };
|
|
@@ -239,7 +273,9 @@ export const listConnections = async (userId: string) => {
|
|
|
239
273
|
scopes: token.scopes,
|
|
240
274
|
groupId: token.groupId ?? null,
|
|
241
275
|
createdAt: (token.grantedAt ?? token.createdAt).toISOString(),
|
|
242
|
-
|
|
276
|
+
// Set when the refresh token is exchanged, not when a tool runs — access
|
|
277
|
+
// tokens are validated statelessly, so the server never sees ordinary use.
|
|
278
|
+
lastRenewedAt: token.lastUsedAt?.toISOString() ?? null,
|
|
243
279
|
revokedAt: token.revokedAt?.toISOString() ?? null
|
|
244
280
|
}));
|
|
245
281
|
};
|