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
11 changes: 11 additions & 0 deletions src/core/deviceAuth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,14 @@ export type PollStep =
| { action: "poll"; delayMs: number; state: PollState }
| { action: "done"; tokens: DeviceTokens }
| { action: "fail"; reason: PollFailure; detail: string | undefined };

/** What the authorization endpoint hands back to start a flow. */
export type DeviceAuthorization = {
deviceCode: string;
userCode: string;
verificationUri: string;
/** Verification URI with the user code prefilled, when the server sent one. */
verificationUriComplete: string | undefined;
expiresInSec: number;
intervalSec: number;
};
45 changes: 2 additions & 43 deletions src/core/messages/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { formatSeconds } from "~/core/formatSeconds.js";
import { authErrorMessages } from "./authErrors.js";

export const authMessages = {
title: "QA Wolf Authentication",
Expand Down Expand Up @@ -30,48 +30,7 @@ export const authMessages = {
success: "Logged out successfully.",
cancelled: "Logout cancelled.",
},
errors: {
identity: {
invalidOrUnauthorized: "API key is invalid or unauthorized",
unexpectedFormat: "Could not verify API key: unexpected response format",
couldNotVerify: (detail: string, status: number) =>
`Could not verify API key: ${detail || `HTTP ${status}`}`,
couldNotVerifyNetwork: (cause: string) =>
`Could not verify API key: ${cause}`,
timedOut: (timeoutMs: number) =>
`Could not verify API key: the QA Wolf API did not answer within ${formatSeconds(timeoutMs)}.`,
},
request: {
rejected401: (noun: string | undefined) =>
`QA Wolf API rejected the${noun ? ` ${noun}` : ""} request (HTTP 401). Check your API key.`,
rejected402: (noun: string | undefined) =>
`QA Wolf API refused the${noun ? ` ${noun}` : ""} request (HTTP 402): billing prevented it.`,
rejected403: (noun: string | undefined) =>
`QA Wolf API rejected the${noun ? ` ${noun}` : ""} request (HTTP 403). Check that your API key has access to this environment.`,
notFound404: (noun: string | undefined) =>
`QA Wolf API could not find ${noun ? `${noun} for that environment` : "that environment"} (HTTP 404). Check the --env value.`,
failedWithStatus: (status: number, noun: string | undefined) =>
`QA Wolf API${noun ? ` ${noun}` : ""} request failed (HTTP ${status}).`,
networkUnreachable: (baseUrl: string, noun: string | undefined) =>
`Could not reach the QA Wolf API at ${baseUrl}${noun ? ` to fetch ${noun}` : ""}. Check your network connection and QAWOLF_HOST_URL.`,
timedOut: (timeoutMs: number, noun: string | undefined) =>
`The QA Wolf API${noun ? ` ${noun}` : ""} request timed out after ${formatSeconds(timeoutMs)}. The work may still be finishing on the platform.`,
unexpectedResponse: (noun: string | undefined) =>
`Unexpected${noun ? ` ${noun}` : ""} response from the QA Wolf API.`,
},
bundle: {
linkExpired:
"The flow bundle download link has expired. Please run `qawolf flows pull` again to refresh.",
failedWithStatus: (status: number) =>
`Could not download the flow bundle (HTTP ${status}).`,
networkUnreachable:
"Could not reach the flow bundle storage. Check your network connection and try again.",
timedOut: (timeoutMs: number) =>
`Downloading the flow bundle stalled — no data arrived for ${formatSeconds(timeoutMs)}. Please try again.`,
malformed:
"The flow bundle download was malformed. Please run `qawolf flows pull` again.",
},
},
errors: authErrorMessages,
whoami: {
source: (source: string) => `Source: ${source}`,
authFailed: (source: string, error: string) =>
Expand Down
54 changes: 54 additions & 0 deletions src/core/messages/authErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { formatSeconds } from "~/core/formatSeconds.js";

/** Failure text shared by the auth, identity, request and bundle paths. */
export const authErrorMessages = {
identity: {
invalidOrUnauthorized: "API key is invalid or unauthorized",
unexpectedFormat: "Could not verify API key: unexpected response format",
couldNotVerify: (detail: string, status: number) =>
`Could not verify API key: ${detail || `HTTP ${status}`}`,
couldNotVerifyNetwork: (cause: string) =>
`Could not verify API key: ${cause}`,
timedOut: (timeoutMs: number) =>
`Could not verify API key: the QA Wolf API did not answer within ${formatSeconds(timeoutMs)}.`,
},
request: {
rejected401: (noun: string | undefined) =>
`QA Wolf API rejected the${noun ? ` ${noun}` : ""} request (HTTP 401). Check your API key.`,
rejected402: (noun: string | undefined) =>
`QA Wolf API refused the${noun ? ` ${noun}` : ""} request (HTTP 402): billing prevented it.`,
rejected403: (noun: string | undefined) =>
`QA Wolf API rejected the${noun ? ` ${noun}` : ""} request (HTTP 403). Check that your API key has access to this environment.`,
notFound404: (noun: string | undefined) =>
`QA Wolf API could not find ${noun ? `${noun} for that environment` : "that environment"} (HTTP 404). Check the --env value.`,
failedWithStatus: (status: number, noun: string | undefined) =>
`QA Wolf API${noun ? ` ${noun}` : ""} request failed (HTTP ${status}).`,
networkUnreachable: (baseUrl: string, noun: string | undefined) =>
`Could not reach the QA Wolf API at ${baseUrl}${noun ? ` to fetch ${noun}` : ""}. Check your network connection and QAWOLF_HOST_URL.`,
timedOut: (timeoutMs: number, noun: string | undefined) =>
`The QA Wolf API${noun ? ` ${noun}` : ""} request timed out after ${formatSeconds(timeoutMs)}. The work may still be finishing on the platform.`,
unexpectedResponse: (noun: string | undefined) =>
`Unexpected${noun ? ` ${noun}` : ""} response from the QA Wolf API.`,
},
workos: {
unexpectedResponse: "WorkOS returned an unexpected response",
unexpectedResponseWithStatus: (status: number) =>
`WorkOS returned an unexpected response (HTTP ${status})`,
unreachable: (detail: string) => `Could not reach WorkOS: ${detail}`,
redirected:
"WorkOS answered with a redirect, which the CLI does not follow for a sign-in request",
noClientForSession: "This session names no WorkOS client",
},
bundle: {
linkExpired:
"The flow bundle download link has expired. Please run `qawolf flows pull` again to refresh.",
failedWithStatus: (status: number) =>
`Could not download the flow bundle (HTTP ${status}).`,
networkUnreachable:
"Could not reach the flow bundle storage. Check your network connection and try again.",
timedOut: (timeoutMs: number) =>
`Downloading the flow bundle stalled — no data arrived for ${formatSeconds(timeoutMs)}. Please try again.`,
malformed:
"The flow bundle download was malformed. Please run `qawolf flows pull` again.",
},
} as const;
115 changes: 115 additions & 0 deletions src/shell/openBrowser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, it, mock } from "bun:test";

import type { SpawnFn } from "./spawn.js";
import { openBrowser } from "./openBrowser.js";

function makeSpawn(exitCode = 0) {
return mock<SpawnFn>(async () => ({ exitCode, stdout: "", stderr: "" }));
}

const url = "https://example.com/device?user_code=WDJB-MJHT";

// The launch timeout is a fallback, not part of these assertions: a sleep that
// never settles keeps each test measuring the launcher itself.
const neverSleep = () => new Promise<void>(() => {});

describe("openBrowser", () => {
it("uses open on macOS", async () => {
const spawn = makeSpawn();

const opened = await openBrowser(url, {
spawn,
platform: "darwin",
sleep: neverSleep,
});

expect(opened).toBe(true);
expect(spawn).toHaveBeenCalledWith("open", [url], { platform: "darwin" });
});

it("uses xdg-open on Linux", async () => {
const spawn = makeSpawn();

await openBrowser(url, { spawn, platform: "linux", sleep: neverSleep });

expect(spawn).toHaveBeenCalledWith("xdg-open", [url], {
platform: "linux",
});
});

it("uses rundll32 on Windows so the URL never reaches a shell", async () => {
const spawn = makeSpawn();

await openBrowser(url, { spawn, platform: "win32", sleep: neverSleep });

expect(spawn).toHaveBeenCalledWith(
"rundll32",
["url.dll,FileProtocolHandler", url],
{ platform: "win32" },
);
});

it("reports failure when the launcher exits non-zero", async () => {
const opened = await openBrowser(url, {
spawn: makeSpawn(1),
platform: "darwin",
sleep: neverSleep,
});

expect(opened).toBe(false);
});

it("reports failure instead of throwing when no launcher exists", async () => {
const spawn = mock<SpawnFn>(async () => {
throw Error("spawn xdg-open ENOENT");
});

const opened = await openBrowser(url, {
spawn,
platform: "linux",
sleep: neverSleep,
});

expect(opened).toBe(false);
});

it("refuses to launch anything that is not http or https", async () => {
const spawn = makeSpawn();

const opened = await openBrowser("file:///etc/passwd", {
spawn,
sleep: neverSleep,
platform: "darwin",
});

expect(opened).toBe(false);
expect(spawn).not.toHaveBeenCalled();
});

it("refuses to launch a value that is not a URL at all", async () => {
const spawn = makeSpawn();

const opened = await openBrowser("not a url", {
spawn,
sleep: neverSleep,
platform: "darwin",
});

expect(opened).toBe(false);
expect(spawn).not.toHaveBeenCalled();
});
// xdg-open may run a foreground handler and not return until the browser is
// closed. The device flow has to print its next step and start polling long
// before then, so the launcher is not waited on indefinitely.
it("stops waiting on a launcher that does not return", async () => {
const neverSpawn = mock(() => new Promise<never>(() => {}));

const opened = await openBrowser(url, {
spawn: neverSpawn as unknown as SpawnFn,
platform: "linux",
sleep: async () => {},
});

expect(opened).toBe(true);
});
});
70 changes: 70 additions & 0 deletions src/shell/openBrowser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { SpawnFn } from "./spawn.js";

type OpenBrowserDeps = {
spawn: SpawnFn;
platform: NodeJS.Platform;
sleep: (ms: number) => Promise<void>;
};

/**
* How long to wait for the launcher before assuming it worked.
*
* `open` and `rundll32` hand off and exit at once, but `xdg-open` may run a
* foreground handler and not return until the browser itself closes. Waiting on
* that would hold the device flow before it prints its next step or polls once,
* so the code could expire while the person is looking at an approved page.
*/
const launchTimeoutMs = 2_000;

function launcher(
url: string,
platform: NodeJS.Platform,
): { cmd: string; args: string[] } {
if (platform === "darwin") return { cmd: "open", args: [url] };
// rundll32 hands the URL straight to the shell's protocol handler. `start`
// would be the usual answer, but it only exists inside cmd.exe, and routing a
// server-supplied URL through a command interpreter invites injection.
if (platform === "win32") {
return { cmd: "rundll32", args: ["url.dll,FileProtocolHandler", url] };
}
return { cmd: "xdg-open", args: [url] };
}

/**
* Opens a verification URL in the person's browser.
*
* Best-effort by design: the caller always prints the URL as well, so a
* headless box, a missing launcher, or a locked-down desktop costs a copy and
* paste rather than the whole flow. Never throws.
*/
export async function openBrowser(
url: string,
deps: OpenBrowserDeps,
): Promise<boolean> {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}

// The URL arrives from a network response, so the scheme is checked before it
// reaches a protocol handler that would happily act on file: or a custom one.
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return false;
}

