Skip to content

Commit c6242d9

Browse files
fix: change npm profile to create tokens with GAT support (#8706)
This pull request introduces extensive enhancements to the npm token management command, adding support for creating Granular Access Tokens (GATs) with fine-grained permissions. It updates the CLI interface, configuration, and documentation to allow users to specify token details such as name, description, expiration, package/scope/org restrictions, permission levels, and bypassing two-factor authentication. The changes also improve error messaging and ensure all new options are reflected in the config and docs. --------- Co-authored-by: Gar <[email protected]>
1 parent e9f0418 commit c6242d9

File tree

7 files changed

+646
-110
lines changed

7 files changed

+646
-110
lines changed

lib/commands/token.js

Lines changed: 112 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,38 @@
11
const { log, output, META } = require('proc-log')
2-
const { listTokens, createToken, removeToken } = require('npm-profile')
2+
const fetch = require('npm-registry-fetch')
33
const { otplease } = require('../utils/auth.js')
44
const readUserInfo = require('../utils/read-user-info.js')
55
const BaseCommand = require('../base-cmd.js')
66

7+
async 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+
716
class Token extends BaseCommand {
817
static description = 'Manage your authentication tokens'
918
static name = 'token'
10-
static usage = ['list', 'revoke <id|token>', 'create [--read-only] [--cidr=list]']
11-
static params = ['read-only', 'cidr', 'registry', 'otp']
19+
static usage = ['list', 'revoke <id|token>', 'create --name=<name> [--token-description=<desc>] [--packages=<pkg1,pkg2>] [--packages-all] [--scopes=<scope1,scope2>] [--orgs=<org1,org2>] [--packages-and-scopes-permission=<read-only|read-write|no-access>] [--orgs-permission=<read-only|read-write|no-access>] [--expires=<days>] [--cidr=<ip-range>] [--bypass-2fa] [--password=<pass>]']
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+
]
1236

1337
static async completion (opts) {
1438
const argv = opts.conf.argv.remain
@@ -48,7 +72,7 @@ class Token extends BaseCommand {
4872
const json = this.npm.config.get('json')
4973
const parseable = this.npm.config.get('parseable')
5074
log.info('token', 'getting list')
51-
const tokens = await listTokens(this.npm.flatOptions)
75+
const tokens = await paginate('/-/npm/v1/tokens', this.npm.flatOptions)
5276
if (json) {
5377
output.buffer(tokens)
5478
return
@@ -89,10 +113,9 @@ class Token extends BaseCommand {
89113
const json = this.npm.config.get('json')
90114
const parseable = this.npm.config.get('parseable')
91115
const toRemove = []
92-
const opts = { ...this.npm.flatOptions }
93116
log.info('token', `removing ${toRemove.length} tokens`)
94-
const tokens = await listTokens(opts)
95-
args.forEach(id => {
117+
const tokens = await paginate('/-/npm/v1/tokens', this.npm.flatOptions)
118+
for (const id of args) {
96119
const matches = tokens.filter(token => token.key.indexOf(id) === 0)
97120
if (matches.length === 1) {
98121
toRemove.push(matches[0].key)
@@ -108,12 +131,16 @@ class Token extends BaseCommand {
108131

109132
toRemove.push(id)
110133
}
111-
})
112-
await Promise.all(
113-
toRemove.map(key => {
114-
return otplease(this.npm, opts, c => removeToken(key, c))
115-
})
116-
)
134+
}
135+
for (const tokenKey of toRemove) {
136+
await otplease(this.npm, this.npm.flatOptions, opts =>
137+
fetch(`/-/npm/v1/tokens/token/${tokenKey}`, {
138+
...opts,
139+
method: 'DELETE',
140+
ignoreBody: true,
141+
})
142+
)
143+
}
117144
if (json) {
118145
output.buffer(toRemove)
119146
} else if (parseable) {
@@ -127,15 +154,74 @@ class Token extends BaseCommand {
127154
const json = this.npm.config.get('json')
128155
const parseable = this.npm.config.get('parseable')
129156
const cidr = this.npm.config.get('cidr')
130-
const readonly = this.npm.config.get('read-only')
157+
const name = this.npm.config.get('name')
158+
const tokenDescription = this.npm.config.get('token-description')
159+
const expires = this.npm.config.get('expires')
160+
const packages = this.npm.config.get('packages')
161+
const packagesAll = this.npm.config.get('packages-all')
162+
const scopes = this.npm.config.get('scopes')
163+
const orgs = this.npm.config.get('orgs')
164+
const packagesAndScopesPermission = this.npm.config.get('packages-and-scopes-permission')
165+
const orgsPermission = this.npm.config.get('orgs-permission')
166+
const bypassTwoFactor = this.npm.config.get('bypass-2fa')
167+
let password = this.npm.config.get('password')
131168

132169
const validCIDR = await this.validateCIDRList(cidr)
133-
const password = await readUserInfo.password()
170+
171+
/* istanbul ignore if - skip testing read input */
172+
if (!password) {
173+
password = await readUserInfo.password()
174+
}
175+
176+
const tokenData = {
177+
name: name,
178+
password: password,
179+
}
180+
181+
if (tokenDescription) {
182+
tokenData.description = tokenDescription
183+
}
184+
185+
if (packages?.length > 0) {
186+
tokenData.packages = packages
187+
}
188+
if (packagesAll) {
189+
tokenData.packages_all = true
190+
}
191+
if (scopes?.length > 0) {
192+
tokenData.scopes = scopes
193+
}
194+
if (orgs?.length > 0) {
195+
tokenData.orgs = orgs
196+
}
197+
198+
if (packagesAndScopesPermission) {
199+
tokenData.packages_and_scopes_permission = packagesAndScopesPermission
200+
}
201+
if (orgsPermission) {
202+
tokenData.orgs_permission = orgsPermission
203+
}
204+
205+
// Add expiration in days
206+
if (expires) {
207+
tokenData.expires = parseInt(expires, 10)
208+
}
209+
210+
// Add optional fields
211+
if (validCIDR?.length > 0) {
212+
tokenData.cidr_whitelist = validCIDR
213+
}
214+
if (bypassTwoFactor) {
215+
tokenData.bypass_2fa = true
216+
}
217+
134218
log.info('token', 'creating')
135-
const result = await otplease(
136-
this.npm,
137-
{ ...this.npm.flatOptions },
138-
c => createToken(password, readonly, validCIDR, c)
219+
const result = await otplease(this.npm, this.npm.flatOptions, opts =>
220+
fetch.json('/-/npm/v1/tokens', {
221+
...opts,
222+
method: 'POST',
223+
body: tokenData,
224+
})
139225
)
140226
delete result.key
141227
delete result.updated
@@ -145,12 +231,16 @@ class Token extends BaseCommand {
145231
Object.keys(result).forEach(k => output.standard(k + '\t' + result[k]))
146232
} else {
147233
const chalk = this.npm.chalk
148-
// Identical to list
149-
const level = result.readonly ? 'read only' : 'publish'
234+
// Display based on access level
235+
// Identical to list? XXX
236+
const level = result.access === 'read-only' || result.readonly ? 'read only' : 'publish'
150237
output.standard(`Created ${chalk.blue(level)} token ${result.token}`, { [META]: true, redact: false })
151238
if (result.cidr_whitelist?.length) {
152239
output.standard(`with IP whitelist: ${chalk.green(result.cidr_whitelist.join(','))}`)
153240
}
241+
if (result.expires) {
242+
output.standard(`expires: ${result.expires}`)
243+
}
154244
}
155245
}
156246

@@ -180,7 +270,7 @@ class Token extends BaseCommand {
180270
for (const cidr of list) {
181271
if (isCidrV6(cidr)) {
182272
throw this.invalidCIDRError(
183-
`CIDR whitelist can only contain IPv4 addresses${cidr} is IPv6`
273+
`CIDR whitelist can only contain IPv4 addresses, ${cidr} is IPv6`
184274
)
185275
}
186276

mock-registry/lib/index.js

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,7 @@ class MockRegistry {
442442
}
443443

444444
getTokens (tokens) {
445-
return this.nock.get('/-/npm/v1/tokens')
445+
return this.nock.get(this.fullPath('/-/npm/v1/tokens'))
446446
.reply(200, {
447447
objects: tokens,
448448
urls: {},
@@ -451,19 +451,26 @@ class MockRegistry {
451451
})
452452
}
453453

454-
createToken ({ password, readonly = false, cidr = [] }) {
455-
return this.nock.post('/-/npm/v1/tokens', {
456-
password,
457-
readonly,
458-
cidr_whitelist: cidr,
459-
}).reply(200, {
460-
key: 'n3wk3y',
461-
token: 'n3wt0k3n',
462-
created: new Date(),
463-
updated: new Date(),
464-
readonly,
465-
cidr_whitelist: cidr,
466-
})
454+
// The server has rules for what resultData correlates with what tokenData but we don't need to be 100% in sync with that, we just need to be able to pass all of the possible tokenData attributes, and be able to accept all of the possible resultData attributes
455+
createToken (tokenData, resultData = {}) {
456+
return this.nock.post(this.fullPath('/-/npm/v1/tokens'), tokenData)
457+
.reply(201, {
458+
id: `0xdeadbeef`,
459+
key: 'n3wk3y',
460+
token: 'n3wt0k3n',
461+
created: new Date(),
462+
updated: new Date(),
463+
access: 'read-only',
464+
name: tokenData.name,
465+
password: tokenData.password,
466+
...resultData,
467+
})
468+
}
469+
470+
revokeToken (token) {
471+
return this.nock.delete(
472+
this.fullPath(`/-/npm/v1/tokens/token/${token}`)
473+
).reply(200)
467474
}
468475

469476
async package ({ manifest, times = 1, query, tarballs }) {

tap-snapshots/test/lib/commands/config.js.test.cjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
2323
"before": null,
2424
"bin-links": true,
2525
"browser": null,
26+
"bypass-2fa": false,
2627
"ca": null,
2728
"cache-max": null,
2829
"cache-min": 0,
@@ -48,6 +49,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
4849
"engine-strict": false,
4950
"expect-result-count": null,
5051
"expect-results": null,
52+
"expires": null,
5153
"fetch-retries": 2,
5254
"fetch-retry-factor": 10,
5355
"fetch-retry-maxtimeout": 60000,
@@ -97,6 +99,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
9799
"logs-dir": null,
98100
"logs-max": 10,
99101
"long": false,
102+
"name": null,
100103
"maxsockets": 15,
101104
"message": "%s",
102105
"node-gyp": "{CWD}/node_modules/node-gyp/bin/node-gyp.js",
@@ -108,13 +111,15 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
108111
"omit": [],
109112
"omit-lockfile-registry-resolved": false,
110113
"only": null,
114+
"orgs": null,
111115
"optional": null,
112116
"os": null,
113117
"otp": null,
114118
"package": [],
115119
"package-lock": true,
116120
"package-lock-only": false,
117121
"pack-destination": ".",
122+
"packages": [],
118123
"parseable": false,
119124
"prefer-dedupe": false,
120125
"prefer-offline": false,
@@ -141,6 +146,11 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
141146
"sbom-format": null,
142147
"sbom-type": "library",
143148
"scope": "",
149+
"scopes": null,
150+
"packages-all": false,
151+
"packages-and-scopes-permission": null,
152+
"orgs-permission": null,
153+
"token-description": null,
144154
"script-shell": null,
145155
"searchexclude": "",
146156
"searchlimit": 20,
@@ -187,6 +197,7 @@ auth-type = "web"
187197
before = null
188198
bin-links = true
189199
browser = null
200+
bypass-2fa = false
190201
ca = null
191202
; cache = "{CACHE}" ; overridden by cli
192203
cache-max = null
@@ -214,6 +225,7 @@ editor = "{EDITOR}"
214225
engine-strict = false
215226
expect-result-count = null
216227
expect-results = null
228+
expires = null
217229
fetch-retries = 2
218230
fetch-retry-factor = 10
219231
fetch-retry-maxtimeout = 60000
@@ -266,6 +278,7 @@ logs-max = 10
266278
; long = false ; overridden by cli
267279
maxsockets = 15
268280
message = "%s"
281+
name = null
269282
node-gyp = "{CWD}/node_modules/node-gyp/bin/node-gyp.js"
270283
node-options = null
271284
noproxy = [""]
@@ -275,13 +288,19 @@ omit = []
275288
omit-lockfile-registry-resolved = false
276289
only = null
277290
optional = null
291+
orgs = null
292+
orgs-permission = null
278293
os = null
279294
otp = null
280295
pack-destination = "."
281296
package = []
282297
package-lock = true
283298
package-lock-only = false
299+
packages = []
300+
packages-all = false
301+
packages-and-scopes-permission = null
284302
parseable = false
303+
password = (protected)
285304
prefer-dedupe = false
286305
prefer-offline = false
287306
prefer-online = false
@@ -307,6 +326,7 @@ save-prod = false
307326
sbom-format = null
308327
sbom-type = "library"
309328
scope = ""
329+
scopes = null
310330
script-shell = null
311331
searchexclude = ""
312332
searchlimit = 20
@@ -321,6 +341,7 @@ strict-ssl = true
321341
tag = "latest"
322342
tag-version-prefix = "v"
323343
timing = false
344+
token-description = null
324345
umask = 0
325346
unicode = false
326347
update-notifier = true

0 commit comments

Comments
 (0)