diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index 47a4b823..b2e6ffc8 100644 --- a/packages/cli/commands/auth.ts +++ b/packages/cli/commands/auth.ts @@ -4,17 +4,17 @@ import ora from "ora"; import { authStore } from "../stores/auth.js"; import { configStore } from "../stores/config.js"; -import { authenticateUser } from "../utils/auth.js"; +import { waitForAccessToken } from "../utils/auth.js"; import { handleError } from "../utils/errors.js"; export const loginAction = async () => { - const baseUrl = configStore.getConfig("baseUrl"); - const spinner = ora(`Logging in to ${chalk.cyan(baseUrl)}...`).start(); + const { baseUrl, apiUrl } = configStore.getConfig(); + try { - await authenticateUser(baseUrl); - spinner.succeed(`Logged in to ${chalk.cyan(baseUrl)} successfully!`); + await waitForAccessToken(baseUrl, apiUrl); + console.log(`Logged in to ${chalk.cyan(baseUrl)} successfully!`); } catch (error) { - spinner.fail("Login failed."); + console.error("Login failed."); void handleError(error, "Login"); } }; diff --git a/packages/cli/package.json b/packages/cli/package.json index 27d8c139..a89bf006 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/cli", - "version": "0.2.3", + "version": "0.2.4", "packageManager": "yarn@4.1.1", "description": "CLI for Bucket service", "main": "./dist/index.js", diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index bd7217d7..be81d123 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -1,102 +1,122 @@ +import crypto from "crypto"; import http from "http"; +import chalk from "chalk"; import open from "open"; import { authStore } from "../stores/auth.js"; import { configStore } from "../stores/config.js"; -import { loginUrl } from "./path.js"; +import { errorUrl, loginUrl, successUrl } from "./path.js"; -function corsHeaders(baseUrl: string): Record { - return { - "Access-Control-Allow-Origin": baseUrl, - "Access-Control-Allow-Methods": "GET", - "Access-Control-Allow-Headers": "Authorization", - }; +interface waitForAccessToken { + accessToken: string; + expiresAt: Date; } -export async function authenticateUser(baseUrl: string) { - return new Promise((resolve, reject) => { - let isResolved = false; +export async function waitForAccessToken(baseUrl: string, apiUrl: string) { + let resolve: (args: waitForAccessToken) => void, + reject: (arg0: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); - const server = http.createServer(async (req, res) => { - const url = new URL(req.url ?? "/", "http://127.0.0.1"); - const headers = corsHeaders(baseUrl); + // PCKE code verifier and challenge + const codeVerifier = crypto.randomUUID(); + const codeChallenge = crypto + .createHash("sha256") + .update(codeVerifier) + .digest("base64") + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + + const timeout = setTimeout(() => { + cleanupAndReject(new Error("Authentication timed out after 60 seconds")); + }, 60000); + + function cleanupAndReject(error: Error) { + cleanup(); + reject(error); + } - // Ensure we don't process requests after resolution - if (isResolved) { - res.writeHead(503, headers).end(); - return; - } + function cleanup() { + clearTimeout(timeout); + server.close(); + server.closeAllConnections(); + } - if (url.pathname !== "/cli-login") { - res.writeHead(404).end("Invalid path"); - cleanupAndReject(new Error("Could not authenticate: Invalid path")); - return; - } + const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); - // Handle preflight request - if (req.method === "OPTIONS") { - res.writeHead(200, headers); - res.end(); - return; - } + if (url.pathname !== "/cli-login") { + res.writeHead(404).end("Invalid path"); + cleanupAndReject(new Error("Could not authenticate: Invalid path")); + return; + } - if (!req.headers.authorization?.startsWith("Bearer ")) { - res.writeHead(400, headers).end("Could not authenticate"); - cleanupAndReject(new Error("Could not authenticate")); - return; - } + const code = url.searchParams.get("code"); - const token = req.headers.authorization.slice("Bearer ".length); - headers["Content-Type"] = "application/json"; - res.writeHead(200, headers); - res.end(JSON.stringify({ result: "success" })); - - try { - await authStore.setToken(baseUrl, token); - cleanupAndResolve(token); - } catch (error) { - cleanupAndReject( - error instanceof Error ? error : new Error("Failed to store token"), - ); - } - }); + if (!code) { + res.writeHead(400).end("Could not authenticate"); + cleanupAndReject(new Error("Could not authenticate: no code provided")); + return; + } - const timeout = setTimeout(() => { - cleanupAndReject(new Error("Authentication timed out after 60 seconds")); - }, 60000); + const response = await fetch(`${apiUrl}/oauth/cli/access-token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code, + codeVerifier, + }), + }); - function cleanupAndResolve(token: string) { - if (isResolved) return; - isResolved = true; - cleanup(); - resolve(token); + if (!response.ok) { + res + .writeHead(302, { + location: errorUrl( + baseUrl, + "Could not authenticate: Unable to fetch access token", + ), + }) + .end("Could not authenticate"); + cleanupAndReject(new Error("Could not authenticate")); + return; } + res + .writeHead(302, { + location: successUrl(baseUrl), + }) + .end("Authentication successful"); + + const jsonResponse = await response.json(); + + cleanup(); + resolve({ + accessToken: jsonResponse.accessToken, + expiresAt: new Date(jsonResponse.expiresAt), + }); + }); - function cleanupAndReject(error: Error) { - if (isResolved) return; - isResolved = true; - cleanup(); - reject(error); - } + server.listen(); + const address = server.address(); + if (address == null || typeof address !== "object") { + throw new Error("Could not start server"); + } - function cleanup() { - clearTimeout(timeout); - server.close(); - // Force-close any remaining connections - server.getConnections((err, count) => { - if (err || count === 0) return; - server.closeAllConnections(); - }); - } + const port = address.port; + const browserUrl = loginUrl(apiUrl, port, codeChallenge); - server.listen(); - const address = server.address(); - if (address && typeof address === "object") { - const port = address.port; - void open(loginUrl(baseUrl, port)); - } - }); + console.log( + `Opened web browser to facilitate login: ${chalk.cyan(browserUrl)}`, + ); + + void open(browserUrl); + + return promise; } export async function authRequest>( @@ -110,10 +130,10 @@ export async function authRequest>( const token = authStore.getToken(baseUrl); if (!token) { - await authenticateUser(baseUrl); + const accessToken = await waitForAccessToken(baseUrl, apiUrl); + await authStore.setToken(baseUrl, accessToken.accessToken); return authRequest(url, options); } - const resolvedUrl = new URL(`${apiUrl}/${url}`); if (options?.params) { Object.entries(options.params).forEach(([key, value]) => { @@ -133,7 +153,7 @@ export async function authRequest>( if (response.status === 401) { await authStore.setToken(baseUrl, undefined); if (retryCount < 1) { - await authenticateUser(baseUrl); + await waitForAccessToken(baseUrl, apiUrl); return authRequest(url, options, retryCount + 1); } } diff --git a/packages/cli/utils/path.ts b/packages/cli/utils/path.ts index 5bade673..ccb22f17 100644 --- a/packages/cli/utils/path.ts +++ b/packages/cli/utils/path.ts @@ -13,14 +13,21 @@ export function stripTrailingSlash(str: T): T { return str?.endsWith("/") ? (str.slice(0, -1) as T) : str; } +export const successUrl = (baseUrl: string) => `${baseUrl}/cli-login/success`; +export const errorUrl = (baseUrl: string, error: string) => + `${baseUrl}/cli-login/error?error=${error}`; + +export const loginUrl = ( + baseUrl: string, + localPort: number, + codeChallenge: string, +) => + `${baseUrl}/oauth/cli/authorize?port=${localPort}&codeChallenge=${codeChallenge}`; + export const baseUrlSuffix = (baseUrl: string) => { return baseUrl !== DEFAULT_BASE_URL ? ` at ${chalk.cyan(baseUrl)}` : ""; }; -export const loginUrl = (baseUrl: string, localPort: number) => - `${baseUrl}/login?redirect_url=` + - encodeURIComponent("/cli-login?port=" + localPort); - export function environmentUrl(baseUrl: string, environment: UrlArgs): string { return `${baseUrl}/envs/${slug(environment)}`; }