Skip to content

Commit dcbf037

Browse files
committed
feat: use new login scheme which supports Safari
1 parent 5cc7d5c commit dcbf037

3 files changed

Lines changed: 118 additions & 88 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/utils/auth.ts

Lines changed: 106 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,128 @@
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

79
import { loginUrl } from "./constants.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-
};
15-
}
16-
17-
export async function authenticateUser(baseUrl: string) {
18-
return new Promise<string>((resolve, reject) => {
19-
let isResolved = false;
11+
const successUrl = (baseUrl: string) => `${baseUrl}/cli-login/success`;
12+
const errorUrl = (baseUrl: string, error: string) =>
13+
`${baseUrl}/cli-login/error?error=${error}`;
2014

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);
15+
interface waitForAccessToken {
16+
accessToken: string;
17+
expiresAt: Date;
18+
}
2419

25-
// Ensure we don't process requests after resolution
26-
if (isResolved) {
27-
res.writeHead(503, headers).end();
28-
return;
29-
}
20+
export async function waitForAccessToken(baseUrl: string, apiUrl: string) {
21+
let resolve: (args: waitForAccessToken) => void,
22+
reject: (arg0: Error) => void;
23+
const promise = new Promise<waitForAccessToken>((res, rej) => {
24+
resolve = res;
25+
reject = rej;
26+
});
3027

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-
}
28+
// PCKE code verifier and challenge
29+
const codeVerifier = crypto.randomUUID();
30+
const codeChallenge = crypto
31+
.createHash("sha256")
32+
.update(codeVerifier)
33+
.digest("base64")
34+
.replace(/=/g, "")
35+
.replace(/\+/g, "-")
36+
.replace(/\//g, "_");
37+
38+
const timeout = setTimeout(() => {
39+
cleanupAndReject(new Error("Authentication timed out after 30 seconds"));
40+
}, 30000);
41+
42+
function cleanupAndReject(error: Error) {
43+
cleanup();
44+
reject(error);
45+
}
3646

37-
// Handle preflight request
38-
if (req.method === "OPTIONS") {
39-
res.writeHead(200, headers);
40-
res.end();
41-
return;
42-
}
47+
function cleanup() {
48+
clearTimeout(timeout);
49+
server.close();
50+
server.closeAllConnections();
51+
}
4352

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-
}
53+
const server = http.createServer(async (req, res) => {
54+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
4955

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-
});
56+
if (url.pathname !== "/cli-login") {
57+
res.writeHead(404).end("Invalid path");
58+
cleanupAndReject(new Error("Could not authenticate: Invalid path"));
59+
return;
60+
}
6461

65-
const timeout = setTimeout(() => {
66-
cleanupAndReject(new Error("Authentication timed out after 60 seconds"));
67-
}, 60000);
62+
const fullUrl = new URL(`http://localhost${req.url}`);
6863

69-
function cleanupAndResolve(token: string) {
70-
if (isResolved) return;
71-
isResolved = true;
72-
cleanup();
73-
resolve(token);
74-
}
64+
const code = fullUrl.searchParams.get("code");
7565

76-
function cleanupAndReject(error: Error) {
77-
if (isResolved) return;
78-
isResolved = true;
79-
cleanup();
80-
reject(error);
66+
if (!code) {
67+
res.writeHead(400).end("Could not authenticate");
68+
cleanupAndReject(new Error("Could not authenticate: no code provided"));
69+
return;
8170
}
8271

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-
}
72+
const response = await fetch(`${apiUrl}/oauth/cli/access-token`, {
73+
method: "POST",
74+
headers: {
75+
"Content-Type": "application/json",
76+
},
77+
body: JSON.stringify({
78+
code,
79+
codeVerifier,
80+
}),
81+
});
9282

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));
83+
if (!response.ok) {
84+
res
85+
.writeHead(302, {
86+
location: errorUrl(
87+
baseUrl,
88+
"Could not authenticate: Unable to fetch access token",
89+
),
90+
})
91+
.end("Could not authenticate");
92+
cleanupAndReject(new Error("Could not authenticate"));
93+
return;
9894
}
95+
res
96+
.writeHead(302, {
97+
location: successUrl(baseUrl),
98+
})
99+
.end("Authentication successful");
100+
101+
const jsonResponse = await response.json();
102+
103+
cleanup();
104+
resolve({
105+
accessToken: jsonResponse.accessToken,
106+
expiresAt: new Date(jsonResponse.expiresAt),
107+
});
99108
});
109+
110+
server.listen();
111+
const address = server.address();
112+
if (address == null || typeof address !== "object") {
113+
throw new Error("Could not start server");
114+
}
115+
116+
const port = address.port;
117+
const browserUrl = loginUrl(apiUrl, port, codeChallenge);
118+
119+
console.log(
120+
`Opened web browser to facilitate login: ${chalk.cyan(browserUrl)}`,
121+
);
122+
123+
void open(browserUrl);
124+
125+
return promise;
100126
}
101127

102128
export async function authRequest<T = Record<string, unknown>>(
@@ -110,7 +136,8 @@ export async function authRequest<T = Record<string, unknown>>(
110136
const token = authStore.getToken(baseUrl);
111137

112138
if (!token) {
113-
await authenticateUser(baseUrl);
139+
const accessToken = await waitForAccessToken(baseUrl, apiUrl);
140+
await authStore.setToken(baseUrl, accessToken.accessToken);
114141
return authRequest(url, options);
115142
}
116143

@@ -133,7 +160,7 @@ export async function authRequest<T = Record<string, unknown>>(
133160
if (response.status === 401) {
134161
await authStore.setToken(baseUrl, undefined);
135162
if (retryCount < 1) {
136-
await authenticateUser(baseUrl);
163+
await waitForAccessToken(baseUrl, apiUrl);
137164
return authRequest(url, options, retryCount + 1);
138165
}
139166
}

packages/cli/utils/constants.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ export const DEFAULT_TYPES_OUTPUT = join("gen", "features.ts");
1414

1515
export const chalkBrand = chalk.hex("#847CFB");
1616

17-
export const loginUrl = (baseUrl: string, localPort: number) =>
18-
`${baseUrl}/login?redirect_url=` +
19-
encodeURIComponent("/cli-login?port=" + localPort);
17+
export const loginUrl = (
18+
baseUrl: string,
19+
localPort: number,
20+
codeChallenge: string,
21+
) =>
22+
`${baseUrl}/oauth/cli/authorize?port=${localPort}&codeChallenge=${codeChallenge}`;

0 commit comments

Comments
 (0)