Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/actions/internal-registry/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Internal registry actions

Two composite actions that let a GitHub Actions job talk to the internal npm
registry without a static token. The job's own OIDC identity is exchanged for a
short-lived credential, per run.

| action | what it does |
| --- | --- |
| [`auth`](./auth) | Mints the credential. Optionally writes it into the npm user config so later `install` / `publish` steps just work. |
| [`dependency-check`](./dependency-check) | Asserts every version pinned in a lockfile is actually served by the registry. |

Both are versioned with their own tags (`internal-registry-auth-v1`,
`internal-registry-dependency-check-v2`). Pin a tag — do not track `main`.

## Using it from another repository

They are public actions, so any repository in any organization can use them.

Read-only check:

```yaml
jobs:
dependency-check:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # required, and it must be set on the JOB
steps:
- uses: actions/checkout@v7
- id: auth
uses: facebook/lexical/.github/actions/internal-registry/auth@internal-registry-auth-v1
with:
write-npmrc: 'false'
on-missing-oidc: skip
- uses: facebook/lexical/.github/actions/internal-registry/dependency-check@internal-registry-dependency-check-v2
with:
token: ${{ steps.auth.outputs.token }}
```

Installing or publishing scoped packages:

```yaml
- uses: actions/setup-node@v6 # BEFORE auth: setup-node rewrites the npm config
with:
node-version: '24'
- uses: facebook/lexical/.github/actions/internal-registry/auth@internal-registry-auth-v1
with:
scopes: '@acme @acme-ui'
- run: npm publish
```

## Things that will bite you

- **`permissions: id-token: write` must be on the job.** A job-level
`permissions` block *replaces* the workflow-level one rather than merging
with it, so setting it only at the workflow level leaves the job with no
OIDC identity.
- **Run `actions/setup-node` before `auth`, not after.** When `setup-node` is
given a `registry-url` it rewrites the npm user config, which would discard
what `auth` wrote.
- **`auth` writes *user* config, not a project-local `.npmrc`.** npm does not
walk up from the directory it publishes from, so a repo-root `.npmrc` is
invisible to `npm publish` run in a subdirectory. This is the usual cause of
a confusing `ENEEDAUTH` when the credential is demonstrably present.
- **The two actions compose in the workflow; they do not nest.** A local
`uses: ./...` inside a composite action resolves against the *caller's*
workspace, not the repository the action came from, so a remote composite
action cannot reference a sibling. This is why `dependency-check` takes a
`token` input instead of calling `auth` itself.
- **Fork pull requests have no OIDC identity.** Use `on-missing-oidc: skip` and
gate on a required `merge_group` run.

## Diagnosing a rejected credential

`auth` logs the OIDC subject it minted for:

```
Credential minted for subject: repo:my-org/my-repo:ref:refs/heads/main
```

That string is what the registry authorizes against, so quote it when asking
for access. It is not a secret; the credential itself is masked. Note that some
repositories are configured to emit an immutable subject that embeds numeric
IDs (`repo:my-org@123/my-repo@456:...`) — check with:

```bash
gh api repos/<owner>/<repo>/actions/oidc/customization/sub
```
59 changes: 59 additions & 0 deletions .github/actions/internal-registry/auth/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Internal registry auth
description: >-
Mints a short-lived registry credential from the job's GitHub OIDC identity,
and optionally writes it into the npm user config so later steps can install
or publish. No static token is involved: the credential is minted per run and
never persisted. The calling job must grant `permissions: id-token: write`.
Run `actions/setup-node` BEFORE this action — setup-node rewrites the npm
config that this action appends to.

inputs:
registry-url:
description: Registry base URL.
required: false
default: https://registry.facebook.net
scopes:
description: >-
Space-separated npm scopes to route to the registry, for example
"@acme @acme-ui". Leave empty to mint a credential without routing any
scope, which is what a read-only check wants.
required: false
default: ''
write-npmrc:
description: >-
Whether to append the credential and the scope routing to the npm user
config. Set to false to receive the token as an output only.
required: false
default: 'true'
on-missing-oidc:
description: >-
What to do when the job has no OIDC identity, which is the case for a
fork pull request: "fail" or "skip". When skipping, the outputs are empty
so the caller can branch on them.
required: false
default: fail

outputs:
token:
description: >-
The minted credential. Empty when the job had no OIDC identity and
on-missing-oidc was "skip".
value: ${{ steps.mint.outputs.token }}
subject:
description: >-
The OIDC subject the credential was minted for. This is the exact string
the registry authorizes against, so it is what to quote when a request is
rejected.
value: ${{ steps.mint.outputs.subject }}

