-
Notifications
You must be signed in to change notification settings - Fork 3.5k
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
reggi
wants to merge
8
commits into
latest
Choose a base branch
from
oidc
base: latest
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+996
β8
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
9021253
feat: adds support for oidc publish
reggi 2ffc2ea
fix: change to the oidc flow for more granular control over log levelβ¦
reggi 83ef001
Small change to registry fetch options
reggi cb60ef4
Merge branch 'latest' into oidc
reggi 567f15b
feat: npm view oidc considerations (#8432)
reggi e4ad90c
feat: oidc provenance by default (#8412)
reggi 3d0ee62
give it a dry-run test
reggi 19c25b9
fix: checks for sigstore env for gitlab provenance (#8439)
reggi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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') | ||
wraithgar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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, | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.