Files
Reactive-Resume/apps/server/src/http/auth.ts
T
Amruth Pillai 3fc0896a34 fix(auth): honor client-requested token_endpoint_auth_method during DCR
Dynamic client registration unconditionally rewrote token_endpoint_auth_method
to "none" for every unauthenticated request, downgrading clients that asked for
client_secret_basic or client_secret_post to public clients. Those clients were
issued no client_secret but still authenticated at the token endpoint with the
method they registered, so the exchange failed with 401 invalid_client.
Connecting Composio to the MCP server hit this on every attempt.

Default to "none" only when the client omits the field, which keeps PKCE-only
MCP clients working while confidential clients receive a usable secret.
2026-09-10 12:47:52 +02:00

212 lines
7.3 KiB
TypeScript

import { APIError } from "better-auth/api";
import { auth } from "@reactive-resume/auth/config";
import { env } from "@reactive-resume/env/server";
import { isAllowedOAuthRedirectUri } from "@reactive-resume/utils/url-security.node";
const oauthAuthorizeSanitizedParams = [
"prompt",
"redirect_uri",
"client_id",
"code_challenge",
"code_challenge_method",
"response_type",
"scope",
"state",
"resource",
] as const;
function sanitizeOAuthAuthorizeRequest(request: Request): Request {
if (request.method !== "GET") return request;
const url = new URL(request.url);
if (!url.pathname.endsWith("/oauth2/authorize")) return request;
const sanitizeValue = (value: string) =>
value
.replace(/[\r\n\t]+/g, " ")
.replace(/\s+/g, " ")
.trim();
const sanitizeParam = (key: string) => {
const values = url.searchParams.getAll(key);
if (!values.length) return;
url.searchParams.delete(key);
for (const value of values) url.searchParams.append(key, sanitizeValue(value));
};
for (const key of oauthAuthorizeSanitizedParams) sanitizeParam(key);
const redirectUri = url.searchParams.get("redirect_uri");
if (redirectUri && !URL.canParse(redirectUri)) {
try {
const decodedRedirectUri = decodeURIComponent(redirectUri);
if (URL.canParse(decodedRedirectUri)) {
url.searchParams.set("redirect_uri", decodedRedirectUri);
}
} catch {
// Ignore malformed encoded values and let Better Auth validation handle them.
}
}
if (url.toString() === request.url) return request;
return new Request(url.toString(), request);
}
function isRegistrationPayload(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function defaultPublicClientRegistration(request: Request): Promise<Request> {
if (request.method !== "POST") return request;
const url = new URL(request.url);
if (!url.pathname.endsWith("/oauth2/register")) return request;
const cloned = request.clone();
let body: Record<string, unknown>;
try {
const payload: unknown = await cloned.json();
if (!isRegistrationPayload(payload)) return request;
body = payload;
} catch {
return request;
}
// MCP native clients often omit OIDC application_type. Infer it only for
// exact HTTP loopback callbacks; the provider still validates every URI.
if (body.application_type === undefined && Array.isArray(body.redirect_uris) && body.redirect_uris.length > 0) {
const allLoopback = body.redirect_uris.every(
(uri: unknown) =>
typeof uri === "string" && /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?(?:[/?]|$)/i.test(uri),
);
if (allLoopback) body.application_type = "native";
}
// MCP clients that authenticate with PKCE alone omit the method, and Better Auth
// would otherwise register them as `client_secret_basic`. Honor an explicit choice:
// forcing it to "none" issues no client secret, so the client's own Basic/post
// credentials are rejected at the token endpoint with 401 invalid_client.
if (!request.headers.get("authorization")) {
body.token_endpoint_auth_method ??= "none";
}
return new Request(url.toString(), {
method: request.method,
headers: request.headers,
body: JSON.stringify(body),
});
}
async function validateDynamicClientRegistrationRequest(request: Request): Promise<Response | undefined> {
if (request.method !== "POST") return;
const url = new URL(request.url);
if (!url.pathname.endsWith("/oauth2/register")) return;
const cloned = request.clone();
let body: Record<string, unknown>;
try {
const payload: unknown = await cloned.json();
if (!isRegistrationPayload(payload)) {
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
}
body = payload;
} catch {
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
}
const oauthTrustedOrigins = [new URL(env.APP_URL).origin.toLowerCase()];
const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
for (const redirectUri of redirectUris) {
if (
typeof redirectUri !== "string" ||
!isAllowedOAuthRedirectUri(redirectUri, oauthTrustedOrigins, {
allowUnsafe: env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI,
})
) {
return Response.json(
{ error: "invalid_redirect_uri", error_description: "redirect_uri is not allowed" },
{ status: 400 },
);
}
}
}
export async function handleAuth(request: Request) {
const registrationValidationError = await validateDynamicClientRegistrationRequest(request);
if (registrationValidationError) return registrationValidationError;
const sanitizedRequest = sanitizeOAuthAuthorizeRequest(request);
const finalRequest = await defaultPublicClientRegistration(sanitizedRequest);
return auth.handler(finalRequest);
}
export async function handleOAuth(request: Request) {
try {
return await resumeOAuth(request);
} catch (error) {
// Before-hooks can throw even when the provider is called with asResponse.
if (error instanceof APIError) return Response.json(error.body, { status: error.statusCode });
throw error;
}
}
async function resumeOAuth(request: Request) {
const session = await auth.api.getSession({ headers: request.headers });
const url = new URL(request.url);
if (session?.user) {
// Resume authorization without granting consent. The provider decides whether
// the user must sign in, explicitly approve a client, or reuse an existing grant.
// Its signed query must survive the login round trip byte-for-byte.
const response = await auth.api.oauth2Continue({
asResponse: true,
request,
headers: request.headers,
body: { postLogin: true, oauth_query: url.search.slice(1) },
});
if (!(response instanceof Response)) throw new Error("OAuth provider did not return a response");
if (!response.ok) return response;
const result: unknown = await response.json().catch(() => null);
if (
!result ||
typeof result !== "object" ||
!("url" in result) ||
typeof result.url !== "string" ||
!result.url ||
!(result.url.startsWith("/") || URL.canParse(result.url)) ||
!URL.canParse(result.url, env.APP_URL)
)
return Response.json({ error: "invalid_provider_response" }, { status: 502 });
const headers = new Headers(response.headers);
headers.delete("content-type");
headers.delete("content-length");
const target = new URL(result.url, env.APP_URL);
if (["javascript:", "data:", "vbscript:", "file:", "blob:"].includes(target.protocol)) {
return Response.json({ error: "invalid_provider_response" }, { status: 502 });
}
if (target.origin === new URL(env.APP_URL).origin && target.pathname === "/api/auth/oauth") {
return redirectToOAuthLogin(target, true, headers);
}
headers.set("Location", result.url);
return new Response(null, { status: 302, headers });
}
return redirectToOAuthLogin(url);
}
function redirectToOAuthLogin(url: URL, reauthenticate = false, headers = new Headers()) {
const prompt = new Set(url.searchParams.get("prompt")?.split(" ") ?? []);
const loginUrl = new URL(prompt.has("create") ? "/auth/register" : "/auth/login", env.APP_URL);
if (reauthenticate) loginUrl.searchParams.set("reauthenticate", "true");
loginUrl.searchParams.set("callbackURL", `/api/auth/oauth${url.search}`);
headers.set("Location", `${loginUrl.pathname}${loginUrl.search}`);
return new Response(null, {
status: 302,
headers,
});
}