diff --git a/README.md b/README.md index 5b4ad119..4909045a 100755 --- a/README.md +++ b/README.md @@ -195,6 +195,10 @@ The local LLM can even enrich existing listings by checking the listing online. For more information on how to set it up and use it, please refer to the [MCP Readme](lib/mcp/README.md). +#### Connect Claude.ai or ChatGPT over OAuth + +Set Fredy's `baseUrl` to its public HTTPS URL, then add `/api/mcp` as a custom MCP server in Claude.ai or ChatGPT. Fredy advertises OAuth discovery metadata, dynamically registers the client, and asks you to sign in and approve read access. OAuth access tokens expire after one hour and refresh automatically; existing MCP tokens continue to work for local clients. + ------------------------------------------------------------------------ ## 💶 Financing Calculator diff --git a/lib/api/api.js b/lib/api/api.js index d845aea4..9b39f911 100644 --- a/lib/api/api.js +++ b/lib/api/api.js @@ -33,6 +33,7 @@ import priceTrackingPlugin from './routes/priceTrackingRouter.js'; import notificationAdapterPlugin from './routes/notificationAdapterRouter.js'; import providerPlugin from './routes/providerRouter.js'; import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js'; +import { registerMcpOAuthRoutes } from '../mcp/mcpOAuthRoute.js'; const settings = await getSettings(); const PORT = settings.port || 9998; @@ -128,6 +129,7 @@ fastify.register(async (app) => { }); // MCP Streamable HTTP (Bearer token auth - no session) +await registerMcpOAuthRoutes(fastify); registerMcpRoutes(fastify); // SPA fallback - serve index.html for all non-API GET requests diff --git a/lib/mcp/mcpAuthentication.js b/lib/mcp/mcpAuthentication.js index 03f3d776..58df3bfe 100644 --- a/lib/mcp/mcpAuthentication.js +++ b/lib/mcp/mcpAuthentication.js @@ -12,6 +12,7 @@ import { getUser, validateMcpToken } from '../services/storage/userStorage.js'; import { canAccessJob } from '../services/security/access.js'; +import { validateAccessToken } from './mcpOAuthStorage.js'; /** * Authenticate an MCP tool call by extracting and validating the user from authInfo. @@ -61,5 +62,21 @@ export function authenticateRequest(req) { if (!authHeader.startsWith('Bearer ')) return null; const token = authHeader.slice(7).trim(); if (!token) return null; - return validateMcpToken(token); + // Keep manually generated tokens working while OAuth clients transition to short-lived, + // audience-bound credentials. + return validateAccessToken(token, mcpResource(req)) ?? validateMcpToken(token); +} + +/** + * Resolve the canonical resource URL from the request host for access-token audience checks. + * The OAuth route emits this exact value from the configured public base URL. + * @param {import('http').IncomingMessage} req + * @returns {string} + */ +function mcpResource(req) { + const forwardedProto = String(req.headers['x-forwarded-proto'] || '') + .split(',')[0] + .trim(); + const protocol = forwardedProto || 'http'; + return `${protocol}://${req.headers.host}/api/mcp`; } diff --git a/lib/mcp/mcpHttpRoute.js b/lib/mcp/mcpHttpRoute.js index 8719489c..910c0694 100644 --- a/lib/mcp/mcpHttpRoute.js +++ b/lib/mcp/mcpHttpRoute.js @@ -6,6 +6,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { createMcpServer } from './mcpAdapter.js'; import { authenticateRequest } from './mcpAuthentication.js'; +import { getSettings } from '../services/storage/settingsStorage.js'; import logger from '../services/logger.js'; import crypto from 'crypto'; @@ -21,7 +22,8 @@ const sessions = new Map(); */ function getOrCreateSession(sessionId, auth) { if (sessionId && sessions.has(sessionId)) { - return sessions.get(sessionId); + const entry = sessions.get(sessionId); + return entry.userId === auth.userId ? entry : null; } const transport = new StreamableHTTPServerTransport({ @@ -58,14 +60,27 @@ function getOrCreateSession(sessionId, auth) { * @param {import('fastify').FastifyInstance} fastify */ export function registerMcpRoutes(fastify) { + const unauthorized = async (request, reply) => { + const settings = await getSettings(); + const baseUrl = typeof settings.baseUrl === 'string' ? settings.baseUrl.trim().replace(/\/$/, '') : ''; + if (baseUrl) + reply.header( + 'www-authenticate', + `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource/api/mcp"`, + ); + return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' }); + }; + fastify.post('/api/mcp', async (request, reply) => { const auth = authenticateRequest(request.raw); if (!auth) { - return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' }); + return unauthorized(request, reply); } const sessionId = request.raw.headers['mcp-session-id']; - const { server, transport } = getOrCreateSession(sessionId, auth); + const entry = getOrCreateSession(sessionId, auth); + if (!entry) return reply.code(403).send({ error: 'MCP session belongs to another user.' }); + const { server, transport } = entry; if (!transport.onmessage) { await server.connect(transport); @@ -80,7 +95,7 @@ export function registerMcpRoutes(fastify) { fastify.get('/api/mcp', async (request, reply) => { const auth = authenticateRequest(request.raw); if (!auth) { - return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' }); + return unauthorized(request, reply); } const sessionId = request.raw.headers['mcp-session-id']; @@ -88,7 +103,9 @@ export function registerMcpRoutes(fastify) { return reply.code(400).send({ error: 'Invalid or missing session. Send an initialize request first.' }); } - const { transport } = sessions.get(sessionId); + const entry = sessions.get(sessionId); + if (entry.userId !== auth.userId) return reply.code(403).send({ error: 'MCP session belongs to another user.' }); + const { transport } = entry; reply.hijack(); await transport.handleRequest(request.raw, reply.raw); }); @@ -96,7 +113,7 @@ export function registerMcpRoutes(fastify) { fastify.delete('/api/mcp', async (request, reply) => { const auth = authenticateRequest(request.raw); if (!auth) { - return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' }); + return unauthorized(request, reply); } const sessionId = request.raw.headers['mcp-session-id']; @@ -104,7 +121,9 @@ export function registerMcpRoutes(fastify) { return reply.code(404).send({ error: 'Session not found.' }); } - const { transport } = sessions.get(sessionId); + const entry = sessions.get(sessionId); + if (entry.userId !== auth.userId) return reply.code(403).send({ error: 'MCP session belongs to another user.' }); + const { transport } = entry; await transport.close(); sessions.delete(sessionId); return { ok: true }; diff --git a/lib/mcp/mcpOAuthRoute.js b/lib/mcp/mcpOAuthRoute.js new file mode 100644 index 00000000..47ac918f --- /dev/null +++ b/lib/mcp/mcpOAuthRoute.js @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { getSettings } from '../services/storage/settingsStorage.js'; +import { getUser } from '../services/storage/userStorage.js'; +import { isUnauthorized } from '../api/security.js'; +import { + createAuthorizationCode, + createClient, + getClient, + redeemAuthorizationCode, + refreshAccessToken, +} from './mcpOAuthStorage.js'; + +const SCOPE = 'mcp:read'; +const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`); +const PKCE_CHALLENGE = /^[A-Za-z0-9_-]{43}$/; + +async function oauthUrls() { + const settings = await getSettings(); + const baseUrl = typeof settings.baseUrl === 'string' ? settings.baseUrl.trim().replace(/\/$/, '') : ''; + if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) + throw new Error('Fredy baseUrl must be configured for OAuth'); + return { + baseUrl, + resource: `${baseUrl}/api/mcp`, + }; +} + +function validRedirectUri(uri) { + try { + const parsed = new URL(uri); + return ( + parsed.protocol === 'https:' || + (parsed.protocol === 'http:' && ['127.0.0.1', '::1', 'localhost'].includes(parsed.hostname)) + ); + } catch { + return false; + } +} + +/** @param {string} redirectUri @param {Record} params */ +function redirect(redirectUri, params) { + const url = new URL(redirectUri); + for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value); + return url.toString(); +} + +/** @param {import('fastify').FastifyRequest} request */ +async function authorizationRequest(request) { + const params = request.method === 'GET' ? request.query : request.body; + const client = typeof params?.client_id === 'string' ? getClient(params.client_id) : null; + if (!client || typeof params?.redirect_uri !== 'string' || !client.redirectUris.includes(params.redirect_uri)) + return { error: 'invalid_request' }; + const urls = await oauthUrls(); + if ( + params.response_type !== 'code' || + params.code_challenge_method !== 'S256' || + typeof params.code_challenge !== 'string' || + !PKCE_CHALLENGE.test(params.code_challenge) || + params.resource !== urls.resource || + String(params.scope || '') + .split(' ') + .includes(SCOPE) === false + ) + return { error: 'invalid_request', redirectUri: params.redirect_uri, state: params.state }; + return { params, client, urls }; +} + +/** @param {import('fastify').FastifyInstance} fastify */ +export async function registerMcpOAuthRoutes(fastify) { + // OAuth token requests are form-encoded by specification. Keeping this small parser local + // avoids making a general request parser accept this content type across the application. + fastify.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' }, (_request, body, done) => { + done(null, Object.fromEntries(new URLSearchParams(body))); + }); + + fastify.get('/.well-known/oauth-protected-resource/api/mcp', async () => { + const urls = await oauthUrls(); + return { resource: urls.resource, authorization_servers: [urls.baseUrl], scopes_supported: [SCOPE] }; + }); + + fastify.get('/.well-known/oauth-authorization-server', async () => { + const urls = await oauthUrls(); + return { + issuer: urls.baseUrl, + authorization_endpoint: `${urls.baseUrl}/api/oauth/authorize`, + token_endpoint: `${urls.baseUrl}/api/oauth/token`, + registration_endpoint: `${urls.baseUrl}/api/oauth/register`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'], + scopes_supported: [SCOPE], + }; + }); + + fastify.post('/api/oauth/register', async (request, reply) => { + const { + redirect_uris: redirectUris, + client_name: clientName, + grant_types, + token_endpoint_auth_method: authMethod, + } = request.body || {}; + if (!Array.isArray(redirectUris) || redirectUris.length === 0 || !redirectUris.every(validRedirectUri)) { + return reply.code(400).send({ error: 'invalid_redirect_uri' }); + } + if ( + (grant_types && !grant_types.every((grant) => ['authorization_code', 'refresh_token'].includes(grant))) || + (authMethod && authMethod !== 'none') + ) { + return reply.code(400).send({ error: 'invalid_client_metadata' }); + } + const client = createClient({ clientName: typeof clientName === 'string' ? clientName : undefined, redirectUris }); + return reply.code(201).send({ + client_id: client.clientId, + client_name: clientName, + redirect_uris: client.redirectUris, + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }); + }); + + fastify.get('/api/oauth/authorize', async (request, reply) => { + const authorization = await authorizationRequest(request); + if (authorization.error) return reply.code(400).send({ error: authorization.error }); + if (await isUnauthorized(request)) { + const loginUrl = `/#/login?returnTo=${encodeURIComponent(request.raw.url)}`; + return reply + .type('text/html') + .code(401) + .send(`

