Skip to content

Commit 437e088

Browse files
authored
Merge branch 'main' into cli-docs
2 parents ca84bbe + c106b8c commit 437e088

4 files changed

Lines changed: 119 additions & 92 deletions

File tree

packages/cli/commands/auth.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@ import ora from "ora";
44

55
import { authStore } from "../stores/auth.js";
66
import { configStore } from "../stores/config.js";
7-
import { authenticateUser } from "../utils/auth.js";
7+
import { waitForAccessToken } from "../utils/auth.js";
88
import { handleError } from "../utils/errors.js";
99

1010
export const loginAction = async () => {
11-
const baseUrl = configStore.getConfig("baseUrl");
12-
const spinner = ora(`Logging in to ${chalk.cyan(baseUrl)}...`).start();
11+
const { baseUrl, apiUrl } = configStore.getConfig();
12+
1313
try {
14-
await authenticateUser(baseUrl);
15-
spinner.succeed(`Logged in to ${chalk.cyan(baseUrl)} successfully!`);
14+
await waitForAccessToken(baseUrl, apiUrl);
15+
console.log(`Logged in to ${chalk.cyan(baseUrl)} successfully!`);
1616
} catch (error) {
17-
spinner.fail("Login failed.");
17+
console.error("Login failed.");
1818
void handleError(error, "Login");
1919
}
2020
};

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@bucketco/cli",
3-
"version": "0.2.3",
3+
"version": "0.2.4",
44
"packageManager": "yarn@4.1.1",
55
"description": "CLI for Bucket service",
66
"main": "./dist/index.js",

packages/cli/utils/auth.ts

Lines changed: 101 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,122 @@
1+
import crypto from "crypto";
12
import http from "http";
3+
import chalk from "chalk";
24
import open from "open";
35

46
import { authStore } from "../stores/auth.js";
57
import { configStore } from "../stores/config.js";
68

7-
import { loginUrl } from "./path.js";
9+
import { errorUrl, loginUrl, successUrl } from "./path.js";
810

