Skip to content
Open
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
8 changes: 8 additions & 0 deletions docs/lib/content/commands/npm-trust.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ At least one of these flags is required when creating a trust configuration. You

The required options depend on the CI/CD provider you're configuring. Detailed information about each option is available in the [managing trusted publisher configurations](https://docs.npmjs.com/trusted-publishers#managing-trusted-publisher-configurations) section of the npm documentation. If a provider is repository-based and the option is not provided, npm will use the `repository.url` field from your `package.json`, if available.

For Buildkite, specify the organization and pipeline slugs whose OIDC claims should be trusted:

```bash
npm trust buildkite <package> --organization <slug> --pipeline <slug> --allow-publish
```

When publishing from that pipeline, npm requests an OIDC token from the Buildkite agent and exchanges it for a short-lived npm registry token. No long-lived npm publish token needs to be stored in the pipeline.

Currently, the registry only supports one configuration per package. If you attempt to create a new trust relationship when one already exists, it will result in an error. To replace an existing configuration:

1. Use `npm trust list [package]` to view the ID of the existing trusted publisher
Expand Down
2 changes: 1 addition & 1 deletion docs/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -721,7 +721,7 @@ t.test('replaceParams with name edge cases', async t => {
// Tests subcommand code path including line 184 (aliases in subcommand definitions)
// npm trust has subcommands with definitions that include aliases (repo, env)
await testCommandDoc(t, 'npm-trust', 'Create a trusted relationship between a package and a OIDC provider', {
match: [/--repo/, /--env/],
match: [/npm trust buildkite/, /--organization/, /--repo/, /--env/],
})
})
})
Expand Down
98 changes: 98 additions & 0 deletions lib/commands/trust/buildkite.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
const Definition = require('@npmcli/config/lib/definitions/definition.js')
const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js')
const TrustCommand = require('../../trust-cmd.js')
const { trustDefinitions } = require('../../trust-cmd.js')

class TrustBuildkite extends TrustCommand {
static description = 'Create a trusted relationship between a package and Buildkite'
static name = 'buildkite'
static positionals = 1
static providerName = 'Buildkite'
static providerEntity = 'Buildkite pipeline'

static usage = [
'[package] --organization <slug> --pipeline <slug> [--allow-publish] [--allow-stage-publish] [-y|--yes]',
]

static definitions = [
new Definition('organization', {
default: null,
type: String,
required: true,
description: 'Buildkite organization slug',
alias: ['org'],
}),
new Definition('pipeline', {
default: null,
type: String,
required: true,
description: 'Buildkite pipeline slug',
}),
trustDefinitions['allow-publish'],
trustDefinitions['allow-stage-publish'],
// globals are alphabetical
globalDefinitions['dry-run'],
globalDefinitions.json,
globalDefinitions.registry,
globalDefinitions.yes,
]

static optionsToBody ({ organization, pipeline }) {
return {
type: 'buildkite',
claims: {
organization_slug: organization,
pipeline_slug: pipeline,
},
}
}

static bodyToOptions (body) {
return {
...(body.id) && { id: body.id },
...(body.type) && { type: body.type },
...(body.claims?.organization_slug) && {
organization: body.claims.organization_slug,
},
...(body.claims?.pipeline_slug) && { pipeline: body.claims.pipeline_slug },
}
}

async flagsToOptions ({ positionalArgs, flags }) {
const content = await this.optionalPkgJson()
const pkgName = positionalArgs[0] || content.name
const { organization, pipeline } = flags

if (!pkgName) {
throw new Error('Package name must be specified either as an argument or in package.json file')
}
if (!organization) {
throw new Error('organization is required')
}
if (!pipeline) {
throw new Error('pipeline is required')
}

return {
values: {
package: pkgName,
organization,
pipeline,
},
fromPackageJson: {
package: !positionalArgs[0] && Boolean(content.name),
},
warnings: [],
urls: {
package: this.getFrontendUrl({ pkgName }),
pipeline: new URL(`${organization}/${pipeline}`, 'https://buildkite.com').toString(),
},
}
}

async exec (positionalArgs, flags) {
await this.createConfigCommand({ positionalArgs, flags })
}
}

module.exports = TrustBuildkite
1 change: 1 addition & 0 deletions lib/commands/trust/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class Trust extends BaseCommand {
github: require('./github.js'),
gitlab: require('./gitlab.js'),
circleci: require('./circleci.js'),
buildkite: require('./buildkite.js'),
list: require('./list.js'),
revoke: require('./revoke.js'),
}
Expand Down
5 changes: 4 additions & 1 deletion lib/commands/trust/list.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const { otplease } = require('../../utils/auth.js')
const npmFetch = require('npm-registry-fetch')
const npa = require('npm-package-arg')
const TrustBuildkite = require('./buildkite.js')
const TrustCircleCI = require('./circleci.js')
const TrustGithub = require('./github.js')
const TrustGitlab = require('./gitlab.js')
Expand All @@ -22,7 +23,9 @@ class TrustList extends TrustCommand {
]

