Skip to content

Commit 3a930c9

Browse files
committed
feat: adds support for oidc publish
1 parent e758dd7 commit 3a930c9

File tree

6 files changed

+633
-5
lines changed

6 files changed

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

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+
this.nock.post(this.fullPath('/-/npm/v1/oidc/token/exchange'), body => {
636+
return body.package_name === packageName && body.id_token === idToken
637+
}).reply(statusCode, statusCode !== 500 ? { token: token } : { message: 'Internal Server Error' })
638+
return { token }
639+
}
629640
}
630641

631642
module.exports = MockRegistry

mock-registry/lib/oidc.js

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

0 commit comments

Comments
 (0)