oidc.js 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  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. const libaccess = require('libnpmaccess')
  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, GitLab, and CircleCI.
  11. * 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.
  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. * @see https://circleci.com/docs/openid-connect-tokens/ for CircleCI 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. try {
  26. if (!(
  27. /** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L152 */
  28. ciInfo.GITHUB_ACTIONS ||
  29. /** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L161C13-L161C22 */
  30. ciInfo.GITLAB ||
  31. /** @see https://github.com/watson/ci-info/blob/v4.2.0/vendors.json#L78 */
  32. ciInfo.CIRCLE
  33. )) {
  34. return undefined
  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. if (!idToken && ciInfo.GITHUB_ACTIONS) {
  48. /**
  49. * GitHub Actions provides these environment variables:
  50. * - `ACTIONS_ID_TOKEN_REQUEST_URL`: The URL to request the ID token.
  51. * - `ACTIONS_ID_TOKEN_REQUEST_TOKEN`: The token to authenticate the request.
  52. * Only when a workflow has the following permissions:
  53. * ```
  54. * permissions:
  55. * id-token: write
  56. * ```
  57. * @see https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers#adding-permissions-settings
  58. */
  59. if (!(
  60. process.env.ACTIONS_ID_TOKEN_REQUEST_URL &&
  61. process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
  62. )) {
  63. log.silly('oidc', 'Skipped because incorrect permissions for id-token within GitHub workflow')
  64. return undefined
  65. }
  66. /**
  67. * The specification for an audience is `npm:registry.npmjs.org`, where "registry.npmjs.org" can be any supported registry.
  68. */
  69. const audience = `npm:${new URL(registry).hostname}`
  70. const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL)
  71. url.searchParams.append('audience', audience)
  72. const startTime = Date.now()
  73. const response = await fetch(url.href, {
  74. retry: opts.retry,
  75. headers: {
  76. Accept: 'application/json',
  77. Authorization: `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`,
  78. },
  79. })
  80. const elapsedTime = Date.now() - startTime
  81. log.http(
  82. 'fetch',
  83. `GET ${url.href} ${response.status} ${elapsedTime}ms`
  84. )
  85. const json = await response.json()
  86. if (!response.ok) {
  87. log.verbose('oidc', `Failed to fetch id_token from GitHub: received an invalid response`)
  88. return undefined
  89. }
  90. if (!json.value) {
  91. log.verbose('oidc', `Failed to fetch id_token from GitHub: missing value`)
  92. return undefined
  93. }
  94. idToken = json.value
  95. }
  96. if (!idToken) {
  97. log.silly('oidc', 'Skipped because no id_token available')
  98. return undefined
  99. }
  100. const parsedRegistry = new URL(registry)
  101. const regKey = `//${parsedRegistry.host}${parsedRegistry.pathname}`
  102. const authTokenKey = `${regKey}:_authToken`
  103. const escapedPackageName = npa(packageName).escapedName
  104. let response
  105. try {
  106. response = await npmFetch.json(new URL(`/-/npm/v1/oidc/token/exchange/package/${escapedPackageName}`, registry), {
  107. ...opts,
  108. [authTokenKey]: idToken, // Use the idToken as the auth token for the request
  109. method: 'POST',
  110. })
  111. } catch (error) {
  112. log.verbose('oidc', `Failed token exchange request with body message: ${error?.body?.message || 'Unknown error'}`)
  113. return undefined
  114. }
  115. if (!response?.token) {
  116. log.verbose('oidc', 'Failed because token exchange was missing the token in the response body')
  117. return undefined
  118. }
  119. /*
  120. * The "opts" object is a clone of npm.flatOptions and is passed through the `publish` command, eventually reaching `otplease`.
  121. * To ensure the token is accessible during the publishing process, it must be directly attached to the `opts` object.
  122. * Additionally, the token is required by the "live" configuration or getters within `config`.
  123. */
  124. opts[authTokenKey] = response.token
  125. config.set(authTokenKey, response.token, 'user')
  126. log.verbose('oidc', `Successfully retrieved and set token`)
  127. try {
  128. const isDefaultProvenance = config.isDefault('provenance')
  129. // CircleCI doesn't support provenance yet, so skip the auto-enable logic
  130. if (isDefaultProvenance && !ciInfo.CIRCLE) {
  131. const [headerB64, payloadB64] = idToken.split('.')
  132. if (headerB64 && payloadB64) {
  133. const payloadJson = Buffer.from(payloadB64, 'base64').toString('utf8')
  134. const payload = JSON.parse(payloadJson)
  135. if (
  136. (ciInfo.GITHUB_ACTIONS && payload.repository_visibility === 'public') ||
  137. // only set provenance for gitlab if the repo is public and SIGSTORE_ID_TOKEN is available
  138. (ciInfo.GITLAB && payload.project_visibility === 'public' && process.env.SIGSTORE_ID_TOKEN)
  139. ) {
  140. const visibility = await libaccess.getVisibility(packageName, opts)
  141. if (visibility?.public) {
  142. log.verbose('oidc', `Enabling provenance`)
  143. opts.provenance = true
  144. config.set('provenance', true, 'user')
  145. }
  146. }
  147. }
  148. }
  149. } catch (error) {
  150. log.verbose('oidc', `Failed to set provenance with message: ${error?.message || 'Unknown error'}`)
  151. }
  152. } catch (error) {
  153. log.verbose('oidc', `Failure with message: ${error?.message || 'Unknown error'}`)
  154. }
  155. return undefined
  156. }
  157. module.exports = {
  158. oidc,
  159. }