Skip to content

Commit 7d900c4

Browse files
reggiwraithgar
andauthored
fix: oidc visibility check for provenance (#8467)
When someone with a public repository and a private package attempts to publish with OIDC, the publish command will fail because provenance is enabled, there is currently no visibility check before auto enabling provenance. This is a very rare edge case, but it's still incorrect. OIDC will always gracefully fail, but provenance does not. * This fix adds all the provenance related code after the OIDC auth token is set. * This requires provenance to be in the default config state (not set by the user) for OIDC to even consider auto-enabling provannece. --------- Co-authored-by: Gar <[email protected]>
1 parent d4e56b2 commit 7d900c4

File tree

3 files changed

+135
-34
lines changed

3 files changed

+135
-34
lines changed

lib/utils/oidc.js

Lines changed: 26 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const npmFetch = require('npm-registry-fetch')
33
const ciInfo = require('ci-info')
44
const fetch = require('make-fetch-happen')
55
const npa = require('npm-package-arg')
6+
const libaccess = require('libnpmaccess')
67

78
/**
89
* Handles OpenID Connect (OIDC) token retrieval and exchange for CI environments.
@@ -108,31 +109,6 @@ async function oidc ({ packageName, registry, opts, config }) {
108109
return undefined
109110
}
110111

111-
// this checks if the user configured provenance or it's the default unset value
112-
const isDefaultProvenance = config.isDefault('provenance')
113-
const provenanceIntent = config.get('provenance')
114-
let enableProvenance = false
115-
116-
// if provenance is the default value or the user explicitly set it
117-
if (isDefaultProvenance || provenanceIntent) {
118-
const [headerB64, payloadB64] = idToken.split('.')
119-
if (headerB64 && payloadB64) {
120-
const payloadJson = Buffer.from(payloadB64, 'base64').toString('utf8')
121-
try {
122-
const payload = JSON.parse(payloadJson)
123-
if (ciInfo.GITHUB_ACTIONS && payload.repository_visibility === 'public') {
124-
enableProvenance = true
125-
}
126-
// only set provenance for gitlab if SIGSTORE_ID_TOKEN is available
127-
if (ciInfo.GITLAB && payload.project_visibility === 'public' && process.env.SIGSTORE_ID_TOKEN) {
128-
enableProvenance = true
129-
}
130-
} catch (e) {
131-
// Failed to parse idToken payload as JSON
132-
}
133-
}
134-
}
135-
136112
const parsedRegistry = new URL(registry)
137113
const regKey = `//${parsedRegistry.host}${parsedRegistry.pathname}`
138114
const authTokenKey = `${regKey}:_authToken`
@@ -155,12 +131,6 @@ async function oidc ({ packageName, registry, opts, config }) {
155131
return undefined
156132
}
157133

158-
if (enableProvenance) {
159-
// Repository is public, setting provenance
160-
opts.provenance = true
161-
config.set('provenance', true, 'user')
162-
}
163-
164134
/*
165135
* The "opts" object is a clone of npm.flatOptions and is passed through the `publish` command,
166136
* eventually reaching `otplease`. To ensure the token is accessible during the publishing process,
@@ -170,6 +140,31 @@ async function oidc ({ packageName, registry, opts, config }) {
170140
opts[authTokenKey] = response.token
171141
config.set(authTokenKey, response.token, 'user')
172142
log.verbose('oidc', `Successfully retrieved and set token`)
143+
144+
try {
145+
const isDefaultProvenance = config.isDefault('provenance')
146+
if (isDefaultProvenance) {
147+
const [headerB64, payloadB64] = idToken.split('.')
148+
if (headerB64 && payloadB64) {
149+
const payloadJson = Buffer.from(payloadB64, 'base64').toString('utf8')
150+
const payload = JSON.parse(payloadJson)
151+
if (
152+
(ciInfo.GITHUB_ACTIONS && payload.repository_visibility === 'public') ||
153+
// only set provenance for gitlab if the repo is public and SIGSTORE_ID_TOKEN is available
154+
(ciInfo.GITLAB && payload.project_visibility === 'public' && process.env.SIGSTORE_ID_TOKEN)
155+
) {
156+
const visibility = await libaccess.getVisibility(packageName, opts)
157+
if (visibility?.public) {
158+
log.verbose('oidc', `Enabling provenance`)
159+
opts.provenance = true
160+
config.set('provenance', true, 'user')
161+
}
162+
}
163+
}
164+
}
165+
} catch (error) {
166+
log.verbose('oidc', `Failed to set provenance with message: ${error?.message || 'Unknown error'}`)
167+
}
173168
} catch (error) {
174169
log.verbose('oidc', `Failure with message: ${error?.message || 'Unknown error'}`)
175170
}

test/fixtures/mock-oidc.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,11 @@ const mockOidc = async (t, {
3939
config = {},
4040
packageJson = {},
4141
load = {},
42-
mockGithubOidcOptions = null,
43-
mockOidcTokenExchangeOptions = null,
42+
mockGithubOidcOptions = false,
43+
mockOidcTokenExchangeOptions = false,
4444
publishOptions = {},
4545
provenance = false,
46+
oidcVisibilityOptions = false,
4647
}) => {
4748
const github = oidcOptions.github ?? false
4849
const gitlab = oidcOptions.gitlab ?? false
@@ -113,9 +114,17 @@ const mockOidc = async (t, {
113114
})
114115
}
115116

117+
if (oidcVisibilityOptions) {
118+
registry.getVisibility({ spec: packageName, visibility: oidcVisibilityOptions })
119+
}
120+
116121
registry.publish(packageName, publishOptions)
117122

118-
if ((github || gitlab) && provenance) {
123+
/**
124+
* this will nock / mock all the successful requirements for provenance and
125+
* assumes when a test has "provenance true" that these calls are expected
126+
*/
127+
if (provenance) {
119128
registry.getVisibility({ spec: packageName, visibility: { public: true } })
120129
mockProvenance(t, {
121130
oidcURL: ACTIONS_ID_TOKEN_REQUEST_URL,

test/lib/commands/publish.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1317,6 +1317,7 @@ t.test('oidc token exchange - no provenance', t => {
13171317
})
13181318

13191319
t.test('oidc token exchange - provenance', (t) => {
1320+
const githubPrivateIdToken = githubIdToken({ visibility: 'private' })
13201321
const githubPublicIdToken = githubIdToken({ visibility: 'public' })
13211322
const gitlabPublicIdToken = gitlabIdToken({ visibility: 'public' })
13221323
const SIGSTORE_ID_TOKEN = sigstoreIdToken()
@@ -1340,6 +1341,7 @@ t.test('oidc token exchange - provenance', (t) => {
13401341
token: 'exchange-token',
13411342
},
13421343
provenance: true,
1344+
oidcVisibilityOptions: { public: true },
13431345
}))
13441346

13451347
t.test('default registry success gitlab', oidcPublishTest({
@@ -1357,6 +1359,7 @@ t.test('oidc token exchange - provenance', (t) => {
13571359
token: 'exchange-token',
13581360
},
13591361
provenance: true,
1362+
oidcVisibilityOptions: { public: true },
13601363
}))
13611364

13621365
t.test('default registry success gitlab without SIGSTORE_ID_TOKEN', oidcPublishTest({
@@ -1376,6 +1379,10 @@ t.test('oidc token exchange - provenance', (t) => {
13761379
provenance: false,
13771380
}))
13781381

1382+
/**
1383+
* when the user sets provenance to true or false
1384+
* the OIDC flow should not concern itself with provenance at all
1385+
*/
13791386
t.test('setting provenance true in config should enable provenance', oidcPublishTest({
13801387
oidcOptions: { github: true },
13811388
config: {
@@ -1475,5 +1482,95 @@ t.test('oidc token exchange - provenance', (t) => {
14751482
provenance: false,
14761483
}))
14771484

1485+
t.test('attempt to publish a private package with OIDC provenance should be false', oidcPublishTest({
1486+
oidcOptions: { github: true },
1487+
config: {
1488+
'//registry.npmjs.org/:_authToken': 'existing-fallback-token',
1489+
},
1490+
mockGithubOidcOptions: {
1491+
audience: 'npm:registry.npmjs.org',
1492+
idToken: githubPublicIdToken,
1493+
},
1494+
mockOidcTokenExchangeOptions: {
1495+
idToken: githubPublicIdToken,
1496+
body: {
1497+
token: 'exchange-token',
1498+
},
1499+
},
1500+
publishOptions: {
1501+
token: 'exchange-token',
1502+
},
1503+
provenance: false,
1504+
oidcVisibilityOptions: { public: false },
1505+
}))
1506+
1507+
/** this call shows that if the repo is private, the visibility check will not be called */
1508+
t.test('attempt to publish a private repository with OIDC provenance should be false', oidcPublishTest({
1509+
oidcOptions: { github: true },
1510+
config: {
1511+
'//registry.npmjs.org/:_authToken': 'existing-fallback-token',
1512+
},
1513+
mockGithubOidcOptions: {
1514+
audience: 'npm:registry.npmjs.org',
1515+
idToken: githubPrivateIdToken,
1516+
},
1517+
mockOidcTokenExchangeOptions: {
1518+
idToken: githubPrivateIdToken,
1519+
body: {
1520+
token: 'exchange-token',
1521+
},
1522+
},
1523+
publishOptions: {
1524+
token: 'exchange-token',
1525+
},
1526+
provenance: false,
1527+
}))
1528+
1529+
const provenanceFailures = [[
1530+
new Error('Valid error'),
1531+
'verbose oidc Failed to set provenance with message: Valid error',
1532+
], [
1533+
'Valid error',
1534+
'verbose oidc Failed to set provenance with message: Unknown error',
1535+
]]
1536+
1537+
provenanceFailures.forEach(([error, logMessage], index) => {
1538+
t.test(`provenance visibility check failure, coverage for try-catch ${index}`, async t => {
1539+
const { npm, logs, joinedOutput } = await mockOidc(t, {
1540+
load: {
1541+
mocks: {
1542+
libnpmaccess: {
1543+
getVisibility: () => {
1544+
throw error
1545+
},
1546+
},
1547+
},
1548+
},
1549+
oidcOptions: { github: true },
1550+
config: {
1551+
'//registry.npmjs.org/:_authToken': 'existing-fallback-token',
1552+
},
1553+
mockGithubOidcOptions: {
1554+
audience: 'npm:registry.npmjs.org',
1555+
idToken: githubPublicIdToken,
1556+
},
1557+
mockOidcTokenExchangeOptions: {
1558+
idToken: githubPublicIdToken,
1559+
body: {
1560+
token: 'exchange-token',
1561+
},
1562+
},
1563+
publishOptions: {
1564+
token: 'exchange-token',
1565+
},
1566+
provenance: false,
1567+
})
1568+
1569+
await npm.exec('publish', [])
1570+
t.match(joinedOutput(), '+ @npmcli/[email protected]')
1571+
t.ok(logs.includes(logMessage))
1572+
})
1573+
})
1574+
14781575
t.end()
14791576
})

0 commit comments

Comments
 (0)