diff --git a/lib/utils/oidc.js b/lib/utils/oidc.js index 694177eef18b6..b13a440d84fc6 100644 --- a/lib/utils/oidc.js +++ b/lib/utils/oidc.js @@ -116,14 +116,44 @@ async function oidc ({ packageName, registry, opts, config }) { 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 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) 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') 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..eaddb8f783663 100644 --- a/test/fixtures/mock-oidc.js +++ b/test/fixtures/mock-oidc.js @@ -1,6 +1,35 @@ 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 = { @@ -17,6 +46,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 +59,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 +85,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 +98,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 +162,6 @@ class MockOidc { module.exports = { MockOidc, + gitlabIdToken, + githubIdToken, } diff --git a/test/lib/commands/publish.js b/test/lib/commands/publish.js index 13a284e0c1f5b..a705b016eff9a 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, + }) + } + 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', }) - if (mockGithubOidcOptions) { - oidc.mockGithubOidc(mockGithubOidcOptions) - } - if (mockOidcTokenExchangeOptions) { - registry.mockOidcTokenExchange({ - packageName, - ...mockOidcTokenExchangeOptions, - }) - } - registry.publish(packageName, publishOptions) - await npm.exec('publish', []) - oidc.reset() } + + 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,123 @@ 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('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/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') {