diff --git a/package-lock.json b/package-lock.json index 67896ee..63983c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@seatable/mcp-seatable", - "version": "1.6.0", + "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@seatable/mcp-seatable", - "version": "1.6.0", + "version": "1.6.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.17.4", diff --git a/package.json b/package.json index 457fb96..513d3c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@seatable/mcp-seatable", - "version": "1.6.0", + "version": "1.6.1", "type": "module", "license": "MIT", "mcpName": "io.github.seatable/seatable", diff --git a/src/auth/oauthProvider.ts b/src/auth/oauthProvider.ts index 154d87f..a7289ba 100644 --- a/src/auth/oauthProvider.ts +++ b/src/auth/oauthProvider.ts @@ -88,6 +88,12 @@ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', '[::1]', 'localhost']) */ export const DEFAULT_TRUSTED_REDIRECT_HOSTS = ['claude.ai', 'claude.com', 'chatgpt.com'] +/** The protected resource clients authorize against — the Streamable HTTP endpoint. */ +export const MCP_RESOURCE_PATH = '/mcp' + +/** RFC 9728 well-known prefix. The resource path is appended to it. */ +export const PROTECTED_RESOURCE_PATH = '/.well-known/oauth-protected-resource' + /** Schemes that can execute or read local content and must never be a callback. */ const FORBIDDEN_SCHEMES = new Set(['javascript', 'data', 'file', 'blob', 'vbscript', 'about', 'view-source']) @@ -238,6 +244,54 @@ export class OAuthProvider { res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(metadata)) } + /** + * The resource identifier clients authorize against: the MCP endpoint itself. + */ + private resourceIdentifier(req: IncomingMessage): string { + return `${this.resolveBaseUrl(req)}${MCP_RESOURCE_PATH}` + } + + /** + * Where the protected resource metadata for that identifier lives. + * + * RFC 9728 section 3.1: the resource's path is appended to the well-known + * suffix, so `https://host/mcp` is described at + * `https://host/.well-known/oauth-protected-resource/mcp`. + */ + resourceMetadataUrl(req: IncomingMessage): string { + return `${this.resolveBaseUrl(req)}${PROTECTED_RESOURCE_PATH}${MCP_RESOURCE_PATH}` + } + + /** + * GET /.well-known/oauth-protected-resource[/mcp] — RFC 9728 metadata + * + * `authorization_servers` MUST agree with the issuer that handleMetadata() + * reports. A conformant client follows this document INSTEAD of probing + * /.well-known/oauth-authorization-server, so a disagreement here breaks + * clients that work today. Both derive from resolveBaseUrl() for that reason. + */ + handleProtectedResourceMetadata(req: IncomingMessage, res: ServerResponse): void { + const metadata = { + resource: this.resourceIdentifier(req), + authorization_servers: [this.resolveBaseUrl(req)], + bearer_methods_supported: ['header'], + resource_documentation: 'https://github.com/seatable/seatable-mcp', + } + res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(metadata)) + } + + /** + * The WWW-Authenticate challenge for a 401, pointing at the metadata above. + * + * RFC 6750 section 3.1: a request that carried no credential at all gets no + * error code — only one that presented a credential we rejected does. + */ + challenge(req: IncomingMessage, error?: 'invalid_token'): string { + const params = [`resource_metadata="${this.resourceMetadataUrl(req)}"`] + if (error) params.unshift(`error="${error}"`) + return `Bearer ${params.join(', ')}` + } + /** * POST /register — Dynamic Client Registration (RFC 7591) * diff --git a/src/http/httpServer.ts b/src/http/httpServer.ts index 0f6d356..30980bf 100644 --- a/src/http/httpServer.ts +++ b/src/http/httpServer.ts @@ -3,7 +3,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' -import { OAuthProvider } from '../auth/oauthProvider.js' +import { MCP_RESOURCE_PATH, OAuthProvider, PROTECTED_RESOURCE_PATH } from '../auth/oauthProvider.js' import { TokenValidator } from '../auth/tokenValidator.js' import { getEnv, type ServerMode, VERSION } from '../config/env.js' import { logger } from '../logger.js' @@ -141,6 +141,18 @@ export async function startHttpServer(options: StartHttpServerOptions = {}) { return candidate } + /** + * Reject with 401. In managed mode the response carries the RFC 9728 pointer + * a conformant client needs to discover the authorization server — without it + * the client cannot begin an OAuth flow at all. + */ + function unauthorized(req: IncomingMessage, res: ServerResponse, message: string, error?: 'invalid_token'): void { + const headers: Record = { 'content-type': 'text/plain' } + const challenge = oauthProvider?.challenge(req, error) + if (challenge) headers['www-authenticate'] = challenge + res.writeHead(401, headers).end(message) + } + const trustProxy = env.TRUST_PROXY ?? true function getClientIp(req: IncomingMessage): string { @@ -220,13 +232,13 @@ export async function startHttpServer(options: StartHttpServerOptions = {}) { const bearer = extractBearerToken(req) if (!bearer) { logger.warn({ ip: getClientIp(req) }, 'Missing Authorization header') - res.writeHead(401, { 'content-type': 'text/plain' }).end('Missing Authorization header') + unauthorized(req, res, 'Missing Authorization header') return } apiToken = await resolveApiToken(bearer) if (!apiToken) { logger.warn({ ip: getClientIp(req) }, 'Invalid API token') - res.writeHead(401, { 'content-type': 'text/plain' }).end('Invalid API token') + unauthorized(req, res, 'Invalid API token', 'invalid_token') return } } @@ -299,13 +311,13 @@ export async function startHttpServer(options: StartHttpServerOptions = {}) { const bearer = extractBearerToken(req) if (!bearer) { logger.warn({ ip: getClientIp(req), session: sessionFingerprint(sessionId) }, 'Session request without Authorization header') - res.writeHead(401, { 'content-type': 'text/plain' }).end('Missing Authorization header') + unauthorized(req, res, 'Missing Authorization header') return } const apiToken = await resolveApiToken(bearer) if (!apiToken) { logger.warn({ ip: getClientIp(req), session: sessionFingerprint(sessionId) }, 'Session request with invalid credential') - res.writeHead(401, { 'content-type': 'text/plain' }).end('Invalid API token') + unauthorized(req, res, 'Invalid API token', 'invalid_token') return } presentedDigest = digest(apiToken) @@ -407,6 +419,17 @@ export async function startHttpServer(options: StartHttpServerOptions = {}) { return } + // Both the bare suffix and the resource-path form: clients differ in which + // they probe, and the resource identifier is the only /mcp we serve. + if ( + oauthProvider && + req.method === 'GET' && + (url.pathname === PROTECTED_RESOURCE_PATH || url.pathname === `${PROTECTED_RESOURCE_PATH}${MCP_RESOURCE_PATH}`) + ) { + oauthProvider.handleProtectedResourceMetadata(req, res) + return + } + if (oauthProvider && (url.pathname === '/authorize' || url.pathname === '/oauth/authorize') && (req.method === 'GET' || req.method === 'POST')) { if (oauthThrottled(req, res, req.method === 'POST')) return try { diff --git a/tests/oauthProtectedResource.spec.ts b/tests/oauthProtectedResource.spec.ts new file mode 100644 index 0000000..aac59df --- /dev/null +++ b/tests/oauthProtectedResource.spec.ts @@ -0,0 +1,266 @@ +import type { AddressInfo } from 'node:net' + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('../src/metrics/metricsServer', () => ({ + startMetricsServer: vi.fn().mockResolvedValue(undefined), +})) + +/** Tokens the fake SeaTable backend considers valid. */ +const VALID_TOKENS = new Set(['token-valid']) + +vi.mock('../src/auth/tokenValidator', () => ({ + TokenValidator: class { + async validate(token: string): Promise { + return VALID_TOKENS.has(token) + } + cleanup(): void {} + destroy(): void {} + }, +})) + +import { startHttpServer } from '../src/http/httpServer' + +type Server = ReturnType + +const JSON_HEADERS = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', +} + +const INITIALIZE = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } }, +}) + +async function close(server: Server | undefined): Promise { + if (server) await new Promise((resolve) => server.close(() => resolve())) +} + +/** + * RFC 9728 + MCP authorization (revision 2025-06-18). + * + * An MCP server acting as an OAuth resource server MUST publish protected + * resource metadata and MUST point at it from the WWW-Authenticate header of + * every 401. Without both, a strictly conformant client cannot discover the + * authorization server and fails to connect before any OAuth window opens. + * + * Written against the gap reported on 2026-09-01: /.well-known/oauth-protected-resource + * answered 404 and the 401 carried no WWW-Authenticate at all. + */ +describe('managed mode / RFC 9728 protected resource metadata', () => { + let server: Server + let baseUrl: string + + beforeAll(async () => { + process.env.SEATABLE_SERVER_URL = 'http://localhost' + process.env.SEATABLE_MODE = 'managed' + process.env.SEATABLE_MOCK = 'true' + process.env.SEATABLE_TOKEN_SECRET = 'protected-resource-spec-secret-long-enough' + delete process.env.SEATABLE_API_TOKEN + delete process.env.SEATABLE_MCP_HOSTNAME + + server = await startHttpServer({ port: 0 }) + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + }) + + afterAll(async () => { + delete process.env.SEATABLE_MODE + delete process.env.SEATABLE_TOKEN_SECRET + await close(server) + }) + + it('serves the document at the root well-known path', async () => { + const res = await fetch(`${baseUrl}/.well-known/oauth-protected-resource`) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('application/json') + }) + + it('serves the document at the path-suffixed location for the /mcp resource', async () => { + const res = await fetch(`${baseUrl}/.well-known/oauth-protected-resource/mcp`) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('application/json') + }) + + it('names the MCP endpoint as the resource identifier', async () => { + const res = await fetch(`${baseUrl}/.well-known/oauth-protected-resource/mcp`) + const body = await res.json() + expect(body.resource).toBe(`${baseUrl}/mcp`) + }) + + it('advertises bearer tokens in the Authorization header', async () => { + const res = await fetch(`${baseUrl}/.well-known/oauth-protected-resource`) + const body = await res.json() + expect(body.bearer_methods_supported).toContain('header') + }) + + /** + * The regression that would break Claude and ChatGPT: once WWW-Authenticate + * exists, conformant clients follow it INSTEAD of probing the legacy + * /.well-known/oauth-authorization-server path. If the two documents disagree + * about the issuer, clients that work today stop working. + */ + it('points at the same issuer the authorization server metadata reports', async () => { + const [asRes, prRes] = await Promise.all([ + fetch(`${baseUrl}/.well-known/oauth-authorization-server`), + fetch(`${baseUrl}/.well-known/oauth-protected-resource`), + ]) + const asMeta = await asRes.json() + const prMeta = await prRes.json() + + expect(Array.isArray(prMeta.authorization_servers)).toBe(true) + expect(prMeta.authorization_servers).toContain(asMeta.issuer) + }) + + it('uses the configured public hostname for absolute URLs', async () => { + process.env.SEATABLE_MCP_HOSTNAME = 'mcp.seatable.com' + let hostnameServer: Server | undefined + try { + hostnameServer = await startHttpServer({ port: 0 }) + const port = (hostnameServer.address() as AddressInfo).port + const res = await fetch(`http://127.0.0.1:${port}/.well-known/oauth-protected-resource/mcp`) + const body = await res.json() + expect(body.resource).toBe('https://mcp.seatable.com/mcp') + expect(body.authorization_servers).toContain('https://mcp.seatable.com') + } finally { + delete process.env.SEATABLE_MCP_HOSTNAME + await close(hostnameServer) + } + }) +}) + +describe('managed mode / WWW-Authenticate on 401', () => { + let server: Server + let baseUrl: string + + beforeAll(async () => { + process.env.SEATABLE_SERVER_URL = 'http://localhost' + process.env.SEATABLE_MODE = 'managed' + process.env.SEATABLE_MOCK = 'true' + process.env.SEATABLE_TOKEN_SECRET = 'protected-resource-spec-secret-long-enough' + delete process.env.SEATABLE_API_TOKEN + delete process.env.SEATABLE_MCP_HOSTNAME + + server = await startHttpServer({ port: 0 }) + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + }) + + afterAll(async () => { + delete process.env.SEATABLE_MODE + delete process.env.SEATABLE_TOKEN_SECRET + await close(server) + }) + + it('points a credential-less request at the resource metadata', async () => { + const res = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: JSON_HEADERS, + body: INITIALIZE, + }) + expect(res.status).toBe(401) + + const challenge = res.headers.get('www-authenticate') + expect(challenge).toBeTruthy() + expect(challenge).toMatch(/^Bearer\b/) + expect(challenge).toContain(`resource_metadata="${baseUrl}/.well-known/oauth-protected-resource/mcp"`) + }) + + /** + * RFC 6750 section 3.1: a request that carries no credential at all must not + * be answered with an error code — only a rejected one may be. + */ + it('omits an error code when no credential was presented', async () => { + const res = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: JSON_HEADERS, + body: INITIALIZE, + }) + expect(res.headers.get('www-authenticate')).not.toContain('error=') + }) + + it('reports invalid_token when a credential was presented and rejected', async () => { + const res = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: { ...JSON_HEADERS, authorization: 'Bearer token-nope' }, + body: INITIALIZE, + }) + expect(res.status).toBe(401) + + const challenge = res.headers.get('www-authenticate') + expect(challenge).toContain('error="invalid_token"') + expect(challenge).toContain('resource_metadata=') + }) + + it('challenges a session request that carries no credential', async () => { + const init = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: { ...JSON_HEADERS, authorization: 'Bearer token-valid' }, + body: INITIALIZE, + }) + expect(init.status).toBe(200) + const sessionId = init.headers.get('mcp-session-id')! + expect(sessionId).toBeTruthy() + + const res = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: { ...JSON_HEADERS, 'mcp-session-id': sessionId }, + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), + }) + expect(res.status).toBe(401) + expect(res.headers.get('www-authenticate')).toContain('resource_metadata=') + }) + + it('challenges a session request whose credential was rejected', async () => { + const init = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: { ...JSON_HEADERS, authorization: 'Bearer token-valid' }, + body: INITIALIZE, + }) + const sessionId = init.headers.get('mcp-session-id')! + + const res = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers: { ...JSON_HEADERS, 'mcp-session-id': sessionId, authorization: 'Bearer token-nope' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), + }) + expect(res.status).toBe(401) + expect(res.headers.get('www-authenticate')).toContain('error="invalid_token"') + }) +}) + +/** + * Selfhosted mode has no OAuth and no bearer requirement, so there is no + * protected resource to describe. Advertising one would point clients at + * endpoints that do not exist in this mode. + */ +describe('selfhosted mode / no protected resource metadata', () => { + let server: Server + let baseUrl: string + + beforeAll(async () => { + process.env.SEATABLE_SERVER_URL = 'http://localhost' + process.env.SEATABLE_API_TOKEN = 'test-token' + process.env.SEATABLE_MOCK = 'true' + delete process.env.SEATABLE_MODE + delete process.env.SEATABLE_TOKEN_SECRET + + server = await startHttpServer({ port: 0 }) + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + }) + + afterAll(async () => { + await close(server) + }) + + it('answers 404 at the root well-known path', async () => { + const res = await fetch(`${baseUrl}/.well-known/oauth-protected-resource`) + expect(res.status).toBe(404) + }) + + it('answers 404 at the path-suffixed location', async () => { + const res = await fetch(`${baseUrl}/.well-known/oauth-protected-resource/mcp`) + expect(res.status).toBe(404) + }) +})