Skip to content

Commit a58a99d

Browse files
DonLeeTejasri-Microsoft
authored andcommitted
feat(deploy): automate Entra ID app registration + EasyAuth config
Adds infra/scripts/configure_auth.{sh,ps1} invoked at the end of the azd postprovision hook so 'azd up' produces a fully authenticated deployment without the manual steps in docs/ConfigureAppAuthentication.md. Idempotent (reuses app regs persisted in azd env by appId, reuses existing container app secrets) and skippable via AZURE_SKIP_AUTH_SETUP. Covers: - Web + API app registrations with redirect URIs, exposed scopes, Graph User.Read, ID/access token issuance - Best-effort admin consent with clear manual-action message on failure - Container App EasyAuth Microsoft provider on both apps - API authConfig allowedApplications = Web client id - Web container env vars APP_WEB_CLIENT_ID / APP_WEB_SCOPE / APP_API_SCOPE - Final lockdown: Web -> RedirectToLoginPage, API -> Return401
1 parent 450e26d commit a58a99d

4 files changed

Lines changed: 658 additions & 0 deletions

File tree

infra/scripts/configure_auth.ps1

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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

Comments
 (0)