mcp-oauth-provider.mjs
308 lines 10.1 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Issue #1 Phase D3 — OAuth 2.1 provider for hosted MCP.
3 * Implements OAuthServerProvider from @modelcontextprotocol/sdk.
4 * Reuses the Hub's existing Google/GitHub OAuth flow and wraps it with MCP-standard
5 * PKCE + dynamic client registration.
6 *
7 * C3 (docs/COMPANION-APP-OAUTH-SERVERSIDE-GATE.md §6): emits `iss` = canonical issuer
8 * identifier on the loopback redirect in completeMcpAuthorization (RFC 9207 §2).
9 * C5: validates redirect_uri at token exchange when provided (RFC 6749 §4.1.3).
10 */
11
12 import { randomUUID, createHash, timingSafeEqual } from 'node:crypto';
13 import jwt from 'jsonwebtoken';
14
15 const MCP_TOKEN_EXPIRY_SECONDS = 3600;
16 const MCP_REFRESH_TOKEN_EXPIRY_SECONDS = 86400;
17 const AUTH_CODE_TTL_MS = 5 * 60 * 1000;
18 const MAX_CLIENTS = 500;
19 const MAX_PENDING_CODES = 1000;
20 const REFRESH_SWEEP_INTERVAL_MS = 10 * 60 * 1000;
21
22 function sha256(s) {
23 return createHash('sha256').update(s).digest('base64url');
24 }
25
26 /**
27 * In-memory client registration store for dynamic MCP client registration.
28 * Production should move to a persistent store.
29 */
30 class InMemoryClientsStore {
31 constructor() {
32 /** @type {Map<string, object>} */
33 this._clients = new Map();
34 }
35
36 getClient(clientId) {
37 return this._clients.get(clientId);
38 }
39
40 registerClient(clientInfo) {
41 if (this._clients.size >= MAX_CLIENTS) {
42 let oldest = null;
43 let oldestTime = Infinity;
44 for (const [id, c] of this._clients) {
45 if (c.client_id_issued_at < oldestTime) {
46 oldest = id;
47 oldestTime = c.client_id_issued_at;
48 }
49 }
50 if (oldest) this._clients.delete(oldest);
51 }
52
53 const clientId = randomUUID();
54 const now = Math.floor(Date.now() / 1000);
55 const full = {
56 ...clientInfo,
57 client_id: clientId,
58 client_id_issued_at: now,
59 };
60 this._clients.set(clientId, full);
61 return full;
62 }
63 }
64
65 /**
66 * Knowtation OAuth provider that bridges the Hub's existing auth
67 * with the MCP SDK's OAuth 2.1 expectations.
68 */
69 export class KnowtationOAuthProvider {
70 /**
71 * @param {{
72 * sessionSecret: string,
73 * baseUrl: string,
74 * loginUrl?: string,
75 * }} opts
76 */
77 constructor(opts) {
78 this._sessionSecret = opts.sessionSecret;
79 this._baseUrl = opts.baseUrl.replace(/\/$/, '');
80 // C3: canonical issuer identifier — matches the `issuer.href` the mcpAuthRouter
81 // advertises in discovery metadata (new URL(BASE_URL).href). URL normalises
82 // bare-host URLs by appending a trailing slash, so we preserve that here.
83 this._issuerUrl = new URL(this._baseUrl).href;
84 this._loginUrl = opts.loginUrl || `${this._baseUrl}/auth/login`;
85 this._clientStore = new InMemoryClientsStore();
86 /** @type {Map<string, { clientId: string, codeChallenge: string, redirectUri: string, state?: string, scopes: string[], userId?: string, expires: number }>} */
87 this._pendingCodes = new Map();
88 /** @type {Map<string, { clientId: string, userId: string, scopes: string[], expires: number }>} */
89 this._refreshTokens = new Map();
90
91 this._sweepTimer = setInterval(() => this._sweepExpiredRefreshTokens(), REFRESH_SWEEP_INTERVAL_MS);
92 if (this._sweepTimer.unref) this._sweepTimer.unref();
93 }
94
95 get clientsStore() {
96 return this._clientStore;
97 }
98
99 /**
100 * Start the authorization flow by redirecting to the Hub's login page.
101 * The Hub login callback will need to handle the MCP state and call back to completeMcpAuthorization.
102 */
103 async authorize(client, params, res) {
104 const code = randomUUID();
105 this._pendingCodes.set(code, {
106 clientId: client.client_id,
107 codeChallenge: params.codeChallenge,
108 redirectUri: params.redirectUri,
109 state: params.state,
110 scopes: params.scopes || [],
111 expires: Date.now() + AUTH_CODE_TTL_MS,
112 });
113
114 this._pruneExpiredCodes();
115
116 const mcpState = Buffer.from(JSON.stringify({
117 code,
118 clientId: client.client_id,
119 redirectUri: params.redirectUri,
120 state: params.state,
121 })).toString('base64url');
122
123 const loginUrl = new URL(this._loginUrl);
124 loginUrl.searchParams.set('provider', 'google');
125 loginUrl.searchParams.set('mcp_state', mcpState);
126 res.redirect(loginUrl.toString());
127 }
128
129 /**
130 * Called after Hub OAuth callback succeeds.
131 * Binds the auth code to the authenticated user and redirects back to the MCP client.
132 *
133 * @param {string} mcpStateBase64 - The mcp_state parameter from the login callback
134 * @param {string} userId - The authenticated user's ID
135 * @param {import('express').Response} res
136 */
137 completeMcpAuthorization(mcpStateBase64, userId, res) {
138 let mcpState;
139 try {
140 mcpState = JSON.parse(Buffer.from(mcpStateBase64, 'base64url').toString());
141 } catch (_) {
142 res.status(400).json({ error: 'invalid_mcp_state' });
143 return;
144 }
145
146 const pending = this._pendingCodes.get(mcpState.code);
147 if (!pending || pending.clientId !== mcpState.clientId || Date.now() > pending.expires) {
148 res.status(400).json({ error: 'invalid_or_expired_code' });
149 return;
150 }
151
152 pending.userId = userId;
153
154 const redirectUrl = new URL(mcpState.redirectUri);
155 redirectUrl.searchParams.set('code', mcpState.code);
156 if (mcpState.state) redirectUrl.searchParams.set('state', mcpState.state);
157 // C3 (RFC 9207 §2): emit iss = canonical issuer identifier so clients that pass
158 // expectedIssuer get constant-time mix-up defense with no client-side change.
159 // Value exactly equals the `issuer` field in the discovery metadata.
160 redirectUrl.searchParams.set('iss', this._issuerUrl);
161 res.redirect(redirectUrl.toString());
162 }
163
164 async challengeForAuthorizationCode(_client, authorizationCode) {
165 const pending = this._pendingCodes.get(authorizationCode);
166 if (!pending) throw new Error('Unknown authorization code');
167 return pending.codeChallenge;
168 }
169
170 async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, _resource) {
171 const pending = this._pendingCodes.get(authorizationCode);
172 if (!pending) throw new Error('Unknown authorization code');
173 if (pending.clientId !== client.client_id) throw new Error('Client mismatch');
174 if (Date.now() > pending.expires) {
175 this._pendingCodes.delete(authorizationCode);
176 throw new Error('Authorization code expired');
177 }
178 if (!pending.userId) throw new Error('Authorization not completed');
179 // C5 (RFC 6749 §4.1.3): when redirect_uri is provided in the token request it MUST
180 // exactly equal the one bound at authorization. Absent when the SDK omits it for
181 // clients that did not include it in the auth request (tolerated for back-compat).
182 if (redirectUri !== undefined && redirectUri !== pending.redirectUri) {
183 throw new Error('redirect_uri mismatch');
184 }
185
186 this._pendingCodes.delete(authorizationCode);
187
188 const scopes = pending.scopes.length > 0 ? pending.scopes : ['vault:read'];
189 const now = Math.floor(Date.now() / 1000);
190 const accessToken = jwt.sign(
191 {
192 sub: pending.userId,
193 client_id: client.client_id,
194 scopes,
195 type: 'mcp_access',
196 },
197 this._sessionSecret,
198 { expiresIn: MCP_TOKEN_EXPIRY_SECONDS }
199 );
200
201 const refreshToken = randomUUID();
202 this._refreshTokens.set(refreshToken, {
203 clientId: client.client_id,
204 userId: pending.userId,
205 scopes,
206 expires: now + MCP_REFRESH_TOKEN_EXPIRY_SECONDS,
207 });
208
209 return {
210 access_token: accessToken,
211 token_type: 'bearer',
212 expires_in: MCP_TOKEN_EXPIRY_SECONDS,
213 refresh_token: refreshToken,
214 scope: scopes.join(' '),
215 };
216 }
217
218 async exchangeRefreshToken(client, refreshToken, scopes, _resource) {
219 const stored = this._refreshTokens.get(refreshToken);
220 if (!stored) throw new Error('Unknown refresh token');
221 if (stored.clientId !== client.client_id) throw new Error('Client mismatch');
222 if (Math.floor(Date.now() / 1000) > stored.expires) {
223 this._refreshTokens.delete(refreshToken);
224 throw new Error('Refresh token expired');
225 }
226
227 this._refreshTokens.delete(refreshToken);
228
229 const effectiveScopes = scopes && scopes.length > 0
230 ? scopes.filter((s) => stored.scopes.includes(s))
231 : stored.scopes;
232
233 const accessToken = jwt.sign(
234 {
235 sub: stored.userId,
236 client_id: client.client_id,
237 scopes: effectiveScopes,
238 type: 'mcp_access',
239 },
240 this._sessionSecret,
241 { expiresIn: MCP_TOKEN_EXPIRY_SECONDS }
242 );
243
244 const newRefreshToken = randomUUID();
245 this._refreshTokens.set(newRefreshToken, {
246 clientId: client.client_id,
247 userId: stored.userId,
248 scopes: effectiveScopes,
249 expires: Math.floor(Date.now() / 1000) + MCP_REFRESH_TOKEN_EXPIRY_SECONDS,
250 });
251
252 return {
253 access_token: accessToken,
254 token_type: 'bearer',
255 expires_in: MCP_TOKEN_EXPIRY_SECONDS,
256 refresh_token: newRefreshToken,
257 scope: effectiveScopes.join(' '),
258 };
259 }
260
261 async verifyAccessToken(token) {
262 try {
263 const payload = jwt.verify(token, this._sessionSecret);
264 if (payload.type !== 'mcp_access') throw new Error('Not an MCP access token');
265 return {
266 token,
267 clientId: payload.client_id,
268 scopes: payload.scopes || [],
269 expiresAt: payload.exp,
270 extra: { sub: payload.sub },
271 };
272 } catch (e) {
273 throw new Error(`Invalid access token: ${e.message}`);
274 }
275 }
276
277 async revokeToken(client, request) {
278 const token = request.token;
279 if (this._refreshTokens.has(token)) {
280 const stored = this._refreshTokens.get(token);
281 if (stored.clientId === client.client_id) {
282 this._refreshTokens.delete(token);
283 }
284 }
285 }
286
287 _pruneExpiredCodes() {
288 if (this._pendingCodes.size <= MAX_PENDING_CODES) return;
289 const now = Date.now();
290 for (const [code, pending] of this._pendingCodes) {
291 if (now > pending.expires) this._pendingCodes.delete(code);
292 }
293 }
294
295 _sweepExpiredRefreshTokens() {
296 const nowSec = Math.floor(Date.now() / 1000);
297 for (const [token, stored] of this._refreshTokens) {
298 if (nowSec > stored.expires) this._refreshTokens.delete(token);
299 }
300 }
301
302 destroy() {
303 if (this._sweepTimer) {
304 clearInterval(this._sweepTimer);
305 this._sweepTimer = null;
306 }
307 }
308 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago