|
| 1 | +# Automates the app registration + EasyAuth configuration that is otherwise |
| 2 | +# performed manually per docs/ConfigureAppAuthentication.md. |
| 3 | +# |
| 4 | +# Idempotent: safe to re-run. Reuses existing app registrations and container |
| 5 | +# app secrets where possible. |
| 6 | +# |
| 7 | +# Skip with: azd env set AZURE_SKIP_AUTH_SETUP true |
| 8 | + |
| 9 | +$ErrorActionPreference = "Stop" |
| 10 | + |
| 11 | +if ($env:AZURE_SKIP_AUTH_SETUP -eq "true") { |
| 12 | + Write-Host "⏭️ AZURE_SKIP_AUTH_SETUP=true — skipping auth configuration." |
| 13 | + return |
| 14 | +} |
| 15 | + |
| 16 | +Write-Host "" |
| 17 | +Write-Host "============================================================" |
| 18 | +Write-Host "🔐 Configuring Entra ID authentication (Web + API)" |
| 19 | +Write-Host "============================================================" |
| 20 | + |
| 21 | +function Azd-Get($key, $default = "") { |
| 22 | + try { return (azd env get-value $key 2>$null) } catch { return $default } |
| 23 | +} |
| 24 | + |
| 25 | +$EnvName = Azd-Get "AZURE_ENV_NAME" "cps" |
| 26 | +$ResourceGroup = Azd-Get "AZURE_RESOURCE_GROUP" |
| 27 | +$SubscriptionId = Azd-Get "AZURE_SUBSCRIPTION_ID" |
| 28 | +$TenantId = Azd-Get "AZURE_TENANT_ID" |
| 29 | +if (-not $TenantId) { $TenantId = (az account show --query tenantId -o tsv) } |
| 30 | + |
| 31 | +$WebName = Azd-Get "CONTAINER_WEB_APP_NAME" |
| 32 | +$WebFqdn = Azd-Get "CONTAINER_WEB_APP_FQDN" |
| 33 | +$ApiName = Azd-Get "CONTAINER_API_APP_NAME" |
| 34 | +$ApiFqdn = Azd-Get "CONTAINER_API_APP_FQDN" |
| 35 | + |
| 36 | +$WebDisplayName = "$EnvName-web-app" |
| 37 | +$ApiDisplayName = "$EnvName-api-app" |
| 38 | + |
| 39 | +$WebUrl = "https://$WebFqdn" |
| 40 | +$ApiUrl = "https://$ApiFqdn" |
| 41 | +$WebAuthCallback = "$WebUrl/.auth/login/aad/callback" |
| 42 | +$ApiAuthCallback = "$ApiUrl/.auth/login/aad/callback" |
| 43 | + |
| 44 | +$GraphAppId = "00000003-0000-0000-c000-000000000000" |
| 45 | +$GraphUserReadScopeId = "e1fe6dd8-ba31-4d61-89e7-88639da4683d" |
| 46 | +$CaSecretName = "microsoft-provider-authentication-secret" |
| 47 | + |
| 48 | +function Retry($Block, $Max = 6, $Delay = 10) { |
| 49 | + for ($i = 1; $i -le $Max; $i++) { |
| 50 | + try { return & $Block } catch { |
| 51 | + if ($i -eq $Max) { throw } |
| 52 | + Write-Host " ↻ retry $i/$Max after ${Delay}s..." |
| 53 | + Start-Sleep -Seconds $Delay |
| 54 | + } |
| 55 | + } |
| 56 | +} |
| 57 | + |
| 58 | +function Find-AppIdByEnvOrName($EnvKey, $DisplayName) { |
| 59 | + $id = Azd-Get $EnvKey "" |
| 60 | + if ($id) { |
| 61 | + $exists = az ad app show --id $id 2>$null |
| 62 | + if ($LASTEXITCODE -eq 0) { return $id } |
| 63 | + } |
| 64 | + $ids = az ad app list --display-name $DisplayName --query "[].appId" -o tsv |
| 65 | + $arr = @($ids -split "`n" | Where-Object { $_ }) |
| 66 | + if ($arr.Count -gt 1) { throw "Multiple app registrations with displayName '$DisplayName'. Clean up or set $EnvKey manually." } |
| 67 | + if ($arr.Count -eq 1) { return $arr[0] } |
| 68 | + return "" |
| 69 | +} |
| 70 | + |
| 71 | +# --- Step 1: API app registration -------------------------------------------- |
| 72 | +Write-Host "" |
| 73 | +Write-Host "➡️ Step 1/6: API app registration ($ApiDisplayName)" |
| 74 | + |
| 75 | +$ApiClientId = Find-AppIdByEnvOrName "AZURE_AUTH_API_CLIENT_ID" $ApiDisplayName |
| 76 | +if (-not $ApiClientId) { |
| 77 | + $ApiClientId = az ad app create --display-name $ApiDisplayName ` |
| 78 | + --sign-in-audience AzureADMyOrg ` |
| 79 | + --web-redirect-uris $ApiAuthCallback ` |
| 80 | + --enable-id-token-issuance true ` |
| 81 | + --query appId -o tsv |
| 82 | + Write-Host " ✓ Created API app: $ApiClientId" |
| 83 | +} else { |
| 84 | + Write-Host " ↺ Reusing API app: $ApiClientId" |
| 85 | + Retry { az ad app update --id $ApiClientId --web-redirect-uris $ApiAuthCallback --enable-id-token-issuance true | Out-Null } |
| 86 | +} |
| 87 | +azd env set AZURE_AUTH_API_CLIENT_ID $ApiClientId | Out-Null |
| 88 | + |
| 89 | +Retry { |
| 90 | + az ad sp show --id $ApiClientId 2>$null | Out-Null |
| 91 | + if ($LASTEXITCODE -ne 0) { az ad sp create --id $ApiClientId | Out-Null } |
| 92 | +} |
| 93 | + |
| 94 | +$ApiAppObjectId = az ad app show --id $ApiClientId --query id -o tsv |
| 95 | +$ApiIdentifierUri = "api://$ApiClientId" |
| 96 | + |
| 97 | +$ApiScopeId = az ad app show --id $ApiClientId --query "api.oauth2PermissionScopes[?value=='user_impersonation'].id | [0]" -o tsv |
| 98 | +if (-not $ApiScopeId -or $ApiScopeId -eq "null") { |
| 99 | + $ApiScopeId = [guid]::NewGuid().ToString() |
| 100 | + $patch = @{ |
| 101 | + identifierUris = @($ApiIdentifierUri) |
| 102 | + api = @{ |
| 103 | + oauth2PermissionScopes = @(@{ |
| 104 | + id = $ApiScopeId |
| 105 | + adminConsentDescription = "Allow the application to access the API on behalf of the signed-in user." |
| 106 | + adminConsentDisplayName = "Access API as user" |
| 107 | + userConsentDescription = "Allow the application to access the API on your behalf." |
| 108 | + userConsentDisplayName = "Access API" |
| 109 | + value = "user_impersonation" |
| 110 | + type = "User" |
| 111 | + isEnabled = $true |
| 112 | + }) |
| 113 | + } |
| 114 | + } | ConvertTo-Json -Depth 10 |
| 115 | + $tmp = New-TemporaryFile |
| 116 | + $patch | Out-File -FilePath $tmp -Encoding utf8 |
| 117 | + Retry { az rest --method PATCH --url "https://graph.microsoft.com/v1.0/applications/$ApiAppObjectId" --headers "Content-Type=application/json" --body "@$tmp" | Out-Null } |
| 118 | + Remove-Item $tmp |
| 119 | + Write-Host " ✓ Exposed scope api://$ApiClientId/user_impersonation" |
| 120 | +} else { |
| 121 | + Write-Host " ↺ API scope already exposed" |
| 122 | +} |
| 123 | +$ApiScopeValue = "api://$ApiClientId/user_impersonation" |
| 124 | + |
| 125 | +# --- Step 2: Web app registration -------------------------------------------- |
| 126 | +Write-Host "" |
| 127 | +Write-Host "➡️ Step 2/6: Web app registration ($WebDisplayName)" |
| 128 | + |
| 129 | +$WebClientId = Find-AppIdByEnvOrName "AZURE_AUTH_WEB_CLIENT_ID" $WebDisplayName |
| 130 | +if (-not $WebClientId) { |
| 131 | + $WebClientId = az ad app create --display-name $WebDisplayName ` |
| 132 | + --sign-in-audience AzureADMyOrg ` |
| 133 | + --web-redirect-uris $WebAuthCallback ` |
| 134 | + --enable-id-token-issuance true ` |
| 135 | + --enable-access-token-issuance true ` |
| 136 | + --query appId -o tsv |
| 137 | + Write-Host " ✓ Created Web app: $WebClientId" |
| 138 | +} else { |
| 139 | + Write-Host " ↺ Reusing Web app: $WebClientId" |
| 140 | + Retry { az ad app update --id $WebClientId --web-redirect-uris $WebAuthCallback --enable-id-token-issuance true --enable-access-token-issuance true | Out-Null } |
| 141 | +} |
| 142 | +azd env set AZURE_AUTH_WEB_CLIENT_ID $WebClientId | Out-Null |
| 143 | + |
| 144 | +Retry { |
| 145 | + az ad sp show --id $WebClientId 2>$null | Out-Null |
| 146 | + if ($LASTEXITCODE -ne 0) { az ad sp create --id $WebClientId | Out-Null } |
| 147 | +} |
| 148 | + |
| 149 | +$WebAppObjectId = az ad app show --id $WebClientId --query id -o tsv |
| 150 | +$WebIdentifierUri = "api://$WebClientId" |
| 151 | + |
| 152 | +$WebScopeId = az ad app show --id $WebClientId --query "api.oauth2PermissionScopes[?value=='user_impersonation'].id | [0]" -o tsv |
| 153 | +if (-not $WebScopeId -or $WebScopeId -eq "null") { $WebScopeId = [guid]::NewGuid().ToString() } |
| 154 | + |
| 155 | +$webPatch = @{ |
| 156 | + identifierUris = @($WebIdentifierUri) |
| 157 | + spa = @{ redirectUris = @($WebUrl, "$WebUrl/") } |
| 158 | + api = @{ |
| 159 | + knownClientApplications = @() |
| 160 | + oauth2PermissionScopes = @(@{ |
| 161 | + id = $WebScopeId |
| 162 | + adminConsentDescription = "Allow the app to sign in the user." |
| 163 | + adminConsentDisplayName = "Sign in" |
| 164 | + userConsentDescription = "Allow the app to sign you in." |
| 165 | + userConsentDisplayName = "Sign in" |
| 166 | + value = "user_impersonation" |
| 167 | + type = "User" |
| 168 | + isEnabled = $true |
| 169 | + }) |
| 170 | + } |
| 171 | + requiredResourceAccess = @( |
| 172 | + @{ resourceAppId = $ApiClientId; resourceAccess = @(@{ id = $ApiScopeId; type = "Scope" }) }, |
| 173 | + @{ resourceAppId = $GraphAppId; resourceAccess = @(@{ id = $GraphUserReadScopeId; type = "Scope" }) } |
| 174 | + ) |
| 175 | +} | ConvertTo-Json -Depth 10 |
| 176 | +$tmp = New-TemporaryFile |
| 177 | +$webPatch | Out-File -FilePath $tmp -Encoding utf8 |
| 178 | +Retry { az rest --method PATCH --url "https://graph.microsoft.com/v1.0/applications/$WebAppObjectId" --headers "Content-Type=application/json" --body "@$tmp" | Out-Null } |
| 179 | +Remove-Item $tmp |
| 180 | +Write-Host " ✓ Web SPA redirect, scope, and required permissions configured" |
| 181 | +$WebScopeValue = "api://$WebClientId/user_impersonation" |
| 182 | + |
| 183 | +# --- Step 3: Admin consent --------------------------------------------------- |
| 184 | +Write-Host "" |
| 185 | +Write-Host "➡️ Step 3/6: Granting admin consent" |
| 186 | +$ConsentOk = $true |
| 187 | +try { |
| 188 | + Retry { az ad app permission admin-consent --id $WebClientId | Out-Null } |
| 189 | + Write-Host " ✓ Admin consent granted" |
| 190 | +} catch { |
| 191 | + $ConsentOk = $false |
| 192 | + Write-Host " ⚠️ Admin consent failed. Sign-in may fail until a tenant admin runs:" |
| 193 | + Write-Host " az ad app permission admin-consent --id $WebClientId" |
| 194 | + Write-Host " Or: https://login.microsoftonline.com/$TenantId/adminconsent?client_id=$WebClientId" |
| 195 | +} |
| 196 | + |
| 197 | +# --- Step 4: Container App secrets ------------------------------------------ |
| 198 | +Write-Host "" |
| 199 | +Write-Host "➡️ Step 4/6: Client secrets" |
| 200 | + |
| 201 | +function Ensure-CaSecret($AppId, $CaName) { |
| 202 | + $existing = az containerapp secret list -n $CaName -g $ResourceGroup --query "[?name=='$CaSecretName'].name | [0]" -o tsv |
| 203 | + if ($existing -and $existing -ne "null") { |
| 204 | + Write-Host " ↺ Container App '$CaName' already has '$CaSecretName' — not rotating." |
| 205 | + return |
| 206 | + } |
| 207 | + $secret = az ad app credential reset --id $AppId --append --display-name "containerapp-easyauth" --years 2 --query password -o tsv |
| 208 | + az containerapp secret set -n $CaName -g $ResourceGroup --secrets "$CaSecretName=$secret" --output none |
| 209 | + Write-Host " ✓ Stored new client secret in '$CaName'" |
| 210 | +} |
| 211 | + |
| 212 | +Ensure-CaSecret $ApiClientId $ApiName |
| 213 | +Ensure-CaSecret $WebClientId $WebName |
| 214 | + |
| 215 | +# --- Step 5: Enable EasyAuth ------------------------------------------------ |
| 216 | +Write-Host "" |
| 217 | +Write-Host "➡️ Step 5/6: Enabling EasyAuth on Web + API container apps" |
| 218 | +$Issuer = "https://login.microsoftonline.com/$TenantId/v2.0" |
| 219 | + |
| 220 | +function Configure-EasyAuth($CaName, $ClientId, $Audience) { |
| 221 | + az containerapp auth microsoft update -n $CaName -g $ResourceGroup ` |
| 222 | + --client-id $ClientId ` |
| 223 | + --client-secret-name $CaSecretName ` |
| 224 | + --tenant-id $TenantId ` |
| 225 | + --issuer $Issuer ` |
| 226 | + --allowed-token-audiences $Audience ` |
| 227 | + --yes --output none |
| 228 | +} |
| 229 | + |
| 230 | +Configure-EasyAuth $ApiName $ApiClientId $ApiIdentifierUri |
| 231 | +Configure-EasyAuth $WebName $WebClientId $WebIdentifierUri |
| 232 | + |
| 233 | +az containerapp auth update -n $WebName -g $ResourceGroup --enabled true --unauthenticated-client-action AllowAnonymous --output none |
| 234 | +az containerapp auth update -n $ApiName -g $ResourceGroup --enabled true --unauthenticated-client-action AllowAnonymous --output none |
| 235 | +Write-Host " ✓ EasyAuth providers configured" |
| 236 | + |
| 237 | +# --- Step 6: Env vars + allowedApplications + lockdown ---------------------- |
| 238 | +Write-Host "" |
| 239 | +Write-Host "➡️ Step 6/6: Wiring env vars and caller allowlist" |
| 240 | + |
| 241 | +az containerapp update -n $WebName -g $ResourceGroup ` |
| 242 | + --set-env-vars "APP_WEB_CLIENT_ID=$WebClientId" "APP_WEB_SCOPE=$WebScopeValue" "APP_API_SCOPE=$ApiScopeValue" "APP_AUTH_ENABLED=true" ` |
| 243 | + --output none |
| 244 | +Write-Host " ✓ Web env vars updated" |
| 245 | + |
| 246 | +$authUrl = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.App/containerApps/$ApiName/authConfigs/current?api-version=2024-03-01" |
| 247 | +$current = az rest --method get --url $authUrl | ConvertFrom-Json |
| 248 | +if (-not $current.properties) { $current | Add-Member -MemberType NoteProperty -Name properties -Value (@{}) } |
| 249 | +if (-not $current.properties.identityProviders) { $current.properties | Add-Member -MemberType NoteProperty -Name identityProviders -Value (@{}) } |
| 250 | +if (-not $current.properties.identityProviders.azureActiveDirectory) { $current.properties.identityProviders | Add-Member -MemberType NoteProperty -Name azureActiveDirectory -Value (@{}) } |
| 251 | +$aad = $current.properties.identityProviders.azureActiveDirectory |
| 252 | +if (-not $aad.validation) { $aad | Add-Member -MemberType NoteProperty -Name validation -Value (@{}) } |
| 253 | +if (-not $aad.validation.defaultAuthorizationPolicy) { $aad.validation | Add-Member -MemberType NoteProperty -Name defaultAuthorizationPolicy -Value (@{}) } |
| 254 | +$policy = $aad.validation.defaultAuthorizationPolicy |
| 255 | +$allowed = @() |
| 256 | +if ($policy.allowedApplications) { $allowed = @($policy.allowedApplications) } |
| 257 | +if ($allowed -notcontains $WebClientId) { $allowed += $WebClientId } |
| 258 | +$policy.allowedApplications = $allowed |
| 259 | + |
| 260 | +$tmp = New-TemporaryFile |
| 261 | +$current | ConvertTo-Json -Depth 20 | Out-File -FilePath $tmp -Encoding utf8 |
| 262 | +Retry { az rest --method put --url $authUrl --headers "Content-Type=application/json" --body "@$tmp" | Out-Null } |
| 263 | +Remove-Item $tmp |
| 264 | +Write-Host " ✓ API 'allowed applications' includes Web client id" |
| 265 | + |
| 266 | +az containerapp auth update -n $WebName -g $ResourceGroup --unauthenticated-client-action RedirectToLoginPage --output none |
| 267 | +az containerapp auth update -n $ApiName -g $ResourceGroup --unauthenticated-client-action Return401 --output none |
| 268 | +Write-Host " ✓ Unauthenticated requests: Web → login, API → 401" |
| 269 | + |
| 270 | +Write-Host "" |
| 271 | +Write-Host "============================================================" |
| 272 | +Write-Host "🔐 Auth configuration complete." |
| 273 | +Write-Host " Web client id : $WebClientId" |
| 274 | +Write-Host " API client id : $ApiClientId" |
| 275 | +Write-Host " Web scope : $WebScopeValue" |
| 276 | +Write-Host " API scope : $ApiScopeValue" |
| 277 | +if (-not $ConsentOk) { Write-Host " ⚠️ Admin consent pending — see step 3 above." } |
| 278 | +Write-Host " Note: EasyAuth rollout can take up to 10 minutes." |
| 279 | +Write-Host "============================================================" |
0 commit comments