@tumbaland/backend-core 1.33.0 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseScopes = exports.verifyAccessToken = exports.mintAccessToken = void 0;
7
+ const crypto_1 = require("crypto");
8
+ const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
9
+ const env_1 = require("../config/env");
10
+ const types_1 = require("../apiKeys/types");
11
+ /**
12
+ * Access tokens for the OAuth flow that lets an assistant connect to Tumbaland.
13
+ *
14
+ * These are JWTs rather than database rows so the MCP server can validate one
15
+ * without a query on every tool call, and because OAuth expects short-lived
16
+ * bearer tokens with a refresh path rather than the indefinite credentials an
17
+ * API key is. The trade is that a token cannot be revoked before it expires —
18
+ * hence the deliberately short life, with revocation applied at the refresh
19
+ * token, which is the thing that actually persists.
20
+ */
21
+ /** Marks a token as issued by the OAuth flow, for the MCP resource specifically. */
22
+ const TOKEN_TYPE = 'mcp_access';
23
+ /** Short enough that a leaked token is a small window, long enough to be usable. */
24
+ const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
25
+ const mintAccessToken = (input) => {
26
+ const scope = input.scopes.join(' ');
27
+ const accessToken = jsonwebtoken_1.default.sign({
28
+ sub: input.userId,
29
+ aud: input.resource,
30
+ iss: input.issuer,
31
+ scope,
32
+ groupId: input.groupId,
33
+ email: input.email,
34
+ name: input.name,
35
+ typ: TOKEN_TYPE,
36
+ jti: (0, crypto_1.randomUUID)()
37
+ }, (0, env_1.requireEnv)('JWT_SECRET'), { expiresIn: ACCESS_TOKEN_TTL_SECONDS });
38
+ return { accessToken, expiresIn: ACCESS_TOKEN_TTL_SECONDS, scope };
39
+ };
40
+ exports.mintAccessToken = mintAccessToken;
41
+ /**
42
+ * Check a bearer token presented to the MCP server.
43
+ *
44
+ * Two checks beyond the signature carry real weight. The audience must match
45
+ * this server: a token minted for one resource must not work against another,
46
+ * which is what RFC 8707 binding is for and what stops a token obtained for
47
+ * somewhere else being replayed here. And the type must be `mcp_access`, so an
48
+ * ordinary session JWT — same secret, same issuer, but no scopes and no tenant
49
+ * pin — cannot be presented as an access token and quietly get everything.
50
+ */
51
+ const verifyAccessToken = (token, expectedAudience) => {
52
+ let claims;
53
+ try {
54
+ claims = jsonwebtoken_1.default.verify(token, (0, env_1.requireEnv)('JWT_SECRET'));
55
+ }
56
+ catch (error) {
57
+ const expired = error?.name === 'TokenExpiredError';
58
+ return { ok: false, rejection: expired ? 'expired' : 'bad-signature' };
59
+ }
60
+ if (claims.typ !== TOKEN_TYPE)
61
+ return { ok: false, rejection: 'wrong-type' };
62
+ if (claims.aud !== expectedAudience)
63
+ return { ok: false, rejection: 'wrong-audience' };
64
+ if (!claims.sub)
65
+ return { ok: false, rejection: 'malformed' };
66
+ return {
67
+ ok: true,
68
+ userId: claims.sub,
69
+ email: claims.email ?? '',
70
+ name: claims.name ?? '',
71
+ scopes: (claims.scope ?? '').split(' ').filter(types_1.isApiKeyScope),
72
+ groupId: claims.groupId ?? null
73
+ };
74
+ };
75
+ exports.verifyAccessToken = verifyAccessToken;
76
+ /** Parse a space-separated `scope` parameter, dropping anything we do not define. */
77
+ const parseScopes = (scope) => typeof scope === 'string' ? scope.split(/\s+/).filter(types_1.isApiKeyScope) : [];
78
+ exports.parseScopes = parseScopes;
79
+ //# sourceMappingURL=tokens.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../src/oauth/tokens.ts"],"names":[],"mappings":";;;;;;AAAA,mCAAoC;AACpC,gEAA+B;AAC/B,uCAA2C;AAC3C,4CAAmE;AAEnE;;;;;;;;;GASG;AAEH,oFAAoF;AACpF,MAAM,UAAU,GAAG,YAAY,CAAC;AAEhC,oFAAoF;AACpF,MAAM,wBAAwB,GAAG,EAAE,GAAG,EAAE,CAAC;AAoClC,MAAM,eAAe,GAAG,CAAC,KAA2B,EAAqB,EAAE;IAChF,MAAM,KAAK,GAAG,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAErC,MAAM,WAAW,GAAG,sBAAG,CAAC,IAAI,CAC1B;QACE,GAAG,EAAE,KAAK,CAAC,MAAM;QACjB,GAAG,EAAE,KAAK,CAAC,QAAQ;QACnB,GAAG,EAAE,KAAK,CAAC,MAAM;QACjB,KAAK;QACL,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,GAAG,EAAE,UAAU;QACf,GAAG,EAAE,IAAA,mBAAU,GAAE;KAClB,EACD,IAAA,gBAAU,EAAC,YAAY,CAAC,EACxB,EAAE,SAAS,EAAE,wBAAwB,EAAE,CACxC,CAAC;IAEF,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,wBAAwB,EAAE,KAAK,EAAE,CAAC;AACrE,CAAC,CAAC;AApBW,QAAA,eAAe,mBAoB1B;AAYF;;;;;;;;;GASG;AACI,MAAM,iBAAiB,GAAG,CAC/B,KAAa,EACb,gBAAwB,EACC,EAAE;IAC3B,IAAI,MAAyB,CAAC;IAC9B,IAAI,CAAC;QACH,MAAM,GAAG,sBAAG,CAAC,MAAM,CAAC,KAAK,EAAE,IAAA,gBAAU,EAAC,YAAY,CAAC,CAAsB,CAAC;IAC5E,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,OAAO,GAAI,KAAe,EAAE,IAAI,KAAK,mBAAmB,CAAC;QAC/D,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,eAAe,EAAE,CAAC;IACzE,CAAC;IAED,IAAI,MAAM,CAAC,GAAG,KAAK,UAAU;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,YAAY,EAAE,CAAC;IAC7E,IAAI,MAAM,CAAC,GAAG,KAAK,gBAAgB;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,gBAAgB,EAAE,CAAC;IACvF,IAAI,CAAC,MAAM,CAAC,GAAG;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,CAAC;IAE9D,OAAO;QACL,EAAE,EAAE,IAAI;QACR,MAAM,EAAE,MAAM,CAAC,GAAG;QAClB,KAAK,EAAE,MAAM,CAAC,KAAK,IAAI,EAAE;QACzB,IAAI,EAAE,MAAM,CAAC,IAAI,IAAI,EAAE;QACvB,MAAM,EAAE,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,qBAAa,CAAC;QAC7D,OAAO,EAAE,MAAM,CAAC,OAAO,IAAI,IAAI;KAChC,CAAC;AACJ,CAAC,CAAC;AAxBW,QAAA,iBAAiB,qBAwB5B;AAEF,qFAAqF;AAC9E,MAAM,WAAW,GAAG,CAAC,KAAc,EAAiB,EAAE,CAC3D,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,qBAAa,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;AAD/D,QAAA,WAAW,eACoD"}
package/jest.config.js CHANGED
@@ -8,7 +8,17 @@ module.exports = {
8
8
  '^.+\\.ts$': ['ts-jest', { tsconfig: { types: ['jest', 'node'] } }]
9
9
  },
