diff --git a/.changeset/browser-sign-in.md b/.changeset/browser-sign-in.md new file mode 100644 index 000000000..f156f2adf --- /dev/null +++ b/.changeset/browser-sign-in.md @@ -0,0 +1,11 @@ +--- +"@qawolf/cli": minor +--- + +`qawolf auth login` now asks how you want to sign in. Choose `Browser` to sign in with your QA Wolf account, or `API key` to paste a team key as before. The browser option shows a short code, opens the verification page, and waits until you confirm the code. If the CLI cannot open a browser, it prints the URL for you to open. + +Browser sign-in uses WorkOS Connect. The CLI reads the sign-in provider and the public client ID from the QA Wolf deployment it points at, so it works against any host that publishes them. The session it stores is bound to that deployment's API URL. A deployment that does not publish a WorkOS Connect configuration says so, and you can run the command again and choose the API key path. + +The CLI keeps the session in the system keychain, and falls back to a file that only its owner can read. It refreshes the session automatically before the access token expires, and it keeps the session bound to the same deployment on every refresh. `qawolf auth logout` removes both the session and the API key. An API key still takes precedence over a browser session. + +If you point the CLI at a different deployment, it does not reuse the session from the previous one. Sign in again for the new deployment. diff --git a/skills/qawolf-cli/SKILL.md b/skills/qawolf-cli/SKILL.md index 03d3faa92..0a1ea8e29 100644 --- a/skills/qawolf-cli/SKILL.md +++ b/skills/qawolf-cli/SKILL.md @@ -125,7 +125,7 @@ that `url`; never guess a route and never send a repository link in its place. | Command | Kind | What it does | | --- | --- | --- | -| `qawolf auth login` | local | Authenticate with your QA Wolf API key | +| `qawolf auth login` | local | Authenticate with QA Wolf in a browser or with an API key | | `qawolf auth logout` | local | Remove stored credentials | | `qawolf auth whoami` | read | Show authentication status | | `qawolf automate` | write | Request automation for draft flows. First create a named local .flow.ts draft for every requested journey that does not already have a matching draft; never reuse a generic starter or placeholder. Each new draft must start with a JSDoc Goal: description, import flow from @qawolf/flows/web, and use export default flow(...); a comment-only file or direct test(...) call is not a valid draft. Commit and push all changes with Git to publish them, then list remote drafts to resolve every selected ID. Do not use patch to create or rename a selected flow. Finally make one automation request containing all requested flow IDs. | diff --git a/src/commands/auth/index.ts b/src/commands/auth/index.ts index 0be86ea96..3931cc414 100644 --- a/src/commands/auth/index.ts +++ b/src/commands/auth/index.ts @@ -16,7 +16,7 @@ export function registerAuthCommand( .description("Manage authentication with QA Wolf"); declareCommandKind(auth.command("login"), "local") - .description("Authenticate with your QA Wolf API key") + .description("Authenticate with QA Wolf in a browser or with an API key") .action(withContext(signals, handleLogin)); declareCommandKind(auth.command("logout"), "local") diff --git a/src/commands/auth/login.test.ts b/src/commands/auth/login.test.ts new file mode 100644 index 000000000..31cce5f0c --- /dev/null +++ b/src/commands/auth/login.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; + +import type { ApiKeyResult } from "~/domains/auth/types.js"; +import type { CommandContext } from "~/shell/commandContext.js"; +import type { UI } from "~/shell/ui/types.js"; +import { handleLogin } from "./login.js"; + +afterEach(() => { + mock.restore(); +}); + +function makeCtx( + ui: Partial & { mode: UI["mode"] }, +): CommandContext & { ui: UI } { + return { + ui: { + gap: mock(), + intro: mock(), + info: mock(), + warn: mock(), + cancel: mock(), + error: mock(), + ...ui, + } as unknown as UI, + configDir: "/config", + } as unknown as CommandContext & { ui: UI }; +} + +function makeDeps( + overrides: { + resolveApiKey?: () => Promise; + } = {}, +) { + return { + resolveApiKey: overrides.resolveApiKey ?? (async () => undefined), + loginWithApiKey: mock(async () => undefined), + loginWithDevice: mock(async () => undefined), + }; +} + +describe("handleLogin", () => { + it("refuses to run without an interactive terminal", async () => { + const ctx = makeCtx({ mode: "json" }); + const deps = makeDeps(); + + const result = await handleLogin(ctx, deps); + + expect(result).toEqual({ error: "non-interactive" }); + expect(deps.loginWithDevice).not.toHaveBeenCalled(); + expect(deps.loginWithApiKey).not.toHaveBeenCalled(); + }); + + it("routes to browser sign-in when the browser option is chosen", async () => { + const ctx = makeCtx({ + mode: "human", + select: mock(async () => ({ ok: true as const, value: "browser" })), + }); + const deps = makeDeps(); + + await handleLogin(ctx, deps); + + expect(deps.loginWithDevice).toHaveBeenCalledTimes(1); + expect(deps.loginWithApiKey).not.toHaveBeenCalled(); + }); + + it("routes to the API key prompt when that option is chosen", async () => { + const ctx = makeCtx({ + mode: "human", + select: mock(async () => ({ ok: true as const, value: "api-key" })), + }); + const deps = makeDeps(); + + await handleLogin(ctx, deps); + + expect(deps.loginWithApiKey).toHaveBeenCalledTimes(1); + expect(deps.loginWithDevice).not.toHaveBeenCalled(); + }); + + it("signs in with neither method when the choice is dismissed", async () => { + const ctx = makeCtx({ + mode: "human", + select: mock(async () => ({ ok: false as const })), + }); + const deps = makeDeps(); + + await handleLogin(ctx, deps); + + expect(ctx.ui.cancel).toHaveBeenCalled(); + expect(deps.loginWithApiKey).not.toHaveBeenCalled(); + expect(deps.loginWithDevice).not.toHaveBeenCalled(); + }); + + it("stops when an already-authenticated person declines to sign in again", async () => { + const ctx = makeCtx({ + mode: "human", + confirm: mock(async () => ({ ok: true as const, value: false })), + select: mock(async () => ({ ok: true as const, value: "browser" })), + }); + const deps = makeDeps({ + resolveApiKey: async () => ({ + key: "qaw_existing", + source: "env", + }), + }); + + await handleLogin(ctx, deps); + + expect(deps.loginWithDevice).not.toHaveBeenCalled(); + expect(ctx.ui.select).not.toHaveBeenCalled(); + }); + + it("offers the choice when an already-authenticated person confirms", async () => { + const ctx = makeCtx({ + mode: "human", + confirm: mock(async () => ({ ok: true as const, value: true })), + select: mock(async () => ({ ok: true as const, value: "browser" })), + }); + const deps = makeDeps({ + resolveApiKey: async () => ({ + key: "qaw_existing", + source: "env", + }), + }); + + await handleLogin(ctx, deps); + + expect(deps.loginWithDevice).toHaveBeenCalledTimes(1); + }); + // The precedence itself is deliberate; being told "Signed in as ..." while + // every later command keeps using the old key is not. + it.each([ + ["env" as const, "unset the variable"], + ["keychain" as const, "auth logout"], + ["file" as const, "auth logout"], + ])( + "warns before browser sign-in that a %s API key still wins", + async (source, remedy) => { + const ctx = makeCtx({ + mode: "human", + confirm: mock(async () => ({ ok: true as const, value: true })), + select: mock(async () => ({ ok: true as const, value: "browser" })), + }); + const deps = makeDeps({ + resolveApiKey: async () => ({ key: "qaw_old", source }), + }); + + await handleLogin(ctx, deps); + + expect(ctx.ui.warn).toHaveBeenCalledTimes(1); + expect( + (ctx.ui.warn as ReturnType).mock.calls[0]?.[0], + ).toContain(remedy); + expect(deps.loginWithDevice).toHaveBeenCalledTimes(1); + }, + ); + + it("does not warn when the previous session was itself a browser one", async () => { + const ctx = makeCtx({ + mode: "human", + confirm: mock(async () => ({ ok: true as const, value: true })), + select: mock(async () => ({ ok: true as const, value: "browser" })), + }); + const deps = makeDeps({ + resolveApiKey: async () => ({ key: "access_old", source: "browser" }), + }); + + await handleLogin(ctx, deps); + + expect(ctx.ui.warn).not.toHaveBeenCalled(); + }); + + it("does not warn on the API key path, where nothing is shadowed", async () => { + const ctx = makeCtx({ + mode: "human", + confirm: mock(async () => ({ ok: true as const, value: true })), + select: mock(async () => ({ ok: true as const, value: "api-key" })), + }); + const deps = makeDeps({ + resolveApiKey: async () => ({ key: "qaw_old", source: "keychain" }), + }); + + await handleLogin(ctx, deps); + + expect(ctx.ui.warn).not.toHaveBeenCalled(); + expect(deps.loginWithApiKey).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 5a427cb36..4db64b352 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -1,21 +1,31 @@ -import { - resolveApiKey, - saveApiKey, - validateApiKey, -} from "~/domains/auth/index.js"; -import { createPlatformClient } from "~/shell/platform/createPlatformClient.js"; -import { - type CommandContext, - type CommandResult, -} from "~/shell/commandContext.js"; import { authMessages } from "~/core/messages/index.js"; +import { resolveApiKey as realResolveApiKey } from "~/domains/auth/index.js"; +import type { ApiKeyResult } from "~/domains/auth/types.js"; +import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; +import { loginWithApiKey as realLoginWithApiKey } from "./loginApiKey.js"; +import { loginWithDevice as realLoginWithDevice } from "./loginDevice.js"; -export async function handleLogin(ctx: CommandContext): Promise { +type LoginDeps = { + resolveApiKey?: ( + configDir: string, + fs: CommandContext["fs"], + ) => Promise; + loginWithApiKey?: (ctx: CommandContext) => Promise; + loginWithDevice?: (ctx: CommandContext) => Promise; +}; + +const browserMethod = "browser"; + +export async function handleLogin( + ctx: CommandContext, + deps: LoginDeps = {}, +): Promise { if (ctx.ui.mode !== "human") { ctx.ui.error(authMessages.login.nonInteractive); return { error: "non-interactive" }; } + const resolveApiKey = deps.resolveApiKey ?? realResolveApiKey; const existing = await resolveApiKey(ctx.configDir, ctx.fs); if (existing) { const reauth = await ctx.ui.confirm(authMessages.login.reAuthPrompt); @@ -28,45 +38,42 @@ export async function handleLogin(ctx: CommandContext): Promise { ctx.ui.gap(); ctx.ui.intro(authMessages.title); - const result = await ctx.ui.password( - authMessages.promptApiKey, - "Set QAWOLF_API_KEY to authenticate in non-interactive environments.", - ); - if (!result.ok) { + // The two credentials do not grant the same access — an API key carries team + // scope a user token does not — so the choice is explicit rather than a + // default that quietly narrows what later commands can do. + const method = await ctx.ui.select(authMessages.login.chooseMethod, [ + { + value: browserMethod, + label: authMessages.login.methodBrowser, + hint: authMessages.login.methodBrowserHint, + }, + { + value: "api-key", + label: authMessages.login.methodApiKey, + hint: authMessages.login.methodApiKeyHint, + }, + ]); + + if (!method.ok) { ctx.ui.cancel(authMessages.cancelled); return; } - if (!result.value.trim()) { - ctx.ui.cancel(authMessages.cancelled); - return; + if (method.value !== browserMethod) { + return (deps.loginWithApiKey ?? realLoginWithApiKey)(ctx); } - await ctx.ui.withProgress( - [ - { - message: authMessages.verifying, - task: async () => { - const v = await validateApiKey({ - platformClient: createPlatformClient(result.value, { - baseUrl: ctx.apiBaseUrl, - fetch: globalThis.fetch, - }), - }); - if (!v.valid) throw Error(v.error); - }, - }, - { - message: authMessages.storing, - task: async () => saveApiKey(ctx.configDir, result.value, ctx.fs), - }, - ], - ([, saveResult]) => { - return saveResult.stored === "file" - ? authMessages.storedFile - : authMessages.storedKeychain; - }, - ); + // Said before the flow starts rather than after it: the browser round trip + // ends in "Signed in as ...", and a caveat printed after that reads as an + // afterthought to a sign-in the person believes already took effect. A + // previous browser session is simply replaced, so only an API key shadows. + if (existing && existing.source !== "browser") { + ctx.ui.warn( + existing.source === "env" + ? authMessages.login.apiKeyPrecedence.env + : authMessages.login.apiKeyPrecedence.stored, + ); + } - ctx.ui.outro(authMessages.outroSuccess); + return (deps.loginWithDevice ?? realLoginWithDevice)(ctx); } diff --git a/src/commands/auth/loginApiKey.ts b/src/commands/auth/loginApiKey.ts new file mode 100644 index 000000000..d8f896b70 --- /dev/null +++ b/src/commands/auth/loginApiKey.ts @@ -0,0 +1,54 @@ +import { saveApiKey, validateApiKey } from "~/domains/auth/index.js"; +import { createPlatformClient } from "~/shell/platform/createPlatformClient.js"; +import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; +import { authMessages } from "~/core/messages/index.js"; + +/** Paste-a-key sign-in. Assumes the caller has already shown the intro. */ +export async function loginWithApiKey( + ctx: CommandContext, +): Promise { + const result = await ctx.ui.password( + authMessages.promptApiKey, + "Set QAWOLF_API_KEY to authenticate in non-interactive environments.", + ); + if (!result.ok) { + ctx.ui.cancel(authMessages.cancelled); + return; + } + + // Normalised once: a pasted key routinely carries whitespace, and validating + // one string while storing another would persist a key that cannot work. + const apiKey = result.value.trim(); + if (!apiKey) { + ctx.ui.cancel(authMessages.cancelled); + return; + } + + await ctx.ui.withProgress( + [ + { + message: authMessages.verifying, + task: async () => { + const v = await validateApiKey({ + platformClient: createPlatformClient(apiKey, { + baseUrl: ctx.apiBaseUrl, + fetch: globalThis.fetch, + }), + }); + if (!v.valid) throw Error(v.error); + }, + }, + { + message: authMessages.storing, + task: async () => saveApiKey(ctx.configDir, apiKey, ctx.fs), + }, + ], + ([, saveResult]) => { + return saveResult.stored === "file" + ? authMessages.storedFile + : authMessages.storedKeychain; + }, + ); + + ctx.ui.outro(authMessages.outroSuccess); +} diff --git a/src/commands/auth/loginDevice.test.ts b/src/commands/auth/loginDevice.test.ts new file mode 100644 index 000000000..90de0cc08 --- /dev/null +++ b/src/commands/auth/loginDevice.test.ts @@ -0,0 +1,236 @@ +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; + +import { Entry } from "@napi-rs/keyring"; + +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; +import type { CommandContext } from "~/shell/commandContext.js"; +import type { UI } from "~/shell/ui/types.js"; +import { loginWithDevice } from "./loginDevice.js"; + +afterEach(() => { + mock.restore(); +}); + +const apiBaseUrl = "https://app.example"; +const issuer = "https://signin.example"; +const resource = "https://app.example/api"; + +function makeJwt(payload: unknown): string { + const encode = (value: unknown) => + Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); + return [encode({ alg: "RS256" }), encode(payload), "sig"].join("."); +} + +const environmentToken = makeJwt({ iss: issuer, aud: "client_01ENV", exp: 2 }); +const boundToken = makeJwt({ iss: issuer, aud: resource, exp: 2, org_id: "o" }); + +type Route = (request: Request) => Response | Promise; + +/** + * The whole conversation, as the servers involved would hold it: deployment + * config, issuer metadata, the three grants, and identity. + */ +function makeServers(overrides: Partial> = {}) { + const bearer: string[] = []; + const routes: Record = { + [`${apiBaseUrl}/api/v0/auth/config`]: () => + Response.json({ + workOsClientId: "client_01ENV", + authorizationServer: issuer, + workOsConnectClientId: "client_01CONNECT", + }), + [`${issuer}/.well-known/oauth-authorization-server`]: () => + Response.json({ + issuer, + device_authorization_endpoint: `${issuer}/oauth2/device_authorization`, + token_endpoint: `${issuer}/oauth2/token`, + }), + [`${issuer}/oauth2/device_authorization`]: () => + Response.json({ + device_code: "device_abc", + user_code: "WDJB-MJHT", + verification_uri: `${issuer}/device`, + expires_in: 300, + interval: 1, + }), + [`${issuer}/oauth2/token`]: async (request) => { + const form = new URLSearchParams(await request.text()); + if (form.get("grant_type") === "refresh_token") { + return Response.json({ + access_token: boundToken, + refresh_token: "refresh_rotated", + }); + } + return Response.json({ + access_token: environmentToken, + refresh_token: "refresh_from_device", + }); + }, + [`${apiBaseUrl}/api/v0/identity`]: (request) => { + bearer.push(request.headers.get("authorization") ?? ""); + return Response.json({ + user: { id: "user_1", email: "person@example.com" }, + organization: { id: "org_platform", name: "Acme" }, + }); + }, + ...overrides, + }; + const fetchFn = (async (input: string, init?: RequestInit) => { + const url = input; + const route = routes[url]; + if (!route) return new Response("not found", { status: 404 }); + return route(new Request(url, init)); + }) as unknown as typeof fetch; + return { fetchFn, bearer }; +} + +function makeCtx() { + const fs = makeMemoryFs(); + const ui = { + note: mock(), + info: mock(), + step: mock(), + outro: mock(), + } as unknown as UI; + const ctx = { + ui, + configDir: "/config", + apiBaseUrl, + fs, + signals: { register: () => () => {} }, + log: () => ({ debug: () => {} }), + } as unknown as CommandContext; + return { ctx, ui, fs }; +} + +describe("loginWithDevice", () => { + it("signs in through the resource-bound refresh and stores that session", async () => { + spyOn(Entry.prototype, "setPassword").mockImplementation(() => { + throw Error("keychain unavailable"); + }); + const { ctx, ui, fs } = makeCtx(); + const { fetchFn, bearer } = makeServers(); + const opened: string[] = []; + + const result = await loginWithDevice(ctx, { + fetch: fetchFn, + platform: "darwin", + openBrowser: async (url) => { + opened.push(url); + return true; + }, + }); + + expect(result).toBeUndefined(); + expect(opened).toEqual([`${issuer}/device`]); + // Identity saw the bound token only; the first one never left the CLI. + expect(bearer).toEqual([`Bearer ${boundToken}`]); + expect(ui.outro).toHaveBeenCalledWith("Signed in as person@example.com."); + + const stored: unknown = JSON.parse( + await fs.readFile("/config/tokens.json"), + ); + expect(stored).toEqual({ + accessToken: boundToken, + refreshToken: "refresh_rotated", + expiresAt: 2_000, + organizationId: "o", + email: "person@example.com", + issuer, + clientId: "client_01CONNECT", + resource, + }); + }); + + it("does not report success when the refresh still yields the environment audience", async () => { + const setPassword = spyOn(Entry.prototype, "setPassword").mockReturnValue( + undefined, + ); + const { ctx, ui } = makeCtx(); + const { fetchFn, bearer } = makeServers({ + [`${issuer}/oauth2/token`]: () => + Response.json({ + access_token: environmentToken, + refresh_token: "refresh_any", + }), + }); + + const result = await loginWithDevice(ctx, { + fetch: fetchFn, + platform: "darwin", + openBrowser: async () => true, + }); + + if (!result) throw Error("expected a failure"); + expect(result.error).toContain("would not accept"); + expect(bearer).toEqual([]); + expect(setPassword).not.toHaveBeenCalled(); + expect(ui.outro).not.toHaveBeenCalled(); + }); + + it("does not report success when the API rejects the bound token", async () => { + const setPassword = spyOn(Entry.prototype, "setPassword").mockReturnValue( + undefined, + ); + const { ctx } = makeCtx(); + const { fetchFn } = makeServers({ + [`${apiBaseUrl}/api/v0/identity`]: () => + Response.json({ failureMessage: "nope" }, { status: 401 }), + }); + + const result = await loginWithDevice(ctx, { + fetch: fetchFn, + platform: "darwin", + openBrowser: async () => true, + }); + + if (!result) throw Error("expected a failure"); + expect(result.error).toContain("did not accept"); + expect(setPassword).not.toHaveBeenCalled(); + }); + + it("tells a legacy-only deployment apart from one offering nothing", async () => { + const { ctx } = makeCtx(); + const { fetchFn } = makeServers({ + [`${apiBaseUrl}/api/v0/auth/config`]: () => + Response.json({ workOsClientId: "client_01ENV" }), + }); + + const result = await loginWithDevice(ctx, { + fetch: fetchFn, + platform: "darwin", + openBrowser: async () => true, + }); + + if (!result) throw Error("expected a failure"); + expect(result.error).toContain("WorkOS Connect"); + }); + + it("reports an unregistered resource as a deployment fault, not a retry", async () => { + const { ctx } = makeCtx(); + const tokenCalls: string[] = []; + const { fetchFn } = makeServers({ + [`${issuer}/oauth2/token`]: async (request) => { + const form = new URLSearchParams(await request.text()); + tokenCalls.push(form.get("grant_type") ?? ""); + if (form.get("grant_type") === "refresh_token") { + return Response.json({ error: "invalid_target" }, { status: 400 }); + } + return Response.json({ + access_token: environmentToken, + refresh_token: "refresh_from_device", + }); + }, + }); + + const result = await loginWithDevice(ctx, { + fetch: fetchFn, + platform: "darwin", + openBrowser: async () => true, + }); + + if (!result) throw Error("expected a failure"); + expect(result.errorBody).toContain(resource); + expect(tokenCalls.filter((g) => g === "refresh_token")).toHaveLength(1); + }); +}); diff --git a/src/commands/auth/loginDevice.ts b/src/commands/auth/loginDevice.ts new file mode 100644 index 000000000..ad32752fe --- /dev/null +++ b/src/commands/auth/loginDevice.ts @@ -0,0 +1,138 @@ +import { authMessages } from "~/core/messages/index.js"; +import { sleep } from "~/core/sleep.js"; +import { + type ConnectConfig, + resolveConnectConfig, +} from "~/domains/auth/connectConfig.js"; +import { deviceLogin } from "~/domains/auth/deviceLogin.js"; +import { fetchSessionEmail } from "~/domains/auth/sessionEmail.js"; +import { saveTokens } from "~/domains/auth/store/saveTokens.js"; +import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; +import { pollDeviceToken } from "~/shell/workos/pollDeviceToken.js"; +import { refreshAccessToken } from "~/shell/workos/refreshAccessToken.js"; +import { requestDeviceAuthorization } from "~/shell/workos/requestDeviceAuthorization.js"; +import { defaultOpenBrowser, showDeviceCode } from "./showDeviceCode.js"; + +export type LoginDeviceDeps = { + env?: Record; + platform?: NodeJS.Platform; + fetch?: typeof globalThis.fetch; + openBrowser?: (url: string) => Promise; +}; + +function describeConfigFailure( + ctx: CommandContext, + result: Exclude< + Awaited>, + { kind: "configured" } + >, +): CommandResult { + const m = authMessages.device; + switch (result.kind) { + case "unreachable": + ctx.log("auth").debug(`auth config unreachable: ${result.detail}`); + return { error: m.configUnreachable, errorBody: result.detail }; + case "unavailable": + return { error: m.unavailable }; + case "legacy-only": + return { error: m.legacyOnly }; + case "misconfigured": + return { error: m.misconfigured, errorBody: result.detail }; + case "discovery-failed": + return { error: m.discoveryFailed, errorBody: result.detail }; + } +} + +async function signIn( + ctx: CommandContext, + config: ConnectConfig, + deps: Required, +): Promise { + const workos = { + fetch: deps.fetch, + clientId: config.clientId, + resource: config.resource, + endpoints: config.endpoints, + }; + + // Ctrl-C runs the signal registry, which flips this flag so the polling loop + // stops at its next check instead of being killed mid-request. + let cancelled = false; + const unregister = ctx.signals.register(() => { + cancelled = true; + }); + + try { + const result = await deviceLogin({ + requestAuthorization: () => requestDeviceAuthorization(workos), + pollToken: (deviceCode) => pollDeviceToken(deviceCode, workos), + refreshTokens: (refreshToken) => refreshAccessToken(refreshToken, workos), + binding: { issuer: config.issuer, resource: config.resource }, + fetchEmail: (accessToken) => + fetchSessionEmail(accessToken, { + fetch: deps.fetch, + baseUrl: ctx.apiBaseUrl, + }), + onPrompt: (authorization) => + showDeviceCode(ctx, authorization, { + platform: deps.platform, + openBrowser: deps.openBrowser, + }), + sleep, + now: () => Date.now(), + isCancelled: () => cancelled, + }); + + if (!result.ok) { + // Returned rather than printed: withContext already renders a + // CommandResult, so printing here too showed the copy followed by the + // bare reason code. + return { + error: authMessages.device.failed[result.reason], + ...(result.detail ? { errorBody: result.detail } : {}), + }; + } + + // Only the resource-bound pair the API has accepted is worth keeping. The + // binding rides with it so a later refresh asks the deployment nothing. + await saveTokens( + ctx.configDir, + { + ...result.session, + issuer: config.issuer, + clientId: config.clientId, + resource: config.resource, + }, + ctx.fs, + ); + + ctx.ui.outro(authMessages.device.signedIn(result.session.email)); + return; + } finally { + unregister(); + } +} + +/** Browser sign-in. Assumes the caller has already shown the intro. */ +export async function loginWithDevice( + ctx: CommandContext, + deps: LoginDeviceDeps = {}, +): Promise { + const platform = deps.platform ?? process.platform; + const resolved: Required = { + env: deps.env ?? process.env, + platform, + fetch: deps.fetch ?? globalThis.fetch, + openBrowser: deps.openBrowser ?? defaultOpenBrowser(platform), + }; + + // The deployment publishes the issuer and client id it signs people in + // with, so the CLI carries none and follows whatever host it is aimed at. + const config = await resolveConnectConfig({ + apiBaseUrl: ctx.apiBaseUrl, + fetch: resolved.fetch, + }); + if (config.kind !== "configured") return describeConfigFailure(ctx, config); + + return signIn(ctx, config.config, resolved); +} diff --git a/src/commands/auth/logout.test.ts b/src/commands/auth/logout.test.ts new file mode 100644 index 000000000..5e012af65 --- /dev/null +++ b/src/commands/auth/logout.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; + +import type { CommandContext } from "~/shell/commandContext.js"; +import type { UI } from "~/shell/ui/types.js"; +import { handleLogout } from "./logout.js"; + +afterEach(() => { + mock.restore(); +}); + +type Task = { message: string; task: () => T | Promise }; + +function makeCtx( + ui: Partial & { mode: UI["mode"] }, +): CommandContext & { ui: UI } { + return { + ui: { + gap: mock(), + intro: mock(), + info: mock(), + warn: mock(), + cancel: mock(), + outro: mock(), + output: mock(), + // Run every task so the test observes the real deletion calls. + withProgress: mock( + async (tasks: Task[], summarise?: unknown) => { + const results = []; + for (const t of tasks) results.push(await t.task()); + if (typeof summarise === "function") summarise(results); + return results; + }, + ), + ...ui, + } as unknown as UI, + configDir: "/config", + } as unknown as CommandContext & { ui: UI }; +} + +function makeDeps(args: { + stored: boolean; + env?: Record; +}) { + return { + hasStoredCredentials: mock(async () => args.stored), + deleteApiKey: mock(async () => ({ + keychain: "deleted" as const, + file: "deleted" as const, + })), + deleteTokens: mock(async () => ({ + keychain: "deleted" as const, + file: "deleted" as const, + })), + env: args.env ?? {}, + }; +} + +// A factory, not a shared constant: a module-level mock would carry its call +// record from one test into the next. +function confirmed() { + return { + mode: "human" as const, + confirm: mock(async () => ({ ok: true as const, value: true })), + }; +} + +describe("handleLogout", () => { + it("clears browser tokens as well as the stored API key", async () => { + const ctx = makeCtx(confirmed()); + const deps = makeDeps({ stored: true }); + + await handleLogout(ctx, deps); + + expect(deps.deleteApiKey).toHaveBeenCalledTimes(1); + expect(deps.deleteTokens).toHaveBeenCalledTimes(1); + }); + + // The bug this replaces: deletion used to sit behind resolveApiKey, which + // refreshes a browser session over the network. Offline, or once WorkOS had + // rotated the refresh token away, logout reported "not authenticated" and + // left the credentials on disk. + it("clears credentials that can no longer be resolved", async () => { + const ctx = makeCtx(confirmed()); + const deps = makeDeps({ stored: true }); + + await handleLogout(ctx, deps); + + expect(ctx.ui.info).not.toHaveBeenCalled(); + expect(deps.deleteApiKey).toHaveBeenCalledTimes(1); + expect(deps.deleteTokens).toHaveBeenCalledTimes(1); + }); + + it("does not consult the network to decide whether to delete", async () => { + const ctx = makeCtx(confirmed()); + const deps = makeDeps({ stored: true }); + + await handleLogout(ctx, deps); + + expect(deps.hasStoredCredentials).toHaveBeenCalledTimes(1); + expect(deps.hasStoredCredentials).toHaveBeenCalledWith( + "/config", + undefined, + ); + }); + + it("deletes nothing when there is nothing stored", async () => { + const ctx = makeCtx({ mode: "human" }); + const deps = makeDeps({ stored: false }); + + await handleLogout(ctx, deps); + + expect(deps.deleteApiKey).not.toHaveBeenCalled(); + expect(deps.deleteTokens).not.toHaveBeenCalled(); + }); + + it("warns that an environment variable cannot be removed", async () => { + const ctx = makeCtx(confirmed()); + const deps = makeDeps({ + stored: false, + env: { QAWOLF_API_KEY: "qaw_env" }, + }); + + await handleLogout(ctx, deps); + + expect(ctx.ui.warn).toHaveBeenCalled(); + }); + + it("still clears storage when only an environment key is set", async () => { + const ctx = makeCtx(confirmed()); + const deps = makeDeps({ + stored: false, + env: { QAWOLF_API_KEY: "qaw_env" }, + }); + + await handleLogout(ctx, deps); + + expect(deps.deleteTokens).toHaveBeenCalledTimes(1); + }); + + it("deletes nothing when the confirmation is declined", async () => { + const ctx = makeCtx({ + mode: "human", + confirm: mock(async () => ({ ok: true as const, value: false })), + }); + const deps = makeDeps({ stored: true }); + + await handleLogout(ctx, deps); + + expect(deps.deleteApiKey).not.toHaveBeenCalled(); + expect(deps.deleteTokens).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 629002b03..066d30876 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -1,21 +1,49 @@ -import { deleteApiKey, resolveApiKey } from "~/domains/auth/index.js"; -import { - type CommandContext, - type CommandResult, -} from "~/shell/commandContext.js"; +import { deleteApiKey as realDeleteApiKey } from "~/domains/auth/index.js"; +import { deleteTokens as realDeleteTokens } from "~/domains/auth/store/deleteTokens.js"; +import { hasStoredCredentials as realHasStoredCredentials } from "~/domains/auth/store/index.js"; +import type { CommandContext, CommandResult } from "~/shell/commandContext.js"; import { authMessages } from "~/core/messages/index.js"; +type LogoutDeps = { + hasStoredCredentials?: ( + configDir: string, + fs: CommandContext["fs"], + ) => Promise; + deleteApiKey?: ( + configDir: string, + fs: CommandContext["fs"], + ) => Promise; + deleteTokens?: ( + configDir: string, + fs: CommandContext["fs"], + ) => Promise; + env?: Record; +}; + export async function handleLogout( ctx: CommandContext, + deps: LogoutDeps = {}, ): Promise { - const resolved = await resolveApiKey(ctx.configDir, ctx.fs); + const hasStoredCredentials = + deps.hasStoredCredentials ?? realHasStoredCredentials; + const deleteApiKey = deps.deleteApiKey ?? realDeleteApiKey; + const deleteTokens = deps.deleteTokens ?? realDeleteTokens; + const env = deps.env ?? process.env; + + // Storage is asked directly rather than through resolveApiKey. Resolving a + // browser session refreshes it over the network, so being offline or holding + // a refresh token WorkOS has already rotated away would report "not + // authenticated" and leave the credentials in place — the one case where + // clearing them matters most. + const envKey = env["QAWOLF_API_KEY"]?.trim(); + const stored = await hasStoredCredentials(ctx.configDir, ctx.fs); - if (!resolved) { + if (!envKey && !stored) { ctx.ui.info(authMessages.logout.notAuthenticated); return; } - if (resolved.source === "env") { + if (envKey) { ctx.ui.warn(authMessages.logout.envVarWarning); } @@ -34,7 +62,15 @@ export async function handleLogout( [ { message: authMessages.logout.deleting, - task: () => deleteApiKey(ctx.configDir, ctx.fs), + // Both credential kinds go, whichever one is present. Clearing only the + // one in use would leave the other to take over on the next command, + // so "logged out" would not be true. + task: async () => { + await Promise.all([ + deleteApiKey(ctx.configDir, ctx.fs), + deleteTokens(ctx.configDir, ctx.fs), + ]); + }, }, ], () => authMessages.logout.credentialsRemoved, diff --git a/src/commands/auth/showDeviceCode.ts b/src/commands/auth/showDeviceCode.ts new file mode 100644 index 000000000..10f017d74 --- /dev/null +++ b/src/commands/auth/showDeviceCode.ts @@ -0,0 +1,44 @@ +import type { DeviceAuthorization } from "~/core/deviceAuth/types.js"; +import { authMessages } from "~/core/messages/index.js"; +import { sleep } from "~/core/sleep.js"; +import type { CommandContext } from "~/shell/commandContext.js"; +import { openBrowser } from "~/shell/openBrowser.js"; +import { defaultSpawn } from "~/shell/spawn.js"; + +export type ShowDeviceCodeDeps = { + platform: NodeJS.Platform; + openBrowser: (url: string) => Promise; +}; + +export function defaultOpenBrowser( + platform: NodeJS.Platform, +): (url: string) => Promise { + return (url) => openBrowser(url, { sleep, spawn: defaultSpawn, platform }); +} + +/** Shows the user code, opens the verification page, and says it is waiting. */ +export async function showDeviceCode( + ctx: CommandContext, + authorization: DeviceAuthorization, + deps: ShowDeviceCodeDeps, +): Promise { + const url = + authorization.verificationUriComplete ?? authorization.verificationUri; + ctx.ui.note( + [ + authMessages.device.confirmCode(authorization.userCode), + authMessages.device.visitUrl(url), + url === authorization.verificationUri + ? undefined + : authMessages.device.visitUrlPlain(authorization.verificationUri), + ] + .filter((line): line is string => Boolean(line)) + .join("\n"), + authMessages.title, + ); + + const opened = await deps.openBrowser(url); + if (!opened) ctx.ui.info(authMessages.device.openFailed(url)); + + ctx.ui.step(authMessages.device.waiting); +} diff --git a/src/core/deviceAuth/pollState.test.ts b/src/core/deviceAuth/pollState.test.ts new file mode 100644 index 000000000..471c78554 --- /dev/null +++ b/src/core/deviceAuth/pollState.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "bun:test"; + +import { nextPollStep, slowDownIncrementMs } from "./pollState.js"; +import type { DeviceTokens, PollState } from "./types.js"; + +const state: PollState = { intervalMs: 5_000, deadlineMs: 300_000 }; + +const tokens: DeviceTokens = { + accessToken: "access", + refreshToken: "refresh", + expiresAt: undefined, + organizationId: undefined, +}; + +describe("nextPollStep", () => { + it("finishes when the response carries tokens", () => { + const step = nextPollStep(state, { kind: "tokens", tokens }, 0); + + expect(step).toEqual({ action: "done", tokens }); + }); + + it("finishes on tokens even after the deadline has passed", () => { + const step = nextPollStep(state, { kind: "tokens", tokens }, 300_001); + + expect(step).toEqual({ action: "done", tokens }); + }); + + it("polls again at the current interval while authorization is pending", () => { + const step = nextPollStep(state, { kind: "pending" }, 0); + + expect(step).toEqual({ action: "poll", delayMs: 5_000, state }); + }); + + it("raises the interval by five seconds on slow-down", () => { + const step = nextPollStep(state, { kind: "slow-down" }, 0); + + expect(step).toEqual({ + action: "poll", + delayMs: 5_000 + slowDownIncrementMs, + state: { intervalMs: 5_000 + slowDownIncrementMs, deadlineMs: 300_000 }, + }); + }); + + it("keeps the raised interval for later pending responses", () => { + const slowed = nextPollStep(state, { kind: "slow-down" }, 0); + if (slowed.action !== "poll") throw Error("expected to keep polling"); + + const step = nextPollStep(slowed.state, { kind: "pending" }, 0); + + expect(step).toEqual({ + action: "poll", + delayMs: 10_000, + state: slowed.state, + }); + }); + + it("fails when the person rejects the request", () => { + const step = nextPollStep(state, { kind: "denied" }, 0); + + expect(step).toEqual({ + action: "fail", + reason: "access-denied", + detail: undefined, + }); + }); + + it("fails when the device code expires", () => { + const step = nextPollStep(state, { kind: "expired" }, 0); + + expect(step).toEqual({ + action: "fail", + reason: "expired", + detail: undefined, + }); + }); + + it("times out once the deadline passes, however long the server stalls", () => { + const step = nextPollStep(state, { kind: "pending" }, 300_001); + + expect(step).toEqual({ + action: "fail", + reason: "timeout", + detail: undefined, + }); + }); + + it("keeps polling at the deadline itself", () => { + const step = nextPollStep(state, { kind: "pending" }, 300_000); + + expect(step).toEqual({ action: "poll", delayMs: 5_000, state }); + }); + + it("retries a server it could not reach, at double the interval", () => { + const step = nextPollStep(state, { kind: "unreachable", detail: "x" }, 0); + + expect(step).toEqual({ + action: "poll", + delayMs: 10_000, + state: { intervalMs: 10_000, deadlineMs: 300_000 }, + }); + }); + + it("doubles again while it still cannot reach the server", () => { + const first = nextPollStep(state, { kind: "unreachable", detail: "x" }, 0); + if (first.action !== "poll") throw Error("expected to keep polling"); + + const second = nextPollStep( + first.state, + { kind: "unreachable", detail: "x" }, + 0, + ); + + expect(second).toEqual({ + action: "poll", + delayMs: 20_000, + state: { intervalMs: 20_000, deadlineMs: 300_000 }, + }); + }); + + it("keeps the backed-off interval once the server answers again", () => { + const backedOff = nextPollStep( + state, + { kind: "unreachable", detail: "x" }, + 0, + ); + if (backedOff.action !== "poll") throw Error("expected to keep polling"); + + const step = nextPollStep(backedOff.state, { kind: "pending" }, 0); + + expect(step).toEqual({ + action: "poll", + delayMs: 10_000, + state: backedOff.state, + }); + }); + + it("stops retrying an unreachable server once the deadline passes", () => { + const step = nextPollStep( + state, + { kind: "unreachable", detail: "x" }, + 300_001, + ); + + expect(step).toEqual({ + action: "fail", + reason: "timeout", + detail: undefined, + }); + }); + + it("fails with the detail from an unexpected error", () => { + const step = nextPollStep(state, { kind: "error", detail: "boom" }, 0); + + expect(step).toEqual({ + action: "fail", + reason: "network", + detail: "boom", + }); + }); +}); diff --git a/src/core/deviceAuth/pollState.ts b/src/core/deviceAuth/pollState.ts new file mode 100644 index 000000000..43a94e81d --- /dev/null +++ b/src/core/deviceAuth/pollState.ts @@ -0,0 +1,68 @@ +import type { PollResponse, PollState, PollStep } from "./types.js"; + +/** + * The increase lasts for the rest of the flow. Treating it as a one-off skip + * would return to the very rate the server just objected to. + */ +export const slowDownIncrementMs = 5_000; + +/** + * RFC 8628 asks a client meeting a connection error to slow down before + * retrying, and recommends doubling. Persists for the rest of the flow: a + * network that dropped one request is likelier to drop the next. + */ +const unreachableBackoffFactor = 2; + +/** Pure, so the protocol is testable without a clock or a socket. */ +export function nextPollStep( + state: PollState, + response: PollResponse, + nowMs: number, +): PollStep { + // Tokens win over the deadline. An approval that lands as the code expires is + // still an approval, and rejecting it would strand someone who just finished. + if (response.kind === "tokens") { + return { action: "done", tokens: response.tokens }; + } + + if (nowMs > state.deadlineMs) { + return { action: "fail", reason: "timeout", detail: undefined }; + } + + switch (response.kind) { + case "pending": + return { action: "poll", delayMs: state.intervalMs, state }; + + case "slow-down": { + const slowed: PollState = { + intervalMs: state.intervalMs + slowDownIncrementMs, + deadlineMs: state.deadlineMs, + }; + return { action: "poll", delayMs: slowed.intervalMs, state: slowed }; + } + + case "denied": + return { action: "fail", reason: "access-denied", detail: undefined }; + + case "expired": + return { action: "fail", reason: "expired", detail: undefined }; + + // Retryable, unlike `error`. The person has often already approved in the + // browser by now, so abandoning the flow over one dropped request would + // throw away work they have done and cannot see failing. + case "unreachable": { + const backedOff: PollState = { + intervalMs: state.intervalMs * unreachableBackoffFactor, + deadlineMs: state.deadlineMs, + }; + return { + action: "poll", + delayMs: backedOff.intervalMs, + state: backedOff, + }; + } + + case "error": + return { action: "fail", reason: "network", detail: response.detail }; + } +} diff --git a/src/core/deviceAuth/resource.test.ts b/src/core/deviceAuth/resource.test.ts new file mode 100644 index 000000000..0a3a6c70e --- /dev/null +++ b/src/core/deviceAuth/resource.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test"; + +import { apiResource, sameIssuer } from "./resource.js"; + +describe("apiResource", () => { + it("is the deployment origin followed by /api", () => { + expect(apiResource("https://app.qawolf.com")).toBe( + "https://app.qawolf.com/api", + ); + }); + + it("keeps the port, which is part of the deployment's identity", () => { + expect(apiResource("http://localhost:3000")).toBe( + "http://localhost:3000/api", + ); + }); + + it("does not double a slash the host url carries", () => { + expect(apiResource("https://app.qawolf.com/")).toBe( + "https://app.qawolf.com/api", + ); + }); + + it("ignores a path on the host url; the resource is the origin's", () => { + expect(apiResource("https://app.qawolf.com/some/page")).toBe( + "https://app.qawolf.com/api", + ); + }); +}); + +describe("sameIssuer", () => { + it("matches an issuer regardless of a trailing slash", () => { + expect( + sameIssuer("https://signin.example/", "https://signin.example"), + ).toBe(true); + }); + + it("does not match a different host", () => { + expect(sameIssuer("https://signin.example", "https://other.example")).toBe( + false, + ); + }); + + it("does not match a different scheme", () => { + expect(sameIssuer("http://signin.example", "https://signin.example")).toBe( + false, + ); + }); +}); diff --git a/src/core/deviceAuth/resource.ts b/src/core/deviceAuth/resource.ts new file mode 100644 index 000000000..8fac6b0d3 --- /dev/null +++ b/src/core/deviceAuth/resource.ts @@ -0,0 +1,21 @@ +/** + * The API resource a deployment's tokens must be bound to: its origin followed + * by `/api`. The API derives the same string from its own configured origin, so + * scheme, host and port all have to agree, and `/api/` is a different string. + */ +export function apiResource(hostUrl: string): string { + return new URL("/api", hostUrl).href; +} + +function withoutTrailingSlash(url: string): string { + return url.replace(/\/+$/, ""); +} + +/** + * Issuers compare as strings once a trailing slash is discounted. Anything + * looser — case folding, resolving a path — would let one server's metadata + * or token pass for another's. + */ +export function sameIssuer(a: string, b: string): boolean { + return withoutTrailingSlash(a) === withoutTrailingSlash(b); +} diff --git a/src/core/deviceAuth/tokenClaims.test.ts b/src/core/deviceAuth/tokenClaims.test.ts new file mode 100644 index 000000000..f65de146f --- /dev/null +++ b/src/core/deviceAuth/tokenClaims.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "bun:test"; + +import { readTokenClaims, verifyTokenBinding } from "./tokenClaims.js"; + +function base64Url(value: string): string { + return Buffer.from(value, "utf8").toString("base64url"); +} + +function makeJwt(payload: unknown): string { + return [ + base64Url(JSON.stringify({ alg: "RS256", typ: "JWT" })), + base64Url(JSON.stringify(payload)), + "signature-is-not-checked-here", + ].join("."); +} + +const binding = { + issuer: "https://signin.example", + resource: "https://app.example/api", +}; + +const good = { + iss: "https://signin.example", + aud: "https://app.example/api", + exp: 1_700_000_000, + org_id: "org_1", +}; + +describe("verifyTokenBinding", () => { + it("accepts a token whose audience is exactly the resource", () => { + expect(verifyTokenBinding(makeJwt(good), binding)).toEqual({ + ok: true, + expiresAt: 1_700_000_000_000, + organizationId: "org_1", + }); + }); + + it("accepts an audience array that contains the exact resource", () => { + const token = makeJwt({ + ...good, + aud: ["https://app.example/api/mcp", "https://app.example/api"], + }); + + expect(verifyTokenBinding(token, binding).ok).toBe(true); + }); + + // The live failure: WorkOS answers the device grant with the environment + // client id as the audience, which the API rejects. + it("rejects a token whose audience is the environment client id", () => { + const token = makeJwt({ ...good, aud: "client_01ENV" }); + + expect(verifyTokenBinding(token, binding)).toEqual({ + ok: false, + reason: "audience-mismatch", + }); + }); + + it("rejects an audience that differs only by a trailing slash", () => { + const token = makeJwt({ ...good, aud: "https://app.example/api/" }); + + expect(verifyTokenBinding(token, binding).ok).toBe(false); + }); + + it("rejects a token without an audience", () => { + const { aud: _aud, ...withoutAud } = good; + + expect(verifyTokenBinding(makeJwt(withoutAud), binding)).toEqual({ + ok: false, + reason: "audience-mismatch", + }); + }); + + it("rejects a token from another issuer", () => { + const token = makeJwt({ ...good, iss: "https://other.example" }); + + expect(verifyTokenBinding(token, binding)).toEqual({ + ok: false, + reason: "issuer-mismatch", + }); + }); + + it("tolerates a trailing slash on either side of the issuer", () => { + const token = makeJwt({ ...good, iss: "https://signin.example/" }); + + expect(verifyTokenBinding(token, binding).ok).toBe(true); + expect( + verifyTokenBinding(makeJwt(good), { + ...binding, + issuer: "https://signin.example/", + }).ok, + ).toBe(true); + }); + + it("rejects a token that cannot be decoded", () => { + expect(verifyTokenBinding("not.a.jwt.at.all", binding)).toEqual({ + ok: false, + reason: "malformed", + }); + expect(verifyTokenBinding("", binding)).toEqual({ + ok: false, + reason: "malformed", + }); + }); + + it("reports an unreadable expiry as unknown rather than failing", () => { + const token = makeJwt({ ...good, exp: "soon" }); + + expect(verifyTokenBinding(token, binding)).toEqual({ + ok: true, + expiresAt: undefined, + organizationId: "org_1", + }); + }); + + it("leaves the organization undefined when the token names none", () => { + const { org_id: _org, ...withoutOrg } = good; + + const result = verifyTokenBinding(makeJwt(withoutOrg), binding); + + if (!result.ok) throw Error("expected ok"); + expect(result.organizationId).toBeUndefined(); + }); +}); + +describe("readTokenClaims", () => { + it("returns undefined for anything that is not a three-segment token", () => { + expect(readTokenClaims("nope")).toBeUndefined(); + }); + + it("returns undefined when the payload is not a JSON object", () => { + const token = ["h", base64Url("[1,2]"), "s"].join("."); + + expect(readTokenClaims(token)).toBeUndefined(); + }); + + it("returns the decoded payload otherwise", () => { + expect(readTokenClaims(makeJwt({ sub: "user_1" }))).toEqual({ + sub: "user_1", + }); + }); +}); diff --git a/src/core/deviceAuth/tokenClaims.ts b/src/core/deviceAuth/tokenClaims.ts new file mode 100644 index 000000000..9ea908132 --- /dev/null +++ b/src/core/deviceAuth/tokenClaims.ts @@ -0,0 +1,91 @@ +import { sameIssuer } from "./resource.js"; + +export type TokenClaims = Record; + +/** + * The payload of a JWT, decoded without verifying the signature. The values + * decide what the CLI does next — when to refresh, whether a token is worth + * presenting — while the API remains the judge of whether a token is genuine. + * + * Undefined for anything that is not a three-segment token carrying a JSON + * object: an unreadable token means "do not trust it", not "crash". + */ +export function readTokenClaims(token: string): TokenClaims | undefined { + const segments = token.split("."); + if (segments.length !== 3) return undefined; + + const [, payload] = segments; + if (!payload) return undefined; + + let claims: unknown; + try { + claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + } catch { + return undefined; + } + + if (typeof claims !== "object" || claims === null || Array.isArray(claims)) { + return undefined; + } + return claims as TokenClaims; +} + +export type TokenBinding = + | { + ok: true; + /** Epoch ms; absent when the token carried no readable expiry. */ + expiresAt: number | undefined; + /** The `org_id` claim: the WorkOS organization the token is scoped to. */ + organizationId: string | undefined; + } + | { + ok: false; + reason: "malformed" | "issuer-mismatch" | "audience-mismatch"; + }; + +function readExpiry(claims: TokenClaims): number | undefined { + const exp = claims["exp"]; + if (typeof exp !== "number" || !Number.isFinite(exp)) return undefined; + return exp * 1_000; +} + +function hasAudience(claims: TokenClaims, resource: string): boolean { + const aud = claims["aud"]; + if (typeof aud === "string") return aud === resource; + // An array is acceptable when it names the exact resource; a token issued + // for both the API and MCP resources is still a token for the API. + return Array.isArray(aud) && aud.includes(resource); +} + +/** + * Whether a token is one the API would accept: issued by the configured + * issuer, for exactly the configured resource. + * + * A consistency check, not verification. Its purpose is to stop the CLI + * presenting a token that is bound to something else — the live failure was + * a device grant answering with the environment client id as the audience — + * so the person sees a clear reason instead of an opaque 401. + */ +export function verifyTokenBinding( + accessToken: string, + binding: { issuer: string; resource: string }, +): TokenBinding { + const claims = readTokenClaims(accessToken); + if (!claims) return { ok: false, reason: "malformed" }; + + const iss = claims["iss"]; + if (typeof iss !== "string" || !sameIssuer(iss, binding.issuer)) { + return { ok: false, reason: "issuer-mismatch" }; + } + + if (!hasAudience(claims, binding.resource)) { + return { ok: false, reason: "audience-mismatch" }; + } + + const orgId = claims["org_id"]; + return { + ok: true, + expiresAt: readExpiry(claims), + organizationId: typeof orgId === "string" && orgId ? orgId : undefined, + }; +} diff --git a/src/core/deviceAuth/tokenExpiry.test.ts b/src/core/deviceAuth/tokenExpiry.test.ts new file mode 100644 index 000000000..b676ae540 --- /dev/null +++ b/src/core/deviceAuth/tokenExpiry.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "bun:test"; + +import { readAccessTokenExpiry } from "./tokenExpiry.js"; + +function base64Url(value: string): string { + return Buffer.from(value, "utf8").toString("base64url"); +} + +function makeJwt(payload: unknown): string { + return [ + base64Url(JSON.stringify({ alg: "RS256", typ: "JWT" })), + base64Url(JSON.stringify(payload)), + "signature-is-not-checked-here", + ].join("."); +} + +describe("readAccessTokenExpiry", () => { + it("converts the exp claim from seconds to epoch milliseconds", () => { + const token = makeJwt({ exp: 1_700_000_000, sub: "user_1" }); + + expect(readAccessTokenExpiry(token)).toBe(1_700_000_000_000); + }); + + it("returns undefined when the payload carries no exp claim", () => { + expect(readAccessTokenExpiry(makeJwt({ sub: "user_1" }))).toBeUndefined(); + }); + + it("returns undefined when exp is not a number", () => { + expect(readAccessTokenExpiry(makeJwt({ exp: "soon" }))).toBeUndefined(); + }); + + it("returns undefined for a token that is not three segments", () => { + expect(readAccessTokenExpiry("not.ajwt")).toBeUndefined(); + }); + + it("returns undefined when the payload segment is not JSON", () => { + const token = ["header", base64Url("not json at all"), "sig"].join("."); + + expect(readAccessTokenExpiry(token)).toBeUndefined(); + }); + + it("returns undefined for an empty token", () => { + expect(readAccessTokenExpiry("")).toBeUndefined(); + }); + + it("decodes payloads containing base64url-only characters", () => { + // A payload whose base64 encoding needs - and _ rather than + and /. + const token = makeJwt({ exp: 1_700_000_001, note: "??~~??>>>" }); + + expect(readAccessTokenExpiry(token)).toBe(1_700_000_001_000); + }); +}); diff --git a/src/core/deviceAuth/tokenExpiry.ts b/src/core/deviceAuth/tokenExpiry.ts new file mode 100644 index 000000000..7e0b3bba0 --- /dev/null +++ b/src/core/deviceAuth/tokenExpiry.ts @@ -0,0 +1,13 @@ +import { readTokenClaims } from "./tokenClaims.js"; + +/** + * Epoch milliseconds from the `exp` claim inside the token, which is the only + * expiry a token response reliably carries. Undefined for anything malformed — + * an unreadable expiry means "refresh it", not "crash the command". + */ +export function readAccessTokenExpiry(accessToken: string): number | undefined { + const exp = readTokenClaims(accessToken)?.["exp"]; + if (typeof exp !== "number" || !Number.isFinite(exp)) return undefined; + + return exp * 1_000; +} diff --git a/src/core/deviceAuth/types.ts b/src/core/deviceAuth/types.ts new file mode 100644 index 000000000..52001e6b6 --- /dev/null +++ b/src/core/deviceAuth/types.ts @@ -0,0 +1,56 @@ +/** + * What the token endpoint hands back, as far as the CLI keeps it. No email: + * a Connect token response is a plain OAuth one, and who the person is comes + * from the API once it has accepted the token. + */ +export type DeviceTokens = { + accessToken: string; + refreshToken: string; + /** Epoch ms; absent when the token carried no readable expiry. */ + expiresAt: number | undefined; + /** + * WorkOS organization the token is scoped to, read from its `org_id` claim. + * The API confines the session to it, so it is kept to show which one was + * granted and to notice when a refresh lands somewhere else. + */ + organizationId: string | undefined; +}; + +/** One token-endpoint answer, already narrowed from its OAuth error code. */ +export type PollResponse = + | { kind: "tokens"; tokens: DeviceTokens } + | { kind: "pending" } + | { kind: "slow-down" } + | { kind: "denied" } + | { kind: "expired" } + /** The server answered something unrecognised, or refused outright. */ + | { kind: "error"; detail: string } + /** + * The server never gave a usable answer — unreachable, or failing with a 5xx + * or 429. Transient either way, so worth retrying. + */ + | { kind: "unreachable"; detail: string }; + +export type PollState = { + intervalMs: number; + /** Epoch ms the device code stops being redeemable. */ + deadlineMs: number; +}; + +export type PollFailure = "access-denied" | "expired" | "timeout" | "network"; + +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..2f75dec22 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", @@ -18,6 +18,57 @@ export const authMessages = { nonInteractive: "auth login requires an interactive terminal. Set the QAWOLF_API_KEY environment variable for CI authentication.", reAuthPrompt: "You are already authenticated. Re-authenticate?", + chooseMethod: "How do you want to sign in?", + methodBrowser: "Browser", + methodBrowserHint: "Sign in with your QA Wolf account", + methodApiKey: "API key", + methodApiKeyHint: "Paste a team key — needed for flows pull and flows run", + // An API key deliberately outranks a browser session, because it carries + // team scope a user token does not. Said out loud here because the sign-in + // that follows reports success, and without this the person is left + // believing they changed which identity their commands use. + apiKeyPrecedence: { + env: "QAWOLF_API_KEY is set, and an API key takes precedence over a browser session. Commands continue to use that key until you unset the variable.", + stored: + "A stored API key takes precedence over a browser session. Commands continue to use that key until you run 'qawolf auth logout' and sign in again.", + }, + }, + device: { + unavailable: + "This QA Wolf deployment does not offer browser sign-in. Run 'qawolf auth login' again and choose 'API key'.", + configUnreachable: + "Could not ask this QA Wolf deployment whether it offers browser sign-in. Check your connection, then try again.", + legacyOnly: + "This QA Wolf deployment does not offer WorkOS Connect sign-in yet, and this version of the CLI signs in no other way. Run 'qawolf auth login' again and choose 'API key'.", + misconfigured: + "This QA Wolf deployment publishes an incomplete browser sign-in configuration. Ask whoever runs it to check its WorkOS Connect settings.", + discoveryFailed: + "The sign-in provider this QA Wolf deployment names did not answer as one. Ask whoever runs the deployment to check its WorkOS Connect settings.", + confirmCode: (userCode: string) => `Your code is ${userCode}`, + visitUrl: (url: string) => `Confirm it at ${url}`, + // RFC 8628 asks a client using the prefilled URL to show the plain one too, + // for anyone who cannot follow the shortcut — a wrapped or truncated long + // URL in a narrow terminal being exactly that case. + visitUrlPlain: (url: string) => `Or go to ${url} and enter the code`, + openFailed: (url: string) => + `Could not open a browser automatically. Open ${url} yourself to continue.`, + waiting: "Waiting for you to finish in the browser", + signedIn: (email: string) => `Signed in as ${email}.`, + failed: { + "access-denied": "The sign-in request was rejected.", + expired: "The sign-in request expired. Run 'qawolf auth login' to retry.", + timeout: + "The sign-in request timed out. Run 'qawolf auth login' to retry.", + network: "Could not reach WorkOS to complete sign-in.", + unavailable: "Could not start browser sign-in.", + cancelled: "Sign-in cancelled.", + "refresh-failed": + "Sign-in was approved, but WorkOS did not issue a token for this QA Wolf deployment.", + "token-rejected": + "Sign-in was approved, but WorkOS issued a token this QA Wolf deployment would not accept. Ask whoever runs the deployment to check that its API URL is registered with WorkOS.", + "identity-rejected": + "Sign-in was approved, but the QA Wolf API did not accept the new session.", + }, }, logout: { title: "Log Out", @@ -30,48 +81,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) => @@ -111,6 +121,8 @@ export const authMessages = { `ID: ${input.user.id}`, `Organization: ${input.organization.name}`, `Source: ${input.source}`, - ].join("\n"), + ] + .filter((line): line is string => Boolean(line)) + .join("\n"), }, } as const; diff --git a/src/core/messages/authErrors.ts b/src/core/messages/authErrors.ts new file mode 100644 index 000000000..6db91a68f --- /dev/null +++ b/src/core/messages/authErrors.ts @@ -0,0 +1,74 @@ +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)}.`, + notUserSession: + "The QA Wolf API accepted the token but did not describe a user session", + }, + 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.`, + }, + authConfig: { + halfConfigured: (missingField: string) => + `This QA Wolf deployment publishes an incomplete sign-in configuration: ${missingField} is missing.`, + }, + 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", + noRefreshToken: "WorkOS returned no refresh token", + resourceNotRegistered: (resource: string) => + `WorkOS does not recognise ${resource} as a registered resource. The QA Wolf deployment's API URL has to be registered with its sign-in provider before browser sign-in can work.`, + tokenNotBound: (reason: string) => + `WorkOS returned a token the QA Wolf API would not accept (${reason})`, + metadata: { + unavailable: (status: number) => + `The sign-in provider did not serve its authorization server metadata (HTTP ${status})`, + issuerMismatch: (expected: string, actual: string) => + `The sign-in provider's metadata names issuer ${actual}, but the deployment configured ${expected}`, + missingEndpoint: (name: string) => + `The sign-in provider's metadata advertises no ${name} endpoint`, + foreignEndpoint: (name: string) => + `The sign-in provider's metadata puts the ${name} endpoint on a different origin from the issuer`, + }, + }, + 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/domains/auth/connectConfig.test.ts b/src/domains/auth/connectConfig.test.ts new file mode 100644 index 000000000..46b72d15d --- /dev/null +++ b/src/domains/auth/connectConfig.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "bun:test"; + +import { resolveConnectConfig } from "./connectConfig.js"; + +const apiBaseUrl = "https://app.example"; +const issuer = "https://signin.example"; + +const authConfig = { + workOsClientId: "client_01ENV", + authorizationServer: issuer, + workOsConnectClientId: "client_01CONNECT", +}; + +const metadata = { + issuer, + device_authorization_endpoint: `${issuer}/oauth2/device_authorization`, + token_endpoint: `${issuer}/oauth2/token`, +}; + +/** Answers by URL, so the test states what each server publishes. */ +function routingFetch( + routes: Record Response | Promise>, +) { + const calls: string[] = []; + const fetchFn = (async (input: string) => { + const url = input; + calls.push(url); + const route = routes[url]; + if (!route) return new Response("not found", { status: 404 }); + return route(); + }) as unknown as typeof fetch; + return { calls, fetchFn }; +} + +describe("resolveConnectConfig", () => { + it("assembles the issuer, Connect client, resource and endpoints", async () => { + const { fetchFn } = routingFetch({ + [`${apiBaseUrl}/api/v0/auth/config`]: () => Response.json(authConfig), + [`${issuer}/.well-known/oauth-authorization-server`]: () => + Response.json(metadata), + }); + + const result = await resolveConnectConfig({ apiBaseUrl, fetch: fetchFn }); + + expect(result).toEqual({ + kind: "configured", + config: { + issuer, + clientId: "client_01CONNECT", + resource: "https://app.example/api", + endpoints: { + deviceAuthorization: `${issuer}/oauth2/device_authorization`, + token: `${issuer}/oauth2/token`, + }, + }, + }); + }); + + it("asks the deployment before the issuer, and the issuer only once configured", async () => { + const { calls, fetchFn } = routingFetch({ + [`${apiBaseUrl}/api/v0/auth/config`]: () => + Response.json({ workOsClientId: "client_01ENV" }), + }); + + const result = await resolveConnectConfig({ apiBaseUrl, fetch: fetchFn }); + + expect(result).toEqual({ kind: "legacy-only" }); + expect(calls).toEqual([`${apiBaseUrl}/api/v0/auth/config`]); + }); + + it("reports a deployment that offers no browser sign-in", async () => { + const { fetchFn } = routingFetch({}); + + const result = await resolveConnectConfig({ apiBaseUrl, fetch: fetchFn }); + + expect(result).toEqual({ kind: "unavailable" }); + }); + + it("passes a half configuration through as misconfigured", async () => { + const { fetchFn } = routingFetch({ + [`${apiBaseUrl}/api/v0/auth/config`]: () => + Response.json({ ...authConfig, authorizationServer: undefined }), + }); + + const result = await resolveConnectConfig({ apiBaseUrl, fetch: fetchFn }); + + expect(result.kind).toBe("misconfigured"); + }); + + it("reports an unreachable deployment as such", async () => { + const result = await resolveConnectConfig({ + apiBaseUrl, + fetch: (async () => { + throw Error("connect ECONNREFUSED"); + }) as unknown as typeof fetch, + }); + + if (result.kind !== "unreachable") throw Error("expected unreachable"); + expect(result.detail).toContain("ECONNREFUSED"); + }); + + it("reports an issuer that will not serve metadata as a configuration fault", async () => { + const { fetchFn } = routingFetch({ + [`${apiBaseUrl}/api/v0/auth/config`]: () => Response.json(authConfig), + [`${issuer}/.well-known/oauth-authorization-server`]: () => + Response.json({ ...metadata, issuer: "https://other.example" }), + }); + + const result = await resolveConnectConfig({ apiBaseUrl, fetch: fetchFn }); + + if (result.kind !== "discovery-failed") { + throw Error(`expected discovery-failed, got ${result.kind}`); + } + expect(result.detail).toContain("issuer"); + }); + + it("reports an issuer that is down as unreachable, not misconfigured", async () => { + const { fetchFn } = routingFetch({ + [`${apiBaseUrl}/api/v0/auth/config`]: () => Response.json(authConfig), + [`${issuer}/.well-known/oauth-authorization-server`]: () => + Response.json({ error: "boom" }, { status: 503 }), + }); + + const result = await resolveConnectConfig({ apiBaseUrl, fetch: fetchFn }); + + expect(result.kind).toBe("unreachable"); + }); + + it("derives the resource from the deployment url, port included", async () => { + const localBase = "http://localhost:3000"; + const { fetchFn } = routingFetch({ + [`${localBase}/api/v0/auth/config`]: () => Response.json(authConfig), + [`${issuer}/.well-known/oauth-authorization-server`]: () => + Response.json(metadata), + }); + + const result = await resolveConnectConfig({ + apiBaseUrl: localBase, + fetch: fetchFn, + }); + + if (result.kind !== "configured") throw Error("expected configured"); + expect(result.config.resource).toBe("http://localhost:3000/api"); + }); +}); diff --git a/src/domains/auth/connectConfig.ts b/src/domains/auth/connectConfig.ts new file mode 100644 index 000000000..3fd037578 --- /dev/null +++ b/src/domains/auth/connectConfig.ts @@ -0,0 +1,76 @@ +import { apiResource } from "~/core/deviceAuth/resource.js"; +import { getAuthConfig } from "~/shell/platform/getAuthConfig.js"; +import { discoverIssuer } from "~/shell/workos/discoverIssuer.js"; +import type { IssuerEndpoints } from "~/shell/workos/types.js"; + +/** Everything a Connect sign-in against one deployment needs. */ +export type ConnectConfig = { + issuer: string; + clientId: string; + /** The deployment's API resource, derived from the url the CLI is aimed at. */ + resource: string; + endpoints: IssuerEndpoints; +}; + +export type ConnectConfigResult = + | { kind: "configured"; config: ConnectConfig } + /** The deployment answered, and offers no browser sign-in. */ + | { kind: "unavailable" } + /** The deployment offers only the pre-Connect flow, whose tokens the API refuses. */ + | { kind: "legacy-only" } + /** The deployment publishes half a Connect configuration. */ + | { kind: "misconfigured"; detail: string } + /** The deployment or the issuer could not be asked; worth trying again. */ + | { kind: "unreachable"; detail: string } + /** The issuer answered with metadata the CLI will not sign in against. */ + | { kind: "discovery-failed"; detail: string }; + +type Deps = { + fetch: typeof globalThis.fetch; + apiBaseUrl: string; +}; + +/** + * Two discoveries in sequence: the deployment names its issuer and public + * client, then the issuer names its endpoints. The deployment is asked first + * and the issuer only when there is one, so a deployment without Connect + * costs one request and no contact with WorkOS. + */ +export async function resolveConnectConfig( + deps: Deps, +): Promise { + const authConfig = await getAuthConfig({ + baseUrl: deps.apiBaseUrl, + fetch: deps.fetch, + }); + + switch (authConfig.kind) { + case "unreachable": + return { kind: "unreachable", detail: authConfig.detail }; + case "unconfigured": + return { kind: "unavailable" }; + case "legacy-only": + return { kind: "legacy-only" }; + case "misconfigured": + return { kind: "misconfigured", detail: authConfig.detail }; + case "configured": + break; + } + + const endpoints = await discoverIssuer(authConfig.issuer, deps.fetch); + if (!endpoints.ok) { + return endpoints.retryable + ? { kind: "unreachable", detail: endpoints.error } + : { kind: "discovery-failed", detail: endpoints.error }; + } + + return { + kind: "configured", + config: { + issuer: authConfig.issuer, + clientId: authConfig.clientId, + resource: apiResource(deps.apiBaseUrl), + endpoints: endpoints.value, + }, + }; +} diff --git a/src/domains/auth/deviceLogin.bind.test.ts b/src/domains/auth/deviceLogin.bind.test.ts new file mode 100644 index 000000000..790382898 --- /dev/null +++ b/src/domains/auth/deviceLogin.bind.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "bun:test"; + +import type { DeviceTokens } from "~/core/deviceAuth/types.js"; +import { deviceLogin } from "./deviceLogin.js"; +import { + approved, + boundTokens, + deviceGrantTokens, + makeDeps, + makeJwt, + session, + testBinding, +} from "./deviceLogin.testUtils.js"; + +describe("deviceLogin: the resource-bound refresh", () => { + it("retries a transient fault, then succeeds", async () => { + const { deps, refreshCalls, clock } = makeDeps([approved], { + refresh: [ + { ok: false, error: "HTTP 503", retryable: true }, + { ok: true, value: boundTokens }, + ], + }); + + const result = await deviceLogin(deps); + + expect(result).toEqual({ ok: true, session }); + expect(refreshCalls).toEqual([ + "refresh_from_device", + "refresh_from_device", + ]); + expect(clock.slept).toEqual([1_000]); + }); + + it("gives up on a transient fault after a bounded number of attempts", async () => { + const { deps, refreshCalls, emailCalls } = makeDeps([approved], { + refresh: [{ ok: false, error: "HTTP 503", retryable: true }], + }); + + const result = await deviceLogin(deps); + + expect(result).toEqual({ + ok: false, + reason: "refresh-failed", + detail: "HTTP 503", + }); + expect(refreshCalls.length).toBe(4); + expect(emailCalls).toEqual([]); + }); + + it("does not retry a refusal", async () => { + const { deps, refreshCalls, emailCalls } = makeDeps([approved], { + refresh: [{ ok: false, error: "invalid_target", retryable: false }], + }); + + const result = await deviceLogin(deps); + + expect(result).toEqual({ + ok: false, + reason: "refresh-failed", + detail: "invalid_target", + }); + expect(refreshCalls.length).toBe(1); + expect(emailCalls).toEqual([]); + }); + + it.each([ + [ + "still carries the environment audience", + { ...boundTokens, accessToken: deviceGrantTokens.accessToken }, + ], + [ + "names another issuer", + { + ...boundTokens, + accessToken: makeJwt({ + iss: "https://other.example", + aud: testBinding.resource, + exp: 1, + }), + }, + ], + ["cannot be decoded", { ...boundTokens, accessToken: "garbage" }], + ] satisfies [string, DeviceTokens][])( + "rejects a refreshed token that %s without presenting it", + async (_label, tokens) => { + const { deps, emailCalls } = makeDeps([approved], { + refresh: [{ ok: true, value: tokens }], + }); + + const result = await deviceLogin(deps); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toBe("token-rejected"); + expect(emailCalls).toEqual([]); + }, + ); + + it("does not report success when the API rejects the session", async () => { + const { deps } = makeDeps([approved], { + email: { ok: false, error: "HTTP 401" }, + }); + + const result = await deviceLogin(deps); + + expect(result).toEqual({ + ok: false, + reason: "identity-rejected", + detail: "HTTP 401", + }); + }); + + it("stops before the refresh when cancelled during approval", async () => { + let cancelled = false; + const { deps, refreshCalls } = makeDeps([{ kind: "pending" }, approved], { + isCancelled: () => cancelled, + }); + const wrapped = { + ...deps, + pollToken: async (deviceCode: string) => { + const response = await deps.pollToken(deviceCode); + if (response.kind === "tokens") cancelled = true; + return response; + }, + }; + + const result = await deviceLogin(wrapped); + + expect(result.ok).toBe(false); + expect(refreshCalls).toEqual([]); + }); +}); diff --git a/src/domains/auth/deviceLogin.test.ts b/src/domains/auth/deviceLogin.test.ts new file mode 100644 index 000000000..fbd50c468 --- /dev/null +++ b/src/domains/auth/deviceLogin.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "bun:test"; + +import { deviceLogin } from "./deviceLogin.js"; +import { + approved, + boundTokens, + makeDeps, + session, + testAuthorization, + testBinding, + testIssuer, +} from "./deviceLogin.testUtils.js"; + +describe("deviceLogin", () => { + it("returns the resource-bound session once the person approves", async () => { + const { deps } = makeDeps([approved]); + + const result = await deviceLogin(deps); + + expect(result).toEqual({ ok: true, session }); + }); + + // The live failure. The device grant answers with a token whose audience is + // the environment client id; only the refresh that follows yields one for + // the API resource. Nothing may be done with the first. + it("exchanges the device grant's refresh token before using anything", async () => { + const { deps, refreshCalls, emailCalls } = makeDeps([approved]); + + const result = await deviceLogin(deps); + + expect(refreshCalls).toEqual(["refresh_from_device"]); + // Identity was asked once, with the bound token, never the first one. + expect(emailCalls).toEqual([boundTokens.accessToken]); + if (!result.ok) throw Error("expected success"); + expect(result.session.accessToken).toBe(boundTokens.accessToken); + expect(result.session.refreshToken).toBe("refresh_rotated"); + }); + + it("shows the code before polling so the person can act on it", async () => { + const { deps, prompted, poller } = makeDeps([approved]); + + await deviceLogin(deps); + + expect(prompted).toEqual([testAuthorization]); + expect(poller.calls).toEqual(["device_abc"]); + }); + + it("waits the advertised interval between polls", async () => { + const { deps, clock } = makeDeps([ + { kind: "pending" }, + { kind: "pending" }, + approved, + ]); + + await deviceLogin(deps); + + expect(clock.slept).toEqual([5_000, 5_000]); + }); + + it("backs off further once the server asks it to slow down", async () => { + const { deps, clock } = makeDeps([ + { kind: "pending" }, + { kind: "slow-down" }, + { kind: "pending" }, + approved, + ]); + + await deviceLogin(deps); + + expect(clock.slept).toEqual([5_000, 10_000, 10_000]); + }); + + it("rides out a dropped request instead of abandoning the sign-in", async () => { + const { deps, clock, poller } = makeDeps([ + { kind: "pending" }, + { kind: "unreachable", detail: "socket hang up" }, + approved, + ]); + + const result = await deviceLogin(deps); + + expect(result).toEqual({ ok: true, session }); + expect(poller.calls.length).toBe(3); + // 5s as advertised, then doubled after the request that failed. + expect(clock.slept).toEqual([5_000, 10_000]); + }); + + it("stops with access-denied when the person rejects the request", async () => { + const { deps } = makeDeps([{ kind: "pending" }, { kind: "denied" }]); + + const result = await deviceLogin(deps); + + expect(result).toEqual({ + ok: false, + reason: "access-denied", + detail: undefined, + }); + }); + + it("stops with expired when the device code lapses", async () => { + const { deps } = makeDeps([{ kind: "expired" }]); + + const result = await deviceLogin(deps); + + expect(result).toEqual({ ok: false, reason: "expired", detail: undefined }); + }); + + it("times out rather than polling forever against a stalled server", async () => { + const { deps, poller } = makeDeps([{ kind: "pending" }]); + + const result = await deviceLogin(deps); + + expect(result).toEqual({ ok: false, reason: "timeout", detail: undefined }); + // 300s deadline, 5s interval. Poll n happens at (n-1)*5s, so poll 61 lands + // exactly on the deadline and still counts; poll 62 at 305s is the first + // past it, and the one that reports the timeout. + expect(poller.calls.length).toBe(62); + }); + + it("reports the flow unavailable when authorization cannot start", async () => { + const { deps, poller } = makeDeps([], { + authorizationError: "device grant not enabled", + }); + + const result = await deviceLogin(deps); + + expect(result).toEqual({ + ok: false, + reason: "unavailable", + detail: "device grant not enabled", + }); + expect(poller.calls).toEqual([]); + }); + + it("stops without polling when cancelled before it starts", async () => { + const { deps, poller } = makeDeps([approved], { isCancelled: () => true }); + + const result = await deviceLogin(deps); + + expect(result).toEqual({ + ok: false, + reason: "cancelled", + detail: undefined, + }); + expect(poller.calls).toEqual([]); + }); + + it("stops polling as soon as it is cancelled mid-flow", async () => { + let cancelled = false; + const { deps, poller } = makeDeps([{ kind: "pending" }, approved], { + isCancelled: () => cancelled, + }); + const wrapped = { + ...deps, + sleep: async (ms: number) => { + cancelled = true; + await deps.sleep(ms); + }, + }; + + const result = await deviceLogin(wrapped); + + expect(result).toEqual({ + ok: false, + reason: "cancelled", + detail: undefined, + }); + expect(poller.calls.length).toBe(1); + }); + + it("carries the issuer it was bound against", () => { + // Guards the fixture: every token above claims this issuer. + expect(testBinding.issuer).toBe(testIssuer); + }); +}); diff --git a/src/domains/auth/deviceLogin.testUtils.ts b/src/domains/auth/deviceLogin.testUtils.ts new file mode 100644 index 000000000..235102bb5 --- /dev/null +++ b/src/domains/auth/deviceLogin.testUtils.ts @@ -0,0 +1,146 @@ +import type { + DeviceAuthorization, + DeviceTokens, + PollResponse, +} from "~/core/deviceAuth/types.js"; +import type { DeviceLoginDeps } from "./deviceLogin.js"; + +export const testIssuer = "https://signin.example"; +export const testResource = "https://app.example/api"; +export const testBinding = { issuer: testIssuer, resource: testResource }; + +export function makeJwt(payload: unknown): string { + const encode = (value: unknown) => + Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); + return [encode({ alg: "RS256" }), encode(payload), "sig"].join("."); +} + +/** What the device grant answers with: bound to the environment, not the API. */ +export const environmentAccessToken = makeJwt({ + iss: testIssuer, + aud: "client_01ENV", + exp: 1_700_000_000, + org_id: "org_1", +}); + +/** What the resource refresh answers with: the token the API accepts. */ +export const boundAccessToken = makeJwt({ + iss: testIssuer, + aud: testResource, + exp: 1_700_000_100, + org_id: "org_1", +}); + +export const testAuthorization: DeviceAuthorization = { + deviceCode: "device_abc", + userCode: "WDJB-MJHT", + verificationUri: "https://example.com/device", + verificationUriComplete: "https://example.com/device?user_code=WDJB-MJHT", + expiresInSec: 300, + intervalSec: 5, +}; + +export const deviceGrantTokens: DeviceTokens = { + accessToken: environmentAccessToken, + refreshToken: "refresh_from_device", + expiresAt: 1_700_000_000_000, + organizationId: "org_1", +}; + +export const boundTokens: DeviceTokens = { + accessToken: boundAccessToken, + refreshToken: "refresh_rotated", + expiresAt: 1_700_000_100_000, + organizationId: "org_1", +}; + +/** + * A clock that only moves when the code under test sleeps, so polling tests + * assert real elapsed time without waiting for it. + */ +export function makeFakeClock() { + let nowMs = 0; + const slept: number[] = []; + return { + now: () => nowMs, + slept, + sleep: async (ms: number) => { + slept.push(ms); + nowMs += ms; + }, + advance: (ms: number) => { + nowMs += ms; + }, + }; +} + +/** Hands back scripted poll answers in order, repeating the last one. */ +export function makePoller(responses: PollResponse[]) { + const calls: string[] = []; + let index = 0; + return { + calls, + poll: async (deviceCode: string): Promise => { + calls.push(deviceCode); + const response = responses[Math.min(index, responses.length - 1)]; + index += 1; + if (!response) throw Error("no scripted poll response"); + return response; + }, + }; +} + +type RefreshResult = Awaited>; +type EmailResult = Awaited>; + +export function makeDeps( + responses: Parameters[0], + overrides: { + authorization?: DeviceAuthorization; + authorizationError?: string; + isCancelled?: () => boolean; + refresh?: RefreshResult[]; + email?: EmailResult; + } = {}, +) { + const clock = makeFakeClock(); + const poller = makePoller(responses); + const prompted: DeviceAuthorization[] = []; + const refreshCalls: string[] = []; + const emailCalls: string[] = []; + const refreshScript = overrides.refresh ?? [{ ok: true, value: boundTokens }]; + + const deps: DeviceLoginDeps = { + requestAuthorization: async () => + overrides.authorizationError + ? { ok: false, error: overrides.authorizationError } + : { ok: true, value: overrides.authorization ?? testAuthorization }, + pollToken: poller.poll, + refreshTokens: async (refreshToken) => { + refreshCalls.push(refreshToken); + const next = + refreshScript[ + Math.min(refreshCalls.length - 1, refreshScript.length - 1) + ]; + if (!next) throw Error("no scripted refresh response"); + return next; + }, + binding: testBinding, + fetchEmail: async (accessToken) => { + emailCalls.push(accessToken); + return overrides.email ?? { ok: true, email: "person@example.com" }; + }, + onPrompt: (authorization) => { + prompted.push(authorization); + }, + sleep: clock.sleep, + now: clock.now, + isCancelled: overrides.isCancelled ?? (() => false), + }; + + return { clock, poller, prompted, refreshCalls, emailCalls, deps }; +} + +export const approved = { kind: "tokens" as const, tokens: deviceGrantTokens }; + +export const session = { ...boundTokens, email: "person@example.com" }; diff --git a/src/domains/auth/deviceLogin.ts b/src/domains/auth/deviceLogin.ts new file mode 100644 index 000000000..69020c1ce --- /dev/null +++ b/src/domains/auth/deviceLogin.ts @@ -0,0 +1,149 @@ +import { nextPollStep } from "~/core/deviceAuth/pollState.js"; +import { verifyTokenBinding } from "~/core/deviceAuth/tokenClaims.js"; +import type { + DeviceAuthorization, + DeviceTokens, + PollFailure, + PollResponse, + PollState, +} from "~/core/deviceAuth/types.js"; +import { authErrorMessages } from "~/core/messages/authErrors.js"; + +type Outcome = + | { ok: true; value: T } + | { ok: false; error: string; retryable: boolean }; + +export type DeviceLoginDeps = { + requestAuthorization: () => Promise< + { ok: true; value: DeviceAuthorization } | { ok: false; error: string } + >; + pollToken: (deviceCode: string) => Promise; + /** The resource-bound exchange that turns an approved grant into a session. */ + refreshTokens: (refreshToken: string) => Promise>; + /** What a usable token must be issued by and for. */ + binding: { issuer: string; resource: string }; + /** Asks the API who the token belongs to; its acceptance is the real test. */ + fetchEmail: ( + accessToken: string, + ) => Promise<{ ok: true; email: string } | { ok: false; error: string }>; + /** Shows the user code and verification URL before polling begins. */ + onPrompt: (authorization: DeviceAuthorization) => void | Promise; + sleep: (ms: number) => Promise; + now: () => number; + isCancelled: () => boolean; +}; + +type DeviceLoginSession = DeviceTokens & { email: string }; + +export type DeviceLoginResult = + | { ok: true; session: DeviceLoginSession } + | { + ok: false; + reason: + | PollFailure + | "unavailable" + | "cancelled" + | "refresh-failed" + | "token-rejected" + | "identity-rejected"; + detail: string | undefined; + }; + +type Failure = Extract; + +const cancelled: Failure = { + ok: false, + reason: "cancelled", + detail: undefined, +}; + +/** + * The refresh happens once, right after approval, with nothing to show the + * person meanwhile. Bounded so a WorkOS outage ends in a message rather than + * a spinner; short because the person is sitting there. + */ +const refreshRetryDelaysMs = [1_000, 2_000, 4_000] as const; + +async function approve( + deps: DeviceLoginDeps, +): Promise<{ ok: true; tokens: DeviceTokens } | Failure> { + const authorization = await deps.requestAuthorization(); + if (!authorization.ok) { + return { ok: false, reason: "unavailable", detail: authorization.error }; + } + + await deps.onPrompt(authorization.value); + + let state: PollState = { + intervalMs: authorization.value.intervalSec * 1_000, + deadlineMs: deps.now() + authorization.value.expiresInSec * 1_000, + }; + + for (;;) { + if (deps.isCancelled()) return cancelled; + + const response = await deps.pollToken(authorization.value.deviceCode); + const step = nextPollStep(state, response, deps.now()); + + if (step.action === "done") return { ok: true, tokens: step.tokens }; + if (step.action === "fail") { + return { ok: false, reason: step.reason, detail: step.detail }; + } + + state = step.state; + await deps.sleep(step.delayMs); + } +} + +async function bind( + refreshToken: string, + deps: DeviceLoginDeps, +): Promise> { + let attempt = 0; + for (;;) { + const result = await deps.refreshTokens(refreshToken); + const delay = refreshRetryDelaysMs[attempt]; + if (result.ok || !result.retryable || delay === undefined) return result; + attempt += 1; + await deps.sleep(delay); + } +} + +/** + * Runs a device authorization flow to completion. Approval alone does not end + * it: WorkOS answers the device grant with a token for the environment client + * id, which the API refuses, and only a refresh naming the API resource yields + * a usable one. So the grant's refresh token is spent at once, the result is + * checked against the binding, and the API is asked to confirm it. Nothing is + * ever done with the first token, and nothing is written to storage. + */ +export async function deviceLogin( + deps: DeviceLoginDeps, +): Promise { + if (deps.isCancelled()) return cancelled; + + const approved = await approve(deps); + if (!approved.ok) return approved; + if (deps.isCancelled()) return cancelled; + + const bound = await bind(approved.tokens.refreshToken, deps); + if (!bound.ok) { + return { ok: false, reason: "refresh-failed", detail: bound.error }; + } + + const binding = verifyTokenBinding(bound.value.accessToken, deps.binding); + if (!binding.ok) { + return { + ok: false, + reason: "token-rejected", + detail: authErrorMessages.workos.tokenNotBound(binding.reason), + }; + } + + const identity = await deps.fetchEmail(bound.value.accessToken); + if (!identity.ok) { + return { ok: false, reason: "identity-rejected", detail: identity.error }; + } + + return { ok: true, session: { ...bound.value, email: identity.email } }; +} diff --git a/src/domains/auth/resolve.test.ts b/src/domains/auth/resolve.test.ts index fa3a543e8..d1ca78f1a 100644 --- a/src/domains/auth/resolve.test.ts +++ b/src/domains/auth/resolve.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, mock } from "bun:test"; import { makeMemoryFs } from "~/shell/fs.testUtils.js"; +import type { OauthToken } from "./resolveOauthToken.js"; import type { LoadApiKeyResult } from "./types.js"; import { requireApiKey, resolveApiKey } from "./resolve.js"; @@ -10,99 +11,167 @@ afterEach(() => { const memFs = makeMemoryFs(); +/** Browser sign-in holds nothing, so only the API key paths are exercised. */ +const noOauth = async (): Promise => undefined; + +function mockLoad(result?: LoadApiKeyResult) { + const fn = mock<(configDir: string) => Promise>(); + if (result) fn.mockResolvedValue(result); + return fn; +} + describe("resolveApiKey", () => { it("returns env var when QAWOLF_API_KEY is set", async () => { - const mockLoadApiKey = - mock<(configDir: string) => Promise>(); + const loadApiKey = mockLoad(); const result = await resolveApiKey("/tmp/config", memFs, { - loadApiKey: mockLoadApiKey, + loadApiKey, + resolveOauth: noOauth, env: { QAWOLF_API_KEY: "qaw_test_key" }, }); - expect(result).toEqual({ key: "qaw_test_key", source: "env" }); - expect(mockLoadApiKey).not.toHaveBeenCalled(); + expect(result).toEqual({ + key: "qaw_test_key", + source: "env", + }); + expect(loadApiKey).not.toHaveBeenCalled(); }); it("trims whitespace from env var", async () => { - const mockLoadApiKey = - mock<(configDir: string) => Promise>(); + const loadApiKey = mockLoad(); const result = await resolveApiKey("/tmp/config", memFs, { - loadApiKey: mockLoadApiKey, + loadApiKey, + resolveOauth: noOauth, env: { QAWOLF_API_KEY: " qaw_test_key " }, }); - expect(result).toEqual({ key: "qaw_test_key", source: "env" }); - expect(mockLoadApiKey).not.toHaveBeenCalled(); + expect(result).toEqual({ + key: "qaw_test_key", + source: "env", + }); + expect(loadApiKey).not.toHaveBeenCalled(); }); it("skips whitespace-only env var", async () => { - const mockLoadApiKey = mock< - (configDir: string) => Promise - >().mockResolvedValue({ found: false }); + const loadApiKey = mockLoad({ found: false }); const result = await resolveApiKey("/tmp/config", memFs, { - loadApiKey: mockLoadApiKey, + loadApiKey, + resolveOauth: noOauth, env: { QAWOLF_API_KEY: " " }, }); expect(result).toBeUndefined(); - expect(mockLoadApiKey).toHaveBeenCalledWith("/tmp/config"); + expect(loadApiKey).toHaveBeenCalledWith("/tmp/config"); }); it("returns stored key when env var is not set", async () => { - const mockLoadApiKey = mock< - (configDir: string) => Promise - >().mockResolvedValue({ - found: true, + const result = await resolveApiKey("/tmp/config", memFs, { + loadApiKey: mockLoad({ + found: true, + key: "qaw_stored", + source: "keychain", + }), + resolveOauth: noOauth, + env: {}, + }); + + expect(result).toEqual({ key: "qaw_stored", source: "keychain", }); + }); + it("returns undefined when nothing found", async () => { const result = await resolveApiKey("/tmp/config", memFs, { - loadApiKey: mockLoadApiKey, + loadApiKey: mockLoad({ found: false }), + resolveOauth: noOauth, env: {}, }); - expect(result).toEqual({ key: "qaw_stored", source: "keychain" }); + expect(result).toBeUndefined(); }); - it("returns undefined when nothing found", async () => { - const mockLoadApiKey = mock< - (configDir: string) => Promise - >().mockResolvedValue({ found: false }); + it("falls back to browser sign-in when no API key is stored", async () => { + const result = await resolveApiKey("/tmp/config", memFs, { + loadApiKey: mockLoad({ found: false }), + resolveOauth: async () => ({ + key: "access_abc", + email: "person@example.com", + }), + env: {}, + }); + + expect(result).toEqual({ + key: "access_abc", + source: "browser", + }); + }); + + it("prefers a stored API key over browser sign-in for its team scope", async () => { + const resolveOauth = mock(async () => ({ + key: "access_abc", + email: "person@example.com", + })); const result = await resolveApiKey("/tmp/config", memFs, { - loadApiKey: mockLoadApiKey, + loadApiKey: mockLoad({ + found: true, + key: "qaw_stored", + source: "keychain", + }), + resolveOauth, env: {}, }); - expect(result).toBeUndefined(); + expect(result).toEqual({ + key: "qaw_stored", + source: "keychain", + }); + expect(resolveOauth).not.toHaveBeenCalled(); + }); + + it("prefers the environment variable over browser sign-in", async () => { + const resolveOauth = mock(async () => ({ + key: "access_abc", + email: "person@example.com", + })); + + const result = await resolveApiKey("/tmp/config", memFs, { + loadApiKey: mockLoad(), + resolveOauth, + env: { QAWOLF_API_KEY: "qaw_env" }, + }); + + expect(result).toEqual({ + key: "qaw_env", + source: "env", + }); + expect(resolveOauth).not.toHaveBeenCalled(); }); }); describe("requireApiKey", () => { it("returns the resolved ApiKeyResult when a key exists", async () => { - const mockLoad = mock<(configDir: string) => Promise>(); - const result = await requireApiKey("/tmp/config", memFs, { - loadApiKey: mockLoad, + loadApiKey: mockLoad(), + resolveOauth: noOauth, env: { QAWOLF_API_KEY: "qaw_key" }, }); - expect(result).toEqual({ key: "qaw_key", source: "env" }); + expect(result).toEqual({ + key: "qaw_key", + source: "env", + }); }); it("throws the standard message when no key is found", async () => { - const mockLoad = mock< - (configDir: string) => Promise - >().mockResolvedValue({ found: false }); - let caughtError: unknown; try { await requireApiKey("/tmp/config", memFs, { - loadApiKey: mockLoad, + loadApiKey: mockLoad({ found: false }), + resolveOauth: noOauth, env: {}, }); } catch (e) { diff --git a/src/domains/auth/resolve.ts b/src/domains/auth/resolve.ts index 64828f7f3..3d3c6859e 100644 --- a/src/domains/auth/resolve.ts +++ b/src/domains/auth/resolve.ts @@ -1,22 +1,68 @@ import { Entry } from "@napi-rs/keyring"; +import { apiResource } from "~/core/deviceAuth/resource.js"; import type { Fs } from "~/shell/fs.js"; +import { resolveHostUrl } from "~/shell/resolveHostUrl.js"; +import { discoverIssuer } from "~/shell/workos/discoverIssuer.js"; +import { refreshAccessToken } from "~/shell/workos/refreshAccessToken.js"; import { loadApiKey as realLoadApiKey } from "./store/index.js"; +import { loadTokens as realLoadTokens } from "./store/loadTokens.js"; +import { saveTokens as realSaveTokens } from "./store/saveTokens.js"; +import { + type OauthToken, + resolveOauthToken, + type ResolveOauthTokenDeps, +} from "./resolveOauthToken.js"; import type { ApiKeyResult, LoadApiKeyResult } from "./types.js"; type ResolveApiKeyDeps = { loadApiKey: (configDir: string) => Promise; + resolveOauth: (configDir: string) => Promise; env: Record; }; +function makeOauthDeps(fs: Fs, apiBaseUrl: string): ResolveOauthTokenDeps { + return { + loadTokens: (configDir) => + realLoadTokens(configDir, { EntryClass: Entry, fs }), + // The stored session names its issuer, client and resource, so renewing a + // token asks the deployment nothing. The issuer is still asked where its + // token endpoint is: metadata is cheap, and pinning an endpoint would + // outlive a provider that moved it. + refreshTokens: async ({ refreshToken, issuer, clientId, resource }) => { + const endpoints = await discoverIssuer(issuer, globalThis.fetch); + if (!endpoints.ok) return endpoints; + return refreshAccessToken(refreshToken, { + fetch: globalThis.fetch, + clientId, + resource, + endpoints: endpoints.value, + }); + }, + saveTokens: (configDir, tokens) => realSaveTokens(configDir, tokens, fs), + now: () => Date.now(), + resource: apiResource(apiBaseUrl), + }; +} + function makeDefaultDeps(fs: Fs): ResolveApiKeyDeps { + const env = process.env; return { loadApiKey: (configDir) => realLoadApiKey(configDir, { EntryClass: Entry, fs }), - env: process.env, + resolveOauth: (configDir) => + resolveOauthToken(configDir, makeOauthDeps(fs, resolveHostUrl(env))), + env, }; } +/** + * Finds the credential the CLI should present, highest precedence first: the + * environment variable, then a stored API key, then browser sign-in. + * + * An API key outranks browser sign-in because it carries team scope that a user + * token does not, so someone holding both keeps the broader access. + */ export async function resolveApiKey( configDir: string, fs: Fs, @@ -33,6 +79,11 @@ export async function resolveApiKey( return { key: stored.key, source: stored.source }; } + const oauth = await resolvedDeps.resolveOauth(configDir); + if (oauth) { + return { key: oauth.key, source: "browser" }; + } + return undefined; } diff --git a/src/domains/auth/resolveOauthToken.race.test.ts b/src/domains/auth/resolveOauthToken.race.test.ts new file mode 100644 index 000000000..76a40db2d --- /dev/null +++ b/src/domains/auth/resolveOauthToken.race.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it, mock } from "bun:test"; + +import { makeJwt, testIssuer, testResource } from "./deviceLogin.testUtils.js"; +import { resolveOauthToken } from "./resolveOauthToken.js"; +import type { LoadTokensResult, StoredSession } from "./types.js"; + +const nowMs = 1_700_000_000_000; + +function boundToken(label: string, expiresAt: number, orgId = "org_1") { + return makeJwt({ + iss: testIssuer, + aud: testResource, + exp: expiresAt / 1_000, + org_id: orgId, + label, + }); +} + +const spent: StoredSession = { + accessToken: boundToken("old", nowMs - 1), + refreshToken: "refresh_stale", + // Already past the margin, so resolving it always attempts a refresh. + expiresAt: nowMs - 1, + email: "person@example.com", + organizationId: "org_1", + issuer: testIssuer, + clientId: "client_1", + resource: testResource, +}; + +const winner: StoredSession = { + ...spent, + accessToken: boundToken("winner", nowMs + 600_000), + refreshToken: "refresh_rotated", + expiresAt: nowMs + 600_000, +}; + +function found(tokens: StoredSession): LoadTokensResult { + return { found: true, tokens, source: "keychain" }; +} + +const revoked = async () => ({ + ok: false as const, + error: "invalid_grant", + retryable: false, +}); + +/** Loads `first` once, then `after` for every later read. */ +function loadsThen(first: StoredSession, after: StoredSession) { + const loadTokens = mock(async () => found(after)); + loadTokens.mockResolvedValueOnce(found(first)); + return loadTokens; +} + +describe("resolveOauthToken when a refresh does not succeed", () => { + // A lost refresh race must not read as "signed out". WorkOS rotates on every + // exchange, so whichever process won has already written a pair this one can + // use. + it("adopts a pair another command installed while this refresh failed", async () => { + const loadTokens = loadsThen(spent, winner); + + const result = await resolveOauthToken("/config", { + loadTokens, + refreshTokens: revoked, + saveTokens: async () => {}, + now: () => nowMs, + resource: testResource, + }); + + expect(result).toEqual({ + key: winner.accessToken, + email: "person@example.com", + }); + expect(loadTokens).toHaveBeenCalledTimes(2); + }); + + // The pair on disk may have been written by a command aimed elsewhere, or + // granted for another organization. Neither is this command's session. + it.each([ + [ + "another deployment", + { ...winner, resource: "https://elsewhere.example/api" }, + ], + [ + "another organization", + { + ...winner, + organizationId: "org_2", + accessToken: boundToken("winner", nowMs + 600_000, "org_2"), + }, + ], + [ + "an audience the API refuses", + { + ...winner, + accessToken: makeJwt({ iss: testIssuer, aud: "client_01ENV", exp: 1 }), + }, + ], + ] satisfies [string, StoredSession][])( + "does not adopt a pair bound to %s", + async (_label, onDisk) => { + const result = await resolveOauthToken("/config", { + loadTokens: loadsThen(spent, onDisk), + refreshTokens: revoked, + saveTokens: async () => {}, + now: () => nowMs, + resource: testResource, + }); + + expect(result).toBeUndefined(); + }, + ); + + // The margin is a head start, not an expiry: the token in hand still works, + // so a dropped packet inside it must not end the session. + it("keeps an unexpired token when the refresh fails transiently", async () => { + const insideMargin: StoredSession = { + ...spent, + accessToken: boundToken("still-good", nowMs + 5_000), + expiresAt: nowMs + 5_000, + }; + + const result = await resolveOauthToken("/config", { + loadTokens: async () => found(insideMargin), + refreshTokens: async () => ({ + ok: false as const, + error: "socket hang up", + retryable: true, + }), + saveTokens: async () => {}, + now: () => nowMs, + resource: testResource, + }); + + expect(result?.key).toBe(insideMargin.accessToken); + }); + + // The fallback is only worth taking if the token in hand would be accepted. + it("does not fall back to an unexpired token with the wrong audience", async () => { + const wrongAudience: StoredSession = { + ...spent, + accessToken: makeJwt({ + iss: testIssuer, + aud: "client_01ENV", + exp: (nowMs + 5_000) / 1_000, + }), + expiresAt: nowMs + 5_000, + }; + + const result = await resolveOauthToken("/config", { + loadTokens: async () => found(wrongAudience), + refreshTokens: async () => ({ + ok: false as const, + error: "socket hang up", + retryable: true, + }), + saveTokens: async () => {}, + now: () => nowMs, + resource: testResource, + }); + + expect(result).toBeUndefined(); + }); + + // A write that fails costs the next command a sign-in either way; failing + // this one as well would only take away a credential that works. + it("returns the refreshed token even when it cannot be persisted", async () => { + const result = await resolveOauthToken("/config", { + loadTokens: async () => found(spent), + refreshTokens: async () => ({ + ok: true as const, + value: { + accessToken: winner.accessToken, + refreshToken: "refresh_new", + expiresAt: nowMs + 600_000, + organizationId: "org_1", + }, + }), + saveTokens: async () => { + throw Object.assign(Error("permission denied"), { code: "EACCES" }); + }, + now: () => nowMs, + resource: testResource, + }); + + expect(result?.key).toBe(winner.accessToken); + }); + + it("still reports nothing when the stored pair is unchanged", async () => { + const result = await resolveOauthToken("/config", { + loadTokens: async () => found(spent), + refreshTokens: revoked, + saveTokens: async () => {}, + now: () => nowMs, + resource: testResource, + }); + + expect(result).toBeUndefined(); + }); +}); diff --git a/src/domains/auth/resolveOauthToken.rotation.test.ts b/src/domains/auth/resolveOauthToken.rotation.test.ts new file mode 100644 index 000000000..5187371d8 --- /dev/null +++ b/src/domains/auth/resolveOauthToken.rotation.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, mock } from "bun:test"; + +import type { DeviceTokens } from "~/core/deviceAuth/types.js"; +import { testIssuer, testResource } from "./deviceLogin.testUtils.js"; +import { + resolveOauthToken, + type ResolveOauthTokenDeps, +} from "./resolveOauthToken.js"; +import { + boundToken, + environmentToken, + expectedRefreshArgs, + makeDeps, + nowMs, + refreshed, + stored, +} from "./resolveOauthToken.testUtils.js"; +import type { StoredSession } from "./types.js"; + +describe("resolveOauthToken across renewals", () => { + // Observed live: a refresh that omits `resource` answers with the environment + // audience. Such a token must never reach the API, whatever else happened. + it("rejects a refreshed token that carries the wrong audience", async () => { + const { deps, saveTokens } = makeDeps( + { + found: true, + tokens: { ...stored, expiresAt: nowMs - 1 }, + source: "keychain", + }, + [ + { + ok: true, + value: { + ...refreshed, + accessToken: environmentToken(nowMs + 600_000), + }, + }, + ], + ); + + const result = await resolveOauthToken("/config", deps); + + expect(result).toBeUndefined(); + // The rotation still happened, so the replacement refresh token is kept: + // dropping it would lock every later attempt out as well. + expect(saveTokens).toHaveBeenCalledTimes(1); + }); + + it("chains two rotations, each spending the token the last one issued", async () => { + const second: DeviceTokens = { + accessToken: boundToken("third", nowMs + 1_200_000), + refreshToken: "refresh_third", + expiresAt: nowMs + 1_200_000, + organizationId: "org_1", + }; + let onDisk: StoredSession = { ...stored, expiresAt: nowMs - 1 }; + const refreshTokens = mock( + async (args: Parameters[0]) => + args.refreshToken === "refresh_old" + ? { ok: true as const, value: refreshed } + : { ok: true as const, value: second }, + ); + const deps: ResolveOauthTokenDeps = { + loadTokens: async () => ({ + found: true, + tokens: onDisk, + source: "keychain", + }), + refreshTokens, + saveTokens: async (_dir, tokens) => { + onDisk = tokens; + }, + now: () => nowMs, + resource: testResource, + }; + + const first = await resolveOauthToken("/config", deps); + // Force the second renewal: the pair on disk is now spent by fiat. + onDisk = { ...onDisk, expiresAt: nowMs - 1 }; + const next = await resolveOauthToken("/config", deps); + + expect(first?.key).toBe(refreshed.accessToken); + expect(next?.key).toBe(second.accessToken); + expect(refreshTokens.mock.calls.map(([args]) => args)).toEqual([ + expectedRefreshArgs, + { ...expectedRefreshArgs, refreshToken: "refresh_new" }, + ]); + expect(onDisk.refreshToken).toBe("refresh_third"); + expect(onDisk.issuer).toBe(testIssuer); + expect(onDisk.resource).toBe(testResource); + expect(onDisk.email).toBe("person@example.com"); + }); + + // Pointing the CLI at another deployment must not present — or spend — the + // session that belongs to the previous one. + it("ignores a session bound to a different deployment", async () => { + const { deps, refreshTokens } = makeDeps({ + found: true, + tokens: stored, + source: "keychain", + }); + + const result = await resolveOauthToken("/config", { + ...deps, + resource: "https://elsewhere.example/api", + }); + + expect(result).toBeUndefined(); + expect(refreshTokens).not.toHaveBeenCalled(); + }); + + // Nothing to fall back to and nothing to retry: the resource is not + // registered, which no amount of signing in changes. + it("returns undefined, once, when the resource is not registered", async () => { + const { deps, refreshTokens } = makeDeps( + { + found: true, + tokens: { ...stored, expiresAt: nowMs - 1 }, + source: "file", + }, + [{ ok: false, error: "invalid_target", retryable: false }], + ); + + const result = await resolveOauthToken("/config", deps); + + expect(result).toBeUndefined(); + expect(refreshTokens).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/domains/auth/resolveOauthToken.test.ts b/src/domains/auth/resolveOauthToken.test.ts new file mode 100644 index 000000000..a046e58af --- /dev/null +++ b/src/domains/auth/resolveOauthToken.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "bun:test"; + +import { testIssuer, testResource } from "./deviceLogin.testUtils.js"; +import { expiryMarginMs, resolveOauthToken } from "./resolveOauthToken.js"; +import { + environmentToken, + expectedRefreshArgs, + makeDeps, + nowMs, + refreshed, + stored, +} from "./resolveOauthToken.testUtils.js"; + +describe("resolveOauthToken", () => { + it("uses the stored access token while it remains valid", async () => { + const { deps, refreshTokens } = makeDeps({ + found: true, + tokens: stored, + source: "keychain", + }); + + const result = await resolveOauthToken("/config", deps); + + expect(result).toEqual({ + key: stored.accessToken, + email: "person@example.com", + }); + expect(refreshTokens).not.toHaveBeenCalled(); + }); + + it("refreshes an expired access token with the session's own binding", async () => { + const { deps, refreshTokens } = makeDeps({ + found: true, + tokens: { ...stored, expiresAt: nowMs - 1 }, + source: "keychain", + }); + + const result = await resolveOauthToken("/config", deps); + + expect(result).toEqual({ + key: refreshed.accessToken, + email: "person@example.com", + }); + expect(refreshTokens).toHaveBeenCalledWith(expectedRefreshArgs); + }); + + it("refreshes a token that expires inside the safety margin", async () => { + const { deps, refreshTokens } = makeDeps({ + found: true, + tokens: { ...stored, expiresAt: nowMs + expiryMarginMs - 1 }, + source: "keychain", + }); + + await resolveOauthToken("/config", deps); + + expect(refreshTokens).toHaveBeenCalledWith(expectedRefreshArgs); + }); + + it("refreshes when the stored expiry is unknown", async () => { + const { deps, refreshTokens } = makeDeps({ + found: true, + tokens: { ...stored, expiresAt: undefined }, + source: "keychain", + }); + + await resolveOauthToken("/config", deps); + + expect(refreshTokens).toHaveBeenCalledWith(expectedRefreshArgs); + }); + + // An unexpired token is only worth presenting if it is bound to the API. + // One stored with another audience would be refused, so it is renewed. + it("refreshes an unexpired token that is not bound to the resource", async () => { + const { deps, refreshTokens } = makeDeps({ + found: true, + tokens: { ...stored, accessToken: environmentToken(nowMs + 60_000) }, + source: "keychain", + }); + + const result = await resolveOauthToken("/config", deps); + + expect(refreshTokens).toHaveBeenCalledTimes(1); + expect(result?.key).toBe(refreshed.accessToken); + }); + + it("persists the rotated pair together with the session's binding and email", async () => { + const { deps, saveTokens } = makeDeps({ + found: true, + tokens: { ...stored, expiresAt: nowMs - 1 }, + source: "keychain", + }); + + await resolveOauthToken("/config", deps); + + expect(saveTokens).toHaveBeenCalledWith("/config", { + ...refreshed, + email: "person@example.com", + issuer: testIssuer, + clientId: "client_1", + resource: testResource, + }); + }); + + it("returns undefined when no tokens are stored", async () => { + const { deps, refreshTokens } = makeDeps({ found: false }); + + const result = await resolveOauthToken("/config", deps); + + expect(result).toBeUndefined(); + expect(refreshTokens).not.toHaveBeenCalled(); + }); + + it("returns undefined when the refresh token has been revoked", async () => { + const { deps, saveTokens } = makeDeps( + { + found: true, + tokens: { ...stored, expiresAt: nowMs - 1 }, + source: "file", + }, + [{ ok: false, error: "token revoked", retryable: false }], + ); + + const result = await resolveOauthToken("/config", deps); + + expect(result).toBeUndefined(); + expect(saveTokens).not.toHaveBeenCalled(); + }); +}); diff --git a/src/domains/auth/resolveOauthToken.testUtils.ts b/src/domains/auth/resolveOauthToken.testUtils.ts new file mode 100644 index 000000000..50af5100e --- /dev/null +++ b/src/domains/auth/resolveOauthToken.testUtils.ts @@ -0,0 +1,96 @@ +import { mock } from "bun:test"; + +import type { DeviceTokens } from "~/core/deviceAuth/types.js"; +import { makeJwt, testIssuer, testResource } from "./deviceLogin.testUtils.js"; +import type { ResolveOauthTokenDeps } from "./resolveOauthToken.js"; +import type { LoadTokensResult, StoredSession } from "./types.js"; + +export const nowMs = 1_700_000_000_000; + +export function boundToken( + label: string, + expiresAt: number, + orgId = "org_1", +): string { + return makeJwt({ + iss: testIssuer, + aud: testResource, + exp: expiresAt / 1_000, + org_id: orgId, + label, + }); +} + +/** What a refresh without `resource` answers with: the environment audience. */ +export function environmentToken(expiresAt: number): string { + return makeJwt({ + iss: testIssuer, + aud: "client_01ENV", + exp: expiresAt / 1_000, + org_id: "org_1", + }); +} + +export const stored: StoredSession = { + accessToken: boundToken("old", nowMs + 60_000), + refreshToken: "refresh_old", + expiresAt: nowMs + 60_000, + email: "person@example.com", + organizationId: "org_1", + issuer: testIssuer, + clientId: "client_1", + resource: testResource, +}; + +export const refreshed: DeviceTokens = { + accessToken: boundToken("new", nowMs + 600_000), + refreshToken: "refresh_new", + expiresAt: nowMs + 600_000, + organizationId: "org_1", +}; + +export const expectedRefreshArgs = { + refreshToken: "refresh_old", + issuer: testIssuer, + clientId: "client_1", + resource: testResource, +}; + +export type RefreshResult = Awaited< + ReturnType +>; + +export function makeDeps( + loadResult: LoadTokensResult, + refreshResults: RefreshResult[] = [{ ok: true, value: refreshed }], +) { + const saveTokens = mock( + async (_configDir: string, _tokens: StoredSession) => { + // storage is asserted through the spy, not through a filesystem + }, + ); + const refreshTokens = mock( + async (_args: Parameters[0]) => { + const next = + refreshResults[ + Math.min( + refreshTokens.mock.calls.length - 1, + refreshResults.length - 1, + ) + ]; + if (!next) throw Error("no scripted refresh result"); + return next; + }, + ); + return { + saveTokens, + refreshTokens, + deps: { + loadTokens: async () => loadResult, + refreshTokens, + saveTokens, + now: () => nowMs, + resource: testResource, + } satisfies ResolveOauthTokenDeps, + }; +} diff --git a/src/domains/auth/resolveOauthToken.ts b/src/domains/auth/resolveOauthToken.ts new file mode 100644 index 000000000..35c540461 --- /dev/null +++ b/src/domains/auth/resolveOauthToken.ts @@ -0,0 +1,139 @@ +import { verifyTokenBinding } from "~/core/deviceAuth/tokenClaims.js"; +import type { DeviceTokens } from "~/core/deviceAuth/types.js"; +import type { LoadTokensResult, StoredSession } from "./types.js"; + +/** + * A token valid for another second will have expired by the time a slow + * request reaches the API, and the resulting 401 looks like a bug. + */ +export const expiryMarginMs = 30_000; + +export type ResolveOauthTokenDeps = { + loadTokens: (configDir: string) => Promise; + refreshTokens: (args: { + refreshToken: string; + issuer: string; + clientId: string; + resource: string; + }) => Promise< + | { ok: true; value: DeviceTokens } + | { ok: false; error: string; retryable: boolean } + >; + saveTokens: (configDir: string, tokens: StoredSession) => Promise; + now: () => number; + /** The API resource of the deployment the CLI is aimed at right now. */ + resource: string; +}; + +export type OauthToken = { key: string; email: string }; + +/** Whether the API would accept this session's access token as it stands. */ +function isBound(session: StoredSession): boolean { + return verifyTokenBinding(session.accessToken, { + issuer: session.issuer, + resource: session.resource, + }).ok; +} + +/** + * Whether a pair found on disk after a failed refresh is this command's + * session: the same deployment, the same organization, and a token the API + * would take. Another command aimed elsewhere writes to the same store. + */ +function isSameSession( + candidate: StoredSession, + session: StoredSession, +): boolean { + return ( + candidate.resource === session.resource && + candidate.organizationId === session.organizationId && + isBound(candidate) + ); +} + +/** + * Undefined whenever a token cannot be produced. A failed refresh means "sign + * in again", which the caller reports as not authenticated rather than as an + * error. + */ +export async function resolveOauthToken( + configDir: string, + deps: ResolveOauthTokenDeps, +): Promise { + const stored = await deps.loadTokens(configDir); + if (!stored.found) return undefined; + + const { tokens } = stored; + // A session belongs to the deployment it was bound to. Presenting it to + // another would be refused, and refreshing it would spend the other + // deployment's session for nothing. + if (tokens.resource !== deps.resource) return undefined; + + const expiresAt = tokens.expiresAt; + const unexpired = expiresAt !== undefined && expiresAt > deps.now(); + const isFresh = + expiresAt !== undefined && expiresAt - expiryMarginMs > deps.now(); + + if (isFresh && isBound(tokens)) { + return { key: tokens.accessToken, email: tokens.email }; + } + + // The resource goes on every refresh: without it WorkOS answers with the + // environment audience again, and the session ends on the next request. + const refreshed = await deps.refreshTokens({ + refreshToken: tokens.refreshToken, + issuer: tokens.issuer, + clientId: tokens.clientId, + resource: tokens.resource, + }); + if (!refreshed.ok) { + // The margin is a head start, not an expiry. A dropped packet inside it + // leaves a token that still works, so ending the session over one would + // sign someone out mid-command for nothing. + if (refreshed.retryable && unexpired && isBound(tokens)) { + return { key: tokens.accessToken, email: tokens.email }; + } + + // Another command may have refreshed while this one was in flight — the + // workers of a single `flows run` all resolve at once. Adopt whatever is on + // disk before reporting a dead session: the winner's pair is valid for this + // process too, and reporting "not authenticated" over a lost race sends + // someone to sign in again for nothing. + const current = await deps.loadTokens(configDir); + if ( + current.found && + current.tokens.refreshToken !== tokens.refreshToken && + isSameSession(current.tokens, tokens) + ) { + return { key: current.tokens.accessToken, email: current.tokens.email }; + } + return undefined; + } + + const renewed: StoredSession = { + ...refreshed.value, + email: tokens.email, + issuer: tokens.issuer, + clientId: tokens.clientId, + resource: tokens.resource, + }; + + // Refresh tokens rotate, so the whole pair has to land in storage. Persisting + // only the access token would spend the refresh token and lock the next + // refresh out. Saved before the audience check for the same reason: the + // rotation has happened whether or not the token turns out usable. + try { + await deps.saveTokens(configDir, renewed); + } catch { + // The token in hand works for this command. Failing here as well would cost + // the caller a working credential and change nothing: the refresh already + // spent the stored token, so the next command has to sign in again whether + // this one succeeds or not. + } + + // Never present a token the API would refuse; the person would see an + // opaque 401 in place of a reason. + if (!isBound(renewed)) return undefined; + + return { key: renewed.accessToken, email: renewed.email }; +} diff --git a/src/domains/auth/sessionEmail.ts b/src/domains/auth/sessionEmail.ts new file mode 100644 index 000000000..1bac5d810 --- /dev/null +++ b/src/domains/auth/sessionEmail.ts @@ -0,0 +1,29 @@ +import { authErrorMessages } from "~/core/messages/authErrors.js"; +import { describeIdentityError } from "~/shell/platform/describeErrors.js"; +import { getIdentity } from "~/shell/platform/getIdentity.js"; + +type Deps = { + fetch: typeof globalThis.fetch; + baseUrl: string; +}; + +/** + * Who a freshly bound token belongs to, according to the API. A Connect token + * response names nobody, and the API's acceptance is the real test of the + * token anyway, so the two questions are asked in one round trip. + */ +export async function fetchSessionEmail( + accessToken: string, + deps: Deps, +): Promise<{ ok: true; email: string } | { ok: false; error: string }> { + const identity = await getIdentity(accessToken, deps); + if (!identity.ok) { + return { ok: false, error: describeIdentityError(identity.error).error }; + } + + if (!("user" in identity.data)) { + return { ok: false, error: authErrorMessages.identity.notUserSession }; + } + + return { ok: true, email: identity.data.user.email }; +} diff --git a/src/domains/auth/store/constants.ts b/src/domains/auth/store/constants.ts index c72975f2d..807c083b4 100644 --- a/src/domains/auth/store/constants.ts +++ b/src/domains/auth/store/constants.ts @@ -1,3 +1,8 @@ export const service = "qawolf-cli"; export const account = "api-key"; export const credentialsFile = "credentials.json"; + +// A separate entry, not new fields on the api-key record: moving that record to +// a JSON payload would strand every key already in a keychain. +export const tokensAccount = "oauth-tokens"; +export const tokensFile = "tokens.json"; diff --git a/src/domains/auth/store/delete.ts b/src/domains/auth/store/delete.ts index 5b5d5fc0a..5c5ec9897 100644 --- a/src/domains/auth/store/delete.ts +++ b/src/domains/auth/store/delete.ts @@ -1,12 +1,13 @@ +import { isNoEntError } from "~/core/errors.js"; import type { Fs } from "~/shell/fs.js"; import { join } from "node:path"; import { Entry } from "@napi-rs/keyring"; import { account, credentialsFile, service } from "./constants.js"; -import type { DeleteApiKeyResult } from "./types.js"; +import type { DeleteCredentialResult } from "./types.js"; -function deleteFromKeychain(): DeleteApiKeyResult["keychain"] { +function deleteFromKeychain(): DeleteCredentialResult["keychain"] { try { new Entry(service, account).deletePassword(); return "deleted"; @@ -18,19 +19,23 @@ function deleteFromKeychain(): DeleteApiKeyResult["keychain"] { async function deleteFromFile( configDir: string, fs: Fs, -): Promise { +): Promise { try { await fs.unlink(join(configDir, credentialsFile)); return "deleted"; - } catch { - return "not-found"; + } catch (err: unknown) { + // Only a missing file is "not-found". Swallowing a permission or I/O error + // would let logout report "Credentials removed" over a credential that is + // still on disk. + if (isNoEntError(err)) return "not-found"; + throw err; } } export async function deleteApiKey( configDir: string, fs: Fs, -): Promise { +): Promise { const [keychain, file] = await Promise.all([ Promise.resolve(deleteFromKeychain()), deleteFromFile(configDir, fs), diff --git a/src/domains/auth/store/deleteTokens.ts b/src/domains/auth/store/deleteTokens.ts new file mode 100644 index 000000000..8ba046623 --- /dev/null +++ b/src/domains/auth/store/deleteTokens.ts @@ -0,0 +1,44 @@ +import { isNoEntError } from "~/core/errors.js"; +import type { Fs } from "~/shell/fs.js"; +import { join } from "node:path"; + +import { Entry } from "@napi-rs/keyring"; + +import { service, tokensAccount, tokensFile } from "./constants.js"; +import type { DeleteCredentialResult } from "./types.js"; + +function deleteFromKeychain(): DeleteCredentialResult["keychain"] { + try { + new Entry(service, tokensAccount).deletePassword(); + return "deleted"; + } catch { + return "unavailable"; + } +} + +async function deleteFromFile( + configDir: string, + fs: Fs, +): Promise { + try { + await fs.unlink(join(configDir, tokensFile)); + return "deleted"; + } catch (err: unknown) { + // Only a missing file is "not-found". Swallowing a permission or I/O error + // would let logout report "Credentials removed" over a credential that is + // still on disk. + if (isNoEntError(err)) return "not-found"; + throw err; + } +} + +export async function deleteTokens( + configDir: string, + fs: Fs, +): Promise { + const [keychain, file] = await Promise.all([ + Promise.resolve(deleteFromKeychain()), + deleteFromFile(configDir, fs), + ]); + return { keychain, file }; +} diff --git a/src/domains/auth/store/hasStoredCredentials.ts b/src/domains/auth/store/hasStoredCredentials.ts new file mode 100644 index 000000000..75dc019a0 --- /dev/null +++ b/src/domains/auth/store/hasStoredCredentials.ts @@ -0,0 +1,63 @@ +import { join } from "node:path"; + +import { Entry } from "@napi-rs/keyring"; + +import { isNoEntError } from "~/core/errors.js"; +import type { Fs } from "~/shell/fs.js"; +import { + account, + credentialsFile, + service, + tokensAccount, + tokensFile, +} from "./constants.js"; + +async function fileExists( + path: string, + fs: Pick, +): Promise { + try { + await fs.readFile(path); + return true; + } catch (err: unknown) { + // Anything other than "missing" means something is there that cannot be + // read — a truncated write, a permission problem — which logout still has + // to remove. + return !isNoEntError(err); + } +} + +function keychainHolds(entryAccount: string): boolean { + try { + return Boolean(new Entry(service, entryAccount).getPassword()); + } catch { + // No usable keychain on this machine, so nothing of ours is in it. + return false; + } +} + +/** + * Whether this machine holds a credential at all. + * + * Presence, not validity: a payload that will not parse still has to be + * cleared, so this asks whether the file or the keychain entry is there rather + * than whether it loads. `resolveApiKey` cannot stand in for either question — + * it refreshes a browser session over the network, so an offline machine reads + * as "nothing stored" while the credentials are still on disk. + */ +export async function hasStoredCredentials( + configDir: string, + fs: Pick, +): Promise { + const [apiKeyFile, tokenFile] = await Promise.all([ + fileExists(join(configDir, credentialsFile), fs), + fileExists(join(configDir, tokensFile), fs), + ]); + + return ( + apiKeyFile || + tokenFile || + keychainHolds(account) || + keychainHolds(tokensAccount) + ); +} diff --git a/src/domains/auth/store/index.ts b/src/domains/auth/store/index.ts index 45ea80a0e..946045933 100644 --- a/src/domains/auth/store/index.ts +++ b/src/domains/auth/store/index.ts @@ -1,3 +1,4 @@ export { deleteApiKey } from "./delete.js"; +export { hasStoredCredentials } from "./hasStoredCredentials.js"; export { loadApiKey } from "./load.js"; export { saveApiKey } from "./save.js"; diff --git a/src/domains/auth/store/loadTokens.test.ts b/src/domains/auth/store/loadTokens.test.ts new file mode 100644 index 000000000..fc1fe9e96 --- /dev/null +++ b/src/domains/auth/store/loadTokens.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "bun:test"; + +import type { StoredSession } from "~/domains/auth/types.js"; +import type { Fs } from "~/shell/fs.js"; +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; + +import { loadTokens } from "./loadTokens.js"; +import { + makeEntryClass, + makeThrowingEntryClass, + tokens, +} from "./tokens.testUtils.js"; + +describe("loadTokens", () => { + it("returns tokens held in the keychain", async () => { + const EntryClass = makeEntryClass(() => JSON.stringify(tokens)); + + const result = await loadTokens("/config", { + EntryClass, + fs: makeMemoryFs(), + }); + + expect(result).toEqual({ found: true, tokens, source: "keychain" }); + }); + + it("falls back to the token file when the keychain throws", async () => { + const memFs = makeMemoryFs(); + await memFs.mkdir("/config", { recursive: true }); + await memFs.writeFile("/config/tokens.json", JSON.stringify(tokens)); + + const result = await loadTokens("/config", { + EntryClass: makeThrowingEntryClass("keychain locked"), + fs: memFs, + }); + + expect(result).toEqual({ found: true, tokens, source: "file" }); + }); + + it("round-trips the organization the token was granted for", async () => { + const EntryClass = makeEntryClass(() => JSON.stringify(tokens)); + + const result = await loadTokens("/config", { + EntryClass, + fs: makeMemoryFs(), + }); + + if (!result.found) throw Error("expected stored tokens"); + expect(result.tokens.organizationId).toBe("org_1"); + }); + + // A refresh is only redeemable against its issuer and client, and only + // yields a usable token when it asks for the same resource. All three ride + // with the session so a later refresh asks the deployment nothing. + it("round-trips the issuer, client and resource the session is bound to", async () => { + const EntryClass = makeEntryClass(() => JSON.stringify(tokens)); + + const result = await loadTokens("/config", { + EntryClass, + fs: makeMemoryFs(), + }); + + if (!result.found) throw Error("expected stored tokens"); + expect(result.tokens.issuer).toBe("https://signin.example"); + expect(result.tokens.clientId).toBe("client_1"); + expect(result.tokens.resource).toBe("https://app.example/api"); + }); + + // A session from before Connect has no issuer or resource to refresh + // against. Guessing them from the current deployment could bind a refresh to + // the wrong place, so the record is treated as needing a fresh sign-in. + it("does not load a session that predates Connect, and says why", async () => { + const legacy = { + accessToken: "access_abc", + refreshToken: "refresh_abc", + expiresAt: 1_700_000_000_000, + email: "person@example.com", + organizationId: "org_1", + clientId: "client_1", + }; + const EntryClass = makeEntryClass(() => JSON.stringify(legacy)); + + const result = await loadTokens("/config", { + EntryClass, + fs: makeMemoryFs(), + }); + + expect(result.found).toBe(false); + if (result.found) return; + expect(result.errors?.keychain).toContain("sign in again"); + }); + + it("round-trips tokens whose expiry is unknown", async () => { + const withoutExpiry: StoredSession = { ...tokens, expiresAt: undefined }; + const EntryClass = makeEntryClass(() => JSON.stringify(withoutExpiry)); + + const result = await loadTokens("/config", { + EntryClass, + fs: makeMemoryFs(), + }); + + expect(result).toEqual({ + found: true, + tokens: withoutExpiry, + source: "keychain", + }); + }); + + it("reports not found when neither store holds tokens", async () => { + const result = await loadTokens("/config", { + EntryClass: makeEntryClass(() => ""), + fs: makeMemoryFs(), + }); + + expect(result.found).toBe(false); + }); + + it("reports not found when the stored payload fails validation", async () => { + const EntryClass = makeEntryClass(() => + JSON.stringify({ accessToken: "only-this" }), + ); + + const result = await loadTokens("/config", { + EntryClass, + fs: makeMemoryFs(), + }); + + expect(result.found).toBe(false); + }); + + // The shape a truncated or half-flushed write actually leaves behind. The + // parse throws out of parseTokens rather than returning, so this covers the + // catch that keeps a corrupt store from crashing the command. + it("reports a store holding bytes that are not JSON", async () => { + const memFs = makeMemoryFs(); + await memFs.mkdir("/config", { recursive: true }); + await memFs.writeFile("/config/tokens.json", '{"accessToken": "trunc'); + + const result = await loadTokens("/config", { + EntryClass: makeEntryClass(() => ""), + fs: memFs as unknown as Fs, + }); + + expect(result.found).toBe(false); + if (result.found) return; + expect(result.errors?.file).toBeDefined(); + }); + + it("reports a store holding JSON that is not a session", async () => { + const memFs = makeMemoryFs(); + await memFs.mkdir("/config", { recursive: true }); + await memFs.writeFile("/config/tokens.json", '{"nonsense": true}'); + + const result = await loadTokens("/config", { + EntryClass: makeEntryClass(() => ""), + fs: memFs as unknown as Fs, + }); + + expect(result.found).toBe(false); + }); +}); diff --git a/src/domains/auth/store/loadTokens.ts b/src/domains/auth/store/loadTokens.ts new file mode 100644 index 000000000..64807614d --- /dev/null +++ b/src/domains/auth/store/loadTokens.ts @@ -0,0 +1,84 @@ +import type { Fs } from "~/shell/fs.js"; +import { join } from "node:path"; + +import type { Entry } from "@napi-rs/keyring"; + +import { errorMessage } from "~/core/errors.js"; +import type { LoadTokensResult, StoredSession } from "~/domains/auth/types.js"; +import { service, tokensAccount, tokensFile } from "./constants.js"; +import { legacyTokensSchema, oauthTokensSchema } from "./types.js"; + +type LoadTokensDeps = { + EntryClass: typeof Entry; + fs: Pick; +}; + +const invalidPayload = "Invalid stored token format"; + +// A pre-Connect session names no issuer or resource to refresh against, and +// guessing them from the current deployment could bind a refresh to the wrong +// place. Reported as its own thing so the person is told to sign in again +// rather than that their credential store is corrupt. +const legacySession = + "The stored session predates WorkOS Connect sign-in; sign in again with 'qawolf auth login'"; + +function parseTokens( + raw: string, +): { tokens: StoredSession } | { error: string } { + const json: unknown = JSON.parse(raw); + const parsed = oauthTokensSchema.safeParse(json); + if (!parsed.success) { + const legacy = legacyTokensSchema.safeParse(json); + return { + error: + legacy.success && legacy.data.issuer === undefined + ? legacySession + : invalidPayload, + }; + } + return { + tokens: { + accessToken: parsed.data.accessToken, + refreshToken: parsed.data.refreshToken, + expiresAt: parsed.data.expiresAt, + email: parsed.data.email, + organizationId: parsed.data.organizationId, + issuer: parsed.data.issuer, + clientId: parsed.data.clientId, + resource: parsed.data.resource, + }, + }; +} + +export async function loadTokens( + configDir: string, + deps: LoadTokensDeps, +): Promise { + const errors: { keychain?: string; file?: string } = {}; + + try { + const raw = new deps.EntryClass(service, tokensAccount).getPassword(); + if (raw) { + const parsed = parseTokens(raw); + if ("tokens" in parsed) { + return { found: true, tokens: parsed.tokens, source: "keychain" }; + } + errors.keychain = parsed.error; + } + } catch (err: unknown) { + errors.keychain = errorMessage(err); + } + + try { + const raw = await deps.fs.readFile(join(configDir, tokensFile)); + const parsed = parseTokens(raw); + if ("tokens" in parsed) { + return { found: true, tokens: parsed.tokens, source: "file" }; + } + errors.file = parsed.error; + } catch (err: unknown) { + errors.file = errorMessage(err); + } + + return { found: false, errors }; +} diff --git a/src/domains/auth/store/save.ts b/src/domains/auth/store/save.ts index f82350742..9870f44eb 100644 --- a/src/domains/auth/store/save.ts +++ b/src/domains/auth/store/save.ts @@ -5,7 +5,7 @@ import { Entry } from "@napi-rs/keyring"; import { errorMessage } from "~/core/errors.js"; import { account, credentialsFile, service } from "./constants.js"; -import type { CredentialsFile, SaveApiKeyResult } from "./types.js"; +import type { CredentialsFile, SaveCredentialResult } from "./types.js"; async function saveToFile( configDir: string, @@ -27,7 +27,7 @@ export async function saveApiKey( configDir: string, key: string, fs: Fs, -): Promise { +): Promise { try { const entry = new Entry(service, account); entry.setPassword(key); diff --git a/src/domains/auth/store/saveTokens.ts b/src/domains/auth/store/saveTokens.ts new file mode 100644 index 000000000..8bb0e3d75 --- /dev/null +++ b/src/domains/auth/store/saveTokens.ts @@ -0,0 +1,38 @@ +import type { StoredSession } from "~/domains/auth/types.js"; +import type { Fs } from "~/shell/fs.js"; +import { join } from "node:path"; + +import { Entry } from "@napi-rs/keyring"; + +import { errorMessage } from "~/core/errors.js"; +import { service, tokensAccount, tokensFile } from "./constants.js"; +import type { SaveCredentialResult } from "./types.js"; + +async function saveToFile( + configDir: string, + tokens: StoredSession, + fs: Fs, +): Promise { + // rwx------ (owner only) + await fs.mkdir(configDir, { recursive: true, mode: 0o700 }); + // rw------- (owner read/write only) + await fs.writeFile( + join(configDir, tokensFile), + JSON.stringify(tokens, undefined, 2), + { mode: 0o600 }, + ); +} + +export async function saveTokens( + configDir: string, + tokens: StoredSession, + fs: Fs, +): Promise { + try { + new Entry(service, tokensAccount).setPassword(JSON.stringify(tokens)); + return { stored: "keychain" }; + } catch (err: unknown) { + await saveToFile(configDir, tokens, fs); + return { stored: "file", keychainError: errorMessage(err) }; + } +} diff --git a/src/domains/auth/store/tokens.test.ts b/src/domains/auth/store/tokens.test.ts new file mode 100644 index 000000000..060575c37 --- /dev/null +++ b/src/domains/auth/store/tokens.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; + +import { Entry } from "@napi-rs/keyring"; + +import type { Fs } from "~/shell/fs.js"; +import { makeMemoryFs } from "~/shell/fs.testUtils.js"; + +import { deleteTokens } from "./deleteTokens.js"; +import { saveTokens } from "./saveTokens.js"; +import { tokens } from "./tokens.testUtils.js"; + +afterEach(() => { + mock.restore(); +}); + +describe("saveTokens", () => { + it("stores tokens in the keychain when it is available", async () => { + spyOn(Entry.prototype, "setPassword").mockReturnValue(undefined); + const memFs = makeMemoryFs(); + await memFs.mkdir("/config", { recursive: true }); + + const result = await saveTokens("/config", tokens, memFs); + + expect(result.stored).toBe("keychain"); + expect(await memFs.pathExists("/config/tokens.json")).toBe(false); + }); + + it("falls back to a token file when the keychain throws", async () => { + spyOn(Entry.prototype, "setPassword").mockImplementation(() => { + throw Error("keychain unavailable"); + }); + const memFs = makeMemoryFs(); + await memFs.mkdir("/config", { recursive: true }); + + const result = await saveTokens("/config", tokens, memFs); + + expect(result.stored).toBe("file"); + const contents = await memFs.readFile("/config/tokens.json"); + expect(JSON.parse(contents)).toEqual(tokens); + }); + + it("writes the token file so only its owner can read it", async () => { + spyOn(Entry.prototype, "setPassword").mockImplementation(() => { + throw Error("keychain unavailable"); + }); + const memFs = makeMemoryFs(); + await memFs.mkdir("/config", { recursive: true }); + const modes: (number | undefined)[] = []; + const recordingFs: Fs = { + ...memFs, + writeFile: (path, data, options) => { + modes.push(options?.mode); + return memFs.writeFile(path, data, options); + }, + }; + + await saveTokens("/config", tokens, recordingFs); + + expect(modes).toEqual([0o600]); + }); +}); + +describe("deleteTokens", () => { + it("removes the token file", async () => { + spyOn(Entry.prototype, "deletePassword").mockReturnValue(true); + const memFs = makeMemoryFs(); + await memFs.mkdir("/config", { recursive: true }); + await memFs.writeFile("/config/tokens.json", JSON.stringify(tokens)); + + const result = await deleteTokens("/config", memFs); + + expect(result.file).toBe("deleted"); + expect(await memFs.pathExists("/config/tokens.json")).toBe(false); + }); + + it("reports not-found when there is no token file", async () => { + spyOn(Entry.prototype, "deletePassword").mockReturnValue(true); + const memFs = makeMemoryFs(); + await memFs.mkdir("/config", { recursive: true }); + + const result = await deleteTokens("/config", memFs); + + expect(result.file).toBe("not-found"); + }); + + // Reporting "Credentials removed" over a file that is still there is worse + // than failing loudly. + it("propagates a deletion failure that is not a missing file", async () => { + spyOn(Entry.prototype, "deletePassword").mockReturnValue(true); + const failing = { + unlink: async () => { + throw Object.assign(Error("permission denied"), { code: "EACCES" }); + }, + } as unknown as Fs; + + let caught: unknown; + try { + await deleteTokens("/config", failing); + } catch (err) { + caught = err; + } + + expect((caught as Error | undefined)?.message).toBe("permission denied"); + }); +}); diff --git a/src/domains/auth/store/tokens.testUtils.ts b/src/domains/auth/store/tokens.testUtils.ts new file mode 100644 index 000000000..52dcf0f67 --- /dev/null +++ b/src/domains/auth/store/tokens.testUtils.ts @@ -0,0 +1,31 @@ +import type { Entry } from "@napi-rs/keyring"; + +import type { StoredSession } from "~/domains/auth/types.js"; + +export const tokens: StoredSession = { + accessToken: "access_abc", + refreshToken: "refresh_abc", + expiresAt: 1_700_000_000_000, + email: "person@example.com", + organizationId: "org_1", + issuer: "https://signin.example", + clientId: "client_1", + resource: "https://app.example/api", +}; + +export function makeEntryClass(getPassword: () => string): typeof Entry { + return class { + getPassword = getPassword; + } as unknown as typeof Entry; +} + +export function makeThrowingEntryClass(message: string): typeof Entry { + return class { + constructor(_service: string, _account: string) { + throw Error(message); + } + getPassword(): string { + throw Error("unreachable"); + } + } as unknown as typeof Entry; +} diff --git a/src/domains/auth/store/types.ts b/src/domains/auth/store/types.ts index 42cd3bbd0..5fb7437cf 100644 --- a/src/domains/auth/store/types.ts +++ b/src/domains/auth/store/types.ts @@ -8,7 +8,31 @@ export const credentialsFileSchema = z.object({ export type CredentialsFile = z.infer; -export type SaveApiKeyResult = { +export const oauthTokensSchema = z.object({ + accessToken: z.string().min(1), + refreshToken: z.string().min(1), + /** Epoch ms. Absent when the access token carried no readable expiry. */ + expiresAt: z.number().int().optional(), + email: z.string().min(1), + /** WorkOS organization the token was granted for. */ + organizationId: z.string().min(1).optional(), + /** Where the tokens came from and what they are bound to; refreshes reuse all three. */ + issuer: z.string().min(1), + clientId: z.string().min(1), + resource: z.string().min(1), +}); + +/** + * The shape a session from before Connect took. Recognised only so its + * presence can be reported as "sign in again" rather than as corruption. + */ +export const legacyTokensSchema = z.object({ + accessToken: z.string().min(1), + refreshToken: z.string().min(1), + issuer: z.string().optional(), +}); + +export type SaveCredentialResult = { keychain: { stored: "keychain" }; file: { stored: "file"; keychainError: string }; }[StorageSource]; @@ -18,4 +42,6 @@ type DeleteOutcomeMap = { file: "deleted" | "not-found"; }; -export type DeleteApiKeyResult = { [K in StorageSource]: DeleteOutcomeMap[K] }; +export type DeleteCredentialResult = { + [K in StorageSource]: DeleteOutcomeMap[K]; +}; diff --git a/src/domains/auth/types.ts b/src/domains/auth/types.ts index 5096994aa..e67edb118 100644 --- a/src/domains/auth/types.ts +++ b/src/domains/auth/types.ts @@ -1,9 +1,12 @@ +import type { DeviceTokens } from "~/core/deviceAuth/types.js"; + // "keychain" = OS credential store (macOS Keychain, Windows Credential Manager, etc.) // "file" = fallback JSON file in the config directory export type StorageSource = "keychain" | "file"; -// "env" = QAWOLF_API_KEY environment variable -type ApiKeySource = "env" | StorageSource; +// "env" = QAWOLF_API_KEY environment variable +// "browser" = access token from browser sign-in, held in either storage +type ApiKeySource = "env" | StorageSource | "browser"; export type ApiKeyResult = { key: string; @@ -14,6 +17,27 @@ export type LoadApiKeyResult = | { found: true; key: string; source: StorageSource } | { found: false; errors?: { keychain?: string; file?: string } }; +/** + * What browser sign-in persists: the token pair, who it belongs to, and what + * it is bound to. A refresh token is only redeemable against its issuer and + * client, and only yields a usable token when it asks for the same resource, + * so all three ride with the session rather than being asked of the + * deployment again — which could answer differently once the CLI is pointed + * elsewhere. + */ +export type StoredSession = DeviceTokens & { + /** From the API's identity response, not from the token. */ + email: string; + issuer: string; + clientId: string; + /** The API resource the tokens are bound to; also names the deployment. */ + resource: string; +}; + +export type LoadTokensResult = + | { found: true; tokens: StoredSession; source: StorageSource } + | { found: false; errors?: { keychain?: string; file?: string } }; + export type ValidateApiKeyResult = | { valid: true } | { valid: false; error: string }; 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..354d05cc0 --- /dev/null +++ b/src/shell/platform/getAuthConfig.test.ts @@ -0,0 +1,170 @@ +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"; + +/** What a Connect-enabled deployment publishes. The two ids are distinct. */ +const connect = { + workOsClientId: "client_01ENV", + authorizationServer: "https://signin.example", + workOsConnectClientId: "client_01CONNECT", +}; + +describe("getAuthConfig", () => { + it("reads the deployment's sign-in configuration without credentials", async () => { + const mockFetch = createFetchMock(jsonResponse(connect)); + + 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(); + }); + + // The environment client id is what the legacy flow signed in with. Tokens + // it issues carry that id as their audience, which the API rejects. + it("selects the Connect client id and issuer, not the environment id", async () => { + const result = await getAuthConfig({ + baseUrl, + fetch: createFetchMock(jsonResponse(connect)), + }); + + expect(result).toEqual({ + kind: "configured", + issuer: "https://signin.example", + clientId: "client_01CONNECT", + }); + }); + + it("reads a deployment publishing only the environment id as legacy-only", async () => { + const result = await getAuthConfig({ + baseUrl, + fetch: createFetchMock(jsonResponse({ workOsClientId: "client_01ENV" })), + }); + + expect(result).toEqual({ kind: "legacy-only" }); + }); + + // Half a Connect configuration is a deployment mistake, and substituting the + // environment id would sign someone in to a token the API then refuses. + it.each([ + ["the issuer", "authorizationServer"], + ["the Connect client id", "workOsConnectClientId"], + ])("reads a configuration missing %s as misconfigured", async (_l, field) => { + const { [field]: _dropped, ...partial } = connect as Record; + + const result = await getAuthConfig({ + baseUrl, + fetch: createFetchMock(jsonResponse(partial)), + }); + + if (result.kind !== "misconfigured") { + throw Error(`expected misconfigured, got ${result.kind}`); + } + expect(result.detail).toContain(field); + }); + + it("treats a blank Connect field as absent", async () => { + const result = await getAuthConfig({ + baseUrl, + fetch: createFetchMock( + jsonResponse({ ...connect, workOsConnectClientId: " " }), + ), + }); + + expect(result.kind).toBe("misconfigured"); + }); + + 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 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], + ])("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..cba4951ca --- /dev/null +++ b/src/shell/platform/getAuthConfig.ts @@ -0,0 +1,99 @@ +import { z } from "zod"; + +import { errorMessage } from "~/core/errors.js"; +import { authErrorMessages } from "~/core/messages/authErrors.js"; + +type Deps = { + fetch: typeof globalThis.fetch; + baseUrl: string; +}; + +const timeoutMs = 10_000; + +const authConfigBody = z.object({ + /** The WorkOS environment client id, which the legacy flow signed in with. */ + workOsClientId: z.string().min(1), + /** The WorkOS Connect issuer, when the deployment accepts Connect tokens. */ + authorizationServer: z.string().optional(), + /** The public Connect application to sign in with, paired with the issuer. */ + workOsConnectClientId: z.string().optional(), +}); + +export type AuthConfigResult = + /** Connect sign-in is on offer: discover the issuer, sign in as this client. */ + | { kind: "configured"; issuer: string; clientId: string } + /** + * The deployment publishes only the environment client id. Tokens from that + * flow carry it as their audience, which the API refuses, so this is + * reported as its own thing rather than as a client to sign in with. + */ + | { kind: "legacy-only" } + /** One half of the Connect configuration without the other. */ + | { kind: "misconfigured"; detail: 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 }; + +function classify(body: z.infer): AuthConfigResult { + const issuer = body.authorizationServer?.trim(); + const clientId = body.workOsConnectClientId?.trim(); + + if (issuer && clientId) return { kind: "configured", issuer, clientId }; + if (!issuer && !clientId) return { kind: "legacy-only" }; + + // Substituting the environment id here would sign someone in to a token the + // API then refuses, with nothing to say why. + return { + kind: "misconfigured", + detail: authErrorMessages.authConfig.halfConfigured( + issuer ? "workOsConnectClientId" : "authorizationServer", + ), + }; +} + +/** + * 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. A 5xx, a 429 or a 408 + // is the server failing to answer at all, 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 >= 500 || + response.status === 429 || + response.status === 408 + ) { + return { kind: "unreachable", detail: `HTTP ${response.status}` }; + } + if (!response.ok) return { kind: "unconfigured" }; + + 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 ? classify(parsed.data) : { kind: "unconfigured" }; +} diff --git a/src/shell/workos/connectTokens.ts b/src/shell/workos/connectTokens.ts new file mode 100644 index 000000000..c4bc59ad1 --- /dev/null +++ b/src/shell/workos/connectTokens.ts @@ -0,0 +1,34 @@ +import { readTokenClaims } from "~/core/deviceAuth/tokenClaims.js"; +import { readAccessTokenExpiry } from "~/core/deviceAuth/tokenExpiry.js"; +import type { DeviceTokens } from "~/core/deviceAuth/types.js"; +import { authErrorMessages } from "~/core/messages/authErrors.js"; +import { unexpectedResponse } from "./send.js"; +import { connectTokenBody } from "./types.js"; + +/** + * A successful token-endpoint body, shared by the device grant and the refresh + * grant since Connect answers both the same way. + */ +export function readConnectTokens( + json: unknown, +): { ok: true; tokens: DeviceTokens } | { ok: false; error: string } { + const parsed = connectTokenBody.safeParse(json); + if (!parsed.success) return { ok: false, error: unexpectedResponse }; + + // Without one there is no way to reach the resource-bound token the API + // accepts, so the response is unusable however the rest of it looks. + if (!parsed.data.refresh_token) { + return { ok: false, error: authErrorMessages.workos.noRefreshToken }; + } + + const orgId = readTokenClaims(parsed.data.access_token)?.["org_id"]; + return { + ok: true, + tokens: { + accessToken: parsed.data.access_token, + refreshToken: parsed.data.refresh_token, + expiresAt: readAccessTokenExpiry(parsed.data.access_token), + organizationId: typeof orgId === "string" && orgId ? orgId : undefined, + }, + }; +} diff --git a/src/shell/workos/discoverIssuer.test.ts b/src/shell/workos/discoverIssuer.test.ts new file mode 100644 index 000000000..518b31dcb --- /dev/null +++ b/src/shell/workos/discoverIssuer.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, mock } from "bun:test"; + +import { discoverIssuer } from "./discoverIssuer.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 issuer = "https://signin.example"; + +const metadata = { + issuer: "https://signin.example", + authorization_endpoint: "https://signin.example/oauth2/authorize", + device_authorization_endpoint: + "https://signin.example/oauth2/device_authorization", + token_endpoint: "https://signin.example/oauth2/token", +}; + +type Result = Awaited>; + +function expectFailure(result: Result) { + if (result.ok) throw Error("expected failure, got success"); + return result; +} + +describe("discoverIssuer", () => { + it("reads the well-known authorization server document without credentials", async () => { + const mockFetch = createFetchMock(jsonResponse(metadata)); + + await discoverIssuer(issuer, mockFetch); + + const [url, init] = (mockFetch as unknown as ReturnType).mock + .calls[0] as [string, RequestInit]; + expect(url).toBe( + "https://signin.example/.well-known/oauth-authorization-server", + ); + expect(init.headers).toBeUndefined(); + // A redirect would carry later requests to a host nobody vetted. + expect(init.redirect).toBe("manual"); + }); + + it("returns the device and token endpoints the issuer advertises", async () => { + const result = await discoverIssuer( + issuer, + createFetchMock(jsonResponse(metadata)), + ); + + expect(result).toEqual({ + ok: true, + value: { + deviceAuthorization: + "https://signin.example/oauth2/device_authorization", + token: "https://signin.example/oauth2/token", + }, + }); + }); + + it("tolerates a trailing slash on the configured issuer", async () => { + const mockFetch = createFetchMock(jsonResponse(metadata)); + + const result = await discoverIssuer("https://signin.example/", mockFetch); + + expect(result.ok).toBe(true); + const [url] = (mockFetch as unknown as ReturnType).mock + .calls[0] as [string]; + expect(url).toBe( + "https://signin.example/.well-known/oauth-authorization-server", + ); + }); + + // RFC 8414 section 3.3: a client must reject metadata whose issuer does not + // match, or a document served for one server could speak for another. + it("refuses metadata whose issuer is not the one configured", async () => { + const result = await discoverIssuer( + issuer, + createFetchMock(jsonResponse({ ...metadata, issuer: "https://evil" })), + ); + + const failure = expectFailure(result); + expect(failure.retryable).toBe(false); + expect(failure.error).toContain("issuer"); + }); + + it.each([ + ["device authorization", "device_authorization_endpoint"], + ["token", "token_endpoint"], + ])( + "refuses metadata that advertises no %s endpoint", + async (_label, field) => { + const { [field]: _dropped, ...partial } = metadata as Record< + string, + string + >; + + const result = await discoverIssuer( + issuer, + createFetchMock(jsonResponse(partial)), + ); + + expect(expectFailure(result).retryable).toBe(false); + }, + ); + + // The endpoints receive the device code and the refresh token. Following a + // document that points them at another origin would hand those to it. + it("refuses an endpoint on a different origin from the issuer", async () => { + const result = await discoverIssuer( + issuer, + createFetchMock( + jsonResponse({ + ...metadata, + token_endpoint: "https://api.workos.com/user_management/authenticate", + }), + ), + ); + + const failure = expectFailure(result); + expect(failure.retryable).toBe(false); + expect(failure.error).toContain("origin"); + }); + + it("refuses to follow a redirect", async () => { + const result = await discoverIssuer( + issuer, + createFetchMock( + new Response(undefined, { + status: 302, + headers: { location: "https://elsewhere.example/metadata" }, + }), + ), + ); + + expect(expectFailure(result).retryable).toBe(false); + }); + + it("reports an issuer that serves no metadata as a configuration fault", async () => { + const result = await discoverIssuer( + issuer, + createFetchMock(jsonResponse({ error: "not found" }, { status: 404 })), + ); + + expect(expectFailure(result).retryable).toBe(false); + }); + + it.each([ + ["a failing server", 503], + ["rate limiting", 429], + ])("marks %s as retryable", async (_label, status) => { + const result = await discoverIssuer( + issuer, + createFetchMock(jsonResponse({ error: "boom" }, { status })), + ); + + expect(expectFailure(result).retryable).toBe(true); + }); + + it("marks an unreachable issuer as retryable", async () => { + const mockFetch = mock().mockRejectedValue( + Error("connect ECONNREFUSED"), + ) as unknown as typeof fetch; + + const result = await discoverIssuer(issuer, mockFetch); + + const failure = expectFailure(result); + expect(failure.retryable).toBe(true); + expect(failure.error).toContain("ECONNREFUSED"); + }); + + it("reports a body that is not JSON as retryable, as a captive portal would cause", async () => { + const result = await discoverIssuer( + issuer, + createFetchMock( + new Response("hi", { + status: 200, + headers: { "content-type": "text/html" }, + }), + ), + ); + + expect(expectFailure(result).retryable).toBe(true); + }); +}); diff --git a/src/shell/workos/discoverIssuer.ts b/src/shell/workos/discoverIssuer.ts new file mode 100644 index 000000000..a4a1c45e5 --- /dev/null +++ b/src/shell/workos/discoverIssuer.ts @@ -0,0 +1,108 @@ +import { sameIssuer } from "~/core/deviceAuth/resource.js"; +import { errorMessage } from "~/core/errors.js"; +import { authErrorMessages } from "~/core/messages/authErrors.js"; +import { isTransientStatus, unexpectedResponse } from "./send.js"; +import { + type AuthorizationResult, + authorizationServerMetadata, + type IssuerEndpoints, +} from "./types.js"; + +const timeoutMs = 10_000; +const messages = authErrorMessages.workos.metadata; + +function metadataUrl(issuer: string): string { + return `${issuer.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`; +} + +function failure( + error: string, + retryable: boolean, +): AuthorizationResult { + return { ok: false, error, retryable }; +} + +/** + * The endpoints will receive a device code and, later, every refresh token. + * A document that pointed them at another origin — a legacy WorkOS host, or + * anything else — would hand those over, so the origin has to be the issuer's. + */ +function onIssuerOrigin(endpoint: string, issuer: string): boolean { + try { + return new URL(endpoint).origin === new URL(issuer).origin; + } catch { + return false; + } +} + +/** + * RFC 8414 discovery of the two grant endpoints, checked against the issuer + * the deployment named. Read without credentials and without following + * redirects: nothing about a redirect target has been vetted. + */ +export async function discoverIssuer( + issuer: string, + fetchFn: typeof globalThis.fetch, +): Promise> { + let response: Response; + try { + response = await fetchFn(metadataUrl(issuer), { + redirect: "manual", + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (err: unknown) { + return failure( + authErrorMessages.workos.unreachable(errorMessage(err)), + true, + ); + } + + if (response.status >= 300 && response.status < 400) { + return failure(authErrorMessages.workos.redirected, false); + } + if (isTransientStatus(response.status)) { + return failure(messages.unavailable(response.status), true); + } + if (!response.ok) + return failure(messages.unavailable(response.status), false); + + let json: unknown; + try { + json = await response.json(); + } catch { + // A 200 that is not JSON is a captive portal or a proxy answering in the + // issuer's place, which the next attempt may get past. + return failure(unexpectedResponse, true); + } + + const parsed = authorizationServerMetadata.safeParse(json); + if (!parsed.success) return failure(unexpectedResponse, false); + + const metadata = parsed.data; + if (!sameIssuer(metadata.issuer, issuer)) { + return failure(messages.issuerMismatch(issuer, metadata.issuer), false); + } + + const endpoints = { + deviceAuthorization: metadata.device_authorization_endpoint, + token: metadata.token_endpoint, + }; + for (const [name, endpoint] of Object.entries(endpoints)) { + if (!endpoint) return failure(messages.missingEndpoint(name), false); + if (!onIssuerOrigin(endpoint, issuer)) { + return failure(messages.foreignEndpoint(name), false); + } + } + if (!endpoints.deviceAuthorization || !endpoints.token) { + // Unreachable after the loop above; narrows the type without a cast. + return failure(unexpectedResponse, false); + } + + return { + ok: true, + value: { + deviceAuthorization: endpoints.deviceAuthorization, + token: endpoints.token, + }, + }; +} diff --git a/src/shell/workos/pollDeviceToken.faults.test.ts b/src/shell/workos/pollDeviceToken.faults.test.ts new file mode 100644 index 000000000..abd4dc4fd --- /dev/null +++ b/src/shell/workos/pollDeviceToken.faults.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, mock } from "bun:test"; + +import { pollDeviceToken } from "./pollDeviceToken.js"; +import { createFetchMock, jsonResponse, testDeps } from "./workos.testUtils.js"; + +function textResponse(body: string, status: number): Response { + return new Response(body, { + status, + headers: { "content-type": "text/html" }, + }); +} + +describe("pollDeviceToken under transport faults", () => { + 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", { + ...testDeps, + 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", { + ...testDeps, + fetch: createFetchMock(jsonResponse({ nonsense: true })), + }); + + if (result.kind !== "error") throw Error("expected an error response"); + expect(result.detail).toContain("unexpected response"); + }); + + 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", { + ...testDeps, + 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", { + ...testDeps, + fetch: createFetchMock(jsonResponse({ nope: true }, { status: 400 })), + }); + + if (result.kind !== "error") throw Error("expected an error response"); + expect(result.detail).toContain("HTTP 400"); + }); + + // The device code is a credential. A redirect that forwarded it would hand + // it to whichever host the response named. + it("refuses a redirect rather than following it", async () => { + const result = await pollDeviceToken("device_abc", { + ...testDeps, + fetch: createFetchMock( + new Response(undefined, { + status: 302, + headers: { location: "https://elsewhere.example/token" }, + }), + ), + }); + + if (result.kind !== "error") throw Error("expected an error response"); + expect(result.detail).toContain("redirect"); + }); +}); diff --git a/src/shell/workos/pollDeviceToken.test.ts b/src/shell/workos/pollDeviceToken.test.ts new file mode 100644 index 000000000..96540f6d5 --- /dev/null +++ b/src/shell/workos/pollDeviceToken.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "bun:test"; + +import { pollDeviceToken } from "./pollDeviceToken.js"; +import { + boundAccessToken, + createFetchMock, + jsonResponse, + makeJwt, + testDeps, +} from "./workos.testUtils.js"; + +/** + * Connect-shaped: a plain OAuth token response. No `user`, no top-level + * `organization_id` — those were the legacy WorkOS User Management shape. + */ +const success = { + access_token: boundAccessToken, + refresh_token: "refresh_abc", + token_type: "Bearer", + expires_in: 3600, + scope: "openid profile email offline_access", +}; + +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 fields to the discovered token endpoint", async () => { + const mockFetch = createFetchMock(jsonResponse(success)); + + await pollDeviceToken("device_abc", { ...testDeps, fetch: mockFetch }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://signin.example/oauth2/token", + expect.objectContaining({ + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: "client_123", + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: "device_abc", + resource: "https://app.example/api", + }).toString(), + }), + ); + }); + + it("returns tokens with the expiry and organization read from the access token", async () => { + const result = await pollDeviceToken("device_abc", { + ...testDeps, + fetch: createFetchMock(jsonResponse(success)), + }); + + expect(result).toEqual({ + kind: "tokens", + tokens: { + accessToken: boundAccessToken, + refreshToken: "refresh_abc", + expiresAt: 1_700_000_000_000, + organizationId: "org_1", + }, + }); + }); + + it("keeps the organization undefined when the token names none", async () => { + const result = await pollDeviceToken("device_abc", { + ...testDeps, + fetch: createFetchMock( + jsonResponse({ ...success, access_token: makeJwt({ exp: 1 }) }), + ), + }); + + if (result.kind !== "tokens") throw Error("expected tokens"); + expect(result.tokens.organizationId).toBeUndefined(); + }); + + // Without a refresh token there is no way to obtain the resource-bound + // token the API accepts, so the grant is unusable however it looks. + it("refuses a token response that carries no refresh token", async () => { + const { refresh_token: _refresh, ...withoutRefresh } = success; + const result = await pollDeviceToken("device_abc", { + ...testDeps, + fetch: createFetchMock(jsonResponse(withoutRefresh)), + }); + + if (result.kind !== "error") throw Error("expected an error response"); + expect(result.detail).toContain("refresh token"); + }); + + it("reads WorkOS authentication errors, which carry a code rather than an error", async () => { + const result = await pollDeviceToken("device_abc", { + ...testDeps, + fetch: createFetchMock( + jsonResponse( + { + code: "organization_selection_required", + message: "Choose an organization to continue.", + }, + { 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", { + ...testDeps, + fetch: createFetchMock(errorResponse("authorization_pending")), + }); + + expect(result).toEqual({ kind: "pending" }); + }); + + it("reports slow_down as slow-down", async () => { + const result = await pollDeviceToken("device_abc", { + ...testDeps, + fetch: createFetchMock(errorResponse("slow_down")), + }); + + expect(result).toEqual({ kind: "slow-down" }); + }); + + it("reports access_denied as denied", async () => { + const result = await pollDeviceToken("device_abc", { + ...testDeps, + fetch: createFetchMock(errorResponse("access_denied")), + }); + + expect(result).toEqual({ kind: "denied" }); + }); + + it("reports expired_token as expired", async () => { + const result = await pollDeviceToken("device_abc", { + ...testDeps, + 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", { + ...testDeps, + 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", { + ...testDeps, + fetch: createFetchMock(errorResponse("invalid_client", "unknown client")), + }); + + expect(result).toEqual({ kind: "error", detail: "unknown client" }); + }); +}); diff --git a/src/shell/workos/pollDeviceToken.ts b/src/shell/workos/pollDeviceToken.ts new file mode 100644 index 000000000..5953c14a2 --- /dev/null +++ b/src/shell/workos/pollDeviceToken.ts @@ -0,0 +1,73 @@ +import type { PollResponse } from "~/core/deviceAuth/types.js"; +import { readConnectTokens } from "./connectTokens.js"; +import { sendWorkosRequest } from "./send.js"; +import 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. + * + * The token this yields is not yet one the API accepts: WorkOS answered the + * device grant with the environment client id as the audience whatever + * `resource` asked for. The refresh that follows is what binds it. + */ +export async function pollDeviceToken( + deviceCode: string, + deps: WorkosDeps, +): Promise { + const outcome = await sendWorkosRequest( + deps.endpoints.token, + { + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: deps.clientId, + grant_type: deviceCodeGrantType, + device_code: deviceCode, + resource: deps.resource, + }).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 tokens = readConnectTokens(outcome.json); + if (!tokens.ok) return { kind: "error", detail: tokens.error }; + + return { kind: "tokens", tokens: tokens.tokens }; +} diff --git a/src/shell/workos/refreshAccessToken.test.ts b/src/shell/workos/refreshAccessToken.test.ts new file mode 100644 index 000000000..94c0b4eb2 --- /dev/null +++ b/src/shell/workos/refreshAccessToken.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, mock } from "bun:test"; + +import { refreshAccessToken } from "./refreshAccessToken.js"; +import { + boundAccessToken, + createFetchMock, + jsonResponse, + testDeps, +} from "./workos.testUtils.js"; + +const success = { + access_token: boundAccessToken, + refresh_token: "refresh_2", + token_type: "Bearer", + expires_in: 3600, +}; + +describe("refreshAccessToken", () => { + // `resource` on every refresh: omitting it was observed to hand back a + // token whose audience is the environment client id, which the API refuses. + it("posts the refresh grant with the resource, as form fields", async () => { + const mockFetch = createFetchMock(jsonResponse(success)); + + await refreshAccessToken("refresh_1", { ...testDeps, fetch: mockFetch }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://signin.example/oauth2/token", + expect.objectContaining({ + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: "client_123", + grant_type: "refresh_token", + refresh_token: "refresh_1", + resource: "https://app.example/api", + }).toString(), + }), + ); + }); + + it("returns the rotated refresh token, not the one it was given", async () => { + const result = await refreshAccessToken("refresh_1", { + ...testDeps, + fetch: createFetchMock(jsonResponse(success)), + }); + + expect(result).toEqual({ + ok: true, + value: { + accessToken: boundAccessToken, + refreshToken: "refresh_2", + expiresAt: 1_700_000_000_000, + organizationId: "org_1", + }, + }); + }); + + it("refuses a response that rotates away the refresh token without a replacement", async () => { + const { refresh_token: _refresh, ...withoutRefresh } = success; + const result = await refreshAccessToken("refresh_1", { + ...testDeps, + fetch: createFetchMock(jsonResponse(withoutRefresh)), + }); + + if (result.ok) throw Error("expected failure, got success"); + expect(result.error).toContain("refresh token"); + expect(result.retryable).toBe(false); + }); + + it("fails when the refresh token has been revoked", async () => { + const result = await refreshAccessToken("refresh_1", { + ...testDeps, + fetch: createFetchMock( + jsonResponse( + { error: "invalid_grant", error_description: "token revoked" }, + { status: 400 }, + ), + ), + }); + + expect(result).toEqual({ + ok: false, + error: "token revoked", + retryable: false, + }); + }); + + // An unregistered resource is a deployment-configuration fault: no retry and + // no sign-in changes it, and a fallback to the environment audience would + // only produce a token the API refuses. + it("names the resource when WorkOS rejects it as an invalid target", async () => { + const result = await refreshAccessToken("refresh_1", { + ...testDeps, + fetch: createFetchMock( + jsonResponse( + { + error: "invalid_target", + error_description: "The requested resource is invalid", + }, + { status: 400 }, + ), + ), + }); + + if (result.ok) throw Error("expected failure, got success"); + expect(result.retryable).toBe(false); + expect(result.error).toContain("https://app.example/api"); + expect(result.error).toContain("registered"); + }); + + 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", { + ...testDeps, + 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", { + ...testDeps, + 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", { + ...testDeps, + fetch: createFetchMock( + jsonResponse({ error: "invalid_grant" }, { status: 400 }), + ), + }); + + if (result.ok) throw Error("expected failure, got success"); + expect(result.retryable).toBe(false); + }); + + it("refuses a redirect, which would forward the refresh token elsewhere", async () => { + const result = await refreshAccessToken("refresh_1", { + ...testDeps, + fetch: createFetchMock( + new Response(undefined, { + status: 308, + headers: { location: "https://elsewhere.example/token" }, + }), + ), + }); + + if (result.ok) throw Error("expected failure, got success"); + expect(result.retryable).toBe(false); + expect(result.error).toContain("redirect"); + }); +}); diff --git a/src/shell/workos/refreshAccessToken.ts b/src/shell/workos/refreshAccessToken.ts new file mode 100644 index 000000000..7cfbf7cfa --- /dev/null +++ b/src/shell/workos/refreshAccessToken.ts @@ -0,0 +1,64 @@ +import type { DeviceTokens } from "~/core/deviceAuth/types.js"; +import { authErrorMessages } from "~/core/messages/authErrors.js"; +import { readConnectTokens } from "./connectTokens.js"; +import { sendWorkosRequest } from "./send.js"; +import type { AuthorizationResult, WorkosDeps } from "./types.js"; + +/** + * Trades a refresh token for a fresh pair bound to the API resource. + * + * 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. + * + * `resource` goes on every refresh. Omitting it was observed to hand back + * a token whose audience is the environment client id, which the API refuses, + * so a refresh without it would quietly end the session on the next request. + */ +export async function refreshAccessToken( + refreshToken: string, + deps: WorkosDeps, +): Promise> { + const outcome = await sendWorkosRequest( + deps.endpoints.token, + { + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: deps.clientId, + grant_type: "refresh_token", + refresh_token: refreshToken, + resource: deps.resource, + }).toString(), + }, + deps.fetch, + ); + + if (outcome.kind === "failure") { + return { ok: false, error: outcome.detail, retryable: outcome.retryable }; + } + + if (outcome.kind === "oauth-error") { + // An unregistered resource is a deployment-configuration fault, not a + // spent session: neither retrying nor signing in again changes it, and + // falling back to a token without the resource would only produce one the + // API refuses. Named as such so nobody is sent round that loop. + if (outcome.code === "invalid_target") { + return { + ok: false, + error: authErrorMessages.workos.resourceNotRegistered(deps.resource), + retryable: false, + }; + } + // A protocol answer WorkOS meant. Repeating it changes nothing. + return { + ok: false, + error: outcome.description ?? outcome.code, + retryable: false, + }; + } + + const tokens = readConnectTokens(outcome.json); + if (!tokens.ok) return { ok: false, error: tokens.error, retryable: false }; + + return { ok: true, value: tokens.tokens }; +} diff --git a/src/shell/workos/requestDeviceAuthorization.test.ts b/src/shell/workos/requestDeviceAuthorization.test.ts new file mode 100644 index 000000000..02849b825 --- /dev/null +++ b/src/shell/workos/requestDeviceAuthorization.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, mock } from "bun:test"; + +import { requestDeviceAuthorization } from "./requestDeviceAuthorization.js"; +import { createFetchMock, jsonResponse, testDeps } from "./workos.testUtils.js"; + +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, +}; + +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 grant request as form fields to the discovered endpoint", async () => { + const mockFetch = createFetchMock(jsonResponse(authorization)); + + await requestDeviceAuthorization({ ...testDeps, fetch: mockFetch }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://signin.example/oauth2/device_authorization", + expect.objectContaining({ + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: "client_123", + // offline_access is what earns a refresh token, and the refresh is + // the only exchange that yields a token the API accepts. + scope: "openid profile email offline_access", + resource: "https://app.example/api", + }).toString(), + }), + ); + }); + + it("returns the authorization in the shape the poller expects", async () => { + const result = await requestDeviceAuthorization({ + ...testDeps, + 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({ + ...testDeps, + 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({ + ...testDeps, + 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({ + ...testDeps, + 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({ + ...testDeps, + 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({ + ...testDeps, + 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({ + ...testDeps, + fetch: mockFetch, + }); + + expect(expectError(result)).toContain("connect ECONNREFUSED"); + }); + + // The endpoint receives a public client id and nothing else worth stealing, + // but the token endpoint it pairs with does. Same transport, same rule. + it("does not follow a redirect", async () => { + const result = await requestDeviceAuthorization({ + ...testDeps, + fetch: createFetchMock( + new Response(undefined, { + status: 307, + headers: { location: "https://elsewhere.example" }, + }), + ), + }); + + const error = expectError(result); + expect(error).toContain("redirect"); + if (result.ok) return; + expect(result.retryable).toBe(false); + }); +}); diff --git a/src/shell/workos/requestDeviceAuthorization.ts b/src/shell/workos/requestDeviceAuthorization.ts new file mode 100644 index 000000000..351b2b3c5 --- /dev/null +++ b/src/shell/workos/requestDeviceAuthorization.ts @@ -0,0 +1,61 @@ +import type { DeviceAuthorization } from "~/core/deviceAuth/types.js"; +import { sendWorkosRequest, unexpectedResponse } from "./send.js"; +import { + type AuthorizationResult, + defaultIntervalSec, + deviceAuthorizationBody, + deviceScope, + type WorkosDeps, +} from "./types.js"; + +/** + * Starts a device flow at the endpoint the issuer advertised. The resource is + * asked for here as well as on the grants: RFC 8707 puts it on every request, + * even though WorkOS was observed to honour it only on the refresh. + */ +export async function requestDeviceAuthorization( + deps: WorkosDeps, +): Promise> { + const outcome = await sendWorkosRequest( + deps.endpoints.deviceAuthorization, + { + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: deps.clientId, + scope: deviceScope, + resource: deps.resource, + }).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 = 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.ts b/src/shell/workos/send.ts new file mode 100644 index 000000000..33c5e44f7 --- /dev/null +++ b/src/shell/workos/send.ts @@ -0,0 +1,129 @@ +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. + */ +export function isTransientStatus(status: number): boolean { + return status >= 500 || status === 429 || status === 408; +} + +function isRedirect(status: number): boolean { + return status >= 300 && status < 400; +} + +/** + * One token-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 (isRedirect(response.status)) { + 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..6f46eb3f3 --- /dev/null +++ b/src/shell/workos/types.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; + +/** Interval the device grant assumes when the server states none. */ +export const defaultIntervalSec = 5; + +/** + * `offline_access` is what earns a refresh token, and the refresh is the only + * exchange observed to yield a token bound to the API resource. + */ +export const deviceScope = "openid profile email offline_access"; + +/** Where the issuer's metadata says the two grants live. */ +export type IssuerEndpoints = { + deviceAuthorization: string; + token: string; +}; + +/** + * Everything a Connect request needs. The CLI is a public client: there is no + * secret here, and none is ever sent. + */ +export type WorkosDeps = { + fetch: typeof globalThis.fetch; + clientId: string; + /** The API resource every grant asks to be bound to. */ + resource: string; + endpoints: IssuerEndpoints; +}; + +export const authorizationServerMetadata = z.object({ + issuer: z.string().min(1), + device_authorization_endpoint: z.string().min(1).optional(), + token_endpoint: z.string().min(1).optional(), +}); + +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(), +}); + +/** + * A plain OAuth token response. The refresh token is optional on the wire and + * required by the CLI, which reports its absence as its own failure rather + * than as an unrecognised body. + */ +export const connectTokenBody = z.object({ + access_token: z.string().min(1), + refresh_token: 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 }; diff --git a/src/shell/workos/wireFormat.test.ts b/src/shell/workos/wireFormat.test.ts new file mode 100644 index 000000000..cc00b589b --- /dev/null +++ b/src/shell/workos/wireFormat.test.ts @@ -0,0 +1,237 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; + +import { deviceLogin } from "~/domains/auth/deviceLogin.js"; +import { discoverIssuer } from "./discoverIssuer.js"; +import { pollDeviceToken } from "./pollDeviceToken.js"; +import { refreshAccessToken } from "./refreshAccessToken.js"; +import { requestDeviceAuthorization } from "./requestDeviceAuthorization.js"; +import { makeJwt } from "./workos.testUtils.js"; + +/** + * Drives the client against a real HTTP server rather than a stubbed `fetch`. + * + * The unit tests assert what the client *sends*; these assert that a server + * parsing those bytes the ordinary way gets the values back out, and that the + * sequence as a whole reproduces what WorkOS was observed to do: the device grant answers + * with a token for the environment client id, and only the refresh that + * follows answers with one for the API resource. + */ + +const clientId = "client_01CONNECT"; +const environmentClientId = "client_01ENV"; +let issuer = ""; +let resource = ""; + +/** Bodies as the server parsed them, so the test asserts on decoded values. */ +const received: { + metadata: number; + authorize: Record[]; + deviceGrant: Record[]; + refreshGrant: Record[]; + identityBearer: string[]; +} = { + metadata: 0, + authorize: [], + deviceGrant: [], + refreshGrant: [], + identityBearer: [], +}; + +function environmentToken(): string { + return makeJwt({ iss: issuer, aud: environmentClientId, exp: 1_700_000_000 }); +} + +function resourceToken(): string { + return makeJwt({ + iss: issuer, + aud: resource, + exp: 1_700_000_100, + org_id: "org_1", + }); +} + +let server: ReturnType; + +beforeAll(() => { + server = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url); + + if (url.pathname === "/.well-known/oauth-authorization-server") { + received.metadata += 1; + return Response.json({ + issuer, + device_authorization_endpoint: `${issuer}/oauth2/device_authorization`, + token_endpoint: `${issuer}/oauth2/token`, + }); + } + + if (url.pathname === "/oauth2/device_authorization") { + const fields = Object.fromEntries( + new URLSearchParams(await request.text()).entries(), + ); + received.authorize.push(fields); + return Response.json({ + device_code: "device_abc", + user_code: "WDJB-MJHT", + verification_uri: `${issuer}/device`, + verification_uri_complete: `${issuer}/device?u=WDJB-MJHT`, + expires_in: 300, + interval: 1, + }); + } + + if (url.pathname === "/oauth2/token") { + const fields = Object.fromEntries( + new URLSearchParams(await request.text()).entries(), + ); + + if (fields["grant_type"] === "refresh_token") { + received.refreshGrant.push(fields); + if (fields["refresh_token"] !== "refresh_from_device") { + return Response.json({ error: "invalid_grant" }, { status: 400 }); + } + return Response.json({ + access_token: resourceToken(), + refresh_token: "refresh_rotated", + token_type: "Bearer", + expires_in: 3600, + }); + } + + received.deviceGrant.push(fields); + // Stay pending once so the polling loop is exercised for real. + if (received.deviceGrant.length === 1) { + return Response.json( + { error: "authorization_pending" }, + { status: 400 }, + ); + } + // As observed live: the device grant ignores `resource`. + return Response.json({ + access_token: environmentToken(), + refresh_token: "refresh_from_device", + token_type: "Bearer", + expires_in: 3600, + }); + } + + if (url.pathname === "/api/v0/identity") { + received.identityBearer.push( + request.headers.get("authorization") ?? "", + ); + return Response.json({ + user: { id: "user_1", email: "person@example.com" }, + organization: { id: "org_platform_1", name: "Acme" }, + }); + } + + return new Response("not found", { status: 404 }); + }, + }); + issuer = `http://localhost:${server.port}`; + resource = `${issuer}/api`; +}); + +afterAll(async () => { + await server.stop(true); +}); + +describe("WorkOS Connect wire format", () => { + it("completes device login through the resource-bound refresh", async () => { + const discovered = await discoverIssuer(issuer, globalThis.fetch); + if (!discovered.ok) throw Error(discovered.error); + + const deps = { + fetch: globalThis.fetch, + clientId, + resource, + endpoints: discovered.value, + }; + const slept: number[] = []; + + const result = await deviceLogin({ + requestAuthorization: () => requestDeviceAuthorization(deps), + pollToken: (deviceCode) => pollDeviceToken(deviceCode, deps), + refreshTokens: (refreshToken) => refreshAccessToken(refreshToken, deps), + binding: { issuer, resource }, + fetchEmail: async (accessToken) => { + const response = await fetch(`${issuer}/api/v0/identity`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const body = (await response.json()) as { user: { email: string } }; + return { ok: true, email: body.user.email }; + }, + onPrompt: () => {}, + sleep: async (ms) => { + slept.push(ms); + }, + now: () => Date.now(), + isCancelled: () => false, + }); + + // The session is the second pair, never the first. + expect(result).toEqual({ + ok: true, + session: { + accessToken: resourceToken(), + refreshToken: "refresh_rotated", + expiresAt: 1_700_000_100_000, + organizationId: "org_1", + email: "person@example.com", + }, + }); + + expect(received.metadata).toBe(1); + + expect(received.authorize).toEqual([ + { + client_id: clientId, + scope: "openid profile email offline_access", + resource, + }, + ]); + + expect(received.deviceGrant).toEqual([ + { + client_id: clientId, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: "device_abc", + resource, + }, + { + client_id: clientId, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: "device_abc", + resource, + }, + ]); + + expect(received.refreshGrant).toEqual([ + { + client_id: clientId, + grant_type: "refresh_token", + refresh_token: "refresh_from_device", + resource, + }, + ]); + + // Identity was asked exactly once, and only with the resource-bound token. + expect(received.identityBearer).toEqual([`Bearer ${resourceToken()}`]); + + // A public client: nothing that looks like a secret went over the wire. + for (const fields of [ + ...received.authorize, + ...received.deviceGrant, + ...received.refreshGrant, + ]) { + expect(Object.keys(fields)).not.toContain("client_secret"); + expect(fields["client_id"]).toBe(clientId); + expect(fields["resource"]).toBe(resource); + } + + // It waited the interval the server advertised, not a hardcoded one. + expect(slept).toEqual([1_000]); + }); +}); diff --git a/src/shell/workos/workos.testUtils.ts b/src/shell/workos/workos.testUtils.ts new file mode 100644 index 000000000..bd306aa2d --- /dev/null +++ b/src/shell/workos/workos.testUtils.ts @@ -0,0 +1,43 @@ +import { mock } from "bun:test"; + +import type { WorkosDeps } from "./types.js"; + +export function createFetchMock(resolvedValue: Response) { + return mock().mockResolvedValue( + resolvedValue, + ) as unknown as typeof fetch; +} + +export function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +export function makeJwt(payload: unknown): string { + const encode = (value: unknown) => + Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); + return [encode({ alg: "RS256" }), encode(payload), "sig"].join("."); +} + +export const testIssuer = "https://signin.example"; +export const testResource = "https://app.example/api"; + +/** A token the API would accept: bound to the issuer and the API resource. */ +export const boundAccessToken = makeJwt({ + iss: testIssuer, + aud: testResource, + exp: 1_700_000_000, + org_id: "org_1", +}); + +export const testDeps: Omit = { + clientId: "client_123", + resource: testResource, + endpoints: { + deviceAuthorization: "https://signin.example/oauth2/device_authorization", + token: "https://signin.example/oauth2/token", + }, +};