Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 112 additions & 22 deletions lib/commands/token.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,38 @@
const { log, output, META } = require('proc-log')
const { listTokens, createToken, removeToken } = require('npm-profile')
const fetch = require('npm-registry-fetch')
const { otplease } = require('../utils/auth.js')
const readUserInfo = require('../utils/read-user-info.js')
const BaseCommand = require('../base-cmd.js')

async function paginate (href, opts, items = []) {
while (href) {
const result = await fetch.json(href, opts)
items = items.concat(result.objects)
href = result.urls.next
}
return items
}

class Token extends BaseCommand {
static description = 'Manage your authentication tokens'
static name = 'token'
static usage = ['list', 'revoke <id|token>', 'create [--read-only] [--cidr=list]']
static params = ['read-only', 'cidr', 'registry', 'otp']
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>]']
static params = ['name',
'token-description',
'expires',
'packages',
'packages-all',
'scopes',
'orgs',
'packages-and-scopes-permission',
'orgs-permission',
'cidr',
'bypass-2fa',
'password',
'registry',
'otp',
'read-only',
]

static async completion (opts) {
const argv = opts.conf.argv.remain
Expand Down Expand Up @@ -48,7 +72,7 @@ class Token extends BaseCommand {
const json = this.npm.config.get('json')
const parseable = this.npm.config.get('parseable')
log.info('token', 'getting list')
const tokens = await listTokens(this.npm.flatOptions)
const tokens = await paginate('/-/npm/v1/tokens', this.npm.flatOptions)
if (json) {
output.buffer(tokens)
return
Expand Down Expand Up @@ -89,10 +113,9 @@ class Token extends BaseCommand {
const json = this.npm.config.get('json')
const parseable = this.npm.config.get('parseable')
const toRemove = []
const opts = { ...this.npm.flatOptions }
log.info('token', `removing ${toRemove.length} tokens`)
const tokens = await listTokens(opts)
args.forEach(id => {
const tokens = await paginate('/-/npm/v1/tokens', this.npm.flatOptions)
for (const id of args) {
const matches = tokens.filter(token => token.key.indexOf(id) === 0)
if (matches.length === 1) {
toRemove.push(matches[0].key)
Expand All @@ -108,12 +131,16 @@ class Token extends BaseCommand {

toRemove.push(id)
}
})
await Promise.all(
toRemove.map(key => {
return otplease(this.npm, opts, c => removeToken(key, c))
})
)
}
for (const tokenKey of toRemove) {
await otplease(this.npm, this.npm.flatOptions, opts =>
fetch(`/-/npm/v1/tokens/token/${tokenKey}`, {
...opts,
method: 'DELETE',
ignoreBody: true,
})
)
}
if (json) {
output.buffer(toRemove)
} else if (parseable) {
Expand All @@ -127,15 +154,74 @@ class Token extends BaseCommand {
const json = this.npm.config.get('json')
const parseable = this.npm.config.get('parseable')
const cidr = this.npm.config.get('cidr')
const readonly = this.npm.config.get('read-only')
const name = this.npm.config.get('name')
const tokenDescription = this.npm.config.get('token-description')
const expires = this.npm.config.get('expires')
const packages = this.npm.config.get('packages')
const packagesAll = this.npm.config.get('packages-all')
const scopes = this.npm.config.get('scopes')
const orgs = this.npm.config.get('orgs')
const packagesAndScopesPermission = this.npm.config.get('packages-and-scopes-permission')
const orgsPermission = this.npm.config.get('orgs-permission')
const bypassTwoFactor = this.npm.config.get('bypass-2fa')
let password = this.npm.config.get('password')

const validCIDR = await this.validateCIDRList(cidr)
const password = await readUserInfo.password()

/* istanbul ignore if - skip testing read input */
if (!password) {
password = await readUserInfo.password()
}

const tokenData = {
name: name,
password: password,
}

if (tokenDescription) {
tokenData.description = tokenDescription
}

if (packages?.length > 0) {
tokenData.packages = packages
}
if (packagesAll) {
tokenData.packages_all = true
}
if (scopes?.length > 0) {
tokenData.scopes = scopes
}
if (orgs?.length > 0) {
tokenData.orgs = orgs
}

if (packagesAndScopesPermission) {
tokenData.packages_and_scopes_permission = packagesAndScopesPermission
}
if (orgsPermission) {
tokenData.orgs_permission = orgsPermission
}

// Add expiration in days
if (expires) {
tokenData.expires = parseInt(expires, 10)
}

// Add optional fields
if (validCIDR?.length > 0) {
tokenData.cidr_whitelist = validCIDR
}
if (bypassTwoFactor) {
tokenData.bypass_2fa = true
}

log.info('token', 'creating')
const result = await otplease(
this.npm,
{ ...this.npm.flatOptions },
c => createToken(password, readonly, validCIDR, c)
const result = await otplease(this.npm, this.npm.flatOptions, opts =>
fetch.json('/-/npm/v1/tokens', {
...opts,
method: 'POST',
body: tokenData,
})
)
delete result.key
delete result.updated
Expand All @@ -145,12 +231,16 @@ class Token extends BaseCommand {
Object.keys(result).forEach(k => output.standard(k + '\t' + result[k]))
} else {
const chalk = this.npm.chalk
// Identical to list
const level = result.readonly ? 'read only' : 'publish'
// Display based on access level
// Identical to list? XXX
const level = result.access === 'read-only' || result.readonly ? 'read only' : 'publish'
output.standard(`Created ${chalk.blue(level)} token ${result.token}`, { [META]: true, redact: false })
if (result.cidr_whitelist?.length) {
output.standard(`with IP whitelist: ${chalk.green(result.cidr_whitelist.join(','))}`)
}
if (result.expires) {
output.standard(`expires: ${result.expires}`)
}
}
}

Expand Down Expand Up @@ -180,7 +270,7 @@ class Token extends BaseCommand {
for (const cidr of list) {
if (isCidrV6(cidr)) {
throw this.invalidCIDRError(
`CIDR whitelist can only contain IPv4 addresses${cidr} is IPv6`
`CIDR whitelist can only contain IPv4 addresses, ${cidr} is IPv6`
)
}

Expand Down
35 changes: 21 additions & 14 deletions mock-registry/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ class MockRegistry {
}

getTokens (tokens) {
return this.nock.get('/-/npm/v1/tokens')
return this.nock.get(this.fullPath('/-/npm/v1/tokens'))
.reply(200, {
objects: tokens,
urls: {},
Expand All @@ -451,19 +451,26 @@ class MockRegistry {
})
}

createToken ({ password, readonly = false, cidr = [] }) {
return this.nock.post('/-/npm/v1/tokens', {
password,
readonly,
cidr_whitelist: cidr,
}).reply(200, {
key: 'n3wk3y',
token: 'n3wt0k3n',
created: new Date(),
updated: new Date(),
readonly,
cidr_whitelist: cidr,
})
// 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
createToken (tokenData, resultData = {}) {
return this.nock.post(this.fullPath('/-/npm/v1/tokens'), tokenData)
.reply(201, {
id: `0xdeadbeef`,
key: 'n3wk3y',
token: 'n3wt0k3n',
created: new Date(),
updated: new Date(),
access: 'read-only',
name: tokenData.name,
password: tokenData.password,
...resultData,
})
}

revokeToken (token) {
return this.nock.delete(
this.fullPath(`/-/npm/v1/tokens/token/${token}`)
).reply(200)
}

async package ({ manifest, times = 1, query, tarballs }) {
Expand Down
21 changes: 21 additions & 0 deletions tap-snapshots/test/lib/commands/config.js.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"before": null,
"bin-links": true,
"browser": null,
"bypass-2fa": false,
"ca": null,
"cache-max": null,
"cache-min": 0,
Expand All @@ -48,6 +49,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"engine-strict": false,
"expect-result-count": null,
"expect-results": null,
"expires": null,
"fetch-retries": 2,
"fetch-retry-factor": 10,
"fetch-retry-maxtimeout": 60000,
Expand Down Expand Up @@ -97,6 +99,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"logs-dir": null,
"logs-max": 10,
"long": false,
"name": null,
"maxsockets": 15,
"message": "%s",
"node-gyp": "{CWD}/node_modules/node-gyp/bin/node-gyp.js",
Expand All @@ -108,13 +111,15 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"omit": [],
"omit-lockfile-registry-resolved": false,
"only": null,
"orgs": null,
"optional": null,
"os": null,
"otp": null,
"package": [],
"package-lock": true,
"package-lock-only": false,
"pack-destination": ".",
"packages": [],
"parseable": false,
"prefer-dedupe": false,
"prefer-offline": false,
Expand All @@ -141,6 +146,11 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna
"sbom-format": null,
"sbom-type": "library",
"scope": "",
"scopes": null,
"packages-all": false,
"packages-and-scopes-permission": null,
"orgs-permission": null,
"token-description": null,
"script-shell": null,
"searchexclude": "",
"searchlimit": 20,
Expand Down Expand Up @@ -187,6 +197,7 @@ auth-type = "web"
before = null
bin-links = true
browser = null
bypass-2fa = false
ca = null
; cache = "{CACHE}" ; overridden by cli
cache-max = null
Expand Down Expand Up @@ -214,6 +225,7 @@ editor = "{EDITOR}"
engine-strict = false
expect-result-count = null
expect-results = null
expires = null
fetch-retries = 2
fetch-retry-factor = 10
fetch-retry-maxtimeout = 60000
Expand Down Expand Up @@ -266,6 +278,7 @@ logs-max = 10
; long = false ; overridden by cli
maxsockets = 15
message = "%s"
name = null
node-gyp = "{CWD}/node_modules/node-gyp/bin/node-gyp.js"
node-options = null
noproxy = [""]
Expand All @@ -275,13 +288,19 @@ omit = []
omit-lockfile-registry-resolved = false
only = null
optional = null
orgs = null
orgs-permission = null
os = null
otp = null
pack-destination = "."
package = []
package-lock = true
package-lock-only = false
packages = []
packages-all = false
packages-and-scopes-permission = null
parseable = false
password = (protected)
prefer-dedupe = false
prefer-offline = false
prefer-online = false
Expand All @@ -307,6 +326,7 @@ save-prod = false
sbom-format = null
sbom-type = "library"
scope = ""
scopes = null
script-shell = null
searchexclude = ""
searchlimit = 20
Expand All @@ -321,6 +341,7 @@ strict-ssl = true
tag = "latest"
tag-version-prefix = "v"
timing = false
token-description = null
umask = 0
unicode = false
update-notifier = true
Expand Down
Loading
Loading