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