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 4 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
169 changes: 169 additions & 0 deletions lib/utils/oidc.js
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)
if (exitingToken) {
log.silly('oidc', 'Existing token found')
} else {
log.silly('oidc', 'No existing token found')
}
Comment on lines +126 to +130
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 Existing token found in the workflow, it can also remind them to remove that token if they only use it to publish.


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,
}
21 changes: 16 additions & 5 deletions mock-registry/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,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 +373,7 @@ class MockRegistry {
}
}
if (!noPut) {
this.putPackage(name, { code: putCode, packageJson, access })
this.putPackage(name, { code: putCode, packageJson, access, token })
}
}

Expand All @@ -391,10 +391,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 +630,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
131 changes: 131 additions & 0 deletions test/fixtures/mock-oidc.js
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,
}
Loading
Loading