mcp-oauth-provider.mjs
372 lines 12.2 KB
Raw
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor ⚠ breaking 10 days 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 * Phase A (durable agent auth): refresh tokens are persisted via the shared
8 * `createGatewayRefreshStore({ consistency: 'strong' })` / `refresh-token-core`
9 * (hash-at-rest, rotation, reuse→family revoke). Do not use an in-memory Map.
10 *
11 * C3 (docs/COMPANION-APP-OAUTH-SERVERSIDE-GATE.md §6): emits `iss` = canonical issuer
12 * identifier on the loopback redirect in completeMcpAuthorization (RFC 9207 §2).
13 * C5: validates redirect_uri at token exchange when provided (RFC 6749 §4.1.3).
14 */
15
16 import { randomUUID, createHash } from 'node:crypto';
17 import jwt from 'jsonwebtoken';
18 import {
19 DEFAULT_TOKEN_TTL_MS,
20 DEFAULT_FAMILY_TTL_MS,
21 REFRESH_FAILURE,
22 } from '../lib/refresh-token-core.mjs';
23
24 const MCP_TOKEN_EXPIRY_SECONDS = 3600;
25 const AUTH_CODE_TTL_MS = 5 * 60 * 1000;
26 const MAX_CLIENTS = 500;
27 const MAX_PENDING_CODES = 1000;
28
29 function sha256(s) {
30 return createHash('sha256').update(s).digest('base64url');
31 }
32
33 /**
34 * In-memory client registration store for dynamic MCP client registration.
35 * Production should move to a persistent store.
36 */
37 class InMemoryClientsStore {
38 constructor() {
39 /** @type {Map<string, object>} */
40 this._clients = new Map();
41 }
42
43 getClient(clientId) {
44 return this._clients.get(clientId);
45 }
46
47 registerClient(clientInfo) {
48 if (this._clients.size >= MAX_CLIENTS) {
49 let oldest = null;
50 let oldestTime = Infinity;
51 for (const [id, c] of this._clients) {
52 if (c.client_id_issued_at < oldestTime) {
53 oldest = id;
54 oldestTime = c.client_id_issued_at;
55 }
56 }
57 if (oldest) this._clients.delete(oldest);
58 }
59
60 const clientId = randomUUID();
61 const now = Math.floor(Date.now() / 1000);
62 const full = {
63 ...clientInfo,
64 client_id: clientId,
65 client_id_issued_at: now,
66 };
67 this._clients.set(clientId, full);
68 return full;
69 }
70 }
71
72 /**
73 * Build agent label for refresh-record meta (multi-Hermes revoke list).
74 * Prefer explicit option, then dynamic client_name, then client_id.
75 * @param {object} client
76 * @param {string} [explicit]
77 * @returns {string}
78 */
79 export function resolveMcpAgentLabel(client, explicit) {
80 if (typeof explicit === 'string' && explicit.trim()) return explicit.trim().slice(0, 128);
81 const name = client && typeof client.client_name === 'string' ? client.client_name.trim() : '';
82 if (name) return name.slice(0, 128);
83 return String(client?.client_id || 'mcp-client').slice(0, 128);
84 }
85
86 /**
87 * Map refresh-token-core failure reasons to Error messages (no secrets).
88 * @param {string} reason
89 * @returns {Error}
90 */
91 function refreshFailureError(reason) {
92 switch (reason) {
93 case REFRESH_FAILURE.REUSE:
94 return new Error('Refresh token reuse detected');
95 case REFRESH_FAILURE.REVOKED:
96 return new Error('Refresh token revoked');
97 case REFRESH_FAILURE.EXPIRED:
98 return new Error('Refresh token expired');
99 default:
100 return new Error('Unknown refresh token');
101 }
102 }
103
104 /**
105 * Knowtation OAuth provider that bridges the Hub's existing auth
106 * with the MCP SDK's OAuth 2.1 expectations.
107 */
108 export class KnowtationOAuthProvider {
109 /**
110 * @param {{
111 * sessionSecret: string,
112 * baseUrl: string,
113 * loginUrl?: string,
114 * refreshStore: {
115 * issue: Function,
116 * rotate: Function,
117 * revoke: Function,
118 * peek?: Function,
119 * },
120 * agentLabel?: string,
121 * }} opts
122 */
123 constructor(opts) {
124 if (!opts?.refreshStore?.issue || !opts?.refreshStore?.rotate || !opts?.refreshStore?.revoke) {
125 throw new Error('KnowtationOAuthProvider requires refreshStore { issue, rotate, revoke }');
126 }
127 this._sessionSecret = opts.sessionSecret;
128 this._baseUrl = opts.baseUrl.replace(/\/$/, '');
129 // C3: canonical issuer identifier — matches the `issuer.href` the mcpAuthRouter
130 // advertises in discovery metadata (new URL(BASE_URL).href). URL normalises
131 // bare-host URLs by appending a trailing slash, so we preserve that here.
132 this._issuerUrl = new URL(this._baseUrl).href;
133 this._loginUrl = opts.loginUrl || `${this._baseUrl}/auth/login`;
134 this._clientStore = new InMemoryClientsStore();
135 this._refreshStore = opts.refreshStore;
136 this._defaultAgentLabel = typeof opts.agentLabel === 'string' ? opts.agentLabel : undefined;
137 /** @type {Map<string, { clientId: string, codeChallenge: string, redirectUri: string, state?: string, scopes: string[], userId?: string, expires: number }>} */
138 this._pendingCodes = new Map();
139 }
140
141 get clientsStore() {
142 return this._clientStore;
143 }
144
145 /**
146 * Start the authorization flow by redirecting to the Hub's login page.
147 * The Hub login callback will need to handle the MCP state and call back to completeMcpAuthorization.
148 */
149 async authorize(client, params, res) {
150 const code = randomUUID();
151 this._pendingCodes.set(code, {
152 clientId: client.client_id,
153 codeChallenge: params.codeChallenge,
154 redirectUri: params.redirectUri,
155 state: params.state,
156 scopes: params.scopes || [],
157 expires: Date.now() + AUTH_CODE_TTL_MS,
158 });
159
160 this._pruneExpiredCodes();
161
162 const mcpState = Buffer.from(JSON.stringify({
163 code,
164 clientId: client.client_id,
165 redirectUri: params.redirectUri,
166 state: params.state,
167 })).toString('base64url');
168
169 const loginUrl = new URL(this._loginUrl);
170 loginUrl.searchParams.set('provider', 'google');
171 loginUrl.searchParams.set('mcp_state', mcpState);
172 res.redirect(loginUrl.toString());
173 }
174
175 /**
176 * Called after Hub OAuth callback succeeds.
177 * Binds the auth code to the authenticated user and redirects back to the MCP client.
178 *
179 * @param {string} mcpStateBase64 - The mcp_state parameter from the login callback
180 * @param {string} userId - The authenticated user's ID
181 * @param {import('express').Response} res
182 */
183 completeMcpAuthorization(mcpStateBase64, userId, res) {
184 let mcpState;
185 try {
186 mcpState = JSON.parse(Buffer.from(mcpStateBase64, 'base64url').toString());
187 } catch (_) {
188 res.status(400).json({ error: 'invalid_mcp_state' });
189 return;
190 }
191
192 const pending = this._pendingCodes.get(mcpState.code);
193 if (!pending || pending.clientId !== mcpState.clientId || Date.now() > pending.expires) {
194 res.status(400).json({ error: 'invalid_or_expired_code' });
195 return;
196 }
197
198 pending.userId = userId;
199
200 const redirectUrl = new URL(mcpState.redirectUri);
201 redirectUrl.searchParams.set('code', mcpState.code);
202 if (mcpState.state) redirectUrl.searchParams.set('state', mcpState.state);
203 // C3 (RFC 9207 §2): emit iss = canonical issuer identifier so clients that pass
204 // expectedIssuer get constant-time mix-up defense with no client-side change.
205 // Value exactly equals the `issuer` field in the discovery metadata.
206 redirectUrl.searchParams.set('iss', this._issuerUrl);
207 res.redirect(redirectUrl.toString());
208 }
209
210 async challengeForAuthorizationCode(_client, authorizationCode) {
211 const pending = this._pendingCodes.get(authorizationCode);
212 if (!pending) throw new Error('Unknown authorization code');
213 return pending.codeChallenge;
214 }
215
216 async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, _resource) {
217 const pending = this._pendingCodes.get(authorizationCode);
218 if (!pending) throw new Error('Unknown authorization code');
219 if (pending.clientId !== client.client_id) throw new Error('Client mismatch');
220 if (Date.now() > pending.expires) {
221 this._pendingCodes.delete(authorizationCode);
222 throw new Error('Authorization code expired');
223 }
224 if (!pending.userId) throw new Error('Authorization not completed');
225 // C5 (RFC 6749 §4.1.3): when redirect_uri is provided in the token request it MUST
226 // exactly equal the one bound at authorization. Absent when the SDK omits it for
227 // clients that did not include it in the auth request (tolerated for back-compat).
228 if (redirectUri !== undefined && redirectUri !== pending.redirectUri) {
229 throw new Error('redirect_uri mismatch');
230 }
231
232 this._pendingCodes.delete(authorizationCode);
233
234 const scopes = pending.scopes.length > 0 ? pending.scopes : ['vault:read'];
235 const accessToken = jwt.sign(
236 {
237 sub: pending.userId,
238 client_id: client.client_id,
239 scopes,
240 type: 'mcp_access',
241 },
242 this._sessionSecret,
243 { expiresIn: MCP_TOKEN_EXPIRY_SECONDS }
244 );
245
246 const agent = resolveMcpAgentLabel(client, this._defaultAgentLabel);
247 let refreshResult;
248 try {
249 refreshResult = await this._refreshStore.issue(pending.userId, {
250 tokenTtlMs: DEFAULT_TOKEN_TTL_MS,
251 familyTtlMs: DEFAULT_FAMILY_TTL_MS,
252 meta: {
253 agent,
254 client_id: client.client_id,
255 scopes: scopes.join(' '),
256 },
257 });
258 } catch (_) {
259 throw new Error('Refresh token issuance failed');
260 }
261
262 return {
263 access_token: accessToken,
264 token_type: 'bearer',
265 expires_in: MCP_TOKEN_EXPIRY_SECONDS,
266 refresh_token: refreshResult.token,
267 scope: scopes.join(' '),
268 };
269 }
270
271 async exchangeRefreshToken(client, refreshToken, scopes, _resource) {
272 if (typeof this._refreshStore.peek === 'function') {
273 const peeked = await this._refreshStore.peek(refreshToken);
274 if (!peeked) throw new Error('Unknown refresh token');
275 if (peeked.meta?.client_id && peeked.meta.client_id !== client.client_id) {
276 throw new Error('Client mismatch');
277 }
278 if (peeked.revoked) throw refreshFailureError(REFRESH_FAILURE.REVOKED);
279 }
280
281 let result;
282 try {
283 result = await this._refreshStore.rotate(String(refreshToken), {});
284 } catch (_) {
285 throw new Error('Refresh token rotation failed');
286 }
287
288 if (!result.ok) {
289 throw refreshFailureError(result.reason);
290 }
291
292 const meta = result.meta || {};
293 if (meta.client_id && meta.client_id !== client.client_id) {
294 // Should be unreachable after peek; fail closed without leaking.
295 throw new Error('Client mismatch');
296 }
297
298 const storedScopes = typeof meta.scopes === 'string' && meta.scopes.trim()
299 ? meta.scopes.trim().split(/\s+/).filter(Boolean)
300 : ['vault:read'];
301
302 const effectiveScopes = scopes && scopes.length > 0
303 ? scopes.filter((s) => storedScopes.includes(s))
304 : storedScopes;
305
306 const accessToken = jwt.sign(
307 {
308 sub: result.sub,
309 client_id: client.client_id,
310 scopes: effectiveScopes,
311 type: 'mcp_access',
312 },
313 this._sessionSecret,
314 { expiresIn: MCP_TOKEN_EXPIRY_SECONDS }
315 );
316
317 return {
318 access_token: accessToken,
319 token_type: 'bearer',
320 expires_in: MCP_TOKEN_EXPIRY_SECONDS,
321 refresh_token: result.token,
322 scope: effectiveScopes.join(' '),
323 };
324 }
325
326 async verifyAccessToken(token) {
327 try {
328 const payload = jwt.verify(token, this._sessionSecret);
329 if (payload.type !== 'mcp_access') throw new Error('Not an MCP access token');
330 return {
331 token,
332 clientId: payload.client_id,
333 scopes: payload.scopes || [],
334 expiresAt: payload.exp,
335 extra: { sub: payload.sub },
336 };
337 } catch (e) {
338 throw new Error(`Invalid access token: ${e.message}`);
339 }
340 }
341
342 async revokeToken(client, request) {
343 const token = request.token;
344 if (!token) return;
345 if (typeof this._refreshStore.peek === 'function') {
346 const peeked = await this._refreshStore.peek(token);
347 if (peeked?.meta?.client_id && peeked.meta.client_id !== client.client_id) {
348 return;
349 }
350 }
351 try {
352 await this._refreshStore.revoke(String(token));
353 } catch (_) {
354 // RFC 7009: revocation is best-effort.
355 }
356 }
357
358 _pruneExpiredCodes() {
359 if (this._pendingCodes.size <= MAX_PENDING_CODES) return;
360 const now = Date.now();
361 for (const [code, pending] of this._pendingCodes) {
362 if (now > pending.expires) this._pendingCodes.delete(code);
363 }
364 }
365
366 destroy() {
367 // No timers; durable store owns persistence.
368 }
369 }
370
371 // Re-export for tests that assert TTL alignment.
372 export { MCP_TOKEN_EXPIRY_SECONDS, DEFAULT_TOKEN_TTL_MS, DEFAULT_FAMILY_TTL_MS, sha256 };
File History 5 commits
sha256:b5f647cb9c409f563d4671fe3fc05ddea01fabfed9b41fc11cb923588e1c1baf mirror: GitHub Phase A durable MCP OAuth (#270) Human minor 10 days ago
sha256:d8c648b20a4d53b2673c5c082ee7edfa7b2fc9b11080832da1f38807b6bf940b fix(7C-L1b): route hosted delegation proposals through cani… Human minor 30 days ago
sha256:f4def6a1a567d25eac87e879d96235c0588804a6c9b770d3958e74b22231db59 fix(test): align hub.js cache-bust contract with hub integr… Human 49 days ago
sha256:2827ba9e7632a4b141c50caf1e8f7d77abbc3515be20e7465f2bccb0ac4edf91 fix: repair endpoint now sets has_active_subscription when … Human minor 49 days ago