Skip to content
Open
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
59 changes: 46 additions & 13 deletions packages/cli/src/cli/commands/auth/login-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
writeAuth,
} from "@/core/auth/index.js";
import { AuthExpiredError, InternalError } from "@/core/errors.js";
import { isHeadlessEnv, loginViaLoopback } from "./loopback-flow.js";

async function generateAndDisplayDeviceCode(
log: Logger,
Expand Down Expand Up @@ -85,6 +86,19 @@ async function waitForAuthentication(
return tokenResponse;
}

async function loginViaDeviceCode(
log: Logger,
runTask: RunTaskFn,
): Promise<TokenResponse> {
const deviceCodeResponse = await generateAndDisplayDeviceCode(log, runTask);
return waitForAuthentication(
deviceCodeResponse.deviceCode,
deviceCodeResponse.expiresIn,
deviceCodeResponse.interval,
runTask,
);
}

async function saveAuthData(
response: TokenResponse,
userInfo: UserInfoResponse,
Expand All @@ -100,22 +114,41 @@ async function saveAuthData(
});
}

interface LoginOptions {
/** Force device-code flow, skipping the loopback browser flow. */
deviceCode?: boolean;
}

/**
* Execute the login flow (device code authentication).
* This function is separate from the command to avoid circular dependencies.
* Execute the login flow.
*
* Default path: loopback (RFC 8252) with PKCE — opens browser, awaits localhost
* callback. Falls back to device code on headless environments (SSH, CI, no
* DISPLAY) or if the loopback flow fails for any reason.
*/
export async function login({
log,
runTask,
}: CLIContext): Promise<RunCommandResult> {
const deviceCodeResponse = await generateAndDisplayDeviceCode(log, runTask);
export async function login(
{ log, runTask }: CLIContext,
options: LoginOptions = {},
): Promise<RunCommandResult> {
let token: TokenResponse | undefined;

const token = await waitForAuthentication(
deviceCodeResponse.deviceCode,
deviceCodeResponse.expiresIn,
deviceCodeResponse.interval,
runTask,
);
const useDeviceCode = options.deviceCode || isHeadlessEnv();

if (!useDeviceCode) {
try {
token = await loginViaLoopback(log, runTask);
} catch (error) {
log.warn(
`Browser sign-in unavailable (${
error instanceof Error ? error.message : String(error)
}). Falling back to device code.`,
);
}
}

if (!token) {
token = await loginViaDeviceCode(log, runTask);
}

const userInfo = await getUserInfo(token.accessToken);

Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/cli/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,10 @@ export function getLoginCommand(): Command {
requireAppConfig: false,
})
.description("Authenticate with Base44")
.option(
"--device-code",
"Use device code flow instead of opening a browser",
false,
)
.action(login);
}
89 changes: 89 additions & 0 deletions packages/cli/src/cli/commands/auth/loopback-flow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { Logger } from "@base44-cli/logger";
import open from "open";
import type { RunTaskFn } from "@/cli/utils/runTask.js";
import { theme } from "@/cli/utils/theme.js";
import {
buildAuthorizeUrl,
exchangeCodeForToken,
type TokenResponse,
} from "@/core/auth/index.js";
import { startLoopbackServer } from "@/core/auth/loopback-server.js";
import { generatePkcePair, generateState } from "@/core/auth/pkce.js";

const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000;

/**
* Returns true if the current environment can't reach a localhost callback
* (SSH, no DISPLAY on Linux, common CI signals). In these cases we skip the
* loopback flow and use device code instead.
*/
export function isHeadlessEnv(): boolean {
const env = process.env;
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return true;
if (env.CI || env.CONTINUOUS_INTEGRATION) return true;
if (process.platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
return true;
}
return false;
}

/**
* Loopback (RFC 8252) authorization-code-with-PKCE login.
*
* Caller is responsible for falling back to device code if this throws.
*/
export async function loginViaLoopback(
log: Logger,
runTask: RunTaskFn,
): Promise<TokenResponse> {
const server = await startLoopbackServer();
const pkce = generatePkcePair();
const state = generateState();

try {
const authorizeUrl = buildAuthorizeUrl({
redirectUri: server.redirectUri,
state,
codeChallenge: pkce.codeChallenge,
});

log.info(
`Opening your browser to sign in.\nIf it doesn't open, visit: ${theme.styles.dim(
authorizeUrl,
)}`,
);

try {
await open(authorizeUrl);
} catch {
// Browser open failed — the user can still paste the URL manually.
}

const { code } = await runTask(
"Waiting for browser sign-in...",
async () => server.waitForCallback(state, CALLBACK_TIMEOUT_MS),
{
successMessage: "Browser sign-in completed",
errorMessage: "Browser sign-in failed",
},
);

const token = await runTask(
"Exchanging authorization code...",
async () =>
exchangeCodeForToken({
code,
redirectUri: server.redirectUri,
codeVerifier: pkce.codeVerifier,
}),
{
successMessage: "Authentication completed!",
errorMessage: "Token exchange failed",
},
);

return token;
} finally {
server.close();
}
}
69 changes: 68 additions & 1 deletion packages/cli/src/core/auth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@ import {
UserInfoSchema,
} from "@/core/auth/schema.js";
import { oauthClient } from "@/core/clients/index.js";
import { getBase44ApiUrl } from "@/core/config.js";
import { AUTH_CLIENT_ID } from "@/core/consts.js";
import { ApiError, SchemaValidationError } from "@/core/errors.js";

const OAUTH_SCOPE = "apps:read apps:write offline";

export async function generateDeviceCode(): Promise<DeviceCodeResponse> {
const response = await oauthClient.post("oauth/device/code", {
json: {
client_id: AUTH_CLIENT_ID,
scope: "apps:read apps:write",
scope: OAUTH_SCOPE,
},
throwHttpErrors: false,
});
Expand Down Expand Up @@ -142,6 +145,70 @@ export async function renewAccessToken(
return result.data;
}

export function buildAuthorizeUrl(params: {
redirectUri: string;
state: string;
codeChallenge: string;
}): string {
const search = new URLSearchParams({
response_type: "code",
client_id: AUTH_CLIENT_ID,
redirect_uri: params.redirectUri,
scope: OAUTH_SCOPE,
state: params.state,
code_challenge: params.codeChallenge,
code_challenge_method: "S256",
});
return `${getBase44ApiUrl().replace(/\/$/, "")}/oauth/authorize?${search.toString()}`;
}

export async function exchangeCodeForToken(params: {
code: string;
redirectUri: string;
codeVerifier: string;
}): Promise<TokenResponse> {
const searchParams = new URLSearchParams();
searchParams.set("grant_type", "authorization_code");
searchParams.set("code", params.code);
searchParams.set("redirect_uri", params.redirectUri);
searchParams.set("client_id", AUTH_CLIENT_ID);
searchParams.set("code_verifier", params.codeVerifier);

const response = await oauthClient.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 ApiError(`Token exchange failed: ${response.statusText}`, {
statusCode: response.status,
});
}
const { error, error_description } = errorResult.data;
throw new ApiError(error_description ?? `OAuth error: ${error}`, {
statusCode: response.status,
});
}

const result = TokenResponseSchema.safeParse(json);

if (!result.success) {
throw new SchemaValidationError(
"Invalid token response from server",
result.error,
);
}

return result.data;
}

export async function getUserInfo(
accessToken: string,
): Promise<UserInfoResponse> {
Expand Down
Loading
Loading