diff --git a/package-lock.json b/package-lock.json index a1ae977b..307f58c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "commander": "^12.1.0", "globby": "^16.1.0", "jsonc-parser": "^3.3.1", + "ky": "^1.14.2", "p-wait-for": "^6.0.0", "zod": "^4.3.5" }, @@ -4453,6 +4454,18 @@ "json-buffer": "3.0.1" } }, + "node_modules/ky": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.2.tgz", + "integrity": "sha512-q3RBbsO5A5zrPhB6CaCS8ZUv+NWCXv6JJT4Em0i264G9W0fdPB8YRfnnEi7Dm7X7omAkBIPojzYJ2D1oHTHqug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/package.json b/package.json index 10f1ecc8..e8c261ad 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "commander": "^12.1.0", "globby": "^16.1.0", "jsonc-parser": "^3.3.1", + "ky": "^1.14.2", "p-wait-for": "^6.0.0", "zod": "^4.3.5" }, diff --git a/src/cli/commands/auth/login.ts b/src/cli/commands/auth/login.ts index 6367977b..29710c76 100644 --- a/src/cli/commands/auth/login.ts +++ b/src/cli/commands/auth/login.ts @@ -1,12 +1,18 @@ import { Command } from "commander"; +import chalk from "chalk"; import { log } from "@clack/prompts"; import pWaitFor from "p-wait-for"; import { writeAuth, generateDeviceCode, getTokenFromDeviceCode, + getUserInfo, +} from "@core/auth/index.js"; +import type { + DeviceCodeResponse, + TokenResponse, + UserInfoResponse, } from "@core/auth/index.js"; -import type { DeviceCodeResponse, TokenResponse } from "@core/auth/index.js"; import { runCommand, runTask } from "../../utils/index.js"; async function generateAndDisplayDeviceCode(): Promise { @@ -22,8 +28,8 @@ async function generateAndDisplayDeviceCode(): Promise { ); log.info( - `Please visit: ${deviceCodeResponse.verificationUrl}\n` + - `Enter your device code: ${deviceCodeResponse.userCode}` + `Your code is: ${chalk.bold(deviceCodeResponse.userCode)}` + + `\nPlease visit: ${deviceCodeResponse.verificationUriComplete}` ); return deviceCodeResponse; @@ -31,7 +37,8 @@ async function generateAndDisplayDeviceCode(): Promise { async function waitForAuthentication( deviceCode: string, - expiresIn: number + expiresIn: number, + interval: number ): Promise { let tokenResponse: TokenResponse | undefined; @@ -49,7 +56,7 @@ async function waitForAuthentication( return false; }, { - interval: 2000, + interval: interval * 1000, timeout: expiresIn * 1000, } ); @@ -73,11 +80,18 @@ async function waitForAuthentication( return tokenResponse; } -async function saveAuthData(token: TokenResponse): Promise { +async function saveAuthData( + response: TokenResponse, + userInfo: UserInfoResponse +): Promise { + const expiresAt = Date.now() + response.expiresIn * 1000; + await writeAuth({ - token: token.token, - email: token.email, - name: token.name, + accessToken: response.accessToken, + refreshToken: response.refreshToken, + expiresAt, + email: userInfo.email, + name: userInfo.name, }); } @@ -86,12 +100,15 @@ async function login(): Promise { const token = await waitForAuthentication( deviceCodeResponse.deviceCode, - deviceCodeResponse.expiresIn + deviceCodeResponse.expiresIn, + deviceCodeResponse.interval ); - await saveAuthData(token); + const userInfo = await getUserInfo(token.accessToken); + + await saveAuthData(token, userInfo); - log.success(`Logged in as ${token.name}`); + log.success(`Successfully logged in as ${chalk.bold(userInfo.email)}`); } export const loginCommand = new Command("login") diff --git a/src/cli/index.ts b/src/cli/index.ts index f0021b87..b7ee129d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,11 +1,11 @@ #!/usr/bin/env node import { Command } from "commander"; -import { getPackageVersion } from "./utils/index.js"; import { loginCommand } from "./commands/auth/login.js"; import { whoamiCommand } from "./commands/auth/whoami.js"; import { logoutCommand } from "./commands/auth/logout.js"; import { showProjectCommand } from "./commands/project/show-project.js"; +import packageJson from "../../package.json"; const program = new Command(); @@ -14,7 +14,7 @@ program .description( "Base44 CLI - Unified interface for managing Base44 applications" ) - .version(getPackageVersion()); + .version(packageJson.version); // Register authentication commands program.addCommand(loginCommand); diff --git a/src/cli/utils/index.ts b/src/cli/utils/index.ts index 8e7b5ed6..f28dc0d6 100644 --- a/src/cli/utils/index.ts +++ b/src/cli/utils/index.ts @@ -1,4 +1,2 @@ -export * from "./packageVersion.js"; export * from "./runCommand.js"; export * from "./runTask.js"; - diff --git a/src/cli/utils/packageVersion.ts b/src/cli/utils/packageVersion.ts deleted file mode 100644 index 26ce352e..00000000 --- a/src/cli/utils/packageVersion.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; - -export function getPackageVersion(): string { - const __filename = fileURLToPath(import.meta.url); - const __dirname = dirname(__filename); - - // From dist/cli/index.js -> package.json at root (bundled output) - const packageJsonPath = join(__dirname, "..", "..", "package.json"); - const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); - return packageJson.version; -} - diff --git a/src/cli/utils/runCommand.ts b/src/cli/utils/runCommand.ts index 55ad065d..431a0e20 100644 --- a/src/cli/utils/runCommand.ts +++ b/src/cli/utils/runCommand.ts @@ -1,6 +1,5 @@ import { intro, log } from "@clack/prompts"; import chalk from "chalk"; -import { AuthApiError, AuthValidationError } from "@core/errors.js"; const base44Color = chalk.bgHex("#E86B3C"); @@ -18,11 +17,8 @@ export async function runCommand( try { await commandFn(); } catch (e) { - if (e instanceof AuthValidationError) { - const issues = e.issues.map((i) => i.message).join(", "); - log.error(`Invalid response from server: ${issues}`); - } else if (e instanceof AuthApiError || e instanceof Error) { - log.error(e.message); + if (e instanceof Error) { + log.error(e.stack ?? e.message); } else { log.error(String(e)); } diff --git a/src/core/auth/api.ts b/src/core/auth/api.ts index ab585814..7255a754 100644 --- a/src/core/auth/api.ts +++ b/src/core/auth/api.ts @@ -1,101 +1,154 @@ import { AuthApiError, AuthValidationError } from "../errors.js"; -import { DeviceCodeResponseSchema, TokenResponseSchema } from "./schema.js"; -import type { DeviceCodeResponse, TokenResponse } from "./schema.js"; - -async function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -const deviceCodeToTokenMap = new Map< - string, - { startTime: number; readyAfter: number } ->(); +import { + DeviceCodeResponseSchema, + TokenResponseSchema, + OAuthErrorSchema, + UserInfoSchema, +} from "./schema.js"; +import type { + DeviceCodeResponse, + TokenResponse, + UserInfoResponse, +} from "./schema.js"; +import { AUTH_CLIENT_ID } from "../consts.js"; +import authClient from "./authClient.js"; export async function generateDeviceCode(): Promise { - try { - await delay(1000); + const response = await authClient.post("oauth/device/code", { + json: { + client_id: AUTH_CLIENT_ID, + scope: "apps:read apps:write", + }, + throwHttpErrors: false, + }); + + if (!response.ok) { + throw new AuthApiError( + `Failed to generate device code: ${response.status} ${response.statusText}` + ); + } - const deviceCode = `device-code-${Date.now()}`; + const result = DeviceCodeResponseSchema.safeParse(await response.json()); - deviceCodeToTokenMap.set(deviceCode, { - startTime: Date.now(), - readyAfter: 5000, - }); + if (!result.success) { + throw new AuthValidationError( + `Invalid device code response from server: ${result.error.message}` + ); + } - const mockResponse: DeviceCodeResponse = { - deviceCode, - userCode: "ABCD-1234", - verificationUrl: "https://app.base44.com/verify", - expiresIn: 600, - }; + return result.data; +} - const result = DeviceCodeResponseSchema.safeParse(mockResponse); - if (!result.success) { +export async function getTokenFromDeviceCode( + deviceCode: string +): Promise { + const searchParams = new URLSearchParams(); + searchParams.set( + "grant_type", + "urn:ietf:params:oauth:grant-type:device_code" + ); + searchParams.set("device_code", deviceCode); + searchParams.set("client_id", AUTH_CLIENT_ID); + + const response = await authClient.post("oauth/token", { + body: searchParams.toString(), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + throwHttpErrors: false, + }); + + const json = await response.json(); + + if (!response.ok) { + const errorResult = OAuthErrorSchema.safeParse(json); + + if (!errorResult.success) { throw new AuthValidationError( - "Invalid device code response from server", - result.error.issues.map((issue) => ({ - message: issue.message, - path: issue.path.map(String), - })) + `Token request failed: ${errorResult.error.message}` ); } - return result.data; - } catch (error) { - if (error instanceof AuthValidationError) { - throw error; + const { error, error_description } = errorResult.data; + + // Polling states - user hasn't completed auth yet + if (error === "authorization_pending" || error === "slow_down") { + return null; } - throw new AuthApiError( - "Failed to generate device code", - error instanceof Error ? error : new Error(String(error)) + + // Actual errors + throw new AuthApiError(error_description ?? `OAuth error: ${error}`); + } + + const result = TokenResponseSchema.safeParse(json); + + if (!result.success) { + throw new AuthValidationError( + `Invalid token response from server: ${result.error.message}` ); } + + return result.data; } -export async function getTokenFromDeviceCode( - deviceCode: string -): Promise { - try { - await delay(1000); +export async function renewAccessToken( + refreshToken: string +): Promise { + const searchParams = new URLSearchParams(); + searchParams.set("grant_type", "refresh_token"); + searchParams.set("refresh_token", refreshToken); + searchParams.set("client_id", AUTH_CLIENT_ID); + + const response = await authClient.post("oauth/token", { + body: searchParams.toString(), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + throwHttpErrors: false, + }); + + const json = await response.json(); + + if (!response.ok) { + const errorResult = OAuthErrorSchema.safeParse(json); + + if (!errorResult.success) { + throw new AuthApiError(`Token refresh failed: ${response.statusText}`); + } - const deviceInfo = deviceCodeToTokenMap.get(deviceCode); + const { error, error_description } = errorResult.data; + throw new AuthApiError(error_description ?? `OAuth error: ${error}`); + } - if (!deviceInfo) { - return null; - } + const result = TokenResponseSchema.safeParse(json); - const elapsed = Date.now() - deviceInfo.startTime; + if (!result.success) { + throw new AuthValidationError( + `Invalid token response from server: ${result.error.message}` + ); + } - if (elapsed < deviceInfo.readyAfter) { - return null; - } + return result.data; +} - const mockResponse: TokenResponse = { - token: `mock-token-${Date.now()}`, - email: "shahart@base44.com", - name: "Shahar Talmi", - }; +export async function getUserInfo( + accessToken: string +): Promise { + const response = await authClient.get("oauth/userinfo", { + headers: { Authorization: `Bearer ${accessToken}` }, + }); - const result = TokenResponseSchema.safeParse(mockResponse); - if (!result.success) { - throw new AuthValidationError( - "Invalid token response from server", - result.error.issues.map((issue) => ({ - message: issue.message, - path: issue.path.map(String), - })) - ); - } + if (!response.ok) { + throw new AuthApiError(`Failed to fetch user info: ${response.status}`); + } - deviceCodeToTokenMap.delete(deviceCode); - return result.data; - } catch (error) { - if (error instanceof AuthValidationError || error instanceof AuthApiError) { - throw error; - } - throw new AuthApiError( - "Failed to retrieve token from device code", - error instanceof Error ? error : new Error(String(error)) + const result = UserInfoSchema.safeParse(await response.json()); + + if (!result.success) { + throw new AuthValidationError( + `Invalid UserInfo response from server: ${result.error.message}` ); } + + return result.data; } diff --git a/src/core/auth/authClient.ts b/src/core/auth/authClient.ts new file mode 100644 index 00000000..18a158be --- /dev/null +++ b/src/core/auth/authClient.ts @@ -0,0 +1,16 @@ +import ky from "ky"; +import { getBase44ApiUrl } from "../consts.js"; + +/** + * Separate ky instance for OAuth endpoints. + * These don't need Authorization headers (they use client_id + tokens in body). + */ +const authClient = ky.create({ + prefixUrl: getBase44ApiUrl(), + headers: { + "User-Agent": "Base44 CLI", + }, +}); + +export default authClient; + diff --git a/src/core/auth/config.ts b/src/core/auth/config.ts index 594fa0b4..393fb2ca 100644 --- a/src/core/auth/config.ts +++ b/src/core/auth/config.ts @@ -1,8 +1,15 @@ import { getAuthFilePath } from "../consts.js"; import { readJsonFile, writeJsonFile, deleteFile } from "../utils/fs.js"; +import { renewAccessToken } from "./api.js"; import { AuthDataSchema } from "./schema.js"; import type { AuthData } from "./schema.js"; +// Buffer time before expiration to trigger proactive refresh (60 seconds) +const TOKEN_REFRESH_BUFFER_MS = 60 * 1000; + +// Lock to prevent concurrent token refreshes +let refreshPromise: Promise | null = null; + export async function readAuth(): Promise { try { const parsed = await readJsonFile(getAuthFilePath()); @@ -65,3 +72,46 @@ export async function deleteAuth(): Promise { ); } } + +/** + * Checks if the access token is expired or about to expire. + */ +export function isTokenExpired(auth: AuthData): boolean { + return Date.now() >= auth.expiresAt - TOKEN_REFRESH_BUFFER_MS; +} + +/** + * Refreshes the access token and saves the new tokens. + * Returns the new access token, or null if refresh failed. + * Uses a lock to prevent concurrent refresh requests. + */ +export async function refreshAndSaveTokens(): Promise { + // If a refresh is already in progress, wait for it + if (refreshPromise) { + return refreshPromise; + } + + refreshPromise = (async () => { + try { + const auth = await readAuth(); + const tokenResponse = await renewAccessToken(auth.refreshToken); + + await writeAuth({ + ...auth, + accessToken: tokenResponse.accessToken, + refreshToken: tokenResponse.refreshToken, + expiresAt: Date.now() + tokenResponse.expiresIn * 1000, + }); + + return tokenResponse.accessToken; + } catch { + // Refresh failed - delete auth, user needs to login again + await deleteAuth(); + return null; + } finally { + refreshPromise = null; + } + })(); + + return refreshPromise; +} diff --git a/src/core/auth/schema.ts b/src/core/auth/schema.ts index e689569d..abe39a2d 100644 --- a/src/core/auth/schema.ts +++ b/src/core/auth/schema.ts @@ -2,27 +2,72 @@ import { z } from "zod"; // Auth data schema (stored locally) export const AuthDataSchema = z.object({ - token: z.string().min(1, "Token cannot be empty"), + accessToken: z.string().min(1, "Token cannot be empty"), + refreshToken: z.string().min(1, "Refresh token cannot be empty"), + expiresAt: z.number().int().positive("Expires at must be a positive integer"), email: z.email(), name: z.string().min(1, "Name cannot be empty"), }); export type AuthData = z.infer; -// API response schemas -export const DeviceCodeResponseSchema = z.object({ - deviceCode: z.string().min(1, "Device code cannot be empty"), - userCode: z.string().min(1, "User code cannot be empty"), - verificationUrl: z.url("Invalid verification URL"), - expiresIn: z.number().int().positive("Expires in must be a positive integer"), -}); +export const DeviceCodeResponseSchema = z + .object({ + device_code: z.string().min(1, "Device code cannot be empty"), + user_code: z.string().min(1, "User code cannot be empty"), + verification_uri: z.url("Invalid verification URL"), + verification_uri_complete: z.url("Invalid complete verification URL"), + expires_in: z + .number() + .int() + .positive("Expires in must be a positive integer"), + interval: z + .number() + .int() + .positive("Interval in must be a positive integer"), + }) + .transform((data) => ({ + deviceCode: data.device_code, + userCode: data.user_code, + verificationUri: data.verification_uri, + verificationUriComplete: data.verification_uri_complete, + expiresIn: data.expires_in, + interval: data.interval, + })); export type DeviceCodeResponse = z.infer; -export const TokenResponseSchema = z.object({ - token: z.string().min(1, "Token cannot be empty"), - email: z.email("Invalid email address"), - name: z.string().min(1, "Name cannot be empty"), -}); +export const TokenResponseSchema = z + .object({ + access_token: z.string().min(1, "Token cannot be empty"), + token_type: z.string().min(1, "Token type cannot be empty"), + expires_in: z + .number() + .int() + .positive("Expires in must be a positive integer"), + refresh_token: z.string().min(1, "Refresh token cannot be empty"), + scope: z.string().optional(), + }) + .transform((data) => ({ + accessToken: data.access_token, + tokenType: data.token_type, + expiresIn: data.expires_in, + refreshToken: data.refresh_token, + scope: data.scope, + })); export type TokenResponse = z.infer; + +export const OAuthErrorSchema = z.object({ + error: z.string(), + error_description: z.string().optional(), +}); + +export type OAuthError = z.infer; + +export const UserInfoSchema = z.object({ + email: z.email(), + name: z.string(), +}); + +export type UserInfoResponse = z.infer; diff --git a/src/core/consts.ts b/src/core/consts.ts index 7fbb9b4b..a7909326 100644 --- a/src/core/consts.ts +++ b/src/core/consts.ts @@ -20,3 +20,11 @@ export function getProjectConfigPatterns() { "config.json", ]; } + +export const AUTH_CLIENT_ID = "base44_cli"; + +const DEFAULT_API_URL = "https://app.base44.com"; + +export function getBase44ApiUrl(): string { + return process.env.BASE44_API_URL || DEFAULT_API_URL; +} diff --git a/src/core/errors.ts b/src/core/errors.ts index 15812e38..c9a64640 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -6,10 +6,7 @@ export class AuthApiError extends Error { } export class AuthValidationError extends Error { - constructor( - message: string, - public readonly issues: Array<{ message: string; path: string[] }> - ) { + constructor(message: string) { super(message); this.name = "AuthValidationError"; } diff --git a/src/core/resources/entity/config.ts b/src/core/resources/entity/config.ts index 37a8fff5..5b603b37 100644 --- a/src/core/resources/entity/config.ts +++ b/src/core/resources/entity/config.ts @@ -34,4 +34,3 @@ export async function readAllEntities(entitiesDir: string): Promise { return entities; } - diff --git a/src/core/utils/httpClient.ts b/src/core/utils/httpClient.ts new file mode 100644 index 00000000..f16bdb4a --- /dev/null +++ b/src/core/utils/httpClient.ts @@ -0,0 +1,75 @@ +import ky from "ky"; +import type { KyRequest, KyResponse, NormalizedOptions } from "ky"; +import { getBase44ApiUrl } from "../consts.js"; +import { + readAuth, + refreshAndSaveTokens, + isTokenExpired, +} from "../auth/config.js"; + +// Track requests that have already been retried to prevent infinite loops +const retriedRequests = new WeakSet(); + +/** + * Handles 401 responses by refreshing the token and retrying the request. + * Only retries once per request to prevent infinite loops. + */ +async function handleUnauthorized( + request: KyRequest, + _options: NormalizedOptions, + response: KyResponse +): Promise { + if (response.status !== 401) { + return; + } + + // Prevent infinite retry loop - only retry once per request + if (retriedRequests.has(request)) { + return; + } + + const newAccessToken = await refreshAndSaveTokens(); + + if (!newAccessToken) { + // Refresh failed, let the 401 propagate + return; + } + + // Mark this request as retried and retry with new token + retriedRequests.add(request); + return ky(request, { + headers: { Authorization: `Bearer ${newAccessToken}` }, + }); +} + +const httpClient = ky.create({ + prefixUrl: getBase44ApiUrl(), + headers: { + "User-Agent": "Base44 CLI", + }, + hooks: { + beforeRequest: [ + async (request) => { + try { + const auth = await readAuth(); + + // Proactively refresh if token is expired or about to expire + if (isTokenExpired(auth)) { + const newAccessToken = await refreshAndSaveTokens(); + if (newAccessToken) { + request.headers.set("Authorization", `Bearer ${newAccessToken}`); + return; + } + } + + request.headers.set("Authorization", `Bearer ${auth.accessToken}`); + } catch { + // No auth available, continue without header + } + }, + ], + afterResponse: [handleUnauthorized], + }, +}); + +export default httpClient; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 844adcf0..b6995bfe 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -1 +1,2 @@ export * from "./fs.js"; +export * from "./httpClient.js";