diff --git a/lib/commands/publish.js b/lib/commands/publish.js index cc15087f0b368..6586e652c7b81 100644 --- a/lib/commands/publish.js +++ b/lib/commands/publish.js @@ -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' @@ -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) diff --git a/lib/commands/view.js b/lib/commands/view.js index eb6f0fcab8e6a..3d62c2e9083d8 100644 --- a/lib/commands/view.js +++ b/lib/commands/view.js @@ -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) } diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js new file mode 100644 index 0000000000000..5569154457ca8 --- /dev/null +++ b/lib/utils/oidc.js @@ -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, +} diff --git a/mock-registry/lib/index.js b/mock-registry/lib/index.js index 8248631519054..31ae2679c0e98 100644 --- a/mock-registry/lib/index.js +++ b/mock-registry/lib/index.js @@ -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}`) } } @@ -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 @@ -373,7 +377,7 @@ class MockRegistry { } } if (!noPut) { - this.putPackage(name, { code: putCode, packageJson, access }) + this.putPackage(name, { code: putCode, packageJson, access, token }) } } @@ -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) { @@ -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 diff --git a/mock-registry/lib/provenance.js b/mock-registry/lib/provenance.js new file mode 100644 index 0000000000000..108c158efb9a8 --- /dev/null +++ b/mock-registry/lib/provenance.js @@ -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: 'foo@bar.com', + })).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, +} diff --git a/test/fixtures/mock-oidc.js b/test/fixtures/mock-oidc.js new file mode 100644 index 0000000000000..8a9c5358e06de --- /dev/null +++ b/test/fixtures/mock-oidc.js @@ -0,0 +1,182 @@ +const nock = require('nock') +const ciInfo = require('ci-info') + +// this is an effort to not add a dependency to the cli just for testing +function makeJwt (payload) { + const header = { alg: 'none', typ: 'JWT' } + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64') + const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64') + // empty signature section + return `${headerB64}.${payloadB64}.` +} + +function gitlabIdToken ({ visibility = 'public' } = { visibility: 'public' }) { + const now = Math.floor(Date.now() / 1000) + const payload = { + project_visibility: visibility, + iat: now, + exp: now + 3600, // 1 hour expiration + } + return makeJwt(payload) +} + +function githubIdToken ({ visibility = 'public' } = { visibility: 'public' }) { + const now = Math.floor(Date.now() / 1000) + const payload = { + repository_visibility: visibility, + iat: now, + exp: now + 3600, // 1 hour expiration + } + return makeJwt(payload) +} + +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', + SIGSTORE_ID_TOKEN: undefined, + } + 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.SIGSTORE_ID_TOKEN = options.SIGSTORE_ID_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, + SIGSTORE_ID_TOKEN: process.env.SIGSTORE_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 + delete process.env.SIGSTORE_ID_TOKEN + + ciInfo.GITHUB_ACTIONS = false + ciInfo.GITLAB = false + + if (this.github) { + if (typeof this.ACTIONS_ID_TOKEN_REQUEST_URL === 'string') { + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = this.ACTIONS_ID_TOKEN_REQUEST_URL + } + if (typeof this.ACTIONS_ID_TOKEN_REQUEST_TOKEN === 'string') { + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = this.ACTIONS_ID_TOKEN_REQUEST_TOKEN + } + ciInfo.GITHUB_ACTIONS = true + } + + if (this.gitlab) { + if (typeof this.NPM_ID_TOKEN === 'string') { + process.env.NPM_ID_TOKEN = this.NPM_ID_TOKEN + } + if (typeof this.SIGSTORE_ID_TOKEN === 'string') { + process.env.SIGSTORE_ID_TOKEN = this.SIGSTORE_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) { + if (typeof this.originalEnv[key] === 'string') { + process.env[key] = this.originalEnv[key] + } else { + delete process.env[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, + gitlabIdToken, + githubIdToken, +} diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index 3d1d629e31ba4..d62fb380d1d7a 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -5,6 +5,8 @@ const pacote = require('pacote') const Arborist = require('@npmcli/arborist') const path = require('node:path') const fs = require('node:fs') +const { MockOidc, githubIdToken, gitlabIdToken } = require('../../fixtures/mock-oidc') +const { MockProvenance } = require('@npmcli/mock-registry/lib/provenance') const pkg = '@npmcli/test-package' const token = 'test-auth-token' @@ -988,3 +990,453 @@ t.test('semver highest dist tag', async t => { await npm.exec('publish', []) }) }) + +const oidcPublishTest = ({ + oidcOptions = {}, + packageName = '@npmcli/test-package', + config = {}, + packageJson = {}, + load = {}, + mockGithubOidcOptions = null, + mockOidcTokenExchangeOptions = null, + publishOptions = {}, + provenance = false, +}) => { + return async (t) => { + const oidc = MockOidc.tnock(t, oidcOptions) + const { npm, registry } = await loadNpmWithRegistry(t, { + config, + prefixDir: { + 'package.json': JSON.stringify({ + name: packageName, + version: '1.0.0', + ...packageJson, + }, null, 2), + }, + ...load, + }) + if (mockGithubOidcOptions) { + oidc.mockGithubOidc(mockGithubOidcOptions) + } + if (mockOidcTokenExchangeOptions) { + registry.mockOidcTokenExchange({ + packageName, + ...mockOidcTokenExchangeOptions, + }) + } + + registry.publish(packageName, publishOptions) + + if ((oidc.github || oidc.gitlab) && provenance) { + registry.getVisibility({ spec: packageName, visibility: { public: true } }) + + MockProvenance.successfulNock({ + oidcURL: oidc.ACTIONS_ID_TOKEN_REQUEST_URL, + requestToken: oidc.ACTIONS_ID_TOKEN_REQUEST_TOKEN, + workflowPath: '.github/workflows/publish.yml', + repository: 'github/foo', + serverUrl: 'https://github.com', + ref: 'refs/tags/pkg@1.0.0', + sha: 'deadbeef', + runID: '123456', + runAttempt: '1', + runnerEnv: 'github-hosted', + }) + } + + await npm.exec('publish', []) + + oidc.reset() + } +} + +t.test('oidc token exchange - no provenance', t => { + const githubPrivateIdToken = githubIdToken({ visibility: 'private' }) + const gitlabPrivateIdToken = gitlabIdToken({ visibility: 'private' }) + + t.test('oidc token 500 with fallback', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + statusCode: 500, + }, + publishOptions: { + token: 'existing-fallback-token', + }, + })) + + t.test('oidc token invalid body with fallback', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: null, + }, + publishOptions: { + token: 'existing-fallback-token', + }, + })) + + t.test('token exchange 500 with fallback', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: githubPrivateIdToken, + }, + mockOidcTokenExchangeOptions: { + statusCode: 500, + idToken: githubPrivateIdToken, + body: { + message: 'oidc token exchange failed', + }, + }, + publishOptions: { + token: 'existing-fallback-token', + }, + })) + + t.test('token exchange 500 (with no body message) with fallback', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: githubPrivateIdToken, + }, + mockOidcTokenExchangeOptions: { + idToken: githubPrivateIdToken, + statusCode: 500, + body: undefined, + }, + publishOptions: { + token: 'existing-fallback-token', + }, + })) + + t.test('token exchange invalid body with fallback', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: githubPrivateIdToken, + }, + mockOidcTokenExchangeOptions: { + idToken: githubPrivateIdToken, + body: { + token: null, + }, + }, + publishOptions: { + token: 'existing-fallback-token', + }, + })) + + t.test('github + missing ACTIONS_ID_TOKEN_REQUEST_URL', oidcPublishTest({ + oidcOptions: { github: true, ACTIONS_ID_TOKEN_REQUEST_URL: '' }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + publishOptions: { + token: 'existing-fallback-token', + }, + })) + + t.test('gitlab + missing NPM_ID_TOKEN', oidcPublishTest({ + oidcOptions: { gitlab: true, NPM_ID_TOKEN: '' }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + publishOptions: { + token: 'existing-fallback-token', + }, + })) + + t.test('no ci', oidcPublishTest({ + oidcOptions: { github: false, gitlab: false }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + publishOptions: { + token: 'existing-fallback-token', + }, + })) + + // default registry success + + t.test('default registry success github', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: githubPrivateIdToken, + }, + mockOidcTokenExchangeOptions: { + idToken: githubPrivateIdToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + })) + + t.test('default registry success gitlab', oidcPublishTest({ + oidcOptions: { gitlab: true, NPM_ID_TOKEN: gitlabPrivateIdToken }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockOidcTokenExchangeOptions: { + idToken: gitlabPrivateIdToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + })) + + // custom registry success + + t.test('custom registry (config) success github', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + registry: 'https://registry.zzz.org', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.zzz.org', + idToken: githubPrivateIdToken, + }, + mockOidcTokenExchangeOptions: { + idToken: githubPrivateIdToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + })) + + t.test('custom registry (scoped config) success github', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '@npmcli:registry': 'https://registry.zzz.org', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.zzz.org', + idToken: githubPrivateIdToken, + }, + mockOidcTokenExchangeOptions: { + idToken: githubPrivateIdToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + load: { + registry: 'https://registry.zzz.org', + }, + })) + + t.test('custom registry (publishConfig) success github', oidcPublishTest({ + oidcOptions: { github: true }, + packageJson: { + publishConfig: { + registry: 'https://registry.zzz.org', + }, + }, + mockGithubOidcOptions: { + audience: 'npm:registry.zzz.org', + idToken: githubPrivateIdToken, + }, + mockOidcTokenExchangeOptions: { + idToken: githubPrivateIdToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + load: { + registry: 'https://registry.zzz.org', + }, + })) + + t.test('dry-run can be used to check oidc config but not publish', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + 'dry-run': true, + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: githubPrivateIdToken, + }, + mockOidcTokenExchangeOptions: { + idToken: githubPrivateIdToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + noPut: true, + }, + })) + + t.end() +}) + +t.test('oidc token exchange -- provenance', (t) => { + const githubPublicIdToken = githubIdToken({ visibility: 'public' }) + const gitlabPublicIdToken = gitlabIdToken({ visibility: 'public' }) + const SIGSTORE_ID_TOKEN = MockProvenance.sigstoreIdToken() + + t.test('default registry success github', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: githubPublicIdToken, + }, + mockOidcTokenExchangeOptions: { + idToken: githubPublicIdToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + provenance: true, + })) + + t.test('default registry success gitlab', oidcPublishTest({ + oidcOptions: { gitlab: true, NPM_ID_TOKEN: gitlabPublicIdToken, SIGSTORE_ID_TOKEN }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockOidcTokenExchangeOptions: { + idToken: gitlabPublicIdToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + provenance: true, + })) + + t.test('default registry success gitlab without SIGSTORE_ID_TOKEN', oidcPublishTest({ + oidcOptions: { gitlab: true, NPM_ID_TOKEN: gitlabPublicIdToken }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockOidcTokenExchangeOptions: { + idToken: gitlabPublicIdToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + provenance: false, + })) + + t.test('setting provenance true in config should enable provenance', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + provenance: true, + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: githubPublicIdToken, + }, + mockOidcTokenExchangeOptions: { + idToken: githubPublicIdToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + provenance: true, + })) + + t.test('setting provenance false in config should not use provenance', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + provenance: false, + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: githubPublicIdToken, + }, + mockOidcTokenExchangeOptions: { + idToken: githubPublicIdToken, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + })) + + const brokenJwts = [ + 'x.invalid-jwt.x', + 'x.invalid-jwt.', + 'x.invalid-jwt', + 'x.', + 'x', + ] + + brokenJwts.map((brokenJwt) => { + // windows does not like `.` in the filename + t.test(`broken jwt ${brokenJwt.replaceAll('.', '_')}`, oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: brokenJwt, + }, + mockOidcTokenExchangeOptions: { + idToken: brokenJwt, + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + })) + }) + + t.end() +}) diff --git a/test/lib/commands/view.js b/test/lib/commands/view.js index e2ef35a5fd5b7..5b63cecf7daf7 100644 --- a/test/lib/commands/view.js +++ b/test/lib/commands/view.js @@ -126,6 +126,34 @@ const packument = (nv, opts) => { '1.0.1': {}, }, }, + 'cyan-oidc': { + _npmUser: { + name: 'claudia', + email: 'claudia@cyan.com', + trustedPublisher: { + id: 'github', + oidcConfigId: 'oidc:a0e127d0-8d66-45d0-8264-e4f8372c7249', + }, + }, + name: 'cyan', + 'dist-tags': { + latest: '1.0.0', + }, + versions: { + '1.0.0': { + version: '1.0.0', + name: 'cyan', + dist: { + shasum: '123', + tarball: 'http://hm.cyan.com/1.0.0.tgz', + integrity: '---', + fileCount: 1, + unpackedSize: 1000000, + }, + }, + '1.0.1': {}, + }, + }, brown: { name: 'brown', }, @@ -438,6 +466,12 @@ t.test('package with --json and semver range', async t => { t.matchSnapshot(joinedOutput()) }) +t.test('package with _npmUser.trustedPublisher shows cleaned up property with --json', async t => { + const { view, joinedOutput } = await loadMockNpm(t, { config: { json: true } }) + await view.exec(['cyan-oidc@^1.0.0']) + t.match(joinedOutput(), /claudia /, 'uses oidc trustedPublisher info for _npmUser') +}) + t.test('package with --json and no versions', async t => { const { view, joinedOutput } = await loadMockNpm(t, { config: { json: true } }) await view.exec(['brown']) diff --git a/workspaces/libnpmpublish/lib/publish.js b/workspaces/libnpmpublish/lib/publish.js index 001dff8de87f0..933e142422b6c 100644 --- a/workspaces/libnpmpublish/lib/publish.js +++ b/workspaces/libnpmpublish/lib/publish.js @@ -205,7 +205,7 @@ const ensureProvenanceGeneration = async (registry, spec, opts) => { if (opts.access !== 'public') { try { const res = await npmFetch - .json(`${registry}/-/package/${spec.escapedName}/visibility`, opts) + .json(`/-/package/${spec.escapedName}/visibility`, { ...opts, registry }) visibility = res } catch (err) { if (err.code !== 'E404') {