From 88f21364f65d3e907fdfb725e3d8a029d6431f31 Mon Sep 17 00:00:00 2001 From: ShJ-code Date: Fri, 19 Jun 2026 00:52:12 -0400 Subject: [PATCH] feat(ci): add composite action to log in to Keystone with JWT Add a reusable `.github/actions/login_jwt` composite action that requests a GitHub Actions OIDC token (or accepts a pre-fetched JWT) and exchanges it for a Keystone token via POST /v4/federation/identity_providers/{idp_id}/jwt, masking the secrets and exposing the result as the `token` output. Wire a self-test into the federation-github job that registers a JWT IdP and mapping and asserts the action returns a token, and replace the manual-curl TODO in doc/src/federation/jwt.md with the action's usage. Closes #228 --- .github/actions/login_jwt/action.yml | 101 +++++++++++++++++++++++++++ .github/workflows/functional.yml | 52 ++++++++++++++ doc/src/federation/jwt.md | 58 ++++++++++----- 3 files changed, 194 insertions(+), 17 deletions(-) create mode 100644 .github/actions/login_jwt/action.yml diff --git a/.github/actions/login_jwt/action.yml b/.github/actions/login_jwt/action.yml new file mode 100644 index 000000000..0f0c533c1 --- /dev/null +++ b/.github/actions/login_jwt/action.yml @@ -0,0 +1,101 @@ +name: 'Login to Keystone with JWT' +description: > + Exchange a federated OIDC JWT for a Keystone session token. By default the + action requests a GitHub Actions OIDC token and swaps it for a Keystone token + using the JWT federation login endpoint. A pre-fetched JWT (e.g. from a + non-GitHub IdP) can be supplied via the `jwt` input instead. + +inputs: + keystone_url: + description: 'Base URL of the Keystone service (e.g. https://keystone.example.com).' + required: true + idp_id: + description: 'Identifier of the federated identity provider registered in Keystone.' + required: true + mapping: + description: 'Name of the Keystone attribute mapping (sent as the openstack-mapping header).' + required: true + audience: + description: > + Audience to request for the GitHub OIDC token. Only used when `jwt` is not + provided. Must match the mapping `bound_audiences` configured in Keystone. + required: false + default: 'https://github.com' + jwt: + description: > + A pre-fetched OIDC JWT to exchange. When empty (default) the action + requests a GitHub Actions OIDC token, which requires the calling job to + grant `permissions: id-token: write`. + required: false + default: '' + +outputs: + token: + description: 'The issued Keystone token (value of the X-Subject-Token response header).' + value: ${{ steps.exchange.outputs.token }} + +runs: + using: 'composite' + steps: + - name: Obtain OIDC JWT + id: jwt + shell: bash + env: + INPUT_JWT: ${{ inputs.jwt }} + AUDIENCE: ${{ inputs.audience }} + run: | + set -euo pipefail + if [ -n "${INPUT_JWT}" ]; then + echo "Using JWT supplied via the 'jwt' input." + JWT="${INPUT_JWT}" + else + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "::error::No 'jwt' input provided and the GitHub OIDC token endpoint is unavailable. Ensure the calling job sets 'permissions: id-token: write'." >&2 + exit 1 + fi + echo "Requesting GitHub Actions OIDC token for audience '${AUDIENCE}'." + TOKEN_JSON=$(curl -sS \ + -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${AUDIENCE}") + JWT=$(echo "${TOKEN_JSON}" | jq -r '.value') + fi + if [ -z "${JWT}" ] || [ "${JWT}" = "null" ]; then + echo "::error::Failed to obtain an OIDC JWT." >&2 + exit 1 + fi + echo "::add-mask::${JWT}" + echo "jwt=${JWT}" >> "${GITHUB_OUTPUT}" + + - name: Exchange JWT for Keystone token + id: exchange + shell: bash + env: + KEYSTONE_URL: ${{ inputs.keystone_url }} + IDP_ID: ${{ inputs.idp_id }} + MAPPING: ${{ inputs.mapping }} + JWT: ${{ steps.jwt.outputs.jwt }} + run: | + set -euo pipefail + URL="${KEYSTONE_URL%/}/v4/federation/identity_providers/${IDP_ID}/jwt" + echo "Exchanging JWT for a Keystone token at ${URL}." + HEADERS_FILE="$(mktemp)" + trap 'rm -f "${HEADERS_FILE}"' EXIT + HTTP_CODE=$(curl -sS -o /dev/null -D "${HEADERS_FILE}" -w '%{http_code}' \ + -X POST "${URL}" \ + -H "Authorization: bearer ${JWT}" \ + -H "openstack-mapping: ${MAPPING}") + if [ "${HTTP_CODE}" -lt 200 ] || [ "${HTTP_CODE}" -ge 300 ]; then + echo "::error::Keystone JWT login failed with HTTP ${HTTP_CODE}." >&2 + cat "${HEADERS_FILE}" >&2 + exit 1 + fi + # Extract the X-Subject-Token header (case-insensitive, tolerate CRLF). + TOKEN=$(grep -i '^x-subject-token:' "${HEADERS_FILE}" | tail -n1 \ + | sed -e 's/^[^:]*:[[:space:]]*//' -e 's/[[:space:]]*$//' | tr -d '\r' || true) + if [ -z "${TOKEN}" ]; then + echo "::error::Keystone did not return an X-Subject-Token header." >&2 + exit 1 + fi + echo "::add-mask::${TOKEN}" + echo "token=${TOKEN}" >> "${GITHUB_OUTPUT}" + echo "Successfully obtained a Keystone token." diff --git a/.github/workflows/functional.yml b/.github/workflows/functional.yml index 61eb8cc76..86078fd52 100644 --- a/.github/workflows/functional.yml +++ b/.github/workflows/functional.yml @@ -261,6 +261,58 @@ jobs: GITHUB_SUB: "repo:openstack-experimental/keystone:pull_request" run: cargo test --test github -- --nocapture + - name: Register IdP and mapping for the login_jwt action self-test + id: setup_action_idp + env: + GITHUB_SUB: "repo:openstack-experimental/keystone:pull_request" + run: | + set -euo pipefail + # Obtain an admin token from the rust Keystone. + ADMIN_TOKEN=$(curl -sS -D - -o /dev/null \ + -X POST "${KEYSTONE_URL}/v3/auth/tokens" \ + -H "Content-Type: application/json" \ + -d '{"auth":{"identity":{"methods":["password"],"password":{"user":{"name":"admin","password":"password","domain":{"id":"default"}}}},"scope":{"project":{"name":"admin","domain":{"id":"default"}}}}}' \ + | grep -i '^x-subject-token:' | tail -n1 \ + | sed -e 's/^[^:]*:[[:space:]]*//' | tr -d '\r') + test -n "${ADMIN_TOKEN}" + # Register the GitHub identity provider. + IDP_ID=$(curl -sS \ + -X POST "${KEYSTONE_URL}/v4/federation/identity_providers" \ + -H "x-auth-token: ${ADMIN_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"identity_provider":{"name":"github-action-test","enabled":true,"bound_issuer":"https://token.actions.githubusercontent.com","jwks_url":"https://token.actions.githubusercontent.com/.well-known/jwks"}}' \ + | jq -r '.identity_provider.id') + test -n "${IDP_ID}" && test "${IDP_ID}" != "null" + # Create the JWT attribute mapping bound to this workflow. + HTTP_CODE=$(curl -sS -o /dev/null -w '%{http_code}' \ + -X POST "${KEYSTONE_URL}/v4/federation/mappings" \ + -H "x-auth-token: ${ADMIN_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"mapping\":{\"name\":\"github-action\",\"type\":\"jwt\",\"enabled\":true,\"idp_id\":\"${IDP_ID}\",\"domain_id\":\"default\",\"bound_audiences\":[\"https://github.com\"],\"bound_subject\":\"${GITHUB_SUB}\",\"bound_claims\":{\"base_ref\":\"main\"},\"user_id_claim\":\"actor_id\",\"user_name_claim\":\"actor\"}}") + if [ "${HTTP_CODE}" -lt 200 ] || [ "${HTTP_CODE}" -ge 300 ]; then + echo "::error::Failed to create mapping (HTTP ${HTTP_CODE})." >&2 + exit 1 + fi + echo "idp_id=${IDP_ID}" >> "${GITHUB_OUTPUT}" + + - name: Login to Keystone with JWT (action self-test) + id: login_jwt_selftest + uses: ./.github/actions/login_jwt + with: + keystone_url: ${{ env.KEYSTONE_URL }} + idp_id: ${{ steps.setup_action_idp.outputs.idp_id }} + mapping: github-action + audience: https://github.com + + - name: Verify the login_jwt action returned a Keystone token + run: | + set -euo pipefail + if [ -z "${{ steps.login_jwt_selftest.outputs.token }}" ]; then + echo "::error::login_jwt action did not return a token." >&2 + exit 1 + fi + echo "login_jwt action successfully returned a Keystone token." + - name: Dump py-keystone logs if: failure() run: docker logs keystone diff --git a/doc/src/federation/jwt.md b/doc/src/federation/jwt.md index 9d549c897..023afaa07 100644 --- a/doc/src/federation/jwt.md +++ b/doc/src/federation/jwt.md @@ -88,26 +88,50 @@ TODO: add more claims according to A way for the workflow to obtain the JWT [is described here](https://docs.github.com/en/actions/reference/security/oidc#methods-for-requesting-the-oidc-token). +Keystone ships a reusable composite action, [`login_jwt`](https://github.com/openstack-experimental/keystone/tree/main/.github/actions/login_jwt), +that performs the whole exchange: it requests the GitHub OIDC token and swaps it +for a Keystone token via the JWT login endpoint, masking the secrets and exposing +the result as the `token` output. The calling job only needs to grant the +`id-token: write` permission. + ```yaml -... permissions: - token: write + id-token: write contents: read -job: - ... - - name: Get GitHub JWT token - id: get_token - run: | - TOKEN_JSON=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ - "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://github.com") - - TOKEN=$(echo $TOKEN_JSON | jq -r .value) - echo "token=$TOKEN" >> $GITHUB_OUTPUT - ... - # TODO: build a proper command for capturing the actual token and/or write a dedicated action for that. - - name: Exchange GitHub JWT for Keystone token - run: | - KEYSTONE_TOKEN=$(curl -H "Authorization: bearer ${{ steps.get_token.outputs.token }}" -H "openstack-mapping: gtmema_keystone_main" https://keystone_url/v4/federation/identity_providers/IDP/jwt) +jobs: + example: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Login to Keystone with JWT + id: keystone_login + uses: openstack-experimental/keystone/.github/actions/login_jwt@main + with: + keystone_url: https://keystone.example.com + idp_id: + mapping: gtema_keystone_main + # audience defaults to https://github.com and must match the + # mapping `bound_audiences` configured in Keystone. + + - name: Use the Keystone token + env: + OS_TOKEN: ${{ steps.keystone_login.outputs.token }} + run: | + curl -H "X-Auth-Token: ${OS_TOKEN}" \ + https://keystone.example.com/v3/auth/tokens +``` + +If you already hold a JWT (for example one issued by a non-GitHub IdP), pass it +via the `jwt` input and the OIDC request step is skipped: +```yaml + - name: Login to Keystone with JWT + uses: openstack-experimental/keystone/.github/actions/login_jwt@main + with: + keystone_url: https://keystone.example.com + idp_id: + mapping: gtema_keystone_main + jwt: ${{ steps.some_previous_step.outputs.jwt }} ```