diff --git a/deploy/scripts/lib.sh b/deploy/scripts/lib.sh new file mode 100644 index 0000000..a54d7e7 --- /dev/null +++ b/deploy/scripts/lib.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Shared helpers for the production deploy scripts. + +step() { printf '\n==> %s\n' "$1"; } +fail() { printf 'error: %s\n' "$1" >&2; exit 1; } + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "$1 is required but not installed." +} + +# env_get — prints the value, empty if the key is absent. +env_get() { + grep -E "^$2=" "$1" 2>/dev/null | tail -n 1 | cut -d= -f2- +} + +# env_set — replaces the key in place, appends if missing. +env_set() { + _file=$1; _key=$2; _value=$3 + _tmp=$(mktemp) + if grep -qE "^$_key=" "$_file"; then + awk -v k="$_key" -v v="$_value" ' + index($0, k "=") == 1 { print k "=" v; next } + { print } + ' "$_file" >"$_tmp" + else + cp "$_file" "$_tmp" + printf '%s=%s\n' "$_key" "$_value" >>"$_tmp" + fi + cat "$_tmp" >"$_file" + rm -f "$_tmp" +} diff --git a/deploy/scripts/migrate.prod.sh b/deploy/scripts/migrate.prod.sh deleted file mode 100644 index 6c41068..0000000 --- a/deploy/scripts/migrate.prod.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/sh -# Applies EF Core migrations to the prod Postgres, from a throwaway SDK -# container attached to the compose network (postgres is not published to the host). -set -eu - -DEPLOY_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" -BACKEND_DIR="$(CDPATH= cd -- "$DEPLOY_DIR/../backend" && pwd)" -ENV_FILE="$DEPLOY_DIR/.env.prod" - -. "$ENV_FILE" - -NETWORK="musify-prod_default" -CONN="Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}" - -docker run --rm \ - --network "$NETWORK" \ - -v "$BACKEND_DIR":/src \ - -w /src/Musify.Infrastructure \ - mcr.microsoft.com/dotnet/sdk:10.0 \ - sh -c "dotnet tool install --global dotnet-ef >/dev/null 2>&1 || true; \ - export PATH=\$PATH:/root/.dotnet/tools; \ - dotnet restore Musify.Infrastructure.csproj; \ - dotnet user-secrets set 'Database:ConnectionString' '$CONN' >/dev/null; \ - dotnet ef database update --context Database" diff --git a/deploy/scripts/migrate.sh b/deploy/scripts/migrate.sh new file mode 100644 index 0000000..0945d49 --- /dev/null +++ b/deploy/scripts/migrate.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Applies the EF Core migrations to the production database from a throwaway SDK +# container attached to the compose network (Postgres is not published to the host). +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +DEPLOY_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +BACKEND_DIR=$(CDPATH= cd -- "$DEPLOY_DIR/../backend" && pwd) +ENV_FILE="$DEPLOY_DIR/.env.prod" +NETWORK=${MUSIFY_NETWORK:-musify-prod_default} + +. "$SCRIPT_DIR/lib.sh" + +CONN="Host=postgres;Port=5432;Database=$(env_get "$ENV_FILE" POSTGRES_DB);Username=$(env_get "$ENV_FILE" POSTGRES_USER);Password=$(env_get "$ENV_FILE" POSTGRES_PASSWORD)" + +# Musify.Infrastructure is both the migrations and the startup project: it owns +# the IDesignTimeDbContextFactory and the EFCore.Design package. +docker run --rm \ + --network "$NETWORK" \ + -v "$BACKEND_DIR":/src \ + -w /src/Musify.Infrastructure \ + -e CONN="$CONN" \ + mcr.microsoft.com/dotnet/sdk:10.0 \ + sh -c 'dotnet tool install --global dotnet-ef >/dev/null 2>&1 || true + export PATH=$PATH:/root/.dotnet/tools + dotnet restore Musify.Infrastructure.csproj + dotnet user-secrets set "Database:ConnectionString" "$CONN" >/dev/null + dotnet ef database update --context Database' diff --git a/deploy/scripts/stream-keys.sh b/deploy/scripts/stream-keys.sh new file mode 100644 index 0000000..34f7ffa --- /dev/null +++ b/deploy/scripts/stream-keys.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Generates the RS256 key pair used to sign (API) and verify (Gateway) stream +# tickets. No-op if both keys already exist. +set -eu + +KEYS_DIR=${1:?usage: stream-keys.sh } +PRIVATE_KEY="$KEYS_DIR/stream_private.pem" +PUBLIC_KEY="$KEYS_DIR/stream_public.pem" + +if [ -f "$PRIVATE_KEY" ] && [ -f "$PUBLIC_KEY" ]; then + echo "Stream-ticket keys already present in $KEYS_DIR" + exit 0 +fi + +mkdir -p "$KEYS_DIR" +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$PRIVATE_KEY" 2>/dev/null +openssl rsa -in "$PRIVATE_KEY" -pubout -out "$PUBLIC_KEY" 2>/dev/null +chmod 644 "$PUBLIC_KEY" +chmod 600 "$PRIVATE_KEY" + +echo "Generated stream-ticket keys in $KEYS_DIR" diff --git a/deploy/up.sh b/deploy/up.sh new file mode 100644 index 0000000..c9fbd05 --- /dev/null +++ b/deploy/up.sh @@ -0,0 +1,103 @@ +#!/bin/sh +# Brings up (or updates) the Musify production stack on the server. +# +# ./up.sh build + start everything, provision Zitadel, migrate +# ./up.sh --no-build same, reusing the images that are already built +# ./up.sh --down stop and remove the containers (volumes are kept) +# +# Everything it needs lives in deploy/.env.prod (see .env.prod.example) and in +# deploy/nginx/certs/ (origin.pem + origin.key). +set -eu + +DEPLOY_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ENV_FILE="$DEPLOY_DIR/.env.prod" +INFRA="postgres zitadel rabbitmq seaweedfs seaweedfs-init jaeger" + +. "$DEPLOY_DIR/scripts/lib.sh" + +require_command docker +require_command openssl + +compose() { + docker compose --env-file "$ENV_FILE" \ + -f "$DEPLOY_DIR/compose.yml" -f "$DEPLOY_DIR/compose.prod.yml" \ + --profile apps "$@" +} + +BUILD=--build +for arg in "$@"; do + case "$arg" in + --no-build) BUILD= ;; + --down) step "Stopping the stack (volumes are kept)"; compose down --remove-orphans; exit 0 ;; + *) fail "unknown option: $arg" ;; + esac +done + +# Preflight ------------------------------------------------------------------ +if [ ! -f "$ENV_FILE" ]; then + cp "$DEPLOY_DIR/.env.prod.example" "$ENV_FILE" + chmod 600 "$ENV_FILE" + fail "created $ENV_FILE from the template — fill it in (domain + secrets) and run again." +fi + +if grep -q 'CHANGE_ME\|example\.com' "$ENV_FILE"; then + fail "$ENV_FILE still contains CHANGE_ME or example.com placeholders." +fi + +for cert in origin.pem origin.key; do + [ -f "$DEPLOY_DIR/nginx/certs/$cert" ] \ + || fail "missing nginx/certs/$cert (Cloudflare Origin Certificate)." +done + +mkdir -p "$DEPLOY_DIR/secrets" "$DEPLOY_DIR/keys" "$DEPLOY_DIR/zitadel/.output" +case "$(env_get "$ENV_FILE" YTDLP_ADDITIONAL_ARGUMENTS)" in + *--cookies*) + [ -f "$DEPLOY_DIR/secrets/youtube_cookies.txt" ] \ + || echo "warning: YTDLP_ADDITIONAL_ARGUMENTS expects secrets/youtube_cookies.txt, which is missing." ;; +esac + +step "Ensuring stream-ticket keys" +sh "$DEPLOY_DIR/scripts/stream-keys.sh" "$DEPLOY_DIR/keys" + +# Infrastructure ------------------------------------------------------------- +step "Starting the infrastructure" +# shellcheck disable=SC2086 +compose up -d $INFRA + +step "Waiting for Postgres" +deadline=$(( $(date +%s) + 120 )) +until [ "$(docker inspect --format '{{.State.Health.Status}}' musify-postgres 2>/dev/null)" = healthy ]; do + [ "$(date +%s)" -lt "$deadline" ] || fail "Postgres did not become healthy in time." + sleep 2 +done + +# nginx has to be up before provisioning: it terminates TLS for the Zitadel +# domain. --no-deps keeps the apps from starting before they have a client id. +step "Starting the edge" +compose up -d --no-deps nginx + +# Zitadel + database --------------------------------------------------------- +sh "$DEPLOY_DIR/zitadel/provision.sh" + +step "Applying EF Core migrations" +sh "$DEPLOY_DIR/scripts/migrate.sh" + +# Applications --------------------------------------------------------------- +step "Starting the applications" +# shellcheck disable=SC2086 +compose up -d $BUILD + +step "Done" +compose ps +cat < +EOF diff --git a/deploy/zitadel/provision.prod.ps1 b/deploy/zitadel/provision.prod.ps1 deleted file mode 100644 index 9053d5b..0000000 --- a/deploy/zitadel/provision.prod.ps1 +++ /dev/null @@ -1,157 +0,0 @@ -#requires -Version 7.0 -<# -.SYNOPSIS - Provisions Zitadel for Musify in production (idempotent). - -.DESCRIPTION - Same idea as provision.ps1 but for the VPS deploy: creates the project, the - API OIDC app (PKCE, JWT) and the Web OIDC app (confidential, JWT), then - wires the values into deploy/.env.prod. Run from deploy/zitadel/ on the VPS - after `docker compose ... up -d` has brought zitadel up. -#> -[CmdletBinding()] -param( - [string]$ProjectName = "Musify", - [string]$ApiAppName = "Musify API", - [string]$WebAppName = "Musify Web" -) - -$ErrorActionPreference = "Stop" -$deployDir = Split-Path $PSScriptRoot -Parent -$envPath = Join-Path $deployDir ".env.prod" -$patFile = Join-Path $PSScriptRoot ".output/admin-sa.pat" - -function Read-DotEnv($path) { - $map = @{} - Get-Content $path | ForEach-Object { - if ($_ -match '^\s*#' -or $_ -notmatch '=') { return } - $k, $v = $_.Split('=', 2) - $map[$k.Trim()] = $v.Trim() - } - return $map -} - -$cfg = Read-DotEnv $envPath -$issuer = "https://$($cfg['AUTH_DOMAIN'])" -$apiUrl = "https://$($cfg['API_DOMAIN'])" -$webUrl = "https://$($cfg['WEB_DOMAIN'])" - -$deadline = (Get-Date).AddMinutes(3) -while (-not (Test-Path $patFile) -and (Get-Date) -lt $deadline) { Start-Sleep -Seconds 3 } -if (-not (Test-Path $patFile)) { throw "PAT not found ($patFile). Check 'docker compose logs zitadel'." } - -$headers = @{ Authorization = "Bearer $((Get-Content $patFile -Raw).Trim())" } -$mgmt = "$issuer/management/v1" - -function Invoke-Zitadel($method, $path, $body) { - $params = @{ Method = $method; Uri = "$mgmt$path"; Headers = $headers; ContentType = "application/json"; TimeoutSec = 20 } - if ($body) { $params.Body = ($body | ConvertTo-Json -Depth 10) } - Invoke-RestMethod @params -SkipCertificateCheck -} - -Write-Host "Waiting for Zitadel at $issuer" -$deadline = (Get-Date).AddMinutes(2) -do { - try { Invoke-RestMethod "$issuer/.well-known/openid-configuration" -TimeoutSec 5 -SkipCertificateCheck | Out-Null; $ready = $true } - catch { $ready = $false; Start-Sleep 3 } -} until ($ready -or (Get-Date) -gt $deadline) -if (-not $ready) { throw "Zitadel did not respond in time." } - -Write-Host "Disabling required Login UI v2 (use built-in /ui/login)" -try { - Invoke-RestMethod -Method PUT -Uri "$issuer/v2/features/instance" -Headers $headers ` - -ContentType "application/json" -Body (@{ loginV2 = @{ required = $false } } | ConvertTo-Json) -TimeoutSec 20 -SkipCertificateCheck | Out-Null -} catch { - Write-Host " warning: could not update loginV2 feature: $($_.Exception.Message)" -} - -Write-Host "Project '$ProjectName'" -$project = (Invoke-Zitadel POST "/projects/_search" @{ - queries = @(@{ nameQuery = @{ name = $ProjectName; method = "TEXT_QUERY_METHOD_EQUALS" } }) -}).result | Where-Object { $_.name -eq $ProjectName } | Select-Object -First 1 - -if ($project) { - $projectId = $project.id - Write-Host " reused (id $projectId)" -} else { - $projectId = (Invoke-Zitadel POST "/projects" @{ name = $ProjectName }).id - Write-Host " created (id $projectId)" -} - -Write-Host "OIDC app '$ApiAppName' (public, PKCE)" -$apiApp = (Invoke-Zitadel POST "/projects/$projectId/apps/_search" @{ - queries = @(@{ nameQuery = @{ name = $ApiAppName; method = "TEXT_QUERY_METHOD_EQUALS" } }) -}).result | Where-Object { $_.name -eq $ApiAppName } | Select-Object -First 1 - -if ($apiApp) { - $apiClientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($apiApp.id)" $null).app.oidcConfig.clientId - Write-Host " reused (clientId $apiClientId)" -} else { - $apiClientId = (Invoke-Zitadel POST "/projects/$projectId/apps/oidc" @{ - name = $ApiAppName - redirectUris = @("$apiUrl/scalar/", "$apiUrl/scalar/oauth2-redirect.html") - postLogoutRedirectUris = @("$apiUrl/scalar/") - responseTypes = @("OIDC_RESPONSE_TYPE_CODE") - grantTypes = @("OIDC_GRANT_TYPE_AUTHORIZATION_CODE") - appType = "OIDC_APP_TYPE_USER_AGENT" - authMethodType = "OIDC_AUTH_METHOD_TYPE_NONE" - version = "OIDC_VERSION_1_0" - devMode = $false - accessTokenType = "OIDC_TOKEN_TYPE_JWT" - accessTokenRoleAssertion = $true - idTokenRoleAssertion = $true - idTokenUserinfoAssertion = $true - }).clientId - Write-Host " created (clientId $apiClientId)" -} - -Write-Host "OIDC app '$WebAppName' (confidential)" -$webApp = (Invoke-Zitadel POST "/projects/$projectId/apps/_search" @{ - queries = @(@{ nameQuery = @{ name = $WebAppName; method = "TEXT_QUERY_METHOD_EQUALS" } }) -}).result | Where-Object { $_.name -eq $WebAppName } | Select-Object -First 1 - -$webClientSecret = $null -if ($webApp) { - $webClientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($webApp.id)" $null).app.oidcConfig.clientId - Write-Host " reused (clientId $webClientId, secret NOT regenerated)" -} else { - $created = Invoke-Zitadel POST "/projects/$projectId/apps/oidc" @{ - name = $WebAppName - redirectUris = @("$webUrl/auth/callback") - postLogoutRedirectUris = @("$webUrl/") - responseTypes = @("OIDC_RESPONSE_TYPE_CODE") - grantTypes = @("OIDC_GRANT_TYPE_AUTHORIZATION_CODE") - appType = "OIDC_APP_TYPE_WEB" - authMethodType = "OIDC_AUTH_METHOD_TYPE_BASIC" - version = "OIDC_VERSION_1_0" - devMode = $false - accessTokenType = "OIDC_TOKEN_TYPE_JWT" - accessTokenRoleAssertion = $true - idTokenRoleAssertion = $true - idTokenUserinfoAssertion = $true - } - $webClientId = $created.clientId - $webClientSecret = $created.clientSecret - Write-Host " created (clientId $webClientId)" -} - -$envLines = Get-Content $envPath -function Set-EnvValue([string[]]$lines, [string]$key, [string]$value) { - if ($lines -match "^\s*$key=") { - return $lines -replace "^\s*$key=.*", "$key=$value" - } - return $lines + "$key=$value" -} -$envLines = Set-EnvValue $envLines "AUTH_CLIENT_ID" $apiClientId -$envLines = Set-EnvValue $envLines "WEB_CLIENT_ID" $webClientId -if ($webClientSecret) { - $envLines = Set-EnvValue $envLines "WEB_CLIENT_SECRET" $webClientSecret -} -Set-Content -Path $envPath -Value $envLines -Encoding utf8 - -Write-Host "`nZitadel provisioned:" -ForegroundColor Green -Write-Host " Project: $ProjectName ($projectId)" -Write-Host " API app: $apiClientId" -Write-Host " Web app: $webClientId" -Write-Host " Issuer: $issuer" -if ($webClientSecret) { Write-Host " Web app secret written to .env.prod (WEB_CLIENT_SECRET)" } diff --git a/deploy/zitadel/provision.sh b/deploy/zitadel/provision.sh new file mode 100644 index 0000000..1ab2890 --- /dev/null +++ b/deploy/zitadel/provision.sh @@ -0,0 +1,145 @@ +#!/bin/sh +# Provisions Zitadel for Musify in production (idempotent). +# +# Creates the Musify project, the API OIDC app (public, PKCE, JWT access token) +# and the web player OIDC app (confidential), then writes AUTH_CLIENT_ID, +# WEB_CLIENT_ID and WEB_CLIENT_SECRET into deploy/.env.prod. Authenticates with +# the service-account PAT that Zitadel writes on init. Re-running reuses the +# existing project and apps. +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +DEPLOY_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +ENV_FILE="$DEPLOY_DIR/.env.prod" +PAT_FILE="$SCRIPT_DIR/.output/admin-sa.pat" + +. "$DEPLOY_DIR/scripts/lib.sh" + +require_command curl +require_command jq + +PROJECT_NAME=${PROJECT_NAME:-Musify} +API_APP_NAME=${API_APP_NAME:-Musify API} +WEB_APP_NAME=${WEB_APP_NAME:-Musify Web} + +ISSUER=$(env_get "$ENV_FILE" PUBLIC_AUTH_URL) +API_URL=$(env_get "$ENV_FILE" PUBLIC_API_URL) +WEB_URL=$(env_get "$ENV_FILE" PUBLIC_WEB_URL) +MGMT="$ISSUER/management/v1" + +step "Provisioning Zitadel at $ISSUER" + +# Zitadel writes the PAT during init; on a cold start it may take a moment. +deadline=$(( $(date +%s) + 180 )) +while [ ! -f "$PAT_FILE" ] && [ "$(date +%s)" -lt "$deadline" ]; do sleep 3; done +[ -f "$PAT_FILE" ] || fail "PAT not found ($PAT_FILE). Check 'docker logs musify-zitadel'." +PAT=$(tr -d '\r\n' <"$PAT_FILE") + +api_call() { + # api_call [json body] + if [ $# -ge 3 ]; then + curl -fsS -X "$1" "$MGMT$2" \ + -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" -d "$3" + else + curl -fsS -X "$1" "$MGMT$2" -H "Authorization: Bearer $PAT" + fi +} + +deadline=$(( $(date +%s) + 120 )) +until curl -fsS "$ISSUER/.well-known/openid-configuration" >/dev/null 2>&1; do + [ "$(date +%s)" -lt "$deadline" ] || fail "Zitadel did not respond at $ISSUER." + sleep 3 +done + +# The images tagged `latest` require the standalone Login UI v2 container, which +# this stack does not run, so OIDC would redirect to /ui/v2/login and 404. +curl -fsS -X PUT "$ISSUER/v2/features/instance" \ + -H "Authorization: Bearer $PAT" -H "Content-Type: application/json" \ + -d '{"loginV2":{"required":false}}' >/dev/null 2>&1 \ + || echo " warning: could not disable the required Login UI v2" + +name_query() { + printf '{"queries":[{"nameQuery":{"name":"%s","method":"TEXT_QUERY_METHOD_EQUALS"}}]}' "$1" +} + +# Project -------------------------------------------------------------------- +PROJECT_ID=$(api_call POST "/projects/_search" "$(name_query "$PROJECT_NAME")" \ + | jq -r --arg n "$PROJECT_NAME" '.result // [] | map(select(.name == $n)) | first | .id // empty') + +if [ -n "$PROJECT_ID" ]; then + echo " project '$PROJECT_NAME' reused (id $PROJECT_ID)" +else + PROJECT_ID=$(api_call POST "/projects" "$(printf '{"name":"%s"}' "$PROJECT_NAME")" | jq -r '.id') + echo " project '$PROJECT_NAME' created (id $PROJECT_ID)" +fi + +find_app() { + api_call POST "/projects/$PROJECT_ID/apps/_search" "$(name_query "$1")" \ + | jq -r --arg n "$1" '.result // [] | map(select(.name == $n)) | first | .id // empty' +} + +# API app: public client used by the Scalar UI to obtain a token ------------- +API_APP_ID=$(find_app "$API_APP_NAME") +if [ -n "$API_APP_ID" ]; then + API_CLIENT_ID=$(api_call GET "/projects/$PROJECT_ID/apps/$API_APP_ID" | jq -r '.app.oidcConfig.clientId') + echo " app '$API_APP_NAME' reused (clientId $API_CLIENT_ID)" +else + API_CLIENT_ID=$(api_call POST "/projects/$PROJECT_ID/apps/oidc" "$(jq -n \ + --arg name "$API_APP_NAME" --arg api "$API_URL" '{ + name: $name, + redirectUris: [($api + "/scalar/"), ($api + "/scalar/oauth2-redirect.html")], + postLogoutRedirectUris: [($api + "/scalar/")], + responseTypes: ["OIDC_RESPONSE_TYPE_CODE"], + grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"], + appType: "OIDC_APP_TYPE_USER_AGENT", + authMethodType: "OIDC_AUTH_METHOD_TYPE_NONE", + version: "OIDC_VERSION_1_0", + devMode: false, + accessTokenType: "OIDC_TOKEN_TYPE_JWT", + accessTokenRoleAssertion: true, + idTokenRoleAssertion: true, + idTokenUserinfoAssertion: true + }')" | jq -r '.clientId') + echo " app '$API_APP_NAME' created (clientId $API_CLIENT_ID)" +fi + +# Web app: confidential client used by the SvelteKit server ------------------ +WEB_APP_ID=$(find_app "$WEB_APP_NAME") +WEB_CLIENT_SECRET=$(env_get "$ENV_FILE" WEB_CLIENT_SECRET) +if [ -n "$WEB_APP_ID" ]; then + WEB_CLIENT_ID=$(api_call GET "/projects/$PROJECT_ID/apps/$WEB_APP_ID" | jq -r '.app.oidcConfig.clientId') + if [ -z "$WEB_CLIENT_SECRET" ]; then + # The secret is only returned once, so regenerate it if we no longer have it. + WEB_CLIENT_SECRET=$(api_call POST "/projects/$PROJECT_ID/apps/$WEB_APP_ID/oidc_config/_generate_client_secret" '{}' \ + | jq -r '.clientSecret') + echo " app '$WEB_APP_NAME' reused (clientId $WEB_CLIENT_ID, secret regenerated)" + else + echo " app '$WEB_APP_NAME' reused (clientId $WEB_CLIENT_ID)" + fi +else + CREATED=$(api_call POST "/projects/$PROJECT_ID/apps/oidc" "$(jq -n \ + --arg name "$WEB_APP_NAME" --arg web "$WEB_URL" '{ + name: $name, + redirectUris: [($web + "/auth/callback")], + postLogoutRedirectUris: [($web + "/")], + responseTypes: ["OIDC_RESPONSE_TYPE_CODE"], + grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"], + appType: "OIDC_APP_TYPE_WEB", + authMethodType: "OIDC_AUTH_METHOD_TYPE_BASIC", + version: "OIDC_VERSION_1_0", + devMode: false, + accessTokenType: "OIDC_TOKEN_TYPE_JWT", + accessTokenRoleAssertion: true, + idTokenRoleAssertion: true, + idTokenUserinfoAssertion: true + }')") + WEB_CLIENT_ID=$(echo "$CREATED" | jq -r '.clientId') + WEB_CLIENT_SECRET=$(echo "$CREATED" | jq -r '.clientSecret') + echo " app '$WEB_APP_NAME' created (clientId $WEB_CLIENT_ID)" +fi + +env_set "$ENV_FILE" AUTH_CLIENT_ID "$API_CLIENT_ID" +env_set "$ENV_FILE" WEB_CLIENT_ID "$WEB_CLIENT_ID" +env_set "$ENV_FILE" WEB_CLIENT_SECRET "$WEB_CLIENT_SECRET" + +echo " wired into $(basename "$ENV_FILE")"