const { cmd, args } = launcher(url, deps.platform);

// Settled either way so a launcher that fails after the timeout cannot reject
// unobserved.
const launched = deps
.spawn(cmd, args, { platform: deps.platform })
.then((result) => result.exitCode === 0)
.catch(() => false);

// A launcher still running at the timeout is treated as success: it is far
// likelier to be holding a browser open than to be about to fail, and saying
// "could not open" over a browser that did open only confuses.
return Promise.race([launched, deps.sleep(launchTimeoutMs).then(() => true)]);
}
84 changes: 84 additions & 0 deletions src/shell/platform/bearerTransmission.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from "bun:test";

import { z } from "zod";

import { createTrpcClient } from "./createTrpcClient.js";
import { getIdentity } from "./getIdentity.js";

/**
* OAuth 2.1 (draft-ietf-oauth-v2-1, section 5.1) puts one hard requirement on a
* client sending a bearer token:
*
* "clients MUST NOT send the access token in a URI query parameter"
* "Clients MUST use one of the two methods defined below, and MUST NOT use
* more than one method to transmit the token in each request."
*
* A token in a URL leaks into server logs, proxy logs, browser history and
* `Referer` headers. These tests fail if any request the CLI makes to the QA
* Wolf API ever puts the credential somewhere other than the Authorization
* header, which is the kind of change that looks harmless in review.
*/

