Skip to content

Commit 9021253

Browse files
committed
feat: adds support for oidc publish
1 parent f163d01 commit 9021253

File tree

5 files changed

+558
-5
lines changed

5 files changed

+558
-5
lines changed

lib/commands/publish.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const { getContents, logTar } = require('../utils/tar.js')
1616
const { flatten } = require('@npmcli/config/lib/definitions')
1717
const pkgJson = require('@npmcli/package-json')
1818
const BaseCommand = require('../base-cmd.js')
19+
const { oidc } = require('../../lib/utils/oidc.js')
1920

2021
class Publish extends BaseCommand {
2122
static description = 'Publish a package'
@@ -136,6 +137,9 @@ class Publish extends BaseCommand {
136137
npa(`${manifest.name}@${defaultTag}`)
137138

138139
const registry = npmFetch.pickRegistry(resolved, opts)
140+
141+
await oidc({ packageName: manifest.name, registry, opts, config: this.npm.config })
142+
139143
const creds = this.npm.config.getCredentialsByURI(registry)
140144
const noCreds = !(creds.token || creds.username || creds.certfile && creds.keyfile)
141145
const outputRegistry = replaceInfo(registry)

lib/utils/oidc.js

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
const { log } = require('proc-log')
2+
const npmFetch = require('npm-registry-fetch')
3+
const ciInfo = require('ci-info')
4+
const fetch = require('make-fetch-happen')
5+
const npa = require('npm-package-arg')
6+
7+
/**
8+
* Handles OpenID Connect (OIDC) token retrieval and exchange for CI environments.
9+
*
10+
* This function is designed to work in Continuous Integration (CI) environments such as GitHub Actions
11+
* and GitLab. It retrieves an OIDC token from the CI environment, exchanges it for an npm token, and
12+
* sets the token in the provided configuration for authentication with the npm registry.
13+
*
14+
* This function is intended to never throw, as it mutates the state of the `opts` and `config` objects on success.
15+
* OIDC is always an optional feature, and the function should not throw if OIDC is not configured by the registry.
16+
*
17+
* @see https://github.com/watson/ci-info for CI environment detection.
18+
* @see https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect for GitHub Actions OIDC.
19+
*/
20+
async function oidc ({ packageName, registry, opts, config }) {
21+
/*
22+
* This code should never run when people try to publish locally on their machines.
23+
* It is designed to execute only in Continuous Integration (CI) environments.
24+
*/
25+
26+
try {
27+
if (!(
28+
/** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L152 */
29+
ciInfo.GITHUB_ACTIONS ||
30+
/** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L161C13-L161C22 */
31+
ciInfo.GITLAB
32+
)) {
33+
return undefined
34+
}
35+
36+
log.silly('oidc', 'Determining if npm should use OIDC publishing')
37+
38+
/**
39+
* Check if the environment variable `NPM_ID_TOKEN` is set.
40+
* In GitLab CI, the ID token is provided via an environment variable,
41+
* with `NPM_ID_TOKEN` serving as a predefined default. For consistency,
42+
* all supported CI environments are expected to support this variable.
43+
* In contrast, GitHub Actions uses a request-based approach to retrieve the ID token.
44+
* The presence of this token within GitHub Actions will override the request-based approach.
45+
* This variable follows the prefix/suffix convention from sigstore (e.g., `SIGSTORE_ID_TOKEN`).
46+
* @see https://docs.sigstore.dev/cosign/signing/overview/
47+
*/
48+
let idToken = process.env.NPM_ID_TOKEN
49+
50+
if (idToken) {
51+
log.silly('oidc', 'NPM_ID_TOKEN present')
52+
} else {
53+
log.silly('oidc', 'NPM_ID_TOKEN not present, checking for GITHUB_ACTIONS')
54+
if (ciInfo.GITHUB_ACTIONS) {
55+
/**
56+
* GitHub Actions provides these environment variables:
57+
* - `ACTIONS_ID_TOKEN_REQUEST_URL`: The URL to request the ID token.
58+
* - `ACTIONS_ID_TOKEN_REQUEST_TOKEN`: The token to authenticate the request.
59+
* Only when a workflow has the following permissions:
60+
* ```
61+
* permissions:
62+
* id-token: write
63+
* ```
64+
* @see https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers#adding-permissions-settings
65+
*/
66+
if (
67+
process.env.ACTIONS_ID_TOKEN_REQUEST_URL &&
68+
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
69+
) {
70+
log.silly('oidc', '"GITHUB_ACTIONS" detected with "ACTIONS_ID_" envs, fetching id_token')
71+
72+
/**
73+
* The specification for an audience is `npm:registry.npmjs.org`,
74+
* where "registry.npmjs.org" can be any supported registry.
75+
*/
76+
const audience = `npm:${new URL(registry).hostname}`
77+
log.silly('oidc', `Using audience: ${audience}`)
78+
const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL)
79+
url.searchParams.append('audience', audience)
80+
const startTime = Date.now()
81+
const response = await fetch(url.href, {
82+
retry: opts.retry,
83+
headers: {
84+
Accept: 'application/json',
85+
Authorization: `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`,
86+
},
87+
})
88+
89+
const elapsedTime = Date.now() - startTime
90+
91+
log.http(
92+
'fetch',
93+
`GET ${url.href} ${response.status} ${elapsedTime}ms`
94+
)
95+
96+
const json = await response.json()
97+
98+
if (!response.ok) {
99+
throw new Error(`Failed to fetch id_token from GitHub: received an invalid response`)
100+
}
101+
102+
if (!json.value) {
103+
throw new Error(`Failed to fetch id_token from GitHub: missing value`)
104+
}
105+
106+
log.silly('oidc', 'GITHUB_ACTIONS valid fetch response for id_token')
107+
idToken = json.value
108+
} else {
109+
throw new Error('GITHUB_ACTIONS detected. If you intend to publish using OIDC, please set workflow permissions for `id-token: write`')
110+
}
111+
}
112+
}
113+
114+
if (!idToken) {
115+
log.silly('oidc', 'Exiting OIDC, no id_token available')
116+
return undefined
117+
}
118+
119+
log.silly('oidc', `id_token has a length of ${idToken.length} characters`)
120+
121+
const parsedRegistry = new URL(registry)
122+
const regKey = `//${parsedRegistry.host}${parsedRegistry.pathname}`
123+
const authTokenKey = `${regKey}:_authToken`
124+
125+
const exitingToken = config.get(authTokenKey)
126+
if (exitingToken) {
127+
log.silly('oidc', 'Existing token found')
128+
} else {
129+
log.silly('oidc', 'No existing token found')
130+
}
131+
132+
const escapedPackageName = npa(packageName).escapedName
133+
const response = await npmFetch.json(new URL(`/-/npm/v1/oidc/token/exchange/package/${escapedPackageName}`, registry), {
134+
...{
135+
...opts,
136+
[authTokenKey]: idToken, // Use the idToken as the auth token for the request
137+
},
138+
method: 'POST',
139+
headers: {
140+
...opts.headers,
141+
'Content-Type': 'application/json',
142+
// this will not work because the existing auth token will replace it.
143+
// authorization: `Bearer ${idToken}`,
144+
},
145+
})
146+
147+
if (!response?.token) {
148+
throw new Error('OIDC token exchange failure: missing token in response body')
149+
}
150+
/*
151+
* The "opts" object is a clone of npm.flatOptions and is passed through the `publish` command,
152+
* eventually reaching `otplease`. To ensure the token is accessible during the publishing process,
153+
* it must be directly attached to the `opts` object.
154+
* Additionally, the token is required by the "live" configuration or getters within `config`.
155+
*/
156+
opts[authTokenKey] = response.token
157+
config.set(authTokenKey, response.token, 'user')
158+
log.silly('oidc', `OIDC token successfully retrieved`)
159+
} catch (error) {
160+
log.verbose('oidc', error.message)
161+
if (error?.body?.message) {
162+
log.verbose('oidc', `Registry body response error message "${error.body.message}"`)
163+
}
164+
}
165+
return undefined
166+
}
167+
168+
module.exports = {
169+
oidc,
170+
}

mock-registry/lib/index.js

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ class MockRegistry {
359359
}
360360

361361
publish (name, {
362-
packageJson, access, noGet, noPut, putCode, manifest, packuments,
362+
packageJson, access, noGet, noPut, putCode, manifest, packuments, token,
363363
} = {}) {
364364
if (!noGet) {
365365
// this getPackage call is used to get the latest semver version before publish
@@ -373,7 +373,7 @@ class MockRegistry {
373373
}
374374
}
375375
if (!noPut) {
376-
this.putPackage(name, { code: putCode, packageJson, access })
376+
this.putPackage(name, { code: putCode, packageJson, access, token })
377377
}
378378
}
379379

@@ -391,10 +391,14 @@ class MockRegistry {
391391
this.nock = nock
392392
}
393393

394-
putPackage (name, { code = 200, resp = {}, ...putPackagePayload }) {
395-
this.nock.put(`/${npa(name).escapedName}`, body => {
394+
putPackage (name, { code = 200, resp = {}, token, ...putPackagePayload }) {
395+
let n = this.nock.put(`/${npa(name).escapedName}`, body => {
396396
return this.#tap.match(body, this.putPackagePayload({ name, ...putPackagePayload }))
397-
}).reply(code, resp)
397+
})
398+
if (token) {
399+
n = n.matchHeader('authorization', `Bearer ${token}`)
400+
}
401+
n.reply(code, resp)
398402
}
399403

400404
putPackagePayload (opts) {
@@ -626,6 +630,13 @@ class MockRegistry {
626630
}
627631
}
628632
}
633+
634+
mockOidcTokenExchange ({ packageName, idToken, token, statusCode = 200 } = {}) {
635+
const encodedPackageName = npa(packageName).escapedName
636+
this.nock.post(this.fullPath(`/-/npm/v1/oidc/token/exchange/package/${encodedPackageName}`))
637+
.matchHeader('authorization', `Bearer ${idToken}`)
638+
.reply(statusCode, statusCode !== 500 ? { token } : { message: 'Internal Server Error' })
639+
}
629640
}
630641

