diff --git a/docs/lib/content/commands/npm-trust.md b/docs/lib/content/commands/npm-trust.md index 64b00adbd1462..6a6ad958e42b4 100644 --- a/docs/lib/content/commands/npm-trust.md +++ b/docs/lib/content/commands/npm-trust.md @@ -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 --organization --pipeline --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 diff --git a/docs/test/index.js b/docs/test/index.js index 5a2a2c2b7d611..f5d7a43ea10d8 100644 --- a/docs/test/index.js +++ b/docs/test/index.js @@ -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/], }) }) }) diff --git a/lib/commands/trust/buildkite.js b/lib/commands/trust/buildkite.js new file mode 100644 index 0000000000000..9316b699bd6b6 --- /dev/null +++ b/lib/commands/trust/buildkite.js @@ -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 --pipeline [--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 diff --git a/lib/commands/trust/index.js b/lib/commands/trust/index.js index 9b866a2cd5e61..2371ec55f97c9 100644 --- a/lib/commands/trust/index.js +++ b/lib/commands/trust/index.js @@ -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'), } diff --git a/lib/commands/trust/list.js b/lib/commands/trust/list.js index 3d5c3aeb0dbc1..ffb0303a60a91 100644 --- a/lib/commands/trust/list.js +++ b/lib/commands/trust/list.js @@ -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') @@ -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) diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index 00f32c642621c..b8f028cc99e44 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -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. @@ -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 }) { /* @@ -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, @@ -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() @@ -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 @@ -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') diff --git a/tap-snapshots/test/lib/commands/completion.js.test.cjs b/tap-snapshots/test/lib/commands/completion.js.test.cjs index c2d4abc9d4c97..af4e6f8d4ced6 100644 --- a/tap-snapshots/test/lib/commands/completion.js.test.cjs +++ b/tap-snapshots/test/lib/commands/completion.js.test.cjs @@ -151,6 +151,7 @@ Array [ github gitlab circleci + buildkite list revoke ), diff --git a/test/fixtures/mock-oidc.js b/test/fixtures/mock-oidc.js index d15d52c1b819f..66fbcfac269e9 100644 --- a/test/fixtures/mock-oidc.js +++ b/test/fixtures/mock-oidc.js @@ -45,6 +45,18 @@ 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', @@ -52,6 +64,7 @@ const mockOidc = async (t, { packageJson = {}, load = {}, mockGithubOidcOptions = false, + mockBuildkiteOidcOptions = false, mockOidcTokenExchangeOptions = false, publishOptions = {}, provenance = false, @@ -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' @@ -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 } : {}), @@ -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' } @@ -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', @@ -114,6 +148,7 @@ const mockOidc = async (t, { }, null, 2), }, ...load, + mocks, }) if (mockGithubOidcOptions) { @@ -176,6 +211,7 @@ const oidcPublishTest = (opts) => { } module.exports = { + buildkiteIdToken, circleciIdToken, gitlabIdToken, githubIdToken, diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index fe286ff46b748..d8e6f701c00b8 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -5,7 +5,14 @@ const pacote = require('pacote') const Arborist = require('@npmcli/arborist') const path = require('node:path') const fs = require('node:fs') -const { circleciIdToken, githubIdToken, gitlabIdToken, oidcPublishTest, mockOidc } = require('../../fixtures/mock-oidc') +const { + buildkiteIdToken, + circleciIdToken, + githubIdToken, + gitlabIdToken, + oidcPublishTest, + mockOidc, +} = require('../../fixtures/mock-oidc') const { sigstoreIdToken } = require('@npmcli/mock-registry/lib/provenance') const mockGlobals = require('@npmcli/mock-globals') @@ -1329,6 +1336,60 @@ t.test('oidc token exchange - no provenance', t => { }, })) + t.test('buildkite missing OIDC token', oidcPublishTest({ + oidcOptions: { buildkite: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockBuildkiteOidcOptions: { + audience: 'npm:registry.npmjs.org', + }, + publishOptions: { + token: 'existing-fallback-token', + }, + logsContain: [ + 'silly oidc Skipped because no id_token available', + ], + })) + + t.test('buildkite OIDC request failure with fallback', oidcPublishTest({ + oidcOptions: { buildkite: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockBuildkiteOidcOptions: { + audience: 'npm:registry.npmjs.org', + error: new Error('command failed'), + }, + publishOptions: { + token: 'existing-fallback-token', + }, + logsContain: [ + 'verbose oidc Failed to fetch id_token from Buildkite', + ], + })) + + const buildkiteToken = buildkiteIdToken() + t.test('default registry success buildkite', oidcPublishTest({ + oidcOptions: { buildkite: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockBuildkiteOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: buildkiteToken, + }, + mockOidcTokenExchangeOptions: { + idToken: buildkiteToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + })) + // custom registry success t.test('custom registry config success github', oidcPublishTest({ diff --git a/test/lib/commands/trust/buildkite.js b/test/lib/commands/trust/buildkite.js new file mode 100644 index 0000000000000..0a9a1673ecf77 --- /dev/null +++ b/test/lib/commands/trust/buildkite.js @@ -0,0 +1,141 @@ +const t = require('tap') +const { load: loadMockNpm } = require('../../../fixtures/mock-npm.js') +const MockRegistry = require('@npmcli/mock-registry') + +const packageName = '@npmcli/test-package' +const auth = { '//registry.npmjs.org/:_authToken': 'test-auth-token' } + +t.test('buildkite with all options provided', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { ...auth, yes: true }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + registry.trustCreate({ packageName }) + + await npm.exec('trust', [ + 'buildkite', + packageName, + '--yes', + '--organization', 'npm', + '--pipeline', 'cli', + '--allow-publish', + ]) +}) + +t.test('buildkite uses package name from package.json', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { ...auth, yes: true }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + registry.trustCreate({ packageName }) + + await npm.exec('trust', [ + 'buildkite', + '--yes', + '--org', 'npm', + '--pipeline', 'cli', + '--allow-publish', + ]) +}) + +t.test('buildkite missing package name', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: {}, + config: auth, + }) + + await t.rejects(npm.exec('trust', [ + 'buildkite', + '--yes', + '--organization', 'npm', + '--pipeline', 'cli', + '--allow-publish', + ]), { message: /Package name must be specified/ }) +}) + +t.test('buildkite missing organization', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: {}, + config: auth, + }) + + await t.rejects(npm.exec('trust', [ + 'buildkite', + packageName, + '--yes', + '--pipeline', 'cli', + '--allow-publish', + ]), { message: /organization is required/ }) +}) + +t.test('buildkite missing pipeline', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: {}, + config: auth, + }) + + await t.rejects(npm.exec('trust', [ + 'buildkite', + packageName, + '--yes', + '--organization', 'npm', + '--allow-publish', + ]), { message: /pipeline is required/ }) +}) + +t.test('optionsToBody maps Buildkite claims', t => { + const TrustBuildkite = require('../../../../lib/commands/trust/buildkite.js') + const body = TrustBuildkite.optionsToBody({ + organization: 'npm', + pipeline: 'cli', + }) + + t.strictSame(body, { + type: 'buildkite', + claims: { + organization_slug: 'npm', + pipeline_slug: 'cli', + }, + }) + t.end() +}) + +t.test('bodyToOptions maps Buildkite claims', t => { + const TrustBuildkite = require('../../../../lib/commands/trust/buildkite.js') + const options = TrustBuildkite.bodyToOptions({ + id: 'test-id', + type: 'buildkite', + claims: { + organization_slug: 'npm', + pipeline_slug: 'cli', + }, + }) + + t.strictSame(options, { + id: 'test-id', + type: 'buildkite', + organization: 'npm', + pipeline: 'cli', + }) + t.end() +}) diff --git a/test/lib/commands/trust/list.js b/test/lib/commands/trust/list.js index 8a66f390aaa31..3ef7d966b56d2 100644 --- a/test/lib/commands/trust/list.js +++ b/test/lib/commands/trust/list.js @@ -259,6 +259,39 @@ t.test('list with circleci trust type', async t => { await npm.exec('trust', ['list', packageName]) }) +t.test('list with buildkite trust type', async t => { + const { npm } = await loadMockNpm(t, { + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + }), + }, + config: { + '//registry.npmjs.org/:_authToken': 'test-auth-token', + }, + }) + + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: 'test-auth-token', + }) + registry.trustList({ + packageName, + body: [{ + id: 'test-id-1', + type: 'buildkite', + claims: { + organization_slug: 'npm', + pipeline_slug: 'cli', + }, + }], + }) + + await npm.exec('trust', ['list', packageName]) +}) + t.test('list with unknown trust type', async t => { const { npm } = await loadMockNpm(t, { prefixDir: {