Skip to content

Commit dfcfec9

Browse files
committed
feat!: Adds Granular Access Token (GAT) support to npm token command
BREAKING CHANGE: The `npm token create` command now requires the `--name` and `--access` parameters for creating tokens. The `--read-only` flag has been deprecated in favor of the more flexible `--access` parameter which accepts `read-only` or `read-write`. Additional parameters such as `--expires`, `--packages`, `--scopes`, `--orgs`, and `--bypass-2fa` have been added to support GAT features.
1 parent 06510a8 commit dfcfec9

File tree

5 files changed

+573
-68
lines changed

5 files changed

+573
-68
lines changed

lib/commands/token.js

Lines changed: 98 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
11
const { log, output } = require('proc-log')
22
const { listTokens, createToken, removeToken } = require('npm-profile')
33
const { otplease } = require('../utils/auth.js')
4-
const readUserInfo = require('../utils/read-user-info.js')
54
const BaseCommand = require('../base-cmd.js')
65

76
class Token extends BaseCommand {
87
static description = 'Manage your authentication tokens'
98
static name = 'token'
10-
static usage = ['list', 'revoke <id|token>', 'create [--read-only] [--cidr=list]']
11-
static params = ['read-only', 'cidr', 'registry', 'otp']
9+
static usage = ['list', 'revoke <id|token>', 'create --name=<name> --access=<read-only|read-write> [--expires=<YYYY-MM-DD>] [--packages=<pkg1,pkg2>] [--scopes=<scope1,scope2>] [--orgs=<org1,org2>] [--cidr=<ip-range>] [--bypass-2fa]']
10+
static params = ['name',
11+
'expires',
12+
'access',
13+
'packages',
14+
'scopes',
15+
'orgs',
16+
'cidr',
17+
'bypass-2fa',
18+
'registry',
19+
'otp',
20+
'read-only',
21+
]
1222

1323
static async completion (opts) {
1424
const argv = opts.conf.argv.remain
@@ -127,15 +137,91 @@ class Token extends BaseCommand {
127137
const json = this.npm.config.get('json')
128138
const parseable = this.npm.config.get('parseable')
129139
const cidr = this.npm.config.get('cidr')
130-
const readonly = this.npm.config.get('read-only')
140+
const name = this.npm.config.get('name')
141+
const expires = this.npm.config.get('expires')
142+
const access = this.npm.config.get('access')
143+
const packages = this.npm.config.get('packages')
144+
const scopes = this.npm.config.get('scopes')
145+
const orgs = this.npm.config.get('orgs')
146+
const bypassTwoFactor = this.npm.config.get('bypass-2fa')
147+
148+
// Validate required parameters
149+
if (!name) {
150+
throw this.usageError('--name is required for token creation')
151+
}
152+
if (!access) {
153+
throw this.usageError('--access is required (use "read-only" or "read-write")')
154+
}
155+
if (!['read-only', 'read-write'].includes(access)) {
156+
throw this.usageError('--access must be either "read-only" or "read-write"')
157+
}
131158

132159
const validCIDR = await this.validateCIDRList(cidr)
133-
const password = await readUserInfo.password()
160+
// Build GAT token data structure
161+
const tokenData = {
162+
token_type: 'granular',
163+
token_name: name,
164+
}
165+
166+
// Convert access to permission action (read-only -> read, read-write -> write)
167+
const permissionAction = access === 'read-only' ? 'read' : 'write'
168+
169+
// Build scopes array (combines packages, scopes, and orgs)
170+
const scopesArray = []
171+
if (packages?.length > 0) {
172+
packages.forEach(pkg => {
173+
scopesArray.push({ type: 'package', name: pkg })
174+
})
175+
}
176+
if (scopes?.length > 0) {
177+
scopes.forEach(scope => {
178+
scopesArray.push({ type: 'package', name: scope })
179+
})
180+
}
181+
if (orgs?.length > 0) {
182+
orgs.forEach(org => {
183+
scopesArray.push({ type: 'org', name: org })
184+
})
185+
}
186+
tokenData.scopes = scopesArray
187+
188+
// Build permissions array based on what types are present
189+
const permissionsArray = []
190+
const hasPackageScope = packages?.length > 0 || scopes?.length > 0
191+
const hasOrgScope = orgs?.length > 0
192+
193+
if (hasPackageScope) {
194+
permissionsArray.push({ name: 'package', action: permissionAction })
195+
}
196+
if (hasOrgScope) {
197+
permissionsArray.push({ name: 'org', action: permissionAction })
198+
}
199+
tokenData.permissions = permissionsArray
200+
201+
// Add expiration in days (default to 7 days if not provided)
202+
if (expires) {
203+
const expiresDate = new Date(expires)
204+
const now = new Date()
205+
const diffDays = Math.ceil((expiresDate - now) / (1000 * 60 * 60 * 24))
206+
tokenData.expirationInDays = Math.max(1, diffDays) // minimum 1 day
207+
} else {
208+
tokenData.expirationInDays = 7
209+
}
210+
211+
// Add optional fields
212+
if (validCIDR?.length > 0) {
213+
tokenData.cidr_whitelist = validCIDR
214+
}
215+
if (bypassTwoFactor) {
216+
tokenData.bypass_2fa = true
217+
}
218+
134219
log.info('token', 'creating')
220+
log.silly('token', 'request body:', JSON.stringify(tokenData, null, 2))
135221
const result = await otplease(
136222
this.npm,
137223
{ ...this.npm.flatOptions },
138-
c => createToken(password, readonly, validCIDR, c)
224+
c => createToken(tokenData, c)
139225
)
140226
delete result.key
141227
delete result.updated
@@ -145,12 +231,15 @@ 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+
const level = result.access === 'read-only' || result.readonly ? 'read only' : 'publish'
150236
output.standard(`Created ${chalk.blue(level)} token ${result.token}`)
151237
if (result.cidr_whitelist?.length) {
152238
output.standard(`with IP whitelist: ${chalk.green(result.cidr_whitelist.join(','))}`)
153239
}
240+
if (result.expires) {
241+
output.standard(`expires: ${result.expires}`)
242+
}
154243
}
155244
}
156245

@@ -180,7 +269,7 @@ class Token extends BaseCommand {
180269
for (const cidr of list) {
181270
if (isCidrV6(cidr)) {
182271
throw this.invalidCIDRError(
183-
`CIDR whitelist can only contain IPv4 addresses${cidr} is IPv6`
272+
`CIDR whitelist can only contain IPv4 addresses, ${cidr} is IPv6`
184273
)
185274
}
186275

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

Lines changed: 12 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,7 @@ 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,
144150
"script-shell": null,
145151
"searchexclude": "",
146152
"searchlimit": 20,
@@ -187,6 +193,7 @@ auth-type = "web"
187193
before = null
188194
bin-links = true
189195
browser = null
196+
bypass-2fa = false
190197
ca = null
191198
; cache = "{CACHE}" ; overridden by cli
192199
cache-max = null
@@ -214,6 +221,7 @@ editor = "{EDITOR}"
214221
engine-strict = false
215222
expect-result-count = null
216223
expect-results = null
224+
expires = null
217225
fetch-retries = 2
218226
fetch-retry-factor = 10
219227
fetch-retry-maxtimeout = 60000
@@ -266,6 +274,7 @@ logs-max = 10
266274
; long = false ; overridden by cli
267275
maxsockets = 15
268276
message = "%s"
277+
name = null
269278
node-gyp = "{CWD}/node_modules/node-gyp/bin/node-gyp.js"
270279
node-options = null
271280
noproxy = [""]
@@ -275,12 +284,14 @@ omit = []
275284
omit-lockfile-registry-resolved = false
276285
only = null
277286
optional = null
287+
orgs = null
278288
os = null
279289
otp = null
280290
pack-destination = "."
281291
package = []
282292
package-lock = true
283293
package-lock-only = false
294+
packages = []
284295
parseable = false
285296
prefer-dedupe = false
286297
prefer-offline = false
@@ -307,6 +318,7 @@ save-prod = false
307318
sbom-format = null
308319
sbom-type = "library"
309320
scope = ""
321+
scopes = null
310322
script-shell = null
311323
searchexclude = ""
312324
searchlimit = 20

0 commit comments

Comments
 (0)