#!/usr/bin/env bash set -Eeuo pipefail # Never inherit caller xtrace: this script handles bootstrap credentials, # bearer tokens, and the Gitea OIDC client secret. set +x umask 077 readonly REPOSITORY_ROOT="/home/donghyeon/workspace/platform" readonly KEYCLOAK_NAMESPACE="keycloak" readonly KEYCLOAK_NAME="keycloak" readonly KEYCLOAK_SERVICE="keycloak-service" readonly KEYCLOAK_HOST="id.learn.hyeonworks.com" readonly KEYCLOAK_REALM="hyeonworks" readonly GITEA_NAMESPACE="gitea" readonly GITEA_OIDC_SECRET="gitea-keycloak-oidc" readonly GITEA_CLIENT_ID="gitea" readonly GITEA_ROOT_URL="https://git.learn.hyeonworks.com" readonly GITEA_REDIRECT_URI="${GITEA_ROOT_URL}/user/oauth2/keycloak/callback" readonly LOCAL_PORT="${KEYCLOAK_LOCAL_PORT:-18080}" readonly LOCAL_BASE_URL="http://127.0.0.1:${LOCAL_PORT}" fail() { printf 'ERROR: %s\n' "$*" >&2 exit 1 } usage() { printf '%s\n' \ 'Usage: bash scripts/bootstrap/configure-keycloak-gitea-oidc.sh --execute' \ '' \ 'Uses the Operator-generated temporary Keycloak administrator through a' \ 'loopback-only kubectl port-forward. It creates or updates the hyeonworks' \ 'realm and confidential Gitea client, then writes only the generated client' \ 'credential to gitea/gitea-keycloak-oidc.' \ '' \ 'No credential, token, or Secret payload is printed or written to Git.' } [[ "${1:-}" == "--execute" && "$#" -eq 1 ]] || { usage exit 2 } [[ "$LOCAL_PORT" =~ ^[0-9]+$ ]] || fail "KEYCLOAK_LOCAL_PORT must be numeric" (( LOCAL_PORT >= 1024 && LOCAL_PORT <= 65535 )) || \ fail "KEYCLOAK_LOCAL_PORT must be between 1024 and 65535" [[ "$PWD" == "$REPOSITORY_ROOT" ]] || \ fail "run from ${REPOSITORY_ROOT}" [[ -t 0 ]] || fail "an interactive terminal is required" for required_binary in \ kubectl curl jq base64 mktemp chmod kill sleep seq rg sed tr sort rm; do command -v "$required_binary" >/dev/null 2>&1 || \ fail "${required_binary} is required" done readonly CURRENT_CONTEXT="$(kubectl config current-context)" readonly API_SERVER="$( kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}' )" readonly TARGET_NODE="$( kubectl get nodes \ --selector='node-role.kubernetes.io/control-plane' \ --output=jsonpath='{.items[0].metadata.name}' )" [[ -n "$CURRENT_CONTEXT" ]] || fail "kubectl current-context is empty" [[ -n "$API_SERVER" ]] || fail "the selected Kubernetes API server is empty" [[ -n "$TARGET_NODE" ]] || fail "the control-plane node was not found" kubectl get namespace "$KEYCLOAK_NAMESPACE" >/dev/null kubectl get namespace "$GITEA_NAMESPACE" >/dev/null kubectl --namespace "$KEYCLOAK_NAMESPACE" get \ "keycloak.k8s.keycloak.org/${KEYCLOAK_NAME}" >/dev/null kubectl --namespace "$KEYCLOAK_NAMESPACE" wait \ --for=condition=Ready \ "keycloak.k8s.keycloak.org/${KEYCLOAK_NAME}" \ --timeout=30s >/dev/null kubectl --namespace "$KEYCLOAK_NAMESPACE" get \ "service/${KEYCLOAK_SERVICE}" >/dev/null kubectl --namespace "$KEYCLOAK_NAMESPACE" get \ "secret/${KEYCLOAK_NAME}-initial-admin" >/dev/null printf 'Current context: %s\n' "$CURRENT_CONTEXT" printf 'API server: %s\n' "$API_SERVER" printf 'Target node: %s\n' "$TARGET_NODE" printf 'Realm: %s\n' "$KEYCLOAK_REALM" printf 'OIDC client: %s\n' "$GITEA_CLIENT_ID" printf 'Redirect URI: %s\n' "$GITEA_REDIRECT_URI" printf 'Type APPLY %s to configure Keycloak and create the Gitea OIDC Secret: ' \ "$CURRENT_CONTEXT" read -r confirmation [[ "$confirmation" == "APPLY ${CURRENT_CONTEXT}" ]] || fail "cancelled" [[ "$(kubectl config current-context)" == "$CURRENT_CONTEXT" ]] || \ fail "kubectl context changed after confirmation" [[ "$( kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}' )" == "$API_SERVER" ]] || fail "Kubernetes API server changed after confirmation" [[ "$( kubectl get nodes \ --selector='node-role.kubernetes.io/control-plane' \ --output=jsonpath='{.items[0].metadata.name}' )" == "$TARGET_NODE" ]] || fail "target control-plane node changed after confirmation" kubectl get node "$TARGET_NODE" >/dev/null 2>&1 || \ fail "target node disappeared after confirmation: ${TARGET_NODE}" readonly TEMP_DIR="$(mktemp -d /tmp/keycloak-gitea-oidc.XXXXXX)" readonly PORT_FORWARD_LOG="${TEMP_DIR}/port-forward.log" readonly ADMIN_USERNAME_FILE="${TEMP_DIR}/admin-username" readonly ADMIN_PASSWORD_FILE="${TEMP_DIR}/admin-password" readonly TOKEN_RESPONSE_FILE="${TEMP_DIR}/token-response.json" readonly ACCESS_TOKEN_FILE="${TEMP_DIR}/access-token" readonly AUTH_CONFIG_FILE="${TEMP_DIR}/curl-auth.conf" readonly REALM_FILE="${TEMP_DIR}/realm.json" readonly CLIENT_FILE="${TEMP_DIR}/client.json" readonly RESPONSE_FILE="${TEMP_DIR}/response.json" readonly CLIENT_SECRET_RESPONSE_FILE="${TEMP_DIR}/client-secret.json" readonly CLIENT_SECRET_FILE="${TEMP_DIR}/client-secret" port_forward_pid="" cleanup() { local cleanup_rc=$? if [[ -n "$port_forward_pid" ]] && kill -0 "$port_forward_pid" 2>/dev/null; then kill "$port_forward_pid" 2>/dev/null || true wait "$port_forward_pid" 2>/dev/null || true fi case "$TEMP_DIR" in /tmp/keycloak-gitea-oidc.*) rm -rf -- "$TEMP_DIR" ;; *) printf 'WARNING: refusing to remove unexpected temp path: %s\n' \ "$TEMP_DIR" >&2 ;; esac exit "$cleanup_rc" } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM kubectl --namespace "$KEYCLOAK_NAMESPACE" get \ "secret/${KEYCLOAK_NAME}-initial-admin" \ --output=jsonpath='{.data.username}' \ | base64 --decode >"$ADMIN_USERNAME_FILE" kubectl --namespace "$KEYCLOAK_NAMESPACE" get \ "secret/${KEYCLOAK_NAME}-initial-admin" \ --output=jsonpath='{.data.password}' \ | base64 --decode >"$ADMIN_PASSWORD_FILE" chmod 0600 "$ADMIN_USERNAME_FILE" "$ADMIN_PASSWORD_FILE" [[ -s "$ADMIN_USERNAME_FILE" ]] || fail "temporary admin username is empty" [[ -s "$ADMIN_PASSWORD_FILE" ]] || fail "temporary admin password is empty" kubectl --namespace "$KEYCLOAK_NAMESPACE" port-forward \ --address=127.0.0.1 \ "service/${KEYCLOAK_SERVICE}" \ "${LOCAL_PORT}:8080" >"$PORT_FORWARD_LOG" 2>&1 & port_forward_pid=$! port_forward_ready=0 for _ in $(seq 1 30); do kill -0 "$port_forward_pid" 2>/dev/null || { printf 'Port-forward failed; non-sensitive log follows:\n' >&2 sed -n '1,20p' "$PORT_FORWARD_LOG" >&2 fail "Keycloak port-forward exited" } if rg --quiet --fixed-strings \ "Forwarding from 127.0.0.1:${LOCAL_PORT} -> 8080" \ "$PORT_FORWARD_LOG"; then port_forward_ready=1 break fi sleep 1 done (( port_forward_ready == 1 )) || fail "Keycloak port-forward did not become ready" curl_common=( --disable --silent --show-error --noproxy '*' --connect-timeout 3 --max-time 20 --header "Host: ${KEYCLOAK_HOST}" --header 'X-Forwarded-Proto: https' --header 'X-Forwarded-Port: 443' ) curl "${curl_common[@]}" \ --fail-with-body \ --output "$TOKEN_RESPONSE_FILE" \ --data-urlencode 'grant_type=password' \ --data-urlencode 'client_id=admin-cli' \ --data-urlencode "username@${ADMIN_USERNAME_FILE}" \ --data-urlencode "password@${ADMIN_PASSWORD_FILE}" \ "${LOCAL_BASE_URL}/realms/master/protocol/openid-connect/token" \ >/dev/null || fail "temporary Keycloak administrator authentication failed" jq --exit-status --raw-output \ '.access_token | select(type == "string" and length > 0)' \ "$TOKEN_RESPONSE_FILE" >"$ACCESS_TOKEN_FILE" || \ fail "Keycloak token response did not contain an access token" chmod 0600 "$ACCESS_TOKEN_FILE" { printf 'header = "Authorization: Bearer ' tr -d '\r\n' <"$ACCESS_TOKEN_FILE" printf '"\n' } >"$AUTH_CONFIG_FILE" chmod 0600 "$AUTH_CONFIG_FILE" cat >"$REALM_FILE" <<'JSON' { "realm": "hyeonworks", "displayName": "Hyeonworks", "enabled": true, "sslRequired": "external", "registrationAllowed": false, "registrationEmailAsUsername": false, "rememberMe": true, "verifyEmail": false, "loginWithEmailAllowed": true, "duplicateEmailsAllowed": false, "resetPasswordAllowed": true, "editUsernameAllowed": false, "bruteForceProtected": true, "permanentLockout": false, "maxFailureWaitSeconds": 900, "minimumQuickLoginWaitSeconds": 60, "waitIncrementSeconds": 60, "quickLoginCheckMilliSeconds": 1000, "maxDeltaTimeSeconds": 43200, "failureFactor": 5, "internationalizationEnabled": true, "supportedLocales": ["ko", "en"], "defaultLocale": "ko" } JSON cat >"$CLIENT_FILE" <<'JSON' { "clientId": "gitea", "name": "Hyeonworks Gitea", "description": "Gitea confidential OIDC client managed by the platform bootstrap", "enabled": true, "protocol": "openid-connect", "clientAuthenticatorType": "client-secret", "publicClient": false, "standardFlowEnabled": true, "implicitFlowEnabled": false, "directAccessGrantsEnabled": false, "serviceAccountsEnabled": false, "authorizationServicesEnabled": false, "consentRequired": false, "fullScopeAllowed": false, "rootUrl": "https://git.learn.hyeonworks.com", "baseUrl": "https://git.learn.hyeonworks.com/", "redirectUris": [ "https://git.learn.hyeonworks.com/user/oauth2/keycloak/callback" ], "webOrigins": [ "https://git.learn.hyeonworks.com" ], "attributes": { "post.logout.redirect.uris": "https://git.learn.hyeonworks.com/*", "oauth2.device.authorization.grant.enabled": "false", "oidc.ciba.grant.enabled": "false" } } JSON admin_request() { curl "${curl_common[@]}" \ --config "$AUTH_CONFIG_FILE" \ "$@" } realm_status="$( admin_request \ --output "$RESPONSE_FILE" \ --write-out '%{http_code}' \ "${LOCAL_BASE_URL}/admin/realms/${KEYCLOAK_REALM}" )" case "$realm_status" in 200) update_status="$( admin_request \ --request PUT \ --header 'Content-Type: application/json' \ --data-binary "@${REALM_FILE}" \ --output "$RESPONSE_FILE" \ --write-out '%{http_code}' \ "${LOCAL_BASE_URL}/admin/realms/${KEYCLOAK_REALM}" )" [[ "$update_status" == "204" ]] || \ fail "Keycloak realm update failed with HTTP ${update_status}" printf 'Updated Keycloak realm %s.\n' "$KEYCLOAK_REALM" ;; 404) create_status="$( admin_request \ --request POST \ --header 'Content-Type: application/json' \ --data-binary "@${REALM_FILE}" \ --output "$RESPONSE_FILE" \ --write-out '%{http_code}' \ "${LOCAL_BASE_URL}/admin/realms" )" [[ "$create_status" == "201" ]] || \ fail "Keycloak realm creation failed with HTTP ${create_status}" printf 'Created Keycloak realm %s.\n' "$KEYCLOAK_REALM" ;; *) fail "Keycloak realm lookup failed with HTTP ${realm_status}" ;; esac client_lookup_status="$( admin_request \ --get \ --data-urlencode "clientId=${GITEA_CLIENT_ID}" \ --data-urlencode 'max=2' \ --output "$RESPONSE_FILE" \ --write-out '%{http_code}' \ "${LOCAL_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/clients" )" [[ "$client_lookup_status" == "200" ]] || \ fail "Keycloak client lookup failed with HTTP ${client_lookup_status}" client_count="$(jq 'length' "$RESPONSE_FILE")" [[ "$client_count" == "0" || "$client_count" == "1" ]] || \ fail "more than one Keycloak client uses clientId=${GITEA_CLIENT_ID}" if [[ "$client_count" == "0" ]]; then client_create_status="$( admin_request \ --request POST \ --header 'Content-Type: application/json' \ --data-binary "@${CLIENT_FILE}" \ --output "$RESPONSE_FILE" \ --write-out '%{http_code}' \ "${LOCAL_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/clients" )" [[ "$client_create_status" == "201" ]] || \ fail "Keycloak Gitea client creation failed with HTTP ${client_create_status}" printf 'Created confidential Keycloak client %s.\n' "$GITEA_CLIENT_ID" else client_uuid="$(jq --exit-status --raw-output '.[0].id' "$RESPONSE_FILE")" [[ -n "$client_uuid" ]] || fail "existing Gitea client has no internal id" client_update_status="$( admin_request \ --request PUT \ --header 'Content-Type: application/json' \ --data-binary "@${CLIENT_FILE}" \ --output "$RESPONSE_FILE" \ --write-out '%{http_code}' \ "${LOCAL_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${client_uuid}" )" [[ "$client_update_status" == "204" ]] || \ fail "Keycloak Gitea client update failed with HTTP ${client_update_status}" printf 'Updated confidential Keycloak client %s.\n' "$GITEA_CLIENT_ID" fi client_lookup_status="$( admin_request \ --get \ --data-urlencode "clientId=${GITEA_CLIENT_ID}" \ --data-urlencode 'max=2' \ --output "$RESPONSE_FILE" \ --write-out '%{http_code}' \ "${LOCAL_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/clients" )" [[ "$client_lookup_status" == "200" ]] || \ fail "post-update Keycloak client lookup failed with HTTP ${client_lookup_status}" [[ "$(jq 'length' "$RESPONSE_FILE")" == "1" ]] || \ fail "post-update Gitea client lookup did not return exactly one client" client_uuid="$(jq --exit-status --raw-output '.[0].id' "$RESPONSE_FILE")" client_secret_status="$( admin_request \ --output "$CLIENT_SECRET_RESPONSE_FILE" \ --write-out '%{http_code}' \ "${LOCAL_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${client_uuid}/client-secret" )" [[ "$client_secret_status" == "200" ]] || \ fail "Keycloak client-secret retrieval failed with HTTP ${client_secret_status}" jq --exit-status --raw-output --join-output \ '.value | select(type == "string" and length >= 16)' \ "$CLIENT_SECRET_RESPONSE_FILE" >"$CLIENT_SECRET_FILE" || \ fail "Keycloak returned an invalid Gitea client secret" chmod 0600 "$CLIENT_SECRET_FILE" kubectl --namespace "$GITEA_NAMESPACE" create secret generic \ "$GITEA_OIDC_SECRET" \ --from-literal="key=${GITEA_CLIENT_ID}" \ --from-file="secret=${CLIENT_SECRET_FILE}" \ --dry-run=client \ --output=yaml \ | kubectl apply --filename=- >/dev/null kubectl --namespace "$GITEA_NAMESPACE" label secret "$GITEA_OIDC_SECRET" \ app.kubernetes.io/name=gitea \ app.kubernetes.io/instance=gitea \ app.kubernetes.io/component=oidc-client \ app.kubernetes.io/part-of=platform \ app.kubernetes.io/managed-by=bootstrap-script \ --overwrite >/dev/null secret_type="$( kubectl --namespace "$GITEA_NAMESPACE" get secret "$GITEA_OIDC_SECRET" \ --output=jsonpath='{.type}' )" secret_keys="$( kubectl --namespace "$GITEA_NAMESPACE" get secret "$GITEA_OIDC_SECRET" \ --output=go-template='{{range $key, $value := .data}}{{$key}}{{"\n"}}{{end}}' \ | LC_ALL=C sort )" [[ "$secret_type" == "Opaque" ]] || fail "Gitea OIDC Secret type is not Opaque" [[ "$secret_keys" == $'key\nsecret' ]] || \ fail "Gitea OIDC Secret key contract is invalid" discovery_status="$( curl "${curl_common[@]}" \ --output "$RESPONSE_FILE" \ --write-out '%{http_code}' \ "${LOCAL_BASE_URL}/realms/${KEYCLOAK_REALM}/.well-known/openid-configuration" )" [[ "$discovery_status" == "200" ]] || \ fail "Keycloak OIDC discovery failed with HTTP ${discovery_status}" jq --exit-status \ --arg issuer "https://${KEYCLOAK_HOST}/realms/${KEYCLOAK_REALM}" \ '.issuer == $issuer and (.authorization_endpoint | startswith($issuer)) and (.token_endpoint | startswith($issuer))' \ "$RESPONSE_FILE" >/dev/null || \ fail "Keycloak discovery metadata contains an unexpected public issuer" printf '\nKEYCLOAK OIDC BOOTSTRAP SUCCESS\n' printf 'Realm: %s\n' "$KEYCLOAK_REALM" printf 'Client: %s (confidential, authorization code flow)\n' "$GITEA_CLIENT_ID" printf 'Gitea Secret: %s/%s\n' "$GITEA_NAMESPACE" "$GITEA_OIDC_SECRET" printf 'Secret and token payloads were not printed and the temporary files were removed.\n' printf 'Next: run scripts/bootstrap/apply-host-nginx-keycloak.sh, then apply-gitea-oidc.sh.\n' printf 'Security follow-up: replace the temporary Keycloak administrator with a named administrator and MFA.\n'