Skip to content

Commit c19e0c7

Browse files
moranshe-maxclaude
andcommitted
feat(login): browser-based loopback OAuth flow with device-code fallback
Adds an RFC 8252 authorization-code-with-PKCE login path as the default, matching gcloud, gh, claude. The existing RFC 8628 device-code flow is kept as automatic fallback and explicit opt-out via --device-code. - core/auth/pkce.ts — S256 code_verifier/challenge + state generator. - core/auth/loopback-server.ts — picks a free port via get-port, binds 127.0.0.1, awaits /callback, validates state, returns success HTML, then unref() + closeAllConnections() so Node exits promptly after login completes. - core/auth/api.ts — buildAuthorizeUrl() and exchangeCodeForToken(). Scope set to "apps:read apps:write offline" so the auth-code grant receives a refresh_token (device-code path unchanged in behavior). - cli/commands/auth/loopback-flow.ts — orchestrates browser open, callback wait, token exchange. isHeadlessEnv() detects SSH/CI/no-DISPLAY. - cli/commands/auth/login-flow.ts — tries loopback first, falls back to device-code on headless env or any loopback failure. - cli/commands/auth/login.ts — new --device-code flag. Reuses existing writeAuth/refreshAndSaveTokens/AuthDataSchema — credential file layout unchanged, refresh flow unchanged. No new runtime dependencies (open, get-port already in devDependencies and bundled). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e2f38bb commit c19e0c7

6 files changed

Lines changed: 401 additions & 14 deletions

File tree

packages/cli/src/cli/commands/auth/login-flow.ts

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
writeAuth,
1616
} from "@/core/auth/index.js";
1717
import { AuthExpiredError, InternalError } from "@/core/errors.js";
18+
import { isHeadlessEnv, loginViaLoopback } from "./loopback-flow.js";
1819

1920
async function generateAndDisplayDeviceCode(
2021
log: Logger,
@@ -85,6 +86,19 @@ async function waitForAuthentication(
8586
return tokenResponse;
8687
}
8788

89+
async function loginViaDeviceCode(
90+
log: Logger,
91+
runTask: RunTaskFn,
92+
): Promise<TokenResponse> {
93+
const deviceCodeResponse = await generateAndDisplayDeviceCode(log, runTask);
94+
return waitForAuthentication(
95+
deviceCodeResponse.deviceCode,
96+
deviceCodeResponse.expiresIn,
97+
deviceCodeResponse.interval,
98+
runTask,
99+
);
100+
}
101+
88102
async function saveAuthData(
89103
response: TokenResponse,
90104
userInfo: UserInfoResponse,
@@ -100,22 +114,41 @@ async function saveAuthData(
100114
});
101115
}
102116