Sign in to Fredy to continue

`); + } + return reply + .type('text/html') + .send( + `Authorize Claude

Authorize access

Allow this client to read your Fredy listings and jobs?

`, + ); + }); + + fastify.post('/api/oauth/authorize', async (request, reply) => { + const authorization = await authorizationRequest(request); + if (authorization.error) return reply.code(400).send({ error: authorization.error }); + if (await isUnauthorized(request)) return reply.code(401).send({ error: 'login_required' }); + const user = getUser(request.session.currentUser); + if (!user) return reply.code(401).send({ error: 'login_required' }); + const code = createAuthorizationCode({ + clientId: authorization.client.clientId, + userId: user.id, + redirectUri: authorization.params.redirect_uri, + codeChallenge: authorization.params.code_challenge, + resource: authorization.urls.resource, + scopes: [SCOPE], + }); + return reply.redirect( + redirect(authorization.params.redirect_uri, { + code, + ...(authorization.params.state ? { state: authorization.params.state } : {}), + }), + ); + }); + + fastify.post('/api/oauth/token', async (request, reply) => { + const body = request.body || {}; + let tokens; + if ( + body.grant_type === 'authorization_code' && + typeof body.code === 'string' && + typeof body.client_id === 'string' && + typeof body.redirect_uri === 'string' && + typeof body.code_verifier === 'string' + ) { + tokens = redeemAuthorizationCode({ + code: body.code, + clientId: body.client_id, + redirectUri: body.redirect_uri, + codeVerifier: body.code_verifier, + }); + } else if ( + body.grant_type === 'refresh_token' && + typeof body.refresh_token === 'string' && + typeof body.client_id === 'string' + ) { + tokens = refreshAccessToken({ refreshToken: body.refresh_token, clientId: body.client_id }); + } else return reply.code(400).send({ error: 'invalid_request' }); + if (!tokens) return reply.code(400).send({ error: 'invalid_grant' }); + return { + access_token: tokens.accessToken, + refresh_token: tokens.refreshToken, + token_type: 'Bearer', + expires_in: tokens.expiresIn, + scope: tokens.scopes.join(' '), + }; + }); +} diff --git a/lib/mcp/mcpOAuthStorage.js b/lib/mcp/mcpOAuthStorage.js new file mode 100644 index 00000000..276be910 --- /dev/null +++ b/lib/mcp/mcpOAuthStorage.js @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import crypto from 'crypto'; +import { nanoid } from 'nanoid'; +import SqliteConnection from '../services/storage/SqliteConnection.js'; + +const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000; +const AUTHORIZATION_CODE_TTL_MS = 5 * 60 * 1000; +const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +const hash = (value) => crypto.createHash('sha256').update(value).digest('hex'); +const secret = () => crypto.randomBytes(32).toString('base64url'); + +/** @param {{clientName?: string, redirectUris: string[]}} client */ +export function createClient({ clientName, redirectUris }) { + const clientId = nanoid(32); + SqliteConnection.execute( + `INSERT INTO oauth_clients (id, name, redirect_uris, created_at) VALUES (@id, @name, @redirectUris, @createdAt)`, + { id: clientId, name: clientName ?? null, redirectUris: JSON.stringify(redirectUris), createdAt: Date.now() }, + ); + return { clientId, redirectUris }; +} + +/** @param {string} clientId */ +export function getClient(clientId) { + const row = SqliteConnection.query( + `SELECT id, redirect_uris AS redirectUris FROM oauth_clients WHERE id = @clientId`, + { + clientId, + }, + )[0]; + return row ? { clientId: row.id, redirectUris: JSON.parse(row.redirectUris) } : null; +} + +/** @param {{clientId: string, userId: string, redirectUri: string, codeChallenge: string, resource: string, scopes: string[]}} params */ +export function createAuthorizationCode(params) { + const code = secret(); + SqliteConnection.execute( + `INSERT INTO oauth_authorization_codes + (code_hash, client_id, user_id, redirect_uri, code_challenge, resource, scopes, expires_at) + VALUES (@codeHash, @clientId, @userId, @redirectUri, @codeChallenge, @resource, @scopes, @expiresAt)`, + { + ...params, + codeHash: hash(code), + scopes: JSON.stringify(params.scopes), + expiresAt: Date.now() + AUTHORIZATION_CODE_TTL_MS, + }, + ); + return code; +} + +/** @param {{code: string, clientId: string, redirectUri: string, codeVerifier: string}} params */ +export function redeemAuthorizationCode(params) { + return SqliteConnection.withTransaction((db) => { + const codeHash = hash(params.code); + const row = db.prepare(`SELECT * FROM oauth_authorization_codes WHERE code_hash = ?`).get(codeHash); + if ( + !row || + row.expires_at <= Date.now() || + row.client_id !== params.clientId || + row.redirect_uri !== params.redirectUri + ) + return null; + const verifierHash = crypto.createHash('sha256').update(params.codeVerifier).digest('base64url'); + if (verifierHash.length !== row.code_challenge.length) return null; + if (!crypto.timingSafeEqual(Buffer.from(verifierHash), Buffer.from(row.code_challenge))) return null; + db.prepare(`DELETE FROM oauth_authorization_codes WHERE code_hash = ?`).run(codeHash); + return issueTokens(db, { + clientId: row.client_id, + userId: row.user_id, + resource: row.resource, + scopes: JSON.parse(row.scopes), + familyId: nanoid(), + }); + }); +} + +/** @param {{refreshToken: string, clientId: string}} params */ +export function refreshAccessToken(params) { + return SqliteConnection.withTransaction((db) => { + const row = db.prepare(`SELECT * FROM oauth_refresh_tokens WHERE token_hash = ?`).get(hash(params.refreshToken)); + if (!row || row.client_id !== params.clientId || row.expires_at <= Date.now() || row.revoked_at != null) { + if (row?.family_id) + db.prepare(`UPDATE oauth_refresh_tokens SET revoked_at = ? WHERE family_id = ?`).run(Date.now(), row.family_id); + return null; + } + db.prepare(`UPDATE oauth_refresh_tokens SET revoked_at = ? WHERE token_hash = ?`).run(Date.now(), row.token_hash); + return issueTokens(db, { + clientId: row.client_id, + userId: row.user_id, + resource: row.resource, + scopes: JSON.parse(row.scopes), + familyId: row.family_id, + }); + }); +} + +/** @param {import('better-sqlite3').Database} db @param {{clientId: string, userId: string, resource: string, scopes: string[], familyId: string}} params */ +function issueTokens(db, params) { + const accessToken = secret(); + const refreshToken = secret(); + const now = Date.now(); + db.prepare( + `INSERT INTO oauth_access_tokens (token_hash, client_id, user_id, resource, scopes, expires_at) + VALUES (@tokenHash, @clientId, @userId, @resource, @scopes, @expiresAt)`, + ).run({ + ...params, + tokenHash: hash(accessToken), + scopes: JSON.stringify(params.scopes), + expiresAt: now + ACCESS_TOKEN_TTL_MS, + }); + db.prepare( + `INSERT INTO oauth_refresh_tokens (token_hash, family_id, client_id, user_id, resource, scopes, expires_at) + VALUES (@tokenHash, @familyId, @clientId, @userId, @resource, @scopes, @expiresAt)`, + ).run({ + ...params, + tokenHash: hash(refreshToken), + scopes: JSON.stringify(params.scopes), + expiresAt: now + REFRESH_TOKEN_TTL_MS, + }); + return { accessToken, refreshToken, expiresIn: ACCESS_TOKEN_TTL_MS / 1000, scopes: params.scopes }; +} + +/** @param {string} token @param {string} resource */ +export function validateAccessToken(token, resource) { + const row = SqliteConnection.query( + `SELECT user_id AS userId, resource, scopes, expires_at AS expiresAt, revoked_at AS revokedAt + FROM oauth_access_tokens WHERE token_hash = @tokenHash LIMIT 1`, + { tokenHash: hash(token) }, + )[0]; + if (!row || row.resource !== resource || row.expiresAt <= Date.now() || row.revokedAt != null) return null; + const scopes = JSON.parse(row.scopes); + return scopes.includes('mcp:read') ? { userId: row.userId, scopes } : null; +} diff --git a/lib/services/storage/migrations/sql/32.mcp-oauth.js b/lib/services/storage/migrations/sql/32.mcp-oauth.js new file mode 100644 index 00000000..de432f3e --- /dev/null +++ b/lib/services/storage/migrations/sql/32.mcp-oauth.js @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +/** + * OAuth credentials for the MCP protected resource. + * + * Raw codes and tokens are never persisted: a database read alone must not grant API access. + * @param {import('better-sqlite3').Database} db + * @returns {void} + */ +export function up(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS oauth_clients ( + id TEXT PRIMARY KEY, + name TEXT, + redirect_uris TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS oauth_authorization_codes ( + code_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + redirect_uri TEXT NOT NULL, + code_challenge TEXT NOT NULL, + resource TEXT NOT NULL, + scopes TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS oauth_access_tokens ( + token_hash TEXT PRIMARY KEY, + client_id TEXT NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + resource TEXT NOT NULL, + scopes TEXT NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER + ); + CREATE TABLE IF NOT EXISTS oauth_refresh_tokens ( + token_hash TEXT PRIMARY KEY, + family_id TEXT NOT NULL, + client_id TEXT NOT NULL REFERENCES oauth_clients(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + resource TEXT NOT NULL, + scopes TEXT NOT NULL, + expires_at INTEGER NOT NULL, + revoked_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_oauth_access_tokens_expiry ON oauth_access_tokens (expires_at); + CREATE INDEX IF NOT EXISTS idx_oauth_refresh_tokens_family ON oauth_refresh_tokens (family_id); + `); +} diff --git a/test/api/mcpOAuthRoute.test.js b/test/api/mcpOAuthRoute.test.js new file mode 100644 index 00000000..2e270d37 --- /dev/null +++ b/test/api/mcpOAuthRoute.test.js @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import Fastify from 'fastify'; + +vi.mock('../../lib/mcp/mcpOAuthStorage.js', () => ({ + createClient: vi.fn(), + createAuthorizationCode: vi.fn(), + getClient: vi.fn(), + redeemAuthorizationCode: vi.fn(), + refreshAccessToken: vi.fn(), +})); +vi.mock('../../lib/services/storage/settingsStorage.js', () => ({ + getSettings: vi.fn(async () => ({ baseUrl: 'https://fredy.example' })), +})); + +import { createClient, getClient } from '../../lib/mcp/mcpOAuthStorage.js'; +import { registerMcpOAuthRoutes } from '../../lib/mcp/mcpOAuthRoute.js'; + +async function buildApp() { + const app = Fastify(); + await registerMcpOAuthRoutes(app); + await app.ready(); + return app; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('MCP OAuth discovery', () => { + it('advertises protected-resource metadata from the MCP endpoint', async () => { + const app = await buildApp(); + + const response = await app.inject({ method: 'GET', url: '/.well-known/oauth-protected-resource/api/mcp' }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + resource: 'https://fredy.example/api/mcp', + authorization_servers: ['https://fredy.example'], + scopes_supported: ['mcp:read'], + }); + await app.close(); + }); + + it('dynamically registers a public client with exact redirect URIs', async () => { + createClient.mockReturnValue({ clientId: 'client-1', redirectUris: ['https://claude.ai/oauth/callback'] }); + const app = await buildApp(); + + const response = await app.inject({ + method: 'POST', + url: '/api/oauth/register', + payload: { + client_name: 'Claude', + redirect_uris: ['https://claude.ai/oauth/callback'], + grant_types: ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: 'none', + }, + }); + + expect(response.statusCode).toBe(201); + expect(createClient).toHaveBeenCalledWith({ + clientName: 'Claude', + redirectUris: ['https://claude.ai/oauth/callback'], + }); + expect(response.json()).toMatchObject({ + client_id: 'client-1', + redirect_uris: ['https://claude.ai/oauth/callback'], + token_endpoint_auth_method: 'none', + }); + await app.close(); + }); + + it('rejects dynamic registration with an insecure redirect URI', async () => { + const app = await buildApp(); + + const response = await app.inject({ + method: 'POST', + url: '/api/oauth/register', + payload: { redirect_uris: ['http://attacker.example/callback'] }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: 'invalid_redirect_uri' }); + expect(createClient).not.toHaveBeenCalled(); + await app.close(); + }); + + it('accepts form-encoded OAuth token requests', async () => { + const app = await buildApp(); + + const response = await app.inject({ + method: 'POST', + url: '/api/oauth/token', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + payload: 'grant_type=unsupported', + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: 'invalid_request' }); + await app.close(); + }); + + it('returns an unauthenticated user to the validated authorization request after login', async () => { + getClient.mockReturnValue({ clientId: 'client-1', redirectUris: ['https://claude.ai/oauth/callback'] }); + const app = await buildApp(); + const query = new URLSearchParams({ + response_type: 'code', + client_id: 'client-1', + redirect_uri: 'https://claude.ai/oauth/callback', + code_challenge: 'a'.repeat(43), + code_challenge_method: 'S256', + resource: 'https://fredy.example/api/mcp', + scope: 'mcp:read', + }); + + const response = await app.inject({ method: 'GET', url: `/api/oauth/authorize?${query}` }); + + expect(response.statusCode).toBe(401); + expect(response.body).toContain('/#/login?returnTo=%2Fapi%2Foauth%2Fauthorize%3F'); + await app.close(); + }); +}); diff --git a/ui/src/views/login/Login.jsx b/ui/src/views/login/Login.jsx index 8fba1ca9..c93c89d3 100644 --- a/ui/src/views/login/Login.jsx +++ b/ui/src/views/login/Login.jsx @@ -76,6 +76,13 @@ export default function Login() { } await actions.user.getCurrentUser(); + const returnTo = new URLSearchParams(location.search).get('returnTo'); + // OAuth passes a server-relative authorization URL. Restrict this hand-off so the login + // screen cannot be used as an open redirect. + if (typeof returnTo === 'string' && returnTo.startsWith('/api/oauth/authorize?')) { + window.location.assign(returnTo); + return; + } navigate(location.state?.from?.pathname || '/dashboard'); };