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