10
10
  coverageProvider: 'v8',
11
- collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts', '!src/index.ts', '!src/types/**'],
11
+ // Barrels are excluded, not just the root one: a file of `export { x } from
12
+ // './y'` has no logic to test, but Jest counts every re-exported function as
13
+ // an uncovered one — which is what held global function coverage at ~78%
14
+ // while every module underneath was thoroughly tested.
15
+ collectCoverageFrom: [
16
+ 'src/**/*.ts',
17
+ '!src/**/*.test.ts',
18
+ '!src/index.ts',
19
+ '!src/**/index.ts',
20
+ '!src/types/**'
21
+ ],
12
22
  coverageReporters: ['text', 'json-summary'],
13
23
  coverageThreshold: { global: { statements: 90, branches: 80, functions: 90, lines: 90 } }
14
24
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tumbaland/backend-core",
3
- "version": "1.33.0",
3
+ "version": "1.34.0",
4
4
  "description": "Core shared functionality for Tumbaland backend services",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
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);
@@ -0,0 +1,325 @@
1
+ import { createHash } from 'crypto';
2
+
3
+ jest.mock('./models', () => ({
4
+ OAuthClient: { create: jest.fn(), findOne: jest.fn(), find: jest.fn() },
5
+ AuthorizationCode: { create: jest.fn(), findOneAndUpdate: jest.fn(), exists: jest.fn() },
6
+ RefreshToken: { create: jest.fn(), findOne: jest.fn(), find: jest.fn(), updateMany: jest.fn() }
7
+ }));
8
+
9
+ import { AuthorizationCode, OAuthClient, RefreshToken } from './models';
10
+ import {
11
+ registerClient,
12
+ isRegisteredRedirect,
13
+ issueAuthorizationCode,
14
+ redeemAuthorizationCode,
15
+ issueRefreshToken,
16
+ redeemRefreshToken,
17
+ revokeRefreshTokensForUser
18
+ } from './service';
19
+
20
+ const mockedCodeUpdate = AuthorizationCode.findOneAndUpdate as unknown as jest.Mock;
21
+ const mockedCodeExists = AuthorizationCode.exists as unknown as jest.Mock;
22
+ const mockedCodeCreate = AuthorizationCode.create as unknown as jest.Mock;
23
+ const mockedClientCreate = OAuthClient.create as unknown as jest.Mock;
24
+ const mockedRefreshCreate = RefreshToken.create as unknown as jest.Mock;
25
+ const mockedRefreshFindOne = RefreshToken.findOne as unknown as jest.Mock;
26
+ const mockedRefreshUpdateMany = RefreshToken.updateMany as unknown as jest.Mock;
27
+
28
+ const VERIFIER = 'a-code-verifier-long-enough-to-be-real';
29
+ const CHALLENGE = createHash('sha256').update(VERIFIER).digest('base64url');
30
+
31
+ const storedCode = (over: Record<string, unknown> = {}) => ({
32
+ clientId: 'client-1',
33
+ redirectUri: 'https://claude.ai/callback',
34
+ codeChallenge: CHALLENGE,
35
+ expiresAt: new Date(Date.now() + 30_000),
36
+ ...over
37
+ });
38
+
39
+ beforeEach(() => {
40
+ jest.clearAllMocks();
41
+ mockedClientCreate.mockImplementation(async (doc) => doc);
42
+ mockedCodeCreate.mockImplementation(async (doc) => doc);
43
+ mockedRefreshCreate.mockImplementation(async (doc) => doc);
44
+ });
45
+
46
+ describe('registerClient', () => {
47
+ it('issues a namespaced client id and keeps the redirect URIs', async () => {
48
+ const client = await registerClient({
49
+ clientName: 'Claude',
50
+ redirectUris: ['https://claude.ai/callback']
51
+ });
52
+
53
+ expect(client.clientId).toMatch(/^tmb-client-[0-9a-f]{32}$/);
54
+ expect(client.redirectUris).toEqual(['https://claude.ai/callback']);
55
+ });
56
+
57
+ it('never issues the same id twice', async () => {
58
+ const a = await registerClient({ clientName: 'A', redirectUris: ['https://a/cb'] });
59
+ const b = await registerClient({ clientName: 'B', redirectUris: ['https://b/cb'] });
60
+ expect(a.clientId).not.toBe(b.clientId);
61
+ });
62
+ });
63
+
64
+ describe('isRegisteredRedirect', () => {
65
+ const client = { redirectUris: ['https://claude.ai/callback'] };
66
+
67
+ it('accepts a registered URI', () => {
68
+ expect(isRegisteredRedirect(client, 'https://claude.ai/callback')).toBe(true);
69
+ });
70
+
71
+ it.each([
72
+ ['a different host', 'https://evil.example/callback'],
73
+ ['a path suffix', 'https://claude.ai/callback/extra'],
74
+ ['an added query', 'https://claude.ai/callback?next=https://evil.example'],
75
+ ['a prefix', 'https://claude.ai/call']
76
+ ])('rejects %s — matching is exact, never prefix', (_label, uri) => {
77
+ // Loose redirect matching is the classic way an authorization code gets
78
+ // delivered somewhere the client never controlled.
79
+ expect(isRegisteredRedirect(client, uri)).toBe(false);
80
+ });
81
+ });
82
+
83
+ describe('issueAuthorizationCode', () => {
84
+ it('stores what was consented to, and expires within a minute', async () => {
85
+ const before = Date.now();
86
+ await issueAuthorizationCode({
87
+ clientId: 'client-1',
88
+ userId: 'u1',
89
+ userEmail: 'u1@example.com',
90
+ userName: 'Tester',
91
+ redirectUri: 'https://claude.ai/callback',
92
+ scopes: ['relationship:read'],
93
+ groupId: 'g1',
94
+ resource: 'https://mcp.tumbaland.eu',
95
+ codeChallenge: CHALLENGE
96
+ });
97
+
98
+ const [doc] = mockedCodeCreate.mock.calls[0];
99
+ expect(doc).toMatchObject({ userId: 'u1', groupId: 'g1', scopes: ['relationship:read'] });
100
+ expect(doc.expiresAt.getTime() - before).toBeLessThanOrEqual(60_000);
101
+ });
102
+ });
103
+
104
+ describe('redeemAuthorizationCode', () => {
105
+ it('accepts a correct exchange', async () => {
106
+ mockedCodeUpdate.mockResolvedValue(storedCode());
107
+
108
+ const result = await redeemAuthorizationCode(
109
+ 'code',
110
+ 'client-1',
111
+ 'https://claude.ai/callback',
112
+ VERIFIER
113
+ );
114
+
115
+ expect(result.ok).toBe(true);
116
+ });
117
+
118
+ it('marks the code used in the same operation that reads it', async () => {
119
+ mockedCodeUpdate.mockResolvedValue(storedCode());
120
+
121
+ await redeemAuthorizationCode('code', 'client-1', 'https://claude.ai/callback', VERIFIER);
122
+
123
+ // Atomic: two simultaneous exchanges cannot both win, which a
124
+ // read-then-check would allow.
125
+ expect(mockedCodeUpdate).toHaveBeenCalledWith(
126
+ { code: 'code', usedAt: { $exists: false } },
127
+ { $set: { usedAt: expect.any(Date) } },
128
+ { new: true }
129
+ );
130
+ });
131
+
132
+ it('refuses a code that has already been exchanged', async () => {
133
+ mockedCodeUpdate.mockResolvedValue(null);
134
+ mockedCodeExists.mockResolvedValue(true);
135
+
136
+ await expect(
137
+ redeemAuthorizationCode('code', 'client-1', 'https://claude.ai/callback', VERIFIER)
138
+ ).resolves.toEqual({ ok: false, rejection: 'already-used' });
139
+ });
140
+
141
+ it('refuses a code that never existed', async () => {
142
+ mockedCodeUpdate.mockResolvedValue(null);
143
+ mockedCodeExists.mockResolvedValue(false);
144
+
145
+ await expect(
146
+ redeemAuthorizationCode('code', 'client-1', 'https://claude.ai/callback', VERIFIER)
147
+ ).resolves.toEqual({ ok: false, rejection: 'unknown' });
148
+ });
149
+
150
+ it('refuses the wrong PKCE verifier', async () => {
151
+ // Without this, an intercepted code could be exchanged by whoever stole it.
152
+ mockedCodeUpdate.mockResolvedValue(storedCode());
153
+
154
+ await expect(
155
+ redeemAuthorizationCode('code', 'client-1', 'https://claude.ai/callback', 'wrong-verifier')
156
+ ).resolves.toEqual({ ok: false, rejection: 'pkce-failed' });
157
+ });
158
+
159
+ it('refuses a different client redeeming the code', async () => {
160
+ mockedCodeUpdate.mockResolvedValue(storedCode());
161
+
162
+ await expect(
163
+ redeemAuthorizationCode('code', 'other-client', 'https://claude.ai/callback', VERIFIER)
164
+ ).resolves.toEqual({ ok: false, rejection: 'client-mismatch' });
165
+ });
166
+
167
+ it('refuses a redirect URI that differs from the one authorized', async () => {
168
+ mockedCodeUpdate.mockResolvedValue(storedCode());
169
+
170
+ await expect(
171
+ redeemAuthorizationCode('code', 'client-1', 'https://evil.example/cb', VERIFIER)
172
+ ).resolves.toEqual({ ok: false, rejection: 'redirect-mismatch' });
173
+ });
174
+
175
+ it('refuses an expired code', async () => {
176
+ mockedCodeUpdate.mockResolvedValue(storedCode({ expiresAt: new Date(Date.now() - 1000) }));
177
+
178
+ await expect(
179
+ redeemAuthorizationCode('code', 'client-1', 'https://claude.ai/callback', VERIFIER)
180
+ ).resolves.toEqual({ ok: false, rejection: 'expired' });
181
+ });
182
+ });
183
+
184
+ describe('refresh token rotation', () => {
185
+ it('retires the presented token and issues a replacement', async () => {
186
+ const record = {
187
+ userId: 'u1',
188
+ scopes: ['relationship:read'],
189
+ groupId: null,
190
+ resource: 'https://mcp.tumbaland.eu',
191
+ grantedAt: new Date('2026-03-01T00:00:00.000Z'),
192
+ createdAt: new Date('2026-09-01T00:00:00.000Z'),
193
+ save: jest.fn()
194
+ } as Record<string, unknown>;
195
+ mockedRefreshFindOne.mockResolvedValue(record);
196
+
197
+ const result = await redeemRefreshToken('old-token', 'client-1');
198
+
199
+ // The old one is dead the moment it is used: a stolen copy stops working as
200
+ // soon as the honest client refreshes.
201
+ expect(record.revokedAt).toBeInstanceOf(Date);
202
+ expect(result.rotatedToken).toBeTruthy();
203
+ expect(result.rotatedToken).not.toBe('old-token');
204
+ });
205
+
206
+ it('carries the original grant date onto the replacement', async () => {
207
+ const grantedAt = new Date('2026-03-01T00:00:00.000Z');
208
+ mockedRefreshFindOne.mockResolvedValue({
209
+ userId: 'u1',
210
+ scopes: [],
211
+ groupId: null,
212
+ resource: 'r',
213
+ grantedAt,
214
+ save: jest.fn()
215
+ });
216
+
217
+ await redeemRefreshToken('old-token', 'client-1');
218
+
219
+ // Otherwise the UI would say the user connected an hour ago when they
220
+ // connected in March.
221
+ expect(mockedRefreshCreate.mock.calls[0][0].grantedAt).toBe(grantedAt);
222
+ });
223
+
224
+ it('treats reuse of a retired token as a compromise and cuts the whole grant', async () => {
225
+ // Honest client and thief now both hold tokens from one grant and there is
226
+ // no way to tell which just called, so everything goes.
227
+ mockedRefreshFindOne.mockResolvedValue({
228
+ userId: 'u1',
229
+ revokedAt: new Date(),
230
+ scopes: [],
231
+ groupId: null,
232
+ resource: 'r',
233
+ save: jest.fn()
234
+ });
235
+ mockedRefreshUpdateMany.mockResolvedValue({ modifiedCount: 1 });
236
+
237
+ const result = await redeemRefreshToken('retired-token', 'client-1');
238
+
239
+ expect(result).toMatchObject({ ok: false, reused: true });
240
+ expect(mockedRefreshUpdateMany).toHaveBeenCalledWith(
241
+ { userId: 'u1', clientId: 'client-1', revokedAt: { $exists: false } },
242
+ { $set: { revokedAt: expect.any(Date) } }
243
+ );
244
+ });
245
+
246
+ it('does not report an unknown token as reuse', async () => {
247
+ mockedRefreshFindOne.mockResolvedValue(null);
248
+ await expect(redeemRefreshToken('nope', 'client-1')).resolves.toEqual({ ok: false });
249
+ expect(mockedRefreshUpdateMany).not.toHaveBeenCalled();
250
+ });
251
+ });
252
+
253
+ describe('refresh tokens', () => {
254
+ it('stores only a digest, never the token itself', async () => {
255
+ const { token } = await issueRefreshToken({
256
+ clientId: 'client-1',
257
+ userId: 'u1',
258
+ scopes: ['relationship:read'],
259
+ groupId: null,
260
+ resource: 'https://mcp.tumbaland.eu'
261
+ });
262
+
263
+ const [doc] = mockedRefreshCreate.mock.calls[0];
264
+ expect(doc.tokenHash).toBe(createHash('sha256').update(token).digest('hex'));
265
+ expect(JSON.stringify(doc)).not.toContain(token);
266
+ });
267
+
268
+ it('looks a token up by digest and returns what it may do', async () => {
269
+ mockedRefreshFindOne.mockResolvedValue({
270
+ userId: 'u1',
271
+ scopes: ['relationship:read'],
272
+ groupId: 'g1',
273
+ resource: 'https://mcp.tumbaland.eu',
274
+ grantedAt: new Date(),
275
+ save: jest.fn()
276
+ });
277
+
278
+ await expect(redeemRefreshToken('tok', 'client-1')).resolves.toMatchObject({
279
+ ok: true,
280
+ userId: 'u1',
281
+ groupId: 'g1'
282
+ });
283
+ });
284
+
285
+ it('refuses a revoked token, which is how an assistant is cut off', async () => {
286
+ mockedRefreshFindOne.mockResolvedValue({
287
+ userId: 'u1',
288
+ revokedAt: new Date(),
289
+ scopes: [],
290
+ groupId: null,
291
+ resource: 'r',
292
+ save: jest.fn()
293
+ });
294
+ mockedRefreshUpdateMany.mockResolvedValue({ modifiedCount: 0 });
295
+
296
+ await expect(redeemRefreshToken('tok', 'client-1')).resolves.toMatchObject({ ok: false });
297
+ });
298
+
299
+ it('refuses an unknown token', async () => {
300
+ mockedRefreshFindOne.mockResolvedValue(null);
301
+ await expect(redeemRefreshToken('tok', 'client-1')).resolves.toEqual({ ok: false });
302
+ });
303
+
304
+ it('revokes only tokens that are still live', async () => {
305
+ mockedRefreshUpdateMany.mockResolvedValue({ modifiedCount: 2 });
306
+
307
+ await revokeRefreshTokensForUser('u1');
308
+
309
+ expect(mockedRefreshUpdateMany).toHaveBeenCalledWith(
310
+ { userId: 'u1', revokedAt: { $exists: false } },
311
+ { $set: { revokedAt: expect.any(Date) } }
312
+ );
313
+ });
314
+
315
+ it('can cut off one assistant without touching the others', async () => {
316
+ mockedRefreshUpdateMany.mockResolvedValue({ modifiedCount: 1 });
317
+
318
+ await revokeRefreshTokensForUser('u1', 'client-1');
319
+
320
+ expect(mockedRefreshUpdateMany).toHaveBeenCalledWith(
321
+ expect.objectContaining({ clientId: 'client-1' }),
322
+ expect.anything()
323
+ );
324
+ });
325
+ });