446 lines
16 KiB
Bash
Executable File
446 lines
16 KiB
Bash
Executable File
#!/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'
|