Skip to content

feat: adds support for oidc publish #8336

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: latest
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
200 changes: 200 additions & 0 deletions lib/utils/oidc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
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
)) {
log.silly('oidc', 'Not running OIDC, not in a supported CI environment')
return undefined
}

log.silly('oidc', 'Determining if npm should use OIDC publishing')

/**
* 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) {
log.silly('oidc', 'NPM_ID_TOKEN present')
} else {
log.silly('oidc', 'NPM_ID_TOKEN not present, checking for GITHUB_ACTIONS')
if (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
) {
/**
* 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
} else {
log.silly('oidc', 'GITHUB_ACTIONS detected. If you intend to publish using OIDC, please set workflow permissions for `id-token: write`')
return undefined
}
}
}

if (!idToken) {
log.silly('oidc', 'Exiting OIDC, 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')
const skipProvenance = isDefaultProvenance || provenanceIntent

if (skipProvenance) {
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) {
log.silly('oidc', 'Failed to parse idToken payload as JSON')
}
}

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

log.silly('oidc', `id_token has a length of ${idToken.length} characters`)

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

const existingToken = config.get(authTokenKey)
if (existingToken) {
log.silly('oidc', 'Existing token found')
} else {
log.silly('oidc', 'No existing token found')
}

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) {
if (error?.body?.message) {
log.verbose('oidc', `Registry body response error message "${error.body.message}"`)
}
return undefined
}

if (!response?.token) {
log.verbose('oidc', 'OIDC token exchange failure: missing token in 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.silly('oidc', `OIDC token successfully retrieved`)
} catch (error) {
/* istanbul ignore next */
log.verbose('oidc', 'Failure checking OIDC config', 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
99 changes: 99 additions & 0 deletions mock-registry/lib/provenance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
'use strict'
const t = require('tap')
const mockGlobals = require('@npmcli/mock-globals')
const nock = require('nock')

class MockProvenance {
static sigstoreIdToken () {
return `.${Buffer.from(JSON.stringify({
iss: 'https://oauth2.sigstore.dev/auth',
email: '[email protected]',
})).toString('base64')}.`
}

static successfulNock ({
oidcURL,
requestToken,
workflowPath,
repository,
serverUrl,
ref,
sha,
runID,
runAttempt,
runnerEnv,
}) {
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 idToken = this.sigstoreIdToken()

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,
}
Loading
Loading