|
| 1 | +#!/usr/bin/env bash |
| 2 | +# Push hosted-login-UI (v2) custom translations to a ZITADEL instance. |
| 3 | +# |
| 4 | +# Required env vars: |
| 5 | +# ZITADEL_DOMAIN instance domain, no scheme (e.g. auth.futo.org) |
| 6 | +# ZITADEL_PROFILE_JSON JWT profile JSON for a machine user with |
| 7 | +# iam.policy.write (i.e. IAM_OWNER) |
| 8 | +# TRANSLATIONS_FILE path to a JSON file with the override keys |
| 9 | +# (deep-merged over bundled defaults by the v2 UI) |
| 10 | +# LOCALE BCP 47 language tag (e.g. "en") |
| 11 | +# |
| 12 | +# Requires: curl, jq, openssl (all standard on a dev box). |
| 13 | + |
| 14 | +set -euo pipefail |
| 15 | + |
| 16 | +: "${ZITADEL_DOMAIN:?ZITADEL_DOMAIN is required}" |
| 17 | +: "${ZITADEL_PROFILE_JSON:?ZITADEL_PROFILE_JSON is required}" |
| 18 | +: "${TRANSLATIONS_FILE:?TRANSLATIONS_FILE is required}" |
| 19 | +: "${LOCALE:?LOCALE is required}" |
| 20 | + |
| 21 | +# Tolerate values that include a scheme and/or trailing slash. |
| 22 | +ZITADEL_DOMAIN="${ZITADEL_DOMAIN#https://}" |
| 23 | +ZITADEL_DOMAIN="${ZITADEL_DOMAIN#http://}" |
| 24 | +ZITADEL_DOMAIN="${ZITADEL_DOMAIN%/}" |
| 25 | + |
| 26 | +if [[ ! -f "$TRANSLATIONS_FILE" ]]; then |
| 27 | + echo "translations file not found: $TRANSLATIONS_FILE" >&2 |
| 28 | + exit 1 |
| 29 | +fi |
| 30 | + |
| 31 | +# -- base64url helpers ------------------------------------------------------ |
| 32 | +b64url() { |
| 33 | + # Stream in -> base64url out (no padding). `tr -d '\n'` instead of GNU `base64 -w0` |
| 34 | + # so this works on macOS/BSD base64 too. |
| 35 | + base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=' |
| 36 | +} |
| 37 | + |
| 38 | +# -- parse profile JSON ----------------------------------------------------- |
| 39 | +profile_tmp=""; key_tmp=""; signing_tmp=""; sig_tmp="" |
| 40 | +trap 'rm -f "$profile_tmp" "$key_tmp" "$signing_tmp" "$sig_tmp"' EXIT |
| 41 | +profile_tmp=$(mktemp) |
| 42 | +echo "$ZITADEL_PROFILE_JSON" >"$profile_tmp" |
| 43 | + |
| 44 | +key_id=$(jq -r '.keyId' "$profile_tmp") |
| 45 | +user_id=$(jq -r '.userId' "$profile_tmp") |
| 46 | +if [[ -z "$key_id" || "$key_id" == "null" || -z "$user_id" || "$user_id" == "null" ]]; then |
| 47 | + echo "ZITADEL_PROFILE_JSON is missing keyId or userId" >&2 |
| 48 | + exit 1 |
| 49 | +fi |
| 50 | + |
| 51 | +key_tmp=$(mktemp) |
| 52 | +jq -r '.key' "$profile_tmp" >"$key_tmp" |
| 53 | + |
| 54 | +# -- build + sign JWT assertion -------------------------------------------- |
| 55 | +now=$(date +%s) |
| 56 | +exp=$((now + 60)) |
| 57 | +issuer="https://${ZITADEL_DOMAIN}" |
| 58 | + |
| 59 | +header=$(printf '{"alg":"RS256","typ":"JWT","kid":"%s"}' "$key_id" | b64url) |
| 60 | +payload=$(jq -cn \ |
| 61 | + --arg iss "$user_id" \ |
| 62 | + --arg sub "$user_id" \ |
| 63 | + --arg aud "$issuer" \ |
| 64 | + --argjson iat "$now" \ |
| 65 | + --argjson exp "$exp" \ |
| 66 | + '{iss:$iss, sub:$sub, aud:$aud, iat:$iat, exp:$exp}' | b64url) |
| 67 | + |
| 68 | +signing_tmp=$(mktemp) |
| 69 | +printf '%s.%s' "$header" "$payload" >"$signing_tmp" |
| 70 | + |
| 71 | +sig_tmp=$(mktemp) |
| 72 | +openssl dgst -sha256 -sign "$key_tmp" -out "$sig_tmp" "$signing_tmp" |
| 73 | +signature=$(b64url <"$sig_tmp") |
| 74 | + |
| 75 | +assertion="${header}.${payload}.${signature}" |
| 76 | + |
| 77 | +# -- exchange for access token --------------------------------------------- |
| 78 | +token_response=$(curl -sS -X POST "${issuer}/oauth/v2/token" \ |
| 79 | + -H "Content-Type: application/x-www-form-urlencoded" \ |
| 80 | + --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \ |
| 81 | + --data-urlencode "scope=openid urn:zitadel:iam:org:project:id:zitadel:aud" \ |
| 82 | + --data-urlencode "assertion=${assertion}") |
| 83 | + |
| 84 | +access_token=$(jq -r '.access_token // empty' <<<"$token_response") |
| 85 | +if [[ -z "$access_token" ]]; then |
| 86 | + echo "token exchange failed:" >&2 |
| 87 | + echo "$token_response" >&2 |
| 88 | + exit 1 |
| 89 | +fi |
| 90 | + |
| 91 | +# -- PUT translations ------------------------------------------------------- |
| 92 | +body=$(jq -c --arg locale "$LOCALE" \ |
| 93 | + '{instance: true, locale: $locale, translations: .}' \ |
| 94 | + "$TRANSLATIONS_FILE") |
| 95 | + |
| 96 | +response=$(curl -sS -w '\n%{http_code}' -X PUT \ |
| 97 | + "${issuer}/v2/settings/hosted_login_translation" \ |
| 98 | + -H "Authorization: Bearer ${access_token}" \ |
| 99 | + -H "Content-Type: application/json" \ |
| 100 | + --data "$body") |
| 101 | + |
| 102 | +status=$(tail -n1 <<<"$response") |
| 103 | +body_out=$(sed '$d' <<<"$response") |
| 104 | + |
| 105 | +if [[ "$status" -lt 200 || "$status" -ge 300 ]]; then |
| 106 | + echo "PUT /v2/settings/hosted_login_translation failed (HTTP $status):" >&2 |
| 107 | + echo "$body_out" >&2 |
| 108 | + exit 1 |
| 109 | +fi |
| 110 | + |
| 111 | +echo "hosted login translations updated for locale=$LOCALE (HTTP $status)" |
0 commit comments