Skip to content

Commit 0b60367

Browse files
authored
feat(otp): Adds opts.otpPrompt to provide an OTP on demand
This adds support for an opts.otpPrompt function which will be called whenever an OTP is needed. The request will then be retried with the OTP provided. If the method throws or fails to provide an OTP value, then the 401 error is raised.
1 parent 8e1d54e commit 0b60367

File tree

3 files changed

+154
-35
lines changed

3 files changed

+154
-35
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,19 @@ This is a one-time password from a two-factor authenticator. It is required for
418418
certain registry interactions when two-factor auth is enabled for a user
419419
account.
420420

421+
##### <a name="opts-otpPrompt"></a> `opts.otpPrompt`
422+
423+
* Type: Function
424+
* Default: null
425+
426+
This is a method which will be called to provide an OTP if the server
427+
responds with a 401 response indicating that a one-time-password is
428+
required.
429+
430+
It may return a promise, which must resolve to the OTP value to be used.
431+
If the method fails to provide an OTP value, then the fetch will fail with
432+
the auth error that indicated an OTP was needed.
433+
421434
##### <a name="opts-password"></a> `opts.password`
422435

423436
* Alias: `_password`

index.js

Lines changed: 52 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use strict'
22

3+
const { HttpErrorAuthOTP } = require('./errors.js')
34
const checkResponse = require('./check-response.js')
45
const getAuth = require('./auth.js')
56
const fetch = require('make-fetch-happen')
@@ -98,40 +99,57 @@ function regFetch (uri, /* istanbul ignore next */ opts_ = {}) {
9899
opts.preferOnline = true
99100
}
100101

101-
const doFetch = (body) => fetch(uri, {
102-
agent: opts.agent,
103-
algorithms: opts.algorithms,
104-
body,
105-
cache: getCacheMode(opts),
106-
cacheManager: opts.cache,
107-
ca: opts.ca,
108-
cert: opts.cert,
109-
headers,
110-
integrity: opts.integrity,
111-
key: opts.key,
112-
localAddress: opts.localAddress,
113-
maxSockets: opts.maxSockets,
114-
memoize: opts.memoize,
115-
method: method,
116-
noProxy: opts.noProxy,
117-
proxy: opts.httpsProxy || opts.proxy,
118-
retry: opts.retry ? opts.retry : {
119-
retries: opts.fetchRetries,
120-
factor: opts.fetchRetryFactor,
121-
minTimeout: opts.fetchRetryMintimeout,
122-
maxTimeout: opts.fetchRetryMaxtimeout,
123-
},
124-
strictSSL: opts.strictSSL,
125-
timeout: opts.timeout || 30 * 1000,
126-
}).then(res => checkResponse({
127-
method,
128-
uri,
129-
res,
130-
registry,
131-
startTime,
132-
auth,
133-
opts,
134-
}))
102+
const doFetch = async body => {
103+
const p = fetch(uri, {
104+
agent: opts.agent,
105+
algorithms: opts.algorithms,
106+
body,
107+
cache: getCacheMode(opts),
108+
cacheManager: opts.cache,
109+
ca: opts.ca,
110+
cert: opts.cert,
111+
headers,
112+
integrity: opts.integrity,
113+
key: opts.key,
114+
localAddress: opts.localAddress,
115+
maxSockets: opts.maxSockets,
116+
memoize: opts.memoize,
117+
method: method,
118+
noProxy: opts.noProxy,
119+
proxy: opts.httpsProxy || opts.proxy,
120+
retry: opts.retry ? opts.retry : {
121+
retries: opts.fetchRetries,
122+
factor: opts.fetchRetryFactor,
123+
minTimeout: opts.fetchRetryMintimeout,
124+
maxTimeout: opts.fetchRetryMaxtimeout,
125+
},
126+
strictSSL: opts.strictSSL,
127+
timeout: opts.timeout || 30 * 1000,
128+
}).then(res => checkResponse({
129+
method,
130+
uri,
131+
res,
132+
registry,
133+
startTime,
134+
auth,
135+
opts,
136+
}))
137+
138+
if (typeof opts.otpPrompt === 'function') {
139+
return p.catch(async er => {
140+
if (er instanceof HttpErrorAuthOTP) {
141+
// if otp fails to complete, we fail with that failure
142+
const otp = await opts.otpPrompt()
143+
// if no otp provided, throw the original HTTP error
144+
if (!otp)
145+
throw er
146+
return regFetch(uri, { ...opts, otp })
147+
}
148+
throw er
149+
})
150+
} else
151+
return p
152+
}
135153

