diff --git a/lib/adduser.js b/lib/adduser.js index 4ef9edbab768f..2f0be193fda4e 100644 --- a/lib/adduser.js +++ b/lib/adduser.js @@ -1,6 +1,15 @@ +'use strict' + const log = require('npmlog') const npm = require('./npm.js') +const output = require('./utils/output.js') const usageUtil = require('./utils/usage.js') +const authTypes = { + legacy: require('./auth/legacy.js'), + oauth: require('./auth/oauth.js'), + saml: require('./auth/saml.js'), + sso: require('./auth/sso.js') +} const usage = usageUtil( 'adduser', @@ -11,8 +20,7 @@ const completion = require('./utils/completion/none.js') const cmd = (args, cb) => adduser(args).then(() => cb()).catch(cb) -const getRegistry = opts => { - const { scope, registry } = opts +const getRegistry = ({ scope, registry }) => { if (scope) { const scopedRegistry = npm.config.get(`${scope}:registry`) const cliRegistry = npm.config.get('registry', 'cli') @@ -24,37 +32,51 @@ const getRegistry = opts => { } const getAuthType = ({ authType }) => { - try { - return require('./auth/' + authType) - } catch (e) { + const type = authTypes[authType] + + if (!type) { throw new Error('no such auth module') } + + return type } -const adduser = async args => { - const registry = getRegistry(npm.flatOptions) +const saveConfig = () => new Promise((resolve, reject) => { + npm.config.save('user', er => er ? reject(er) : resolve()) +}) + +const updateConfig = async ({ newCreds, registry, scope }) => { + npm.config.del('_token', 'user') // prevent legacy pollution + + if (scope) { + npm.config.set(scope + ':registry', registry, 'user') + } + + npm.config.setCredentialsByURI(registry, newCreds) + await saveConfig() +} + +const adduser = async (args) => { const { scope } = npm.flatOptions + const registry = getRegistry(npm.flatOptions) + const auth = getAuthType(npm.flatOptions) const creds = npm.config.getCredentialsByURI(registry) log.disableProgress() - const auth = getAuthType(npm.flatOptions) + const { message, newCreds } = await auth({ + creds, + registry, + scope + }) - // XXX make auth.login() promise-returning so we don't have to wrap here - await new Promise((res, rej) => { - auth.login(creds, registry, scope, function (er, newCreds) { - if (er) { - return rej(er) - } - - npm.config.del('_token', 'user') // prevent legacy pollution - if (scope) { - npm.config.set(scope + ':registry', registry, 'user') - } - npm.config.setCredentialsByURI(registry, newCreds) - npm.config.save('user', er => er ? rej(er) : res()) - }) + await updateConfig({ + newCreds, + registry, + scope }) + + output(message) } module.exports = Object.assign(cmd, { completion, usage }) diff --git a/lib/auth/legacy.js b/lib/auth/legacy.js index c42ef16bb4029..883a32fd51e13 100644 --- a/lib/auth/legacy.js +++ b/lib/auth/legacy.js @@ -1,75 +1,101 @@ 'use strict' -const read = require('../utils/read-user-info.js') -const profile = require('npm-profile') const log = require('npmlog') -const npm = require('../npm.js') -const output = require('../utils/output.js') -const openUrl = require('../utils/open-url') +const profile = require('npm-profile') +const openUrl = require('../utils/open-url.js') +const read = require('../utils/read-user-info.js') + +// TODO: refactor lib/utils/open-url and its usages const openerPromise = (url) => new Promise((resolve, reject) => { openUrl(url, 'to complete your login please visit', (er) => er ? reject(er) : resolve()) }) -const loginPrompter = (creds) => { +const loginPrompter = async (creds) => { const opts = { log: log } - return read.username('Username:', creds.username, opts).then((u) => { - creds.username = u - return read.password('Password:', creds.password) - }).then((p) => { - creds.password = p - return read.email('Email: (this IS public) ', creds.email, opts) - }).then((e) => { - creds.email = e - return creds - }) + + creds.username = await read.username('Username:', creds.username, opts) + creds.password = await read.password('Password:', creds.password) + creds.email = await read.email('Email: (this IS public) ', creds.email, opts) + + return creds } -module.exports.login = (creds = {}, registry, scope, cb) => { - const opts = { - ...npm.flatOptions, - scope, - registry, - creds +const login = async (opts) => { + let res + + const requestOTP = async () => { + const otp = await read.otp( + 'Enter one-time password from your authenticator app: ' + ) + + return profile.loginCouch( + opts.creds.username, + opts.creds.password, + { ...opts, otp } + ) } - login(opts).then((newCreds) => cb(null, newCreds)).catch(cb) -} -function login (opts) { - return profile.login(openerPromise, loginPrompter, opts) - .catch((err) => { - if (err.code === 'EOTP') throw err - const u = opts.creds.username - const p = opts.creds.password - const e = opts.creds.email - if (!(u && p && e)) throw err - return profile.adduserCouch(u, e, p, opts) - }) - .catch((err) => { - if (err.code !== 'EOTP') throw err - return read.otp( - 'Enter one-time password from your authenticator app: ' - ).then(otp => { - const u = opts.creds.username - const p = opts.creds.password - return profile.loginCouch(u, p, { ...opts, otp }) - }) - }).then((result) => { - const newCreds = {} - if (result && result.token) { - newCreds.token = result.token + const addNewUser = async () => { + let newUser + + try { + newUser = await profile.adduserCouch( + opts.creds.username, + opts.creds.email, + opts.creds.password, + opts + ) + } catch (err) { + if (err.code === 'EOTP') { + newUser = await requestOTP() } else { - newCreds.username = opts.creds.username - newCreds.password = opts.creds.password - newCreds.email = opts.creds.email - newCreds.alwaysAuth = opts.alwaysAuth + throw err } + } - const usermsg = opts.creds.username ? ' user ' + opts.creds.username : '' - opts.log.info('login', 'Authorized' + usermsg) - const scopeMessage = opts.scope ? ' to scope ' + opts.scope : '' - const userout = opts.creds.username ? ' as ' + opts.creds.username : '' - output('Logged in%s%s on %s.', userout, scopeMessage, opts.registry) - return newCreds - }) + return newUser + } + + try { + res = await profile.login(openerPromise, loginPrompter, opts) + } catch (err) { + const needsMoreInfo = !(opts && + opts.creds && + opts.creds.username && + opts.creds.password && + opts.creds.email) + if (err.code === 'EOTP') { + res = await requestOTP() + } else if (needsMoreInfo) { + throw err + } else { + // TODO: maybe this needs to check for err.code === 'E400' instead? + res = await addNewUser() + } + } + + const newCreds = {} + if (res && res.token) { + newCreds.token = res.token + } else { + newCreds.username = opts.creds.username + newCreds.password = opts.creds.password + newCreds.email = opts.creds.email + newCreds.alwaysAuth = opts.creds.alwaysAuth + } + + const usermsg = opts.creds.username ? ` user ${opts.creds.username}` : '' + const scopeMessage = opts.scope ? ` to scope ${opts.scope}` : '' + const userout = opts.creds.username ? ` as ${opts.creds.username}` : '' + const message = `Logged in${userout}${scopeMessage} on ${opts.registry}.` + + log.info('login', `Authorized${usermsg}`) + + return { + message, + newCreds + } } + +module.exports = login diff --git a/lib/auth/oauth.js b/lib/auth/oauth.js index 1cb3ffec6f97e..ee45317113421 100644 --- a/lib/auth/oauth.js +++ b/lib/auth/oauth.js @@ -1,7 +1,9 @@ -var ssoAuth = require('./sso') -var npm = require('../npm') +const sso = require('./sso.js') +const npm = require('../npm.js') -module.exports.login = function login () { +const login = (opts) => { npm.config.set('sso-type', 'oauth') - ssoAuth.login.apply(this, arguments) + return sso(opts) } + +module.exports = login diff --git a/lib/auth/saml.js b/lib/auth/saml.js index ae92ea5bbfb37..f30d82849dbf9 100644 --- a/lib/auth/saml.js +++ b/lib/auth/saml.js @@ -1,7 +1,9 @@ -var ssoAuth = require('./sso') -var npm = require('../npm') +const sso = require('./sso.js') +const npm = require('../npm.js') -module.exports.login = function login () { +const login = (opts) => { npm.config.set('sso-type', 'saml') - ssoAuth.login.apply(this, arguments) + return sso(opts) } + +module.exports = login diff --git a/lib/auth/sso.js b/lib/auth/sso.js index 82e11fe94ee4d..5444e9ca01243 100644 --- a/lib/auth/sso.js +++ b/lib/auth/sso.js @@ -9,47 +9,17 @@ // CLI, we can remove this, and fold the lib/auth/legacy.js back into // lib/adduser.js +const { promisify } = require('util') + const log = require('npmlog') -const npm = require('../npm.js') +const profile = require('npm-profile') const npmFetch = require('npm-registry-fetch') -const output = require('../utils/output.js') -const { promisify } = require('util') + +const npm = require('../npm.js') const openUrl = promisify(require('../utils/open-url.js')) const otplease = require('../utils/otplease.js') -const profile = require('npm-profile') - -module.exports.login = function login (creds, registry, scope, cb) { - log.warn('deprecated', 'SSO --auth-type is deprecated') - const opts = { ...npm.flatOptions, creds, registry, scope } - const ssoType = opts.ssoType - if (!ssoType) { return cb(new Error('Missing option: sso-type')) } - // We're reusing the legacy login endpoint, so we need some dummy - // stuff here to pass validation. They're never used. - const auth = { - username: 'npm_' + ssoType + '_auth_dummy_user', - password: 'placeholder', - email: 'support@npmjs.com', - authType: ssoType - } - - otplease(opts, - opts => profile.loginCouch(auth.username, auth.password, opts) - ).then(({ token, sso }) => { - if (!token) { throw new Error('no SSO token returned') } - if (!sso) { throw new Error('no SSO URL returned by services') } - return openUrl(sso, 'to complete your login please visit').then(() => { - return pollForSession(registry, token, opts) - }).then(username => { - log.info('adduser', 'Authorized user %s', username) - var scopeMessage = scope ? ' to scope ' + scope : '' - output('Logged in as %s%s on %s.', username, scopeMessage, registry) - return { token } - }) - }).then(res => cb(null, res), cb) -} - -function pollForSession (registry, token, opts) { +const pollForSession = ({ registry, token, opts }) => { log.info('adduser', 'Polling for validated SSO session') return npmFetch.json( '/-/whoami', { ...opts, registry, forceAuth: { token } } @@ -58,7 +28,7 @@ function pollForSession (registry, token, opts) { err => { if (err.code === 'E401') { return sleep(opts.ssoPollFrequency).then(() => { - return pollForSession(registry, token, opts) + return pollForSession({ registry, token, opts }) }) } else { throw err @@ -70,3 +40,46 @@ function pollForSession (registry, token, opts) { function sleep (time) { return new Promise((resolve) => setTimeout(resolve, time)) } + +const login = async ({ creds, registry, scope }) => { + log.warn('deprecated', 'SSO --auth-type is deprecated') + + const opts = { ...npm.flatOptions, creds, registry, scope } + const { ssoType } = opts + + if (!ssoType) { + throw new Error('Missing option: sso-type') + } + + // We're reusing the legacy login endpoint, so we need some dummy + // stuff here to pass validation. They're never used. + const auth = { + username: 'npm_' + ssoType + '_auth_dummy_user', + password: 'placeholder', + email: 'support@npmjs.com', + authType: ssoType + } + + const { token, sso } = await otplease(opts, + opts => profile.loginCouch(auth.username, auth.password, opts) + ) + + if (!token) { throw new Error('no SSO token returned') } + if (!sso) { throw new Error('no SSO URL returned by services') } + + await openUrl(sso, 'to complete your login please visit') + + const username = await pollForSession({ registry, token, opts }) + + log.info('adduser', `Authorized user ${username}`) + + const scopeMessage = scope ? ' to scope ' + scope : '' + const message = `Logged in as ${username}${scopeMessage} on ${registry}.` + + return { + message, + newCreds: { token } + } +} + +module.exports = login diff --git a/test/lib/adduser.js b/test/lib/adduser.js new file mode 100644 index 0000000000000..d6c153cebc878 --- /dev/null +++ b/test/lib/adduser.js @@ -0,0 +1,181 @@ +const requireInject = require('require-inject') +const { test } = require('tap') +const getCredentialsByURI = require('../../lib/config/get-credentials-by-uri.js') +const setCredentialsByURI = require('../../lib/config/set-credentials-by-uri.js') + +let result = '' + +const _flatOptions = { + authType: 'legacy', + registry: 'https://registry.npmjs.org/', + scope: '' +} + +let failSave = false +let deletedConfig = {} +let setConfig = {} +const authDummy = () => Promise.resolve({ + message: 'success', + newCreds: { + username: 'u', + password: 'p', + email: 'u@npmjs.org', + alwaysAuth: false + } +}) + +const adduser = requireInject('../../lib/adduser.js', { + npmlog: { + disableProgress: () => null + }, + '../../lib/npm.js': { + flatOptions: _flatOptions, + config: { + del (key, where) { + deletedConfig = { + ...deletedConfig, + [key]: where + } + }, + get (key, where) { + if (!where || where === 'user') { + return _flatOptions[key] + } + }, + getCredentialsByURI, + save (_, cb) { + if (failSave) { + return cb(new Error('error saving user config')) + } + cb() + }, + set (key, value, where) { + setConfig = { + ...setConfig, + [key]: { + value, + where + } + } + }, + setCredentialsByURI + } + }, + '../../lib/utils/output.js': msg => { result = msg }, + '../../lib/auth/legacy.js': authDummy +}) + +test('simple login', (t) => { + adduser([], (err) => { + t.ifError(err, 'npm adduser') + + t.deepEqual( + deletedConfig, + { + _token: 'user', + '//registry.npmjs.org/:_authToken': 'user' + }, + 'should delete token in user config' + ) + + t.deepEqual( + setConfig, + { + '//registry.npmjs.org/:_password': { value: 'cA==', where: 'user' }, + '//registry.npmjs.org/:username': { value: 'u', where: 'user' }, + '//registry.npmjs.org/:email': { value: 'u@npmjs.org', where: 'user' }, + '//registry.npmjs.org/:always-auth': { value: false, where: 'user' } + }, + 'should set expected user configs' + ) + + t.equal( + result, + 'success', + 'should output auth success msg' + ) + + deletedConfig = {} + setConfig = {} + result = '' + t.end() + }) +}) + +test('bad auth type', (t) => { + _flatOptions.authType = 'foo' + + adduser([], (err) => { + t.match( + err, + /Error: no such auth module/, + 'should throw bad auth type error' + ) + + _flatOptions.authType = 'legacy' + deletedConfig = {} + setConfig = {} + result = '' + t.end() + }) +}) + +test('scoped login', (t) => { + _flatOptions.scope = '@myscope' + + adduser([], (err) => { + t.ifError(err, 'npm adduser') + + t.deepEqual( + setConfig['@myscope:registry'], + { value: 'https://registry.npmjs.org/', where: 'user' }, + 'should set scoped registry config' + ) + + _flatOptions.scope = '' + deletedConfig = {} + setConfig = {} + result = '' + t.end() + }) +}) + +test('scoped login with valid scoped registry config', (t) => { + _flatOptions['@myscope:registry'] = 'https://diff-registry.npmjs.com/' + _flatOptions.scope = '@myscope' + + adduser([], (err) => { + t.ifError(err, 'npm adduser') + + t.deepEqual( + setConfig['@myscope:registry'], + { value: 'https://diff-registry.npmjs.com/', where: 'user' }, + 'should keep scoped registry config' + ) + + delete _flatOptions['@myscope:registry'] + _flatOptions.scope = '' + deletedConfig = {} + setConfig = {} + result = '' + t.end() + }) +}) + +test('save config failure', (t) => { + failSave = true + + adduser([], (err) => { + t.match( + err, + /error saving user config/, + 'should throw config.save error' + ) + + failSave = false + deletedConfig = {} + setConfig = {} + result = '' + t.end() + }) +}) diff --git a/test/lib/auth/legacy.js b/test/lib/auth/legacy.js new file mode 100644 index 0000000000000..1607641d8390e --- /dev/null +++ b/test/lib/auth/legacy.js @@ -0,0 +1,427 @@ +const requireInject = require('require-inject') +const { test } = require('tap') + +let log = '' + +const token = '24528a24f240' +const profile = {} +const read = {} +const legacy = requireInject('../../../lib/auth/legacy.js', { + npmlog: { + info: (...msgs) => { + log += msgs.join(' ') + } + }, + 'npm-profile': profile, + '../../../lib/utils/open-url.js': (url, msg, cb) => { + if (url) { + cb() + } else { + cb(Object.assign( + new Error('failed open url'), + { code: 'ERROR' } + )) + } + }, + '../../../lib/utils/read-user-info.js': read +}) + +test('login using username/password with token result', async (t) => { + profile.login = () => { + return { token } + } + + const { + message, + newCreds + } = await legacy({ + creds: { + username: 'u', + password: 'p', + email: 'u@npmjs.org', + alwaysAuth: false + }, + registry: 'https://registry.npmjs.org/', + scope: '' + }) + + t.equal( + message, + 'Logged in as u on https://registry.npmjs.org/.', + 'should have correct message result' + ) + + t.equal( + log, + 'login Authorized user u', + 'should have correct message result' + ) + + t.deepEqual( + newCreds, + { token }, + 'should return expected obj from profile.login' + ) + + log = '' + delete profile.login +}) + +test('login using username/password with user info result', async (t) => { + profile.login = () => { + return null + } + + const { + message, + newCreds + } = await legacy({ + creds: { + username: 'u', + password: 'p', + email: 'u@npmjs.org', + alwaysAuth: false + }, + registry: 'https://registry.npmjs.org/', + scope: '' + }) + + t.equal( + message, + 'Logged in as u on https://registry.npmjs.org/.', + 'should have correct message result' + ) + + t.deepEqual( + newCreds, + { + username: 'u', + password: 'p', + email: 'u@npmjs.org', + alwaysAuth: false + }, + 'should return used credentials' + ) + + log = '' + delete profile.login +}) + +test('login otp requested', async (t) => { + t.plan(5) + + profile.login = () => Promise.reject(Object.assign( + new Error('needs otp'), + { code: 'EOTP' } + )) + profile.loginCouch = (username, password, { otp }) => { + t.equal(username, 'u', 'should use provided username to loginCouch') + t.equal(password, 'p', 'should use provided password to loginCouch') + t.equal(otp, '1234', 'should use provided otp code to loginCouch') + + return { token } + } + read.otp = () => Promise.resolve('1234') + + const { + message, + newCreds + } = await legacy({ + creds: { + username: 'u', + password: 'p', + email: 'u@npmjs.org', + alwaysAuth: false + }, + registry: 'https://registry.npmjs.org/', + scope: '' + }) + + t.equal( + message, + 'Logged in as u on https://registry.npmjs.org/.', + 'should have correct message result' + ) + + t.deepEqual( + newCreds, + { token }, + 'should return token from loginCouch result' + ) + + log = '' + delete profile.login + delete profile.loginCouch + delete read.otp +}) + +test('login missing basic credential info', async (t) => { + profile.login = () => Promise.reject(Object.assign( + new Error('missing info'), + { code: 'ERROR' } + )) + + await t.rejects( + legacy({ + creds: { + username: 'u', + password: 'p' + }, + registry: 'https://registry.npmjs.org/', + scope: '' + }), + { code: 'ERROR' }, + 'should throw server response error' + ) + + log = '' + delete profile.login +}) + +test('create new user when user not found', async (t) => { + t.plan(6) + + profile.login = () => Promise.reject(Object.assign( + new Error('User does not exist'), + { code: 'ERROR' } + )) + profile.adduserCouch = (username, email, password) => { + t.equal(username, 'u', 'should use provided username to adduserCouch') + t.equal(email, 'u@npmjs.org', 'should use provided email to adduserCouch') + t.equal(password, 'p', 'should use provided password to adduserCouch') + + return { token } + } + + const { + message, + newCreds + } = await legacy({ + creds: { + username: 'u', + password: 'p', + email: 'u@npmjs.org', + alwaysAuth: false + }, + registry: 'https://registry.npmjs.org/', + scope: '' + }) + + t.equal( + message, + 'Logged in as u on https://registry.npmjs.org/.', + 'should have correct message result' + ) + + t.equal( + log, + 'login Authorized user u', + 'should have correct message result' + ) + + t.deepEqual( + newCreds, + { token }, + 'should return expected obj from profile.login' + ) + + log = '' + delete profile.adduserCouch + delete profile.login +}) + +test('prompts for user info if required', async (t) => { + t.plan(4) + + profile.login = async (opener, prompt, opts) => { + t.equal(opts.creds.alwaysAuth, true, 'should have refs to creds if any') + await opener('https://registry.npmjs.org/-/v1/login') + const creds = await prompt(opts.creds) + return creds + } + read.username = () => Promise.resolve('foo') + read.password = () => Promise.resolve('pass') + read.email = () => Promise.resolve('foo@npmjs.org') + + const { + message, + newCreds + } = await legacy({ + creds: { + alwaysAuth: true + }, + registry: 'https://registry.npmjs.org/', + scope: '' + }) + + t.equal( + message, + 'Logged in as foo on https://registry.npmjs.org/.', + 'should have correct message result' + ) + + t.equal( + log, + 'login Authorized user foo', + 'should have correct message result' + ) + + t.deepEqual( + newCreds, + { + username: 'foo', + password: 'pass', + email: 'foo@npmjs.org', + alwaysAuth: true + }, + 'should return result from profile.login containing prompt info' + ) + + log = '' + delete profile.login + delete read.username + delete read.password + delete read.email +}) + +test('request otp when creating new user', async (t) => { + t.plan(3) + + profile.login = () => Promise.reject(Object.assign( + new Error('User does not exist'), + { code: 'ERROR' } + )) + profile.adduserCouch = () => Promise.reject(Object.assign( + new Error('needs otp'), + { code: 'EOTP' } + )) + profile.loginCouch = (username, password, { otp }) => { + t.equal(username, 'u', 'should use provided username to loginCouch') + t.equal(password, 'p', 'should use provided password to loginCouch') + t.equal(otp, '1234', 'should now use provided otp code to loginCouch') + + return { token } + } + read.otp = () => Promise.resolve('1234') + + await legacy({ + creds: { + username: 'u', + password: 'p', + email: 'u@npmjs.org', + alwaysAuth: false + }, + registry: 'https://registry.npmjs.org/', + scope: '' + }) + + log = '' + delete profile.adduserCouch + delete profile.login + delete profile.loginCouch + delete read.otp +}) + +test('unknown error during user creation', async (t) => { + profile.login = () => Promise.reject(Object.assign( + new Error('missing info'), + { code: 'ERROR' } + )) + profile.adduserCouch = () => Promise.reject(Object.assign( + new Error('unkown error'), + { code: 'ERROR' } + )) + + await t.rejects( + legacy({ + creds: { + username: 'u', + password: 'p', + email: 'u@npmjs.org', + alwaysAuth: false + }, + registry: 'https://registry.npmjs.org/', + scope: '' + }), + { code: 'ERROR' }, + 'should throw unknown error' + ) + + log = '' + delete profile.adduserCouch + delete profile.login +}) + +test('open url error', async (t) => { + profile.login = async (opener, prompt, opts) => { await opener() } + + await t.rejects( + legacy({ + creds: { + username: 'u', + password: 'p' + }, + registry: 'https://registry.npmjs.org/', + scope: '' + }), + { message: 'failed open url', code: 'ERROR' }, + 'should throw unknown error' + ) + + log = '' + delete profile.login +}) + +test('login no credentials provided', async (t) => { + profile.login = () => ({ token }) + + await legacy({ + creds: { + username: undefined, + password: undefined, + email: undefined, + alwaysAuth: undefined + }, + registry: 'https://registry.npmjs.org/', + scope: '' + }) + + t.equal( + log, + 'login Authorized', + 'should have correct message result' + ) + + log = '' + delete profile.login +}) + +test('scoped login', async (t) => { + profile.login = () => ({ token }) + + const { message } = await legacy({ + creds: { + username: 'u', + password: 'p', + email: 'u@npmjs.org', + alwaysAuth: false + }, + registry: 'https://diff-registry.npmjs.org/', + scope: 'myscope' + }) + + t.equal( + message, + 'Logged in as u to scope myscope on https://diff-registry.npmjs.org/.', + 'should have correct message result' + ) + + t.equal( + log, + 'login Authorized user u', + 'should have correct message result' + ) + + log = '' + delete profile.login +}) diff --git a/test/lib/auth/oauth.js b/test/lib/auth/oauth.js new file mode 100644 index 0000000000000..a8461d235e5e5 --- /dev/null +++ b/test/lib/auth/oauth.js @@ -0,0 +1,29 @@ +const requireInject = require('require-inject') +const { test } = require('tap') + +test('oauth login', (t) => { + t.plan(3) + const oauthOpts = { + creds: {}, + registry: 'https://diff-registry.npmjs.org/', + scope: 'myscope' + } + + const oauth = requireInject('../../../lib/auth/oauth.js', { + '../../../lib/auth/sso.js': (opts) => { + t.equal(opts, oauthOpts, 'should forward opts') + }, + '../../../lib/npm.js': { + config: { + set: (key, value) => { + t.equal(key, 'sso-type', 'should define sso-type') + t.equal(value, 'oauth', 'should set sso-type to oauth') + } + } + } + }) + + oauth(oauthOpts) + + t.end() +}) diff --git a/test/lib/auth/saml.js b/test/lib/auth/saml.js new file mode 100644 index 0000000000000..3e0015bf39be3 --- /dev/null +++ b/test/lib/auth/saml.js @@ -0,0 +1,29 @@ +const requireInject = require('require-inject') +const { test } = require('tap') + +test('saml login', (t) => { + t.plan(3) + const samlOpts = { + creds: {}, + registry: 'https://diff-registry.npmjs.org/', + scope: 'myscope' + } + + const saml = requireInject('../../../lib/auth/saml.js', { + '../../../lib/auth/sso.js': (opts) => { + t.equal(opts, samlOpts, 'should forward opts') + }, + '../../../lib/npm.js': { + config: { + set: (key, value) => { + t.equal(key, 'sso-type', 'should define sso-type') + t.equal(value, 'saml', 'should set sso-type to saml') + } + } + } + }) + + saml(samlOpts) + + t.end() +}) diff --git a/test/lib/auth/sso.js b/test/lib/auth/sso.js new file mode 100644 index 0000000000000..0e04309c82bf7 --- /dev/null +++ b/test/lib/auth/sso.js @@ -0,0 +1,266 @@ +const requireInject = require('require-inject') +const { test } = require('tap') + +let log = '' +let warn = '' + +const _flatOptions = { + ssoType: 'oauth' +} +const token = '24528a24f240' +const SSO_URL = 'https://registry.npmjs.org/{SSO_URL}' +const profile = {} +const npmFetch = {} +const sso = requireInject('../../../lib/auth/sso.js', { + npmlog: { + info: (...msgs) => { + log += msgs.join(' ') + '\n' + }, + warn: (...msgs) => { + warn += msgs.join(' ') + } + }, + 'npm-profile': profile, + 'npm-registry-fetch': npmFetch, + '../../../lib/npm.js': { + flatOptions: _flatOptions + }, + '../../../lib/utils/open-url.js': (url, msg, cb) => { + if (url) { + cb() + } else { + cb(Object.assign( + new Error('failed open url'), + { code: 'ERROR' } + )) + } + }, + '../../../lib/utils/otplease.js': (opts, fn) => { + if (opts) { + return fn({ ...opts, otp: '1234' }) + } else { + throw Object.assign( + new Error('failed retrieving otp'), + { code: 'ERROR' } + ) + } + } +}) + +test('empty login', async (t) => { + _flatOptions.ssoType = false + + await t.rejects( + sso({}), + { message: 'Missing option: sso-type' }, + 'should throw if no sso-type defined in flatOptions' + ) + + t.equal( + warn, + 'deprecated SSO --auth-type is deprecated', + 'should print deprecation warning' + ) + + _flatOptions.ssoType = 'oauth' + log = '' + warn = '' +}) + +test('simple login', async (t) => { + t.plan(6) + + profile.loginCouch = (username, password, opts) => { + t.equal(username, 'npm_oauth_auth_dummy_user', 'should use dummy user') + t.equal(password, 'placeholder', 'should use dummy password') + t.deepEqual( + opts, + { + creds: {}, + otp: '1234', + registry: 'https://registry.npmjs.org/', + scope: '', + ssoType: 'oauth' + }, + 'should use dummy password' + ) + + return { token, sso: SSO_URL } + } + npmFetch.json = () => Promise.resolve({ username: 'foo' }) + + const { + message, + newCreds + } = await sso({ + creds: {}, + registry: 'https://registry.npmjs.org/', + scope: '' + }) + + t.equal( + message, + 'Logged in as foo on https://registry.npmjs.org/.', + 'should have correct message result' + ) + + t.equal( + log, + 'adduser Polling for validated SSO session\nadduser Authorized user foo\n', + 'should have correct logged info msg' + ) + + t.deepEqual( + newCreds, + { token }, + 'should return expected resulting credentials' + ) + + log = '' + warn = '' + delete profile.loginCouch + delete npmFetch.json +}) + +test('polling retry', async (t) => { + t.plan(3) + + profile.loginCouch = () => ({ token, sso: SSO_URL }) + npmFetch.json = () => { + // assert expected values during retry + npmFetch.json = (url, { registry, forceAuth: { token: expected } }) => { + t.equal( + url, + '/-/whoami', + 'should reach for expected endpoint' + ) + + t.equal( + registry, + 'https://registry.npmjs.org/', + 'should use expected registry value' + ) + + t.equal( + expected, + token, + 'should use expected token retrieved from initial loginCouch' + ) + + return Promise.resolve({ username: 'foo' }) + } + + // initial fetch returns retry code + return Promise.reject(Object.assign( + new Error('nothing yet'), + { code: 'E401' } + )) + } + + await sso({ + creds: {}, + registry: 'https://registry.npmjs.org/', + scope: '' + }) + + log = '' + warn = '' + delete profile.loginCouch + delete npmFetch.json +}) + +test('polling error', async (t) => { + profile.loginCouch = () => ({ token, sso: SSO_URL }) + npmFetch.json = () => Promise.reject(Object.assign( + new Error('unknown error'), + { code: 'ERROR' } + )) + + await t.rejects( + sso({ + creds: {}, + registry: 'https://registry.npmjs.org/', + scope: '' + }), + { message: 'unknown error', code: 'ERROR' }, + 'should throw unknown error' + ) + + log = '' + warn = '' + delete profile.loginCouch + delete npmFetch.json +}) + +test('no token retrieved from loginCouch', async (t) => { + profile.loginCouch = () => ({}) + + await t.rejects( + sso({ + creds: {}, + registry: 'https://registry.npmjs.org/', + scope: '' + }), + { message: 'no SSO token returned' }, + 'should throw no SSO token returned error' + ) + + log = '' + warn = '' + delete profile.loginCouch +}) + +test('no sso url retrieved from loginCouch', async (t) => { + profile.loginCouch = () => Promise.resolve({ token }) + + await t.rejects( + sso({ + creds: {}, + registry: 'https://registry.npmjs.org/', + scope: '' + }), + { message: 'no SSO URL returned by services' }, + 'should throw no SSO url returned error' + ) + + log = '' + warn = '' + delete profile.loginCouch +}) + +test('scoped login', async (t) => { + profile.loginCouch = () => ({ token, sso: SSO_URL }) + npmFetch.json = () => Promise.resolve({ username: 'foo' }) + + const { + message, + newCreds + } = await sso({ + creds: {}, + registry: 'https://diff-registry.npmjs.org/', + scope: 'myscope' + }) + + t.equal( + message, + 'Logged in as foo to scope myscope on https://diff-registry.npmjs.org/.', + 'should have correct message result' + ) + + t.equal( + log, + 'adduser Polling for validated SSO session\nadduser Authorized user foo\n', + 'should have correct logged info msg' + ) + + t.deepEqual( + newCreds, + { token }, + 'should return expected resulting credentials' + ) + + log = '' + warn = '' + delete profile.loginCouch + delete npmFetch.json +})