feat(deploy): provision the web player client in development too

The web player's OIDC app had to be created by hand in the Zitadel console and
its client id copied into web-player/.env. provision.ps1 now creates both apps,
regenerates the client secret when it is no longer known, and wires the values
into deploy/.env, Musify.Api user-secrets and web-player/.env.

up.ps1 follows the new compose files, gains -Apps and -Tools for the two
profiles, generates the keys before starting rather than after, and no longer
swallows a provisioning failure as a warning.
This commit is contained in:
2026-07-20 15:14:00 +02:00
parent 5a7755efa9
commit d683e22cb6
3 changed files with 184 additions and 102 deletions
+108 -60
View File
@@ -1,31 +1,40 @@
#requires -Version 5.1
<#
.SYNOPSIS
Provisions Zitadel for Musify (idempotent).
Provisions Zitadel for Musify in development (idempotent).
.DESCRIPTION
Creates the project and the OIDC app (PKCE, JWT access token) and wires the
generated ClientId into Musify.Api user-secrets and deploy/.env (AUTH_CLIENT_ID).
Uses the service-account PAT that Zitadel writes on init (zitadel/.output/admin-sa.pat).
Re-running reuses the existing project/app and refreshes the wired values.
Creates the Musify project, the API OIDC app (public, PKCE, JWT access
token) and the web player OIDC app (confidential), then wires the resulting
client ids into deploy/.env, Musify.Api user-secrets and web-player/.env.
Authenticates with the service-account PAT that Zitadel writes on init
(zitadel/.output/admin-sa.pat). Re-running reuses the existing project and
apps and just refreshes the wired values.
#>
[CmdletBinding()]
param(
[string]$ProjectName = "Musify",
[string]$AppName = "Musify API (Scalar)"
[string]$ApiAppName = "Musify API (Scalar)",
[string]$WebAppName = "Musify Web"
)
$ErrorActionPreference = "Stop"
$deployDir = Split-Path $PSScriptRoot -Parent
$apiProject = Resolve-Path (Join-Path $deployDir "..\backend\Musify.Api")
$deployDir = Split-Path $PSScriptRoot -Parent
$repoDir = Split-Path $deployDir -Parent
$apiProject = Join-Path $repoDir "backend\Musify.Api"
$webEnvFile = Join-Path $repoDir "web-player\.env"
$envFile = Join-Path $deployDir ".env"
$patFile = Join-Path $PSScriptRoot ".output\admin-sa.pat"
. (Join-Path $deployDir "scripts\common.ps1")
$cfg = Read-DotEnv (Join-Path $deployDir ".env")
$issuer = "http://$($cfg['ZITADEL_EXTERNAL_DOMAIN']):$($cfg['ZITADEL_EXTERNAL_PORT'])"
$apiBase = "http://localhost:5111"
$scalarRedirect = "$apiBase/scalar/"
$cfg = Read-DotEnv $envFile
$issuer = $cfg['PUBLIC_AUTH_URL']
$apiUrl = $cfg['PUBLIC_API_URL']
$webUrl = $cfg['PUBLIC_WEB_URL']
$viteUrl = "http://localhost:5173" # `npm run dev` in web-player/
Write-Step "Provisioning Zitadel at $issuer"
# Zitadel writes the PAT during init; on a cold start it may take a moment.
$deadline = (Get-Date).AddMinutes(3)
@@ -41,7 +50,6 @@ function Invoke-Zitadel($method, $path, $body) {
Invoke-RestMethod @params
}
Write-Step "Waiting for Zitadel at $issuer"
$deadline = (Get-Date).AddMinutes(2)
do {
try { Invoke-RestMethod "$issuer/.well-known/openid-configuration" -TimeoutSec 5 | Out-Null; $ready = $true }
@@ -49,86 +57,126 @@ do {
} until ($ready -or (Get-Date) -gt $deadline)
if (-not $ready) { throw "Zitadel did not respond in time." }
# Login UI (idempotent) ------------------------------------------------------
# The 'latest' Zitadel image requires the Login UI v2 (a separate 'login'
# container that this stack does not run), so OIDC would redirect to
# /ui/v2/login and 404. Disable the requirement to use the built-in v1 login.
Write-Step "Disabling required Login UI v2 (use built-in /ui/login)"
# 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.
# Opting out keeps the built-in v1 login.
try {
Invoke-RestMethod -Method PUT -Uri "$issuer/v2/features/instance" -Headers $headers `
-ContentType "application/json" -Body (@{ loginV2 = @{ required = $false } } | ConvertTo-Json) -TimeoutSec 20 | Out-Null
Write-Host " loginV2.required = false"
} catch {
Write-Host " warning: could not update loginV2 feature: $($_.Exception.Message)"
Write-Host " warning: could not disable the required Login UI v2: $($_.Exception.Message)"
}
# Project (idempotent) -------------------------------------------------------
Write-Step "Project '$ProjectName'"
# Project --------------------------------------------------------------------
$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)"
Write-Host " project '$ProjectName' reused (id $projectId)"
} else {
$projectId = (Invoke-Zitadel POST "/projects" @{ name = $ProjectName }).id
Write-Host " created (id $projectId)"
Write-Host " project '$ProjectName' created (id $projectId)"
}
# OIDC app (idempotent) ------------------------------------------------------
Write-Step "OIDC app '$AppName'"
$app = (Invoke-Zitadel POST "/projects/$projectId/apps/_search" @{
queries = @(@{ nameQuery = @{ name = $AppName; method = "TEXT_QUERY_METHOD_EQUALS" } })
}).result | Where-Object { $_.name -eq $AppName } | Select-Object -First 1
function Get-ZitadelApp($name) {
(Invoke-Zitadel POST "/projects/$projectId/apps/_search" @{
queries = @(@{ nameQuery = @{ name = $name; method = "TEXT_QUERY_METHOD_EQUALS" } })
}).result | Where-Object { $_.name -eq $name } | Select-Object -First 1
}
if ($app) {
$clientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($app.id)" $null).app.oidcConfig.clientId
Write-Host " reused (clientId $clientId)"
# API app: public client used by the Scalar UI to obtain a token -------------
$apiApp = Get-ZitadelApp $ApiAppName
if ($apiApp) {
$apiClientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($apiApp.id)" $null).app.oidcConfig.clientId
Write-Host " app '$ApiAppName' reused (clientId $apiClientId)"
} else {
$clientId = (Invoke-Zitadel POST "/projects/$projectId/apps/oidc" @{
name = $AppName
redirectUris = @($scalarRedirect, "$apiBase/scalar/oauth2-redirect.html")
postLogoutRedirectUris = @($scalarRedirect)
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 = $true # allow http redirect URIs
accessTokenType = "OIDC_TOKEN_TYPE_JWT" # required so JwtBearer can validate
$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 = $true # allow http redirect URIs
accessTokenType = "OIDC_TOKEN_TYPE_JWT" # required so JwtBearer can validate it
accessTokenRoleAssertion = $true
idTokenRoleAssertion = $true
idTokenUserinfoAssertion = $true
}).clientId
Write-Host " created (clientId $clientId)"
Write-Host " app '$ApiAppName' created (clientId $apiClientId)"
}
# Wire the config: user-secrets (local runs) + .env (docker runs) -------------
Write-Step "Wiring Authentication into Musify.Api user-secrets"
# Web app: confidential client used by the SvelteKit server ------------------
$webApp = Get-ZitadelApp $WebAppName
$webSecret = $cfg['WEB_CLIENT_SECRET']
if ($webApp) {
$webClientId = (Invoke-Zitadel GET "/projects/$projectId/apps/$($webApp.id)" $null).app.oidcConfig.clientId
# The secret is only returned once, so regenerate it if we no longer have it.
if (-not $webSecret) {
$webSecret = (Invoke-Zitadel POST "/projects/$projectId/apps/$($webApp.id)/oidc_config/_generate_client_secret" @{}).clientSecret
Write-Host " app '$WebAppName' reused (clientId $webClientId, secret regenerated)"
} else {
Write-Host " app '$WebAppName' reused (clientId $webClientId)"
}
} else {
$created = Invoke-Zitadel POST "/projects/$projectId/apps/oidc" @{
name = $WebAppName
redirectUris = @("$webUrl/auth/callback", "$viteUrl/auth/callback")
postLogoutRedirectUris = @("$webUrl/", "$viteUrl/")
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 = $true
accessTokenType = "OIDC_TOKEN_TYPE_JWT"
accessTokenRoleAssertion = $true
idTokenRoleAssertion = $true
idTokenUserinfoAssertion = $true
}
$webClientId = $created.clientId
$webSecret = $created.clientSecret
Write-Host " app '$WebAppName' created (clientId $webClientId)"
}
# Wire the values ------------------------------------------------------------
Set-DotEnvValue $envFile "AUTH_CLIENT_ID" $apiClientId
Set-DotEnvValue $envFile "WEB_CLIENT_ID" $webClientId
Set-DotEnvValue $envFile "WEB_CLIENT_SECRET" $webSecret
# Musify.Api reads these from user-secrets when it runs outside Docker.
@{
"Authentication:ClientId" = $clientId
"Authentication:AudienceAddress" = $clientId
"Authentication:ClientId" = $apiClientId
"Authentication:AudienceAddress" = $apiClientId
"Authentication:IssuerAddress" = $issuer
"Authentication:MetadataAddress" = "$issuer/.well-known/openid-configuration"
"Authentication:AuthorizationEndpoint" = "$issuer/oauth/v2/authorize?prompt=login"
"Authentication:TokenEndpoint" = "$issuer/oauth/v2/token"
"Authentication:ScalarRedirectUri" = $scalarRedirect
"Authentication:ScalarRedirectUri" = "$apiUrl/scalar/"
"Authentication:RequireHttpsMetadata" = "false"
}.GetEnumerator() | ForEach-Object {
dotnet user-secrets set $_.Key $_.Value --project $apiProject | Out-Null
}
$envPath = Join-Path $deployDir ".env"
$envLines = Get-Content $envPath
if ($envLines -match '^\s*AUTH_CLIENT_ID=') {
$envLines = $envLines -replace '^\s*AUTH_CLIENT_ID=.*', "AUTH_CLIENT_ID=$clientId"
} else {
$envLines += "AUTH_CLIENT_ID=$clientId"
# web-player/.env is what `npm run dev` reads (the container gets its config
# from compose instead).
if (-not (Test-Path $webEnvFile)) {
Copy-Item (Join-Path $repoDir "web-player\.env.example") $webEnvFile
}
@{
ZITADEL_ISSUER = $issuer
ZITADEL_CLIENT_ID = $webClientId
ZITADEL_CLIENT_SECRET = $webSecret
AUTH_REDIRECT_URI = "$viteUrl/auth/callback"
AUTH_POST_LOGOUT_URI = "$viteUrl/"
SESSION_SECRET = $cfg['SESSION_SECRET']
API_BASE_URL = $apiUrl
}.GetEnumerator() | ForEach-Object {
Set-DotEnvValue $webEnvFile $_.Key $_.Value
}
Set-Content -Path $envPath -Value $envLines -Encoding utf8
Write-Host "`nZitadel provisioned:" -ForegroundColor Green
Write-Host " Project: $ProjectName ($projectId)"
Write-Host " ClientId: $clientId"
Write-Host " Issuer: $issuer"
Write-Host " wired into deploy/.env, Musify.Api user-secrets and web-player/.env"