const token = "unmistakable-token-value";

function recordingFetch(body: unknown = {}) {
const calls: { url: string; init: RequestInit }[] = [];
const fetchFn = ((url: string, init: RequestInit = {}) => {
calls.push({ url, init });
return Promise.resolve(
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
}) as unknown as typeof fetch;
return { calls, fetchFn };
}

function expectHeaderOnly(call: { url: string; init: RequestInit }) {
const headers = call.init.headers as Record<string, string> | undefined;
expect(headers?.["Authorization"]).toBe(`Bearer ${token}`);
expect(call.url).not.toContain(token);
const body = typeof call.init.body === "string" ? call.init.body : "";
expect(body).not.toContain(token);
}

const baseUrl = "https://test.qawolf.com";

describe("bearer token transmission", () => {
it("sends the identity request's token in the Authorization header only", async () => {
const { calls, fetchFn } = recordingFetch({
organization: { id: "o", name: "n" },
});

await getIdentity(token, { baseUrl, fetch: fetchFn });

expect(calls).toHaveLength(1);
expectHeaderOnly(calls[0]!);
});

it("keeps the token out of a query, even though a query carries the input", async () => {
const { calls, fetchFn } = recordingFetch({
result: { data: { ok: true } },
});
const trpc = createTrpcClient(token, { baseUrl, fetch: fetchFn });

await trpc.query("some.route", { a: 1 }, z.unknown());

expect(calls).toHaveLength(1);
expect(calls[0]!.url).toContain("input=");
expectHeaderOnly(calls[0]!);
});

it("keeps the token out of a mutation body", async () => {
const { calls, fetchFn } = recordingFetch({
result: { data: { ok: true } },
});
const trpc = createTrpcClient(token, { baseUrl, fetch: fetchFn });

await trpc.mutation("some.route", { a: 1 }, z.unknown());

expect(calls).toHaveLength(1);
expectHeaderOnly(calls[0]!);
});
});
Loading
Loading