feat: adds support for oidc publish (#8336) · npm/cli@1cce318 · GitHub
Skip to content

Commit 1cce318

Browse files
authored
feat: adds support for oidc publish (#8336)
1 parent 804a964 commit 1cce318

9 files changed

Lines changed: 958 additions & 8 deletions

File tree

lib/commands/publish.js

Lines changed: 4 additions & 0 deletions

lib/commands/view.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,10 +448,12 @@ function cleanup (data) {
448448
}
449449

450450
const keys = Object.keys(data)
451+
451452
if (keys.length <= 3 && data.name && (
452453
(keys.length === 1) ||
453454
(keys.length === 3 && data.email && data.url) ||
454-
(keys.length === 2 && (data.email || data.url))
455+
(keys.length === 2 && (data.email || data.url)) ||
456+
data.trustedPublisher
455457
)) {
456458
data = unparsePerson(data)
457459
}

lib/utils/oidc.js

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
const { log } = require('proc-log')
2+
const npmFetch = require('npm-registry-fetch')
3+
const ciInfo = require('ci-info')
4+
const fetch = require('make-fetch-happen')
5+
const npa = require('npm-package-arg')
6+
7+
/**
8+
* Handles OpenID Connect (OIDC) token retrieval and exchange for CI environments.
9+
*
10+
* This function is designed to work in Continuous Integration (CI) environments such as GitHub Actions
11+
* and GitLab. It retrieves an OIDC token from the CI environment, exchanges it for an npm token, and
12+
* sets the token in the provided configuration for authentication with the npm registry.
13+
*
14+
* This function is intended to never throw, as it mutates the state of the `opts` and `config` objects on success.
15+
* OIDC is always an optional feature, and the function should not throw if OIDC is not configured by the registry.
16+
*
17+
* @see https://github.com/watson/ci-info for CI environment detection.
18+
* @see https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect for GitHub Actions OIDC.
19+
*/
20+
async function oidc ({ packageName, registry, opts, config }) {
21+
/*
22+
* This code should never run when people try to publish locally on their machines.
23+
* It is designed to execute only in Continuous Integration (CI) environments.
24+
*/
25+
26+
try {
27+
if (!(
28+
/** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L152 */
29+
ciInfo.GITHUB_ACTIONS ||
30+
/** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L161C13-L161C22 */
31+
ciInfo.GITLAB
32+
)) {
33+
return undefined
34+
}
35+
36+
/**
37+
* Check if the environment variable `NPM_ID_TOKEN` is set.
38+
* In GitLab CI, the ID token is provided via an environment variable,
39+
* with `NPM_ID_TOKEN` serving as a predefined default. For consistency,
40+
* all supported CI environments are expected to support this variable.
41+
* In contrast, GitHub Actions uses a request-based approach to retrieve the ID token.
42+
* The presence of this token within GitHub Actions will override the request-based approach.
43+
* This variable follows the prefix/suffix convention from sigstore (e.g., `SIGSTORE_ID_TOKEN`).
44+
* @see https://docs.sigstore.dev/cosign/signing/overview/
45+
*/
46+
let idToken = process.env.NPM_ID_TOKEN
47+
48+
if (!idToken && ciInfo.GITHUB_ACTIONS) {
49+
/**
50+
* GitHub Actions provides these environment variables:
51+
* - `ACTIONS_ID_TOKEN_REQUEST_URL`: The URL to request the ID token.
52+
* - `ACTIONS_ID_TOKEN_REQUEST_TOKEN`: The token to authenticate the request.
53+
* Only when a workflow has the following permissions:
54+
* ```
55+
* permissions:
56+
* id-token: write
57+
* ```
58+
* @see https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers#adding-permissions-settings
59+
*/
60+
if (!(
61+
process.env.ACTIONS_ID_TOKEN_REQUEST_URL &&
62+
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
63+
)) {
64+
log.silly('oidc', 'Skipped because incorrect permissions for id-token within GitHub workflow')
65+
return undefined
66+
}
67+
68+
/**
69+
* The specification for an audience is `npm:registry.npmjs.org`,
70+
* where "registry.npmjs.org" can be any supported registry.
71+
*/
72+
const audience = `npm:${new URL(registry).hostname}`
73+
const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL)
74+
url.searchParams.append('audience', audience)
75+
const startTime = Date.now()
76+
const response = await fetch(url.href, {
77+
retry: opts.retry,
78+
headers: {
79+
Accept: 'application/json',
80+
Authorization: `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`,
81+
},
82+
})
83+
84+
const elapsedTime = Date.now() - startTime
85+
86+
log.http(
87+
'fetch',
88+
`GET ${url.href} ${response.status} ${elapsedTime}ms`
89+
)
90+
91+
const json = await response.json()
92+
93+
if (!response.ok) {
94+
log.verbose('oidc', `Failed to fetch id_token from GitHub: received an invalid response`)
95+
return undefined
96+
}
97+
98+
if (!json.value) {
99+
log.verbose('oidc', `Failed to fetch id_token from GitHub: missing value`)
100+
return undefined
101+
}
102+
103+
idToken = json.value
104+
}
105+
106+
if (!idToken) {
107+
log.silly('oidc', 'Skipped because no id_token available')
108+
return undefined
109+
}
110+
111+
// this checks if the user configured provenance or it's the default unset value
112+
const isDefaultProvenance = config.isDefault('provenance')
113+
const provenanceIntent = config.get('provenance')
114+
115+
// if provenance is the default value or the user explicitly set it
116+
if (isDefaultProvenance || provenanceIntent) {
117+
const [headerB64, payloadB64] = idToken.split('.')
118+
let enableProvenance = false
119+
if (headerB64 && payloadB64) {
120+
const payloadJson = Buffer.from(payloadB64, 'base64').toString('utf8')
121+
try {
122+
const payload = JSON.parse(payloadJson)
123+
if (ciInfo.GITHUB_ACTIONS && payload.repository_visibility === 'public') {
124+
enableProvenance = true
125+
}
126+
// only set provenance for gitlab if SIGSTORE_ID_TOKEN is available
127+
if (ciInfo.GITLAB && payload.project_visibility === 'public' && process.env.SIGSTORE_ID_TOKEN) {
128+
enableProvenance = true
129+
}
130+
} catch (e) {
131+
// Failed to parse idToken payload as JSON
132+
}
133+
}
134+
135+
if (enableProvenance) {
136+
// Repository is public, setting provenance
137+
opts.provenance = true
138+
config.set('provenance', true, 'user')
139+
}
140+
}
141+
142+
const parsedRegistry = new URL(registry)
143+
const regKey = `//${parsedRegistry.host}${parsedRegistry.pathname}`
144+
const authTokenKey = `${regKey}:_authToken`
145+
146+
const escapedPackageName = npa(packageName).escapedName
147+
let response
148+
try {
149+
response = await npmFetch.json(new URL(`/-/npm/v1/oidc/token/exchange/package/${escapedPackageName}`, registry), {
150+
...opts,
151+
[authTokenKey]: idToken, // Use the idToken as the auth token for the request
152+
method: 'POST',
153+
})
154+
} catch (error) {
155+
log.verbose('oidc', `Failed token exchange request with body message: ${error?.body?.message || 'Unknown error'}`)
156+
return undefined
157+
}
158+
159+
if (!response?.token) {
160+
log.verbose('oidc', 'Failed because token exchange was missing the token in the response body')
161+
return undefined
162+
}
163+
/*
164+
* The "opts" object is a clone of npm.flatOptions and is passed through the `publish` command,
165+
* eventually reaching `otplease`. To ensure the token is accessible during the publishing process,
166+
* it must be directly attached to the `opts` object.
167+
* Additionally, the token is required by the "live" configuration or getters within `config`.
168+
*/
169+
opts[authTokenKey] = response.token
170+
config.set(authTokenKey, response.token, 'user')
171+
log.verbose('oidc', `Successfully retrieved and set token`)
172+
} catch (error) {
173+
log.verbose('oidc', `Failure with message: ${error?.message || 'Unknown error'}`)
174+
}
175+
return undefined
176+
}
177+
178+
module.exports = {
179+
oidc,
180+
}

mock-registry/lib/index.js

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,11 @@ class MockRegistry {
8080
// XXX: this is opt-in currently because it breaks some existing CLI
8181
// tests. We should work towards making this the default for all tests.
8282
t.comment(logReq(req, 'interceptors', 'socket', 'response', '_events'))
83-
t.fail(`Unmatched request: ${req.method} ${req.path}`)
83+
const protocol = req?.options?.protocol || 'http:'
84+
const hostname = req?.options?.hostname || req?.hostname || 'localhost'
85+
const p = req?.path || '/'
86+
const url = new URL(p, `${protocol}//${hostname}`).toString()
87+
t.fail(`Unmatched request: ${req.method} ${url}`)
8488
}
8589
}
8690

