index.js 7.7 KB

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