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
1 change: 1 addition & 0 deletions cli/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
isApiKeyIdentity,
login,
logout,
misnamedApiKeyHint,
readToken,
saveToken,
type UserInfo,
Expand Down
218 changes: 218 additions & 0 deletions cli/auth/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3719,3 +3719,221 @@ describe("Interactive sign-in completion", () => {
}
});
});

describe("Missing-auth hint for VERYFRONT_API_KEY", () => {
const API_KEY_HINT = "VERYFRONT_API_KEY is set, but the CLI reads VERYFRONT_API_TOKEN. " +
"Set VERYFRONT_API_TOKEN to the same key, or run 'veryfront login'.";

async function runWhoami(
vars: {
VERYFRONT_API_KEY?: string;
VERYFRONT_API_TOKEN?: string;
VERYFRONT_API_TOKEN_FILE?: string;
VERYFRONT_API_URL?: string;
},
jsonMode: boolean,
apiKeyEnvFile?: string,
storedToken?: string,
apiUrlFromProjectEnv = false,
veryfrontJson?: Record<string, unknown>,
): Promise<{ result: unknown; output: string[] }> {
const dir = await makeTempDir({ prefix: "whoami-api-key-" });
const env = createTestEnvironmentConfig({
homeDir: dir,
xdgConfigHome: dir,
// veryfront.json steering only applies under the default API base.
apiBaseUrl: veryfrontJson ? undefined : "https://auth.example.test",
apiUrl: undefined,
apiToken: vars.VERYFRONT_API_TOKEN,
...(vars.VERYFRONT_API_URL ? { apiUrl: vars.VERYFRONT_API_URL } : {}),
});
const names = [
"VERYFRONT_API_KEY",
"VERYFRONT_API_TOKEN",
"VERYFRONT_API_TOKEN_FILE",
"VERYFRONT_API_URL",
] as const;
const originals = names.map((name) => getEnv(name));
const originalLog = console.log;
const output: string[] = [];
const { markEnvFileSource, markProcessEnvSource } = await import(
"veryfront/utils/env-loader"
);
try {
for (const name of names) {
const value = vars[name];
if (value === undefined) deleteEnv(name);
else setEnv(name, value);
}
if (apiKeyEnvFile) markEnvFileSource("VERYFRONT_API_KEY", apiKeyEnvFile);
if (storedToken) await saveToken(storedToken, env);
if (apiUrlFromProjectEnv) markEnvFileSource("VERYFRONT_API_URL", `${dir}/.env`);
if (veryfrontJson) {
await Deno.writeTextFile(`${dir}/veryfront.json`, JSON.stringify(veryfrontJson));
}
console.log = (...args: unknown[]) => output.push(args.map(String).join(" "));
setJsonMode(jsonMode);
const { whoami } = await import("./login.ts");
const { withCwd } = await import("#veryfront/testing/cwd.ts");
const result = await withMockFetch(
() => Promise.resolve(new Response(null, { status: 401 })),
() => withCwd(dir, () => whoami(env)),
);
return { result, output };
} finally {
setJsonMode(false);
console.log = originalLog;
markProcessEnvSource("VERYFRONT_API_KEY");
markProcessEnvSource("VERYFRONT_API_URL");
names.forEach((name, index) => {
const original = originals[index];
if (original === undefined) deleteEnv(name);
else setEnv(name, original);
});
await remove(dir, { recursive: true });
}
}

it("names VERYFRONT_API_TOKEN in whoami output when only VERYFRONT_API_KEY is set", async () => {
const { result, output } = await runWhoami({ VERYFRONT_API_KEY: "vf_misnamed_key" }, false);
const text = output.join("\n");

assertEquals(result, null);
assertStringIncludes(text, "Not logged in");
assertStringIncludes(
text,
"VERYFRONT_API_KEY is set, but the CLI reads VERYFRONT_API_TOKEN.",
);
assertStringIncludes(
text,
"Set VERYFRONT_API_TOKEN to the same key, or run 'veryfront login'.",
);
assertEquals(text.includes("Run 'veryfront login' to authenticate"), false);
assertEquals(text.includes("vf_misnamed_key"), false);
});

it("puts the hint in the whoami JSON envelope when only VERYFRONT_API_KEY is set", async () => {
const { result, output } = await runWhoami({ VERYFRONT_API_KEY: "vf_misnamed_key" }, true);

assertEquals(result, null);
assertEquals(JSON.parse(output.join("\n")), {
success: false,
command: "whoami",
error: {
code: "AUTHENTICATION_ERROR",
slug: "authentication-required",
registrySlug: "authentication-required",
message: `Not logged in. ${API_KEY_HINT}`,
context: { authenticated: false },
},
});
});

it("keeps the plain message when VERYFRONT_API_TOKEN is also set", async () => {
const { result, output } = await runWhoami({
VERYFRONT_API_KEY: "vf_misnamed_key",
VERYFRONT_API_TOKEN: "vf_rejected_token",
}, true);

assertEquals(result, null);
assertEquals(
JSON.parse(output.join("\n")).error.message,
"Not logged in. Run 'veryfront login' to authenticate.",
);
});

it("leaves the hint out for a whitespace-only VERYFRONT_API_KEY", async () => {
const { result, output } = await runWhoami({ VERYFRONT_API_KEY: " " }, true);

assertEquals(result, null);
assertEquals(
JSON.parse(output.join("\n")).error.message,
"Not logged in. Run 'veryfront login' to authenticate.",
);
});

it("keeps the hint when only veryfront.json names another API host", async () => {
const { result, output } = await runWhoami(
{ VERYFRONT_API_KEY: "vf_misnamed_key" },
true,
undefined,
undefined,
false,
{ apiUrl: "https://project.example/api" },
);

assertEquals(result, null);
assertEquals(JSON.parse(output.join("\n")).error.message, `Not logged in. ${API_KEY_HINT}`);
});

it("treats a whitespace-only VERYFRONT_API_TOKEN as unset", async () => {
const { result, output } = await runWhoami({
VERYFRONT_API_KEY: "vf_misnamed_key",
VERYFRONT_API_TOKEN: " ",
}, true);

assertEquals(result, null);
assertEquals(JSON.parse(output.join("\n")).error.message, `Not logged in. ${API_KEY_HINT}`);
});

it("keeps the plain message when VERYFRONT_API_KEY comes from a project .env", async () => {
const { result, output } = await runWhoami(
{ VERYFRONT_API_KEY: "vf_checkout_key" },
true,
"/srv/checkout/.env",
);

assertEquals(result, null);
assertEquals(
JSON.parse(output.join("\n")).error.message,
"Not logged in. Run 'veryfront login' to authenticate.",
);
});

it("keeps the plain message when a stored login is the credential that failed", async () => {
const { result, output } = await runWhoami(
{ VERYFRONT_API_KEY: "vf_misnamed_key" },
true,
undefined,
"vf_stored_session",
);

assertEquals(result, null);
assertEquals(
JSON.parse(output.join("\n")).error.message,
"Not logged in. Run 'veryfront login' to authenticate.",
);
});

it("keeps the plain message when the project steers the API host", async () => {
const { result, output } = await runWhoami(
{
VERYFRONT_API_KEY: "vf_misnamed_key",
VERYFRONT_API_URL: "https://attacker.example/api",
},
true,
undefined,
undefined,
true,
);

assertEquals(result, null);
assertEquals(
JSON.parse(output.join("\n")).error.message,
"Not logged in. Run 'veryfront login' to authenticate.",
);
});

it("keeps the plain message when VERYFRONT_API_TOKEN_FILE supplies the credential", async () => {
const { result, output } = await runWhoami({
VERYFRONT_API_KEY: "vf_misnamed_key",
VERYFRONT_API_TOKEN_FILE: "/run/secrets/veryfront-token",
}, true);

assertEquals(result, null);
assertEquals(
JSON.parse(output.join("\n")).error.message,
"Not logged in. Run 'veryfront login' to authenticate.",
);
});
});
84 changes: 76 additions & 8 deletions cli/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import {
import {
type ApiTokenSource,
assertApiUrlAcceptsNewCredential,
readConfigJsonFile,
resolveApiCredentialCandidatesForAuth,
resolveApiUrlTrust,
} from "../shared/config.ts";
import {
createErrorEnvelope,
Expand All @@ -26,7 +28,7 @@ import {
} from "../shared/json-output.ts";
import { isInteractive } from "../shared/interactive.ts";
import { getEnvSource } from "veryfront/utils/env-loader";
import { basename, isAbsolute, relative } from "veryfront/platform/path";
import { basename, isAbsolute, join, relative } from "veryfront/platform/path";
import { cwd, getEnv } from "veryfront/platform";

const applyIntrinsic = Reflect.apply;
Expand Down Expand Up @@ -117,6 +119,60 @@ export interface CredentialValidationOptions {
* block sign-in forever, so the check is bounded and a timeout simply falls
* through to the normal flow.
*/
const MISNAMED_API_KEY_HINT_LINES = [
"VERYFRONT_API_KEY is set, but the CLI reads VERYFRONT_API_TOKEN.",
"Set VERYFRONT_API_TOKEN to the same key, or run 'veryfront login'.",
] as const;

/**
* Studio's key page tells users to export `VERYFRONT_API_KEY`, which the CLI
* does not read. When that is the only one set, missing-auth messages name the
* variable the CLI does read instead of a bare "Not logged in".
*
* The hint only applies to a key the user exported themselves. A key loaded
* from a project `.env` belongs to whoever wrote the checkout, so it is never
* suggested as the user's own credential. When any credential is configured
* (a token file, a stored login, a `veryfront.json` token), the failure is
* about that credential, so the hint stays out of the way.
*/
export async function misnamedApiKeyHint(
env: EnvironmentConfig = getEnvironmentConfig(),
projectDir: string = cwd(),
): Promise<string | undefined> {
if (!getEnv("VERYFRONT_API_KEY")?.trim()) return undefined;
if (getEnvSource("VERYFRONT_API_KEY").source !== "process") return undefined;
if (getEnv("VERYFRONT_API_TOKEN")?.trim() || getEnv("VERYFRONT_API_TOKEN_FILE")?.trim()) {
return undefined;
}
if (await hasConfiguredCredential(env, projectDir)) return undefined;
return MISNAMED_API_KEY_HINT_LINES.join(" ");
}

async function hasConfiguredCredential(
env: EnvironmentConfig,
projectDir: string,
): Promise<boolean> {
try {
// A host steered by the project's environment files filters out the
// user's own credentials, and would filter a newly exported
// VERYFRONT_API_TOKEN the same way. A veryfront.json apiUrl alone does
// not: shell credentials still validate against the operator's host.
const configFile = await readConfigJsonFile(projectDir);
const trust = resolveApiUrlTrust(env, configFile, join(projectDir, "veryfront.json"));
if (
trust.repositorySteered &&
(trust.steeringEnvFile !== undefined || trust.steeringConfigFile === undefined)
) {
return true;
}
const candidates = await resolveApiCredentialCandidatesForAuth(env, projectDir);
return candidates.length > 0;
Comment on lines +168 to +169

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count credentials before applying host trust filtering

When a project .env steers the API URL and the user has both a stored login and an exported VERYFRONT_API_KEY, resolveApiCredentialCandidatesForAuth() deliberately filters out the token-store candidate, so this helper concludes that no credential is configured and whoami, login, and up recommend copying the API key into VERYFRONT_API_TOKEN. That recovery cannot work because the same repository-host trust rule will filter the new token too. Check the unfiltered configured credential sources, or suppress the hint for a repository-steered host. Fresh evidence beyond the prior retained-credential report is this trust-filtered candidate path.

AGENTS.md reference: AGENTS.md:L26-L28

Useful? React with 👍 / 👎.

} catch {
// Unknown credential state: the hint is optional, so leave it out.
return true;
}
}

const DEFAULT_EXISTING_SESSION_TIMEOUT_MS = 5_000;
const DEFAULT_OAUTH_BROWSER_LAUNCH_TIMEOUT_MS = 10_000;
let existingSessionTimeoutMs = DEFAULT_EXISTING_SESSION_TIMEOUT_MS;
Expand All @@ -137,12 +193,16 @@ function loginIdentityData(
return { ...identity, source: displayedSource };
}

async function outputLoginAuthenticationRequiredJson(): Promise<void> {
async function outputLoginAuthenticationRequiredJson(
env: EnvironmentConfig,
projectDir: string,
): Promise<void> {
const hint = await misnamedApiKeyHint(env, projectDir);
await outputJson(createErrorEnvelope("login", {
code: "AUTHENTICATION_ERROR",
slug: "authentication-required",
registrySlug: "authentication-required",
message: "Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode.",
message: `Not logged in. ${hint ?? "Set VERYFRONT_API_TOKEN or run in interactive mode."}`,
}));
}

Expand Down Expand Up @@ -885,7 +945,7 @@ export async function login(
if (existing === "failure-output") return null;
if (existing) return existing;
if (isJsonMode()) {
await outputLoginAuthenticationRequiredJson();
await outputLoginAuthenticationRequiredJson(env, projectDir);
return null;
}
}
Expand All @@ -898,7 +958,10 @@ export async function login(
await assertApiUrlAcceptsNewCredential(env, projectDir);

if (!isInteractive() && (method === undefined || method === "token")) {
cliLogger.error("Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode.");
const hint = await misnamedApiKeyHint(env, projectDir);
cliLogger.error(
`Not logged in. ${hint ?? "Set VERYFRONT_API_TOKEN or run in interactive mode."}`,
);
return null;
}

Expand Down Expand Up @@ -936,7 +999,10 @@ async function requestInteractiveAuthentication(
validationOptions: CredentialValidationOptions,
): Promise<AuthIdentity | null> {
if (!isTTY() || !isInteractive()) {
cliLogger.error("Not logged in. Set VERYFRONT_API_TOKEN or run in interactive mode.");
const hint = await misnamedApiKeyHint(env, projectDir);
cliLogger.error(
`Not logged in. ${hint ?? "Set VERYFRONT_API_TOKEN or run in interactive mode."}`,
);
return null;
}

Expand Down Expand Up @@ -1086,20 +1152,22 @@ export async function whoami(
if (candidate.authoritative) break;
}

const hint = candidates.length > 0 ? undefined : await misnamedApiKeyHint(env);
if (isJsonMode()) {
await outputJson(createErrorEnvelope("whoami", {
code: "AUTHENTICATION_ERROR",
slug: "authentication-required",
registrySlug: "authentication-required",
message: "Not logged in. Run 'veryfront login' to authenticate.",
message: `Not logged in. ${hint ?? "Run 'veryfront login' to authenticate."}`,
context: { authenticated: false },
}));
return null;
}

console.log();
console.log(" " + error("✗") + " Not logged in");
console.log(" " + dim("Run 'veryfront login' to authenticate"));
const hintLines = hint ? MISNAMED_API_KEY_HINT_LINES : ["Run 'veryfront login' to authenticate"];
for (const line of hintLines) console.log(" " + dim(line));

return null;
}
Expand Down
Loading
Loading