631642
module.exports = MockRegistry

test/fixtures/mock-oidc.js

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
const nock = require('nock')
2+
const ciInfo = require('ci-info')
3+
4+
class MockOidc {
5+
constructor (opts) {
6+
const defaultOpts = {
7+
github: false,
8+
gitlab: false,
9+
ACTIONS_ID_TOKEN_REQUEST_URL: 'https://github.com/actions/id-token',
10+
ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'ACTIONS_ID_TOKEN_REQUEST_TOKEN',
11+
NPM_ID_TOKEN: 'NPM_ID_TOKEN',
12+
GITHUB_ID_TOKEN: 'mock-github-id-token',
13+
}
14+
const options = { ...defaultOpts, ...opts }
15+
16+
this.github = options.github
17+
this.gitlab = options.gitlab
18+
this.ACTIONS_ID_TOKEN_REQUEST_URL = options.ACTIONS_ID_TOKEN_REQUEST_URL
19+
this.ACTIONS_ID_TOKEN_REQUEST_TOKEN = options.ACTIONS_ID_TOKEN_REQUEST_TOKEN
20+
this.NPM_ID_TOKEN = options.NPM_ID_TOKEN
21+
this.GITHUB_ID_TOKEN = options.GITHUB_ID_TOKEN
22+
23+
// Backup only the relevant environment variables and ciInfo values
24+
this.originalEnv = {
25+
CI: process.env.CI,
26+
GITHUB_ACTIONS: process.env.GITHUB_ACTIONS,
27+
ACTIONS_ID_TOKEN_REQUEST_URL: process.env.ACTIONS_ID_TOKEN_REQUEST_URL,
28+
ACTIONS_ID_TOKEN_REQUEST_TOKEN: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN,
29+
GITLAB_CI: process.env.GITLAB_CI,
30+
NPM_ID_TOKEN: process.env.NPM_ID_TOKEN,
31+
}
32+
this.originalCiInfo = {
33+
GITLAB: ciInfo.GITLAB,
34+
GITHUB_ACTIONS: ciInfo.GITHUB_ACTIONS,
35+
}
36+
this.setupEnvironment()
37+
}
38+
39+
get idToken () {
40+
if (this.github) {
41+
return this.GITHUB_ID_TOKEN
42+
}
43+
if (this.gitlab) {
44+
return this.NPM_ID_TOKEN
45+
}
46+
return undefined
47+
}
48+
49+
setupEnvironment () {
50+
delete process.env.CI
51+
delete process.env.GITHUB_ACTIONS
52+
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL
53+
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
54+
delete process.env.GITLAB_CI
55+
delete process.env.NPM_ID_TOKEN
56+
57+
ciInfo.GITHUB_ACTIONS = false
58+
ciInfo.GITLAB = false
59+
60+
if (this.github) {
61+
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = this.ACTIONS_ID_TOKEN_REQUEST_URL
62+
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = this.ACTIONS_ID_TOKEN_REQUEST_TOKEN
63+
ciInfo.GITHUB_ACTIONS = true
64+
}
65+
66+
if (this.gitlab) {
67+
process.env.NPM_ID_TOKEN = this.NPM_ID_TOKEN
68+
ciInfo.GITLAB = true
69+
}
70+
}
71+
72+
mockGithubOidc ({ idToken = this.GITHUB_ID_TOKEN, audience, statusCode = 200 } = {}) {
73+
const url = new URL(this.ACTIONS_ID_TOKEN_REQUEST_URL)
74+
return nock(url.origin)
75+
.get(url.pathname)
76+
.query({ audience })
77+
.matchHeader('authorization', `Bearer ${this.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`)
78+
.matchHeader('accept', 'application/json')
79+
.reply(statusCode, statusCode !== 500 ? { value: idToken } : { message: 'Internal Server Error' })
80+
}
81+
82+
reset () {
83+
// Restore only the backed-up environment variables
84+
for (const key in this.originalEnv) {
85+
process.env[key] = this.originalEnv[key]
86+
}
87+
88+
// Restore the original ciInfo values
89+
ciInfo.GITLAB = this.originalCiInfo.GITLAB
90+
ciInfo.GITHUB_ACTIONS = this.originalCiInfo.GITHUB_ACTIONS
91+
92+
nock.cleanAll()
93+
}
94+
95+
static tnock (t, opts = {}, { debug = false, strict = false } = {}) {
96+
const instance = new MockOidc(opts)
97+
98+
const noMatch = (req) => {
99+
if (debug) {
100+
/* eslint-disable-next-line no-console */
101+
console.error('NO MATCH', t.name, req.options ? req.options : req.path)
102+
}
103+
if (strict) {
104+
t.comment(`Unmatched request: ${req.method} ${req.path}`)
105+
t.fail(`Unmatched request: ${req.method} ${req.path}`)
106+
}
107+
}
108+
109+
nock.emitter.on('no match', noMatch)
110+
nock.disableNetConnect()
111+
112+
if (strict) {
113+
t.afterEach(() => {
114+
t.strictSame(nock.pendingMocks(), [], 'no pending mocks after each')
115+
})
116+
}
117+
118+
t.teardown(() => {
119+
nock.enableNetConnect()
120+
nock.emitter.off('no match', noMatch)
121+
nock.cleanAll()
122+
instance.reset()
123+
})
124+
125+
return instance
126+
}
127+
}
128+
129+
module.exports = {
130+
MockOidc,
131+
}

0 commit comments

Comments
 (0)