Skip to content

Commit 85e1186

Browse files
committed
refactor(auth): split 437-LOC flow.ts into focused modules
The audit's K item. flow.ts mixed OAuth orchestration, callback HTTP server, HTML response rendering, token exchange, and browser-open heuristics in one file. Split by responsibility: flow.ts (98 LOC) orchestration: runOAuthLogin + OAuthConfig + RunOAuthLoginOptions; re-exports the public surface so existing consumers (cli.ts, token-manager.ts, FirstRunSetup.tsx, tests) need no changes authorization-url.ts PKCE-aware authorization URL builder callback-server.ts awaitCallback + the success/error HTML the browser sees on redirect (private helpers renderResponse / escapeHtml stay private) token-exchange.ts exchangeCode / refreshAccessToken / revokeToken (private tokenToCredentials helper colocated) browser-open.ts openBrowser + isHeadlessSession + browserOpenCommand 741 tests still pass. No behavior change.
1 parent 226bd89 commit 85e1186

5 files changed

Lines changed: 364 additions & 350 deletions

File tree

src/auth/authorization-url.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { OAuthConfig } from "./flow.js";
2+
3+
export interface AuthorizationUrlParams {
4+
codeChallenge: string;
5+
state: string;
6+
redirectUri: string;
7+
}
8+
9+
/**
10+
* Build the URL we open in the user's browser. Every parameter is
11+
* URL-encoded — a space in the scope used to break the redirect in
12+
* the Go v1 (commit ac1dd56), and we don't want that bug to come
13+
* back unnoticed.
14+
*/
15+
export function buildAuthorizationUrl(config: OAuthConfig, params: AuthorizationUrlParams): string {
16+
const url = new URL(config.authorizationUrl);
17+
url.searchParams.set("response_type", "code");
18+
url.searchParams.set("client_id", config.clientId);
19+
url.searchParams.set("redirect_uri", params.redirectUri);
20+
url.searchParams.set("scope", config.scopes.join(" "));
21+
url.searchParams.set("state", params.state);
22+
url.searchParams.set("code_challenge", params.codeChallenge);
23+
url.searchParams.set("code_challenge_method", "S256");
24+
return url.toString();
25+
}

