#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" OUTPUT_DIR="$ROOT_DIR/.generated" OUTPUT_FILE="$OUTPUT_DIR/auth-config.env" MODE="${1:-build}" if [[ -f "$ROOT_DIR/.env" ]]; then # shellcheck disable=SC1091 set -a source "$ROOT_DIR/.env" set +a fi USERFRONT_URL="${USERFRONT_URL:-http://localhost:5000}" OATHKEEPER_PUBLIC_URL="${OATHKEEPER_PUBLIC_URL:-$USERFRONT_URL}" HYDRA_PUBLIC_URL="${HYDRA_PUBLIC_URL:-${OATHKEEPER_PUBLIC_URL%/}/oidc}" KRATOS_UI_URL="${KRATOS_UI_URL:-http://localhost:5000}" ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-http://localhost:5173/auth/callback}" DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-http://localhost:5174/callback}" KRATOS_ALLOWED_RETURN_URLS_EXTRA="${KRATOS_ALLOWED_RETURN_URLS_EXTRA:-}" declare -a WARNINGS=() fail() { echo "[auth-config] ERROR: $1" >&2 exit 1 } warn() { WARNINGS+=("$1") } validate_dotenv_line_safety() { local key="$1" local env_file="$ROOT_DIR/.env" [[ -f "$env_file" ]] || return 0 local raw_line raw_line="$(grep -E "^${key}=" "$env_file" | tail -n 1 || true)" [[ -n "$raw_line" ]] || return 0 if [[ "$raw_line" == *" #"* ]]; then fail ".env line for $key contains inline comment. Use comment-only line above the key." fi if [[ "$raw_line" =~ [[:space:]]+$ ]]; then fail ".env line for $key has trailing whitespace." fi } trim() { local value="$1" value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" printf '%s' "$value" } csv_to_lines() { local csv="$1" printf '%s\n' "$csv" | tr ',' '\n' | while IFS= read -r raw; do local item item="$(trim "$raw")" if [[ -n "$item" ]]; then printf '%s\n' "$item" fi done } list_to_lines() { local raw="$1" raw="$(trim "$raw")" if [[ -z "$raw" || "$raw" == "[]" ]]; then return 0 fi if [[ "$raw" =~ ^\[(.*)\]$ ]]; then local inner="${BASH_REMATCH[1]}" inner="$(trim "$inner")" if [[ -z "$inner" ]]; then return 0 fi printf '%s\n' "$inner" | tr ',' '\n' | while IFS= read -r token; do local item item="$(trim "$token")" item="${item#\"}" item="${item%\"}" item="${item#\'}" item="${item%\'}" item="$(trim "$item")" if [[ -n "$item" ]]; then printf '%s\n' "$item" fi done return 0 fi csv_to_lines "$raw" } is_http_url() { local url="$1" [[ "$url" =~ ^https?://[^[:space:]]+$ ]] } canonicalize_url() { local url="$1" if [[ "$url" =~ ^(https?://[^/]+)(/.*)?$ ]]; then local origin="${BASH_REMATCH[1]}" local path="${BASH_REMATCH[2]:-}" if [[ -z "$path" || "$path" == "/" ]]; then printf '%s' "$origin" return fi path="${path%/}" if [[ -z "$path" ]]; then path="/" fi printf '%s%s' "$origin" "$path" return fi printf '%s' "$url" } is_origin_like_url() { local url="$1" [[ "$url" =~ ^https?://[^/]+/?$ ]] } url_path() { local url="$1" if [[ "$url" =~ ^https?://[^/]+(/.*)?$ ]]; then local path="${BASH_REMATCH[1]:-/}" printf '%s' "$path" return fi printf '' } json_escape() { local value="$1" value="${value//\\/\\\\}" value="${value//\"/\\\"}" printf '%s' "$value" } join_csv() { local -n arr_ref=$1 local out="" local first=1 for item in "${arr_ref[@]}"; do if [[ $first -eq 1 ]]; then out="$item" first=0 else out="$out,$item" fi done printf '%s' "$out" } to_json_array() { local -n arr_ref=$1 local json="[" local first=1 for item in "${arr_ref[@]}"; do local escaped escaped="$(json_escape "$item")" if [[ $first -eq 1 ]]; then json="$json\"$escaped\"" first=0 else json="$json,\"$escaped\"" fi done json="$json]" printf '%s' "$json" } collect_values() { declare -ga ADMIN_CALLBACKS=() declare -ga DEV_CALLBACKS=() declare -ga EXTRA_ALLOWED_RETURNS=() while IFS= read -r item; do ADMIN_CALLBACKS+=("$item") done < <(csv_to_lines "$ADMINFRONT_CALLBACK_URLS") while IFS= read -r item; do DEV_CALLBACKS+=("$item") done < <(csv_to_lines "$DEVFRONT_CALLBACK_URLS") while IFS= read -r item; do EXTRA_ALLOWED_RETURNS+=("$item") done < <(list_to_lines "$KRATOS_ALLOWED_RETURN_URLS_EXTRA") } validate_urls() { local key="$1" local value="$2" if ! is_http_url "$value"; then fail "$key must be a valid http/https URL: $value" fi } validate_callback_group() { local group_name="$1" local expected_path="$2" shift 2 local urls=("$@") if [[ ${#urls[@]} -eq 0 ]]; then fail "$group_name is empty" fi local matched_expected=0 local has_path=0 for url in "${urls[@]}"; do validate_urls "$group_name entry" "$url" local canonical canonical="$(canonicalize_url "$url")" if [[ "$url" != "$canonical" ]]; then fail "$group_name entry must not end with trailing slash: $url" fi local path path="$(url_path "$canonical")" if [[ -n "$path" && "$path" != "/" ]]; then has_path=1 fi if [[ "$path" == "$expected_path" ]]; then matched_expected=1 fi done if [[ $has_path -eq 0 ]]; then fail "$group_name must include path segment (callback path required)" fi if [[ $matched_expected -eq 0 ]]; then warn "$group_name does not include recommended path: $expected_path" fi } validate_gateway_mapping() { validate_urls "HYDRA_PUBLIC_URL" "$HYDRA_PUBLIC_URL" validate_urls "USERFRONT_URL" "$USERFRONT_URL" validate_urls "KRATOS_UI_URL" "$KRATOS_UI_URL" local mode="" if [[ "$HYDRA_PUBLIC_URL" =~ ^https?://hydra(:[0-9]+)?(/|$) ]]; then mode="direct_match" else mode="mapped_match" if ! grep -Eq 'location /oidc' "$ROOT_DIR/gateway/nginx.conf"; then mode="unmapped_fail" fi if ! grep -Eq 'rewrite \^/oidc/\(\.\*\)\$ /\$1 break;' "$ROOT_DIR/gateway/nginx.conf"; then mode="unmapped_fail" fi if ! grep -Eq '"url": "<\.\*>://<\.\*>/oidc/oauth2/<\.\*>"' "$ROOT_DIR/docker/ory/oathkeeper/rules.json"; then mode="unmapped_fail" fi if ! grep -Eq '"strip_path_prefix": "/oidc"' "$ROOT_DIR/docker/ory/oathkeeper/rules.json"; then mode="unmapped_fail" fi fi if [[ "$mode" == "unmapped_fail" ]]; then fail "Public/Internal Hydra URL mismatch exists but gateway mapping rules are incomplete" fi OIDC_HYDRA_URL_MATCH_MODE="$mode" } build_allowed_return_urls() { declare -ga KRATOS_ALLOWED_RETURN_URLS=() declare -gA _seen_allowed=() add_allowed_url() { local candidate="$1" candidate="$(trim "$candidate")" [[ -z "$candidate" ]] && return validate_urls "allowed_return_url" "$candidate" candidate="$(canonicalize_url "$candidate")" if [[ -z "${_seen_allowed[$candidate]:-}" ]]; then KRATOS_ALLOWED_RETURN_URLS+=("$candidate") _seen_allowed["$candidate"]=1 fi } add_allowed_with_slash_variant() { local candidate="$1" add_allowed_url "$candidate" local normalized normalized="$(canonicalize_url "$candidate")" if is_origin_like_url "$candidate"; then add_allowed_url "${normalized}/" fi } add_allowed_with_slash_variant "$KRATOS_UI_URL" add_allowed_with_slash_variant "$USERFRONT_URL" for url in "${ADMIN_CALLBACKS[@]}"; do add_allowed_url "$url" done for url in "${DEV_CALLBACKS[@]}"; do add_allowed_url "$url" done for url in "${EXTRA_ALLOWED_RETURNS[@]}"; do add_allowed_url "$url" done if [[ ${#KRATOS_ALLOWED_RETURN_URLS[@]} -eq 0 ]]; then fail "KRATOS allowed return URL list is empty" fi } write_output() { mkdir -p "$OUTPUT_DIR" local admin_csv dev_csv returns_json admin_csv="$(join_csv ADMIN_CALLBACKS)" dev_csv="$(join_csv DEV_CALLBACKS)" returns_json="$(to_json_array KRATOS_ALLOWED_RETURN_URLS)" cat >"$OUTPUT_FILE" </dev/null 2>&1; then warn "docker command is unavailable; runtime hydra verification skipped" return fi if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^ory_hydra$'; then warn "ory_hydra is not running; runtime hydra verification skipped" return fi local admin_info dev_info if ! admin_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint http://hydra:4445 adminfront 2>/dev/null)"; then fail "failed to read hydra client 'adminfront' from running container" fi if ! dev_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint http://hydra:4445 devfront 2>/dev/null)"; then fail "failed to read hydra client 'devfront' from running container" fi for callback in "${ADMIN_CALLBACKS[@]}"; do if ! grep -Fq "$callback" <<<"$admin_info"; then fail "adminfront hydra client does not include callback: $callback" fi done for callback in "${DEV_CALLBACKS[@]}"; do if ! grep -Fq "$callback" <<<"$dev_info"; then fail "devfront hydra client does not include callback: $callback" fi done } run_validation() { validate_dotenv_line_safety "USERFRONT_URL" validate_dotenv_line_safety "BACKEND_URL" validate_dotenv_line_safety "OATHKEEPER_PUBLIC_URL" validate_dotenv_line_safety "HYDRA_PUBLIC_URL" validate_dotenv_line_safety "KRATOS_BROWSER_URL" validate_dotenv_line_safety "KRATOS_UI_URL" validate_dotenv_line_safety "ADMINFRONT_CALLBACK_URLS" validate_dotenv_line_safety "DEVFRONT_CALLBACK_URLS" collect_values validate_callback_group "ADMINFRONT_CALLBACK_URLS" "/auth/callback" "${ADMIN_CALLBACKS[@]}" validate_callback_group "DEVFRONT_CALLBACK_URLS" "/callback" "${DEV_CALLBACKS[@]}" validate_gateway_mapping build_allowed_return_urls } print_summary() { echo "[auth-config] mode: $MODE" echo "[auth-config] hydra_url_match_mode: $OIDC_HYDRA_URL_MATCH_MODE" echo "[auth-config] admin_callbacks: $(join_csv ADMIN_CALLBACKS)" echo "[auth-config] dev_callbacks: $(join_csv DEV_CALLBACKS)" echo "[auth-config] kratos_allowed_return_urls_count: ${#KRATOS_ALLOWED_RETURN_URLS[@]}" if [[ ${#WARNINGS[@]} -gt 0 ]]; then for message in "${WARNINGS[@]}"; do echo "[auth-config] WARN: $message" done fi } case "$MODE" in build) run_validation write_output print_summary echo "[auth-config] wrote: $OUTPUT_FILE" ;; validate) run_validation print_summary ;; verify) run_validation validate_compose_wiring verify_runtime_hydra_clients print_summary echo "[auth-config] compose wiring verified" ;; *) fail "Unsupported mode: $MODE (supported: build|validate|verify)" ;; esac