From 9021253a971a5cff4271fa36c3f5bc21d765b11f Mon Sep 17 00:00:00 2001 From: reggi Date: Fri, 27 Jun 2025 13:21:15 -0400 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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) {