Skip to content
4 changes: 4 additions & 0 deletions lib/commands/publish.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion lib/commands/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -448,10 +448,12 @@ function cleanup (data) {
}

const keys = Object.keys(data)

if (keys.length <= 3 && data.name && (
(keys.length === 1) ||
(keys.length === 3 && data.email && data.url) ||
(keys.length === 2 && (data.email || data.url))
(keys.length === 2 && (data.email || data.url)) ||
data.trustedPublisher
)) {
data = unparsePerson(data)
}
Expand Down
180 changes: 180 additions & 0 deletions lib/utils/oidc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
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
}

/**
* 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 && 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', 'Skipped because incorrect permissions for id-token within GitHub workflow')
return undefined
}

/**
* 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}`
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) {
log.verbose('oidc', `Failed to fetch id_token from GitHub: received an invalid response`)
return undefined
}

if (!json.value) {
log.verbose('oidc', `Failed to fetch id_token from GitHub: missing value`)
return undefined
}

idToken = json.value
}

if (!idToken) {
log.silly('oidc', 'Skipped because no id_token available')
return undefined
}

// this checks if the user configured provenance or it's the default unset value
const isDefaultProvenance = config.isDefault('provenance')
const provenanceIntent = config.get('provenance')

// if provenance is the default value or the user explicitly set it
if (isDefaultProvenance || provenanceIntent) {
const [headerB64, payloadB64] = idToken.split('.')
let enableProvenance = false
if (headerB64 && payloadB64) {
const payloadJson = Buffer.from(payloadB64, 'base64').toString('utf8')
try {
const payload = JSON.parse(payloadJson)
if (ciInfo.GITHUB_ACTIONS && payload.repository_visibility === 'public') {
enableProvenance = true
}
// only set provenance for gitlab if SIGSTORE_ID_TOKEN is available
if (ciInfo.GITLAB && payload.project_visibility === 'public' && process.env.SIGSTORE_ID_TOKEN) {
enableProvenance = true
}
} catch (e) {
// Failed to parse idToken payload as JSON
}
}

if (enableProvenance) {
// Repository is public, setting provenance
opts.provenance = true
config.set('provenance', true, 'user')
}
}

const parsedRegistry = new URL(registry)
const regKey = `//${parsedRegistry.host}${parsedRegistry.pathname}`
const authTokenKey = `${regKey}:_authToken`

const escapedPackageName = npa(packageName).escapedName
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',
})
} catch (error) {
log.verbose('oidc', `Failed token exchange request with body message: ${error?.body?.message || 'Unknown error'}`)
return undefined
}

if (!response?.token) {
log.verbose('oidc', 'Failed because token exchange was missing the token in the response body')
return undefined
}
/*
* 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.verbose('oidc', `Successfully retrieved and set token`)
} catch (error) {
log.verbose('oidc', `Failure with message: ${error?.message || 'Unknown error'}`)
}
return undefined
}

module.exports = {
oidc,
}
27 changes: 21 additions & 6 deletions mock-registry/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ class MockRegistry {
// XXX: this is opt-in currently because it breaks some existing CLI
// tests. We should work towards making this the default for all tests.
t.comment(logReq(req, 'interceptors', 'socket', 'response', '_events'))
t.fail(`Unmatched request: ${req.method} ${req.path}`)
const protocol = req?.options?.protocol || 'http:'
const hostname = req?.options?.hostname || req?.hostname || 'localhost'
const p = req?.path || '/'
const url = new URL(p, `${protocol}//${hostname}`).toString()
t.fail(`Unmatched request: ${req.method} ${url}`)
}
}

Expand Down Expand Up @@ -359,7 +363,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
Expand All @@ -373,7 +377,7 @@ class MockRegistry {
}
}
if (!noPut) {
this.putPackage(name, { code: putCode, packageJson, access })
this.putPackage(name, { code: putCode, packageJson, access, token })
}
}

Expand All @@ -391,10 +395,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) {
Expand Down Expand Up @@ -626,6 +634,13 @@ class MockRegistry {
}
}
}

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, body || {})
}
}

module.exports = MockRegistry
97 changes: 97 additions & 0 deletions mock-registry/lib/provenance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
const mockGlobals = require('@npmcli/mock-globals')
const nock = require('nock')

const sigstoreIdToken = () => {
return `.${Buffer.from(JSON.stringify({
iss: 'https://oauth2.sigstore.dev/auth',
email: 'foo@bar.com',
}))
.toString('base64')}.`
}

const mockProvenance = (t, {
oidcURL,
requestToken,
workflowPath,
repository,
serverUrl,
ref,
sha,
runID,
runAttempt,
runnerEnv,
}) => {
const idToken = sigstoreIdToken()

mockGlobals(t, {
'process.env': {
CI: true,
GITHUB_ACTIONS: true,
ACTIONS_ID_TOKEN_REQUEST_URL: oidcURL,
ACTIONS_ID_TOKEN_REQUEST_TOKEN: requestToken,
GITHUB_WORKFLOW_REF: `${repository}/${workflowPath}@${ref}`,
GITHUB_REPOSITORY: repository,
GITHUB_SERVER_URL: serverUrl,
GITHUB_REF: ref,
GITHUB_SHA: sha,
GITHUB_RUN_ID: runID,
GITHUB_RUN_ATTEMPT: runAttempt,
RUNNER_ENVIRONMENT: runnerEnv,
},
})

const url = new URL(oidcURL)
nock(url.origin)
.get(url.pathname)
.query({ audience: 'sigstore' })
.matchHeader('authorization', `Bearer ${requestToken}`)
.matchHeader('accept', 'application/json')
.reply(200, { value: idToken })

const leafCertificate = `-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n`

// Mock the Fulcio signing certificate endpoint
nock('https://fulcio.sigstore.dev')
.post('/api/v2/signingCert')
.reply(200, {
signedCertificateEmbeddedSct: {
chain: {
certificates: [
leafCertificate,
`-----BEGIN CERTIFICATE-----\nxyz\n-----END CERTIFICATE-----\n`,
],
},
},
})

nock('https://rekor.sigstore.dev')
.post('/api/v1/log/entries')
.reply(201, {
'69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6': {
body: Buffer.from(JSON.stringify({
kind: 'hashedrekord',
apiVersion: '0.0.1',
spec: {
signature: {
content: 'ABC123',
publicKey: { content: Buffer.from(leafCertificate).toString('base64') },
},
},
})).toString(
'base64'
),
integratedTime: 1654015743,
logID:
'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d',
logIndex: 2513258,
verification: {
signedEntryTimestamp: 'MEUCIQD6CD7ZNLUipFoxzmSL/L8Ewic4SRkXN77UjfJZ7d/wAAIgatokSuX9Rg0iWxAgSfHMtcsagtDCQalU5IvXdQ+yLEA=',
},
},
})
}

module.exports = {
mockProvenance,
sigstoreIdToken,
}
Loading
Loading