Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
6520e73
fix(login): report an existing session instead of re-prompting
kojiwakayama Aug 15, 2026
4d71f81
Keep bare login aligned with stored sessions
kojiwakayama Aug 15, 2026
53f2c7c
fix(login): say when an existing session came from the environment
kwakayama Aug 15, 2026
f635412
fix(login): bound the existing-session preflight with a deadline
kojiwakayama Aug 15, 2026
bbc148f
Bound login preflight to one credential deadline
kojiwakayama Aug 15, 2026
cf5c736
Keep login output on one declared contract
kojiwakayama Aug 15, 2026
4637eb3
Keep environment-backed account switching truthful
kojiwakayama Aug 15, 2026
e1474e2
Keep environment login precedence truthful
kojiwakayama Aug 15, 2026
56e7723
Describe environment precedence without erasing stored state
kojiwakayama Aug 15, 2026
8d5f92d
Keep credential precedence explicit in login guidance
kojiwakayama Aug 15, 2026
80f9ec6
Keep unverified credentials out of login claims
kojiwakayama Aug 15, 2026
dc149ce
Honor login JSON output for valid existing sessions
kojiwakayama Aug 15, 2026
4fec6c9
Keep JSON login failures machine-readable
kojiwakayama Aug 15, 2026
3444f67
Preserve login preflight network failures
kojiwakayama Aug 15, 2026
cf3e1c9
Preserve login JSON validation failure causes
kojiwakayama Aug 15, 2026
33770c9
Preserve login JSON validation failure classes
kojiwakayama Aug 15, 2026
d96d0e5
Preserve environment login validation priority
kojiwakayama Aug 15, 2026
32bdbf6
Preserve malformed environment login failures
kojiwakayama Aug 15, 2026
8cc8b84
Keep JSON login validation outcomes precise
kojiwakayama Aug 15, 2026
5ea395b
Keep explicit JSON login failures routable
kojiwakayama Aug 15, 2026
dbf7b7d
Keep shell token precedence visible in login
kojiwakayama Aug 15, 2026
e863559
Honor dotenv token precedence in login
kojiwakayama Aug 15, 2026
d9edc39
Keep stored-token precedence over dotenv login
kojiwakayama Aug 15, 2026
0b653d5
Keep login precedence mergeable with main
kojiwakayama Aug 15, 2026
590ee3b
Honor config-file token precedence in login
kojiwakayama Aug 15, 2026
0d5b04e
Honor config API metadata during login preflight
kojiwakayama Aug 15, 2026
854cc5f
Respect configured API URL for login preflight
kojiwakayama Aug 15, 2026
de23a1e
Keep login preflight bounded to JSON config
kojiwakayama Aug 15, 2026
99c1df5
Clarify config-token account switching
kojiwakayama Aug 15, 2026
84a2297
Stop login under rejected authoritative tokens
kojiwakayama Aug 15, 2026
4cf0e39
Stop replacement login after authoritative validation outages
kojiwakayama Aug 15, 2026
bddb5e9
Propagate unexpected strict credential failures
kojiwakayama Aug 15, 2026
b5f2b63
Align auth consumers with config-file credentials
kojiwakayama Aug 15, 2026
eabcfff
Clarify login JSON-mode method limits
kojiwakayama Aug 15, 2026
b402e05
Keep CLI authentication scoped to the command target
kojiwakayama Aug 15, 2026
9f00d33
Preserve dotenv fallback after stored session expiry
kojiwakayama Aug 15, 2026
65a38c2
Let init deploy with its validated project credential
kojiwakayama Aug 15, 2026
9559ebf
Keep authentication fallbacks usable by target commands
kojiwakayama Aug 15, 2026
029df04
Preserve stored sessions through validation outages
kojiwakayama Aug 16, 2026
840251c
Keep unavailable stored credentials authoritative
kojiwakayama Aug 16, 2026
65d5390
Prevent login help from promising ineffective account switches
kojiwakayama Aug 16, 2026
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
244 changes: 244 additions & 0 deletions cli/auth/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,250 @@ describe("Login Module", { sanitizeOps: false, sanitizeResources: false }, () =>
resetInteractiveMode();
}
});

