Skip to content

Commit 14e33ea

Browse files
feat(cli): add interactive login prompt with provider selection (#16093)
* feat(cli): add interactive login prompt with provider selection When running `fern login` without an active Auth0 session, the CLI now presents an interactive menu to choose a login provider: - Continue with GitHub - Continue with Google - Continue with Postman - Continue with SSO (prompts for email, resolves enterprise connection) If an active Auth0 session exists, authentication completes silently as before (via prompt=none silent auth check). Non-TTY environments and --device-code / --email flags are unaffected. Co-Authored-By: rishabh <rishabh@buildwithfern.com> * fix: use local token check instead of browser-based silent auth Replace prompt=none browser-based silent auth with isLoggedIn() check. Previously, the CLI would open a browser tab with prompt=none to detect an existing Auth0 session, causing a brief browser flash for users without a session. Now the CLI checks the locally stored token instead: - If a valid token exists → open browser directly (Auth0 session handles it) - If no token → show the interactive prompt without touching the browser Also removes the now-unused trySilentAuth(), NoSessionError, and parseAuthResponse() from doAuth0LoginFlow.ts. Co-Authored-By: rishabh <rishabh@buildwithfern.com> * fix: add device-code fallback to interactive prompt path Extract loginWithDeviceCodeFallback() helper that wraps doAuth0LoginFlow with a try/catch falling back to doAuth0DeviceAuthorizationFlow. Both the main login path and the interactive prompt path (promptAndLogin) now use this helper, ensuring transient failures (port conflicts, browser launch failures, Auth0 errors) fall back to device-code flow instead of surfacing as hard errors. Co-Authored-By: rishabh <rishabh@buildwithfern.com> * fix(cli): address Claude review - forward connection to device-code fallback, validate email, fix Wizard message Co-Authored-By: rishabh <rishabh@buildwithfern.com> * fix(cli): trim email input in promptForEmail Co-Authored-By: rishabh <rishabh@buildwithfern.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: rishabh <rishabh@buildwithfern.com>
1 parent 4bf51a3 commit 14e33ea

6 files changed

Lines changed: 106 additions & 9 deletions

File tree

packages/cli/cli-v2/src/init/Wizard.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,6 @@ export class Wizard {
198198
return undefined;
199199
}
200200

201-
this.context.stderr.info(` ${Icons.info} Opening browser to log in to Fern...`);
202201
const taskContext = new TaskContextAdapter({ context: this.context, logLevel: LogLevel.Info });
203202
const { accessToken, idToken } = await getTokenFromAuth0(taskContext, {
204203
useDeviceCodeFlow: false,
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
- summary: |
2+
Add interactive login prompt to `fern login`. When no active Auth0 session
3+
exists, the CLI now presents a menu to choose a login provider (GitHub,
4+
Google, Postman, or SSO). Selecting SSO prompts for an email address and
5+
routes directly to the organization's identity provider. If an active
6+
session already exists, authentication completes silently as before.
7+
type: feat

packages/cli/login/src/auth0-login/doAuth0DeviceAuthorizationFlow.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,14 @@ export async function doAuth0DeviceAuthorizationFlow({
2828
auth0Domain,
2929
auth0ClientId,
3030
audience,
31-
context
31+
context,
32+
connection
3233
}: {
3334
auth0Domain: string;
3435
auth0ClientId: string;
3536
audience: string;
3637
context: TaskContext;
38+
connection?: string;
3739
}): Promise<Auth0TokenResponse> {
3840
const deviceCodeResponse = await axios.request<DeviceCodeResponse>({
3941
method: "POST",
@@ -42,7 +44,8 @@ export async function doAuth0DeviceAuthorizationFlow({
4244
data: qs.stringify({
4345
client_id: auth0ClientId,
4446
audience,
45-
scope: "openid profile email offline_access"
47+
scope: "openid profile email offline_access",
48+
...(connection != null ? { connection } : {})
4649
}),
4750
validateStatus: () => true
4851
});

packages/cli/login/src/auth0-login/doAuth0LoginFlow.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,6 @@ function constructAuth0Url({
165165
queryParams.set("connection", connection);
166166
}
167167

168-
// Force re-authentication to allow switching accounts.
169168
if (forceReauth) {
170169
queryParams.set("prompt", "login");
171170
}

packages/cli/login/src/constants.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,15 @@ export function getDashboardBaseUrl(): string {
88
"https://dashboard.buildwithfern.com"
99
);
1010
}
11+
12+
export interface LoginOption {
13+
label: string;
14+
connection: string;
15+
}
16+
17+
export const LOGIN_OPTIONS: LoginOption[] = [
18+
{ label: "Continue with GitHub", connection: "github" },
19+
{ label: "Continue with Google", connection: "google-oauth2" },
20+
{ label: "Continue with Postman", connection: "postman" },
21+
{ label: "Continue with SSO", connection: "enterprise-sso" }
22+
];

packages/cli/login/src/login.ts

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import { FernUserToken, storeToken } from "@fern-api/auth";
1+
import { FernUserToken, isLoggedIn, storeToken } from "@fern-api/auth";
22
import { getPosthogManager } from "@fern-api/posthog-manager";
33
import { TaskContext } from "@fern-api/task-context";
44
import chalk from "chalk";
5-
5+
import inquirer from "inquirer";
66
import { doAuth0DeviceAuthorizationFlow } from "./auth0-login/doAuth0DeviceAuthorizationFlow.js";
77
import { type Auth0TokenResponse, doAuth0LoginFlow } from "./auth0-login/doAuth0LoginFlow.js";
88
import { resolveSsoConnection } from "./auth0-login/resolveSsoConnection.js";
9-
import { AUTH0_CLIENT_ID, AUTH0_DOMAIN, getDashboardBaseUrl, VENUS_AUDIENCE } from "./constants.js";
9+
import { AUTH0_CLIENT_ID, AUTH0_DOMAIN, getDashboardBaseUrl, LOGIN_OPTIONS, VENUS_AUDIENCE } from "./constants.js";
1010

1111
export type { Auth0TokenResponse } from "./auth0-login/doAuth0LoginFlow.js";
1212

@@ -63,20 +63,97 @@ export async function getTokenFromAuth0(
6363
});
6464
}
6565

66+
// In TTY mode, check for a locally stored token to decide the flow:
67+
// - If already logged in → open the browser directly (Auth0 session handles it)
68+
// - If not logged in → show an interactive prompt so the user stays in the CLI
69+
if (!forceReauth && process.stdout.isTTY && !(await isLoggedIn())) {
70+
return await promptAndLogin(context);
71+
}
72+
73+
return await loginWithDeviceCodeFallback(context, { forceReauth });
74+
}
75+
76+
async function loginWithDeviceCodeFallback(
77+
context: TaskContext,
78+
{ forceReauth = false, connection }: { forceReauth?: boolean; connection?: string } = {}
79+
): Promise<Auth0TokenResponse> {
6680
try {
6781
return await doAuth0LoginFlow({
6882
context,
6983
auth0Domain: AUTH0_DOMAIN,
7084
auth0ClientId: AUTH0_CLIENT_ID,
7185
audience: VENUS_AUDIENCE,
72-
forceReauth
86+
forceReauth,
87+
connection
7388
});
7489
} catch {
7590
return await doAuth0DeviceAuthorizationFlow({
7691
auth0Domain: AUTH0_DOMAIN,
7792
auth0ClientId: AUTH0_CLIENT_ID,
7893
audience: VENUS_AUDIENCE,
79-
context
94+
context,
95+
connection
96+
});
97+
}
98+
}
99+
100+
async function promptAndLogin(context: TaskContext): Promise<Auth0TokenResponse> {
101+
const choices = LOGIN_OPTIONS.map((opt) => ({ name: opt.label, value: opt.connection }));
102+
103+
const selectedConnection = await promptForConnection(context, choices);
104+
105+
// SSO requires resolving the enterprise connection from the user's email
106+
if (selectedConnection === "enterprise-sso") {
107+
const ssoEmail = await promptForEmail(context);
108+
109+
const resolvedConnection = await resolveSsoConnection({
110+
dashboardBaseUrl: getDashboardBaseUrl(),
111+
email: ssoEmail
80112
});
113+
114+
return await loginWithDeviceCodeFallback(context, { connection: resolvedConnection });
81115
}
116+
117+
return await loginWithDeviceCodeFallback(context, { connection: selectedConnection });
118+
}
119+
120+
async function promptForConnection(
121+
context: TaskContext,
122+
choices: Array<{ name: string; value: string }>
123+
): Promise<string> {
124+
let result = "";
125+
await context.takeOverTerminal(async () => {
126+
const { connection } = await inquirer.prompt<{ connection: string }>([
127+
{
128+
type: "list",
129+
name: "connection",
130+
message: "How would you like to log in?",
131+
choices
132+
}
133+
]);
134+
result = connection;
135+
});
136+
return result;
137+
}
138+
139+
async function promptForEmail(context: TaskContext): Promise<string> {
140+
let result = "";
141+
await context.takeOverTerminal(async () => {
142+
const { email } = await inquirer.prompt<{ email: string }>([
143+
{
144+
type: "input",
145+
name: "email",
146+
message: "Enter your email address:",
147+
validate: (input: string) => {
148+
const trimmed = input.trim();
149+
if (trimmed.length === 0 || !trimmed.includes("@")) {
150+
return "Please enter a valid email address.";
151+
}
152+
return true;
153+
}
154+
}
155+
]);
156+
result = email.trim();
157+
});
158+
return result;
82159
}

0 commit comments

Comments
 (0)