runs:
using: composite
steps:
- id: mint
shell: bash
env:
REGISTRY_URL: ${{ inputs.registry-url }}
REGISTRY_SCOPES: ${{ inputs.scopes }}
WRITE_NPMRC: ${{ inputs.write-npmrc }}
ON_MISSING_OIDC: ${{ inputs.on-missing-oidc }}
run: node "${{ github.action_path }}/mint.mjs"
171 changes: 171 additions & 0 deletions .github/actions/internal-registry/auth/mint.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

// Mints a short-lived registry credential from the job's GitHub OIDC identity.
//
// Two hops: the Actions token service issues an OIDC id_token for a fixed
// audience, and the identity provider exchanges that for a registry
// credential. Nothing is cached or persisted beyond the job.

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

const OIDC_AUDIENCE = 'meta_jwt_access_token';
const EXCHANGE_URL =
'https://www.internalfb.com/intern/crypto_jwt/access_token_exchange/';
const EXCHANGE_PEER = 'metaccio';
const EXCHANGE_AUDIENCE = 'metaccio';

// Neither hop has a default timeout under Node's global fetch, and a hung
// request would burn the job's whole timeout budget with no useful log line.
const OIDC_TIMEOUT_MS = 20_000;
const EXCHANGE_TIMEOUT_MS = 25_000;

const REGISTRY_URL = (process.env.REGISTRY_URL ?? '').trim();
const SCOPES = (process.env.REGISTRY_SCOPES ?? '').split(/\s+/).filter(Boolean);
const WRITE_NPMRC = (process.env.WRITE_NPMRC ?? 'true') !== 'false';
const ON_MISSING_OIDC = (process.env.ON_MISSING_OIDC ?? 'fail').trim();

function fail(message) {
console.error(`::error::${message}`);
process.exit(1);
}

function setOutput(name, value) {
// A delimiter rather than `name=value`, so a value that is not what we
// expect cannot inject further workflow commands.
const delimiter = `ghadelim_${name}`;
fs.appendFileSync(
process.env.GITHUB_OUTPUT,
`${name}<<${delimiter}\n${value}\n${delimiter}\n`,
);
}

if (!REGISTRY_URL) {
fail('registry-url is empty.');
}

let registry;
try {
registry = new URL(REGISTRY_URL);
} catch {
fail(`registry-url is not a valid URL: ${REGISTRY_URL}`);
}

const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;

if (!requestUrl || !requestToken) {
if (ON_MISSING_OIDC === 'skip') {
console.warn(
'::notice::No OIDC identity available (most likely a fork pull request) — skipping.',
);
setOutput('token', '');
setOutput('subject', '');
process.exit(0);
}
fail(
'This job has no OIDC identity. Add `permissions: id-token: write` to the ' +
'job — a job-level permissions block replaces the workflow-level one, so ' +
'it has to be set on the job itself, not only on the workflow.',
);
}

async function fetchIdToken() {
const response = await fetch(`${requestUrl}&audience=${OIDC_AUDIENCE}`, {
headers: {Authorization: `bearer ${requestToken}`},
signal: AbortSignal.timeout(OIDC_TIMEOUT_MS),
});
if (!response.ok) {
fail(`Could not obtain an OIDC id_token (HTTP ${response.status}).`);
}
const {value} = await response.json();
if (!value) {
fail('The OIDC token service returned an empty id_token.');
}
return value;
}

async function exchangeForCredential(idToken) {
const response = await fetch(EXCHANGE_URL, {
body: new URLSearchParams({
audience: EXCHANGE_AUDIENCE,
id_token: idToken,
peer: EXCHANGE_PEER,
}),
headers: {
Accept: 'application/jwt',
'Content-Type': 'application/x-www-form-urlencoded',
},
method: 'POST',
signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
});
if (!response.ok) {
fail(
`Token exchange failed (HTTP ${response.status}). A 403 usually means ` +
'this repository has not been granted access to the registry yet.',
);
}
const credential = (await response.text()).trim();
// A JWS is three dot-separated segments; anything else is an error page.
if (credential.split('.').length !== 3) {
fail('Token exchange did not return a credential.');
}
return credential;
}

function subjectOf(credential) {
try {
const claims = JSON.parse(
Buffer.from(credential.split('.')[1], 'base64url').toString('utf8'),
);
return claims.sub;
} catch {
return undefined;
}
}

const credential = await exchangeForCredential(await fetchIdToken());

// Mask before the credential can reach any later log line.
process.stdout.write(`::add-mask::${credential}\n`);

