Skip to content
Merged
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
12 changes: 6 additions & 6 deletions packages/cli/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
};
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
182 changes: 101 additions & 81 deletions packages/cli/utils/auth.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
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<string>((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<waitForAccessToken>((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<T = Record<string, unknown>>(
Expand All @@ -110,10 +130,10 @@ export async function authRequest<T = Record<string, unknown>>(
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]) => {
Expand All @@ -133,7 +153,7 @@ export async function authRequest<T = Record<string, unknown>>(
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);
}
}
Expand Down
15 changes: 11 additions & 4 deletions packages/cli/utils/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,21 @@ export function stripTrailingSlash<T extends string | undefined>(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)}`;
}
Expand Down