#requires -Version 5.1 <# .SYNOPSIS Provisions Zitadel for Musify in development (idempotent). .DESCRIPTION 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]$ApiAppName = "Musify API (Scalar)", [string]$WebAppName = "Musify Web" ) $ErrorActionPreference = "Stop" $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 $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) 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 logs musify-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 } $deadline = (Get-Date).AddMinutes(2) do { try { Invoke-RestMethod "$issuer/.well-known/openid-configuration" -TimeoutSec 5 | 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." } # 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 } catch { Write-Host " warning: could not disable the required Login UI v2: $($_.Exception.Message)" } # 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 " project '$ProjectName' reused (id $projectId)" } else { $projectId = (Invoke-Zitadel POST "/projects" @{ name = $ProjectName }).id Write-Host " project '$ProjectName' created (id $projectId)" } 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 } # 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 { $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 " app '$ApiAppName' created (clientId $apiClientId)" } # 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" = $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" = "$apiUrl/scalar/" "Authentication:RequireHttpsMetadata" = "false" }.GetEnumerator() | ForEach-Object { dotnet user-secrets set $_.Key $_.Value --project $apiProject | Out-Null } # 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 } Write-Host " wired into deploy/.env, Musify.Api user-secrets and web-player/.env"