@tumbaland/backend-core 1.35.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/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
|
@@ -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
|
};
|