9-
function corsHeaders(baseUrl: string): Record<string, string> {
10-
return {
11-
"Access-Control-Allow-Origin": baseUrl,
12-
"Access-Control-Allow-Methods": "GET",
13-
"Access-Control-Allow-Headers": "Authorization",
14-
};
11+
interface waitForAccessToken {
12+
accessToken: string;
13+
expiresAt: Date;
1514
}
1615

17-
export async function authenticateUser(baseUrl: string) {
18-
return new Promise<string>((resolve, reject) => {
19-
let isResolved = false;
16+
export async function waitForAccessToken(baseUrl: string, apiUrl: string) {
17+
let resolve: (args: waitForAccessToken) => void,
18+
reject: (arg0: Error) => void;
19+
const promise = new Promise<waitForAccessToken>((res, rej) => {
20+
resolve = res;
21+
reject = rej;
22+
});
2023

21-
const server = http.createServer(async (req, res) => {
22-
const url = new URL(req.url ?? "/", "http://127.0.0.1");
23-
const headers = corsHeaders(baseUrl);
24+
// PCKE code verifier and challenge
25+
const codeVerifier = crypto.randomUUID();
26+
const codeChallenge = crypto
27+
.createHash("sha256")
28+
.update(codeVerifier)
29+
.digest("base64")
30+
.replace(/=/g, "")
31+
.replace(/\+/g, "-")
32+
.replace(/\//g, "_");
33+
34+
const timeout = setTimeout(() => {
35+
cleanupAndReject(new Error("Authentication timed out after 60 seconds"));
36+
}, 60000);
37+
38+
function cleanupAndReject(error: Error) {
39+
cleanup();
40+
reject(error);
41+
}
2442

25-
// Ensure we don't process requests after resolution
26-
if (isResolved) {
27-
res.writeHead(503, headers).end();
28-
return;
29-
}
43+
function cleanup() {
44+
clearTimeout(timeout);
45+
server.close();
46+
server.closeAllConnections();
47+
}
3048

31-
if (url.pathname !== "/cli-login") {
32-
res.writeHead(404).end("Invalid path");
33-
cleanupAndReject(new Error("Could not authenticate: Invalid path"));
34-
return;
35-
}
49+
const server = http.createServer(async (req, res) => {
50+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
3651

37-
// Handle preflight request
38-
if (req.method === "OPTIONS") {
39-
res.writeHead(200, headers);
40-
res.end();
41-
return;
42-
}
52+
if (url.pathname !== "/cli-login") {
53+
res.writeHead(404).end("Invalid path");
54+
cleanupAndReject(new Error("Could not authenticate: Invalid path"));
55+
return;
56+
}
4357

44-
if (!req.headers.authorization?.startsWith("Bearer ")) {
45-
res.writeHead(400, headers).end("Could not authenticate");
46-
cleanupAndReject(new Error("Could not authenticate"));
47-
return;
48-
}
58+
const code = url.searchParams.get("code");
4959

50-
const token = req.headers.authorization.slice("Bearer ".length);
51-
headers["Content-Type"] = "application/json";
52-
res.writeHead(200, headers);
53-
res.end(JSON.stringify({ result: "success" }));
54-
55-
try {
56-
await authStore.setToken(baseUrl, token);
57-
cleanupAndResolve(token);
58-
} catch (error) {
59-
cleanupAndReject(
60-
error instanceof Error ? error : new Error("Failed to store token"),
61-
);
62-
}
63-
});
60+
if (!code) {
61+
res.writeHead(400).end("Could not authenticate");
62+
cleanupAndReject(new Error("Could not authenticate: no code provided"));
63+
return;
64+
}
6465

65-
const timeout = setTimeout(() => {
66-
cleanupAndReject(new Error("Authentication timed out after 60 seconds"));
67-
}, 60000);
66+
const response = await fetch(`${apiUrl}/oauth/cli/access-token`, {
67+
method: "POST",
68+
headers: {
69+
"Content-Type": "application/json",
70+
},
71+
body: JSON.stringify({
72+
code,
73+
codeVerifier,
74+
}),
75+
});
6876

69-
function cleanupAndResolve(token: string) {
70-
if (isResolved) return;
71-
isResolved = true;
72-
cleanup();
73-
resolve(token);
77+
if (!response.ok) {
78+
res
79+
.writeHead(302, {
80+
location: errorUrl(
81+
baseUrl,
82+
"Could not authenticate: Unable to fetch access token",
83+
),
84+
})
85+
.end("Could not authenticate");
86+
cleanupAndReject(new Error("Could not authenticate"));
87+
return;
7488
}
89+
res
90+
.writeHead(302, {
91+
location: successUrl(baseUrl),
92+
})
93+
.end("Authentication successful");
94+
95+
const jsonResponse = await response.json();
96+
97+
cleanup();
98+
resolve({
99+
accessToken: jsonResponse.accessToken,
100+
expiresAt: new Date(jsonResponse.expiresAt),
101+
});
102+
});
75103

76-
function cleanupAndReject(error: Error) {
77-
if (isResolved) return;
78-
isResolved = true;
79-
cleanup();
80-
reject(error);
81-
}
104+
server.listen();
105+
const address = server.address();
106+
if (address == null || typeof address !== "object") {
107+
throw new Error("Could not start server");
108+
}
82109

83-
function cleanup() {
84-
clearTimeout(timeout);
85-
server.close();
86-
// Force-close any remaining connections
87-
server.getConnections((err, count) => {
88-
if (err || count === 0) return;
89-
server.closeAllConnections();
90-
});
91-
}
110+
const port = address.port;
111+
const browserUrl = loginUrl(apiUrl, port, codeChallenge);
92112

93-
server.listen();
94-
const address = server.address();
95-
if (address && typeof address === "object") {
96-
const port = address.port;
97-
void open(loginUrl(baseUrl, port));
98-
}
99-
});
113+
console.log(
114+
`Opened web browser to facilitate login: ${chalk.cyan(browserUrl)}`,
115+
);
116+
117+
void open(browserUrl);
118+
119+
return promise;
100120
}
101121

102122
export async function authRequest<T = Record<string, unknown>>(
@@ -110,10 +130,10 @@ export async function authRequest<T = Record<string, unknown>>(
110130
const token = authStore.getToken(baseUrl);
111131

112132
if (!token) {
113-
await authenticateUser(baseUrl);
133+
const accessToken = await waitForAccessToken(baseUrl, apiUrl);
134+
await authStore.setToken(baseUrl, accessToken.accessToken);
114135
return authRequest(url, options);
115136
}
116-
117137
const resolvedUrl = new URL(`${apiUrl}/${url}`);
118138
if (options?.params) {
119139
Object.entries(options.params).forEach(([key, value]) => {
@@ -133,7 +153,7 @@ export async function authRequest<T = Record<string, unknown>>(
133153
if (response.status === 401) {
134154
await authStore.setToken(baseUrl, undefined);
135155
if (retryCount < 1) {
136-
await authenticateUser(baseUrl);
156+
await waitForAccessToken(baseUrl, apiUrl);
137157
return authRequest(url, options, retryCount + 1);
138158
}
139159
}

packages/cli/utils/path.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,21 @@ export function stripTrailingSlash<T extends string | undefined>(str: T): T {
1313
return str?.endsWith("/") ? (str.slice(0, -1) as T) : str;
1414
}
1515

16+
export const successUrl = (baseUrl: string) => `${baseUrl}/cli-login/success`;
17+
export const errorUrl = (baseUrl: string, error: string) =>
18+
`${baseUrl}/cli-login/error?error=${error}`;
19+
20+
export const loginUrl = (
21+
baseUrl: string,
22+
localPort: number,
23+
codeChallenge: string,
24+
) =>
25+
`${baseUrl}/oauth/cli/authorize?port=${localPort}&codeChallenge=${codeChallenge}`;
26+
1627
export const baseUrlSuffix = (baseUrl: string) => {
1728
return baseUrl !== DEFAULT_BASE_URL ? ` at ${chalk.cyan(baseUrl)}` : "";
1829
};
1930

20-
export const loginUrl = (baseUrl: string, localPort: number) =>
21-
`${baseUrl}/login?redirect_url=` +
22-
encodeURIComponent("/cli-login?port=" + localPort);
23-
2431
export function environmentUrl(baseUrl: string, environment: UrlArgs): string {
2532
return `${baseUrl}/envs/${slug(environment)}`;
2633
}

0 commit comments

Comments
 (0)