diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa14bdc..15b7d8e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### New Features + +* Every command that talks to an instance now obtains a two-factor session when that instance's Partner Portal requires one — `deploy`, `sync`, `exec`, `exec-graphql`, `exec-liquid`, `constants`, `data export`/`import`, `migrations`, `logs`, `pull` and the GUI. Reads are covered as well as writes: a token reaches every record through GraphQL and runs arbitrary Liquid through `exec liquid`, so protecting only deploys would have protected only the source code. `deploy` and `sync` ask up front, before doing any work; the rest ask when the instance refuses and then retry. The code is exchanged with the **Portal** for a short-lived session token (8 hours) which is cached per portal+instance under `~/.pos-cli/sessions.json` (0600), so the prompt appears once per session and not once per command; `--otp-code` and `POS_PORTAL_OTP_CODE` skip it for scripts. The prompt is raised before any spinner starts — a spinner repaints its line on a timer and used to paint straight over it, which looked like a hang. Note that the code never travels through the instance: instances run tenant-authored Liquid, so one that passed through could be harvested and replayed inside its 30-second window. Requires the matching Partner Portal and platformOS releases; against a portal or instance without them, nothing changes. + +* Partner Portal accounts with two-factor authentication enabled can now authenticate from the CLI. `pos-cli env add --email`, `pos-cli env refresh-token`, `pos-cli modules push` and the `pos-cli dns` email fallback prompt for a code (a recovery code works too) when the portal asks for a second factor, and retry the request with it. `--otp-code ` and the `POS_PORTAL_OTP_CODE` environment variable skip the prompt for scripted use; a non-interactive run explains what to set instead of hanging on a prompt that nobody can answer. A rejected code says so instead of blaming the password, and an account the portal has locked for too many attempts stops immediately rather than spending prompts on codes that would be refused unread. pos-cli gives up after three rejected codes, short of the portal's 5-attempt budget, so a typo here cannot trigger the 15-minute lock that is shared with the web UI. Previously these commands reported every one of these as "check if your email/password are correct", which left no way to tell a 2FA challenge from a wrong password — the browser-based `pos-cli env add --url` device flow was unaffected and remains the simplest option. Portals older than the `two_factor_invalid`/`two_factor_locked` responses are still handled: a code pos-cli sent itself can only have been refused for being wrong, since the portal would not have asked for one unless the password had already passed. + ## 6.4.0 (2026-08-20) ### New Features diff --git a/CLAUDE.md b/CLAUDE.md index 39f94220..042e6688 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,7 @@ pos-cli/ │ ├── ServerError.js # Centralized error handling │ ├── settings.js # Environment configuration (.pos file) │ ├── environments.js # Authentication flows +│ ├── utils/twoFactor.js # Partner Portal 2FA: prompt/retry around password auth │ ├── portal.js # Partner Portal API client │ ├── watch.js # File watching for sync mode │ ├── archive.js # Deployment archive creation diff --git a/README.md b/README.md index 453fcf80..793baffc 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,42 @@ Note that [`modules install`/`update`](#installation) take their registry URL fr The Instance details page in the Partner Portal shows the `env add` command pre-filled with both URLs, ready to copy. +#### Two-Factor Authentication + +If your Partner Portal account has two-factor authentication enabled, the token `env add` mints is good for a year against every Instance you can deploy to, so the portal asks for a second factor before issuing one. + +Nothing extra is needed for the default flow: `pos-cli env add [environment] --url [url]` (no `--email`) authorizes in the browser, where you answer the 2FA challenge like any other portal login. + +When you authenticate with `--email`, pos-cli prompts for the code after your password: + + pos-cli env add staging --url https://example.com --email you@example.com + Password: ****** + This account has two-factor authentication enabled. Your password was accepted. + Two-factor code (or a recovery code): 123456 + +A recovery code from the list you saved when you enabled 2FA is accepted anywhere the six-digit code is. To skip the prompt, pass `--otp-code` or set `POS_PORTAL_OTP_CODE`: + + pos-cli env add staging --url https://example.com --email you@example.com --otp-code 123456 + POS_PORTAL_OTP_CODE=123456 pos-cli env refresh-token staging + +The same applies to `pos-cli env refresh-token` and `pos-cli modules push`. In a non-interactive environment (CI, a `--json` run) pos-cli will not prompt — supply `POS_PORTAL_OTP_CODE`, or prefer `pos-cli env add [environment] --url [url] --token [token]`, which needs neither a password nor a code. + +#### Instance Sessions + +An instance can require that it is used with a credential whose holder has proved a second factor — the year-long token in `.pos` is not one. This covers **every command that talks to the instance**, not just deploys: `deploy`, `sync`, `exec`, `exec-graphql`, `exec-liquid`, `constants`, `data export`/`import`, `migrations`, `logs`, `pull`, the GUI. A token reaches every record in the instance through GraphQL and runs arbitrary Liquid through `exec liquid`, so reads are not exempt. + +The first command that needs one asks for a code: + + pos-cli deploy staging + This instance requires a two-factor code. + Two-factor code (or a recovery code): 123456 + +The resulting session lasts 8 hours and is cached in `~/.pos-cli/sessions.json` (owner-readable only), so every later command in that window runs without a prompt — one code unlocks the whole session, whichever command asked for it. + +For scripted runs, set `POS_PORTAL_OTP_CODE`: it works for **every** command, while the `--otp-code` flag exists only on `deploy`, `sync`, `env add`, `env refresh-token` and `modules push`. A recovery code works in either and does not expire on a timer. `deploy` and `sync` ask up front, before doing any work; other commands ask at the moment the instance refuses, and then retry the request that was refused. + +pos-cli stops after three rejected codes. The Partner Portal locks an account for 15 minutes after five, and that counter is shared with the web UI, so the remaining attempts are left for you to spend deliberately. If the account is already locked, pos-cli says so and stops without asking for a code — while the lock holds, even a correct code is refused unread. + The configuration for your environments is stored in the `.pos` file. ### Syncing Changes diff --git a/bin/pos-cli-deploy.js b/bin/pos-cli-deploy.js index 41eccbf5..2c20a931 100755 --- a/bin/pos-cli-deploy.js +++ b/bin/pos-cli-deploy.js @@ -4,6 +4,25 @@ import { program } from '../lib/program.js'; import { fetchSettings } from '../lib/settings.js'; import logger from '../lib/logger.js'; import deployStrategy from '../lib/deploy/strategy.js'; +import { ensureSession } from '../lib/twoFactorSession.js'; +import { reportCommandError } from '../lib/reportCommandError.js'; + +// Letting a TwoFactorError escape the action would print a stack trace over the message it +// carries. Anything else keeps its existing behaviour. +const ensureTwoFactorSession = async (authData, params) => { + try { + await ensureSession({ + portalUrl: authData.partner_portal_url, + instanceUrl: authData.url, + token: authData.token, + otpCode: params.otpCode + }); + } catch (e) { + if (e.name !== 'TwoFactorError') throw e; + + await reportCommandError(e); + } +}; program .name('pos-cli deploy') @@ -14,6 +33,10 @@ program .option('-p --partial-deploy', 'Partial deployment, does not remove data from directories missing from the build') .option('--dry-run', 'Validate the release on the server without applying any changes') .option('-v, --verbose', 'Show full file paths in deploy report (default: summary only)') + .option( + '--otp-code ', + 'two-factor code (or a recovery code) for the deploy session, when this instance requires one. Can also be set as POS_PORTAL_OTP_CODE' + ) .action(async (environment, params) => { if (params.force) logger.Warn('-f flag is deprecated and does not do anything.'); @@ -39,6 +62,10 @@ program VERBOSE: !!params.verbose }); + // Before any work or any spinner: if this instance needs a two-factor session, ask for + // the code now rather than partway through the upload. + await ensureTwoFactorSession(authData, params); + deployStrategy.run({ strategy, opts: { env, authData, params } }); }); diff --git a/bin/pos-cli-env-add.js b/bin/pos-cli-env-add.js index b375599b..91b77ca3 100755 --- a/bin/pos-cli-env-add.js +++ b/bin/pos-cli-env-add.js @@ -1,8 +1,7 @@ #!/usr/bin/env node import { program } from '../lib/program.js'; -import ServerError from '../lib/ServerError.js'; -import logger from '../lib/logger.js'; +import { reportCommandError } from '../lib/reportCommandError.js'; import addEnv from '../lib/envs/add.js'; program.showHelpAfterError(); @@ -20,14 +19,15 @@ program '--token ', 'if you have a token you can add it directly to pos-cli configuration without connecting to portal' ) + .option( + '--otp-code ', + 'two-factor code (or a recovery code) for accounts with 2FA enabled. Can also be set as POS_PORTAL_OTP_CODE. Only needed with --email; you are prompted for one when it is missing' + ) .action(async (environment, params) => { try { await addEnv(environment, params); } catch (e) { - if (ServerError.isNetworkError(e)) - await ServerError.handler(e); - else - await logger.Error(e); + await reportCommandError(e); } }); diff --git a/bin/pos-cli-env-refresh-token.js b/bin/pos-cli-env-refresh-token.js index 73eff094..acf34d3b 100644 --- a/bin/pos-cli-env-refresh-token.js +++ b/bin/pos-cli-env-refresh-token.js @@ -1,21 +1,21 @@ import { program } from '../lib/program.js'; -import logger from '../lib/logger.js'; import { fetchSettings } from '../lib/settings.js'; import refreshToken from '../lib/envs/refreshToken.js'; -import ServerError from '../lib/ServerError.js'; +import { reportCommandError } from '../lib/reportCommandError.js'; program .name('pos-cli env refresh-token') .arguments('[environment]', 'name of environment. Example: staging') - .action(async (environment, _params) => { + .option( + '--otp-code ', + 'two-factor code (or a recovery code) for accounts with 2FA enabled. Can also be set as POS_PORTAL_OTP_CODE. Only used by environments that store an email; you are prompted for one when it is missing' + ) + .action(async (environment, params) => { try { const authData = await fetchSettings(environment); - await refreshToken(environment, authData); + await refreshToken(environment, authData, { otpCode: params.otpCode }); } catch (e) { - if (ServerError.isNetworkError(e)) - await ServerError.handler(e); - else - await logger.Error(e); + await reportCommandError(e); process.exit(1); } }); diff --git a/bin/pos-cli-modules-list.js b/bin/pos-cli-modules-list.js index becb3aad..448abb37 100755 --- a/bin/pos-cli-modules-list.js +++ b/bin/pos-cli-modules-list.js @@ -5,6 +5,7 @@ import { program } from '../lib/program.js'; import Gateway from '../lib/proxy.js'; import logger from '../lib/logger.js'; import { fetchSettings } from '../lib/settings.js'; +import { reportCommandError } from '../lib/reportCommandError.js'; program .name('pos-cli modules list') @@ -22,7 +23,7 @@ program logger.Info(`\t- ${module}`, { hideTimestamp: true }); }); } - }).catch(logger.Debug); + }).catch(error => reportCommandError(error, { prefix: 'Listing modules failed' })); }); program.parse(process.argv); diff --git a/bin/pos-cli-modules-push.js b/bin/pos-cli-modules-push.js index 56a32eae..7b2df4e1 100644 --- a/bin/pos-cli-modules-push.js +++ b/bin/pos-cli-modules-push.js @@ -13,6 +13,10 @@ program .requiredOption('--email ', 'Partner Portal account email. Example: foo@example.com') .option('--path ', 'module root directory, default is current directory') .option('--name ', 'name of the module you would like to publish') + .option( + '--otp-code ', + 'two-factor code (or a recovery code) for accounts with 2FA enabled. Can also be set as POS_PORTAL_OTP_CODE. Only needed with --email; you are prompted for one when it is missing' + ) .action(async (params) => { if (params.path) process.chdir(params.path); checkParams(params); diff --git a/bin/pos-cli-sync.js b/bin/pos-cli-sync.js index a96148a1..f053e0ae 100755 --- a/bin/pos-cli-sync.js +++ b/bin/pos-cli-sync.js @@ -6,9 +6,28 @@ import { start as watchStart, setupGracefulShutdown, sendFile } from '../lib/wat import { fetchSettings } from '../lib/settings.js'; import logger from '../lib/logger.js'; import Gateway from '../lib/proxy.js'; +import { ensureSession } from '../lib/twoFactorSession.js'; +import { reportCommandError } from '../lib/reportCommandError.js'; const DEFAULT_CONCURRENCY = 3; +// Letting a TwoFactorError escape the action would print a stack trace over the message it +// carries. Anything else keeps its existing behaviour. +const ensureTwoFactorSession = async (authData, params) => { + try { + await ensureSession({ + portalUrl: authData.partner_portal_url, + instanceUrl: authData.url, + token: authData.token, + otpCode: params.otpCode + }); + } catch (e) { + if (e.name !== 'TwoFactorError') throw e; + + await reportCommandError(e); + } +}; + program .name('pos-cli sync') .argument('[environment]', 'Name of environment. Example: staging') @@ -17,15 +36,27 @@ program .option('-o, --open', 'When ready, open default browser with instance') .option('-f, --file-path ', 'sync single file and exit') .option('-l, --livereload', 'Use livereload') + .option( + '--otp-code ', + 'two-factor code (or a recovery code) for the deploy session, when this instance requires one. Can also be set as POS_PORTAL_OTP_CODE' + ) .action(async (environment, params) => { const authData = await fetchSettings(environment); const env = Object.assign(process.env, { MARKETPLACE_EMAIL: authData.email, MARKETPLACE_TOKEN: authData.token, MARKETPLACE_URL: authData.url, - CONCURRENCY: process.env.CONCURRENCY || params.concurrency + CONCURRENCY: process.env.CONCURRENCY || params.concurrency, + // watch.js rebuilds the Gateway's settings from these env vars, losing the + // environment's partner_portal_url on the way; the Gateway reads it back from here + // so a two-factor session lands under the right portal. Same export `deploy` does. + PARTNER_PORTAL_HOST: process.env.PARTNER_PORTAL_HOST || authData.partner_portal_url }); + // Asked for before the watcher starts and before any spinner: sync then runs + // unattended for hours, and a prompt raised underneath a spinner is painted over. + await ensureTwoFactorSession(authData, params); + // Handle single file sync if (params.filePath) { const gateway = new Gateway({ diff --git a/lib/dns/auth.js b/lib/dns/auth.js index 32b538a0..e236280e 100644 --- a/lib/dns/auth.js +++ b/lib/dns/auth.js @@ -21,10 +21,10 @@ const resolveSettings = (envName, label) => { return settings; }; -const authenticateInteractively = async (baseUrl, email) => { +const authenticateInteractively = async (baseUrl, email, otpCode) => { await logger.Info(`Authenticating ${email} on ${baseUrl}`); const password = await readPassword(); - return DnsPortalClient.authenticate(baseUrl, email, password); + return DnsPortalClient.authenticate(baseUrl, email, password, { otpCode, interactive: true }); }; // Resolves everything a dns command needs to talk to one portal ("source" or "target"): @@ -34,6 +34,7 @@ const resolvePortalContext = async (envName, { portalUrl, token, email, + otpCode, instanceUuid, label = 'portal', readOnly = false, @@ -62,7 +63,7 @@ const resolvePortalContext = async (envName, { 'which would corrupt --json output — pass --token or use a stored environment token instead.' ); } - authToken = await authenticateInteractively(baseUrl, email); + authToken = await authenticateInteractively(baseUrl, email, otpCode); } if (!authToken) { throw new Error( @@ -80,7 +81,7 @@ const resolvePortalContext = async (envName, { if (!(error instanceof PortalAuthError) || email || !fallbackEmail || !tty || !interactive) throw error; await logger.Warn(error.message); - authToken = await authenticateInteractively(baseUrl, fallbackEmail); + authToken = await authenticateInteractively(baseUrl, fallbackEmail, otpCode); client = new DnsPortalClient({ baseUrl, token: authToken, readOnly }); await client.listInstances(); } diff --git a/lib/dns/portalClient.js b/lib/dns/portalClient.js index ca68593f..96927d4f 100644 --- a/lib/dns/portalClient.js +++ b/lib/dns/portalClient.js @@ -1,4 +1,5 @@ import { apiRequest } from '../apiRequest.js'; +import { withTwoFactor } from '../utils/twoFactor.js'; const DESTRUCTIVE_PREFIX = 'Destructive DNS change blocked'; @@ -112,14 +113,21 @@ class DnsPortalClient { this.readOnly = readOnly; } - static async authenticate(baseUrl, email, password) { + // otpCode/interactive are passed through to withTwoFactor: a 2FA account answers a + // password-only request with 401 two_factor_required, which would otherwise be reported + // as an expired token. `interactive: false` (a --json run, where a prompt would corrupt + // the output) turns that into an explanatory error instead of a prompt. + static async authenticate(baseUrl, email, password, { otpCode, interactive } = {}) { const base = normalizeBaseUrl(baseUrl); try { - const response = await apiRequest({ - method: 'POST', - uri: `${base}/api/authenticate`, - body: { email, password } - }); + const response = await withTwoFactor( + code => apiRequest({ + method: 'POST', + uri: `${base}/api/authenticate`, + body: code ? { email, password, otp_code: code } : { email, password } + }), + { otpCode, interactive } + ); if (!response || !response.auth_token) throw new PortalAuthError(base); return response.auth_token; } catch (error) { diff --git a/lib/envs/add.js b/lib/envs/add.js index 4caf0924..ea5e7cab 100644 --- a/lib/envs/add.js +++ b/lib/envs/add.js @@ -3,6 +3,7 @@ import logger from '../logger.js'; import * as validate from '../validators/index.js'; import { storeEnvironment, deviceAuthorizationFlow } from '../environments.js'; import { readPassword } from '../utils/password.js'; +import { withTwoFactor } from '../utils/twoFactor.js'; const checkParams = (env, params) => { if (params.email) validate.email(params.email); @@ -18,8 +19,8 @@ const saveToken = (settings, token) => { logger.Success(`Environment ${settings.url} as ${settings.environment} has been added successfully.`); }; -const login = async (email, password, url) => { - return Portal.login(email, password, url) +const login = async (email, password, url, otpCode) => { + return Portal.login(email, password, url, otpCode) .then(response => { if (response) return Promise.resolve(response[0].token); }); @@ -56,7 +57,12 @@ const addEnv = async (environment, params) => { const password = await readPassword(); logger.Info(`Asking ${Portal.url()} for access token...`); - token = await login(params.email, password, params.url); + // The token this mints is good for a year against every instance the user can deploy + // to, so the portal asks a 2FA account for its second factor before issuing one. + token = await withTwoFactor( + otpCode => login(params.email, password, params.url, otpCode), + { otpCode: params.otpCode } + ); } if (token) { diff --git a/lib/envs/refreshToken.js b/lib/envs/refreshToken.js index 4086dc61..4f9cb9ff 100644 --- a/lib/envs/refreshToken.js +++ b/lib/envs/refreshToken.js @@ -2,15 +2,16 @@ import Portal from '../portal.js'; import logger from '../logger.js'; import { readPassword } from '../utils/password.js'; import { storeEnvironment, deviceAuthorizationFlow } from '../environments.js'; +import { withTwoFactor } from '../utils/twoFactor.js'; -const login = async (email, password, url) => { - return Portal.login(email, password, url) +const login = async (email, password, url, otpCode) => { + return Portal.login(email, password, url, otpCode) .then(response => { if (response) return Promise.resolve(response[0].token); }); }; -const refreshToken = async (environment, authData) => { +const refreshToken = async (environment, authData, { otpCode } = {}) => { let token; if (!authData.email) { @@ -24,7 +25,10 @@ const refreshToken = async (environment, authData) => { const password = await readPassword(); logger.Info(`Asking ${Portal.url()} for access token...`); - token = await login(authData.email, password, authData.url); + token = await withTwoFactor( + code => login(authData.email, password, authData.url, code), + { otpCode } + ); } if (token) { diff --git a/lib/modules.js b/lib/modules.js index e20562fd..f91d1014 100644 --- a/lib/modules.js +++ b/lib/modules.js @@ -11,6 +11,7 @@ import { presignUrlForPortal } from './presignUrl.js'; import { uploadFile } from './s3UploadFile.js'; import waitForStatus from './data/waitForStatus.js'; import { readPassword } from './utils/password.js'; +import { withTwoFactor } from './utils/twoFactor.js'; import ServerError from './ServerError.js'; import { POS_MODULE_FILE as moduleManifestFileName, POS_MODULE_LOCK_FILE as moduleLockFileName } from './modules/paths.js'; @@ -159,15 +160,19 @@ const getModule = async (token, name) => { const getToken = async (params) => { const password = process.env.POS_PORTAL_PASSWORD || await readPassword(); logger.Info(`Asking ${Portal.url()} for access token...`); - return portalAuthToken(params.email, password); + return portalAuthToken(params.email, password, params.otpCode); }; -const portalAuthToken = async (email, password) => { +const portalAuthToken = async (email, password, otpCode) => { try { - const token = await Portal.jwtToken(email, password); + const token = await withTwoFactor(code => Portal.jwtToken(email, password, code), { otpCode }); return token.auth_token; } catch (e) { - if (ServerError.isNetworkError(e)) + // Without this branch a 2FA account would exit(1) with nothing printed at all, since + // a TwoFactorError is not one of ServerError's network errors. + if (e.name === 'TwoFactorError') + await logger.Error(e.message, { hideTimestamp: true }); + else if (ServerError.isNetworkError(e)) await ServerError.handler(e); else process.exit(1); diff --git a/lib/ora.js b/lib/ora.js index 543f6f03..7bdeabe0 100644 --- a/lib/ora.js +++ b/lib/ora.js @@ -32,6 +32,42 @@ import ora from 'ora'; * The cost of opting out of discardStdin is cosmetic: keys typed during a spinner echo * over the spinner line. */ -const spinner = (options = {}) => ora({ discardStdin: false, ...options }); +// Which spinners are currently drawing. A spinner repaints its line on a timer, so +// anything else that writes to the terminal while one is up -- a prompt, above all -- is +// overwritten between keystrokes. pauseActiveSpinners lets that code clear the line first. +const active = new Set(); + +const spinner = (options = {}) => { + const instance = ora({ discardStdin: false, ...options }); + + const start = instance.start.bind(instance); + const stop = instance.stop.bind(instance); + + instance.start = (...args) => { + active.add(instance); + return start(...args); + }; + // succeed/fail/warn all land here through ora's own stopAndPersist, so one override is + // enough to keep the set honest. + instance.stop = (...args) => { + active.delete(instance); + return stop(...args); + }; + + return instance; +}; + +/** + * Clears every spinner that is currently drawing and returns a function that restarts + * them. Use it around anything that needs the terminal to itself -- notably the + * two-factor prompt, which is otherwise painted over and looks like a hang. + */ +const pauseActiveSpinners = () => { + const paused = [...active]; + paused.forEach(instance => instance.stop()); + + return () => paused.forEach(instance => instance.start()); +}; export default spinner; +export { pauseActiveSpinners }; diff --git a/lib/portal.js b/lib/portal.js index c73f253f..e459d096 100644 --- a/lib/portal.js +++ b/lib/portal.js @@ -6,19 +6,57 @@ const Portal = { return process.env.PARTNER_PORTAL_HOST || 'https://partners.platformos.com'; }, - login: (email, password, url) => { + // otpCode travels in its own header rather than as a third colon-delimited field of + // UserAuthorization: a password may contain a colon, and there would be no telling which + // segment was which. The portal reads it as Api::UserTokensController::OTP_CODE_HEADER. + login: (email, password, url, otpCode) => { logger.Debug('Portal.login ' + email + ' to ' + Portal.url()); + const headers = { UserAuthorization: `${email}:${password}`, InstanceDomain: url }; + if (otpCode) headers.UserOtpCode = otpCode; + return apiRequest({ uri: `${Portal.url()}/api/user_tokens`, - headers: { UserAuthorization: `${email}:${password}`, InstanceDomain: url } + headers }); }, - jwtToken: (email, password) => { + jwtToken: (email, password, otpCode) => { + const formData = { email: email, password: password }; + if (otpCode) formData.otp_code = otpCode; + return apiRequest({ method: 'POST', uri: `${Portal.url()}/api/authenticate`, - formData: { email: email, password: password } + formData + }); + }, + // What the Portal knows about a token, including whether its holder must prove a second + // factor before deploying and whether they already have. The Instance asks the same + // question of the same endpoint when it validates the token (see its + // Api::OAuth::RequestAuthorization), so both sides read one verdict. + tokenInfo: ({ portalUrl, token }) => { + const base = (portalUrl || Portal.url()).replace(/\/+$/, ''); + + return apiRequest({ + method: 'GET', + uri: `${base}/oauth/token/info`, + headers: { Authorization: `Bearer ${token}` } + }); + }, + + // Exchanges a credential the caller already holds for a short-lived two-factor session + // an Instance will accept for a deploy. Deliberately a Portal call and not an Instance + // one: Instances run tenant-authored code, so a code that travelled through one could be + // harvested and replayed inside the thirty seconds it stays valid. + twoFactorSession: ({ portalUrl, token, instanceDomain, otpCode }) => { + const base = (portalUrl || Portal.url()).replace(/\/+$/, ''); + logger.Debug(`[Portal.twoFactorSession] Requesting a session from ${base} for ${instanceDomain}`); + + return apiRequest({ + method: 'POST', + uri: `${base}/api/two_factor_session`, + headers: { Authorization: `Bearer ${token}` }, + body: { instance_domain: instanceDomain, otp_code: otpCode || undefined } }); }, findModules: (token, name) => { diff --git a/lib/proxy.js b/lib/proxy.js index 1f5002d8..fa346564 100644 --- a/lib/proxy.js +++ b/lib/proxy.js @@ -1,14 +1,21 @@ import { apiRequest } from './apiRequest.js'; import logger from './logger.js'; +import Portal from './portal.js'; +import { needsTwoFactorSession, readSession, startSession } from './twoFactorSession.js'; import pkg from '../package.json' with { type: 'json' }; const version = pkg.version; class Gateway { - constructor({ url, token, email }, client) { + constructor({ url, token, email, partner_portal_url }, client) { this.url = url; this.api_url = `${url}/api/app_builder`; this.private_api_url = `${url}/api/private`; this.client = client; + this.token = token; + // Falls back to PARTNER_PORTAL_HOST / the public default so the session cache key is + // the same whether the caller passed the environment's settings object straight in or + // rebuilt it as MARKETPLACE_* env vars along the way (sync and the GUI server do). + this.partnerPortalUrl = partner_portal_url || Portal.url(); this.defaultHeaders = { Authorization: `Token ${token}`, @@ -21,24 +28,54 @@ class Gateway { logger.Debug(`Request headers: ${JSON.stringify(censored, null, 2)}`); } + // The credential to present: a two-factor session when one is in force for this + // instance, otherwise the long-lived token from .pos. Read per request rather than + // cached on the instance so a session started mid-`sync` is picked up by the next call. + authorizationHeader() { + const session = readSession(this.partnerPortalUrl, this.url); + return { Authorization: `Token ${session ? session.token : this.token}` }; + } + + // Every Gateway request goes through here so there is exactly one place that knows how + // to answer an Instance asking for a second factor: step up with the Portal, then retry + // the request that was refused. Only the two_factor_required body triggers it, so an + // expired or revoked token still fails as the authentication error it is. + async apiRequest(options) { + const withAuth = () => ({ ...options, headers: { ...options.headers, ...this.authorizationHeader() } }); + + try { + return await apiRequest(withAuth()); + } catch (error) { + if (!needsTwoFactorSession(error)) throw error; + + await startSession({ + portalUrl: this.partnerPortalUrl, + instanceUrl: this.url, + token: this.token + }); + + return apiRequest(withAuth()); + } + } + cloneInstanceStatus(id) { - return apiRequest({ method: 'GET', uri: `${this.api_url}/instance_clone_imports/${id}`, headers: this.defaultHeaders }); + return this.apiRequest({ method: 'GET', uri: `${this.api_url}/instance_clone_imports/${id}`, headers: this.defaultHeaders }); } cloneInstanceInit(formData = {}) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/instance_clone_imports`, json: formData, headers: this.defaultHeaders }); + return this.apiRequest({ method: 'POST', uri: `${this.api_url}/instance_clone_imports`, json: formData, headers: this.defaultHeaders }); } cloneInstanceExport(formData = {}) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/instance_clone_exports`, json: formData, headers: this.defaultHeaders }); + return this.apiRequest({ method: 'POST', uri: `${this.api_url}/instance_clone_exports`, json: formData, headers: this.defaultHeaders }); } appExportStart(formData = {}) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/marketplace_releases/backup`, formData, headers: this.defaultHeaders }); + return this.apiRequest({ method: 'POST', uri: `${this.api_url}/marketplace_releases/backup`, formData, headers: this.defaultHeaders }); } appExportStatus(id) { - return apiRequest({ uri: `${this.api_url}/marketplace_releases/${id}`, headers: this.defaultHeaders }); + return this.apiRequest({ uri: `${this.api_url}/marketplace_releases/${id}`, headers: this.defaultHeaders }); } dataExportStart(export_internal, csv_import = false) { @@ -47,7 +84,7 @@ class Gateway { if (csv_import) { uri += '?csv_export=true'; } - return apiRequest({ method: 'POST', uri, formData, headers: this.defaultHeaders }); + return this.apiRequest({ method: 'POST', uri, formData, headers: this.defaultHeaders }); } dataExportStatus(id, csv_import = false) { @@ -55,11 +92,11 @@ class Gateway { if (csv_import) { uri += '?csv_export=true'; } - return apiRequest({ uri, headers: this.defaultHeaders }); + return this.apiRequest({ uri, headers: this.defaultHeaders }); } dataImportStart(formData) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/imports`, json: formData, headers: this.defaultHeaders }); + return this.apiRequest({ method: 'POST', uri: `${this.api_url}/imports`, json: formData, headers: this.defaultHeaders }); } dataImportStatus(id, csv_import = false) { @@ -67,16 +104,16 @@ class Gateway { if (csv_import) { uri += '?csv_import=true'; } - return apiRequest({ uri, headers: this.defaultHeaders }); + return this.apiRequest({ uri, headers: this.defaultHeaders }); } dataUpdate(formData) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/data_updates`, formData, headers: this.defaultHeaders }); + return this.apiRequest({ method: 'POST', uri: `${this.api_url}/data_updates`, formData, headers: this.defaultHeaders }); } dataClean(confirmation, include_schema) { const uri = `${this.api_url}/data_clean`; - return apiRequest({ + return this.apiRequest({ method: 'POST', uri, json: { confirmation, include_schema }, @@ -85,15 +122,15 @@ class Gateway { } dataCleanStatus(id) { - return apiRequest({ method: 'GET', uri: `${this.api_url}/data_clean/${id}`, headers: this.defaultHeaders }); + return this.apiRequest({ method: 'GET', uri: `${this.api_url}/data_clean/${id}`, headers: this.defaultHeaders }); } ping() { - return apiRequest({ uri: `${this.api_url}/logs`, headers: this.defaultHeaders }); + return this.apiRequest({ uri: `${this.api_url}/logs`, headers: this.defaultHeaders }); } logs(json, { signal } = {}) { - return apiRequest({ uri: `${this.api_url}/logs?last_id=${json.lastId}`, json: true, forever: true, headers: this.defaultHeaders, signal }); + return this.apiRequest({ uri: `${this.api_url}/logs?last_id=${json.lastId}`, json: true, forever: true, headers: this.defaultHeaders, signal }); } logsv2(params) { @@ -107,57 +144,57 @@ class Gateway { } getInstance() { - return apiRequest({ uri: `${this.api_url}/instance`, headers: this.defaultHeaders }); + return this.apiRequest({ uri: `${this.api_url}/instance`, headers: this.defaultHeaders }); } getStatus(id) { - return apiRequest({ uri: `${this.api_url}/marketplace_releases/${id}`, forever: true, headers: this.defaultHeaders }); + return this.apiRequest({ uri: `${this.api_url}/marketplace_releases/${id}`, forever: true, headers: this.defaultHeaders }); } graph(json) { - return apiRequest({ method: 'POST', uri: `${this.url}/api/graph`, json, forever: true, headers: this.defaultHeaders }); + return this.apiRequest({ method: 'POST', uri: `${this.url}/api/graph`, json, forever: true, headers: this.defaultHeaders }); } liquid(json) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/liquid_exec`, json, forever: true, headers: this.defaultHeaders }); + return this.apiRequest({ method: 'POST', uri: `${this.api_url}/liquid_exec`, json, forever: true, headers: this.defaultHeaders }); } test(name) { - return apiRequest({ uri: `${this.url}/_tests/run.js?name=${name}`, headers: this.defaultHeaders }); + return this.apiRequest({ uri: `${this.url}/_tests/run.js?name=${name}`, headers: this.defaultHeaders }); } testRunAsync() { - return apiRequest({ uri: `${this.url}/_tests/run_async`, headers: this.defaultHeaders }); + return this.apiRequest({ uri: `${this.url}/_tests/run_async`, headers: this.defaultHeaders }); } listModules() { - return apiRequest({ uri: `${this.api_url}/installed_modules`, headers: this.defaultHeaders }); + return this.apiRequest({ uri: `${this.api_url}/installed_modules`, headers: this.defaultHeaders }); } removeModule(formData) { - return apiRequest({ method: 'DELETE', uri: `${this.api_url}/installed_modules`, formData, headers: this.defaultHeaders }); + return this.apiRequest({ method: 'DELETE', uri: `${this.api_url}/installed_modules`, formData, headers: this.defaultHeaders }); } listMigrations() { - return apiRequest({ uri: `${this.api_url}/migrations`, headers: this.defaultHeaders }); + return this.apiRequest({ uri: `${this.api_url}/migrations`, headers: this.defaultHeaders }); } generateMigration(formData) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/migrations`, formData, headers: this.defaultHeaders }); + return this.apiRequest({ method: 'POST', uri: `${this.api_url}/migrations`, formData, headers: this.defaultHeaders }); } runMigration(formData) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/migrations/run`, formData, headers: this.defaultHeaders }); + return this.apiRequest({ method: 'POST', uri: `${this.api_url}/migrations/run`, formData, headers: this.defaultHeaders }); } sendManifest(manifest, releaseId) { const json = { manifest }; if (releaseId) json.marketplace_release_id = releaseId; - return apiRequest({ method: 'POST', uri: `${this.api_url}/assets_manifest`, json, headers: this.defaultHeaders }); + return this.apiRequest({ method: 'POST', uri: `${this.api_url}/assets_manifest`, json, headers: this.defaultHeaders }); } sync(formData) { - return apiRequest({ + return this.apiRequest({ method: 'PUT', uri: `${this.api_url}/marketplace_releases/sync`, formData, @@ -167,7 +204,7 @@ class Gateway { } delete(formData) { - return apiRequest({ + return this.apiRequest({ method: 'DELETE', uri: `${this.api_url}/marketplace_releases/sync`, formData, @@ -177,7 +214,7 @@ class Gateway { } push(formData) { - return apiRequest({ method: 'POST', uri: `${this.api_url}/marketplace_releases`, formData, headers: this.defaultHeaders }); + return this.apiRequest({ method: 'POST', uri: `${this.api_url}/marketplace_releases`, formData, headers: this.defaultHeaders }); } } diff --git a/lib/reportCommandError.js b/lib/reportCommandError.js new file mode 100644 index 00000000..f6762f40 --- /dev/null +++ b/lib/reportCommandError.js @@ -0,0 +1,27 @@ +import logger from './logger.js'; +import ServerError from './ServerError.js'; + +/** + * The one way a command reports a failure it cannot handle. + * + * A TwoFactorError already carries a multi-line, actionable message, and it must not be + * passed to logger.Error as an object: the formatter JSON-encodes an Error's message, + * turning the line breaks into escaped \n. Network and HTTP failures keep going to + * ServerError, which knows how to explain a 502 or a refused connection. + * + * @param {Error} error + * @param {{ prefix?: string, exit?: boolean }} options `prefix` names the operation that + * failed; `exit` is passed through to logger.Error for callers that keep running. + */ +const reportCommandError = async (error, { prefix, exit = true } = {}) => { + if (error?.name === 'TwoFactorError') { + return logger.Error(error.message, { hideTimestamp: true, exit }); + } + + if (ServerError.isNetworkError(error)) return ServerError.handler(error); + + const message = prefix ? `${prefix}: ${error?.message || error}` : error; + return logger.Error(message, { exit }); +}; + +export { reportCommandError }; diff --git a/lib/twoFactorSession.js b/lib/twoFactorSession.js new file mode 100644 index 00000000..aebb01b7 --- /dev/null +++ b/lib/twoFactorSession.js @@ -0,0 +1,161 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import Portal from './portal.js'; +import logger from './logger.js'; +import { withTwoFactor } from './utils/twoFactor.js'; + +// What an Instance answers a write with when the credential presented has not proved a +// second factor and the Partner Portal says its holder must (see the Instance's +// Api::AppBuilder::BaseController#require_two_factor_session). +const TWO_FACTOR_REQUIRED = 'two_factor_required'; + +// Sessions are credentials, not configuration, so they are kept out of .pos — which lives +// in the repository and is routinely shared — and in the user's home directory with +// owner-only permissions. +const SESSION_DIR = () => path.join(os.homedir(), '.pos-cli'); +const SESSION_FILE = () => path.join(SESSION_DIR(), 'sessions.json'); + +// A session belongs to one instance on one portal: the same instance URL served by a +// different portal is a different credential entirely. +const sessionKey = (portalUrl, instanceUrl) => `${normalize(portalUrl)}|${normalize(instanceUrl)}`; + +const normalize = (url) => String(url || '').replace(/\/+$/, ''); + +const readStore = () => { + try { + return JSON.parse(fs.readFileSync(SESSION_FILE(), 'utf8')); + } catch { + // A missing, unreadable or corrupt store just means "no session" — never a hard + // failure, since the remedy is always to prompt for a code again. + return {}; + } +}; + +const writeStore = (store) => { + try { + fs.mkdirSync(SESSION_DIR(), { recursive: true, mode: 0o700 }); + // mkdir's mode is masked by umask (usually 022, giving 755), so it is set again here: + // the directory listing alone tells an onlooker which portals and instances this + // machine holds live deploy sessions for. + fs.chmodSync(SESSION_DIR(), 0o700); + fs.writeFileSync(SESSION_FILE(), JSON.stringify(store, null, 2), { mode: 0o600 }); + } catch (error) { + // Losing the cache costs a prompt on the next command, which is not worth failing for. + logger.Debug(`[twoFactorSession] Could not persist session: ${error.message}`); + } +}; + +// Expired entries are dropped on read rather than returned for the server to reject: +// re-prompting up front is what "enforce when the command starts" means. A minute of slack +// keeps a session from expiring midway through a deploy that just passed this check. +const EXPIRY_MARGIN_MS = 60 * 1000; + +// Only for the message below — the Portal decides the real lifetime and reports it as +// expires_at, which is what the store actually honours. +const SESSION_HOURS = 8; + +const readSession = (portalUrl, instanceUrl) => { + const entry = readStore()[sessionKey(portalUrl, instanceUrl)]; + if (!entry || !entry.token) return null; + + const expiresAt = Date.parse(entry.expiresAt); + if (Number.isNaN(expiresAt) || expiresAt - EXPIRY_MARGIN_MS <= Date.now()) return null; + + return entry; +}; + +const writeSession = (portalUrl, instanceUrl, session) => { + const store = readStore(); + store[sessionKey(portalUrl, instanceUrl)] = session; + writeStore(store); +}; + +const clearSession = (portalUrl, instanceUrl) => { + const store = readStore(); + delete store[sessionKey(portalUrl, instanceUrl)]; + writeStore(store); +}; + +const needsTwoFactorSession = (error) => { + if (!error || error.statusCode !== 401) return false; + const body = error.response?.body; + return !!body && typeof body === 'object' && body.error === TWO_FACTOR_REQUIRED; +}; + +// Prompts for a code and trades it with the Portal for a session token. withTwoFactor does +// the prompting, the retries and the lockout handling: the step-up endpoint answers with +// the same two_factor_required / _invalid / _locked bodies as every other Portal endpoint +// that can refuse a code, which is the whole point of them being one vocabulary. +const startSession = async ({ portalUrl, instanceUrl, token, otpCode, interactive }) => { + const response = await withTwoFactor( + code => Portal.twoFactorSession({ portalUrl, token, instanceDomain: instanceUrl, otpCode: code }), + { + otpCode, + interactive, + prelude: 'This instance requires a two-factor code.', + // Not the usual "use a long-lived token" advice: a long-lived token is precisely + // what this instance has just refused, so pointing at one would send the operator + // in a circle. + unattendedHint: + `\nA session lasts ${SESSION_HOURS} hours, so an unattended run needs a code at the start of each one; ` + + 'a recovery code works and does not expire on a timer.' + } + ); + + if (!response || !response.token) { + throw new Error(`${normalize(portalUrl)} did not return a two-factor session token.`); + } + + const session = { token: response.token, expiresAt: response.expires_at }; + writeSession(portalUrl, instanceUrl, session); + logger.Debug(`[twoFactorSession] Session stored, expires ${session.expiresAt}`); + return session; +}; + +/** + * Makes sure a two-factor session exists before a command that needs one starts working. + * + * Every command reaches the Instance through Gateway, which steps up on demand, so this is + * not what makes the rule hold — it is what makes the prompt land at a sensible moment for + * the two long-running commands. + * + * Called at the top of `deploy` and `sync`, deliberately before any spinner is up: a + * spinner repaints its line on a timer, so a prompt raised underneath one is painted over + * and the command looks like it has hung. It also means the operator is asked once, up + * front, rather than partway through an upload. + * + * Returns null when no session is needed — the account is not enrolled, or its Partner + * does not require one — in which case the long-lived token keeps working as before. + */ +const ensureSession = async ({ portalUrl, instanceUrl, token, otpCode, interactive }) => { + const existing = readSession(portalUrl, instanceUrl); + if (existing) { + logger.Debug('[twoFactorSession] Reusing a stored session'); + return existing; + } + + let info; + try { + info = await Portal.tokenInfo({ portalUrl, token }); + } catch (error) { + // A Portal that cannot answer is not a reason to refuse to deploy: the Instance is the + // side that actually enforces this, and it will ask for a session if it wants one. + logger.Debug(`[twoFactorSession] Could not read token info: ${error.message}`); + return null; + } + + if (!info || !info.two_factor_required || info.two_factor_session) return null; + + return startSession({ portalUrl, instanceUrl, token, otpCode, interactive }); +}; + +export { + TWO_FACTOR_REQUIRED, + ensureSession, + clearSession, + needsTwoFactorSession, + readSession, + sessionKey, + startSession +}; diff --git a/lib/utils/twoFactor.js b/lib/utils/twoFactor.js new file mode 100644 index 00000000..6fea66b8 --- /dev/null +++ b/lib/utils/twoFactor.js @@ -0,0 +1,212 @@ +import rl from 'readline'; +import logger from '../logger.js'; +import { pauseActiveSpinners } from '../ora.js'; + +// The Partner Portal names a two-factor failure in the 401 body (its TwoFactorApiResponse +// concern) precisely so a client can pick its next move instead of reporting the password +// as wrong. Every other failure -- a wrong password above all -- stays a bodiless 401. +const TWO_FACTOR_REQUIRED = 'two_factor_required'; // no code was sent +const TWO_FACTOR_INVALID = 'two_factor_invalid'; // wrong code, attempts left +const TWO_FACTOR_LOCKED = 'two_factor_locked'; // budget spent, retrying is pointless + +const OTP_CODE_ENV_VAR = 'POS_PORTAL_OTP_CODE'; + +// The portal locks an account for 15 minutes after 5 wrong codes +// (User::OTP_MAX_FAILED_ATTEMPTS / OTP_LOCK_DURATION). That counter lives on the user row +// and is shared with every other surface, the web UI included, so stopping at 3 leaves the +// operator attempts to spend elsewhere rather than locking them out of the portal over a +// mistyped digit here. +const MAX_ATTEMPTS = 3; + +class TwoFactorError extends Error { + constructor(message) { + super(message); + this.name = 'TwoFactorError'; + } +} + +const errorCode = (error) => { + if (!error || error.statusCode !== 401) return null; + const body = error.response?.body; + return body && typeof body === 'object' ? body.error : null; +}; + +const isTwoFactorRequired = (error) => errorCode(error) === TWO_FACTOR_REQUIRED; +const isTwoFactorInvalid = (error) => errorCode(error) === TWO_FACTOR_INVALID; +const isTwoFactorLocked = (error) => errorCode(error) === TWO_FACTOR_LOCKED; + +const isUnauthorized = (error) => !!error && error.statusCode === 401; + +// Portals older than the two_factor_invalid/two_factor_locked codes answer a wrong code +// with a bodiless 401, which is also what a wrong password looks like. pos-cli talks to +// private-stack deployments that upgrade on their own schedule, so the old inference has +// to stay: a code we sent ourselves can only have been refused for being wrong, because +// the portal would not have asked for one at all unless the password had passed. +const isRejectedCode = (error, codeWasSent) => + codeWasSent && (isTwoFactorInvalid(error) || (isUnauthorized(error) && !errorCode(error))); + +// Authenticator apps display codes in groups ("123 456") and recovery codes get pasted +// with stray whitespace. The portal compares the string it is handed, so normalize here. +const normalizeCode = (code) => String(code ?? '').replace(/\s+/g, ''); + +const presetCode = (otpCode) => normalizeCode(otpCode || process.env[OTP_CODE_ENV_VAR] || '') || null; + +const OTP_PROMPT = 'Two-factor code (or a recovery code): '; + +// One readline interface serves every attempt of a retry loop. Creating a fresh one per +// prompt does not work: an interface built over process.stdin after an earlier one was +// closed fires 'close' immediately instead of reading, so the second prompt would abort +// rather than ask -- exactly the case a user hits after mistyping their first code. +// +// The prompt deliberately echoes, unlike the password one: a TOTP code is single-use and +// expires in 30 seconds, and seeing the digits is what lets an operator catch a typo +// before it costs one of the five attempts the portal allows. +const createOtpPrompt = () => { + const reader = rl.createInterface({ input: process.stdin, output: process.stdout }); + let closed = false; + let pending = null; + + // A stdin that ends while a prompt is up fires 'close' and never calls the question + // callback; resolving null there is what keeps the loop from hanging forever. + reader.on('close', () => { + closed = true; + const resolve = pending; + pending = null; + if (resolve) resolve(null); + }); + + return { + ask: () => new Promise(resolve => { + if (closed) return resolve(null); + + pending = resolve; + reader.question(OTP_PROMPT, code => { + pending = null; + logger.Log(''); + resolve(normalizeCode(code)); + }); + }), + close: () => reader.close() + }; +}; + +const sourceOfPreset = (otpCode) => (otpCode ? '--otp-code' : OTP_CODE_ENV_VAR); + +// The right unattended advice differs by caller, and getting it wrong is worse than +// giving none: a long-lived token is the answer when the code is gating a *login*, and +// exactly the wrong answer when it is gating a deploy, which such a token can no longer do. +const PASSWORD_PRELUDE = + 'This account has two-factor authentication enabled. Your password was accepted.'; + +const TOKEN_HINT = + '\nFor unattended use prefer a long-lived token: `pos-cli env add --url --token ` needs no password and no code.'; + +const nonInteractiveMessage = (rejectedPreset, otpCode, unattendedHint = TOKEN_HINT) => + (rejectedPreset + ? `The two-factor code supplied via ${sourceOfPreset(otpCode)} was rejected by the Partner Portal.` + + '\nA TOTP code is only valid for about 30 seconds — generate a fresh one, or use one of your recovery codes.' + : 'This Partner Portal account has two-factor authentication enabled, and there is no terminal to prompt for a code on.' + + `\nPass --otp-code , set ${OTP_CODE_ENV_VAR}, or run the command in an interactive terminal.`) + + unattendedHint; + +const attemptsLeftWarning = (attempts) => + `That code was not accepted (attempt ${attempts} of ${MAX_ATTEMPTS}).`; + +const exhaustedMessage = () => + `Two-factor authentication failed ${MAX_ATTEMPTS} times, so pos-cli stopped trying.` + + '\nThe Partner Portal locks an account for 15 minutes after 5 wrong codes — the remaining attempts are left for you to spend deliberately.' + + '\nCheck that your authenticator app clock is in sync, or use one of the recovery codes you saved when you enabled 2FA.'; + +// The portal has told us the budget is already spent, so every further code would be +// refused unread. Stopping here also stops the hammering that keeps the lock alive. +const lockedMessage = () => + 'Too many two-factor attempts — the Partner Portal has locked this account for 15 minutes.' + + '\nFurther codes are refused unread until the lock expires, so pos-cli stopped rather than retrying.'; + +/** + * Runs a portal request that authenticates with an email and password, supplying a + * second factor when the portal asks for one. + * + * `run` is called with the code to send (null when there is none) and must reject with + * the error apiRequest throws, so the 401 body can be read. + * + * @param {(code: string|null) => Promise} run + * @param {{ otpCode?: string, interactive?: boolean, unattendedHint?: string, prelude?: string }} options + * @returns {Promise} whatever `run` resolves to + */ +const withTwoFactor = async (run, { otpCode, interactive, unattendedHint, prelude = PASSWORD_PRELUDE } = {}) => { + const preset = presetCode(otpCode); + + let failure; + try { + return await run(preset); + } catch (error) { + failure = error; + } + + // Retrying a locked account only refreshes the reason it is locked, so stop at once — + // whether the lock was already there or the preset code just earned it. + if (isTwoFactorLocked(failure)) throw new TwoFactorError(lockedMessage()); + + // Without this a wrong --otp-code would surface as the generic "check if your + // email/password are correct", since a rejected code is a 401 like any other. + const rejectedPreset = isRejectedCode(failure, Boolean(preset)); + if (!isTwoFactorRequired(failure) && !rejectedPreset) throw failure; + + const canPrompt = interactive ?? Boolean(process.stdin.isTTY); + if (!canPrompt) throw new TwoFactorError(nonInteractiveMessage(rejectedPreset, otpCode, unattendedHint)); + + if (rejectedPreset) { + await logger.Warn(`The two-factor code supplied via ${sourceOfPreset(otpCode)} was not accepted.`); + } else { + // Reaching here means the primary credential was accepted and only the second factor + // is outstanding, which is worth saying: otherwise a prompt appearing after a password + // reads as "that password was wrong, try again". + await logger.Info(prelude, { hideTimestamp: true }); + } + + // A deploy or sync is mid-spinner when the Instance asks for a second factor, and a + // spinner repaints over anything else on the line -- the prompt included, which made it + // look like the command had hung with no explanation. + const resumeSpinners = pauseActiveSpinners(); + const prompt = createOtpPrompt(); + try { + let attempts = 0; + while (attempts < MAX_ATTEMPTS) { + const code = await prompt.ask(); + if (code === null) throw new TwoFactorError(nonInteractiveMessage(false, otpCode, unattendedHint)); + if (!code) { + await logger.Warn('No code entered — press Ctrl+C to abort.'); + continue; + } + + attempts += 1; + try { + return await run(code); + } catch (error) { + // Anything that is not a 401 (a 500, a network drop) is the caller's problem, not + // a wrong code — do not burn attempts on it. + if (!isUnauthorized(error)) throw error; + if (isTwoFactorLocked(error)) throw new TwoFactorError(lockedMessage()); + if (attempts < MAX_ATTEMPTS) await logger.Warn(attemptsLeftWarning(attempts)); + } + } + + throw new TwoFactorError(exhaustedMessage()); + } finally { + prompt.close(); + resumeSpinners(); + } +}; + +export { + MAX_ATTEMPTS, + TOKEN_HINT, + OTP_CODE_ENV_VAR, + TwoFactorError, + isTwoFactorInvalid, + isTwoFactorLocked, + isTwoFactorRequired, + normalizeCode, + withTwoFactor +}; diff --git a/lib/watch.js b/lib/watch.js index d0ab9599..30389efd 100644 --- a/lib/watch.js +++ b/lib/watch.js @@ -39,7 +39,16 @@ const filePathUnixified = filePath => .replace(/\\/g, '/') .replace(new RegExp(`^${dir.APP}/`), '') .replace(new RegExp(`^${dir.LEGACY_APP}/`), ''); -const moduleAssetRegex = new RegExp('^modules/\\w+/public/assets'); +// Module directory names are arbitrary and hyphens are common ("common-styling"), +// but `\w+` matched neither hyphens nor dots. Those modules' assets were therefore +// not recognized as assets at all and went out through pushFile as ordinary code +// files, which does not preserve them byte for byte and leaves their Content-Type +// to be derived remotely instead of sent with the upload — silently breaking .js +// and .css. `[^/]+` accepts the same module names as deploy's +// `modules/*/{private,public}/assets/**` glob and shouldBeSynced's +// `^modules/.*/(public|private)`, so a file is now classified the same way +// whichever command sends it. +const moduleAssetRegex = /^modules\/[^/]+\/public\/assets\//; // Paths that must never be watched. chokidar v4+ dropped fsevents, so on macOS // each watched directory costs one file descriptor (kqueue). Pruning these keeps diff --git a/test/unit/env-add-unit.test.js b/test/unit/env-add-unit.test.js index 5eedac81..320f3d23 100644 --- a/test/unit/env-add-unit.test.js +++ b/test/unit/env-add-unit.test.js @@ -26,7 +26,7 @@ vi.mock('#lib/portal.js', async () => { interval: 1 }), fetchDeviceAccessToken: () => Promise.resolve({ access_token: mockAccessToken }), - login: () => Promise.resolve([{ token: mockAccessToken }]) + login: vi.fn(() => Promise.resolve([{ token: mockAccessToken }])) } }; }); @@ -44,6 +44,28 @@ vi.mock('#lib/logger.js', async () => { }; }); +vi.mock('#lib/utils/password.js', () => ({ + readPassword: vi.fn(() => Promise.resolve('test-password')) +})); + +// Stands in for the readline prompt withTwoFactor() puts up; answers are queued per test. +const otpAnswers = []; +vi.mock('readline', () => ({ + default: { + createInterface: () => { + const handlers = {}; + return { + on: (event, handler) => { handlers[event] = handler; }, + close: () => {}, + question: (_prompt, callback) => { + if (otpAnswers.length) return callback(otpAnswers.shift()); + return handlers.close?.(); + } + }; + } + } +})); + vi.mock('#lib/validators/index.js', () => ({ existence: { directoryExists: () => true, fileExists: () => true }, url: () => true, @@ -53,26 +75,45 @@ vi.mock('#lib/validators/index.js', () => ({ })); let addEnv; +let mockPortal; let originalCwd; +let originalIsTTY; let tempDir; beforeAll(async () => { const addMod = await import('#lib/envs/add.js'); addEnv = addMod.default; + + mockPortal = (await import('#lib/portal.js')).default; }); beforeEach(() => { originalCwd = process.cwd(); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pos-cli-test-')); process.chdir(tempDir); + + originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = true; + otpAnswers.length = 0; + delete process.env.POS_PORTAL_OTP_CODE; + mockPortal.login.mockReset(); + mockPortal.login.mockResolvedValue([{ token: mockAccessToken }]); }); afterEach(() => { process.chdir(originalCwd); + process.stdin.isTTY = originalIsTTY; + delete process.env.POS_PORTAL_OTP_CODE; fs.rmSync(tempDir, { recursive: true, force: true }); mockAccessToken = 'mock-token-12345'; }); +const twoFactorRequired = () => Object.assign(new Error('Request failed with status 401'), { + name: 'StatusCodeError', + statusCode: 401, + response: { statusCode: 401, body: { error: 'two_factor_required', errors: ['Two-factor code required'] } } +}); + describe('env add with mocked portal', () => { test('creates .pos file with token from device authorization flow', async () => { const environment = 'staging'; @@ -229,4 +270,44 @@ describe('env add with mocked portal', () => { // Restore original mock Portal.default.requestDeviceAuthorization = originalRequestDeviceAuth; }); + + test('sends --otp-code to the portal without prompting', async () => { + await addEnv('staging', { + url: 'https://staging.example.com', + email: 'user@example.com', + otpCode: '123 456' + }); + + expect(mockPortal.login).toHaveBeenCalledWith( + 'user@example.com', 'test-password', 'https://staging.example.com/', '123456' + ); + expect(settingsFromDotPos('staging')['token']).toBe('mock-token-12345'); + }); + + test('prompts for a code when the portal answers two_factor_required, then stores the token', async () => { + otpAnswers.push('654321'); + mockPortal.login.mockImplementation((_email, _password, _url, otpCode) => { + if (!otpCode) return Promise.reject(twoFactorRequired()); + return Promise.resolve([{ token: 'token-behind-2fa' }]); + }); + + await addEnv('staging', { url: 'https://staging.example.com', email: 'user@example.com' }); + + expect(mockPortal.login).toHaveBeenCalledTimes(2); + expect(settingsFromDotPos('staging')['token']).toBe('token-behind-2fa'); + }); + + test('fails with an actionable error instead of prompting when stdin is not a terminal', async () => { + process.stdin.isTTY = false; + mockPortal.login.mockRejectedValue(twoFactorRequired()); + + await expect( + addEnv('staging', { url: 'https://staging.example.com', email: 'user@example.com' }) + ).rejects.toMatchObject({ + name: 'TwoFactorError', + message: expect.stringContaining('POS_PORTAL_OTP_CODE') + }); + + expect(fs.existsSync('.pos')).toBe(false); + }); }); diff --git a/test/unit/env-refresh-token-unit.test.js b/test/unit/env-refresh-token-unit.test.js index a744cbf8..d2b4f24f 100644 --- a/test/unit/env-refresh-token-unit.test.js +++ b/test/unit/env-refresh-token-unit.test.js @@ -31,6 +31,7 @@ vi.mock('#lib/portal.js', async () => { vi.mock('#lib/logger.js', () => ({ default: { + Log: vi.fn(), Success: vi.fn(), Debug: vi.fn(), Info: vi.fn(), @@ -43,10 +44,35 @@ vi.mock('#lib/utils/password.js', () => ({ readPassword: vi.fn(() => Promise.resolve('test-password')) })); +// Stands in for the readline prompt withTwoFactor() puts up; answers are queued per test. +const otpAnswers = []; +vi.mock('readline', () => ({ + default: { + createInterface: () => { + const handlers = {}; + return { + on: (event, handler) => { handlers[event] = handler; }, + close: () => {}, + question: (_prompt, callback) => { + if (otpAnswers.length) return callback(otpAnswers.shift()); + return handlers.close?.(); + } + }; + } + } +})); + +const twoFactorRequired = () => Object.assign(new Error('Request failed with status 401'), { + name: 'StatusCodeError', + statusCode: 401, + response: { statusCode: 401, body: { error: 'two_factor_required', errors: ['Two-factor code required'] } } +}); + let refreshToken; let mockLogger; let mockPortal; let originalCwd; +let originalIsTTY; let tempDir; beforeAll(async () => { @@ -67,6 +93,12 @@ beforeEach(() => { vi.clearAllMocks(); + originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = true; + otpAnswers.length = 0; + delete process.env.POS_PORTAL_OTP_CODE; + mockPortal.login.mockResolvedValue([{ token: 'refreshed-token-12345' }]); + mockPortal.requestDeviceAuthorization.mockResolvedValue({ verification_uri_complete: 'http://example.com/xxxx', device_code: 'device_code', @@ -76,6 +108,8 @@ beforeEach(() => { afterEach(() => { process.chdir(originalCwd); + process.stdin.isTTY = originalIsTTY; + delete process.env.POS_PORTAL_OTP_CODE; fs.rmSync(tempDir, { recursive: true, force: true }); }); @@ -99,7 +133,7 @@ describe('env refresh-token', () => { const token = await refreshToken(environment, authData); expect(token).toBe('refreshed-token-12345'); - expect(mockPortal.login).toHaveBeenCalledWith('user@example.com', 'test-password', 'https://staging.example.com'); + expect(mockPortal.login).toHaveBeenCalledWith('user@example.com', 'test-password', 'https://staging.example.com', null); expect(mockPortal.requestDeviceAuthorization).not.toHaveBeenCalled(); const settings = settingsFromDotPos(environment); @@ -153,4 +187,49 @@ describe('env refresh-token', () => { expect.anything() ); }); + + test('sends --otp-code to the portal without prompting', async () => { + const authData = { url: 'https://staging.example.com', token: 'old-token', email: 'user@example.com' }; + + await refreshToken('staging', authData, { otpCode: '123 456' }); + + expect(mockPortal.login).toHaveBeenCalledWith( + 'user@example.com', 'test-password', 'https://staging.example.com', '123456' + ); + }); + + test('reads a code from POS_PORTAL_OTP_CODE when no flag is given', async () => { + process.env.POS_PORTAL_OTP_CODE = '654321'; + const authData = { url: 'https://staging.example.com', token: 'old-token', email: 'user@example.com' }; + + await refreshToken('staging', authData); + + expect(mockPortal.login).toHaveBeenCalledWith( + 'user@example.com', 'test-password', 'https://staging.example.com', '654321' + ); + }); + + test('prompts for a code when the portal answers two_factor_required', async () => { + otpAnswers.push('654321'); + mockPortal.login.mockImplementation((_email, _password, _url, otpCode) => { + if (!otpCode) return Promise.reject(twoFactorRequired()); + return Promise.resolve([{ token: 'token-behind-2fa' }]); + }); + + const authData = { url: 'https://staging.example.com', token: 'old-token', email: 'user@example.com' }; + const token = await refreshToken('staging', authData); + + expect(token).toBe('token-behind-2fa'); + expect(settingsFromDotPos('staging').token).toBe('token-behind-2fa'); + }); + + test('leaves the stored token alone when 2FA cannot be answered', async () => { + process.stdin.isTTY = false; + mockPortal.login.mockRejectedValue(twoFactorRequired()); + + const authData = { url: 'https://staging.example.com', token: 'old-token', email: 'user@example.com' }; + + await expect(refreshToken('staging', authData)).rejects.toMatchObject({ name: 'TwoFactorError' }); + expect(fs.existsSync('.pos')).toBe(false); + }); }); diff --git a/test/unit/twoFactor.test.js b/test/unit/twoFactor.test.js new file mode 100644 index 00000000..fcbfe184 --- /dev/null +++ b/test/unit/twoFactor.test.js @@ -0,0 +1,428 @@ +import { vi, describe, test, expect, beforeEach, afterEach } from 'vitest'; +import nock from 'nock'; + +// A controllable stand-in for the readline prompt. Answers are queued per test; an +// exhausted queue emits 'close' instead, which is what a drained or closed stdin does. +// +// It also reproduces the Node behaviour that makes reusing one interface necessary: an +// interface built over process.stdin *after* an earlier one was closed fires 'close' +// immediately instead of reading. Without that, a prompt-per-attempt implementation looks +// fine under test and then aborts on the first retry in a real terminal. +const answers = []; +const readlineState = { interfacesCreated: 0, anyClosed: false }; +vi.mock('readline', () => ({ + default: { + createInterface: () => { + const handlers = {}; + const bornClosed = readlineState.anyClosed; + readlineState.interfacesCreated += 1; + return { + on: (event, handler) => { + handlers[event] = handler; + if (event === 'close' && bornClosed) handler(); + }, + close: () => { + readlineState.anyClosed = true; + handlers.close?.(); + }, + question: (_prompt, callback) => { + if (answers.length) return callback(answers.shift()); + return handlers.close?.(); + } + }; + } + } +})); + +vi.mock('#lib/logger.js', () => ({ + default: { + Log: vi.fn(), + Debug: vi.fn(), + Info: vi.fn(), + Warn: vi.fn(), + Success: vi.fn(), + Error: vi.fn() + } +})); + +const { + MAX_ATTEMPTS, + OTP_CODE_ENV_VAR, + isTwoFactorInvalid, + isTwoFactorLocked, + isTwoFactorRequired, + normalizeCode, + withTwoFactor +} = await import('#lib/utils/twoFactor.js'); + +// Shape of what apiRequest throws for the portal's "send me a code" answer. +const twoFactorRequired = () => Object.assign(new Error('Request failed with status 401'), { + name: 'StatusCodeError', + statusCode: 401, + response: { statusCode: 401, body: { error: 'two_factor_required', errors: ['Two-factor code required'] } } +}); + +const twoFactorBody = (code, message) => Object.assign(new Error('Request failed with status 401'), { + name: 'StatusCodeError', + statusCode: 401, + response: { statusCode: 401, body: { error: code, errors: [message] } } +}); + +const twoFactorInvalid = () => twoFactorBody('two_factor_invalid', 'Invalid two-factor code'); +const twoFactorLocked = () => twoFactorBody('two_factor_locked', 'Too many two-factor attempts'); + +// A bodiless 401 — what a wrong password gets, and what a portal too old to send +// two_factor_invalid/two_factor_locked answers a wrong code with. +const unauthorized = () => Object.assign(new Error('Request failed with status 401'), { + name: 'StatusCodeError', + statusCode: 401, + response: { statusCode: 401, body: '' } +}); + +let originalIsTTY; + +beforeEach(() => { + answers.length = 0; + readlineState.interfacesCreated = 0; + readlineState.anyClosed = false; + originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = true; + delete process.env[OTP_CODE_ENV_VAR]; + vi.clearAllMocks(); +}); + +afterEach(() => { + process.stdin.isTTY = originalIsTTY; + delete process.env[OTP_CODE_ENV_VAR]; +}); + +describe('portal error codes', () => { + test('each matcher recognises only its own code on a 401', () => { + expect(isTwoFactorRequired(twoFactorRequired())).toBe(true); + expect(isTwoFactorInvalid(twoFactorInvalid())).toBe(true); + expect(isTwoFactorLocked(twoFactorLocked())).toBe(true); + + expect(isTwoFactorRequired(twoFactorInvalid())).toBe(false); + expect(isTwoFactorInvalid(twoFactorLocked())).toBe(false); + expect(isTwoFactorLocked(twoFactorRequired())).toBe(false); + }); + + test('none of them match a bodiless 401, a non-401, or a 401 HTML page', () => { + for (const matcher of [isTwoFactorRequired, isTwoFactorInvalid, isTwoFactorLocked]) { + expect(matcher(unauthorized())).toBe(false); + expect(matcher(null)).toBe(false); + expect(matcher({ statusCode: 403, response: { body: { error: 'two_factor_required' } } })).toBe(false); + expect(matcher({ statusCode: 401, response: { body: 'two_factor_locked' } })).toBe(false); + } + }); +}); + +describe('normalizeCode', () => { + test('strips the whitespace authenticator apps and copy-paste introduce', () => { + expect(normalizeCode(' 123 456 ')).toBe('123456'); + expect(normalizeCode('abcd-efgh\n')).toBe('abcd-efgh'); + expect(normalizeCode(undefined)).toBe(''); + }); +}); + +describe('withTwoFactor', () => { + test('passes no code and never prompts for an account without 2FA', async () => { + const run = vi.fn(async () => 'token'); + + await expect(withTwoFactor(run)).resolves.toBe('token'); + + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith(null); + }); + + test('sends a code given as an option without prompting', async () => { + const run = vi.fn(async () => 'token'); + + await expect(withTwoFactor(run, { otpCode: '123 456' })).resolves.toBe('token'); + + expect(run).toHaveBeenCalledWith('123456'); + expect(answers.length).toBe(0); + }); + + test(`sends a code from ${OTP_CODE_ENV_VAR} when no option is given`, async () => { + process.env[OTP_CODE_ENV_VAR] = '654321'; + const run = vi.fn(async () => 'token'); + + await expect(withTwoFactor(run)).resolves.toBe('token'); + + expect(run).toHaveBeenCalledWith('654321'); + }); + + test('prompts for a code when the portal asks for one, then retries', async () => { + answers.push('123 456'); + const run = vi.fn(async code => { + if (!code) throw twoFactorRequired(); + return `token-for-${code}`; + }); + + await expect(withTwoFactor(run)).resolves.toBe('token-for-123456'); + + expect(run).toHaveBeenNthCalledWith(1, null); + expect(run).toHaveBeenNthCalledWith(2, '123456'); + }); + + test('re-prompts after a wrong code and succeeds on a later attempt', async () => { + answers.push('000000', '123456'); + const run = vi.fn(async code => { + if (!code) throw twoFactorRequired(); + if (code !== '123456') throw twoFactorInvalid(); + return 'token'; + }); + + await expect(withTwoFactor(run)).resolves.toBe('token'); + + expect(run).toHaveBeenCalledTimes(3); + // Every attempt must share one readline interface — a second one built over an + // already-closed process.stdin would abort instead of asking again. + expect(readlineState.interfacesCreated).toBe(1); + }); + + test(`gives up after ${MAX_ATTEMPTS} wrong codes and explains the portal lockout`, async () => { + answers.push('000000', '111111', '222222', '333333'); + const run = vi.fn(async code => { + if (!code) throw twoFactorRequired(); + throw twoFactorInvalid(); + }); + + await expect(withTwoFactor(run)).rejects.toMatchObject({ + name: 'TwoFactorError', + message: expect.stringContaining('locks an account for 15 minutes') + }); + + // One password-only probe plus exactly MAX_ATTEMPTS codes — the 4th answer is + // never read, so the portal's 5-attempt budget is not spent here. + expect(run).toHaveBeenCalledTimes(MAX_ATTEMPTS + 1); + expect(answers).toEqual(['333333']); + }); + + test('reports a rejected --otp-code as a code problem, not a password problem', async () => { + process.stdin.isTTY = false; + const run = vi.fn(async () => { throw twoFactorInvalid(); }); + + await expect(withTwoFactor(run, { otpCode: '000000' })).rejects.toMatchObject({ + name: 'TwoFactorError', + message: expect.stringContaining('--otp-code') + }); + }); + + // pos-cli talks to private-stack portals that upgrade on their own schedule. + describe('against a portal too old to send two_factor_invalid', () => { + test('still blames a rejected preset code rather than the password', async () => { + process.stdin.isTTY = false; + const run = vi.fn(async () => { throw unauthorized(); }); + + await expect(withTwoFactor(run, { otpCode: '000000' })).rejects.toMatchObject({ + name: 'TwoFactorError', + message: expect.stringContaining('--otp-code') + }); + }); + + test('still re-prompts after a wrong typed code', async () => { + answers.push('000000', '123456'); + const run = vi.fn(async code => { + if (!code) throw twoFactorRequired(); + if (code !== '123456') throw unauthorized(); + return 'token'; + }); + + await expect(withTwoFactor(run)).resolves.toBe('token'); + }); + + // The inference only holds for a code we sent: a bodiless 401 with no code in play + // is a wrong password and must stay one. + test('leaves a bodiless 401 alone when no code was ever sent', async () => { + const run = vi.fn(async () => { throw unauthorized(); }); + + await expect(withTwoFactor(run)).rejects.toMatchObject({ statusCode: 401 }); + + expect(run).toHaveBeenCalledTimes(1); + }); + }); + + describe('two_factor_locked', () => { + test('stops immediately instead of prompting when the account is already locked', async () => { + const run = vi.fn(async () => { throw twoFactorLocked(); }); + + await expect(withTwoFactor(run)).rejects.toMatchObject({ + name: 'TwoFactorError', + message: expect.stringContaining('locked this account for 15 minutes') + }); + + expect(run).toHaveBeenCalledTimes(1); + expect(readlineState.interfacesCreated).toBe(0); + }); + + test('stops mid-loop when an attempt earns the lock, leaving later answers unread', async () => { + answers.push('000000', '111111', '222222'); + const run = vi.fn(async code => { + if (!code) throw twoFactorRequired(); + if (code === '000000') throw twoFactorInvalid(); + throw twoFactorLocked(); + }); + + await expect(withTwoFactor(run)).rejects.toMatchObject({ + name: 'TwoFactorError', + message: expect.stringContaining('refused unread') + }); + + // The password probe plus two codes — the third answer is never asked for. + expect(run).toHaveBeenCalledTimes(3); + expect(answers).toEqual(['222222']); + }); + + test('stops rather than prompting when a preset code earns the lock', async () => { + const run = vi.fn(async () => { throw twoFactorLocked(); }); + + await expect(withTwoFactor(run, { otpCode: '000000' })).rejects.toMatchObject({ + name: 'TwoFactorError' + }); + + expect(readlineState.interfacesCreated).toBe(0); + }); + }); + + test('prompts to replace a rejected preset code when there is a terminal', async () => { + answers.push('123456'); + const run = vi.fn(async code => { + if (code !== '123456') throw unauthorized(); + return 'token'; + }); + + await expect(withTwoFactor(run, { otpCode: '000000' })).resolves.toBe('token'); + + expect(run).toHaveBeenNthCalledWith(1, '000000'); + expect(run).toHaveBeenNthCalledWith(2, '123456'); + }); + + test('explains what to set instead of prompting when stdin is not a terminal', async () => { + process.stdin.isTTY = false; + const run = vi.fn(async () => { throw twoFactorRequired(); }); + + await expect(withTwoFactor(run)).rejects.toMatchObject({ + name: 'TwoFactorError', + message: expect.stringContaining(OTP_CODE_ENV_VAR) + }); + + expect(run).toHaveBeenCalledTimes(1); + }); + + test('honours an explicit interactive:false even on a terminal (--json runs)', async () => { + const run = vi.fn(async () => { throw twoFactorRequired(); }); + + await expect(withTwoFactor(run, { interactive: false })).rejects.toMatchObject({ + name: 'TwoFactorError' + }); + }); + + test('aborts instead of looping when stdin closes at the prompt', async () => { + const run = vi.fn(async () => { throw twoFactorRequired(); }); + + await expect(withTwoFactor(run)).rejects.toMatchObject({ name: 'TwoFactorError' }); + + expect(run).toHaveBeenCalledTimes(1); + }); + + test('re-prompts without spending an attempt when the answer is empty', async () => { + answers.push('', '123456'); + const run = vi.fn(async code => { + if (!code) throw twoFactorRequired(); + return 'token'; + }); + + await expect(withTwoFactor(run)).resolves.toBe('token'); + + // The empty line never reached the portal. + expect(run).toHaveBeenCalledTimes(2); + }); + + test('propagates non-401 failures untouched', async () => { + const run = vi.fn(async () => { + throw Object.assign(new Error('Request failed with status 500'), { + name: 'StatusCodeError', + statusCode: 500 + }); + }); + + await expect(withTwoFactor(run)).rejects.toMatchObject({ statusCode: 500 }); + + expect(run).toHaveBeenCalledTimes(1); + }); + + test('does not spend attempts on a server error raised after a code was entered', async () => { + answers.push('123456', '654321'); + const run = vi.fn(async code => { + if (!code) throw twoFactorRequired(); + throw Object.assign(new Error('boom'), { name: 'StatusCodeError', statusCode: 500 }); + }); + + await expect(withTwoFactor(run)).rejects.toMatchObject({ statusCode: 500 }); + + expect(run).toHaveBeenCalledTimes(2); + expect(answers).toEqual(['654321']); + }); +}); + +describe('Portal requests carry the code', () => { + const PORTAL = 'https://portal.example.com'; + let Portal; + + beforeEach(async () => { + process.env.PARTNER_PORTAL_HOST = PORTAL; + Portal = (await import('#lib/portal.js')).default; + nock.cleanAll(); + }); + + afterEach(() => { + delete process.env.PARTNER_PORTAL_HOST; + nock.cleanAll(); + }); + + test('GET /api/user_tokens sends the code in the UserOtpCode header', async () => { + const scope = nock(PORTAL, { + reqheaders: { + UserAuthorization: 'user@example.com:secret', + UserOtpCode: '123456' + } + }).get('/api/user_tokens').reply(200, [{ token: 'access-token' }]); + + await expect(Portal.login('user@example.com', 'secret', 'https://example.com/', '123456')) + .resolves.toEqual([{ token: 'access-token' }]); + + scope.done(); + }); + + test('GET /api/user_tokens omits the header when there is no code', async () => { + const scope = nock(PORTAL, { badheaders: ['UserOtpCode'] }) + .get('/api/user_tokens').reply(200, [{ token: 'access-token' }]); + + await Portal.login('user@example.com', 'secret', 'https://example.com/'); + + scope.done(); + }); + + test('POST /api/authenticate sends the code as otp_code', async () => { + const scope = nock(PORTAL) + .post('/api/authenticate', body => /name="otp_code"[\s\S]*123456/.test(body)) + .reply(200, { auth_token: 'jwt' }); + + await expect(Portal.jwtToken('user@example.com', 'secret', '123456')) + .resolves.toEqual({ auth_token: 'jwt' }); + + scope.done(); + }); + + test('POST /api/authenticate omits otp_code when there is no code', async () => { + const scope = nock(PORTAL) + .post('/api/authenticate', body => !/name="otp_code"/.test(body)) + .reply(200, { auth_token: 'jwt' }); + + await Portal.jwtToken('user@example.com', 'secret'); + + scope.done(); + }); +}); diff --git a/test/unit/twoFactorSession.test.js b/test/unit/twoFactorSession.test.js new file mode 100644 index 00000000..ad2c7a5d --- /dev/null +++ b/test/unit/twoFactorSession.test.js @@ -0,0 +1,188 @@ +import { vi, describe, test, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const answers = []; +vi.mock('readline', () => ({ + default: { + createInterface: () => { + const handlers = {}; + return { + on: (event, handler) => { handlers[event] = handler; }, + close: () => {}, + question: (_prompt, callback) => { + if (answers.length) return callback(answers.shift()); + return handlers.close?.(); + } + }; + } + } +})); + +vi.mock('#lib/logger.js', () => ({ + default: { Log: vi.fn(), Debug: vi.fn(), Info: vi.fn(), Warn: vi.fn(), Success: vi.fn(), Error: vi.fn() } +})); + +vi.mock('#lib/portal.js', () => ({ + default: { + url: () => 'https://partners.platformos.com', + tokenInfo: vi.fn(), + twoFactorSession: vi.fn() + } +})); + +const Portal = (await import('#lib/portal.js')).default; +const { ensureSession, needsTwoFactorSession, readSession, startSession } = + await import('#lib/twoFactorSession.js'); + +const PORTAL = 'http://portal.test'; +const INSTANCE = 'http://shop.example.com'; + +// What the Instance answers a write with when it wants a session (its +// Api::AppBuilder::BaseController#require_two_factor_session). +const sessionRequired = () => Object.assign(new Error('Request failed with status 401'), { + name: 'StatusCodeError', + statusCode: 401, + response: { statusCode: 401, body: { error: 'two_factor_required', errors: ['...'] } } +}); + +const inOneHour = () => new Date(Date.now() + 3600_000).toISOString(); + +let home; +let originalHome; +let originalIsTTY; + +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'pos-cli-home-')); + originalHome = os.homedir; + os.homedir = () => home; + originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = true; + answers.length = 0; + delete process.env.POS_PORTAL_OTP_CODE; + vi.clearAllMocks(); +}); + +afterEach(() => { + os.homedir = originalHome; + process.stdin.isTTY = originalIsTTY; + fs.rmSync(home, { recursive: true, force: true }); +}); + +describe('needsTwoFactorSession', () => { + test('matches only the instance two_factor_required body on a 401', () => { + expect(needsTwoFactorSession(sessionRequired())).toBe(true); + expect(needsTwoFactorSession({ statusCode: 401, response: { body: '' } })).toBe(false); + expect(needsTwoFactorSession({ statusCode: 403, response: { body: { error: 'two_factor_required' } } })).toBe(false); + expect(needsTwoFactorSession(null)).toBe(false); + }); +}); + +describe('the session store', () => { + test('round-trips a session and keeps it to the owner', async () => { + Portal.twoFactorSession.mockResolvedValue({ token: 'session-token', expires_at: inOneHour() }); + + await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }); + + expect(readSession(PORTAL, INSTANCE).token).toBe('session-token'); + const file = path.join(home, '.pos-cli', 'sessions.json'); + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + expect(fs.statSync(path.dirname(file)).mode & 0o777).toBe(0o700); + }); + + test('never writes a session into .pos, which is shared', async () => { + Portal.twoFactorSession.mockResolvedValue({ token: 'session-token', expires_at: inOneHour() }); + + await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }); + + expect(fs.existsSync('.pos')).toBe(false); + }); + + test('treats an expired session as no session', async () => { + Portal.twoFactorSession.mockResolvedValue({ + token: 'session-token', + expires_at: new Date(Date.now() - 1000).toISOString() + }); + + await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }); + + expect(readSession(PORTAL, INSTANCE)).toBeNull(); + }); + + // The same instance URL served by a different portal is a different credential. + test('scopes a session to one portal and one instance', async () => { + Portal.twoFactorSession.mockResolvedValue({ token: 'session-token', expires_at: inOneHour() }); + + await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }); + + expect(readSession('http://other-portal.test', INSTANCE)).toBeNull(); + expect(readSession(PORTAL, 'http://other.example.com')).toBeNull(); + // Trailing slashes are a formatting difference, not a different instance. + expect(readSession(`${PORTAL}/`, `${INSTANCE}/`).token).toBe('session-token'); + }); +}); + +describe('ensureSession', () => { + test('does nothing when the portal does not require a second factor', async () => { + Portal.tokenInfo.mockResolvedValue({ two_factor_required: false, two_factor_session: false }); + + await expect(ensureSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived' })).resolves.toBeNull(); + + expect(Portal.twoFactorSession).not.toHaveBeenCalled(); + }); + + test('prompts and starts a session when one is required', async () => { + Portal.tokenInfo.mockResolvedValue({ two_factor_required: true, two_factor_session: false }); + Portal.twoFactorSession.mockImplementation(({ otpCode }) => { + if (!otpCode) return Promise.reject(sessionRequired()); + return Promise.resolve({ token: 'session-token', expires_at: inOneHour() }); + }); + answers.push('123456'); + + const session = await ensureSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived' }); + + expect(session.token).toBe('session-token'); + expect(readSession(PORTAL, INSTANCE).token).toBe('session-token'); + }); + + test('reuses a stored session without asking the portal anything', async () => { + Portal.twoFactorSession.mockResolvedValue({ token: 'session-token', expires_at: inOneHour() }); + await startSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived', otpCode: '123456' }); + vi.clearAllMocks(); + + const session = await ensureSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived' }); + + expect(session.token).toBe('session-token'); + expect(Portal.tokenInfo).not.toHaveBeenCalled(); + expect(Portal.twoFactorSession).not.toHaveBeenCalled(); + }); + + // The Instance is what actually enforces this; a Portal that cannot answer must not be + // able to block a deploy that would otherwise have been allowed. + test('proceeds when the portal cannot be reached', async () => { + Portal.tokenInfo.mockRejectedValue(new Error('ECONNREFUSED')); + + await expect(ensureSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived' })).resolves.toBeNull(); + }); + + test('refuses with guidance rather than prompting when there is no terminal', async () => { + process.stdin.isTTY = false; + Portal.tokenInfo.mockResolvedValue({ two_factor_required: true, two_factor_session: false }); + Portal.twoFactorSession.mockRejectedValue(sessionRequired()); + + await expect(ensureSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived' })) + .rejects.toMatchObject({ name: 'TwoFactorError' }); + }); + + // A long-lived token is exactly what the instance just refused, so telling the operator + // to go and get one would send them in a circle. + test('does not advise a long-lived token when one is what was refused', async () => { + process.stdin.isTTY = false; + Portal.tokenInfo.mockResolvedValue({ two_factor_required: true, two_factor_session: false }); + Portal.twoFactorSession.mockRejectedValue(sessionRequired()); + + await expect(ensureSession({ portalUrl: PORTAL, instanceUrl: INSTANCE, token: 'long-lived' })) + .rejects.toMatchObject({ message: expect.not.stringContaining('--token') }); + }); +}); diff --git a/test/unit/watch.test.js b/test/unit/watch.test.js index 471dcf1c..ca931502 100644 --- a/test/unit/watch.test.js +++ b/test/unit/watch.test.js @@ -303,7 +303,10 @@ describe('asset sync', () => { }); gateway = { getInstance: vi.fn().mockResolvedValue({ id: 'inst-1' }), - sendManifest: vi.fn().mockResolvedValue({}) + sendManifest: vi.fn().mockResolvedValue({}), + // Present so the routing tests can assert an asset never takes the + // code-file path; the upload tests below never reach it. + sync: vi.fn().mockResolvedValue({}) }; }); @@ -414,6 +417,39 @@ describe('asset sync', () => { }); }); + // A module directory name is not restricted to word characters, and hyphens are + // the norm ("common-styling", "oauth-github"). The asset matcher used `\w+`, so + // for those modules every asset was misrouted to pushFile and uploaded as a code + // file: it came back with line endings rewritten and a Content-Type derived + // remotely rather than the one sync sends, which is enough for a browser to + // refuse to execute a .js file that is otherwise byte-perfect. deploy globs + // `modules/*/...` and so never had the problem, which is why deploying the same + // file always appeared to "fix" it. + test.each([ + ['modules/common-styling/public/assets/js/styleguide.js', 'assets/modules/common-styling/js/${filename}'], + ['modules/oauth-github/public/assets/style/main.css', 'assets/modules/oauth-github/style/${filename}'], + ['modules/pos.module/public/assets/js/app.js', 'assets/modules/pos.module/js/${filename}'] + ])('uploads %s directly instead of sending it as a code file', async (assetPath, key) => { + uploadFileFormData.mockResolvedValue(true); + + await sendFile(gateway, assetPath); + + expect(gateway.sync).not.toHaveBeenCalled(); + expect(uploadFileFormData).toHaveBeenCalledTimes(1); + expect(uploadFileFormData).toHaveBeenLastCalledWith(assetPath, { + url: 'https://s3.example.com/bucket', + fields: { key } + }); + expect(gateway.sendManifest).toHaveBeenCalledTimes(1); + }); + + test('still sends non-asset files in a hyphenated module as code files', async () => { + await sendFile(gateway, 'modules/common-styling/public/views/pages/index.liquid'); + + expect(uploadFileFormData).not.toHaveBeenCalled(); + expect(gateway.sync).toHaveBeenCalledTimes(1); + }); + test('keeps the batch for the next flush when registering the assets fails', async () => { uploadFileFormData.mockResolvedValue(true); gateway.sendManifest.mockRejectedValueOnce(new Error('502 Bad Gateway'));