static bodyToOptions (body) {
if (body.type === 'circleci') {
if (body.type === 'buildkite') {
return TrustBuildkite.bodyToOptions(body)
} else if (body.type === 'circleci') {
return TrustCircleCI.bodyToOptions(body)
} else if (body.type === 'github') {
return TrustGithub.bodyToOptions(body)
Expand Down
35 changes: 27 additions & 8 deletions lib/utils/oidc.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ const ciInfo = require('ci-info')
const fetch = require('make-fetch-happen')
const npa = require('npm-package-arg')
const libaccess = require('libnpmaccess')
const spawn = require('@npmcli/promise-spawn')

/**
* Handles OpenID Connect (OIDC) token retrieval and exchange for CI environments.
*
* This function is designed to work in Continuous Integration (CI) environments such as GitHub Actions, GitLab, and CircleCI.
* This function is designed to work in Continuous Integration (CI) environments such as GitHub Actions, GitLab, CircleCI, and Buildkite.
* It retrieves an OIDC token from the CI environment, exchanges it for an npm token, and sets the token in the provided configuration for authentication with the npm registry.
*
* This function is intended to never throw, as it mutates the state of the `opts` and `config` objects on success.
Expand All @@ -17,6 +18,7 @@ const libaccess = require('libnpmaccess')
* @see https://github.com/watson/ci-info for CI environment detection.
* @see https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect for GitHub Actions OIDC.
* @see https://circleci.com/docs/openid-connect-tokens/ for CircleCI OIDC.
* @see https://buildkite.com/docs/agent/cli/reference/oidc for Buildkite OIDC.
*/
async function oidc ({ packageName, registry, opts, config }) {
/*
Expand All @@ -31,11 +33,17 @@ async function oidc ({ packageName, registry, opts, config }) {
/** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L161C13-L161C22 */
ciInfo.GITLAB ||
/** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L78 */
ciInfo.CIRCLE
ciInfo.CIRCLE ||
ciInfo.BUILDKITE
)) {
return undefined
}

/**
* The specification for an audience is `npm:registry.npmjs.org`, where "registry.npmjs.org" can be any supported registry.
*/
const audience = `npm:${new URL(registry).hostname}`

/**
* Check if the environment variable `NPM_ID_TOKEN` is set.
* In GitLab CI, the ID token is provided via an environment variable,
Expand Down Expand Up @@ -68,10 +76,6 @@ async function oidc ({ packageName, registry, opts, config }) {
return undefined
}

/**
* The specification for an audience is `npm:registry.npmjs.org`, where "registry.npmjs.org" can be any supported registry.
*/
const audience = `npm:${new URL(registry).hostname}`
const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL)
url.searchParams.append('audience', audience)
const startTime = Date.now()
Expand Down Expand Up @@ -105,6 +109,21 @@ async function oidc ({ packageName, registry, opts, config }) {
idToken = json.value
}

if (!idToken && ciInfo.BUILDKITE) {
try {
const result = await spawn('buildkite-agent', [
'oidc',
'request-token',
'--audience',
audience,
])
idToken = result.stdout.trim()
} catch {
log.verbose('oidc', 'Failed to fetch id_token from Buildkite')
return undefined
}
}

if (!idToken) {
log.silly('oidc', 'Skipped because no id_token available')
return undefined
Expand Down Expand Up @@ -143,8 +162,8 @@ async function oidc ({ packageName, registry, opts, config }) {

try {
const isDefaultProvenance = config.isDefault('provenance')
// CircleCI doesn't support provenance yet, so skip the auto-enable logic
if (isDefaultProvenance && !ciInfo.CIRCLE) {
// Automatic provenance is currently supported only in GitHub Actions and GitLab CI.
if (isDefaultProvenance && (ciInfo.GITHUB_ACTIONS || ciInfo.GITLAB)) {
const [headerB64, payloadB64] = idToken.split('.')
if (headerB64 && payloadB64) {
const payloadJson = Buffer.from(payloadB64, 'base64').toString('utf8')
Expand Down
1 change: 1 addition & 0 deletions tap-snapshots/test/lib/commands/completion.js.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ Array [
github
gitlab
circleci
buildkite
list
revoke
),
Expand Down
38 changes: 37 additions & 1 deletion test/fixtures/mock-oidc.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,26 @@ function circleciIdToken () {
return makeJwt(payload)
}

function buildkiteIdToken () {
const now = Math.floor(Date.now() / 1000)
const payload = {
organization_slug: 'npm',
pipeline_slug: 'trust-publish-test',
runner_environment: 'buildkite-hosted',
iat: now,
exp: now + 300,
}
return makeJwt(payload)
}

const mockOidc = async (t, {
oidcOptions = {},
packageName = '@npmcli/test-package',
config = {},
packageJson = {},
load = {},
mockGithubOidcOptions = false,
mockBuildkiteOidcOptions = false,
mockOidcTokenExchangeOptions = false,
publishOptions = {},
provenance = false,
Expand All @@ -60,6 +73,7 @@ const mockOidc = async (t, {
const github = oidcOptions.github ?? false
const gitlab = oidcOptions.gitlab ?? false
const circleci = oidcOptions.circleci ?? false
const buildkite = oidcOptions.buildkite ?? false

const ACTIONS_ID_TOKEN_REQUEST_URL = oidcOptions.ACTIONS_ID_TOKEN_REQUEST_URL ?? 'https://github.com/actions/id-token'
const ACTIONS_ID_TOKEN_REQUEST_TOKEN = oidcOptions.ACTIONS_ID_TOKEN_REQUEST_TOKEN ?? 'ACTIONS_ID_TOKEN_REQUEST_TOKEN'
Expand All @@ -69,10 +83,11 @@ const mockOidc = async (t, {
env: {
ACTIONS_ID_TOKEN_REQUEST_TOKEN: ACTIONS_ID_TOKEN_REQUEST_TOKEN,
ACTIONS_ID_TOKEN_REQUEST_URL: ACTIONS_ID_TOKEN_REQUEST_URL,
CI: github || gitlab || circleci ? 'true' : undefined,
CI: github || gitlab || circleci || buildkite ? 'true' : undefined,
...(github ? { GITHUB_ACTIONS: 'true' } : {}),
...(gitlab ? { GITLAB_CI: 'true' } : {}),
...(circleci ? { CIRCLECI: 'true' } : {}),
...(buildkite ? { BUILDKITE: 'true' } : {}),
...(oidcOptions.NPM_ID_TOKEN ? { NPM_ID_TOKEN: oidcOptions.NPM_ID_TOKEN } : {}),
/* eslint-disable-next-line max-len */
...(oidcOptions.SIGSTORE_ID_TOKEN ? { SIGSTORE_ID_TOKEN: oidcOptions.SIGSTORE_ID_TOKEN } : {}),
Expand All @@ -83,9 +98,11 @@ const mockOidc = async (t, {
const GITHUB_ACTIONS = ciInfo.GITHUB_ACTIONS
const GITLAB = ciInfo.GITLAB
const CIRCLE = ciInfo.CIRCLE
const BUILDKITE = ciInfo.BUILDKITE
delete ciInfo.GITHUB_ACTIONS
delete ciInfo.GITLAB
delete ciInfo.CIRCLE
delete ciInfo.BUILDKITE
if (github) {
ciInfo.GITHUB_ACTIONS = 'true'
}
Expand All @@ -95,12 +112,29 @@ const mockOidc = async (t, {
if (circleci) {
ciInfo.CIRCLE = 'true'
}
if (buildkite) {
ciInfo.BUILDKITE = 'true'
}
t.teardown(() => {
ciInfo.GITHUB_ACTIONS = GITHUB_ACTIONS
ciInfo.GITLAB = GITLAB
ciInfo.CIRCLE = CIRCLE
ciInfo.BUILDKITE = BUILDKITE
})

const mocks = { ...load.mocks }
if (buildkite) {
mocks['@npmcli/promise-spawn'] = async (command, args) => {
const { audience, error, idToken = '' } = mockBuildkiteOidcOptions || {}
t.equal(command, 'buildkite-agent')
t.strictSame(args, ['oidc', 'request-token', '--audience', audience])
if (error) {
throw error
}
return { stdout: idToken }
}
}

const { npm, registry, joinedOutput, logs } = await loadNpmWithRegistry(t, {
config: {
loglevel: 'silly',
Expand All @@ -114,6 +148,7 @@ const mockOidc = async (t, {
}, null, 2),
},
...load,
mocks,
})

if (mockGithubOidcOptions) {
Expand Down Expand Up @@ -176,6 +211,7 @@ const oidcPublishTest = (opts) => {
}

module.exports = {
buildkiteIdToken,
circleciIdToken,
gitlabIdToken,
githubIdToken,
Expand Down
Loading