Skip to content

Commit 6a57fb9

Browse files
committed
refactor(cli): streamline type generation and enhance authentication flow
- Removed the `checkOnly` option from `generateTypesAction` to simplify the type generation process. - Improved error handling in `waitForAccessToken` with more descriptive messages and a consistent timeout mechanism. - Introduced a new `registerClient` function to encapsulate client registration logic with the OAuth server. - Added a `DEFAULT_AUTH_TIMEOUT` constant for configurable authentication timeout. - Cleaned up unused code related to type checking in files to enhance maintainability.
1 parent ba8488b commit 6a57fb9

6 files changed

Lines changed: 126 additions & 132 deletions

File tree

.vscode/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"cSpell.words": [
4343
"booleanish",
4444
"bucketco",
45-
"openfeature"
45+
"openfeature",
46+
"PKCE"
4647
]
4748
}

packages/cli/commands/features.ts

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
MissingEnvIdError,
1414
} from "../utils/errors.js";
1515
import {
16-
checkTypesInFile,
1716
genFeatureKey,
1817
genTypes,
1918
indentLines,
@@ -24,7 +23,6 @@ import {
2423
appIdOption,
2524
featureKeyOption,
2625
featureNameArgument,
27-
typesCheckOnlyOption,
2826
typesFormatOption,
2927
typesOutOption,
3028
} from "../utils/options.js";
@@ -134,13 +132,7 @@ export const listFeaturesAction = async () => {
134132
}
135133
};
136134

