Skip to content

Commit 2242f25

Browse files
authored
fix(webauth): improve error messages around webauth in non-TTY (#8952)
## Add webauth URLs to EOTP error messages When npm returns an EOTP error with `authUrl` and `doneUrl` in the response body (web-based OTP flow), these URLs are now included in the error output. **What:** - Display `authUrl` (for browser authentication) and `doneUrl` (for token retrieval) in non-TTY EOTP error messages - Include both URLs in `--json` output as `error.authUrl` and `error.doneUrl` - Adjusted messaging to differentiate webauth flow from traditional TOTP authenticator flow **Why:** - In non-interactive/CI environments, the webauth URLs were not surfaced, making it impossible to complete authentication - Tools wrapping `npm publish` (like [changesets](changesets/changesets#1773)) need access to these URLs to implement web OTP support - The `doneUrl` is required for polling to retrieve the token after browser authentication completes
1 parent b3f8475 commit 2242f25

File tree

5 files changed

+100
-7
lines changed

5 files changed

+100
-7
lines changed

lib/utils/error-message.js

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const errorMessage = (er, npm) => {
77
const summary = []
88
const detail = []
99
const files = []
10+
let json
1011

1112
er.message &&= replaceInfo(er.message)
1213
er.stack &&= replaceInfo(er.stack)
@@ -123,12 +124,24 @@ const errorMessage = (er, npm) => {
123124
case 'E401':
124125
// E401 is for places where we accidentally neglect OTP stuff
125126
if (er.code === 'EOTP' || /one-time pass/.test(er.message)) {
126-
summary.push(['', 'This operation requires a one-time password from your authenticator.'])
127-
detail.push(['', [
128-
'You can provide a one-time password by passing --otp=<code> to the command you ran.',
129-
'If you already provided a one-time password then it is likely that you either typoed',
130-
'it, or it timed out. Please try again.',
131-
].join('\n')])
127+
const authUrl = er.body?.authUrl
128+
const doneUrl = er.body?.doneUrl
129+
if (authUrl && doneUrl) {
130+
json = { authUrl, doneUrl }
131+
summary.push(['', 'This operation requires a one-time password.'])
132+
detail.push(['', `Open this URL in your browser to authenticate:`])
133+
detail.push(['', ` ${authUrl}`])
134+
detail.push(['', ''])
135+
detail.push(['', `After authenticating, your token can be retrieved from:`])
136+
detail.push(['', ` ${doneUrl}`])
137+
} else {
138+
summary.push(['', 'This operation requires a one-time password from your authenticator.'])
139+
detail.push(['', [
140+
'You can provide a one-time password by passing --otp=<code> to the command you ran.',
141+
'If you already provided a one-time password then it is likely that you either typoed',
142+
'it, or it timed out. Please try again.',
143+
].join('\n')])
144+
}
132145
} else {
133146
// npm ERR! code E401
134147
// npm ERR! Unable to authenticate, need: Basic
@@ -369,6 +382,7 @@ const errorMessage = (er, npm) => {
369382
summary,
370383
detail,
371384
files,
385+
json,
372386
}
373387
}
374388

@@ -430,7 +444,7 @@ const getError = (err, { npm, command, pkg }) => {
430444
// so they have redacted information
431445
err.code ??= err.message.match(/^(?:Error: )?(E[A-Z]+)/)?.[1]
432446
// this mutates the error and redacts stack/message
433-
const { summary, detail, files } = errorMessage(err, npm)
447+
const { summary, detail, files, json } = errorMessage(err, npm)
434448

435449
return {
436450
err,
@@ -440,6 +454,7 @@ const getError = (err, { npm, command, pkg }) => {
440454
summary,
441455
detail,
442456
files,
457+
json,
443458
verbose: ['type', 'stack', 'statusCode', 'pkgid']
444459
.filter(k => err[k])
445460
.map(k => [k, replaceInfo(err[k])]),

lib/utils/output-error.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const jsonError = (error, npm) => {
1919
code: error.code,
2020
summary: (error.summary || []).map(l => l.slice(1).join(' ')).join('\n').trim(),
2121
detail: (error.detail || []).map(l => l.slice(1).join(' ')).join('\n').trim(),
22+
...error.json,
2223
}
2324
}
2425
}

tap-snapshots/test/lib/utils/error-message.js.test.cjs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,6 +1015,43 @@ Object {
10151015
}
10161016
`
10171017

1018+
exports[`test/lib/utils/error-message.js TAP eotp/e401 one-time pass webauth challenge > must match snapshot 1`] = `
1019+
Object {
1020+
"detail": Array [
1021+
Array [
1022+
"",
1023+
"Open this URL in your browser to authenticate:",
1024+
],
1025+
Array [
1026+
"",
1027+
" https://registry.npmjs.org/-/auth/login/abc123",
1028+
],
1029+
Array [
1030+
"",
1031+
"",
1032+
],
1033+
Array [
1034+
"",
1035+
"After authenticating, your token can be retrieved from:",
1036+
],
1037+
Array [
1038+
"",
1039+
" https://registry.npmjs.org/-/auth/done/abc123",
1040+
],
1041+
],
1042+
"json": Object {
1043+
"authUrl": "https://registry.npmjs.org/-/auth/login/abc123",
1044+
"doneUrl": "https://registry.npmjs.org/-/auth/done/abc123",
1045+
},
1046+
"summary": Array [
1047+
Array [
1048+
"",
1049+
"This operation requires a one-time password.",
1050+
],
1051+
],
1052+
}
1053+
`
1054+
10181055
exports[`test/lib/utils/error-message.js TAP eotp/e401 www-authenticate challenges Basic realm=by, charset="UTF-8", challenge="your friends" > must match snapshot 1`] = `
10191056
Object {
10201057
"detail": Array [

test/lib/cli/exit-handler.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,35 @@ t.test('merges output buffers errors with --json', async (t) => {
296296
)
297297
})
298298

299+
t.test('json output includes authUrl and doneUrl for webauth EOTP errors', async (t) => {
300+
const { exitHandler, outputs } = await mockExitHandler(t, {
301+
config: { json: true },
302+
error: Object.assign(new Error('one-time pass required'), {
303+
code: 'EOTP',
304+
body: {
305+
authUrl: 'https://registry.npmjs.org/-/auth/login/abc123',
306+
doneUrl: 'https://registry.npmjs.org/-/auth/done/abc123',
307+
},
308+
}),
309+
})
310+
311+
await exitHandler()
312+
313+
t.equal(process.exitCode, 1)
314+
const jsonOutput = JSON.parse(outputs[0])
315+
t.same(jsonOutput.error, {
316+
code: 'EOTP',
317+
summary: 'This operation requires a one-time password.',
318+
detail: 'Open this URL in your browser to authenticate:\n' +
319+
' https://registry.npmjs.org/-/auth/login/abc123\n' +
320+
'\n' +
321+
'After authenticating, your token can be retrieved from:\n' +
322+
' https://registry.npmjs.org/-/auth/done/abc123',
323+
authUrl: 'https://registry.npmjs.org/-/auth/login/abc123',
324+
doneUrl: 'https://registry.npmjs.org/-/auth/done/abc123',
325+
})
326+
})
327+
299328
t.test('output buffer without json', async (t) => {
300329
const { exitHandler, outputs, logs } = await mockExitHandler(t, {
301330
error: err('Error: EBADTHING Something happened'),

test/lib/utils/error-message.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,17 @@ t.test('eotp/e401', async t => {
281281
t.end()
282282
})
283283

284+
t.test('one-time pass webauth challenge', t => {
285+
t.matchSnapshot(errorMessage(Object.assign(new Error('nope'), {
286+
code: 'EOTP',
287+
body: {
288+
authUrl: 'https://registry.npmjs.org/-/auth/login/abc123',
289+
doneUrl: 'https://registry.npmjs.org/-/auth/done/abc123',
290+
},
291+
})))
292+
t.end()
293+
})
294+
284295
t.test('www-authenticate challenges', t => {
285296
const auths = [
286297
'Bearer realm=do, charset="UTF-8", challenge="yourself"',

0 commit comments

Comments
 (0)