Skip to content

Commit 9a33ad0

Browse files
authored
feat: adds circleci to oidc (#8925)
This pull request adds support for CircleCI as a provider of OpenID Connect (OIDC) tokens in CI environments, alongside existing support for GitHub Actions and GitLab. The implementation includes both code changes to detect and handle CircleCI OIDC tokens and new tests to ensure correct behavior. ## Usage In your `.circleci/config.yml`: ```yaml version: 2.1 jobs: publish: docker: - image: cimg/node:lts steps: - checkout - run: name: Publish to npm command: | NPM_AUDIENCE="npm:$(npm config get registry | sed 's|https\?://||;s|/$||')" NPM_ID_TOKEN=$(circleci run oidc get --claims "{\"aud\": \"$NPM_AUDIENCE\"}") npm publish workflows: publish: jobs: - publish ``` Note: Unlike GitHub Actions and GitLab, CircleCI requires manually fetching the OIDC token with the correct audience claim using the `circleci` CLI.
1 parent 07e6edd commit 9a33ad0

File tree

3 files changed

+60
-6
lines changed

3 files changed

+60
-6
lines changed

lib/utils/oidc.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@ const libaccess = require('libnpmaccess')
88
/**
99
* Handles OpenID Connect (OIDC) token retrieval and exchange for CI environments.
1010
*
11-
* This function is designed to work in Continuous Integration (CI) environments such as GitHub Actions
12-
* and GitLab. It retrieves an OIDC token from the CI environment, exchanges it for an npm token, and
11+
* This function is designed to work in Continuous Integration (CI) environments such as GitHub Actions,
12+
* GitLab, and CircleCI. It retrieves an OIDC token from the CI environment, exchanges it for an npm token, and
1313
* sets the token in the provided configuration for authentication with the npm registry.
1414
*
1515
* This function is intended to never throw, as it mutates the state of the `opts` and `config` objects on success.
1616
* OIDC is always an optional feature, and the function should not throw if OIDC is not configured by the registry.
1717
*
1818
* @see https://github.com/watson/ci-info for CI environment detection.
1919
* @see https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect for GitHub Actions OIDC.
20+
* @see https://circleci.com/docs/openid-connect-tokens/ for CircleCI OIDC.
2021
*/
2122
async function oidc ({ packageName, registry, opts, config }) {
2223
/*
@@ -29,7 +30,9 @@ async function oidc ({ packageName, registry, opts, config }) {
2930
/** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L152 */
3031
ciInfo.GITHUB_ACTIONS ||
3132
/** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L161C13-L161C22 */
32-
ciInfo.GITLAB
33+
ciInfo.GITLAB ||
34+
/** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L78 */
35+
ciInfo.CIRCLE
3336
)) {
3437
return undefined
3538
}
@@ -143,7 +146,8 @@ async function oidc ({ packageName, registry, opts, config }) {
143146

144147
try {
145148
const isDefaultProvenance = config.isDefault('provenance')
146-
if (isDefaultProvenance) {
149+
// CircleCI doesn't support provenance yet, so skip the auto-enable logic
150+
if (isDefaultProvenance && !ciInfo.CIRCLE) {
147151
const [headerB64, payloadB64] = idToken.split('.')
148152
if (headerB64 && payloadB64) {
149153
const payloadJson = Buffer.from(payloadB64, 'base64').toString('utf8')

test/fixtures/mock-oidc.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ function githubIdToken ({ visibility = 'public' } = { visibility: 'public' }) {
3333
return makeJwt(payload)
3434
}
3535

36+
function circleciIdToken () {
37+
const now = Math.floor(Date.now() / 1000)
38+
const payload = {
39+
'oidc.circleci.com/org-id': 'c9035eb6-6eb2-4c85-8a81-d9ee6a1fa8c2',
40+
'oidc.circleci.com/project-id': 'ecc458d2-fbdc-4d9a-93c4-ac065ed3c3ca',
41+
'oidc.circleci.com/vcs-origin': 'github.com/npm/trust-publish-test',
42+
iat: now,
43+
exp: now + 3600, // 1 hour expiration
44+
}
45+
return makeJwt(payload)
46+
}
47+
3648
const mockOidc = async (t, {
3749
oidcOptions = {},
3850
packageName = '@npmcli/test-package',
@@ -47,6 +59,7 @@ const mockOidc = async (t, {
4759
}) => {
4860
const github = oidcOptions.github ?? false
4961
const gitlab = oidcOptions.gitlab ?? false
62+
const circleci = oidcOptions.circleci ?? false
5063

5164
const ACTIONS_ID_TOKEN_REQUEST_URL = oidcOptions.ACTIONS_ID_TOKEN_REQUEST_URL ?? 'https://github.com/actions/id-token'
5265
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, {
5669
env: {
5770
ACTIONS_ID_TOKEN_REQUEST_TOKEN: ACTIONS_ID_TOKEN_REQUEST_TOKEN,
5871
ACTIONS_ID_TOKEN_REQUEST_URL: ACTIONS_ID_TOKEN_REQUEST_URL,
59-
CI: github || gitlab ? 'true' : undefined,
72+
CI: github || gitlab || circleci ? 'true' : undefined,
6073
...(github ? { GITHUB_ACTIONS: 'true' } : {}),
6174
...(gitlab ? { GITLAB_CI: 'true' } : {}),
75+
...(circleci ? { CIRCLECI: 'true' } : {}),
6276
...(oidcOptions.NPM_ID_TOKEN ? { NPM_ID_TOKEN: oidcOptions.NPM_ID_TOKEN } : {}),
6377
/* eslint-disable-next-line max-len */
6478
...(oidcOptions.SIGSTORE_ID_TOKEN ? { SIGSTORE_ID_TOKEN: oidcOptions.SIGSTORE_ID_TOKEN } : {}),
@@ -68,17 +82,23 @@ const mockOidc = async (t, {
6882

6983
const GITHUB_ACTIONS = ciInfo.GITHUB_ACTIONS
7084
const GITLAB = ciInfo.GITLAB
85+
const CIRCLE = ciInfo.CIRCLE
7186
delete ciInfo.GITHUB_ACTIONS
7287
delete ciInfo.GITLAB
88+
delete ciInfo.CIRCLE
7389
if (github) {
7490
ciInfo.GITHUB_ACTIONS = 'true'
7591
}
7692
if (gitlab) {
7793
ciInfo.GITLAB = 'true'
7894
}
95+
if (circleci) {
96+
ciInfo.CIRCLE = 'true'
97+
}
7998
t.teardown(() => {
8099
ciInfo.GITHUB_ACTIONS = GITHUB_ACTIONS
81100
ciInfo.GITLAB = GITLAB
101+
ciInfo.CIRCLE = CIRCLE
82102
})
83103

84104
const { npm, registry, joinedOutput, logs } = await loadNpmWithRegistry(t, {
@@ -156,6 +176,7 @@ const oidcPublishTest = (opts) => {
156176
}
157177

158178
module.exports = {
179+
circleciIdToken,
159180
gitlabIdToken,
160181
githubIdToken,
161182
mockOidc,

test/lib/commands/publish.js

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const pacote = require('pacote')
55
const Arborist = require('@npmcli/arborist')
66
const path = require('node:path')
77
const fs = require('node:fs')
8-
const { githubIdToken, gitlabIdToken, oidcPublishTest, mockOidc } = require('../../fixtures/mock-oidc')
8+
const { circleciIdToken, githubIdToken, gitlabIdToken, oidcPublishTest, mockOidc } = require('../../fixtures/mock-oidc')
99
const { sigstoreIdToken } = require('@npmcli/mock-registry/lib/provenance')
1010
const mockGlobals = require('@npmcli/mock-globals')
1111

@@ -1222,6 +1222,35 @@ t.test('oidc token exchange - no provenance', t => {
12221222
},
12231223
}))
12241224

1225+
t.test('circleci missing NPM_ID_TOKEN', oidcPublishTest({
1226+
oidcOptions: { circleci: true, NPM_ID_TOKEN: '' },
1227+
config: {
1228+
'//registry.npmjs.org/:_authToken': 'existing-fallback-token',
1229+
},
1230+
publishOptions: {
1231+
token: 'existing-fallback-token',
1232+
},
1233+
logsContain: [
1234+
'silly oidc Skipped because no id_token available',
1235+
],
1236+
}))
1237+
1238+
t.test('default registry success circleci', oidcPublishTest({
1239+
oidcOptions: { circleci: true, NPM_ID_TOKEN: circleciIdToken() },
1240+
config: {
1241+
'//registry.npmjs.org/:_authToken': 'existing-fallback-token',
1242+
},
1243+
mockOidcTokenExchangeOptions: {
1244+
idToken: circleciIdToken(),
1245+
body: {
1246+
token: 'exchange-token',
1247+
},
1248+
},
1249+
publishOptions: {
1250+
token: 'exchange-token',
1251+
},
1252+
}))
1253+
12251254
// custom registry success
12261255

12271256
t.test('custom registry config success github', oidcPublishTest({

0 commit comments

Comments
 (0)