diff --git a/packages/cli/src/cli/commands/auth/login-flow.ts b/packages/cli/src/cli/commands/auth/login-flow.ts index 269fb187..8ddac0b2 100644 --- a/packages/cli/src/cli/commands/auth/login-flow.ts +++ b/packages/cli/src/cli/commands/auth/login-flow.ts @@ -15,6 +15,7 @@ import { writeAuth, } from "@/core/auth/index.js"; import { AuthExpiredError, InternalError } from "@/core/errors.js"; +import { isHeadlessEnv, loginViaLoopback } from "./loopback-flow.js"; async function generateAndDisplayDeviceCode( log: Logger, @@ -85,6 +86,19 @@ async function waitForAuthentication( return tokenResponse; } +async function loginViaDeviceCode( + log: Logger, + runTask: RunTaskFn, +): Promise { + const deviceCodeResponse = await generateAndDisplayDeviceCode(log, runTask); + return waitForAuthentication( + deviceCodeResponse.deviceCode, + deviceCodeResponse.expiresIn, + deviceCodeResponse.interval, + runTask, + ); +} + async function saveAuthData( response: TokenResponse, userInfo: UserInfoResponse, @@ -100,22 +114,41 @@ async function saveAuthData( }); } +interface LoginOptions { + /** Force device-code flow, skipping the loopback browser flow. */ + deviceCode?: boolean; +} + /** - * Execute the login flow (device code authentication). - * This function is separate from the command to avoid circular dependencies. + * Execute the login flow. + * + * Default path: loopback (RFC 8252) with PKCE — opens browser, awaits localhost + * callback. Falls back to device code on headless environments (SSH, CI, no + * DISPLAY) or if the loopback flow fails for any reason. */ -export async function login({ - log, - runTask, -}: CLIContext): Promise { - const deviceCodeResponse = await generateAndDisplayDeviceCode(log, runTask); +export async function login( + { log, runTask }: CLIContext, + options: LoginOptions = {}, +): Promise { + let token: TokenResponse | undefined; - const token = await waitForAuthentication( - deviceCodeResponse.deviceCode, - deviceCodeResponse.expiresIn, - deviceCodeResponse.interval, - runTask, - ); + const useDeviceCode = options.deviceCode || isHeadlessEnv(); + + if (!useDeviceCode) { + try { + token = await loginViaLoopback(log, runTask); + } catch (error) { + log.warn( + `Browser sign-in unavailable (${ + error instanceof Error ? error.message : String(error) + }). Falling back to device code.`, + ); + } + } + + if (!token) { + token = await loginViaDeviceCode(log, runTask); + } const userInfo = await getUserInfo(token.accessToken); diff --git a/packages/cli/src/cli/commands/auth/login.ts b/packages/cli/src/cli/commands/auth/login.ts index 860921ab..fb3d80c2 100644 --- a/packages/cli/src/cli/commands/auth/login.ts +++ b/packages/cli/src/cli/commands/auth/login.ts @@ -8,5 +8,10 @@ export function getLoginCommand(): Command { requireAppConfig: false, }) .description("Authenticate with Base44") + .option( + "--device-code", + "Use device code flow instead of opening a browser", + false, + ) .action(login); } diff --git a/packages/cli/src/cli/commands/auth/loopback-flow.ts b/packages/cli/src/cli/commands/auth/loopback-flow.ts new file mode 100644 index 00000000..3e5e3f8c --- /dev/null +++ b/packages/cli/src/cli/commands/auth/loopback-flow.ts @@ -0,0 +1,89 @@ +import type { Logger } from "@base44-cli/logger"; +import open from "open"; +import type { RunTaskFn } from "@/cli/utils/runTask.js"; +import { theme } from "@/cli/utils/theme.js"; +import { + buildAuthorizeUrl, + exchangeCodeForToken, + type TokenResponse, +} from "@/core/auth/index.js"; +import { startLoopbackServer } from "@/core/auth/loopback-server.js"; +import { generatePkcePair, generateState } from "@/core/auth/pkce.js"; + +const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; + +/** + * Returns true if the current environment can't reach a localhost callback + * (SSH, no DISPLAY on Linux, common CI signals). In these cases we skip the + * loopback flow and use device code instead. + */ +export function isHeadlessEnv(): boolean { + const env = process.env; + if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return true; + if (env.CI || env.CONTINUOUS_INTEGRATION) return true; + if (process.platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) { + return true; + } + return false; +} + +/** + * Loopback (RFC 8252) authorization-code-with-PKCE login. + * + * Caller is responsible for falling back to device code if this throws. + */ +export async function loginViaLoopback( + log: Logger, + runTask: RunTaskFn, +): Promise { + const server = await startLoopbackServer(); + const pkce = generatePkcePair(); + const state = generateState(); + + try { + const authorizeUrl = buildAuthorizeUrl({ + redirectUri: server.redirectUri, + state, + codeChallenge: pkce.codeChallenge, + }); + + log.info( + `Opening your browser to sign in.\nIf it doesn't open, visit: ${theme.styles.dim( + authorizeUrl, + )}`, + ); + + try { + await open(authorizeUrl); + } catch { + // Browser open failed — the user can still paste the URL manually. + } + + const { code } = await runTask( + "Waiting for browser sign-in...", + async () => server.waitForCallback(state, CALLBACK_TIMEOUT_MS), + { + successMessage: "Browser sign-in completed", + errorMessage: "Browser sign-in failed", + }, + ); + + const token = await runTask( + "Exchanging authorization code...", + async () => + exchangeCodeForToken({ + code, + redirectUri: server.redirectUri, + codeVerifier: pkce.codeVerifier, + }), + { + successMessage: "Authentication completed!", + errorMessage: "Token exchange failed", + }, + ); + + return token; + } finally { + server.close(); + } +} diff --git a/packages/cli/src/core/auth/api.ts b/packages/cli/src/core/auth/api.ts index 0065f3a6..c604c1a9 100644 --- a/packages/cli/src/core/auth/api.ts +++ b/packages/cli/src/core/auth/api.ts @@ -10,14 +10,17 @@ import { UserInfoSchema, } from "@/core/auth/schema.js"; import { oauthClient } from "@/core/clients/index.js"; +import { getBase44ApiUrl } from "@/core/config.js"; import { AUTH_CLIENT_ID } from "@/core/consts.js"; import { ApiError, SchemaValidationError } from "@/core/errors.js"; +const OAUTH_SCOPE = "apps:read apps:write offline"; + export async function generateDeviceCode(): Promise { const response = await oauthClient.post("oauth/device/code", { json: { client_id: AUTH_CLIENT_ID, - scope: "apps:read apps:write", + scope: OAUTH_SCOPE, }, throwHttpErrors: false, }); @@ -142,6 +145,70 @@ export async function renewAccessToken( return result.data; } +export function buildAuthorizeUrl(params: { + redirectUri: string; + state: string; + codeChallenge: string; +}): string { + const search = new URLSearchParams({ + response_type: "code", + client_id: AUTH_CLIENT_ID, + redirect_uri: params.redirectUri, + scope: OAUTH_SCOPE, + state: params.state, + code_challenge: params.codeChallenge, + code_challenge_method: "S256", + }); + return `${getBase44ApiUrl().replace(/\/$/, "")}/oauth/authorize?${search.toString()}`; +} + +export async function exchangeCodeForToken(params: { + code: string; + redirectUri: string; + codeVerifier: string; +}): Promise { + const searchParams = new URLSearchParams(); + searchParams.set("grant_type", "authorization_code"); + searchParams.set("code", params.code); + searchParams.set("redirect_uri", params.redirectUri); + searchParams.set("client_id", AUTH_CLIENT_ID); + searchParams.set("code_verifier", params.codeVerifier); + + const response = await oauthClient.post("oauth/token", { + body: searchParams.toString(), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + throwHttpErrors: false, + }); + + const json = await response.json(); + + if (!response.ok) { + const errorResult = OAuthErrorSchema.safeParse(json); + if (!errorResult.success) { + throw new ApiError(`Token exchange failed: ${response.statusText}`, { + statusCode: response.status, + }); + } + const { error, error_description } = errorResult.data; + throw new ApiError(error_description ?? `OAuth error: ${error}`, { + statusCode: response.status, + }); + } + + const result = TokenResponseSchema.safeParse(json); + + if (!result.success) { + throw new SchemaValidationError( + "Invalid token response from server", + result.error, + ); + } + + return result.data; +} + export async function getUserInfo( accessToken: string, ): Promise { diff --git a/packages/cli/src/core/auth/loopback-server.ts b/packages/cli/src/core/auth/loopback-server.ts new file mode 100644 index 00000000..ce2026d4 --- /dev/null +++ b/packages/cli/src/core/auth/loopback-server.ts @@ -0,0 +1,166 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import getPort from "get-port"; +import { ApiError, InternalError } from "@/core/errors.js"; + +const LOOPBACK_HOST = "127.0.0.1"; +const CALLBACK_PATH = "/callback"; + +const SUCCESS_HTML = ` +Base44 CLI + +

You're signed in.

You can close this tab and return to your terminal.

`; + +const ERROR_HTML = (msg: string) => ` +Base44 CLI + +

Sign-in failed

${msg}

`; + +interface LoopbackCallbackResult { + code: string; +} + +interface LoopbackServer { + port: number; + redirectUri: string; + /** Resolves when the OAuth provider redirects to /callback with a valid code. */ + waitForCallback( + expectedState: string, + timeoutMs: number, + ): Promise; + close(): void; +} + +export async function startLoopbackServer(): Promise { + const port = await getPort({ host: LOOPBACK_HOST }); + + let resolveCallback: ((result: LoopbackCallbackResult) => void) | null = null; + let rejectCallback: ((err: Error) => void) | null = null; + + const server: Server = createServer((req, res) => { + const url = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}:${port}`); + + if (url.pathname !== CALLBACK_PATH) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + return; + } + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + const errorDescription = url.searchParams.get("error_description"); + + if (error) { + const message = errorDescription ?? error; + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(ERROR_HTML(message)); + rejectCallback?.( + new ApiError(`Authorization failed: ${message}`, { statusCode: 400 }), + ); + return; + } + + // We compare against the server's currently-expected state. waitForCallback + // captures it in a closure; until waitForCallback is called, every callback + // is rejected as unsolicited. + if (!code || !state) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(ERROR_HTML("Missing code or state parameter.")); + rejectCallback?.( + new ApiError("OAuth callback missing code or state", { + statusCode: 400, + }), + ); + return; + } + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(SUCCESS_HTML); + + // Defer to waitForCallback to validate state and resolve. + pendingCallback = { code, state }; + if (resolveCallback) { + tryDeliverCallback(); + } + }); + + let pendingCallback: { code: string; state: string } | null = null; + let expectedState: string | null = null; + + function tryDeliverCallback(): void { + if (!pendingCallback || !expectedState) return; + const { code, state } = pendingCallback; + pendingCallback = null; + if (state !== expectedState) { + rejectCallback?.( + new ApiError("OAuth state mismatch — possible CSRF", { + statusCode: 400, + }), + ); + return; + } + resolveCallback?.({ code }); + } + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, LOOPBACK_HOST, () => { + server.removeListener("error", reject); + // Don't keep the event loop alive on our account. Once the login flow's + // promises settle and the server is closed, the process should exit + // naturally. Without this, a browser keep-alive connection holds the + // listening socket open and Node hangs after "Successfully logged in". + server.unref(); + resolve(); + }); + }); + + const actualPort = (server.address() as AddressInfo).port; + const redirectUri = `http://${LOOPBACK_HOST}:${actualPort}${CALLBACK_PATH}`; + + return { + port: actualPort, + redirectUri, + waitForCallback(state, timeoutMs) { + expectedState = state; + return new Promise((resolve, reject) => { + resolveCallback = resolve; + rejectCallback = reject; + + const timer = setTimeout(() => { + reject( + new InternalError( + `Timed out waiting for browser authentication after ${Math.round( + timeoutMs / 1000, + )}s`, + ), + ); + }, timeoutMs); + + const wrappedResolve = (r: LoopbackCallbackResult) => { + clearTimeout(timer); + resolve(r); + }; + const wrappedReject = (e: Error) => { + clearTimeout(timer); + reject(e); + }; + resolveCallback = wrappedResolve; + rejectCallback = wrappedReject; + + // A callback may have arrived before waitForCallback was called. + tryDeliverCallback(); + }); + }, + close() { + server.close(); + // server.close() only stops accepting new connections; existing + // keep-alive sockets persist until idle timeout. Force-close them so + // the process can exit promptly. + server.closeAllConnections?.(); + }, + }; +} diff --git a/packages/cli/src/core/auth/pkce.ts b/packages/cli/src/core/auth/pkce.ts new file mode 100644 index 00000000..5895f11f --- /dev/null +++ b/packages/cli/src/core/auth/pkce.ts @@ -0,0 +1,27 @@ +import { createHash, randomBytes } from "node:crypto"; + +interface PkcePair { + codeVerifier: string; + codeChallenge: string; + codeChallengeMethod: "S256"; +} + +function base64UrlEncode(buffer: Buffer): string { + return buffer + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +export function generatePkcePair(): PkcePair { + const codeVerifier = base64UrlEncode(randomBytes(32)); + const codeChallenge = base64UrlEncode( + createHash("sha256").update(codeVerifier).digest(), + ); + return { codeVerifier, codeChallenge, codeChallengeMethod: "S256" }; +} + +export function generateState(): string { + return base64UrlEncode(randomBytes(16)); +}