Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions src/sync/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,35 @@ export interface CallbackResult {
port: number
}

/**
* Themed HTML for the OAuth callback landing page.
*
* Matches the CodeBurn dashboard theme (dash/src/index.css): warm paper
* surfaces, ink text, forest-green accent. Everything is inline — this page
* is served once from a throwaway localhost server, so it must not depend
* on network fonts, external CSS, or dashboard assets.
*/
export function renderCallbackPage(ok: boolean, title: string, message: string): string {
const accent = ok ? '#1f8a5b' : '#c8541f' // --primary / --chart-5 (terracotta)
const mark = ok ? '✓' : '✕' // ✓ / ✕
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CodeBurn — ${title}</title>
</head>
<body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#e9e6de;color:#16181d;font-family:'Geist',system-ui,-apple-system,'Segoe UI',sans-serif;letter-spacing:-0.006em;">
<main style="background:#ffffff;border:1px solid rgba(23,27,32,0.08);border-radius:8px;padding:48px 56px;max-width:420px;text-align:center;box-shadow:0 1px 3px rgba(23,27,32,0.06);">
<div style="width:56px;height:56px;margin:0 auto 20px;border-radius:50%;background:${accent};color:#ffffff;font-size:28px;line-height:56px;font-weight:700;">${mark}</div>
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:#2c5242;">${title}</h1>
<p style="margin:0;font-size:15px;color:#5d626b;line-height:1.5;">${message}</p>
<p style="margin:28px 0 0;font-size:12px;color:#8a857c;">CodeBurn <span style="color:${accent};">&bull;</span> local sync login</p>
</main>
</body>
</html>`
}

export function startCallbackServer(
expectedState: string,
timeoutMs: number = 300_000, // 5 minutes
Expand Down Expand Up @@ -179,8 +208,8 @@ export function startCallbackServer(
// Connection: close on every response — the callback server is
// single-purpose and must never leave pooled keep-alive sockets behind.
if (error) {
res.writeHead(400, { 'Content-Type': 'text/html', 'Connection': 'close' })
res.end('<html><body><h1>Login failed</h1><p>You can close this tab.</p></body></html>')
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' })
res.end(renderCallbackPage(false, 'Login failed', 'The identity provider rejected the login. You can close this tab and retry from the terminal.'))
clearTimeout(timer)
shutdown()
reject(new AuthError(`IdP returned error: ${error}`))
Expand All @@ -199,8 +228,8 @@ export function startCallbackServer(
return
}

res.writeHead(200, { 'Content-Type': 'text/html', 'Connection': 'close' })
res.end('<html><body><h1>✓ Login successful</h1><p>You can close this tab.</p></body></html>')
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' })
res.end(renderCallbackPage(true, 'Login successful', 'CodeBurn sync is now authenticated. You can close this tab and return to the terminal.'))
clearTimeout(timer)
shutdown()
resolve({ code, port: resolvedPort })
Expand Down
199 changes: 110 additions & 89 deletions src/sync/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
import type { Command } from 'commander'
import { randomBytes } from 'crypto'

import { fetchDiscoveryDoc } from './discovery.js'
import { fetchDiscoveryDoc, DiscoveryError } from './discovery.js'
import {
AuthError,
fetchOidcConfig,
generatePkce,
buildAuthUrl,
Expand All @@ -33,100 +34,120 @@ export function registerSyncCommands(program: Command): void {
.command('setup <url>')
.description('Configure sync with a remote endpoint (one-time)')
.action(async (url: string) => {
const baseUrl = url.replace(/\/$/, '')
process.stderr.write(`Fetching discovery doc from ${baseUrl}...\n`)

// 1. Fetch codeburn discovery doc
const discovery = await fetchDiscoveryDoc(baseUrl)
process.stderr.write(` Issuer: ${discovery.issuer}\n`)
process.stderr.write(` Client: ${discovery.client_id}\n`)

// 2. Fetch OIDC configuration from the issuer
const oidc = await fetchOidcConfig(discovery.issuer)
process.stderr.write(` Auth endpoint: ${oidc.authorization_endpoint}\n`)

// 3. Resolve scopes
const scopes = resolveScopes(discovery.scopes, oidc.scopes_supported)

// 4. Generate PKCE
const pkce = generatePkce()
const state = randomBytes(16).toString('hex')

// 5. Start callback server — await the actually-bound port (port
// fallback means it may not be the first in CALLBACK_PORTS)
const { promise: callbackPromise, ready } = startCallbackServer(state)
const port = await ready
const redirectUri = `http://127.0.0.1:${port}/callback`

// 6. Build auth URL and open browser
const authUrl = buildAuthUrl({
authorization_endpoint: oidc.authorization_endpoint,
client_id: discovery.client_id,
redirect_uri: redirectUri,
scopes,
state,
pkce,
})

process.stderr.write(`\nOpening browser for login...\n`)
process.stderr.write(`If the browser doesn't open, visit:\n ${authUrl}\n\n`)

// Open browser (best-effort, platform-specific).
// execFileSync with args array — authUrl comes from the remote discovery
// doc so it must never be shell-interpolated. Scheme is also validated.
try {
if (!/^https:\/\//.test(authUrl)) {
throw new Error('auth URL must be https')
const baseUrl = url.replace(/\/$/, '')
process.stderr.write(`Fetching discovery doc from ${baseUrl}...\n`)

// 1. Fetch codeburn discovery doc
const discovery = await fetchDiscoveryDoc(baseUrl)
process.stderr.write(` Issuer: ${discovery.issuer}\n`)
process.stderr.write(` Client: ${discovery.client_id}\n`)

// 2. Fetch OIDC configuration from the issuer
const oidc = await fetchOidcConfig(discovery.issuer)
process.stderr.write(` Auth endpoint: ${oidc.authorization_endpoint}\n`)

// 3. Resolve scopes
const scopes = resolveScopes(discovery.scopes, oidc.scopes_supported)

// 4. Generate PKCE
const pkce = generatePkce()
const state = randomBytes(16).toString('hex')

// 5. Start callback server — await the actually-bound port (port
// fallback means it may not be the first in CALLBACK_PORTS)
const { promise: callbackPromise, ready } = startCallbackServer(state)
// If all ports are in use, BOTH `ready` and `callbackPromise` reject.
// Mark callbackPromise as handled so the second rejection can't crash
// the process as an unhandled rejection while we're throwing from
// `await ready`. The later `await callbackPromise` still throws.
callbackPromise.catch(() => {})
const port = await ready
const redirectUri = `http://127.0.0.1:${port}/callback`

// 6. Build auth URL and open browser
const authUrl = buildAuthUrl({
authorization_endpoint: oidc.authorization_endpoint,
client_id: discovery.client_id,
redirect_uri: redirectUri,
scopes,
state,
pkce,
})

process.stderr.write(`\nOpening browser for login...\n`)
process.stderr.write(`If the browser doesn't open, visit:\n ${authUrl}\n\n`)

// Open browser (best-effort, platform-specific).
// execFileSync with args array — authUrl comes from the remote discovery
// doc so it must never be shell-interpolated. Scheme is also validated.
try {
if (!/^https:\/\//.test(authUrl)) {
throw new Error('auth URL must be https')
}
const { execFileSync } = await import('child_process')
if (process.platform === 'darwin') {
execFileSync('open', [authUrl], { stdio: 'ignore' })
} else if (process.platform === 'win32') {
// Do NOT use `cmd /c start` here: cmd.exe treats `&` as a command
// separator, so the auth URL's query string gets truncated at the
// first parameter and the IdP sees a bare /authorize request.
// rundll32's FileProtocolHandler receives the URL as a plain
// process argument with no shell parsing, so `&` survives intact.
execFileSync('rundll32', ['url.dll,FileProtocolHandler', authUrl], { stdio: 'ignore' })
} else {
execFileSync('xdg-open', [authUrl], { stdio: 'ignore' })
}
} catch {
// Browser open failed — user sees the URL above
}
const { execFileSync } = await import('child_process')
if (process.platform === 'darwin') {
execFileSync('open', [authUrl], { stdio: 'ignore' })
} else if (process.platform === 'win32') {
// `start` is a cmd builtin; empty first arg is the window title
execFileSync('cmd', ['/c', 'start', '', authUrl], { stdio: 'ignore' })
} else {
execFileSync('xdg-open', [authUrl], { stdio: 'ignore' })

// 7. Wait for callback
process.stderr.write(`Waiting for login (5 min timeout)...\n`)
const callback = await callbackPromise

// 8. Exchange code for tokens
const tokenRedirectUri = `http://127.0.0.1:${callback.port}/callback`
const tokens = await exchangeCode(
oidc.token_endpoint,
callback.code,
pkce.code_verifier,
tokenRedirectUri,
discovery.client_id,
)

if (!tokens.refresh_token) {
process.stderr.write(`Warning: IdP did not return a refresh token. You may need to re-authenticate frequently.\n`)
}
} catch {
// Browser open failed — user sees the URL above
}

// 7. Wait for callback
process.stderr.write(`Waiting for login (5 min timeout)...\n`)
const callback = await callbackPromise

// 8. Exchange code for tokens
const tokenRedirectUri = `http://127.0.0.1:${callback.port}/callback`
const tokens = await exchangeCode(
oidc.token_endpoint,
callback.code,
pkce.code_verifier,
tokenRedirectUri,
discovery.client_id,
)

if (!tokens.refresh_token) {
process.stderr.write(`Warning: IdP did not return a refresh token. You may need to re-authenticate frequently.\n`)
}
// 9. Store credentials
const store = createCredentialStore()
if (tokens.refresh_token) {
store.store(tokens.refresh_token)
}

// 9. Store credentials
const store = createCredentialStore()
if (tokens.refresh_token) {
store.store(tokens.refresh_token)
}
// 10. Write config
writeSyncConfig({
baseUrl,
clientId: discovery.client_id,
tracesPath: discovery.traces_path,
issuer: discovery.issuer,
})

// 10. Write config
writeSyncConfig({
baseUrl,
clientId: discovery.client_id,
tracesPath: discovery.traces_path,
issuer: discovery.issuer,
})

process.stderr.write(`\n✓ Sync configured successfully.\n`)
process.stderr.write(` Endpoint: ${baseUrl}\n`)
process.stderr.write(` Token stored in: ${store.method()}\n`)
process.stderr.write(`\nRun \`codeburn sync push\` to send telemetry data.\n`)
process.stderr.write(`\n✓ Sync configured successfully.\n`)
process.stderr.write(` Endpoint: ${baseUrl}\n`)
process.stderr.write(` Token stored in: ${store.method()}\n`)
process.stderr.write(`\nRun \`codeburn sync push\` to send telemetry data.\n`)
} catch (err) {
// Known errors (login timeout, port exhaustion, discovery failures)
// get a clean one-line message instead of a raw Node crash dump.
if (err instanceof AuthError || err instanceof DiscoveryError) {
process.stderr.write(`\nError: ${err.message}\n`)
} else {
process.stderr.write(`\nError: ${(err as Error).stack ?? (err as Error).message}\n`)
}
process.exit(1)
}
})

// --- status ---
Expand Down
Loading