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