1const Table = require('cli-table3')
2const log = require('../utils/log-shim.js')
3const profile = require('npm-profile')
4
5const otplease = require('../utils/otplease.js')
6const pulseTillDone = require('../utils/pulse-till-done.js')
7const readUserInfo = require('../utils/read-user-info.js')
8
9const BaseCommand = require('../base-command.js')
10class Token extends BaseCommand {
11  static description = 'Manage your authentication tokens'
12  static name = 'token'
13  static usage = ['list', 'revoke <id|token>', 'create [--read-only] [--cidr=list]']
14  static params = ['read-only', 'cidr', 'registry', 'otp']
15
16  static async completion (opts) {
17    const argv = opts.conf.argv.remain
18    const subcommands = ['list', 'revoke', 'create']
19    if (argv.length === 2) {
20      return subcommands
21    }
22
23    if (subcommands.includes(argv[2])) {
24      return []
25    }
26
27    throw new Error(argv[2] + ' not recognized')
28  }
29
30  async exec (args) {
31    log.gauge.show('token')
32    if (args.length === 0) {
33      return this.list()
34    }
35    switch (args[0]) {
36      case 'list':
37      case 'ls':
38        return this.list()
39      case 'delete':
40      case 'revoke':
41      case 'remove':
42      case 'rm':
43        return this.rm(args.slice(1))
44      case 'create':
45        return this.create(args.slice(1))
46      default:
47        throw this.usageError(`${args[0]} is not a recognized subcommand.`)
48    }
49  }
50
51  async list () {
52    const conf = this.config()
53    log.info('token', 'getting list')
54    const tokens = await pulseTillDone.withPromise(profile.listTokens(conf))
55    if (conf.json) {
56      this.npm.output(JSON.stringify(tokens, null, 2))
57      return
58    } else if (conf.parseable) {
59      this.npm.output(['key', 'token', 'created', 'readonly', 'CIDR whitelist'].join('\t'))
60      tokens.forEach(token => {
61        this.npm.output(
62          [
63            token.key,
64            token.token,
65            token.created,
66            token.readonly ? 'true' : 'false',
67            token.cidr_whitelist ? token.cidr_whitelist.join(',') : '',
68          ].join('\t')
69        )
70      })
71      return
72    }
73    this.generateTokenIds(tokens, 6)
74    const idWidth = tokens.reduce((acc, token) => Math.max(acc, token.id.length), 0)
75    const table = new Table({
76      head: ['id', 'token', 'created', 'readonly', 'CIDR whitelist'],
77      colWidths: [Math.max(idWidth, 2) + 2, 9, 12, 10],
78    })
79    tokens.forEach(token => {
80      table.push([
81        token.id,
82        token.token + '…',
83        String(token.created).slice(0, 10),
84        token.readonly ? 'yes' : 'no',
85        token.cidr_whitelist ? token.cidr_whitelist.join(', ') : '',
86      ])
87    })
88    this.npm.output(table.toString())
89  }
90
91  async rm (args) {
92    if (args.length === 0) {
93      throw this.usageError('`<tokenKey>` argument is required.')
94    }
95
96    const conf = this.config()
97    const toRemove = []
98    const progress = log.newItem('removing tokens', toRemove.length)
99    progress.info('token', 'getting existing list')
100    const tokens = await pulseTillDone.withPromise(profile.listTokens(conf))
101    args.forEach(id => {
102      const matches = tokens.filter(token => token.key.indexOf(id) === 0)
103      if (matches.length === 1) {
104        toRemove.push(matches[0].key)
105      } else if (matches.length > 1) {
106        throw new Error(
107          /* eslint-disable-next-line max-len */
108          `Token ID "${id}" was ambiguous, a new token may have been created since you last ran \`npm token list\`.`
109        )
110      } else {
111        const tokenMatches = tokens.some(t => id.indexOf(t.token) === 0)
112        if (!tokenMatches) {
113          throw new Error(`Unknown token id or value "${id}".`)
114        }
115
116        toRemove.push(id)
117      }
118    })
119    await Promise.all(
120      toRemove.map(key => {
121        return otplease(this.npm, conf, c => profile.removeToken(key, c))
122      })
123    )
124    if (conf.json) {
125      this.npm.output(JSON.stringify(toRemove))
126    } else if (conf.parseable) {
127      this.npm.output(toRemove.join('\t'))
128    } else {
129      this.npm.output('Removed ' + toRemove.length + ' token' + (toRemove.length !== 1 ? 's' : ''))
130    }
131  }
132
133  async create (args) {
134    const conf = this.config()
135    const cidr = conf.cidr
136    const readonly = conf.readOnly
137
138    const password = await readUserInfo.password()
139    const validCIDR = await this.validateCIDRList(cidr)
140    log.info('token', 'creating')
141    const result = await pulseTillDone.withPromise(
142      otplease(this.npm, conf, c => profile.createToken(password, readonly, validCIDR, c))
143    )
144    delete result.key
145    delete result.updated
146    if (conf.json) {
147      this.npm.output(JSON.stringify(result))
148    } else if (conf.parseable) {
149      Object.keys(result).forEach(k => this.npm.output(k + '\t' + result[k]))
150    } else {
151      const table = new Table()
152      for (const k of Object.keys(result)) {
153        table.push({ [this.npm.chalk.bold(k)]: String(result[k]) })
154      }
155      this.npm.output(table.toString())
156    }
157  }
158
159  config () {
160    const conf = { ...this.npm.flatOptions }
161    const creds = this.npm.config.getCredentialsByURI(conf.registry)
162    if (creds.token) {
163      conf.auth = { token: creds.token }
164    } else if (creds.username) {
165      conf.auth = {
166        basic: {
167          username: creds.username,
168          password: creds.password,
169        },
170      }
171    } else if (creds.auth) {
172      const auth = Buffer.from(creds.auth, 'base64').toString().split(':', 2)
173      conf.auth = {
174        basic: {
175          username: auth[0],
176          password: auth[1],
177        },
178      }
179    } else {
180      conf.auth = {}
181    }
182
183    if (conf.otp) {
184      conf.auth.otp = conf.otp
185    }
186    return conf
187  }
188
189  invalidCIDRError (msg) {
190    return Object.assign(new Error(msg), { code: 'EINVALIDCIDR' })
191  }
192
193  generateTokenIds (tokens, minLength) {
194    const byId = {}
195    for (const token of tokens) {
196      token.id = token.key
197      for (let ii = minLength; ii < token.key.length; ++ii) {
198        const match = tokens.some(
199          ot => ot !== token && ot.key.slice(0, ii) === token.key.slice(0, ii)
200        )
201        if (!match) {
202          token.id = token.key.slice(0, ii)
203          break
204        }
205      }
206      byId[token.id] = token
207    }
208    return byId
209  }
210
211  async validateCIDRList (cidrs) {
212    const { v4: isCidrV4, v6: isCidrV6 } = await import('is-cidr')
213    const maybeList = [].concat(cidrs).filter(Boolean)
214    const list = maybeList.length === 1 ? maybeList[0].split(/,\s*/) : maybeList
215    for (const cidr of list) {
216      if (isCidrV6(cidr)) {
217        throw this.invalidCIDRError(
218          'CIDR whitelist can only contain IPv4 addresses, ' + cidr + ' is IPv6'
219        )
220      }
221
222      if (!isCidrV4(cidr)) {
223        throw this.invalidCIDRError('CIDR whitelist contains invalid CIDR entry: ' + cidr)
224      }
225    }
226    return list
227  }
228}
229module.exports = Token
230