it("reports an existing valid session instead of asking for a token again", async () => {
// `veryfront login` is step 1 of the documented deploy journey. Run by an
// already-authenticated developer it prompted for a token and exited 1,
// which makes the first documented step fail for the common case.
const originalFetch = globalThis.fetch;
const originalLog = console.log;
const output: string[] = [];
await saveToken("stored-valid-token", testEnv);

try {
setNonInteractive(true);
globalThis.fetch = (() =>
Promise.resolve(
Response.json({ id: "user-123", email: "test@example.com" }),
)) as typeof fetch;
console.log = (message?: unknown) => output.push(String(message));

const { login } = await import("./login.ts");
const result = await login(undefined, testEnv);

assertEquals(result, { id: "user-123", email: "test@example.com" });
const printed = output.join("\n");
assertStringIncludes(printed, "test@example.com");
// Must not have fallen through to the token prompt.
assertEquals(printed.includes("Enter your API token"), false);
// Never echo the stored credential.
assertEquals(printed.includes("stored-valid-token"), false);
} finally {
console.log = originalLog;
globalThis.fetch = originalFetch;
resetInteractiveMode();
await safeDeleteToken();
}
});

it("falls back to a valid stored session when the env token is invalid", async () => {
const originalFetch = globalThis.fetch;
const originalLog = console.log;
const output: string[] = [];
const requestedAuth: string[] = [];
await saveToken("stored-valid-token", testEnv);

try {
setNonInteractive(true);
globalThis.fetch = ((_: string | URL | Request, init?: RequestInit) => {
const auth = String(new Headers(init?.headers).get("authorization") ?? "");
requestedAuth.push(auth);
if (auth === "Bearer env-invalid-token") {
return Promise.resolve(new Response(null, { status: 401 }));
}
return Promise.resolve(
Response.json({ id: "user-123", email: "stored@example.com" }),
);
}) as typeof fetch;
console.log = (message?: unknown) => output.push(String(message));

const { login } = await import("./login.ts");
const env = { ...testEnv, apiToken: "env-invalid-token" };
const result = await login(undefined, env);

assertEquals(result, { id: "user-123", email: "stored@example.com" });
assertEquals(requestedAuth, [
"Bearer env-invalid-token",
"Bearer stored-valid-token",
]);
const printed = output.join("\n");
assertStringIncludes(printed, "stored@example.com");
assertEquals(printed.includes("Enter your API token"), false);
assertEquals(printed.includes("stored-valid-token"), false);
} finally {
console.log = originalLog;
globalThis.fetch = originalFetch;
resetInteractiveMode();
await safeDeleteToken();
}
});

it("says an environment session came from the environment, since nothing is stored", async () => {
// The variable is commonly set by a `.env` in the working directory the
// developer has forgotten about — the case `whoami` now names. Reporting a
// bare "already authenticated" implies `login` stored something, when the
// session actually ends at the directory boundary.
const originalFetch = globalThis.fetch;
const originalLog = console.log;
const output: string[] = [];

try {
setNonInteractive(true);
globalThis.fetch = (() =>
Promise.resolve(
new Response(JSON.stringify({ data: [], page_info: {} }), {
status: 200,
headers: { "content-type": "application/json" },
}),
)) as typeof fetch;
console.log = (message?: unknown) => output.push(String(message));

const { login } = await import("./login.ts");
const result = await login(undefined, { ...testEnv, apiToken: "vf_env_only" });

assertEquals(result, { authenticated: true, type: "apiKey" });
const printed = output.join("\n");
assertStringIncludes(printed, "VERYFRONT_API_TOKEN");
assertStringIncludes(printed, "no stored login");
// Nothing was persisted, and the credential is never echoed.
assertEquals(await readToken(testEnv), null);
assertEquals(printed.includes("vf_env_only"), false);
} finally {
console.log = originalLog;
globalThis.fetch = originalFetch;
resetInteractiveMode();
await safeDeleteToken();
}
});

it("does not claim the environment when the session is a stored login", async () => {
const originalFetch = globalThis.fetch;
const originalLog = console.log;
const output: string[] = [];
await saveToken("stored-valid-token", testEnv);

try {
setNonInteractive(true);
globalThis.fetch = (() =>
Promise.resolve(
Response.json({ id: "user-123", email: "test@example.com" }),
)) as typeof fetch;
console.log = (message?: unknown) => output.push(String(message));

const { login } = await import("./login.ts");
await login(undefined, testEnv);

const printed = output.join("\n");
assertEquals(printed.includes("no stored login"), false);
assertEquals(printed.includes("VERYFRONT_API_TOKEN"), false);
} finally {
console.log = originalLog;
globalThis.fetch = originalFetch;
resetInteractiveMode();
await safeDeleteToken();
}
});