136154
return Promise.resolve(body).then(doFetch)
137155
}

test/errors.js

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const npa = require('npm-package-arg')
44
const npmlog = require('npmlog')
55
const t = require('tap')
66
const tnock = require('./util/tnock.js')
7+
const { HttpErrorAuthOTP } = require('./errors.js')
78

89
const fetch = require('../index.js')
910

@@ -24,7 +25,11 @@ t.test('generic request errors', t => {
2425
tnock(t, OPTS.registry)
2526
.get('/ohno/oops')
2627
.reply(400, 'failwhale!')
27-
return fetch('/ohno/oops', OPTS)
28+
// verify that the otpPrompt won't save from non-OTP errors
29+
const otpPrompt = () => {
30+
throw new Error('nope')
31+
}
32+
return fetch('/ohno/oops', { ...OPTS, otpPrompt })
2833
.then(
2934
() => {
3035
throw new Error('should not have succeeded!')
@@ -128,6 +133,89 @@ t.test('OTP error', t => {
128133
)
129134
})
130135

136+
t.test('OTP error with prompt', t => {
137+
let OTP = null
138+
tnock(t, OPTS.registry)
139+
.get('/otplease').times(2)
140+
.matchHeader('npm-otp', otp => {
141+
if (otp) {
142+
OTP = otp[0]
143+
t.strictSame(otp, ['12345'], 'got expected otp')
144+
}
145+
return true
146+
})
147+
.reply((...args) => {
148+
if (OTP === '12345')
149+
return [200, { ok: 'this is fine' }, {}]
150+
else
151+
return [401, { error: 'otp, please' }, { 'www-authenticate': 'otp' }]
152+
})
153+
154+
const otpPrompt = async () => '12345'
155+
return fetch('/otplease', { ...OPTS, otpPrompt })
156+
.then(res => {
157+
t.strictSame(res.status, 200, 'got 200 response')
158+
return res.json()
159+
}).then(body => {
160+
t.strictSame(body, { ok: 'this is fine' }, 'got expected body')
161+
})
162+
})
163+
164+
t.test('OTP error with prompt, expired OTP in settings', t => {
165+
let OTP = null
166+
tnock(t, OPTS.registry)
167+
.get('/otplease').times(2)
168+
.matchHeader('npm-otp', otp => {
169+
if (otp) {
170+
if (!OTP)
171+
t.strictSame(otp, ['98765'], 'got invalid otp first')
172+
else
173+
t.strictSame(otp, ['12345'], 'got expected otp')
174+
OTP = otp[0]
175+
}
176+
return true
177+
})
178+
.reply((...args) => {
179+
if (OTP === '12345')
180+
return [200, { ok: 'this is fine' }, {}]
181+
else
182+
return [401, { error: 'otp, please' }, { 'www-authenticate': 'otp' }]
183+
})
184+
185+
const otpPrompt = async () => '12345'
186+
return fetch('/otplease', { ...OPTS, otpPrompt, otp: '98765' })
187+
.then(res => {
188+
t.strictSame(res.status, 200, 'got 200 response')
189+
return res.json()
190+
}).then(body => {
191+
t.strictSame(body, { ok: 'this is fine' }, 'got expected body')
192+
})
193+
})
194+
195+
t.test('OTP error with prompt that fails', t => {
196+
tnock(t, OPTS.registry)
197+
.get('/otplease')
198+
.reply((...args) => {
199+
return [401, { error: 'otp, please' }, { 'www-authenticate': 'otp' }]
200+
})
201+
202+
const otpPrompt = async () => {
203+
throw new Error('whoopsie')
204+
}
205+
return t.rejects(fetch('/otplease', { ...OPTS, otpPrompt }), HttpErrorAuthOTP)
206+
})
207+
208+
t.test('OTP error with prompt that returns nothing', t => {
209+
tnock(t, OPTS.registry)
210+
.get('/otplease')
211+
.reply((...args) => {
212+
return [401, { error: 'otp, please' }, { 'www-authenticate': 'otp' }]
213+
})
214+
215+
const otpPrompt = async () => {}
216+
return t.rejects(fetch('/otplease', { ...OPTS, otpPrompt }), HttpErrorAuthOTP)
217+
})
218+
131219
t.test('OTP error when missing www-authenticate', t => {
132220
tnock(t, OPTS.registry)
133221
.get('/otplease')

0 commit comments

Comments
 (0)