* feat(mcp): speak spec revision 2026-07-28 (stateless core) Adopt the 2026-07-28 MCP spec revision on the connector endpoint while keeping every handshake-era client (2025-06-18 and earlier) byte-identical: - Accept per-request _meta protocol negotiation (io.modelcontextprotocol/protocolVersion); unsupported versions return UnsupportedProtocolVersionError (-32022) with the supported list. - Implement server/discover (spec MUST): supported revisions, capabilities including the extensions field, identity, instructions, freshness hints. - Decorate results for stateless clients: required resultType, serverInfo in _meta, and CacheableResult ttlMs/cacheScope on tools/list, prompts/list, resources/list, resources/read. - Validate the standard Mcp-Method/Mcp-Name request headers when present (HeaderMismatchError -32020); absence stays accepted. - Declare the ratified MCP Apps extension (io.modelcontextprotocol/ui) in capabilities; the widgets already use the ratified mime type and _meta.ui.resourceUri shape, so no widget changes are needed. - OAuth: include the RFC 9207 iss parameter on every authorization response (success and error) and advertise authorization_response_iss_parameter_supported in RFC 8414 metadata. Resource-not-found already used -32602 and tools/list ordering was already deterministic; both are covered by the new test file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): Mcp-Name covers params.uri, base64 sentinel, version-header consistency Review follow-ups against the transport spec text: Mcp-Name mirrors params.name OR params.uri (resources/read), values arrive base64-wrapped in the =?base64?...?= sentinel and must be decoded before comparison, and an MCP-Protocol-Version header that disagrees with the _meta protocol version is a HeaderMismatch. Absence of any header stays accepted since this server supports handshake-era clients (spec-sanctioned leniency). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
392 lines
15 KiB
TypeScript
392 lines
15 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import crypto from 'crypto'
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
createClient: vi.fn(),
|
|
isAllowedRedirectUri: vi.fn(),
|
|
requireCompanyId: vi.fn(),
|
|
getBranding: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/auth/oauth-codes', () => ({
|
|
createAuthCode: vi.fn(() => 'test-auth-code'),
|
|
}))
|
|
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createClient: () => mocks.createClient(),
|
|
}))
|
|
|
|
vi.mock('@/lib/auth/oauth-allowlist', () => ({
|
|
isAllowedRedirectUri: (...args: unknown[]) => mocks.isAllowedRedirectUri(...args),
|
|
}))
|
|
|
|
vi.mock('@/lib/company/context', () => ({
|
|
requireCompanyId: (...args: unknown[]) => mocks.requireCompanyId(...args),
|
|
}))
|
|
|
|
vi.mock('@/lib/branding/service', () => ({
|
|
getBranding: () => mocks.getBranding(),
|
|
}))
|
|
|
|
import { GET, POST } from '../route'
|
|
|
|
function buildAuthorizeUrl(params: Record<string, string>): string {
|
|
const url = new URL('http://localhost/api/mcp-oauth/authorize')
|
|
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v))
|
|
return url.toString()
|
|
}
|
|
|
|
function buildSupabase(
|
|
user: { id: string } | null,
|
|
companyName = 'Test AB',
|
|
aal: { currentLevel: string; nextLevel: string } = { currentLevel: 'aal2', nextLevel: 'aal2' },
|
|
) {
|
|
return {
|
|
auth: {
|
|
getUser: vi.fn().mockResolvedValue({ data: { user }, error: null }),
|
|
mfa: {
|
|
getAuthenticatorAssuranceLevel: vi.fn().mockResolvedValue({ data: aal, error: null }),
|
|
},
|
|
},
|
|
from: vi.fn().mockReturnValue({
|
|
select: vi.fn().mockReturnValue({
|
|
eq: vi.fn().mockReturnValue({
|
|
single: vi.fn().mockResolvedValue({
|
|
data: { company_name: companyName },
|
|
error: null,
|
|
}),
|
|
}),
|
|
}),
|
|
}),
|
|
}
|
|
}
|
|
|
|
describe('GET /api/mcp-oauth/authorize: CSP', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
|
|
mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1' }))
|
|
mocks.isAllowedRedirectUri.mockResolvedValue(true)
|
|
mocks.requireCompanyId.mockResolvedValue('company-1')
|
|
mocks.getBranding.mockReturnValue({ appName: 'gnubok' })
|
|
})
|
|
|
|
it("form-action includes the redirect_uri origin so the post-consent redirect isn't blocked", async () => {
|
|
// Regression: the consent form POSTs same-origin, but the server's 303
|
|
// response redirects to the client callback. CSP form-action re-checks
|
|
// every hop in the chain, so 'self' alone blocks the post-consent step.
|
|
const request = new Request(
|
|
buildAuthorizeUrl({
|
|
response_type: 'code',
|
|
redirect_uri: 'https://claude.ai/api/mcp/auth_callback',
|
|
code_challenge: 'abc',
|
|
code_challenge_method: 'S256',
|
|
scope: 'mcp',
|
|
state: 'xyz',
|
|
})
|
|
)
|
|
const response = await GET(request)
|
|
expect(response.status).toBe(200)
|
|
|
|
const csp = response.headers.get('Content-Security-Policy')
|
|
expect(csp).toBeTruthy()
|
|
expect(csp).toMatch(/form-action 'self' https:\/\/claude\.ai(;|$)/)
|
|
// 'self' is preserved so the same-origin POST still works.
|
|
expect(csp).toContain("form-action 'self'")
|
|
})
|
|
|
|
it('form-action uses the redirect origin only (no path/query leakage)', async () => {
|
|
const request = new Request(
|
|
buildAuthorizeUrl({
|
|
response_type: 'code',
|
|
redirect_uri: 'https://claude.com/api/oauth/callback?env=prod',
|
|
code_challenge: 'abc',
|
|
code_challenge_method: 'S256',
|
|
scope: 'mcp',
|
|
})
|
|
)
|
|
const response = await GET(request)
|
|
expect(response.status).toBe(200)
|
|
|
|
const csp = response.headers.get('Content-Security-Policy') ?? ''
|
|
expect(csp).toContain('https://claude.com')
|
|
// Origin only: no path, no query string in the source expression.
|
|
expect(csp).not.toContain('/api/oauth/callback')
|
|
expect(csp).not.toContain('env=prod')
|
|
})
|
|
|
|
it('HTML-escapes the reflected query string in the form action', async () => {
|
|
// The consent form posts back to the same URL, so url.search is echoed into
|
|
// an HTML attribute, and only redirect_uri/client_id/scope are validated:
|
|
// any extra parameter reaches that attribute.
|
|
//
|
|
// Two layers, and it is worth being precise about which does what. WHATWG
|
|
// URL parsing already percent-encodes " < > in the query component, so an
|
|
// injected tag arrives inert and CodeQL's js/reflected-xss report is not a
|
|
// live exploit. But `&` is NOT in that encode set, so without escaping the
|
|
// attribute carries raw ampersands, which is invalid HTML and leaves the
|
|
// page one refactor (a raw header, a non-WHATWG parser) away from a real
|
|
// breakout. This asserts the escaping layer, independent of the parser.
|
|
const request = new Request(
|
|
buildAuthorizeUrl({
|
|
response_type: 'code',
|
|
redirect_uri: 'https://claude.com/api/oauth/callback',
|
|
code_challenge: 'abc',
|
|
code_challenge_method: 'S256',
|
|
scope: 'mcp',
|
|
}) + '&evil=%22%3E%3Cscript%3Ealert(1)%3C%2Fscript%3E'
|
|
)
|
|
const response = await GET(request)
|
|
expect(response.status).toBe(200)
|
|
|
|
const html = await response.text()
|
|
const action = html.match(/<form method="POST" action="([^"]*)"/)?.[1]
|
|
expect(action).toBeDefined()
|
|
|
|
// Separators are entity-encoded: proof escapeHtml ran over the whole thing.
|
|
expect(action).toContain('&evil=')
|
|
expect(action).not.toMatch(/&(?!amp;|quot;|lt;|gt;)/)
|
|
// The attribute is never closed early, so no raw markup escapes into the page.
|
|
expect(html).not.toContain('"><script>')
|
|
expect(html).not.toContain('<script>alert(1)</script>')
|
|
})
|
|
|
|
it('renders both read and write rows when client passes only the legacy `mcp` scope marker', async () => {
|
|
// Claude's connector sends scope=mcp today. The consent UI must render
|
|
// every scope group so the user can opt into write/approval rows if they
|
|
// want, but each write/approve row MUST start unchecked. Affirmative
|
|
// opt-in is the access-control gate (GDPR Art. 25(2), ISO 27001:2022
|
|
// A.5.18 / A.8.2, SOC 2 CC6.3, ASVS V10.2.2 / V2.3.1).
|
|
const request = new Request(
|
|
buildAuthorizeUrl({
|
|
response_type: 'code',
|
|
redirect_uri: 'https://claude.ai/api/mcp/auth_callback',
|
|
code_challenge: 'abc',
|
|
code_challenge_method: 'S256',
|
|
scope: 'mcp',
|
|
})
|
|
)
|
|
const response = await GET(request)
|
|
expect(response.status).toBe(200)
|
|
const html = await response.text()
|
|
|
|
// Every scope row is rendered so the user can opt into / out of each one.
|
|
expect(html).toMatch(/value="transactions:write"/)
|
|
expect(html).toMatch(/value="bookkeeping:write"/)
|
|
expect(html).toMatch(/value="invoices:write"/)
|
|
expect(html).toMatch(/value="pending_operations:approve"/)
|
|
|
|
// Write and approval scopes MUST render unchecked. Users have to make an
|
|
// affirmative, deliberate selection for each destructive permission.
|
|
const writeRow = html.match(/<input[^>]*value="transactions:write"[^>]*>/)?.[0]
|
|
expect(writeRow).toBeDefined()
|
|
expect(writeRow!).not.toContain('checked')
|
|
|
|
const approveRow = html.match(/<input[^>]*value="pending_operations:approve"[^>]*>/)?.[0]
|
|
expect(approveRow).toBeDefined()
|
|
expect(approveRow!).not.toContain('checked')
|
|
|
|
const bookkeepingRow = html.match(/<input[^>]*value="bookkeeping:write"[^>]*>/)?.[0]
|
|
expect(bookkeepingRow).toBeDefined()
|
|
expect(bookkeepingRow!).not.toContain('checked')
|
|
|
|
// The :read counterpart is pre-checked (safe default).
|
|
const readRow = html.match(/<input[^>]*value="transactions:read"[^>]*>/)?.[0]
|
|
expect(readRow).toBeDefined()
|
|
expect(readRow!).toContain('checked')
|
|
})
|
|
|
|
it('renders only the requested scopes when the client passes them explicitly', async () => {
|
|
// RFC 6749 §3.3 strict least-privilege: an explicit `scope=` shrinks the
|
|
// ceiling, so a client that asked for read-only cannot have a write box
|
|
// surface at consent time.
|
|
const request = new Request(
|
|
buildAuthorizeUrl({
|
|
response_type: 'code',
|
|
redirect_uri: 'https://claude.ai/api/mcp/auth_callback',
|
|
code_challenge: 'abc',
|
|
code_challenge_method: 'S256',
|
|
scope: 'transactions:read invoices:read',
|
|
})
|
|
)
|
|
const response = await GET(request)
|
|
expect(response.status).toBe(200)
|
|
const html = await response.text()
|
|
|
|
expect(html).toContain('value="transactions:read"')
|
|
expect(html).toContain('value="invoices:read"')
|
|
expect(html).not.toContain('value="transactions:write"')
|
|
expect(html).not.toContain('value="bookkeeping:write"')
|
|
})
|
|
|
|
it('rejects disallowed redirect_uri before any CSP would be emitted', async () => {
|
|
mocks.isAllowedRedirectUri.mockResolvedValue(false)
|
|
const request = new Request(
|
|
buildAuthorizeUrl({
|
|
response_type: 'code',
|
|
redirect_uri: 'https://evil.example/cb',
|
|
code_challenge: 'abc',
|
|
code_challenge_method: 'S256',
|
|
scope: 'mcp',
|
|
})
|
|
)
|
|
const response = await GET(request)
|
|
expect(response.status).toBe(400)
|
|
// Important: the form-action whitelist must never be populated from an
|
|
// untrusted origin. A 400 here keeps the allowlist as the single source
|
|
// of truth for which origins can land at this endpoint.
|
|
})
|
|
})
|
|
|
|
describe('MFA step-up on /api/mcp-oauth/authorize', () => {
|
|
// Consent here ultimately mints a long-lived API key that bypasses MFA on
|
|
// every subsequent request, so an AAL1 (password-only) session must never
|
|
// reach the consent page or approve it. The middleware MFA gate exempts
|
|
// /api/mcp-oauth/*, making the route responsible for its own step-up.
|
|
const authorizeParams = {
|
|
response_type: 'code',
|
|
redirect_uri: 'https://claude.ai/api/mcp/auth_callback',
|
|
code_challenge: 'abc',
|
|
code_challenge_method: 'S256',
|
|
scope: 'mcp',
|
|
state: 'xyz',
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
|
|
vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true')
|
|
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
|
|
mocks.isAllowedRedirectUri.mockResolvedValue(true)
|
|
mocks.requireCompanyId.mockResolvedValue('company-1')
|
|
mocks.getBranding.mockReturnValue({ appName: 'gnubok' })
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
it('GET redirects an AAL1 session to /mfa/verify with returnTo', async () => {
|
|
mocks.createClient.mockResolvedValue(
|
|
buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal1', nextLevel: 'aal2' }),
|
|
)
|
|
|
|
const response = await GET(new Request(buildAuthorizeUrl(authorizeParams)))
|
|
|
|
expect(response.status).toBeGreaterThanOrEqual(300)
|
|
expect(response.status).toBeLessThan(400)
|
|
const location = new URL(response.headers.get('location')!)
|
|
expect(location.pathname).toBe('/mfa/verify')
|
|
const returnTo = new URL(location.searchParams.get('returnTo')!, location.origin)
|
|
expect(returnTo.pathname).toBe('/api/mcp-oauth/authorize')
|
|
expect(returnTo.searchParams.get('state')).toBe('xyz')
|
|
})
|
|
|
|
it('POST rejects an AAL1 session even when the consent form is forged', async () => {
|
|
mocks.createClient.mockResolvedValue(
|
|
buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal1', nextLevel: 'aal2' }),
|
|
)
|
|
|
|
const formData = new FormData()
|
|
formData.set('consent', 'allow')
|
|
const response = await POST(
|
|
new Request(buildAuthorizeUrl(authorizeParams), { method: 'POST', body: formData }),
|
|
)
|
|
|
|
expect(response.status).toBeGreaterThanOrEqual(300)
|
|
expect(response.status).toBeLessThan(400)
|
|
expect(new URL(response.headers.get('location')!).pathname).toBe('/mfa/verify')
|
|
// No auth code must be minted: the redirect target is the step-up page,
|
|
// never the client callback.
|
|
expect(response.headers.get('location')).not.toContain('code=')
|
|
})
|
|
|
|
it('GET renders consent for an AAL2 session', async () => {
|
|
mocks.createClient.mockResolvedValue(
|
|
buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal2', nextLevel: 'aal2' }),
|
|
)
|
|
|
|
const response = await GET(new Request(buildAuthorizeUrl(authorizeParams)))
|
|
expect(response.status).toBe(200)
|
|
})
|
|
|
|
it('GET skips step-up for BankID-linked users (inherently 2FA)', async () => {
|
|
const supabase = buildSupabase(
|
|
{ id: 'user-1' },
|
|
'Test AB',
|
|
{ currentLevel: 'aal1', nextLevel: 'aal2' },
|
|
)
|
|
;(supabase.auth.getUser as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
data: { user: { id: 'user-1', app_metadata: { bankid_linked: true } } },
|
|
error: null,
|
|
})
|
|
mocks.createClient.mockResolvedValue(supabase)
|
|
|
|
const response = await GET(new Request(buildAuthorizeUrl(authorizeParams)))
|
|
expect(response.status).toBe(200)
|
|
})
|
|
})
|
|
|
|
describe('RFC 9207 iss parameter on authorization responses', () => {
|
|
const authorizeParams = {
|
|
response_type: 'code',
|
|
redirect_uri: 'https://claude.ai/api/mcp/auth_callback',
|
|
code_challenge: 'abc',
|
|
code_challenge_method: 'S256',
|
|
scope: 'mcp',
|
|
state: 'xyz',
|
|
}
|
|
|
|
// Mirrors getScopeSigningKey/signScopeBinding in the route so the POST can
|
|
// present a scope binding that verifies against the test service key.
|
|
function signScope(scopeParam: string): string {
|
|
const key = crypto.createHash('sha256').update('oauth-scope:test-service-key').digest()
|
|
return crypto.createHmac('sha256', key).update(scopeParam).digest('base64url')
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key'
|
|
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.test.example')
|
|
mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1' }))
|
|
mocks.isAllowedRedirectUri.mockResolvedValue(true)
|
|
mocks.requireCompanyId.mockResolvedValue('company-1')
|
|
mocks.getBranding.mockReturnValue({ appName: 'gnubok' })
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
it('includes iss alongside code and state on the success redirect', async () => {
|
|
const formData = new FormData()
|
|
formData.set('consent', 'allow')
|
|
formData.set('scope_binding', 'mcp')
|
|
formData.set('scope_binding_sig', signScope('mcp'))
|
|
|
|
const response = await POST(
|
|
new Request(buildAuthorizeUrl(authorizeParams), { method: 'POST', body: formData }),
|
|
)
|
|
|
|
expect(response.status).toBe(303)
|
|
const location = new URL(response.headers.get('location')!)
|
|
expect(location.searchParams.get('code')).toBe('test-auth-code')
|
|
expect(location.searchParams.get('state')).toBe('xyz')
|
|
expect(location.searchParams.get('iss')).toBe('https://app.test.example')
|
|
})
|
|
|
|
it('includes iss on error redirects (access_denied)', async () => {
|
|
const formData = new FormData()
|
|
formData.set('consent', 'deny')
|
|
|
|
const response = await POST(
|
|
new Request(buildAuthorizeUrl(authorizeParams), { method: 'POST', body: formData }),
|
|
)
|
|
|
|
expect(response.status).toBe(303)
|
|
const location = new URL(response.headers.get('location')!)
|
|
expect(location.searchParams.get('error')).toBe('access_denied')
|
|
expect(location.searchParams.get('iss')).toBe('https://app.test.example')
|
|
})
|
|
})
|