diff --git a/lib/commands/publish.js b/lib/commands/publish.js index cc15087f0b368..6586e652c7b81 100644 --- a/lib/commands/publish.js +++ b/lib/commands/publish.js @@ -16,6 +16,7 @@ const { getContents, logTar } = require('../utils/tar.js') const { flatten } = require('@npmcli/config/lib/definitions') const pkgJson = require('@npmcli/package-json') const BaseCommand = require('../base-cmd.js') +const { oidc } = require('../../lib/utils/oidc.js') class Publish extends BaseCommand { static description = 'Publish a package' @@ -136,6 +137,9 @@ class Publish extends BaseCommand { npa(`${manifest.name}@${defaultTag}`) const registry = npmFetch.pickRegistry(resolved, opts) + + await oidc({ packageName: manifest.name, registry, opts, config: this.npm.config }) + const creds = this.npm.config.getCredentialsByURI(registry) const noCreds = !(creds.token || creds.username || creds.certfile && creds.keyfile) const outputRegistry = replaceInfo(registry) diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js new file mode 100644 index 0000000000000..694177eef18b6 --- /dev/null +++ b/lib/utils/oidc.js @@ -0,0 +1,169 @@ +const { log } = require('proc-log') +const npmFetch = require('npm-registry-fetch') +const ciInfo = require('ci-info') +const fetch = require('make-fetch-happen') +const npa = require('npm-package-arg') + +/** + * 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 + * and GitLab. 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. + * OIDC is always an optional feature, and the function should not throw if OIDC is not configured by the registry. + * + * @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. + */ +async function oidc ({ packageName, registry, opts, config }) { + /* + * This code should never run when people try to publish locally on their machines. + * It is designed to execute only in Continuous Integration (CI) environments. + */ + + try { + if (!( + /** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L152 */ + ciInfo.GITHUB_ACTIONS || + /** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L161C13-L161C22 */ + ciInfo.GITLAB + )) { + log.silly('oidc', 'Not running OIDC, not in a supported CI environment') + return undefined + } + + log.silly('oidc', 'Determining if npm should use OIDC publishing') + + /** + * Check if the environment variable `NPM_ID_TOKEN` is set. + * In GitLab CI, the ID token is provided via an environment variable, + * with `NPM_ID_TOKEN` serving as a predefined default. For consistency, + * all supported CI environments are expected to support this variable. + * In contrast, GitHub Actions uses a request-based approach to retrieve the ID token. + * The presence of this token within GitHub Actions will override the request-based approach. + * This variable follows the prefix/suffix convention from sigstore (e.g., `SIGSTORE_ID_TOKEN`). + * @see https://docs.sigstore.dev/cosign/signing/overview/ + */ + let idToken = process.env.NPM_ID_TOKEN + + if (idToken) { + log.silly('oidc', 'NPM_ID_TOKEN present') + } else { + log.silly('oidc', 'NPM_ID_TOKEN not present, checking for GITHUB_ACTIONS') + if (ciInfo.GITHUB_ACTIONS) { + /** + * GitHub Actions provides these environment variables: + * - `ACTIONS_ID_TOKEN_REQUEST_URL`: The URL to request the ID token. + * - `ACTIONS_ID_TOKEN_REQUEST_TOKEN`: The token to authenticate the request. + * Only when a workflow has the following permissions: + * ``` + * permissions: + * id-token: write + * ``` + * @see https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers#adding-permissions-settings + */ + if ( + process.env.ACTIONS_ID_TOKEN_REQUEST_URL && + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN + ) { + /** + * 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() + const response = await fetch(url.href, { + retry: opts.retry, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`, + }, + }) + + const elapsedTime = Date.now() - startTime + + log.http( + 'fetch', + `GET ${url.href} ${response.status} ${elapsedTime}ms` + ) + + const json = await response.json() + + if (!response.ok) { + log.verbose('oidc', `Failed to fetch id_token from GitHub: received an invalid response`) + return undefined + } + + if (!json.value) { + log.verbose('oidc', `Failed to fetch id_token from GitHub: missing value`) + return undefined + } + + idToken = json.value + } else { + log.silly('oidc', 'GITHUB_ACTIONS detected. If you intend to publish using OIDC, please set workflow permissions for `id-token: write`') + return undefined + } + } + } + + if (!idToken) { + log.silly('oidc', 'Exiting OIDC, no id_token available') + return undefined + } + + log.silly('oidc', `id_token has a length of ${idToken.length} characters`) + + const parsedRegistry = new URL(registry) + const regKey = `//${parsedRegistry.host}${parsedRegistry.pathname}` + const authTokenKey = `${regKey}:_authToken` + + const exitingToken = config.get(authTokenKey) + if (exitingToken) { + log.silly('oidc', 'Existing token found') + } else { + log.silly('oidc', 'No existing token found') + } + + const escapedPackageName = npa(packageName).escapedName + let response + try { + response = await npmFetch.json(new URL(`/-/npm/v1/oidc/token/exchange/package/${escapedPackageName}`, registry), { + ...opts, + [authTokenKey]: idToken, // Use the idToken as the auth token for the request + method: 'POST', + }) + } catch (error) { + if (error?.body?.message) { + log.verbose('oidc', `Registry body response error message "${error.body.message}"`) + } + return undefined + } + + if (!response?.token) { + log.verbose('oidc', 'OIDC token exchange failure: missing token in response body') + return undefined + } + /* + * The "opts" object is a clone of npm.flatOptions and is passed through the `publish` command, + * eventually reaching `otplease`. To ensure the token is accessible during the publishing process, + * it must be directly attached to the `opts` object. + * Additionally, the token is required by the "live" configuration or getters within `config`. + */ + opts[authTokenKey] = response.token + config.set(authTokenKey, response.token, 'user') + log.silly('oidc', `OIDC token successfully retrieved`) + } catch (error) { + /* istanbul ignore next */ + log.verbose('oidc', 'Failure checking OIDC config', error) + } + return undefined +} + +module.exports = { + oidc, +} diff --git a/mock-registry/lib/index.js b/mock-registry/lib/index.js index 8248631519054..65cf4b8983aa3 100644 --- a/mock-registry/lib/index.js +++ b/mock-registry/lib/index.js @@ -359,7 +359,7 @@ class MockRegistry { } publish (name, { - packageJson, access, noGet, noPut, putCode, manifest, packuments, + packageJson, access, noGet, noPut, putCode, manifest, packuments, token, } = {}) { if (!noGet) { // this getPackage call is used to get the latest semver version before publish @@ -373,7 +373,7 @@ class MockRegistry { } } if (!noPut) { - this.putPackage(name, { code: putCode, packageJson, access }) + this.putPackage(name, { code: putCode, packageJson, access, token }) } } @@ -391,10 +391,14 @@ class MockRegistry { this.nock = nock } - putPackage (name, { code = 200, resp = {}, ...putPackagePayload }) { - this.nock.put(`/${npa(name).escapedName}`, body => { + putPackage (name, { code = 200, resp = {}, token, ...putPackagePayload }) { + let n = this.nock.put(`/${npa(name).escapedName}`, body => { return this.#tap.match(body, this.putPackagePayload({ name, ...putPackagePayload })) - }).reply(code, resp) + }) + if (token) { + n = n.matchHeader('authorization', `Bearer ${token}`) + } + n.reply(code, resp) } putPackagePayload (opts) { @@ -626,6 +630,13 @@ class MockRegistry { } } } + + mockOidcTokenExchange ({ packageName, idToken, statusCode = 200, body } = {}) { + const encodedPackageName = npa(packageName).escapedName + this.nock.post(this.fullPath(`/-/npm/v1/oidc/token/exchange/package/${encodedPackageName}`)) + .matchHeader('authorization', `Bearer ${idToken}`) + .reply(statusCode, body || {}) + } } module.exports = MockRegistry diff --git a/test/fixtures/mock-oidc.js b/test/fixtures/mock-oidc.js new file mode 100644 index 0000000000000..4dd625e9744ce --- /dev/null +++ b/test/fixtures/mock-oidc.js @@ -0,0 +1,131 @@ +const nock = require('nock') +const ciInfo = require('ci-info') + +class MockOidc { + constructor (opts) { + const defaultOpts = { + github: false, + gitlab: false, + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://github.com/actions/id-token', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'ACTIONS_ID_TOKEN_REQUEST_TOKEN', + NPM_ID_TOKEN: 'NPM_ID_TOKEN', + GITHUB_ID_TOKEN: 'mock-github-id-token', + } + const options = { ...defaultOpts, ...opts } + + this.github = options.github + this.gitlab = options.gitlab + this.ACTIONS_ID_TOKEN_REQUEST_URL = options.ACTIONS_ID_TOKEN_REQUEST_URL + this.ACTIONS_ID_TOKEN_REQUEST_TOKEN = options.ACTIONS_ID_TOKEN_REQUEST_TOKEN + this.NPM_ID_TOKEN = options.NPM_ID_TOKEN + this.GITHUB_ID_TOKEN = options.GITHUB_ID_TOKEN + + // Backup only the relevant environment variables and ciInfo values + this.originalEnv = { + CI: process.env.CI, + GITHUB_ACTIONS: process.env.GITHUB_ACTIONS, + ACTIONS_ID_TOKEN_REQUEST_URL: process.env.ACTIONS_ID_TOKEN_REQUEST_URL, + ACTIONS_ID_TOKEN_REQUEST_TOKEN: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN, + GITLAB_CI: process.env.GITLAB_CI, + NPM_ID_TOKEN: process.env.NPM_ID_TOKEN, + } + this.originalCiInfo = { + GITLAB: ciInfo.GITLAB, + GITHUB_ACTIONS: ciInfo.GITHUB_ACTIONS, + } + this.setupEnvironment() + } + + get idToken () { + if (this.github) { + return this.GITHUB_ID_TOKEN + } + if (this.gitlab) { + return this.NPM_ID_TOKEN + } + return undefined + } + + setupEnvironment () { + delete process.env.CI + delete process.env.GITHUB_ACTIONS + delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL + delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN + delete process.env.GITLAB_CI + delete process.env.NPM_ID_TOKEN + + ciInfo.GITHUB_ACTIONS = false + ciInfo.GITLAB = false + + if (this.github) { + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = this.ACTIONS_ID_TOKEN_REQUEST_URL + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = this.ACTIONS_ID_TOKEN_REQUEST_TOKEN + ciInfo.GITHUB_ACTIONS = true + } + + if (this.gitlab) { + process.env.NPM_ID_TOKEN = this.NPM_ID_TOKEN + ciInfo.GITLAB = true + } + } + + mockGithubOidc ({ idToken = this.GITHUB_ID_TOKEN, audience, statusCode = 200 } = {}) { + const url = new URL(this.ACTIONS_ID_TOKEN_REQUEST_URL) + return nock(url.origin) + .get(url.pathname) + .query({ audience }) + .matchHeader('authorization', `Bearer ${this.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`) + .matchHeader('accept', 'application/json') + .reply(statusCode, statusCode !== 500 ? { value: idToken } : { message: 'Internal Server Error' }) + } + + reset () { + // Restore only the backed-up environment variables + for (const key in this.originalEnv) { + process.env[key] = this.originalEnv[key] + } + + // Restore the original ciInfo values + ciInfo.GITLAB = this.originalCiInfo.GITLAB + ciInfo.GITHUB_ACTIONS = this.originalCiInfo.GITHUB_ACTIONS + + nock.cleanAll() + } + + static tnock (t, opts = {}, { debug = false, strict = false } = {}) { + const instance = new MockOidc(opts) + + const noMatch = (req) => { + if (debug) { + /* eslint-disable-next-line no-console */ + console.error('NO MATCH', t.name, req.options ? req.options : req.path) + } + if (strict) { + t.comment(`Unmatched request: ${req.method} ${req.path}`) + t.fail(`Unmatched request: ${req.method} ${req.path}`) + } + } + + nock.emitter.on('no match', noMatch) + nock.disableNetConnect() + + if (strict) { + t.afterEach(() => { + t.strictSame(nock.pendingMocks(), [], 'no pending mocks after each') + }) + } + + t.teardown(() => { + nock.enableNetConnect() + nock.emitter.off('no match', noMatch) + nock.cleanAll() + instance.reset() + }) + + return instance + } +} + +module.exports = { + MockOidc, +} diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index 3d1d629e31ba4..13a284e0c1f5b 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -5,6 +5,7 @@ const pacote = require('pacote') const Arborist = require('@npmcli/arborist') const path = require('node:path') const fs = require('node:fs') +const { MockOidc } = require('../../fixtures/mock-oidc') const pkg = '@npmcli/test-package' const token = 'test-auth-token' @@ -988,3 +989,270 @@ t.test('semver highest dist tag', async t => { await npm.exec('publish', []) }) }) + +t.test('oidc token exchange', t => { + const oidcPublishTest = ({ + oidcOptions = {}, + packageName = '@npmcli/test-package', + config = {}, + packageJson = {}, + load = {}, + mockGithubOidcOptions = null, + mockOidcTokenExchangeOptions = null, + publishOptions = {}, + }) => { + return async (t) => { + const oidc = MockOidc.tnock(t, oidcOptions) + const { npm, registry } = await loadNpmWithRegistry(t, { + config, + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + ...packageJson, + }, null, 2), + }, + ...load, + }) + if (mockGithubOidcOptions) { + oidc.mockGithubOidc(mockGithubOidcOptions) + } + if (mockOidcTokenExchangeOptions) { + registry.mockOidcTokenExchange({ + packageName, + ...mockOidcTokenExchangeOptions, + }) + } + registry.publish(packageName, publishOptions) + await npm.exec('publish', []) + oidc.reset() + } + } + + // fallback failures + + t.test('oidc token 500 with fallback', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + statusCode: 500, + }, + publishOptions: { + token: 'existing-fallback-token', + }, + })) + + t.test('oidc token invalid body with fallback', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: null, + }, + publishOptions: { + token: 'existing-fallback-token', + }, + })) + + t.test('token exchange 500 with fallback', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: 'github-jwt-id-token', + }, + mockOidcTokenExchangeOptions: { + statusCode: 500, + idToken: 'github-jwt-id-token', + body: { + message: 'oidc token exchange failed', + }, + }, + publishOptions: { + token: 'existing-fallback-token', + }, + })) + + t.test('token exchange 500 (with no body message) with fallback', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: 'github-jwt-id-token', + }, + mockOidcTokenExchangeOptions: { + statusCode: 500, + idToken: 'github-jwt-id-token', + }, + publishOptions: { + token: 'existing-fallback-token', + }, + })) + + t.test('token exchange invalid body with fallback', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: 'github-jwt-id-token', + }, + mockOidcTokenExchangeOptions: { + token: null, + idToken: 'github-jwt-id-token', + }, + publishOptions: { + token: 'existing-fallback-token', + }, + })) + + t.test('github + missing ACTIONS_ID_TOKEN_REQUEST_URL', oidcPublishTest({ + oidcOptions: { github: true, ACTIONS_ID_TOKEN_REQUEST_URL: '' }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + publishOptions: { + token: 'existing-fallback-token', + }, + })) + + t.test('gitlab + missing NPM_ID_TOKEN', oidcPublishTest({ + oidcOptions: { gitlab: true, NPM_ID_TOKEN: '' }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + publishOptions: { + token: 'existing-fallback-token', + }, + })) + + t.test('no ci', oidcPublishTest({ + oidcOptions: { github: false, gitlab: false }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + publishOptions: { + token: 'existing-fallback-token', + }, + })) + + // default registry success + + t.test('default registry success github', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: 'github-jwt-id-token', + }, + mockOidcTokenExchangeOptions: { + idToken: 'github-jwt-id-token', + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + })) + + t.test('default registry success gitlab', oidcPublishTest({ + oidcOptions: { gitlab: true, NPM_ID_TOKEN: 'gitlab-jwt-id-token' }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockOidcTokenExchangeOptions: { + idToken: 'gitlab-jwt-id-token', + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + })) + + // custom registry success + + t.test('custom registry (config) success github', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + registry: 'https://registry.zzz.org', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.zzz.org', + idToken: 'github-jwt-id-token', + }, + mockOidcTokenExchangeOptions: { + idToken: 'github-jwt-id-token', + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + })) + + t.test('custom registry (scoped config) success github', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '@npmcli:registry': 'https://registry.zzz.org', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.zzz.org', + idToken: 'github-jwt-id-token', + }, + mockOidcTokenExchangeOptions: { + idToken: 'github-jwt-id-token', + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + load: { + registry: 'https://registry.zzz.org', + }, + })) + + t.test('custom registry (publishConfig) success github', oidcPublishTest({ + oidcOptions: { github: true }, + packageJson: { + publishConfig: { + registry: 'https://registry.zzz.org', + }, + }, + mockGithubOidcOptions: { + audience: 'npm:registry.zzz.org', + idToken: 'github-jwt-id-token', + }, + mockOidcTokenExchangeOptions: { + idToken: 'github-jwt-id-token', + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + load: { + registry: 'https://registry.zzz.org', + }, + })) + + t.end() +})