Skip to content
Open
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
101 changes: 101 additions & 0 deletions .github/actions/login_jwt/action.yml
Original file line number Diff line number Diff line change
@@ -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."
52 changes: 52 additions & 0 deletions .github/workflows/functional.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 41 additions & 17 deletions doc/src/federation/jwt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <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: <IDP_ID>
mapping: gtema_keystone_main
jwt: ${{ steps.some_previous_step.outputs.jwt }}
```
Loading