it("does not hang when the API accepts the connection but never answers", async () => {
// The existing-session preflight is best-effort. Without a deadline a
// stalled API blocks bare `login` forever, so it never reaches the normal
// sign-in flow this change promises as the fallback.
const originalFetch = globalThis.fetch;
await saveToken("stored-valid-token", testEnv);

try {
setNonInteractive(true);
const { __setExistingSessionTimeoutForTests } = await import("./login.ts");
__setExistingSessionTimeoutForTests(50);

// Models a genuinely stalled request: the connection is accepted and the
// promise settles only once the abort signal fires. If the signal is not
// threaded through, this never resolves and the race below reports it.
globalThis.fetch = ((_input: string | URL | Request, init?: RequestInit) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new DOMException("The signal has been aborted", "AbortError"));
});
})) as typeof fetch;

const { login } = await import("./login.ts");
const outcome = await Promise.race([
login(undefined, testEnv),
new Promise((resolve) =>
setTimeout(() => resolve("TIMED_OUT"), 3000)
),
]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

assertEquals(outcome, null);
} finally {
const { __setExistingSessionTimeoutForTests } = await import("./login.ts");
__setExistingSessionTimeoutForTests();
globalThis.fetch = originalFetch;
resetInteractiveMode();
await safeDeleteToken();
}
});

it("shares one preflight deadline across environment and stored credentials", async () => {
const originalFetch = globalThis.fetch;
const signals: AbortSignal[] = [];
await saveToken("stored-valid-token", testEnv);

try {
setNonInteractive(true);
const { __setExistingSessionTimeoutForTests } = await import("./login.ts");
__setExistingSessionTimeoutForTests(50);

globalThis.fetch = ((_input: string | URL | Request, init?: RequestInit) =>
new Promise((_resolve, reject) => {
const signal = init?.signal;
if (!signal) {
return;
}
signals.push(signal);
const rejectAbort = () =>
reject(new DOMException("The signal has been aborted", "AbortError"));
if (signal.aborted) {
rejectAbort();
} else signal.addEventListener("abort", rejectAbort, { once: true });
})) as typeof fetch;

const { login } = await import("./login.ts");
const result = await login(undefined, { ...testEnv, apiToken: "env-token" });

assertEquals(result, null);
assertEquals(signals.length, 2);
assertEquals(signals[0], signals[1]);
} finally {
const { __setExistingSessionTimeoutForTests } = await import("./login.ts");
__setExistingSessionTimeoutForTests();
globalThis.fetch = originalFetch;
resetInteractiveMode();
await safeDeleteToken();
}
});

it("still re-authenticates when a method is explicitly requested", async () => {
// Switching accounts must stay possible: an explicit method is intent to
// sign in again, so the existing session must not short-circuit it.
const originalFetch = globalThis.fetch;
await saveToken("stored-valid-token", testEnv);

try {
setNonInteractive(true);
globalThis.fetch = (() =>
Promise.resolve(
Response.json({ id: "user-123", email: "test@example.com" }),
)) as typeof fetch;

const { login } = await import("./login.ts");
assertEquals(await login("token", testEnv), null);
} finally {
globalThis.fetch = originalFetch;
resetInteractiveMode();
await safeDeleteToken();
}
});
});