137-
type GenerateTypesOptions = {
138-
checkOnly?: boolean;
139-
};
140-
141-
export const generateTypesAction = async ({
142-
checkOnly,
143-
}: GenerateTypesOptions = {}) => {
135+
export const generateTypesAction = async () => {
144136
const { baseUrl, appId } = configStore.getConfig();
145137
const typesOutput = configStore.getConfig("typesOutput");
146138

@@ -182,36 +174,17 @@ export const generateTypesAction = async ({
182174
}
183175

184176
try {
185-
spinner = ora(
186-
`${checkOnly ? "Checking" : "Generating"} feature types...`,
187-
).start();
177+
spinner = ora(`Generating feature types...`).start();
188178
const projectPath = configStore.getProjectPath();
189179

190180
// Generate types for each output configuration
191181
for (const output of typesOutput) {
192182
const types = genTypes(features, output.format);
193183

194-
if (checkOnly) {
195-
const { fullPath, isUpToDate } = await checkTypesInFile(
196-
types,
197-
output.path,
198-
projectPath,
199-
);
200-
201-
if (!isUpToDate) {
202-
spinner.fail(`Types are not up to date in ${chalk.cyan(fullPath)}.`);
203-
handleError(`Type check failed.`, "Features Types");
204-
} else {
205-
spinner.succeed(
206-
`All ${output.format} types are up to date in ${chalk.cyan(relative(projectPath, fullPath))}.`,
207-
);
208-
}
209-
} else {
210-
const outPath = await writeTypesToFile(types, output.path, projectPath);
211-
spinner.succeed(
212-
`Generated ${output.format} types in ${chalk.cyan(relative(projectPath, outPath))}.`,
213-
);
214-
}
184+
const outPath = await writeTypesToFile(types, output.path, projectPath);
185+
spinner.succeed(
186+
`Generated ${output.format} types in ${chalk.cyan(relative(projectPath, outPath))}.`,
187+
);
215188
}
216189
} catch (error) {
217190
spinner?.fail("Type generation failed.");
@@ -245,7 +218,6 @@ export function registerFeatureCommands(cli: Command) {
245218
.addOption(appIdOption)
246219
.addOption(typesOutOption)
247220
.addOption(typesFormatOption)
248-
.addOption(typesCheckOnlyOption)
249221
.action(generateTypesAction);
250222

251223
// Update the config with the cli override values

packages/cli/utils/auth.ts

Lines changed: 115 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { configStore } from "../stores/config.js";
99
import {
1010
CLIENT_VERSION_HEADER_NAME,
1111
CLIENT_VERSION_HEADER_VALUE,
12+
DEFAULT_AUTH_TIMEOUT,
1213
} from "./constants.js";
1314
import { ResponseError } from "./errors.js";
1415
import { ParamType } from "./types.js";
@@ -49,18 +50,77 @@ async function getOAuthServerUrls(apiUrl: string) {
4950
throw new Error("Failed to fetch OAuth server metadata");
5051
}
5152

52-
export async function waitForAccessToken(baseUrl: string, apiUrl: string) {
53-
const { authorizationEndpoint, tokenEndpoint, registrationEndpoint } =
54-
await getOAuthServerUrls(apiUrl);
53+
async function registerClient(
54+
registrationEndpoint: string,
55+
redirectUri: string,
56+
) {
57+
const registrationResponse = await fetch(registrationEndpoint, {
58+
method: "POST",
59+
headers: {
60+
"Content-Type": "application/json",
61+
},
62+
body: JSON.stringify({
63+
client_name: "Bucket CLI",
64+
token_endpoint_auth_method: "none",
65+
grant_types: ["authorization_code"],
66+
redirect_uris: [redirectUri],
67+
}),
68+
signal: AbortSignal.timeout(5000),
69+
});
5570

56-
let resolve: (args: waitForAccessToken) => void;
57-
let reject: (arg0: Error) => void;
71+
if (!registrationResponse.ok) {
72+
throw new Error(`Could not register client with OAuth server`);
73+
}
5874

59-
const promise = new Promise<waitForAccessToken>((res, rej) => {
60-
resolve = res;
61-
reject = rej;
75+
const registrationData = (await registrationResponse.json()) as {
76+
client_id: string;
77+
};
78+
79+
return registrationData.client_id;
80+
}
81+
82+
async function exchangeCodeForToken(
83+
tokenEndpoint: string,
84+
clientId: string,
85+
code: string,
86+
codeVerifier: string,
87+
redirectUri: string,
88+
) {
89+
const response = await fetch(tokenEndpoint, {
90+
method: "POST",
91+
headers: {
92+
"Content-Type": "application/x-www-form-urlencoded",
93+
},
94+
body: new URLSearchParams({
95+
grant_type: "authorization_code",
96+
client_id: clientId,
97+
code,
98+
code_verifier: codeVerifier,
99+
redirect_uri: redirectUri,
100+
}),
101+
signal: AbortSignal.timeout(5000),
62102
});
63103

104+
if (!response.ok) {
105+
let errorDescription: string | undefined;
106+
107+
try {
108+
const jsonResponse = await response.json();
109+
errorDescription = jsonResponse.error_description || jsonResponse.error;
110+
} catch {
111+
// ignore
112+
}
113+
114+
return { error: errorDescription ?? "unknown error" };
115+
}
116+
117+
return {
118+
accessToken: (await response.json()).access_token,
119+
expiresAt: new Date(Date.now() + (await response.json()).expires_in * 1000),
120+
};
121+
}
122+
123+
function createChallenge() {
64124
// PKCE code verifier and challenge
65125
const codeVerifier = crypto.randomBytes(32).toString("base64url");
66126
const codeChallenge = crypto
@@ -71,13 +131,34 @@ export async function waitForAccessToken(baseUrl: string, apiUrl: string) {
71131
.replace(/\+/g, "-")
72132
.replace(/\//g, "_");
73133

134+
const state = crypto.randomUUID();
135+
136+
return { codeVerifier, codeChallenge, state };
137+
}
138+
139+
export async function waitForAccessToken(baseUrl: string, apiUrl: string) {
140+
const { authorizationEndpoint, tokenEndpoint, registrationEndpoint } =
141+
await getOAuthServerUrls(apiUrl);
142+
143+
let resolve: (args: waitForAccessToken) => void;
144+
let reject: (arg0: Error) => void;
145+
146+
const promise = new Promise<waitForAccessToken>((res, rej) => {
147+
resolve = res;
148+
reject = rej;
149+
});
150+
151+
const { codeVerifier, codeChallenge, state } = createChallenge();
152+
74153
const timeout = setTimeout(() => {
75-
cleanupAndReject(new Error("Authentication timed out after 60 seconds"));
76-
}, 60000);
154+
cleanupAndReject(
155+
`authentication timed out after ${DEFAULT_AUTH_TIMEOUT / 1000} seconds`,
156+
);
157+
}, DEFAULT_AUTH_TIMEOUT);
77158

78-
function cleanupAndReject(error: Error) {
159+
function cleanupAndReject(message: string) {
79160
cleanup();
80-
reject(error);
161+
reject(new Error(`Could not authenticate: ${message}`));
81162
}
82163

83164
function cleanup() {
@@ -95,37 +176,16 @@ export async function waitForAccessToken(baseUrl: string, apiUrl: string) {
95176
throw new Error("Could not start server");
96177
}
97178

98-
const redirectUri = `http://localhost:${address.port}/callback`;
99-
100-
const registrationResponse = await fetch(registrationEndpoint, {
101-
method: "POST",
102-
headers: {
103-
"Content-Type": "application/json",
104-
},
105-
body: JSON.stringify({
106-
client_name: "Bucket CLI",
107-
token_endpoint_auth_method: "none",
108-
grant_types: ["authorization_code"],
109-
redirect_uris: [redirectUri],
110-
}),
111-
signal: AbortSignal.timeout(5000),
112-
});
113-
114-
if (!registrationResponse.ok) {
115-
throw new Error(`Could not register client with OAuth server`);
116-
}
117-
118-
const registrationData = (await registrationResponse.json()) as {
119-
client_id: string;
120-
};
179+
const callbackPath = "/oauth_callback";
180+
const redirectUri = `http://localhost:${address.port}${callbackPath}`;
121181

122-
const clientId = registrationData.client_id;
182+
const clientId = await registerClient(registrationEndpoint, redirectUri);
123183

124184
const params = {
125185
response_type: "code",
126186
client_id: clientId,
127187
redirect_uri: redirectUri,
128-
state: crypto.randomUUID(),
188+
state,
129189
code_challenge: codeChallenge,
130190
code_challenge_method: "S256",
131191
};
@@ -134,20 +194,18 @@ export async function waitForAccessToken(baseUrl: string, apiUrl: string) {
134194

135195
server.on("request", async (req, res) => {
136196
if (!clientId || !redirectUri) {
137-
res.writeHead(500).end("Could not authenticate: something went wrong");
197+
res.writeHead(500).end("Something went wrong");
138198

139-
cleanupAndReject(
140-
new Error("Could not authenticate: something went wrong"),
141-
);
199+
cleanupAndReject("something went wrong");
142200
return;
143201
}
144202

145203
const url = new URL(req.url ?? "/", "http://127.0.0.1");
146204

147-
if (url.pathname !== "/callback") {
205+
if (url.pathname !== callbackPath) {
148206
res.writeHead(404).end("Invalid path");
149207

150-
cleanupAndReject(new Error("Could not authenticate: invalid path"));
208+
cleanupAndReject("invalid path");
151209
return;
152210
}
153211

@@ -156,64 +214,48 @@ export async function waitForAccessToken(baseUrl: string, apiUrl: string) {
156214
res.writeHead(400).end("Could not authenticate");
157215

158216
const errorDescription = url.searchParams.get("error_description");
159-
cleanupAndReject(
160-
new Error(`Could not authenticate: ${errorDescription || error} `),
161-
);
217+
cleanupAndReject(`${errorDescription || error} `);
162218
return;
163219
}
164220

165221
const code = url.searchParams.get("code");
166222
if (!code) {
167223
res.writeHead(400).end("Could not authenticate");
168224

169-
cleanupAndReject(new Error("Could not authenticate: no code provided"));
225+
cleanupAndReject("no code provided");
170226
return;
171227
}
172228

173-
const response = await fetch(tokenEndpoint, {
174-
method: "POST",
175-
headers: {
176-
"Content-Type": "application/x-www-form-urlencoded",
177-
},
178-
body: new URLSearchParams({
179-
grant_type: "authorization_code",
180-
client_id: clientId,
181-
code,
182-
code_verifier: codeVerifier,
183-
redirect_uri: redirectUri,
184-
}),
185-
signal: AbortSignal.timeout(5000),
186-
});
229+
const response = await exchangeCodeForToken(
230+
tokenEndpoint,
231+
clientId,
232+
code,
233+
codeVerifier,
234+
redirectUri,
235+
);
187236

188-
if (!response.ok) {
237+
if ("error" in response) {
189238
res
190239
.writeHead(302, {
191240
location: errorUrl(
192241
baseUrl,
193-
"Could not authenticate: Unable to fetch access token",
242+
"Could not authenticate: unable to fetch access token",
194243
),
195244
})
196245
.end("Could not authenticate");
197246

198-
const json = await response.json();
199-
cleanupAndReject(
200-
new Error(`Could not authenticate: ${JSON.stringify(json)}`),
201-
);
247+
cleanupAndReject(JSON.stringify(response.error));
202248
return;
203249
}
250+
204251
res
205252
.writeHead(302, {
206253
location: successUrl(baseUrl),
207254
})
208255
.end("Authentication successful");
209256

210-
const jsonResponse = await response.json();
211-
212257
cleanup();
213-
resolve({
214-
accessToken: jsonResponse.access_token,
215-
expiresAt: new Date(Date.now() + jsonResponse.expires_in * 1000),
216-
});
258+
resolve(response);
217259
});
218260

219261
console.log(

packages/cli/utils/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export const DEFAULT_BASE_URL = "https://app.bucket.co";
1414
export const DEFAULT_API_URL = `${DEFAULT_BASE_URL}/api`;
1515
export const DEFAULT_TYPES_OUTPUT = join("gen", "features.d.ts");
1616

17+
export const DEFAULT_AUTH_TIMEOUT = 60000; // 60 seconds
18+
1719
export const MODULE_ROOT = fileURLToPath(import.meta.url).substring(
1820
0,
1921
fileURLToPath(import.meta.url).lastIndexOf("cli") + 3,

0 commit comments

Comments
 (0)