src/auth/browser-open.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { exec } from "node:child_process";
2+
3+
/**
4+
* Heuristic: is this process running somewhere that obviously can't
5+
* launch a GUI browser? We bail out of the auto-open attempt instead
6+
* of letting xdg-open ENOENT or `open` hang on a headless macOS box.
7+
*/
8+
export function isHeadlessSession(): boolean {
9+
const env = process.env;
10+
if (env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT) return true;
11+
if (process.platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) return true;
12+
return false;
13+
}
14+
15+
/** Best-effort browser open. Falls back to printing the URL on platforms we can't detect. */
16+
export async function openBrowser(url: string): Promise<void> {
17+
const command = browserOpenCommand(url);
18+
if (!command) {
19+
throw new Error(`unsupported platform ${process.platform}`);
20+
}
21+
await new Promise<void>((resolve, reject) => {
22+
exec(command, (err) => (err ? reject(err) : resolve()));
23+
});
24+
}
25+
26+
function browserOpenCommand(url: string): string | null {
27+
const escaped = url.replace(/"/g, '\\"');
28+
if (process.platform === "darwin") return `open "${escaped}"`;
29+
if (process.platform === "win32") return `start "" "${escaped}"`;
30+
if (process.platform === "linux") return `xdg-open "${escaped}"`;
31+
return null;
32+
}

src/auth/callback-server.ts

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import type { IncomingMessage, Server, ServerResponse } from "node:http";
2+
import { constantTimeEquals } from "./pkce.js";
3+
4+
export interface CallbackResult {
5+
code: string;
6+
state: string;
7+
}
8+
9+
/**
10+
* Spin a localhost HTTP server, listen for one /callback hit, return
11+
* the code+state. Validates the returned state against the supplied
12+
* value to defend against CSRF; treats anything else as an error.
13+
*
14+
* Resolves with { code, state } on success. Rejects on timeout, state
15+
* mismatch, or upstream error param.
16+
*/
17+
export function awaitCallback(server: Server, expectedState: string, timeoutMs: number): Promise<CallbackResult> {
18+
return new Promise((resolve, reject) => {
19+
let settled = false;
20+
const safeResolve = (value: CallbackResult): void => {
21+
if (settled) return;
22+
settled = true;
23+
clearTimeout(timer);
24+
resolve(value);
25+
};
26+
const safeReject = (err: Error): void => {
27+
if (settled) return;
28+
settled = true;
29+
clearTimeout(timer);
30+
reject(err);
31+
};
32+
33+
const timer = setTimeout(() => {
34+
server.close();
35+
safeReject(new Error(`OAuth callback timed out after ${Math.round(timeoutMs / 1000)}s`));
36+
}, timeoutMs);
37+
38+
server.on("request", (req: IncomingMessage, res: ServerResponse) => {
39+
const url = new URL(req.url ?? "/", "http://localhost");
40+
if (url.pathname !== "/callback") {
41+
res.statusCode = 404;
42+
res.end("Not Found. The codebase OAuth callback expects /callback.");
43+
return;
44+
}
45+
const error = url.searchParams.get("error");
46+
if (error) {
47+
const desc = url.searchParams.get("error_description") ?? "";
48+
renderResponse(res, false, `Sign-in failed: ${error}${desc ? ` — ${desc}` : ""}`);
49+
server.close();
50+
safeReject(new Error(`OAuth provider returned error: ${error}${desc ? ` — ${desc}` : ""}`));
51+
return;
52+
}
53+
const code = url.searchParams.get("code") ?? "";
54+
const state = url.searchParams.get("state") ?? "";
55+
if (!code) {
56+
renderResponse(res, false, "Sign-in failed: provider did not return a code.");
57+
server.close();
58+
safeReject(new Error("OAuth callback missing code parameter"));
59+
return;
60+
}
61+
if (!constantTimeEquals(state, expectedState)) {
62+
renderResponse(res, false, "Sign-in failed: state mismatch (possible CSRF).");
63+
server.close();
64+
safeReject(new Error("OAuth callback state mismatch"));
65+
return;
66+
}
67+
68+
renderResponse(res, true, "Signed in. You can close this tab.");
69+
server.close();
70+
safeResolve({ code, state });
71+
});
72+
73+
server.on("error", (err) => {
74+
safeReject(err);
75+
});
76+
});
77+
}
78+
79+
function renderResponse(res: ServerResponse, ok: boolean, message: string): void {
80+
const title = ok ? "Signed in" : "Sign-in failed";
81+
const accent = ok ? "#22c55e" : "#ef4444";
82+
const subtitle = ok ? "You can close this tab." : "Return to your terminal for details.";
83+
// Inline SVG logo + minimal CSS. No external deps so the page renders
84+
// before any network even on the CLI's localhost-only callback host.
85+
const html = `<!doctype html>
86+
<html lang="en">
87+
<head>
88+
<meta charset="utf-8" />
89+
<meta name="viewport" content="width=device-width, initial-scale=1" />
90+
<title>codebase · ${title}</title>
91+
<style>
92+
:root { color-scheme: light dark; }
93+
* { box-sizing: border-box; }
94+
html, body { margin: 0; padding: 0; }
95+
body {
96+
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
97+
background: #0a0a0b;
98+
color: #f1f1f3;
99+
min-height: 100vh;
100+
display: flex;
101+
align-items: center;
102+
justify-content: center;
103+
padding: 24px;
104+
}
105+
@media (prefers-color-scheme: light) {
106+
body { background: #fafafa; color: #0a0a0b; }
107+
.card { background: #fff; border-color: #e5e5ea; box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.06); }
108+
.subtitle { color: #6b6b73; }
109+
.hint { color: #6b6b73; }
110+
}
111+
.card {
112+
max-width: 440px;
113+
width: 100%;
114+
padding: 40px 36px 32px;
115+
border: 1px solid #1f1f23;
116+
border-radius: 16px;
117+
background: #131316;
118+
box-shadow: 0 1px 2px rgba(0,0,0,0.4), 0 12px 40px rgba(0,0,0,0.4);
119+
text-align: center;
120+
}
121+
.logo {
122+
width: 64px;
123+
height: 64px;
124+
margin: 0 auto 24px;
125+
display: block;
126+
}
127+
.logo rect { fill: currentColor; }
128+
.icon {
129+
width: 48px;
130+
height: 48px;
131+
margin: 0 auto 12px;
132+
border-radius: 999px;
133+
background: ${accent}22;
134+
color: ${accent};
135+
display: flex;
136+
align-items: center;
137+
justify-content: center;
138+
font-size: 28px;
139+
font-weight: 600;
140+
}
141+
h1 {
142+
font-size: 22px;
143+
font-weight: 600;
144+
margin: 0 0 4px;
145+
letter-spacing: -0.01em;
146+
}
147+
.subtitle {
148+
color: #a3a3ab;
149+
font-size: 14px;
150+
margin: 0 0 24px;
151+
}
152+
.message {
153+
background: rgba(255,255,255,0.04);
154+
border: 1px solid rgba(255,255,255,0.06);
155+
padding: 12px 14px;
156+
border-radius: 8px;
157+
font-size: 13px;
158+
text-align: center;
159+
white-space: pre-wrap;
160+
}
161+
@media (prefers-color-scheme: light) {
162+
.message { background: #f5f5f7; border-color: #e5e5ea; }
163+
}
164+
.hint {
165+
margin-top: 18px;
166+
font-size: 12px;
167+
color: #6b6b73;
168+
}
169+
.brand {
170+
font-size: 12px;
171+
color: #6b6b73;
172+
letter-spacing: 0.02em;
173+
text-transform: uppercase;
174+
margin-bottom: 8px;
175+
}
176+
</style>
177+
</head>
178+
<body>
179+
<div class="card">
180+
<svg class="logo" viewBox="7 6 6 7" shape-rendering="crispEdges" aria-hidden="true">
181+
<rect width="1" height="1" x="9" y="7" /><rect width="1" height="1" x="10" y="7" /><rect width="1" height="1" x="11" y="7" />
182+
<rect width="1" height="1" x="8" y="8" /><rect width="1" height="1" x="8" y="9" /><rect width="1" height="1" x="8" y="10" />
183+
<rect width="1" height="1" x="9" y="11" /><rect width="1" height="1" x="10" y="11" /><rect width="1" height="1" x="11" y="11" />
184+
</svg>
185+
<div class="brand">codebase</div>
186+
<div class="icon" aria-hidden="true">${ok ? "✓" : "!"}</div>
187+
<h1>${title}</h1>
188+
<p class="subtitle">${subtitle}</p>
189+
<div class="message">${escapeHtml(message)}</div>
190+
<p class="hint">${ok ? "Your terminal is signed in via codebase.design." : "Try <code>codebase auth login</code> again, or check your terminal for the error."}</p>
191+
</div>
192+
</body>
193+
</html>`;
194+
res.statusCode = ok ? 200 : 400;
195+
res.setHeader("Content-Type", "text/html; charset=utf-8");
196+
res.setHeader("Cache-Control", "no-store");
197+
res.end(html);
198+
}
199+
200+
function escapeHtml(s: string): string {
201+
return s
202+
.replace(/&/g, "&amp;")
203+
.replace(/</g, "&lt;")
204+
.replace(/>/g, "&gt;")
205+
.replace(/"/g, "&quot;")
206+
.replace(/'/g, "&#39;");
207+
}

0 commit comments

Comments
 (0)