diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index f507d25..662f5c1 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -84,8 +84,6 @@ jobs: os: windows-latest shell: cmd node-version: - - 16.14.0 - - 16.x - 18.0.0 - 18.x - 20.x diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35c5dc3..a64e756 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,8 +61,6 @@ jobs: os: windows-latest shell: cmd node-version: - - 16.14.0 - - 16.x - 18.0.0 - 18.x - 20.x diff --git a/lib/index.js b/lib/index.js index e5b5dd0..578c0a1 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,37 +1,34 @@ -'use strict' - +const { URL } = require('node:url') +const timers = require('node:timers/promises') const fetch = require('npm-registry-fetch') const { HttpErrorBase } = require('npm-registry-fetch/lib/errors') -const EventEmitter = require('events') -const os = require('os') -const { URL } = require('url') const { log } = require('proc-log') // try loginWeb, catch the "not supported" message and fall back to couch -const login = (opener, prompter, opts = {}) => { - const { creds } = opts - return loginWeb(opener, opts).catch(er => { +const login = async (opener, prompter, opts = {}) => { + try { + return await loginWeb(opener, opts) + } catch (er) { if (er instanceof WebLoginNotSupported) { - log.verbose('web login not supported, trying couch') - return prompter(creds) - .then(data => loginCouch(data.username, data.password, opts)) - } else { - throw er + log.verbose('web login', 'not supported, trying couch') + const { username, password } = await prompter(opts.creds) + return loginCouch(username, password, opts) } - }) + throw er + } } -const adduser = (opener, prompter, opts = {}) => { - const { creds } = opts - return adduserWeb(opener, opts).catch(er => { +const adduser = async (opener, prompter, opts = {}) => { + try { + return await adduserWeb(opener, opts) + } catch (er) { if (er instanceof WebLoginNotSupported) { - log.verbose('web adduser not supported, trying couch') - return prompter(creds) - .then(data => adduserCouch(data.username, data.email, data.password, opts)) - } else { - throw er + log.verbose('web adduser', 'not supported, trying couch') + const { username, email, password } = await prompter(opts.creds) + return adduserCouch(username, email, password, opts) } - }) + throw er + } } const adduserWeb = (opener, opts = {}) => { @@ -47,88 +44,104 @@ const loginWeb = (opener, opts = {}) => { const isValidUrl = u => { try { return /^https?:$/.test(new URL(u).protocol) - } catch (er) { + } catch { return false } } -const webAuth = (opener, opts, body) => { - const { hostname } = opts - body.hostname = hostname || os.hostname() - const target = '/-/v1/login' - const doneEmitter = new EventEmitter() - return fetch(target, { - ...opts, - method: 'POST', - body, - }).then(res => { - return Promise.all([res, res.json()]) - }).then(([res, content]) => { - const { doneUrl, loginUrl } = content +const webAuth = async (opener, opts, body) => { + try { + const res = await fetch('/-/v1/login', { + ...opts, + method: 'POST', + body, + }) + + const content = await res.json() log.verbose('web auth', 'got response', content) + + const { doneUrl, loginUrl } = content if (!isValidUrl(doneUrl) || !isValidUrl(loginUrl)) { throw new WebLoginInvalidResponse('POST', res, content) } - return content - }).then(({ doneUrl, loginUrl }) => { - log.verbose('web auth', 'opening url pair') - - const openPromise = opener(loginUrl, doneEmitter) - const webAuthCheckPromise = webAuthCheckLogin(doneUrl, { ...opts, cache: false }) - .then(authResult => { - log.verbose('web auth', 'done-check finished') - - // cancel open prompt if it's present - doneEmitter.emit('abort') - - return authResult - }) - - return Promise.all([openPromise, webAuthCheckPromise]).then( - // pick the auth result and pass it along - ([, authResult]) => authResult - ) - }).catch(er => { - // cancel open prompt if it's present - doneEmitter.emit('abort') + return await webAuthOpener(opener, loginUrl, doneUrl, opts) + } catch (er) { if ((er.statusCode >= 400 && er.statusCode <= 499) || er.statusCode === 500) { throw new WebLoginNotSupported('POST', { status: er.statusCode, headers: { raw: () => er.headers }, }, er.body) - } else { - throw er } - }) + throw er + } } -const webAuthCheckLogin = (doneUrl, opts) => { - return fetch(doneUrl, opts).then(res => { - return Promise.all([res, res.json()]) - }).then(([res, content]) => { - if (res.status === 200) { - if (!content.token) { - throw new WebLoginInvalidResponse('GET', res, content) - } else { - return content - } - } else if (res.status === 202) { - const retry = +res.headers.get('retry-after') * 1000 - if (retry > 0) { - return sleep(retry).then(() => webAuthCheckLogin(doneUrl, opts)) - } else { - return webAuthCheckLogin(doneUrl, opts) - } - } else { +const webAuthOpener = async (opener, loginUrl, doneUrl, opts) => { + const abortController = new AbortController() + const { signal } = abortController + try { + log.verbose('web auth', 'opening url pair') + const [, authResult] = await Promise.all([ + opener(loginUrl, { signal }).catch((err) => { + if (err.name === 'AbortError') { + abortController.abort() + return + } + throw err + }), + webAuthCheckLogin(doneUrl, { ...opts, cache: false }, { signal }).then((r) => { + log.verbose('web auth', 'done-check finished') + abortController.abort() + return r + }), + ]) + return authResult + } catch (er) { + abortController.abort() + throw er + } +} + +const webAuthCheckLogin = async (doneUrl, opts, { signal } = {}) => { + signal?.throwIfAborted() + + const res = await fetch(doneUrl, opts) + const content = await res.json() + + if (res.status === 200) { + if (!content.token) { throw new WebLoginInvalidResponse('GET', res, content) } + return content + } + + if (res.status === 202) { + const retry = +res.headers.get('retry-after') * 1000 + if (retry > 0) { + await timers.setTimeout(retry, null, { ref: false, signal }) + } + return webAuthCheckLogin(doneUrl, opts, { signal }) + } + + throw new WebLoginInvalidResponse('GET', res, content) +} + +const couchEndpoint = (username) => `/-/user/org.couchdb.user:${encodeURIComponent(username)}` + +const putCouch = async (path, username, body, opts) => { + const result = await fetch.json(`${couchEndpoint(username)}${path}`, { + ...opts, + method: 'PUT', + body, }) + result.username = username + return result } -const adduserCouch = (username, email, password, opts = {}) => { +const adduserCouch = async (username, email, password, opts = {}) => { const body = { - _id: 'org.couchdb.user:' + username, + _id: `org.couchdb.user:${username}`, name: username, password: password, email: email, @@ -136,129 +149,103 @@ const adduserCouch = (username, email, password, opts = {}) => { roles: [], date: new Date().toISOString(), } - const logObj = { + + log.verbose('adduser', 'before first PUT', { ...body, password: 'XXXXX', - } - log.verbose('adduser', 'before first PUT', logObj) - - const target = '/-/user/org.couchdb.user:' + encodeURIComponent(username) - return fetch.json(target, { - ...opts, - method: 'PUT', - body, - }).then(result => { - result.username = username - return result }) + + return putCouch('', username, body, opts) } -const loginCouch = (username, password, opts = {}) => { +const loginCouch = async (username, password, opts = {}) => { const body = { - _id: 'org.couchdb.user:' + username, + _id: `org.couchdb.user:${username}`, name: username, password: password, type: 'user', roles: [], date: new Date().toISOString(), } - const logObj = { + + log.verbose('login', 'before first PUT', { ...body, password: 'XXXXX', - } - log.verbose('login', 'before first PUT', logObj) + }) - const target = '/-/user/org.couchdb.user:' + encodeURIComponent(username) - return fetch.json(target, { - ...opts, - method: 'PUT', - body, - }).catch(err => { + try { + return await putCouch('', username, body, opts) + } catch (err) { if (err.code === 'E400') { err.message = `There is no user with the username "${username}".` throw err } + if (err.code !== 'E409') { throw err } - return fetch.json(target, { - ...opts, - query: { write: true }, - }).then(result => { - Object.keys(result).forEach(k => { - if (!body[k] || k === 'roles') { - body[k] = result[k] - } - }) - const { otp } = opts - return fetch.json(`${target}/-rev/${body._rev}`, { - ...opts, - method: 'PUT', - body, - forceAuth: { - username, - password: Buffer.from(password, 'utf8').toString('base64'), - otp, - }, - }) - }) - }).then(result => { - result.username = username - return result - }) -} + } -const get = (opts = {}) => fetch.json('/-/npm/v1/user', opts) + const result = await fetch.json(couchEndpoint(username), { + ...opts, + query: { write: true }, + }) -const set = (profile, opts = {}) => { - Object.keys(profile).forEach(key => { - // profile keys can't be empty strings, but they CAN be null - if (profile[key] === '') { - profile[key] = null + for (const k of Object.keys(result)) { + if (!body[k] || k === 'roles') { + body[k] = result[k] } - }) - return fetch.json('/-/npm/v1/user', { + } + + return putCouch(`/-rev/${body._rev}`, username, body, { ...opts, - method: 'POST', - body: profile, + forceAuth: { + username, + password: Buffer.from(password, 'utf8').toString('base64'), + otp: opts.otp, + }, }) } -const listTokens = (opts = {}) => { - const untilLastPage = (href, objects) => { - return fetch.json(href, opts).then(result => { - objects = objects ? objects.concat(result.objects) : result.objects - if (result.urls.next) { - return untilLastPage(result.urls.next, objects) - } else { - return objects - } - }) +const get = (opts = {}) => fetch.json('/-/npm/v1/user', opts) + +const set = (profile, opts = {}) => fetch.json('/-/npm/v1/user', { + ...opts, + method: 'POST', + // profile keys can't be empty strings, but they CAN be null + body: Object.fromEntries(Object.entries(profile).map(([k, v]) => [k, v === '' ? null : v])), +}) + +const paginate = async (href, opts, items = []) => { + const result = await fetch.json(href, opts) + items = items.concat(result.objects) + if (result.urls.next) { + return paginate(result.urls.next, opts, items) } - return untilLastPage('/-/npm/v1/tokens') + return items } -const removeToken = (tokenKey, opts = {}) => { - const target = `/-/npm/v1/tokens/token/${tokenKey}` - return fetch(target, { +const listTokens = (opts = {}) => paginate('/-/npm/v1/tokens', opts) + +const removeToken = async (tokenKey, opts = {}) => { + await fetch(`/-/npm/v1/tokens/token/${tokenKey}`, { ...opts, method: 'DELETE', ignoreBody: true, - }).then(() => null) -} - -const createToken = (password, readonly, cidrs, opts = {}) => { - return fetch.json('/-/npm/v1/tokens', { - ...opts, - method: 'POST', - body: { - password: password, - readonly: readonly, - cidr_whitelist: cidrs, - }, }) + return null } +const createToken = (password, readonly, cidrs, opts = {}) => fetch.json('/-/npm/v1/tokens', { + ...opts, + method: 'POST', + body: { + password: password, + readonly: readonly, + cidr_whitelist: cidrs, + }, +}) + class WebLoginInvalidResponse extends HttpErrorBase { constructor (method, res, body) { super(method, res, body) @@ -276,8 +263,6 @@ class WebLoginNotSupported extends HttpErrorBase { } } -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) - module.exports = { adduserCouch, loginCouch, @@ -291,4 +276,5 @@ module.exports = { removeToken, createToken, webAuthCheckLogin, + webAuthOpener, } diff --git a/package.json b/package.json index acdf4d6..98c4bec 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ ] }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": ">=18.0.0" }, "templateOSS": { "//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.", diff --git a/tap-snapshots/test-login.js-TAP.test.js b/tap-snapshots/test-login.js-TAP.test.js deleted file mode 100644 index 88d0bc8..0000000 --- a/tap-snapshots/test-login.js-TAP.test.js +++ /dev/null @@ -1,75 +0,0 @@ -/* IMPORTANT - * This snapshot file is auto-generated, but designed for humans. - * It should be checked into source control and tracked carefully. - * Re-generate by setting TAP_SNAPSHOT=1 and running tests. - * Make sure to inspect the output below. Do not ignore changes! - */ -'use strict' -exports[`test/login.js TAP cleanup > got expected requests 1`] = ` -[ - "start server", - "login web", - "POST /weblogin/-/v1/login", - "GET /weblogin/-/v1/login/blerg", - "adduser web", - "POST /weblogin/-/v1/login", - "GET /weblogin/-/v1/login/blerg", - "login web by default", - "POST /weblogin/-/v1/login", - "GET /weblogin/-/v1/login/blerg", - "adduser web", - "POST /weblogin/-/v1/login", - "GET /weblogin/-/v1/login/blerg", - "adduser web by default", - "POST /weblogin/-/v1/login", - "GET /weblogin/-/v1/login/blerg", - "login couch", - "PUT /couchdb/-/user/org.couchdb.user:user", - "PUT /couchdb/-/user/org.couchdb.user:exists", - "GET /couchdb/-/user/org.couchdb.user:exists?write=true", - "PUT /couchdb/-/user/org.couchdb.user:exists/-rev/goodbloodmoon", - "PUT /couchdb/-/user/org.couchdb.user:nemo", - "adduser couch", - "PUT /couchdb/-/user/org.couchdb.user:user", - "login fallback to couch", - "POST /couchdb/-/v1/login", - "PUT /couchdb/-/user/org.couchdb.user:user", - "adduser fallback to couch", - "POST /couchdb/-/v1/login", - "PUT /couchdb/-/user/org.couchdb.user:user", - "501s", - "POST /501/-/v1/login", - "POST /501/-/v1/login", - "POST /501/-/v1/login", - "POST /501/-/v1/login", - "PUT /501/-/user/org.couchdb.user:user", - "PUT /501/-/user/org.couchdb.user:user", - "fail at login step", - "POST /invalid-login/-/v1/login", - "fail at login step by having an invalid url", - "POST /invalid-login-url/-/v1/login", - "fail at the done step", - "POST /invalid-done/-/v1/login", - "GET /invalid-done/-/v1/login/blerg", - "notoken response from login endpoint (status 200, bad data)", - "POST /notoken/-/v1/login", - "GET /notoken/-/v1/login/blerg", - "POST /notoken/-/v1/login", - "GET /notoken/-/v1/login/blerg", - "retry-after 202 response", - "POST /retry-after/-/v1/login", - "GET /retry-after/-/v1/login/blerg", - "GET /retry-after/-/v1/login/blerg", - "POST /retry-after/-/v1/login", - "GET /retry-after/-/v1/login/blerg", - "GET /retry-after/-/v1/login/blerg", - "no retry-after 202 response", - "POST /retry-again/-/v1/login", - "GET /retry-again/-/v1/login/blerg", - "GET /retry-again/-/v1/login/blerg", - "POST /retry-again/-/v1/login", - "GET /retry-again/-/v1/login/blerg", - "GET /retry-again/-/v1/login/blerg", - "cleanup" -] -` diff --git a/tap-snapshots/test/login.js.test.cjs b/tap-snapshots/test/login.js.test.cjs index 1fe6577..9803fb1 100644 --- a/tap-snapshots/test/login.js.test.cjs +++ b/tap-snapshots/test/login.js.test.cjs @@ -9,6 +9,7 @@ exports[`test/login.js TAP cleanup > got expected requests 1`] = ` [ "POST /weblogin/-/v1/login", "GET /weblogin/-/v1/login/blerg", + "GET /weblogin/-/v1/login/blerg", "POST /weblogin/-/v1/login", "GET /weblogin/-/v1/login/blerg", "POST /weblogin/-/v1/login", @@ -52,6 +53,14 @@ exports[`test/login.js TAP cleanup > got expected requests 1`] = ` "GET /retry-again/-/v1/login/blerg", "POST /retry-again/-/v1/login", "GET /retry-again/-/v1/login/blerg", - "GET /retry-again/-/v1/login/blerg" + "GET /retry-again/-/v1/login/blerg", + "POST /retry-long-time/-/v1/login", + "GET /retry-long-time/-/v1/login/loooooong", + "POST /retry-long-time/-/v1/login", + "GET /retry-long-time/-/v1/login/loooooong", + "POST /retry-long-time/-/v1/login", + "GET /retry-long-time/-/v1/login/loooooong", + "POST /retry-long-time/-/v1/login", + "GET /retry-long-time/-/v1/login/loooooong" ] ` diff --git a/test/index.js b/test/index.js index 017c734..0f290d2 100644 --- a/test/index.js +++ b/test/index.js @@ -1,8 +1,5 @@ -'use strict' - const test = require('tap').test const tnock = require('./fixtures/tnock.js') - const profile = require('..') const registry = 'https://registry.npmjs.org/' @@ -141,9 +138,9 @@ test('login fallback to couch when web login fails cancels opener promise', t => .reply(404, { error: 'Not found' }) let cancelled = false - const opener = (url, doneEmitter) => { + const opener = async (url, { signal }) => { t.equal(url, loginUrl) - doneEmitter.on('abort', () => { + signal.addEventListener('abort', () => { cancelled = true }) } diff --git a/test/login.js b/test/login.js index 61c9696..7769af6 100644 --- a/test/login.js +++ b/test/login.js @@ -1,6 +1,7 @@ const t = require('tap') const profile = require('..') const http = require('http') +const timers = require('timers/promises') const PORT = +process.env.PORT || 13445 const reg = 'http://localhost:' + PORT @@ -132,6 +133,17 @@ const server = http.createServer((q, s) => { return respond(s, 200, { token: 'blerg' }) } + case '/retry-long-time/-/v1/login': + return respond(s, 200, { + loginUrl: 'http://www.example.com/loooooong', + doneUrl: reg + '/retry-long-time/-/v1/login/loooooong', + }) + + case '/retry-long-time/-/v1/login/loooooong': { + s.setHeader('retry-after', 60 * 1000) + return respond(s, 202, {}) + } + case '/invalid-login/-/v1/login': return respond(s, 200, { salt: 'im helping' }) @@ -165,7 +177,7 @@ t.test('start server', t => server.listen(PORT, t.end)) t.test('login web', t => { let calledOpener = false - const opener = () => new Promise(resolve => { + const opener = async () => new Promise(resolve => { calledOpener = true resolve() }) @@ -180,6 +192,12 @@ t.test('login web', t => { }) }) +t.test('webAuthCheckLogin', async t => { + await t.resolveMatch(profile.webAuthCheckLogin(reg + '/weblogin/-/v1/login/blerg', { + registry: reg + '/weblogin/', + }), { token: 'blerg' }) +}) + t.test('adduser web', t => { let calledOpener = false const opener = () => new Promise(resolve => { @@ -517,6 +535,32 @@ t.test('no retry-after 202 response', t => { t.end() }) +t.test('opener error with long 202 response', t => { + const registry = reg + '/retry-long-time/' + + const err = new Error('opener error') + const throwOpener = async () => { + await timers.setTimeout(100) + throw err + } + const abortOpener = async () => { + await timers.setTimeout(100, null, { signal: AbortSignal.timeout(10) }) + } + const prompter = () => { + throw new Error('should not do this') + } + + t.test('loginWeb', async t => { + await t.rejects(profile.loginWeb(throwOpener, { registry }), err) + await t.rejects(profile.loginWeb(abortOpener, { registry }), { name: 'AbortError' }) + }) + t.test('login', async t => { + await t.rejects(profile.login(throwOpener, prompter, { registry }), err) + await t.rejects(profile.login(abortOpener, prompter, { registry }), { name: 'AbortError' }) + }) + t.end() +}) + t.test('cleanup', t => { // NOTE: snapshot paths are not platform-independent process.platform !== 'win32' &&