@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,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) : [];
|