mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: OpenRouter OAuth denial redirects show provider errors (#105448)
* fix(openrouter): surface pasted OAuth redirect errors Co-authored-by: chatgpt-codex-connector[bot] <261883814+chatgpt-codex-connector[bot]@users.noreply.github.com> * fix(openrouter): align callback OAuth error state checks Co-authored-by: chatgpt-codex-connector[bot] <261883814+chatgpt-codex-connector[bot]@users.noreply.github.com> * test(openrouter): initialize callback readiness --------- Co-authored-by: chatgpt-codex-connector[bot] <261883814+chatgpt-codex-connector[bot]@users.noreply.github.com> Co-authored-by: Altay <altay@hey.com>
This commit is contained in:
@@ -138,6 +138,27 @@ function createOpenRouterOAuthContext(params: {
|
||||
return { ctx, progress, note, text, log, openUrl };
|
||||
}
|
||||
|
||||
async function requestLocalOpenRouterOAuthCallback(
|
||||
query: string,
|
||||
): Promise<{ callback: Promise<unknown>; response: Response; body: string }> {
|
||||
let markReady = () => {};
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
markReady = resolve;
|
||||
});
|
||||
const callback = waitForOpenRouterOAuthCallback({
|
||||
expectedState: "state-1",
|
||||
timeoutMs: 1000,
|
||||
onProgress: markReady,
|
||||
});
|
||||
callback.catch(() => undefined);
|
||||
await ready;
|
||||
|
||||
const response = await fetch(`${OPENROUTER_OAUTH_REDIRECT_URI}?${query}`, {
|
||||
headers: { Connection: "close" },
|
||||
});
|
||||
return { callback, response, body: await response.text() };
|
||||
}
|
||||
|
||||
describe("OpenRouter OAuth", () => {
|
||||
it("builds the documented PKCE authorize URL", () => {
|
||||
const url = new URL(
|
||||
@@ -167,6 +188,30 @@ describe("OpenRouter OAuth", () => {
|
||||
code: "AUTHCODE",
|
||||
state: "state-1",
|
||||
});
|
||||
expect(() =>
|
||||
parseOpenRouterOAuthCallbackInput(
|
||||
`${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1&error=access_denied&error_description=Denied`,
|
||||
"state-1",
|
||||
),
|
||||
).toThrow("OpenRouter OAuth error: access_denied: Denied");
|
||||
expect(() =>
|
||||
parseOpenRouterOAuthCallbackInput(
|
||||
"state=state-1&error=access_denied&error_description=Denied",
|
||||
"state-1",
|
||||
),
|
||||
).toThrow("OpenRouter OAuth error: access_denied: Denied");
|
||||
expect(() =>
|
||||
parseOpenRouterOAuthCallbackInput(
|
||||
`${OPENROUTER_OAUTH_REDIRECT_URI}?error=access_denied&error_description=Denied`,
|
||||
"state-1",
|
||||
),
|
||||
).toThrow("Missing OpenRouter OAuth state");
|
||||
expect(() =>
|
||||
parseOpenRouterOAuthCallbackInput(
|
||||
`${OPENROUTER_OAUTH_REDIRECT_URI}?state=wrong&error=access_denied&error_description=Denied`,
|
||||
"state-1",
|
||||
),
|
||||
).toThrow("OpenRouter OAuth state mismatch");
|
||||
expect(buildOpenRouterOAuthRedirectUri({ state: "state-1" })).toBe(
|
||||
`${OPENROUTER_OAUTH_REDIRECT_URI}?state=state-1`,
|
||||
);
|
||||
@@ -348,6 +393,29 @@ describe("OpenRouter OAuth", () => {
|
||||
expect(text).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates local callback state before surfacing OpenRouter OAuth errors", async () => {
|
||||
const denied = await requestLocalOpenRouterOAuthCallback(
|
||||
"state=state-1&error=access_denied&error_description=Denied",
|
||||
);
|
||||
expect(denied.response.status).toBe(400);
|
||||
expect(denied.body).toBe("OpenRouter authentication failed: access_denied: Denied");
|
||||
await expect(denied.callback).rejects.toThrow("OpenRouter OAuth error: access_denied: Denied");
|
||||
|
||||
const missingState = await requestLocalOpenRouterOAuthCallback(
|
||||
"error=access_denied&error_description=Denied",
|
||||
);
|
||||
expect(missingState.response.status).toBe(400);
|
||||
expect(missingState.body).toBe("Invalid OAuth state");
|
||||
await expect(missingState.callback).rejects.toThrow("Missing OpenRouter OAuth state");
|
||||
|
||||
const wrongState = await requestLocalOpenRouterOAuthCallback(
|
||||
"state=wrong&error=access_denied&error_description=Denied",
|
||||
);
|
||||
expect(wrongState.response.status).toBe(400);
|
||||
expect(wrongState.body).toBe("Invalid OAuth state");
|
||||
await expect(wrongState.callback).rejects.toThrow("OpenRouter OAuth state mismatch");
|
||||
});
|
||||
|
||||
it("exposes stable auth choice metadata", () => {
|
||||
expect(OPENROUTER_OAUTH_CHOICE_ID).toBe("openrouter-oauth");
|
||||
});
|
||||
|
||||
@@ -143,11 +143,18 @@ export function parseOpenRouterOAuthCallbackInput(
|
||||
}
|
||||
|
||||
const parseParams = (params: URLSearchParams): OpenRouterOAuthCallbackResult => {
|
||||
const state = requireOpenRouterOAuthState(readString(params.get("state")), expectedState);
|
||||
const error = readString(params.get("error"));
|
||||
if (error) {
|
||||
const description = readString(params.get("error_description"));
|
||||
throw new Error(
|
||||
`OpenRouter OAuth error: ${description ? `${error}: ${description}` : error}`,
|
||||
);
|
||||
}
|
||||
const code = readString(params.get("code"));
|
||||
if (!code) {
|
||||
throw new Error("Missing 'code' parameter in redirect URL.");
|
||||
}
|
||||
const state = requireOpenRouterOAuthState(readString(params.get("state")), expectedState);
|
||||
return { code, state };
|
||||
};
|
||||
|
||||
@@ -156,7 +163,7 @@ export function parseOpenRouterOAuthCallbackInput(
|
||||
return parseParams(url.searchParams);
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError) {
|
||||
if (trimmed.includes("code=")) {
|
||||
if (trimmed.includes("code=") || trimmed.includes("error=")) {
|
||||
return parseParams(new URLSearchParams(trimmed));
|
||||
}
|
||||
throw new Error("Paste the full OpenRouter redirect URL, not just the code.", {
|
||||
@@ -231,12 +238,25 @@ export async function waitForOpenRouterOAuthCallback(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const error = readString(requestUrl.searchParams.get("error"));
|
||||
if (error) {
|
||||
const state = readString(requestUrl.searchParams.get("state"));
|
||||
try {
|
||||
requireOpenRouterOAuthState(state, params.expectedState);
|
||||
} catch (err) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end(`OpenRouter authentication failed: ${error}`);
|
||||
finish(new Error(`OpenRouter OAuth error: ${error}`));
|
||||
res.end("Invalid OAuth state");
|
||||
finish(err instanceof Error ? err : new Error("OpenRouter OAuth state mismatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
const error = readString(requestUrl.searchParams.get("error"));
|
||||
if (error) {
|
||||
const description = readString(requestUrl.searchParams.get("error_description"));
|
||||
const detail = description ? `${error}: ${description}` : error;
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end(`OpenRouter authentication failed: ${detail}`);
|
||||
finish(new Error(`OpenRouter OAuth error: ${detail}`));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -248,16 +268,6 @@ export async function waitForOpenRouterOAuthCallback(params: {
|
||||
finish(new Error("Missing OpenRouter OAuth code"));
|
||||
return;
|
||||
}
|
||||
const state = readString(requestUrl.searchParams.get("state"));
|
||||
try {
|
||||
requireOpenRouterOAuthState(state, params.expectedState);
|
||||
} catch (err) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Invalid OAuth state");
|
||||
finish(err instanceof Error ? err : new Error("OpenRouter OAuth state mismatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
|
||||
Reference in New Issue
Block a user