diff --git a/README.md b/README.md index 09f646e..1c14c2e 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ npx @decodo/cli scrape https://ip.decodo.com --token "$DECODO_AUTH_TOKEN" ## Authentication -Get a basic auth token from the Decodo [Playground](https://dashboard.decodo.com/playground). +Get an auth token from the Decodo [Playground](https://dashboard.decodo.com/playground). ```bash # Interactive — saves token to config @@ -252,7 +252,7 @@ Use the CLI when your agent needs to scrape from a shell, terminal, CI/CD pipeli | Variable | Description | | --- | --- | -| `DECODO_AUTH_TOKEN` | Basic auth token (overrides saved config, below `--token`) | +| `DECODO_AUTH_TOKEN` | Auth token (overrides saved config, below `--token`) | | `DECODO_CONFIG_HOME` | Override config directory (default: `$XDG_CONFIG_HOME/decodo`, else `~/.config/decodo`) | ## Exit codes diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8220b7a..f9c683f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -116,7 +116,7 @@ then add a branch in `resolveCliExitCode` (and a hint in `handleCliError` if use reports its `source` (`flag` | `env` | `config` | `none`). Persistent config lives in a JSON file resolved through `platform/services/paths.ts` (via `env-paths`) and managed by `auth/services/config.ts` (`readConfig`/`writeConfig`/`clearConfig`). The config file is -written with `0o600` permissions and only persists a validated `authToken`. The `setup`, +written with `0o600` permissions and only persists a validated credential. The `setup`, `reset`, and `whoami` commands are the user-facing surface over these helpers; `mask.ts` keeps tokens from being printed in full. diff --git a/package.json b/package.json index bea7b15..490a826 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@decodo/cli", - "version": "1.0.2", + "version": "1.0.3", "description": "Official CLI for the Decodo APIs", "license": "MIT", "type": "module", @@ -37,7 +37,7 @@ }, "packageManager": "pnpm@10.33.3", "dependencies": { - "@decodo/sdk-ts": "^2.1.2", + "@decodo/sdk-ts": "^2.3.0", "commander": "^14.0.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0146088..e2b28b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,8 +13,8 @@ importers: .: dependencies: '@decodo/sdk-ts': - specifier: ^2.1.2 - version: 2.1.2 + specifier: ^2.3.0 + version: 2.3.0 commander: specifier: ^14.0.0 version: 14.0.3 @@ -105,8 +105,8 @@ packages: resolution: {integrity: sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==} engines: {node: '>= 20.12.0'} - '@decodo/sdk-ts@2.1.2': - resolution: {integrity: sha512-V/SdHS0DV9L8gBJc5ljQbTmbgV7C8Cn7V91qn3JfnHKgvSmuUq2kLH27UPgCbAa0T2ZVXOkBzbIog5ZnOzAUMg==} + '@decodo/sdk-ts@2.3.0': + resolution: {integrity: sha512-iTTmTvBhezY4SD9sQk+0jfKxMhNSTtUDGscw0J6cHDIhLsjpAtKE+duh5+HjTqjgMJ3o+8Be36ZLqbGTFBjWXA==} engines: {node: '>=18.0.0'} '@esbuild/aix-ppc64@0.28.1': @@ -963,7 +963,7 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@decodo/sdk-ts@2.1.2': + '@decodo/sdk-ts@2.3.0': dependencies: zod: 4.4.3 diff --git a/src/auth/commands/setup.ts b/src/auth/commands/setup.ts index 015f0ca..b9ff471 100644 --- a/src/auth/commands/setup.ts +++ b/src/auth/commands/setup.ts @@ -1,21 +1,67 @@ +import { AuthenticationError } from "@decodo/sdk-ts"; import { Command } from "commander"; import { getRootOpts } from "../../cli/services/global-opts.js"; import { CliUsageError } from "../../platform/errors/cli-usage-error.js"; import { handleCliError } from "../../platform/services/handle-cli-error.js"; import { promptHidden } from "../../platform/services/prompt-hidden.js"; -import { validateAuthToken } from "../../scrape/services/auth-validation.js"; -import { PLAYGROUND_URL } from "../constants.js"; +import { validateCredential } from "../../scrape/services/auth-validation.js"; +import { AUTH_TYPE, PLAYGROUND_URL } from "../constants.js"; import { getConfigPath, writeConfig } from "../services/config.js"; +import { detectCredentialType } from "../services/detect-credential-type.js"; +import type { DecodoConfig } from "../types/config.js"; +import type { AuthCredential, AuthType } from "../types/credential.js"; -const TOKEN_PROMPT = `Paste your Web Scraping API basic auth token (${PLAYGROUND_URL}): `; +const TOKEN_PROMPT = `Paste your Web Scraping API auth token (${PLAYGROUND_URL}): `; + +interface SetupOptions { + token?: string; +} + +function oppositeAuthType(type: AuthType): AuthType { + return type === AUTH_TYPE.TOKEN ? AUTH_TYPE.API_KEY : AUTH_TYPE.TOKEN; +} + +function toConfig(credential: AuthCredential): DecodoConfig { + if (credential.type === AUTH_TYPE.API_KEY) { + return { apiKey: credential.value }; + } + + return { authToken: credential.value }; +} + +async function verifyCredential(value: string): Promise { + const detected: AuthCredential = { + type: detectCredentialType(value), + value, + }; + + try { + await validateCredential(detected); + return detected; + } catch (err) { + if (!(err instanceof AuthenticationError)) { + throw err; + } + + const fallback: AuthCredential = { + type: oppositeAuthType(detected.type), + value, + }; + + try { + await validateCredential(fallback); + } catch { + throw err; + } + + return fallback; + } +} export const setupCommand = new Command("setup") .description("Configure the Decodo CLI with your auth token") - .option( - "--token ", - "Web Scraping API basic auth token (non-interactive)" - ) - .action(async (options: { token?: string }, command) => { + .option("--token ", "Web Scraping API auth token (non-interactive)") + .action(async (options: SetupOptions, command) => { const rootOpts = getRootOpts(command); const token = ( options.token?.trim() || @@ -28,8 +74,8 @@ export const setupCommand = new Command("setup") } try { - await validateAuthToken(token); - await writeConfig({ authToken: token }); + const credential = await verifyCredential(token); + await writeConfig(toConfig(credential)); console.log(`Setup complete. Configuration saved to ${getConfigPath()}`); } catch (err) { handleCliError(err, { fallbackMessage: "Setup failed." }); diff --git a/src/auth/commands/whoami.ts b/src/auth/commands/whoami.ts index c63d9ec..bf041eb 100644 --- a/src/auth/commands/whoami.ts +++ b/src/auth/commands/whoami.ts @@ -1,22 +1,31 @@ import { Command } from "commander"; import { getRootOpts } from "../../cli/services/global-opts.js"; import { handleCliError } from "../../platform/services/handle-cli-error.js"; +import { AUTH_TYPE } from "../constants.js"; import { AuthRequiredError } from "../errors/auth-required-error.js"; import { mask } from "../services/mask.js"; import { resolveAuthToken } from "../services/resolve-token.js"; +import type { AuthType } from "../types/credential.js"; + +const CREDENTIAL_LABEL: Record = { + [AUTH_TYPE.API_KEY]: "api key", + [AUTH_TYPE.TOKEN]: "token", +}; export const whoamiCommand = new Command("whoami") .description("Show the active auth source and masked token") .action(async (_options, command) => { const rootOpts = getRootOpts(command); - const { token, source } = await resolveAuthToken({ + const { credential, source } = await resolveAuthToken({ token: rootOpts.token, }); - if (!token) { + if (!credential) { handleCliError(new AuthRequiredError()); } console.log(`source: ${source}`); - console.log(`token: ${mask(token, 4, -4)}`); + console.log( + `${CREDENTIAL_LABEL[credential.type]}: ${mask(credential.value, 4, -4)}` + ); }); diff --git a/src/auth/constants.ts b/src/auth/constants.ts index 1a286d6..7460922 100644 --- a/src/auth/constants.ts +++ b/src/auth/constants.ts @@ -1,3 +1,8 @@ export const PLAYGROUND_URL = "https://dashboard.decodo.com/playground"; export const AUTH_MISSING_MESSAGE = "No auth token found."; + +export const AUTH_TYPE = { + TOKEN: "token", + API_KEY: "apiKey", +} as const; diff --git a/src/auth/services/config.ts b/src/auth/services/config.ts index 5d8cfc8..bb5c1d7 100644 --- a/src/auth/services/config.ts +++ b/src/auth/services/config.ts @@ -10,6 +10,19 @@ export function getConfigPath(): string { return join(getConfigDir(), CONFIG_FILE); } +function readCredentialField( + parsed: Partial, + key: keyof DecodoConfig +): string | undefined { + const value = parsed[key]; + + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + + return; +} + function parseConfig( raw: string, configPath: string @@ -22,13 +35,28 @@ function parseConfig( throw new ConfigParseError(configPath); } - if (typeof parsed.authToken === "string" && parsed.authToken.length > 0) { - return { - authToken: parsed.authToken, - }; + if (!parsed || typeof parsed !== "object") { + return; } - return; + const apiKey = readCredentialField(parsed, "apiKey"); + const authToken = readCredentialField(parsed, "authToken"); + + if (!(apiKey || authToken)) { + return; + } + + const config: DecodoConfig = {}; + + if (apiKey) { + config.apiKey = apiKey; + } + + if (authToken) { + config.authToken = authToken; + } + + return config; } export async function readConfig(): Promise { diff --git a/src/auth/services/detect-credential-type.ts b/src/auth/services/detect-credential-type.ts new file mode 100644 index 0000000..7e47d1d --- /dev/null +++ b/src/auth/services/detect-credential-type.ts @@ -0,0 +1,14 @@ +import { AUTH_TYPE } from "../constants.js"; +import type { AuthType } from "../types/credential.js"; + +const PRINTABLE_ASCII = /^[\x20-\x7e]+$/; + +export function detectCredentialType(value: string): AuthType { + const decoded = Buffer.from(value, "base64").toString("utf8"); + + if (PRINTABLE_ASCII.test(decoded) && decoded.includes(":")) { + return AUTH_TYPE.TOKEN; + } + + return AUTH_TYPE.API_KEY; +} diff --git a/src/auth/services/resolve-token.ts b/src/auth/services/resolve-token.ts index c9a59d8..2c91859 100644 --- a/src/auth/services/resolve-token.ts +++ b/src/auth/services/resolve-token.ts @@ -1,34 +1,53 @@ +import { AUTH_TYPE } from "../constants.js"; +import type { AuthCredential } from "../types/credential.js"; import { readConfig } from "./config.js"; +import { detectCredentialType } from "./detect-credential-type.js"; export type AuthSource = "flag" | "env" | "config" | "none"; export interface ResolvedAuth { + credential: AuthCredential | undefined; source: AuthSource; - token: string | undefined; } export interface ResolveAuthOptions { token?: string; } +function detect(value: string): AuthCredential { + return { type: detectCredentialType(value), value }; +} + export async function resolveAuthToken( options: ResolveAuthOptions = {} ): Promise { - if (options.token) { - return { token: options.token, source: "flag" }; + const flagToken = options.token?.trim(); + + if (flagToken) { + return { credential: detect(flagToken), source: "flag" }; } - const envToken = process.env.DECODO_AUTH_TOKEN; + const envToken = process.env.DECODO_AUTH_TOKEN?.trim(); if (envToken) { - return { token: envToken, source: "env" }; + return { credential: detect(envToken), source: "env" }; } const config = await readConfig(); if (config?.authToken) { - return { token: config.authToken, source: "config" }; + return { + credential: { type: AUTH_TYPE.TOKEN, value: config.authToken }, + source: "config", + }; + } + + if (config?.apiKey) { + return { + credential: { type: AUTH_TYPE.API_KEY, value: config.apiKey }, + source: "config", + }; } - return { token: undefined, source: "none" }; + return { credential: undefined, source: "none" }; } diff --git a/src/auth/types/config.ts b/src/auth/types/config.ts index 0e89951..ea6586e 100644 --- a/src/auth/types/config.ts +++ b/src/auth/types/config.ts @@ -1,3 +1,4 @@ export interface DecodoConfig { - authToken: string; + apiKey?: string; + authToken?: string; } diff --git a/src/auth/types/credential.ts b/src/auth/types/credential.ts new file mode 100644 index 0000000..b794f33 --- /dev/null +++ b/src/auth/types/credential.ts @@ -0,0 +1,8 @@ +import type { AUTH_TYPE } from "../constants.js"; + +export type AuthType = (typeof AUTH_TYPE)[keyof typeof AUTH_TYPE]; + +export interface AuthCredential { + type: AuthType; + value: string; +} diff --git a/src/index.ts b/src/index.ts index 537f2cf..ed81eb7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,7 @@ const program = new Command() .option("-v, --verbose", "Print debug logs to stderr") .option( "--token ", - "Basic auth token (overrides DECODO_AUTH_TOKEN and saved config)" + "Auth token (overrides DECODO_AUTH_TOKEN and saved config)" ); async function main(): Promise { diff --git a/src/scrape/services/auth-validation.ts b/src/scrape/services/auth-validation.ts index 9e4a73a..97e491d 100644 --- a/src/scrape/services/auth-validation.ts +++ b/src/scrape/services/auth-validation.ts @@ -5,12 +5,15 @@ import { Target as ScrapeTarget, TimeoutError, } from "@decodo/sdk-ts"; +import type { AuthCredential } from "../../auth/types/credential.js"; import { createDecodoClient } from "./client.js"; const AUTH_PROBE_URL = "https://does-not-exist.decodo.com"; -export async function validateAuthToken(token: string): Promise { - const client = createDecodoClient(token); +export async function validateCredential( + credential: AuthCredential +): Promise { + const client = createDecodoClient(credential); try { await client.webScrapingApi.scrape({ diff --git a/src/scrape/services/client.ts b/src/scrape/services/client.ts index d1f1df9..38ee18d 100644 --- a/src/scrape/services/client.ts +++ b/src/scrape/services/client.ts @@ -1,15 +1,19 @@ import { DecodoClient, type DecodoSchema } from "@decodo/sdk-ts"; +import { AUTH_TYPE } from "../../auth/constants.js"; +import type { AuthCredential } from "../../auth/types/credential.js"; import { INTEGRATION_HEADER } from "../constants.js"; export function createDecodoClient( - token: string, + credential: AuthCredential, schema?: DecodoSchema ): DecodoClient { + const credentials = + credential.type === AUTH_TYPE.API_KEY + ? { apiKey: credential.value } + : { token: credential.value }; + return new DecodoClient({ - webScrapingApi: { - token, - integrationHeader: INTEGRATION_HEADER, - }, + webScrapingApi: { ...credentials, integrationHeader: INTEGRATION_HEADER }, schema, }); } diff --git a/src/scrape/services/run-target-scrape.ts b/src/scrape/services/run-target-scrape.ts index 50c2a04..177564e 100644 --- a/src/scrape/services/run-target-scrape.ts +++ b/src/scrape/services/run-target-scrape.ts @@ -18,7 +18,7 @@ import { buildScrapeBody, getTargetCommandConfig } from "./command-builder.js"; import { formatScrapeRequestLog } from "./format-scrape-request-log.js"; async function executeScrape({ - token, + credential, schema, body, options, @@ -26,7 +26,7 @@ async function executeScrape({ input, verbose = false, }: ExecuteScrapeOptions): Promise { - const client = createDecodoClient(token, schema); + const client = createDecodoClient(credential, schema); const startedAt = Date.now(); const response = await client.webScrapingApi.scrape( body as unknown as ScrapeRequest @@ -89,8 +89,11 @@ export function createTargetAction( try { const auth = await resolveAuthToken({ token: rootOpts.token }); - verboseLog(verbose, `auth source=${auth.source}`); - if (!auth.token) { + verboseLog( + verbose, + `auth source=${auth.source} type=${auth.credential?.type ?? "none"}` + ); + if (!auth.credential) { throw new AuthRequiredError(); } @@ -102,7 +105,7 @@ export function createTargetAction( input ); await executeScrape({ - token: auth.token, + credential: auth.credential, schema, body, options, diff --git a/src/scrape/types/run-target-scrape.ts b/src/scrape/types/run-target-scrape.ts index b974b5f..0ace6b5 100644 --- a/src/scrape/types/run-target-scrape.ts +++ b/src/scrape/types/run-target-scrape.ts @@ -1,13 +1,14 @@ import type { DecodoSchema } from "@decodo/sdk-ts"; +import type { AuthCredential } from "../../auth/types/credential.js"; import type { WriteScrapeResponseContext } from "../../output/types/write-scrape-response.js"; export interface ExecuteScrapeOptions { body: Record; + credential: AuthCredential; input?: string; options: Record; outputContext?: Partial; schema: DecodoSchema; - token: string; verbose?: boolean; } diff --git a/tests/auth/commands/setup.test.ts b/tests/auth/commands/setup.test.ts index 9333eb1..be99191 100644 --- a/tests/auth/commands/setup.test.ts +++ b/tests/auth/commands/setup.test.ts @@ -4,6 +4,9 @@ import { isolateConfigHome } from "../../platform/helpers/config-home.js"; const mockPromptHidden = vi.hoisted(() => vi.fn()); +const API_KEY_SHAPED = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + vi.mock("../../../src/platform/services/prompt-hidden.js", () => ({ promptHidden: mockPromptHidden, })); @@ -64,15 +67,63 @@ describe("setupCommand", () => { }); it("saves config on successful validation", async () => { - await runSetup(["--token", "valid-token"]); + await runSetup(["--token", "VTAwMDAwMDAwMDE6UFdfdmFsaWRzZWNyZXQ="]); const { readConfig } = await import("../../../src/auth/services/config.js"); expect(await readConfig()).toEqual({ - authToken: "valid-token", + authToken: "VTAwMDAwMDAwMDE6UFdfdmFsaWRzZWNyZXQ=", }); expect(stdout.join("\n")).toContain("Setup complete"); }); + it("validates a token against the scraper api endpoint", async () => { + await runSetup(["--token", "VTAwMDAwMDAwMDE6UFdfdmFsaWRzZWNyZXQ="]); + + expect(fetch).toHaveBeenCalledWith( + "https://scraper-api.decodo.com/v2/scrape", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Basic VTAwMDAwMDAwMDE6UFdfdmFsaWRzZWNyZXQ=", + }), + }) + ); + }); + + it("falls back to the opposite auth type when the detected one is rejected", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ message: "Invalid credentials", status: "failed" }), + } as Response); + + await runSetup(["--token", API_KEY_SHAPED]); + + expect(fetch).toHaveBeenCalledTimes(2); + const { readConfig } = await import("../../../src/auth/services/config.js"); + expect(await readConfig()).toEqual({ authToken: API_KEY_SHAPED }); + }); + + it("reports the detected type's error when both auth types fail", async () => { + vi.mocked(fetch) + .mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ message: "ORIGINAL-error", status: "failed" }), + } as Response) + .mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ message: "FALLBACK-error", status: "failed" }), + } as Response); + + await expect(runSetup(["--token", API_KEY_SHAPED])).rejects.toThrow( + "process.exit:3" + ); + + expect(stderr.join("\n")).toContain("ORIGINAL-error"); + expect(stderr.join("\n")).not.toContain("FALLBACK-error"); + }); + it("does not save config on 401", async () => { vi.mocked(fetch).mockResolvedValue({ ok: false, @@ -90,21 +141,24 @@ describe("setupCommand", () => { }); it("saves config when token comes from global --token", async () => { - await runSetup([], ["--token", "global-token"]); + await runSetup([], ["--token", "VTAwMDAwMDAwMDI6UFdfZ2xvYmFsc2VjcmV0"]); const { readConfig } = await import("../../../src/auth/services/config.js"); expect(await readConfig()).toEqual({ - authToken: "global-token", + authToken: "VTAwMDAwMDAwMDI6UFdfZ2xvYmFsc2VjcmV0", }); expect(stdout.join("\n")).toContain("Setup complete"); }); it("prefers setup --token over global --token", async () => { - await runSetup(["--token", "setup-token"], ["--token", "global-token"]); + await runSetup( + ["--token", "VTAwMDAwMDAwMDM6UFdfc2V0dXBzZWNyZXQ="], + ["--token", "VTAwMDAwMDAwMDI6UFdfZ2xvYmFsc2VjcmV0"] + ); const { readConfig } = await import("../../../src/auth/services/config.js"); expect(await readConfig()).toEqual({ - authToken: "setup-token", + authToken: "VTAwMDAwMDAwMDM6UFdfc2V0dXBzZWNyZXQ=", }); }); @@ -147,9 +201,9 @@ describe("setupCommand", () => { }), } as Response); - await expect(runSetup(["--token", "valid-token"])).rejects.toThrow( - "process.exit:5" - ); + await expect( + runSetup(["--token", "VTAwMDAwMDAwMDE6UFdfdmFsaWRzZWNyZXQ="]) + ).rejects.toThrow("process.exit:5"); const { readConfig } = await import("../../../src/auth/services/config.js"); expect(await readConfig()).toBeUndefined(); @@ -158,14 +212,16 @@ describe("setupCommand", () => { }); it("prompts for token interactively when no flags are provided", async () => { - mockPromptHidden.mockResolvedValue("prompted-token"); + mockPromptHidden.mockResolvedValue( + "VTAwMDAwMDAwMDQ6UFdfcHJvbXB0ZWRzZWNyZXQ=" + ); await runSetup([]); expect(mockPromptHidden).toHaveBeenCalledOnce(); const { readConfig } = await import("../../../src/auth/services/config.js"); expect(await readConfig()).toEqual({ - authToken: "prompted-token", + authToken: "VTAwMDAwMDAwMDQ6UFdfcHJvbXB0ZWRzZWNyZXQ=", }); expect(stdout.join("\n")).toContain("Setup complete"); }); diff --git a/tests/auth/commands/whoami.test.ts b/tests/auth/commands/whoami.test.ts index b3275b3..54fcc34 100644 --- a/tests/auth/commands/whoami.test.ts +++ b/tests/auth/commands/whoami.test.ts @@ -51,34 +51,57 @@ describe("whoamiCommand", () => { const { writeConfig } = await import( "../../../src/auth/services/config.js" ); - await writeConfig({ authToken: "abcdefghijklmnop" }); + await writeConfig({ authToken: "VTAwMDAwMDAwMDU6UFdfd2hvYW1pc2VjcmV0" }); await runWhoami(["whoami"]); expect(stdout).toContain("source: config"); - expect(stdout).toContain("token: abcd...mnop"); + expect(stdout).toContain("token: VTAw...cmV0"); }); it("prints auth source and masked token from global --token", async () => { - await runWhoami(["--token", "abcdefghijklmnop", "whoami"]); + await runWhoami([ + "--token", + "VTAwMDAwMDAwMDU6UFdfd2hvYW1pc2VjcmV0", + "whoami", + ]); expect(stdout).toContain("source: flag"); - expect(stdout).toContain("token: abcd...mnop"); + expect(stdout).toContain("token: VTAw...cmV0"); }); it("prefers global --token over saved config", async () => { const { writeConfig } = await import( "../../../src/auth/services/config.js" ); - await writeConfig({ authToken: "config-token-value" }); + await writeConfig({ authToken: "VTAwMDAwMDAwMDY6UFdfY29uZmlnc2VjcmV0" }); - await runWhoami(["--token", "flag-token-value", "whoami"]); + await runWhoami([ + "--token", + "VTAwMDAwMDAwMDI6UFdfZ2xvYmFsc2VjcmV0", + "whoami", + ]); expect(stdout).toContain("source: flag"); - expect(stdout).toContain("token: flag...alue"); + expect(stdout).toContain("token: VTAw...cmV0"); }); - it("exits with code 3 when no token is available", async () => { + it("prints the api key label for a saved api key", async () => { + const { writeConfig } = await import( + "../../../src/auth/services/config.js" + ); + await writeConfig({ + apiKey: + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }); + + await runWhoami(["whoami"]); + + expect(stdout).toContain("source: config"); + expect(stdout).toContain("api key: 0123...cdef"); + }); + + it("exits with code 3 when no credential is available", async () => { await expect(runWhoami(["whoami"])).rejects.toThrow("process.exit:3"); expect(exitCode).toBe(3); }); diff --git a/tests/auth/services/detect-credential-type.test.ts b/tests/auth/services/detect-credential-type.test.ts new file mode 100644 index 0000000..89a3e34 --- /dev/null +++ b/tests/auth/services/detect-credential-type.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { detectCredentialType } from "../../../src/auth/services/detect-credential-type.js"; + +const BASIC_TOKEN = "VTAwMDAwMDAwMDA6UFdfZXhhbXBsZXNlY3JldA=="; +const API_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +describe("detectCredentialType", () => { + it("detects a base64 user:password token as a basic auth token", () => { + expect(detectCredentialType(BASIC_TOKEN)).toBe("token"); + }); + + it("detects a 64-character hex string as an api key", () => { + expect(detectCredentialType(API_KEY)).toBe("apiKey"); + }); + + it("treats a value that decodes without a colon as an api key", () => { + const noColon = Buffer.from("nocolonhere").toString("base64"); + expect(detectCredentialType(noColon)).toBe("apiKey"); + }); + + it("treats a non-base64 value as an api key", () => { + expect(detectCredentialType("not base64 at all!!")).toBe("apiKey"); + }); + + it("keeps a token with a colon inside the password as a basic token", () => { + const nested = Buffer.from("user:pa:ss").toString("base64"); + expect(detectCredentialType(nested)).toBe("token"); + }); +}); diff --git a/tests/auth/services/resolve-token.test.ts b/tests/auth/services/resolve-token.test.ts index 7f3de9e..d8f2d86 100644 --- a/tests/auth/services/resolve-token.test.ts +++ b/tests/auth/services/resolve-token.test.ts @@ -1,6 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AUTH_TYPE } from "../../../src/auth/constants.js"; import { isolateConfigHome } from "../../platform/helpers/config-home.js"; +const BASIC_TOKEN = "VTAwMDAwMDAwMDA6UFdfZXhhbXBsZXNlY3JldA=="; +const API_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +async function resolve(options?: { token?: string }) { + const { resolveAuthToken } = await import( + "../../../src/auth/services/resolve-token.js" + ); + return resolveAuthToken(options); +} + describe("resolveAuthToken", () => { let restoreConfigHome: () => void; let previousEnvToken: string | undefined; @@ -22,52 +34,90 @@ describe("resolveAuthToken", () => { vi.resetModules(); }); - it("prefers flag over env and config", async () => { - process.env.DECODO_AUTH_TOKEN = "env-token"; + it("prefers the flag over env and config", async () => { + process.env.DECODO_AUTH_TOKEN = BASIC_TOKEN; const { writeConfig } = await import( "../../../src/auth/services/config.js" ); await writeConfig({ authToken: "config-token" }); - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" - ); - const result = await resolveAuthToken({ token: "flag-token" }); - expect(result).toEqual({ token: "flag-token", source: "flag" }); + expect(await resolve({ token: BASIC_TOKEN })).toEqual({ + credential: { type: AUTH_TYPE.TOKEN, value: BASIC_TOKEN }, + source: "flag", + }); + }); + + it("infers an api key passed through --token", async () => { + expect(await resolve({ token: API_KEY })).toEqual({ + credential: { type: AUTH_TYPE.API_KEY, value: API_KEY }, + source: "flag", + }); + }); + + it("infers an api key from DECODO_AUTH_TOKEN", async () => { + process.env.DECODO_AUTH_TOKEN = API_KEY; + + expect(await resolve()).toEqual({ + credential: { type: AUTH_TYPE.API_KEY, value: API_KEY }, + source: "env", + }); }); it("prefers env over config", async () => { - process.env.DECODO_AUTH_TOKEN = "env-token"; + process.env.DECODO_AUTH_TOKEN = BASIC_TOKEN; const { writeConfig } = await import( "../../../src/auth/services/config.js" ); await writeConfig({ authToken: "config-token" }); - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" - ); - const result = await resolveAuthToken(); - expect(result).toEqual({ token: "env-token", source: "env" }); + expect(await resolve()).toEqual({ + credential: { type: AUTH_TYPE.TOKEN, value: BASIC_TOKEN }, + source: "env", + }); }); - it("reads token from config file", async () => { + it("uses the persisted kind for a saved api key without re-detecting", async () => { const { writeConfig } = await import( "../../../src/auth/services/config.js" ); - await writeConfig({ authToken: "config-token" }); + await writeConfig({ apiKey: BASIC_TOKEN }); - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" - ); - const result = await resolveAuthToken(); - expect(result).toEqual({ token: "config-token", source: "config" }); + expect(await resolve()).toEqual({ + credential: { type: AUTH_TYPE.API_KEY, value: BASIC_TOKEN }, + source: "config", + }); }); - it("returns none when no token is available", async () => { - const { resolveAuthToken } = await import( - "../../../src/auth/services/resolve-token.js" + it("reads a saved auth token from config", async () => { + const { writeConfig } = await import( + "../../../src/auth/services/config.js" ); - const result = await resolveAuthToken(); - expect(result).toEqual({ token: undefined, source: "none" }); + await writeConfig({ authToken: BASIC_TOKEN }); + + expect(await resolve()).toEqual({ + credential: { type: AUTH_TYPE.TOKEN, value: BASIC_TOKEN }, + source: "config", + }); + }); + + it("returns none when no credential is available", async () => { + expect(await resolve()).toEqual({ + credential: undefined, + source: "none", + }); + }); + + it("treats a whitespace-only flag as no credential", async () => { + expect(await resolve({ token: " " })).toEqual({ + credential: undefined, + source: "none", + }); + }); + + it("trims surrounding whitespace before detecting", async () => { + expect(await resolve({ token: ` ${API_KEY}\n` })).toEqual({ + credential: { type: AUTH_TYPE.API_KEY, value: API_KEY }, + source: "flag", + }); }); }); diff --git a/tests/scrape/commands/scrape.test.ts b/tests/scrape/commands/scrape.test.ts index 9bc9839..16f4cc3 100644 --- a/tests/scrape/commands/scrape.test.ts +++ b/tests/scrape/commands/scrape.test.ts @@ -1,6 +1,7 @@ import { BundledSchema, ValidationError } from "@decodo/sdk-ts"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AUTH_TYPE } from "../../../src/auth/constants.js"; import { resolveAuthToken } from "../../../src/auth/services/resolve-token.js"; import { createScrapeCommand } from "../../../src/scrape/commands/scrape.js"; import { createDecodoClient } from "../../../src/scrape/services/client.js"; @@ -27,7 +28,7 @@ describe("createScrapeCommand", () => { }); vi.mocked(resolveAuthToken).mockResolvedValue({ - token: "test-token", + credential: { type: AUTH_TYPE.TOKEN, value: "test-token" }, source: "flag", }); vi.spyOn(process, "exit").mockImplementation((code) => { diff --git a/tests/scrape/commands/screenshot.test.ts b/tests/scrape/commands/screenshot.test.ts index c8a89f0..98c93c0 100644 --- a/tests/scrape/commands/screenshot.test.ts +++ b/tests/scrape/commands/screenshot.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { BundledSchema, ValidationError } from "@decodo/sdk-ts"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AUTH_TYPE } from "../../../src/auth/constants.js"; import { resolveAuthToken } from "../../../src/auth/services/resolve-token.js"; import { BINARY_TTY_ERROR } from "../../../src/platform/services/write-binary.js"; import { createScreenshotCommand } from "../../../src/scrape/commands/screenshot.js"; @@ -31,7 +32,7 @@ describe("createScreenshotCommand", () => { stdoutBytes = undefined; vi.mocked(resolveAuthToken).mockResolvedValue({ - token: "test-token", + credential: { type: AUTH_TYPE.TOKEN, value: "test-token" }, source: "flag", }); vi.spyOn(process, "exit").mockImplementation((code) => { diff --git a/tests/scrape/commands/search.test.ts b/tests/scrape/commands/search.test.ts index 8bf840e..397b208 100644 --- a/tests/scrape/commands/search.test.ts +++ b/tests/scrape/commands/search.test.ts @@ -1,6 +1,7 @@ import { BundledSchema, ValidationError } from "@decodo/sdk-ts"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AUTH_TYPE } from "../../../src/auth/constants.js"; import { resolveAuthToken } from "../../../src/auth/services/resolve-token.js"; import { createSearchCommand } from "../../../src/scrape/commands/search.js"; import { createDecodoClient } from "../../../src/scrape/services/client.js"; @@ -27,7 +28,7 @@ describe("createSearchCommand", () => { }); vi.mocked(resolveAuthToken).mockResolvedValue({ - token: "test-token", + credential: { type: AUTH_TYPE.TOKEN, value: "test-token" }, source: "flag", }); vi.spyOn(process, "exit").mockImplementation((code) => { diff --git a/tests/scrape/services/auth-validation.test.ts b/tests/scrape/services/auth-validation.test.ts index 4fd054d..74ea866 100644 --- a/tests/scrape/services/auth-validation.test.ts +++ b/tests/scrape/services/auth-validation.test.ts @@ -5,14 +5,24 @@ import { Target as ScrapeTarget, } from "@decodo/sdk-ts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { validateAuthToken } from "../../../src/scrape/services/auth-validation.js"; +import { AUTH_TYPE } from "../../../src/auth/constants.js"; +import { validateCredential } from "../../../src/scrape/services/auth-validation.js"; import { createDecodoClient } from "../../../src/scrape/services/client.js"; vi.mock("../../../src/scrape/services/client.js", () => ({ createDecodoClient: vi.fn(), })); -describe("validateAuthToken", () => { +const TOKEN_CREDENTIAL = { + type: AUTH_TYPE.TOKEN, + value: "test-token", +} as const; +const API_KEY_CREDENTIAL = { + type: AUTH_TYPE.API_KEY, + value: "test-api-key", +} as const; + +describe("validateCredential", () => { const scrape = vi.fn(); beforeEach(() => { @@ -29,33 +39,41 @@ describe("validateAuthToken", () => { it("probes auth with the stats-invisible URL", async () => { scrape.mockResolvedValue({ results: [] }); - await validateAuthToken("test-token"); + await validateCredential(TOKEN_CREDENTIAL); - expect(createDecodoClient).toHaveBeenCalledWith("test-token"); + expect(createDecodoClient).toHaveBeenCalledWith(TOKEN_CREDENTIAL); expect(scrape).toHaveBeenCalledWith({ target: ScrapeTarget.Universal, url: "https://does-not-exist.decodo.com", }); }); + it("probes auth with an api key credential", async () => { + scrape.mockResolvedValue({ results: [] }); + + await validateCredential(API_KEY_CREDENTIAL); + + expect(createDecodoClient).toHaveBeenCalledWith(API_KEY_CREDENTIAL); + }); + it("rejects invalid tokens", async () => { scrape.mockRejectedValue(new AuthenticationError("Username invalid.")); - await expect(validateAuthToken("bad-token")).rejects.toThrow( - AuthenticationError - ); + await expect( + validateCredential({ type: AUTH_TYPE.TOKEN, value: "bad-token" }) + ).rejects.toThrow(AuthenticationError); }); it("accepts valid tokens when the probe scrape fails with DecodoError", async () => { scrape.mockRejectedValue(new DecodoError("Request processing failed", 422)); - await expect(validateAuthToken("test-token")).resolves.toBeUndefined(); + await expect(validateCredential(TOKEN_CREDENTIAL)).resolves.toBeUndefined(); }); it("rethrows rate limit errors", async () => { scrape.mockRejectedValue(new RateLimitError("Rate limit exceeded")); - await expect(validateAuthToken("test-token")).rejects.toThrow( + await expect(validateCredential(TOKEN_CREDENTIAL)).rejects.toThrow( RateLimitError ); }); diff --git a/tests/scrape/services/run-target-scrape.test.ts b/tests/scrape/services/run-target-scrape.test.ts index 360b960..ad79477 100644 --- a/tests/scrape/services/run-target-scrape.test.ts +++ b/tests/scrape/services/run-target-scrape.test.ts @@ -1,6 +1,7 @@ import { BundledSchema, ValidationError } from "@decodo/sdk-ts"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AUTH_TYPE } from "../../../src/auth/constants.js"; import { ConfigParseError } from "../../../src/auth/errors/config-parse-error.js"; import { resolveAuthToken } from "../../../src/auth/services/resolve-token.js"; import { attachScrapeOutputOptions } from "../../../src/output/commands/attach-output-options.js"; @@ -42,7 +43,7 @@ describe("createTargetAction", () => { }); vi.mocked(resolveAuthToken).mockResolvedValue({ - token: "test-token", + credential: { type: AUTH_TYPE.TOKEN, value: "test-token" }, source: "flag", }); vi.spyOn(process, "exit").mockImplementation((code) => { @@ -93,7 +94,7 @@ describe("createTargetAction", () => { markdown: false, }); expect(createDecodoClient).toHaveBeenCalledWith( - "test-token", + { type: AUTH_TYPE.TOKEN, value: "test-token" }, BundledSchema.shared ); expect(stdout).toBe('{"ok":true}\n'); @@ -121,7 +122,7 @@ describe("createTargetAction", () => { { from: "user" } ); - expect(stderr).toContain("[verbose] auth source=flag\n"); + expect(stderr).toContain("[verbose] auth source=flag type=token\n"); expect(stderr).toContain( "[verbose] request target=google_search query=coffee\n" );