Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 })
Comment thread
reggi marked this conversation as resolved.

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)
Comment thread
reggi marked this conversation as resolved.
Outdated
if (exitingToken) {
log.silly('oidc', 'Existing token found')
} else {
log.silly('oidc', 'No existing token found')
}
Comment thread
reggi marked this conversation as resolved.
Outdated

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')
Comment thread
wraithgar marked this conversation as 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,
}
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