• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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