From f62142c6eefc5662960dc6b9c3e3fe424c588016 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Wed, 21 Jan 2026 14:46:13 -0800 Subject: [PATCH 1/3] feat: adds circleci to oidc --- lib/utils/oidc.js | 9 ++++++--- test/fixtures/mock-oidc.js | 23 ++++++++++++++++++++++- test/lib/commands/publish.js | 31 ++++++++++++++++++++++++++++++- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index 24524f4b4bf72..e0b4ca3f2ae9e 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -8,8 +8,8 @@ const libaccess = require('libnpmaccess') /** * 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 + * This function is designed to work in Continuous Integration (CI) environments such as GitHub Actions, + * GitLab, and CircleCI. It retrieves an OIDC token from the CI environment, exchanges it for an npm token, and * sets the token in the provided configuration for authentication with the npm registry. * * This function is intended to never throw, as it mutates the state of the `opts` and `config` objects on success. @@ -17,6 +17,7 @@ const libaccess = require('libnpmaccess') * * @see https://github.com/watson/ci-info for CI environment detection. * @see https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect for GitHub Actions OIDC. + * @see https://circleci.com/docs/openid-connect-tokens/ for CircleCI OIDC. */ async function oidc ({ packageName, registry, opts, config }) { /* @@ -29,7 +30,9 @@ async function oidc ({ packageName, registry, opts, config }) { /** @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 + ciInfo.GITLAB || + /** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L78 */ + ciInfo.CIRCLE )) { return undefined } diff --git a/test/fixtures/mock-oidc.js b/test/fixtures/mock-oidc.js index 3af720670b947..d15d52c1b819f 100644 --- a/test/fixtures/mock-oidc.js +++ b/test/fixtures/mock-oidc.js @@ -33,6 +33,18 @@ function githubIdToken ({ visibility = 'public' } = { visibility: 'public' }) { return makeJwt(payload) } +function circleciIdToken () { + const now = Math.floor(Date.now() / 1000) + const payload = { + 'oidc.circleci.com/org-id': 'c9035eb6-6eb2-4c85-8a81-d9ee6a1fa8c2', + 'oidc.circleci.com/project-id': 'ecc458d2-fbdc-4d9a-93c4-ac065ed3c3ca', + 'oidc.circleci.com/vcs-origin': 'github.com/npm/trust-publish-test', + iat: now, + exp: now + 3600, // 1 hour expiration + } + return makeJwt(payload) +} + const mockOidc = async (t, { oidcOptions = {}, packageName = '@npmcli/test-package', @@ -47,6 +59,7 @@ const mockOidc = async (t, { }) => { const github = oidcOptions.github ?? false const gitlab = oidcOptions.gitlab ?? false + const circleci = oidcOptions.circleci ?? 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' @@ -56,9 +69,10 @@ const mockOidc = async (t, { env: { ACTIONS_ID_TOKEN_REQUEST_TOKEN: ACTIONS_ID_TOKEN_REQUEST_TOKEN, ACTIONS_ID_TOKEN_REQUEST_URL: ACTIONS_ID_TOKEN_REQUEST_URL, - CI: github || gitlab ? 'true' : undefined, + CI: github || gitlab || circleci ? 'true' : undefined, ...(github ? { GITHUB_ACTIONS: 'true' } : {}), ...(gitlab ? { GITLAB_CI: 'true' } : {}), + ...(circleci ? { CIRCLECI: '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 } : {}), @@ -68,17 +82,23 @@ const mockOidc = async (t, { const GITHUB_ACTIONS = ciInfo.GITHUB_ACTIONS const GITLAB = ciInfo.GITLAB + const CIRCLE = ciInfo.CIRCLE delete ciInfo.GITHUB_ACTIONS delete ciInfo.GITLAB + delete ciInfo.CIRCLE if (github) { ciInfo.GITHUB_ACTIONS = 'true' } if (gitlab) { ciInfo.GITLAB = 'true' } + if (circleci) { + ciInfo.CIRCLE = 'true' + } t.teardown(() => { ciInfo.GITHUB_ACTIONS = GITHUB_ACTIONS ciInfo.GITLAB = GITLAB + ciInfo.CIRCLE = CIRCLE }) const { npm, registry, joinedOutput, logs } = await loadNpmWithRegistry(t, { @@ -156,6 +176,7 @@ const oidcPublishTest = (opts) => { } module.exports = { + circleciIdToken, gitlabIdToken, githubIdToken, mockOidc, diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index e444121b77e11..1abe9eb547d8f 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -5,7 +5,7 @@ const pacote = require('pacote') const Arborist = require('@npmcli/arborist') const path = require('node:path') const fs = require('node:fs') -const { githubIdToken, gitlabIdToken, oidcPublishTest, mockOidc } = require('../../fixtures/mock-oidc') +const { circleciIdToken, githubIdToken, gitlabIdToken, oidcPublishTest, mockOidc } = require('../../fixtures/mock-oidc') const { sigstoreIdToken } = require('@npmcli/mock-registry/lib/provenance') const mockGlobals = require('@npmcli/mock-globals') @@ -1222,6 +1222,35 @@ t.test('oidc token exchange - no provenance', t => { }, })) + t.test('circleci missing NPM_ID_TOKEN', oidcPublishTest({ + oidcOptions: { circleci: true, NPM_ID_TOKEN: '' }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + publishOptions: { + token: 'existing-fallback-token', + }, + logsContain: [ + 'silly oidc Skipped because no id_token available', + ], + })) + + t.test('default registry success circleci', oidcPublishTest({ + oidcOptions: { circleci: true, NPM_ID_TOKEN: circleciIdToken() }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockOidcTokenExchangeOptions: { + idToken: circleciIdToken(), + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + })) + // custom registry success t.test('custom registry config success github', oidcPublishTest({ From 95b44037e60fb06cda5348d8009ea82f963f7b73 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Fri, 23 Jan 2026 08:38:07 -0800 Subject: [PATCH 2/3] explicitly remove provenance for circleci --- lib/utils/oidc.js | 5 +++++ test/lib/commands/publish.js | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index e0b4ca3f2ae9e..b3794058d8bde 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -162,6 +162,11 @@ async function oidc ({ packageName, registry, opts, config }) { opts.provenance = true config.set('provenance', true, 'user') } + } else if (ciInfo.CIRCLE) { + // not supported yet for CircleCI + log.verbose('oidc', `Disabling provenance for CircleCI (not supported)`) + opts.provenance = false + config.set('provenance', false, 'user') } } } diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index 1abe9eb547d8f..bc6acae1a6b1b 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -1248,7 +1248,11 @@ t.test('oidc token exchange - no provenance', t => { }, publishOptions: { token: 'exchange-token', + provenance: false, }, + logsContain: [ + 'verbose oidc Disabling provenance for CircleCI (not supported)', + ], })) // custom registry success From d776bf5e0c108117a9f7a7f75d4d548b9a95117e Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Wed, 28 Jan 2026 07:17:06 -0800 Subject: [PATCH 3/3] address feedback --- lib/utils/oidc.js | 8 ++------ test/lib/commands/publish.js | 4 ---- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index b3794058d8bde..690bc96dba4a4 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -146,7 +146,8 @@ async function oidc ({ packageName, registry, opts, config }) { try { const isDefaultProvenance = config.isDefault('provenance') - if (isDefaultProvenance) { + // CircleCI doesn't support provenance yet, so skip the auto-enable logic + if (isDefaultProvenance && !ciInfo.CIRCLE) { const [headerB64, payloadB64] = idToken.split('.') if (headerB64 && payloadB64) { const payloadJson = Buffer.from(payloadB64, 'base64').toString('utf8') @@ -162,11 +163,6 @@ async function oidc ({ packageName, registry, opts, config }) { opts.provenance = true config.set('provenance', true, 'user') } - } else if (ciInfo.CIRCLE) { - // not supported yet for CircleCI - log.verbose('oidc', `Disabling provenance for CircleCI (not supported)`) - opts.provenance = false - config.set('provenance', false, 'user') } } } diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index bc6acae1a6b1b..1abe9eb547d8f 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -1248,11 +1248,7 @@ t.test('oidc token exchange - no provenance', t => { }, publishOptions: { token: 'exchange-token', - provenance: false, }, - logsContain: [ - 'verbose oidc Disabling provenance for CircleCI (not supported)', - ], })) // custom registry success