codekingpro/portable-devtools
114k
1const { log, output, META } = require('proc-log')
2const fetch = require('npm-registry-fetch')
3const { otplease } = require('../utils/auth.js')
4const readUserInfo = require('../utils/read-user-info.js')
5const BaseCommand = require('../base-cmd.js')
6
7async function paginate (href, opts, items = []) {
8 while (href) {
9 const result = await fetch.json(href, opts)
10 items = items.concat(result.objects)
11 href = result.urls.next
12 }
13 return items
14}
15
16class Token extends BaseCommand {
17 static description = 'Manage your authentication tokens'
18 static name = 'token'
19 static usage = ['list', 'revoke <id|token>', 'create']
20 static params = ['name',
21 'token-description',
22 'expires',
23 'packages',
24 'packages-all',
25 'scopes',
26 'orgs',
27 'packages-and-scopes-permission',
28 'orgs-permission',
29 'cidr',
30 'bypass-2fa',
31 'password',
32 'registry',
33 'otp',
34 'read-only',
35 ]
36
37 static async completion (opts) {
38 const argv = opts.conf.argv.remain
39 const subcommands = ['list', 'revoke', 'create']
40 if (argv.length === 2) {
41 return subcommands
42 }
43
44 if (subcommands.includes(argv[2])) {
45 return []
46 }
47
48 throw new Error(argv[2] + ' not recognized')
49 }
50
51 async exec (args) {
52 if (args.length === 0) {
53 return this.list()
54 }
55 switch (args[0]) {
56 case 'list':
57 case 'ls':
58 return this.list()
59 case 'rm':
60 case 'delete':
61 case 'revoke':
62 case 'remove':
63 return this.rm(args.slice(1))
64 case 'create':
65 return this.create(args.slice(1))
66 default:
67 throw this.usageError(`${args[0]} is not a recognized subcommand.`)
68 }
69 }
70
71 async list () {
72 const json = this.npm.config.get('json')
73 const parseable = this.npm.config.get('parseable')
74 log.info('token', 'getting list')
75 const tokens = await paginate('/-/npm/v1/tokens', this.npm.flatOptions)
76 if (json) {
77 output.buffer(tokens)
78 return
79 }
80 if (parseable) {
81 output.standard(['key', 'token', 'created', 'readonly', 'CIDR whitelist'].join('\t'))
82 tokens.forEach(token => {
83 output.standard(
84 [
85 token.key,
86 token.token,
87 token.created,
88 token.readonly ? 'true' : 'false',
89 token.cidr_whitelist ? token.cidr_whitelist.join(',') : '',
90 ].join('\t')
91 )
92 })
93 return
94 }
95 this.generateTokenIds(tokens, 6)
96 const chalk = this.npm.chalk
97 for (const token of tokens) {
98 const created = String(token.created).slice(0, 10)
99 output.standard(`${chalk.blue('Token')} ${token.token}… with id ${chalk.cyan(token.id)} created ${created}`)
100 if (token.cidr_whitelist) {
101 output.standard(`with IP whitelist: ${chalk.green(token.cidr_whitelist.join(','))}`)
102 }
103 output.standard()
104 }
105 }
106
107 async rm (args) {
108 if (args.length === 0) {
109 throw this.usageError('`<tokenKey>` argument is required.')
110 }
111
112 const json = this.npm.config.get('json')
113 const parseable = this.npm.config.get('parseable')
114 const toRemove = []
115 log.info('token', `removing ${toRemove.length} tokens`)
116 const tokens = await paginate('/-/npm/v1/tokens', this.npm.flatOptions)
117 for (const id of args) {
118 const matches = tokens.filter(token => token.key.indexOf(id) === 0)
119 if (matches.length === 1) {
120 toRemove.push(matches[0].key)
121 } else if (matches.length > 1) {
122 throw new Error(
123 `Token ID "${id}" was ambiguous, a new token may have been created since you last ran \`npm token list\`.`
124 )
125 } else {
126 const tokenMatches = tokens.some(t => id.indexOf(t.token) === 0)
127 if (!tokenMatches) {
128 throw new Error(`Unknown token id or value "${id}".`)
129 }
130
131 toRemove.push(id)
132 }
133 }
134 for (const tokenKey of toRemove) {
135 await otplease(this.npm, this.npm.flatOptions, opts =>
136 fetch(`/-/npm/v1/tokens/token/${tokenKey}`, {
137 ...opts,
138 method: 'DELETE',
139 ignoreBody: true,
140 })
141 )
142 }
143 if (json) {
144 output.buffer(toRemove)
145 } else if (parseable) {
146 output.standard(toRemove.join('\t'))
147 } else {
148 output.standard('Removed ' + toRemove.length + ' token' + (toRemove.length !== 1 ? 's' : ''))
149 }
150 }
151
152 async create () {
153 const json = this.npm.config.get('json')
154 const parseable = this.npm.config.get('parseable')
155 const cidr = this.npm.config.get('cidr')
156 const name = this.npm.config.get('name')
157 const tokenDescription = this.npm.config.get('token-description')
158 const expires = this.npm.config.get('expires')
159 const packages = this.npm.config.get('packages')
160 const packagesAll = this.npm.config.get('packages-all')
161 const scopes = this.npm.config.get('scopes')
162 const orgs = this.npm.config.get('orgs')
163 const packagesAndScopesPermission = this.npm.config.get('packages-and-scopes-permission')
164 const orgsPermission = this.npm.config.get('orgs-permission')
165 const bypassTwoFactor = this.npm.config.get('bypass-2fa')
166 let password = this.npm.config.get('password')
167
168 const validCIDR = await this.validateCIDRList(cidr)
169
170 /* istanbul ignore if - skip testing read input */
171 if (!password) {
172 password = await readUserInfo.password()
173 }
174
175 const tokenData = {
176 name: name,
177 password: password,
178 }
179
180 if (tokenDescription) {
181 tokenData.description = tokenDescription
182 }
183
184 if (packages?.length > 0) {
185 tokenData.packages = packages
186 }
187 if (packagesAll) {
188 tokenData.packages_all = true
189 }
190 if (scopes?.length > 0) {
191 tokenData.scopes = scopes
192 }
193 if (orgs?.length > 0) {
194 tokenData.orgs = orgs
195 }
196
197 if (packagesAndScopesPermission) {
198 tokenData.packages_and_scopes_permission = packagesAndScopesPermission
199 }
200 if (orgsPermission) {
201 tokenData.orgs_permission = orgsPermission
202 }
203
204 // Add expiration in days
205 if (expires) {
206 tokenData.expires = parseInt(expires, 10)
207 }
208
209 // Add optional fields
210 if (validCIDR?.length > 0) {
211 tokenData.cidr_whitelist = validCIDR
212 }
213 if (bypassTwoFactor) {
214 tokenData.bypass_2fa = true
215 }
216
217 log.info('token', 'creating')
218 const result = await otplease(this.npm, this.npm.flatOptions, opts =>
219 fetch.json('/-/npm/v1/tokens', {
220 ...opts,
221 method: 'POST',
222 body: tokenData,
223 })
224 )
225 delete result.key
226 delete result.updated
227 if (json) {
228 output.buffer(result)
229 } else if (parseable) {
230 Object.keys(result).forEach(k => output.standard(k + '\t' + result[k]))
231 } else {
232 const chalk = this.npm.chalk
233 output.standard(`Created token ${result.token}`, { [META]: true, redact: false })
234 if (result.cidr_whitelist?.length) {
235 output.standard(`with IP whitelist: ${chalk.green(result.cidr_whitelist.join(','))}`)
236 }
237 if (result.expires) {
238 output.standard(`expires: ${result.expires}`)
239 }
240 }
241 }
242
243 invalidCIDRError (msg) {
244 return Object.assign(new Error(msg), { code: 'EINVALIDCIDR' })
245 }
246
247 generateTokenIds (tokens, minLength) {
248 for (const token of tokens) {
249 token.id = token.key
250 for (let ii = minLength; ii < token.key.length; ++ii) {
251 const match = tokens.some(
252 ot => ot !== token && ot.key.slice(0, ii) === token.key.slice(0, ii)
253 )
254 if (!match) {
255 token.id = token.key.slice(0, ii)
256 break
257 }
258 }
259 }
260 }
261
262 async validateCIDRList (cidrs) {
263 const { v4: isCidrV4, v6: isCidrV6 } = await import('is-cidr')
264 const maybeList = [].concat(cidrs).filter(Boolean)
265 const list = maybeList.length === 1 ? maybeList[0].split(/,\s*/) : maybeList
266 for (const cidr of list) {
267 if (isCidrV6(cidr)) {
268 throw this.invalidCIDRError(
269 `CIDR whitelist can only contain IPv4 addresses, ${cidr} is IPv6`
270 )
271 }
272
273 if (!isCidrV4(cidr)) {
274 throw this.invalidCIDRError(`CIDR whitelist contains invalid CIDR entry: ${cidr}`)
275 }
276 }
277 return list
278 }
279}
280
281module.exports = Token
282 