From 017ffb92f70557be13e33869099194644a9a707f Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Tue, 25 Aug 2026 04:34:46 +0800 Subject: [PATCH] test: add live Keycloak OIDC gate (#6562) --- .github/workflows/oidc-keycloak.yml | 110 ++++++++ .../fixtures/keycloak-rustfs-ci-realm.json | 42 +++ scripts/test/oidc_keycloak_live.sh | 241 ++++++++++++++++++ 3 files changed, 393 insertions(+) create mode 100644 .github/workflows/oidc-keycloak.yml create mode 100644 scripts/test/fixtures/keycloak-rustfs-ci-realm.json create mode 100755 scripts/test/oidc_keycloak_live.sh diff --git a/.github/workflows/oidc-keycloak.yml b/.github/workflows/oidc-keycloak.yml new file mode 100644 index 000000000..31a57efbc --- /dev/null +++ b/.github/workflows/oidc-keycloak.yml @@ -0,0 +1,110 @@ +# Copyright 2024 RustFS Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: OIDC Keycloak Live + +on: + pull_request: + paths: + - ".github/workflows/oidc-keycloak.yml" + - "crates/config/src/constants/oidc.rs" + - "crates/iam/src/federation/**" + - "crates/iam/src/oidc.rs" + - "rustfs/src/admin/handlers/oidc.rs" + - "rustfs/src/admin/handlers/sts.rs" + - "scripts/test/oidc_keycloak_live.sh" + - "scripts/test/fixtures/keycloak-rustfs-ci-realm.json" + push: + branches: [main] + paths: + - ".github/workflows/oidc-keycloak.yml" + - "crates/config/src/constants/oidc.rs" + - "crates/iam/src/federation/**" + - "crates/iam/src/oidc.rs" + - "rustfs/src/admin/handlers/oidc.rs" + - "rustfs/src/admin/handlers/sts.rs" + - "scripts/test/oidc_keycloak_live.sh" + - "scripts/test/fixtures/keycloak-rustfs-ci-realm.json" + schedule: + - cron: "23 2 * * 1" + timezone: "Asia/Shanghai" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: oidc-keycloak-live-${{ github.ref }} + cancel-in-progress: true + +jobs: + oidc-keycloak-live: + name: OIDC Keycloak live gate + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup Rust environment + uses: ./.github/actions/setup + with: + cache-shared-key: oidc-keycloak-live + cache-save-if: "true" + install-build-packaging-tools: "false" + install-test-tools: "false" + + - name: Build RustFS + run: cargo build --locked -p rustfs --bin rustfs + + - name: Install pinned request signer + run: | + python3 -m pip install --user --upgrade pip "awscurl==0.44" + echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" + + - name: Run live Keycloak discovery, JWT and STS checks + run: bash scripts/test/oidc_keycloak_live.sh ./target/debug/rustfs + + - name: Upload service logs + if: failure() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: oidc-keycloak-live-${{ github.run_number }} + path: ${{ runner.temp }}/rustfs-keycloak-live-*/**/*.log + if-no-files-found: ignore + retention-days: 3 + + alert-on-failure: + name: Alert on scheduled failure + needs: oidc-keycloak-live + if: >- + always() && github.event_name == 'schedule' && + (needs.oidc-keycloak-live.result == 'failure' || needs.oidc-keycloak-live.result == 'cancelled') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + - name: Open or update failure-tracking issue + uses: ./.github/actions/schedule-failure-issue + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/scripts/test/fixtures/keycloak-rustfs-ci-realm.json b/scripts/test/fixtures/keycloak-rustfs-ci-realm.json new file mode 100644 index 000000000..fd6d567e7 --- /dev/null +++ b/scripts/test/fixtures/keycloak-rustfs-ci-realm.json @@ -0,0 +1,42 @@ +{ + "realm": "rustfs-ci", + "enabled": true, + "sslRequired": "none", + "accessTokenLifespan": 300, + "clients": [ + { + "clientId": "rustfs-ci", + "secret": "rustfs-ci-secret", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": true + }, + { + "clientId": "wrong-audience", + "secret": "wrong-audience-secret", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": true + } + ], + "users": [ + { + "id": "00000000-0000-0000-0000-000000000001", + "username": "alice", + "email": "alice@example.test", + "emailVerified": true, + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "alice-password", + "temporary": false + } + ] + } + ] +} diff --git a/scripts/test/oidc_keycloak_live.sh b/scripts/test/oidc_keycloak_live.sh new file mode 100755 index 000000000..db2e51197 --- /dev/null +++ b/scripts/test/oidc_keycloak_live.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +RUSTFS_BINARY="${1:-${ROOT_DIR}/target/debug/rustfs}" +REALM_FIXTURE="${ROOT_DIR}/scripts/test/fixtures/keycloak-rustfs-ci-realm.json" +KEYCLOAK_IMAGE="${KEYCLOAK_IMAGE:-quay.io/keycloak/keycloak@sha256:6a7217a100bd3e5de4063a27a538ef999a3c5a88c4b4ec0ffc0a642aee7b2597}" +WORK_DIR="${RUNNER_TEMP:-/tmp}/rustfs-keycloak-live-${$}" +KEYCLOAK_CONTAINER="rustfs-keycloak-live-${$}" +RUSTFS_PID="" +mkdir -p "${WORK_DIR}" + +cleanup() { + local status=$? + if [[ -n "${RUSTFS_PID}" ]] && kill -0 "${RUSTFS_PID}" 2>/dev/null; then + kill "${RUSTFS_PID}" 2>/dev/null || true + wait "${RUSTFS_PID}" 2>/dev/null || true + fi + if [[ "${status}" -ne 0 ]]; then + docker logs "${KEYCLOAK_CONTAINER}" >"${WORK_DIR}/keycloak.log" 2>&1 || true + echo "live-gate logs retained in ${WORK_DIR}" >&2 + fi + docker rm -f "${KEYCLOAK_CONTAINER}" >/dev/null 2>&1 || true + if [[ "${status}" -eq 0 ]]; then + rm -rf "${WORK_DIR}" + fi +} +trap cleanup EXIT INT TERM + +for command in curl docker python3 awscurl; do + command -v "${command}" >/dev/null || { + echo "missing required command: ${command}" >&2 + exit 1 + } +done +[[ -x "${RUSTFS_BINARY}" ]] || { + echo "RustFS binary is not executable: ${RUSTFS_BINARY}" >&2 + exit 1 +} +[[ -f "${REALM_FIXTURE}" ]] || { + echo "Keycloak realm fixture is missing: ${REALM_FIXTURE}" >&2 + exit 1 +} + +free_port() { + python3 - <<'PY' +import socket + +with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + print(sock.getsockname()[1]) +PY +} + +KEYCLOAK_PORT="$(free_port)" +RUSTFS_PORT="$(free_port)" +KEYCLOAK_ORIGIN="http://127.0.0.1:${KEYCLOAK_PORT}" +ISSUER="${KEYCLOAK_ORIGIN}/realms/rustfs-ci" +DISCOVERY_URL="${ISSUER}/.well-known/openid-configuration" +RUSTFS_ORIGIN="http://127.0.0.1:${RUSTFS_PORT}" + +docker run --detach --rm \ + --name "${KEYCLOAK_CONTAINER}" \ + --memory 1g \ + --publish "127.0.0.1:${KEYCLOAK_PORT}:8080" \ + --env KC_BOOTSTRAP_ADMIN_USERNAME=admin \ + --env KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ + --env KC_HEALTH_ENABLED=true \ + --env "KC_HOSTNAME=${KEYCLOAK_ORIGIN}" \ + --volume "${REALM_FIXTURE}:/opt/keycloak/data/import/rustfs-ci-realm.json:ro" \ + "${KEYCLOAK_IMAGE}" start-dev --import-realm >/dev/null + +for _ in $(seq 1 120); do + if curl --noproxy '*' -fsS "${DISCOVERY_URL}" >"${WORK_DIR}/discovery.json" 2>/dev/null; then + break + fi + if [[ "$(docker inspect -f '{{.State.Running}}' "${KEYCLOAK_CONTAINER}" 2>/dev/null || true)" != "true" ]]; then + docker logs "${KEYCLOAK_CONTAINER}" >&2 || true + echo "Keycloak exited before discovery became ready" >&2 + exit 1 + fi + sleep 1 +done +curl --noproxy '*' -fsS "${DISCOVERY_URL}" >"${WORK_DIR}/discovery.json" +python3 - "${WORK_DIR}/discovery.json" "${ISSUER}" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + discovery = json.load(source) +expected_issuer = sys.argv[2] +assert discovery.get("issuer") == expected_issuer, discovery +for field in ("token_endpoint", "jwks_uri"): + assert discovery.get(field), discovery +PY + +mkdir -p "${WORK_DIR}/rustfs-data" +env \ + NO_PROXY=127.0.0.1,localhost \ + RUSTFS_ACCESS_KEY=rustfsadmin \ + RUSTFS_SECRET_KEY=rustfsadmin \ + RUSTFS_OUTBOUND_ALLOW_ORIGINS="${KEYCLOAK_ORIGIN}" \ + RUSTFS_IDENTITY_OPENID_ENABLE=on \ + RUSTFS_IDENTITY_OPENID_CONFIG_URL="${DISCOVERY_URL}" \ + RUSTFS_IDENTITY_OPENID_ISSUER="${ISSUER}" \ + RUSTFS_IDENTITY_OPENID_CLIENT_ID=rustfs-ci \ + RUSTFS_IDENTITY_OPENID_CLIENT_SECRET=rustfs-ci-secret \ + RUSTFS_IDENTITY_OPENID_SCOPES=openid,profile,email \ + RUSTFS_IDENTITY_OPENID_ROLE_POLICY=consoleAdmin \ + "${RUSTFS_BINARY}" --address "127.0.0.1:${RUSTFS_PORT}" "${WORK_DIR}/rustfs-data" \ + >"${WORK_DIR}/rustfs.log" 2>&1 & +RUSTFS_PID=$! + +for _ in $(seq 1 90); do + if curl --noproxy '*' -fsS "${RUSTFS_ORIGIN}/health/ready" >/dev/null 2>&1; then + break + fi + if ! kill -0 "${RUSTFS_PID}" 2>/dev/null; then + cat "${WORK_DIR}/rustfs.log" >&2 + echo "RustFS exited before becoming ready" >&2 + exit 1 + fi + sleep 1 +done +curl --noproxy '*' -fsS "${RUSTFS_ORIGIN}/health/ready" >/dev/null + +token_for_client() { + local client_id="$1" + local client_secret="$2" + local response_file="${WORK_DIR}/token-${client_id}.json" + curl --noproxy '*' -fsS "${ISSUER}/protocol/openid-connect/token" \ + --data-urlencode grant_type=password \ + --data-urlencode "client_id=${client_id}" \ + --data-urlencode "client_secret=${client_secret}" \ + --data-urlencode username=alice \ + --data-urlencode password=alice-password \ + --data-urlencode scope=openid \ + >"${response_file}" + python3 - "${response_file}" "${ISSUER}" "${client_id}" <<'PY' +import base64 +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + token = json.load(source)["id_token"] +payload = token.split(".")[1] +payload += "=" * (-len(payload) % 4) +claims = json.loads(base64.urlsafe_b64decode(payload)) +assert claims["iss"] == sys.argv[2], claims +audience = claims["aud"] +if isinstance(audience, str): + audience = [audience] +assert sys.argv[3] in audience, claims +print(token) +PY +} + +GOOD_TOKEN="$(token_for_client rustfs-ci rustfs-ci-secret)" +GOOD_STATUS="$(curl --noproxy '*' -sS \ + -D "${WORK_DIR}/sts-good.headers" \ + -o "${WORK_DIR}/sts-good.xml" \ + -w '%{http_code}' \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -X POST "${RUSTFS_ORIGIN}/" \ + --data-urlencode Action=AssumeRoleWithWebIdentity \ + --data-urlencode Version=2011-06-15 \ + --data-urlencode DurationSeconds=900 \ + --data-urlencode "WebIdentityToken=${GOOD_TOKEN}")" +[[ "${GOOD_STATUS}" == 200 ]] || { + cat "${WORK_DIR}/sts-good.xml" >&2 + cat "${WORK_DIR}/rustfs.log" >&2 + echo "expected valid Keycloak token to return HTTP 200, got ${GOOD_STATUS}" >&2 + exit 1 +} +grep -Eiq '^content-type: application/xml' "${WORK_DIR}/sts-good.headers" + +IFS=$'\t' read -r STS_ACCESS_KEY STS_SECRET_KEY STS_SESSION_TOKEN < <( + python3 - "${WORK_DIR}/sts-good.xml" <<'PY' +import sys +import xml.etree.ElementTree as ET + +root = ET.parse(sys.argv[1]).getroot() +values = {} +for element in root.iter(): + values[element.tag.rsplit("}", 1)[-1]] = element.text or "" +for field in ("AccessKeyId", "SecretAccessKey", "SessionToken", "Expiration", "SubjectFromWebIdentityToken"): + assert values.get(field), values +print("\t".join(values[field] for field in ("AccessKeyId", "SecretAccessKey", "SessionToken"))) +PY +) + +awscurl --fail-with-body --service s3 --region us-east-1 \ + --access_key "${STS_ACCESS_KEY}" \ + --secret_key "${STS_SECRET_KEY}" \ + --security_token "${STS_SESSION_TOKEN}" \ + "${RUSTFS_ORIGIN}/" >"${WORK_DIR}/list-buckets.xml" +grep -q '&2 + echo "expected tampered token to return HTTP 403, got ${TAMPERED_STATUS}" >&2 + exit 1 +} +grep -q 'AccessDenied' "${WORK_DIR}/sts-tampered.xml" + +BAD_TOKEN="$(token_for_client wrong-audience wrong-audience-secret)" +BAD_STATUS="$(curl --noproxy '*' -sS \ + -o "${WORK_DIR}/sts-bad.xml" \ + -w '%{http_code}' \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -X POST "${RUSTFS_ORIGIN}/" \ + --data-urlencode Action=AssumeRoleWithWebIdentity \ + --data-urlencode Version=2011-06-15 \ + --data-urlencode DurationSeconds=900 \ + --data-urlencode "WebIdentityToken=${BAD_TOKEN}")" +[[ "${BAD_STATUS}" == 403 ]] || { + cat "${WORK_DIR}/sts-bad.xml" >&2 + echo "expected wrong-audience token to return HTTP 403, got ${BAD_STATUS}" >&2 + exit 1 +} +grep -q 'AccessDenied' "${WORK_DIR}/sts-bad.xml" + +echo "OIDC Keycloak live gate passed"