// The subject is not a secret, and it is the exact string the registry
// authorizes against. Printing it turns an opaque rejection into a
// self-diagnosing one.
const subject = subjectOf(credential) ?? '<could not decode>';
console.warn(`Credential minted for subject: ${subject}`);

setOutput('token', credential);
setOutput('subject', subject);

if (WRITE_NPMRC) {
// setup-node points npm at a config under RUNNER_TEMP when its own
// `registry-url` is set; respect that, or npm reads ~/.npmrc and never sees
// these lines. Writing *user* config also means npm finds the credential
// from any working directory — a project-local .npmrc does not, because npm
// does not walk up from the directory it publishes from.
const npmrc =
process.env.NPM_CONFIG_USERCONFIG || path.join(os.homedir(), '.npmrc');
const authKey = `//${registry.host}${registry.pathname.replace(/\/?$/, '/')}:_authToken`;
const lines = [
...SCOPES.map(scope => `${scope}:registry=${REGISTRY_URL}`),
`${authKey}=${credential}`,
];
// Appended, not overwritten: setup-node may already have written an entry
// that a dual-target publish still needs. npm's ini parse is last-wins, so a
// scope written above is redirected by ours.
fs.mkdirSync(path.dirname(npmrc), {recursive: true});
fs.appendFileSync(npmrc, `\n${lines.join('\n')}\n`);
console.warn(
SCOPES.length > 0
? `Routed ${SCOPES.join(', ')} to ${REGISTRY_URL} (${npmrc})`
: `Wrote the registry credential to ${npmrc}`,
);
}
48 changes: 17 additions & 31 deletions .github/actions/internal-registry/dependency-check/action.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
name: Internal registry dependency check
description: >-
Checks that every dependency version pinned in the lockfile is available from
the configured registry. Authenticates with a short-lived token minted from
the job's GitHub OIDC identity. The calling job must grant
`permissions: id-token: write` and check out the repo. Fork PRs can't mint
OIDC and are skipped with a notice; enforce via a required merge_group run.
the configured registry. Takes a credential from the sibling `auth` action
rather than minting one itself, so there is a single implementation of the
OIDC exchange. The calling job must check out the repo. A fork pull request
cannot mint OIDC, so `auth` yields an empty token and this check is skipped
with a notice; enforce via a required merge_group run.

inputs:
token:
description: >-
Registry credential, as produced by the sibling `auth` action. An empty
value skips the check, which is what a fork pull request produces.
required: true
lockfile:
description: >-
Path to the lockfile to check. Format is auto-detected by filename:
Expand All @@ -28,35 +34,15 @@ runs:
- uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
- name: Skip when no credential is available
if: inputs.token == ''
shell: bash
run: echo "::notice::No registry credential (most likely a fork pull request) — deferring to the required merge-queue run."
- name: Check dependencies are available in the registry
if: inputs.token != ''
shell: bash
env:
REGISTRY_URL: ${{ inputs.registry-url }}
REGISTRY_TOKEN: ${{ inputs.token }}
LOCKFILE: ${{ inputs.lockfile }}
run: |
set -euo pipefail

# Fork PRs get a read-only token with no OIDC, so they can't
# authenticate. Pass with a notice (the required merge-queue run gates
# them) so this check always reports a conclusion and is never stuck.
if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then
echo "::notice::No OIDC identity available (likely a fork PR) — deferring to the required merge-queue run."
exit 0
fi

# 1) Mint this job's GitHub OIDC token.
ID=$(curl -sS --max-time 20 \
-H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=meta_jwt_access_token" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['value'])")

# 2) Exchange it for a short-lived registry token.
TOKEN=$(curl -sS --max-time 25 -H "Accept: application/jwt" \
--data-urlencode "id_token=$ID" \
--data-urlencode "peer=metaccio" \
--data-urlencode "audience=metaccio" \
https://www.internalfb.com/intern/crypto_jwt/access_token_exchange/)
[ "$(printf '%s' "$TOKEN" | tr -cd '.' | wc -c)" -eq 2 ] || { echo "::error::OIDC token exchange failed"; exit 1; }

# 3) Assert every pinned dependency version is served by the registry.
REGISTRY_TOKEN="$TOKEN" node "${{ github.action_path }}/check.mjs"
run: node "${{ github.action_path }}/check.mjs"
7 changes: 7 additions & 0 deletions .github/workflows/internal-registry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,11 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v7
- id: auth
uses: ./.github/actions/internal-registry/auth
with:
write-npmrc: 'false'
on-missing-oidc: skip
- uses: ./.github/actions/internal-registry/dependency-check
with:
token: ${{ steps.auth.outputs.token }}
Loading