@@ -359,7 +363,7 @@ class MockRegistry {
359363
}
360364

361365
publish (name, {
362-
packageJson, access, noGet, noPut, putCode, manifest, packuments,
366+
packageJson, access, noGet, noPut, putCode, manifest, packuments, token,
363367
} = {}) {
364368
if (!noGet) {
365369
// this getPackage call is used to get the latest semver version before publish
@@ -373,7 +377,7 @@ class MockRegistry {
373377
}
374378
}
375379
if (!noPut) {
376-
this.putPackage(name, { code: putCode, packageJson, access })
380+
this.putPackage(name, { code: putCode, packageJson, access, token })
377381
}
378382
}
379383

@@ -391,10 +395,14 @@ class MockRegistry {
391395
this.nock = nock
392396
}
393397

394-
putPackage (name, { code = 200, resp = {}, ...putPackagePayload }) {
395-
this.nock.put(`/${npa(name).escapedName}`, body => {
398+
putPackage (name, { code = 200, resp = {}, token, ...putPackagePayload }) {
399+
let n = this.nock.put(`/${npa(name).escapedName}`, body => {
396400
return this.#tap.match(body, this.putPackagePayload({ name, ...putPackagePayload }))
397-
}).reply(code, resp)
401+
})
402+
if (token) {
403+
n = n.matchHeader('authorization', `Bearer ${token}`)
404+
}
405+
n.reply(code, resp)
398406
}
399407

400408
putPackagePayload (opts) {
@@ -626,6 +634,13 @@ class MockRegistry {
626634
}
627635
}
628636
}
637+
638+
mockOidcTokenExchange ({ packageName, idToken, statusCode = 200, body } = {}) {
639+
const encodedPackageName = npa(packageName).escapedName
640+
this.nock.post(this.fullPath(`/-/npm/v1/oidc/token/exchange/package/${encodedPackageName}`))
641+
.matchHeader('authorization', `Bearer ${idToken}`)
642+
.reply(statusCode, body || {})
643+
}
629644
}
630645

631646
module.exports = MockRegistry

mock-registry/lib/provenance.js

Lines changed: 97 additions & 0 deletions

0 commit comments

Comments
 (0)