117+
interface LoginOptions {
118+
/** Force device-code flow, skipping the loopback browser flow. */
119+
deviceCode?: boolean;
120+
}
121+
103122
/**
104-
* Execute the login flow (device code authentication).
105-
* This function is separate from the command to avoid circular dependencies.
123+
* Execute the login flow.
124+
*
125+
* Default path: loopback (RFC 8252) with PKCE — opens browser, awaits localhost
126+
* callback. Falls back to device code on headless environments (SSH, CI, no
127+
* DISPLAY) or if the loopback flow fails for any reason.
106128
*/
107-
export async function login({
108-
log,
109-
runTask,
110-
}: CLIContext): Promise<RunCommandResult> {
111-
const deviceCodeResponse = await generateAndDisplayDeviceCode(log, runTask);
129+
export async function login(
130+
{ log, runTask }: CLIContext,
131+
options: LoginOptions = {},
132+
): Promise<RunCommandResult> {
133+
let token: TokenResponse | undefined;
112134

113-
const token = await waitForAuthentication(
114-
deviceCodeResponse.deviceCode,
115-
deviceCodeResponse.expiresIn,
116-
deviceCodeResponse.interval,
117-
runTask,
118-
);
135+
const useDeviceCode = options.deviceCode || isHeadlessEnv();
136+
137+
if (!useDeviceCode) {
138+
try {
139+
token = await loginViaLoopback(log, runTask);
140+
} catch (error) {
141+
log.warn(
142+
`Browser sign-in unavailable (${
143+
error instanceof Error ? error.message : String(error)
144+
}). Falling back to device code.`,
145+
);
146+
}
147+
}
148+
149+
if (!token) {
150+
token = await loginViaDeviceCode(log, runTask);
151+
}
119152

120153
const userInfo = await getUserInfo(token.accessToken);
121154

packages/cli/src/cli/commands/auth/login.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,10 @@ export function getLoginCommand(): Command {
88
requireAppConfig: false,
99
})
1010
.description("Authenticate with Base44")
11+
.option(
12+
"--device-code",
13+
"Use device code flow instead of opening a browser",
14+
false,
15+
)
1116
.action(login);
1217
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type { Logger } from "@base44-cli/logger";
2+
import open from "open";
3+
import type { RunTaskFn } from "@/cli/utils/runTask.js";
4+
import { theme } from "@/cli/utils/theme.js";
5+
import {
6+
buildAuthorizeUrl,
7+
exchangeCodeForToken,
8+
type TokenResponse,
9+
} from "@/core/auth/index.js";
10+
import { startLoopbackServer } from "@/core/auth/loopback-server.js";
11+
import { generatePkcePair, generateState } from "@/core/auth/pkce.js";
12+
13+
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000;
14+
15+
/**
16+
* Returns true if the current environment can't reach a localhost callback
17+
* (SSH, no DISPLAY on Linux, common CI signals). In these cases we skip the
18+
* loopback flow and use device code instead.
19+
*/
20+
export function isHeadlessEnv(): boolean {
21+
const env = process.env;
22+
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return true;
23+
if (env.CI || env.CONTINUOUS_INTEGRATION) return true;
24+
if (process.platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
25+
return true;
26+
}
27+
return false;
28+
}
29+
30+
/**
31+
* Loopback (RFC 8252) authorization-code-with-PKCE login.
32+
*
33+
* Caller is responsible for falling back to device code if this throws.
34+
*/
35+
export async function loginViaLoopback(
36+
log: Logger,
37+
runTask: RunTaskFn,
38+
): Promise<TokenResponse> {
39+
const server = await startLoopbackServer();
40+
const pkce = generatePkcePair();
41+
const state = generateState();
42+
43+
try {
44+
const authorizeUrl = buildAuthorizeUrl({
45+
redirectUri: server.redirectUri,
46+
state,
47+
codeChallenge: pkce.codeChallenge,
48+
});
49+
50+
log.info(
51+
`Opening your browser to sign in.\nIf it doesn't open, visit: ${theme.styles.dim(
52+
authorizeUrl,
53+
)}`,
54+
);
55+
56+
try {
57+
await open(authorizeUrl);
58+
} catch {
59+
// Browser open failed — the user can still paste the URL manually.
60+
}
61+
62+
const { code } = await runTask(
63+
"Waiting for browser sign-in...",
64+
async () => server.waitForCallback(state, CALLBACK_TIMEOUT_MS),
65+
{
66+
successMessage: "Browser sign-in completed",
67+
errorMessage: "Browser sign-in failed",
68+
},
69+
);
70+
71+
const token = await runTask(
72+
"Exchanging authorization code...",
73+
async () =>
74+
exchangeCodeForToken({
75+
code,
76+
redirectUri: server.redirectUri,
77+
codeVerifier: pkce.codeVerifier,
78+
}),
79+
{
80+
successMessage: "Authentication completed!",
81+
errorMessage: "Token exchange failed",
82+
},
83+
);
84+
85+
return token;
86+
} finally {
87+
server.close();
88+
}
89+
}

packages/cli/src/core/auth/api.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@ import {
1010
UserInfoSchema,
1111
} from "@/core/auth/schema.js";
1212
import { oauthClient } from "@/core/clients/index.js";
13+
import { getBase44ApiUrl } from "@/core/config.js";
1314
import { AUTH_CLIENT_ID } from "@/core/consts.js";
1415
import { ApiError, SchemaValidationError } from "@/core/errors.js";
1516

17+
const OAUTH_SCOPE = "apps:read apps:write offline";
18+
1619
export async function generateDeviceCode(): Promise<DeviceCodeResponse> {
1720
const response = await oauthClient.post("oauth/device/code", {
1821
json: {
1922
client_id: AUTH_CLIENT_ID,
20-
scope: "apps:read apps:write",
23+
scope: OAUTH_SCOPE,
2124
},
2225
throwHttpErrors: false,
2326
});
@@ -142,6 +145,70 @@ export async function renewAccessToken(
142145
return result.data;
143146
}
144147

148+
export function buildAuthorizeUrl(params: {
149+
redirectUri: string;
150+
state: string;
151+
codeChallenge: string;
152+
}): string {
153+
const search = new URLSearchParams({
154+
response_type: "code",
155+
client_id: AUTH_CLIENT_ID,
156+
redirect_uri: params.redirectUri,
157+
scope: OAUTH_SCOPE,
158+
state: params.state,
159+
code_challenge: params.codeChallenge,
160+
code_challenge_method: "S256",
161+
});
162+
return `${getBase44ApiUrl().replace(/\/$/, "")}/oauth/authorize?${search.toString()}`;
163+
}
164+
165+
export async function exchangeCodeForToken(params: {
166+
code: string;
167+
redirectUri: string;
168+
codeVerifier: string;
169+
}): Promise<TokenResponse> {
170+
const searchParams = new URLSearchParams();
171+
searchParams.set("grant_type", "authorization_code");
172+
searchParams.set("code", params.code);
173+
searchParams.set("redirect_uri", params.redirectUri);
174+
searchParams.set("client_id", AUTH_CLIENT_ID);
175+
searchParams.set("code_verifier", params.codeVerifier);
176+
177+
const response = await oauthClient.post("oauth/token", {
178+
body: searchParams.toString(),
179+
headers: {
180+
"Content-Type": "application/x-www-form-urlencoded",
181+
},
182+
throwHttpErrors: false,
183+
});
184+
185+
const json = await response.json();
186+
187+
if (!response.ok) {
188+
const errorResult = OAuthErrorSchema.safeParse(json);
189+
if (!errorResult.success) {
190+
throw new ApiError(`Token exchange failed: ${response.statusText}`, {
191+
statusCode: response.status,
192+
});
193+
}
194+
const { error, error_description } = errorResult.data;
195+
throw new ApiError(error_description ?? `OAuth error: ${error}`, {
196+
statusCode: response.status,
197+
});
198+
}
199+
200+
const result = TokenResponseSchema.safeParse(json);
201+
202+
if (!result.success) {
203+
throw new SchemaValidationError(
204+
"Invalid token response from server",
205+
result.error,
206+
);
207+
}
208+
209+
return result.data;
210+
}
211+
145212
export async function getUserInfo(
146213
accessToken: string,
147214
): Promise<UserInfoResponse> {

0 commit comments

Comments
 (0)