1'use strict' 2 3const fetch = require('npm-registry-fetch') 4const { HttpErrorBase } = require('npm-registry-fetch/errors.js') 5const os = require('os') 6const pudding = require('figgy-pudding') 7const validate = require('aproba') 8 9exports.adduserCouch = adduserCouch 10exports.loginCouch = loginCouch 11exports.adduserWeb = adduserWeb 12exports.loginWeb = loginWeb 13exports.login = login 14exports.adduser = adduser 15exports.get = get 16exports.set = set 17exports.listTokens = listTokens 18exports.removeToken = removeToken 19exports.createToken = createToken 20 21const url = require('url') 22 23const isValidUrl = u => { 24 if (u && typeof u === 'string') { 25 const p = url.parse(u) 26 return p.slashes && p.host && p.path && /^https?:$/.test(p.protocol) 27 } 28 return false 29} 30 31const ProfileConfig = pudding({ 32 creds: {}, 33 hostname: {}, 34 otp: {} 35}) 36 37// try loginWeb, catch the "not supported" message and fall back to couch 38function login (opener, prompter, opts) { 39 validate('FFO', arguments) 40 opts = ProfileConfig(opts) 41 return loginWeb(opener, opts).catch(er => { 42 if (er instanceof WebLoginNotSupported) { 43 process.emit('log', 'verbose', 'web login not supported, trying couch') 44 return prompter(opts.creds) 45 .then(data => loginCouch(data.username, data.password, opts)) 46 } else { 47 throw er 48 } 49 }) 50} 51 52function adduser (opener, prompter, opts) { 53 validate('FFO', arguments) 54 opts = ProfileConfig(opts) 55 return adduserWeb(opener, opts).catch(er => { 56 if (er instanceof WebLoginNotSupported) { 57 process.emit('log', 'verbose', 'web adduser not supported, trying couch') 58 return prompter(opts.creds) 59 .then(data => adduserCouch(data.username, data.email, data.password, opts)) 60 } else { 61 throw er 62 } 63 }) 64} 65 66function adduserWeb (opener, opts) { 67 validate('FO', arguments) 68 const body = { create: true } 69 process.emit('log', 'verbose', 'web adduser', 'before first POST') 70 return webAuth(opener, opts, body) 71} 72 73function loginWeb (opener, opts) { 74 validate('FO', arguments) 75 process.emit('log', 'verbose', 'web login', 'before first POST') 76 return webAuth(opener, opts, {}) 77} 78 79function webAuth (opener, opts, body) { 80 opts = ProfileConfig(opts) 81 body.hostname = opts.hostname || os.hostname() 82 const target = '/-/v1/login' 83 return fetch(target, opts.concat({ 84 method: 'POST', 85 body 86 })).then(res => { 87 return Promise.all([res, res.json()]) 88 }).then(([res, content]) => { 89 const { doneUrl, loginUrl } = content 90 process.emit('log', 'verbose', 'web auth', 'got response', content) 91 if (!isValidUrl(doneUrl) || !isValidUrl(loginUrl)) { 92 throw new WebLoginInvalidResponse('POST', res, content) 93 } 94 return content 95 }).then(({ doneUrl, loginUrl }) => { 96 process.emit('log', 'verbose', 'web auth', 'opening url pair') 97 return opener(loginUrl).then( 98 () => webAuthCheckLogin(doneUrl, opts.concat({ cache: false })) 99 ) 100 }).catch(er => { 101 if ((er.statusCode >= 400 && er.statusCode <= 499) || er.statusCode === 500) { 102 throw new WebLoginNotSupported('POST', { 103 status: er.statusCode, 104 headers: { raw: () => er.headers } 105 }, er.body) 106 } else { 107 throw er 108 } 109 }) 110} 111 112function webAuthCheckLogin (doneUrl, opts) { 113 return fetch(doneUrl, opts).then(res => { 114 return Promise.all([res, res.json()]) 115 }).then(([res, content]) => { 116 if (res.status === 200) { 117 if (!content.token) { 118 throw new WebLoginInvalidResponse('GET', res, content) 119 } else { 120 return content 121 } 122 } else if (res.status === 202) { 123 const retry = +res.headers.get('retry-after') * 1000 124 if (retry > 0) { 125 return sleep(retry).then(() => webAuthCheckLogin(doneUrl, opts)) 126 } else { 127 return webAuthCheckLogin(doneUrl, opts) 128 } 129 } else { 130 throw new WebLoginInvalidResponse('GET', res, content) 131 } 132 }) 133} 134 135function adduserCouch (username, email, password, opts) { 136 validate('SSSO', arguments) 137 opts = ProfileConfig(opts) 138 const body = { 139 _id: 'org.couchdb.user:' + username, 140 name: username, 141 password: password, 142 email: email, 143 type: 'user', 144 roles: [], 145 date: new Date().toISOString() 146 } 147 const logObj = {} 148 Object.keys(body).forEach(k => { 149 logObj[k] = k === 'password' ? 'XXXXX' : body[k] 150 }) 151 process.emit('log', 'verbose', 'adduser', 'before first PUT', logObj) 152 153 const target = '/-/user/org.couchdb.user:' + encodeURIComponent(username) 154 return fetch.json(target, opts.concat({ 155 method: 'PUT', 156 body 157 })).then(result => { 158 result.username = username 159 return result 160 }) 161} 162 163function loginCouch (username, password, opts) { 164 validate('SSO', arguments) 165 opts = ProfileConfig(opts) 166 const body = { 167 _id: 'org.couchdb.user:' + username, 168 name: username, 169 password: password, 170 type: 'user', 171 roles: [], 172 date: new Date().toISOString() 173 } 174 const logObj = {} 175 Object.keys(body).forEach(k => { 176 logObj[k] = k === 'password' ? 'XXXXX' : body[k] 177 }) 178 process.emit('log', 'verbose', 'login', 'before first PUT', logObj) 179 180 const target = '-/user/org.couchdb.user:' + encodeURIComponent(username) 181 return fetch.json(target, opts.concat({ 182 method: 'PUT', 183 body 184 })).catch(err => { 185 if (err.code === 'E400') { 186 err.message = `There is no user with the username "${username}".` 187 throw err 188 } 189 if (err.code !== 'E409') throw err 190 return fetch.json(target, opts.concat({ 191 query: { write: true } 192 })).then(result => { 193 Object.keys(result).forEach(function (k) { 194 if (!body[k] || k === 'roles') { 195 body[k] = result[k] 196 } 197 }) 198 return fetch.json(`${target}/-rev/${body._rev}`, opts.concat({ 199 method: 'PUT', 200 body, 201 forceAuth: { 202 username, 203 password: Buffer.from(password, 'utf8').toString('base64'), 204 otp: opts.otp 205 } 206 })) 207 }) 208 }).then(result => { 209 result.username = username 210 return result 211 }) 212} 213 214function get (opts) { 215 validate('O', arguments) 216 return fetch.json('/-/npm/v1/user', opts) 217} 218 219function set (profile, opts) { 220 validate('OO', arguments) 221 Object.keys(profile).forEach(key => { 222 // profile keys can't be empty strings, but they CAN be null 223 if (profile[key] === '') profile[key] = null 224 }) 225 return fetch.json('/-/npm/v1/user', ProfileConfig(opts, { 226 method: 'POST', 227 body: profile 228 })) 229} 230 231function listTokens (opts) { 232 validate('O', arguments) 233 opts = ProfileConfig(opts) 234 235 return untilLastPage('/-/npm/v1/tokens') 236 237 function untilLastPage (href, objects) { 238 return fetch.json(href, opts).then(result => { 239 objects = objects ? objects.concat(result.objects) : result.objects 240 if (result.urls.next) { 241 return untilLastPage(result.urls.next, objects) 242 } else { 243 return objects 244 } 245 }) 246 } 247} 248 249function removeToken (tokenKey, opts) { 250 validate('SO', arguments) 251 const target = `/-/npm/v1/tokens/token/${tokenKey}` 252 return fetch(target, ProfileConfig(opts, { 253 method: 'DELETE', 254 ignoreBody: true 255 })).then(() => null) 256} 257 258function createToken (password, readonly, cidrs, opts) { 259 validate('SBAO', arguments) 260 return fetch.json('/-/npm/v1/tokens', ProfileConfig(opts, { 261 method: 'POST', 262 body: { 263 password: password, 264 readonly: readonly, 265 cidr_whitelist: cidrs 266 } 267 })) 268} 269 270class WebLoginInvalidResponse extends HttpErrorBase { 271 constructor (method, res, body) { 272 super(method, res, body) 273 this.message = 'Invalid response from web login endpoint' 274 Error.captureStackTrace(this, WebLoginInvalidResponse) 275 } 276} 277 278class WebLoginNotSupported extends HttpErrorBase { 279 constructor (method, res, body) { 280 super(method, res, body) 281 this.message = 'Web login not supported' 282 this.code = 'ENYI' 283 Error.captureStackTrace(this, WebLoginNotSupported) 284 } 285} 286 287function sleep (ms) { 288 return new Promise((resolve, reject) => setTimeout(resolve, ms)) 289} 290