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/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/auth.ts b/src/auth.ts index e2d08307..66503b6a 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,194 +1,490 @@ -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 * as z from "zod/mini"; - -import { CREDENTIALS_PATH } from "./config"; +import { setTimeout as sleep } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; + +import { + CredentialsError, + lockLogin, + prepareLogin, + readCredentials, + readLoginSummary, + withCredentialsLock, + writeCredentials, + type Session, + type StoredLogin, +} from "./credentials"; import { DEFAULT_PRISMIC_HOST, env } from "./env"; -import { exists, readJsonFile, writeFileRecursive } from "./lib/file"; -import { stringify } from "./lib/json"; -import { refreshToken as baseRefreshToken, validateToken } from "./lib/prismic/client"; +import { detectAgent } from "./lib/ai"; +import { + type Auth0Config, + type DeviceLoginTokens, + DeviceFlowError, + type PendingLogin, + pollDeviceLogin, + refreshAccessToken, + revokeRefreshToken, + startDeviceLogin, +} from "./lib/auth0"; +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"; - -const LOGIN_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes -const PREFERRED_PORT = 5555; -const LOGIN_SOURCE = "prismic-cli"; +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()))); -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; +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; + /** Set once the login is saved, so a later Ctrl-C doesn't call it cancelled. */ + saved?: SavedLogin; +}; + +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" }; + +let renewal: Promise | undefined; +let ended = false; + +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; + 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; } -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; +async function getSessionToken( + options: { forceRefresh?: boolean } = {}, +): Promise { + const { forceRefresh = false } = options; + 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 freshToken; + + 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) { + // If the renewal fails, a token that hasn't expired still works, unless a + // server refused it. + const token = getUnexpiredToken(session); + const isRenewalError = error instanceof DeviceFlowError || error instanceof CredentialsError; + if (isRenewalError && token && !forceRefresh) return token; + throw error; + } +} + +async function renewLogin( + lock: Lock, + storedLogin: StoredLogin | undefined, + staleToken: string | undefined, + options: { canRetry: boolean }, +): Promise { + const session = storedLogin?.session; + 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 freshToken; + + const { refreshToken, auth0Domain, clientId, host } = session; + const tokens = await refreshAccessToken(refreshToken, session); + if (tokens) { + await writeCredentials(lock, { ...storedLogin, session: { ...session, ...tokens } }); + return tokens.accessToken; + } + + // 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 }); + } + + await writeCredentials(lock, { + ...storedLogin, + session: { host, auth0Domain, clientId, pendingLogin: session.pendingLogin }, + }); + ended = true; } -let login: Promise | undefined; +function getUnexpiredToken(session: Session, marginMs = 0): string | undefined { + const { accessToken, expiresAt } = session; + if (accessToken && expiresAt && expiresAt - marginMs > Date.now()) return accessToken; +} 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; +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); +} + +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(); + // Waits for a save in progress, which holds the lock. + if (login.pending) await setPendingLogin(login.pending, undefined); + 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; try { - await rm(CREDENTIALS_PATH, { force: true }); - await forgetTrackedUser(); - return true; - } catch { - return false; + outcome = await completeLogin(login, { browser: browser && isPerson }); + } catch (error) { + recordLogin({ + result: getLoginResult(error), + resumed: login.resumed, + startedAt: login.startedAt, + }); + throw error; + } finally { + process.removeAllListeners("SIGINT"); + for (const handler of handlers) process.on("SIGINT", handler); + } + + 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."); } + 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"); + } + + 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); + } +} + +// 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 }; + } - const url = await buildLoginUrl(host, address.port); - options.onReady(url); + 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 }; + }); +} - 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); - } +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; + } +} + +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); + } +} - server.once("listening", onListening); - server.listen(PREFERRED_PORT, "0.0.0.0"); - }); +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(() => {}); } -const LEGACY_AUTH_FILE_PATH = new URL(".prismic", appendTrailingSlash(pathToFileURL(homedir()))); +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 }); + 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 login.saved; + }); +} export async function cleanupLegacyAuthFile(): Promise { let contents: string; @@ -211,20 +507,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. - } -} 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 026111b3..7d656863 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,5 +4,8 @@ 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 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 new file mode 100644 index 00000000..eed447b0 --- /dev/null +++ b/src/credentials.ts @@ -0,0 +1,384 @@ +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, + 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, 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; + +// 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(), + 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()), + // 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; + +const FallbackReasonSchema = z.enum([ + "wsl", + "secret-service-missing", + "locked", + "write-rejected", + "timeout", + "module-missing", +]); +export type FallbackReason = z.infer; + +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()), + 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), +}); +type LoginSummary = z.infer; + +// `storeReason` says why a login went to a file, for telemetry. +export type StoredLogin = { store: StoreMode; storeReason?: FallbackReason; session: Session }; + +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<{ login?: StoredLogin }> { + const json = parseJson(await readPrivateFile(CREDENTIALS_PATH)); + 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) return {}; + if (version !== 2) { + throw new CredentialsError( + "Your Prismic login was saved by a newer version of the CLI. Update the CLI, or run `prismic login` again.", + ); + } + if (!store) throw undecryptable(); + + const session = z.safeParse( + SessionSchema, + store === "file" ? json : parseJson(await decryptSession()), + ); + if (!session.success) throw undecryptable(); + + 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 = { + "module-missing": "module-missing", + unavailable: "secret-service-missing", + locked: "locked", + rejected: "write-rejected", + timeout: "timeout", +}; + +// 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 }> { + 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] }; + } +} + +// 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, 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, 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; + 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 { 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); + 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) => { + 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 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(); + } +} + +// 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"; +} + +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) => { + 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(); + 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: "the keychain is locked in this session. Unlock it and try again", + timeout: "the keychain didn't respond. Try 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 +// 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( + "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 { + 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( + `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"); + if (code === "EACCES" || code === "EPERM") { + throw new CredentialsError( + `Can't read ${fileURLToPath(path)}. Delete it and run \`prismic login\` again.`, + ); + } + 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/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 new file mode 100644 index 00000000..fc59f9ec --- /dev/null +++ b/src/lib/auth0.ts @@ -0,0 +1,270 @@ +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; + +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(), + deviceCode: z.string(), + userCode: z.string(), + verificationUri: z.string(), + verificationUriComplete: z.optional(z.string()), + expiresAt: z.number(), + intervalMs: z.number(), + startedAt: z.number(), +}); +export type PendingLogin = z.infer; + +export 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 }), + headers: AUTH0_HEADERS, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + 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, + startedAt: Date.now(), + }; +} + +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 | 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 { + 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, + }), + headers: AUTH0_HEADERS, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + schema: DeviceTokenSchema, + }); + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: Date.now() + tokens.expires_in * 1000, + }; + } catch (error) { + 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()); + } + } +} + +// 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, or an Auth0 Action +// denies it, 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, + }), + headers: AUTH0_HEADERS, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + schema: RefreshedTokenSchema, + }).catch((error) => { + 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; + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? refreshToken, + expiresAt: Date.now() + tokens.expires_in * 1000, + }; +} + +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 }), + headers: AUTH0_HEADERS, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }).catch((error) => { + throw new DeviceFlowError(isTransient(error) ? "unavailable" : "misconfigured", error); + }); +} + +// 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; + return ( + error instanceof NetworkError || + (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; +} + +// `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(detail ? `${reason} (${detail})` : reason); + this.reason = reason; + this.detail = 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 NetworkError) return "network error"; + return "unexpected response"; +} diff --git a/src/lib/keyring.ts b/src/lib/keyring.ts new file mode 100644 index 00000000..27c8a329 --- /dev/null +++ b/src/lib/keyring.ts @@ -0,0 +1,125 @@ +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 }; + +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) { + await security(["delete-generic-password", "-s", service, "-a", account], undefined, options); + }, +}; + +function security( + args: string[], + input: string | undefined, + options: KeyringOptions, +): 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"], + }); + 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) { + await 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..6ff8cce5 --- /dev/null +++ b/src/lib/lock.ts @@ -0,0 +1,136 @@ +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; +const STALE_MS = 10_000; +const HEARTBEAT_MS = 2_000; + +type Owner = { id: string; pid: number; hostname: string }; + +export type Lock = { + isHeld(): Promise; + release(): Promise; +}; + +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)) 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(() => {}); + }, HEARTBEAT_MS); + 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): 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 > STALE_MS; + 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/src/lib/prismic/client.ts b/src/lib/prismic/client.ts index 2e03f938..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() }), @@ -30,8 +24,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 { 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 5cc8c8f0..c00b419c 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 { DeviceFlowError, type DeviceFlowErrorReason } 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; +type LoginResult = + | "success" + | "expired" + | "declined" + | "email-unverified" + | "cancelled" + | "wait-limit" + | "error"; + let repository: string | undefined; let agent: string | undefined; let auth: string | undefined; @@ -40,22 +51,31 @@ 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; +let command: { type: string; watch?: boolean } | undefined; export async function initTracking(config: { host: string; - token?: string; + loggedIn: boolean; 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); + auth = detectAuth(config.loggedIn); ({ runtime, runtimeProvider } = detectRuntime()); const storedIds = await readIds(); @@ -100,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"; } @@ -169,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, @@ -188,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, @@ -204,6 +224,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 +239,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 recordLogin(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 recordLogout(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: unknown): LoginResult { + return error instanceof DeviceFlowError ? LOGIN_RESULTS[error.reason] : "error"; +} + const PrismicRcSchema = z.object({ telemetry: z.boolean(), }); diff --git a/test/auth0.ts b/test/auth0.ts new file mode 100644 index 00000000..2efe3226 --- /dev/null +++ b/test/auth0.ts @@ -0,0 +1,195 @@ +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; delayMs?: number }; + +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; + 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; + expiresAt: number; + status: "pending" | "approved" | "denied" | "redeemed"; + description?: string; + expiresIn?: number; +}; + +export async function createFakeAuth0(options: { accessToken: string }): Promise { + const { accessToken } = options; + const clientId = `fake-${randomToken(8)}`; + const requests: { + path: string; + params: Record; + userAgent: string | undefined; + }[] = []; + 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, userAgent: req.headers["user-agent"] }); + + const response = responses.get(path)?.shift(); + if (response) { + await sleep(response.delayMs ?? 0); + send(res, response.status, response.body); + 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 userCode = `${hex.slice(0, 4)}-${hex.slice(4)}`; + const code: DeviceCode = { + deviceCode: randomToken(32), + 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", userCode); + return send(res, 200, { + device_code: code.deviceCode, + user_code: userCode, + verification_uri: verificationUri.href, + verification_uri_complete: verificationUriComplete.href, + expires_in: 900, + interval: 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, code.expiresIn); + } + 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 issueRefreshToken(): string { + const refreshToken = randomToken(32); + refreshTokens.add(refreshToken); + return refreshToken; + } + + function sendTokens(res: ServerResponse, expiresIn = 3600): void { + send(res, 200, { + access_token: accessToken, + refresh_token: issueRefreshToken(), + expires_in: expiresIn, + token_type: "Bearer", + }); + } + + function latestCode(): DeviceCode { + const code = codes.at(-1); + if (!code) throw new Error("No device code 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, + 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"; + code.expiresIn = expiresIn; + }, + deny: ({ description } = {}) => { + const code = latestCode(); + code.status = "denied"; + code.description = description; + }, + 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)); + }, + }; +} + +function send(res: ServerResponse, status: number, body?: unknown): void { + if (res.destroyed) return; + res.writeHead(status, { "Content-Type": "application/json" }); + 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/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/login.test.ts b/test/login.test.ts index 38e2956f..d5683291 100644 --- a/test/login.test.ts +++ b/test/login.test.ts @@ -1,7 +1,7 @@ -import { readFile } from "node:fs/promises"; -import { createServer } from "node:net"; +import { readFile, stat } from "node:fs/promises"; -import { captureOutput, it } from "./it"; +import packageJson from "../package.json" with { type: "json" }; +import { approveLogin, captureOutput, it, waitForLoginPrompt } from "./it"; it("supports --help", async ({ expect, prismic }) => { const { stdout, stderr, exitCode } = await prismic("login", ["--help"]); @@ -9,55 +9,198 @@ 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(); + // 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); - 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.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, 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/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", },