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
51 changes: 29 additions & 22 deletions src/cmd/flows/onboard.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { Confirm } from "@cliffy/prompt";
import { colors } from "@cliffy/ansi/colors";
import {
API_KEY_KEY,
DEFAULT_WRAP_WIDTH,
GET_API_KEY_URL,
GLOBAL_VT_CONFIG_PATH,
VT_README_URL,
} from "~/consts.ts";
import { ensureDir } from "@std/fs";
import wrap from "word-wrap";
import { globalConfig } from "~/vt/VTConfig.ts";
import { delay } from "@std/async";
import { oicdLoginFlow } from "../../oauth.ts";

/**
Expand Down Expand Up @@ -40,13 +38,15 @@ function welcomeToVt(): void {

/**
* The onboarding flow for users using vt for the first time. This handles
* walking the user through setting their API key and informing them on how to
* get started.
* walking the user through authenticating via OAuth device authorization flow
* and informing them on how to get started.
*
* OAuth is the default authentication method. Users who need to use an API
* key directly (e.g., for CI environments) can use:
* `vt config set apiKey <key>`
*
* @param options Options for the onboarding flow
* @param options.showWelcome Whether to show the welcome message
* @param options.showApiKeyPrompt Whether to show the API key prompt
* @param options.openBrowser Whether to open the browser to get the API key
*/
export async function onboardFlow(
options?: { showWelcome?: boolean },
Expand All @@ -56,28 +56,22 @@ export async function onboardFlow(
if (options.showWelcome) {
welcomeToVt();
console.log();

console.log(" To get started, you need to authenticate with Val Town.");
console.log();
}

const goToWebsite: boolean = await Confirm.prompt({
message:
`We can log you in automatically! Would you like to proceed to vt's login page in your browser?\n`,
});
console.log(" To get started, you need to authenticate with Val Town.");
console.log();

if (goToWebsite) {
await delay(300);
try {
const tokens = await oicdLoginFlow();

// Set the API key in the environment for the current session
Deno.env.set("VAL_TOWN_API_KEY", tokens.access_token);
Deno.env.set(API_KEY_KEY, tokens.access_token);

// Ensure the global config directory exists
await ensureDir(GLOBAL_VT_CONFIG_PATH);

// Add the API key to the config
globalConfig.saveGlobalConfig({
await globalConfig.saveGlobalConfig({
apiKey: tokens.access_token,
refreshToken: tokens.refresh_token,
});
Expand All @@ -88,12 +82,25 @@ export async function onboardFlow(
`head over to ${VT_README_URL}`,
);
console.log();
} else {
} catch (error) {
// If the device auth flow fails (e.g., no browser, network issues),
// fall back to showing the manual API key instructions.
console.log();
console.log(
colors.yellow(
"Automatic login failed" +
(error instanceof Error ? `: ${error.message}` : "."),
),
);
console.log();
console.log(
"You can get an API key at " + GET_API_KEY_URL +
" with user read, val read + write, telemetry read permissions, " +
"and come back and run `vt config set apiKey <your new API key>` when you are ready.",
"You can authenticate manually by running:\n" +
colors.cyan(" vt login") +
"\n\nor by setting an API key directly:\n" +
colors.cyan(" vt config set apiKey <your API key>") +
"\n\nYou can generate an API key at " +
colors.cyan("https://www.val.town/settings/api") +
" with user read, val read + write, telemetry read permissions.",
);
}
}
152 changes: 152 additions & 0 deletions src/cmd/tests/oauth_default_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { assertEquals, assertStringIncludes } from "@std/assert";
import { VTConfigSchema } from "~/vt/vt/schemas.ts";
import { doWithTempDir } from "~/vt/lib/utils/misc.ts";
import { runVtCommand } from "~/cmd/tests/utils.ts";
import { join } from "@std/path";
import { ensureDir } from "@std/fs";
import { stringify as stringifyYaml } from "@std/yaml";

/**
* Tests for issue #230: OAuth as default authentication.
*
* These tests verify:
* 1. The config schema accepts OAuth token lengths (43 chars)
* 2. Auth-exempt commands (login, logout, upgrade) don't require auth
* 3. Error messages reference `vt login` as the primary recovery action
*/

// ---------------------------------------------------------------------------
// Schema validation: OAuth token length acceptance
// ---------------------------------------------------------------------------

Deno.test({
name: "VTConfigSchema accepts 32-char API keys",
fn() {
const key = "a".repeat(32);
const result = VTConfigSchema.safeParse({ apiKey: key });
assertEquals(result.success, true);
},
});

Deno.test({
name: "VTConfigSchema accepts 33-char API keys",
fn() {
const key = "a".repeat(33);
const result = VTConfigSchema.safeParse({ apiKey: key });
assertEquals(result.success, true);
},
});

Deno.test({
name: "VTConfigSchema accepts 43-char OAuth access tokens",
fn() {
const key = "a".repeat(43);
const result = VTConfigSchema.safeParse({ apiKey: key });
assertEquals(result.success, true);
},
});

Deno.test({
name: "VTConfigSchema rejects invalid token lengths",
fn() {
const key = "a".repeat(20);
const result = VTConfigSchema.safeParse({ apiKey: key });
assertEquals(result.success, false);
},
});

Deno.test({
name: "VTConfigSchema accepts null apiKey",
fn() {
const result = VTConfigSchema.safeParse({ apiKey: null });
assertEquals(result.success, true);
},
});

Deno.test({
name: "VTConfigSchema accepts refreshToken alongside apiKey",
fn() {
const result = VTConfigSchema.safeParse({
apiKey: "a".repeat(43),
refreshToken: "some_refresh_token_value",
});
assertEquals(result.success, true);
},
});

Deno.test({
name: "VTConfigSchema accepts null refreshToken",
fn() {
const result = VTConfigSchema.safeParse({
apiKey: "a".repeat(32),
refreshToken: null,
});
assertEquals(result.success, true);
},
});

// ---------------------------------------------------------------------------
// Auth-exempt commands: login, logout, upgrade should not require auth
// ---------------------------------------------------------------------------

Deno.test({
name: "vt upgrade does not require authentication",
permissions: "inherit",
async fn() {
await doWithTempDir(async (tmpDir) => {
// Create a minimal config with a null (invalid) apiKey so that
// ensureValidApiKey() would normally trigger the onboard flow.
const configDir = join(tmpDir, "vt");
await ensureDir(configDir);
await Deno.writeTextFile(
join(configDir, "config.yaml"),
stringifyYaml({ apiKey: null }),
);

// `vt upgrade` should run without triggering auth. It will fail
// because there's no real upgrade to do, but it should NOT prompt
// for login or crash with an auth error.
const [output, _code] = await runVtCommand(
["upgrade"],
tmpDir,
{ env: { "XDG_CONFIG_HOME": tmpDir }, autoConfirm: false },
);

// The output should NOT contain the onboarding/auth prompt text
assertEquals(
output.includes("authenticate with Val Town"),
false,
"upgrade command should not trigger authentication flow",
);
});
},
sanitizeResources: false,
});

// ---------------------------------------------------------------------------
// Error message improvements
// ---------------------------------------------------------------------------

Deno.test({
name: "sanitizeErrors for 401 mentions vt login",
async fn() {
// Import and test the sanitizeErrors function directly
const { sanitizeErrors } = await import("~/cmd/utils.ts");
const ValTown = (await import("@valtown/sdk")).default;

// Create a mock 401 error
const error = new ValTown.APIError(
401,
{ message: "Unauthorized" },
"Unauthorized",
{},
);

const message = sanitizeErrors(error);
assertStringIncludes(
message,
"vt login",
"401 error message should suggest `vt login`",
);
},
});
4 changes: 2 additions & 2 deletions src/cmd/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,12 @@ export function sanitizeErrors(error: unknown): string {
}
} else if (error.status === 401) {
suffixedExtra =
"You may need to re-authenticate. To set a new API key, use `vt config set apiKey new_api_key`";
"You may need to re-authenticate. Run `vt login` to log in again, or use `vt config set apiKey <key>` to set an API key directly.";
}

if (error.message.includes("required permissions")) {
suffixedExtra +=
"To set a new API key, use `vt config set apiKey new_api_key`";
"Run `vt login` to re-authenticate, or use `vt config set apiKey <key>` to set an API key directly.";
}

// Remove leading numbers from error message
Expand Down
4 changes: 3 additions & 1 deletion src/vt/VTConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,9 @@ export async function ensureGlobalVtConfig(): Promise<void> {
// be overwriting anything)
if (Deno.env.has(API_KEY_KEY)) {
const apiKey = Deno.env.get(API_KEY_KEY)!; // (!, we just checked)
if (apiKey.length == 32 || apiKey.length == 33) {
if (apiKey.length == 32 || apiKey.length == 33 || apiKey.length == 43) {
// 32-33 chars: traditional API keys (vtwn_...)
// 43 chars: OAuth access tokens
startingConfig.apiKey = apiKey;
} else startingConfig.apiKey = "0".repeat(32);
}
Expand Down
3 changes: 2 additions & 1 deletion src/vt/vt/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ export const VTConfigSchema = z.object({
val === null || val.length === 32 || val.length === 33 ||
val.length === 43,
{ // 43 is for oauth
message: "API key must be 32-33 characters long when provided",
message:
"API key must be 32-33 characters, or a 43-character OAuth token",
},
)
.nullable(),
Expand Down
7 changes: 6 additions & 1 deletion vt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,12 @@ if (import.meta.main) {
await registerOutdatedWarning();
}

if (!["logout"].includes(Deno.args[0])) {
// Commands that don't require authentication:
// - logout: clearing credentials shouldn't need valid credentials
// - login: this IS the auth flow; requiring auth first is circular
// - upgrade: updating the CLI binary doesn't need API access
const authExemptCommands = ["logout", "login", "upgrade"];
if (!authExemptCommands.includes(Deno.args[0])) {
await ensureValidApiKey();
}

Expand Down