From dcbf037925bf48b94bad74fe0a29ff2d605e9420 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 13 Mar 2025 14:12:55 +0100 Subject: [PATCH 1/3] feat: use new login scheme which supports Safari --- packages/cli/commands/auth.ts | 12 +-- packages/cli/utils/auth.ts | 185 ++++++++++++++++++-------------- packages/cli/utils/constants.ts | 9 +- 3 files changed, 118 insertions(+), 88 deletions(-) diff --git a/packages/cli/commands/auth.ts b/packages/cli/commands/auth.ts index 55882a04..beb4665c 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/utils/auth.ts b/packages/cli/utils/auth.ts index 7f4d75e7..eb549498 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -1,4 +1,6 @@ +import crypto from "crypto"; import http from "http"; +import chalk from "chalk"; import open from "open"; import { authStore } from "../stores/auth.js"; @@ -6,97 +8,121 @@ import { configStore } from "../stores/config.js"; import { loginUrl } from "./constants.js"; -function corsHeaders(baseUrl: string): Record { - return { - "Access-Control-Allow-Origin": baseUrl, - "Access-Control-Allow-Methods": "GET", - "Access-Control-Allow-Headers": "Authorization", - }; -} - -export async function authenticateUser(baseUrl: string) { - return new Promise((resolve, reject) => { - let isResolved = false; +const successUrl = (baseUrl: string) => `${baseUrl}/cli-login/success`; +const errorUrl = (baseUrl: string, error: string) => + `${baseUrl}/cli-login/error?error=${error}`; - const server = http.createServer(async (req, res) => { - const url = new URL(req.url ?? "/", "http://127.0.0.1"); - const headers = corsHeaders(baseUrl); +interface waitForAccessToken { + accessToken: string; + expiresAt: Date; +} - // Ensure we don't process requests after resolution - if (isResolved) { - res.writeHead(503, headers).end(); - return; - } +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; + }); - if (url.pathname !== "/cli-login") { - res.writeHead(404).end("Invalid path"); - cleanupAndReject(new Error("Could not authenticate: Invalid path")); - return; - } + // 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 30 seconds")); + }, 30000); + + function cleanupAndReject(error: Error) { + cleanup(); + reject(error); + } - // Handle preflight request - if (req.method === "OPTIONS") { - res.writeHead(200, headers); - res.end(); - return; - } + function cleanup() { + clearTimeout(timeout); + server.close(); + server.closeAllConnections(); + } - if (!req.headers.authorization?.startsWith("Bearer ")) { - res.writeHead(400, headers).end("Could not authenticate"); - cleanupAndReject(new Error("Could not authenticate")); - return; - } + const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); - 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 (url.pathname !== "/cli-login") { + res.writeHead(404).end("Invalid path"); + cleanupAndReject(new Error("Could not authenticate: Invalid path")); + return; + } - const timeout = setTimeout(() => { - cleanupAndReject(new Error("Authentication timed out after 60 seconds")); - }, 60000); + const fullUrl = new URL(`http://localhost${req.url}`); - function cleanupAndResolve(token: string) { - if (isResolved) return; - isResolved = true; - cleanup(); - resolve(token); - } + const code = fullUrl.searchParams.get("code"); - function cleanupAndReject(error: Error) { - if (isResolved) return; - isResolved = true; - cleanup(); - reject(error); + if (!code) { + res.writeHead(400).end("Could not authenticate"); + cleanupAndReject(new Error("Could not authenticate: no code provided")); + return; } - function cleanup() { - clearTimeout(timeout); - server.close(); - // Force-close any remaining connections - server.getConnections((err, count) => { - if (err || count === 0) return; - server.closeAllConnections(); - }); - } + const response = await fetch(`${apiUrl}/oauth/cli/access-token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + code, + codeVerifier, + }), + }); - server.listen(); - const address = server.address(); - if (address && typeof address === "object") { - const port = address.port; - void open(loginUrl(baseUrl, port)); + 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), + }); }); + + server.listen(); + const address = server.address(); + if (address == null || typeof address !== "object") { + throw new Error("Could not start server"); + } + + const port = address.port; + const browserUrl = loginUrl(apiUrl, port, codeChallenge); + + console.log( + `Opened web browser to facilitate login: ${chalk.cyan(browserUrl)}`, + ); + + void open(browserUrl); + + return promise; } export async function authRequest>( @@ -110,7 +136,8 @@ 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); } @@ -133,7 +160,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/constants.ts b/packages/cli/utils/constants.ts index 8d00b7bb..654b8cdc 100644 --- a/packages/cli/utils/constants.ts +++ b/packages/cli/utils/constants.ts @@ -14,6 +14,9 @@ export const DEFAULT_TYPES_OUTPUT = join("gen", "features.ts"); export const chalkBrand = chalk.hex("#847CFB"); -export const loginUrl = (baseUrl: string, localPort: number) => - `${baseUrl}/login?redirect_url=` + - encodeURIComponent("/cli-login?port=" + localPort); +export const loginUrl = ( + baseUrl: string, + localPort: number, + codeChallenge: string, +) => + `${baseUrl}/oauth/cli/authorize?port=${localPort}&codeChallenge=${codeChallenge}`; From 06087495f127399f8e8c07309d2ba9fb7255cc44 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 13 Mar 2025 19:36:48 +0100 Subject: [PATCH 2/3] PR review --- packages/cli/utils/auth.ts | 14 ++++---------- packages/cli/utils/constants.ts | 7 ------- packages/cli/utils/path.ts | 11 +++++++++++ 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/cli/utils/auth.ts b/packages/cli/utils/auth.ts index eb549498..8fa03f1d 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -6,11 +6,7 @@ import open from "open"; import { authStore } from "../stores/auth.js"; import { configStore } from "../stores/config.js"; -import { loginUrl } from "./constants.js"; - -const successUrl = (baseUrl: string) => `${baseUrl}/cli-login/success`; -const errorUrl = (baseUrl: string, error: string) => - `${baseUrl}/cli-login/error?error=${error}`; +import { errorUrl, loginUrl, successUrl } from "./path.js"; interface waitForAccessToken { accessToken: string; @@ -36,8 +32,8 @@ export async function waitForAccessToken(baseUrl: string, apiUrl: string) { .replace(/\//g, "_"); const timeout = setTimeout(() => { - cleanupAndReject(new Error("Authentication timed out after 30 seconds")); - }, 30000); + cleanupAndReject(new Error("Authentication timed out after 60 seconds")); + }, 60000); function cleanupAndReject(error: Error) { cleanup(); @@ -59,9 +55,7 @@ export async function waitForAccessToken(baseUrl: string, apiUrl: string) { return; } - const fullUrl = new URL(`http://localhost${req.url}`); - - const code = fullUrl.searchParams.get("code"); + const code = url.searchParams.get("code"); if (!code) { res.writeHead(400).end("Could not authenticate"); diff --git a/packages/cli/utils/constants.ts b/packages/cli/utils/constants.ts index a7bbfcbe..844d4174 100644 --- a/packages/cli/utils/constants.ts +++ b/packages/cli/utils/constants.ts @@ -10,10 +10,3 @@ export const SCHEMA_URL = `https://unpkg.com/@bucketco/cli@latest/schema.json`; export const DEFAULT_BASE_URL = "https://app.bucket.co"; export const DEFAULT_API_URL = `${DEFAULT_BASE_URL}/api`; export const DEFAULT_TYPES_OUTPUT = join("gen", "features.d.ts"); - -export const loginUrl = ( - baseUrl: string, - localPort: number, - codeChallenge: string, -) => - `${baseUrl}/oauth/cli/authorize?port=${localPort}&codeChallenge=${codeChallenge}`; diff --git a/packages/cli/utils/path.ts b/packages/cli/utils/path.ts index a608cf1d..07b72b6c 100644 --- a/packages/cli/utils/path.ts +++ b/packages/cli/utils/path.ts @@ -1,3 +1,14 @@ 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}`; From 5b203c6743c24b5ce0d292546959a4e071879907 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 14 Mar 2025 11:52:38 +0100 Subject: [PATCH 3/3] chore(cli): 0.2.4 --- packages/cli/package.json | 2 +- packages/cli/utils/auth.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) 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 8fa03f1d..be81d123 100644 --- a/packages/cli/utils/auth.ts +++ b/packages/cli/utils/auth.ts @@ -134,7 +134,6 @@ export async function authRequest>( 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]) => {