392 lines
18 KiB
TypeScript
392 lines
18 KiB
TypeScript
import assert from "node:assert/strict"
|
|
import test from "node:test"
|
|
import { createHash } from "node:crypto"
|
|
import { z } from "zod"
|
|
import {
|
|
createDefaultScenario,
|
|
createEnterpriseMcpMockServer,
|
|
getProviderProfile,
|
|
} from "../src/index.js"
|
|
import type { ProviderProfileId } from "../src/index.js"
|
|
import { authorizeClient, authorizeManual, callRpc, initializeSession, rpcHeaders } from "./wire-client.js"
|
|
|
|
const oauthClientSecret = "synthetic-test-client-secret-32-bytes"
|
|
|
|
async function invalidClientTokenResponse(profileId: ProviderProfileId): Promise<Response> {
|
|
const scenario = createDefaultScenario(profileId)
|
|
const profile = getProviderProfile(profileId)
|
|
const server = createEnterpriseMcpMockServer({ scenario, secrets: { oauthClientSecret } })
|
|
await server.start()
|
|
try {
|
|
const verifier = "wire-test-pkce-verifier-with-more-than-43-characters-1234567890"
|
|
const challenge = createHash("sha256").update(verifier).digest("base64url")
|
|
const redirectUri = scenario.oauth.redirectUris[0]
|
|
assert.ok(redirectUri)
|
|
const authorize = new URL(profile.oauth.authorizationPath, server.baseUrl)
|
|
authorize.searchParams.set("response_type", "code")
|
|
authorize.searchParams.set("client_id", scenario.oauth.clientId)
|
|
authorize.searchParams.set("redirect_uri", redirectUri)
|
|
authorize.searchParams.set("scope", scenario.oauth.authorizationScopes.join(" "))
|
|
authorize.searchParams.set("resource", server.mcpUrl)
|
|
authorize.searchParams.set("state", "invalid-client-state")
|
|
authorize.searchParams.set("code_challenge", challenge)
|
|
authorize.searchParams.set("code_challenge_method", "S256")
|
|
const authorizeResponse = await fetch(authorize, { redirect: "manual" })
|
|
assert.equal(authorizeResponse.status, 302)
|
|
const location = authorizeResponse.headers.get("location")
|
|
assert.ok(location)
|
|
const code = new URL(location).searchParams.get("code")
|
|
assert.ok(code)
|
|
|
|
return await fetch(new URL(profile.oauth.tokenPath, server.baseUrl), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
client_id: scenario.oauth.clientId,
|
|
client_secret: "wrong-secret-must-not-be-echoed",
|
|
redirect_uri: redirectUri,
|
|
code,
|
|
code_verifier: verifier,
|
|
resource: server.mcpUrl,
|
|
}),
|
|
})
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
}
|
|
|
|
test("ServiceNow profile exposes exact manual OAuth topology and enforces exact redirects", async () => {
|
|
const scenario = createDefaultScenario("servicenow-inbound-quickstart")
|
|
const profile = getProviderProfile(scenario.profileId)
|
|
const server = createEnterpriseMcpMockServer({ scenario, secrets: { oauthClientSecret } })
|
|
await server.start()
|
|
try {
|
|
const metadataResponse = await fetch(new URL("/.well-known/oauth-authorization-server", server.baseUrl))
|
|
const metadataText = await metadataResponse.text()
|
|
const metadataValue: unknown = JSON.parse(metadataText)
|
|
const metadata = z.object({
|
|
authorization_endpoint: z.url(),
|
|
token_endpoint: z.url(),
|
|
revocation_endpoint: z.url(),
|
|
registration_endpoint: z.unknown().optional(),
|
|
}).parse(metadataValue)
|
|
assert.equal(new URL(metadata.authorization_endpoint).pathname, "/oauth_auth.do")
|
|
assert.equal(new URL(metadata.token_endpoint).pathname, "/oauth_token.do")
|
|
assert.equal(new URL(metadata.revocation_endpoint).pathname, "/oauth_revoke.do")
|
|
assert.equal(metadata.registration_endpoint, undefined)
|
|
|
|
const invalidRedirect = new URL(profile.oauth.authorizationPath, server.baseUrl)
|
|
invalidRedirect.searchParams.set("response_type", "code")
|
|
invalidRedirect.searchParams.set("client_id", scenario.oauth.clientId)
|
|
invalidRedirect.searchParams.set("redirect_uri", "https://attacker.example/callback")
|
|
invalidRedirect.searchParams.set("scope", scenario.oauth.authorizationScopes.join(" "))
|
|
invalidRedirect.searchParams.set("resource", server.mcpUrl)
|
|
invalidRedirect.searchParams.set("state", "state")
|
|
invalidRedirect.searchParams.set("code_challenge", "challenge")
|
|
invalidRedirect.searchParams.set("code_challenge_method", "S256")
|
|
const invalidRedirectResponse = await fetch(invalidRedirect, { redirect: "manual" })
|
|
assert.equal(invalidRedirectResponse.status, 400)
|
|
|
|
const token = await authorizeManual(server.baseUrl, scenario, oauthClientSecret)
|
|
assert.match(token.accessToken, /^access-token-/)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
test("Microsoft and ServiceNow retain distinct safe invalid-client evidence", async () => {
|
|
const microsoftResponse = await invalidClientTokenResponse("microsoft-enterprise")
|
|
assert.equal(microsoftResponse.status, 401)
|
|
const microsoftValue: unknown = JSON.parse(await microsoftResponse.text())
|
|
const microsoftError = z.object({
|
|
error: z.literal("invalid_client"),
|
|
error_description: z.string(),
|
|
error_codes: z.tuple([z.literal(7_000_215)]),
|
|
timestamp: z.string(),
|
|
trace_id: z.string().uuid(),
|
|
correlation_id: z.string().uuid(),
|
|
}).parse(microsoftValue)
|
|
assert.match(microsoftError.error_description, /AADSTS7000215/)
|
|
assert.match(microsoftError.error_description, /client secret value, not the client secret ID/)
|
|
assert.doesNotMatch(microsoftError.error_description, /wrong-secret-must-not-be-echoed/)
|
|
|
|
const serviceNowResponse = await invalidClientTokenResponse("servicenow-inbound-quickstart")
|
|
assert.equal(serviceNowResponse.status, 401)
|
|
const serviceNowValue: unknown = JSON.parse(await serviceNowResponse.text())
|
|
const serviceNowError = z.object({
|
|
error: z.literal("invalid_client"),
|
|
error_description: z.string(),
|
|
}).strict().parse(serviceNowValue)
|
|
assert.match(serviceNowError.error_description, /ServiceNow OAuth application/)
|
|
assert.match(serviceNowError.error_description, /client ID and client secret/)
|
|
assert.doesNotMatch(serviceNowError.error_description, /wrong-secret-must-not-be-echoed/)
|
|
assert.doesNotMatch(serviceNowError.error_description, /AADSTS/)
|
|
})
|
|
|
|
test("dynamic registration retains none and client_secret_post authentication methods", async () => {
|
|
const scenario = createDefaultScenario("synthetic-enterprise-oauth-mcp")
|
|
const profile = getProviderProfile(scenario.profileId)
|
|
const registrationPath = profile.oauth.registrationPath
|
|
assert.ok(registrationPath)
|
|
const server = createEnterpriseMcpMockServer({ scenario, secrets: { oauthClientSecret } })
|
|
await server.start()
|
|
try {
|
|
const noneRegistration = await fetch(new URL(registrationPath, server.baseUrl), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ redirect_uris: scenario.oauth.redirectUris, token_endpoint_auth_method: "none" }),
|
|
})
|
|
assert.equal(noneRegistration.status, 201)
|
|
const noneValue: unknown = JSON.parse(await noneRegistration.text())
|
|
const noneClient = z.object({ client_id: z.string(), token_endpoint_auth_method: z.literal("none") }).parse(noneValue)
|
|
const noneToken = await authorizeClient(server.baseUrl, scenario, noneClient.client_id)
|
|
assert.ok(noneToken.accessToken)
|
|
|
|
const secretRegistration = await fetch(new URL(registrationPath, server.baseUrl), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ redirect_uris: scenario.oauth.redirectUris, token_endpoint_auth_method: "client_secret_post" }),
|
|
})
|
|
assert.equal(secretRegistration.status, 201)
|
|
const secretValue: unknown = JSON.parse(await secretRegistration.text())
|
|
const secretClient = z.object({
|
|
client_id: z.string(),
|
|
client_secret: z.string(),
|
|
token_endpoint_auth_method: z.literal("client_secret_post"),
|
|
}).parse(secretValue)
|
|
const secretToken = await authorizeClient(server.baseUrl, scenario, secretClient.client_id, secretClient.client_secret)
|
|
assert.ok(secretToken.accessToken)
|
|
await assert.rejects(authorizeClient(server.baseUrl, scenario, secretClient.client_id), /Token exchange failed with 401/)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
test("refresh rotates tokens and revocation invalidates the active access token", async () => {
|
|
const scenario = createDefaultScenario("servicenow-inbound-quickstart")
|
|
const profile = getProviderProfile(scenario.profileId)
|
|
const server = createEnterpriseMcpMockServer({ scenario, secrets: { oauthClientSecret } })
|
|
await server.start()
|
|
try {
|
|
const first = await authorizeManual(server.baseUrl, scenario, oauthClientSecret)
|
|
const refreshForm = new URLSearchParams({
|
|
grant_type: "refresh_token",
|
|
client_id: scenario.oauth.clientId,
|
|
client_secret: oauthClientSecret,
|
|
refresh_token: first.refreshToken,
|
|
})
|
|
const refreshResponse = await fetch(new URL(profile.oauth.tokenPath, server.baseUrl), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: refreshForm,
|
|
})
|
|
assert.equal(refreshResponse.status, 200)
|
|
const refreshedValue: unknown = JSON.parse(await refreshResponse.text())
|
|
const refreshed = z.object({ access_token: z.string(), refresh_token: z.string() }).parse(refreshedValue)
|
|
assert.notEqual(refreshed.access_token, first.accessToken)
|
|
assert.notEqual(refreshed.refresh_token, first.refreshToken)
|
|
|
|
const replayResponse = await fetch(new URL(profile.oauth.tokenPath, server.baseUrl), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: refreshForm,
|
|
})
|
|
assert.equal(replayResponse.status, 400)
|
|
|
|
const revokeResponse = await fetch(new URL(profile.oauth.revocationPath, server.baseUrl), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({
|
|
token: refreshed.access_token,
|
|
client_id: scenario.oauth.clientId,
|
|
client_secret: oauthClientSecret,
|
|
}),
|
|
})
|
|
assert.equal(revokeResponse.status, 200)
|
|
const protectedResponse = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: `Bearer ${refreshed.access_token}`,
|
|
accept: "application/json, text/event-stream",
|
|
"content-type": "application/json",
|
|
origin: new URL(server.baseUrl).origin,
|
|
},
|
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
|
|
})
|
|
assert.equal(protectedResponse.status, 401)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
test("authorization codes bind exact PKCE verifier and become unusable after one exchange", async () => {
|
|
const scenario = createDefaultScenario("servicenow-inbound-quickstart")
|
|
const profile = getProviderProfile(scenario.profileId)
|
|
const redirectUri = scenario.oauth.redirectUris[0]
|
|
assert.ok(redirectUri)
|
|
const server = createEnterpriseMcpMockServer({ scenario, secrets: { oauthClientSecret } })
|
|
await server.start()
|
|
try {
|
|
const verifier = "wire-test-pkce-verifier-with-more-than-43-characters-1234567890"
|
|
const challenge = createHash("sha256").update(verifier).digest("base64url")
|
|
const authorizeUrl = new URL(profile.oauth.authorizationPath, server.baseUrl)
|
|
authorizeUrl.searchParams.set("response_type", "code")
|
|
authorizeUrl.searchParams.set("client_id", scenario.oauth.clientId)
|
|
authorizeUrl.searchParams.set("redirect_uri", redirectUri)
|
|
authorizeUrl.searchParams.set("scope", scenario.oauth.authorizationScopes.join(" "))
|
|
authorizeUrl.searchParams.set("resource", server.mcpUrl)
|
|
authorizeUrl.searchParams.set("state", "one-time-state")
|
|
authorizeUrl.searchParams.set("code_challenge", challenge)
|
|
authorizeUrl.searchParams.set("code_challenge_method", "S256")
|
|
const authorizeResponse = await fetch(authorizeUrl, { redirect: "manual" })
|
|
const location = authorizeResponse.headers.get("location")
|
|
assert.ok(location)
|
|
const code = new URL(location).searchParams.get("code")
|
|
assert.ok(code)
|
|
|
|
const tokenRequest = (codeVerifier: string): Promise<Response> =>
|
|
fetch(new URL(profile.oauth.tokenPath, server.baseUrl), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
client_id: scenario.oauth.clientId,
|
|
client_secret: oauthClientSecret,
|
|
redirect_uri: redirectUri,
|
|
resource: server.mcpUrl,
|
|
code,
|
|
code_verifier: codeVerifier,
|
|
}),
|
|
})
|
|
assert.equal((await tokenRequest("wrong-verifier")).status, 400)
|
|
assert.equal((await tokenRequest(verifier)).status, 200)
|
|
assert.equal((await tokenRequest(verifier)).status, 400)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
test("authorization rejects unsupported response types and malformed PKCE challenges", async () => {
|
|
const scenario = createDefaultScenario("servicenow-inbound-quickstart")
|
|
const profile = getProviderProfile(scenario.profileId)
|
|
const redirectUri = scenario.oauth.redirectUris[0]
|
|
assert.ok(redirectUri)
|
|
const server = createEnterpriseMcpMockServer({ scenario, secrets: { oauthClientSecret } })
|
|
await server.start()
|
|
try {
|
|
const authorize = (responseType: string, challenge: string): Promise<Response> => {
|
|
const url = new URL(profile.oauth.authorizationPath, server.baseUrl)
|
|
url.searchParams.set("response_type", responseType)
|
|
url.searchParams.set("client_id", scenario.oauth.clientId)
|
|
url.searchParams.set("redirect_uri", redirectUri)
|
|
url.searchParams.set("scope", scenario.oauth.authorizationScopes.join(" "))
|
|
url.searchParams.set("resource", server.mcpUrl)
|
|
url.searchParams.set("state", "state")
|
|
url.searchParams.set("code_challenge", challenge)
|
|
url.searchParams.set("code_challenge_method", "S256")
|
|
return fetch(url, { redirect: "manual" })
|
|
}
|
|
assert.equal((await authorize("token", "A".repeat(43))).status, 400)
|
|
assert.equal((await authorize("code", "short")).status, 400)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
test("refresh authority outlives access expiry and preserves only the original MCP session lineage", async () => {
|
|
let now = 1_700_000_000_000
|
|
let counter = 0
|
|
const scenario = createDefaultScenario("servicenow-inbound-quickstart")
|
|
const profile = getProviderProfile(scenario.profileId)
|
|
const server = createEnterpriseMcpMockServer({
|
|
scenario,
|
|
secrets: { oauthClientSecret },
|
|
environment: {
|
|
now: () => now,
|
|
randomId: () => `refresh-${++counter}`,
|
|
opaqueValue: (prefix) => `${prefix}-${++counter}`,
|
|
},
|
|
})
|
|
await server.start()
|
|
try {
|
|
const first = await authorizeManual(server.baseUrl, scenario, oauthClientSecret)
|
|
const session = await initializeSession(server.baseUrl, scenario, first)
|
|
now += 3_600_001
|
|
const refreshResponse = await fetch(new URL(profile.oauth.tokenPath, server.baseUrl), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({
|
|
grant_type: "refresh_token",
|
|
client_id: scenario.oauth.clientId,
|
|
client_secret: oauthClientSecret,
|
|
refresh_token: first.refreshToken,
|
|
}),
|
|
})
|
|
assert.equal(refreshResponse.status, 200)
|
|
const refreshed = z.object({ access_token: z.string(), refresh_token: z.string() }).parse(JSON.parse(await refreshResponse.text()))
|
|
const continued = await callRpc(
|
|
server.baseUrl,
|
|
scenario,
|
|
{ ...session, accessToken: refreshed.access_token, refreshToken: refreshed.refresh_token },
|
|
44,
|
|
"tools/list",
|
|
{},
|
|
)
|
|
assert.equal(continued.envelope.error, undefined)
|
|
const expiredAccess = await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: rpcHeaders(server.baseUrl, first.accessToken),
|
|
body: JSON.stringify({ jsonrpc: "2.0", id: 45, method: "initialize", params: {} }),
|
|
})
|
|
assert.equal(expiredAccess.status, 401)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
test("one dynamic OAuth client cannot revoke another client's token", async () => {
|
|
const scenario = createDefaultScenario("synthetic-enterprise-oauth-mcp")
|
|
const profile = getProviderProfile(scenario.profileId)
|
|
assert.ok(profile.oauth.registrationPath)
|
|
const server = createEnterpriseMcpMockServer({ scenario, secrets: { oauthClientSecret: "" } })
|
|
await server.start()
|
|
try {
|
|
const register = async (): Promise<string> => {
|
|
const response = await fetch(new URL(profile.oauth.registrationPath ?? "", server.baseUrl), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ redirect_uris: scenario.oauth.redirectUris, token_endpoint_auth_method: "none" }),
|
|
})
|
|
return z.object({ client_id: z.string() }).parse(JSON.parse(await response.text())).client_id
|
|
}
|
|
const clientA = await register()
|
|
const clientB = await register()
|
|
const tokenA = await authorizeClient(server.baseUrl, scenario, clientA)
|
|
const denied = await fetch(new URL(profile.oauth.revocationPath, server.baseUrl), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({ token: tokenA.accessToken, client_id: clientB }),
|
|
})
|
|
assert.equal(denied.status, 401)
|
|
assert.equal((await fetch(server.mcpUrl, {
|
|
method: "POST",
|
|
headers: rpcHeaders(server.baseUrl, tokenA.accessToken),
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "initialize",
|
|
params: { protocolVersion: scenario.protocol.version, capabilities: {}, clientInfo: { name: "owner-check", version: "1" } },
|
|
}),
|
|
})).status, 200)
|
|
const allowed = await fetch(new URL(profile.oauth.revocationPath, server.baseUrl), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({ token: tokenA.accessToken, client_id: clientA }),
|
|
})
|
|
assert.equal(allowed.status, 200)
|
|
} finally {
|
|
await server.stop()
|
|
}
|
|
})
|