fix(mcp): record why a client fails to resolve at /authorize

CIMD client resolution swallowed every fetch failure as ssrf_blocked and
/authorize logged a bare invalid event, so an unreachable metadata host
was indistinguishable from a genuine SSRF block and the consent flow
returned invalid_client with no diagnosable cause.

Split the resolution failure into ssrf_blocked (a real private-host
refusal) and fetch_failed (timeout / connect error), carrying the
concrete SafeFetchError kind and cause as a short non-secret detail.
Thread the resolution reason and detail into the authorize.invalid
annotation behind a pinned meta shape, and cache a resolved CIMD
document for up to 24h so the consent flow does not re-fetch on every
hit.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 15:18:12 +02:00
1 parent 324df70071
commit 3c6cb8b08d
3 files changed
+228 -24

No files matched your search

+44 -3
View File
@@ -118,16 +118,42 @@ interface ValidatedRequest {
resource: string;
}
/**
* The gate a request failed, pinned to a stable enum so the
* `mcp.oauth.authorize.invalid` annotation stays dashboard-friendly. The
* swallowed-reason gap (a bare `invalid` event) is exactly why the prod CIMD
* failure was undiagnosable.
*/
type ValidateFailure =
| "invalid_params"
| "client_unresolved"
| "redirect_mismatch"
| "bad_response_type"
| "pkce_required"
| "audience_mismatch";
type ValidateMeta = {
reason: ValidateFailure;
/** For `client_unresolved`: the `resolveClient` reason. */
clientReason?: string;
/** For `client_unresolved`: a short, non-secret transport diagnostic. */
detail?: string;
};
/** Shared validation for GET + POST. Returns a ready error Response on failure. */
async function validate(
params: Record<string, string | undefined>,
requestUrl: string,
): Promise<{ ok: true; v: ValidatedRequest } | { ok: false; res: Response }> {
): Promise<
| { ok: true; v: ValidatedRequest }
| { ok: false; res: Response; meta: ValidateMeta }
> {
const parsed = z.object(baseParams).safeParse(params);
if (!parsed.success) {
return {
ok: false,
res: badRequest("invalid_request", "Missing or malformed parameters"),
meta: { reason: "invalid_params" },
};
}
const p = parsed.data;
@@ -139,6 +165,11 @@ async function validate(
return {
ok: false,
res: badRequest("invalid_client", "Unknown or unresolvable client"),
meta: {
reason: "client_unresolved",
clientReason: resolved.reason,
...(resolved.detail ? { detail: resolved.detail } : {}),
},
};
}
@@ -150,6 +181,7 @@ async function validate(
"invalid_request",
"redirect_uri does not match the registered client",
),
meta: { reason: "redirect_mismatch" },
};
}
@@ -161,6 +193,7 @@ async function validate(
"unsupported_response_type",
"Only response_type=code is supported",
),
meta: { reason: "bad_response_type" },
};
}
@@ -175,6 +208,7 @@ async function validate(
"invalid_request",
"PKCE with code_challenge_method=S256 is required",
),
meta: { reason: "pkce_required" },
};
}
@@ -186,6 +220,7 @@ async function validate(
"invalid_target",
"resource must equal this server's MCP endpoint",
),
meta: { reason: "audience_mismatch" },
};
}
@@ -280,7 +315,10 @@ export async function GET(request: NextRequest): Promise<Response> {
const params = Object.fromEntries(url.searchParams.entries());
const result = await validate(params, request.url);
if (!result.ok) {
annotate({ action: { name: "mcp.oauth.authorize.invalid" } });
annotate({
action: { name: "mcp.oauth.authorize.invalid" },
meta: result.meta,
});
return result.res;
}
const { v } = result;
@@ -409,7 +447,10 @@ export async function POST(request: NextRequest): Promise<Response> {
const result = await validate(params, request.url);
if (!result.ok) {
annotate({ action: { name: "mcp.oauth.authorize.invalid" } });
annotate({
action: { name: "mcp.oauth.authorize.invalid" },
meta: result.meta,
});
return result.res;
}
const { v } = result;