Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
54 changes: 54 additions & 0 deletions src/auth/oauthProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])

Expand Down Expand Up @@ -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)
*
Expand Down
33 changes: 28 additions & 5 deletions src/http/httpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, string> = { '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 {
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading