Improve OAuth login UI with consistent dialog component

- Add LoginDialogComponent with proper borders (top/bottom DynamicBorder)
- Refactor all OAuth providers to use racing approach (browser callback vs manual paste)
- Add onEscape handler to Input component for cancellation
- Add abortable sleep for GitHub Copilot polling (instant cancel on Escape)
- Show OS-specific click hint (Cmd+click on macOS, Ctrl+click elsewhere)
- Clear content between login phases (fixes GitHub Copilot two-phase flow)
- Use InteractiveMode's showStatus/showError for result messages
- Reorder providers: Anthropic, ChatGPT, GitHub Copilot, Gemini CLI, Antigravity
This commit is contained in:
Mario Zechner 2026-01-05 19:58:44 +01:00
parent 05b9d55656
commit 9b12719ab1
9 changed files with 550 additions and 200 deletions

View file

@ -25,23 +25,21 @@ const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_URL = "https://oauth2.googleapis.com/token";
const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
type CallbackServerInfo = {
server: Server;
cancelWait: () => void;
waitForCode: () => Promise<{ code: string; state: string } | null>;
};
/**
* Start a local HTTP server to receive the OAuth callback
*/
async function startCallbackServer(): Promise<{
server: Server;
getCode: () => Promise<{ code: string; state: string }>;
}> {
async function startCallbackServer(): Promise<CallbackServerInfo> {
const { createServer } = await import("http");
return new Promise((resolve, reject) => {
let codeResolve: (value: { code: string; state: string }) => void;
let codeReject: (error: Error) => void;
const codePromise = new Promise<{ code: string; state: string }>((res, rej) => {
codeResolve = res;
codeReject = rej;
});
let result: { code: string; state: string } | null = null;
let cancelled = false;
const server = createServer((req, res) => {
const url = new URL(req.url || "", `http://localhost:8085`);
@ -56,7 +54,6 @@ async function startCallbackServer(): Promise<{
res.end(
`<html><body><h1>Authentication Failed</h1><p>Error: ${error}</p><p>You can close this window.</p></body></html>`,
);
codeReject(new Error(`OAuth error: ${error}`));
return;
}
@ -65,13 +62,12 @@ async function startCallbackServer(): Promise<{
res.end(
`<html><body><h1>Authentication Successful</h1><p>You can close this window and return to the terminal.</p></body></html>`,
);
codeResolve({ code, state });
result = { 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>`,
);
codeReject(new Error("Missing code or state in callback"));
}
} else {
res.writeHead(404);
@ -86,12 +82,40 @@ async function startCallbackServer(): Promise<{
server.listen(8085, "127.0.0.1", () => {
resolve({
server,
getCode: () => codePromise,
cancelWait: () => {
cancelled = true;
},
waitForCode: async () => {
const sleep = () => new Promise((r) => setTimeout(r, 100));
while (!result && !cancelled) {
await sleep();
}
return result;
},
});
});
});
}
/**
* Parse redirect URL to extract code and state
*/
function parseRedirectUrl(input: string): { code?: string; state?: string } {
const value = input.trim();
if (!value) return {};
try {
const url = new URL(value);
return {
code: url.searchParams.get("code") ?? undefined,
state: url.searchParams.get("state") ?? undefined,
};
} catch {
// Not a URL, return empty
return {};
}
}
interface LoadCodeAssistPayload {
cloudaicompanionProject?: string;
currentTier?: { id?: string };
@ -257,16 +281,21 @@ export async function refreshGoogleCloudToken(refreshToken: string, projectId: s
*
* @param onAuth - Callback with URL and optional instructions
* @param onProgress - Optional progress callback
* @param onManualCodeInput - Optional promise that resolves with user-pasted redirect URL.
* Races with browser callback - whichever completes first wins.
*/
export async function loginGeminiCli(
onAuth: (info: { url: string; instructions?: string }) => void,
onProgress?: (message: string) => void,
onManualCodeInput?: () => Promise<string>,
): Promise<OAuthCredentials> {
const { verifier, challenge } = await generatePKCE();
// Start local server for callback
onProgress?.("Starting local server for OAuth callback...");
const { server, getCode } = await startCallbackServer();
const server = await startCallbackServer();
let code: string | undefined;
try {
// Build authorization URL
@ -287,16 +316,75 @@ export async function loginGeminiCli(
// Notify caller with URL to open
onAuth({
url: authUrl,
instructions: "Complete the sign-in in your browser. The callback will be captured automatically.",
instructions: "Complete the sign-in in your browser.",
});
// Wait for the callback
// Wait for the callback, racing with manual input if provided
onProgress?.("Waiting for OAuth callback...");
const { code, state } = await getCode();
// Verify state matches
if (state !== verifier) {
throw new Error("OAuth state mismatch - possible CSRF attack");
if (onManualCodeInput) {
// Race between browser callback and manual input
let manualInput: string | undefined;
let manualError: Error | undefined;
const manualPromise = onManualCodeInput()
.then((input) => {
manualInput = input;
server.cancelWait();
})
.catch((err) => {
manualError = err instanceof Error ? err : new Error(String(err));
server.cancelWait();
});
const result = await server.waitForCode();
// If manual input was cancelled, throw that error
if (manualError) {
throw manualError;
}
if (result?.code) {
// Browser callback won - verify state
if (result.state !== verifier) {
throw new Error("OAuth state mismatch - possible CSRF attack");
}
code = result.code;
} else if (manualInput) {
// Manual input won
const parsed = parseRedirectUrl(manualInput);
if (parsed.state && parsed.state !== verifier) {
throw new Error("OAuth state mismatch - possible CSRF attack");
}
code = parsed.code;
}
// If still no code, wait for manual promise and try that
if (!code) {
await manualPromise;
if (manualError) {
throw manualError;
}
if (manualInput) {
const parsed = parseRedirectUrl(manualInput);
if (parsed.state && parsed.state !== verifier) {
throw new Error("OAuth state mismatch - possible CSRF attack");
}
code = parsed.code;
}
}
} else {
// Original flow: just wait for callback
const result = await server.waitForCode();
if (result?.code) {
if (result.state !== verifier) {
throw new Error("OAuth state mismatch - possible CSRF attack");
}
code = result.code;
}
}
if (!code) {
throw new Error("No authorization code received");
}
// Exchange code for tokens
@ -351,6 +439,6 @@ export async function loginGeminiCli(
return credentials;
} finally {
server.close();
server.server.close();
}
}