mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
fix(ai): align oauth callback flows closes #2316
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { Server } from "node:http";
|
||||
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js";
|
||||
import { generatePKCE } from "./pkce.js";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.js";
|
||||
|
||||
@@ -33,18 +34,6 @@ const CALLBACK_PATH = "/callback";
|
||||
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
||||
const SCOPES =
|
||||
"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
|
||||
const SUCCESS_HTML = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Authentication successful</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Authentication successful. Return to your terminal to continue.</p>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
async function getNodeApis(): Promise<NodeApis> {
|
||||
if (nodeApis) return nodeApis;
|
||||
if (!nodeApisPromise) {
|
||||
@@ -110,15 +99,22 @@ async function startCallbackServer(expectedState: string): Promise<CallbackServe
|
||||
const { createServer } = await getNodeApis();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let result: { code: string; state: string } | null = null;
|
||||
let cancelled = false;
|
||||
let settleWait: ((value: { code: string; state: string } | null) => void) | undefined;
|
||||
const waitForCodePromise = new Promise<{ code: string; state: string } | null>((resolveWait) => {
|
||||
let settled = false;
|
||||
settleWait = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolveWait(value);
|
||||
};
|
||||
});
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || "", "http://localhost");
|
||||
if (url.pathname !== CALLBACK_PATH) {
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("Not found");
|
||||
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(oauthErrorHtml("Callback route not found."));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,27 +124,25 @@ async function startCallbackServer(expectedState: string): Promise<CallbackServe
|
||||
|
||||
if (error) {
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p></body></html>`);
|
||||
res.end(oauthErrorHtml("Anthropic authentication did not complete.", `Error: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(
|
||||
`<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
|
||||
);
|
||||
res.end(oauthErrorHtml("Missing code or state parameter."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== expectedState) {
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(`<html><body><h1>Authentication Failed</h1><p>State mismatch.</p></body></html>`);
|
||||
res.end(oauthErrorHtml("State mismatch."));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(SUCCESS_HTML);
|
||||
result = { code, state };
|
||||
res.end(oauthSuccessHtml("Anthropic authentication completed. You can close this window."));
|
||||
settleWait?.({ code, state });
|
||||
} catch {
|
||||
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("Internal error");
|
||||
@@ -164,15 +158,9 @@ async function startCallbackServer(expectedState: string): Promise<CallbackServe
|
||||
server,
|
||||
redirectUri: REDIRECT_URI,
|
||||
cancelWait: () => {
|
||||
cancelled = true;
|
||||
},
|
||||
waitForCode: async () => {
|
||||
const sleep = () => new Promise((r) => setTimeout(r, 100));
|
||||
while (!result && !cancelled) {
|
||||
await sleep();
|
||||
}
|
||||
return result;
|
||||
settleWait?.(null);
|
||||
},
|
||||
waitForCode: () => waitForCodePromise,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { Server } from "node:http";
|
||||
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js";
|
||||
import { generatePKCE } from "./pkce.js";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js";
|
||||
|
||||
@@ -67,8 +68,15 @@ async function startCallbackServer(): Promise<CallbackServerInfo> {
|
||||
const createServer = await getNodeCreateServer();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let result: { code: string; state: string } | null = null;
|
||||
let cancelled = false;
|
||||
let settleWait: ((value: { code: string; state: string } | null) => void) | undefined;
|
||||
const waitForCodePromise = new Promise<{ code: string; state: string } | null>((resolveWait) => {
|
||||
let settled = false;
|
||||
settleWait = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolveWait(value);
|
||||
};
|
||||
});
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url || "", `http://localhost:51121`);
|
||||
@@ -79,28 +87,22 @@ async function startCallbackServer(): Promise<CallbackServerInfo> {
|
||||
const error = url.searchParams.get("error");
|
||||
|
||||
if (error) {
|
||||
res.writeHead(400, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
|
||||
);
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(oauthErrorHtml("Google authentication did not complete.", `Error: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (code && state) {
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
`<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
|
||||
);
|
||||
result = { code, state };
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(oauthSuccessHtml("Google authentication completed. You can close this window."));
|
||||
settleWait?.({ code, state });
|
||||
} else {
|
||||
res.writeHead(400, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
`<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
|
||||
);
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(oauthErrorHtml("Missing code or state parameter."));
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(oauthErrorHtml("Callback route not found."));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -112,15 +114,9 @@ async function startCallbackServer(): Promise<CallbackServerInfo> {
|
||||
resolve({
|
||||
server,
|
||||
cancelWait: () => {
|
||||
cancelled = true;
|
||||
},
|
||||
waitForCode: async () => {
|
||||
const sleep = () => new Promise((r) => setTimeout(r, 100));
|
||||
while (!result && !cancelled) {
|
||||
await sleep();
|
||||
}
|
||||
return result;
|
||||
settleWait?.(null);
|
||||
},
|
||||
waitForCode: () => waitForCodePromise,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { Server } from "node:http";
|
||||
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js";
|
||||
import { generatePKCE } from "./pkce.js";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js";
|
||||
|
||||
@@ -59,8 +60,15 @@ async function startCallbackServer(): Promise<CallbackServerInfo> {
|
||||
const createServer = await getNodeCreateServer();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let result: { code: string; state: string } | null = null;
|
||||
let cancelled = false;
|
||||
let settleWait: ((value: { code: string; state: string } | null) => void) | undefined;
|
||||
const waitForCodePromise = new Promise<{ code: string; state: string } | null>((resolveWait) => {
|
||||
let settled = false;
|
||||
settleWait = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolveWait(value);
|
||||
};
|
||||
});
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url || "", `http://localhost:8085`);
|
||||
@@ -71,28 +79,22 @@ async function startCallbackServer(): Promise<CallbackServerInfo> {
|
||||
const error = url.searchParams.get("error");
|
||||
|
||||
if (error) {
|
||||
res.writeHead(400, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
|
||||
);
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(oauthErrorHtml("Google authentication did not complete.", `Error: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (code && state) {
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
`<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
|
||||
);
|
||||
result = { code, state };
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(oauthSuccessHtml("Google authentication completed. You can close this window."));
|
||||
settleWait?.({ code, state });
|
||||
} else {
|
||||
res.writeHead(400, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
`<html><body><h1>Authentication Failed</h1><p>Missing code or state parameter.</p></body></html>`,
|
||||
);
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(oauthErrorHtml("Missing code or state parameter."));
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(oauthErrorHtml("Callback route not found."));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -104,15 +106,9 @@ async function startCallbackServer(): Promise<CallbackServerInfo> {
|
||||
resolve({
|
||||
server,
|
||||
cancelWait: () => {
|
||||
cancelled = true;
|
||||
},
|
||||
waitForCode: async () => {
|
||||
const sleep = () => new Promise((r) => setTimeout(r, 100));
|
||||
while (!result && !cancelled) {
|
||||
await sleep();
|
||||
}
|
||||
return result;
|
||||
settleWait?.(null);
|
||||
},
|
||||
waitForCode: () => waitForCodePromise,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" aria-hidden="true"><path fill="#fff" fill-rule="evenodd" d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"/><path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/></svg>`;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function renderPage(options: { title: string; heading: string; message: string; details?: string }): string {
|
||||
const title = escapeHtml(options.title);
|
||||
const heading = escapeHtml(options.heading);
|
||||
const message = escapeHtml(options.message);
|
||||
const details = options.details ? escapeHtml(options.details) : undefined;
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
:root {
|
||||
--text: #fafafa;
|
||||
--text-dim: #a1a1aa;
|
||||
--page-bg: #09090b;
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { color-scheme: dark; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: var(--page-bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
text-align: center;
|
||||
}
|
||||
main {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 28px;
|
||||
line-height: 1.15;
|
||||
font-weight: 650;
|
||||
color: var(--text);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.7;
|
||||
color: var(--text-dim);
|
||||
font-size: 15px;
|
||||
}
|
||||
.details {
|
||||
margin-top: 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="logo">${LOGO_SVG}</div>
|
||||
<h1>${heading}</h1>
|
||||
<p>${message}</p>
|
||||
${details ? `<div class="details">${details}</div>` : ""}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function oauthSuccessHtml(message: string): string {
|
||||
return renderPage({
|
||||
title: "Authentication successful",
|
||||
heading: "Authentication successful",
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
export function oauthErrorHtml(message: string, details?: string): string {
|
||||
return renderPage({
|
||||
title: "Authentication failed",
|
||||
heading: "Authentication failed",
|
||||
message,
|
||||
details,
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,7 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version
|
||||
});
|
||||
}
|
||||
|
||||
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.js";
|
||||
import { generatePKCE } from "./pkce.js";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.js";
|
||||
|
||||
@@ -27,18 +28,6 @@ const REDIRECT_URI = "http://localhost:1455/auth/callback";
|
||||
const SCOPE = "openid profile email offline_access";
|
||||
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
|
||||
|
||||
const SUCCESS_HTML = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Authentication successful</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Authentication successful. Return to your terminal to continue.</p>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number };
|
||||
type TokenFailure = { type: "failed" };
|
||||
type TokenResult = TokenSuccess | TokenFailure;
|
||||
@@ -229,27 +218,31 @@ function startLocalOAuthServer(state: string): Promise<OAuthServerInfo> {
|
||||
const url = new URL(req.url || "", "http://localhost");
|
||||
if (url.pathname !== "/auth/callback") {
|
||||
res.statusCode = 404;
|
||||
res.end("Not found");
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(oauthErrorHtml("Callback route not found."));
|
||||
return;
|
||||
}
|
||||
if (url.searchParams.get("state") !== state) {
|
||||
res.statusCode = 400;
|
||||
res.end("State mismatch");
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(oauthErrorHtml("State mismatch."));
|
||||
return;
|
||||
}
|
||||
const code = url.searchParams.get("code");
|
||||
if (!code) {
|
||||
res.statusCode = 400;
|
||||
res.end("Missing authorization code");
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(oauthErrorHtml("Missing authorization code."));
|
||||
return;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(SUCCESS_HTML);
|
||||
res.end(oauthSuccessHtml("OpenAI authentication completed. You can close this window."));
|
||||
settleWait?.({ code });
|
||||
} catch {
|
||||
res.statusCode = 500;
|
||||
res.end("Internal error");
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(oauthErrorHtml("Internal error while processing OAuth callback."));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user