From 9021253a971a5cff4271fa36c3f5bc21d765b11f Mon Sep 17 00:00:00 2001 From: reggi Date: Fri, 27 Jun 2025 13:21:15 -0400 Subject: [PATCH 01/14] feat: adds support for oidc publish --- lib/commands/publish.js | 4 + lib/utils/oidc.js | 170 +++++++++++++++++++++++++ mock-registry/lib/index.js | 21 +++- test/fixtures/mock-oidc.js | 131 +++++++++++++++++++ test/lib/commands/publish.js | 237 +++++++++++++++++++++++++++++++++++ 5 files changed, 558 insertions(+), 5 deletions(-) create mode 100644 lib/utils/oidc.js create mode 100644 test/fixtures/mock-oidc.js 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..fd88cc844040f --- /dev/null +++ b/lib/utils/oidc.js @@ -0,0 +1,170 @@ +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 + )) { + 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 + ) { + log.silly('oidc', '"GITHUB_ACTIONS" detected with "ACTIONS_ID_" envs, fetching id_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}` + log.silly('oidc', `Using audience: ${audience}`) + 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) { + throw new Error(`Failed to fetch id_token from GitHub: received an invalid response`) + } + + if (!json.value) { + throw new Error(`Failed to fetch id_token from GitHub: missing value`) + } + + log.silly('oidc', 'GITHUB_ACTIONS valid fetch response for id_token') + idToken = json.value + } else { + throw new Error('GITHUB_ACTIONS detected. If you intend to publish using OIDC, please set workflow permissions for `id-token: write`') + } + } + } + + 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 + const 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', + headers: { + ...opts.headers, + 'Content-Type': 'application/json', + // this will not work because the existing auth token will replace it. + // authorization: `Bearer ${idToken}`, + }, + }) + + if (!response?.token) { + throw new Error('OIDC token exchange failure: missing token in response body') + } + /* + * 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) { + log.verbose('oidc', error.message) + if (error?.body?.message) { + log.verbose('oidc', `Registry body response error message "${error.body.message}"`) + } + } + return undefined +} + +module.exports = { + oidc, +} diff --git a/mock-registry/lib/index.js b/mock-registry/lib/index.js index 8248631519054..f442499627042 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, token, statusCode = 200 } = {}) { + const encodedPackageName = npa(packageName).escapedName + this.nock.post(this.fullPath(`/-/npm/v1/oidc/token/exchange/package/${encodedPackageName}`)) + .matchHeader('authorization', `Bearer ${idToken}`) + .reply(statusCode, statusCode !== 500 ? { token } : { message: 'Internal Server Error' }) + } } 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..7b08a644adae5 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,239 @@ 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', + }, + 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', + 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', + 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', + 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', + 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', + token: 'exchange-token', + }, + publishOptions: { + token: 'exchange-token', + }, + load: { + registry: 'https://registry.zzz.org', + }, + })) + + t.end() +}) From 2ffc2eaa6241b96b56b128d886fcc5a76f58ff8b Mon Sep 17 00:00:00 2001 From: Reggi Date: Fri, 27 Jun 2025 16:02:08 -0400 Subject: [PATCH 02/14] fix: change to the oidc flow for more granular control over log levels (#8399) Co-authored-by: Chris Sidi --- lib/utils/oidc.js | 57 ++++++++++++++++++++---------------- mock-registry/lib/index.js | 4 +-- test/lib/commands/publish.js | 41 ++++++++++++++++++++++---- 3 files changed, 70 insertions(+), 32 deletions(-) diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index fd88cc844040f..279b6335da352 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -30,6 +30,7 @@ async function oidc ({ packageName, registry, opts, config }) { /** @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 } @@ -67,14 +68,11 @@ async function oidc ({ packageName, registry, opts, config }) { process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN ) { - log.silly('oidc', '"GITHUB_ACTIONS" detected with "ACTIONS_ID_" envs, fetching id_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}` - log.silly('oidc', `Using audience: ${audience}`) const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL) url.searchParams.append('audience', audience) const startTime = Date.now() @@ -96,17 +94,19 @@ async function oidc ({ packageName, registry, opts, config }) { const json = await response.json() if (!response.ok) { - throw new Error(`Failed to fetch id_token from GitHub: received an invalid response`) + log.verbose('oidc', `Failed to fetch id_token from GitHub: received an invalid response`) + return undefined } if (!json.value) { - throw new Error(`Failed to fetch id_token from GitHub: missing value`) + log.verbose('oidc', `Failed to fetch id_token from GitHub: missing value`) + return undefined } - log.silly('oidc', 'GITHUB_ACTIONS valid fetch response for id_token') idToken = json.value } else { - throw new Error('GITHUB_ACTIONS detected. If you intend to publish using OIDC, please set workflow permissions for `id-token: write`') + log.silly('oidc', 'GITHUB_ACTIONS detected. If you intend to publish using OIDC, please set workflow permissions for `id-token: write`') + return undefined } } } @@ -130,22 +130,31 @@ async function oidc ({ packageName, registry, opts, config }) { } const escapedPackageName = npa(packageName).escapedName - const 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', - headers: { - ...opts.headers, - 'Content-Type': 'application/json', - // this will not work because the existing auth token will replace it. - // authorization: `Bearer ${idToken}`, - }, - }) + 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', + headers: { + ...opts.headers, + 'Content-Type': 'application/json', + // this will not work because the existing auth token will replace it. + // authorization: `Bearer ${idToken}`, + }, + }) + } catch (error) { + if (error?.body?.message) { + log.verbose('oidc', `Registry body response error message "${error.body.message}"`) + } + return undefined + } if (!response?.token) { - throw new Error('OIDC token exchange failure: missing token in response body') + 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, @@ -157,10 +166,8 @@ async function oidc ({ packageName, registry, opts, config }) { config.set(authTokenKey, response.token, 'user') log.silly('oidc', `OIDC token successfully retrieved`) } catch (error) { - log.verbose('oidc', error.message) - if (error?.body?.message) { - log.verbose('oidc', `Registry body response error message "${error.body.message}"`) - } + /* istanbul ignore next */ + log.verbose('oidc', 'Failure checking OIDC config', error) } return undefined } diff --git a/mock-registry/lib/index.js b/mock-registry/lib/index.js index f442499627042..65cf4b8983aa3 100644 --- a/mock-registry/lib/index.js +++ b/mock-registry/lib/index.js @@ -631,11 +631,11 @@ class MockRegistry { } } - mockOidcTokenExchange ({ packageName, idToken, token, statusCode = 200 } = {}) { + 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, statusCode !== 500 ? { token } : { message: 'Internal Server Error' }) + .reply(statusCode, body || {}) } } diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index 7b08a644adae5..13a284e0c1f5b 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -1060,6 +1060,27 @@ t.test('oidc token exchange', t => { })) 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', @@ -1138,7 +1159,9 @@ t.test('oidc token exchange', t => { }, mockOidcTokenExchangeOptions: { idToken: 'github-jwt-id-token', - token: 'exchange-token', + body: { + token: 'exchange-token', + }, }, publishOptions: { token: 'exchange-token', @@ -1152,7 +1175,9 @@ t.test('oidc token exchange', t => { }, mockOidcTokenExchangeOptions: { idToken: 'gitlab-jwt-id-token', - token: 'exchange-token', + body: { + token: 'exchange-token', + }, }, publishOptions: { token: 'exchange-token', @@ -1172,7 +1197,9 @@ t.test('oidc token exchange', t => { }, mockOidcTokenExchangeOptions: { idToken: 'github-jwt-id-token', - token: 'exchange-token', + body: { + token: 'exchange-token', + }, }, publishOptions: { token: 'exchange-token', @@ -1190,7 +1217,9 @@ t.test('oidc token exchange', t => { }, mockOidcTokenExchangeOptions: { idToken: 'github-jwt-id-token', - token: 'exchange-token', + body: { + token: 'exchange-token', + }, }, publishOptions: { token: 'exchange-token', @@ -1213,7 +1242,9 @@ t.test('oidc token exchange', t => { }, mockOidcTokenExchangeOptions: { idToken: 'github-jwt-id-token', - token: 'exchange-token', + body: { + token: 'exchange-token', + }, }, publishOptions: { token: 'exchange-token', From 83ef001d2cd5f2a80171d98779dd2b1870674e63 Mon Sep 17 00:00:00 2001 From: Reggi Date: Mon, 30 Jun 2025 10:46:04 -0400 Subject: [PATCH 03/14] Small change to registry fetch options I don't believe we need to destructure the objects as we we're doing. --- lib/utils/oidc.js | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index 279b6335da352..694177eef18b6 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -133,17 +133,9 @@ async function oidc ({ packageName, registry, opts, config }) { 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 - }, + ...opts, + [authTokenKey]: idToken, // Use the idToken as the auth token for the request method: 'POST', - headers: { - ...opts.headers, - 'Content-Type': 'application/json', - // this will not work because the existing auth token will replace it. - // authorization: `Bearer ${idToken}`, - }, }) } catch (error) { if (error?.body?.message) { From 567f15b160feaced1b49d4cc73f45cd1c86f849a Mon Sep 17 00:00:00 2001 From: Reggi Date: Mon, 14 Jul 2025 11:01:20 -0400 Subject: [PATCH 04/14] feat: npm view oidc considerations (#8432) --- lib/commands/view.js | 4 +++- test/lib/commands/view.js | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/commands/view.js b/lib/commands/view.js index eb6f0fcab8e6a..3d62c2e9083d8 100644 --- a/lib/commands/view.js +++ b/lib/commands/view.js @@ -448,10 +448,12 @@ function cleanup (data) { } const keys = Object.keys(data) + if (keys.length <= 3 && data.name && ( (keys.length === 1) || (keys.length === 3 && data.email && data.url) || - (keys.length === 2 && (data.email || data.url)) + (keys.length === 2 && (data.email || data.url)) || + data.trustedPublisher )) { data = unparsePerson(data) } diff --git a/test/lib/commands/view.js b/test/lib/commands/view.js index e2ef35a5fd5b7..5b63cecf7daf7 100644 --- a/test/lib/commands/view.js +++ b/test/lib/commands/view.js @@ -126,6 +126,34 @@ const packument = (nv, opts) => { '1.0.1': {}, }, }, + 'cyan-oidc': { + _npmUser: { + name: 'claudia', + email: 'claudia@cyan.com', + trustedPublisher: { + id: 'github', + oidcConfigId: 'oidc:a0e127d0-8d66-45d0-8264-e4f8372c7249', + }, + }, + name: 'cyan', + 'dist-tags': { + latest: '1.0.0', + }, + versions: { + '1.0.0': { + version: '1.0.0', + name: 'cyan', + dist: { + shasum: '123', + tarball: 'http://hm.cyan.com/1.0.0.tgz', + integrity: '---', + fileCount: 1, + unpackedSize: 1000000, + }, + }, + '1.0.1': {}, + }, + }, brown: { name: 'brown', }, @@ -438,6 +466,12 @@ t.test('package with --json and semver range', async t => { t.matchSnapshot(joinedOutput()) }) +t.test('package with _npmUser.trustedPublisher shows cleaned up property with --json', async t => { + const { view, joinedOutput } = await loadMockNpm(t, { config: { json: true } }) + await view.exec(['cyan-oidc@^1.0.0']) + t.match(joinedOutput(), /claudia /, 'uses oidc trustedPublisher info for _npmUser') +}) + t.test('package with --json and no versions', async t => { const { view, joinedOutput } = await loadMockNpm(t, { config: { json: true } }) await view.exec(['brown']) From e4ad90c35565e626ce7469dbb2a933944ffb2e1e Mon Sep 17 00:00:00 2001 From: Reggi Date: Mon, 14 Jul 2025 11:01:43 -0400 Subject: [PATCH 05/14] feat: oidc provenance by default (#8412) This PR adds "auto" or "default" provenance to publishes that use OIDC within github and gitlab. It does this by checking the OIDC id token payload and checking if the current repo's visibility is public or private if it's public we do the equivalent of adding the `--provenance` flag. --- lib/utils/oidc.js | 34 +++- mock-registry/lib/index.js | 6 +- mock-registry/lib/provenance.js | 99 ++++++++++ test/fixtures/mock-oidc.js | 36 ++++ test/lib/commands/publish.js | 253 +++++++++++++++++++----- workspaces/libnpmpublish/lib/publish.js | 2 +- 6 files changed, 372 insertions(+), 58 deletions(-) create mode 100644 mock-registry/lib/provenance.js diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index 694177eef18b6..b13a440d84fc6 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -116,14 +116,44 @@ async function oidc ({ packageName, registry, opts, config }) { return undefined } + // this checks if the user configured provenance or it's the default unset value + const isDefaultProvenance = config.isDefault('provenance') + const provenanceIntent = config.get('provenance') + const skipProvenance = isDefaultProvenance || provenanceIntent + + if (skipProvenance) { + const [headerB64, payloadB64] = idToken.split('.') + let isPublicRepo = false + if (headerB64 && payloadB64) { + const payloadJson = Buffer.from(payloadB64, 'base64').toString('utf8') + try { + const payload = JSON.parse(payloadJson) + if (ciInfo.GITHUB_ACTIONS && payload.repository_visibility === 'public') { + isPublicRepo = true + } + if (ciInfo.GITLAB && payload.project_visibility === 'public') { + isPublicRepo = true + } + } catch (e) { + log.silly('oidc', 'Failed to parse idToken payload as JSON') + } + } + + if (isPublicRepo) { + log.silly('oidc', 'Repository is public, setting provenance') + opts.provenance = true + config.set('provenance', true, 'user') + } + } + 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) { + const existingToken = config.get(authTokenKey) + if (existingToken) { log.silly('oidc', 'Existing token found') } else { log.silly('oidc', 'No existing token found') diff --git a/mock-registry/lib/index.js b/mock-registry/lib/index.js index 65cf4b8983aa3..31ae2679c0e98 100644 --- a/mock-registry/lib/index.js +++ b/mock-registry/lib/index.js @@ -80,7 +80,11 @@ class MockRegistry { // XXX: this is opt-in currently because it breaks some existing CLI // tests. We should work towards making this the default for all tests. t.comment(logReq(req, 'interceptors', 'socket', 'response', '_events')) - t.fail(`Unmatched request: ${req.method} ${req.path}`) + const protocol = req?.options?.protocol || 'http:' + const hostname = req?.options?.hostname || req?.hostname || 'localhost' + const p = req?.path || '/' + const url = new URL(p, `${protocol}//${hostname}`).toString() + t.fail(`Unmatched request: ${req.method} ${url}`) } } diff --git a/mock-registry/lib/provenance.js b/mock-registry/lib/provenance.js new file mode 100644 index 0000000000000..108c158efb9a8 --- /dev/null +++ b/mock-registry/lib/provenance.js @@ -0,0 +1,99 @@ +'use strict' +const t = require('tap') +const mockGlobals = require('@npmcli/mock-globals') +const nock = require('nock') + +class MockProvenance { + static sigstoreIdToken () { + return `.${Buffer.from(JSON.stringify({ + iss: 'https://oauth2.sigstore.dev/auth', + email: 'foo@bar.com', + })).toString('base64')}.` + } + + static successfulNock ({ + oidcURL, + requestToken, + workflowPath, + repository, + serverUrl, + ref, + sha, + runID, + runAttempt, + runnerEnv, + }) { + mockGlobals(t, { + 'process.env': { + CI: true, + GITHUB_ACTIONS: true, + ACTIONS_ID_TOKEN_REQUEST_URL: oidcURL, + ACTIONS_ID_TOKEN_REQUEST_TOKEN: requestToken, + GITHUB_WORKFLOW_REF: `${repository}/${workflowPath}@${ref}`, + GITHUB_REPOSITORY: repository, + GITHUB_SERVER_URL: serverUrl, + GITHUB_REF: ref, + GITHUB_SHA: sha, + GITHUB_RUN_ID: runID, + GITHUB_RUN_ATTEMPT: runAttempt, + RUNNER_ENVIRONMENT: runnerEnv, + }, + }) + + const idToken = this.sigstoreIdToken() + + const url = new URL(oidcURL) + nock(url.origin) + .get(url.pathname) + .query({ audience: 'sigstore' }) + .matchHeader('authorization', `Bearer ${requestToken}`) + .matchHeader('accept', 'application/json') + .reply(200, { value: idToken }) + + const leafCertificate = `-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n` + + // Mock the Fulcio signing certificate endpoint + nock('https://fulcio.sigstore.dev') + .post('/api/v2/signingCert') + .reply(200, { + signedCertificateEmbeddedSct: { + chain: { + certificates: [ + leafCertificate, + `-----BEGIN CERTIFICATE-----\nxyz\n-----END CERTIFICATE-----\n`, + ], + }, + }, + }) + + nock('https://rekor.sigstore.dev') + .post('/api/v1/log/entries') + .reply(201, { + '69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6': { + body: Buffer.from(JSON.stringify({ + kind: 'hashedrekord', + apiVersion: '0.0.1', + spec: { + signature: { + content: 'ABC123', + publicKey: { content: Buffer.from(leafCertificate).toString('base64') }, + }, + }, + })).toString( + 'base64' + ), + integratedTime: 1654015743, + logID: + 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d', + logIndex: 2513258, + verification: { + signedEntryTimestamp: 'MEUCIQD6CD7ZNLUipFoxzmSL/L8Ewic4SRkXN77UjfJZ7d/wAAIgatokSuX9Rg0iWxAgSfHMtcsagtDCQalU5IvXdQ+yLEA=', + }, + }, + }) + } +} + +module.exports = { + MockProvenance, +} diff --git a/test/fixtures/mock-oidc.js b/test/fixtures/mock-oidc.js index 4dd625e9744ce..eaddb8f783663 100644 --- a/test/fixtures/mock-oidc.js +++ b/test/fixtures/mock-oidc.js @@ -1,6 +1,35 @@ const nock = require('nock') const ciInfo = require('ci-info') +// this is an effort to not add a dependency to the cli just for testing +function makeJwt (payload) { + const header = { alg: 'none', typ: 'JWT' } + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64') + const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64') + // empty signature section + return `${headerB64}.${payloadB64}.` +} + +function gitlabIdToken ({ visibility = 'public' } = { visibility: 'public' }) { + const now = Math.floor(Date.now() / 1000) + const payload = { + project_visibility: visibility, + iat: now, + exp: now + 3600, // 1 hour expiration + } + return makeJwt(payload) +} + +function githubIdToken ({ visibility = 'public' } = { visibility: 'public' }) { + const now = Math.floor(Date.now() / 1000) + const payload = { + repository_visibility: visibility, + iat: now, + exp: now + 3600, // 1 hour expiration + } + return makeJwt(payload) +} + class MockOidc { constructor (opts) { const defaultOpts = { @@ -17,6 +46,8 @@ class MockOidc { 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.SIGSTORE_ID_TOKEN = options.SIGSTORE_ID_TOKEN + this.NPM_ID_TOKEN = options.NPM_ID_TOKEN this.GITHUB_ID_TOKEN = options.GITHUB_ID_TOKEN @@ -28,6 +59,7 @@ class MockOidc { 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, + SIGSTORE_ID_TOKEN: process.env.SIGSTORE_ID_TOKEN, } this.originalCiInfo = { GITLAB: ciInfo.GITLAB, @@ -53,6 +85,7 @@ class MockOidc { delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN delete process.env.GITLAB_CI delete process.env.NPM_ID_TOKEN + delete process.env.SIGSTORE_ID_TOKEN ciInfo.GITHUB_ACTIONS = false ciInfo.GITLAB = false @@ -65,6 +98,7 @@ class MockOidc { if (this.gitlab) { process.env.NPM_ID_TOKEN = this.NPM_ID_TOKEN + process.env.SIGSTORE_ID_TOKEN = this.SIGSTORE_ID_TOKEN ciInfo.GITLAB = true } } @@ -128,4 +162,6 @@ class MockOidc { module.exports = { MockOidc, + gitlabIdToken, + githubIdToken, } diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index 13a284e0c1f5b..a705b016eff9a 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -5,7 +5,8 @@ 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 { MockOidc, githubIdToken, gitlabIdToken } = require('../../fixtures/mock-oidc') +const { MockProvenance } = require('@npmcli/mock-registry/lib/provenance') const pkg = '@npmcli/test-package' const token = 'test-auth-token' @@ -990,46 +991,67 @@ t.test('semver highest dist tag', async t => { }) }) -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, +const oidcPublishTest = ({ + oidcOptions = {}, + packageName = '@npmcli/test-package', + config = {}, + packageJson = {}, + load = {}, + mockGithubOidcOptions = null, + mockOidcTokenExchangeOptions = null, + publishOptions = {}, + provenance = false, +}) => { + 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) + + if ((oidc.github || oidc.gitlab) && provenance) { + registry.getVisibility({ spec: packageName, visibility: { public: true } }) + + MockProvenance.successfulNock({ + oidcURL: oidc.ACTIONS_ID_TOKEN_REQUEST_URL, + requestToken: oidc.ACTIONS_ID_TOKEN_REQUEST_TOKEN, + workflowPath: '.github/workflows/publish.yml', + repository: 'github/foo', + serverUrl: 'https://github.com', + ref: 'refs/tags/pkg@1.0.0', + sha: 'deadbeef', + runID: '123456', + runAttempt: '1', + runnerEnv: 'github-hosted', }) - if (mockGithubOidcOptions) { - oidc.mockGithubOidc(mockGithubOidcOptions) - } - if (mockOidcTokenExchangeOptions) { - registry.mockOidcTokenExchange({ - packageName, - ...mockOidcTokenExchangeOptions, - }) - } - registry.publish(packageName, publishOptions) - await npm.exec('publish', []) - oidc.reset() } + + await npm.exec('publish', []) + + oidc.reset() } +} - // fallback failures +t.test('oidc token exchange - no provenance', t => { + const githubPrivateIdToken = githubIdToken({ visibility: 'private' }) + const gitlabPrivateIdToken = gitlabIdToken({ visibility: 'private' }) t.test('oidc token 500 with fallback', oidcPublishTest({ oidcOptions: { github: true }, @@ -1066,11 +1088,11 @@ t.test('oidc token exchange', t => { }, mockGithubOidcOptions: { audience: 'npm:registry.npmjs.org', - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, }, mockOidcTokenExchangeOptions: { statusCode: 500, - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, body: { message: 'oidc token exchange failed', }, @@ -1087,11 +1109,12 @@ t.test('oidc token exchange', t => { }, mockGithubOidcOptions: { audience: 'npm:registry.npmjs.org', - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, }, mockOidcTokenExchangeOptions: { + idToken: githubPrivateIdToken, statusCode: 500, - idToken: 'github-jwt-id-token', + body: undefined, }, publishOptions: { token: 'existing-fallback-token', @@ -1105,11 +1128,13 @@ t.test('oidc token exchange', t => { }, mockGithubOidcOptions: { audience: 'npm:registry.npmjs.org', - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, }, mockOidcTokenExchangeOptions: { - token: null, - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, + body: { + token: null, + }, }, publishOptions: { token: 'existing-fallback-token', @@ -1155,10 +1180,10 @@ t.test('oidc token exchange', t => { }, mockGithubOidcOptions: { audience: 'npm:registry.npmjs.org', - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, }, mockOidcTokenExchangeOptions: { - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, body: { token: 'exchange-token', }, @@ -1169,12 +1194,12 @@ t.test('oidc token exchange', t => { })) t.test('default registry success gitlab', oidcPublishTest({ - oidcOptions: { gitlab: true, NPM_ID_TOKEN: 'gitlab-jwt-id-token' }, + oidcOptions: { gitlab: true, NPM_ID_TOKEN: gitlabPrivateIdToken }, config: { '//registry.npmjs.org/:_authToken': 'existing-fallback-token', }, mockOidcTokenExchangeOptions: { - idToken: 'gitlab-jwt-id-token', + idToken: gitlabPrivateIdToken, body: { token: 'exchange-token', }, @@ -1193,10 +1218,10 @@ t.test('oidc token exchange', t => { }, mockGithubOidcOptions: { audience: 'npm:registry.zzz.org', - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, }, mockOidcTokenExchangeOptions: { - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, body: { token: 'exchange-token', }, @@ -1213,10 +1238,10 @@ t.test('oidc token exchange', t => { }, mockGithubOidcOptions: { audience: 'npm:registry.zzz.org', - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, }, mockOidcTokenExchangeOptions: { - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, body: { token: 'exchange-token', }, @@ -1238,10 +1263,10 @@ t.test('oidc token exchange', t => { }, mockGithubOidcOptions: { audience: 'npm:registry.zzz.org', - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, }, mockOidcTokenExchangeOptions: { - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, body: { token: 'exchange-token', }, @@ -1256,3 +1281,123 @@ t.test('oidc token exchange', t => { t.end() }) + +t.test('oidc token exchange -- provenance', (t) => { + const githubPublicIdToken = githubIdToken({ visibility: 'public' }) + const gitlabPublicIdToken = gitlabIdToken({ visibility: 'public' }) + const SIGSTORE_ID_TOKEN = MockProvenance.sigstoreIdToken() + + 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: githubPublicIdToken, + }, + mockOidcTokenExchangeOptions: { + idToken: githubPublicIdToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + provenance: true, + })) + + t.test('default registry success gitlab', oidcPublishTest({ + oidcOptions: { gitlab: true, NPM_ID_TOKEN: gitlabPublicIdToken, SIGSTORE_ID_TOKEN }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockOidcTokenExchangeOptions: { + idToken: gitlabPublicIdToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + provenance: true, + })) + + t.test('setting provenance true in config should enable provenance', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + provenance: true, + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: githubPublicIdToken, + }, + mockOidcTokenExchangeOptions: { + idToken: githubPublicIdToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + provenance: true, + })) + + t.test('setting provenance false in config should not use provenance', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + provenance: false, + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: githubPublicIdToken, + }, + mockOidcTokenExchangeOptions: { + idToken: githubPublicIdToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + })) + + const brokenJwts = [ + 'x.invalid-jwt.x', + 'x.invalid-jwt.', + 'x.invalid-jwt', + 'x.', + 'x', + ] + + brokenJwts.map((brokenJwt) => { + // windows does not like `.` in the filename + t.test(`broken jwt ${brokenJwt.replaceAll('.', '_')}`, oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: brokenJwt, + }, + mockOidcTokenExchangeOptions: { + idToken: brokenJwt, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + })) + }) + + t.end() +}) diff --git a/workspaces/libnpmpublish/lib/publish.js b/workspaces/libnpmpublish/lib/publish.js index 001dff8de87f0..933e142422b6c 100644 --- a/workspaces/libnpmpublish/lib/publish.js +++ b/workspaces/libnpmpublish/lib/publish.js @@ -205,7 +205,7 @@ const ensureProvenanceGeneration = async (registry, spec, opts) => { if (opts.access !== 'public') { try { const res = await npmFetch - .json(`${registry}/-/package/${spec.escapedName}/visibility`, opts) + .json(`/-/package/${spec.escapedName}/visibility`, { ...opts, registry }) visibility = res } catch (err) { if (err.code !== 'E404') { From 3d0ee62b94a5aa5fb8474d93ba3a0bd10b171ce7 Mon Sep 17 00:00:00 2001 From: reggi Date: Mon, 14 Jul 2025 11:21:52 -0400 Subject: [PATCH 06/14] give it a dry-run test --- test/lib/commands/publish.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index a705b016eff9a..0915ae212e4bd 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -1024,6 +1024,7 @@ const oidcPublishTest = ({ ...mockOidcTokenExchangeOptions, }) } + registry.publish(packageName, publishOptions) if ((oidc.github || oidc.gitlab) && provenance) { @@ -1279,6 +1280,27 @@ t.test('oidc token exchange - no provenance', t => { }, })) + t.test('dry-run can be used to check oidc config but not publish', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + 'dry-run': true, + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: githubPrivateIdToken, + }, + mockOidcTokenExchangeOptions: { + idToken: githubPrivateIdToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + noPut: true, + }, + })) + t.end() }) From 19c25b92fe54b94ca6ec2b8e2de485d7b182531a Mon Sep 17 00:00:00 2001 From: Reggi Date: Thu, 17 Jul 2025 12:23:08 -0400 Subject: [PATCH 07/14] fix: checks for sigstore env for gitlab provenance (#8439) small update to allow gitlab to NOT REQUIRE provenance --- lib/utils/oidc.js | 11 ++++++----- test/fixtures/mock-oidc.js | 25 ++++++++++++++++++++----- test/lib/commands/publish.js | 17 +++++++++++++++++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index b13a440d84fc6..5569154457ca8 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -123,23 +123,24 @@ async function oidc ({ packageName, registry, opts, config }) { if (skipProvenance) { const [headerB64, payloadB64] = idToken.split('.') - let isPublicRepo = false + let enableProvenance = false if (headerB64 && payloadB64) { const payloadJson = Buffer.from(payloadB64, 'base64').toString('utf8') try { const payload = JSON.parse(payloadJson) if (ciInfo.GITHUB_ACTIONS && payload.repository_visibility === 'public') { - isPublicRepo = true + enableProvenance = true } - if (ciInfo.GITLAB && payload.project_visibility === 'public') { - isPublicRepo = true + // only set provenance for gitlab if SIGSTORE_ID_TOKEN is available + if (ciInfo.GITLAB && payload.project_visibility === 'public' && process.env.SIGSTORE_ID_TOKEN) { + enableProvenance = true } } catch (e) { log.silly('oidc', 'Failed to parse idToken payload as JSON') } } - if (isPublicRepo) { + if (enableProvenance) { log.silly('oidc', 'Repository is public, setting provenance') opts.provenance = true config.set('provenance', true, 'user') diff --git a/test/fixtures/mock-oidc.js b/test/fixtures/mock-oidc.js index eaddb8f783663..8a9c5358e06de 100644 --- a/test/fixtures/mock-oidc.js +++ b/test/fixtures/mock-oidc.js @@ -39,6 +39,7 @@ class MockOidc { ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'ACTIONS_ID_TOKEN_REQUEST_TOKEN', NPM_ID_TOKEN: 'NPM_ID_TOKEN', GITHUB_ID_TOKEN: 'mock-github-id-token', + SIGSTORE_ID_TOKEN: undefined, } const options = { ...defaultOpts, ...opts } @@ -61,6 +62,7 @@ class MockOidc { NPM_ID_TOKEN: process.env.NPM_ID_TOKEN, SIGSTORE_ID_TOKEN: process.env.SIGSTORE_ID_TOKEN, } + this.originalCiInfo = { GITLAB: ciInfo.GITLAB, GITHUB_ACTIONS: ciInfo.GITHUB_ACTIONS, @@ -91,14 +93,22 @@ class MockOidc { 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 + if (typeof this.ACTIONS_ID_TOKEN_REQUEST_URL === 'string') { + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = this.ACTIONS_ID_TOKEN_REQUEST_URL + } + if (typeof this.ACTIONS_ID_TOKEN_REQUEST_TOKEN === 'string') { + 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 - process.env.SIGSTORE_ID_TOKEN = this.SIGSTORE_ID_TOKEN + if (typeof this.NPM_ID_TOKEN === 'string') { + process.env.NPM_ID_TOKEN = this.NPM_ID_TOKEN + } + if (typeof this.SIGSTORE_ID_TOKEN === 'string') { + process.env.SIGSTORE_ID_TOKEN = this.SIGSTORE_ID_TOKEN + } ciInfo.GITLAB = true } } @@ -115,8 +125,13 @@ class MockOidc { reset () { // Restore only the backed-up environment variables + for (const key in this.originalEnv) { - process.env[key] = this.originalEnv[key] + if (typeof this.originalEnv[key] === 'string') { + process.env[key] = this.originalEnv[key] + } else { + delete process.env[key] + } } // Restore the original ciInfo values diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index 0915ae212e4bd..d62fb380d1d7a 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -1347,6 +1347,23 @@ t.test('oidc token exchange -- provenance', (t) => { provenance: true, })) + t.test('default registry success gitlab without SIGSTORE_ID_TOKEN', oidcPublishTest({ + oidcOptions: { gitlab: true, NPM_ID_TOKEN: gitlabPublicIdToken }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockOidcTokenExchangeOptions: { + idToken: gitlabPublicIdToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + provenance: false, + })) + t.test('setting provenance true in config should enable provenance', oidcPublishTest({ oidcOptions: { github: true }, config: { From 3874ef312381e80b65628aa192da5e4ac2954755 Mon Sep 17 00:00:00 2001 From: Reggi Date: Mon, 21 Jul 2025 14:53:43 -0400 Subject: [PATCH 08/14] chore: simplifies the oidc test / nocks (#8446) --- mock-registry/lib/provenance.js | 162 +++++++++++----------- test/fixtures/mock-oidc.js | 229 ++++++++++++++------------------ test/lib/commands/publish.js | 65 +-------- 3 files changed, 180 insertions(+), 276 deletions(-) diff --git a/mock-registry/lib/provenance.js b/mock-registry/lib/provenance.js index 108c158efb9a8..d978aa61cb2c0 100644 --- a/mock-registry/lib/provenance.js +++ b/mock-registry/lib/provenance.js @@ -1,99 +1,97 @@ -'use strict' -const t = require('tap') const mockGlobals = require('@npmcli/mock-globals') const nock = require('nock') -class MockProvenance { - static sigstoreIdToken () { - return `.${Buffer.from(JSON.stringify({ - iss: 'https://oauth2.sigstore.dev/auth', - email: 'foo@bar.com', - })).toString('base64')}.` - } +const sigstoreIdToken = () => { + return `.${Buffer.from(JSON.stringify({ + iss: 'https://oauth2.sigstore.dev/auth', + email: 'foo@bar.com', + })) + .toString('base64')}.` +} - static successfulNock ({ - oidcURL, - requestToken, - workflowPath, - repository, - serverUrl, - ref, - sha, - runID, - runAttempt, - runnerEnv, - }) { - mockGlobals(t, { - 'process.env': { - CI: true, - GITHUB_ACTIONS: true, - ACTIONS_ID_TOKEN_REQUEST_URL: oidcURL, - ACTIONS_ID_TOKEN_REQUEST_TOKEN: requestToken, - GITHUB_WORKFLOW_REF: `${repository}/${workflowPath}@${ref}`, - GITHUB_REPOSITORY: repository, - GITHUB_SERVER_URL: serverUrl, - GITHUB_REF: ref, - GITHUB_SHA: sha, - GITHUB_RUN_ID: runID, - GITHUB_RUN_ATTEMPT: runAttempt, - RUNNER_ENVIRONMENT: runnerEnv, - }, - }) +const mockProvenance = (t, { + oidcURL, + requestToken, + workflowPath, + repository, + serverUrl, + ref, + sha, + runID, + runAttempt, + runnerEnv, +}) => { + const idToken = sigstoreIdToken() - const idToken = this.sigstoreIdToken() + mockGlobals(t, { + 'process.env': { + CI: true, + GITHUB_ACTIONS: true, + ACTIONS_ID_TOKEN_REQUEST_URL: oidcURL, + ACTIONS_ID_TOKEN_REQUEST_TOKEN: requestToken, + GITHUB_WORKFLOW_REF: `${repository}/${workflowPath}@${ref}`, + GITHUB_REPOSITORY: repository, + GITHUB_SERVER_URL: serverUrl, + GITHUB_REF: ref, + GITHUB_SHA: sha, + GITHUB_RUN_ID: runID, + GITHUB_RUN_ATTEMPT: runAttempt, + RUNNER_ENVIRONMENT: runnerEnv, + }, + }) - const url = new URL(oidcURL) - nock(url.origin) - .get(url.pathname) - .query({ audience: 'sigstore' }) - .matchHeader('authorization', `Bearer ${requestToken}`) - .matchHeader('accept', 'application/json') - .reply(200, { value: idToken }) + const url = new URL(oidcURL) + nock(url.origin) + .get(url.pathname) + .query({ audience: 'sigstore' }) + .matchHeader('authorization', `Bearer ${requestToken}`) + .matchHeader('accept', 'application/json') + .reply(200, { value: idToken }) - const leafCertificate = `-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n` + const leafCertificate = `-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n` - // Mock the Fulcio signing certificate endpoint - nock('https://fulcio.sigstore.dev') - .post('/api/v2/signingCert') - .reply(200, { - signedCertificateEmbeddedSct: { - chain: { - certificates: [ - leafCertificate, - `-----BEGIN CERTIFICATE-----\nxyz\n-----END CERTIFICATE-----\n`, - ], - }, + // Mock the Fulcio signing certificate endpoint + nock('https://fulcio.sigstore.dev') + .post('/api/v2/signingCert') + .reply(200, { + signedCertificateEmbeddedSct: { + chain: { + certificates: [ + leafCertificate, + `-----BEGIN CERTIFICATE-----\nxyz\n-----END CERTIFICATE-----\n`, + ], }, - }) + }, + }) - nock('https://rekor.sigstore.dev') - .post('/api/v1/log/entries') - .reply(201, { - '69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6': { - body: Buffer.from(JSON.stringify({ - kind: 'hashedrekord', - apiVersion: '0.0.1', - spec: { - signature: { - content: 'ABC123', - publicKey: { content: Buffer.from(leafCertificate).toString('base64') }, - }, + nock('https://rekor.sigstore.dev') + .post('/api/v1/log/entries') + .reply(201, { + '69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6': { + body: Buffer.from(JSON.stringify({ + kind: 'hashedrekord', + apiVersion: '0.0.1', + spec: { + signature: { + content: 'ABC123', + publicKey: { content: Buffer.from(leafCertificate).toString('base64') }, }, - })).toString( - 'base64' - ), - integratedTime: 1654015743, - logID: - 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d', - logIndex: 2513258, - verification: { - signedEntryTimestamp: 'MEUCIQD6CD7ZNLUipFoxzmSL/L8Ewic4SRkXN77UjfJZ7d/wAAIgatokSuX9Rg0iWxAgSfHMtcsagtDCQalU5IvXdQ+yLEA=', }, + })).toString( + 'base64' + ), + integratedTime: 1654015743, + logID: + 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d', + logIndex: 2513258, + verification: { + signedEntryTimestamp: 'MEUCIQD6CD7ZNLUipFoxzmSL/L8Ewic4SRkXN77UjfJZ7d/wAAIgatokSuX9Rg0iWxAgSfHMtcsagtDCQalU5IvXdQ+yLEA=', }, - }) - } + }, + }) } module.exports = { - MockProvenance, + mockProvenance, + sigstoreIdToken, } diff --git a/test/fixtures/mock-oidc.js b/test/fixtures/mock-oidc.js index 8a9c5358e06de..03a35522f7345 100644 --- a/test/fixtures/mock-oidc.js +++ b/test/fixtures/mock-oidc.js @@ -1,5 +1,8 @@ -const nock = require('nock') const ciInfo = require('ci-info') +const nock = require('nock') +const mockGlobals = require('@npmcli/mock-globals') +const { loadNpmWithRegistry } = require('./mock-npm') +const { mockProvenance } = require('@npmcli/mock-registry/lib/provenance') // this is an effort to not add a dependency to the cli just for testing function makeJwt (payload) { @@ -30,153 +33,115 @@ function githubIdToken ({ visibility = 'public' } = { visibility: 'public' }) { return makeJwt(payload) } -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', - SIGSTORE_ID_TOKEN: undefined, - } - 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.SIGSTORE_ID_TOKEN = options.SIGSTORE_ID_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, - SIGSTORE_ID_TOKEN: process.env.SIGSTORE_ID_TOKEN, - } - - this.originalCiInfo = { - GITLAB: ciInfo.GITLAB, - GITHUB_ACTIONS: ciInfo.GITHUB_ACTIONS, - } - this.setupEnvironment() +const mockOidc = async (t, { + oidcOptions = {}, + packageName = '@npmcli/test-package', + config = {}, + packageJson = {}, + load = {}, + mockGithubOidcOptions = null, + mockOidcTokenExchangeOptions = null, + publishOptions = {}, + provenance = false, +}) => { + const github = oidcOptions.github ?? false + const gitlab = oidcOptions.gitlab ?? 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' + + mockGlobals(t, { + process: { + env: { + ACTIONS_ID_TOKEN_REQUEST_TOKEN: ACTIONS_ID_TOKEN_REQUEST_TOKEN, + ACTIONS_ID_TOKEN_REQUEST_URL: ACTIONS_ID_TOKEN_REQUEST_URL, + CI: github || gitlab ? 'true' : undefined, + ...(github ? { GITHUB_ACTIONS: 'true' } : {}), + ...(gitlab ? { GITLAB_CI: '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 } : {}), + }, + }, + }) + + const GITHUB_ACTIONS = ciInfo.GITHUB_ACTIONS + const GITLAB = ciInfo.GITLAB + delete ciInfo.GITHUB_ACTIONS + delete ciInfo.GITLAB + if (github) { + ciInfo.GITHUB_ACTIONS = 'true' } - - 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 - delete process.env.SIGSTORE_ID_TOKEN - - ciInfo.GITHUB_ACTIONS = false - ciInfo.GITLAB = false - - if (this.github) { - if (typeof this.ACTIONS_ID_TOKEN_REQUEST_URL === 'string') { - process.env.ACTIONS_ID_TOKEN_REQUEST_URL = this.ACTIONS_ID_TOKEN_REQUEST_URL - } - if (typeof this.ACTIONS_ID_TOKEN_REQUEST_TOKEN === 'string') { - process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = this.ACTIONS_ID_TOKEN_REQUEST_TOKEN - } - ciInfo.GITHUB_ACTIONS = true - } - - if (this.gitlab) { - if (typeof this.NPM_ID_TOKEN === 'string') { - process.env.NPM_ID_TOKEN = this.NPM_ID_TOKEN - } - if (typeof this.SIGSTORE_ID_TOKEN === 'string') { - process.env.SIGSTORE_ID_TOKEN = this.SIGSTORE_ID_TOKEN - } - ciInfo.GITLAB = true - } + if (gitlab) { + 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) + t.teardown(() => { + ciInfo.GITHUB_ACTIONS = GITHUB_ACTIONS + ciInfo.GITLAB = GITLAB + }) + + const { npm, registry, joinedOutput } = await loadNpmWithRegistry(t, { + config, + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + ...packageJson, + }, null, 2), + }, + ...load, + }) + + if (mockGithubOidcOptions) { + const { idToken, audience, statusCode = 200 } = mockGithubOidcOptions + const url = new URL(ACTIONS_ID_TOKEN_REQUEST_URL) + nock(url.origin) .get(url.pathname) .query({ audience }) - .matchHeader('authorization', `Bearer ${this.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`) + .matchHeader('authorization', `Bearer ${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) { - if (typeof this.originalEnv[key] === 'string') { - process.env[key] = this.originalEnv[key] - } else { - delete process.env[key] - } - } - - // Restore the original ciInfo values - ciInfo.GITLAB = this.originalCiInfo.GITLAB - ciInfo.GITHUB_ACTIONS = this.originalCiInfo.GITHUB_ACTIONS - - nock.cleanAll() + if (mockOidcTokenExchangeOptions) { + registry.mockOidcTokenExchange({ + packageName, + ...mockOidcTokenExchangeOptions, + }) } - 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() + registry.publish(packageName, publishOptions) + + if ((github || gitlab) && provenance) { + registry.getVisibility({ spec: packageName, visibility: { public: true } }) + mockProvenance(t, { + oidcURL: ACTIONS_ID_TOKEN_REQUEST_URL, + requestToken: ACTIONS_ID_TOKEN_REQUEST_TOKEN, + workflowPath: '.github/workflows/publish.yml', + repository: 'github/foo', + serverUrl: 'https://github.com', + ref: 'refs/tags/pkg@1.0.0', + sha: 'deadbeef', + runID: '123456', + runAttempt: '1', + runnerEnv: 'github-hosted', }) + } + + return { npm, joinedOutput } +} - return instance +const oidcPublishTest = (opts) => { + return async (t) => { + const { npm, joinedOutput } = await mockOidc(t, opts) + await npm.exec('publish', []) + t.match(joinedOutput(), '+ @npmcli/test-package@1.0.0') } } module.exports = { - MockOidc, gitlabIdToken, githubIdToken, + mockOidc, + oidcPublishTest, } diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index d62fb380d1d7a..7a285ecbb2488 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -5,8 +5,8 @@ const pacote = require('pacote') const Arborist = require('@npmcli/arborist') const path = require('node:path') const fs = require('node:fs') -const { MockOidc, githubIdToken, gitlabIdToken } = require('../../fixtures/mock-oidc') -const { MockProvenance } = require('@npmcli/mock-registry/lib/provenance') +const { githubIdToken, gitlabIdToken, oidcPublishTest } = require('../../fixtures/mock-oidc') +const { sigstoreIdToken } = require('@npmcli/mock-registry/lib/provenance') const pkg = '@npmcli/test-package' const token = 'test-auth-token' @@ -991,65 +991,6 @@ t.test('semver highest dist tag', async t => { }) }) -const oidcPublishTest = ({ - oidcOptions = {}, - packageName = '@npmcli/test-package', - config = {}, - packageJson = {}, - load = {}, - mockGithubOidcOptions = null, - mockOidcTokenExchangeOptions = null, - publishOptions = {}, - provenance = false, -}) => { - 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) - - if ((oidc.github || oidc.gitlab) && provenance) { - registry.getVisibility({ spec: packageName, visibility: { public: true } }) - - MockProvenance.successfulNock({ - oidcURL: oidc.ACTIONS_ID_TOKEN_REQUEST_URL, - requestToken: oidc.ACTIONS_ID_TOKEN_REQUEST_TOKEN, - workflowPath: '.github/workflows/publish.yml', - repository: 'github/foo', - serverUrl: 'https://github.com', - ref: 'refs/tags/pkg@1.0.0', - sha: 'deadbeef', - runID: '123456', - runAttempt: '1', - runnerEnv: 'github-hosted', - }) - } - - await npm.exec('publish', []) - - oidc.reset() - } -} - t.test('oidc token exchange - no provenance', t => { const githubPrivateIdToken = githubIdToken({ visibility: 'private' }) const gitlabPrivateIdToken = gitlabIdToken({ visibility: 'private' }) @@ -1307,7 +1248,7 @@ t.test('oidc token exchange - no provenance', t => { t.test('oidc token exchange -- provenance', (t) => { const githubPublicIdToken = githubIdToken({ visibility: 'public' }) const gitlabPublicIdToken = gitlabIdToken({ visibility: 'public' }) - const SIGSTORE_ID_TOKEN = MockProvenance.sigstoreIdToken() + const SIGSTORE_ID_TOKEN = sigstoreIdToken() t.test('default registry success github', oidcPublishTest({ oidcOptions: { github: true }, From afe5d83432a575e912e04b60be9a1f12b0aa030d Mon Sep 17 00:00:00 2001 From: reggi Date: Tue, 22 Jul 2025 12:05:33 -0400 Subject: [PATCH 09/14] rm exisiting token check --- lib/utils/oidc.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index 5569154457ca8..ddb86fb117545 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -153,13 +153,6 @@ async function oidc ({ packageName, registry, opts, config }) { const regKey = `//${parsedRegistry.host}${parsedRegistry.pathname}` const authTokenKey = `${regKey}:_authToken` - const existingToken = config.get(authTokenKey) - if (existingToken) { - log.silly('oidc', 'Existing token found') - } else { - log.silly('oidc', 'No existing token found') - } - const escapedPackageName = npa(packageName).escapedName let response try { From cfc8ff714509bff86bede7508ea3ef12213dead8 Mon Sep 17 00:00:00 2001 From: reggi Date: Tue, 22 Jul 2025 12:22:39 -0400 Subject: [PATCH 10/14] updated logs --- lib/utils/oidc.js | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index ddb86fb117545..a0bdda807db88 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -30,12 +30,10 @@ async function oidc ({ packageName, registry, opts, config }) { /** @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') + log.silly('oidc', 'Skipped because not running 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, @@ -49,9 +47,9 @@ async function oidc ({ packageName, registry, opts, config }) { let idToken = process.env.NPM_ID_TOKEN if (idToken) { - log.silly('oidc', 'NPM_ID_TOKEN present') + // NPM_ID_TOKEN present } else { - log.silly('oidc', 'NPM_ID_TOKEN not present, checking for GITHUB_ACTIONS') + // NPM_ID_TOKEN not present, checking for GITHUB_ACTIONS if (ciInfo.GITHUB_ACTIONS) { /** * GitHub Actions provides these environment variables: @@ -105,14 +103,14 @@ async function oidc ({ packageName, registry, opts, config }) { 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`') + log.silly('oidc', 'Skipped with GITHUB_ACTIONS detected because incorrect permissions for id-token within workflow') return undefined } } } if (!idToken) { - log.silly('oidc', 'Exiting OIDC, no id_token available') + log.silly('oidc', 'Skipped because no id_token available') return undefined } @@ -136,19 +134,17 @@ async function oidc ({ packageName, registry, opts, config }) { enableProvenance = true } } catch (e) { - log.silly('oidc', 'Failed to parse idToken payload as JSON') + // Failed to parse idToken payload as JSON } } if (enableProvenance) { - log.silly('oidc', 'Repository is public, setting provenance') + // Repository is public, setting provenance opts.provenance = true config.set('provenance', true, 'user') } } - 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` @@ -163,13 +159,13 @@ async function oidc ({ packageName, registry, opts, config }) { }) } catch (error) { if (error?.body?.message) { - log.verbose('oidc', `Registry body response error message "${error.body.message}"`) + log.verbose('oidc', `Failed with 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') + log.verbose('oidc', 'Failed with token exchange missing token in response body') return undefined } /* @@ -180,10 +176,10 @@ async function oidc ({ packageName, registry, opts, config }) { */ opts[authTokenKey] = response.token config.set(authTokenKey, response.token, 'user') - log.silly('oidc', `OIDC token successfully retrieved`) + log.verbose('oidc', `Successfully retrieved and set token`) } catch (error) { /* istanbul ignore next */ - log.verbose('oidc', 'Failure checking OIDC config', error) + log.verbose('oidc', 'Failed checking config', error) } return undefined } From d1378e17f85e2b18b8213d5fb068406afe1911ad Mon Sep 17 00:00:00 2001 From: reggi Date: Tue, 22 Jul 2025 12:24:39 -0400 Subject: [PATCH 11/14] normalize the verbiage --- lib/utils/oidc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index a0bdda807db88..d647721b10153 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -103,7 +103,7 @@ async function oidc ({ packageName, registry, opts, config }) { idToken = json.value } else { - log.silly('oidc', 'Skipped with GITHUB_ACTIONS detected because incorrect permissions for id-token within workflow') + log.silly('oidc', 'Skipped because incorrect permissions for id-token within GitHub workflow') return undefined } } From 848a0dbd7854c10d51c007e4bd73a47dba94ff62 Mon Sep 17 00:00:00 2001 From: Reggi Date: Wed, 23 Jul 2025 11:00:12 -0400 Subject: [PATCH 12/14] chore: cleanup logs (#8451) --- lib/utils/oidc.js | 9 +++---- test/fixtures/mock-oidc.js | 9 ++++--- test/lib/commands/publish.js | 49 +++++++++++++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index d647721b10153..7f453cb122ed7 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -30,7 +30,7 @@ async function oidc ({ packageName, registry, opts, config }) { /** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L161C13-L161C22 */ ciInfo.GITLAB )) { - log.silly('oidc', 'Skipped because not running in a supported CI environment') + log.silly('oidc', 'Skipped because unsupported CI environment') return undefined } @@ -159,13 +159,13 @@ async function oidc ({ packageName, registry, opts, config }) { }) } catch (error) { if (error?.body?.message) { - log.verbose('oidc', `Failed with registry body response error message "${error.body.message}"`) + log.verbose('oidc', `Failed with body message "${error.body.message}"`) } return undefined } if (!response?.token) { - log.verbose('oidc', 'Failed with token exchange missing token in response body') + log.verbose('oidc', 'Failed because token exchange was missing the token in the response body') return undefined } /* @@ -178,8 +178,7 @@ async function oidc ({ packageName, registry, opts, config }) { config.set(authTokenKey, response.token, 'user') log.verbose('oidc', `Successfully retrieved and set token`) } catch (error) { - /* istanbul ignore next */ - log.verbose('oidc', 'Failed checking config', error) + log.verbose('oidc', `Failure with message "${error?.message || 'Unknown error'}"`) } return undefined } diff --git a/test/fixtures/mock-oidc.js b/test/fixtures/mock-oidc.js index 03a35522f7345..2b9302aa5b460 100644 --- a/test/fixtures/mock-oidc.js +++ b/test/fixtures/mock-oidc.js @@ -80,8 +80,11 @@ const mockOidc = async (t, { ciInfo.GITLAB = GITLAB }) - const { npm, registry, joinedOutput } = await loadNpmWithRegistry(t, { - config, + const { npm, registry, joinedOutput, logs } = await loadNpmWithRegistry(t, { + config: { + loglevel: 'silly', + ...config, + }, prefixDir: { 'package.json': JSON.stringify({ name: packageName, @@ -128,7 +131,7 @@ const mockOidc = async (t, { }) } - return { npm, joinedOutput } + return { npm, joinedOutput, logs, ACTIONS_ID_TOKEN_REQUEST_URL } } const oidcPublishTest = (opts) => { diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index 7a285ecbb2488..530a097d07542 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -5,8 +5,9 @@ const pacote = require('pacote') const Arborist = require('@npmcli/arborist') const path = require('node:path') const fs = require('node:fs') -const { githubIdToken, gitlabIdToken, oidcPublishTest } = require('../../fixtures/mock-oidc') +const { githubIdToken, gitlabIdToken, oidcPublishTest, mockOidc } = require('../../fixtures/mock-oidc') const { sigstoreIdToken } = require('@npmcli/mock-registry/lib/provenance') +const mockGlobals = require('@npmcli/mock-globals') const pkg = '@npmcli/test-package' const token = 'test-auth-token' @@ -1135,6 +1136,52 @@ t.test('oidc token exchange - no provenance', t => { }, })) + t.test('global try / catch failure via malformed url', oidcPublishTest({ + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + oidcOptions: { + github: true, + // malformed url should trigger a global try / catch + ACTIONS_ID_TOKEN_REQUEST_URL: '//github.com', + }, + publishOptions: { + token: 'existing-fallback-token', + }, + })) + + t.test('global try / catch failure via throw non Error', async t => { + const { npm, logs, joinedOutput, ACTIONS_ID_TOKEN_REQUEST_URL } = await mockOidc(t, { + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + oidcOptions: { + github: true, + }, + publishOptions: { + token: 'existing-fallback-token', + }, + }) + + class URLOverride extends URL { + constructor (...args) { + const [url] = args + if (url === ACTIONS_ID_TOKEN_REQUEST_URL) { + throw 'Specifically throwing a non errror object to test global try / catch' + } + super(...args) + } + } + + mockGlobals(t, { + URL: URLOverride, + }) + + await npm.exec('publish', []) + t.match(joinedOutput(), '+ @npmcli/test-package@1.0.0') + t.ok(logs.includes('verbose oidc Failure with message "Unknown error"')) + }) + t.test('default registry success gitlab', oidcPublishTest({ oidcOptions: { gitlab: true, NPM_ID_TOKEN: gitlabPrivateIdToken }, config: { From c0aa0e852b3e1a789c892a2f8692c13fd24ecb9d Mon Sep 17 00:00:00 2001 From: Reggi Date: Wed, 23 Jul 2025 12:19:32 -0400 Subject: [PATCH 13/14] chore: remove unnecessary ifs (#8453) --- lib/utils/oidc.js | 121 +++++++++++++++++------------------ test/fixtures/mock-oidc.js | 6 +- test/lib/commands/publish.js | 51 +++++++++++---- 3 files changed, 101 insertions(+), 77 deletions(-) diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index 7f453cb122ed7..ddf01a2a4eac5 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -46,67 +46,62 @@ async function oidc ({ packageName, registry, opts, config }) { */ let idToken = process.env.NPM_ID_TOKEN - if (idToken) { - // NPM_ID_TOKEN present - } else { - // 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 (!idToken && 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 + )) { + log.silly('oidc', 'Skipped because incorrect permissions for id-token within GitHub workflow') + return undefined + } - if (!json.value) { - log.verbose('oidc', `Failed to fetch id_token from GitHub: missing value`) - 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() + const response = await fetch(url.href, { + retry: opts.retry, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`, + }, + }) - idToken = json.value - } else { - log.silly('oidc', 'Skipped because incorrect permissions for id-token within GitHub workflow') - return undefined - } + 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 } if (!idToken) { @@ -117,9 +112,9 @@ async function oidc ({ packageName, registry, opts, config }) { // this checks if the user configured provenance or it's the default unset value const isDefaultProvenance = config.isDefault('provenance') const provenanceIntent = config.get('provenance') - const skipProvenance = isDefaultProvenance || provenanceIntent - if (skipProvenance) { + // if provenance is the default value or the user explicitly set it + if (isDefaultProvenance || provenanceIntent) { const [headerB64, payloadB64] = idToken.split('.') let enableProvenance = false if (headerB64 && payloadB64) { @@ -158,9 +153,7 @@ async function oidc ({ packageName, registry, opts, config }) { method: 'POST', }) } catch (error) { - if (error?.body?.message) { - log.verbose('oidc', `Failed with body message "${error.body.message}"`) - } + log.verbose('oidc', `Failed token exchange request with body message: ${error?.body?.message || 'Unknown error'}`) return undefined } @@ -178,7 +171,7 @@ async function oidc ({ packageName, registry, opts, config }) { config.set(authTokenKey, response.token, 'user') log.verbose('oidc', `Successfully retrieved and set token`) } catch (error) { - log.verbose('oidc', `Failure with message "${error?.message || 'Unknown error'}"`) + log.verbose('oidc', `Failure with message: ${error?.message || 'Unknown error'}`) } return undefined } diff --git a/test/fixtures/mock-oidc.js b/test/fixtures/mock-oidc.js index 2b9302aa5b460..0d1726a2f91cd 100644 --- a/test/fixtures/mock-oidc.js +++ b/test/fixtures/mock-oidc.js @@ -136,8 +136,12 @@ const mockOidc = async (t, { const oidcPublishTest = (opts) => { return async (t) => { - const { npm, joinedOutput } = await mockOidc(t, opts) + const { logsContain } = opts + const { npm, joinedOutput, logs } = await mockOidc(t, opts) await npm.exec('publish', []) + logsContain?.forEach(item => { + t.ok(logs.includes(item), `Expected log to include: ${item}`) + }) t.match(joinedOutput(), '+ @npmcli/test-package@1.0.0') } } diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index 530a097d07542..fc5abdacb4c69 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -1008,6 +1008,9 @@ t.test('oidc token exchange - no provenance', t => { publishOptions: { token: 'existing-fallback-token', }, + logsContain: [ + 'verbose oidc Failed to fetch id_token from GitHub: received an invalid response', + ], })) t.test('oidc token invalid body with fallback', oidcPublishTest({ @@ -1022,6 +1025,9 @@ t.test('oidc token exchange - no provenance', t => { publishOptions: { token: 'existing-fallback-token', }, + logsContain: [ + 'verbose oidc Failed to fetch id_token from GitHub: missing value', + ], })) t.test('token exchange 500 with fallback', oidcPublishTest({ @@ -1043,9 +1049,12 @@ t.test('oidc token exchange - no provenance', t => { publishOptions: { token: 'existing-fallback-token', }, + logsContain: [ + 'verbose oidc Failed token exchange request with body message: oidc token exchange failed', + ], })) - t.test('token exchange 500 (with no body message) with fallback', oidcPublishTest({ + t.test('token exchange 500 with no body message with fallback', oidcPublishTest({ oidcOptions: { github: true }, config: { '//registry.npmjs.org/:_authToken': 'existing-fallback-token', @@ -1062,6 +1071,9 @@ t.test('oidc token exchange - no provenance', t => { publishOptions: { token: 'existing-fallback-token', }, + logsContain: [ + 'verbose oidc Failed token exchange request with body message: Unknown error', + ], })) t.test('token exchange invalid body with fallback', oidcPublishTest({ @@ -1082,9 +1094,12 @@ t.test('oidc token exchange - no provenance', t => { publishOptions: { token: 'existing-fallback-token', }, + logsContain: [ + 'verbose oidc Failed because token exchange was missing the token in the response body', + ], })) - t.test('github + missing ACTIONS_ID_TOKEN_REQUEST_URL', oidcPublishTest({ + 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', @@ -1092,9 +1107,12 @@ t.test('oidc token exchange - no provenance', t => { publishOptions: { token: 'existing-fallback-token', }, + logsContain: [ + 'silly oidc Skipped because incorrect permissions for id-token within GitHub workflow', + ], })) - t.test('gitlab + missing NPM_ID_TOKEN', oidcPublishTest({ + t.test('gitlab missing NPM_ID_TOKEN', oidcPublishTest({ oidcOptions: { gitlab: true, NPM_ID_TOKEN: '' }, config: { '//registry.npmjs.org/:_authToken': 'existing-fallback-token', @@ -1102,6 +1120,9 @@ t.test('oidc token exchange - no provenance', t => { publishOptions: { token: 'existing-fallback-token', }, + logsContain: [ + 'silly oidc Skipped because no id_token available', + ], })) t.test('no ci', oidcPublishTest({ @@ -1112,6 +1133,9 @@ t.test('oidc token exchange - no provenance', t => { publishOptions: { token: 'existing-fallback-token', }, + logsContain: [ + 'silly oidc Skipped because unsupported CI environment', + ], })) // default registry success @@ -1136,21 +1160,24 @@ t.test('oidc token exchange - no provenance', t => { }, })) - t.test('global try / catch failure via malformed url', oidcPublishTest({ + t.test('global try-catch failure via malformed url', oidcPublishTest({ config: { '//registry.npmjs.org/:_authToken': 'existing-fallback-token', }, oidcOptions: { github: true, - // malformed url should trigger a global try / catch + // malformed url should trigger a global try-catch ACTIONS_ID_TOKEN_REQUEST_URL: '//github.com', }, publishOptions: { token: 'existing-fallback-token', }, + logsContain: [ + 'verbose oidc Failure with message: Invalid URL', + ], })) - t.test('global try / catch failure via throw non Error', async t => { + t.test('global try-catch failure via throw non Error', async t => { const { npm, logs, joinedOutput, ACTIONS_ID_TOKEN_REQUEST_URL } = await mockOidc(t, { config: { '//registry.npmjs.org/:_authToken': 'existing-fallback-token', @@ -1167,7 +1194,7 @@ t.test('oidc token exchange - no provenance', t => { constructor (...args) { const [url] = args if (url === ACTIONS_ID_TOKEN_REQUEST_URL) { - throw 'Specifically throwing a non errror object to test global try / catch' + throw 'Specifically throwing a non errror object to test global try-catch' } super(...args) } @@ -1179,7 +1206,7 @@ t.test('oidc token exchange - no provenance', t => { await npm.exec('publish', []) t.match(joinedOutput(), '+ @npmcli/test-package@1.0.0') - t.ok(logs.includes('verbose oidc Failure with message "Unknown error"')) + t.ok(logs.includes('verbose oidc Failure with message: Unknown error')) }) t.test('default registry success gitlab', oidcPublishTest({ @@ -1200,7 +1227,7 @@ t.test('oidc token exchange - no provenance', t => { // custom registry success - t.test('custom registry (config) success github', oidcPublishTest({ + t.test('custom registry config success github', oidcPublishTest({ oidcOptions: { github: true }, config: { registry: 'https://registry.zzz.org', @@ -1220,7 +1247,7 @@ t.test('oidc token exchange - no provenance', t => { }, })) - t.test('custom registry (scoped config) success github', oidcPublishTest({ + t.test('custom registry scoped config success github', oidcPublishTest({ oidcOptions: { github: true }, config: { '@npmcli:registry': 'https://registry.zzz.org', @@ -1243,7 +1270,7 @@ t.test('oidc token exchange - no provenance', t => { }, })) - t.test('custom registry (publishConfig) success github', oidcPublishTest({ + t.test('custom registry publishConfig success github', oidcPublishTest({ oidcOptions: { github: true }, packageJson: { publishConfig: { @@ -1292,7 +1319,7 @@ t.test('oidc token exchange - no provenance', t => { t.end() }) -t.test('oidc token exchange -- provenance', (t) => { +t.test('oidc token exchange - provenance', (t) => { const githubPublicIdToken = githubIdToken({ visibility: 'public' }) const gitlabPublicIdToken = gitlabIdToken({ visibility: 'public' }) const SIGSTORE_ID_TOKEN = sigstoreIdToken() From 098a26f8c4ac6aa20c0720c9e32dcf02f7b8844e Mon Sep 17 00:00:00 2001 From: reggi Date: Wed, 23 Jul 2025 14:04:23 -0400 Subject: [PATCH 14/14] last minute decision to not log anything locally --- lib/utils/oidc.js | 1 - test/lib/commands/publish.js | 3 --- 2 files changed, 4 deletions(-) diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index ddf01a2a4eac5..53fe6c9ac1390 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -30,7 +30,6 @@ async function oidc ({ packageName, registry, opts, config }) { /** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L161C13-L161C22 */ ciInfo.GITLAB )) { - log.silly('oidc', 'Skipped because unsupported CI environment') return undefined } diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index fc5abdacb4c69..e7d9dbb9ec9b7 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -1133,9 +1133,6 @@ t.test('oidc token exchange - no provenance', t => { publishOptions: { token: 'existing-fallback-token', }, - logsContain: [ - 'silly oidc Skipped because unsupported CI environment', - ], })) // default registry success