From f55cce114bf8837cd1720d491a92cd0862f68f45 Mon Sep 17 00:00:00 2001 From: reggi Date: Wed, 9 Jul 2025 10:15:51 -0400 Subject: [PATCH 1/7] feat: oidc default provenance --- lib/utils/oidc.js | 23 +++ mock-registry/lib/index.js | 6 +- mock-registry/lib/provenance.js | 99 ++++++++++++ test/fixtures/mock-oidc.js | 47 ++++++ test/fixtures/mock-provenance.js | 125 +++++++++++++++ test/lib/commands/publish.js | 199 +++++++++++++++++------- workspaces/libnpmpublish/lib/publish.js | 2 +- 7 files changed, 445 insertions(+), 56 deletions(-) create mode 100644 mock-registry/lib/provenance.js create mode 100644 test/fixtures/mock-provenance.js diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index 694177eef18b6..a6c0470021c4d 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -116,6 +116,29 @@ async function oidc ({ packageName, registry, opts, config }) { return undefined } + const [headerB64, payloadB64] = idToken.split('.') + let isPublicRepo = 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') { + isPublicRepo = true + } + if (ciInfo.GITLAB && payload.project_visibility === 'public') { + isPublicRepo = true + } + } catch (e) { + log.silly('oidc', 'Failed to parse idToken payload as JSON') + } + } + + if (isPublicRepo) { + 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) diff --git a/mock-registry/lib/index.js b/mock-registry/lib/index.js index 65cf4b8983aa3..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}`) } } 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 index 4dd625e9744ce..c4428831132d3 100644 --- a/test/fixtures/mock-oidc.js +++ b/test/fixtures/mock-oidc.js @@ -1,6 +1,45 @@ const nock = require('nock') const ciInfo = require('ci-info') +function tnock (t, host, opts) { + nock.disableNetConnect() + const server = nock(host, opts) + t.teardown(function () { + nock.enableNetConnect() + server.done() + }) + return server +} + +// 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 = { @@ -17,6 +56,8 @@ class MockOidc { 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 @@ -28,6 +69,7 @@ class MockOidc { 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, @@ -53,6 +95,7 @@ class MockOidc { 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 @@ -65,6 +108,7 @@ class MockOidc { if (this.gitlab) { process.env.NPM_ID_TOKEN = this.NPM_ID_TOKEN + process.env.SIGSTORE_ID_TOKEN = this.SIGSTORE_ID_TOKEN ciInfo.GITLAB = true } } @@ -128,4 +172,7 @@ class MockOidc { module.exports = { MockOidc, + gitlabIdToken, + githubIdToken, + tnock, } diff --git a/test/fixtures/mock-provenance.js b/test/fixtures/mock-provenance.js new file mode 100644 index 0000000000000..c7e97278dea03 --- /dev/null +++ b/test/fixtures/mock-provenance.js @@ -0,0 +1,125 @@ +'use strict' +const t = require('tap') +const MockRegistry = require('./index') +const mockGlobals = require('@npmcli/mock-globals') + +class MockProvenance { + 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, + }, + }) + + // Data for mocking the OIDC token request + const oidcClaims = { + iss: 'https://oauth2.sigstore.dev/auth', + email: 'foo@bar.com', + } + const idToken = `.${Buffer.from(JSON.stringify(oidcClaims)).toString('base64')}.` + + // Data for mocking Fulcio certifcate request + const fulcioURL = 'https://mock.fulcio' + const leafCertificate = `-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n` + const rootCertificate = `-----BEGIN CERTIFICATE-----\nxyz\n-----END CERTIFICATE-----\n` + const certificateResponse = { + signedCertificateEmbeddedSct: { + chain: { + certificates: [leafCertificate, rootCertificate], + }, + }, + } + + // Data for mocking Rekor upload + const rekorURL = 'https://mock.rekor' + const signature = 'ABC123' + const b64Cert = Buffer.from(leafCertificate).toString('base64') + const logIndex = 2513258 + const uuid = + '69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6' + + const signatureBundle = { + kind: 'hashedrekord', + apiVersion: '0.0.1', + spec: { + signature: { + content: signature, + publicKey: { content: b64Cert }, + }, + }, + } + + const rekorEntry = { + [uuid]: { + body: Buffer.from(JSON.stringify(signatureBundle)).toString( + 'base64' + ), + integratedTime: 1654015743, + logID: + 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d', + logIndex, + verification: { + /* eslint-disable-next-line max-len */ + signedEntryTimestamp: 'MEUCIQD6CD7ZNLUipFoxzmSL/L8Ewic4SRkXN77UjfJZ7d/wAAIgatokSuX9Rg0iWxAgSfHMtcsagtDCQalU5IvXdQ+yLEA=', + }, + }, + } + + const oidcSrv = MockRegistry.tnock(t, oidcURL) + oidcSrv.get('/?audience=sigstore', undefined, { + authorization: `Bearer ${requestToken}`, + }).reply(200, { value: idToken }) + + const fulcioSrv = MockRegistry.tnock(t, fulcioURL) + fulcioSrv.matchHeader('Content-Type', 'application/json') + .post('/api/v2/signingCert', { + credentials: { oidcIdentityToken: idToken }, + publicKeyRequest: { + publicKey: { + algorithm: 'ECDSA', + content: /.+/i, + }, + proofOfPossession: /.+/i, + }, + }) + .reply(200, certificateResponse) + + const rekorSrv = MockRegistry.tnock(t, rekorURL) + rekorSrv + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/json') + .post('/api/v1/log/entries') + .reply(201, rekorEntry) + + return { + fulcioURL: fulcioURL, + rekorURL: rekorURL, + } + } +} + +module.exports = { + MockProvenance, +} diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index 13a284e0c1f5b..3c909d3ad5b2f 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -5,7 +5,8 @@ const pacote = require('pacote') const Arborist = require('@npmcli/arborist') const path = require('node:path') const fs = require('node:fs') -const { MockOidc } = require('../../fixtures/mock-oidc') +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' @@ -990,46 +991,67 @@ t.test('semver highest dist tag', async t => { }) }) -t.test('oidc token exchange', t => { - const oidcPublishTest = ({ - oidcOptions = {}, - packageName = '@npmcli/test-package', - config = {}, - packageJson = {}, - load = {}, - mockGithubOidcOptions = null, - mockOidcTokenExchangeOptions = null, - publishOptions = {}, - }) => { - 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, +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, }) - if (mockGithubOidcOptions) { - oidc.mockGithubOidc(mockGithubOidcOptions) - } - if (mockOidcTokenExchangeOptions) { - registry.mockOidcTokenExchange({ - packageName, - ...mockOidcTokenExchangeOptions, - }) - } - registry.publish(packageName, publishOptions) - await npm.exec('publish', []) - oidc.reset() } + 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() } +} - // fallback failures +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 }, @@ -1066,11 +1088,11 @@ t.test('oidc token exchange', t => { }, mockGithubOidcOptions: { audience: 'npm:registry.npmjs.org', - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, }, mockOidcTokenExchangeOptions: { statusCode: 500, - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, body: { message: 'oidc token exchange failed', }, @@ -1087,11 +1109,12 @@ t.test('oidc token exchange', t => { }, mockGithubOidcOptions: { audience: 'npm:registry.npmjs.org', - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, }, mockOidcTokenExchangeOptions: { + idToken: githubPrivateIdToken, statusCode: 500, - idToken: 'github-jwt-id-token', + body: undefined, }, publishOptions: { token: 'existing-fallback-token', @@ -1105,11 +1128,13 @@ t.test('oidc token exchange', t => { }, mockGithubOidcOptions: { audience: 'npm:registry.npmjs.org', - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, }, mockOidcTokenExchangeOptions: { - token: null, - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, + body: { + token: null, + }, }, publishOptions: { token: 'existing-fallback-token', @@ -1155,10 +1180,10 @@ t.test('oidc token exchange', t => { }, mockGithubOidcOptions: { audience: 'npm:registry.npmjs.org', - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, }, mockOidcTokenExchangeOptions: { - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, body: { token: 'exchange-token', }, @@ -1169,12 +1194,12 @@ t.test('oidc token exchange', t => { })) t.test('default registry success gitlab', oidcPublishTest({ - oidcOptions: { gitlab: true, NPM_ID_TOKEN: 'gitlab-jwt-id-token' }, + oidcOptions: { gitlab: true, NPM_ID_TOKEN: gitlabPrivateIdToken }, config: { '//registry.npmjs.org/:_authToken': 'existing-fallback-token', }, mockOidcTokenExchangeOptions: { - idToken: 'gitlab-jwt-id-token', + idToken: gitlabPrivateIdToken, body: { token: 'exchange-token', }, @@ -1193,10 +1218,10 @@ t.test('oidc token exchange', t => { }, mockGithubOidcOptions: { audience: 'npm:registry.zzz.org', - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, }, mockOidcTokenExchangeOptions: { - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, body: { token: 'exchange-token', }, @@ -1213,10 +1238,10 @@ t.test('oidc token exchange', t => { }, mockGithubOidcOptions: { audience: 'npm:registry.zzz.org', - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, }, mockOidcTokenExchangeOptions: { - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, body: { token: 'exchange-token', }, @@ -1238,10 +1263,10 @@ t.test('oidc token exchange', t => { }, mockGithubOidcOptions: { audience: 'npm:registry.zzz.org', - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, }, mockOidcTokenExchangeOptions: { - idToken: 'github-jwt-id-token', + idToken: githubPrivateIdToken, body: { token: 'exchange-token', }, @@ -1256,3 +1281,69 @@ t.test('oidc token exchange', t => { 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('valid oidc publish without provenance, invalid jwt id token parsing', oidcPublishTest({ + oidcOptions: { github: true }, + config: { + '//registry.npmjs.org/:_authToken': 'existing-fallback-token', + }, + mockGithubOidcOptions: { + audience: 'npm:registry.npmjs.org', + idToken: 'invalid-jwt', + }, + mockOidcTokenExchangeOptions: { + idToken: 'invalid-jwt', + body: { + token: 'exchange-token', + }, + }, + publishOptions: { + token: 'exchange-token', + }, + })) + + t.end() +}) 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') { From a066331134b5a72cb0e8e40dd9b9e7771beb022d Mon Sep 17 00:00:00 2001 From: reggi Date: Wed, 9 Jul 2025 10:29:38 -0400 Subject: [PATCH 2/7] make it a better, fake jwt --- test/lib/commands/publish.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index 3c909d3ad5b2f..db535cb315540 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -1332,10 +1332,10 @@ t.test('oidc token exchange -- provenance', (t) => { }, mockGithubOidcOptions: { audience: 'npm:registry.npmjs.org', - idToken: 'invalid-jwt', + idToken: 'x.invalid-jwt.x', }, mockOidcTokenExchangeOptions: { - idToken: 'invalid-jwt', + idToken: 'x.invalid-jwt.x', body: { token: 'exchange-token', }, From 9f7484cb4dfa5cf0ed55dec2d6cb3da041a16b90 Mon Sep 17 00:00:00 2001 From: reggi Date: Wed, 9 Jul 2025 10:37:17 -0400 Subject: [PATCH 3/7] typo existingToken --- lib/utils/oidc.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index a6c0470021c4d..4d8a1a92d840e 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -145,8 +145,8 @@ async function oidc ({ packageName, registry, opts, config }) { const regKey = `//${parsedRegistry.host}${parsedRegistry.pathname}` const authTokenKey = `${regKey}:_authToken` - const exitingToken = config.get(authTokenKey) - if (exitingToken) { + const existingToken = config.get(authTokenKey) + if (existingToken) { log.silly('oidc', 'Existing token found') } else { log.silly('oidc', 'No existing token found') From 6ae6e242cdac0a2e1e55c3f2c90abf748011cc09 Mon Sep 17 00:00:00 2001 From: reggi Date: Wed, 9 Jul 2025 10:40:41 -0400 Subject: [PATCH 4/7] layered broken jwts --- test/lib/commands/publish.js | 44 ++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index db535cb315540..e283b0dc9be4e 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -1325,25 +1325,35 @@ t.test('oidc token exchange -- provenance', (t) => { provenance: true, })) - t.test('valid oidc publish without provenance, invalid jwt id token parsing', oidcPublishTest({ - oidcOptions: { github: true }, - config: { - '//registry.npmjs.org/:_authToken': 'existing-fallback-token', - }, - mockGithubOidcOptions: { - audience: 'npm:registry.npmjs.org', - idToken: 'x.invalid-jwt.x', - }, - mockOidcTokenExchangeOptions: { - idToken: 'x.invalid-jwt.x', - body: { + const brokenJwts = [ + 'x.invalid-jwt.x', + 'x.invalid-jwt.', + 'x.invalid-jwt', + 'x.', + 'x', + ] + + brokenJwts.map((brokenJwt) => { + t.test(`broken jwt ${brokenJwt}`, 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', }, - }, - publishOptions: { - token: 'exchange-token', - }, - })) + })) + }) t.end() }) From f61c00704e23d92a73543b5f82113ad9fccc9812 Mon Sep 17 00:00:00 2001 From: reggi Date: Wed, 9 Jul 2025 10:48:39 -0400 Subject: [PATCH 5/7] cleanup --- test/fixtures/mock-oidc.js | 11 --- test/fixtures/mock-provenance.js | 125 ------------------------------- 2 files changed, 136 deletions(-) delete mode 100644 test/fixtures/mock-provenance.js diff --git a/test/fixtures/mock-oidc.js b/test/fixtures/mock-oidc.js index c4428831132d3..eaddb8f783663 100644 --- a/test/fixtures/mock-oidc.js +++ b/test/fixtures/mock-oidc.js @@ -1,16 +1,6 @@ const nock = require('nock') const ciInfo = require('ci-info') -function tnock (t, host, opts) { - nock.disableNetConnect() - const server = nock(host, opts) - t.teardown(function () { - nock.enableNetConnect() - server.done() - }) - return server -} - // this is an effort to not add a dependency to the cli just for testing function makeJwt (payload) { const header = { alg: 'none', typ: 'JWT' } @@ -174,5 +164,4 @@ module.exports = { MockOidc, gitlabIdToken, githubIdToken, - tnock, } diff --git a/test/fixtures/mock-provenance.js b/test/fixtures/mock-provenance.js deleted file mode 100644 index c7e97278dea03..0000000000000 --- a/test/fixtures/mock-provenance.js +++ /dev/null @@ -1,125 +0,0 @@ -'use strict' -const t = require('tap') -const MockRegistry = require('./index') -const mockGlobals = require('@npmcli/mock-globals') - -class MockProvenance { - 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, - }, - }) - - // Data for mocking the OIDC token request - const oidcClaims = { - iss: 'https://oauth2.sigstore.dev/auth', - email: 'foo@bar.com', - } - const idToken = `.${Buffer.from(JSON.stringify(oidcClaims)).toString('base64')}.` - - // Data for mocking Fulcio certifcate request - const fulcioURL = 'https://mock.fulcio' - const leafCertificate = `-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n` - const rootCertificate = `-----BEGIN CERTIFICATE-----\nxyz\n-----END CERTIFICATE-----\n` - const certificateResponse = { - signedCertificateEmbeddedSct: { - chain: { - certificates: [leafCertificate, rootCertificate], - }, - }, - } - - // Data for mocking Rekor upload - const rekorURL = 'https://mock.rekor' - const signature = 'ABC123' - const b64Cert = Buffer.from(leafCertificate).toString('base64') - const logIndex = 2513258 - const uuid = - '69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6' - - const signatureBundle = { - kind: 'hashedrekord', - apiVersion: '0.0.1', - spec: { - signature: { - content: signature, - publicKey: { content: b64Cert }, - }, - }, - } - - const rekorEntry = { - [uuid]: { - body: Buffer.from(JSON.stringify(signatureBundle)).toString( - 'base64' - ), - integratedTime: 1654015743, - logID: - 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d', - logIndex, - verification: { - /* eslint-disable-next-line max-len */ - signedEntryTimestamp: 'MEUCIQD6CD7ZNLUipFoxzmSL/L8Ewic4SRkXN77UjfJZ7d/wAAIgatokSuX9Rg0iWxAgSfHMtcsagtDCQalU5IvXdQ+yLEA=', - }, - }, - } - - const oidcSrv = MockRegistry.tnock(t, oidcURL) - oidcSrv.get('/?audience=sigstore', undefined, { - authorization: `Bearer ${requestToken}`, - }).reply(200, { value: idToken }) - - const fulcioSrv = MockRegistry.tnock(t, fulcioURL) - fulcioSrv.matchHeader('Content-Type', 'application/json') - .post('/api/v2/signingCert', { - credentials: { oidcIdentityToken: idToken }, - publicKeyRequest: { - publicKey: { - algorithm: 'ECDSA', - content: /.+/i, - }, - proofOfPossession: /.+/i, - }, - }) - .reply(200, certificateResponse) - - const rekorSrv = MockRegistry.tnock(t, rekorURL) - rekorSrv - .matchHeader('Accept', 'application/json') - .matchHeader('Content-Type', 'application/json') - .post('/api/v1/log/entries') - .reply(201, rekorEntry) - - return { - fulcioURL: fulcioURL, - rekorURL: rekorURL, - } - } -} - -module.exports = { - MockProvenance, -} From a13b6fa35f36fa263ba1b59d6769ea07824faed7 Mon Sep 17 00:00:00 2001 From: reggi Date: Wed, 9 Jul 2025 10:56:20 -0400 Subject: [PATCH 6/7] fix windows test --- test/lib/commands/publish.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index e283b0dc9be4e..0b3ff77bee175 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -1334,7 +1334,8 @@ t.test('oidc token exchange -- provenance', (t) => { ] brokenJwts.map((brokenJwt) => { - t.test(`broken jwt ${brokenJwt}`, oidcPublishTest({ + // 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', From f1d9a1c18f358a4e3782949168d3b187ca59f313 Mon Sep 17 00:00:00 2001 From: reggi Date: Wed, 9 Jul 2025 11:22:46 -0400 Subject: [PATCH 7/7] detect if provenance was defaulted --- lib/utils/oidc.js | 43 +++++++++++++++++++++--------------- test/lib/commands/publish.js | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index 4d8a1a92d840e..b13a440d84fc6 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -116,27 +116,34 @@ async function oidc ({ packageName, registry, opts, config }) { return undefined } - const [headerB64, payloadB64] = idToken.split('.') - let isPublicRepo = 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') { - isPublicRepo = true - } - if (ciInfo.GITLAB && payload.project_visibility === 'public') { - isPublicRepo = true + // 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 isPublicRepo = 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') { + isPublicRepo = true + } + if (ciInfo.GITLAB && payload.project_visibility === 'public') { + isPublicRepo = true + } + } catch (e) { + log.silly('oidc', 'Failed to parse idToken payload as JSON') } - } catch (e) { - log.silly('oidc', 'Failed to parse idToken payload as JSON') } - } - if (isPublicRepo) { - log.silly('oidc', 'Repository is public, setting provenance') - opts.provenance = true - config.set('provenance', true, 'user') + if (isPublicRepo) { + 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`) diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index 0b3ff77bee175..a705b016eff9a 100644 --- a/test/lib/commands/publish.js +++ b/test/lib/commands/publish.js @@ -1325,6 +1325,49 @@ t.test('oidc token exchange -- provenance', (t) => { provenance: true, })) + 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.',