|  | 
|  | 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 (!(ciInfo.isCI && [ | 
|  | 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 | +    ].some((env) => env))) { | 
|  | 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 | +} | 
0 commit comments