describe("OAuth state", () => {
Expand Down
106 changes: 106 additions & 0 deletions cli/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,34 @@ export type AuthIdentity = UserInfo | ApiKeyIdentity;

export interface CredentialValidationOptions {
throwOnNetworkError?: boolean;
/** Reuse a caller-owned cancellation or deadline across validation attempts. */
signal?: AbortSignal;
/**
* Abort the request after this many milliseconds. Callers that must stay
* responsive pass a deadline; omitting it keeps the previous unbounded
* behaviour for callers that are already the user's main action.
*/
timeoutMs?: number;
}

/**
* How long the bare-`login` preflight will wait on a credential check.
*
* That check is best-effort: it exists only to say "already logged in" instead
* of prompting. A connection the API accepts but never answers would otherwise
* block sign-in forever, so the check is bounded and a timeout simply falls
* through to the normal flow.
*/
const DEFAULT_EXISTING_SESSION_TIMEOUT_MS = 5_000;
let existingSessionTimeoutMs = DEFAULT_EXISTING_SESSION_TIMEOUT_MS;

/** Test seam: shrink the preflight deadline so a stall is observable quickly. */
export function __setExistingSessionTimeoutForTests(ms?: number): void {
existingSessionTimeoutMs = ms ?? DEFAULT_EXISTING_SESSION_TIMEOUT_MS;
}

function requestSignal(options: CredentialValidationOptions): AbortSignal | undefined {
return options.signal ?? (options.timeoutMs ? AbortSignal.timeout(options.timeoutMs) : undefined);
}

const AUTH_OPTIONS: { id: AuthMethod; label: string }[] = [
Expand Down Expand Up @@ -77,6 +105,7 @@ export async function validateToken(
try {
const response = await fetch(`${getApiUrl(env).replace(/\/$/, "")}/me`, {
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
signal: requestSignal(options),
});

if (!response.ok) {
Expand Down Expand Up @@ -114,6 +143,7 @@ async function validateApiKey(
url.searchParams.set("limit", "1");
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
signal: requestSignal(options),
});
await response.body?.cancel();
return response.ok;
Expand Down Expand Up @@ -260,10 +290,86 @@ async function loginWithToken(): Promise<string | null> {
return token;
}

/**
* Report an already-valid session, or null when there is nothing usable.
*
* Returns null on any failure — no credential, a rejected one, or an
* unreachable API — so the caller falls through to the normal sign-in flow
* rather than blocking on a network hiccup.
*/
async function describeExistingSession(
env: EnvironmentConfig,
): Promise<AuthIdentity | null> {
const candidates: { token: string; source: "environment" | "stored" }[] = [];
if (env.apiToken) candidates.push({ token: env.apiToken, source: "environment" });
const storedToken = await readToken(env);
if (storedToken) candidates.push({ token: storedToken, source: "stored" });
if (candidates.length === 0) return null;
const signal = AbortSignal.timeout(existingSessionTimeoutMs);

for (const { token, source } of candidates) {
let identity: AuthIdentity | null;
try {
// Bounded: this preflight only decides whether to say "already logged in"
// instead of prompting. An API that accepts the connection and never
// answers would otherwise block sign-in entirely, so a stall falls
// through to the normal flow rather than holding the command open.
identity = await validateCredential(token, env, {
signal,
});
} catch {
continue;
Comment thread
kojiwakayama marked this conversation as resolved.
Resolved
}
if (!identity) continue;
Comment thread
kojiwakayama marked this conversation as resolved.
Resolved

if (isJsonMode()) {
Comment thread
kojiwakayama marked this conversation as resolved.
Resolved
await outputJson(
createSuccessEnvelope("login", { authenticated: true, existing: true, source }),
);
Comment thread
kojiwakayama marked this conversation as resolved.
Resolved
return identity;
}

console.log();
console.log(
" ✓ " +
(isApiKeyIdentity(identity)
? "Already authenticated with an API key"
: "Already logged in as " + brand(identity.email)),
Comment thread
kojiwakayama marked this conversation as resolved.
Resolved
);
// An environment credential is a valid session for every command, but this
// path stores nothing, and `login` implies it did. The variable is often set
// by a `.env` in the working directory the developer has forgotten about —
// the case `whoami` now names — so the session ends at the directory
// boundary. Say so rather than let them discover it elsewhere.
if (source === "environment") {
console.log(" " + dim("Using VERYFRONT_API_TOKEN; no stored login was created."));
Comment thread
kojiwakayama marked this conversation as resolved.
Resolved
}
console.log(
" " +
dim(
"Run 'veryfront login --token' (or --google, --github, --microsoft) to sign in again.",
Comment thread
kojiwakayama marked this conversation as resolved.
Resolved
),
Comment thread
kojiwakayama marked this conversation as resolved.
Resolved
);
return identity;
}

return null;
}

export async function login(
method?: AuthMethod,
env: EnvironmentConfig = getEnvironmentConfig(),
): Promise<AuthIdentity | null> {
// A bare `veryfront login` is the documented first step of the deploy
// journey, and an already-authenticated developer ran it only to be asked for
// a token they do not need. Report the session instead. An explicit method is
// intent to sign in again, so account switching still works — and a session
// that no longer validates falls through to the normal flow.
if (method === undefined) {
Comment thread
kojiwakayama marked this conversation as resolved.
Resolved
const existing = await describeExistingSession(env);
if (existing) return existing;
Comment thread
kojiwakayama marked this conversation as resolved.
Resolved
}

if (!isInteractive() && (method === undefined || method === "token")) {
cliLogger.error("Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode.");
return null;
Expand Down
2 changes: 1 addition & 1 deletion cli/commands/login/command-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const loginHelp: CommandHelp = {
"veryfront login --token",
],
notes: [
"Without options, prompts for authentication method",
"Without options, a valid session returns immediately. Use an explicit method to sign in again",
Comment thread
kojiwakayama marked this conversation as resolved.
Resolved
"OAuth methods open browser for authentication",
"Token is stored in ~/.config/veryfront/token",
"Exits 1 when no credential was obtained, so scripts can gate on it",
Expand Down
Loading
Loading