@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,245 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'crypto';
2
+ import { AuthorizationCode, IAuthorizationCode, OAuthClient, RefreshToken } from './models';
3
+ import type { ApiKeyScope } from '../apiKeys/types';
4
+
5
+ /** How long a user has between approving and the client exchanging the code. */
6
+ const CODE_TTL_MS = 60 * 1000;
7
+
8
+ const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex');
9
+
10
+ /** base64url of the SHA-256 digest, which is what PKCE `S256` specifies. */
11
+ const s256 = (verifier: string): string =>
12
+ createHash('sha256').update(verifier).digest('base64url');
13
+
14
+ const constantTimeEquals = (a: string, b: string): boolean => {
15
+ const left = Buffer.from(a);
16
+ const right = Buffer.from(b);
17
+ if (left.length !== right.length) return false;
18
+ return timingSafeEqual(left, right);
19
+ };
20
+
21
+ export interface RegisterClientInput {
22
+ clientName: string;
23
+ redirectUris: string[];
24
+ }
25
+
26
+ export interface RegisteredClient {
27
+ clientId: string;
28
+ clientName: string;
29
+ redirectUris: string[];
30
+ }
31
+
32
+ export const registerClient = async (input: RegisterClientInput): Promise<RegisteredClient> => {
33
+ const client = await OAuthClient.create({
34
+ clientId: `tmb-client-${randomBytes(16).toString('hex')}`,
35
+ clientName: input.clientName.slice(0, 200),
36
+ redirectUris: input.redirectUris
37
+ });
38
+
39
+ return {
40
+ clientId: client.clientId,
41
+ clientName: client.clientName,
42
+ redirectUris: client.redirectUris
43
+ };
44
+ };
45
+
46
+ export const findClient = (clientId: string) => OAuthClient.findOne({ clientId });
47
+
48
+ /**
49
+ * Whether a client may be sent back to this URI.
50
+ *
51
+ * Exact string match, deliberately. Prefix or wildcard matching on redirect URIs
52
+ * is the classic way an authorization code ends up delivered to somewhere the
53
+ * client never controlled.
54
+ */
55
+ export const isRegisteredRedirect = (client: { redirectUris: string[] }, uri: string): boolean =>
56
+ client.redirectUris.includes(uri);
57
+
58
+ export interface IssueCodeInput {
59
+ clientId: string;
60
+ userId: string;
61
+ userEmail: string;
62
+ userName: string;
63
+ redirectUri: string;
64
+ scopes: ApiKeyScope[];
65
+ groupId: string | null;
66
+ resource: string;
67
+ codeChallenge: string;
68
+ }
69
+
70
+ export const issueAuthorizationCode = async (input: IssueCodeInput): Promise<string> => {
71
+ const code = randomBytes(32).toString('base64url');
72
+
73
+ await AuthorizationCode.create({
74
+ ...input,
75
+ code,
76
+ expiresAt: new Date(Date.now() + CODE_TTL_MS)
77
+ });
78
+
79
+ return code;
80
+ };
81
+
82
+ export type CodeRejection =
83
+ | 'unknown'
84
+ | 'expired'
85
+ | 'already-used'
86
+ | 'client-mismatch'
87
+ | 'redirect-mismatch'
88
+ | 'pkce-failed';
89
+
90
+ export interface CodeRedemption {
91
+ ok: boolean;
92
+ rejection?: CodeRejection;
93
+ code?: IAuthorizationCode;
94
+ }
95
+
96
+ /**
97
+ * Exchange a code, once.
98
+ *
99
+ * The `usedAt` stamp is set by the same atomic update that fetches the code, so
100
+ * two simultaneous exchanges cannot both succeed — a replayed code loses the
101
+ * race rather than being caught by a check that ran a moment earlier.
102
+ */
103
+ export const redeemAuthorizationCode = async (
104
+ code: string,
105
+ clientId: string,
106
+ redirectUri: string,
107
+ codeVerifier: string
108
+ ): Promise<CodeRedemption> => {
109
+ const record = await AuthorizationCode.findOneAndUpdate(
110
+ { code, usedAt: { $exists: false } },
111
+ { $set: { usedAt: new Date() } },
112
+ { new: true }
113
+ );
114
+
115
+ if (!record) {
116
+ // Either it never existed or it has already been redeemed; the client is
117
+ // told the same thing for both.
118
+ const existed = await AuthorizationCode.exists({ code });
119
+ return { ok: false, rejection: existed ? 'already-used' : 'unknown' };
120
+ }
121
+
122
+ if (record.expiresAt.getTime() <= Date.now()) return { ok: false, rejection: 'expired' };
123
+ if (record.clientId !== clientId) return { ok: false, rejection: 'client-mismatch' };
124
+ if (record.redirectUri !== redirectUri) return { ok: false, rejection: 'redirect-mismatch' };
125
+ if (!constantTimeEquals(s256(codeVerifier), record.codeChallenge)) {
126
+ return { ok: false, rejection: 'pkce-failed' };
127
+ }
128
+
129
+ return { ok: true, code: record };
130
+ };
131
+
132
+ export interface IssuedRefreshToken {
133
+ token: string;
134
+ }
135
+
136
+ export const issueRefreshToken = async (input: {
137
+ clientId: string;
138
+ userId: string;
139
+ scopes: ApiKeyScope[];
140
+ groupId: string | null;
141
+ resource: string;
142
+ }): Promise<IssuedRefreshToken> => {
143
+ const token = randomBytes(32).toString('base64url');
144
+ await RefreshToken.create({ ...input, tokenHash: sha256(token) });
145
+ return { token };
146
+ };
147
+
148
+ export interface RefreshRedemption {
149
+ ok: boolean;
150
+ /** true when a retired token was presented again — treated as a compromise */
151
+ reused?: boolean;
152
+ userId?: string;
153
+ scopes?: ApiKeyScope[];
154
+ groupId?: string | null;
155
+ resource?: string;
156
+ /** the replacement the client must store; the presented one is now dead */
157
+ rotatedToken?: string;
158
+ }
159
+
160
+ /**
161
+ * Exchange a refresh token for a new one, rotating as we go.
162
+ *
163
+ * Rotation matters because these are the long-lived half: an access token is
164
+ * gone within the hour, but a refresh token that never changes is a permanent
165
+ * credential for whoever obtains a copy. Each use retires the old token and
166
+ * issues a fresh one, so a stolen copy stops working as soon as the legitimate
167
+ * client refreshes.
168
+ *
169
+ * Reuse of an already-rotated token is treated as theft rather than as an
170
+ * ordinary failure: the honest client and the thief now both hold tokens
171
+ * descended from the same grant, and there is no way to tell which just called.
172
+ * So the whole chain is revoked and the user reconnects — noisy, but the
173
+ * alternative is leaving an attacker with working access.
174
+ */
175
+ export const redeemRefreshToken = async (
176
+ token: string,
177
+ clientId: string
178
+ ): Promise<RefreshRedemption> => {
179
+ const record = await RefreshToken.findOne({ tokenHash: sha256(token), clientId });
180
+ if (!record) return { ok: false };
181
+
182
+ if (record.revokedAt) {
183
+ // Already rotated or explicitly revoked. If something is still presenting
184
+ // it, a copy is loose — cut every token in this grant.
185
+ await RefreshToken.updateMany(
186
+ { userId: record.userId, clientId, revokedAt: { $exists: false } },
187
+ { $set: { revokedAt: new Date() } }
188
+ );
189
+ return { ok: false, reused: true };
190
+ }
191
+
192
+ record.revokedAt = new Date();
193
+ record.lastUsedAt = new Date();
194
+ await record.save();
195
+
196
+ const rotated = randomBytes(32).toString('base64url');
197
+ await RefreshToken.create({
198
+ tokenHash: sha256(rotated),
199
+ clientId,
200
+ userId: record.userId,
201
+ scopes: record.scopes,
202
+ groupId: record.groupId ?? null,
203
+ resource: record.resource,
204
+ // Carried, not reset: this is still the grant the user approved.
205
+ grantedAt: record.grantedAt ?? record.createdAt
206
+ });
207
+
208
+ return {
209
+ ok: true,
210
+ userId: record.userId,
211
+ scopes: record.scopes as ApiKeyScope[],
212
+ groupId: record.groupId ?? null,
213
+ resource: record.resource,
214
+ rotatedToken: rotated
215
+ };
216
+ };
217
+
218
+ /** Cut off an assistant: without a refresh token it can obtain nothing new. */
219
+ export const revokeRefreshTokensForUser = async (
220
+ userId: string,
221
+ clientId?: string
222
+ ): Promise<number> => {
223
+ const filter: Record<string, unknown> = { userId, revokedAt: { $exists: false } };
224
+ if (clientId) filter.clientId = clientId;
225
+
226
+ const result = await RefreshToken.updateMany(filter, { $set: { revokedAt: new Date() } });
227
+ return result.modifiedCount;
228
+ };
229
+
230
+ export const listConnections = async (userId: string) => {
231
+ const tokens = await RefreshToken.find({ userId }).sort({ createdAt: -1 });
232
+ const clients = await OAuthClient.find({ clientId: { $in: tokens.map((t) => t.clientId) } });
233
+ const nameById = new Map(clients.map((client) => [client.clientId, client.clientName]));
234
+
235
+ return tokens.map((token) => ({
236
+ id: String(token._id),
237
+ clientId: token.clientId,
238
+ clientName: nameById.get(token.clientId) ?? 'Unknown app',
239
+ scopes: token.scopes,
240
+ groupId: token.groupId ?? null,
241
+ createdAt: (token.grantedAt ?? token.createdAt).toISOString(),
242
+ lastUsedAt: token.lastUsedAt?.toISOString() ?? null,
243
+ revokedAt: token.revokedAt?.toISOString() ?? null
244
+ }));
245
+ };
@@ -0,0 +1,129 @@
1
+ const ORIGINAL_ENV = process.env;
2
+
3
+ import jwt from 'jsonwebtoken';
4
+ import { mintAccessToken, verifyAccessToken, parseScopes } from './tokens';
5
+
6
+ const RESOURCE = 'https://mcp.tumbaland.eu';
7
+ const ISSUER = 'https://auth-api.tumbaland.eu';
8
+
9
+ const mint = (over: Partial<Parameters<typeof mintAccessToken>[0]> = {}) =>
10
+ mintAccessToken({
11
+ userId: 'u1',
12
+ email: 'u1@example.com',
13
+ name: 'Tester',
14
+ resource: RESOURCE,
15
+ issuer: ISSUER,
16
+ scopes: ['relationship:read', 'relationship:write'],
17
+ groupId: null,
18
+ ...over
19
+ });
20
+
21
+ beforeEach(() => {
22
+ process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-jwt-secret' };
23
+ });
24
+
25
+ afterEach(() => {
26
+ process.env = ORIGINAL_ENV;
27
+ });
28
+
29
+ describe('mintAccessToken', () => {
30
+ it('binds the token to one resource and carries the granted scopes', () => {
31
+ const { accessToken, scope, expiresIn } = mint();
32
+ const claims = jwt.decode(accessToken) as Record<string, unknown>;
33
+
34
+ expect(claims.aud).toBe(RESOURCE);
35
+ expect(claims.iss).toBe(ISSUER);
36
+ expect(claims.sub).toBe('u1');
37
+ expect(scope).toBe('relationship:read relationship:write');
38
+ expect(expiresIn).toBe(3600);
39
+ });
40
+
41
+ it('carries the tenant pin, so a token cannot wander between groups', () => {
42
+ const claims = jwt.decode(mint({ groupId: 'g1' }).accessToken) as Record<string, unknown>;
43
+ expect(claims.groupId).toBe('g1');
44
+ });
45
+
46
+ it('gives every token a distinct id', () => {
47
+ const a = jwt.decode(mint().accessToken) as Record<string, unknown>;
48
+ const b = jwt.decode(mint().accessToken) as Record<string, unknown>;
49
+ expect(a.jti).not.toBe(b.jti);
50
+ });
51
+ });
52
+
53
+ describe('verifyAccessToken', () => {
54
+ it('accepts a token minted for this resource', () => {
55
+ const { accessToken } = mint();
56
+
57
+ expect(verifyAccessToken(accessToken, RESOURCE)).toMatchObject({
58
+ ok: true,
59
+ userId: 'u1',
60
+ email: 'u1@example.com',
61
+ scopes: ['relationship:read', 'relationship:write'],
62
+ groupId: null
63
+ });
64
+ });
65
+
66
+ it('refuses a token minted for a different resource', () => {
67
+ // RFC 8707 audience binding: a token obtained for somewhere else must not
68
+ // be replayable here, which is the whole point of the resource parameter.
69
+ const { accessToken } = mint({ resource: 'https://mcp.someone-else.example' });
70
+
71
+ expect(verifyAccessToken(accessToken, RESOURCE)).toEqual({
72
+ ok: false,
73
+ rejection: 'wrong-audience'
74
+ });
75
+ });
76
+
77
+ it('refuses an ordinary session JWT presented as an access token', () => {
78
+ // Same secret, same issuer — but no scopes and no tenant pin. Without the
79
+ // type check a user's session cookie would authenticate as an assistant
80
+ // holding every permission.
81
+ const session = jwt.sign(
82
+ { id: 'u1', email: 'u1@example.com', name: 'Tester', groups: ['g1'] },
83
+ 'test-jwt-secret'
84
+ );
85
+
86
+ expect(verifyAccessToken(session, RESOURCE)).toEqual({ ok: false, rejection: 'wrong-type' });
87
+ });
88
+
89
+ it('refuses a token signed with a different secret', () => {
90
+ const forged = jwt.sign({ sub: 'u1', aud: RESOURCE, typ: 'mcp_access' }, 'not-the-secret');
91
+ expect(verifyAccessToken(forged, RESOURCE)).toEqual({ ok: false, rejection: 'bad-signature' });
92
+ });
93
+
94
+ it('reports an expired token distinctly, so the client knows to refresh', () => {
95
+ const expired = jwt.sign(
96
+ { sub: 'u1', aud: RESOURCE, typ: 'mcp_access', scope: '' },
97
+ 'test-jwt-secret',
98
+ { expiresIn: -10 }
99
+ );
100
+
101
+ expect(verifyAccessToken(expired, RESOURCE)).toEqual({ ok: false, rejection: 'expired' });
102
+ });
103
+
104
+ it('refuses gibberish rather than throwing', () => {
105
+ expect(verifyAccessToken('not-a-token', RESOURCE).ok).toBe(false);
106
+ });
107
+
108
+ it('drops scopes it does not define, so a forged claim grants nothing', () => {
109
+ const token = jwt.sign(
110
+ { sub: 'u1', aud: RESOURCE, typ: 'mcp_access', scope: 'relationship:read admin:everything' },
111
+ 'test-jwt-secret'
112
+ );
113
+
114
+ expect(verifyAccessToken(token, RESOURCE).scopes).toEqual(['relationship:read']);
115
+ });
116
+ });
117
+
118
+ describe('parseScopes', () => {
119
+ it('keeps only scopes the system defines', () => {
120
+ expect(parseScopes('relationship:read admin:all finance:write')).toEqual([
121
+ 'relationship:read',
122
+ 'finance:write'
123
+ ]);
124
+ });
125
+
126
+ it.each([[undefined], [null], [42], ['']])('returns nothing for %s', (value) => {
127
+ expect(parseScopes(value)).toEqual([]);
128
+ });
129
+ });
@@ -0,0 +1,127 @@
1
+ import { randomUUID } from 'crypto';
2
+ import jwt from 'jsonwebtoken';
3
+ import { requireEnv } from '../config/env';
4
+ import { isApiKeyScope, type ApiKeyScope } from '../apiKeys/types';
5
+
6
+ /**
7
+ * Access tokens for the OAuth flow that lets an assistant connect to Tumbaland.
8
+ *
9
+ * These are JWTs rather than database rows so the MCP server can validate one
10
+ * without a query on every tool call, and because OAuth expects short-lived
11
+ * bearer tokens with a refresh path rather than the indefinite credentials an
12
+ * API key is. The trade is that a token cannot be revoked before it expires —
13
+ * hence the deliberately short life, with revocation applied at the refresh
14
+ * token, which is the thing that actually persists.
15
+ */
16
+
17
+ /** Marks a token as issued by the OAuth flow, for the MCP resource specifically. */
18
+ const TOKEN_TYPE = 'mcp_access';
19
+
20
+ /** Short enough that a leaked token is a small window, long enough to be usable. */
21
+ const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
22
+
23
+ export interface AccessTokenClaims {
24
+ /** the user the assistant is acting for */
25
+ sub: string;
26
+ /** the MCP server this token may be used against — RFC 8707 audience binding */
27
+ aud: string;
28
+ iss: string;
29
+ scope: string;
30
+ /** the tenant the token is pinned to; null means the user's personal data */
31
+ groupId: string | null;
32
+ email: string;
33
+ name: string;
34
+ typ: typeof TOKEN_TYPE;
35
+ jti: string;
36
+ exp: number;
37
+ iat: number;
38
+ }
39
+
40
+ export interface MintAccessTokenInput {
41
+ userId: string;
42
+ email: string;
43
+ name: string;
44
+ /** the canonical URI of the MCP server the token is for */
45
+ resource: string;
46
+ issuer: string;
47
+ scopes: ApiKeyScope[];
48
+ groupId: string | null;
49
+ }
50
+
51
+ export interface MintedAccessToken {
52
+ accessToken: string;
53
+ expiresIn: number;
54
+ scope: string;
55
+ }
56
+
57
+ export const mintAccessToken = (input: MintAccessTokenInput): MintedAccessToken => {
58
+ const scope = input.scopes.join(' ');
59
+
60
+ const accessToken = jwt.sign(
61
+ {
62
+ sub: input.userId,
63
+ aud: input.resource,
64
+ iss: input.issuer,
65
+ scope,
66
+ groupId: input.groupId,
67
+ email: input.email,
68
+ name: input.name,
69
+ typ: TOKEN_TYPE,
70
+ jti: randomUUID()
71
+ },
72
+ requireEnv('JWT_SECRET'),
73
+ { expiresIn: ACCESS_TOKEN_TTL_SECONDS }
74
+ );
75
+
76
+ return { accessToken, expiresIn: ACCESS_TOKEN_TTL_SECONDS, scope };
77
+ };
78
+
79
+ export interface AccessTokenVerification {
80
+ ok: boolean;
81
+ rejection?: 'malformed' | 'expired' | 'wrong-audience' | 'wrong-type' | 'bad-signature';
82
+ userId?: string;
83
+ email?: string;
84
+ name?: string;
85
+ scopes?: ApiKeyScope[];
86
+ groupId?: string | null;
87
+ }
88
+
89
+ /**
90
+ * Check a bearer token presented to the MCP server.
91
+ *
92
+ * Two checks beyond the signature carry real weight. The audience must match
93
+ * this server: a token minted for one resource must not work against another,
94
+ * which is what RFC 8707 binding is for and what stops a token obtained for
95
+ * somewhere else being replayed here. And the type must be `mcp_access`, so an
96
+ * ordinary session JWT — same secret, same issuer, but no scopes and no tenant
97
+ * pin — cannot be presented as an access token and quietly get everything.
98
+ */
99
+ export const verifyAccessToken = (
100
+ token: string,
101
+ expectedAudience: string
102
+ ): AccessTokenVerification => {
103
+ let claims: AccessTokenClaims;
104
+ try {
105
+ claims = jwt.verify(token, requireEnv('JWT_SECRET')) as AccessTokenClaims;
106
+ } catch (error) {
107
+ const expired = (error as Error)?.name === 'TokenExpiredError';
108
+ return { ok: false, rejection: expired ? 'expired' : 'bad-signature' };
109
+ }
110
+
111
+ if (claims.typ !== TOKEN_TYPE) return { ok: false, rejection: 'wrong-type' };
112
+ if (claims.aud !== expectedAudience) return { ok: false, rejection: 'wrong-audience' };
113
+ if (!claims.sub) return { ok: false, rejection: 'malformed' };
114
+
115
+ return {
116
+ ok: true,
117
+ userId: claims.sub,
118
+ email: claims.email ?? '',
119
+ name: claims.name ?? '',
120
+ scopes: (claims.scope ?? '').split(' ').filter(isApiKeyScope),
121
+ groupId: claims.groupId ?? null
122
+ };
123
+ };
124
+
125
+ /** Parse a space-separated `scope` parameter, dropping anything we do not define. */
126
+ export const parseScopes = (scope: unknown): ApiKeyScope[] =>
127
+ typeof scope === 'string' ? scope.split(/\s+/).filter(isApiKeyScope) : [];