From 18a7bb055379bdc62087a26b249896dc887d3894 Mon Sep 17 00:00:00 2001 From: Chase J <54216608+chajac@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:43:40 +0100 Subject: [PATCH] feat(auth): add the WorkOS device grant client --- src/core/deviceAuth/types.ts | 11 + src/core/messages/auth.ts | 45 +--- src/core/messages/authErrors.ts | 54 ++++ src/shell/openBrowser.test.ts | 115 +++++++++ src/shell/openBrowser.ts | 70 +++++ src/shell/platform/bearerTransmission.test.ts | 84 ++++++ src/shell/platform/getAuthConfig.test.ts | 133 ++++++++++ src/shell/platform/getAuthConfig.ts | 68 +++++ src/shell/workos/config.test.ts | 22 ++ src/shell/workos/config.ts | 20 ++ src/shell/workos/pollDeviceToken.test.ts | 243 ++++++++++++++++++ src/shell/workos/pollDeviceToken.ts | 79 ++++++ src/shell/workos/refreshAccessToken.test.ts | 175 +++++++++++++ src/shell/workos/refreshAccessToken.ts | 70 +++++ .../workos/requestDeviceAuthorization.test.ts | 155 +++++++++++ .../workos/requestDeviceAuthorization.ts | 55 ++++ src/shell/workos/send.test.ts | 34 +++ src/shell/workos/send.ts | 125 +++++++++ src/shell/workos/types.ts | 57 ++++ 19 files changed, 1572 insertions(+), 43 deletions(-) create mode 100644 src/core/messages/authErrors.ts create mode 100644 src/shell/openBrowser.test.ts create mode 100644 src/shell/openBrowser.ts create mode 100644 src/shell/platform/bearerTransmission.test.ts create mode 100644 src/shell/platform/getAuthConfig.test.ts create mode 100644 src/shell/platform/getAuthConfig.ts create mode 100644 src/shell/workos/config.test.ts create mode 100644 src/shell/workos/config.ts create mode 100644 src/shell/workos/pollDeviceToken.test.ts create mode 100644 src/shell/workos/pollDeviceToken.ts create mode 100644 src/shell/workos/refreshAccessToken.test.ts create mode 100644 src/shell/workos/refreshAccessToken.ts create mode 100644 src/shell/workos/requestDeviceAuthorization.test.ts create mode 100644 src/shell/workos/requestDeviceAuthorization.ts create mode 100644 src/shell/workos/send.test.ts create mode 100644 src/shell/workos/send.ts create mode 100644 src/shell/workos/types.ts diff --git a/src/core/deviceAuth/types.ts b/src/core/deviceAuth/types.ts index d0ea39b35..9483a8582 100644 --- a/src/core/deviceAuth/types.ts +++ b/src/core/deviceAuth/types.ts @@ -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; +}; diff --git a/src/core/messages/auth.ts b/src/core/messages/auth.ts index f7d720157..16b8dc98a 100644 --- a/src/core/messages/auth.ts +++ b/src/core/messages/auth.ts @@ -1,4 +1,4 @@ -import { formatSeconds } from "~/core/formatSeconds.js"; +import { authErrorMessages } from "./authErrors.js"; export const authMessages = { title: "QA Wolf Authentication", @@ -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) => diff --git a/src/core/messages/authErrors.ts b/src/core/messages/authErrors.ts new file mode 100644 index 000000000..c4e539cdd --- /dev/null +++ b/src/core/messages/authErrors.ts @@ -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; diff --git a/src/shell/openBrowser.test.ts b/src/shell/openBrowser.test.ts new file mode 100644 index 000000000..f1c3b345b --- /dev/null +++ b/src/shell/openBrowser.test.ts @@ -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(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(() => {}); + +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(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(() => {})); + + const opened = await openBrowser(url, { + spawn: neverSpawn as unknown as SpawnFn, + platform: "linux", + sleep: async () => {}, + }); + + expect(opened).toBe(true); + }); +}); diff --git a/src/shell/openBrowser.ts b/src/shell/openBrowser.ts new file mode 100644 index 000000000..7e9d1512c --- /dev/null +++ b/src/shell/openBrowser.ts @@ -0,0 +1,70 @@ +import type { SpawnFn } from "./spawn.js"; + +type OpenBrowserDeps = { + spawn: SpawnFn; + platform: NodeJS.Platform; + sleep: (ms: number) => Promise; +}; + +/** + * 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 { + 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)]); +} diff --git a/src/shell/platform/bearerTransmission.test.ts b/src/shell/platform/bearerTransmission.test.ts new file mode 100644 index 000000000..9b5436bb2 --- /dev/null +++ b/src/shell/platform/bearerTransmission.test.ts @@ -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 | 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]!); + }); +}); diff --git a/src/shell/platform/getAuthConfig.test.ts b/src/shell/platform/getAuthConfig.test.ts new file mode 100644 index 000000000..5eff49626 --- /dev/null +++ b/src/shell/platform/getAuthConfig.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, mock } from "bun:test"; + +import { getAuthConfig } from "./getAuthConfig.js"; + +function createFetchMock(resolvedValue: Response) { + return mock().mockResolvedValue( + resolvedValue, + ) as unknown as typeof fetch; +} + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +const baseUrl = "https://test.qawolf.com"; + +describe("getAuthConfig", () => { + it("reads the deployment's sign-in configuration without credentials", async () => { + const mockFetch = createFetchMock( + jsonResponse({ workOsClientId: "client_1" }), + ); + + await getAuthConfig({ baseUrl, fetch: mockFetch }); + + const [url, init] = (mockFetch as unknown as ReturnType).mock + .calls[0] as [string, RequestInit]; + expect(url).toBe("https://test.qawolf.com/api/v0/auth/config"); + // No Authorization header: a client needs this before it has a token. + expect(init.headers).toBeUndefined(); + }); + + it("returns the client id the deployment publishes", async () => { + const result = await getAuthConfig({ + baseUrl, + fetch: createFetchMock(jsonResponse({ workOsClientId: "client_1" })), + }); + + expect(result).toEqual({ kind: "configured", clientId: "client_1" }); + }); + + it("reads a deployment that does not serve the route as offering none", async () => { + // Every deployment before this endpoint shipped, production included. + const result = await getAuthConfig({ + baseUrl, + fetch: createFetchMock( + jsonResponse({ failureMessage: "Route not found" }, { status: 404 }), + ), + }); + + expect(result).toEqual({ kind: "unconfigured" }); + }); + + // Distinct from the 404 above: the route answered, and published nothing. + it("reads an answer carrying no client id as offering none", async () => { + const result = await getAuthConfig({ + baseUrl, + fetch: createFetchMock(jsonResponse({ workOsClientId: "" })), + }); + + expect(result).toEqual({ kind: "unconfigured" }); + }); + + it("reads a blank client id as offering none", async () => { + const result = await getAuthConfig({ + baseUrl, + fetch: createFetchMock(jsonResponse({ workOsClientId: " " })), + }); + + expect(result).toEqual({ kind: "unconfigured" }); + }); + + it("reads a body that does not match the contract as offering none", async () => { + const result = await getAuthConfig({ + baseUrl, + fetch: createFetchMock(jsonResponse({ nonsense: true })), + }); + + expect(result).toEqual({ kind: "unconfigured" }); + }); + + // The three below must not read as "this deployment offers no browser + // sign-in": nothing was learned about the deployment at all. + it("separates an unreachable deployment from one that offers none", async () => { + const mockFetch = mock().mockRejectedValue( + Error("connect ECONNREFUSED"), + ) as unknown as typeof fetch; + + const result = await getAuthConfig({ baseUrl, fetch: mockFetch }); + + if (result.kind !== "unreachable") throw Error("expected unreachable"); + expect(result.detail).toContain("ECONNREFUSED"); + }); + + it.each([ + ["a failing server", 503], + ["rate limiting", 429], + ["a request timeout", 408], + // A gateway or a policy answering for the deployment, not the deployment + // saying it offers no browser sign-in. + ["an authentication wall", 401], + ["a forbidden answer", 403], + ])("separates %s from a deployment that offers none", async (_l, status) => { + const result = await getAuthConfig({ + baseUrl, + fetch: createFetchMock( + jsonResponse({ failureMessage: "boom" }, { status }), + ), + }); + + expect(result).toEqual({ + kind: "unreachable", + detail: `HTTP ${status}`, + }); + }); + + it("separates a body it could not read from one that offers none", async () => { + const result = await getAuthConfig({ + baseUrl, + fetch: createFetchMock( + new Response("hi", { + status: 200, + headers: { "content-type": "text/html" }, + }), + ), + }); + + expect(result.kind).toBe("unreachable"); + }); +}); diff --git a/src/shell/platform/getAuthConfig.ts b/src/shell/platform/getAuthConfig.ts new file mode 100644 index 000000000..24b5c8a77 --- /dev/null +++ b/src/shell/platform/getAuthConfig.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +import { errorMessage } from "~/core/errors.js"; + +type Deps = { + fetch: typeof globalThis.fetch; + baseUrl: string; +}; + +const timeoutMs = 10_000; + +const authConfigBody = z.object({ + // Trimmed first: a blank id is no id, and reporting it as configured would + // only fail one step later with a less useful message. + workOsClientId: z.string().trim().min(1), +}); + +export type AuthConfigResult = + | { kind: "configured"; clientId: string } + /** The deployment answered, and offers no browser sign-in. */ + | { kind: "unconfigured" } + /** + * The deployment could not be asked. This says nothing about whether it + * offers browser sign-in, so it must not be reported as though it did. + */ + | { kind: "unreachable"; detail: string }; + +/** + * Read without credentials, because a client needs the id to obtain a token and + * a token to call anything authenticated. + * + * A deployment that predates this route answers 404, which is a real answer: + * it publishes no client id. A timeout or a dropped connection is not, and + * collapsing the two would tell someone on a flaky link a permanent falsehood + * about their deployment. + */ +export async function getAuthConfig(deps: Deps): Promise { + let response: Response; + try { + response = await deps.fetch(`${deps.baseUrl}/api/v0/auth/config`, { + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (err: unknown) { + return { kind: "unreachable", detail: errorMessage(err) }; + } + + // A 404 is the pre-route deployments answering honestly. Anything else that + // is not a success — a 5xx, a 429, a gateway's 401 or 403 — is the server + // failing to answer the question, which says nothing about whether it offers + // browser sign-in. Reporting those as "offers none" states a permanent + // falsehood about the deployment. + if (response.status === 404) return { kind: "unconfigured" }; + if (!response.ok) { + return { kind: "unreachable", detail: `HTTP ${response.status}` }; + } + + let json: unknown; + try { + json = await response.json(); + } catch (err: unknown) { + return { kind: "unreachable", detail: errorMessage(err) }; + } + + const parsed = authConfigBody.safeParse(json); + return parsed.success + ? { kind: "configured", clientId: parsed.data.workOsClientId } + : { kind: "unconfigured" }; +} diff --git a/src/shell/workos/config.test.ts b/src/shell/workos/config.test.ts new file mode 100644 index 000000000..e1a38ed15 --- /dev/null +++ b/src/shell/workos/config.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test"; + +import { resolveWorkosConfig } from "./config.js"; + +describe("resolveWorkosConfig", () => { + it("points at WorkOS with the client id the deployment published", () => { + expect(resolveWorkosConfig("client_1")).toEqual({ + configured: true, + clientId: "client_1", + baseUrl: "https://api.workos.com", + }); + }); + + it("reports browser sign-in unavailable when the deployment published none", () => { + // A deployment that predates the config route, or serves no client id. + expect(resolveWorkosConfig(undefined)).toEqual({ configured: false }); + }); + + it("treats a blank client id as none", () => { + expect(resolveWorkosConfig(" ")).toEqual({ configured: false }); + }); +}); diff --git a/src/shell/workos/config.ts b/src/shell/workos/config.ts new file mode 100644 index 000000000..a206fa2b7 --- /dev/null +++ b/src/shell/workos/config.ts @@ -0,0 +1,20 @@ +import { defaultWorkosBaseUrl } from "./types.js"; + +export type WorkosConfig = + | { configured: false } + | { configured: true; clientId: string; baseUrl: string }; + +/** + * No override by design: the client id is a fact about the deployment rather + * than a preference, and a token verifies only against the client its backend + * checks — a hand-supplied value could only disagree, failing later as an + * opaque rejection. + */ +export function resolveWorkosConfig( + publishedClientId: string | undefined, +): WorkosConfig { + const clientId = publishedClientId?.trim(); + if (!clientId) return { configured: false }; + + return { configured: true, clientId, baseUrl: defaultWorkosBaseUrl }; +} diff --git a/src/shell/workos/pollDeviceToken.test.ts b/src/shell/workos/pollDeviceToken.test.ts new file mode 100644 index 000000000..d597ac2d7 --- /dev/null +++ b/src/shell/workos/pollDeviceToken.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it, mock } from "bun:test"; + +import { pollDeviceToken } from "./pollDeviceToken.js"; + +function createFetchMock(resolvedValue: Response) { + return mock().mockResolvedValue( + resolvedValue, + ) as unknown as typeof fetch; +} + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +function makeJwt(exp: number): string { + const encode = (value: unknown) => + Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); + return [encode({ alg: "RS256" }), encode({ exp }), "sig"].join("."); +} + +const accessToken = makeJwt(1_700_000_000); + +const success = { + access_token: accessToken, + refresh_token: "refresh_abc", + user: { email: "person@example.com" }, + organization_id: "org_1", +}; + +const deps = { + baseUrl: "https://api.example.com", + clientId: "client_123", +}; + +function textResponse(body: string, status: number): Response { + return new Response(body, { + status, + headers: { "content-type": "text/html" }, + }); +} + +function errorResponse(error: string, description?: string): Response { + return jsonResponse( + description ? { error, error_description: description } : { error }, + { status: 400 }, + ); +} + +describe("pollDeviceToken", () => { + it("posts the device code grant as form-encoded parameters", async () => { + const mockFetch = createFetchMock(jsonResponse(success)); + + await pollDeviceToken("device_abc", { ...deps, fetch: mockFetch }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/user_management/authenticate", + expect.objectContaining({ + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: "device_abc", + client_id: "client_123", + }).toString(), + }), + ); + }); + + it("returns tokens with the expiry read from the access token", async () => { + const result = await pollDeviceToken("device_abc", { + ...deps, + fetch: createFetchMock(jsonResponse(success)), + }); + + expect(result).toEqual({ + kind: "tokens", + tokens: { + accessToken, + refreshToken: "refresh_abc", + expiresAt: 1_700_000_000_000, + email: "person@example.com", + organizationId: "org_1", + }, + }); + }); + + it("keeps the organization undefined when the server names none", async () => { + const { organization_id: _org, ...withoutOrg } = success; + const result = await pollDeviceToken("device_abc", { + ...deps, + fetch: createFetchMock(jsonResponse(withoutOrg)), + }); + + if (result.kind !== "tokens") throw Error("expected tokens"); + expect(result.tokens.organizationId).toBeUndefined(); + }); + + it("reads WorkOS authentication errors, which carry a code rather than an error", async () => { + // These are shaped unlike the OAuth errors: `code` and `message`, plus a + // pending token and the organizations to choose between. + const result = await pollDeviceToken("device_abc", { + ...deps, + fetch: createFetchMock( + jsonResponse( + { + code: "organization_selection_required", + message: "Choose an organization to continue.", + pending_authentication_token: "pat_123", + organizations: [{ id: "org_1", name: "Acme" }], + }, + { status: 400 }, + ), + ), + }); + + expect(result).toEqual({ + kind: "error", + detail: "Choose an organization to continue.", + }); + }); + + it("reports authorization_pending as pending", async () => { + const result = await pollDeviceToken("device_abc", { + ...deps, + fetch: createFetchMock(errorResponse("authorization_pending")), + }); + + expect(result).toEqual({ kind: "pending" }); + }); + + it("reports slow_down as slow-down", async () => { + const result = await pollDeviceToken("device_abc", { + ...deps, + fetch: createFetchMock(errorResponse("slow_down")), + }); + + expect(result).toEqual({ kind: "slow-down" }); + }); + + it("reports access_denied as denied", async () => { + const result = await pollDeviceToken("device_abc", { + ...deps, + fetch: createFetchMock(errorResponse("access_denied")), + }); + + expect(result).toEqual({ kind: "denied" }); + }); + + it("reports expired_token as expired", async () => { + const result = await pollDeviceToken("device_abc", { + ...deps, + fetch: createFetchMock(errorResponse("expired_token")), + }); + + expect(result).toEqual({ kind: "expired" }); + }); + + it("treats invalid_grant as expiry, which is what WorkOS sends for a lapsed code", async () => { + const result = await pollDeviceToken("device_abc", { + ...deps, + fetch: createFetchMock( + errorResponse( + "invalid_grant", + "The device code provided is invalid, expired, or has already been used.", + ), + ), + }); + + expect(result).toEqual({ kind: "expired" }); + }); + + it("reports an unrecognised error code with its description", async () => { + const result = await pollDeviceToken("device_abc", { + ...deps, + fetch: createFetchMock(errorResponse("invalid_client", "unknown client")), + }); + + expect(result).toEqual({ kind: "error", detail: "unknown client" }); + }); + + it("reports an unreachable server as retryable, not as a refusal", async () => { + const mockFetch = mock().mockRejectedValue( + Error("socket hang up"), + ) as unknown as typeof fetch; + + const result = await pollDeviceToken("device_abc", { + ...deps, + fetch: mockFetch, + }); + + if (result.kind !== "unreachable") throw Error("expected unreachable"); + expect(result.detail).toContain("socket hang up"); + }); + + it("reports a success body that does not match the contract as an error", async () => { + const result = await pollDeviceToken("device_abc", { + ...deps, + fetch: createFetchMock(jsonResponse({ access_token: "only-this" })), + }); + + if (result.kind !== "error") throw Error("expected an error response"); + expect(result.detail).toContain("unexpected response"); + }); + // A device flow runs for minutes and the person has often already approved in + // the browser, so a fault the server may recover from has to be retried + // rather than ending the flow. + it.each([ + ["a bad gateway from a proxy", textResponse("502", 502)], + [ + "a WorkOS 500", + jsonResponse({ error: "internal_error" }, { status: 500 }), + ], + ["rate limiting", jsonResponse({ message: "slow down" }, { status: 429 })], + [ + "a request timeout", + jsonResponse({ message: "timeout" }, { status: 408 }), + ], + ["a captive portal answering 200", textResponse("hi", 200)], + ])("retries rather than refusing on %s", async (_label, response) => { + const result = await pollDeviceToken("device_abc", { + ...deps, + fetch: createFetchMock(response), + }); + + if (result.kind !== "unreachable") { + throw Error(`expected unreachable, got ${result.kind}`); + } + }); + + it("still refuses on a client error it cannot read", async () => { + const result = await pollDeviceToken("device_abc", { + ...deps, + fetch: createFetchMock(jsonResponse({ nope: true }, { status: 400 })), + }); + + if (result.kind !== "error") throw Error("expected an error response"); + expect(result.detail).toContain("HTTP 400"); + }); +}); diff --git a/src/shell/workos/pollDeviceToken.ts b/src/shell/workos/pollDeviceToken.ts new file mode 100644 index 000000000..b88fe6525 --- /dev/null +++ b/src/shell/workos/pollDeviceToken.ts @@ -0,0 +1,79 @@ +import { readAccessTokenExpiry } from "~/core/deviceAuth/tokenExpiry.js"; +import type { PollResponse } from "~/core/deviceAuth/types.js"; +import { sendWorkosRequest, unexpectedResponse } from "./send.js"; +import { deviceTokenBody, type WorkosDeps } from "./types.js"; + +const deviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code"; + +/** + * One attempt at redeeming a device code, translated from OAuth wire codes into + * the vocabulary the pure state machine understands. Looping and backing off + * are the caller's job. + */ +export async function pollDeviceToken( + deviceCode: string, + deps: WorkosDeps, +): Promise { + const outcome = await sendWorkosRequest( + `${deps.baseUrl}/user_management/authenticate`, + { + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: deviceCodeGrantType, + device_code: deviceCode, + client_id: deps.clientId, + }).toString(), + }, + deps.fetch, + ); + + // A fault that could clear on its own is worth another poll; an answer WorkOS + // meant is not. Keeping them apart is what lets the poller ride out a dropped + // request or a bad gateway instead of stranding someone who has already + // approved in the browser. + if (outcome.kind === "failure") { + return outcome.retryable + ? { kind: "unreachable", detail: outcome.detail } + : { kind: "error", detail: outcome.detail }; + } + + if (outcome.kind === "oauth-error") { + switch (outcome.code) { + case "authorization_pending": + return { kind: "pending" }; + case "slow_down": + return { kind: "slow-down" }; + case "access_denied": + return { kind: "denied" }; + // WorkOS documents expired_token for a lapsed device code, per RFC 8628, + // and reserves invalid_grant for one that is "invalid, malformed, or has + // already been used". The only device code this ever sends is one WorkOS + // just issued and has not redeemed, so expiry is the cause worth naming + // for both. + case "expired_token": + case "invalid_grant": + return { kind: "expired" }; + default: + return { + kind: "error", + detail: outcome.description ?? outcome.code, + }; + } + } + + const parsed = deviceTokenBody.safeParse(outcome.json); + if (!parsed.success) { + return { kind: "error", detail: unexpectedResponse }; + } + + return { + kind: "tokens", + tokens: { + accessToken: parsed.data.access_token, + refreshToken: parsed.data.refresh_token, + expiresAt: readAccessTokenExpiry(parsed.data.access_token), + email: parsed.data.user.email, + organizationId: parsed.data.organization_id, + }, + }; +} diff --git a/src/shell/workos/refreshAccessToken.test.ts b/src/shell/workos/refreshAccessToken.test.ts new file mode 100644 index 000000000..e18088f35 --- /dev/null +++ b/src/shell/workos/refreshAccessToken.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, mock } from "bun:test"; + +import { refreshAccessToken } from "./refreshAccessToken.js"; + +function createFetchMock(resolvedValue: Response) { + return mock().mockResolvedValue( + resolvedValue, + ) as unknown as typeof fetch; +} + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +function makeJwt(exp: number): string { + const encode = (value: unknown) => + Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); + return [encode({ alg: "RS256" }), encode({ exp }), "sig"].join("."); +} + +const deps = { + baseUrl: "https://api.example.com", + clientId: "client_123", +}; + +describe("refreshAccessToken", () => { + it("posts the refresh grant as form-encoded parameters", async () => { + const mockFetch = createFetchMock( + jsonResponse({ + access_token: makeJwt(1_700_000_000), + refresh_token: "refresh_2", + user: { email: "person@example.com" }, + }), + ); + + await refreshAccessToken("refresh_1", undefined, { + ...deps, + fetch: mockFetch, + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/user_management/authenticate", + expect.objectContaining({ + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: "refresh_1", + client_id: "client_123", + }).toString(), + }), + ); + }); + + it("pins the organization when one is given, so a refresh cannot silently move", async () => { + const mockFetch = createFetchMock( + jsonResponse({ + access_token: makeJwt(1_700_000_000), + refresh_token: "refresh_2", + user: { email: "person@example.com" }, + organization_id: "org_1", + }), + ); + + await refreshAccessToken("refresh_1", "org_1", { + ...deps, + fetch: mockFetch, + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/user_management/authenticate", + expect.objectContaining({ + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: "refresh_1", + client_id: "client_123", + organization_id: "org_1", + }).toString(), + }), + ); + }); + + it("returns the rotated refresh token, not the one it was given", async () => { + const accessToken = makeJwt(1_700_000_000); + const result = await refreshAccessToken("refresh_1", undefined, { + ...deps, + fetch: createFetchMock( + jsonResponse({ + access_token: accessToken, + refresh_token: "refresh_2", + user: { email: "person@example.com" }, + organization_id: "org_1", + }), + ), + }); + + expect(result).toEqual({ + ok: true, + value: { + accessToken, + refreshToken: "refresh_2", + expiresAt: 1_700_000_000_000, + email: "person@example.com", + organizationId: "org_1", + }, + }); + }); + + it("fails when the refresh token has been revoked", async () => { + const result = await refreshAccessToken("refresh_1", undefined, { + ...deps, + fetch: createFetchMock( + jsonResponse( + { error: "invalid_grant", error_description: "token revoked" }, + { status: 400 }, + ), + ), + }); + + expect(result).toEqual({ + ok: false, + error: "token revoked", + retryable: false, + }); + }); + + it("fails when the network is unreachable", async () => { + const mockFetch = mock().mockRejectedValue( + Error("socket hang up"), + ) as unknown as typeof fetch; + + const result = await refreshAccessToken("refresh_1", undefined, { + ...deps, + fetch: mockFetch, + }); + + if (result.ok) throw Error("expected failure, got success"); + expect(result.error).toContain("socket hang up"); + // WorkOS asks clients to retry the same refresh token on a transport + // failure, not to tear the session down. + expect(result.retryable).toBe(true); + }); + + it.each([ + ["a WorkOS 500", 500], + ["rate limiting", 429], + ])( + "marks %s retryable, so the session survives it", + async (_label, status) => { + const result = await refreshAccessToken("refresh_1", undefined, { + ...deps, + fetch: createFetchMock(jsonResponse({ error: "oops" }, { status })), + }); + + if (result.ok) throw Error("expected failure, got success"); + expect(result.retryable).toBe(true); + }, + ); + + it("marks a revoked grant terminal, so it is not retried", async () => { + const result = await refreshAccessToken("refresh_1", undefined, { + ...deps, + fetch: createFetchMock( + jsonResponse({ error: "invalid_grant" }, { status: 400 }), + ), + }); + + if (result.ok) throw Error("expected failure, got success"); + expect(result.retryable).toBe(false); + }); +}); diff --git a/src/shell/workos/refreshAccessToken.ts b/src/shell/workos/refreshAccessToken.ts new file mode 100644 index 000000000..f03c1cafa --- /dev/null +++ b/src/shell/workos/refreshAccessToken.ts @@ -0,0 +1,70 @@ +import { readAccessTokenExpiry } from "~/core/deviceAuth/tokenExpiry.js"; +import type { DeviceTokens } from "~/core/deviceAuth/types.js"; +import { sendWorkosRequest, unexpectedResponse } from "./send.js"; +import { + type AuthorizationResult, + deviceTokenBody, + type WorkosDeps, +} from "./types.js"; + +/** + * Trades a refresh token for a fresh access token. + * + * Refresh tokens rotate: the response carries a replacement and the token + * passed in is spent. Callers must persist `refreshToken` from the result, or + * the next refresh fails and the person is silently signed out. + * + * `organizationId` pins the session to one WorkOS organization. WorkOS supports + * this for public clients, and it is what lets the CLI stay in — or move to — a + * chosen organization instead of accepting whichever one it is given. + */ +export async function refreshAccessToken( + refreshToken: string, + organizationId: string | undefined, + deps: WorkosDeps, +): Promise> { + const params = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: deps.clientId, + }); + if (organizationId) params.set("organization_id", organizationId); + + const outcome = await sendWorkosRequest( + `${deps.baseUrl}/user_management/authenticate`, + { + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }, + deps.fetch, + ); + + if (outcome.kind === "failure") { + return { ok: false, error: outcome.detail, retryable: outcome.retryable }; + } + + if (outcome.kind === "oauth-error") { + // A protocol answer WorkOS meant. Repeating it changes nothing. + return { + ok: false, + error: outcome.description ?? outcome.code, + retryable: false, + }; + } + + const parsed = deviceTokenBody.safeParse(outcome.json); + if (!parsed.success) { + return { ok: false, error: unexpectedResponse, retryable: false }; + } + + return { + ok: true, + value: { + accessToken: parsed.data.access_token, + refreshToken: parsed.data.refresh_token, + expiresAt: readAccessTokenExpiry(parsed.data.access_token), + email: parsed.data.user.email, + organizationId: parsed.data.organization_id, + }, + }; +} diff --git a/src/shell/workos/requestDeviceAuthorization.test.ts b/src/shell/workos/requestDeviceAuthorization.test.ts new file mode 100644 index 000000000..d662f7726 --- /dev/null +++ b/src/shell/workos/requestDeviceAuthorization.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, mock } from "bun:test"; + +import { requestDeviceAuthorization } from "./requestDeviceAuthorization.js"; + +function createFetchMock(resolvedValue: Response) { + return mock().mockResolvedValue( + resolvedValue, + ) as unknown as typeof fetch; +} + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +const authorization = { + device_code: "device_abc", + user_code: "WDJB-MJHT", + verification_uri: "https://example.com/device", + verification_uri_complete: "https://example.com/device?user_code=WDJB-MJHT", + expires_in: 300, + interval: 5, +}; + +const deps = { + baseUrl: "https://api.example.com", + clientId: "client_123", +}; + +type Result = Awaited>; + +function expectOk(result: Result) { + if (!result.ok) throw Error(`expected success, got: ${result.error}`); + return result.value; +} + +function expectError(result: Result): string { + if (result.ok) throw Error("expected failure, got success"); + return result.error; +} + +describe("requestDeviceAuthorization", () => { + it("posts the client id as JSON to the device authorization endpoint", async () => { + const mockFetch = createFetchMock(jsonResponse(authorization)); + + await requestDeviceAuthorization({ ...deps, fetch: mockFetch }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://api.example.com/user_management/authorize/device", + expect.objectContaining({ + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ client_id: "client_123" }), + }), + ); + }); + + it("returns the authorization in the shape the poller expects", async () => { + const result = await requestDeviceAuthorization({ + ...deps, + fetch: createFetchMock(jsonResponse(authorization)), + }); + + expect(result).toEqual({ + ok: true, + value: { + deviceCode: "device_abc", + userCode: "WDJB-MJHT", + verificationUri: "https://example.com/device", + verificationUriComplete: + "https://example.com/device?user_code=WDJB-MJHT", + expiresInSec: 300, + intervalSec: 5, + }, + }); + }); + + it("falls back to a five second interval when the server omits one", async () => { + const { interval: _interval, ...withoutInterval } = authorization; + const result = await requestDeviceAuthorization({ + ...deps, + fetch: createFetchMock(jsonResponse(withoutInterval)), + }); + + expect(expectOk(result).intervalSec).toBe(5); + }); + + it("reports a missing complete URI as undefined rather than omitting it", async () => { + const { verification_uri_complete: _complete, ...partial } = authorization; + const result = await requestDeviceAuthorization({ + ...deps, + fetch: createFetchMock(jsonResponse(partial)), + }); + + expect(expectOk(result).verificationUriComplete).toBeUndefined(); + }); + + it("fails when the device grant is not enabled for the client", async () => { + const result = await requestDeviceAuthorization({ + ...deps, + fetch: createFetchMock( + jsonResponse( + { error: "unauthorized_client", error_description: "not enabled" }, + { status: 400 }, + ), + ), + }); + + expect(result).toEqual({ + ok: false, + error: "not enabled", + retryable: false, + }); + }); + + it("fails with the error code when no description is given", async () => { + const result = await requestDeviceAuthorization({ + ...deps, + fetch: createFetchMock( + jsonResponse({ error: "invalid_client" }, { status: 400 }), + ), + }); + + expect(result).toEqual({ + ok: false, + error: "invalid_client", + retryable: false, + }); + }); + + it("fails when the response body does not match the contract", async () => { + const result = await requestDeviceAuthorization({ + ...deps, + fetch: createFetchMock(jsonResponse({ nonsense: true })), + }); + + expect(expectError(result)).toContain("unexpected response"); + }); + + it("fails when the network is unreachable", async () => { + const mockFetch = mock().mockRejectedValue( + Error("connect ECONNREFUSED"), + ) as unknown as typeof fetch; + + const result = await requestDeviceAuthorization({ + ...deps, + fetch: mockFetch, + }); + + expect(expectError(result)).toContain("connect ECONNREFUSED"); + }); +}); diff --git a/src/shell/workos/requestDeviceAuthorization.ts b/src/shell/workos/requestDeviceAuthorization.ts new file mode 100644 index 000000000..97989557f --- /dev/null +++ b/src/shell/workos/requestDeviceAuthorization.ts @@ -0,0 +1,55 @@ +import type { DeviceAuthorization } from "~/core/deviceAuth/types.js"; +import { sendWorkosRequest, unexpectedResponse } from "./send.js"; +import { + type AuthorizationResult, + defaultIntervalSec, + deviceAuthorizationBody, + type WorkosDeps, +} from "./types.js"; + +/** + * Starts a device flow. Note the content type: this endpoint takes JSON, while + * the token endpoint it pairs with takes form encoding. + */ +export async function requestDeviceAuthorization( + deps: WorkosDeps, +): Promise> { + const outcome = await sendWorkosRequest( + `${deps.baseUrl}/user_management/authorize/device`, + { + headers: { "content-type": "application/json" }, + body: JSON.stringify({ client_id: deps.clientId }), + }, + deps.fetch, + ); + + if (outcome.kind === "failure") { + return { ok: false, error: outcome.detail, retryable: outcome.retryable }; + } + + if (outcome.kind === "oauth-error") { + // A protocol answer WorkOS meant. Repeating it changes nothing. + return { + ok: false, + error: outcome.description ?? outcome.code, + retryable: false, + }; + } + + const parsed = deviceAuthorizationBody.safeParse(outcome.json); + if (!parsed.success) { + return { ok: false, error: unexpectedResponse, retryable: false }; + } + + return { + ok: true, + value: { + deviceCode: parsed.data.device_code, + userCode: parsed.data.user_code, + verificationUri: parsed.data.verification_uri, + verificationUriComplete: parsed.data.verification_uri_complete, + expiresInSec: parsed.data.expires_in, + intervalSec: parsed.data.interval ?? defaultIntervalSec, + }, + }; +} diff --git a/src/shell/workos/send.test.ts b/src/shell/workos/send.test.ts new file mode 100644 index 000000000..5f0c510e1 --- /dev/null +++ b/src/shell/workos/send.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, mock } from "bun:test"; + +import { sendWorkosRequest } from "./send.js"; + +const init = { + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "grant_type=refresh_token&refresh_token=refresh_1", +}; + +describe("sendWorkosRequest", () => { + // The body carries a device code or a refresh token. A redirect that + // forwarded it would hand it to whichever host the response named. + it("refuses a redirect rather than following it", async () => { + const mockFetch = mock().mockResolvedValue( + new Response(undefined, { + status: 302, + headers: { location: "https://elsewhere.example/token" }, + }), + ) as unknown as typeof fetch; + + const outcome = await sendWorkosRequest( + "https://api.example.com/user_management/authenticate", + init, + mockFetch, + ); + + if (outcome.kind !== "failure") throw Error("expected a failure"); + expect(outcome.detail).toContain("redirect"); + expect(outcome.retryable).toBe(false); + const [, options] = (mockFetch as unknown as ReturnType).mock + .calls[0] as [string, RequestInit]; + expect(options.redirect).toBe("manual"); + }); +}); diff --git a/src/shell/workos/send.ts b/src/shell/workos/send.ts new file mode 100644 index 000000000..d47c6a0ef --- /dev/null +++ b/src/shell/workos/send.ts @@ -0,0 +1,125 @@ +import { errorMessage } from "~/core/errors.js"; +import { authErrorMessages } from "~/core/messages/authErrors.js"; +import { authenticationErrorBody, oauthErrorBody } from "./types.js"; + +const timeoutMs = 15_000; + +export const unexpectedResponse = authErrorMessages.workos.unexpectedResponse; + +/** + * Statuses worth asking again on: WorkOS failing rather than refusing, which is + * transient in the same way a dropped socket is. 408, 429 and 5xx are the set + * WorkOS documents as retryable. Every other 4xx is an answer it meant, and + * repeating that would change nothing. + */ +function isTransientStatus(status: number): boolean { + return status >= 500 || status === 429 || status === 408; +} + +/** + * One WorkOS device-endpoint round trip, reduced to three outcomes so callers + * never inspect a thrown error. An OAuth error body is a normal part of this + * protocol — `authorization_pending` arrives as HTTP 400 on every poll — so it + * is a distinct outcome rather than a failure. + */ +export type WireOutcome = + | { kind: "json"; json: unknown } + | { kind: "oauth-error"; code: string; description: string | undefined } + /** + * `retryable` says whether repeating the request could answer differently. + * A poller that gives up on a transient fault throws away an approval the + * person has already granted in their browser and cannot see failing. + */ + | { kind: "failure"; detail: string; retryable: boolean }; + +export async function sendWorkosRequest( + url: string, + init: { headers: Record; body: string }, + fetchFn: typeof globalThis.fetch, +): Promise { + let response: Response; + try { + response = await fetchFn(url, { + method: "POST", + headers: init.headers, + body: init.body, + // The body carries a device code or a refresh token. Followed, a + // redirect would replay it to whichever host the response named. + redirect: "manual", + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (err: unknown) { + return { + kind: "failure", + detail: authErrorMessages.workos.unreachable(errorMessage(err)), + retryable: true, + }; + } + + if (response.status >= 300 && response.status < 400) { + return { + kind: "failure", + detail: authErrorMessages.workos.redirected, + retryable: false, + }; + } + + let json: unknown; + try { + json = await response.json(); + } catch { + // A 2xx whose body will not parse is a proxy or a captive portal answering + // in place of WorkOS, so the next attempt may well reach the real server. + return { + kind: "failure", + detail: authErrorMessages.workos.unexpectedResponseWithStatus( + response.status, + ), + retryable: response.ok || isTransientStatus(response.status), + }; + } + + if (response.ok) return { kind: "json", json }; + + // Checked before the error bodies below: a 5xx that happens to carry an + // `error` field is still a server fault, and reading it as a protocol refusal + // would end the flow on a fault that clears on its own. + if (isTransientStatus(response.status)) { + return { + kind: "failure", + detail: authErrorMessages.workos.unexpectedResponseWithStatus( + response.status, + ), + retryable: true, + }; + } + + const parsed = oauthErrorBody.safeParse(json); + if (parsed.success) { + return { + kind: "oauth-error", + code: parsed.data.error, + description: parsed.data.error_description, + }; + } + + // WorkOS answers some conditions with an authentication error instead, which + // uses different field names. Without this branch they read as an + // unrecognised body, which reports a protocol condition as a network fault. + const authError = authenticationErrorBody.safeParse(json); + if (authError.success) { + return { + kind: "oauth-error", + code: authError.data.code, + description: authError.data.message, + }; + } + + return { + kind: "failure", + detail: authErrorMessages.workos.unexpectedResponseWithStatus( + response.status, + ), + retryable: false, + }; +} diff --git a/src/shell/workos/types.ts b/src/shell/workos/types.ts new file mode 100644 index 000000000..4b3416e13 --- /dev/null +++ b/src/shell/workos/types.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +/** WorkOS hosts the device endpoints; the CLI never sends a secret to them. */ +export const defaultWorkosBaseUrl = "https://api.workos.com"; + +/** Interval the device grant assumes when the server states none. */ +export const defaultIntervalSec = 5; + +export type WorkosDeps = { + fetch: typeof globalThis.fetch; + baseUrl: string; + clientId: string; +}; + +export const deviceAuthorizationBody = z.object({ + device_code: z.string().min(1), + user_code: z.string().min(1), + verification_uri: z.string().min(1), + verification_uri_complete: z.string().min(1).optional(), + expires_in: z.number().int().positive(), + interval: z.number().int().positive().optional(), +}); + +export const deviceTokenBody = z.object({ + access_token: z.string().min(1), + refresh_token: z.string().min(1), + user: z.object({ email: z.string().min(1) }), + organization_id: z.string().min(1).optional(), +}); + +export const oauthErrorBody = z.object({ + error: z.string().min(1), + error_description: z.string().min(1).optional(), +}); + +/** + * WorkOS authentication errors are shaped unlike the OAuth ones: `code` and + * `message` rather than `error` and `error_description`. They cover + * `organization_selection_required`, `mfa_enrollment`, `email_verification_required` + * and friends. Parsed separately so such a response reads as what it is instead + * of an unrecognised body. + */ +export const authenticationErrorBody = z.object({ + code: z.string().min(1), + message: z.string().min(1).optional(), +}); + +export type AuthorizationResult = + | { ok: true; value: T } + /** + * `retryable` marks a failure the same request could survive. WorkOS + * classifies 408, 429 and 5xx as transient and asks clients to retry the same + * refresh token; only an OAuth `invalid_grant` means the session is gone. + * Without this flag a dropped packet is indistinguishable from a revoked + * credential, and the person is told to sign in again over a blip. + */ + | { ok: false; error: string; retryable: boolean };