From 1437475a395bdba295d209cc20265cc530b3f5b1 Mon Sep 17 00:00:00 2001 From: lihbr Date: Wed, 11 Nov 2026 15:10:31 +0900 Subject: [PATCH 01/11] chore: start the device flow integration branch Every device flow step merges here. This branch merges into main at release time, with a merge commit. Co-Authored-By: Claude Opus 5.5 From 1ff8d80a2cec9ab7710ba16dd74762611747dcfc Mon Sep 17 00:00:00 2001 From: Lucie <25330882+lihbr@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:36:24 +0900 Subject: [PATCH 02/11] chore(auth): add device flow login modules, not used yet (#467) Adds the building blocks for device flow login. Nothing calls them yet: src/lib/auth0.ts (the device code and token requests, polling, DeviceFlowError), the host to Auth0 tenant table in src/auth.ts, and a fake Auth0 server with temporary tests. Co-Authored-By: Claude Opus 5.5 --- src/auth.ts | 17 +++ src/lib/auth0.ts | 246 +++++++++++++++++++++++++++++++++++++++ test/auth0.ts | 191 ++++++++++++++++++++++++++++++ test/device-flow.test.ts | 229 ++++++++++++++++++++++++++++++++++++ 4 files changed, 683 insertions(+) create mode 100644 src/lib/auth0.ts create mode 100644 test/auth0.ts create mode 100644 test/device-flow.test.ts diff --git a/src/auth.ts b/src/auth.ts index e2d08307..8667365e 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -8,6 +8,7 @@ import * as z from "zod/mini"; import { CREDENTIALS_PATH } from "./config"; import { DEFAULT_PRISMIC_HOST, env } from "./env"; +import type { Auth0Config } from "./lib/auth0"; import { exists, readJsonFile, writeFileRecursive } from "./lib/file"; import { stringify } from "./lib/json"; import { refreshToken as baseRefreshToken, validateToken } from "./lib/prismic/client"; @@ -33,6 +34,22 @@ export async function getHost(): Promise { return env.PRISMIC_HOST || (await readCredentials())?.host || DEFAULT_PRISMIC_HOST; } +// Native client IDs are public. +export function getAuth0Config(host: string): Auth0Config | undefined { + if (host === "prismic.io") { + return { auth0Domain: "auth0.prismic.io", clientId: "V1yuPKJOHhuHNMR4Oeo2fj5uTCfVebny" }; + } + if (host === "wroom.io" || host === "devops-wroom.com") { + return { auth0Domain: "auth0.wroom.io", clientId: "UKxsYw8EcAspkUPRvoFkrO1aj9cA3zkd" }; + } + if (/^[a-z0-9-]+-wroom\.com$/.test(host)) { + return { + auth0Domain: "auth0.platform-wroom.com", + clientId: "Opbx7JjftGb5sgjIWKVx0XDBSrW37say", + }; + } +} + export async function refreshToken(): Promise { if (env.PRISMIC_TOKEN) return; const token = await getToken(); diff --git a/src/lib/auth0.ts b/src/lib/auth0.ts new file mode 100644 index 00000000..76fcc770 --- /dev/null +++ b/src/lib/auth0.ts @@ -0,0 +1,246 @@ +import { setTimeout as sleep } from "node:timers/promises"; + +import * as z from "zod/mini"; + +import { request, RequestError } from "./request"; + +const REQUEST_TIMEOUT_MS = 10_000; +const DEFAULT_INTERVAL_MS = 5_000; +const SLOW_DOWN_MS = 5_000; +const MAX_BACKOFF_MS = 60_000; + +export type Auth0Config = { auth0Domain: string; clientId: string }; + +export const PendingLoginSchema = z.object({ + auth0Domain: z.string(), + clientId: z.string(), + deviceCode: z.string(), + userCode: z.string(), + verificationUri: z.string(), + verificationUriComplete: z.optional(z.string()), + expiresAt: z.number(), + intervalMs: z.number(), +}); +export type PendingLogin = z.infer; + +type DeviceLoginTokens = { + accessToken: string; + refreshToken: string; + expiresAt: number; +}; + +const DeviceCodeSchema = z.object({ + device_code: z.string(), + user_code: z.string(), + verification_uri: z.string(), + verification_uri_complete: z.optional(z.string()), + expires_in: z.number(), + interval: z.optional(z.number()), +}); + +export async function startDeviceLogin( + params: { audience: string; scope: string }, + config: Auth0Config, +): Promise { + const url = new URL("oauth/device/code", getAuth0Url(config.auth0Domain)); + const response = await request(url, { + method: "POST", + body: new URLSearchParams({ client_id: config.clientId, ...params }), + signal: getRequestSignal(), + schema: DeviceCodeSchema, + }).catch((error) => { + const reason = getFailureReason(error) ?? (isTransient(error) ? "unavailable" : undefined); + throw reason ? new DeviceFlowError(reason, error) : error; + }); + return { + auth0Domain: config.auth0Domain, + clientId: config.clientId, + deviceCode: response.device_code, + userCode: response.user_code, + verificationUri: response.verification_uri, + verificationUriComplete: response.verification_uri_complete, + expiresAt: Date.now() + response.expires_in * 1000, + intervalMs: response.interval !== undefined ? response.interval * 1000 : DEFAULT_INTERVAL_MS, + }; +} + +export function getDeviceLoginPrompt(pending: PendingLogin): { + url: string; + userCode: string; + codeInUrl: boolean; +} { + return { + url: pending.verificationUriComplete ?? pending.verificationUri, + userCode: pending.userCode, + codeInUrl: pending.verificationUriComplete !== undefined, + }; +} + +const DeviceTokenSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), + expires_in: z.number(), +}); + +// Polls before waiting, so a resumed login that was already approved +// completes at once. +export async function pollDeviceLogin( + pending: PendingLogin, + options: { + signal?: AbortSignal; + onUpdate?: (pending: PendingLogin) => void | Promise; + } = {}, +): Promise { + const { signal, onUpdate } = options; + let current = pending; + let delayMs = current.intervalMs; + + while (true) { + if (Date.now() >= current.expiresAt) throw new DeviceFlowError("expired"); + + try { + const url = new URL("oauth/token", getAuth0Url(current.auth0Domain)); + const tokens = await request(url, { + method: "POST", + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: current.deviceCode, + client_id: current.clientId, + }), + signal: getRequestSignal(signal), + schema: DeviceTokenSchema, + }); + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: Date.now() + tokens.expires_in * 1000, + }; + } catch (error) { + signal?.throwIfAborted(); + const retryAfterMs = getRetryAfterMs(error); + const code = getOAuthError(error)?.error; + if (code === "authorization_pending") { + delayMs = Math.max(current.intervalMs, retryAfterMs); + } else if (code === "slow_down") { + current = { ...current, intervalMs: current.intervalMs + SLOW_DOWN_MS }; + await onUpdate?.(current); + delayMs = Math.max(current.intervalMs, retryAfterMs); + } else { + const reason = getFailureReason(error); + if (reason) throw new DeviceFlowError(reason, error); + if (!isTransient(error)) throw error; + const backoffMs = Math.min(MAX_BACKOFF_MS, Math.max(delayMs, 1000) * 2); + delayMs = Math.max(current.intervalMs, backoffMs, retryAfterMs); + } + } + + const waitMs = Math.min(delayMs, current.expiresAt - Date.now()); + if (waitMs > 0) { + await sleep(waitMs, undefined, { signal }).catch(() => signal?.throwIfAborted()); + } + } +} + +function getRequestSignal(signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + return signal ? AbortSignal.any([timeout, signal]) : timeout; +} + +// Tests use a local server over HTTP. +function getAuth0Url(auth0Domain: string): URL { + return new URL(/^https?:\/\//.test(auth0Domain) ? auth0Domain : `https://${auth0Domain}/`); +} + +function getFailureReason(error: unknown): DeviceFlowErrorReason | undefined { + if (error instanceof RequestError) { + const oauthError = getOAuthError(error); + switch (oauthError?.error) { + case "expired_token": + return "expired"; + case "access_denied": + return oauthError?.error_description === "email_verification_failed" + ? "email-unverified" + : "declined"; + case "invalid_grant": + return "invalid-grant"; + } + return isTransient(error) ? undefined : "misconfigured"; + } + if (error instanceof z.core.$ZodError) return "misconfigured"; +} + +function isTransient(error: unknown): boolean { + if (error instanceof RequestError) return error.status === 429 || error.status >= 500; + // fetch throws a TypeError on network errors. + return ( + error instanceof TypeError || (error instanceof DOMException && error.name === "TimeoutError") + ); +} + +const OAuthErrorSchema = z.object({ + error: z.string(), + error_description: z.optional(z.string()), +}); + +// Auth0 sends OAuth errors with several statuses, such as `slow_down` as a 429. +function getOAuthError(error: unknown) { + if (error instanceof RequestError) return z.safeParse(OAuthErrorSchema, error.body).data; +} + +// Retry-After is a number of seconds or an HTTP date. +function getRetryAfterMs(error: unknown): number { + const value = error instanceof RequestError ? error.response.headers.get("Retry-After") : null; + if (!value) return 0; + const seconds = Number(value); + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); + const date = Date.parse(value); + if (Number.isFinite(date)) return Math.max(0, date - Date.now()); + return 0; +} + +type DeviceFlowErrorReason = + | "expired" + | "declined" + | "email-unverified" + | "invalid-grant" + | "misconfigured" + | "unavailable"; + +// `error` isn't kept as `cause`, since Sentry reports causes and they can hold +// response details. `detail` is the Auth0 error code or HTTP status. +export class DeviceFlowError extends Error { + name = "DeviceFlowError"; + reason: DeviceFlowErrorReason; + detail: string | undefined; + + constructor(reason: DeviceFlowErrorReason, error?: unknown) { + const detail = error === undefined ? undefined : getDetail(error); + super(getMessage(reason, detail)); + this.reason = reason; + this.detail = detail; + } +} + +function getMessage(reason: DeviceFlowErrorReason, detail: string | undefined): string { + switch (reason) { + case "expired": + return "The device code expired."; + case "declined": + return "The login was declined."; + case "email-unverified": + return "The login was declined because the email address isn't verified."; + case "invalid-grant": + return "The device code is unknown or was already used."; + case "misconfigured": + return `Auth0 rejected the request (${detail}).`; + case "unavailable": + return `Auth0 can't be reached (${detail}).`; + } +} + +function getDetail(error: unknown): string { + if (error instanceof RequestError) return getOAuthError(error)?.error ?? `HTTP ${error.status}`; + if (error instanceof DOMException && error.name === "TimeoutError") return "timeout"; + if (error instanceof TypeError) return "network error"; + return "unexpected response"; +} diff --git a/test/auth0.ts b/test/auth0.ts new file mode 100644 index 00000000..fb9931d0 --- /dev/null +++ b/test/auth0.ts @@ -0,0 +1,191 @@ +import { randomBytes } from "node:crypto"; +import { createServer, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { setTimeout as sleep } from "node:timers/promises"; + +// A fake Auth0 tenant for login tests. A real device login needs a person to +// approve it in a browser, so automated tests can't use Auth0 itself. + +export type FakeAuth0Options = { + accessToken?: string; + expiresIn?: number; + interval?: number; +}; + +export type FakeAuth0Response = { + status: number; + body?: unknown; + headers?: Record; + delayMs?: number; +}; + +export type FakeAuth0 = { + auth0Domain: string; + clientId: string; + requests: { path: string; params: Record }[]; + approve: (options?: { userCode?: string }) => void; + deny: (options?: { userCode?: string; description?: string }) => void; + expire: (options?: { userCode?: string }) => void; + respondOnce: (path: string, response: FakeAuth0Response) => void; + close: () => Promise; +}; + +type DeviceCode = { + deviceCode: string; + userCode: string; + expiresAt: number; + status: "pending" | "approved" | "denied" | "redeemed"; + description?: string; +}; + +export async function createFakeAuth0(options: FakeAuth0Options = {}): Promise { + const { accessToken, expiresIn = 3600, interval = 1 } = options; + const clientId = `fake-${randomToken(8)}`; + const requests: FakeAuth0["requests"] = []; + const responses = new Map(); + const codes: DeviceCode[] = []; + const refreshTokens = new Set(); + + const server = createServer(async (req, res) => { + const path = new URL(req.url ?? "/", "http://localhost").pathname; + let body = ""; + for await (const chunk of req) body += chunk; + const params = Object.fromEntries(new URLSearchParams(body)); + requests.push({ path, params }); + + const response = responses.get(path)?.shift(); + if (response) { + if (response.delayMs) await sleep(response.delayMs); + send(res, response.status, response.body, response.headers); + return; + } + + if (params.client_id !== clientId) { + return sendError(res, 401, "invalid_client", "Unknown client."); + } + + switch (path) { + case "/oauth/device/code": { + const hex = randomBytes(4).toString("hex").toUpperCase(); + const code: DeviceCode = { + deviceCode: randomToken(32), + userCode: `${hex.slice(0, 4)}-${hex.slice(4)}`, + expiresAt: Date.now() + 900_000, + status: "pending", + }; + codes.push(code); + const verificationUri = new URL("activate", auth0Domain); + const verificationUriComplete = new URL(verificationUri); + verificationUriComplete.searchParams.set("user_code", code.userCode); + return send(res, 200, { + device_code: code.deviceCode, + user_code: code.userCode, + verification_uri: verificationUri.href, + verification_uri_complete: verificationUriComplete.href, + expires_in: 900, + interval, + }); + } + + case "/oauth/token": { + if (params.grant_type === "urn:ietf:params:oauth:grant-type:device_code") { + const code = codes.find((code) => code.deviceCode === params.device_code); + if (!code || code.status === "redeemed") { + return sendError(res, 403, "invalid_grant", "Invalid or expired device code."); + } + if (Date.now() >= code.expiresAt) { + return sendError(res, 403, "expired_token", "The device code has expired."); + } + if (code.status === "denied") { + return sendError(res, 403, "access_denied", code.description ?? "Access denied."); + } + if (code.status === "pending") { + return sendError(res, 403, "authorization_pending", "Waiting for approval."); + } + code.status = "redeemed"; + return sendTokens(res); + } + if (params.grant_type === "refresh_token") { + // Each refresh token works once. Auth0 accepts the previous one for 60 + // seconds after rotation, then revokes the whole session. + if (!refreshTokens.delete(params.refresh_token)) { + return sendError(res, 403, "invalid_grant", "Unknown or invalid refresh token."); + } + return sendTokens(res); + } + return sendError(res, 400, "unsupported_grant_type", "Unsupported grant type."); + } + + case "/oauth/revoke": { + refreshTokens.delete(params.token); + return send(res, 200); + } + } + + send(res, 404); + }); + + function sendTokens(res: ServerResponse): void { + const refreshToken = randomToken(32); + refreshTokens.add(refreshToken); + send(res, 200, { + access_token: accessToken ?? randomToken(32), + refresh_token: refreshToken, + expires_in: expiresIn, + token_type: "Bearer", + }); + } + + function findCode(userCode = codes.at(-1)?.userCode): DeviceCode { + const code = codes.find((code) => code.userCode === userCode); + if (!code) throw new Error(`No device code ${userCode ?? "issued yet"}`); + return code; + } + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + const auth0Domain = `http://127.0.0.1:${port}`; + + return { + auth0Domain, + clientId, + requests, + approve: ({ userCode } = {}) => { + findCode(userCode).status = "approved"; + }, + deny: ({ userCode, description } = {}) => { + const code = findCode(userCode); + code.status = "denied"; + code.description = description; + }, + expire: ({ userCode } = {}) => { + findCode(userCode).expiresAt = Date.now(); + }, + respondOnce: (path, response) => { + responses.set(path, [...(responses.get(path) ?? []), response]); + }, + close: async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +function send( + res: ServerResponse, + status: number, + body?: unknown, + headers: Record = {}, +): void { + if (res.destroyed) return; + res.writeHead(status, { "Content-Type": "application/json", ...headers }); + res.end(body === undefined ? undefined : JSON.stringify(body)); +} + +function sendError(res: ServerResponse, status: number, error: string, description: string): void { + send(res, status, { error, error_description: description }); +} + +function randomToken(bytes: number): string { + return randomBytes(bytes).toString("base64url"); +} diff --git a/test/device-flow.test.ts b/test/device-flow.test.ts new file mode 100644 index 00000000..ac186465 --- /dev/null +++ b/test/device-flow.test.ts @@ -0,0 +1,229 @@ +// Temporary. C4, the PR that switches `prismic login` to the device flow, +// deletes this file and tests login end to end instead. + +import { describe, type TestContext } from "vitest"; + +import { + DeviceFlowError, + getDeviceLoginPrompt, + pollDeviceLogin, + startDeviceLogin, +} from "../src/lib/auth0"; +import { createFakeAuth0, type FakeAuth0, type FakeAuth0Options } from "./auth0"; +import { it } from "./it"; + +const PARAMS = { audience: "prismic.io", scope: "openid profile email offline_access" }; + +describe("startDeviceLogin", () => { + it("requests a code for the given audience and scope", async ({ expect, onTestFinished }) => { + const { auth0, start } = await setup(onTestFinished, { interval: 7 }); + const startedAt = Date.now(); + const pending = await start(); + expect(auth0.requests[0].params).toEqual({ client_id: auth0.clientId, ...PARAMS }); + expect(pending).toMatchObject({ + auth0Domain: auth0.auth0Domain, + clientId: auth0.clientId, + verificationUri: `${auth0.auth0Domain}/activate`, + intervalMs: 7_000, + }); + expect(pending.expiresAt).toBeGreaterThanOrEqual(startedAt + 900_000); + expect(getDeviceLoginPrompt(pending)).toEqual({ + url: `${auth0.auth0Domain}/activate?user_code=${pending.userCode}`, + userCode: pending.userCode, + codeInUrl: true, + }); + }); + + it("handles a response without a complete URL or interval", async ({ + expect, + onTestFinished, + }) => { + const { auth0, start } = await setup(onTestFinished); + auth0.respondOnce("/oauth/device/code", { + status: 200, + body: { + device_code: "device-code", + user_code: "BCDF-GHJK", + verification_uri: "https://auth0.prismic.io/activate", + expires_in: 900, + }, + }); + const pending = await start(); + expect(pending.intervalMs).toBe(5_000); + expect(getDeviceLoginPrompt(pending)).toEqual({ + url: "https://auth0.prismic.io/activate", + userCode: "BCDF-GHJK", + codeInUrl: false, + }); + }); + + it("reports a client that isn't set up", async ({ expect, onTestFinished }) => { + const { auth0 } = await setup(onTestFinished); + const error = await startDeviceLogin(PARAMS, { + auth0Domain: auth0.auth0Domain, + clientId: "unknown", + }).catch((error) => error); + expect(error).toBeInstanceOf(DeviceFlowError); + expect(error).toMatchObject({ reason: "misconfigured", detail: "invalid_client" }); + }); + + it("reports an unreachable login service", async ({ expect, onTestFinished }) => { + const { auth0, start } = await setup(onTestFinished); + auth0.respondOnce("/oauth/device/code", { status: 503 }); + await expect(start()).rejects.toMatchObject({ reason: "unavailable", detail: "HTTP 503" }); + }); +}); + +describe("pollDeviceLogin", () => { + it("returns the tokens once approved", async ({ expect, onTestFinished }) => { + const { auth0, start } = await setup(onTestFinished, { accessToken: "access", expiresIn: 60 }); + const pending = await start(); + auth0.respondOnce("/oauth/token", { status: 403, body: { error: "authorization_pending" } }); + auth0.respondOnce("/oauth/token", { status: 403, body: { error: "authorization_pending" } }); + auth0.approve(); + const tokens = await pollDeviceLogin(pending); + expect(tokenRequests(auth0)).toHaveLength(3); + expect(tokenRequests(auth0)[0].params).toEqual({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: pending.deviceCode, + client_id: auth0.clientId, + }); + expect(tokens.accessToken).toBe("access"); + expect(tokens.refreshToken).toEqual(expect.any(String)); + expect(tokens.expiresAt).toBeGreaterThan(Date.now() + 55_000); + expect(tokens.expiresAt).toBeLessThanOrEqual(Date.now() + 60_000); + }); + + it("completes a resumed login that was already approved", async ({ expect, onTestFinished }) => { + const { auth0, start } = await setup(onTestFinished, { interval: 5 }); + const pending = await start(); + auth0.approve(); + const tokens = await pollDeviceLogin({ ...pending, expiresAt: Date.now() + 2_000 }); + expect(tokens.accessToken).toEqual(expect.any(String)); + }); + + it("adds 5 seconds to the interval on slow_down", async ({ expect, onTestFinished }) => { + const { auth0, start } = await setup(onTestFinished); + const pending = await start(); + auth0.respondOnce("/oauth/token", { + status: 429, + body: { error: "slow_down", error_description: "Slow down." }, + }); + auth0.approve(); + const updates: number[] = []; + const startedAt = performance.now(); + await pollDeviceLogin(pending, { + onUpdate: (pending) => void updates.push(pending.intervalMs), + }); + expect(updates).toEqual([5_000]); + expect(performance.now() - startedAt).toBeGreaterThanOrEqual(5_000); + }); + + it("waits for Retry-After", async ({ expect, onTestFinished }) => { + const { auth0, start } = await setup(onTestFinished); + const pending = await start(); + auth0.respondOnce("/oauth/token", { status: 429, headers: { "Retry-After": "3" } }); + auth0.approve(); + const startedAt = performance.now(); + await pollDeviceLogin(pending); + expect(tokenRequests(auth0)).toHaveLength(2); + expect(performance.now() - startedAt).toBeGreaterThanOrEqual(3_000); + }); + + it("backs off after server errors", async ({ expect, onTestFinished }) => { + const { auth0, start } = await setup(onTestFinished); + const pending = await start(); + auth0.respondOnce("/oauth/token", { status: 503, headers: { "Retry-After": "0" } }); + auth0.respondOnce("/oauth/token", { status: 500, body: { error: "server_error" } }); + auth0.approve(); + const startedAt = performance.now(); + await pollDeviceLogin(pending); + expect(tokenRequests(auth0)).toHaveLength(3); + // 2 seconds, then 4. + expect(performance.now() - startedAt).toBeGreaterThanOrEqual(6_000); + }); + + it("retries after a request times out", async ({ expect, onTestFinished }) => { + const { auth0, start } = await setup(onTestFinished); + const pending = await start(); + auth0.respondOnce("/oauth/token", { status: 200, delayMs: 11_000 }); + auth0.approve(); + const startedAt = performance.now(); + const tokens = await pollDeviceLogin(pending); + expect(tokens.accessToken).toEqual(expect.any(String)); + expect(performance.now() - startedAt).toBeGreaterThanOrEqual(10_000); + }); + + it("stops when the code expires", async ({ expect, onTestFinished }) => { + const { auth0, start } = await setup(onTestFinished); + const pending = await start(); + auth0.expire(); + await expect(pollDeviceLogin(pending)).rejects.toMatchObject({ + reason: "expired", + detail: "expired_token", + }); + await expect(pollDeviceLogin({ ...pending, expiresAt: Date.now() })).rejects.toMatchObject({ + reason: "expired", + }); + expect(tokenRequests(auth0)).toHaveLength(1); + }); + + it("reports a declined login", async ({ expect, onTestFinished }) => { + const { auth0, start } = await setup(onTestFinished); + const declined = await start(); + auth0.deny({ userCode: declined.userCode }); + await expect(pollDeviceLogin(declined)).rejects.toMatchObject({ reason: "declined" }); + const unverified = await start(); + auth0.deny({ userCode: unverified.userCode, description: "email_verification_failed" }); + await expect(pollDeviceLogin(unverified)).rejects.toMatchObject({ + reason: "email-unverified", + }); + }); + + it("rejects a code that was already used", async ({ expect, onTestFinished }) => { + const { auth0, start } = await setup(onTestFinished); + const pending = await start(); + auth0.approve(); + await pollDeviceLogin(pending); + await expect(pollDeviceLogin(pending)).rejects.toMatchObject({ reason: "invalid-grant" }); + }); + + it("stops on unknown errors without exposing the code", async ({ expect, onTestFinished }) => { + const { auth0, start } = await setup(onTestFinished); + const pending = await start(); + auth0.respondOnce("/oauth/token", { + status: 403, + body: { error: "unauthorized_client", error_description: pending.deviceCode }, + }); + const error = await pollDeviceLogin(pending).catch((error) => error); + expect(error).toMatchObject({ reason: "misconfigured", detail: "unauthorized_client" }); + expect(error.message).not.toContain(pending.deviceCode); + expect(error.cause).toBeUndefined(); + auth0.respondOnce("/oauth/token", { status: 404, body: pending.deviceCode }); + await expect(pollDeviceLogin(pending)).rejects.toMatchObject({ detail: "HTTP 404" }); + auth0.respondOnce("/oauth/token", { status: 200, body: { access_token: "a", expires_in: 60 } }); + await expect(pollDeviceLogin(pending)).rejects.toMatchObject({ + detail: "unexpected response", + }); + }); + + it("stops when aborted", async ({ expect, onTestFinished }) => { + const { start } = await setup(onTestFinished, { interval: 1 }); + const pending = await start(); + await expect( + pollDeviceLogin(pending, { signal: AbortSignal.timeout(1_500) }), + ).rejects.toMatchObject({ name: "TimeoutError" }); + }); +}); + +async function setup(onTestFinished: TestContext["onTestFinished"], options?: FakeAuth0Options) { + const auth0 = await createFakeAuth0({ interval: 0, ...options }); + onTestFinished(() => auth0.close()); + const start = () => + startDeviceLogin(PARAMS, { auth0Domain: auth0.auth0Domain, clientId: auth0.clientId }); + return { auth0, start }; +} + +function tokenRequests(auth0: FakeAuth0) { + return auth0.requests.filter((request) => request.path === "/oauth/token"); +} From b86535ea446825fd5167ab80cf52b1d665fb1e94 Mon Sep 17 00:00:00 2001 From: Lucie <25330882+lihbr@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:57:44 +0900 Subject: [PATCH 03/11] chore(auth): add the after-login profile step, not used yet (#471) finishLogin() forgets the tracked user and reads /user/profile once, which also creates the Prismic user for a new social or SSO signup. It returns the email for C4's "Logged in" message. Nothing calls it yet. Co-authored-by: lihbr Co-authored-by: Claude Opus 5.5 --- src/auth.ts | 17 ++++++++++++++++- test/finish-login.test.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 test/finish-login.test.ts diff --git a/src/auth.ts b/src/auth.ts index 8667365e..4470af45 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -11,7 +11,11 @@ import { DEFAULT_PRISMIC_HOST, env } from "./env"; import type { Auth0Config } from "./lib/auth0"; import { exists, readJsonFile, writeFileRecursive } from "./lib/file"; import { stringify } from "./lib/json"; -import { refreshToken as baseRefreshToken, validateToken } from "./lib/prismic/client"; +import { + refreshToken as baseRefreshToken, + getUserProfile, + validateToken, +} from "./lib/prismic/client"; import { ForbiddenRequestError, UnauthorizedRequestError } from "./lib/request"; import { appendTrailingSlash } from "./lib/url"; import { forgetTrackedUser } from "./tracking"; @@ -205,6 +209,17 @@ export async function createLoginSession(options: { }); } +// The profile request also creates the Prismic user for a new social or SSO +// signup, so it runs right after a login saves its tokens. +export async function finishLogin(config: { + token: string; + host: string; +}): Promise<{ email: string }> { + await forgetTrackedUser(); + const profile = await getUserProfile({ getToken: async () => config.token, host: config.host }); + return { email: profile.email }; +} + const LEGACY_AUTH_FILE_PATH = new URL(".prismic", appendTrailingSlash(pathToFileURL(homedir()))); export async function cleanupLegacyAuthFile(): Promise { diff --git a/test/finish-login.test.ts b/test/finish-login.test.ts new file mode 100644 index 00000000..14b57ec5 --- /dev/null +++ b/test/finish-login.test.ts @@ -0,0 +1,35 @@ +// Temporary. This unit test covers what happens right after a login until +// `prismic login` uses it. C4, the PR that switches `prismic login` to the +// device flow, deletes this file and tests login end to end instead. + +import { readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { afterEach, vi } from "vitest"; + +import { it } from "./it"; + +// src/env.ts requires PROD, which the build defines. Vitest sets it to "". +process.env.PROD = "false"; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +it("reads the profile once and forgets the tracked user", async ({ expect, home, host, token }) => { + const analyticsPath = new URL("analytics.json", home); + await writeFile(analyticsPath, JSON.stringify({ anonymousId: "anonymous", userId: "user" })); + const fetch = vi.spyOn(globalThis, "fetch"); + // Imports src/auth.ts as a new process would, with its own config directory. + vi.resetModules(); + vi.stubEnv("PRISMIC_CONFIG_DIR", fileURLToPath(home)); + const { finishLogin } = await import("../src/auth"); + + const { email } = await finishLogin({ token, host }); + + expect(email).toBe(process.env.E2E_PRISMIC_EMAIL); + expect(fetch).toHaveBeenCalledOnce(); + expect(String(fetch.mock.calls[0][0])).toBe(`https://api.internal.${host}/user/profile`); + expect(JSON.parse(await readFile(analyticsPath, "utf8"))).toEqual({ anonymousId: "anonymous" }); +}); From 0aa0c019fa901213b8dbaae62b19de7d32fe2d34 Mon Sep 17 00:00:00 2001 From: Lucie <25330882+lihbr@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:58:04 +0900 Subject: [PATCH 04/11] chore(auth): add the credential store and lock, not used yet (#468) Adds the credential store for device flow logins. Nothing calls it yet: src/credentials.ts (one keychain key per config directory, the encrypted credentials.enc, a plaintext fallback whose reason is kept for telemetry, and older credential files), src/lib/keyring.ts (/usr/bin/security on macOS, @napi-rs/keyring elsewhere, now a dependency) and src/lib/lock.ts (the credentials.lock directory lock), with temporary tests. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 237 +++++++++++++++++++++++ package.json | 3 + src/config.ts | 2 + src/credentials.ts | 386 ++++++++++++++++++++++++++++++++++++ src/lib/keyring.ts | 124 ++++++++++++ src/lib/lock.ts | 140 ++++++++++++++ test/credentials.test.ts | 408 +++++++++++++++++++++++++++++++++++++++ test/lock.test.ts | 150 ++++++++++++++ 8 files changed, 1450 insertions(+) create mode 100644 src/credentials.ts create mode 100644 src/lib/keyring.ts create mode 100644 src/lib/lock.ts create mode 100644 test/credentials.test.ts create mode 100644 test/lock.test.ts diff --git a/package-lock.json b/package-lock.json index c0887162..ee16bdec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "prismic", "version": "1.27.0", "license": "Apache-2.0", + "dependencies": { + "@napi-rs/keyring": "^2.1.2" + }, "bin": { "prismic": "dist/index.mjs" }, @@ -336,6 +339,240 @@ } } }, + "node_modules/@napi-rs/keyring": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-2.1.2.tgz", + "integrity": "sha512-9i1SR6tYeQ3o1beLeVMxhN6Fq513m9TZlTs+46+4JgvSNkaFoW9S4WrpV5MjzE+0CzVoC+dZ4RfZDIgW6+afnQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "2.1.2", + "@napi-rs/keyring-darwin-x64": "2.1.2", + "@napi-rs/keyring-freebsd-x64": "2.1.2", + "@napi-rs/keyring-linux-arm-gnueabihf": "2.1.2", + "@napi-rs/keyring-linux-arm64-gnu": "2.1.2", + "@napi-rs/keyring-linux-arm64-musl": "2.1.2", + "@napi-rs/keyring-linux-riscv64-gnu": "2.1.2", + "@napi-rs/keyring-linux-x64-gnu": "2.1.2", + "@napi-rs/keyring-linux-x64-musl": "2.1.2", + "@napi-rs/keyring-win32-arm64-msvc": "2.1.2", + "@napi-rs/keyring-win32-ia32-msvc": "2.1.2", + "@napi-rs/keyring-win32-x64-msvc": "2.1.2" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-2.1.2.tgz", + "integrity": "sha512-vsInUWUzWS6/WzR/TFNwmdtmwKWjnnR8TOpvZ/pjjxDAkJB/waz37PWzp2b8AFcw1aCcq2aQ7k2vvbLQy8pTrw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-2.1.2.tgz", + "integrity": "sha512-wQihYroaQ3r++R6u5c9Gj0TCw1l1F53LCb5aLOXfVvT4DsnDnpe7p7VGYo4/33bLe0iqVevtAj+ET3yKCtOlJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-2.1.2.tgz", + "integrity": "sha512-Ve5ha7MbJsq+4TYyXYiTy4IeZOnYxkBB05uH2+nIrEKBkHmyfHttF814EfWAfoJa63KqBhYRXdYJo0o1WVL2sg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-2.1.2.tgz", + "integrity": "sha512-zxijTnVnTApEyJMjgnLdVQL1g1DAudku/5brrWNDRWj4J0q0nFhUZEtzUA+nY2FvgDjl4s+2YCZYXSTH4TFMUg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-2.1.2.tgz", + "integrity": "sha512-PM+SAcl/yGzgGbeeCsKrnlG4T5DBIDjmDqtKm5an1bJz0o0gwSzR3BMiXf04JenkcqascZpdcIReoEvLj0cvCw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-2.1.2.tgz", + "integrity": "sha512-+ERS9oyInPCALN957cViBDbYJc724/Q+rZ8ZAz4rOHj4B4v0VR4OAWThdYZgZqD54sZVV0MdyX24qva97hYoAQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-2.1.2.tgz", + "integrity": "sha512-pGADtqeT3U1SaFZGjkVsmZVjeNOQx0fr77S+NWszXjZboyaUOLlGKgqG6ssAWzcwxv8uPhKc9Ij17ujNZhSqLw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-2.1.2.tgz", + "integrity": "sha512-Fj5TmTnl2oriuCT5DDEYlhgxYes20uGfgOg10LK9PyPVsI8/PQ1m8I4rq/4A5hkrMtY7wvFb70YmX/yPHDYWvQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-2.1.2.tgz", + "integrity": "sha512-C+KhAeU6x2A43FV3dOmMvUraJb8zsC9VZfsunjZ1cgtuAb208TALaUuAY0jThOphXqhyhBJOs2xTCQhOrRkJIw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-2.1.2.tgz", + "integrity": "sha512-kBq80ZZfjXoa5wHG9kgAVhYYjaPOLgHh5ngt7r1ftwFXPXDPXKH7oM2r2j2Q64cs0pVtmodVtn5Q9e6xCwzQUQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-2.1.2.tgz", + "integrity": "sha512-bhgKvVbWlZ6iR/tmvb2ht6/o0OzarUX9W7hjg+G+eVdGjR1prwtQ9lYo5ZESdn/+zyF1MIFvHkOCGja9YqDcww==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-2.1.2.tgz", + "integrity": "sha512-yQtFdfuwqM0ezKlYO6r2n44in2LGzd4MC679Ddhvojip3t1hn+q/4I+gJU+cIJQVd5FAZSKNrK4tGfew3UTpAQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.6.tgz", diff --git a/package.json b/package.json index f69a2391..f7cdb2ad 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,9 @@ "evals:report": "jq -r '.evals | to_entries[] | \"\\([.value[]|select(.pass)]|length)/\\(.value|length) \\([.value[].durationS]|add/length|round)s \\(.key)\"' evals/results.json", "test": "npm run lint && npm run types && npm run unit" }, + "dependencies": { + "@napi-rs/keyring": "^2.1.2" + }, "devDependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.319", "@openai/codex-sdk": "^0.167.0", diff --git a/src/config.ts b/src/config.ts index 026111b3..e4a41da8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,5 +4,7 @@ import { getConfigDir } from "./lib/config-dir"; export const CONFIG_DIR = getConfigDir("prismic", env.PRISMIC_CONFIG_DIR); export const CREDENTIALS_PATH = new URL("credentials.json", CONFIG_DIR); +export const ENCRYPTED_CREDENTIALS_PATH = new URL("credentials.enc", CONFIG_DIR); +export const CREDENTIALS_LOCK_PATH = new URL("credentials.lock", CONFIG_DIR); export const UPDATE_NOTIFIER_STATE_PATH = new URL("update-notifier.json", CONFIG_DIR); export const ANALYTICS_IDS_PATH = new URL("analytics.json", CONFIG_DIR); diff --git a/src/credentials.ts b/src/credentials.ts new file mode 100644 index 00000000..f84b196c --- /dev/null +++ b/src/credentials.ts @@ -0,0 +1,386 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { chmod, lstat, mkdir, open, rename, rm, stat } from "node:fs/promises"; +import { release } from "node:os"; +import { fileURLToPath } from "node:url"; + +import * as z from "zod/mini"; + +import { + CONFIG_DIR, + CREDENTIALS_LOCK_PATH, + CREDENTIALS_PATH, + ENCRYPTED_CREDENTIALS_PATH, +} from "./config"; +import { PendingLoginSchema } from "./lib/auth0"; +import { exists } from "./lib/file"; +import { stringify } from "./lib/json"; +import { KeyringError, systemKeyring, type KeyringErrorReason } from "./lib/keyring"; +import { acquireLock, type Lock } from "./lib/lock"; + +const KEYCHAIN_SERVICE = "prismic-cli"; +const KEYCHAIN_ACCOUNT = `cli:${createHash("sha256").update(fileURLToPath(CONFIG_DIR)).digest("hex").slice(0, 16)}`; +const KEYCHAIN_LOGIN_TIMEOUT_MS = 5_000; +// Leaves time to answer the keychain's unlock dialog. +const KEYCHAIN_TIMEOUT_MS = 30_000; + +// credentials.enc starts with this byte, then a 12-byte nonce and a 16-byte +// tag, then the ciphertext. +const ENCRYPTED_FORMAT = Buffer.from([1]); + +const SessionSchema = z.object({ + host: z.string(), + auth0Domain: z.string(), + clientId: z.string(), + email: z.optional(z.string()), + accessToken: z.optional(z.string()), + refreshToken: z.optional(z.string()), + /** Milliseconds since the epoch, from the local time of receipt. */ + expiresAt: z.optional(z.number()), + pendingLogin: z.optional(PendingLoginSchema), +}); +export type Session = z.infer; + +const FallbackReasonSchema = z.enum([ + "wsl", + "secret-service-missing", + "locked", + "write-rejected", + "timeout", + "module-missing", +]); +export type FallbackReason = z.infer; + +const CredentialsVersionSchema = z.looseObject({ + version: z.optional(z.unknown()), + store: z.optional(z.unknown()), + storeReason: z.optional(z.unknown()), +}); +const CredentialsHostSchema = z.looseObject({ host: z.optional(z.string()) }); + +export type StoreMode = "keyring" | "file"; + +// `storeReason` says why a login went to a file, for telemetry. +export type StoredLogin = { store: StoreMode; storeReason?: FallbackReason; session: Session }; + +export type StoredCredentials = { + host?: string; + login?: StoredLogin; +}; + +let key: Buffer | undefined; +let warnedUnsafe = false; + +// A login that exists but can't be read throws, never reads as logged out. +export async function readCredentials(): Promise { + const json = parseJson(await readPrivateFile(CREDENTIALS_PATH)); + const file = z.safeParse(CredentialsVersionSchema, json); + if (!file.success) return {}; + const { version, store, storeReason } = file.data; + + // Without a version, the file has only `host` (website generator), or is + // version 1, whose token expires within hours and can't be refreshed. + if (version === undefined) { + const host = z.safeParse(CredentialsHostSchema, json).data?.host; + return host ? { host } : {}; + } + if (version !== 2) { + throw new CredentialsError( + "unsupported-version", + "Your Prismic login was saved by a newer version of the CLI. Update the CLI, or run `prismic login` again.", + ); + } + if (store !== "keyring" && store !== "file") throw undecryptable(); + + const session = z.safeParse( + SessionSchema, + store === "file" ? json : parseJson(await decryptSession()), + ); + if (!session.success) throw undecryptable(); + + // An unknown reason reads as none. + const reason = store === "file" ? z.safeParse(FallbackReasonSchema, storeReason).data : undefined; + return { + host: session.data.host, + login: { store, ...(reason ? { storeReason: reason } : {}), session: session.data }, + }; +} + +const FALLBACK_REASONS: Record = { + "module-missing": "module-missing", + unavailable: "secret-service-missing", + locked: "locked", + rejected: "write-rejected", + timeout: "timeout", +}; + +// Only a login calls this, under the lock it keeps until it writes, so a login +// falls back to a file only when it starts. Holding the lock means two logins +// can't each create a key and then encrypt with the one that lost. +export async function prepareLogin( + lock: Lock, +): Promise<{ store: "keyring" } | { store: "file"; storeReason: FallbackReason }> { + if (process.platform === "linux" && /microsoft/i.test(release())) { + return { store: "file", storeReason: "wsl" }; + } + + try { + // Another process may have deleted the key since this one read it. + key = undefined; + if (!(await readKey(KEYCHAIN_LOGIN_TIMEOUT_MS))) { + const newKey = randomBytes(32); + await assertHeld(lock); + await systemKeyring.set(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, newKey.toString("base64"), { + timeoutMs: KEYCHAIN_LOGIN_TIMEOUT_MS, + }); + key = newKey; + } + return { store: "keyring" }; + } catch (error) { + if (!(error instanceof KeyringError)) throw error; + return { store: "file", storeReason: FALLBACK_REASONS[error.reason] }; + } +} + +export async function writeCredentials( + lock: Lock, + login: StoredLogin, +): Promise<{ warning?: string }> { + const { store, storeReason, session } = login; + let safe; + if (store === "keyring") { + const { host, email, expiresAt, auth0Domain, clientId } = session; + safe = await writePrivateFiles(lock, [ + // credentials.enc is authoritative, so it's written first. + [ENCRYPTED_CREDENTIALS_PATH, encrypt(JSON.stringify(session), await requireKey())], + [ + CREDENTIALS_PATH, + stringify({ version: 2, host, email, expiresAt, store, auth0Domain, clientId }), + ], + ]); + } else { + safe = await writePrivateFiles(lock, [ + [CREDENTIALS_PATH, stringify({ version: 2, store, storeReason, ...session })], + ]); + await removeFile(lock, ENCRYPTED_CREDENTIALS_PATH); + } + + if (safe || warnedUnsafe) return {}; + warnedUnsafe = true; + return { + warning: `Can't make ${fileURLToPath(CREDENTIALS_PATH)} readable only by you on this disk. Set PRISMIC_CONFIG_DIR to a private folder on another disk.`, + }; +} + +export async function clearCredentials(lock: Lock): Promise { + const json = parseJson(await readPrivateFile(CREDENTIALS_PATH).catch(() => undefined)); + const host = z.safeParse(CredentialsHostSchema, json).data?.host; + const hadKey = + z.safeParse(CredentialsVersionSchema, json).data?.store === "keyring" || + (await exists(ENCRYPTED_CREDENTIALS_PATH)); + + if (host) await writePrivateFiles(lock, [[CREDENTIALS_PATH, stringify({ host })]]); + else await removeFile(lock, CREDENTIALS_PATH); + await removeFile(lock, ENCRYPTED_CREDENTIALS_PATH); + + if (!hadKey) return; + key = undefined; + await assertHeld(lock); + await systemKeyring + .delete(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, { timeoutMs: KEYCHAIN_TIMEOUT_MS }) + .catch((error) => { + throw keyringFailure(error, "delete"); + }); +} + +export async function lockCredentials(config: { timeoutMs: number }): Promise { + await ensurePrivateDir(); + return acquireLock(CREDENTIALS_LOCK_PATH, config); +} + +export type CredentialsErrorReason = + | "unsupported-version" + | "unsafe-file" + | "lock-lost" + | "locked" + | "timeout" + | "keychain-unavailable" + | "missing-key" + | "decrypt-failed"; + +export class CredentialsError extends Error { + name = "CredentialsError"; + reason: CredentialsErrorReason; + + constructor(reason: CredentialsErrorReason, message: string, options?: ErrorOptions) { + super(message, options); + this.reason = reason; + } +} + +async function readKey(timeoutMs: number): Promise { + if (key) return key; + const value = await systemKeyring.get(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, { timeoutMs }); + const decoded = value ? Buffer.from(value, "base64") : undefined; + if (decoded?.length === 32) key = decoded; + return key; +} + +async function requireKey(): Promise { + const currentKey = await readKey(KEYCHAIN_TIMEOUT_MS).catch((error) => { + throw keyringFailure(error); + }); + if (!currentKey) throw undecryptable("missing-key"); + return currentKey; +} + +async function decryptSession(): Promise { + const encrypted = await readPrivateFile(ENCRYPTED_CREDENTIALS_PATH); + const wasCached = key !== undefined; + const plaintext = decrypt(encrypted, await requireKey()); + if (plaintext || !wasCached) return plaintext; + + // Another process may have logged in again, with a new key, since this one + // read it. + key = undefined; + return decrypt(encrypted, await requireKey()); +} + +const KEYCHAIN_FAILURES: Partial> = { + locked: ["locked", "the keychain is locked in this session. Unlock it and try again"], + timeout: ["timeout", "the keychain didn't respond. Try again"], +}; + +function keyringFailure(error: unknown, action: "read" | "delete" = "read"): unknown { + if (!(error instanceof KeyringError)) return error; + const [reason, why] = KEYCHAIN_FAILURES[error.reason] ?? [ + "keychain-unavailable", + "the keychain isn't available. Try again", + ]; + // A new login saves to a file when the keychain can't be used. + const alternative = action === "read" ? ", or run `prismic login` again" : ""; + return new CredentialsError( + reason, + `Can't ${action} your Prismic key because ${why}${alternative}.`, + { cause: error }, + ); +} + +function undecryptable(reason: "missing-key" | "decrypt-failed" = "decrypt-failed"): Error { + return new CredentialsError( + reason, + "Your Prismic login can't be decrypted. Run `prismic login` again.", + ); +} + +// Checked right before each rename or delete, so a stalled process can't +// overwrite a newer login or restore a session after a logout. +async function assertHeld(lock: Lock): Promise { + if (await lock.isHeld()) return; + throw new CredentialsError( + "lock-lost", + "Another Prismic command took over the login while this one stalled. Run the command again.", + ); +} + +function encrypt(plaintext: string, key: Buffer): Buffer { + const nonce = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, nonce); + cipher.setAAD(ENCRYPTED_FORMAT); + const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return Buffer.concat([ENCRYPTED_FORMAT, nonce, cipher.getAuthTag(), ciphertext]); +} + +function decrypt(data: Buffer | undefined, key: Buffer): string | undefined { + if (!data || data.length < 29 || data[0] !== ENCRYPTED_FORMAT[0]) return undefined; + try { + const decipher = createDecipheriv("aes-256-gcm", key, data.subarray(1, 13)); + decipher.setAAD(ENCRYPTED_FORMAT); + decipher.setAuthTag(data.subarray(13, 29)); + return Buffer.concat([decipher.update(data.subarray(29)), decipher.final()]).toString("utf8"); + } catch { + return undefined; + } +} + +// On Windows, files rely on %APPDATA%'s ACLs instead of modes. +async function ensurePrivateDir(): Promise { + await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 }); + if (process.platform === "win32") return; + if (((await stat(CONFIG_DIR)).mode & 0o077) !== 0) await chmod(CONFIG_DIR, 0o700); +} + +// Returns false when a file isn't private, because the disk ignores modes, like +// WSL's /mnt/c. +async function writePrivateFiles(lock: Lock, files: [URL, string | Buffer][]): Promise { + await ensurePrivateDir(); + let safe = true; + for (const [path, data] of files) { + const temporaryPath = new URL(`${path.href}.${randomUUID()}.tmp`); + try { + const handle = await open(temporaryPath, "wx", 0o600); + try { + await handle.writeFile(data); + await handle.sync(); + } finally { + await handle.close(); + } + await assertHeld(lock); + await rename(temporaryPath, path); + } catch (error) { + await rm(temporaryPath, { force: true }); + throw error; + } + + if (process.platform === "win32") continue; + const stats = await lstat(path); + if ((stats.mode & 0o777) !== 0o600 || stats.uid !== process.getuid?.()) safe = false; + } + return safe; +} + +async function removeFile(lock: Lock, path: URL): Promise { + await assertHeld(lock); + await rm(path, { force: true }); +} + +async function readPrivateFile(path: URL): Promise { + const unsafe = (why: string) => + new CredentialsError( + "unsafe-file", + `Refusing to read ${fileURLToPath(path)} because ${why}. Delete it and run \`prismic login\` again.`, + ); + + let handle; + try { + if (process.platform === "win32" && (await lstat(path)).isSymbolicLink()) { + throw unsafe("it's a symbolic link"); + } + handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + } catch (error) { + const { code } = error as NodeJS.ErrnoException; + if (code === "ENOENT") return undefined; + if (code === "ELOOP") throw unsafe("it's a symbolic link"); + throw error; + } + + try { + const stats = await handle.stat(); + if (!stats.isFile()) throw unsafe("it isn't a regular file"); + if (process.platform === "win32") return await handle.readFile(); + if (stats.uid !== process.getuid?.()) throw unsafe("it belongs to another user"); + // Older versions wrote credentials.json with the default mode. + if ((stats.mode & 0o077) !== 0) await handle.chmod(0o600).catch(() => {}); + return await handle.readFile(); + } finally { + await handle.close(); + } +} + +function parseJson(data: Buffer | string | undefined): unknown { + try { + return data && JSON.parse(data.toString()); + } catch { + return undefined; + } +} diff --git a/src/lib/keyring.ts b/src/lib/keyring.ts new file mode 100644 index 00000000..b2efbb0c --- /dev/null +++ b/src/lib/keyring.ts @@ -0,0 +1,124 @@ +import { spawn } from "node:child_process"; + +import type { AsyncEntry } from "@napi-rs/keyring"; + +// `security` exits with the low byte of its status code. +const SECURITY_NOT_FOUND = 44; // errSecItemNotFound +const SECURITY_LOCKED = [36, 51]; // errSecInteractionNotAllowed, errSecAuthFailed + +export type KeyringErrorReason = + | "module-missing" + | "unavailable" + | "locked" + | "rejected" + | "timeout"; + +export class KeyringError extends Error { + name = "KeyringError"; + reason: KeyringErrorReason; + + constructor(reason: KeyringErrorReason, options?: ErrorOptions) { + super(`Keychain error: ${reason}`, options); + this.reason = reason; + } +} + +type KeyringOptions = { timeoutMs: number }; + +export type Keyring = { + get(service: string, account: string, options: KeyringOptions): Promise; + set(service: string, account: string, password: string, options: KeyringOptions): Promise; + delete(service: string, account: string, options: KeyringOptions): Promise; +}; + +// Keychain items trust the binary that created them. With a native module, +// that's `node`, so an ad-hoc-signed Homebrew `node` prompts after each upgrade. +const macosKeyring: Keyring = { + async get(service, account, options) { + const args = ["find-generic-password", "-s", service, "-a", account, "-w"]; + return (await security(args, undefined, options))?.replace(/\n$/, ""); + }, + async set(service, account, password, options) { + // On stdin, the password never shows in a process list. `security -i` + // splits lines on spaces. + if (![service, account, password].every((value) => /^[^\s"'\\]+$/.test(value))) { + throw new TypeError("Keychain values can't contain spaces, quotes or backslashes."); + } + const command = `add-generic-password -U -s ${service} -a ${account} -w ${password}\n`; + await security(["-i"], command, options); + }, + async delete(service, account, options) { + const args = ["delete-generic-password", "-s", service, "-a", account]; + return (await security(args, undefined, options)) !== undefined; + }, +}; + +function security( + args: string[], + input: string | undefined, + options: KeyringOptions, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn("/usr/bin/security", args, { + signal: AbortSignal.timeout(options.timeoutMs), + stdio: ["pipe", "pipe", "ignore"], + }); + let stdout = ""; + child.stdout.setEncoding("utf8").on("data", (chunk: string) => (stdout += chunk)); + child.on("error", (error) => { + const reason = error.name === "AbortError" ? "timeout" : "unavailable"; + reject(new KeyringError(reason, { cause: error })); + }); + child.on("close", (code) => { + if (code === 0) resolve(stdout); + else if (code === SECURITY_NOT_FOUND) resolve(undefined); + else reject(new KeyringError(SECURITY_LOCKED.includes(code ?? 0) ? "locked" : "rejected")); + }); + child.stdin.end(input); + }); +} + +const napiKeyring: Keyring = { + async get(service, account, options) { + const password = await napiCall(service, account, options, (entry, signal) => + entry.getPassword(signal), + ); + return password ?? undefined; + }, + async set(service, account, password, options) { + await napiCall(service, account, options, (entry, signal) => + entry.setPassword(password, signal), + ); + }, + async delete(service, account, options) { + return napiCall(service, account, options, (entry, signal) => entry.deleteCredential(signal)); + }, +}; + +async function napiCall( + service: string, + account: string, + options: KeyringOptions, + call: (entry: AsyncEntry, signal: AbortSignal) => Promise, +): Promise { + let Entry: typeof AsyncEntry; + try { + ({ AsyncEntry: Entry } = await import("@napi-rs/keyring")); + } catch (cause) { + throw new KeyringError("module-missing", { cause }); + } + + const signal = AbortSignal.timeout(options.timeoutMs); + try { + // Never the kernel keyring, which a reboot clears. + const entry = new Entry(service, account, { linux: { store: "secret-service" } }); + return await call(entry, signal); + } catch (cause) { + if (signal.aborted) throw new KeyringError("timeout", { cause }); + const message = cause instanceof Error ? cause.message : ""; + if (/locked/i.test(message)) throw new KeyringError("locked", { cause }); + throw new KeyringError(process.platform === "linux" ? "unavailable" : "rejected", { cause }); + } +} + +export const systemKeyring: Keyring = process.platform === "darwin" ? macosKeyring : napiKeyring; diff --git a/src/lib/lock.ts b/src/lib/lock.ts new file mode 100644 index 00000000..63a2ade4 --- /dev/null +++ b/src/lib/lock.ts @@ -0,0 +1,140 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, stat, utimes, writeFile } from "node:fs/promises"; +import { hostname } from "node:os"; +import { setTimeout as sleep } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; + +const OWNER_FILENAME = "owner.json"; +// Claiming a new lock takes milliseconds, so a lock without an owner after +// this long was left by a process that died before claiming it. +const UNCLAIMED_MS = 1_000; + +type Owner = { id: string; pid: number; hostname: string }; + +export type LockOptions = { + timeoutMs: number; + staleMs?: number; + heartbeatMs?: number; +}; + +export type Lock = { + isHeld(): Promise; + release(): Promise; +}; + +export async function acquireLock(path: URL, options: LockOptions): Promise { + const { timeoutMs, staleMs = 10_000, heartbeatMs = 2_000 } = options; + const owner: Owner = { id: randomUUID(), pid: process.pid, hostname: hostname() }; + const deadline = Date.now() + timeoutMs; + + let delayMs = 25; + while (!(await tryCreate(path, owner))) { + if (await recoverIfStale(path, staleMs)) continue; + if (Date.now() >= deadline) { + throw new LockTimeoutError(`Timed out waiting for the lock at ${fileURLToPath(path)}.`); + } + await sleep(delayMs * (0.5 + Math.random())); + delayMs = Math.min(delayMs * 2, 500); + } + + let released = false; + const isHeld = async (): Promise => { + return !released && (await readOwner(path))?.id === owner.id; + }; + + // A waiter's rename can hide the lock for a moment, so a failed check only + // skips one touch. + const heartbeat = setInterval(async () => { + if (!(await isHeld())) return; + const now = new Date(); + await utimes(getOwnerPath(path), now, now).catch(() => {}); + }, heartbeatMs); + heartbeat.unref(); + + return { + isHeld, + async release() { + clearInterval(heartbeat); + const held = await isHeld(); + released = true; + if (held) await rm(path, { recursive: true, force: true }); + }, + }; +} + +export class LockTimeoutError extends Error { + name = "LockTimeoutError"; +} + +async function tryCreate(path: URL, owner: Owner): Promise { + try { + await mkdir(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; + throw error; + } + try { + // `wx` fails if another waiter put an older lock back in place. + await writeFile(getOwnerPath(path), JSON.stringify(owner), { flag: "wx" }); + return true; + } catch (error) { + const { code } = error as NodeJS.ErrnoException; + if (code === "EEXIST" || code === "ENOENT") return false; + throw error; + } +} + +async function recoverIfStale(path: URL, staleMs: number): Promise { + const owner = await readOwner(path); + const touched = await stat(getOwnerPath(path)) + .catch(() => stat(path)) + .catch(() => undefined); + if (!touched) return true; // Released in the meantime. + + const ageMs = Date.now() - touched.mtimeMs; + const isOld = ageMs > staleMs; + const isUnclaimed = !owner && ageMs > UNCLAIMED_MS; + const isOrphan = owner?.hostname === hostname() && !isAlive(owner.pid); + if (!isOld && !isUnclaimed && !isOrphan) return false; + + // Only one waiter can rename a given lock away. + const stalePath = new URL(`${path.href}.stale-${randomUUID()}`); + try { + await rename(path, stalePath); + } catch { + return false; + } + + // Another waiter may have replaced the stale lock since the check. + const moved = await readOwner(stalePath); + if (moved && moved.id !== owner?.id) { + try { + await rename(stalePath, path); + return false; + } catch {} + } + await rm(stalePath, { recursive: true, force: true }); + return true; +} + +async function readOwner(path: URL): Promise { + try { + return JSON.parse(await readFile(getOwnerPath(path), "utf8")); + } catch { + return undefined; + } +} + +function getOwnerPath(path: URL): URL { + return new URL(`${path.href}/${OWNER_FILENAME}`); +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM means the process exists but belongs to another user. + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} diff --git a/test/credentials.test.ts b/test/credentials.test.ts new file mode 100644 index 00000000..5e317dcf --- /dev/null +++ b/test/credentials.test.ts @@ -0,0 +1,408 @@ +// Temporary. These unit tests cover the credential store until `prismic login` +// uses it. C4, the PR that switches `prismic login` to the device flow, deletes +// this file and tests login end to end instead. + +import { randomBytes, randomUUID } from "node:crypto"; +import { chown, mkdir, readdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { beforeEach, describe, expect, vi } from "vitest"; + +import type { Session } from "../src/credentials"; +import type * as Keyring from "../src/lib/keyring"; +import { it } from "./it"; + +// Replaces the system keychain. Set a reason in `keychain.fail` to make that +// call throw. +const keychain = vi.hoisted(() => ({ + items: new Map(), + fail: {} as Partial>, +})); + +vi.mock("../src/lib/keyring", async (importOriginal) => { + const actual = await importOriginal(); + const check = (call: keyof typeof keychain.fail) => { + const reason = keychain.fail[call]; + if (reason) throw new actual.KeyringError(reason); + }; + const systemKeyring: Keyring.Keyring = { + async get(service, account) { + check("get"); + return keychain.items.get(`${service} ${account}`); + }, + async set(service, account, password) { + check("set"); + keychain.items.set(`${service} ${account}`, password); + }, + async delete(service, account) { + check("delete"); + return keychain.items.delete(`${service} ${account}`); + }, + }; + return { ...actual, systemKeyring }; +}); + +const DECRYPT_ERROR = "Your Prismic login can't be decrypted. Run `prismic login` again."; +const LOCKED_ERROR = + "Can't read your Prismic key because the keychain is locked in this session. Unlock it and try again, or run `prismic login` again."; + +const session = { + host: "prismic.io", + auth0Domain: "auth0.prismic.io", + clientId: "client-id", + email: "dev@example.com", + accessToken: "access-token-secret", + refreshToken: "refresh-token-secret", + expiresAt: 1_800_000_000_000, +}; + +// src/env.ts requires PROD, which the build defines. Vitest sets it to "". +process.env.PROD = "false"; + +// Imports the store as a new process would, with its own config directory. +async function loadStore(dir: URL) { + vi.resetModules(); + process.env.PRISMIC_CONFIG_DIR = fileURLToPath(dir); + return import("../src/credentials"); +} + +async function logIn(store: Awaited>, saved: Session = session) { + const lock = await store.lockCredentials({ timeoutMs: 5_000 }); + const choice = await store.prepareLogin(lock); + await store.writeCredentials(lock, { ...choice, session: saved }); + await lock.release(); + return choice; +} + +// The tests share the keychain and the environment. +describe("credential store", { concurrent: false }, () => { + beforeEach(() => { + keychain.items.clear(); + keychain.fail = {}; + }); + + it("round trips a keyring login, with a fresh nonce on every write", async ({ home }) => { + const store = await loadStore(home); + const saved = { + ...session, + pendingLogin: { + auth0Domain: session.auth0Domain, + clientId: session.clientId, + deviceCode: "device-code-secret", + userCode: "BCDF-GHJK", + verificationUri: "https://auth0.prismic.io/activate", + expiresAt: 1_800_000_900_000, + intervalMs: 5_000, + }, + }; + + expect(await logIn(store, saved)).toEqual({ store: "keyring" }); + const first = await readFile(new URL("credentials.enc", home)); + await logIn(store, saved); + const second = await readFile(new URL("credentials.enc", home)); + + // Format byte, then the 12-byte nonce. + expect(first.subarray(1, 13).equals(second.subarray(1, 13))).toBe(false); + expect(first.toString("latin1")).not.toContain(session.refreshToken); + expect(first.toString("latin1")).not.toContain("device-code-secret"); + expect(JSON.parse(await readFile(new URL("credentials.json", home), "utf8"))).toEqual({ + version: 2, + host: session.host, + email: session.email, + expiresAt: session.expiresAt, + store: "keyring", + auth0Domain: session.auth0Domain, + clientId: session.clientId, + }); + expect(await (await loadStore(home)).readCredentials()).toEqual({ + host: session.host, + login: { store: "keyring", session: saved }, + }); + }); + + it("rejects a tampered file, a wrong key and a missing key", async ({ home }) => { + const encryptedPath = new URL("credentials.enc", home); + await logIn(await loadStore(home)); + const original = await readFile(encryptedPath); + const [[name, key]] = keychain.items; + + const tampered = Buffer.from(original); + tampered[tampered.length - 1] ^= 1; + await writeFile(encryptedPath, tampered); + await expect((await loadStore(home)).readCredentials()).rejects.toMatchObject({ + reason: "decrypt-failed", + message: DECRYPT_ERROR, + }); + + await writeFile(encryptedPath, original); + keychain.items.set(name, randomBytes(32).toString("base64")); + await expect((await loadStore(home)).readCredentials()).rejects.toMatchObject({ + reason: "decrypt-failed", + message: DECRYPT_ERROR, + }); + + keychain.items.clear(); + await expect((await loadStore(home)).readCredentials()).rejects.toMatchObject({ + reason: "missing-key", + message: DECRYPT_ERROR, + }); + + keychain.items.set(name, key); + expect((await (await loadStore(home)).readCredentials()).login?.session).toEqual(session); + }); + + it("falls back to a plaintext file when the keychain fails at login", async ({ home }) => { + const cases = [ + ["module-missing", "module-missing"], + ["unavailable", "secret-service-missing"], + ["locked", "locked"], + ["rejected", "write-rejected"], + ["timeout", "timeout"], + ] as const; + for (const [keyringReason, reason] of cases) { + keychain.fail.set = keyringReason; + const store = await loadStore(new URL(`${reason}/`, home)); + + expect(await logIn(store)).toEqual({ store: "file", storeReason: reason }); + expect(await (await loadStore(new URL(`${reason}/`, home))).readCredentials()).toEqual({ + host: session.host, + login: { store: "file", storeReason: reason, session }, + }); + } + }); + + it("keeps a file login in credentials.json and never reads the keychain for it", async ({ + home, + }) => { + keychain.fail.set = "unavailable"; + expect(await logIn(await loadStore(home))).toEqual({ + store: "file", + storeReason: "secret-service-missing", + }); + expect(JSON.parse(await readFile(new URL("credentials.json", home), "utf8"))).toEqual({ + version: 2, + store: "file", + storeReason: "secret-service-missing", + ...session, + }); + expect(await readdir(home)).not.toContain("credentials.enc"); + + keychain.fail = { get: "rejected", set: "rejected", delete: "rejected" }; + expect(await (await loadStore(home)).readCredentials()).toEqual({ + host: session.host, + login: { store: "file", storeReason: "secret-service-missing", session }, + }); + }); + + it("reports a failed key read after login, never a logout", async ({ home }) => { + await logIn(await loadStore(home)); + + keychain.fail.get = "locked"; + await expect((await loadStore(home)).readCredentials()).rejects.toMatchObject({ + reason: "locked", + message: LOCKED_ERROR, + }); + + keychain.fail.get = "timeout"; + await expect((await loadStore(home)).readCredentials()).rejects.toMatchObject({ + reason: "timeout", + }); + }); + + it("reuses one key per config directory", async ({ home }) => { + await logIn(await loadStore(new URL("a/", home))); + const [[account, key]] = keychain.items; + await logIn(await loadStore(new URL("a/", home))); + expect([...keychain.items]).toEqual([[account, key]]); + expect(account).toMatch(/^prismic-cli cli:[0-9a-f]{16}$/); + + await logIn(await loadStore(new URL("b/", home))); + expect(keychain.items.size).toBe(2); + }); + + it("creates a new key at login when another process deleted it", async ({ home }) => { + const store = await loadStore(home); + await logIn(store); + await store.readCredentials(); + + keychain.items.clear(); + await logIn(store); + expect(keychain.items.size).toBe(1); + expect((await (await loadStore(home)).readCredentials()).login?.session).toEqual(session); + }); + + it("reads the new key after another process logged out and in again", async ({ home }) => { + const store = await loadStore(home); + await logIn(store); + expect((await store.readCredentials()).login?.session).toEqual(session); + + const other = await loadStore(home); + const lock = await other.lockCredentials({ timeoutMs: 5_000 }); + await other.clearCredentials(lock); + await lock.release(); + await logIn(other, { ...session, accessToken: "new-access-token" }); + + expect((await store.readCredentials()).login?.session.accessToken).toBe("new-access-token"); + }); + + it.skipIf(process.platform === "win32")( + "writes a private directory and private files through temporary files", + async ({ home }) => { + const dir = new URL("config/", home); + await mkdir(dir, { mode: 0o755 }); + await logIn(await loadStore(dir)); + + expect((await stat(dir)).mode & 0o777).toBe(0o700); + expect((await stat(new URL("credentials.json", dir))).mode & 0o777).toBe(0o600); + expect((await stat(new URL("credentials.enc", dir))).mode & 0o777).toBe(0o600); + expect((await readdir(dir)).sort()).toEqual(["credentials.enc", "credentials.json"]); + }, + ); + + it.skipIf(process.platform === "win32")( + "refuses symlinked credential files", + async ({ home }) => { + const dir = new URL("config/", home); + await logIn(await loadStore(dir)); + const elsewhere = new URL("elsewhere/", home); + await mkdir(elsewhere); + + for (const name of ["credentials.enc", "credentials.json"]) { + const path = new URL(name, dir); + await writeFile(new URL(name, elsewhere), await readFile(path)); + await rm(path); + await symlink(new URL(name, elsewhere), path); + + await expect((await loadStore(dir)).readCredentials()).rejects.toMatchObject({ + reason: "unsafe-file", + message: expect.stringContaining("because it's a symbolic link"), + }); + await rm(path); + await writeFile(path, await readFile(new URL(name, elsewhere)), { mode: 0o600 }); + } + }, + ); + + // Changing a file's owner needs root, so this runs only as root. + it.skipIf(process.getuid?.() !== 0)( + "refuses files that belong to another user", + async ({ home }) => { + await writeFile(new URL("credentials.json", home), JSON.stringify({ token: "t", host: "h" })); + await chown(new URL("credentials.json", home), 65534, 65534); + await expect((await loadStore(home)).readCredentials()).rejects.toMatchObject({ + reason: "unsafe-file", + message: expect.stringContaining("because it belongs to another user"), + }); + }, + ); + + it("reads older files", async ({ home }) => { + const read = async (name: string, contents: string | undefined) => { + const dir = new URL(`${name}/`, home); + await mkdir(dir); + if (contents !== undefined) { + await writeFile(new URL("credentials.json", dir), contents, { mode: 0o644 }); + } + return (await loadStore(dir)).readCredentials(); + }; + + expect(await read("nothing", undefined)).toEqual({}); + expect(await read("host-only", JSON.stringify({ host: "wroom.io" }))).toEqual({ + host: "wroom.io", + }); + expect(await read("version-1", JSON.stringify({ token: "token", host: "wroom.io" }))).toEqual({ + host: "wroom.io", + }); + if (process.platform !== "win32") { + const mode = (await stat(new URL("version-1/credentials.json", home))).mode & 0o777; + expect(mode).toBe(0o600); + } + expect(await read("invalid", "{")).toEqual({}); + expect( + await read( + "unknown-reason", + JSON.stringify({ version: 2, store: "file", storeReason: "new-reason", ...session }), + ), + ).toEqual({ host: session.host, login: { store: "file", session } }); + await expect( + read("damaged", JSON.stringify({ version: 2, store: "file", host: null })), + ).rejects.toMatchObject({ reason: "decrypt-failed" }); + await expect(read("newer", JSON.stringify({ version: 3 }))).rejects.toMatchObject({ + reason: "unsupported-version", + }); + }); + + it("creates a key only under a lock it still holds", async ({ home }) => { + const store = await loadStore(home); + const stalled = await store.lockCredentials({ timeoutMs: 5_000 }); + // Another process took the lock over. + await rm(new URL("credentials.lock", home), { recursive: true }); + + await expect(store.prepareLogin(stalled)).rejects.toMatchObject({ reason: "lock-lost" }); + expect(keychain.items.size).toBe(0); + }); + + it("drops writes from a process that lost the lock", async ({ home }) => { + const store = await loadStore(home); + const stalled = await store.lockCredentials({ timeoutMs: 5_000 }); + + // Another process took the lock over. + await rm(new URL("credentials.lock", home), { recursive: true }); + const current = await store.lockCredentials({ timeoutMs: 5_000 }); + await store.writeCredentials(current, { store: "file", session }); + + await expect( + store.writeCredentials(stalled, { + store: "file", + session: { ...session, accessToken: "old" }, + }), + ).rejects.toMatchObject({ reason: "lock-lost" }); + await expect(store.clearCredentials(stalled)).rejects.toMatchObject({ reason: "lock-lost" }); + expect((await store.readCredentials()).login?.session).toEqual(session); + await current.release(); + }); + + it("logout deletes the session and the key, and keeps the host", async ({ home }) => { + const store = await loadStore(home); + await logIn(store); + + const lock = await store.lockCredentials({ timeoutMs: 5_000 }); + await store.clearCredentials(lock); + await lock.release(); + expect(await store.readCredentials()).toEqual({ host: session.host }); + expect(await readdir(home)).not.toContain("credentials.enc"); + expect(keychain.items.size).toBe(0); + + await logIn(store); + keychain.fail.delete = "locked"; + const again = await store.lockCredentials({ timeoutMs: 5_000 }); + await expect(store.clearCredentials(again)).rejects.toMatchObject({ + reason: "locked", + message: expect.stringContaining("Can't delete your Prismic key"), + }); + await again.release(); + expect(await store.readCredentials()).toEqual({ host: session.host }); + }); +}); + +it("round trips a key through the system keychain", async () => { + const { systemKeyring } = await vi.importActual("../src/lib/keyring"); + const service = "prismic-cli-test"; + const account = `test:${randomUUID()}`; + const options = { timeoutMs: 10_000 }; + try { + await systemKeyring.set(service, account, "key", options); + } catch (error) { + // Linux CI has no Secret Service. + expect(process.platform).toBe("linux"); + expect(error).toMatchObject({ reason: "unavailable" }); + return; + } + try { + expect(await systemKeyring.get(service, account, options)).toBe("key"); + } finally { + expect(await systemKeyring.delete(service, account, options)).toBe(true); + } + expect(await systemKeyring.get(service, account, options)).toBeUndefined(); + expect(await systemKeyring.delete(service, account, options)).toBe(false); +}); diff --git a/test/lock.test.ts b/test/lock.test.ts new file mode 100644 index 00000000..571da4ba --- /dev/null +++ b/test/lock.test.ts @@ -0,0 +1,150 @@ +// Temporary. These unit tests cover the credentials lock until `prismic login` +// uses it. C4, the PR that switches `prismic login` to the device flow, deletes +// this file and tests login end to end instead. + +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdir, readFile, rename, utimes, writeFile } from "node:fs/promises"; +import { setTimeout as sleep } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; + +import { expect } from "vitest"; + +import { acquireLock, LockTimeoutError } from "../src/lib/lock"; +import { it } from "./it"; + +// Node.js strips the types of the imported module. +function lockProcess(path: URL, script: string) { + const lockModule = new URL("../src/lib/lock.ts", import.meta.url).href; + const code = ` + import { acquireLock } from ${JSON.stringify(lockModule)}; + const path = new URL(process.argv[1]); + ${script} + `; + const child = spawn( + process.execPath, + ["--no-warnings", "--input-type=module", "-e", code, path.href], + { stdio: ["pipe", "pipe", "inherit"] }, + ); + let stdout = ""; + child.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk)); + return { + child, + output: () => stdout, + exit: async () => { + const [code] = child.exitCode === null ? await once(child, "exit") : [child.exitCode]; + return code; + }, + }; +} + +it("lets one process at a time hold the lock", async ({ home }) => { + const path = new URL("test.lock", home); + const counter = new URL("counter", home); + await writeFile(counter, "0"); + + // Each process reads the counter, waits, then writes it plus one. Without + // the lock, processes would overwrite each other's increments. + const processes = Array.from({ length: 10 }, () => + lockProcess( + path, + ` + import { readFile, rename, writeFile } from "node:fs/promises"; +import { setTimeout as sleep } from "node:timers/promises"; + const counter = ${JSON.stringify(fileURLToPath(counter))}; + const lock = await acquireLock(path, { timeoutMs: 30_000 }); + const count = Number(await readFile(counter, "utf8")); + await new Promise((resolve) => setTimeout(resolve, 50)); + if (await lock.isHeld()) await writeFile(counter, String(count + 1)); + await lock.release(); + `, + ), + ); + expect(await Promise.all(processes.map((process) => process.exit()))).toEqual(Array(10).fill(0)); + expect(await readFile(counter, "utf8")).toBe("10"); +}); + +it("takes over the lock of a process that exited", async ({ home }) => { + const path = new URL("test.lock", home); + const owner = lockProcess(path, `await acquireLock(path, { timeoutMs: 5_000 });`); + expect(await owner.exit()).toBe(0); + + // The owner's PID is gone, so this doesn't wait for the lock to go stale. + const started = Date.now(); + const lock = await acquireLock(path, { timeoutMs: 5_000, staleMs: 60_000 }); + expect(Date.now() - started).toBeLessThan(2_000); + expect(await lock.isHeld()).toBe(true); + await lock.release(); +}); + +it.skipIf(process.platform === "win32")( + "takes over the lock of a stalled process, which then drops its write", + async ({ home }) => { + const path = new URL("test.lock", home); + const owner = lockProcess( + path, + ` + const lock = await acquireLock(path, { timeoutMs: 5_000, staleMs: 1_000, heartbeatMs: 200 }); + console.log("held"); + process.stdin.once("data", async () => { + console.log((await lock.isHeld()) ? "still held" : "lost"); + process.exit(); + }); + `, + ); + while (!owner.output().includes("held")) await once(owner.child.stdout, "data"); + + // A stopped process is alive but stops touching the lock. + owner.child.kill("SIGSTOP"); + const lock = await acquireLock(path, { timeoutMs: 10_000, staleMs: 1_000 }); + owner.child.kill("SIGCONT"); + + owner.child.stdin.write("check\n"); + expect(await owner.exit()).toBe(0); + expect(owner.output()).toContain("lost"); + expect(await lock.isHeld()).toBe(true); + await lock.release(); + }, +); + +it("keeps the heartbeat after the lock briefly disappears", async ({ home }) => { + const path = new URL("test.lock", home); + const hidden = new URL("hidden.lock", home); + const holder = await acquireLock(path, { timeoutMs: 1_000, staleMs: 500, heartbeatMs: 50 }); + + // A recovering waiter renames the lock away, then puts it back. + await rename(path, hidden); + await sleep(150); + await rename(hidden, path); + + await sleep(700); + await expect(acquireLock(path, { timeoutMs: 200, staleMs: 500 })).rejects.toBeInstanceOf( + LockTimeoutError, + ); + expect(await holder.isHeld()).toBe(true); + await holder.release(); +}); + +it("takes over a lock that no process claimed", async ({ home }) => { + const path = new URL("test.lock", home); + // A process died between creating the lock and writing its owner. + await mkdir(path); + const past = new Date(Date.now() - 2_000); + await utimes(path, past, past); + + const lock = await acquireLock(path, { timeoutMs: 1_000, staleMs: 60_000 }); + expect(await lock.isHeld()).toBe(true); + await lock.release(); +}); + +it("waits for a live holder, then times out", async ({ home }) => { + const path = new URL("test.lock", home); + const holder = await acquireLock(path, { timeoutMs: 1_000 }); + + await expect(acquireLock(path, { timeoutMs: 300 })).rejects.toBeInstanceOf(LockTimeoutError); + + await holder.release(); + expect(await holder.isHeld()).toBe(false); + const next = await acquireLock(path, { timeoutMs: 300 }); + await next.release(); +}); From 962dbfb96e802188b5184bd9b21a845afe40eec9 Mon Sep 17 00:00:00 2001 From: Lucie <25330882+lihbr@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:05:43 +0900 Subject: [PATCH 05/11] chore(analytics): add login details to the End event, not sent yet (#472) Adds login details to the "Prismic CLI End" event instead of new events. Nothing sets them yet: store and storeReason on every command (initTracking), loginResult, loginResumed and loginDurationS after a login (trackLogin), and revoked after a logout (trackLogout). getLoginResult maps DeviceFlowError reasons onto loginResult. Start and End stay the same until C4 calls these, with temporary tests. Co-Authored-By: Claude Opus 5.5 --- src/tracking.ts | 57 +++++++++++++ test/tracking.test.ts | 186 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 test/tracking.test.ts diff --git a/src/tracking.ts b/src/tracking.ts index 5cc8c8f0..254002c9 100644 --- a/src/tracking.ts +++ b/src/tracking.ts @@ -5,8 +5,10 @@ import { pathToFileURL } from "node:url"; import * as z from "zod/mini"; import { ANALYTICS_IDS_PATH } from "./config"; +import type { FallbackReason, StoreMode } from "./credentials"; import { DEFAULT_PRISMIC_HOST, env } from "./env"; import { detectAgent } from "./lib/ai"; +import type { DeviceFlowError } from "./lib/auth0"; import { readJsonFile, writeFileRecursive } from "./lib/file"; import { stringify } from "./lib/json"; import { decodePayload } from "./lib/jwt"; @@ -33,6 +35,15 @@ const usesStoredUser = !env.PRISMIC_TOKEN; // it answers for the user who just left. let forgotUser = false; +export type LoginResult = + | "success" + | "expired" + | "declined" + | "email-unverified" + | "cancelled" + | "wait-limit" + | "error"; + let repository: string | undefined; let agent: string | undefined; let auth: string | undefined; @@ -40,6 +51,10 @@ let runtime: string | undefined; let runtimeProvider: string | undefined; let userIntent: string | undefined; let taskId: string | undefined; +let store: StoreMode | undefined; +let storeReason: FallbackReason | undefined; +let login: { result: LoginResult; resumed: boolean; durationS: number } | undefined; +let revoked: boolean | undefined; let ids: AnalyticsIds | undefined; export async function initTracking(config: { @@ -48,11 +63,15 @@ export async function initTracking(config: { repo?: string; userIntent?: string; taskId?: string; + store?: StoreMode; + storeReason?: FallbackReason; }): Promise { const { host, repo } = config; if (repo) repository = repo; userIntent = config.userIntent; taskId = config.taskId; + store = config.store; + storeReason = config.storeReason; const writeKey = host === DEFAULT_PRISMIC_HOST ? PROD_WRITE_KEY : STAGING_WRITE_KEY; agent = detectAgent(); auth = detectAuth(config.token); @@ -204,6 +223,12 @@ export function trackCommandEnd( error: errorMessage?.slice(0, 512), agent, auth, + store, + storeReason, + loginResult: login?.result, + loginResumed: login?.resumed, + loginDurationS: login?.durationS, + revoked, runtime, runtimeProvider, userIntent, @@ -213,6 +238,38 @@ export function trackCommandEnd( }); } +// Adds to the command's End event. `startedAt` is saved with the pending login, +// so a resumed login counts from its first run. +export function trackLogin(config: { + result: LoginResult; + resumed: boolean; + startedAt: number; + store?: StoreMode; + storeReason?: FallbackReason; +}): void { + const { result, resumed, startedAt } = config; + login = { result, resumed, durationS: Math.round((Date.now() - startedAt) / 1000) }; + if (config.store) ({ store, storeReason } = config); +} + +// Adds to the command's End event. +export function trackLogout(config: { revoked: boolean }): void { + revoked = config.revoked; +} + +const LOGIN_RESULTS: Record = { + expired: "expired", + declined: "declined", + "email-unverified": "email-unverified", + "invalid-grant": "error", + misconfigured: "error", + unavailable: "error", +}; + +export function getLoginResult(error: DeviceFlowError): LoginResult { + return LOGIN_RESULTS[error.reason]; +} + const PrismicRcSchema = z.object({ telemetry: z.boolean(), }); diff --git a/test/tracking.test.ts b/test/tracking.test.ts new file mode 100644 index 00000000..8918cb9b --- /dev/null +++ b/test/tracking.test.ts @@ -0,0 +1,186 @@ +// Temporary. These unit tests cover the login details on the End event until +// commands send them. C4, the PR that switches `prismic login` to the device +// flow, deletes this file and tests them end to end instead. + +import { randomBytes } from "node:crypto"; +import { fileURLToPath } from "node:url"; + +import { afterEach, beforeEach, describe, vi } from "vitest"; + +import type { FallbackReason, StoreMode } from "../src/credentials"; +import { it } from "./it"; + +// Replaces Segment, so tests never send events. +const events = vi.hoisted(() => [] as { event: string; properties?: Record }[]); + +vi.mock("../src/lib/segment", () => ({ + initSegment: () => {}, + trackIdentity: () => {}, + trackEvent: (event: string, config: { properties?: Record } = {}) => { + events.push({ event, properties: config.properties }); + }, +})); + +// src/env.ts requires PROD, which the build defines. Vitest sets it to "". +process.env.PROD = "false"; + +// Imports tracking as a new process would, with its own config directory. +async function loadTracking( + home: URL, + config: { token?: string; store?: StoreMode; storeReason?: FallbackReason } = {}, +) { + vi.resetModules(); + vi.stubEnv("PRISMIC_CONFIG_DIR", fileURLToPath(home)); + vi.stubEnv("AI_AGENT", "test-agent"); + // Gives the same runtime on every machine. + vi.stubEnv("CLAUDE_CODE_REMOTE", "true"); + const tracking = await import("../src/tracking"); + await tracking.initTracking({ host: "wroom.io", ...config }); + return tracking; +} + +function getLoginProperties() { + const { store, storeReason, loginResult, loginResumed, loginDurationS, revoked } = + events.at(-1)?.properties ?? {}; + return { store, storeReason, loginResult, loginResumed, loginDurationS, revoked }; +} + +// The tests share the recorded events and the environment. +describe("login details on the End event", { concurrent: false }, () => { + beforeEach(() => { + events.length = 0; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("adds the store, the login and the logout", async ({ expect, home }) => { + let tracking = await loadTracking(home, { + store: "file", + storeReason: "secret-service-missing", + }); + tracking.trackCommandEnd("repo-list"); + expect(getLoginProperties()).toEqual({ + store: "file", + storeReason: "secret-service-missing", + }); + + tracking = await loadTracking(home); + tracking.trackLogin({ result: "wait-limit", resumed: false, startedAt: Date.now() - 90_000 }); + tracking.trackCommandEnd("repo-list"); + expect(getLoginProperties()).toEqual({ + loginResult: "wait-limit", + loginResumed: false, + loginDurationS: 90, + }); + + tracking = await loadTracking(home); + tracking.trackLogin({ + result: "success", + resumed: true, + startedAt: Date.now() - 120_000, + store: "keyring", + }); + tracking.trackCommandEnd("repo-list"); + expect(getLoginProperties()).toEqual({ + store: "keyring", + loginResult: "success", + loginResumed: true, + loginDurationS: 120, + }); + + tracking = await loadTracking(home, { store: "keyring" }); + tracking.trackLogout({ revoked: false }); + tracking.trackCommandEnd("logout"); + expect(getLoginProperties()).toEqual({ store: "keyring", revoked: false }); + }); + + it("leaves Start and End unchanged until something sets them", async ({ expect, home }) => { + const tracking = await loadTracking(home); + tracking.trackCommandStart("repo-list"); + tracking.trackCommandEnd("repo-list"); + + // Segment receives JSON, which drops undefined properties. + const sent = events.map(({ event, properties }) => [ + event, + Object.keys(JSON.parse(JSON.stringify(properties))), + ]); + const context = ["agent", "auth", "runtime", "runtimeProvider"]; + expect(sent).toEqual([ + ["Prismic CLI Start", ["commandType", "fullCommand", ...context]], + ["Prismic CLI End", ["commandType", "fullCommand", "success", ...context]], + ]); + }); + + it("maps device flow errors onto login results", async ({ expect, home }) => { + const tracking = await loadTracking(home); + const { DeviceFlowError } = await import("../src/lib/auth0"); + const reasons = [ + "expired", + "declined", + "email-unverified", + "invalid-grant", + "misconfigured", + "unavailable", + ] as const; + + const results = reasons.map((reason) => [ + reason, + tracking.getLoginResult(new DeviceFlowError(reason)), + ]); + expect(Object.fromEntries(results)).toEqual({ + expired: "expired", + declined: "declined", + "email-unverified": "email-unverified", + "invalid-grant": "error", + misconfigured: "error", + unavailable: "error", + }); + }); + + it("never sends tokens, codes or the key", async ({ expect, home }) => { + const key = randomBytes(32).toString("base64"); + const auth0 = { host: "prismic.io", auth0Domain: "auth0.prismic.io", clientId: "client-id" }; + const pending = { + ...auth0, + deviceCode: "device-code-secret", + userCode: "BCDF-GHJK", + verificationUri: "https://auth0.prismic.io/activate", + verificationUriComplete: "https://auth0.prismic.io/activate?user_code=BCDF-GHJK", + expiresAt: Date.now() + 900_000, + intervalMs: 5_000, + startedAt: Date.now(), + }; + const login = { + store: "keyring", + key, + session: { + ...auth0, + accessToken: "access-token-secret", + refreshToken: "refresh-token-secret", + pendingLogin: pending, + }, + } as const; + + // Callers hold a whole pending login and saved login, not just the fields + // the End event needs. + const tracking = await loadTracking(home, { token: "access-token-secret", ...login }); + tracking.trackLogin({ ...pending, ...login, result: "success", resumed: true }); + tracking.trackLogout({ revoked: true }); + tracking.trackCommandStart("login"); + tracking.trackCommandEnd("login"); + + expect(events).toHaveLength(2); + const sent = JSON.stringify(events); + for (const secret of [ + "access-token-secret", + "refresh-token-secret", + "device-code-secret", + "BCDF-GHJK", + key, + ]) { + expect(sent).not.toContain(secret); + } + }); +}); From 53a7981a80e67cf7f89e81f327e2a50b3840ef0a Mon Sep 17 00:00:00 2001 From: Lucie <25330882+lihbr@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:08:39 +0900 Subject: [PATCH 06/11] chore(auth): refresh logins before they expire, not used yet (#473) Adds token refresh for device flow logins. Nothing calls it yet: getSessionToken() in src/auth.ts (returns the saved token while it has more than 5 minutes left, otherwise refreshes under the credentials lock with the tenant saved with the session, ends the session on invalid_grant, and keeps the current token while Auth0 can't be reached) and refreshAccessToken() in src/lib/auth0.ts, with temporary tests. Co-Authored-By: Claude Opus 5.5 --- src/auth.ts | 99 +++++++- src/lib/auth0.ts | 35 +++ test/session-token.test.ts | 450 +++++++++++++++++++++++++++++++++++++ 3 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 test/session-token.test.ts diff --git a/src/auth.ts b/src/auth.ts index 4470af45..5977b006 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -7,10 +7,18 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import * as z from "zod/mini"; import { CREDENTIALS_PATH } from "./config"; +import { + lockCredentials, + readCredentials as readStoredCredentials, + writeCredentials, + type Session, + type StoredLogin, +} from "./credentials"; import { DEFAULT_PRISMIC_HOST, env } from "./env"; -import type { Auth0Config } from "./lib/auth0"; +import { type Auth0Config, DeviceFlowError, refreshAccessToken } from "./lib/auth0"; import { exists, readJsonFile, writeFileRecursive } from "./lib/file"; import { stringify } from "./lib/json"; +import type { Lock } from "./lib/lock"; import { refreshToken as baseRefreshToken, getUserProfile, @@ -21,8 +29,10 @@ import { appendTrailingSlash } from "./lib/url"; import { forgetTrackedUser } from "./tracking"; const LOGIN_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes +const REFRESH_MARGIN_MS = 5 * 60 * 1000; // 5 minutes const PREFERRED_PORT = 5555; const LOGIN_SOURCE = "prismic-cli"; +const CREDENTIALS_LOCK_TIMEOUT_MS = 30_000; const CredentialsSchema = z.looseObject({ token: z.optional(z.string().check(z.minLength(1))), @@ -54,6 +64,93 @@ export function getAuth0Config(host: string): Auth0Config | undefined { } } +// "ended" means Auth0 ended the session, so the caller can say the login +// expired and start a new one. +export type SessionToken = + | { status: "active"; token: string } + | { status: "none" } + | { status: "ended" }; + +let renewal: Promise | undefined; + +// `forceRefresh` renews a token that a server refused, even if it's not about +// to expire. +export async function getSessionToken( + options: { forceRefresh?: boolean } = {}, +): Promise { + const { forceRefresh = false } = options; + const session = (await readStoredCredentials()).login?.session; + if (!session?.refreshToken) return { status: "none" }; + + const freshToken = getUnexpiredToken(session, REFRESH_MARGIN_MS); + if (freshToken && !forceRefresh) return { status: "active", token: freshToken }; + + // Parallel calls share one renewal. + renewal ??= renewSession(session.accessToken).finally(() => (renewal = undefined)); + try { + return await renewal; + } catch (error) { + // Without Auth0, the current token works until it expires, unless a + // server already refused it. + const token = getUnexpiredToken(session); + if (error instanceof DeviceFlowError && token && !forceRefresh) { + return { status: "active", token }; + } + throw error; + } +} + +async function renewSession(staleToken: string | undefined): Promise { + const lock = await lockCredentials({ timeoutMs: CREDENTIALS_LOCK_TIMEOUT_MS }); + try { + const { login: storedLogin } = await readStoredCredentials(); + return await renewLogin(lock, storedLogin, staleToken, { canRetry: true }); + } finally { + await lock.release(); + } +} + +async function renewLogin( + lock: Lock, + storedLogin: StoredLogin | undefined, + staleToken: string | undefined, + options: { canRetry: boolean }, +): Promise { + const session = storedLogin?.session; + if (!storedLogin || !session?.refreshToken) return { status: "none" }; + + // Another process renewed it while this one waited for the lock. + const freshToken = getUnexpiredToken(session, REFRESH_MARGIN_MS); + if (freshToken && freshToken !== staleToken) return { status: "active", token: freshToken }; + + const { refreshToken, auth0Domain, clientId, host } = session; + const tokens = await refreshAccessToken(refreshToken, { auth0Domain, clientId }); + if (tokens) { + await writeCredentials(lock, { ...storedLogin, session: { ...session, ...tokens } }); + return { status: "active", token: tokens.accessToken }; + } + + // If the refresh token changed meanwhile, retry once with it, but only while + // holding the lock: using it without saving the result would end that + // session. + const latest = (await readStoredCredentials()).login; + if (options.canRetry && latest?.session.refreshToken !== refreshToken && (await lock.isHeld())) { + return renewLogin(lock, latest, staleToken, { canRetry: false }); + } + + // The write fails if another process took the lock over. + await writeCredentials(lock, { + ...storedLogin, + session: { host, auth0Domain, clientId, pendingLogin: session.pendingLogin }, + }); + return { status: "ended" }; +} + +function getUnexpiredToken(session: Session, marginMs = 0): string | undefined { + const { accessToken, expiresAt } = session; + if (accessToken && expiresAt && expiresAt - marginMs > Date.now()) return accessToken; +} + export async function refreshToken(): Promise { if (env.PRISMIC_TOKEN) return; const token = await getToken(); diff --git a/src/lib/auth0.ts b/src/lib/auth0.ts index 76fcc770..8a029bbe 100644 --- a/src/lib/auth0.ts +++ b/src/lib/auth0.ts @@ -141,6 +141,41 @@ export async function pollDeviceLogin( } } +// Auth0 rotates refresh tokens, but OAuth lets a server keep the same one. +const RefreshedTokenSchema = z.object({ + access_token: z.string(), + refresh_token: z.optional(z.string()), + expires_in: z.number(), +}); + +// Returns nothing when Auth0 refuses the refresh token, since the session has +// ended. +export async function refreshAccessToken( + refreshToken: string, + config: Auth0Config, +): Promise { + const url = new URL("oauth/token", getAuth0Url(config.auth0Domain)); + const tokens = await request(url, { + method: "POST", + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: config.clientId, + }), + signal: getRequestSignal(), + schema: RefreshedTokenSchema, + }).catch((error) => { + if (getOAuthError(error)?.error === "invalid_grant") return undefined; + throw new DeviceFlowError(isTransient(error) ? "unavailable" : "misconfigured", error); + }); + if (!tokens) return; + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? refreshToken, + expiresAt: Date.now() + tokens.expires_in * 1000, + }; +} + function getRequestSignal(signal?: AbortSignal): AbortSignal { const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); return signal ? AbortSignal.any([timeout, signal]) : timeout; diff --git a/test/session-token.test.ts b/test/session-token.test.ts new file mode 100644 index 00000000..1848d493 --- /dev/null +++ b/test/session-token.test.ts @@ -0,0 +1,450 @@ +// Temporary. These unit tests cover the token refresh until commands use it. +// C4, the PR that switches `prismic login` to the device flow, deletes this +// file and tests refresh end to end instead. They use a fake Auth0 tenant, +// since a real login needs a browser approval. + +import { readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { beforeEach, describe, expect, vi, type TestContext } from "vitest"; + +import type * as Credentials from "../src/credentials"; +import { pollDeviceLogin, startDeviceLogin } from "../src/lib/auth0"; +import type * as Keyring from "../src/lib/keyring"; +import { createFakeAuth0, type FakeAuth0 } from "./auth0"; +import { it } from "./it"; + +const keychain = vi.hoisted(() => ({ + items: new Map(), + fail: {} as Partial>, +})); + +vi.mock("../src/lib/keyring", async (importOriginal) => { + const actual = await importOriginal(); + const check = (call: keyof typeof keychain.fail) => { + const reason = keychain.fail[call]; + if (reason) throw new actual.KeyringError(reason); + }; + const systemKeyring: Keyring.Keyring = { + async get(service, account) { + check("get"); + return keychain.items.get(`${service} ${account}`); + }, + async set(service, account, password) { + check("set"); + keychain.items.set(`${service} ${account}`, password); + }, + async delete(service, account) { + check("delete"); + return keychain.items.delete(`${service} ${account}`); + }, + }; + return { ...actual, systemKeyring }; +}); + +const INVALID_GRANT = { status: 403, body: { error: "invalid_grant" } }; +const PARAMS = { audience: "prismic.io", scope: "openid profile email offline_access" }; + +// src/env.ts requires PROD, which the build defines. Vitest sets it to "". +process.env.PROD = "false"; + +// Imports the modules as a new process would, with its own config directory. +async function load(dir: URL) { + vi.resetModules(); + process.env.PRISMIC_CONFIG_DIR = fileURLToPath(dir); + return { auth: await import("../src/auth"), store: await import("../src/credentials") }; +} + +async function startFakeAuth0(onTestFinished: TestContext["onTestFinished"]) { + const auth0 = await createFakeAuth0({ interval: 0 }); + onTestFinished(() => auth0.close()); + return auth0; +} + +async function approveLogin(auth0: FakeAuth0) { + const { auth0Domain, clientId } = auth0; + const pending = await startDeviceLogin(PARAMS, { auth0Domain, clientId }); + auth0.approve(); + return pollDeviceLogin(pending); +} + +// Saves a login whose access token expires in a minute. +async function logIn( + auth0: FakeAuth0, + store: typeof Credentials, + overrides: Partial = {}, +): Promise { + const session = { + host: "prismic.io", + auth0Domain: auth0.auth0Domain, + clientId: auth0.clientId, + email: "dev@example.com", + ...(await approveLogin(auth0)), + expiresAt: Date.now() + 60_000, + ...overrides, + }; + const lock = await store.lockCredentials({ timeoutMs: 5_000 }); + const choice = await store.prepareLogin(lock); + await store.writeCredentials(lock, { ...choice, session }); + await lock.release(); + return session; +} + +async function readSession(dir: URL) { + return (await (await load(dir)).store.readCredentials()).login?.session; +} + +function refreshRequests(auth0: FakeAuth0) { + return auth0.requests + .filter((request) => request.params.grant_type === "refresh_token") + .map((request) => request.params); +} + +// The tests share the keychain and the environment. +describe("getSessionToken", { concurrent: false }, () => { + beforeEach(() => { + keychain.items.clear(); + keychain.fail = {}; + }); + + it("returns a token with more than 5 minutes left, without the lock or Auth0", async ({ + home, + onTestFinished, + }) => { + const auth0 = await startFakeAuth0(onTestFinished); + const { auth, store } = await load(home); + const session = await logIn(auth0, store, { expiresAt: Date.now() + 6 * 60_000 }); + + // Another process holds the lock. + const lock = await store.lockCredentials({ timeoutMs: 5_000 }); + expect(await auth.getSessionToken()).toEqual({ status: "active", token: session.accessToken }); + await lock.release(); + expect(refreshRequests(auth0)).toEqual([]); + }); + + it("refreshes a token about to expire where it was issued, without writing to the keychain", async ({ + home, + onTestFinished, + }) => { + const auth0 = await startFakeAuth0(onTestFinished); + const { auth, store } = await load(home); + const session = await logIn(auth0, store); + const key = [...keychain.items]; + keychain.fail.set = "rejected"; + + const startedAt = Date.now(); + const result = await auth.getSessionToken(); + expect(refreshRequests(auth0)).toEqual([ + { + grant_type: "refresh_token", + refresh_token: session.refreshToken, + client_id: auth0.clientId, + }, + ]); + const saved = await readSession(home); + expect(result).toEqual({ status: "active", token: saved?.accessToken }); + expect(saved).toEqual({ + ...session, + accessToken: expect.any(String), + refreshToken: expect.any(String), + expiresAt: expect.any(Number), + }); + expect(saved?.accessToken).not.toBe(session.accessToken); + expect(saved?.refreshToken).not.toBe(session.refreshToken); + expect(saved?.expiresAt).toBeGreaterThanOrEqual(startedAt + 3_600_000); + const plain = JSON.parse(await readFile(new URL("credentials.json", home), "utf8")); + expect(plain.expiresAt).toBe(saved?.expiresAt); + expect([...keychain.items]).toEqual(key); + }); + + it("keeps the refresh token when Auth0 doesn't send a new one", async ({ + home, + onTestFinished, + }) => { + const auth0 = await startFakeAuth0(onTestFinished); + const { auth, store } = await load(home); + const session = await logIn(auth0, store); + auth0.respondOnce("/oauth/token", { + status: 200, + body: { access_token: "new", expires_in: 3_600 }, + }); + + expect(await auth.getSessionToken()).toEqual({ status: "active", token: "new" }); + expect((await readSession(home))?.refreshToken).toBe(session.refreshToken); + }); + + it("renews a token that isn't about to expire with forceRefresh, and reports no session", async ({ + home, + onTestFinished, + }) => { + const auth0 = await startFakeAuth0(onTestFinished); + const { auth, store } = await load(home); + expect(await auth.getSessionToken()).toEqual({ status: "none" }); + expect(await auth.getSessionToken({ forceRefresh: true })).toEqual({ status: "none" }); + + const session = await logIn(auth0, store, { expiresAt: Date.now() + 3_600_000 }); + const result = await auth.getSessionToken({ forceRefresh: true }); + expect(refreshRequests(auth0)).toHaveLength(1); + expect(result).toEqual({ status: "active", token: (await readSession(home))?.accessToken }); + expect(result).not.toEqual({ status: "active", token: session.accessToken }); + }); + + it("ends the session on invalid_grant, and keeps the host, the key and a pending login", async ({ + home, + onTestFinished, + }) => { + const auth0 = await startFakeAuth0(onTestFinished); + const { auth, store } = await load(home); + const pendingLogin = { + auth0Domain: auth0.auth0Domain, + clientId: auth0.clientId, + deviceCode: "device-code", + userCode: "BCDF-GHJK", + verificationUri: `${auth0.auth0Domain}/activate`, + expiresAt: Date.now() + 900_000, + intervalMs: 5_000, + }; + // The fake tenant doesn't know this refresh token, as after a revoke. + await logIn(auth0, store, { refreshToken: "revoked", pendingLogin }); + const key = [...keychain.items]; + + expect(await auth.getSessionToken()).toEqual({ status: "ended" }); + expect(refreshRequests(auth0)).toHaveLength(1); + expect(await (await load(home)).store.readCredentials()).toEqual({ + host: "prismic.io", + login: { + store: "keyring", + session: { + host: "prismic.io", + auth0Domain: auth0.auth0Domain, + clientId: auth0.clientId, + pendingLogin, + }, + }, + }); + expect([...keychain.items]).toEqual(key); + + expect(await auth.getSessionToken()).toEqual({ status: "none" }); + expect(refreshRequests(auth0)).toHaveLength(1); + }); + + it("retries once with a refresh token that changed during the refresh", async ({ + home, + onTestFinished, + }) => { + const auth0 = await startFakeAuth0(onTestFinished); + // A file login, so the test can save a session as another process would. + keychain.fail.set = "unavailable"; + const { auth, store } = await load(home); + const session = await logIn(auth0, store); + const writeSession = (saved: Credentials.Session) => + writeFile( + new URL("credentials.json", home), + JSON.stringify({ + version: 2, + store: "file", + storeReason: "secret-service-missing", + ...saved, + }), + ); + const approveNewer = async () => ({ + ...session, + ...(await approveLogin(auth0)), + expiresAt: Date.now() + 60_000, + }); + + // Something else saves a newer session while Auth0 rejects the first try. + const newer = await approveNewer(); + auth0.respondOnce("/oauth/token", { ...INVALID_GRANT, delayMs: 500 }); + const result = auth.getSessionToken(); + await vi.waitFor(() => expect(refreshRequests(auth0)).toHaveLength(1)); + await writeSession(newer); + expect(await result).toEqual({ + status: "active", + token: (await readSession(home))?.accessToken, + }); + expect(refreshRequests(auth0).map((params) => params.refresh_token)).toEqual([ + session.refreshToken, + newer.refreshToken, + ]); + + // Only once. + const newest = await approveNewer(); + auth0.respondOnce("/oauth/token", { ...INVALID_GRANT, delayMs: 500 }); + auth0.respondOnce("/oauth/token", INVALID_GRANT); + const ended = auth.getSessionToken({ forceRefresh: true }); + await vi.waitFor(() => expect(refreshRequests(auth0)).toHaveLength(3)); + await writeSession(newest); + expect(await ended).toEqual({ status: "ended" }); + expect(refreshRequests(auth0)).toHaveLength(4); + expect(await readSession(home)).toEqual({ + host: "prismic.io", + auth0Domain: auth0.auth0Domain, + clientId: auth0.clientId, + }); + }); + + it("uses the current token while Auth0 can't refresh it, and never deletes the login", async ({ + home, + onTestFinished, + }) => { + const auth0 = await startFakeAuth0(onTestFinished); + const { auth, store } = await load(home); + const session = await logIn(auth0, store); + const current = { status: "active", token: session.accessToken }; + const secrets = [session.accessToken, session.refreshToken]; + + auth0.respondOnce("/oauth/token", { status: 503 }); + expect(await auth.getSessionToken()).toEqual(current); + auth0.respondOnce("/oauth/token", { status: 401, body: { error: "invalid_client" } }); + expect(await auth.getSessionToken()).toEqual(current); + // Longer than the 10-second timeout. + auth0.respondOnce("/oauth/token", { status: 503, delayMs: 11_000 }); + expect(await auth.getSessionToken()).toEqual(current); + + // A server already refused the current token. The 503 echoes a token to + // check that errors never carry it. + auth0.respondOnce("/oauth/token", { + status: 503, + body: { error: "temporarily_unavailable", error_description: session.refreshToken }, + }); + const forced = await auth.getSessionToken({ forceRefresh: true }).catch((error) => error); + expect(forced).toMatchObject({ reason: "unavailable", detail: "temporarily_unavailable" }); + expect(forced.cause).toBeUndefined(); + for (const secret of secrets) expect(`${forced.stack} ${forced.message}`).not.toContain(secret); + + auth0.respondOnce("/oauth/token", { status: 401, body: { error: "invalid_client" } }); + await expect(auth.getSessionToken({ forceRefresh: true })).rejects.toMatchObject({ + reason: "misconfigured", + detail: "invalid_client", + }); + + const expired = new URL("expired/", home); + const expiredSession = await logIn(auth0, (await load(expired)).store, { + expiresAt: Date.now() - 1_000, + }); + const { auth: expiredAuth } = await load(expired); + await auth0.close(); + await expect(expiredAuth.getSessionToken()).rejects.toMatchObject({ + reason: "unavailable", + detail: "network error", + }); + + expect(await readSession(home)).toEqual(session); + expect(await readSession(expired)).toEqual(expiredSession); + expect(refreshRequests(auth0)).toHaveLength(5); + }); + + it("shares one refresh between parallel calls in one process", async ({ + home, + onTestFinished, + }) => { + const auth0 = await startFakeAuth0(onTestFinished); + const { auth, store } = await load(home); + await logIn(auth0, store); + + const results = await Promise.all(Array.from({ length: 10 }, () => auth.getSessionToken())); + expect(refreshRequests(auth0)).toHaveLength(1); + expect(results).toEqual( + Array(10).fill({ status: "active", token: (await readSession(home))?.accessToken }), + ); + + const forced = await Promise.all( + Array.from({ length: 10 }, () => auth.getSessionToken({ forceRefresh: true })), + ); + expect(refreshRequests(auth0)).toHaveLength(2); + expect(forced).toEqual( + Array(10).fill({ status: "active", token: (await readSession(home))?.accessToken }), + ); + }); + + it("makes one refresh for 10 parallel commands with a token about to expire, and none logs out", async ({ + home, + onTestFinished, + }) => { + const auth0 = await startFakeAuth0(onTestFinished); + await logIn(auth0, (await load(home)).store); + const commands = []; + for (let i = 0; i < 10; i++) commands.push((await load(home)).auth); + + const results = await Promise.all(commands.map((auth) => auth.getSessionToken())); + expect(refreshRequests(auth0)).toHaveLength(1); + expect(results).toEqual( + Array(10).fill({ status: "active", token: (await readSession(home))?.accessToken }), + ); + }); + + it("drops the write of a stalled refresh that lost the lock", async ({ + home, + onTestFinished, + }) => { + const auth0 = await startFakeAuth0(onTestFinished); + const { auth: stalled, store } = await load(home); + await logIn(auth0, store); + + auth0.respondOnce("/oauth/token", { + status: 200, + body: { access_token: "stalled", refresh_token: "stalled", expires_in: 3_600 }, + delayMs: 1_000, + }); + const stalledResult = stalled.getSessionToken().catch((error) => error); + await vi.waitFor(() => expect(refreshRequests(auth0)).toHaveLength(1)); + + // It stalls long enough for another process to take the lock over. + await rm(new URL("credentials.lock", home), { recursive: true }); + const result = await (await load(home)).auth.getSessionToken(); + + expect(await stalledResult).toMatchObject({ reason: "lock-lost" }); + const saved = await readSession(home); + expect(result).toEqual({ status: "active", token: saved?.accessToken }); + expect(saved?.refreshToken).not.toBe("stalled"); + }); + + it("doesn't retry or end the session after losing the lock", async ({ home, onTestFinished }) => { + const auth0 = await startFakeAuth0(onTestFinished); + const { auth: stalled, store } = await load(home); + await logIn(auth0, store); + + auth0.respondOnce("/oauth/token", { ...INVALID_GRANT, delayMs: 1_000 }); + const stalledResult = stalled.getSessionToken().catch((error) => error); + await vi.waitFor(() => expect(refreshRequests(auth0)).toHaveLength(1)); + + // Another process takes the lock over, and saves a newer session. + await rm(new URL("credentials.lock", home), { recursive: true }); + const other = await load(home); + const newer = await logIn(auth0, other.store); + + expect(await stalledResult).toMatchObject({ reason: "lock-lost" }); + expect(refreshRequests(auth0)).toHaveLength(1); + expect(await readSession(home)).toEqual(newer); + }); + + it("doesn't restore a session after a logout that started during the refresh", async ({ + home, + onTestFinished, + }) => { + const auth0 = await startFakeAuth0(onTestFinished); + const { auth: refreshing, store } = await load(home); + await logIn(auth0, store); + + auth0.respondOnce("/oauth/token", { + status: 200, + body: { access_token: "late", refresh_token: "late", expires_in: 3_600 }, + delayMs: 1_000, + }); + const result = refreshing.getSessionToken().catch((error) => error); + await vi.waitFor(() => expect(refreshRequests(auth0)).toHaveLength(1)); + + // The refresh stalls long enough for the logout to take the lock over. + await rm(new URL("credentials.lock", home), { recursive: true }); + const logout = await load(home); + const lock = await logout.store.lockCredentials({ timeoutMs: 5_000 }); + await logout.store.clearCredentials(lock); + await lock.release(); + + expect(await result).toMatchObject({ reason: "lock-lost" }); + expect(await logout.store.readCredentials()).toEqual({ host: "prismic.io" }); + expect(await readdir(home)).not.toContain("credentials.enc"); + expect(keychain.items.size).toBe(0); + }); +}); From 20b96853175c8545eaefffe12fec3c5de11daefa Mon Sep 17 00:00:00 2001 From: Lucie <25330882+lihbr@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:18:13 +0900 Subject: [PATCH 07/11] chore(auth): revoke sessions on logout and re-login, not used yet (#470) Adds revokeRefreshToken() to src/lib/auth0.ts, and endSession() and replaceSession() to src/auth.ts. endSession() revokes the session at the Auth0 tenant that issued it, then deletes the saved login, the keychain key and the tracked user, keeps the host, and returns { revoked, warnings }. replaceSession() saves a new login, then revokes the previous session. Nothing calls them until C4. Temporary tests use the fake Auth0 server. Co-Authored-By: Claude Opus 5.5 --- src/auth.ts | 76 +++++++++++++- src/lib/auth0.ts | 9 ++ test/auth.test.ts | 254 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 test/auth.test.ts diff --git a/src/auth.ts b/src/auth.ts index 5977b006..6a1aedc9 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -8,14 +8,22 @@ import * as z from "zod/mini"; import { CREDENTIALS_PATH } from "./config"; import { + clearCredentials, + CredentialsError, lockCredentials, readCredentials as readStoredCredentials, writeCredentials, + type CredentialsErrorReason, type Session, type StoredLogin, } from "./credentials"; import { DEFAULT_PRISMIC_HOST, env } from "./env"; -import { type Auth0Config, DeviceFlowError, refreshAccessToken } from "./lib/auth0"; +import { + type Auth0Config, + DeviceFlowError, + refreshAccessToken, + revokeRefreshToken, +} from "./lib/auth0"; import { exists, readJsonFile, writeFileRecursive } from "./lib/file"; import { stringify } from "./lib/json"; import type { Lock } from "./lib/lock"; @@ -24,7 +32,7 @@ import { getUserProfile, validateToken, } from "./lib/prismic/client"; -import { ForbiddenRequestError, UnauthorizedRequestError } from "./lib/request"; +import { ForbiddenRequestError, RequestError, UnauthorizedRequestError } from "./lib/request"; import { appendTrailingSlash } from "./lib/url"; import { forgetTrackedUser } from "./tracking"; @@ -199,6 +207,56 @@ export async function logout(): Promise { } } +const KEYCHAIN_PROBLEMS: Partial> = { + locked: "the keychain is locked", + timeout: "the keychain didn't respond", + "keychain-unavailable": "the keychain isn't available", +}; + +export async function endSession(): Promise<{ revoked: boolean; warnings: string[] }> { + let revoked = false; + const warnings: string[] = []; + const lock = await lockCredentials({ timeoutMs: CREDENTIALS_LOCK_TIMEOUT_MS }); + try { + try { + const session = (await readStoredCredentials()).login?.session; + if (session?.refreshToken) { + const { refreshToken, auth0Domain, clientId } = session; + await revokeRefreshToken(refreshToken, { auth0Domain, clientId }); + revoked = true; + } + } catch (error) { + warnings.push( + `Couldn't revoke your session because ${getProblem(error)}. It stays valid until it expires.`, + ); + } + await clearCredentials(lock).catch((error) => { + if (!(error instanceof CredentialsError && error.reason in KEYCHAIN_PROBLEMS)) throw error; + warnings.push(`Couldn't delete your Prismic key because ${getProblem(error)}.`); + }); + await forgetTrackedUser(); + } finally { + await lock.release(); + } + if (env.PRISMIC_TOKEN) warnings.push("PRISMIC_TOKEN is still set, so commands still use it."); + return { revoked, warnings }; +} + +// The new login is saved before the previous one is revoked, so a failed +// revoke only leaves the previous session to expire. +export async function replaceSession( + lock: Lock, + storedLogin: StoredLogin, +): Promise<{ warning?: string }> { + const previous = (await readStoredCredentials().catch(() => undefined))?.login?.session; + const { warning } = await writeCredentials(lock, storedLogin); + if (previous?.refreshToken) { + const { refreshToken, auth0Domain, clientId } = previous; + await revokeRefreshToken(refreshToken, { auth0Domain, clientId }).catch(() => {}); + } + return { warning }; +} + async function readCredentials(): Promise { return readJsonFile(CREDENTIALS_PATH, { schema: CredentialsSchema }).catch(() => undefined); } @@ -357,3 +415,17 @@ export function spawnTokenRefresh(): void { // Silent failure — never breaks the CLI. } } + +function getProblem(error: unknown): string { + if (error instanceof CredentialsError) { + return KEYCHAIN_PROBLEMS[error.reason] ?? "the saved login can't be read"; + } + if (error instanceof RequestError) return `the login service answered HTTP ${error.status}`; + if ( + error instanceof TypeError || + (error instanceof DOMException && error.name === "TimeoutError") + ) { + return "the login service can't be reached"; + } + return "of an unexpected error"; +} diff --git a/src/lib/auth0.ts b/src/lib/auth0.ts index 8a029bbe..8db871b2 100644 --- a/src/lib/auth0.ts +++ b/src/lib/auth0.ts @@ -176,6 +176,15 @@ export async function refreshAccessToken( }; } +export async function revokeRefreshToken(refreshToken: string, config: Auth0Config): Promise { + const url = new URL("oauth/revoke", getAuth0Url(config.auth0Domain)); + await request(url, { + method: "POST", + body: new URLSearchParams({ client_id: config.clientId, token: refreshToken }), + signal: getRequestSignal(), + }); +} + function getRequestSignal(signal?: AbortSignal): AbortSignal { const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); return signal ? AbortSignal.any([timeout, signal]) : timeout; diff --git a/test/auth.test.ts b/test/auth.test.ts new file mode 100644 index 00000000..3b1d8df7 --- /dev/null +++ b/test/auth.test.ts @@ -0,0 +1,254 @@ +// Temporary. These unit tests cover logout and re-login revocation until +// `prismic login` and `prismic logout` use them. C4, the PR that switches them +// to the device flow, deletes this file and tests both end to end instead. +// They use a fake Auth0 tenant, since a real login needs a browser approval. + +import { readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { beforeEach, describe, expect, vi, type TestContext } from "vitest"; + +import type * as Credentials from "../src/credentials"; +import { pollDeviceLogin, startDeviceLogin } from "../src/lib/auth0"; +import type * as Keyring from "../src/lib/keyring"; +import { createFakeAuth0, type FakeAuth0 } from "./auth0"; +import { it } from "./it"; + +const keychain = vi.hoisted(() => ({ + items: new Map(), + fail: {} as Partial>, +})); + +vi.mock("../src/lib/keyring", async (importOriginal) => { + const actual = await importOriginal(); + const check = (call: keyof typeof keychain.fail) => { + const reason = keychain.fail[call]; + if (reason) throw new actual.KeyringError(reason); + }; + const systemKeyring: Keyring.Keyring = { + async get(service, account) { + check("get"); + return keychain.items.get(`${service} ${account}`); + }, + async set(service, account, password) { + check("set"); + keychain.items.set(`${service} ${account}`, password); + }, + async delete(service, account) { + check("delete"); + return keychain.items.delete(`${service} ${account}`); + }, + }; + return { ...actual, systemKeyring }; +}); + +// src/env.ts requires PROD, which the build defines. Vitest sets it to "". +process.env.PROD = "false"; + +// Imports the modules as a new process would, with its own config directory. +async function load(dir: URL, env: Record = {}) { + vi.resetModules(); + Object.assign(process.env, { + PRISMIC_CONFIG_DIR: fileURLToPath(dir), + PRISMIC_TOKEN: "", + ...env, + }); + return { auth: await import("../src/auth"), store: await import("../src/credentials") }; +} + +async function startFakeAuth0(onTestFinished: TestContext["onTestFinished"]) { + const auth0 = await createFakeAuth0({ interval: 0 }); + onTestFinished(() => auth0.close()); + return auth0; +} + +async function logIn(auth0: FakeAuth0): Promise { + const { auth0Domain, clientId } = auth0; + const pending = await startDeviceLogin( + { audience: "prismic.io", scope: "openid profile email offline_access" }, + { auth0Domain, clientId }, + ); + auth0.approve(); + const tokens = await pollDeviceLogin(pending); + return { host: "prismic.io", auth0Domain, clientId, email: "dev@example.com", ...tokens }; +} + +async function save(store: typeof Credentials, session: Credentials.Session) { + const lock = await store.lockCredentials({ timeoutMs: 5_000 }); + const choice = await store.prepareLogin(lock); + await store.writeCredentials(lock, { ...choice, session }); + await lock.release(); +} + +function revokeRequests(auth0: FakeAuth0) { + return auth0.requests + .filter((request) => request.path === "/oauth/revoke") + .map((request) => request.params); +} + +// The tests share the keychain and the environment. +describe("sessions", { concurrent: false }, () => { + beforeEach(() => { + keychain.items.clear(); + keychain.fail = {}; + }); + + it("logout revokes the session where it was issued, and keeps the host", async ({ + home, + onTestFinished, + }) => { + const auth0 = await startFakeAuth0(onTestFinished); + const { auth, store } = await load(home); + const session = await logIn(auth0); + await save(store, session); + await writeFile( + new URL("analytics.json", home), + JSON.stringify({ anonymousId: "anonymous", userId: "user" }), + ); + + expect(await auth.endSession()).toEqual({ revoked: true, warnings: [] }); + expect(revokeRequests(auth0)).toEqual([ + { client_id: auth0.clientId, token: session.refreshToken }, + ]); + expect(await store.readCredentials()).toEqual({ host: "prismic.io" }); + expect(await readdir(home)).not.toContain("credentials.enc"); + expect(keychain.items.size).toBe(0); + expect(JSON.parse(await readFile(new URL("analytics.json", home), "utf8"))).toEqual({ + anonymousId: "anonymous", + }); + }); + + it("logout deletes the login when the revoke fails, and says why", async ({ + home, + onTestFinished, + }) => { + const auth0 = await startFakeAuth0(onTestFinished); + const cases = [ + ["unavailable", { status: 503 }, "the login service answered HTTP 503"], + ["offline", undefined, "the login service can't be reached"], + ] as const; + for (const [name, response, problem] of cases) { + const dir = new URL(`${name}/`, home); + const { auth, store } = await load(dir); + await save(store, await logIn(auth0)); + if (response) auth0.respondOnce("/oauth/revoke", response); + else await auth0.close(); + + expect(await auth.endSession()).toEqual({ + revoked: false, + warnings: [ + `Couldn't revoke your session because ${problem}. It stays valid until it expires.`, + ], + }); + expect(await store.readCredentials()).toEqual({ host: "prismic.io" }); + expect(await readdir(dir)).not.toContain("credentials.enc"); + expect(keychain.items.size).toBe(0); + } + }); + + it("logout deletes the login when the key can't be read, and says why", async ({ + home, + onTestFinished, + }) => { + const auth0 = await startFakeAuth0(onTestFinished); + const cases = [ + [ + "missing", + () => keychain.items.clear(), + [ + "Couldn't revoke your session because the saved login can't be read. It stays valid until it expires.", + ], + ], + [ + "locked", + () => (keychain.fail = { get: "locked", delete: "locked" }), + [ + "Couldn't revoke your session because the keychain is locked. It stays valid until it expires.", + "Couldn't delete your Prismic key because the keychain is locked.", + ], + ], + ] as const; + for (const [name, breakKey, warnings] of cases) { + const dir = new URL(`${name}/`, home); + await save((await load(dir)).store, await logIn(auth0)); + breakKey(); + const { auth, store } = await load(dir); + + expect(await auth.endSession()).toEqual({ revoked: false, warnings }); + expect(await readdir(dir)).not.toContain("credentials.enc"); + keychain.fail = {}; + expect(await store.readCredentials()).toEqual({ host: "prismic.io" }); + } + expect(revokeRequests(auth0)).toEqual([]); + }); + + it("logout clears the saved login when PRISMIC_TOKEN is set, and says so", async ({ + home, + onTestFinished, + }) => { + const auth0 = await startFakeAuth0(onTestFinished); + const { auth, store } = await load(home, { PRISMIC_TOKEN: "write-api-token" }); + const session = await logIn(auth0); + await save(store, session); + + const warnings = ["PRISMIC_TOKEN is still set, so commands still use it."]; + expect(await auth.endSession()).toEqual({ revoked: true, warnings }); + expect(revokeRequests(auth0)).toEqual([ + { client_id: auth0.clientId, token: session.refreshToken }, + ]); + expect(await store.readCredentials()).toEqual({ host: "prismic.io" }); + + // Nothing is saved anymore, only PRISMIC_TOKEN is left. + expect(await auth.endSession()).toEqual({ revoked: false, warnings }); + expect(revokeRequests(auth0)).toHaveLength(1); + }); + + it("a new login revokes the previous session after saving", async ({ home, onTestFinished }) => { + const previousAuth0 = await startFakeAuth0(onTestFinished); + const auth0 = await startFakeAuth0(onTestFinished); + const { auth, store } = await load(home); + const previous = await logIn(previousAuth0); + await save(store, previous); + const next = await logIn(auth0); + + const lock = await store.lockCredentials({ timeoutMs: 5_000 }); + expect(await auth.replaceSession(lock, { store: "keyring", session: next })).toEqual({}); + await lock.release(); + + expect(revokeRequests(previousAuth0)).toEqual([ + { client_id: previousAuth0.clientId, token: previous.refreshToken }, + ]); + expect(revokeRequests(auth0)).toEqual([]); + expect((await store.readCredentials()).login?.session).toEqual(next); + }); + + it("a new login is saved when the revoke fails, and nothing is revoked when the save fails", async ({ + home, + onTestFinished, + }) => { + const auth0 = await startFakeAuth0(onTestFinished); + const { auth, store } = await load(home); + const previous = await logIn(auth0); + await save(store, previous); + + // Another process took the lock over. + const stalled = await store.lockCredentials({ timeoutMs: 5_000 }); + await rm(new URL("credentials.lock", home), { recursive: true }); + await expect( + auth.replaceSession(stalled, { store: "keyring", session: await logIn(auth0) }), + ).rejects.toMatchObject({ reason: "lock-lost" }); + expect(revokeRequests(auth0)).toEqual([]); + expect((await store.readCredentials()).login?.session).toEqual(previous); + await stalled.release(); + + const next = await logIn(auth0); + auth0.respondOnce("/oauth/revoke", { status: 503 }); + const lock = await store.lockCredentials({ timeoutMs: 5_000 }); + await auth.replaceSession(lock, { store: "keyring", session: next }); + await lock.release(); + expect(revokeRequests(auth0)).toEqual([ + { client_id: auth0.clientId, token: previous.refreshToken }, + ]); + expect((await store.readCredentials()).login?.session).toEqual(next); + }); +}); From e3396282f87a632fdfe46777e0ed5019125e59a2 Mon Sep 17 00:00:00 2001 From: Lucie <25330882+lihbr@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:23:06 +0900 Subject: [PATCH 08/11] refactor: send the token to /validate as a Bearer header (#479) The CLI put the token in the /validate URL as ?token=, where logs can capture it. It now sends it as an Authorization: Bearer header, like the other Prismic requests. With no token, it still sends no credentials. Needs authentication-server#123, in prod since Nov 13. Co-Authored-By: Claude Opus 5.5 --- src/lib/prismic/client.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/prismic/client.ts b/src/lib/prismic/client.ts index 2e03f938..f338f41d 100644 --- a/src/lib/prismic/client.ts +++ b/src/lib/prismic/client.ts @@ -30,8 +30,10 @@ export async function validateToken( config: { host: string }, ): Promise> { const url = new URL("validate", getAuthServiceUrl(config.host)); - if (token) url.searchParams.set("token", token); - return request(url, { schema: SessionSchema }); + return request(url, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + schema: SessionSchema, + }); } function getAuthServiceUrl(host: string): URL { From 5eface2b67cc6b8cc25a5ae066f2817b505dcbee Mon Sep 17 00:00:00 2001 From: Lucie <25330882+lihbr@users.noreply.github.com> Date: Fri, 13 Nov 2026 11:47:30 +0900 Subject: [PATCH 09/11] feat(auth): log in with a code from any device (#480) `prismic login`, `prismic init` and the automatic login use Auth0's device flow. The CLI shows a link and a code to approve on any device, so remote machines and AI agents can log in. An agent stops after 90 seconds, and its next run continues the same code. Logins go to the new keychain-backed store, refresh under a lock, and logout revokes them at Auth0. The localhost login server and the old background refresh are gone. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/validate.yml | 16 + evals/it.ts | 4 + src/auth.ts | 666 +++++++++++++++++-------------- src/commands/init.ts | 17 +- src/commands/login.ts | 21 +- src/commands/logout.ts | 45 ++- src/commands/sync.ts | 2 +- src/config.ts | 1 + src/credentials.ts | 194 +++++---- src/env.ts | 5 + src/error.ts | 22 +- src/index.ts | 68 ++-- src/lib/auth0.ts | 93 ++--- src/lib/keyring.ts | 11 +- src/lib/lock.ts | 20 +- src/lib/prismic/client.ts | 6 - src/lib/request.ts | 3 +- src/subprocesses/refreshToken.ts | 5 - src/tracking.ts | 43 +- test/auth.test.ts | 254 ------------ test/auth0.ts | 89 ++--- test/credentials.test.ts | 408 ------------------- test/device-flow.test.ts | 229 ----------- test/finish-login.test.ts | 35 -- test/init.test.ts | 42 +- test/it.ts | 94 ++++- test/locale-list.test.ts | 2 +- test/lock.test.ts | 150 ------- test/login.test.ts | 195 +++++++-- test/logout.test.ts | 52 ++- test/repo-list.test.ts | 61 ++- test/session-token.test.ts | 450 --------------------- test/tracking.test.ts | 186 --------- test/webhook-list.test.ts | 24 +- test/whoami.test.ts | 118 +++++- tsdown.config.ts | 1 - 36 files changed, 1183 insertions(+), 2449 deletions(-) delete mode 100644 src/subprocesses/refreshToken.ts delete mode 100644 test/auth.test.ts delete mode 100644 test/credentials.test.ts delete mode 100644 test/device-flow.test.ts delete mode 100644 test/finish-login.test.ts delete mode 100644 test/lock.test.ts delete mode 100644 test/session-token.test.ts delete mode 100644 test/tracking.test.ts diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ab1946ee..a2a2a5d6 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -89,6 +89,22 @@ jobs: E2E_PRISMIC_EMAIL: ${{ secrets.E2E_PRISMIC_EMAIL }} E2E_PRISMIC_PASSWORD: ${{ secrets.E2E_PRISMIC_PASSWORD }} + # Only macOS saves logins with /usr/bin/security, so it runs the login tests. + test-darwin: + name: test macos auth (Node.js 26) + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 26 + cache: npm + - run: npm ci + - run: npm run unit -- test/login.test.ts test/logout.test.ts test/whoami.test.ts + env: + E2E_PRISMIC_EMAIL: ${{ secrets.E2E_PRISMIC_EMAIL }} + E2E_PRISMIC_PASSWORD: ${{ secrets.E2E_PRISMIC_PASSWORD }} + types: name: "types (TypeScript ${{ matrix.typescript }})" runs-on: ubuntu-latest diff --git a/evals/it.ts b/evals/it.ts index e4aa8f38..1d61afcb 100644 --- a/evals/it.ts +++ b/evals/it.ts @@ -54,6 +54,8 @@ export const it = base.extend<{ bin, project, login, + auth0, + keychain: _keychain, exec, task, repo, @@ -112,6 +114,8 @@ export const it = base.extend<{ { mode: 0o755 }, ); env.PRISMIC_CONFIG_DIR = fileURLToPath(new URL(".config/prismic/", home)); + env.PRISMIC_AUTH0_DOMAIN = auth0.auth0Domain; + env.PRISMIC_AUTH0_CLIENT_ID = auth0.clientId; env.PRISMIC_TYPE_BUILDER_ENABLED = "true"; env.PRISMIC_SENTRY_ENABLED = "false"; env.PRISMIC_TELEMETRY_ENABLED = "false"; diff --git a/src/auth.ts b/src/auth.ts index 6a1aedc9..2670e5ed 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,157 +1,153 @@ -import { spawn } from "node:child_process"; import { readFile, rm } from "node:fs/promises"; -import { createServer } from "node:http"; import { homedir } from "node:os"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { setTimeout as sleep } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; -import * as z from "zod/mini"; - -import { CREDENTIALS_PATH } from "./config"; import { - clearCredentials, CredentialsError, - lockCredentials, - readCredentials as readStoredCredentials, + lockLogin, + prepareLogin, + readCredentials, + readLoginSummary, + withCredentialsLock, writeCredentials, - type CredentialsErrorReason, type Session, type StoredLogin, } from "./credentials"; import { DEFAULT_PRISMIC_HOST, env } from "./env"; +import { detectAgent } from "./lib/ai"; import { type Auth0Config, + type DeviceLoginTokens, DeviceFlowError, + type PendingLogin, + pollDeviceLogin, refreshAccessToken, revokeRefreshToken, + startDeviceLogin, } from "./lib/auth0"; -import { exists, readJsonFile, writeFileRecursive } from "./lib/file"; -import { stringify } from "./lib/json"; -import type { Lock } from "./lib/lock"; -import { - refreshToken as baseRefreshToken, - getUserProfile, - validateToken, -} from "./lib/prismic/client"; -import { ForbiddenRequestError, RequestError, UnauthorizedRequestError } from "./lib/request"; +import { openBrowser } from "./lib/browser"; +import { CommandError } from "./lib/command"; +import { type Lock, LockTimeoutError } from "./lib/lock"; +import { getUserProfile, validateToken } from "./lib/prismic/client"; +import { ForbiddenRequestError, UnauthorizedRequestError } from "./lib/request"; +import { dedent } from "./lib/string"; import { appendTrailingSlash } from "./lib/url"; -import { forgetTrackedUser } from "./tracking"; +import { forgetTrackedUser, getLoginResult, recordLogin, trackCommandEnd } from "./tracking"; + +const REFRESH_MARGIN_MS = 5 * 60 * 1000; +// Agents stop before Claude Code's 2-minute command timeout. +const AGENT_WAIT_MS = (process.env.PROD === "false" && env.PRISMIC_AGENT_WAIT_MS) || 90_000; +// Auth0's device codes expire after 15 minutes. +const LOGIN_WAIT_MS = 15 * 60 * 1000; +const PENDING_POLL_MS = 1000; +const LOGIN_PARAMS = { audience: "prismic.io", scope: "openid profile email offline_access" }; +const LEGACY_AUTH_FILE_PATH = new URL(".prismic", appendTrailingSlash(pathToFileURL(homedir()))); + +type Login = { + host: string; + auth0: Auth0Config; + /** Infinity for a person, who waits until the code expires. */ + deadline: number; + startedAt: number; + resumed: boolean; + /** A login saved by another command meanwhile changes it. */ + tokenBefore?: string; + waited: boolean; + shown?: PendingLogin; + pending?: PendingLogin; + /** Set on Ctrl-C, so its cleanup doesn't read as a logout. */ + cancelled?: boolean; +}; + +type SavedLogin = Pick & { token: string }; +type LoginOutcome = + | ({ status: "logged-in"; email: string } & SavedLogin) + | { status: "wait-limit" } + | { status: "logged-out" }; + +type StartedLogin = + | { status: "pending"; pending: PendingLogin } + | ({ status: "saved" } & SavedLogin) + | { status: "logged-out" }; -const LOGIN_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes -const REFRESH_MARGIN_MS = 5 * 60 * 1000; // 5 minutes -const PREFERRED_PORT = 5555; -const LOGIN_SOURCE = "prismic-cli"; -const CREDENTIALS_LOCK_TIMEOUT_MS = 30_000; +let renewal: Promise | undefined; +let ended = false; -const CredentialsSchema = z.looseObject({ - token: z.optional(z.string().check(z.minLength(1))), - host: z.optional(z.string().check(z.minLength(1))), -}); -type Credentials = z.infer; +let reauthentication: Promise | undefined; +let savedHost: Promise | undefined; export async function getToken(): Promise { - return env.PRISMIC_TOKEN || (await readCredentials())?.token; + return env.PRISMIC_TOKEN || getSessionToken(); } export async function getHost(): Promise { - return env.PRISMIC_HOST || (await readCredentials())?.host || DEFAULT_PRISMIC_HOST; -} - -// Native client IDs are public. -export function getAuth0Config(host: string): Auth0Config | undefined { - if (host === "prismic.io") { - return { auth0Domain: "auth0.prismic.io", clientId: "V1yuPKJOHhuHNMR4Oeo2fj5uTCfVebny" }; - } - if (host === "wroom.io" || host === "devops-wroom.com") { - return { auth0Domain: "auth0.wroom.io", clientId: "UKxsYw8EcAspkUPRvoFkrO1aj9cA3zkd" }; - } - if (/^[a-z0-9-]+-wroom\.com$/.test(host)) { - return { - auth0Domain: "auth0.platform-wroom.com", - clientId: "Opbx7JjftGb5sgjIWKVx0XDBSrW37say", - }; - } + if (env.PRISMIC_HOST) return env.PRISMIC_HOST; + // Read once, so a command keeps its host even if another command logs in to + // another host meanwhile. + savedHost ??= readLoginSummary().then((summary) => summary.host); + return (await savedHost) || DEFAULT_PRISMIC_HOST; } -// "ended" means Auth0 ended the session, so the caller can say the login -// expired and start a new one. -export type SessionToken = - | { status: "active"; token: string } - | { status: "none" } - | { status: "ended" }; - -let renewal: Promise | undefined; - -// `forceRefresh` renews a token that a server refused, even if it's not about -// to expire. -export async function getSessionToken( +async function getSessionToken( options: { forceRefresh?: boolean } = {}, -): Promise { +): Promise { const { forceRefresh = false } = options; - const session = (await readStoredCredentials()).login?.session; - if (!session?.refreshToken) return { status: "none" }; + const session = (await readCredentials()).login?.session; + // Each host has its own Auth0 tenant, so a token works only on its own host. + if (!session?.refreshToken || session.host !== (await getHost())) return; const freshToken = getUnexpiredToken(session, REFRESH_MARGIN_MS); - if (freshToken && !forceRefresh) return { status: "active", token: freshToken }; + if (freshToken && !forceRefresh) return freshToken; - // Parallel calls share one renewal. - renewal ??= renewSession(session.accessToken).finally(() => (renewal = undefined)); + renewal ??= withCredentialsLock(async (lock) => { + const { login } = await readCredentials(); + return renewLogin(lock, login, session.accessToken, { canRetry: true }); + }).finally(() => (renewal = undefined)); try { return await renewal; } catch (error) { - // Without Auth0, the current token works until it expires, unless a - // server already refused it. + // If the renewal fails, a token that hasn't expired still works, unless a + // server refused it. const token = getUnexpiredToken(session); - if (error instanceof DeviceFlowError && token && !forceRefresh) { - return { status: "active", token }; - } + const isRenewalError = error instanceof DeviceFlowError || error instanceof CredentialsError; + if (isRenewalError && token && !forceRefresh) return token; throw error; } } -async function renewSession(staleToken: string | undefined): Promise { - const lock = await lockCredentials({ timeoutMs: CREDENTIALS_LOCK_TIMEOUT_MS }); - try { - const { login: storedLogin } = await readStoredCredentials(); - return await renewLogin(lock, storedLogin, staleToken, { canRetry: true }); - } finally { - await lock.release(); - } -} - async function renewLogin( lock: Lock, storedLogin: StoredLogin | undefined, staleToken: string | undefined, options: { canRetry: boolean }, -): Promise { +): Promise { const session = storedLogin?.session; - if (!storedLogin || !session?.refreshToken) return { status: "none" }; + if (!storedLogin || !session?.refreshToken || session.host !== (await getHost())) return; // Another process renewed it while this one waited for the lock. const freshToken = getUnexpiredToken(session, REFRESH_MARGIN_MS); - if (freshToken && freshToken !== staleToken) return { status: "active", token: freshToken }; + if (freshToken && freshToken !== staleToken) return freshToken; const { refreshToken, auth0Domain, clientId, host } = session; - const tokens = await refreshAccessToken(refreshToken, { auth0Domain, clientId }); + const tokens = await refreshAccessToken(refreshToken, session); if (tokens) { await writeCredentials(lock, { ...storedLogin, session: { ...session, ...tokens } }); - return { status: "active", token: tokens.accessToken }; + return tokens.accessToken; } - // If the refresh token changed meanwhile, retry once with it, but only while - // holding the lock: using it without saving the result would end that - // session. - const latest = (await readStoredCredentials()).login; + // A newer refresh token gets one retry, only under the lock, because a + // refresh that isn't saved would end that session. + const latest = (await readCredentials()).login; if (options.canRetry && latest?.session.refreshToken !== refreshToken && (await lock.isHeld())) { return renewLogin(lock, latest, staleToken, { canRetry: false }); } - // The write fails if another process took the lock over. await writeCredentials(lock, { ...storedLogin, session: { host, auth0Domain, clientId, pendingLogin: session.pendingLogin }, }); - return { status: "ended" }; + ended = true; } function getUnexpiredToken(session: Session, marginMs = 0): string | undefined { @@ -159,223 +155,324 @@ function getUnexpiredToken(session: Session, marginMs = 0): string | undefined { if (accessToken && expiresAt && expiresAt - marginMs > Date.now()) return accessToken; } -export async function refreshToken(): Promise { - if (env.PRISMIC_TOKEN) return; - const token = await getToken(); - if (!token) return; - const host = await getHost(); - const newToken = await baseRefreshToken(token, { host }); - await saveCredentials({ token: newToken, host }); - return newToken; -} - -let login: Promise | undefined; - export async function onUnauthorized( retry: (init?: Pick) => Promise, + sent: Headers, ): Promise { if (env.PRISMIC_TOKEN || process.env.CI) return; - if (!login && !(await isTokenRejected())) return; - login ??= createLoginSession({ - onReady: (url) => console.error(`Your login is missing or expired. Log in to continue: ${url}`), - }).finally(() => { - login = undefined; - }); - await login; - return retry({ headers: { Authorization: `Bearer ${await getToken()}` } }); + // A parallel request may have renewed the token since this one was sent. + const current = await getToken(); + if (current && sent.get("Authorization") !== `Bearer ${current}`) { + return retry({ headers: { Authorization: `Bearer ${current}` } }); + } + if (!reauthentication && !(await isTokenRejected(current))) return; + reauthentication ??= reauthenticate().finally(() => (reauthentication = undefined)); + const token = await reauthentication; + return retry({ headers: { Authorization: `Bearer ${token}` } }); } -async function isTokenRejected(): Promise { +async function isTokenRejected(token: string | undefined): Promise { try { - await validateToken(await getToken(), { host: await getHost() }); + await validateToken(token, { host: await getHost() }); return false; } catch (error) { return error instanceof UnauthorizedRequestError || error instanceof ForbiddenRequestError; } } -export async function logout(): Promise { - const credentialsExist = await exists(CREDENTIALS_PATH); - if (!credentialsExist) return true; - - try { - await rm(CREDENTIALS_PATH, { force: true }); - await forgetTrackedUser(); - return true; - } catch { - return false; - } +export async function reauthenticate(options: { browser?: boolean } = {}): Promise { + const token = await getSessionToken({ forceRefresh: true }); + if (token) return token; + console.error(ended ? "Your login expired." : "You are not logged in."); + return logIn(options); } -const KEYCHAIN_PROBLEMS: Partial> = { - locked: "the keychain is locked", - timeout: "the keychain didn't respond", - "keychain-unavailable": "the keychain isn't available", -}; +export async function logIn(options: { browser?: boolean } = {}): Promise { + const { browser = false } = options; + const host = await getHost(); + const auth0 = getAuth0Config(host); + if (!auth0) throw new CommandError(`Login isn't available for ${host}. Use PRISMIC_TOKEN.`); + + // An agent stops before its command times out and runs it again to continue. + // Without a terminal, nobody may be there to approve, so it stops too. + const isPerson = + detectAgent() === undefined && process.stdin.isTTY === true && process.stderr.isTTY === true; + const login: Login = { + host, + auth0, + deadline: isPerson ? Infinity : Date.now() + AGENT_WAIT_MS, + startedAt: Date.now(), + resumed: false, + waited: false, + tokenBefore: (await readCredentials().catch(() => undefined))?.login?.session.accessToken, + }; + + // Only this handler runs on Ctrl-C, so a command like sync can't exit before + // the login cleans up. + const handlers = process.rawListeners("SIGINT") as NodeJS.SignalsListener[]; + process.removeAllListeners("SIGINT"); + process.once("SIGINT", async () => { + login.cancelled = true; + console.error(); + if (login.pending) await setPendingLogin(login.pending, undefined); + recordLogin({ result: "cancelled", resumed: login.resumed, startedAt: login.startedAt }); + await endCommand("Login cancelled.", 130); + }); -export async function endSession(): Promise<{ revoked: boolean; warnings: string[] }> { - let revoked = false; - const warnings: string[] = []; - const lock = await lockCredentials({ timeoutMs: CREDENTIALS_LOCK_TIMEOUT_MS }); + let outcome: LoginOutcome; try { - try { - const session = (await readStoredCredentials()).login?.session; - if (session?.refreshToken) { - const { refreshToken, auth0Domain, clientId } = session; - await revokeRefreshToken(refreshToken, { auth0Domain, clientId }); - revoked = true; - } - } catch (error) { - warnings.push( - `Couldn't revoke your session because ${getProblem(error)}. It stays valid until it expires.`, - ); - } - await clearCredentials(lock).catch((error) => { - if (!(error instanceof CredentialsError && error.reason in KEYCHAIN_PROBLEMS)) throw error; - warnings.push(`Couldn't delete your Prismic key because ${getProblem(error)}.`); + outcome = await completeLogin(login, { browser: browser && isPerson }); + } catch (error) { + recordLogin({ + result: getLoginResult(error), + resumed: login.resumed, + startedAt: login.startedAt, }); - await forgetTrackedUser(); + throw error; } finally { - await lock.release(); + process.removeAllListeners("SIGINT"); + for (const handler of handlers) process.on("SIGINT", handler); } - if (env.PRISMIC_TOKEN) warnings.push("PRISMIC_TOKEN is still set, so commands still use it."); - return { revoked, warnings }; -} -// The new login is saved before the previous one is revoked, so a failed -// revoke only leaves the previous session to expire. -export async function replaceSession( - lock: Lock, - storedLogin: StoredLogin, -): Promise<{ warning?: string }> { - const previous = (await readStoredCredentials().catch(() => undefined))?.login?.session; - const { warning } = await writeCredentials(lock, storedLogin); - if (previous?.refreshToken) { - const { refreshToken, auth0Domain, clientId } = previous; - await revokeRefreshToken(refreshToken, { auth0Domain, clientId }).catch(() => {}); + const { resumed, startedAt } = login; + if (outcome.status === "wait-limit") { + recordLogin({ result: "wait-limit", resumed, startedAt }); + return endCommand( + "Waiting for approval. After the user approves, run the same command again. It continues this login.", + 1, + ); + } + if (outcome.status === "logged-out") { + recordLogin({ result: "cancelled", resumed, startedAt }); + throw new CommandError("A logout cancelled this login. Run the command again to log in."); } - return { warning }; + const { token, email, store, storeReason } = outcome; + // A command that only waited for another command's login doesn't count it. + if (login.pending) recordLogin({ result: "success", resumed, startedAt, store, storeReason }); + console.error(`Logged in to Prismic as ${email}`); + return token; } -async function readCredentials(): Promise { - return readJsonFile(CREDENTIALS_PATH, { schema: CredentialsSchema }).catch(() => undefined); +// Native client IDs are public. +function getAuth0Config(host: string): Auth0Config | undefined { + // Tests use a fake Auth0 server. Releases drop this branch, so a stray + // variable can't send a real login to another tenant. + if (process.env.PROD === "false" && env.PRISMIC_AUTH0_DOMAIN) { + return { auth0Domain: env.PRISMIC_AUTH0_DOMAIN, clientId: env.PRISMIC_AUTH0_CLIENT_ID ?? "" }; + } + if (host === "prismic.io") { + return { auth0Domain: "auth0.prismic.io", clientId: "V1yuPKJOHhuHNMR4Oeo2fj5uTCfVebny" }; + } + if (host === "wroom.io" || host === "devops-wroom.com") { + return { auth0Domain: "auth0.wroom.io", clientId: "UKxsYw8EcAspkUPRvoFkrO1aj9cA3zkd" }; + } + if (/^[a-z0-9-]+-wroom\.com$/.test(host)) { + return { + auth0Domain: "auth0.platform-wroom.com", + clientId: "Opbx7JjftGb5sgjIWKVx0XDBSrW37say", + }; + } } -async function saveCredentials(credentials: Credentials): Promise { - await writeFileRecursive(CREDENTIALS_PATH, stringify(credentials)); +// Ends the command at once, even inside a request whose errors the command +// catches, like sync's loop. +async function endCommand(message: string, exitCode: number): Promise { + process.exitCode = exitCode; + trackCommandEnd({ error: message }); + // Exiting right after a write can drop it from a pipe. + await new Promise((resolve) => process.stderr.write(`${message}\n`, resolve)); + process.exit(); } -export async function createLoginSession(options: { - onReady: (url: URL) => void; -}): Promise<{ email: string }> { - const host = await getHost(); - const corsOrigin = `https://${host}`; - - return new Promise((resolve, reject) => { - const server = createServer((req, res) => { - if (req.method === "OPTIONS") { - res.writeHead(204, { - "Access-Control-Allow-Origin": corsOrigin, - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - }); - res.end(); - return; - } +async function completeLogin(login: Login, options: { browser: boolean }): Promise { + const loginLock = await lockLoginAfterOthers(login); + if (!loginLock) return { status: "wait-limit" }; - if (req.method === "POST") { - const respond = (status: number, body: unknown): void => { - res.writeHead(status, { - "Access-Control-Allow-Origin": corsOrigin, - "Content-Type": "application/json", - }); - res.end(JSON.stringify(body)); - }; - - let body = ""; - - req.on("data", (chunk) => { - body += chunk.toString(); - }); - - req.on("end", async () => { - try { - const { cookies, email } = JSON.parse(body); - - const cookie: string | undefined = cookies.find((c: string) => - c.startsWith("prismic-auth="), - ); - const token = cookie?.split(";")[0]?.replace(/^prismic-auth=/, ""); - - if (!token) { - respond(400, { error: "Invalid request" }); - return; - } - - await saveCredentials({ token, host }); - await forgetTrackedUser(); - - respond(200, { success: true }); - - clearTimeout(timeoutId); - server.close(); - resolve({ email }); - } catch { - respond(400, { error: "Invalid request" }); - } - }); - - return; + let saved: SavedLogin; + try { + const started = await startLogin(login); + if (started.status === "logged-out") return started; + if (started.status === "saved") { + saved = started; + } else { + const { pending } = started; + if (pending.deviceCode !== login.shown?.deviceCode) printLoginPrompt(pending); + // openBrowser() runs a shell, so the URL from Auth0 can't contain shell + // syntax, including cmd.exe's %NAME%. + const href = pending.verificationUriComplete ?? pending.verificationUri; + if (options.browser && /^https:\/\/[\w\-.:/?=&~+]+$/.test(href) && URL.canParse(href)) { + openBrowser(new URL(href)); } + const tokens = await pollUntilApproved(login, pending); + if ("status" in tokens) return tokens; + const stored = await saveLogin(login, pending, tokens); + if (!stored) return { status: "logged-out" }; + saved = stored; + } + } finally { + await loginLock.release(); + } - res.writeHead(404); - res.end(); - }); + // The profile request also creates the Prismic user for a new social or SSO + // signup, so it runs right after a login saves its tokens. + await forgetTrackedUser(); + const { email } = await getUserProfile({ getToken: async () => saved.token, host: login.host }); + return { ...saved, status: "logged-in", email }; +} - const timeoutId = setTimeout(() => { - server.close(); - reject(new Error("Login timed out. Please try again.")); - }, LOGIN_TIMEOUT_MS); - - const onListening = async (): Promise => { - const address = server.address(); - if (!address || typeof address === "string") { - clearTimeout(timeoutId); - server.close(); - reject(new Error("Failed to start login server")); - return; - } +async function lockLoginAfterOthers(login: Login): Promise { + const waitUntil = Math.min(login.deadline, Date.now() + LOGIN_WAIT_MS); + while (true) { + // Until the other command saves its code, look for it between tries. + const timeoutMs = login.shown ? waitUntil - Date.now() : 0; + try { + return await lockLogin({ timeoutMs }); + } catch (error) { + if (!(error instanceof LockTimeoutError)) throw error; + if (Date.now() >= login.deadline) return; + if (Date.now() >= waitUntil) throw new DeviceFlowError("expired"); + } - const url = await buildLoginUrl(host, address.port); - options.onReady(url); - }; + login.waited = true; + const session = (await readCredentials().catch(() => undefined))?.login?.session; + login.shown = session && getResumableLogin(session, login); + if (login.shown) printLoginPrompt(login.shown); + else await sleep(PENDING_POLL_MS); + } +} - server.on("error", (error: NodeJS.ErrnoException) => { - if (error.code === "EADDRINUSE" && server.listening === false) { - server.listen(0, "0.0.0.0"); - } else { - clearTimeout(timeoutId); - reject(error); - } - }); +// Sets `login.pending` under the lock, so a Ctrl-C right after can delete it. +async function startLogin(login: Login): Promise { + return withCredentialsLock(async (lock) => { + // A saved login that can't be read doesn't stop a new one. + const credentials = await readCredentials().catch(() => undefined); + // A failed login keeps the saved login, but a logout deletes it. + if (login.shown && credentials && !credentials.login) return { status: "logged-out" }; + const stored = credentials?.login; + const resumable = stored && getResumableLogin(stored.session, login); + if (resumable) { + login.pending = resumable; + login.startedAt = resumable.startedAt; + login.resumed = true; + return { status: "pending", pending: resumable }; + } + const current = stored?.session.host === login.host ? stored : undefined; + const token = current && getUnexpiredToken(current.session); + if (login.waited && token && token !== login.tokenBefore) { + return { status: "saved", token, store: current.store, storeReason: current.storeReason }; + } - server.once("listening", onListening); - server.listen(PREFERRED_PORT, "0.0.0.0"); + const pending = { ...(await startDeviceLogin(LOGIN_PARAMS, login.auth0)), host: login.host }; + login.pending = pending; + login.startedAt = pending.startedAt; + const store = await prepareLogin(lock); + // The current session and host stay until the new login replaces them. + const session = stored?.session ?? { + host: (await readLoginSummary()).host ?? login.host, + ...login.auth0, + }; + await writeCredentials(lock, { ...store, session: { ...session, pendingLogin: pending } }); + return { status: "pending", pending }; }); } -// The profile request also creates the Prismic user for a new social or SSO -// signup, so it runs right after a login saves its tokens. -export async function finishLogin(config: { - token: string; - host: string; -}): Promise<{ email: string }> { - await forgetTrackedUser(); - const profile = await getUserProfile({ getToken: async () => config.token, host: config.host }); - return { email: profile.email }; +function getResumableLogin(session: Session, login: Login): PendingLogin | undefined { + const { pendingLogin } = session; + if ( + pendingLogin && + pendingLogin.host === login.host && + pendingLogin.auth0Domain === login.auth0.auth0Domain && + pendingLogin.clientId === login.auth0.clientId && + pendingLogin.expiresAt > Date.now() + ) { + return pendingLogin; + } } -const LEGACY_AUTH_FILE_PATH = new URL(".prismic", appendTrailingSlash(pathToFileURL(homedir()))); +function printLoginPrompt(pending: PendingLogin): void { + const { userCode, verificationUri, verificationUriComplete } = pending; + const minutes = Math.max(1, Math.ceil((pending.expiresAt - Date.now()) / 60_000)); + console.error(dedent` + Open this URL on any device to log in: + ${verificationUriComplete ?? verificationUri} + ${verificationUriComplete !== undefined ? `Check that the page shows the code ${userCode}.` : `Enter the code ${userCode}.`} + Waiting for the login (the code expires in ${minutes} ${minutes === 1 ? "minute" : "minutes"})... + `); +} + +async function pollUntilApproved( + login: Login, + pending: PendingLogin, +): Promise { + const loggedOut = new AbortController(); + const watcher = setInterval(async () => { + const stored = await readCredentials().catch(() => undefined); + if (login.cancelled) return; + if (stored && stored.login?.session.pendingLogin?.deviceCode !== pending.deviceCode) { + loggedOut.abort(); + } + }, PENDING_POLL_MS); + const deadline = Number.isFinite(login.deadline) + ? AbortSignal.timeout(Math.max(0, login.deadline - Date.now())) + : undefined; + try { + return await pollDeviceLogin(pending, { + signal: deadline ? AbortSignal.any([deadline, loggedOut.signal]) : loggedOut.signal, + onUpdate: (next) => setPendingLogin(pending, { ...next, host: login.host }), + }); + } catch (error) { + if (error instanceof DeviceFlowError) { + await setPendingLogin(pending, undefined); + throw error; + } + if (loggedOut.signal.aborted) return { status: "logged-out" }; + if (deadline?.aborted) return { status: "wait-limit" }; + throw error; + } finally { + clearInterval(watcher); + } +} + +async function setPendingLogin( + current: PendingLogin, + next: Session["pendingLogin"], +): Promise { + await withCredentialsLock(async (lock) => { + const stored = (await readCredentials()).login; + if (stored?.session.pendingLogin?.deviceCode !== current.deviceCode) return; + await writeCredentials(lock, { ...stored, session: { ...stored.session, pendingLogin: next } }); + }).catch(() => {}); +} + +async function saveLogin( + login: Login, + pending: PendingLogin, + tokens: DeviceLoginTokens, +): Promise { + return withCredentialsLock(async (lock) => { + // A saved login that can't be read can't have been logged out. + const stored = await readCredentials().catch(() => undefined); + if (stored && stored.login?.session.pendingLogin?.deviceCode !== pending.deviceCode) { + await revokeRefreshToken(tokens.refreshToken, pending).catch(() => {}); + return; + } + + // Keeps the store the pending login used. Choosing again could fall back to + // a file if the keychain locked during the wait. + const store = stored?.login + ? { store: stored.login.store, storeReason: stored.login.storeReason } + : await prepareLogin(lock); + const session = { host: login.host, ...login.auth0, ...tokens }; + await writeCredentials(lock, { ...store, session }); + // The new login is saved first, so a failed revoke only leaves the + // previous session to expire. + const previous = stored?.login?.session; + if (previous?.refreshToken) { + await revokeRefreshToken(previous.refreshToken, previous).catch(() => {}); + } + return { ...store, token: tokens.accessToken }; + }); +} export async function cleanupLegacyAuthFile(): Promise { let contents: string; @@ -398,34 +495,3 @@ export async function cleanupLegacyAuthFile(): Promise { await rm(LEGACY_AUTH_FILE_PATH, { force: true }); } catch {} } - -async function buildLoginUrl(host: string, port: number): Promise { - const url = new URL("dashboard/cli/login", `https://${host}/`); - url.searchParams.set("source", LOGIN_SOURCE); - url.searchParams.set("port", port.toString()); - return url; -} - -export function spawnTokenRefresh(): void { - try { - const script = fileURLToPath(new URL("./subprocesses/refreshToken.mjs", import.meta.url)); - const child = spawn(process.execPath, [script], { detached: true, stdio: "ignore" }); - child.unref(); - } catch { - // Silent failure — never breaks the CLI. - } -} - -function getProblem(error: unknown): string { - if (error instanceof CredentialsError) { - return KEYCHAIN_PROBLEMS[error.reason] ?? "the saved login can't be read"; - } - if (error instanceof RequestError) return `the login service answered HTTP ${error.status}`; - if ( - error instanceof TypeError || - (error instanceof DOMException && error.name === "TimeoutError") - ) { - return "the login service can't be reached"; - } - return "of an unexpected error"; -} diff --git a/src/commands/init.ts b/src/commands/init.ts index 32a26ad2..acfbc0ce 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,9 +1,8 @@ import { rm } from "node:fs/promises"; import { type Adapter, FRAMEWORKS, getAdapter, NoSupportedFrameworkError } from "../adapters"; -import { createLoginSession, getHost, getToken } from "../auth"; +import { getHost, getToken, reauthenticate } from "../auth"; import { DEFAULT_PRISMIC_HOST, env } from "../env"; -import { openBrowser } from "../lib/browser"; import { CommandError, createCommand, exclusiveOptions, type CommandConfig } from "../lib/command"; import { getInstallCommand, @@ -122,19 +121,7 @@ export default createCommand(config, async ({ values }) => { } catch (error) { if (error instanceof UnauthorizedRequestError || error instanceof ForbiddenRequestError) { if (env.PRISMIC_TOKEN) throw error; - console.info("Not logged in. Starting login..."); - const { email } = await createLoginSession({ - onReady: (url) => { - if (noBrowser) { - console.info(`Open this URL to log in: ${url}`); - } else { - console.info("Opening browser to complete login..."); - console.info(`If the browser doesn't open, visit: ${url}`); - openBrowser(url); - } - }, - }); - console.info(`Logged in as ${email}`); + await reauthenticate({ browser: !noBrowser }); } else { throw error; } diff --git a/src/commands/login.ts b/src/commands/login.ts index a14083a5..c08ce243 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,29 +1,14 @@ -import { createLoginSession } from "../auth"; -import { openBrowser } from "../lib/browser"; +import { logIn } from "../auth"; import { createCommand, type CommandConfig } from "../lib/command"; const config = { name: "prismic login", - description: "Log in to Prismic via browser.", + description: "Log in to Prismic. Prints a URL and a code to approve in a browser.", options: { "no-browser": { type: "boolean", description: "Skip opening the browser automatically" }, }, } satisfies CommandConfig; export default createCommand(config, async ({ values }) => { - const { "no-browser": noBrowser } = values; - - const { email } = await createLoginSession({ - onReady: (url) => { - if (noBrowser) { - console.info(`Open this URL to log in: ${url}`); - } else { - console.info("Opening browser to complete login..."); - console.info(`If the browser doesn't open, visit: ${url}`); - openBrowser(url); - } - }, - }); - - console.info(`Logged in to Prismic as ${email}`); + await logIn({ browser: !values["no-browser"] }); }); diff --git a/src/commands/logout.ts b/src/commands/logout.ts index 12762dbc..a92f21a5 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -1,5 +1,13 @@ -import { logout as baseLogout } from "../auth"; -import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { + clearCredentials, + CredentialsError, + readCredentials, + withCredentialsLock, +} from "../credentials"; +import { env } from "../env"; +import { DeviceFlowError, revokeRefreshToken } from "../lib/auth0"; +import { createCommand, type CommandConfig } from "../lib/command"; +import { forgetTrackedUser, recordLogout } from "../tracking"; const config = { name: "prismic logout", @@ -7,10 +15,31 @@ const config = { } satisfies CommandConfig; export default createCommand(config, async () => { - const ok = await baseLogout(); - if (ok) { - console.info("Logged out of Prismic"); - } else { - throw new CommandError("Logout failed. You can log out manually by deleting the file."); - } + let revoked = false; + await withCredentialsLock(async (lock) => { + try { + const session = (await readCredentials()).login?.session; + if (session?.refreshToken) { + await revokeRefreshToken(session.refreshToken, session); + revoked = true; + } + } catch (error) { + if (!(error instanceof CredentialsError || error instanceof DeviceFlowError)) throw error; + console.warn( + `Couldn't revoke your session because ${getProblem(error)}. It stays valid until it expires.`, + ); + } + await clearCredentials(lock); + await forgetTrackedUser(); + }); + recordLogout({ revoked }); + if (env.PRISMIC_TOKEN) console.warn("PRISMIC_TOKEN is still set, so commands still use it."); + console.info("Logged out of Prismic"); }); + +function getProblem(error: CredentialsError | DeviceFlowError): string { + if (error instanceof CredentialsError) return "the saved login can't be read"; + return error.reason === "unavailable" + ? "the Prismic login service can't be reached" + : `the Prismic login service answered ${error.detail}`; +} diff --git a/src/commands/sync.ts b/src/commands/sync.ts index b6a5b373..83477a4c 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -51,7 +51,7 @@ export default createCommand(config, async ({ values }) => { trackCommandStart("sync", { watch: true }); process.on("SIGINT", () => { console.info("\nWatch stopped. Goodbye!"); - trackCommandEnd("sync", { watch: true }); + trackCommandEnd(); process.exit(0); }); diff --git a/src/config.ts b/src/config.ts index e4a41da8..7d656863 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,5 +6,6 @@ export const CONFIG_DIR = getConfigDir("prismic", env.PRISMIC_CONFIG_DIR); export const CREDENTIALS_PATH = new URL("credentials.json", CONFIG_DIR); export const ENCRYPTED_CREDENTIALS_PATH = new URL("credentials.enc", CONFIG_DIR); export const CREDENTIALS_LOCK_PATH = new URL("credentials.lock", CONFIG_DIR); +export const LOGIN_LOCK_PATH = new URL("login.lock", CONFIG_DIR); export const UPDATE_NOTIFIER_STATE_PATH = new URL("update-notifier.json", CONFIG_DIR); export const ANALYTICS_IDS_PATH = new URL("analytics.json", CONFIG_DIR); diff --git a/src/credentials.ts b/src/credentials.ts index f84b196c..eed447b0 100644 --- a/src/credentials.ts +++ b/src/credentials.ts @@ -11,16 +11,18 @@ import { CREDENTIALS_LOCK_PATH, CREDENTIALS_PATH, ENCRYPTED_CREDENTIALS_PATH, + LOGIN_LOCK_PATH, } from "./config"; import { PendingLoginSchema } from "./lib/auth0"; import { exists } from "./lib/file"; import { stringify } from "./lib/json"; import { KeyringError, systemKeyring, type KeyringErrorReason } from "./lib/keyring"; -import { acquireLock, type Lock } from "./lib/lock"; +import { acquireLock, type Lock, LockTimeoutError } from "./lib/lock"; const KEYCHAIN_SERVICE = "prismic-cli"; const KEYCHAIN_ACCOUNT = `cli:${createHash("sha256").update(fileURLToPath(CONFIG_DIR)).digest("hex").slice(0, 16)}`; const KEYCHAIN_LOGIN_TIMEOUT_MS = 5_000; +const CREDENTIALS_LOCK_TIMEOUT_MS = 30_000; // Leaves time to answer the keychain's unlock dialog. const KEYCHAIN_TIMEOUT_MS = 30_000; @@ -32,12 +34,13 @@ const SessionSchema = z.object({ host: z.string(), auth0Domain: z.string(), clientId: z.string(), - email: z.optional(z.string()), accessToken: z.optional(z.string()), refreshToken: z.optional(z.string()), /** Milliseconds since the epoch, from the local time of receipt. */ expiresAt: z.optional(z.number()), - pendingLogin: z.optional(PendingLoginSchema), + // Can be for another host, because the current session stays until the new + // login saves. + pendingLogin: z.optional(z.extend(PendingLoginSchema, { host: z.string() })), }); export type Session = z.infer; @@ -51,46 +54,40 @@ const FallbackReasonSchema = z.enum([ ]); export type FallbackReason = z.infer; -const CredentialsVersionSchema = z.looseObject({ +const StoreModeSchema = z.enum(["keyring", "file"]); +export type StoreMode = z.infer; + +// Every login keeps these fields in plain text. +const LoginSummarySchema = z.object({ version: z.optional(z.unknown()), - store: z.optional(z.unknown()), - storeReason: z.optional(z.unknown()), + host: z.catch(z.optional(z.string()), undefined), + store: z.catch(z.optional(StoreModeSchema), undefined), + storeReason: z.catch(z.optional(FallbackReasonSchema), undefined), + /** Set while a session has tokens. */ + expiresAt: z.catch(z.optional(z.number()), undefined), }); -const CredentialsHostSchema = z.looseObject({ host: z.optional(z.string()) }); - -export type StoreMode = "keyring" | "file"; +type LoginSummary = z.infer; // `storeReason` says why a login went to a file, for telemetry. export type StoredLogin = { store: StoreMode; storeReason?: FallbackReason; session: Session }; -export type StoredCredentials = { - host?: string; - login?: StoredLogin; -}; - let key: Buffer | undefined; let warnedUnsafe = false; // A login that exists but can't be read throws, never reads as logged out. -export async function readCredentials(): Promise { +export async function readCredentials(): Promise<{ login?: StoredLogin }> { const json = parseJson(await readPrivateFile(CREDENTIALS_PATH)); - const file = z.safeParse(CredentialsVersionSchema, json); - if (!file.success) return {}; - const { version, store, storeReason } = file.data; + const { version, store, storeReason } = z.safeParse(LoginSummarySchema, json).data ?? {}; // Without a version, the file has only `host` (website generator), or is // version 1, whose token expires within hours and can't be refreshed. - if (version === undefined) { - const host = z.safeParse(CredentialsHostSchema, json).data?.host; - return host ? { host } : {}; - } + if (version === undefined) return {}; if (version !== 2) { throw new CredentialsError( - "unsupported-version", "Your Prismic login was saved by a newer version of the CLI. Update the CLI, or run `prismic login` again.", ); } - if (store !== "keyring" && store !== "file") throw undecryptable(); + if (!store) throw undecryptable(); const session = z.safeParse( SessionSchema, @@ -98,12 +95,13 @@ export async function readCredentials(): Promise { ); if (!session.success) throw undecryptable(); - // An unknown reason reads as none. - const reason = store === "file" ? z.safeParse(FallbackReasonSchema, storeReason).data : undefined; - return { - host: session.data.host, - login: { store, ...(reason ? { storeReason: reason } : {}), session: session.data }, - }; + return { login: { store, storeReason, session: session.data } }; +} + +// Reads only the plain-text fields, so it never needs the keychain. +export async function readLoginSummary(): Promise { + const json = parseJson(await readPrivateFile(CREDENTIALS_PATH).catch(() => undefined)); + return z.safeParse(LoginSummarySchema, json).data ?? {}; } const FALLBACK_REASONS: Record = { @@ -114,9 +112,8 @@ const FALLBACK_REASONS: Record = { timeout: "timeout", }; -// Only a login calls this, under the lock it keeps until it writes, so a login -// falls back to a file only when it starts. Holding the lock means two logins -// can't each create a key and then encrypt with the one that lost. +// Call it in the same lock hold as the write that uses its key, so no other +// login or logout can change the key in between. export async function prepareLogin( lock: Lock, ): Promise<{ store: "keyring" } | { store: "file"; storeReason: FallbackReason }> { @@ -142,21 +139,16 @@ export async function prepareLogin( } } -export async function writeCredentials( - lock: Lock, - login: StoredLogin, -): Promise<{ warning?: string }> { +// Warns once per process when the disk can't keep the files private. +export async function writeCredentials(lock: Lock, login: StoredLogin): Promise { const { store, storeReason, session } = login; let safe; if (store === "keyring") { - const { host, email, expiresAt, auth0Domain, clientId } = session; + const { host, expiresAt, auth0Domain, clientId } = session; safe = await writePrivateFiles(lock, [ // credentials.enc is authoritative, so it's written first. [ENCRYPTED_CREDENTIALS_PATH, encrypt(JSON.stringify(session), await requireKey())], - [ - CREDENTIALS_PATH, - stringify({ version: 2, host, email, expiresAt, store, auth0Domain, clientId }), - ], + [CREDENTIALS_PATH, stringify({ version: 2, host, expiresAt, store, auth0Domain, clientId })], ]); } else { safe = await writePrivateFiles(lock, [ @@ -165,19 +157,18 @@ export async function writeCredentials( await removeFile(lock, ENCRYPTED_CREDENTIALS_PATH); } - if (safe || warnedUnsafe) return {}; + if (safe || warnedUnsafe) return; warnedUnsafe = true; - return { - warning: `Can't make ${fileURLToPath(CREDENTIALS_PATH)} readable only by you on this disk. Set PRISMIC_CONFIG_DIR to a private folder on another disk.`, - }; + console.warn( + `Can't make ${fileURLToPath(CREDENTIALS_PATH)} readable only by you on this disk. Set PRISMIC_CONFIG_DIR to a private folder on another disk.`, + ); } +// A key the keychain won't delete only gets a warning, since the login is gone +// either way. export async function clearCredentials(lock: Lock): Promise { - const json = parseJson(await readPrivateFile(CREDENTIALS_PATH).catch(() => undefined)); - const host = z.safeParse(CredentialsHostSchema, json).data?.host; - const hadKey = - z.safeParse(CredentialsVersionSchema, json).data?.store === "keyring" || - (await exists(ENCRYPTED_CREDENTIALS_PATH)); + const { host, store } = await readLoginSummary(); + const hadKey = store === "keyring" || (await exists(ENCRYPTED_CREDENTIALS_PATH)); if (host) await writePrivateFiles(lock, [[CREDENTIALS_PATH, stringify({ host })]]); else await removeFile(lock, CREDENTIALS_PATH); @@ -189,33 +180,51 @@ export async function clearCredentials(lock: Lock): Promise { await systemKeyring .delete(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, { timeoutMs: KEYCHAIN_TIMEOUT_MS }) .catch((error) => { - throw keyringFailure(error, "delete"); + if (!(error instanceof KeyringError)) throw error; + console.warn("Can't delete your Prismic key from the keychain. Your next login reuses it."); }); } -export async function lockCredentials(config: { timeoutMs: number }): Promise { - await ensurePrivateDir(); - return acquireLock(CREDENTIALS_LOCK_PATH, config); +export async function withCredentialsLock(callback: (lock: Lock) => Promise): Promise { + const lock = await lockConfigDir(CREDENTIALS_LOCK_PATH, { + timeoutMs: CREDENTIALS_LOCK_TIMEOUT_MS, + }).catch((error) => { + if (!(error instanceof LockTimeoutError)) throw error; + throw new CredentialsError( + "Another Prismic command is still using your login. Run the command again.", + ); + }); + try { + return await callback(lock); + } finally { + await lock.release(); + } } -export type CredentialsErrorReason = - | "unsupported-version" - | "unsafe-file" - | "lock-lost" - | "locked" - | "timeout" - | "keychain-unavailable" - | "missing-key" - | "decrypt-failed"; +// Takes the lock a login holds while it waits for approval. It's separate from +// the credentials lock, so others can still refresh or log out meanwhile. +export async function lockLogin(config: { timeoutMs: number }): Promise { + return lockConfigDir(LOGIN_LOCK_PATH, config); +} + +// Turns a config folder the CLI can't write, as in some agent sandboxes, into a +// clear error. +async function lockConfigDir(path: URL, config: { timeoutMs: number }): Promise { + try { + await ensurePrivateDir(); + return await acquireLock(path, config); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EACCES" && code !== "EPERM" && code !== "EROFS") throw error; + throw new CredentialsError( + `Can't write to ${fileURLToPath(CONFIG_DIR).replace(/[/\\]$/, "")}. Set PRISMIC_CONFIG_DIR to a private folder outside your project.`, + { cause: error }, + ); + } +} export class CredentialsError extends Error { name = "CredentialsError"; - reason: CredentialsErrorReason; - - constructor(reason: CredentialsErrorReason, message: string, options?: ErrorOptions) { - super(message, options); - this.reason = reason; - } } async function readKey(timeoutMs: number): Promise { @@ -228,9 +237,14 @@ async function readKey(timeoutMs: number): Promise { async function requireKey(): Promise { const currentKey = await readKey(KEYCHAIN_TIMEOUT_MS).catch((error) => { - throw keyringFailure(error); + if (!(error instanceof KeyringError)) throw error; + // A new login saves to a file when the keychain can't be used. + throw new CredentialsError( + `Can't read your Prismic key because ${KEYCHAIN_FAILURES[error.reason] ?? "the keychain isn't available. Try again"}, or run \`prismic login\` again.`, + { cause: error }, + ); }); - if (!currentKey) throw undecryptable("missing-key"); + if (!currentKey) throw undecryptable(); return currentKey; } @@ -246,31 +260,13 @@ async function decryptSession(): Promise { return decrypt(encrypted, await requireKey()); } -const KEYCHAIN_FAILURES: Partial> = { - locked: ["locked", "the keychain is locked in this session. Unlock it and try again"], - timeout: ["timeout", "the keychain didn't respond. Try again"], +const KEYCHAIN_FAILURES: Partial> = { + locked: "the keychain is locked in this session. Unlock it and try again", + timeout: "the keychain didn't respond. Try again", }; -function keyringFailure(error: unknown, action: "read" | "delete" = "read"): unknown { - if (!(error instanceof KeyringError)) return error; - const [reason, why] = KEYCHAIN_FAILURES[error.reason] ?? [ - "keychain-unavailable", - "the keychain isn't available. Try again", - ]; - // A new login saves to a file when the keychain can't be used. - const alternative = action === "read" ? ", or run `prismic login` again" : ""; - return new CredentialsError( - reason, - `Can't ${action} your Prismic key because ${why}${alternative}.`, - { cause: error }, - ); -} - -function undecryptable(reason: "missing-key" | "decrypt-failed" = "decrypt-failed"): Error { - return new CredentialsError( - reason, - "Your Prismic login can't be decrypted. Run `prismic login` again.", - ); +function undecryptable(): Error { + return new CredentialsError("Your Prismic login can't be decrypted. Run `prismic login` again."); } // Checked right before each rename or delete, so a stalled process can't @@ -278,7 +274,6 @@ function undecryptable(reason: "missing-key" | "decrypt-failed" = "decrypt-faile async function assertHeld(lock: Lock): Promise { if (await lock.isHeld()) return; throw new CredentialsError( - "lock-lost", "Another Prismic command took over the login while this one stalled. Run the command again.", ); } @@ -313,7 +308,6 @@ async function ensurePrivateDir(): Promise { // Returns false when a file isn't private, because the disk ignores modes, like // WSL's /mnt/c. async function writePrivateFiles(lock: Lock, files: [URL, string | Buffer][]): Promise { - await ensurePrivateDir(); let safe = true; for (const [path, data] of files) { const temporaryPath = new URL(`${path.href}.${randomUUID()}.tmp`); @@ -347,7 +341,6 @@ async function removeFile(lock: Lock, path: URL): Promise { async function readPrivateFile(path: URL): Promise { const unsafe = (why: string) => new CredentialsError( - "unsafe-file", `Refusing to read ${fileURLToPath(path)} because ${why}. Delete it and run \`prismic login\` again.`, ); @@ -361,6 +354,11 @@ async function readPrivateFile(path: URL): Promise { const { code } = error as NodeJS.ErrnoException; if (code === "ENOENT") return undefined; if (code === "ELOOP") throw unsafe("it's a symbolic link"); + if (code === "EACCES" || code === "EPERM") { + throw new CredentialsError( + `Can't read ${fileURLToPath(path)}. Delete it and run \`prismic login\` again.`, + ); + } throw error; } diff --git a/src/env.ts b/src/env.ts index bfc218a1..6ae9af5b 100644 --- a/src/env.ts +++ b/src/env.ts @@ -17,6 +17,11 @@ const Env = z.object({ PRISMIC_CONFIG_DIR: z.optional(z.string()), PRISMIC_TYPE_BUILDER_ENABLED: z.optional(z.stringbool()), PRISMIC_SYNC_POLL_MS: z.optional(z.coerce.number().check(z.gte(100))), + // Only tests set these. Code reads them behind a literal `process.env.PROD` + // check, which production builds drop, and a bad value reads as unset. + PRISMIC_AUTH0_DOMAIN: z.optional(z.string()), + PRISMIC_AUTH0_CLIENT_ID: z.optional(z.string()), + PRISMIC_AGENT_WAIT_MS: z.catch(z.optional(z.coerce.number().check(z.gte(1))), undefined), }); export const env = z.parse(Env, { diff --git a/src/error.ts b/src/error.ts index 9686837e..624cd183 100644 --- a/src/error.ts +++ b/src/error.ts @@ -1,5 +1,6 @@ -import { getToken } from "./auth"; +import { getHost, getToken } from "./auth"; import { env } from "./env"; +import { DeviceFlowError, type DeviceFlowErrorReason } from "./lib/auth0"; import { isInvalidAuthContextError } from "./lib/prismic/auth"; import { BadRequestError, @@ -10,9 +11,26 @@ import { } from "./lib/request"; import { dedent } from "./lib/string"; +const DEVICE_FLOW_MESSAGES: Record, string> = { + expired: "The login code expired. Run the command again.", + declined: "The login was declined in the browser.", + "email-unverified": + "Prismic sent you an email to verify your address. Verify it, then run the command again.", + "invalid-grant": "The login code was already used or isn't valid. Run the command again.", + unavailable: + "Can't reach the Prismic login service. Check your connection, then run the command again.", +}; + export async function getErrorMessage(error: unknown): Promise { + if (error instanceof DeviceFlowError) { + if (error.reason === "misconfigured") { + return `Login isn't set up correctly for ${await getHost()} (${error.detail}). Report this: https://github.com/prismicio/cli/issues`; + } + return DEVICE_FLOW_MESSAGES[error.reason]; + } + if (error instanceof UnauthorizedRequestError || error instanceof ForbiddenRequestError) { - if (!(await getToken())) { + if (!(await getToken().catch(() => undefined))) { return "Not logged in. Run `prismic login` first."; } if (env.PRISMIC_TOKEN) { diff --git a/src/index.ts b/src/index.ts index 4512e0ba..97a0155f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,14 +10,15 @@ import { NoSupportedFrameworkError, SliceNotFoundError, } from "./adapters"; -import { cleanupLegacyAuthFile, getHost, getToken, spawnTokenRefresh } from "./auth"; +import { cleanupLegacyAuthFile, getHost, getToken } from "./auth"; import router from "./commands"; import { UPDATE_NOTIFIER_STATE_PATH } from "./config"; +import { CredentialsError, readLoginSummary } from "./credentials"; import { env } from "./env"; import { getErrorMessage } from "./error"; import { detectAgent } from "./lib/ai"; +import { DeviceFlowError } from "./lib/auth0"; import { CommandError } from "./lib/command"; -import { decodePayload } from "./lib/jwt"; import { MissingPackageJson } from "./lib/packageJson"; import { getUserProfile, UnsupportedFileTypeError } from "./lib/prismic/client"; import { InvalidEnvironmentError } from "./lib/prismic/environments"; @@ -87,6 +88,8 @@ const KNOWN_ERRORS = [ ForbiddenRequestError, BadRequestError, UnknownRequestError, + DeviceFlowError, + CredentialsError, ]; const REPORTED_KNOWN_ERRORS = [BadRequestError, UnknownRequestError]; @@ -152,7 +155,10 @@ async function main(): Promise { if (!help) { const host = await getHost(); - const token = await getToken(); + // Reads only the plain-text fields, so commands that don't use the login + // never wait on the keychain. + const summary = env.PRISMIC_TOKEN ? {} : await readLoginSummary(); + const loggedIn = Boolean(env.PRISMIC_TOKEN || summary.expiresAt); const telemetryEnabled = env.PRISMIC_TELEMETRY_ENABLED ?? (await isTelemetryEnabled()); const sentryEnabled = env.PRISMIC_SENTRY_ENABLED ?? (telemetryEnabled && env.PROD); @@ -161,30 +167,31 @@ async function main(): Promise { await initSentry({ host, repo, userIntent, taskId }); } if (telemetryEnabled) { - await initTracking({ host, token, repo, userIntent, taskId }); + await initTracking({ + host, + loggedIn, + repo, + userIntent, + taskId, + store: summary.store, + storeReason: summary.storeReason, + }); } - if (token) { - const exp = decodePayload(token)?.exp; - const now = Date.now() / 1000; + if (loggedIn && (sentryEnabled || telemetryEnabled)) { + const knownUserId = getTrackedUserId(); - if (!exp || exp - now <= 3600) { - process.on("exit", () => spawnTokenRefresh()); - } - - if (sentryEnabled || telemetryEnabled) { - const knownUserId = getTrackedUserId(); - - if (knownUserId) { - sentrySetUser({ id: knownUserId }); - } else if (!exp || exp > now) { - getUserProfile({ getToken, host }) - .then((profile) => { - trackUser(profile); - sentrySetUser({ id: profile.shortId }); - }) - .catch(() => {}); - } + if (knownUserId) { + sentrySetUser({ id: knownUserId }); + } else { + // Unlike the summary above, this can read the keychain, so it runs once per + // login. + getUserProfile({ getToken, host }) + .then((profile) => { + trackUser(profile); + sentrySetUser({ id: profile.shortId }); + }) + .catch(() => {}); } } } @@ -195,16 +202,16 @@ async function main(): Promise { try { if (isTracked) trackCommandStart(command); await router(); - if (isTracked) trackCommandEnd(command); + if (isTracked) trackCommandEnd(); } catch (error) { process.exitCode = 1; const message = await getErrorMessage(error).catch(() => undefined); - if (isTracked) trackCommandEnd(command, { error: message ?? error }); + if (isTracked) trackCommandEnd({ error: message ?? error }); if (KNOWN_ERRORS.some((type) => error instanceof type)) { console.error(message); - if (REPORTED_KNOWN_ERRORS.some((type) => error instanceof type)) { + if (isReported(error)) { await sentryCaptureError(error); } } else { @@ -254,3 +261,10 @@ function getAgentOptionsError(taskId: string | undefined, userIntent: string | u if (!isTaskId(taskId)) return "--task-id must come from `prismic task-id`"; if (!userIntent) return "missing --user-intent"; } + +// Reports a misconfigured login, which needs a fix on Prismic's side. Its error +// holds only Auth0's error code, never a response body. +function isReported(error: unknown): boolean { + if (error instanceof DeviceFlowError) return error.reason === "misconfigured"; + return REPORTED_KNOWN_ERRORS.some((type) => error instanceof type); +} diff --git a/src/lib/auth0.ts b/src/lib/auth0.ts index 8db871b2..43ac5359 100644 --- a/src/lib/auth0.ts +++ b/src/lib/auth0.ts @@ -2,7 +2,7 @@ import { setTimeout as sleep } from "node:timers/promises"; import * as z from "zod/mini"; -import { request, RequestError } from "./request"; +import { NetworkError, request, RequestError } from "./request"; const REQUEST_TIMEOUT_MS = 10_000; const DEFAULT_INTERVAL_MS = 5_000; @@ -11,6 +11,14 @@ const MAX_BACKOFF_MS = 60_000; export type Auth0Config = { auth0Domain: string; clientId: string }; +export type DeviceFlowErrorReason = + | "expired" + | "declined" + | "email-unverified" + | "invalid-grant" + | "misconfigured" + | "unavailable"; + export const PendingLoginSchema = z.object({ auth0Domain: z.string(), clientId: z.string(), @@ -20,10 +28,11 @@ export const PendingLoginSchema = z.object({ verificationUriComplete: z.optional(z.string()), expiresAt: z.number(), intervalMs: z.number(), + startedAt: z.number(), }); export type PendingLogin = z.infer; -type DeviceLoginTokens = { +export type DeviceLoginTokens = { accessToken: string; refreshToken: string; expiresAt: number; @@ -46,7 +55,7 @@ export async function startDeviceLogin( const response = await request(url, { method: "POST", body: new URLSearchParams({ client_id: config.clientId, ...params }), - signal: getRequestSignal(), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), schema: DeviceCodeSchema, }).catch((error) => { const reason = getFailureReason(error) ?? (isTransient(error) ? "unavailable" : undefined); @@ -61,18 +70,7 @@ export async function startDeviceLogin( verificationUriComplete: response.verification_uri_complete, expiresAt: Date.now() + response.expires_in * 1000, intervalMs: response.interval !== undefined ? response.interval * 1000 : DEFAULT_INTERVAL_MS, - }; -} - -export function getDeviceLoginPrompt(pending: PendingLogin): { - url: string; - userCode: string; - codeInUrl: boolean; -} { - return { - url: pending.verificationUriComplete ?? pending.verificationUri, - userCode: pending.userCode, - codeInUrl: pending.verificationUriComplete !== undefined, + startedAt: Date.now(), }; } @@ -87,15 +85,18 @@ const DeviceTokenSchema = z.object({ export async function pollDeviceLogin( pending: PendingLogin, options: { - signal?: AbortSignal; - onUpdate?: (pending: PendingLogin) => void | Promise; - } = {}, + signal: AbortSignal | undefined; + onUpdate: (pending: PendingLogin) => void | Promise; + }, ): Promise { const { signal, onUpdate } = options; let current = pending; let delayMs = current.intervalMs; + // The signal stops the wait between requests, not a request in flight, which + // may have used up the code. while (true) { + signal?.throwIfAborted(); if (Date.now() >= current.expiresAt) throw new DeviceFlowError("expired"); try { @@ -107,7 +108,7 @@ export async function pollDeviceLogin( device_code: current.deviceCode, client_id: current.clientId, }), - signal: getRequestSignal(signal), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), schema: DeviceTokenSchema, }); return { @@ -116,14 +117,13 @@ export async function pollDeviceLogin( expiresAt: Date.now() + tokens.expires_in * 1000, }; } catch (error) { - signal?.throwIfAborted(); const retryAfterMs = getRetryAfterMs(error); const code = getOAuthError(error)?.error; if (code === "authorization_pending") { delayMs = Math.max(current.intervalMs, retryAfterMs); } else if (code === "slow_down") { current = { ...current, intervalMs: current.intervalMs + SLOW_DOWN_MS }; - await onUpdate?.(current); + await onUpdate(current); delayMs = Math.max(current.intervalMs, retryAfterMs); } else { const reason = getFailureReason(error); @@ -148,8 +148,8 @@ const RefreshedTokenSchema = z.object({ expires_in: z.number(), }); -// Returns nothing when Auth0 refuses the refresh token, since the session has -// ended. +// Returns nothing when Auth0 refuses the refresh token, or an Auth0 Action +// denies it, since the session has ended. export async function refreshAccessToken( refreshToken: string, config: Auth0Config, @@ -162,10 +162,11 @@ export async function refreshAccessToken( refresh_token: refreshToken, client_id: config.clientId, }), - signal: getRequestSignal(), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), schema: RefreshedTokenSchema, }).catch((error) => { - if (getOAuthError(error)?.error === "invalid_grant") return undefined; + const code = getOAuthError(error)?.error; + if (code === "invalid_grant" || code === "access_denied") return undefined; throw new DeviceFlowError(isTransient(error) ? "unavailable" : "misconfigured", error); }); if (!tokens) return; @@ -181,15 +182,12 @@ export async function revokeRefreshToken(refreshToken: string, config: Auth0Conf await request(url, { method: "POST", body: new URLSearchParams({ client_id: config.clientId, token: refreshToken }), - signal: getRequestSignal(), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }).catch((error) => { + throw new DeviceFlowError(isTransient(error) ? "unavailable" : "misconfigured", error); }); } -function getRequestSignal(signal?: AbortSignal): AbortSignal { - const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); - return signal ? AbortSignal.any([timeout, signal]) : timeout; -} - // Tests use a local server over HTTP. function getAuth0Url(auth0Domain: string): URL { return new URL(/^https?:\/\//.test(auth0Domain) ? auth0Domain : `https://${auth0Domain}/`); @@ -215,9 +213,9 @@ function getFailureReason(error: unknown): DeviceFlowErrorReason | undefined { function isTransient(error: unknown): boolean { if (error instanceof RequestError) return error.status === 429 || error.status >= 500; - // fetch throws a TypeError on network errors. return ( - error instanceof TypeError || (error instanceof DOMException && error.name === "TimeoutError") + error instanceof NetworkError || + (error instanceof DOMException && error.name === "TimeoutError") ); } @@ -242,14 +240,6 @@ function getRetryAfterMs(error: unknown): number { return 0; } -type DeviceFlowErrorReason = - | "expired" - | "declined" - | "email-unverified" - | "invalid-grant" - | "misconfigured" - | "unavailable"; - // `error` isn't kept as `cause`, since Sentry reports causes and they can hold // response details. `detail` is the Auth0 error code or HTTP status. export class DeviceFlowError extends Error { @@ -259,32 +249,15 @@ export class DeviceFlowError extends Error { constructor(reason: DeviceFlowErrorReason, error?: unknown) { const detail = error === undefined ? undefined : getDetail(error); - super(getMessage(reason, detail)); + super(detail ? `${reason} (${detail})` : reason); this.reason = reason; this.detail = detail; } } -function getMessage(reason: DeviceFlowErrorReason, detail: string | undefined): string { - switch (reason) { - case "expired": - return "The device code expired."; - case "declined": - return "The login was declined."; - case "email-unverified": - return "The login was declined because the email address isn't verified."; - case "invalid-grant": - return "The device code is unknown or was already used."; - case "misconfigured": - return `Auth0 rejected the request (${detail}).`; - case "unavailable": - return `Auth0 can't be reached (${detail}).`; - } -} - function getDetail(error: unknown): string { if (error instanceof RequestError) return getOAuthError(error)?.error ?? `HTTP ${error.status}`; if (error instanceof DOMException && error.name === "TimeoutError") return "timeout"; - if (error instanceof TypeError) return "network error"; + if (error instanceof NetworkError) return "network error"; return "unexpected response"; } diff --git a/src/lib/keyring.ts b/src/lib/keyring.ts index b2efbb0c..27c8a329 100644 --- a/src/lib/keyring.ts +++ b/src/lib/keyring.ts @@ -25,10 +25,10 @@ export class KeyringError extends Error { type KeyringOptions = { timeoutMs: number }; -export type Keyring = { +type Keyring = { get(service: string, account: string, options: KeyringOptions): Promise; set(service: string, account: string, password: string, options: KeyringOptions): Promise; - delete(service: string, account: string, options: KeyringOptions): Promise; + delete(service: string, account: string, options: KeyringOptions): Promise; }; // Keychain items trust the binary that created them. With a native module, @@ -48,8 +48,7 @@ const macosKeyring: Keyring = { await security(["-i"], command, options); }, async delete(service, account, options) { - const args = ["delete-generic-password", "-s", service, "-a", account]; - return (await security(args, undefined, options)) !== undefined; + await security(["delete-generic-password", "-s", service, "-a", account], undefined, options); }, }; @@ -60,6 +59,8 @@ function security( ): Promise { return new Promise((resolve, reject) => { const child = spawn("/usr/bin/security", args, { + // Runs in its own process group, so a Ctrl-C in the terminal can't make it fail. + detached: true, signal: AbortSignal.timeout(options.timeoutMs), stdio: ["pipe", "pipe", "ignore"], }); @@ -91,7 +92,7 @@ const napiKeyring: Keyring = { ); }, async delete(service, account, options) { - return napiCall(service, account, options, (entry, signal) => entry.deleteCredential(signal)); + await napiCall(service, account, options, (entry, signal) => entry.deleteCredential(signal)); }, }; diff --git a/src/lib/lock.ts b/src/lib/lock.ts index 63a2ade4..6ff8cce5 100644 --- a/src/lib/lock.ts +++ b/src/lib/lock.ts @@ -8,28 +8,24 @@ const OWNER_FILENAME = "owner.json"; // Claiming a new lock takes milliseconds, so a lock without an owner after // this long was left by a process that died before claiming it. const UNCLAIMED_MS = 1_000; +const STALE_MS = 10_000; +const HEARTBEAT_MS = 2_000; type Owner = { id: string; pid: number; hostname: string }; -export type LockOptions = { - timeoutMs: number; - staleMs?: number; - heartbeatMs?: number; -}; - export type Lock = { isHeld(): Promise; release(): Promise; }; -export async function acquireLock(path: URL, options: LockOptions): Promise { - const { timeoutMs, staleMs = 10_000, heartbeatMs = 2_000 } = options; +export async function acquireLock(path: URL, options: { timeoutMs: number }): Promise { + const { timeoutMs } = options; const owner: Owner = { id: randomUUID(), pid: process.pid, hostname: hostname() }; const deadline = Date.now() + timeoutMs; let delayMs = 25; while (!(await tryCreate(path, owner))) { - if (await recoverIfStale(path, staleMs)) continue; + if (await recoverIfStale(path)) continue; if (Date.now() >= deadline) { throw new LockTimeoutError(`Timed out waiting for the lock at ${fileURLToPath(path)}.`); } @@ -48,7 +44,7 @@ export async function acquireLock(path: URL, options: LockOptions): Promise {}); - }, heartbeatMs); + }, HEARTBEAT_MS); heartbeat.unref(); return { @@ -84,7 +80,7 @@ async function tryCreate(path: URL, owner: Owner): Promise { } } -async function recoverIfStale(path: URL, staleMs: number): Promise { +async function recoverIfStale(path: URL): Promise { const owner = await readOwner(path); const touched = await stat(getOwnerPath(path)) .catch(() => stat(path)) @@ -92,7 +88,7 @@ async function recoverIfStale(path: URL, staleMs: number): Promise { if (!touched) return true; // Released in the meantime. const ageMs = Date.now() - touched.mtimeMs; - const isOld = ageMs > staleMs; + const isOld = ageMs > STALE_MS; const isUnclaimed = !owner && ageMs > UNCLAIMED_MS; const isOrphan = owner?.hostname === hostname() && !isAlive(owner.pid); if (!isOld && !isUnclaimed && !isOrphan) return false; diff --git a/src/lib/prismic/client.ts b/src/lib/prismic/client.ts index f338f41d..90b6aaec 100644 --- a/src/lib/prismic/client.ts +++ b/src/lib/prismic/client.ts @@ -14,12 +14,6 @@ type Config = { type RepositoryConfig = Config & { repo: string }; -export async function refreshToken(token: string, config: { host: string }): Promise { - const url = new URL("refreshtoken", getAuthServiceUrl(config.host)); - url.searchParams.set("token", token); - return request(url, { schema: z.string() }); -} - const SessionSchema = z.union([ z.object({ type: z.literal("USER"), email: z.string(), shortId: z.string() }), z.object({ type: z.literal("Machine2Machine"), domain: z.string(), appName: z.string() }), diff --git a/src/lib/request.ts b/src/lib/request.ts index 4e61c5aa..ff2a11bf 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -11,6 +11,7 @@ export type RequestOptions = Omit & { export type UnauthorizedHandler = ( retry: (init?: Pick) => Promise, + sent: Headers, ) => Promise; export async function request( @@ -66,7 +67,7 @@ export async function request( const retryHeaders = new Headers(headers); new Headers(retryInit?.headers).forEach((value, key) => retryHeaders.set(key, value)); return request(input, { ...init, headers: retryHeaders, onUnauthorized: undefined }); - }); + }, headers); if (retried) return result as T; if (response.status === 401) throw new UnauthorizedRequestError(response, value); throw new ForbiddenRequestError(response, value); diff --git a/src/subprocesses/refreshToken.ts b/src/subprocesses/refreshToken.ts deleted file mode 100644 index 3cbd9397..00000000 --- a/src/subprocesses/refreshToken.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { refreshToken } from "../auth"; - -try { - await refreshToken(); -} catch {} diff --git a/src/tracking.ts b/src/tracking.ts index 254002c9..c00b419c 100644 --- a/src/tracking.ts +++ b/src/tracking.ts @@ -8,7 +8,7 @@ import { ANALYTICS_IDS_PATH } from "./config"; import type { FallbackReason, StoreMode } from "./credentials"; import { DEFAULT_PRISMIC_HOST, env } from "./env"; import { detectAgent } from "./lib/ai"; -import type { DeviceFlowError } from "./lib/auth0"; +import { DeviceFlowError, type DeviceFlowErrorReason } from "./lib/auth0"; import { readJsonFile, writeFileRecursive } from "./lib/file"; import { stringify } from "./lib/json"; import { decodePayload } from "./lib/jwt"; @@ -35,7 +35,7 @@ const usesStoredUser = !env.PRISMIC_TOKEN; // it answers for the user who just left. let forgotUser = false; -export type LoginResult = +type LoginResult = | "success" | "expired" | "declined" @@ -56,10 +56,11 @@ let storeReason: FallbackReason | undefined; let login: { result: LoginResult; resumed: boolean; durationS: number } | undefined; let revoked: boolean | undefined; let ids: AnalyticsIds | undefined; +let command: { type: string; watch?: boolean } | undefined; export async function initTracking(config: { host: string; - token?: string; + loggedIn: boolean; repo?: string; userIntent?: string; taskId?: string; @@ -74,7 +75,7 @@ export async function initTracking(config: { storeReason = config.storeReason; const writeKey = host === DEFAULT_PRISMIC_HOST ? PROD_WRITE_KEY : STAGING_WRITE_KEY; agent = detectAgent(); - auth = detectAuth(config.token); + auth = detectAuth(config.loggedIn); ({ runtime, runtimeProvider } = detectRuntime()); const storedIds = await readIds(); @@ -119,10 +120,9 @@ export async function forgetTrackedUser(): Promise { if (storedIds?.userId) await saveIds({ anonymousId: storedIds.anonymousId }); } -function detectAuth(token: string | undefined): string { - if (!token) return "none"; - if (!env.PRISMIC_TOKEN) return "login"; - const payload = decodePayload(token); +function detectAuth(loggedIn: boolean): string { + if (!env.PRISMIC_TOKEN) return loggedIn ? "login" : "none"; + const payload = decodePayload(env.PRISMIC_TOKEN); if (!payload) return "unknown-token"; return payload.type === "machine2machine" ? "write-token" : "user-token"; } @@ -188,11 +188,12 @@ async function saveIds(nextIds: AnalyticsIds): Promise { await writeFileRecursive(ANALYTICS_IDS_PATH, stringify(nextIds)).catch(() => {}); } -export function trackCommandStart(command: string, config: { watch?: boolean } = {}): void { +export function trackCommandStart(type: string, config: { watch?: boolean } = {}): void { const { watch } = config; + command = { type, watch }; trackEvent("Prismic CLI Start", { properties: { - commandType: command, + commandType: type, fullCommand: process.argv.join(" "), repository, watch, @@ -207,15 +208,15 @@ export function trackCommandStart(command: string, config: { watch?: boolean } = }); } -export function trackCommandEnd( - command: string, - config: { watch?: boolean; error?: unknown } = {}, -): void { - const { watch, error } = config; +export function trackCommandEnd(config: { error?: unknown } = {}): void { + if (!command) return; + const { type, watch } = command; + command = undefined; + const { error } = config; const errorMessage = error ? (error instanceof Error ? error.message : String(error)) : undefined; trackEvent("Prismic CLI End", { properties: { - commandType: command, + commandType: type, fullCommand: process.argv.join(" "), success: !process.exitCode, repository, @@ -240,7 +241,7 @@ export function trackCommandEnd( // Adds to the command's End event. `startedAt` is saved with the pending login, // so a resumed login counts from its first run. -export function trackLogin(config: { +export function recordLogin(config: { result: LoginResult; resumed: boolean; startedAt: number; @@ -253,11 +254,11 @@ export function trackLogin(config: { } // Adds to the command's End event. -export function trackLogout(config: { revoked: boolean }): void { +export function recordLogout(config: { revoked: boolean }): void { revoked = config.revoked; } -const LOGIN_RESULTS: Record = { +const LOGIN_RESULTS: Record = { expired: "expired", declined: "declined", "email-unverified": "email-unverified", @@ -266,8 +267,8 @@ const LOGIN_RESULTS: Record = { unavailable: "error", }; -export function getLoginResult(error: DeviceFlowError): LoginResult { - return LOGIN_RESULTS[error.reason]; +export function getLoginResult(error: unknown): LoginResult { + return error instanceof DeviceFlowError ? LOGIN_RESULTS[error.reason] : "error"; } const PrismicRcSchema = z.object({ diff --git a/test/auth.test.ts b/test/auth.test.ts deleted file mode 100644 index 3b1d8df7..00000000 --- a/test/auth.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -// Temporary. These unit tests cover logout and re-login revocation until -// `prismic login` and `prismic logout` use them. C4, the PR that switches them -// to the device flow, deletes this file and tests both end to end instead. -// They use a fake Auth0 tenant, since a real login needs a browser approval. - -import { readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; - -import { beforeEach, describe, expect, vi, type TestContext } from "vitest"; - -import type * as Credentials from "../src/credentials"; -import { pollDeviceLogin, startDeviceLogin } from "../src/lib/auth0"; -import type * as Keyring from "../src/lib/keyring"; -import { createFakeAuth0, type FakeAuth0 } from "./auth0"; -import { it } from "./it"; - -const keychain = vi.hoisted(() => ({ - items: new Map(), - fail: {} as Partial>, -})); - -vi.mock("../src/lib/keyring", async (importOriginal) => { - const actual = await importOriginal(); - const check = (call: keyof typeof keychain.fail) => { - const reason = keychain.fail[call]; - if (reason) throw new actual.KeyringError(reason); - }; - const systemKeyring: Keyring.Keyring = { - async get(service, account) { - check("get"); - return keychain.items.get(`${service} ${account}`); - }, - async set(service, account, password) { - check("set"); - keychain.items.set(`${service} ${account}`, password); - }, - async delete(service, account) { - check("delete"); - return keychain.items.delete(`${service} ${account}`); - }, - }; - return { ...actual, systemKeyring }; -}); - -// src/env.ts requires PROD, which the build defines. Vitest sets it to "". -process.env.PROD = "false"; - -// Imports the modules as a new process would, with its own config directory. -async function load(dir: URL, env: Record = {}) { - vi.resetModules(); - Object.assign(process.env, { - PRISMIC_CONFIG_DIR: fileURLToPath(dir), - PRISMIC_TOKEN: "", - ...env, - }); - return { auth: await import("../src/auth"), store: await import("../src/credentials") }; -} - -async function startFakeAuth0(onTestFinished: TestContext["onTestFinished"]) { - const auth0 = await createFakeAuth0({ interval: 0 }); - onTestFinished(() => auth0.close()); - return auth0; -} - -async function logIn(auth0: FakeAuth0): Promise { - const { auth0Domain, clientId } = auth0; - const pending = await startDeviceLogin( - { audience: "prismic.io", scope: "openid profile email offline_access" }, - { auth0Domain, clientId }, - ); - auth0.approve(); - const tokens = await pollDeviceLogin(pending); - return { host: "prismic.io", auth0Domain, clientId, email: "dev@example.com", ...tokens }; -} - -async function save(store: typeof Credentials, session: Credentials.Session) { - const lock = await store.lockCredentials({ timeoutMs: 5_000 }); - const choice = await store.prepareLogin(lock); - await store.writeCredentials(lock, { ...choice, session }); - await lock.release(); -} - -function revokeRequests(auth0: FakeAuth0) { - return auth0.requests - .filter((request) => request.path === "/oauth/revoke") - .map((request) => request.params); -} - -// The tests share the keychain and the environment. -describe("sessions", { concurrent: false }, () => { - beforeEach(() => { - keychain.items.clear(); - keychain.fail = {}; - }); - - it("logout revokes the session where it was issued, and keeps the host", async ({ - home, - onTestFinished, - }) => { - const auth0 = await startFakeAuth0(onTestFinished); - const { auth, store } = await load(home); - const session = await logIn(auth0); - await save(store, session); - await writeFile( - new URL("analytics.json", home), - JSON.stringify({ anonymousId: "anonymous", userId: "user" }), - ); - - expect(await auth.endSession()).toEqual({ revoked: true, warnings: [] }); - expect(revokeRequests(auth0)).toEqual([ - { client_id: auth0.clientId, token: session.refreshToken }, - ]); - expect(await store.readCredentials()).toEqual({ host: "prismic.io" }); - expect(await readdir(home)).not.toContain("credentials.enc"); - expect(keychain.items.size).toBe(0); - expect(JSON.parse(await readFile(new URL("analytics.json", home), "utf8"))).toEqual({ - anonymousId: "anonymous", - }); - }); - - it("logout deletes the login when the revoke fails, and says why", async ({ - home, - onTestFinished, - }) => { - const auth0 = await startFakeAuth0(onTestFinished); - const cases = [ - ["unavailable", { status: 503 }, "the login service answered HTTP 503"], - ["offline", undefined, "the login service can't be reached"], - ] as const; - for (const [name, response, problem] of cases) { - const dir = new URL(`${name}/`, home); - const { auth, store } = await load(dir); - await save(store, await logIn(auth0)); - if (response) auth0.respondOnce("/oauth/revoke", response); - else await auth0.close(); - - expect(await auth.endSession()).toEqual({ - revoked: false, - warnings: [ - `Couldn't revoke your session because ${problem}. It stays valid until it expires.`, - ], - }); - expect(await store.readCredentials()).toEqual({ host: "prismic.io" }); - expect(await readdir(dir)).not.toContain("credentials.enc"); - expect(keychain.items.size).toBe(0); - } - }); - - it("logout deletes the login when the key can't be read, and says why", async ({ - home, - onTestFinished, - }) => { - const auth0 = await startFakeAuth0(onTestFinished); - const cases = [ - [ - "missing", - () => keychain.items.clear(), - [ - "Couldn't revoke your session because the saved login can't be read. It stays valid until it expires.", - ], - ], - [ - "locked", - () => (keychain.fail = { get: "locked", delete: "locked" }), - [ - "Couldn't revoke your session because the keychain is locked. It stays valid until it expires.", - "Couldn't delete your Prismic key because the keychain is locked.", - ], - ], - ] as const; - for (const [name, breakKey, warnings] of cases) { - const dir = new URL(`${name}/`, home); - await save((await load(dir)).store, await logIn(auth0)); - breakKey(); - const { auth, store } = await load(dir); - - expect(await auth.endSession()).toEqual({ revoked: false, warnings }); - expect(await readdir(dir)).not.toContain("credentials.enc"); - keychain.fail = {}; - expect(await store.readCredentials()).toEqual({ host: "prismic.io" }); - } - expect(revokeRequests(auth0)).toEqual([]); - }); - - it("logout clears the saved login when PRISMIC_TOKEN is set, and says so", async ({ - home, - onTestFinished, - }) => { - const auth0 = await startFakeAuth0(onTestFinished); - const { auth, store } = await load(home, { PRISMIC_TOKEN: "write-api-token" }); - const session = await logIn(auth0); - await save(store, session); - - const warnings = ["PRISMIC_TOKEN is still set, so commands still use it."]; - expect(await auth.endSession()).toEqual({ revoked: true, warnings }); - expect(revokeRequests(auth0)).toEqual([ - { client_id: auth0.clientId, token: session.refreshToken }, - ]); - expect(await store.readCredentials()).toEqual({ host: "prismic.io" }); - - // Nothing is saved anymore, only PRISMIC_TOKEN is left. - expect(await auth.endSession()).toEqual({ revoked: false, warnings }); - expect(revokeRequests(auth0)).toHaveLength(1); - }); - - it("a new login revokes the previous session after saving", async ({ home, onTestFinished }) => { - const previousAuth0 = await startFakeAuth0(onTestFinished); - const auth0 = await startFakeAuth0(onTestFinished); - const { auth, store } = await load(home); - const previous = await logIn(previousAuth0); - await save(store, previous); - const next = await logIn(auth0); - - const lock = await store.lockCredentials({ timeoutMs: 5_000 }); - expect(await auth.replaceSession(lock, { store: "keyring", session: next })).toEqual({}); - await lock.release(); - - expect(revokeRequests(previousAuth0)).toEqual([ - { client_id: previousAuth0.clientId, token: previous.refreshToken }, - ]); - expect(revokeRequests(auth0)).toEqual([]); - expect((await store.readCredentials()).login?.session).toEqual(next); - }); - - it("a new login is saved when the revoke fails, and nothing is revoked when the save fails", async ({ - home, - onTestFinished, - }) => { - const auth0 = await startFakeAuth0(onTestFinished); - const { auth, store } = await load(home); - const previous = await logIn(auth0); - await save(store, previous); - - // Another process took the lock over. - const stalled = await store.lockCredentials({ timeoutMs: 5_000 }); - await rm(new URL("credentials.lock", home), { recursive: true }); - await expect( - auth.replaceSession(stalled, { store: "keyring", session: await logIn(auth0) }), - ).rejects.toMatchObject({ reason: "lock-lost" }); - expect(revokeRequests(auth0)).toEqual([]); - expect((await store.readCredentials()).login?.session).toEqual(previous); - await stalled.release(); - - const next = await logIn(auth0); - auth0.respondOnce("/oauth/revoke", { status: 503 }); - const lock = await store.lockCredentials({ timeoutMs: 5_000 }); - await auth.replaceSession(lock, { store: "keyring", session: next }); - await lock.release(); - expect(revokeRequests(auth0)).toEqual([ - { client_id: auth0.clientId, token: previous.refreshToken }, - ]); - expect((await store.readCredentials()).login?.session).toEqual(next); - }); -}); diff --git a/test/auth0.ts b/test/auth0.ts index fb9931d0..05a2082c 100644 --- a/test/auth0.ts +++ b/test/auth0.ts @@ -1,47 +1,40 @@ import { randomBytes } from "node:crypto"; import { createServer, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; -import { setTimeout as sleep } from "node:timers/promises"; // A fake Auth0 tenant for login tests. A real device login needs a person to // approve it in a browser, so automated tests can't use Auth0 itself. -export type FakeAuth0Options = { - accessToken?: string; - expiresIn?: number; - interval?: number; -}; +const INTERVAL = 0.1; -export type FakeAuth0Response = { - status: number; - body?: unknown; - headers?: Record; - delayMs?: number; -}; +type FakeAuth0Response = { status: number; body?: unknown }; export type FakeAuth0 = { auth0Domain: string; clientId: string; - requests: { path: string; params: Record }[]; - approve: (options?: { userCode?: string }) => void; - deny: (options?: { userCode?: string; description?: string }) => void; - expire: (options?: { userCode?: string }) => void; + requestsTo: (path: string) => Record[]; + /** Approves the latest code. `expiresIn` sets the token's lifetime in seconds. */ + approve: (options?: { expiresIn?: number }) => void; + deny: (options?: { description?: string }) => void; + expire: () => void; respondOnce: (path: string, response: FakeAuth0Response) => void; + /** A refresh token the server accepts, for a login a test saves itself. */ + issueRefreshToken: () => string; close: () => Promise; }; type DeviceCode = { deviceCode: string; - userCode: string; expiresAt: number; status: "pending" | "approved" | "denied" | "redeemed"; description?: string; + expiresIn?: number; }; -export async function createFakeAuth0(options: FakeAuth0Options = {}): Promise { - const { accessToken, expiresIn = 3600, interval = 1 } = options; +export async function createFakeAuth0(options: { accessToken: string }): Promise { + const { accessToken } = options; const clientId = `fake-${randomToken(8)}`; - const requests: FakeAuth0["requests"] = []; + const requests: { path: string; params: Record }[] = []; const responses = new Map(); const codes: DeviceCode[] = []; const refreshTokens = new Set(); @@ -55,8 +48,7 @@ export async function createFakeAuth0(options: FakeAuth0Options = {}): Promise code.userCode === userCode); - if (!code) throw new Error(`No device code ${userCode ?? "issued yet"}`); + function latestCode(): DeviceCode { + const code = codes.at(-1); + if (!code) throw new Error("No device code issued yet"); return code; } @@ -149,21 +145,25 @@ export async function createFakeAuth0(options: FakeAuth0Options = {}): Promise { - findCode(userCode).status = "approved"; + requestsTo: (path) => + requests.filter((request) => request.path === path).map((request) => request.params), + approve: ({ expiresIn } = {}) => { + const code = latestCode(); + code.status = "approved"; + code.expiresIn = expiresIn; }, - deny: ({ userCode, description } = {}) => { - const code = findCode(userCode); + deny: ({ description } = {}) => { + const code = latestCode(); code.status = "denied"; code.description = description; }, - expire: ({ userCode } = {}) => { - findCode(userCode).expiresAt = Date.now(); + expire: () => { + latestCode().expiresAt = Date.now(); }, respondOnce: (path, response) => { responses.set(path, [...(responses.get(path) ?? []), response]); }, + issueRefreshToken, close: async () => { server.closeAllConnections(); await new Promise((resolve) => server.close(resolve)); @@ -171,14 +171,9 @@ export async function createFakeAuth0(options: FakeAuth0Options = {}): Promise = {}, -): void { +function send(res: ServerResponse, status: number, body?: unknown): void { if (res.destroyed) return; - res.writeHead(status, { "Content-Type": "application/json", ...headers }); + res.writeHead(status, { "Content-Type": "application/json" }); res.end(body === undefined ? undefined : JSON.stringify(body)); } diff --git a/test/credentials.test.ts b/test/credentials.test.ts deleted file mode 100644 index 5e317dcf..00000000 --- a/test/credentials.test.ts +++ /dev/null @@ -1,408 +0,0 @@ -// Temporary. These unit tests cover the credential store until `prismic login` -// uses it. C4, the PR that switches `prismic login` to the device flow, deletes -// this file and tests login end to end instead. - -import { randomBytes, randomUUID } from "node:crypto"; -import { chown, mkdir, readdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; - -import { beforeEach, describe, expect, vi } from "vitest"; - -import type { Session } from "../src/credentials"; -import type * as Keyring from "../src/lib/keyring"; -import { it } from "./it"; - -// Replaces the system keychain. Set a reason in `keychain.fail` to make that -// call throw. -const keychain = vi.hoisted(() => ({ - items: new Map(), - fail: {} as Partial>, -})); - -vi.mock("../src/lib/keyring", async (importOriginal) => { - const actual = await importOriginal(); - const check = (call: keyof typeof keychain.fail) => { - const reason = keychain.fail[call]; - if (reason) throw new actual.KeyringError(reason); - }; - const systemKeyring: Keyring.Keyring = { - async get(service, account) { - check("get"); - return keychain.items.get(`${service} ${account}`); - }, - async set(service, account, password) { - check("set"); - keychain.items.set(`${service} ${account}`, password); - }, - async delete(service, account) { - check("delete"); - return keychain.items.delete(`${service} ${account}`); - }, - }; - return { ...actual, systemKeyring }; -}); - -const DECRYPT_ERROR = "Your Prismic login can't be decrypted. Run `prismic login` again."; -const LOCKED_ERROR = - "Can't read your Prismic key because the keychain is locked in this session. Unlock it and try again, or run `prismic login` again."; - -const session = { - host: "prismic.io", - auth0Domain: "auth0.prismic.io", - clientId: "client-id", - email: "dev@example.com", - accessToken: "access-token-secret", - refreshToken: "refresh-token-secret", - expiresAt: 1_800_000_000_000, -}; - -// src/env.ts requires PROD, which the build defines. Vitest sets it to "". -process.env.PROD = "false"; - -// Imports the store as a new process would, with its own config directory. -async function loadStore(dir: URL) { - vi.resetModules(); - process.env.PRISMIC_CONFIG_DIR = fileURLToPath(dir); - return import("../src/credentials"); -} - -async function logIn(store: Awaited>, saved: Session = session) { - const lock = await store.lockCredentials({ timeoutMs: 5_000 }); - const choice = await store.prepareLogin(lock); - await store.writeCredentials(lock, { ...choice, session: saved }); - await lock.release(); - return choice; -} - -// The tests share the keychain and the environment. -describe("credential store", { concurrent: false }, () => { - beforeEach(() => { - keychain.items.clear(); - keychain.fail = {}; - }); - - it("round trips a keyring login, with a fresh nonce on every write", async ({ home }) => { - const store = await loadStore(home); - const saved = { - ...session, - pendingLogin: { - auth0Domain: session.auth0Domain, - clientId: session.clientId, - deviceCode: "device-code-secret", - userCode: "BCDF-GHJK", - verificationUri: "https://auth0.prismic.io/activate", - expiresAt: 1_800_000_900_000, - intervalMs: 5_000, - }, - }; - - expect(await logIn(store, saved)).toEqual({ store: "keyring" }); - const first = await readFile(new URL("credentials.enc", home)); - await logIn(store, saved); - const second = await readFile(new URL("credentials.enc", home)); - - // Format byte, then the 12-byte nonce. - expect(first.subarray(1, 13).equals(second.subarray(1, 13))).toBe(false); - expect(first.toString("latin1")).not.toContain(session.refreshToken); - expect(first.toString("latin1")).not.toContain("device-code-secret"); - expect(JSON.parse(await readFile(new URL("credentials.json", home), "utf8"))).toEqual({ - version: 2, - host: session.host, - email: session.email, - expiresAt: session.expiresAt, - store: "keyring", - auth0Domain: session.auth0Domain, - clientId: session.clientId, - }); - expect(await (await loadStore(home)).readCredentials()).toEqual({ - host: session.host, - login: { store: "keyring", session: saved }, - }); - }); - - it("rejects a tampered file, a wrong key and a missing key", async ({ home }) => { - const encryptedPath = new URL("credentials.enc", home); - await logIn(await loadStore(home)); - const original = await readFile(encryptedPath); - const [[name, key]] = keychain.items; - - const tampered = Buffer.from(original); - tampered[tampered.length - 1] ^= 1; - await writeFile(encryptedPath, tampered); - await expect((await loadStore(home)).readCredentials()).rejects.toMatchObject({ - reason: "decrypt-failed", - message: DECRYPT_ERROR, - }); - - await writeFile(encryptedPath, original); - keychain.items.set(name, randomBytes(32).toString("base64")); - await expect((await loadStore(home)).readCredentials()).rejects.toMatchObject({ - reason: "decrypt-failed", - message: DECRYPT_ERROR, - }); - - keychain.items.clear(); - await expect((await loadStore(home)).readCredentials()).rejects.toMatchObject({ - reason: "missing-key", - message: DECRYPT_ERROR, - }); - - keychain.items.set(name, key); - expect((await (await loadStore(home)).readCredentials()).login?.session).toEqual(session); - }); - - it("falls back to a plaintext file when the keychain fails at login", async ({ home }) => { - const cases = [ - ["module-missing", "module-missing"], - ["unavailable", "secret-service-missing"], - ["locked", "locked"], - ["rejected", "write-rejected"], - ["timeout", "timeout"], - ] as const; - for (const [keyringReason, reason] of cases) { - keychain.fail.set = keyringReason; - const store = await loadStore(new URL(`${reason}/`, home)); - - expect(await logIn(store)).toEqual({ store: "file", storeReason: reason }); - expect(await (await loadStore(new URL(`${reason}/`, home))).readCredentials()).toEqual({ - host: session.host, - login: { store: "file", storeReason: reason, session }, - }); - } - }); - - it("keeps a file login in credentials.json and never reads the keychain for it", async ({ - home, - }) => { - keychain.fail.set = "unavailable"; - expect(await logIn(await loadStore(home))).toEqual({ - store: "file", - storeReason: "secret-service-missing", - }); - expect(JSON.parse(await readFile(new URL("credentials.json", home), "utf8"))).toEqual({ - version: 2, - store: "file", - storeReason: "secret-service-missing", - ...session, - }); - expect(await readdir(home)).not.toContain("credentials.enc"); - - keychain.fail = { get: "rejected", set: "rejected", delete: "rejected" }; - expect(await (await loadStore(home)).readCredentials()).toEqual({ - host: session.host, - login: { store: "file", storeReason: "secret-service-missing", session }, - }); - }); - - it("reports a failed key read after login, never a logout", async ({ home }) => { - await logIn(await loadStore(home)); - - keychain.fail.get = "locked"; - await expect((await loadStore(home)).readCredentials()).rejects.toMatchObject({ - reason: "locked", - message: LOCKED_ERROR, - }); - - keychain.fail.get = "timeout"; - await expect((await loadStore(home)).readCredentials()).rejects.toMatchObject({ - reason: "timeout", - }); - }); - - it("reuses one key per config directory", async ({ home }) => { - await logIn(await loadStore(new URL("a/", home))); - const [[account, key]] = keychain.items; - await logIn(await loadStore(new URL("a/", home))); - expect([...keychain.items]).toEqual([[account, key]]); - expect(account).toMatch(/^prismic-cli cli:[0-9a-f]{16}$/); - - await logIn(await loadStore(new URL("b/", home))); - expect(keychain.items.size).toBe(2); - }); - - it("creates a new key at login when another process deleted it", async ({ home }) => { - const store = await loadStore(home); - await logIn(store); - await store.readCredentials(); - - keychain.items.clear(); - await logIn(store); - expect(keychain.items.size).toBe(1); - expect((await (await loadStore(home)).readCredentials()).login?.session).toEqual(session); - }); - - it("reads the new key after another process logged out and in again", async ({ home }) => { - const store = await loadStore(home); - await logIn(store); - expect((await store.readCredentials()).login?.session).toEqual(session); - - const other = await loadStore(home); - const lock = await other.lockCredentials({ timeoutMs: 5_000 }); - await other.clearCredentials(lock); - await lock.release(); - await logIn(other, { ...session, accessToken: "new-access-token" }); - - expect((await store.readCredentials()).login?.session.accessToken).toBe("new-access-token"); - }); - - it.skipIf(process.platform === "win32")( - "writes a private directory and private files through temporary files", - async ({ home }) => { - const dir = new URL("config/", home); - await mkdir(dir, { mode: 0o755 }); - await logIn(await loadStore(dir)); - - expect((await stat(dir)).mode & 0o777).toBe(0o700); - expect((await stat(new URL("credentials.json", dir))).mode & 0o777).toBe(0o600); - expect((await stat(new URL("credentials.enc", dir))).mode & 0o777).toBe(0o600); - expect((await readdir(dir)).sort()).toEqual(["credentials.enc", "credentials.json"]); - }, - ); - - it.skipIf(process.platform === "win32")( - "refuses symlinked credential files", - async ({ home }) => { - const dir = new URL("config/", home); - await logIn(await loadStore(dir)); - const elsewhere = new URL("elsewhere/", home); - await mkdir(elsewhere); - - for (const name of ["credentials.enc", "credentials.json"]) { - const path = new URL(name, dir); - await writeFile(new URL(name, elsewhere), await readFile(path)); - await rm(path); - await symlink(new URL(name, elsewhere), path); - - await expect((await loadStore(dir)).readCredentials()).rejects.toMatchObject({ - reason: "unsafe-file", - message: expect.stringContaining("because it's a symbolic link"), - }); - await rm(path); - await writeFile(path, await readFile(new URL(name, elsewhere)), { mode: 0o600 }); - } - }, - ); - - // Changing a file's owner needs root, so this runs only as root. - it.skipIf(process.getuid?.() !== 0)( - "refuses files that belong to another user", - async ({ home }) => { - await writeFile(new URL("credentials.json", home), JSON.stringify({ token: "t", host: "h" })); - await chown(new URL("credentials.json", home), 65534, 65534); - await expect((await loadStore(home)).readCredentials()).rejects.toMatchObject({ - reason: "unsafe-file", - message: expect.stringContaining("because it belongs to another user"), - }); - }, - ); - - it("reads older files", async ({ home }) => { - const read = async (name: string, contents: string | undefined) => { - const dir = new URL(`${name}/`, home); - await mkdir(dir); - if (contents !== undefined) { - await writeFile(new URL("credentials.json", dir), contents, { mode: 0o644 }); - } - return (await loadStore(dir)).readCredentials(); - }; - - expect(await read("nothing", undefined)).toEqual({}); - expect(await read("host-only", JSON.stringify({ host: "wroom.io" }))).toEqual({ - host: "wroom.io", - }); - expect(await read("version-1", JSON.stringify({ token: "token", host: "wroom.io" }))).toEqual({ - host: "wroom.io", - }); - if (process.platform !== "win32") { - const mode = (await stat(new URL("version-1/credentials.json", home))).mode & 0o777; - expect(mode).toBe(0o600); - } - expect(await read("invalid", "{")).toEqual({}); - expect( - await read( - "unknown-reason", - JSON.stringify({ version: 2, store: "file", storeReason: "new-reason", ...session }), - ), - ).toEqual({ host: session.host, login: { store: "file", session } }); - await expect( - read("damaged", JSON.stringify({ version: 2, store: "file", host: null })), - ).rejects.toMatchObject({ reason: "decrypt-failed" }); - await expect(read("newer", JSON.stringify({ version: 3 }))).rejects.toMatchObject({ - reason: "unsupported-version", - }); - }); - - it("creates a key only under a lock it still holds", async ({ home }) => { - const store = await loadStore(home); - const stalled = await store.lockCredentials({ timeoutMs: 5_000 }); - // Another process took the lock over. - await rm(new URL("credentials.lock", home), { recursive: true }); - - await expect(store.prepareLogin(stalled)).rejects.toMatchObject({ reason: "lock-lost" }); - expect(keychain.items.size).toBe(0); - }); - - it("drops writes from a process that lost the lock", async ({ home }) => { - const store = await loadStore(home); - const stalled = await store.lockCredentials({ timeoutMs: 5_000 }); - - // Another process took the lock over. - await rm(new URL("credentials.lock", home), { recursive: true }); - const current = await store.lockCredentials({ timeoutMs: 5_000 }); - await store.writeCredentials(current, { store: "file", session }); - - await expect( - store.writeCredentials(stalled, { - store: "file", - session: { ...session, accessToken: "old" }, - }), - ).rejects.toMatchObject({ reason: "lock-lost" }); - await expect(store.clearCredentials(stalled)).rejects.toMatchObject({ reason: "lock-lost" }); - expect((await store.readCredentials()).login?.session).toEqual(session); - await current.release(); - }); - - it("logout deletes the session and the key, and keeps the host", async ({ home }) => { - const store = await loadStore(home); - await logIn(store); - - const lock = await store.lockCredentials({ timeoutMs: 5_000 }); - await store.clearCredentials(lock); - await lock.release(); - expect(await store.readCredentials()).toEqual({ host: session.host }); - expect(await readdir(home)).not.toContain("credentials.enc"); - expect(keychain.items.size).toBe(0); - - await logIn(store); - keychain.fail.delete = "locked"; - const again = await store.lockCredentials({ timeoutMs: 5_000 }); - await expect(store.clearCredentials(again)).rejects.toMatchObject({ - reason: "locked", - message: expect.stringContaining("Can't delete your Prismic key"), - }); - await again.release(); - expect(await store.readCredentials()).toEqual({ host: session.host }); - }); -}); - -it("round trips a key through the system keychain", async () => { - const { systemKeyring } = await vi.importActual("../src/lib/keyring"); - const service = "prismic-cli-test"; - const account = `test:${randomUUID()}`; - const options = { timeoutMs: 10_000 }; - try { - await systemKeyring.set(service, account, "key", options); - } catch (error) { - // Linux CI has no Secret Service. - expect(process.platform).toBe("linux"); - expect(error).toMatchObject({ reason: "unavailable" }); - return; - } - try { - expect(await systemKeyring.get(service, account, options)).toBe("key"); - } finally { - expect(await systemKeyring.delete(service, account, options)).toBe(true); - } - expect(await systemKeyring.get(service, account, options)).toBeUndefined(); - expect(await systemKeyring.delete(service, account, options)).toBe(false); -}); diff --git a/test/device-flow.test.ts b/test/device-flow.test.ts deleted file mode 100644 index ac186465..00000000 --- a/test/device-flow.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -// Temporary. C4, the PR that switches `prismic login` to the device flow, -// deletes this file and tests login end to end instead. - -import { describe, type TestContext } from "vitest"; - -import { - DeviceFlowError, - getDeviceLoginPrompt, - pollDeviceLogin, - startDeviceLogin, -} from "../src/lib/auth0"; -import { createFakeAuth0, type FakeAuth0, type FakeAuth0Options } from "./auth0"; -import { it } from "./it"; - -const PARAMS = { audience: "prismic.io", scope: "openid profile email offline_access" }; - -describe("startDeviceLogin", () => { - it("requests a code for the given audience and scope", async ({ expect, onTestFinished }) => { - const { auth0, start } = await setup(onTestFinished, { interval: 7 }); - const startedAt = Date.now(); - const pending = await start(); - expect(auth0.requests[0].params).toEqual({ client_id: auth0.clientId, ...PARAMS }); - expect(pending).toMatchObject({ - auth0Domain: auth0.auth0Domain, - clientId: auth0.clientId, - verificationUri: `${auth0.auth0Domain}/activate`, - intervalMs: 7_000, - }); - expect(pending.expiresAt).toBeGreaterThanOrEqual(startedAt + 900_000); - expect(getDeviceLoginPrompt(pending)).toEqual({ - url: `${auth0.auth0Domain}/activate?user_code=${pending.userCode}`, - userCode: pending.userCode, - codeInUrl: true, - }); - }); - - it("handles a response without a complete URL or interval", async ({ - expect, - onTestFinished, - }) => { - const { auth0, start } = await setup(onTestFinished); - auth0.respondOnce("/oauth/device/code", { - status: 200, - body: { - device_code: "device-code", - user_code: "BCDF-GHJK", - verification_uri: "https://auth0.prismic.io/activate", - expires_in: 900, - }, - }); - const pending = await start(); - expect(pending.intervalMs).toBe(5_000); - expect(getDeviceLoginPrompt(pending)).toEqual({ - url: "https://auth0.prismic.io/activate", - userCode: "BCDF-GHJK", - codeInUrl: false, - }); - }); - - it("reports a client that isn't set up", async ({ expect, onTestFinished }) => { - const { auth0 } = await setup(onTestFinished); - const error = await startDeviceLogin(PARAMS, { - auth0Domain: auth0.auth0Domain, - clientId: "unknown", - }).catch((error) => error); - expect(error).toBeInstanceOf(DeviceFlowError); - expect(error).toMatchObject({ reason: "misconfigured", detail: "invalid_client" }); - }); - - it("reports an unreachable login service", async ({ expect, onTestFinished }) => { - const { auth0, start } = await setup(onTestFinished); - auth0.respondOnce("/oauth/device/code", { status: 503 }); - await expect(start()).rejects.toMatchObject({ reason: "unavailable", detail: "HTTP 503" }); - }); -}); - -describe("pollDeviceLogin", () => { - it("returns the tokens once approved", async ({ expect, onTestFinished }) => { - const { auth0, start } = await setup(onTestFinished, { accessToken: "access", expiresIn: 60 }); - const pending = await start(); - auth0.respondOnce("/oauth/token", { status: 403, body: { error: "authorization_pending" } }); - auth0.respondOnce("/oauth/token", { status: 403, body: { error: "authorization_pending" } }); - auth0.approve(); - const tokens = await pollDeviceLogin(pending); - expect(tokenRequests(auth0)).toHaveLength(3); - expect(tokenRequests(auth0)[0].params).toEqual({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - device_code: pending.deviceCode, - client_id: auth0.clientId, - }); - expect(tokens.accessToken).toBe("access"); - expect(tokens.refreshToken).toEqual(expect.any(String)); - expect(tokens.expiresAt).toBeGreaterThan(Date.now() + 55_000); - expect(tokens.expiresAt).toBeLessThanOrEqual(Date.now() + 60_000); - }); - - it("completes a resumed login that was already approved", async ({ expect, onTestFinished }) => { - const { auth0, start } = await setup(onTestFinished, { interval: 5 }); - const pending = await start(); - auth0.approve(); - const tokens = await pollDeviceLogin({ ...pending, expiresAt: Date.now() + 2_000 }); - expect(tokens.accessToken).toEqual(expect.any(String)); - }); - - it("adds 5 seconds to the interval on slow_down", async ({ expect, onTestFinished }) => { - const { auth0, start } = await setup(onTestFinished); - const pending = await start(); - auth0.respondOnce("/oauth/token", { - status: 429, - body: { error: "slow_down", error_description: "Slow down." }, - }); - auth0.approve(); - const updates: number[] = []; - const startedAt = performance.now(); - await pollDeviceLogin(pending, { - onUpdate: (pending) => void updates.push(pending.intervalMs), - }); - expect(updates).toEqual([5_000]); - expect(performance.now() - startedAt).toBeGreaterThanOrEqual(5_000); - }); - - it("waits for Retry-After", async ({ expect, onTestFinished }) => { - const { auth0, start } = await setup(onTestFinished); - const pending = await start(); - auth0.respondOnce("/oauth/token", { status: 429, headers: { "Retry-After": "3" } }); - auth0.approve(); - const startedAt = performance.now(); - await pollDeviceLogin(pending); - expect(tokenRequests(auth0)).toHaveLength(2); - expect(performance.now() - startedAt).toBeGreaterThanOrEqual(3_000); - }); - - it("backs off after server errors", async ({ expect, onTestFinished }) => { - const { auth0, start } = await setup(onTestFinished); - const pending = await start(); - auth0.respondOnce("/oauth/token", { status: 503, headers: { "Retry-After": "0" } }); - auth0.respondOnce("/oauth/token", { status: 500, body: { error: "server_error" } }); - auth0.approve(); - const startedAt = performance.now(); - await pollDeviceLogin(pending); - expect(tokenRequests(auth0)).toHaveLength(3); - // 2 seconds, then 4. - expect(performance.now() - startedAt).toBeGreaterThanOrEqual(6_000); - }); - - it("retries after a request times out", async ({ expect, onTestFinished }) => { - const { auth0, start } = await setup(onTestFinished); - const pending = await start(); - auth0.respondOnce("/oauth/token", { status: 200, delayMs: 11_000 }); - auth0.approve(); - const startedAt = performance.now(); - const tokens = await pollDeviceLogin(pending); - expect(tokens.accessToken).toEqual(expect.any(String)); - expect(performance.now() - startedAt).toBeGreaterThanOrEqual(10_000); - }); - - it("stops when the code expires", async ({ expect, onTestFinished }) => { - const { auth0, start } = await setup(onTestFinished); - const pending = await start(); - auth0.expire(); - await expect(pollDeviceLogin(pending)).rejects.toMatchObject({ - reason: "expired", - detail: "expired_token", - }); - await expect(pollDeviceLogin({ ...pending, expiresAt: Date.now() })).rejects.toMatchObject({ - reason: "expired", - }); - expect(tokenRequests(auth0)).toHaveLength(1); - }); - - it("reports a declined login", async ({ expect, onTestFinished }) => { - const { auth0, start } = await setup(onTestFinished); - const declined = await start(); - auth0.deny({ userCode: declined.userCode }); - await expect(pollDeviceLogin(declined)).rejects.toMatchObject({ reason: "declined" }); - const unverified = await start(); - auth0.deny({ userCode: unverified.userCode, description: "email_verification_failed" }); - await expect(pollDeviceLogin(unverified)).rejects.toMatchObject({ - reason: "email-unverified", - }); - }); - - it("rejects a code that was already used", async ({ expect, onTestFinished }) => { - const { auth0, start } = await setup(onTestFinished); - const pending = await start(); - auth0.approve(); - await pollDeviceLogin(pending); - await expect(pollDeviceLogin(pending)).rejects.toMatchObject({ reason: "invalid-grant" }); - }); - - it("stops on unknown errors without exposing the code", async ({ expect, onTestFinished }) => { - const { auth0, start } = await setup(onTestFinished); - const pending = await start(); - auth0.respondOnce("/oauth/token", { - status: 403, - body: { error: "unauthorized_client", error_description: pending.deviceCode }, - }); - const error = await pollDeviceLogin(pending).catch((error) => error); - expect(error).toMatchObject({ reason: "misconfigured", detail: "unauthorized_client" }); - expect(error.message).not.toContain(pending.deviceCode); - expect(error.cause).toBeUndefined(); - auth0.respondOnce("/oauth/token", { status: 404, body: pending.deviceCode }); - await expect(pollDeviceLogin(pending)).rejects.toMatchObject({ detail: "HTTP 404" }); - auth0.respondOnce("/oauth/token", { status: 200, body: { access_token: "a", expires_in: 60 } }); - await expect(pollDeviceLogin(pending)).rejects.toMatchObject({ - detail: "unexpected response", - }); - }); - - it("stops when aborted", async ({ expect, onTestFinished }) => { - const { start } = await setup(onTestFinished, { interval: 1 }); - const pending = await start(); - await expect( - pollDeviceLogin(pending, { signal: AbortSignal.timeout(1_500) }), - ).rejects.toMatchObject({ name: "TimeoutError" }); - }); -}); - -async function setup(onTestFinished: TestContext["onTestFinished"], options?: FakeAuth0Options) { - const auth0 = await createFakeAuth0({ interval: 0, ...options }); - onTestFinished(() => auth0.close()); - const start = () => - startDeviceLogin(PARAMS, { auth0Domain: auth0.auth0Domain, clientId: auth0.clientId }); - return { auth0, start }; -} - -function tokenRequests(auth0: FakeAuth0) { - return auth0.requests.filter((request) => request.path === "/oauth/token"); -} diff --git a/test/finish-login.test.ts b/test/finish-login.test.ts deleted file mode 100644 index 14b57ec5..00000000 --- a/test/finish-login.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Temporary. This unit test covers what happens right after a login until -// `prismic login` uses it. C4, the PR that switches `prismic login` to the -// device flow, deletes this file and tests login end to end instead. - -import { readFile, writeFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; - -import { afterEach, vi } from "vitest"; - -import { it } from "./it"; - -// src/env.ts requires PROD, which the build defines. Vitest sets it to "". -process.env.PROD = "false"; - -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); -}); - -it("reads the profile once and forgets the tracked user", async ({ expect, home, host, token }) => { - const analyticsPath = new URL("analytics.json", home); - await writeFile(analyticsPath, JSON.stringify({ anonymousId: "anonymous", userId: "user" })); - const fetch = vi.spyOn(globalThis, "fetch"); - // Imports src/auth.ts as a new process would, with its own config directory. - vi.resetModules(); - vi.stubEnv("PRISMIC_CONFIG_DIR", fileURLToPath(home)); - const { finishLogin } = await import("../src/auth"); - - const { email } = await finishLogin({ token, host }); - - expect(email).toBe(process.env.E2E_PRISMIC_EMAIL); - expect(fetch).toHaveBeenCalledOnce(); - expect(String(fetch.mock.calls[0][0])).toBe(`https://api.internal.${host}/user/profile`); - expect(JSON.parse(await readFile(analyticsPath, "utf8"))).toEqual({ anonymousId: "anonymous" }); -}); diff --git a/test/init.test.ts b/test/init.test.ts index fe52e54b..ba769d8f 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -3,6 +3,7 @@ import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { describe } from "vitest"; import { + approveLogin, buildCustomType, captureOutput, failInstall, @@ -226,17 +227,46 @@ it("skips framework scaffolding with --no-setup", async ({ expect, project, pris await expect(access(new URL("package-lock.json", project))).rejects.toThrow(); }, 60_000); -it("triggers login flow when not logged in", async ({ expect, project, prismic, logout, repo }) => { +it("logs in when not logged in, even on CI", async ({ + expect, + project, + prismic, + logout, + auth0, + repo, +}) => { await rm(new URL("prismic.config.json", project)); await logout(); - const proc = prismic("init", ["--repo", repo, "--no-browser"]); + const proc = prismic("init", ["--repo", repo, "--no-browser", "--no-setup"], { + nodeOptions: { env: { CI: "true" } }, + }); const output = captureOutput(proc); + await approveLogin(expect, output, auth0); + const { exitCode } = await proc; + expect(exitCode, output()).toBe(0); + expect(output()).toContain("Logged in to Prismic as"); + const configRaw = await readFile(new URL("prismic.config.json", project), "utf-8"); + expect(JSON.parse(configRaw).repositoryName).toBe(repo); +}, 60_000); - // Verify login flow starts, then kill — no need to complete it - await expect.poll(output, { timeout: 15_000 }).toMatch(/port=(\d+)/); - proc.kill(); -}); +it("renews a rejected token instead of logging in again", async ({ + expect, + project, + prismic, + login, + auth0, + repo, +}) => { + await rm(new URL("prismic.config.json", project)); + await login({ accessToken: "rejected-token" }); + + const proc = prismic("init", ["--repo", repo, "--no-setup"]); + const output = captureOutput(proc); + expect((await proc).exitCode, output()).toBe(0); + expect(output()).not.toContain("Open this URL"); + expect(auth0.requestsTo("/oauth/token")).toHaveLength(1); +}, 60_000); it("fails if repo is not in the user's account", async ({ expect, project, prismic }) => { await rm(new URL("prismic.config.json", project)); diff --git a/test/it.ts b/test/it.ts index 0e6eee3f..19b4befb 100644 --- a/test/it.ts +++ b/test/it.ts @@ -10,6 +10,7 @@ import { x } from "tinyexec"; import type { ExpectStatic } from "vitest"; import { inject, test } from "vitest"; +import { createFakeAuth0, type FakeAuth0 } from "./auth0"; import { createRepository, createWriteToken, @@ -30,7 +31,17 @@ export type Fixtures = { project: URL; exec: typeof x; prismic: typeof x; - login: () => Promise<{ token: string; email: string }>; + keychain: void; + /** A fake Auth0 tenant. `prismic` logs in with it, and gets the e2e user's token. */ + auth0: FakeAuth0; + /** Saves a login in a file, as when no keychain is usable. */ + login: (session?: { + host?: string; + auth0Domain?: string; + accessToken?: string; + refreshToken?: string; + expiresAt?: number; + }) => Promise<{ email: string }>; logout: () => Promise; token: string; password: string; @@ -113,20 +124,52 @@ export const it = test.extend({ token: async ({}, use) => { await use(inject("token")); }, - login: async ({ token, home }, use) => { - await use(async () => { + // On macOS, `security` follows HOME, so a keychain in the test's HOME keeps + // tests off the user's keychain. Windows tests use the Credential Manager. + keychain: async ({ home }, use) => { + if (process.platform !== "darwin") return use(); + const path = fileURLToPath(new URL("Library/Keychains/prismic-test.keychain-db", home)); + await mkdir(new URL("Library/Keychains/", home), { recursive: true }); + // `security` drops its settings when this folder is missing. + await mkdir(new URL("Library/Preferences/", home), { recursive: true }); + await security(home, ["create-keychain", "-p", "", path]); + await security(home, ["list-keychains", "-d", "user", "-s", path]); + await security(home, ["default-keychain", "-s", path]); + // Without a timeout, it never locks, so it never asks for its password. + await security(home, ["set-keychain-settings", path]); + await use(); + await security(home, ["delete-keychain", path]); + }, + auth0: async ({ token }, use) => { + const auth0 = await createFakeAuth0({ accessToken: token }); + await use(auth0); + await auth0.close(); + }, + login: async ({ token, home, auth0, host }, use) => { + await use(async (session = {}) => { const configDir = new URL(".config/prismic/", home); await mkdir(configDir, { recursive: true }); await writeFile( new URL("credentials.json", configDir), - JSON.stringify({ token, host: DEFUALT_PRISMIC_HOST }), + JSON.stringify({ + version: 2, + store: "file", + host, + auth0Domain: auth0.auth0Domain, + clientId: auth0.clientId, + accessToken: token, + refreshToken: auth0.issueRefreshToken(), + expiresAt: Date.now() + 60 * 60 * 1000, + ...session, + }), ); - return { token, email: E2E_PRISMIC_EMAIL }; + return { email: E2E_PRISMIC_EMAIL }; }); }, logout: async ({ home }, use) => { await use(async () => { await rm(new URL(".config/prismic/credentials.json", home), { force: true }); + await rm(new URL(".config/prismic/credentials.enc", home), { force: true }); }); }, exec: async ({ home, bin, project }, use) => { @@ -139,6 +182,9 @@ export const it = test.extend({ ...options?.nodeOptions, env: { ...process.env, + // Keeps the CLI off a Linux desktop's keyring. Logins go to a file, + // as on CI. + DBUS_SESSION_BUS_ADDRESS: "unix:path=/nonexistent", ...options?.nodeOptions?.env, PATH: `${fileURLToPath(bin)}${delimiter}${process.env.PATH}`, HOME: fileURLToPath(home), @@ -152,7 +198,7 @@ export const it = test.extend({ if (proc.exitCode === undefined) proc.kill(); } }, - prismic: async ({ home, login, exec }, use) => { + prismic: async ({ home, login, exec, auth0, keychain: _keychain }, use) => { await login(); const configDir = new URL(".config/prismic/", home); await use((command, args = [], options) => @@ -162,6 +208,8 @@ export const it = test.extend({ ...options?.nodeOptions, env: { AI_AGENT: "", + PRISMIC_AUTH0_DOMAIN: auth0.auth0Domain, + PRISMIC_AUTH0_CLIENT_ID: auth0.clientId, PRISMIC_TYPE_BUILDER_ENABLED: "true", PRISMIC_SENTRY_ENABLED: "false", PRISMIC_TELEMETRY_ENABLED: "false", @@ -213,25 +261,29 @@ export async function failInstall(project: URL): Promise { await writeFile(new URL("npm.cmd", bin), "@exit /b 1\r\n"); } -export function captureOutput(proc: Result): () => string { +// tinyexec's own result can miss output that this collects. +export function captureOutput(proc: Result, options: { stderr?: boolean } = {}): () => string { + const { stderr = true } = options; let output = ""; proc.process?.stdout?.on("data", (c: Buffer) => (output += c.toString())); - proc.process?.stderr?.on("data", (c: Buffer) => (output += c.toString())); + if (stderr) proc.process?.stderr?.on("data", (c: Buffer) => (output += c.toString())); return () => output; } -export async function completeLogin( +export async function waitForLoginPrompt( expect: ExpectStatic, output: () => string, - token: string, ): Promise { - await expect.poll(output, { timeout: 15_000 }).toMatch(/port=(\d+)/); - const port = output().match(/port=(\d+)/)![1]; - await fetch(`http://localhost:${port}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cookies: [`prismic-auth=${token}; path=/`], email: "test@example.com" }), - }); + await expect.poll(output, { timeout: 15_000 }).toContain("Waiting for the login"); +} + +export async function approveLogin( + expect: ExpectStatic, + output: () => string, + auth0: FakeAuth0, +): Promise { + await waitForLoginPrompt(expect, output); + auth0.approve(); } export function buildCustomType( @@ -370,3 +422,11 @@ async function getConfig(project: URL): Promise<{ libraries?: string[] }> { const raw = await readFile(path, "utf8"); return JSON.parse(raw); } + +// Runs `security` with the test's HOME, so it only sees the test's keychain. +async function security(home: URL, args: string[]): Promise { + await x("/usr/bin/security", args, { + nodeOptions: { env: { ...process.env, HOME: fileURLToPath(home) } }, + throwOnError: true, + }); +} diff --git a/test/locale-list.test.ts b/test/locale-list.test.ts index e2305415..36b6aae6 100644 --- a/test/locale-list.test.ts +++ b/test/locale-list.test.ts @@ -38,5 +38,5 @@ it("does not log in when the repository is not accessible", async ({ expect, pri ); expect(exitCode).toBe(1); expect(stderr).toContain("You do not have access to this repository"); - expect(stderr).not.toContain("Log in to continue"); + expect(stderr).not.toContain("Open this URL"); }); diff --git a/test/lock.test.ts b/test/lock.test.ts deleted file mode 100644 index 571da4ba..00000000 --- a/test/lock.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -// Temporary. These unit tests cover the credentials lock until `prismic login` -// uses it. C4, the PR that switches `prismic login` to the device flow, deletes -// this file and tests login end to end instead. - -import { spawn } from "node:child_process"; -import { once } from "node:events"; -import { mkdir, readFile, rename, utimes, writeFile } from "node:fs/promises"; -import { setTimeout as sleep } from "node:timers/promises"; -import { fileURLToPath } from "node:url"; - -import { expect } from "vitest"; - -import { acquireLock, LockTimeoutError } from "../src/lib/lock"; -import { it } from "./it"; - -// Node.js strips the types of the imported module. -function lockProcess(path: URL, script: string) { - const lockModule = new URL("../src/lib/lock.ts", import.meta.url).href; - const code = ` - import { acquireLock } from ${JSON.stringify(lockModule)}; - const path = new URL(process.argv[1]); - ${script} - `; - const child = spawn( - process.execPath, - ["--no-warnings", "--input-type=module", "-e", code, path.href], - { stdio: ["pipe", "pipe", "inherit"] }, - ); - let stdout = ""; - child.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk)); - return { - child, - output: () => stdout, - exit: async () => { - const [code] = child.exitCode === null ? await once(child, "exit") : [child.exitCode]; - return code; - }, - }; -} - -it("lets one process at a time hold the lock", async ({ home }) => { - const path = new URL("test.lock", home); - const counter = new URL("counter", home); - await writeFile(counter, "0"); - - // Each process reads the counter, waits, then writes it plus one. Without - // the lock, processes would overwrite each other's increments. - const processes = Array.from({ length: 10 }, () => - lockProcess( - path, - ` - import { readFile, rename, writeFile } from "node:fs/promises"; -import { setTimeout as sleep } from "node:timers/promises"; - const counter = ${JSON.stringify(fileURLToPath(counter))}; - const lock = await acquireLock(path, { timeoutMs: 30_000 }); - const count = Number(await readFile(counter, "utf8")); - await new Promise((resolve) => setTimeout(resolve, 50)); - if (await lock.isHeld()) await writeFile(counter, String(count + 1)); - await lock.release(); - `, - ), - ); - expect(await Promise.all(processes.map((process) => process.exit()))).toEqual(Array(10).fill(0)); - expect(await readFile(counter, "utf8")).toBe("10"); -}); - -it("takes over the lock of a process that exited", async ({ home }) => { - const path = new URL("test.lock", home); - const owner = lockProcess(path, `await acquireLock(path, { timeoutMs: 5_000 });`); - expect(await owner.exit()).toBe(0); - - // The owner's PID is gone, so this doesn't wait for the lock to go stale. - const started = Date.now(); - const lock = await acquireLock(path, { timeoutMs: 5_000, staleMs: 60_000 }); - expect(Date.now() - started).toBeLessThan(2_000); - expect(await lock.isHeld()).toBe(true); - await lock.release(); -}); - -it.skipIf(process.platform === "win32")( - "takes over the lock of a stalled process, which then drops its write", - async ({ home }) => { - const path = new URL("test.lock", home); - const owner = lockProcess( - path, - ` - const lock = await acquireLock(path, { timeoutMs: 5_000, staleMs: 1_000, heartbeatMs: 200 }); - console.log("held"); - process.stdin.once("data", async () => { - console.log((await lock.isHeld()) ? "still held" : "lost"); - process.exit(); - }); - `, - ); - while (!owner.output().includes("held")) await once(owner.child.stdout, "data"); - - // A stopped process is alive but stops touching the lock. - owner.child.kill("SIGSTOP"); - const lock = await acquireLock(path, { timeoutMs: 10_000, staleMs: 1_000 }); - owner.child.kill("SIGCONT"); - - owner.child.stdin.write("check\n"); - expect(await owner.exit()).toBe(0); - expect(owner.output()).toContain("lost"); - expect(await lock.isHeld()).toBe(true); - await lock.release(); - }, -); - -it("keeps the heartbeat after the lock briefly disappears", async ({ home }) => { - const path = new URL("test.lock", home); - const hidden = new URL("hidden.lock", home); - const holder = await acquireLock(path, { timeoutMs: 1_000, staleMs: 500, heartbeatMs: 50 }); - - // A recovering waiter renames the lock away, then puts it back. - await rename(path, hidden); - await sleep(150); - await rename(hidden, path); - - await sleep(700); - await expect(acquireLock(path, { timeoutMs: 200, staleMs: 500 })).rejects.toBeInstanceOf( - LockTimeoutError, - ); - expect(await holder.isHeld()).toBe(true); - await holder.release(); -}); - -it("takes over a lock that no process claimed", async ({ home }) => { - const path = new URL("test.lock", home); - // A process died between creating the lock and writing its owner. - await mkdir(path); - const past = new Date(Date.now() - 2_000); - await utimes(path, past, past); - - const lock = await acquireLock(path, { timeoutMs: 1_000, staleMs: 60_000 }); - expect(await lock.isHeld()).toBe(true); - await lock.release(); -}); - -it("waits for a live holder, then times out", async ({ home }) => { - const path = new URL("test.lock", home); - const holder = await acquireLock(path, { timeoutMs: 1_000 }); - - await expect(acquireLock(path, { timeoutMs: 300 })).rejects.toBeInstanceOf(LockTimeoutError); - - await holder.release(); - expect(await holder.isHeld()).toBe(false); - const next = await acquireLock(path, { timeoutMs: 300 }); - await next.release(); -}); diff --git a/test/login.test.ts b/test/login.test.ts index 38e2956f..e7c357b5 100644 --- a/test/login.test.ts +++ b/test/login.test.ts @@ -1,7 +1,6 @@ -import { readFile } from "node:fs/promises"; -import { createServer } from "node:net"; +import { readFile, stat } from "node:fs/promises"; -import { captureOutput, it } from "./it"; +import { approveLogin, captureOutput, it, waitForLoginPrompt } from "./it"; it("supports --help", async ({ expect, prismic }) => { const { stdout, stderr, exitCode } = await prismic("login", ["--help"]); @@ -9,55 +8,177 @@ it("supports --help", async ({ expect, prismic }) => { expect(stdout).toContain("prismic login [options]"); }); -it("logs in and writes token", async ({ expect, home, prismic, logout }) => { +it("logs in with a code, and saves the login for this user only", async ({ + expect, + home, + prismic, + login, + logout, + auth0, + token, +}) => { + const { email } = await login(); await logout(); + const proc = prismic("login", ["--no-browser"]); const output = captureOutput(proc); + await approveLogin(expect, output, auth0); + const { exitCode } = await proc; + expect(exitCode, output()).toBe(0); + expect(output()).toMatch( + /Open this URL on any device to log in:\n {2}http:\/\/127\.0\.0\.1:\d+\/activate\?user_code=/, + ); + expect(output()).toContain(`Check that the page shows the code ${getUserCode(output())}.`); + expect(output()).toContain(`Logged in to Prismic as ${email}`); + const deviceCode = auth0.requestsTo("/oauth/token")[0]?.device_code; + expect(deviceCode).toBeDefined(); + expect(output()).not.toContain(deviceCode); + expect(output()).not.toContain(token); - await expect.poll(output, { timeout: 10_000 }).toMatch(/port=(\d+)/); - const port = output().match(/port=(\d+)/)![1]; - await fetch(`http://localhost:${port}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - cookies: ["prismic-auth=test-token-123; path=/"], - email: "test@example.com", - }), - }); - await expect.poll(output).toContain("Logged in to Prismic as test@example.com"); + const path = new URL(".config/prismic/credentials.json", home); + const credentials = await readFile(path, "utf8"); + // The `exec` fixture keeps Linux off the keyring, so its logins go to this file. + if (process.platform !== "linux") expect(credentials).not.toContain(token); + if (process.platform !== "win32") expect((await stat(path)).mode & 0o777).toBe(0o600); +}); - const authFile = await readFile(new URL(".config/prismic/credentials.json", home), "utf-8"); - const { token } = JSON.parse(authFile); - expect(token).toBe("test-token-123"); +it("stops an agent at its wait limit, and its next run continues the login", async ({ + expect, + prismic, + logout, + auth0, +}) => { + await logout(); + const { stdout: taskId } = await prismic("task-id"); + const args = ["--task-id", taskId.trim(), "--user-intent", "Log in to Prismic"]; + const env = { AI_AGENT: "test-agent", PRISMIC_AGENT_WAIT_MS: "1000" }; + + const first = await prismic("login", args, { nodeOptions: { env } }); + expect(first.exitCode).toBe(1); + expect(first.stderr).toContain( + "Waiting for approval. After the user approves, run the same command again. It continues this login.", + ); + + auth0.approve(); + const second = await prismic("login", args, { + nodeOptions: { env: { ...env, PRISMIC_AGENT_WAIT_MS: "30000" } }, + }); + expect(second.exitCode, second.stderr).toBe(0); + expect(getUserCode(first.stderr)).toBeDefined(); + expect(getUserCode(second.stderr)).toBe(getUserCode(first.stderr)); + expect(auth0.requestsTo("/oauth/device/code")).toHaveLength(1); }); -it("prints the login URL once when the preferred port is in use", async ({ +it.skipIf(process.platform === "win32")( + "cancels on Ctrl-C, and the next run starts a new login", + async ({ expect, prismic, logout }) => { + await logout(); + const first = prismic("login"); + const output = captureOutput(first); + await waitForLoginPrompt(expect, output); + first.kill("SIGINT"); + const { exitCode } = await first; + expect(exitCode).toBe(130); + expect(output()).toContain("Login cancelled."); + + const second = prismic("login"); + const next = captureOutput(second); + await waitForLoginPrompt(expect, next); + expect(getUserCode(next())).not.toBe(getUserCode(output())); + }, +); + +it("makes another command wait for the login in progress", async ({ expect, prismic, logout, - onTestFinished, + auth0, + repo, }) => { await logout(); - // Another test may already hold the port, which also triggers the fallback. - const blocker = createServer(); - await new Promise((resolve) => - blocker.once("error", resolve).listen(5555, "0.0.0.0", resolve), + const login = prismic("login"); + const repos = prismic("repo", ["list", "--json"], { nodeOptions: { env: { CI: "" } } }); + const loginOutput = captureOutput(login); + const reposOutput = captureOutput(repos); + const reposStdout = captureOutput(repos, { stderr: false }); + await waitForLoginPrompt(expect, loginOutput); + await waitForLoginPrompt(expect, reposOutput); + expect(getUserCode(reposOutput())).toBe(getUserCode(loginOutput())); + + auth0.approve(); + expect((await login).exitCode, loginOutput()).toBe(0); + expect((await repos).exitCode, reposOutput()).toBe(0); + expect(JSON.parse(reposStdout())).toEqual( + expect.arrayContaining([expect.objectContaining({ domain: repo })]), ); - onTestFinished(() => void blocker.close()); + expect(auth0.requestsTo("/oauth/device/code")).toHaveLength(1); +}); - const proc = prismic("login", ["--no-browser"]); +it("explains a login that Auth0 refused", async ({ expect, prismic, logout, auth0 }) => { + await logout(); + const answers = [ + [() => auth0.deny(), "The login was declined in the browser."], + [ + () => auth0.deny({ description: "email_verification_failed" }), + "Prismic sent you an email to verify your address. Verify it, then run the command again.", + ], + [() => auth0.expire(), "The login code expired. Run the command again."], + [ + () => auth0.respondOnce("/oauth/token", { status: 403, body: { error: "invalid_grant" } }), + "The login code was already used or isn't valid. Run the command again.", + ], + ] as const; + + const outputs = []; + for (const [answer, message] of answers) { + const proc = prismic("login"); + const output = captureOutput(proc); + await waitForLoginPrompt(expect, output); + answer(); + expect((await proc).exitCode).toBe(1); + expect(output()).toContain(message); + outputs.push(output()); + } + // Each refused code was deleted, so each run started a new one. + expect(auth0.requestsTo("/oauth/device/code")).toHaveLength(4); + for (const { device_code } of auth0.requestsTo("/oauth/token")) { + expect(outputs.join("\n")).not.toContain(device_code); + } +}); + +it("stops a login when another command logs out", async ({ expect, prismic, logout }) => { + await logout(); + const proc = prismic("login"); const output = captureOutput(proc); - await expect.poll(output, { timeout: 10_000 }).toMatch(/port=(\d+)/); - const port = output().match(/port=(\d+)/)![1]; - await fetch(`http://localhost:${port}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - cookies: ["prismic-auth=test-token-123; path=/"], - email: "test@example.com", - }), + await waitForLoginPrompt(expect, output); + const { exitCode: logoutExitCode, stderr } = await prismic("logout"); + expect(logoutExitCode, stderr).toBe(0); + expect((await proc).exitCode).toBe(1); + expect(output()).toContain("A logout cancelled this login. Run the command again to log in."); +}); + +it("explains a login that isn't set up", async ({ expect, host, prismic, logout }) => { + await logout(); + const { stderr, exitCode } = await prismic("login", [], { + nodeOptions: { env: { PRISMIC_AUTH0_CLIENT_ID: "unknown-client" } }, }); - await expect.poll(output).toContain("Logged in to Prismic as test@example.com"); + expect(exitCode).toBe(1); + expect(stderr).toContain( + `Login isn't set up correctly for ${host} (invalid_client). Report this: https://github.com/prismicio/cli/issues`, + ); +}); - expect(output().match(/port=\d+/g)).toHaveLength(1); +it("revokes the previous session after a new login", async ({ expect, prismic, login, auth0 }) => { + const refreshToken = auth0.issueRefreshToken(); + await login({ refreshToken }); + + const proc = prismic("login"); + await approveLogin(expect, captureOutput(proc), auth0); + expect((await proc).exitCode).toBe(0); + const revokes = auth0.requestsTo("/oauth/revoke"); + expect(revokes.map((params) => params.token)).toEqual([refreshToken]); }); + +function getUserCode(output: string): string | undefined { + return output.match(/the code ([A-Z0-9]{4}-[A-Z0-9]{4})/)?.[1]; +} diff --git a/test/logout.test.ts b/test/logout.test.ts index fe7ce054..22886205 100644 --- a/test/logout.test.ts +++ b/test/logout.test.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; -import { it } from "./it"; +import { approveLogin, captureOutput, it } from "./it"; it("supports --help", async ({ expect, prismic }) => { const { stdout, stderr, exitCode } = await prismic("logout", ["--help"]); @@ -8,13 +8,55 @@ it("supports --help", async ({ expect, prismic }) => { expect(stdout).toContain("prismic logout [options]"); }); -it("logs out and deletes auth file", async ({ expect, home, prismic }) => { +it("revokes the session, and deletes the login but keeps the host", async ({ + expect, + home, + prismic, + logout, + auth0, +}) => { + await logout(); + const login = prismic("login"); + await approveLogin(expect, captureOutput(login), auth0); + expect((await login).exitCode).toBe(0); + const configDir = new URL(".config/prismic/", home); + const encrypted = new URL("credentials.enc", configDir); + // The `exec` fixture keeps Linux off the keyring, so its logins go to a file. + if (process.platform !== "linux") await expect(readFile(encrypted)).resolves.toBeTruthy(); + + const { stdout, stderr, exitCode } = await prismic("logout"); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("Logged out of Prismic"); + expect(auth0.requestsTo("/oauth/revoke")).toHaveLength(1); + const credentials = JSON.parse(await readFile(new URL("credentials.json", configDir), "utf8")); + expect(Object.keys(credentials)).toEqual(["host"]); + await expect(readFile(encrypted)).rejects.toThrow(); +}); + +it("logs out when the session can't be revoked, and says so", async ({ + expect, + home, + prismic, + login, +}) => { + // The .invalid TLD never resolves, like a DNS failure when offline. + await login({ auth0Domain: "https://auth0.invalid" }); const { stdout, stderr, exitCode } = await prismic("logout"); expect(exitCode, stderr).toBe(0); expect(stdout).toContain("Logged out of Prismic"); - await expect( - readFile(new URL(".config/prismic/credentials.json", home), "utf-8"), - ).rejects.toThrow(); + expect(stderr).toContain( + "Couldn't revoke your session because the Prismic login service can't be reached. It stays valid until it expires.", + ); + const credentials = await readFile(new URL(".config/prismic/credentials.json", home), "utf8"); + expect(credentials).not.toContain("accessToken"); +}); + +it("says that PRISMIC_TOKEN still works after logging out", async ({ expect, prismic, token }) => { + const { stderr, exitCode } = await prismic("logout", [], { + nodeOptions: { env: { PRISMIC_TOKEN: token } }, + }); + expect(exitCode, stderr).toBe(0); + expect(stderr).toContain("PRISMIC_TOKEN is still set, so commands still use it."); }); it("succeeds when not logged in", async ({ expect, prismic, logout }) => { diff --git a/test/repo-list.test.ts b/test/repo-list.test.ts index cb264240..0ef47516 100644 --- a/test/repo-list.test.ts +++ b/test/repo-list.test.ts @@ -1,6 +1,4 @@ -import { writeFile } from "node:fs/promises"; - -import { captureOutput, completeLogin, it } from "./it"; +import { approveLogin, captureOutput, it } from "./it"; it("supports --help", async ({ expect, prismic }) => { const { stdout, stderr, exitCode } = await prismic("repo", ["list", "--help"]); @@ -43,34 +41,57 @@ it("lists the one repository a Write API token can write to", async ({ ]); }); -it("logs in and continues when not logged in", async ({ expect, prismic, logout, token, repo }) => { +it("logs in and continues when not logged in", async ({ expect, prismic, logout, auth0, repo }) => { await logout(); const proc = prismic("repo", ["list", "--json"], { nodeOptions: { env: { CI: "" } } }); const output = captureOutput(proc); - await completeLogin(expect, output, token); - const { stdout, exitCode } = await proc; + const stdout = captureOutput(proc, { stderr: false }); + await approveLogin(expect, output, auth0); + const { exitCode } = await proc; expect(exitCode, output()).toBe(0); - expect(output()).toContain("Log in to continue: https://"); - expect(JSON.parse(stdout)).toEqual( + expect(output()).toContain("You are not logged in."); + expect(output()).toContain("Logged in to Prismic as"); + // The login prompt stays out of the command's output. + expect(JSON.parse(stdout())).toEqual( expect.arrayContaining([expect.objectContaining({ domain: repo })]), ); }); -it("logs in again and continues when the login expired", async ({ +it("logs in again and continues when the session ended", async ({ expect, prismic, - home, - host, - token, + login, + auth0, repo, }) => { - await writeFile( - new URL(".config/prismic/credentials.json", home), - JSON.stringify({ token: "expired-token", host }), - ); + // Auth0 no longer knows this refresh token. + await login({ refreshToken: "ended", expiresAt: Date.now() - 1000 }); const proc = prismic("repo", ["list"], { nodeOptions: { env: { CI: "" } } }); - await completeLogin(expect, captureOutput(proc), token); - const { stdout, stderr, exitCode } = await proc; - expect(exitCode, stderr).toBe(0); - expect(stdout).toContain(repo); + const output = captureOutput(proc); + await approveLogin(expect, output, auth0); + const { exitCode } = await proc; + expect(exitCode, output()).toBe(0); + expect(output()).toContain("Your login expired."); + expect(output()).toContain(repo); +}); + +it("doesn't log in when Auth0 can't be reached", async ({ expect, prismic, login, auth0 }) => { + await login({ accessToken: "rejected-token" }); + auth0.respondOnce("/oauth/token", { status: 503 }); + const { stderr, exitCode } = await prismic("repo", ["list"], { + nodeOptions: { env: { CI: "" } }, + }); + expect(exitCode).toBe(1); + expect(stderr).toContain("Can't reach the Prismic login service."); + expect(stderr).not.toContain("Open this URL"); +}); + +it("doesn't log in on CI", async ({ expect, prismic, login, auth0 }) => { + await login({ accessToken: "rejected-token" }); + const { stderr, exitCode } = await prismic("repo", ["list"], { + nodeOptions: { env: { CI: "true" } }, + }); + expect(exitCode).toBe(1); + expect(stderr).not.toContain("Open this URL"); + expect(auth0.requestsTo("/oauth/token")).toHaveLength(0); }); diff --git a/test/session-token.test.ts b/test/session-token.test.ts deleted file mode 100644 index 1848d493..00000000 --- a/test/session-token.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -// Temporary. These unit tests cover the token refresh until commands use it. -// C4, the PR that switches `prismic login` to the device flow, deletes this -// file and tests refresh end to end instead. They use a fake Auth0 tenant, -// since a real login needs a browser approval. - -import { readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; - -import { beforeEach, describe, expect, vi, type TestContext } from "vitest"; - -import type * as Credentials from "../src/credentials"; -import { pollDeviceLogin, startDeviceLogin } from "../src/lib/auth0"; -import type * as Keyring from "../src/lib/keyring"; -import { createFakeAuth0, type FakeAuth0 } from "./auth0"; -import { it } from "./it"; - -const keychain = vi.hoisted(() => ({ - items: new Map(), - fail: {} as Partial>, -})); - -vi.mock("../src/lib/keyring", async (importOriginal) => { - const actual = await importOriginal(); - const check = (call: keyof typeof keychain.fail) => { - const reason = keychain.fail[call]; - if (reason) throw new actual.KeyringError(reason); - }; - const systemKeyring: Keyring.Keyring = { - async get(service, account) { - check("get"); - return keychain.items.get(`${service} ${account}`); - }, - async set(service, account, password) { - check("set"); - keychain.items.set(`${service} ${account}`, password); - }, - async delete(service, account) { - check("delete"); - return keychain.items.delete(`${service} ${account}`); - }, - }; - return { ...actual, systemKeyring }; -}); - -const INVALID_GRANT = { status: 403, body: { error: "invalid_grant" } }; -const PARAMS = { audience: "prismic.io", scope: "openid profile email offline_access" }; - -// src/env.ts requires PROD, which the build defines. Vitest sets it to "". -process.env.PROD = "false"; - -// Imports the modules as a new process would, with its own config directory. -async function load(dir: URL) { - vi.resetModules(); - process.env.PRISMIC_CONFIG_DIR = fileURLToPath(dir); - return { auth: await import("../src/auth"), store: await import("../src/credentials") }; -} - -async function startFakeAuth0(onTestFinished: TestContext["onTestFinished"]) { - const auth0 = await createFakeAuth0({ interval: 0 }); - onTestFinished(() => auth0.close()); - return auth0; -} - -async function approveLogin(auth0: FakeAuth0) { - const { auth0Domain, clientId } = auth0; - const pending = await startDeviceLogin(PARAMS, { auth0Domain, clientId }); - auth0.approve(); - return pollDeviceLogin(pending); -} - -// Saves a login whose access token expires in a minute. -async function logIn( - auth0: FakeAuth0, - store: typeof Credentials, - overrides: Partial = {}, -): Promise { - const session = { - host: "prismic.io", - auth0Domain: auth0.auth0Domain, - clientId: auth0.clientId, - email: "dev@example.com", - ...(await approveLogin(auth0)), - expiresAt: Date.now() + 60_000, - ...overrides, - }; - const lock = await store.lockCredentials({ timeoutMs: 5_000 }); - const choice = await store.prepareLogin(lock); - await store.writeCredentials(lock, { ...choice, session }); - await lock.release(); - return session; -} - -async function readSession(dir: URL) { - return (await (await load(dir)).store.readCredentials()).login?.session; -} - -function refreshRequests(auth0: FakeAuth0) { - return auth0.requests - .filter((request) => request.params.grant_type === "refresh_token") - .map((request) => request.params); -} - -// The tests share the keychain and the environment. -describe("getSessionToken", { concurrent: false }, () => { - beforeEach(() => { - keychain.items.clear(); - keychain.fail = {}; - }); - - it("returns a token with more than 5 minutes left, without the lock or Auth0", async ({ - home, - onTestFinished, - }) => { - const auth0 = await startFakeAuth0(onTestFinished); - const { auth, store } = await load(home); - const session = await logIn(auth0, store, { expiresAt: Date.now() + 6 * 60_000 }); - - // Another process holds the lock. - const lock = await store.lockCredentials({ timeoutMs: 5_000 }); - expect(await auth.getSessionToken()).toEqual({ status: "active", token: session.accessToken }); - await lock.release(); - expect(refreshRequests(auth0)).toEqual([]); - }); - - it("refreshes a token about to expire where it was issued, without writing to the keychain", async ({ - home, - onTestFinished, - }) => { - const auth0 = await startFakeAuth0(onTestFinished); - const { auth, store } = await load(home); - const session = await logIn(auth0, store); - const key = [...keychain.items]; - keychain.fail.set = "rejected"; - - const startedAt = Date.now(); - const result = await auth.getSessionToken(); - expect(refreshRequests(auth0)).toEqual([ - { - grant_type: "refresh_token", - refresh_token: session.refreshToken, - client_id: auth0.clientId, - }, - ]); - const saved = await readSession(home); - expect(result).toEqual({ status: "active", token: saved?.accessToken }); - expect(saved).toEqual({ - ...session, - accessToken: expect.any(String), - refreshToken: expect.any(String), - expiresAt: expect.any(Number), - }); - expect(saved?.accessToken).not.toBe(session.accessToken); - expect(saved?.refreshToken).not.toBe(session.refreshToken); - expect(saved?.expiresAt).toBeGreaterThanOrEqual(startedAt + 3_600_000); - const plain = JSON.parse(await readFile(new URL("credentials.json", home), "utf8")); - expect(plain.expiresAt).toBe(saved?.expiresAt); - expect([...keychain.items]).toEqual(key); - }); - - it("keeps the refresh token when Auth0 doesn't send a new one", async ({ - home, - onTestFinished, - }) => { - const auth0 = await startFakeAuth0(onTestFinished); - const { auth, store } = await load(home); - const session = await logIn(auth0, store); - auth0.respondOnce("/oauth/token", { - status: 200, - body: { access_token: "new", expires_in: 3_600 }, - }); - - expect(await auth.getSessionToken()).toEqual({ status: "active", token: "new" }); - expect((await readSession(home))?.refreshToken).toBe(session.refreshToken); - }); - - it("renews a token that isn't about to expire with forceRefresh, and reports no session", async ({ - home, - onTestFinished, - }) => { - const auth0 = await startFakeAuth0(onTestFinished); - const { auth, store } = await load(home); - expect(await auth.getSessionToken()).toEqual({ status: "none" }); - expect(await auth.getSessionToken({ forceRefresh: true })).toEqual({ status: "none" }); - - const session = await logIn(auth0, store, { expiresAt: Date.now() + 3_600_000 }); - const result = await auth.getSessionToken({ forceRefresh: true }); - expect(refreshRequests(auth0)).toHaveLength(1); - expect(result).toEqual({ status: "active", token: (await readSession(home))?.accessToken }); - expect(result).not.toEqual({ status: "active", token: session.accessToken }); - }); - - it("ends the session on invalid_grant, and keeps the host, the key and a pending login", async ({ - home, - onTestFinished, - }) => { - const auth0 = await startFakeAuth0(onTestFinished); - const { auth, store } = await load(home); - const pendingLogin = { - auth0Domain: auth0.auth0Domain, - clientId: auth0.clientId, - deviceCode: "device-code", - userCode: "BCDF-GHJK", - verificationUri: `${auth0.auth0Domain}/activate`, - expiresAt: Date.now() + 900_000, - intervalMs: 5_000, - }; - // The fake tenant doesn't know this refresh token, as after a revoke. - await logIn(auth0, store, { refreshToken: "revoked", pendingLogin }); - const key = [...keychain.items]; - - expect(await auth.getSessionToken()).toEqual({ status: "ended" }); - expect(refreshRequests(auth0)).toHaveLength(1); - expect(await (await load(home)).store.readCredentials()).toEqual({ - host: "prismic.io", - login: { - store: "keyring", - session: { - host: "prismic.io", - auth0Domain: auth0.auth0Domain, - clientId: auth0.clientId, - pendingLogin, - }, - }, - }); - expect([...keychain.items]).toEqual(key); - - expect(await auth.getSessionToken()).toEqual({ status: "none" }); - expect(refreshRequests(auth0)).toHaveLength(1); - }); - - it("retries once with a refresh token that changed during the refresh", async ({ - home, - onTestFinished, - }) => { - const auth0 = await startFakeAuth0(onTestFinished); - // A file login, so the test can save a session as another process would. - keychain.fail.set = "unavailable"; - const { auth, store } = await load(home); - const session = await logIn(auth0, store); - const writeSession = (saved: Credentials.Session) => - writeFile( - new URL("credentials.json", home), - JSON.stringify({ - version: 2, - store: "file", - storeReason: "secret-service-missing", - ...saved, - }), - ); - const approveNewer = async () => ({ - ...session, - ...(await approveLogin(auth0)), - expiresAt: Date.now() + 60_000, - }); - - // Something else saves a newer session while Auth0 rejects the first try. - const newer = await approveNewer(); - auth0.respondOnce("/oauth/token", { ...INVALID_GRANT, delayMs: 500 }); - const result = auth.getSessionToken(); - await vi.waitFor(() => expect(refreshRequests(auth0)).toHaveLength(1)); - await writeSession(newer); - expect(await result).toEqual({ - status: "active", - token: (await readSession(home))?.accessToken, - }); - expect(refreshRequests(auth0).map((params) => params.refresh_token)).toEqual([ - session.refreshToken, - newer.refreshToken, - ]); - - // Only once. - const newest = await approveNewer(); - auth0.respondOnce("/oauth/token", { ...INVALID_GRANT, delayMs: 500 }); - auth0.respondOnce("/oauth/token", INVALID_GRANT); - const ended = auth.getSessionToken({ forceRefresh: true }); - await vi.waitFor(() => expect(refreshRequests(auth0)).toHaveLength(3)); - await writeSession(newest); - expect(await ended).toEqual({ status: "ended" }); - expect(refreshRequests(auth0)).toHaveLength(4); - expect(await readSession(home)).toEqual({ - host: "prismic.io", - auth0Domain: auth0.auth0Domain, - clientId: auth0.clientId, - }); - }); - - it("uses the current token while Auth0 can't refresh it, and never deletes the login", async ({ - home, - onTestFinished, - }) => { - const auth0 = await startFakeAuth0(onTestFinished); - const { auth, store } = await load(home); - const session = await logIn(auth0, store); - const current = { status: "active", token: session.accessToken }; - const secrets = [session.accessToken, session.refreshToken]; - - auth0.respondOnce("/oauth/token", { status: 503 }); - expect(await auth.getSessionToken()).toEqual(current); - auth0.respondOnce("/oauth/token", { status: 401, body: { error: "invalid_client" } }); - expect(await auth.getSessionToken()).toEqual(current); - // Longer than the 10-second timeout. - auth0.respondOnce("/oauth/token", { status: 503, delayMs: 11_000 }); - expect(await auth.getSessionToken()).toEqual(current); - - // A server already refused the current token. The 503 echoes a token to - // check that errors never carry it. - auth0.respondOnce("/oauth/token", { - status: 503, - body: { error: "temporarily_unavailable", error_description: session.refreshToken }, - }); - const forced = await auth.getSessionToken({ forceRefresh: true }).catch((error) => error); - expect(forced).toMatchObject({ reason: "unavailable", detail: "temporarily_unavailable" }); - expect(forced.cause).toBeUndefined(); - for (const secret of secrets) expect(`${forced.stack} ${forced.message}`).not.toContain(secret); - - auth0.respondOnce("/oauth/token", { status: 401, body: { error: "invalid_client" } }); - await expect(auth.getSessionToken({ forceRefresh: true })).rejects.toMatchObject({ - reason: "misconfigured", - detail: "invalid_client", - }); - - const expired = new URL("expired/", home); - const expiredSession = await logIn(auth0, (await load(expired)).store, { - expiresAt: Date.now() - 1_000, - }); - const { auth: expiredAuth } = await load(expired); - await auth0.close(); - await expect(expiredAuth.getSessionToken()).rejects.toMatchObject({ - reason: "unavailable", - detail: "network error", - }); - - expect(await readSession(home)).toEqual(session); - expect(await readSession(expired)).toEqual(expiredSession); - expect(refreshRequests(auth0)).toHaveLength(5); - }); - - it("shares one refresh between parallel calls in one process", async ({ - home, - onTestFinished, - }) => { - const auth0 = await startFakeAuth0(onTestFinished); - const { auth, store } = await load(home); - await logIn(auth0, store); - - const results = await Promise.all(Array.from({ length: 10 }, () => auth.getSessionToken())); - expect(refreshRequests(auth0)).toHaveLength(1); - expect(results).toEqual( - Array(10).fill({ status: "active", token: (await readSession(home))?.accessToken }), - ); - - const forced = await Promise.all( - Array.from({ length: 10 }, () => auth.getSessionToken({ forceRefresh: true })), - ); - expect(refreshRequests(auth0)).toHaveLength(2); - expect(forced).toEqual( - Array(10).fill({ status: "active", token: (await readSession(home))?.accessToken }), - ); - }); - - it("makes one refresh for 10 parallel commands with a token about to expire, and none logs out", async ({ - home, - onTestFinished, - }) => { - const auth0 = await startFakeAuth0(onTestFinished); - await logIn(auth0, (await load(home)).store); - const commands = []; - for (let i = 0; i < 10; i++) commands.push((await load(home)).auth); - - const results = await Promise.all(commands.map((auth) => auth.getSessionToken())); - expect(refreshRequests(auth0)).toHaveLength(1); - expect(results).toEqual( - Array(10).fill({ status: "active", token: (await readSession(home))?.accessToken }), - ); - }); - - it("drops the write of a stalled refresh that lost the lock", async ({ - home, - onTestFinished, - }) => { - const auth0 = await startFakeAuth0(onTestFinished); - const { auth: stalled, store } = await load(home); - await logIn(auth0, store); - - auth0.respondOnce("/oauth/token", { - status: 200, - body: { access_token: "stalled", refresh_token: "stalled", expires_in: 3_600 }, - delayMs: 1_000, - }); - const stalledResult = stalled.getSessionToken().catch((error) => error); - await vi.waitFor(() => expect(refreshRequests(auth0)).toHaveLength(1)); - - // It stalls long enough for another process to take the lock over. - await rm(new URL("credentials.lock", home), { recursive: true }); - const result = await (await load(home)).auth.getSessionToken(); - - expect(await stalledResult).toMatchObject({ reason: "lock-lost" }); - const saved = await readSession(home); - expect(result).toEqual({ status: "active", token: saved?.accessToken }); - expect(saved?.refreshToken).not.toBe("stalled"); - }); - - it("doesn't retry or end the session after losing the lock", async ({ home, onTestFinished }) => { - const auth0 = await startFakeAuth0(onTestFinished); - const { auth: stalled, store } = await load(home); - await logIn(auth0, store); - - auth0.respondOnce("/oauth/token", { ...INVALID_GRANT, delayMs: 1_000 }); - const stalledResult = stalled.getSessionToken().catch((error) => error); - await vi.waitFor(() => expect(refreshRequests(auth0)).toHaveLength(1)); - - // Another process takes the lock over, and saves a newer session. - await rm(new URL("credentials.lock", home), { recursive: true }); - const other = await load(home); - const newer = await logIn(auth0, other.store); - - expect(await stalledResult).toMatchObject({ reason: "lock-lost" }); - expect(refreshRequests(auth0)).toHaveLength(1); - expect(await readSession(home)).toEqual(newer); - }); - - it("doesn't restore a session after a logout that started during the refresh", async ({ - home, - onTestFinished, - }) => { - const auth0 = await startFakeAuth0(onTestFinished); - const { auth: refreshing, store } = await load(home); - await logIn(auth0, store); - - auth0.respondOnce("/oauth/token", { - status: 200, - body: { access_token: "late", refresh_token: "late", expires_in: 3_600 }, - delayMs: 1_000, - }); - const result = refreshing.getSessionToken().catch((error) => error); - await vi.waitFor(() => expect(refreshRequests(auth0)).toHaveLength(1)); - - // The refresh stalls long enough for the logout to take the lock over. - await rm(new URL("credentials.lock", home), { recursive: true }); - const logout = await load(home); - const lock = await logout.store.lockCredentials({ timeoutMs: 5_000 }); - await logout.store.clearCredentials(lock); - await lock.release(); - - expect(await result).toMatchObject({ reason: "lock-lost" }); - expect(await logout.store.readCredentials()).toEqual({ host: "prismic.io" }); - expect(await readdir(home)).not.toContain("credentials.enc"); - expect(keychain.items.size).toBe(0); - }); -}); diff --git a/test/tracking.test.ts b/test/tracking.test.ts deleted file mode 100644 index 8918cb9b..00000000 --- a/test/tracking.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -// Temporary. These unit tests cover the login details on the End event until -// commands send them. C4, the PR that switches `prismic login` to the device -// flow, deletes this file and tests them end to end instead. - -import { randomBytes } from "node:crypto"; -import { fileURLToPath } from "node:url"; - -import { afterEach, beforeEach, describe, vi } from "vitest"; - -import type { FallbackReason, StoreMode } from "../src/credentials"; -import { it } from "./it"; - -// Replaces Segment, so tests never send events. -const events = vi.hoisted(() => [] as { event: string; properties?: Record }[]); - -vi.mock("../src/lib/segment", () => ({ - initSegment: () => {}, - trackIdentity: () => {}, - trackEvent: (event: string, config: { properties?: Record } = {}) => { - events.push({ event, properties: config.properties }); - }, -})); - -// src/env.ts requires PROD, which the build defines. Vitest sets it to "". -process.env.PROD = "false"; - -// Imports tracking as a new process would, with its own config directory. -async function loadTracking( - home: URL, - config: { token?: string; store?: StoreMode; storeReason?: FallbackReason } = {}, -) { - vi.resetModules(); - vi.stubEnv("PRISMIC_CONFIG_DIR", fileURLToPath(home)); - vi.stubEnv("AI_AGENT", "test-agent"); - // Gives the same runtime on every machine. - vi.stubEnv("CLAUDE_CODE_REMOTE", "true"); - const tracking = await import("../src/tracking"); - await tracking.initTracking({ host: "wroom.io", ...config }); - return tracking; -} - -function getLoginProperties() { - const { store, storeReason, loginResult, loginResumed, loginDurationS, revoked } = - events.at(-1)?.properties ?? {}; - return { store, storeReason, loginResult, loginResumed, loginDurationS, revoked }; -} - -// The tests share the recorded events and the environment. -describe("login details on the End event", { concurrent: false }, () => { - beforeEach(() => { - events.length = 0; - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("adds the store, the login and the logout", async ({ expect, home }) => { - let tracking = await loadTracking(home, { - store: "file", - storeReason: "secret-service-missing", - }); - tracking.trackCommandEnd("repo-list"); - expect(getLoginProperties()).toEqual({ - store: "file", - storeReason: "secret-service-missing", - }); - - tracking = await loadTracking(home); - tracking.trackLogin({ result: "wait-limit", resumed: false, startedAt: Date.now() - 90_000 }); - tracking.trackCommandEnd("repo-list"); - expect(getLoginProperties()).toEqual({ - loginResult: "wait-limit", - loginResumed: false, - loginDurationS: 90, - }); - - tracking = await loadTracking(home); - tracking.trackLogin({ - result: "success", - resumed: true, - startedAt: Date.now() - 120_000, - store: "keyring", - }); - tracking.trackCommandEnd("repo-list"); - expect(getLoginProperties()).toEqual({ - store: "keyring", - loginResult: "success", - loginResumed: true, - loginDurationS: 120, - }); - - tracking = await loadTracking(home, { store: "keyring" }); - tracking.trackLogout({ revoked: false }); - tracking.trackCommandEnd("logout"); - expect(getLoginProperties()).toEqual({ store: "keyring", revoked: false }); - }); - - it("leaves Start and End unchanged until something sets them", async ({ expect, home }) => { - const tracking = await loadTracking(home); - tracking.trackCommandStart("repo-list"); - tracking.trackCommandEnd("repo-list"); - - // Segment receives JSON, which drops undefined properties. - const sent = events.map(({ event, properties }) => [ - event, - Object.keys(JSON.parse(JSON.stringify(properties))), - ]); - const context = ["agent", "auth", "runtime", "runtimeProvider"]; - expect(sent).toEqual([ - ["Prismic CLI Start", ["commandType", "fullCommand", ...context]], - ["Prismic CLI End", ["commandType", "fullCommand", "success", ...context]], - ]); - }); - - it("maps device flow errors onto login results", async ({ expect, home }) => { - const tracking = await loadTracking(home); - const { DeviceFlowError } = await import("../src/lib/auth0"); - const reasons = [ - "expired", - "declined", - "email-unverified", - "invalid-grant", - "misconfigured", - "unavailable", - ] as const; - - const results = reasons.map((reason) => [ - reason, - tracking.getLoginResult(new DeviceFlowError(reason)), - ]); - expect(Object.fromEntries(results)).toEqual({ - expired: "expired", - declined: "declined", - "email-unverified": "email-unverified", - "invalid-grant": "error", - misconfigured: "error", - unavailable: "error", - }); - }); - - it("never sends tokens, codes or the key", async ({ expect, home }) => { - const key = randomBytes(32).toString("base64"); - const auth0 = { host: "prismic.io", auth0Domain: "auth0.prismic.io", clientId: "client-id" }; - const pending = { - ...auth0, - deviceCode: "device-code-secret", - userCode: "BCDF-GHJK", - verificationUri: "https://auth0.prismic.io/activate", - verificationUriComplete: "https://auth0.prismic.io/activate?user_code=BCDF-GHJK", - expiresAt: Date.now() + 900_000, - intervalMs: 5_000, - startedAt: Date.now(), - }; - const login = { - store: "keyring", - key, - session: { - ...auth0, - accessToken: "access-token-secret", - refreshToken: "refresh-token-secret", - pendingLogin: pending, - }, - } as const; - - // Callers hold a whole pending login and saved login, not just the fields - // the End event needs. - const tracking = await loadTracking(home, { token: "access-token-secret", ...login }); - tracking.trackLogin({ ...pending, ...login, result: "success", resumed: true }); - tracking.trackLogout({ revoked: true }); - tracking.trackCommandStart("login"); - tracking.trackCommandEnd("login"); - - expect(events).toHaveLength(2); - const sent = JSON.stringify(events); - for (const secret of [ - "access-token-secret", - "refresh-token-secret", - "device-code-secret", - "BCDF-GHJK", - key, - ]) { - expect(sent).not.toContain(secret); - } - }); -}); diff --git a/test/webhook-list.test.ts b/test/webhook-list.test.ts index 0b1a0238..bed74eae 100644 --- a/test/webhook-list.test.ts +++ b/test/webhook-list.test.ts @@ -1,6 +1,4 @@ -import { writeFile } from "node:fs/promises"; - -import { captureOutput, completeLogin, it } from "./it"; +import { captureOutput, it } from "./it"; import { createWebhook } from "./prismic"; it("supports --help", async ({ expect, prismic }) => { @@ -30,19 +28,17 @@ it("lists webhooks as JSON", async ({ expect, prismic, repo, token, host }) => { expect(parsed).toEqual(expect.arrayContaining([expect.objectContaining({ url })])); }); -it("logs in again and continues when the repository rejects the token", async ({ +it("renews a token the repository rejects, and continues without a login", async ({ expect, prismic, - home, - host, - token, + login, + auth0, }) => { - await writeFile( - new URL(".config/prismic/credentials.json", home), - JSON.stringify({ token: "expired-token", host }), - ); + await login({ accessToken: "rejected-token" }); const proc = prismic("webhook", ["list"], { nodeOptions: { env: { CI: "" } } }); - await completeLogin(expect, captureOutput(proc), token); - const { stderr, exitCode } = await proc; - expect(exitCode, stderr).toBe(0); + const output = captureOutput(proc); + const { exitCode } = await proc; + expect(exitCode, output()).toBe(0); + expect(output()).not.toContain("Open this URL"); + expect(auth0.requestsTo("/oauth/token")).toHaveLength(1); }); diff --git a/test/whoami.test.ts b/test/whoami.test.ts index 73d30d85..587328bd 100644 --- a/test/whoami.test.ts +++ b/test/whoami.test.ts @@ -1,6 +1,6 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { chmod, readFile } from "node:fs/promises"; -import { it } from "./it"; +import { captureOutput, it, waitForLoginPrompt } from "./it"; it("supports --help", async ({ expect, prismic }) => { const { stdout, stderr, exitCode } = await prismic("whoami", ["--help"]); @@ -22,6 +22,15 @@ it("fails when not logged in", async ({ expect, prismic, logout }) => { expect(stderr).toContain("Not logged in. Run `prismic login` first."); }); +it("doesn't send a login's token to another host", async ({ expect, prismic, login, host }) => { + await login({ host: "example.com" }); + const { stderr, exitCode } = await prismic("whoami", [], { + nodeOptions: { env: { PRISMIC_HOST: host } }, + }); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Not logged in. Run `prismic login` first."); +}); + it("reports invalid PRISMIC_TOKEN", async ({ expect, prismic, logout }) => { await logout(); const { stderr, exitCode } = await prismic("whoami", [], { @@ -31,14 +40,8 @@ it("reports invalid PRISMIC_TOKEN", async ({ expect, prismic, logout }) => { expect(stderr).toContain("PRISMIC_TOKEN is invalid or expired"); }); -it("reports stored token auth failure", async ({ expect, prismic, home }) => { - const configDir = new URL(".config/prismic/", home); - await mkdir(configDir, { recursive: true }); - await writeFile( - new URL("credentials.json", configDir), - JSON.stringify({ token: "invalid-token", host: "prismic.io" }), - ); - +it("reports stored token auth failure", async ({ expect, prismic, login }) => { + await login({ accessToken: "invalid-token" }); const { stderr, exitCode } = await prismic("whoami"); expect(exitCode).not.toBe(0); expect(stderr).toContain("Your session is invalid or expired."); @@ -73,3 +76,98 @@ it("reports a Write API token", async ({ expect, prismic, repo, writeToken }) => expect(stdout).toContain("Write API token"); expect(stdout).toContain(repo); }); + +it("uses a token that hasn't expired when Auth0 can't be reached", async ({ + expect, + home, + prismic, + login, +}) => { + // The .invalid TLD never resolves, like a DNS failure when offline. + const auth0Domain = "https://auth0.invalid"; + const { email } = await login({ auth0Domain, expiresAt: Date.now() + 60_000 }); + const fresh = await prismic("whoami"); + expect(fresh.exitCode, fresh.stderr).toBe(0); + expect(fresh.stdout).toContain(email); + + await login({ auth0Domain, expiresAt: Date.now() - 1000 }); + const expired = await prismic("whoami"); + expect(expired.exitCode).toBe(1); + expect(expired.stderr).toContain( + "Can't reach the Prismic login service. Check your connection, then run the command again.", + ); + const credentials = await readFile(new URL(".config/prismic/credentials.json", home), "utf8"); + expect(JSON.parse(credentials).refreshToken).toBeDefined(); +}); + +// Unlike the `login` fixture, `prismic login` saves to the keychain where one +// is usable. +it("refreshes a login saved by `prismic login`", async ({ + expect, + home, + prismic, + logout, + auth0, + token, +}) => { + await logout(); + const login = prismic("login"); + const output = captureOutput(login); + await waitForLoginPrompt(expect, output); + // Expires inside the 5-minute refresh margin. + auth0.approve({ expiresIn: 60 }); + expect((await login).exitCode, output()).toBe(0); + + const { stderr, exitCode } = await prismic("whoami"); + expect(exitCode, stderr).toBe(0); + const grants = auth0.requestsTo("/oauth/token").map((params) => params.grant_type); + expect(grants.filter((grant) => grant === "refresh_token")).toHaveLength(1); + const credentials = await readFile(new URL(".config/prismic/credentials.json", home), "utf8"); + // The `exec` fixture keeps Linux off the keyring, so its logins go to this file. + if (process.platform !== "linux") expect(credentials).not.toContain(token); +}); + +it("refreshes a login about to expire, once for parallel commands", async ({ + expect, + prismic, + login, + auth0, +}) => { + // The fake Auth0 refreshes to the e2e token. + const { email } = await login({ accessToken: "stale-token", expiresAt: Date.now() + 60_000 }); + const results = await Promise.all([prismic("whoami"), prismic("whoami")]); + for (const { stdout, stderr, exitCode } of results) { + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain(email); + } + expect(auth0.requestsTo("/oauth/token")).toHaveLength(1); +}); + +// Some agent sandboxes can't write to the home folder. Folder modes don't +// apply on Windows or to root. +it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "works in a config folder it can't write, until the token expires", + async ({ expect, home, prismic, login, auth0 }) => { + const configDir = new URL(".config/prismic/", home); + const { email } = await login({ expiresAt: Date.now() + 60_000 }); + await chmod(configDir, 0o500); + try { + const fresh = await prismic("whoami"); + expect(fresh.exitCode, fresh.stderr).toBe(0); + expect(fresh.stdout).toContain(email); + // A refresh it can't save would lose the new refresh token. + expect(auth0.requestsTo("/oauth/token")).toHaveLength(0); + + await chmod(configDir, 0o700); + await login({ expiresAt: Date.now() - 1000 }); + await chmod(configDir, 0o500); + const expired = await prismic("whoami"); + expect(expired.exitCode).toBe(1); + expect(expired.stderr).toContain( + "Set PRISMIC_CONFIG_DIR to a private folder outside your project.", + ); + } finally { + await chmod(configDir, 0o700); + } + }, +); diff --git a/tsdown.config.ts b/tsdown.config.ts index 810474a4..37b2c860 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -5,7 +5,6 @@ const PROD = process.env.MODE === "production"; export default defineConfig({ entry: { index: "./src/index.ts", - "subprocesses/refreshToken": "./src/subprocesses/refreshToken.ts", "subprocesses/sendSegmentEvents": "./src/subprocesses/sendSegmentEvents.ts", "subprocesses/updateVersionState": "./src/subprocesses/updateVersionState.ts", }, From a228b58e25527fe329212c03c79653637b2aa545 Mon Sep 17 00:00:00 2001 From: Lucie <25330882+lihbr@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:16:23 +0900 Subject: [PATCH 10/11] fix(auth): don't call a login cancelled when it saved before Ctrl-C (#484) A Ctrl-C while a new login was being saved printed "Login cancelled." and recorded a cancel, although the login had saved. The save keeps the credentials lock while it revokes the previous session, and the Ctrl-C handler waited for that lock. The handler now checks whether the login saved, prints "Logged in to Prismic." and records a success. It still exits 130. Co-Authored-By: Claude Opus 5.5 --- src/auth.ts | 18 +++++++++++++++--- test/auth0.ts | 4 +++- test/login.test.ts | 17 +++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index 2670e5ed..66503b6a 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -58,6 +58,8 @@ type Login = { pending?: PendingLogin; /** Set on Ctrl-C, so its cleanup doesn't read as a logout. */ cancelled?: boolean; + /** Set once the login is saved, so a later Ctrl-C doesn't call it cancelled. */ + saved?: SavedLogin; }; type SavedLogin = Pick & { token: string }; @@ -214,9 +216,18 @@ export async function logIn(options: { browser?: boolean } = {}): Promise { login.cancelled = true; console.error(); + // Waits for a save in progress, which holds the lock. if (login.pending) await setPendingLogin(login.pending, undefined); - recordLogin({ result: "cancelled", resumed: login.resumed, startedAt: login.startedAt }); - await endCommand("Login cancelled.", 130); + const { resumed, startedAt, saved } = login; + const result = saved ? "success" : "cancelled"; + recordLogin({ + result, + resumed, + startedAt, + store: saved?.store, + storeReason: saved?.storeReason, + }); + await endCommand(saved ? "Logged in to Prismic." : "Login cancelled.", 130); }); let outcome: LoginOutcome; @@ -464,13 +475,14 @@ async function saveLogin( : await prepareLogin(lock); const session = { host: login.host, ...login.auth0, ...tokens }; await writeCredentials(lock, { ...store, session }); + login.saved = { ...store, token: tokens.accessToken }; // The new login is saved first, so a failed revoke only leaves the // previous session to expire. const previous = stored?.login?.session; if (previous?.refreshToken) { await revokeRefreshToken(previous.refreshToken, previous).catch(() => {}); } - return { ...store, token: tokens.accessToken }; + return login.saved; }); } diff --git a/test/auth0.ts b/test/auth0.ts index 05a2082c..865ea25f 100644 --- a/test/auth0.ts +++ b/test/auth0.ts @@ -1,13 +1,14 @@ import { randomBytes } from "node:crypto"; import { createServer, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; +import { setTimeout as sleep } from "node:timers/promises"; // A fake Auth0 tenant for login tests. A real device login needs a person to // approve it in a browser, so automated tests can't use Auth0 itself. const INTERVAL = 0.1; -type FakeAuth0Response = { status: number; body?: unknown }; +type FakeAuth0Response = { status: number; body?: unknown; delayMs?: number }; export type FakeAuth0 = { auth0Domain: string; @@ -48,6 +49,7 @@ export async function createFakeAuth0(options: { accessToken: string }): Promise const response = responses.get(path)?.shift(); if (response) { + await sleep(response.delayMs ?? 0); send(res, response.status, response.body); return; } diff --git a/test/login.test.ts b/test/login.test.ts index e7c357b5..34da8bc8 100644 --- a/test/login.test.ts +++ b/test/login.test.ts @@ -88,6 +88,23 @@ it.skipIf(process.platform === "win32")( }, ); +it.skipIf(process.platform === "win32")( + "reports a login that saved before Ctrl-C as saved", + async ({ expect, prismic, login, auth0 }) => { + // The new login revokes this session while it still holds the lock after saving. + await login({ refreshToken: auth0.issueRefreshToken() }); + auth0.respondOnce("/oauth/revoke", { status: 200, delayMs: 2000 }); + const proc = prismic("login"); + const output = captureOutput(proc); + await approveLogin(expect, output, auth0); + await expect.poll(() => auth0.requestsTo("/oauth/revoke")).toHaveLength(1); + proc.kill("SIGINT"); + expect((await proc).exitCode).toBe(130); + expect(output()).toContain("Logged in to Prismic."); + expect(output()).not.toContain("Login cancelled."); + }, +); + it("makes another command wait for the login in progress", async ({ expect, prismic, From 30624f0c0a28d71dc39e68852625310663c95f7c Mon Sep 17 00:00:00 2001 From: Lucie <25330882+lihbr@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:34:31 +0900 Subject: [PATCH 11/11] feat(auth): send the CLI version and OS to Auth0 (#485) The four Auth0 requests now send `User-Agent: prismic-cli/ ()`. Auth0 keeps it with each login, so the dashboard's profile page can show which CLI version and system each login came from. Requests to Prismic keep the plain `prismic-cli`. Co-Authored-By: Claude Opus 5.5 --- src/lib/auth0.ts | 7 +++++++ test/auth0.ts | 11 +++++++++-- test/login.test.ts | 5 +++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/lib/auth0.ts b/src/lib/auth0.ts index 43ac5359..fc59f9ec 100644 --- a/src/lib/auth0.ts +++ b/src/lib/auth0.ts @@ -2,9 +2,12 @@ import { setTimeout as sleep } from "node:timers/promises"; import * as z from "zod/mini"; +import packageJson from "../../package.json" with { type: "json" }; import { NetworkError, request, RequestError } from "./request"; const REQUEST_TIMEOUT_MS = 10_000; +// Auth0 keeps it with the login, so the dashboard can tell a user's logins apart. +const AUTH0_HEADERS = { "User-Agent": `prismic-cli/${packageJson.version} (${process.platform})` }; const DEFAULT_INTERVAL_MS = 5_000; const SLOW_DOWN_MS = 5_000; const MAX_BACKOFF_MS = 60_000; @@ -55,6 +58,7 @@ export async function startDeviceLogin( const response = await request(url, { method: "POST", body: new URLSearchParams({ client_id: config.clientId, ...params }), + headers: AUTH0_HEADERS, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), schema: DeviceCodeSchema, }).catch((error) => { @@ -108,6 +112,7 @@ export async function pollDeviceLogin( device_code: current.deviceCode, client_id: current.clientId, }), + headers: AUTH0_HEADERS, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), schema: DeviceTokenSchema, }); @@ -162,6 +167,7 @@ export async function refreshAccessToken( refresh_token: refreshToken, client_id: config.clientId, }), + headers: AUTH0_HEADERS, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), schema: RefreshedTokenSchema, }).catch((error) => { @@ -182,6 +188,7 @@ export async function revokeRefreshToken(refreshToken: string, config: Auth0Conf await request(url, { method: "POST", body: new URLSearchParams({ client_id: config.clientId, token: refreshToken }), + headers: AUTH0_HEADERS, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }).catch((error) => { throw new DeviceFlowError(isTransient(error) ? "unavailable" : "misconfigured", error); diff --git a/test/auth0.ts b/test/auth0.ts index 865ea25f..2efe3226 100644 --- a/test/auth0.ts +++ b/test/auth0.ts @@ -14,6 +14,7 @@ export type FakeAuth0 = { auth0Domain: string; clientId: string; requestsTo: (path: string) => Record[]; + userAgentsTo: (path: string) => (string | undefined)[]; /** Approves the latest code. `expiresIn` sets the token's lifetime in seconds. */ approve: (options?: { expiresIn?: number }) => void; deny: (options?: { description?: string }) => void; @@ -35,7 +36,11 @@ type DeviceCode = { export async function createFakeAuth0(options: { accessToken: string }): Promise { const { accessToken } = options; const clientId = `fake-${randomToken(8)}`; - const requests: { path: string; params: Record }[] = []; + const requests: { + path: string; + params: Record; + userAgent: string | undefined; + }[] = []; const responses = new Map(); const codes: DeviceCode[] = []; const refreshTokens = new Set(); @@ -45,7 +50,7 @@ export async function createFakeAuth0(options: { accessToken: string }): Promise let body = ""; for await (const chunk of req) body += chunk; const params = Object.fromEntries(new URLSearchParams(body)); - requests.push({ path, params }); + requests.push({ path, params, userAgent: req.headers["user-agent"] }); const response = responses.get(path)?.shift(); if (response) { @@ -149,6 +154,8 @@ export async function createFakeAuth0(options: { accessToken: string }): Promise clientId, requestsTo: (path) => requests.filter((request) => request.path === path).map((request) => request.params), + userAgentsTo: (path) => + requests.filter((request) => request.path === path).map((request) => request.userAgent), approve: ({ expiresIn } = {}) => { const code = latestCode(); code.status = "approved"; diff --git a/test/login.test.ts b/test/login.test.ts index 34da8bc8..d5683291 100644 --- a/test/login.test.ts +++ b/test/login.test.ts @@ -1,5 +1,6 @@ import { readFile, stat } from "node:fs/promises"; +import packageJson from "../package.json" with { type: "json" }; import { approveLogin, captureOutput, it, waitForLoginPrompt } from "./it"; it("supports --help", async ({ expect, prismic }) => { @@ -32,6 +33,10 @@ it("logs in with a code, and saves the login for this user only", async ({ expect(output()).toContain(`Logged in to Prismic as ${email}`); const deviceCode = auth0.requestsTo("/oauth/token")[0]?.device_code; expect(deviceCode).toBeDefined(); + // The dashboard reads the version and platform from it. + expect(auth0.userAgentsTo("/oauth/token")[0]).toBe( + `prismic-cli/${packageJson.version} (${process.platform})`, + ); expect(output()).not.toContain(deviceCode); expect(output()).not.toContain(token);