-
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
base: latest
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,169 @@ | ||
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 | ||
} | ||
|
||
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 exitingToken = config.get(authTokenKey) | ||
reggi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if (exitingToken) { | ||
log.silly('oidc', 'Existing token found') | ||
} else { | ||
log.silly('oidc', 'No existing token found') | ||
} | ||
Comment on lines
+126
to
+130
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the check for an existing token config useful? Maybe we should simplify and remove this code. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do think it's useful to know if we're about to potentially overwrite an existing token or not, but i have no strong feeling about it. If someone sees |
||
|
||
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, | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
const nock = require('nock') | ||
const ciInfo = require('ci-info') | ||
|
||
class MockOidc { | ||
constructor (opts) { | ||
const defaultOpts = { | ||
github: false, | ||
gitlab: false, | ||
ACTIONS_ID_TOKEN_REQUEST_URL: 'https://github.com/actions/id-token', | ||
ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'ACTIONS_ID_TOKEN_REQUEST_TOKEN', | ||
NPM_ID_TOKEN: 'NPM_ID_TOKEN', | ||
GITHUB_ID_TOKEN: 'mock-github-id-token', | ||
} | ||
const options = { ...defaultOpts, ...opts } | ||
|
||
this.github = options.github | ||
this.gitlab = options.gitlab | ||
this.ACTIONS_ID_TOKEN_REQUEST_URL = options.ACTIONS_ID_TOKEN_REQUEST_URL | ||
this.ACTIONS_ID_TOKEN_REQUEST_TOKEN = options.ACTIONS_ID_TOKEN_REQUEST_TOKEN | ||
this.NPM_ID_TOKEN = options.NPM_ID_TOKEN | ||
this.GITHUB_ID_TOKEN = options.GITHUB_ID_TOKEN | ||
|
||
// Backup only the relevant environment variables and ciInfo values | ||
this.originalEnv = { | ||
CI: process.env.CI, | ||
GITHUB_ACTIONS: process.env.GITHUB_ACTIONS, | ||
ACTIONS_ID_TOKEN_REQUEST_URL: process.env.ACTIONS_ID_TOKEN_REQUEST_URL, | ||
ACTIONS_ID_TOKEN_REQUEST_TOKEN: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN, | ||
GITLAB_CI: process.env.GITLAB_CI, | ||
NPM_ID_TOKEN: process.env.NPM_ID_TOKEN, | ||
} | ||
this.originalCiInfo = { | ||
GITLAB: ciInfo.GITLAB, | ||
GITHUB_ACTIONS: ciInfo.GITHUB_ACTIONS, | ||
} | ||
this.setupEnvironment() | ||
} | ||
|
||
get idToken () { | ||
if (this.github) { | ||
return this.GITHUB_ID_TOKEN | ||
} | ||
if (this.gitlab) { | ||
return this.NPM_ID_TOKEN | ||
} | ||
return undefined | ||
} | ||
|
||
setupEnvironment () { | ||
delete process.env.CI | ||
delete process.env.GITHUB_ACTIONS | ||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL | ||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN | ||
delete process.env.GITLAB_CI | ||
delete process.env.NPM_ID_TOKEN | ||
|
||
ciInfo.GITHUB_ACTIONS = false | ||
ciInfo.GITLAB = false | ||
|
||
if (this.github) { | ||
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = this.ACTIONS_ID_TOKEN_REQUEST_URL | ||
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = this.ACTIONS_ID_TOKEN_REQUEST_TOKEN | ||
ciInfo.GITHUB_ACTIONS = true | ||
} | ||
|
||
if (this.gitlab) { | ||
process.env.NPM_ID_TOKEN = this.NPM_ID_TOKEN | ||
ciInfo.GITLAB = true | ||
} | ||
} | ||
|
||
mockGithubOidc ({ idToken = this.GITHUB_ID_TOKEN, audience, statusCode = 200 } = {}) { | ||
const url = new URL(this.ACTIONS_ID_TOKEN_REQUEST_URL) | ||
return nock(url.origin) | ||
.get(url.pathname) | ||
.query({ audience }) | ||
.matchHeader('authorization', `Bearer ${this.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`) | ||
.matchHeader('accept', 'application/json') | ||
.reply(statusCode, statusCode !== 500 ? { value: idToken } : { message: 'Internal Server Error' }) | ||
} | ||
|
||
reset () { | ||
// Restore only the backed-up environment variables | ||
for (const key in this.originalEnv) { | ||
process.env[key] = this.originalEnv[key] | ||
} | ||
|
||
// Restore the original ciInfo values | ||
ciInfo.GITLAB = this.originalCiInfo.GITLAB | ||
ciInfo.GITHUB_ACTIONS = this.originalCiInfo.GITHUB_ACTIONS | ||
|
||
nock.cleanAll() | ||
} | ||
|
||
static tnock (t, opts = {}, { debug = false, strict = false } = {}) { | ||
const instance = new MockOidc(opts) | ||
|
||
const noMatch = (req) => { | ||
if (debug) { | ||
/* eslint-disable-next-line no-console */ | ||
console.error('NO MATCH', t.name, req.options ? req.options : req.path) | ||
} | ||
if (strict) { | ||
t.comment(`Unmatched request: ${req.method} ${req.path}`) | ||
t.fail(`Unmatched request: ${req.method} ${req.path}`) | ||
} | ||
} | ||
|
||
nock.emitter.on('no match', noMatch) | ||
nock.disableNetConnect() | ||
|
||
if (strict) { | ||
t.afterEach(() => { | ||
t.strictSame(nock.pendingMocks(), [], 'no pending mocks after each') | ||
}) | ||
} | ||
|
||
t.teardown(() => { | ||
nock.enableNetConnect() | ||
nock.emitter.off('no match', noMatch) | ||
nock.cleanAll() | ||
instance.reset() | ||
}) | ||
|
||
return instance | ||
} | ||
} | ||
|
||
module.exports = { | ||
MockOidc, | ||
} |
Uh oh!
There was an error while loading. Please reload this page.