Skip to content

Commit 1cce318

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

File tree

9 files changed

+958
-8
lines changed

9 files changed

+958
-8
lines changed

lib/commands/publish.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const { getContents, logTar } = require('../utils/tar.js')
1616
const { flatten } = require('@npmcli/config/lib/definitions')
1717
const pkgJson = require('@npmcli/package-json')
1818
const BaseCommand = require('../base-cmd.js')
19+
const { oidc } = require('../../lib/utils/oidc.js')
1920

2021
class Publish extends BaseCommand {
2122
static description = 'Publish a package'
@@ -136,6 +137,9 @@ class Publish extends BaseCommand {
136137
npa(`${manifest.name}@${defaultTag}`)
137138

138139
const registry = npmFetch.pickRegistry(resolved, opts)
140+
141+
await oidc({ packageName: manifest.name, registry, opts, config: this.npm.config })
142+
139143
const creds = this.npm.config.getCredentialsByURI(registry)
140144
const noCreds = !(creds.token || creds.username || creds.certfile && creds.keyfile)
141145
const outputRegistry = replaceInfo(registry)

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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
const mockGlobals = require('@npmcli/mock-globals')
2+
const nock = require('nock')
3+
4+
const sigstoreIdToken = () => {
5+
return `.${Buffer.from(JSON.stringify({
6+
iss: 'https://oauth2.sigstore.dev/auth',
7+
email: 'foo@bar.com',
8+
}))
9+
.toString('base64')}.`
10+
}
11+
12+
const mockProvenance = (t, {
13+
oidcURL,
14+
requestToken,
15+
workflowPath,
16+
repository,
17+
serverUrl,
18+
ref,
19+
sha,
20+
runID,
21+
runAttempt,
22+
runnerEnv,
23+
}) => {
24+
const idToken = sigstoreIdToken()
25+
26+
mockGlobals(t, {
27+
'process.env': {
28+
CI: true,
29+
GITHUB_ACTIONS: true,
30+
ACTIONS_ID_TOKEN_REQUEST_URL: oidcURL,
31+
ACTIONS_ID_TOKEN_REQUEST_TOKEN: requestToken,
32+
GITHUB_WORKFLOW_REF: `${repository}/${workflowPath}@${ref}`,
33+
GITHUB_REPOSITORY: repository,
34+
GITHUB_SERVER_URL: serverUrl,
35+
GITHUB_REF: ref,
36+
GITHUB_SHA: sha,
37+
GITHUB_RUN_ID: runID,
38+
GITHUB_RUN_ATTEMPT: runAttempt,
39+
RUNNER_ENVIRONMENT: runnerEnv,
40+
},
41+
})
42+
43+
const url = new URL(oidcURL)
44+
nock(url.origin)
45+
.get(url.pathname)
46+
.query({ audience: 'sigstore' })
47+
.matchHeader('authorization', `Bearer ${requestToken}`)
48+
.matchHeader('accept', 'application/json')
49+
.reply(200, { value: idToken })
50+
51+
const leafCertificate = `-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n`
52+
53+
// Mock the Fulcio signing certificate endpoint
54+
nock('https://fulcio.sigstore.dev')
55+
.post('/api/v2/signingCert')
56+
.reply(200, {
57+
signedCertificateEmbeddedSct: {
58+
chain: {
59+
certificates: [
60+
leafCertificate,
61+
`-----BEGIN CERTIFICATE-----\nxyz\n-----END CERTIFICATE-----\n`,
62+
],
63+
},
64+
},
65+
})
66+
67+
nock('https://rekor.sigstore.dev')
68+
.post('/api/v1/log/entries')
69+
.reply(201, {
70+
'69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6': {
71+
body: Buffer.from(JSON.stringify({
72+
kind: 'hashedrekord',
73+
apiVersion: '0.0.1',
74+
spec: {
75+
signature: {
76+
content: 'ABC123',
77+
publicKey: { content: Buffer.from(leafCertificate).toString('base64') },
78+
},
79+
},
80+
})).toString(
81+
'base64'
82+
),
83+
integratedTime: 1654015743,
84+
logID:
85+
'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d',
86+
logIndex: 2513258,
87+
verification: {
88+
signedEntryTimestamp: 'MEUCIQD6CD7ZNLUipFoxzmSL/L8Ewic4SRkXN77UjfJZ7d/wAAIgatokSuX9Rg0iWxAgSfHMtcsagtDCQalU5IvXdQ+yLEA=',
89+
},
90+
},
91+
})
92+
}
93+
94+
module.exports = {
95+
mockProvenance,
96+
sigstoreIdToken,
97+
}

0 commit comments

Comments
 (0)