Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion src/trust.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,15 @@ export const npm = {
}
},
trustConfigurations(name) {
return parseTrustList(capture(['trust', 'list', name, '--json']));
return withSecondFactor(() => parseTrustList(capture(['trust', 'list', name, '--json'])), {
authenticate: () => npm.authenticate(name),
});
},
authenticate(name) {
console.error(
'🔐 npm needs your second factor for trust operations. In the browser, tick "skip 2FA for the next 5 minutes" so the remaining calls run unattended.',
);
interactive(['trust', 'list', name]);
},
trust(name, { repo, file }) {
interactive(['trust', 'github', name, '--repo', repo, '--file', file, '--allow-publish', '--yes']);
Expand All @@ -199,6 +207,35 @@ export function isNotFound(error) {
return /E404/.test(`${error.stdout ?? ''}${error.stderr ?? ''}`);
}

/** Whether a failed npm call was refused for lack of a one-time password. */
export function isOtpRequired(error) {
return /EOTP/.test(`${error.stdout ?? ''}${error.stderr ?? ''}`);
}

/**
* Runs a captured npm call that may demand a second factor. npm only starts
* its browser/OTP flow when stdin AND stdout are terminals, and captured
* calls pipe stdout, so on EOTP the interactive `authenticate` runs with the
* terminal attached (the user can grant a 5-minute 2FA-free window there)
* and the call is retried once.
*/
export function withSecondFactor(fn, { authenticate, isOtp = isOtpRequired }) {
try {
return fn();
} catch (error) {
if (!isOtp(error)) throw error;
}
authenticate();
try {
return fn();
} catch (error) {
if (!isOtp(error)) throw error;
throw new Error(
'npm still requires a one-time password after authenticating; rerun and tick "skip 2FA for the next 5 minutes".',
);
}
}

/** Fails loudly unless the local npm can run `npm trust` and is logged in. */
export function preflight(client = npm) {
const version = client.version();
Expand Down
57 changes: 57 additions & 0 deletions tests/trust.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { test } from 'node:test';
import {
describeConfig,
isNotFound,
isOtpRequired,
matchesExpected,
parseGitHubRepo,
parseTrustList,
planTrust,
preflight,
summarise,
withSecondFactor,
} from '../src/trust.mjs';

const expected = { repo: 'codama-idl/codama', file: 'main.yml' };
Expand Down Expand Up @@ -151,3 +153,58 @@ test('preflight rejects old npm and logged-out sessions', () => {
user: 'lorisleiva',
});
});

test('isOtpRequired reads EOTP from either stream', () => {
assert.equal(isOtpRequired({ stdout: '', stderr: 'npm error code EOTP' }), true);
assert.equal(isOtpRequired({ stdout: '{"error":{"code":"EOTP"}}', stderr: '' }), true);
assert.equal(isOtpRequired({ stdout: '', stderr: 'npm error code E404' }), false);
});

test('withSecondFactor authenticates on EOTP and retries exactly once', () => {
const otp = Object.assign(new Error('otp'), { stderr: 'npm error code EOTP' });
const other = Object.assign(new Error('boom'), { stderr: 'npm error code E500' });
const log = [];
const authenticate = () => log.push('auth');

// No OTP needed: no authentication.
assert.equal(
withSecondFactor(() => 'ok', { authenticate }),
'ok',
);
assert.deepEqual(log, []);

// OTP needed once: authenticate, retry succeeds.
let calls = 0;
const flaky = () => {
if (calls++ === 0) throw otp;
return 'ok';
};
assert.equal(withSecondFactor(flaky, { authenticate }), 'ok');
assert.deepEqual(log, ['auth']);

// Still OTP after authenticating: loud failure, no infinite loop.
assert.throws(
() =>
withSecondFactor(
() => {
throw otp;
},
{ authenticate },
),
/still requires a one-time password/,
);
assert.deepEqual(log, ['auth', 'auth']);

// Unrelated errors propagate untouched, before and after authenticating.
assert.throws(
() =>
withSecondFactor(
() => {
throw other;
},
{ authenticate },
),
/boom/,
);
assert.deepEqual(log, ['auth', 'auth']);
});