fix(auth): only rebuild the response when an override is actually applied

Two review findings on the fetch wrapper:

Reading the body with `response.text()` meant every successful JSON
response was rebuilt while overrides were configured, even when it was
not a metadata document. That dropped native properties a synthesized
`Response` cannot carry (`url`, `redirected`, `type`) from responses the
wrapper has no business altering, and threw outright on a body-less
204/205, whose status Fetch forbids a body on — so an unrelated transport
response could fail. It now parses a clone and returns the caller's own
response unless a document is actually patched.

`new URL` also accepts embedded credentials while Fetch rejects a request
URL carrying them, so `https://user:pass@host/token` passed both the form
and the runtime and then failed deep in the OAuth exchange.
`oauthEndpointUrlError` now rejects a username or password.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 21:30:32 -04:00
commit ac8a08e42f

View file

@ -85,6 +85,14 @@ export function oauthEndpointUrlError(value: string): string | undefined {
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return `"${trimmed}" is not an http(s) URL.`;
}
// `new URL` accepts embedded credentials but Fetch rejects an HTTP(S) request
// URL that carries them, so without this the value would pass both the form
// and the runtime and then fail deep in the OAuth exchange — the failure mode
// this validation exists to prevent. (Sending credentials to an endpoint the
// flow discovered is not something to support quietly, either.)
if (parsed.username || parsed.password) {
return "URL must not contain a username or password.";
}
return undefined;
}
@ -232,18 +240,20 @@ export function withOAuthEndpointOverrides(
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("json")) return response;
const body = await response.text();
// Read through a clone so every path that does NOT patch can return the
// caller's own `Response`, untouched. Rebuilding unconditionally would drop
// native properties a synthesized `Response` cannot carry (`url`,
// `redirected`, `type`) from responses this wrapper has no business
// altering — and would throw outright on a body-less 204/205, whose status
// Fetch forbids a body on. A clone also makes a non-JSON body a plain
// parse failure with nothing consumed.
let parsed: unknown;
try {
parsed = JSON.parse(body);
parsed = await response.clone().json();
} catch {
// Not JSON despite the header. The body is already consumed, so hand back
// an equivalent response rather than the drained original.
return responseWithBody(response, body);
}
if (!isAuthorizationServerMetadata(parsed)) {
return responseWithBody(response, body);
return response;
}
if (!isAuthorizationServerMetadata(parsed)) return response;
return responseWithBody(
response,
JSON.stringify(applyOAuthEndpointOverrides(parsed, overrides)),