codekingpro/portable-devtools
114k
1const { log } = require('proc-log')
2const npmFetch = require('npm-registry-fetch')
3const ciInfo = require('ci-info')
4const fetch = require('make-fetch-happen')
5const npa = require('npm-package-arg')
6const libaccess = require('libnpmaccess')
7
8/**
9 * Handles OpenID Connect (OIDC) token retrieval and exchange for CI environments.
10 *
11 * This function is designed to work in Continuous Integration (CI) environments such as GitHub Actions, GitLab, and CircleCI.
12 * It retrieves an OIDC token from the CI environment, exchanges it for an npm token, and 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 * @see https://circleci.com/docs/openid-connect-tokens/ for CircleCI OIDC.
20 */
21async function oidc ({ packageName, registry, opts, config }) {
22 /*
23 * This code should never run when people try to publish locally on their machines.
24 * It is designed to execute only in Continuous Integration (CI) environments.
25 */
26
27 try {
28 if (!(
29 /** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L152 */
30 ciInfo.GITHUB_ACTIONS ||
31 /** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L161C13-L161C22 */
32 ciInfo.GITLAB ||
33 /** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L78 */
34 ciInfo.CIRCLE
35 )) {
36 return undefined
37 }
38
39 /**
40 * Check if the environment variable `NPM_ID_TOKEN` is set.
41 * In GitLab CI, the ID token is provided via an environment variable,
42 * with `NPM_ID_TOKEN` serving as a predefined default. For consistency,
43 * all supported CI environments are expected to support this variable.
44 * In contrast, GitHub Actions uses a request-based approach to retrieve the ID token.
45 * The presence of this token within GitHub Actions will override the request-based approach.
46 * This variable follows the prefix/suffix convention from sigstore (e.g., `SIGSTORE_ID_TOKEN`).
47 * @see https://docs.sigstore.dev/cosign/signing/overview/
48 */
49 let idToken = process.env.NPM_ID_TOKEN
50
51 if (!idToken && ciInfo.GITHUB_ACTIONS) {
52 /**
53 * GitHub Actions provides these environment variables:
54 * - `ACTIONS_ID_TOKEN_REQUEST_URL`: The URL to request the ID token.
55 * - `ACTIONS_ID_TOKEN_REQUEST_TOKEN`: The token to authenticate the request.
56 * Only when a workflow has the following permissions:
57 * ```
58 * permissions:
59 * id-token: write
60 * ```
61 * @see https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers#adding-permissions-settings
62 */
63 if (!(
64 process.env.ACTIONS_ID_TOKEN_REQUEST_URL &&
65 process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
66 )) {
67 log.silly('oidc', 'Skipped because incorrect permissions for id-token within GitHub workflow')
68 return undefined
69 }
70
71 /**
72 * The specification for an audience is `npm:registry.npmjs.org`, where "registry.npmjs.org" can be any supported registry.
73 */
74 const audience = `npm:${new URL(registry).hostname}`
75 const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL)
76 url.searchParams.append('audience', audience)
77 const startTime = Date.now()
78 const response = await fetch(url.href, {
79 retry: opts.retry,
80 headers: {
81 Accept: 'application/json',
82 Authorization: `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`,
83 },
84 })
85
86 const elapsedTime = Date.now() - startTime
87
88 log.http(
89 'fetch',
90 `GET ${url.href} ${response.status} ${elapsedTime}ms`
91 )
92
93 const json = await response.json()
94
95 if (!response.ok) {
96 log.verbose('oidc', `Failed to fetch id_token from GitHub: received an invalid response`)
97 return undefined
98 }
99
100 if (!json.value) {
101 log.verbose('oidc', `Failed to fetch id_token from GitHub: missing value`)
102 return undefined
103 }
104
105 idToken = json.value
106 }
107
108 if (!idToken) {
109 log.silly('oidc', 'Skipped because no id_token available')
110 return undefined
111 }
112
113 const parsedRegistry = new URL(registry)
114 const regKey = `//${parsedRegistry.host}${parsedRegistry.pathname}`
115 const authTokenKey = `${regKey}:_authToken`
116
117 const escapedPackageName = npa(packageName).escapedName
118 let response
119 try {
120 response = await npmFetch.json(new URL(`/-/npm/v1/oidc/token/exchange/package/${escapedPackageName}`, registry), {
121 ...opts,
122 [authTokenKey]: idToken, // Use the idToken as the auth token for the request
123 method: 'POST',
124 })
125 } catch (error) {
126 log.verbose('oidc', `Failed token exchange request with body message: ${error?.body?.message || 'Unknown error'}`)
127 return undefined
128 }
129
130 if (!response?.token) {
131 log.verbose('oidc', 'Failed because token exchange was missing the token in the response body')
132 return undefined
133 }
134
135 /*
136 * The "opts" object is a clone of npm.flatOptions and is passed through the `publish` command, eventually reaching `otplease`.
137 * To ensure the token is accessible during the publishing process, it must be directly attached to the `opts` object.
138 * Additionally, the token is required by the "live" configuration or getters within `config`.
139 */
140 opts[authTokenKey] = response.token
141 config.set(authTokenKey, response.token, 'user')
142 log.verbose('oidc', `Successfully retrieved and set token`)
143
144 try {
145 const isDefaultProvenance = config.isDefault('provenance')
146 // CircleCI doesn't support provenance yet, so skip the auto-enable logic
147 if (isDefaultProvenance && !ciInfo.CIRCLE) {
148 const [headerB64, payloadB64] = idToken.split('.')
149 if (headerB64 && payloadB64) {
150 const payloadJson = Buffer.from(payloadB64, 'base64').toString('utf8')
151 const payload = JSON.parse(payloadJson)
152 if (
153 (ciInfo.GITHUB_ACTIONS && payload.repository_visibility === 'public') ||
154 // only set provenance for gitlab if the repo is public and SIGSTORE_ID_TOKEN is available
155 (ciInfo.GITLAB && payload.project_visibility === 'public' && process.env.SIGSTORE_ID_TOKEN)
156 ) {
157 const visibility = await libaccess.getVisibility(packageName, opts)
158 if (visibility?.public) {
159 log.verbose('oidc', `Enabling provenance`)
160 opts.provenance = true
161 config.set('provenance', true, 'user')
162 }
163 }
164 }
165 }
166 } catch (error) {
167 log.verbose('oidc', `Failed to set provenance with message: ${error?.message || 'Unknown error'}`)
168 }
169 } catch (error) {
170 log.verbose('oidc', `Failure with message: ${error?.message || 'Unknown error'}`)
171 }
172 return undefined
173}
174
175module.exports = {
176 oidc,
177}
178 