From 725b5dd98bc6d5ffa3421f018c2c5c3e37fe5932 Mon Sep 17 00:00:00 2001 From: Don Lee Date: Fri, 24 Apr 2026 13:03:23 -0700 Subject: [PATCH 1/7] 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 --- infra/scripts/configure_auth.ps1 | 279 +++++++++++++++++++++++ infra/scripts/configure_auth.sh | 365 ++++++++++++++++++++++++++++++ infra/scripts/post_deployment.ps1 | 6 + infra/scripts/post_deployment.sh | 8 + 4 files changed, 658 insertions(+) create mode 100644 infra/scripts/configure_auth.ps1 create mode 100755 infra/scripts/configure_auth.sh diff --git a/infra/scripts/configure_auth.ps1 b/infra/scripts/configure_auth.ps1 new file mode 100644 index 00000000..ec8a0a36 --- /dev/null +++ b/infra/scripts/configure_auth.ps1 @@ -0,0 +1,279 @@ +# Automates the app registration + EasyAuth configuration that is otherwise +# performed manually per docs/ConfigureAppAuthentication.md. +# +# Idempotent: safe to re-run. Reuses existing app registrations and container +# app secrets where possible. +# +# Skip with: azd env set AZURE_SKIP_AUTH_SETUP true + +$ErrorActionPreference = "Stop" + +if ($env:AZURE_SKIP_AUTH_SETUP -eq "true") { + Write-Host "โญ๏ธ AZURE_SKIP_AUTH_SETUP=true โ€” skipping auth configuration." + return +} + +Write-Host "" +Write-Host "============================================================" +Write-Host "๐Ÿ” Configuring Entra ID authentication (Web + API)" +Write-Host "============================================================" + +function Azd-Get($key, $default = "") { + try { return (azd env get-value $key 2>$null) } catch { return $default } +} + +$EnvName = Azd-Get "AZURE_ENV_NAME" "cps" +$ResourceGroup = Azd-Get "AZURE_RESOURCE_GROUP" +$SubscriptionId = Azd-Get "AZURE_SUBSCRIPTION_ID" +$TenantId = Azd-Get "AZURE_TENANT_ID" +if (-not $TenantId) { $TenantId = (az account show --query tenantId -o tsv) } + +$WebName = Azd-Get "CONTAINER_WEB_APP_NAME" +$WebFqdn = Azd-Get "CONTAINER_WEB_APP_FQDN" +$ApiName = Azd-Get "CONTAINER_API_APP_NAME" +$ApiFqdn = Azd-Get "CONTAINER_API_APP_FQDN" + +$WebDisplayName = "$EnvName-web-app" +$ApiDisplayName = "$EnvName-api-app" + +$WebUrl = "https://$WebFqdn" +$ApiUrl = "https://$ApiFqdn" +$WebAuthCallback = "$WebUrl/.auth/login/aad/callback" +$ApiAuthCallback = "$ApiUrl/.auth/login/aad/callback" + +$GraphAppId = "00000003-0000-0000-c000-000000000000" +$GraphUserReadScopeId = "e1fe6dd8-ba31-4d61-89e7-88639da4683d" +$CaSecretName = "microsoft-provider-authentication-secret" + +function Retry($Block, $Max = 6, $Delay = 10) { + for ($i = 1; $i -le $Max; $i++) { + try { return & $Block } catch { + if ($i -eq $Max) { throw } + Write-Host " โ†ป retry $i/$Max after ${Delay}s..." + Start-Sleep -Seconds $Delay + } + } +} + +function Find-AppIdByEnvOrName($EnvKey, $DisplayName) { + $id = Azd-Get $EnvKey "" + if ($id) { + $exists = az ad app show --id $id 2>$null + if ($LASTEXITCODE -eq 0) { return $id } + } + $ids = az ad app list --display-name $DisplayName --query "[].appId" -o tsv + $arr = @($ids -split "`n" | Where-Object { $_ }) + if ($arr.Count -gt 1) { throw "Multiple app registrations with displayName '$DisplayName'. Clean up or set $EnvKey manually." } + if ($arr.Count -eq 1) { return $arr[0] } + return "" +} + +# --- Step 1: API app registration -------------------------------------------- +Write-Host "" +Write-Host "โžก๏ธ Step 1/6: API app registration ($ApiDisplayName)" + +$ApiClientId = Find-AppIdByEnvOrName "AZURE_AUTH_API_CLIENT_ID" $ApiDisplayName +if (-not $ApiClientId) { + $ApiClientId = az ad app create --display-name $ApiDisplayName ` + --sign-in-audience AzureADMyOrg ` + --web-redirect-uris $ApiAuthCallback ` + --enable-id-token-issuance true ` + --query appId -o tsv + Write-Host " โœ“ Created API app: $ApiClientId" +} else { + Write-Host " โ†บ Reusing API app: $ApiClientId" + Retry { az ad app update --id $ApiClientId --web-redirect-uris $ApiAuthCallback --enable-id-token-issuance true | Out-Null } +} +azd env set AZURE_AUTH_API_CLIENT_ID $ApiClientId | Out-Null + +Retry { + az ad sp show --id $ApiClientId 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { az ad sp create --id $ApiClientId | Out-Null } +} + +$ApiAppObjectId = az ad app show --id $ApiClientId --query id -o tsv +$ApiIdentifierUri = "api://$ApiClientId" + +$ApiScopeId = az ad app show --id $ApiClientId --query "api.oauth2PermissionScopes[?value=='user_impersonation'].id | [0]" -o tsv +if (-not $ApiScopeId -or $ApiScopeId -eq "null") { + $ApiScopeId = [guid]::NewGuid().ToString() + $patch = @{ + identifierUris = @($ApiIdentifierUri) + api = @{ + oauth2PermissionScopes = @(@{ + id = $ApiScopeId + adminConsentDescription = "Allow the application to access the API on behalf of the signed-in user." + adminConsentDisplayName = "Access API as user" + userConsentDescription = "Allow the application to access the API on your behalf." + userConsentDisplayName = "Access API" + value = "user_impersonation" + type = "User" + isEnabled = $true + }) + } + } | ConvertTo-Json -Depth 10 + $tmp = New-TemporaryFile + $patch | Out-File -FilePath $tmp -Encoding utf8 + Retry { az rest --method PATCH --url "https://graph.microsoft.com/v1.0/applications/$ApiAppObjectId" --headers "Content-Type=application/json" --body "@$tmp" | Out-Null } + Remove-Item $tmp + Write-Host " โœ“ Exposed scope api://$ApiClientId/user_impersonation" +} else { + Write-Host " โ†บ API scope already exposed" +} +$ApiScopeValue = "api://$ApiClientId/user_impersonation" + +# --- Step 2: Web app registration -------------------------------------------- +Write-Host "" +Write-Host "โžก๏ธ Step 2/6: Web app registration ($WebDisplayName)" + +$WebClientId = Find-AppIdByEnvOrName "AZURE_AUTH_WEB_CLIENT_ID" $WebDisplayName +if (-not $WebClientId) { + $WebClientId = az ad app create --display-name $WebDisplayName ` + --sign-in-audience AzureADMyOrg ` + --web-redirect-uris $WebAuthCallback ` + --enable-id-token-issuance true ` + --enable-access-token-issuance true ` + --query appId -o tsv + Write-Host " โœ“ Created Web app: $WebClientId" +} else { + Write-Host " โ†บ Reusing Web app: $WebClientId" + Retry { az ad app update --id $WebClientId --web-redirect-uris $WebAuthCallback --enable-id-token-issuance true --enable-access-token-issuance true | Out-Null } +} +azd env set AZURE_AUTH_WEB_CLIENT_ID $WebClientId | Out-Null + +Retry { + az ad sp show --id $WebClientId 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { az ad sp create --id $WebClientId | Out-Null } +} + +$WebAppObjectId = az ad app show --id $WebClientId --query id -o tsv +$WebIdentifierUri = "api://$WebClientId" + +$WebScopeId = az ad app show --id $WebClientId --query "api.oauth2PermissionScopes[?value=='user_impersonation'].id | [0]" -o tsv +if (-not $WebScopeId -or $WebScopeId -eq "null") { $WebScopeId = [guid]::NewGuid().ToString() } + +$webPatch = @{ + identifierUris = @($WebIdentifierUri) + spa = @{ redirectUris = @($WebUrl, "$WebUrl/") } + api = @{ + knownClientApplications = @() + oauth2PermissionScopes = @(@{ + id = $WebScopeId + adminConsentDescription = "Allow the app to sign in the user." + adminConsentDisplayName = "Sign in" + userConsentDescription = "Allow the app to sign you in." + userConsentDisplayName = "Sign in" + value = "user_impersonation" + type = "User" + isEnabled = $true + }) + } + requiredResourceAccess = @( + @{ resourceAppId = $ApiClientId; resourceAccess = @(@{ id = $ApiScopeId; type = "Scope" }) }, + @{ resourceAppId = $GraphAppId; resourceAccess = @(@{ id = $GraphUserReadScopeId; type = "Scope" }) } + ) +} | ConvertTo-Json -Depth 10 +$tmp = New-TemporaryFile +$webPatch | Out-File -FilePath $tmp -Encoding utf8 +Retry { az rest --method PATCH --url "https://graph.microsoft.com/v1.0/applications/$WebAppObjectId" --headers "Content-Type=application/json" --body "@$tmp" | Out-Null } +Remove-Item $tmp +Write-Host " โœ“ Web SPA redirect, scope, and required permissions configured" +$WebScopeValue = "api://$WebClientId/user_impersonation" + +# --- Step 3: Admin consent --------------------------------------------------- +Write-Host "" +Write-Host "โžก๏ธ Step 3/6: Granting admin consent" +$ConsentOk = $true +try { + Retry { az ad app permission admin-consent --id $WebClientId | Out-Null } + Write-Host " โœ“ Admin consent granted" +} catch { + $ConsentOk = $false + Write-Host " โš ๏ธ Admin consent failed. Sign-in may fail until a tenant admin runs:" + Write-Host " az ad app permission admin-consent --id $WebClientId" + Write-Host " Or: https://login.microsoftonline.com/$TenantId/adminconsent?client_id=$WebClientId" +} + +# --- Step 4: Container App secrets ------------------------------------------ +Write-Host "" +Write-Host "โžก๏ธ Step 4/6: Client secrets" + +function Ensure-CaSecret($AppId, $CaName) { + $existing = az containerapp secret list -n $CaName -g $ResourceGroup --query "[?name=='$CaSecretName'].name | [0]" -o tsv + if ($existing -and $existing -ne "null") { + Write-Host " โ†บ Container App '$CaName' already has '$CaSecretName' โ€” not rotating." + return + } + $secret = az ad app credential reset --id $AppId --append --display-name "containerapp-easyauth" --years 2 --query password -o tsv + az containerapp secret set -n $CaName -g $ResourceGroup --secrets "$CaSecretName=$secret" --output none + Write-Host " โœ“ Stored new client secret in '$CaName'" +} + +Ensure-CaSecret $ApiClientId $ApiName +Ensure-CaSecret $WebClientId $WebName + +# --- Step 5: Enable EasyAuth ------------------------------------------------ +Write-Host "" +Write-Host "โžก๏ธ Step 5/6: Enabling EasyAuth on Web + API container apps" +$Issuer = "https://login.microsoftonline.com/$TenantId/v2.0" + +function Configure-EasyAuth($CaName, $ClientId, $Audience) { + az containerapp auth microsoft update -n $CaName -g $ResourceGroup ` + --client-id $ClientId ` + --client-secret-name $CaSecretName ` + --tenant-id $TenantId ` + --issuer $Issuer ` + --allowed-token-audiences $Audience ` + --yes --output none +} + +Configure-EasyAuth $ApiName $ApiClientId $ApiIdentifierUri +Configure-EasyAuth $WebName $WebClientId $WebIdentifierUri + +az containerapp auth update -n $WebName -g $ResourceGroup --enabled true --unauthenticated-client-action AllowAnonymous --output none +az containerapp auth update -n $ApiName -g $ResourceGroup --enabled true --unauthenticated-client-action AllowAnonymous --output none +Write-Host " โœ“ EasyAuth providers configured" + +# --- Step 6: Env vars + allowedApplications + lockdown ---------------------- +Write-Host "" +Write-Host "โžก๏ธ Step 6/6: Wiring env vars and caller allowlist" + +az containerapp update -n $WebName -g $ResourceGroup ` + --set-env-vars "APP_WEB_CLIENT_ID=$WebClientId" "APP_WEB_SCOPE=$WebScopeValue" "APP_API_SCOPE=$ApiScopeValue" "APP_AUTH_ENABLED=true" ` + --output none +Write-Host " โœ“ Web env vars updated" + +$authUrl = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.App/containerApps/$ApiName/authConfigs/current?api-version=2024-03-01" +$current = az rest --method get --url $authUrl | ConvertFrom-Json +if (-not $current.properties) { $current | Add-Member -MemberType NoteProperty -Name properties -Value (@{}) } +if (-not $current.properties.identityProviders) { $current.properties | Add-Member -MemberType NoteProperty -Name identityProviders -Value (@{}) } +if (-not $current.properties.identityProviders.azureActiveDirectory) { $current.properties.identityProviders | Add-Member -MemberType NoteProperty -Name azureActiveDirectory -Value (@{}) } +$aad = $current.properties.identityProviders.azureActiveDirectory +if (-not $aad.validation) { $aad | Add-Member -MemberType NoteProperty -Name validation -Value (@{}) } +if (-not $aad.validation.defaultAuthorizationPolicy) { $aad.validation | Add-Member -MemberType NoteProperty -Name defaultAuthorizationPolicy -Value (@{}) } +$policy = $aad.validation.defaultAuthorizationPolicy +$allowed = @() +if ($policy.allowedApplications) { $allowed = @($policy.allowedApplications) } +if ($allowed -notcontains $WebClientId) { $allowed += $WebClientId } +$policy.allowedApplications = $allowed + +$tmp = New-TemporaryFile +$current | ConvertTo-Json -Depth 20 | Out-File -FilePath $tmp -Encoding utf8 +Retry { az rest --method put --url $authUrl --headers "Content-Type=application/json" --body "@$tmp" | Out-Null } +Remove-Item $tmp +Write-Host " โœ“ API 'allowed applications' includes Web client id" + +az containerapp auth update -n $WebName -g $ResourceGroup --unauthenticated-client-action RedirectToLoginPage --output none +az containerapp auth update -n $ApiName -g $ResourceGroup --unauthenticated-client-action Return401 --output none +Write-Host " โœ“ Unauthenticated requests: Web โ†’ login, API โ†’ 401" + +Write-Host "" +Write-Host "============================================================" +Write-Host "๐Ÿ” Auth configuration complete." +Write-Host " Web client id : $WebClientId" +Write-Host " API client id : $ApiClientId" +Write-Host " Web scope : $WebScopeValue" +Write-Host " API scope : $ApiScopeValue" +if (-not $ConsentOk) { Write-Host " โš ๏ธ Admin consent pending โ€” see step 3 above." } +Write-Host " Note: EasyAuth rollout can take up to 10 minutes." +Write-Host "============================================================" diff --git a/infra/scripts/configure_auth.sh b/infra/scripts/configure_auth.sh new file mode 100755 index 00000000..14c538db --- /dev/null +++ b/infra/scripts/configure_auth.sh @@ -0,0 +1,365 @@ +#!/usr/bin/env bash +# Automates the app registration + EasyAuth configuration that is otherwise +# performed manually per docs/ConfigureAppAuthentication.md. +# +# Idempotent: safe to re-run. Reuses existing app registrations and container +# app secrets where possible. +# +# Skip with: azd env set AZURE_SKIP_AUTH_SETUP true + +set -euo pipefail + +if [[ "${AZURE_SKIP_AUTH_SETUP:-false}" == "true" ]]; then + echo "โญ๏ธ AZURE_SKIP_AUTH_SETUP=true โ€” skipping auth configuration." + exit 0 +fi + +echo "" +echo "============================================================" +echo "๐Ÿ” Configuring Entra ID authentication (Web + API)" +echo "============================================================" + +# --- Load values from azd env ------------------------------------------------- +ENV_NAME="$(azd env get-value AZURE_ENV_NAME 2>/dev/null || echo "")" +RESOURCE_GROUP="$(azd env get-value AZURE_RESOURCE_GROUP)" +SUBSCRIPTION_ID="$(azd env get-value AZURE_SUBSCRIPTION_ID)" +TENANT_ID="$(azd env get-value AZURE_TENANT_ID 2>/dev/null || az account show --query tenantId -o tsv)" + +WEB_NAME="$(azd env get-value CONTAINER_WEB_APP_NAME)" +WEB_FQDN="$(azd env get-value CONTAINER_WEB_APP_FQDN)" +API_NAME="$(azd env get-value CONTAINER_API_APP_NAME)" +API_FQDN="$(azd env get-value CONTAINER_API_APP_FQDN)" + +WEB_APP_DISPLAY_NAME="${ENV_NAME:-cps}-web-app" +API_APP_DISPLAY_NAME="${ENV_NAME:-cps}-api-app" + +WEB_URL="https://${WEB_FQDN}" +API_URL="https://${API_FQDN}" +WEB_AUTH_CALLBACK="${WEB_URL}/.auth/login/aad/callback" +API_AUTH_CALLBACK="${API_URL}/.auth/login/aad/callback" + +# Graph delegated User.Read permission +GRAPH_APP_ID="00000003-0000-0000-c000-000000000000" +GRAPH_USER_READ_SCOPE_ID="e1fe6dd8-ba31-4d61-89e7-88639da4683d" # User.Read (delegated) + +# ----------------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------------- + +# Find app reg by previously persisted appId in azd env, else by displayName. +# Returns: appId on stdout, empty if not found. +find_app_by_env_or_name() { + local env_key="$1" + local display_name="$2" + local app_id + app_id="$(azd env get-value "$env_key" 2>/dev/null || echo "")" + if [[ -n "$app_id" ]] && az ad app show --id "$app_id" >/dev/null 2>&1; then + echo "$app_id" + return 0 + fi + # Fall back to displayName + local ids + ids="$(az ad app list --display-name "$display_name" --query "[].appId" -o tsv 2>/dev/null || true)" + local count + count="$(echo "$ids" | grep -c . || true)" + if [[ "$count" -gt 1 ]]; then + echo "โŒ Multiple app registrations found with displayName '$display_name'. Delete duplicates or set $env_key manually." >&2 + exit 1 + fi + echo "$ids" | head -n1 +} + +# Retry an az command on transient Graph propagation failures. +retry() { + local max=${RETRY_COUNT:-6} + local delay=${RETRY_DELAY:-10} + local i=1 + while true; do + if "$@"; then return 0; fi + if (( i >= max )); then return 1; fi + echo " โ†ป retry $i/$max after ${delay}s..." + sleep "$delay" + i=$((i+1)) + done +} + +# ----------------------------------------------------------------------------- +# Step 1: API app registration (exposes user_impersonation scope) +# ----------------------------------------------------------------------------- +echo "" +echo "โžก๏ธ Step 1/6: API app registration ($API_APP_DISPLAY_NAME)" + +API_CLIENT_ID="$(find_app_by_env_or_name AZURE_AUTH_API_CLIENT_ID "$API_APP_DISPLAY_NAME")" +if [[ -z "$API_CLIENT_ID" ]]; then + API_CLIENT_ID="$(az ad app create \ + --display-name "$API_APP_DISPLAY_NAME" \ + --sign-in-audience AzureADMyOrg \ + --web-redirect-uris "$API_AUTH_CALLBACK" \ + --enable-id-token-issuance true \ + --query appId -o tsv)" + echo " โœ“ Created API app: $API_CLIENT_ID" +else + echo " โ†บ Reusing API app: $API_CLIENT_ID" + retry az ad app update --id "$API_CLIENT_ID" \ + --web-redirect-uris "$API_AUTH_CALLBACK" \ + --enable-id-token-issuance true >/dev/null +fi +azd env set AZURE_AUTH_API_CLIENT_ID "$API_CLIENT_ID" >/dev/null + +# Ensure service principal exists (needed for consent + EasyAuth) +retry az ad sp show --id "$API_CLIENT_ID" >/dev/null 2>&1 \ + || az ad sp create --id "$API_CLIENT_ID" >/dev/null + +API_APP_OBJECT_ID="$(az ad app show --id "$API_CLIENT_ID" --query id -o tsv)" +API_IDENTIFIER_URI="api://${API_CLIENT_ID}" + +# Set identifierUri + expose user_impersonation scope (idempotent via Graph PATCH) +API_SCOPE_ID="$(az ad app show --id "$API_CLIENT_ID" \ + --query "api.oauth2PermissionScopes[?value=='user_impersonation'].id | [0]" -o tsv)" +if [[ -z "$API_SCOPE_ID" || "$API_SCOPE_ID" == "null" ]]; then + API_SCOPE_ID="$(cat /proc/sys/kernel/random/uuid)" + cat > /tmp/api_scope_patch.json </dev/null + rm -f /tmp/api_scope_patch.json + echo " โœ“ Exposed scope api://${API_CLIENT_ID}/user_impersonation" +else + echo " โ†บ API scope already exposed" +fi +API_SCOPE_VALUE="api://${API_CLIENT_ID}/user_impersonation" + +# ----------------------------------------------------------------------------- +# Step 2: Web app registration (SPA + EasyAuth callback + exposes scope) +# ----------------------------------------------------------------------------- +echo "" +echo "โžก๏ธ Step 2/6: Web app registration ($WEB_APP_DISPLAY_NAME)" + +WEB_CLIENT_ID="$(find_app_by_env_or_name AZURE_AUTH_WEB_CLIENT_ID "$WEB_APP_DISPLAY_NAME")" +if [[ -z "$WEB_CLIENT_ID" ]]; then + WEB_CLIENT_ID="$(az ad app create \ + --display-name "$WEB_APP_DISPLAY_NAME" \ + --sign-in-audience AzureADMyOrg \ + --web-redirect-uris "$WEB_AUTH_CALLBACK" \ + --enable-id-token-issuance true \ + --enable-access-token-issuance true \ + --query appId -o tsv)" + echo " โœ“ Created Web app: $WEB_CLIENT_ID" +else + echo " โ†บ Reusing Web app: $WEB_CLIENT_ID" + retry az ad app update --id "$WEB_CLIENT_ID" \ + --web-redirect-uris "$WEB_AUTH_CALLBACK" \ + --enable-id-token-issuance true \ + --enable-access-token-issuance true >/dev/null +fi +azd env set AZURE_AUTH_WEB_CLIENT_ID "$WEB_CLIENT_ID" >/dev/null + +retry az ad sp show --id "$WEB_CLIENT_ID" >/dev/null 2>&1 \ + || az ad sp create --id "$WEB_CLIENT_ID" >/dev/null + +WEB_APP_OBJECT_ID="$(az ad app show --id "$WEB_CLIENT_ID" --query id -o tsv)" +WEB_IDENTIFIER_URI="api://${WEB_CLIENT_ID}" + +# Expose user_impersonation scope on the Web app (needed for loginRequest) +# + add SPA redirect URI + declare required resource access on API scope + Graph User.Read +WEB_SCOPE_ID="$(az ad app show --id "$WEB_CLIENT_ID" \ + --query "api.oauth2PermissionScopes[?value=='user_impersonation'].id | [0]" -o tsv)" +[[ -z "$WEB_SCOPE_ID" || "$WEB_SCOPE_ID" == "null" ]] && WEB_SCOPE_ID="$(cat /proc/sys/kernel/random/uuid)" + +cat > /tmp/web_patch.json </dev/null +rm -f /tmp/web_patch.json +echo " โœ“ Web SPA redirect, scope, and required permissions configured" + +WEB_SCOPE_VALUE="api://${WEB_CLIENT_ID}/user_impersonation" + +# ----------------------------------------------------------------------------- +# Step 3: Admin consent (best effort; hard warning if fails) +# ----------------------------------------------------------------------------- +echo "" +echo "โžก๏ธ Step 3/6: Granting admin consent" +CONSENT_OK=true +if ! retry az ad app permission admin-consent --id "$WEB_CLIENT_ID" 2>/tmp/consent_err; then + CONSENT_OK=false + echo " โš ๏ธ Admin consent failed. Sign-in may fail until a tenant admin runs:" + echo " az ad app permission admin-consent --id $WEB_CLIENT_ID" + echo " Or visit: https://login.microsoftonline.com/${TENANT_ID}/adminconsent?client_id=${WEB_CLIENT_ID}" + cat /tmp/consent_err | sed 's/^/ /' + rm -f /tmp/consent_err +else + echo " โœ“ Admin consent granted" +fi + +# ----------------------------------------------------------------------------- +# Step 4: Client secrets + Container App secrets +# ----------------------------------------------------------------------------- +echo "" +echo "โžก๏ธ Step 4/6: Client secrets" + +CA_SECRET_NAME="microsoft-provider-authentication-secret" + +ensure_ca_secret_from_app_reg() { + local app_id="$1" + local ca_name="$2" + local existing + existing="$(az containerapp secret list -n "$ca_name" -g "$RESOURCE_GROUP" \ + --query "[?name=='$CA_SECRET_NAME'].name | [0]" -o tsv 2>/dev/null || true)" + if [[ -n "$existing" && "$existing" != "null" ]]; then + echo " โ†บ Container App '$ca_name' already has '$CA_SECRET_NAME' โ€” not rotating." + return 0 + fi + local secret + secret="$(az ad app credential reset --id "$app_id" --append \ + --display-name "containerapp-easyauth" --years 2 \ + --query password -o tsv)" + az containerapp secret set -n "$ca_name" -g "$RESOURCE_GROUP" \ + --secrets "${CA_SECRET_NAME}=${secret}" --output none + echo " โœ“ Stored new client secret in '$ca_name'" +} + +ensure_ca_secret_from_app_reg "$API_CLIENT_ID" "$API_NAME" +ensure_ca_secret_from_app_reg "$WEB_CLIENT_ID" "$WEB_NAME" + +# ----------------------------------------------------------------------------- +# Step 5: Enable EasyAuth Microsoft provider on both Container Apps +# (allowUnauthenticated for now; env vars update next, strict last) +# ----------------------------------------------------------------------------- +echo "" +echo "โžก๏ธ Step 5/6: Enabling EasyAuth on Web + API container apps" + +OPENID_ISSUER="https://login.microsoftonline.com/${TENANT_ID}/v2.0" + +configure_easyauth_app() { + local ca_name="$1" + local client_id="$2" + local audience="$3" + az containerapp auth microsoft update -n "$ca_name" -g "$RESOURCE_GROUP" \ + --client-id "$client_id" \ + --client-secret-name "$CA_SECRET_NAME" \ + --tenant-id "$TENANT_ID" \ + --issuer "$OPENID_ISSUER" \ + --allowed-token-audiences "$audience" \ + --yes --output none +} + +configure_easyauth_app "$API_NAME" "$API_CLIENT_ID" "$API_IDENTIFIER_URI" +configure_easyauth_app "$WEB_NAME" "$WEB_CLIENT_ID" "$WEB_IDENTIFIER_URI" + +# Make sure auth is enabled and (temporarily) permissive so we can still push +# env vars / verify deployment. Final lockdown happens at the end. +az containerapp auth update -n "$WEB_NAME" -g "$RESOURCE_GROUP" \ + --enabled true --unauthenticated-client-action AllowAnonymous --output none +az containerapp auth update -n "$API_NAME" -g "$RESOURCE_GROUP" \ + --enabled true --unauthenticated-client-action AllowAnonymous --output none + +echo " โœ“ EasyAuth providers configured" + +# ----------------------------------------------------------------------------- +# Step 6: Web env vars + API allowedApplications + final lockdown +# ----------------------------------------------------------------------------- +echo "" +echo "โžก๏ธ Step 6/6: Wiring env vars and caller allowlist" + +# Update Web container env vars (other values left untouched) +az containerapp update -n "$WEB_NAME" -g "$RESOURCE_GROUP" \ + --set-env-vars \ + "APP_WEB_CLIENT_ID=$WEB_CLIENT_ID" \ + "APP_WEB_SCOPE=$WEB_SCOPE_VALUE" \ + "APP_API_SCOPE=$API_SCOPE_VALUE" \ + "APP_AUTH_ENABLED=true" \ + --output none +echo " โœ“ Web env vars: APP_WEB_CLIENT_ID / APP_WEB_SCOPE / APP_API_SCOPE / APP_AUTH_ENABLED" + +# Patch API authConfig: restrict to Web client id +# (equivalent to portal "Allow requests from specific client applications") +API_AUTHCONFIG_URL="/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.App/containerApps/${API_NAME}/authConfigs/current?api-version=2024-03-01" + +CURRENT_AUTH_JSON="$(az rest --method get --url "$API_AUTHCONFIG_URL")" +PATCHED_AUTH_JSON="$(echo "$CURRENT_AUTH_JSON" | python3 -c " +import json, sys +doc = json.load(sys.stdin) +props = doc.setdefault('properties', {}) +idp = props.setdefault('identityProviders', {}) +aad = idp.setdefault('azureActiveDirectory', {}) +val = aad.setdefault('validation', {}) +policy = val.setdefault('defaultAuthorizationPolicy', {}) +allowed = set(policy.get('allowedApplications') or []) +allowed.add('${WEB_CLIENT_ID}') +policy['allowedApplications'] = sorted(allowed) +print(json.dumps(doc)) +")" + +echo "$PATCHED_AUTH_JSON" > /tmp/api_authconfig.json +retry az rest --method put --url "$API_AUTHCONFIG_URL" \ + --headers "Content-Type=application/json" \ + --body @/tmp/api_authconfig.json >/dev/null +rm -f /tmp/api_authconfig.json +echo " โœ“ API 'allowed applications' now includes Web client id" + +# Final lockdown +az containerapp auth update -n "$WEB_NAME" -g "$RESOURCE_GROUP" \ + --unauthenticated-client-action RedirectToLoginPage --output none +az containerapp auth update -n "$API_NAME" -g "$RESOURCE_GROUP" \ + --unauthenticated-client-action Return401 --output none +echo " โœ“ Unauthenticated requests: Web โ†’ login, API โ†’ 401" + +echo "" +echo "============================================================" +echo "๐Ÿ” Auth configuration complete." +echo " Web client id : $WEB_CLIENT_ID" +echo " API client id : $API_CLIENT_ID" +echo " Web scope : $WEB_SCOPE_VALUE" +echo " API scope : $API_SCOPE_VALUE" +if [[ "$CONSENT_OK" != "true" ]]; then + echo " โš ๏ธ Admin consent pending โ€” see step 3 above." +fi +echo " Note: EasyAuth rollout can take up to 10 minutes." +echo "============================================================" diff --git a/infra/scripts/post_deployment.ps1 b/infra/scripts/post_deployment.ps1 index 04104a50..beed773b 100644 --- a/infra/scripts/post_deployment.ps1 +++ b/infra/scripts/post_deployment.ps1 @@ -230,3 +230,9 @@ if (-not $ApiReady) { Write-Host " Schemas registered: $($Registered.Count)" Write-Host ("=" * 60) } + +# --- Configure Entra ID authentication (app registrations + EasyAuth) --- +$authScript = Join-Path $PSScriptRoot "configure_auth.ps1" +if (Test-Path $authScript) { + try { & $authScript } catch { Write-Host "โš ๏ธ Auth configuration had errors: $_" } +} diff --git a/infra/scripts/post_deployment.sh b/infra/scripts/post_deployment.sh index 2b0ee0ad..485980e6 100644 --- a/infra/scripts/post_deployment.sh +++ b/infra/scripts/post_deployment.sh @@ -261,3 +261,11 @@ else echo " โŒ Failed to refresh Cognitive Services account '$CU_ACCOUNT_NAME'." fi fi + + +# --- Configure Entra ID authentication (app registrations + EasyAuth) --- +SCRIPT_DIR_SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -f "$SCRIPT_DIR_SELF/configure_auth.sh" ]; then + sed -i 's/\r$//' "$SCRIPT_DIR_SELF/configure_auth.sh" + bash "$SCRIPT_DIR_SELF/configure_auth.sh" || echo "โš ๏ธ Auth configuration had errors โ€” see output above." +fi From 3bb0db1be8303d32b5968566896bb6992ebe543b Mon Sep 17 00:00:00 2001 From: Don Lee Date: Fri, 24 Apr 2026 13:47:15 -0700 Subject: [PATCH 2/7] fix(auth): resolve tenant from az cli, clear stale audiences, normalize issuer Three fixes discovered during end-to-end azd up testing: 1. `az containerapp auth microsoft update` rejects `--issuer` and `--tenant-id` together; the issuer is derived from tenant-id. 2. `azd env get-value AZURE_TENANT_ID` prints its error message to stdout (not stderr), corrupting TENANT_ID when that key is absent. Read from `az account show` first instead. 3. `--allowed-token-audiences api://` breaks EasyAuth login because the ID tokens it issues have `aud=` (GUID), not the identifierUri. Drop the override and normalize `allowedAudiences` to just the clientId via an authConfig PUT (which also clears stale values left by prior runs and fixes `openIdIssuer` if it was previously corrupted). Verified: Web `/.auth/login/aad` -> 302 to login.microsoftonline.com with the correct client_id, redirect_uri, and scopes; API returns 401 to unauthenticated callers; allowedApplications on the API restricts callers to the Web clientId. --- infra/scripts/configure_auth.ps1 | 59 ++++++++++++++++------------ infra/scripts/configure_auth.sh | 67 ++++++++++++++++++++------------ 2 files changed, 76 insertions(+), 50 deletions(-) diff --git a/infra/scripts/configure_auth.ps1 b/infra/scripts/configure_auth.ps1 index ec8a0a36..53fc6d8b 100644 --- a/infra/scripts/configure_auth.ps1 +++ b/infra/scripts/configure_auth.ps1 @@ -25,8 +25,9 @@ function Azd-Get($key, $default = "") { $EnvName = Azd-Get "AZURE_ENV_NAME" "cps" $ResourceGroup = Azd-Get "AZURE_RESOURCE_GROUP" $SubscriptionId = Azd-Get "AZURE_SUBSCRIPTION_ID" -$TenantId = Azd-Get "AZURE_TENANT_ID" -if (-not $TenantId) { $TenantId = (az account show --query tenantId -o tsv) } +$TenantId = (az account show --query tenantId -o tsv) +if (-not $TenantId) { $TenantId = Azd-Get "AZURE_TENANT_ID" "" } +if (-not $TenantId) { throw "Could not resolve Azure tenant id. Run 'az login' or set AZURE_TENANT_ID." } $WebName = Azd-Get "CONTAINER_WEB_APP_NAME" $WebFqdn = Azd-Get "CONTAINER_WEB_APP_FQDN" @@ -217,18 +218,18 @@ Write-Host "" Write-Host "โžก๏ธ Step 5/6: Enabling EasyAuth on Web + API container apps" $Issuer = "https://login.microsoftonline.com/$TenantId/v2.0" -function Configure-EasyAuth($CaName, $ClientId, $Audience) { +function Configure-EasyAuth($CaName, $ClientId) { + # Note: --tenant-id and --issuer are mutually exclusive. Do not override + # --allowed-token-audiences; EasyAuth issues ID tokens with aud=. az containerapp auth microsoft update -n $CaName -g $ResourceGroup ` --client-id $ClientId ` --client-secret-name $CaSecretName ` --tenant-id $TenantId ` - --issuer $Issuer ` - --allowed-token-audiences $Audience ` --yes --output none } -Configure-EasyAuth $ApiName $ApiClientId $ApiIdentifierUri -Configure-EasyAuth $WebName $WebClientId $WebIdentifierUri +Configure-EasyAuth $ApiName $ApiClientId +Configure-EasyAuth $WebName $WebClientId az containerapp auth update -n $WebName -g $ResourceGroup --enabled true --unauthenticated-client-action AllowAnonymous --output none az containerapp auth update -n $ApiName -g $ResourceGroup --enabled true --unauthenticated-client-action AllowAnonymous --output none @@ -243,25 +244,33 @@ az containerapp update -n $WebName -g $ResourceGroup ` --output none Write-Host " โœ“ Web env vars updated" -$authUrl = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.App/containerApps/$ApiName/authConfigs/current?api-version=2024-03-01" -$current = az rest --method get --url $authUrl | ConvertFrom-Json -if (-not $current.properties) { $current | Add-Member -MemberType NoteProperty -Name properties -Value (@{}) } -if (-not $current.properties.identityProviders) { $current.properties | Add-Member -MemberType NoteProperty -Name identityProviders -Value (@{}) } -if (-not $current.properties.identityProviders.azureActiveDirectory) { $current.properties.identityProviders | Add-Member -MemberType NoteProperty -Name azureActiveDirectory -Value (@{}) } -$aad = $current.properties.identityProviders.azureActiveDirectory -if (-not $aad.validation) { $aad | Add-Member -MemberType NoteProperty -Name validation -Value (@{}) } -if (-not $aad.validation.defaultAuthorizationPolicy) { $aad.validation | Add-Member -MemberType NoteProperty -Name defaultAuthorizationPolicy -Value (@{}) } -$policy = $aad.validation.defaultAuthorizationPolicy -$allowed = @() -if ($policy.allowedApplications) { $allowed = @($policy.allowedApplications) } -if ($allowed -notcontains $WebClientId) { $allowed += $WebClientId } -$policy.allowedApplications = $allowed +function Patch-AuthConfig($CaName, $ClientId, $AddWebAllowed) { + $url = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.App/containerApps/$CaName/authConfigs/current?api-version=2024-03-01" + $current = az rest --method get --url $url | ConvertFrom-Json + if (-not $current.properties) { $current | Add-Member -MemberType NoteProperty -Name properties -Value (@{}) } + if (-not $current.properties.identityProviders) { $current.properties | Add-Member -MemberType NoteProperty -Name identityProviders -Value (@{}) } + if (-not $current.properties.identityProviders.azureActiveDirectory) { $current.properties.identityProviders | Add-Member -MemberType NoteProperty -Name azureActiveDirectory -Value (@{}) } + $aad = $current.properties.identityProviders.azureActiveDirectory + if (-not $aad.registration) { $aad | Add-Member -MemberType NoteProperty -Name registration -Value (@{}) } + $aad.registration.openIdIssuer = "https://login.microsoftonline.com/$TenantId/v2.0" + if (-not $aad.validation) { $aad | Add-Member -MemberType NoteProperty -Name validation -Value (@{}) } + $aad.validation.allowedAudiences = @($ClientId) + if (-not $aad.validation.defaultAuthorizationPolicy) { $aad.validation | Add-Member -MemberType NoteProperty -Name defaultAuthorizationPolicy -Value (@{}) } + $policy = $aad.validation.defaultAuthorizationPolicy + $allowed = @() + if ($policy.allowedApplications) { $allowed = @($policy.allowedApplications) } + if ($AddWebAllowed -and ($allowed -notcontains $WebClientId)) { $allowed += $WebClientId } + $policy.allowedApplications = $allowed -$tmp = New-TemporaryFile -$current | ConvertTo-Json -Depth 20 | Out-File -FilePath $tmp -Encoding utf8 -Retry { az rest --method put --url $authUrl --headers "Content-Type=application/json" --body "@$tmp" | Out-Null } -Remove-Item $tmp -Write-Host " โœ“ API 'allowed applications' includes Web client id" + $tmp = New-TemporaryFile + $current | ConvertTo-Json -Depth 20 | Out-File -FilePath $tmp -Encoding utf8 + Retry { az rest --method put --url $url --headers "Content-Type=application/json" --body "@$tmp" | Out-Null } + Remove-Item $tmp +} + +Patch-AuthConfig $ApiName $ApiClientId $true +Patch-AuthConfig $WebName $WebClientId $false +Write-Host " โœ“ authConfigs normalized (issuer, audiences, allowedApplications)" az containerapp auth update -n $WebName -g $ResourceGroup --unauthenticated-client-action RedirectToLoginPage --output none az containerapp auth update -n $ApiName -g $ResourceGroup --unauthenticated-client-action Return401 --output none diff --git a/infra/scripts/configure_auth.sh b/infra/scripts/configure_auth.sh index 14c538db..bfd2ba92 100755 --- a/infra/scripts/configure_auth.sh +++ b/infra/scripts/configure_auth.sh @@ -23,7 +23,14 @@ echo "============================================================" ENV_NAME="$(azd env get-value AZURE_ENV_NAME 2>/dev/null || echo "")" RESOURCE_GROUP="$(azd env get-value AZURE_RESOURCE_GROUP)" SUBSCRIPTION_ID="$(azd env get-value AZURE_SUBSCRIPTION_ID)" -TENANT_ID="$(azd env get-value AZURE_TENANT_ID 2>/dev/null || az account show --query tenantId -o tsv)" +TENANT_ID="$(az account show --query tenantId -o tsv)" +if [[ -z "$TENANT_ID" ]]; then + TENANT_ID="$(azd env get-value AZURE_TENANT_ID 2>/dev/null || true)" +fi +if [[ -z "$TENANT_ID" ]]; then + echo "โŒ Could not resolve Azure tenant id. Run 'az login' or set AZURE_TENANT_ID." >&2 + exit 1 +fi WEB_NAME="$(azd env get-value CONTAINER_WEB_APP_NAME)" WEB_FQDN="$(azd env get-value CONTAINER_WEB_APP_FQDN)" @@ -275,23 +282,21 @@ ensure_ca_secret_from_app_reg "$WEB_CLIENT_ID" "$WEB_NAME" echo "" echo "โžก๏ธ Step 5/6: Enabling EasyAuth on Web + API container apps" -OPENID_ISSUER="https://login.microsoftonline.com/${TENANT_ID}/v2.0" - configure_easyauth_app() { local ca_name="$1" local client_id="$2" - local audience="$3" + # Note: --tenant-id and --issuer are mutually exclusive; tenant-id derives + # the v2.0 issuer automatically. Do not override --allowed-token-audiences; + # EasyAuth issues ID tokens with aud=, which is the default. az containerapp auth microsoft update -n "$ca_name" -g "$RESOURCE_GROUP" \ --client-id "$client_id" \ --client-secret-name "$CA_SECRET_NAME" \ --tenant-id "$TENANT_ID" \ - --issuer "$OPENID_ISSUER" \ - --allowed-token-audiences "$audience" \ --yes --output none } -configure_easyauth_app "$API_NAME" "$API_CLIENT_ID" "$API_IDENTIFIER_URI" -configure_easyauth_app "$WEB_NAME" "$WEB_CLIENT_ID" "$WEB_IDENTIFIER_URI" +configure_easyauth_app "$API_NAME" "$API_CLIENT_ID" +configure_easyauth_app "$WEB_NAME" "$WEB_CLIENT_ID" # Make sure auth is enabled and (temporarily) permissive so we can still push # env vars / verify deployment. Final lockdown happens at the end. @@ -318,31 +323,43 @@ az containerapp update -n "$WEB_NAME" -g "$RESOURCE_GROUP" \ --output none echo " โœ“ Web env vars: APP_WEB_CLIENT_ID / APP_WEB_SCOPE / APP_API_SCOPE / APP_AUTH_ENABLED" -# Patch API authConfig: restrict to Web client id -# (equivalent to portal "Allow requests from specific client applications") -API_AUTHCONFIG_URL="/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.App/containerApps/${API_NAME}/authConfigs/current?api-version=2024-03-01" - -CURRENT_AUTH_JSON="$(az rest --method get --url "$API_AUTHCONFIG_URL")" -PATCHED_AUTH_JSON="$(echo "$CURRENT_AUTH_JSON" | python3 -c " -import json, sys -doc = json.load(sys.stdin) -props = doc.setdefault('properties', {}) +# Patch both authConfigs: +# - API: add Web client id to allowedApplications +# - Both: reset allowedAudiences to only the clientId, normalize openIdIssuer +patch_authconfig() { + local ca_name="$1" + local client_id="$2" + local add_web_allowed="$3" # "true" / "false" + local url="/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.App/containerApps/${ca_name}/authConfigs/current?api-version=2024-03-01" + local cur patched + cur="$(az rest --method get --url "$url")" + patched="$(echo "$cur" | ADD_WEB="$add_web_allowed" WEB_CLIENT_ID="$WEB_CLIENT_ID" CLIENT_ID="$client_id" TENANT_ID="$TENANT_ID" python3 -c " +import json, os, sys +d = json.load(sys.stdin) +props = d.setdefault('properties', {}) idp = props.setdefault('identityProviders', {}) aad = idp.setdefault('azureActiveDirectory', {}) +reg = aad.setdefault('registration', {}) +reg['openIdIssuer'] = f\"https://login.microsoftonline.com/{os.environ['TENANT_ID']}/v2.0\" val = aad.setdefault('validation', {}) +val['allowedAudiences'] = [os.environ['CLIENT_ID']] policy = val.setdefault('defaultAuthorizationPolicy', {}) allowed = set(policy.get('allowedApplications') or []) -allowed.add('${WEB_CLIENT_ID}') +if os.environ['ADD_WEB'] == 'true': + allowed.add(os.environ['WEB_CLIENT_ID']) policy['allowedApplications'] = sorted(allowed) -print(json.dumps(doc)) +print(json.dumps(d)) ")" + echo "$patched" > /tmp/authconfig_patch.json + retry az rest --method put --url "$url" \ + --headers "Content-Type=application/json" \ + --body @/tmp/authconfig_patch.json >/dev/null + rm -f /tmp/authconfig_patch.json +} -echo "$PATCHED_AUTH_JSON" > /tmp/api_authconfig.json -retry az rest --method put --url "$API_AUTHCONFIG_URL" \ - --headers "Content-Type=application/json" \ - --body @/tmp/api_authconfig.json >/dev/null -rm -f /tmp/api_authconfig.json -echo " โœ“ API 'allowed applications' now includes Web client id" +patch_authconfig "$API_NAME" "$API_CLIENT_ID" "true" +patch_authconfig "$WEB_NAME" "$WEB_CLIENT_ID" "false" +echo " โœ“ authConfigs normalized (issuer, audiences, allowedApplications)" # Final lockdown az containerapp auth update -n "$WEB_NAME" -g "$RESOURCE_GROUP" \ From 833da3b5801b0d5a3829b10829426d96876f12ef Mon Sep 17 00:00:00 2001 From: Don Lee Date: Fri, 24 Apr 2026 13:59:12 -0700 Subject: [PATCH 3/7] fix(auth): accept both v1 (api://guid) and v2 (guid) audiences Default app registrations have requestedAccessTokenVersion=null, which means Entra issues v1 access tokens with aud='api://'. EasyAuth was configured with allowedAudiences=[''] (bare GUID only), so every Web->API call failed audience validation and returned 401. Include both forms so the script works regardless of the app reg's accessTokenAcceptedVersion setting. --- infra/scripts/configure_auth.ps1 | 2 +- infra/scripts/configure_auth.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/scripts/configure_auth.ps1 b/infra/scripts/configure_auth.ps1 index 53fc6d8b..1dc595ca 100644 --- a/infra/scripts/configure_auth.ps1 +++ b/infra/scripts/configure_auth.ps1 @@ -254,7 +254,7 @@ function Patch-AuthConfig($CaName, $ClientId, $AddWebAllowed) { if (-not $aad.registration) { $aad | Add-Member -MemberType NoteProperty -Name registration -Value (@{}) } $aad.registration.openIdIssuer = "https://login.microsoftonline.com/$TenantId/v2.0" if (-not $aad.validation) { $aad | Add-Member -MemberType NoteProperty -Name validation -Value (@{}) } - $aad.validation.allowedAudiences = @($ClientId) + $aad.validation.allowedAudiences = @($ClientId, "api://$ClientId") if (-not $aad.validation.defaultAuthorizationPolicy) { $aad.validation | Add-Member -MemberType NoteProperty -Name defaultAuthorizationPolicy -Value (@{}) } $policy = $aad.validation.defaultAuthorizationPolicy $allowed = @() diff --git a/infra/scripts/configure_auth.sh b/infra/scripts/configure_auth.sh index bfd2ba92..55853064 100755 --- a/infra/scripts/configure_auth.sh +++ b/infra/scripts/configure_auth.sh @@ -342,7 +342,7 @@ aad = idp.setdefault('azureActiveDirectory', {}) reg = aad.setdefault('registration', {}) reg['openIdIssuer'] = f\"https://login.microsoftonline.com/{os.environ['TENANT_ID']}/v2.0\" val = aad.setdefault('validation', {}) -val['allowedAudiences'] = [os.environ['CLIENT_ID']] +val['allowedAudiences'] = [os.environ['CLIENT_ID'], 'api://' + os.environ['CLIENT_ID']] policy = val.setdefault('defaultAuthorizationPolicy', {}) allowed = set(policy.get('allowedApplications') or []) if os.environ['ADD_WEB'] == 'true': From b164e6b91d1fa5bf036a5ef9b9bc4519f4ba30a8 Mon Sep 17 00:00:00 2001 From: Don Lee Date: Fri, 24 Apr 2026 14:03:58 -0700 Subject: [PATCH 4/7] docs: reflect automatic auth configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DeploymentGuide.md ยง5.2 rewritten: describes the automatic flow, permission requirements, admin-consent failure handling, and the AZURE_SKIP_AUTH_SETUP escape hatch. - ConfigureAppAuthentication.md gets a banner making clear the manual steps are now a fallback (for tenants that block programmatic app registration or admin consent). --- docs/ConfigureAppAuthentication.md | 11 +++++++ docs/DeploymentGuide.md | 46 +++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/docs/ConfigureAppAuthentication.md b/docs/ConfigureAppAuthentication.md index 8de1c105..ae2bb404 100644 --- a/docs/ConfigureAppAuthentication.md +++ b/docs/ConfigureAppAuthentication.md @@ -1,5 +1,16 @@ # Set up Authentication in Azure Container App +> ### โœ… Automatic configuration is now the default +> +> As of the latest release, `azd up` **automatically** performs all of the steps below via the `infra/scripts/configure_auth.{sh,ps1}` post-provisioning hook. You should not need to follow this document in most cases. +> +> See [DeploymentGuide.md ยง 5.2](./DeploymentGuide.md#52-configure-authentication-automatic) for details, including how to opt out with `azd env set AZURE_SKIP_AUTH_SETUP true`. +> +> Follow the manual steps below **only** if: +> - You set `AZURE_SKIP_AUTH_SETUP=true` before running `azd up` +> - The automatic script reported an error (most commonly: your identity lacks permission to grant admin consent โ€” a tenant admin still has to consent, but the rest of the configuration is already complete) +> - Your tenant policy prohibits programmatic app registration or secret creation + This document provides step-by-step instructions to configure Azure App Registrations for the front-end and back-end applications. > **Note:** The solution deploys four container apps. Only the **Web** and **API** container apps require Entra ID authentication provider configuration. The **Content Processor** (app) and **Content Process Workflow** (wkfl) containers are internal services that communicate via Storage Queues using managed identity โ€” they do not expose public endpoints. diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index 94c3d2f3..b9c440ac 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -360,12 +360,50 @@ Schema registration process completed. โœ… Schema registration complete. ``` -### 5.2 Configure Authentication (Required) +### 5.2 Configure Authentication (Automatic) -**This step is mandatory for application access:** +Starting with this release, authentication is configured **automatically** as part of the `azd up` post-provisioning hook. The hook: -1. Follow [App Authentication Configuration](./ConfigureAppAuthentication.md). -2. Wait up to 10 minutes for authentication changes to take effect. +1. Creates two Entra ID app registrations (`-web-app`, `-api-app`) with the correct redirect URIs, exposed scopes, and required permissions +2. Grants admin consent (best effort โ€” see note below) +3. Mints client secrets and stores them in Container Apps secrets +4. Enables the Microsoft identity provider on both the Web and API container apps +5. Restricts the API to only accept tokens from the Web app (`allowedApplications`) +6. Sets the `APP_WEB_CLIENT_ID`, `APP_WEB_SCOPE`, `APP_API_SCOPE`, and `APP_AUTH_ENABLED` environment variables on the Web container + +You will see an **`๐Ÿ” Configuring Entra ID authentication`** section in the `azd up` output, ending with a summary of both client IDs and scopes. + +> **Note:** EasyAuth can take up to 10 minutes to fully propagate. If the Web app returns 500/401 immediately after deployment, wait a few minutes and retry. + +#### When automatic configuration is not possible + +Automatic configuration requires permission to: +- Create Entra ID app registrations (**Application Administrator** or equivalent) +- Grant admin consent for delegated permissions (**Cloud Application Administrator** or **Global Administrator**) + +If your identity cannot grant admin consent, the script prints a clear manual action message like: + +``` +โš ๏ธ Admin consent failed. Sign-in may fail until a tenant admin runs: + az ad app permission admin-consent --id + Or visit: https://login.microsoftonline.com//adminconsent?client_id= +``` + +In that case, share the command/URL with your tenant administrator. + +#### Skipping automatic auth configuration + +If your tenant blocks programmatic app registration, or you prefer to configure authentication manually, disable the automation before running `azd up`: + +```bash +azd env set AZURE_SKIP_AUTH_SETUP true +``` + +Then follow the manual instructions: [App Authentication Configuration (manual)](./ConfigureAppAuthentication.md). + +#### Re-running + +The automation is idempotent: re-running `azd up` reuses the existing app registrations (IDs are persisted in `AZURE_AUTH_WEB_CLIENT_ID` / `AZURE_AUTH_API_CLIENT_ID` in the azd environment) and does not rotate client secrets. ### 5.3 Verify Deployment From 7b4b59e9fa4c00af6567fd74de9e7f490a722851 Mon Sep 17 00:00:00 2001 From: Don Lee Date: Fri, 24 Apr 2026 14:08:33 -0700 Subject: [PATCH 5/7] docs: note WAF deployment compatibility for auth automation --- docs/DeploymentGuide.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index b9c440ac..68d53a13 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -405,6 +405,12 @@ Then follow the manual instructions: [App Authentication Configuration (manual)] The automation is idempotent: re-running `azd up` reuses the existing app registrations (IDs are persisted in `AZURE_AUTH_WEB_CLIENT_ID` / `AZURE_AUTH_API_CLIENT_ID` in the azd environment) and does not rotate client secrets. +#### WAF (Well-Architected Framework) deployments + +The automation is fully compatible with the WAF / production profile (`main.waf.parameters.json`, which enables `enablePrivateNetworking`, `enableRedundancy`, and `enableScalability`). The Web and API container apps keep external ingress in the default WAF profile, so the redirect URIs registered by the script (`https:///.auth/login/aad/callback`) remain the correct public entry points. All script operations use the Azure management plane (Graph + ARM) and are unaffected by the private networking applied to backend resources such as Storage, Cosmos DB, and ACR. + +> If you further customize the WAF deployment to make the Web or API container app ingress **internal-only**, automatic configuration still runs, but end-user access to the sign-in page will require reaching the private endpoint (e.g., via the deployed jumpbox or a VPN). + ### 5.3 Verify Deployment 1. Access your application using the **Web App Endpoint** from the deployment output. From e2a1a3ed08429f8ae56c8b6c988126d4cbd1b55d Mon Sep 17 00:00:00 2001 From: DB Lee Date: Fri, 3 Apr 2026 12:13:26 -0700 Subject: [PATCH 6/7] feat: add sample file processing as post-deployment step 4 - Add bundle_info.json manifests for claim_date_of_loss and claim_hail - Add Step 4 to post_deployment.ps1 and post_deployment.sh - Creates claim batch with schemaset ID - Uploads files with mapped schema IDs - Submits batch for workflow processing - Update DeploymentGuide.md with new step and sample output - Update AVMPostDeploymentGuide.md with manual sample processing instructions - Normalize output prefixes: ASCII dashes for ps1, emojis for sh --- docs/AVMPostDeploymentGuide.md | 22 ++- docs/DeploymentGuide.md | 33 +++- infra/scripts/post_deployment.ps1 | 158 ++++++++++++++++-- infra/scripts/post_deployment.sh | 125 ++++++++++++++ .../claim_date_of_loss/bundle_info.json | 8 + .../samples/claim_hail/bundle_info.json | 7 + 6 files changed, 330 insertions(+), 23 deletions(-) create mode 100644 src/ContentProcessorAPI/samples/claim_date_of_loss/bundle_info.json create mode 100644 src/ContentProcessorAPI/samples/claim_hail/bundle_info.json diff --git a/docs/AVMPostDeploymentGuide.md b/docs/AVMPostDeploymentGuide.md index e0a1fe0b..be2593b6 100644 --- a/docs/AVMPostDeploymentGuide.md +++ b/docs/AVMPostDeploymentGuide.md @@ -11,9 +11,10 @@ This document provides guidance on post-deployment steps after deploying the Con After successfully deploying the Content Processing Solution Accelerator using the AVM template, you need to: 1. **Register schemas** โ€” upload schema files, create a schema set, and link them together -2. **Configure authentication** โ€” set up app registration for secure access +2. **Process sample files** โ€” upload and process sample claim bundles for verification +3. **Configure authentication** โ€” set up app registration for secure access -> **Note:** When deploying via `azd up`, schema registration happens automatically through a post-provisioning hook. AVM deployments require the manual steps below. +> **Note:** When deploying via `azd up`, schema registration and sample processing happen automatically through a post-provisioning hook. AVM deployments require the manual steps below. ## Prerequisites @@ -73,14 +74,27 @@ The script is idempotent โ€” it skips schemas and schema sets that already exist > **Want custom schemas?** See [Customize Schema Data](./CustomizeSchemaData.md) to create your own document schemas. -### Step 4: Configure Authentication (Required) +### Step 4: Process Sample File Bundles (Optional) + +After schema registration, you can upload and process the included sample claim bundles to verify the deployment is working end to end. Each sample folder (`claim_date_of_loss/`, `claim_hail/`) contains a `bundle_info.json` manifest that maps files to their schema classes. + +The workflow for each bundle: +1. **Create a claim batch** with the schema set ID via `PUT /claimprocessor/claims` +2. **Upload each file** with its mapped schema ID via `POST /claimprocessor/claims/{claim_id}/files` +3. **Submit the batch** for processing via `POST /claimprocessor/claims` + +You can perform these steps via the web UI or the API directly. See the [API documentation](./API.md) and [Golden Path Workflows](./GoldenPathWorkflows.md) for details. + +> **Note:** When deploying via `azd up`, sample file processing happens automatically as Step 4 of the post-provisioning hook. + +### Step 5: Configure Authentication (Required) **This step is mandatory for application access:** 1. Follow [App Authentication Configuration](./ConfigureAppAuthentication.md). 2. Wait up to 10 minutes for authentication changes to take effect. -### Step 5: Verify Deployment +### Step 6: Verify Deployment 1. Access your application using the Web App URL from your deployment output. 2. Confirm the application loads successfully. diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index 68d53a13..15fd2f0b 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -310,6 +310,7 @@ Schema registration happens **automatically** as part of the `azd up` post-provi 2. Registers the sample schema files (auto claim, damaged car image, police report, repair estimate) 3. Creates an **"Auto Claim"** schema set 4. Adds all registered schemas into the schema set +5. Processes sample file bundles (`claim_date_of_loss/` and `claim_hail/`) โ€” creates claim batches, uploads files with their mapped schemas, and submits them for processing After successful deployment, the terminal displays container app details and schema registration output: @@ -357,7 +358,29 @@ Schema registration process completed. Schema set ID: Schemas added: 4 ============================================================ - โœ… Schema registration complete. + +============================================================ +Step 4: Process sample file bundles +============================================================ + + ๐Ÿ“‚ Processing bundle: claim_date_of_loss + โœ… Claim batch created with ID: + โœ… Uploaded 'claim_form.pdf' successfully. + โœ… Uploaded 'damage_photo.png' successfully. + โœ… Uploaded 'police_report.pdf' successfully. + โœ… Uploaded 'repair_estimate.pdf' successfully. + โœ… Claim batch '' submitted for processing. + + ๐Ÿ“‚ Processing bundle: claim_hail + โœ… Claim batch created with ID: + โœ… Uploaded 'claim_form.pdf' successfully. + โœ… Uploaded 'damage_photo.png' successfully. + โœ… Uploaded 'repair_estimate.pdf' successfully. + โœ… Claim batch '' submitted for processing. + +============================================================ +Sample file processing completed. +============================================================ ``` ### 5.2 Configure Authentication (Automatic) @@ -419,10 +442,12 @@ The automation is fully compatible with the WAF / production profile (`main.waf. ### 5.4 Test the Application +> **Note:** The post-deployment hook automatically uploads and processes two sample claim bundles (`claim_date_of_loss` and `claim_hail`). You can verify the results in the web app immediately after deployment. + **Quick Test Steps:** -1. **Download Samples**: Get sample files from the [samples directory](../src/ContentProcessorAPI/samples) โ€” use the `claim_date_of_loss/` or `claim_hail/` folders for auto claim documents. -2. **Upload**: In the app, select the **"Auto Claim"** schema set, choose a schema (e.g., Auto Insurance Claim Form), click Import Content, and upload a sample file. -3. **Review**: Wait for completion (~1 min), then click the row to verify the extracted data against the source document. +1. **Check Processed Results**: Open the web app โ€” you should see the two sample claim batches already processed with extracted data. +2. **Review**: Click a processed claim row to verify the extracted data against the source document. +3. **Upload More (Optional)**: To test additional uploads, get sample files from the [samples directory](../src/ContentProcessorAPI/samples), select the **"Auto Claim"** schema set, and upload via Import Content. ๐Ÿ“– **Detailed Instructions:** See the complete [Golden Path Workflows](./GoldenPathWorkflows.md) guide for step-by-step testing procedures. diff --git a/infra/scripts/post_deployment.ps1 b/infra/scripts/post_deployment.ps1 index beed773b..1bd482ee 100644 --- a/infra/scripts/post_deployment.ps1 +++ b/infra/scripts/post_deployment.ps1 @@ -1,7 +1,7 @@ # Stop script on any error $ErrorActionPreference = "Stop" -Write-Host "[Search] Fetching container app info from azd environment..." +Write-Host "- Fetching container app info from azd environment..." # Load values from azd env $CONTAINER_WEB_APP_NAME = azd env get-value CONTAINER_WEB_APP_NAME @@ -32,25 +32,25 @@ $FullPath = Resolve-Path $DataScriptPath # Output Write-Host "" -Write-Host "[Info] Web App Details:" -Write-Host " [OK] Name: $CONTAINER_WEB_APP_NAME" -Write-Host " [URL] Endpoint: $CONTAINER_WEB_APP_FQDN" -Write-Host " [Link] Portal URL: $WEB_APP_PORTAL_URL" +Write-Host "- Web App Details:" +Write-Host " - Name: $CONTAINER_WEB_APP_NAME" +Write-Host " - Endpoint: $CONTAINER_WEB_APP_FQDN" +Write-Host " - Portal URL: $WEB_APP_PORTAL_URL" Write-Host "" -Write-Host "[Info] API App Details:" -Write-Host " [OK] Name: $CONTAINER_API_APP_NAME" -Write-Host " [URL] Endpoint: $CONTAINER_API_APP_FQDN" -Write-Host " [Link] Portal URL: $API_APP_PORTAL_URL" +Write-Host "- API App Details:" +Write-Host " - Name: $CONTAINER_API_APP_NAME" +Write-Host " - Endpoint: $CONTAINER_API_APP_FQDN" +Write-Host " - Portal URL: $API_APP_PORTAL_URL" Write-Host "" -Write-Host "[Info] Workflow App Details:" -Write-Host " [OK] Name: $CONTAINER_WORKFLOW_APP_NAME" -Write-Host " [Link] Portal URL: $WORKFLOW_APP_PORTAL_URL" +Write-Host "- Workflow App Details:" +Write-Host " - Name: $CONTAINER_WORKFLOW_APP_NAME" +Write-Host " - Portal URL: $WORKFLOW_APP_PORTAL_URL" Write-Host "" -Write-Host "[Package] Registering schemas and creating schema set..." -Write-Host " [Wait] Waiting for API to be ready..." +Write-Host "- Registering schemas and creating schema set..." +Write-Host " - Waiting for API to be ready..." $MaxRetries = 10 $RetryInterval = 15 @@ -61,7 +61,7 @@ for ($i = 1; $i -le $MaxRetries; $i++) { try { $response = Invoke-WebRequest -Uri "$ApiBaseUrl/schemavault/" -Method GET -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop if ($response.StatusCode -eq 200) { - Write-Host " [OK] API is ready." + Write-Host " - API is ready." $ApiReady = $true break } @@ -229,6 +229,134 @@ if (-not $ApiReady) { Write-Host "Schema registration process completed." Write-Host " Schemas registered: $($Registered.Count)" Write-Host ("=" * 60) + + # --- Step 4: Process sample file bundles --- + if ($SchemaSetId -and $Registered.Count -gt 0) { + Write-Host "" + Write-Host ("=" * 60) + Write-Host "Step 4: Process sample file bundles" + Write-Host ("=" * 60) + + $SamplesDir = Resolve-Path (Join-Path $ScriptDir "..\..\src\ContentProcessorAPI\samples") + $BundleFolders = @("claim_date_of_loss", "claim_hail") + $ClaimProcessorUrl = "$ApiBaseUrl/claimprocessor/claims" + + foreach ($bundle in $BundleFolders) { + $bundleDir = Join-Path $SamplesDir $bundle + $bundleInfoPath = Join-Path $bundleDir "bundle_info.json" + + if (-not (Test-Path $bundleInfoPath)) { + Write-Host " Skipping '$bundle' - no bundle_info.json found." + continue + } + + Write-Host "" + Write-Host " Processing bundle: $bundle" + + $bundleManifest = Get-Content $bundleInfoPath -Raw | ConvertFrom-Json + + # Step 4a: Create claim batch with schemaset ID + Write-Host " - Creating claim batch..." + try { + $claimResp = Invoke-RestMethod -Uri $ClaimProcessorUrl -Method PUT ` + -ContentType "application/json" ` + -Body (@{ schema_collection_id = $SchemaSetId } | ConvertTo-Json) ` + -TimeoutSec 30 -ErrorAction Stop + $claimId = $claimResp.claim_id + Write-Host " - Claim batch created with ID: $claimId" + } catch { + Write-Host " - Failed to create claim batch. Error: $_" + continue + } + + # Step 4b: Upload each file with its mapped schema ID + Add-Type -AssemblyName System.Net.Http + $httpClient = New-Object System.Net.Http.HttpClient + $httpClient.Timeout = [TimeSpan]::FromSeconds(60) + $uploadSuccess = $true + foreach ($entry in $bundleManifest.files) { + $schemaClass = $entry.schema_class + $fileName = $entry.file_name + $filePath = Join-Path $bundleDir $fileName + + if (-not (Test-Path $filePath)) { + Write-Host " - File '$fileName' not found. Skipping." + continue + } + + $schemaId = $Registered[$schemaClass] + if (-not $schemaId) { + Write-Host " - No schema ID found for '$schemaClass'. Skipping '$fileName'." + continue + } + + Write-Host " - Uploading '$fileName' (schema: $schemaClass)..." + + $dataPayload = @{ + Claim_Id = $claimId + Schema_Id = $schemaId + Metadata_Id = "sample-$bundle" + } | ConvertTo-Json -Compress + + $fileBytes = [System.IO.File]::ReadAllBytes((Resolve-Path $filePath)) + $mimeType = switch ([System.IO.Path]::GetExtension($fileName).ToLower()) { + ".pdf" { "application/pdf" } + ".png" { "image/png" } + ".jpg" { "image/jpeg" } + ".jpeg" { "image/jpeg" } + default { "application/octet-stream" } + } + + try { + $multipartContent = New-Object System.Net.Http.MultipartFormDataContent + $jsonContent = [System.Net.Http.StringContent]::new($dataPayload, [System.Text.Encoding]::UTF8, "application/json") + $jsonContent.Headers.ContentDisposition = [System.Net.Http.Headers.ContentDispositionHeaderValue]::Parse("form-data; name=`"data`"") + $multipartContent.Add($jsonContent, "data") + + $fileContent = [System.Net.Http.ByteArrayContent]::new($fileBytes) + $fileContent.Headers.ContentDisposition = [System.Net.Http.Headers.ContentDispositionHeaderValue]::Parse("form-data; name=`"file`"; filename=`"$fileName`"") + $fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse($mimeType) + $multipartContent.Add($fileContent, "file", $fileName) + + $response = $httpClient.PostAsync("$ClaimProcessorUrl/$claimId/files", $multipartContent).Result + $responseBody = $response.Content.ReadAsStringAsync().Result + + if ($response.IsSuccessStatusCode) { + Write-Host " - Uploaded '$fileName' successfully." + } else { + Write-Host " - Failed to upload '$fileName'. HTTP Status: $($response.StatusCode)" + Write-Host " - Error: $responseBody" + $uploadSuccess = $false + } + } catch { + Write-Host " - Failed to upload '$fileName'. Error: $_" + $uploadSuccess = $false + } + } + $httpClient.Dispose() + + # Step 4c: Launch processing + if ($uploadSuccess) { + Write-Host " - Submitting claim batch for processing..." + try { + Invoke-RestMethod -Uri $ClaimProcessorUrl -Method POST ` + -ContentType "application/json" ` + -Body (@{ claim_process_id = $claimId } | ConvertTo-Json) ` + -TimeoutSec 30 -ErrorAction Stop | Out-Null + Write-Host " - Claim batch '$claimId' submitted for processing." + } catch { + Write-Host " - Failed to submit claim batch. Error: $_" + } + } else { + Write-Host " - Skipping batch submission due to upload failures." + } + } + + Write-Host "" + Write-Host ("=" * 60) + Write-Host "Sample file processing completed." + Write-Host ("=" * 60) + } } # --- Configure Entra ID authentication (app registrations + EasyAuth) --- diff --git a/infra/scripts/post_deployment.sh b/infra/scripts/post_deployment.sh index 485980e6..99256487 100644 --- a/infra/scripts/post_deployment.sh +++ b/infra/scripts/post_deployment.sh @@ -237,6 +237,131 @@ else echo "Schema registration process completed." echo " Schemas registered: ${#REGISTERED_IDS[@]}" echo "============================================================" + + # --- Step 4: Process sample file bundles --- + if [ -n "$SCHEMASET_ID" ] && [ -n "$REGISTERED_IDS" ]; then + echo "" + echo "============================================================" + echo "Step 4: Process sample file bundles" + echo "============================================================" + + SAMPLES_DIR="$(realpath "$SCRIPT_DIR/../../src/ContentProcessorAPI/samples")" + CLAIM_PROCESSOR_URL="$API_BASE_URL/claimprocessor/claims" + + for BUNDLE in claim_date_of_loss claim_hail; do + BUNDLE_DIR="$SAMPLES_DIR/$BUNDLE" + BUNDLE_INFO="$BUNDLE_DIR/bundle_info.json" + + if [ ! -f "$BUNDLE_INFO" ]; then + echo " Skipping '$BUNDLE' - no bundle_info.json found." + continue + fi + + echo "" + echo " ๐Ÿ“‚ Processing bundle: $BUNDLE" + + # Step 4a: Create claim batch with schemaset ID + echo " - Creating claim batch..." + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X PUT "$CLAIM_PROCESSOR_URL" \ + -H "Content-Type: application/json" \ + -d "{\"schema_collection_id\": \"$SCHEMASET_ID\"}" \ + --connect-timeout 30) + + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" != "200" ]; then + echo " โŒ Failed to create claim batch. HTTP $HTTP_CODE" + echo " Error: $BODY" + continue + fi + + CLAIM_ID=$(echo "$BODY" | grep -o '"claim_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/') + echo " โœ… Claim batch created with ID: $CLAIM_ID" + + # Step 4b: Upload each file with its mapped schema ID + UPLOAD_SUCCESS=true + FILE_COUNT=$(cat "$BUNDLE_INFO" | grep -o '"file_name"' | wc -l) + + for fidx in $(seq 0 $((FILE_COUNT - 1))); do + FILE_NAME=$(cat "$BUNDLE_INFO" | grep -o '"file_name"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -n "$((fidx + 1))p" | sed 's/.*"\([^"]*\)"$/\1/') + SCHEMA_CLASS=$(cat "$BUNDLE_INFO" | grep -o '"schema_class"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -n "$((fidx + 1))p" | sed 's/.*"\([^"]*\)"$/\1/') + + FILE_PATH="$BUNDLE_DIR/$FILE_NAME" + + if [ ! -f "$FILE_PATH" ]; then + echo " - File '$FILE_NAME' not found. Skipping." + continue + fi + + # Look up schema ID from registered schemas + SCHEMA_ID="" + RIDX=0 + for RID in $REGISTERED_IDS; do + RIDX=$((RIDX + 1)) + RNAME=$(echo "$REGISTERED_NAMES" | tr ' ' '\n' | sed -n "${RIDX}p") + if [ "$RNAME" = "$SCHEMA_CLASS" ]; then + SCHEMA_ID="$RID" + break + fi + done + + if [ -z "$SCHEMA_ID" ]; then + echo " - No schema ID found for '$SCHEMA_CLASS'. Skipping '$FILE_NAME'." + continue + fi + + echo " - Uploading '$FILE_NAME' (schema: $SCHEMA_CLASS)..." + + DATA_JSON="{\"Claim_Id\": \"$CLAIM_ID\", \"Schema_Id\": \"$SCHEMA_ID\", \"Metadata_Id\": \"sample-$BUNDLE\"}" + + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X POST "$CLAIM_PROCESSOR_URL/$CLAIM_ID/files" \ + -F "data=$DATA_JSON" \ + -F "file=@$FILE_PATH" \ + --connect-timeout 60) + + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + + if [ "$HTTP_CODE" = "200" ]; then + echo " โœ… Uploaded '$FILE_NAME' successfully." + else + BODY=$(echo "$RESPONSE" | sed '$d') + echo " โŒ Failed to upload '$FILE_NAME'. HTTP $HTTP_CODE" + echo " Error: $BODY" + UPLOAD_SUCCESS=false + fi + done + + # Step 4c: Launch processing + if [ "$UPLOAD_SUCCESS" = true ]; then + echo " - Submitting claim batch for processing..." + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X POST "$CLAIM_PROCESSOR_URL" \ + -H "Content-Type: application/json" \ + -d "{\"claim_process_id\": \"$CLAIM_ID\"}" \ + --connect-timeout 30) + + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + + if [ "$HTTP_CODE" = "202" ]; then + echo " โœ… Claim batch '$CLAIM_ID' submitted for processing." + else + BODY=$(echo "$RESPONSE" | sed '$d') + echo " โŒ Failed to submit claim batch. HTTP $HTTP_CODE" + echo " Error: $BODY" + fi + else + echo " - Skipping batch submission due to upload failures." + fi + done + + echo "" + echo "============================================================" + echo "Sample file processing completed." + echo "============================================================" + fi fi # --- Refresh Content Understanding Cognitive Services account --- diff --git a/src/ContentProcessorAPI/samples/claim_date_of_loss/bundle_info.json b/src/ContentProcessorAPI/samples/claim_date_of_loss/bundle_info.json new file mode 100644 index 00000000..4ec4f4ac --- /dev/null +++ b/src/ContentProcessorAPI/samples/claim_date_of_loss/bundle_info.json @@ -0,0 +1,8 @@ +{ + "files": [ + { "file_name": "claim_form.pdf", "schema_class": "AutoInsuranceClaimForm" }, + { "file_name": "damage_photo.png", "schema_class": "DamagedVehicleImageAssessment" }, + { "file_name": "police_report.pdf", "schema_class": "PoliceReportDocument" }, + { "file_name": "repair_estimate.pdf", "schema_class": "RepairEstimateDocument" } + ] +} diff --git a/src/ContentProcessorAPI/samples/claim_hail/bundle_info.json b/src/ContentProcessorAPI/samples/claim_hail/bundle_info.json new file mode 100644 index 00000000..dc1dd272 --- /dev/null +++ b/src/ContentProcessorAPI/samples/claim_hail/bundle_info.json @@ -0,0 +1,7 @@ +{ + "files": [ + { "file_name": "claim_form.pdf", "schema_class": "AutoInsuranceClaimForm" }, + { "file_name": "damage_photo.png", "schema_class": "DamagedVehicleImageAssessment" }, + { "file_name": "repair_estimate.pdf", "schema_class": "RepairEstimateDocument" } + ] +} From f6df177c713f6f7560517a357aeb1aa73310af49 Mon Sep 17 00:00:00 2001 From: Don Lee Date: Mon, 27 Apr 2026 14:12:10 -0700 Subject: [PATCH 7/7] fix(deploy): resolve auth + sample-bundle issues uncovered in e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit configure_auth.sh / configure_auth.ps1: - Set globalValidation (requireAuthentication, unauthenticatedClientAction, redirectToProvider) directly in the authConfig PUT โ€” the CLI flags were not reliably populating redirectToProvider, leaving the Web app responding 401 to browser users instead of redirecting to AAD. - Explicitly POST oauth2PermissionGrants to grant the API user_impersonation scope to the Web service principal. 'az ad app permission admin-consent' silently consents Microsoft Graph only and skips custom-API delegated scopes, which made MSAL acquireTokenSilent fail and rendered a blank SPA after successful login. - Override APP_WEB_AUTHORITY env var on the Web container app so MSAL.js uses a properly-formed authority URL. - Restart Web + API container revisions after secrets/env updates so the new values take effect without a manual restart. infra/main.bicep: - Drop redundant slash in APP_WEB_AUTHORITY composition; loginEndpoint already has a trailing slash, so '${loginEndpoint}/${tenantId}' produced a double-slash URL that broke MSAL. infra/scripts/post_deployment.sh: - Fix bash array iteration in Step 4b schema-id lookup. The previous 'for RID in $REGISTERED_IDS' de-references the array as a scalar (only the first element), causing only one file per sample bundle to upload. Switched to indexed iteration with ${!REGISTERED_IDS[@]} and a name lookup against REGISTERED_NAMES[$i]. --- infra/main.bicep | 2 +- infra/scripts/configure_auth.ps1 | 56 +++++++++++++++++++++++++-- infra/scripts/configure_auth.sh | 66 ++++++++++++++++++++++++++++---- infra/scripts/post_deployment.sh | 9 ++--- 4 files changed, 116 insertions(+), 17 deletions(-) diff --git a/infra/main.bicep b/infra/main.bicep index 216088d7..6dfdd0b8 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -1196,7 +1196,7 @@ module avmContainerApp_Web 'br/public:avm/res/app/container-app:0.19.0' = { } { name: 'APP_WEB_AUTHORITY' - value: '${environment().authentication.loginEndpoint}/${tenant().tenantId}' + value: '${environment().authentication.loginEndpoint}${tenant().tenantId}' } { name: 'APP_WEB_SCOPE' diff --git a/infra/scripts/configure_auth.ps1 b/infra/scripts/configure_auth.ps1 index 1dc595ca..d57c6896 100644 --- a/infra/scripts/configure_auth.ps1 +++ b/infra/scripts/configure_auth.ps1 @@ -195,6 +195,33 @@ try { Write-Host " Or: https://login.microsoftonline.com/$TenantId/adminconsent?client_id=$WebClientId" } +# Belt-and-suspenders: explicitly grant the API user_impersonation scope to +# the Web SP. `az ad app permission admin-consent` often skips custom-API +# delegated permissions, leaving MSAL.js silent token acquisition broken +# (which causes the SPA to render a blank page after sign-in). +$WebSpId = az ad sp show --id $WebClientId --query id -o tsv 2>$null +$ApiSpId = az ad sp show --id $ApiClientId --query id -o tsv 2>$null +if ($WebSpId -and $ApiSpId) { + $existing = az rest --method get ` + --url "https://graph.microsoft.com/v1.0/servicePrincipals/$WebSpId/oauth2PermissionGrants" ` + --query "value[?resourceId=='$ApiSpId'] | [0].id" -o tsv 2>$null + if (-not $existing -or $existing -eq "null") { + $body = "{`"clientId`":`"$WebSpId`",`"consentType`":`"AllPrincipals`",`"resourceId`":`"$ApiSpId`",`"scope`":`"user_impersonation`"}" + try { + az rest --method POST ` + --url "https://graph.microsoft.com/v1.0/oauth2PermissionGrants" ` + --headers "Content-Type=application/json" ` + --body $body --output none + Write-Host " โœ“ API user_impersonation scope granted to Web SP" + } catch { + Write-Host " โš ๏ธ Could not auto-grant API user_impersonation; SPA may show blank page until granted manually." + $ConsentOk = $false + } + } else { + Write-Host " โ†บ API user_impersonation scope already granted" + } +} + # --- Step 4: Container App secrets ------------------------------------------ Write-Host "" Write-Host "โžก๏ธ Step 4/6: Client secrets" @@ -240,7 +267,7 @@ Write-Host "" Write-Host "โžก๏ธ Step 6/6: Wiring env vars and caller allowlist" az containerapp update -n $WebName -g $ResourceGroup ` - --set-env-vars "APP_WEB_CLIENT_ID=$WebClientId" "APP_WEB_SCOPE=$WebScopeValue" "APP_API_SCOPE=$ApiScopeValue" "APP_AUTH_ENABLED=true" ` + --set-env-vars "APP_WEB_CLIENT_ID=$WebClientId" "APP_WEB_SCOPE=$WebScopeValue" "APP_API_SCOPE=$ApiScopeValue" "APP_WEB_AUTHORITY=https://login.microsoftonline.com/$TenantId" "APP_AUTH_ENABLED=true" ` --output none Write-Host " โœ“ Web env vars updated" @@ -262,6 +289,19 @@ function Patch-AuthConfig($CaName, $ClientId, $AddWebAllowed) { if ($AddWebAllowed -and ($allowed -notcontains $WebClientId)) { $allowed += $WebClientId } $policy.allowedApplications = $allowed + if (-not $current.properties.platform) { $current.properties | Add-Member -MemberType NoteProperty -Name platform -Value (@{}) } + $current.properties.platform.enabled = $true + if (-not $current.properties.globalValidation) { $current.properties | Add-Member -MemberType NoteProperty -Name globalValidation -Value ([pscustomobject]@{}) } + $gv = $current.properties.globalValidation + if ($gv.PSObject.Properties.Name -notcontains 'requireAuthentication') { $gv | Add-Member -MemberType NoteProperty -Name requireAuthentication -Value $true } else { $gv.requireAuthentication = $true } + if ($AddWebAllowed) { + if ($gv.PSObject.Properties.Name -notcontains 'unauthenticatedClientAction') { $gv | Add-Member -MemberType NoteProperty -Name unauthenticatedClientAction -Value 'Return401' } else { $gv.unauthenticatedClientAction = 'Return401' } + if ($gv.PSObject.Properties.Name -contains 'redirectToProvider') { $gv.PSObject.Properties.Remove('redirectToProvider') } + } else { + if ($gv.PSObject.Properties.Name -notcontains 'unauthenticatedClientAction') { $gv | Add-Member -MemberType NoteProperty -Name unauthenticatedClientAction -Value 'RedirectToLoginPage' } else { $gv.unauthenticatedClientAction = 'RedirectToLoginPage' } + if ($gv.PSObject.Properties.Name -notcontains 'redirectToProvider') { $gv | Add-Member -MemberType NoteProperty -Name redirectToProvider -Value 'azureactivedirectory' } else { $gv.redirectToProvider = 'azureactivedirectory' } + } + $tmp = New-TemporaryFile $current | ConvertTo-Json -Depth 20 | Out-File -FilePath $tmp -Encoding utf8 Retry { az rest --method put --url $url --headers "Content-Type=application/json" --body "@$tmp" | Out-Null } @@ -272,10 +312,20 @@ Patch-AuthConfig $ApiName $ApiClientId $true Patch-AuthConfig $WebName $WebClientId $false Write-Host " โœ“ authConfigs normalized (issuer, audiences, allowedApplications)" -az containerapp auth update -n $WebName -g $ResourceGroup --unauthenticated-client-action RedirectToLoginPage --output none -az containerapp auth update -n $ApiName -g $ResourceGroup --unauthenticated-client-action Return401 --output none Write-Host " โœ“ Unauthenticated requests: Web โ†’ login, API โ†’ 401" +# Restart active revisions so containers pick up newly-set client secrets. +# (`az containerapp secret set` does NOT trigger a new revision on its own.) +function Restart-ActiveRevision($CaName) { + $rev = az containerapp revision list -n $CaName -g $ResourceGroup --query "[?properties.active] | [0].name" -o tsv 2>$null + if ($rev -and $rev -ne "null") { + az containerapp revision restart -n $CaName -g $ResourceGroup --revision $rev --output none 2>$null + } +} +Restart-ActiveRevision $WebName +Restart-ActiveRevision $ApiName +Write-Host " โœ“ Restarted Web + API container revisions to apply secrets" + Write-Host "" Write-Host "============================================================" Write-Host "๐Ÿ” Auth configuration complete." diff --git a/infra/scripts/configure_auth.sh b/infra/scripts/configure_auth.sh index 55853064..c5d72b16 100755 --- a/infra/scripts/configure_auth.sh +++ b/infra/scripts/configure_auth.sh @@ -245,6 +245,33 @@ else echo " โœ“ Admin consent granted" fi +# Belt-and-suspenders: explicitly grant the API scope to the Web SP. +# `az ad app permission admin-consent` is unreliable for app-to-app delegated +# permissions exposed by a freshly-created custom API โ€” the consent often only +# covers Microsoft Graph permissions and silently skips the API. Without the +# API grant, MSAL.js acquireTokenSilent() fails on the SPA and the page is blank. +WEB_SP_ID="$(az ad sp show --id "$WEB_CLIENT_ID" --query id -o tsv 2>/dev/null || true)" +API_SP_ID="$(az ad sp show --id "$API_CLIENT_ID" --query id -o tsv 2>/dev/null || true)" +if [[ -n "$WEB_SP_ID" && -n "$API_SP_ID" ]]; then + EXISTING_GRANT="$(az rest --method get \ + --url "https://graph.microsoft.com/v1.0/servicePrincipals/${WEB_SP_ID}/oauth2PermissionGrants" \ + --query "value[?resourceId=='${API_SP_ID}'] | [0].id" -o tsv 2>/dev/null || true)" + if [[ -z "$EXISTING_GRANT" || "$EXISTING_GRANT" == "null" ]]; then + if az rest --method POST \ + --url "https://graph.microsoft.com/v1.0/oauth2PermissionGrants" \ + --headers "Content-Type=application/json" \ + --body "{\"clientId\":\"${WEB_SP_ID}\",\"consentType\":\"AllPrincipals\",\"resourceId\":\"${API_SP_ID}\",\"scope\":\"user_impersonation\"}" \ + --output none 2>/dev/null; then + echo " โœ“ API user_impersonation scope granted to Web SP" + else + echo " โš ๏ธ Could not auto-grant API user_impersonation; SPA may show blank page until granted manually." + CONSENT_OK=false + fi + else + echo " โ†บ API user_impersonation scope already granted" + fi +fi + # ----------------------------------------------------------------------------- # Step 4: Client secrets + Container App secrets # ----------------------------------------------------------------------------- @@ -314,14 +341,17 @@ echo "" echo "โžก๏ธ Step 6/6: Wiring env vars and caller allowlist" # Update Web container env vars (other values left untouched) +# Also overwrite APP_WEB_AUTHORITY to fix a pre-existing bicep bug that produces +# a malformed authority URL (double slash before tenant id). az containerapp update -n "$WEB_NAME" -g "$RESOURCE_GROUP" \ --set-env-vars \ "APP_WEB_CLIENT_ID=$WEB_CLIENT_ID" \ "APP_WEB_SCOPE=$WEB_SCOPE_VALUE" \ "APP_API_SCOPE=$API_SCOPE_VALUE" \ + "APP_WEB_AUTHORITY=https://login.microsoftonline.com/$TENANT_ID" \ "APP_AUTH_ENABLED=true" \ --output none -echo " โœ“ Web env vars: APP_WEB_CLIENT_ID / APP_WEB_SCOPE / APP_API_SCOPE / APP_AUTH_ENABLED" +echo " โœ“ Web env vars: APP_WEB_CLIENT_ID / APP_WEB_SCOPE / APP_API_SCOPE / APP_WEB_AUTHORITY / APP_AUTH_ENABLED" # Patch both authConfigs: # - API: add Web client id to allowedApplications @@ -329,7 +359,7 @@ echo " โœ“ Web env vars: APP_WEB_CLIENT_ID / APP_WEB_SCOPE / APP_API_SCOPE / AP patch_authconfig() { local ca_name="$1" local client_id="$2" - local add_web_allowed="$3" # "true" / "false" + local add_web_allowed="$3" # "true" (API side) / "false" (Web side) local url="/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.App/containerApps/${ca_name}/authConfigs/current?api-version=2024-03-01" local cur patched cur="$(az rest --method get --url "$url")" @@ -337,6 +367,8 @@ patch_authconfig() { import json, os, sys d = json.load(sys.stdin) props = d.setdefault('properties', {}) +props['platform'] = props.get('platform') or {} +props['platform']['enabled'] = True idp = props.setdefault('identityProviders', {}) aad = idp.setdefault('azureActiveDirectory', {}) reg = aad.setdefault('registration', {}) @@ -348,6 +380,14 @@ allowed = set(policy.get('allowedApplications') or []) if os.environ['ADD_WEB'] == 'true': allowed.add(os.environ['WEB_CLIENT_ID']) policy['allowedApplications'] = sorted(allowed) +gv = props.setdefault('globalValidation', {}) +gv['requireAuthentication'] = True +if os.environ['ADD_WEB'] == 'true': + gv['unauthenticatedClientAction'] = 'Return401' + gv.pop('redirectToProvider', None) +else: + gv['unauthenticatedClientAction'] = 'RedirectToLoginPage' + gv['redirectToProvider'] = 'azureactivedirectory' print(json.dumps(d)) ")" echo "$patched" > /tmp/authconfig_patch.json @@ -361,13 +401,25 @@ patch_authconfig "$API_NAME" "$API_CLIENT_ID" "true" patch_authconfig "$WEB_NAME" "$WEB_CLIENT_ID" "false" echo " โœ“ authConfigs normalized (issuer, audiences, allowedApplications)" -# Final lockdown -az containerapp auth update -n "$WEB_NAME" -g "$RESOURCE_GROUP" \ - --unauthenticated-client-action RedirectToLoginPage --output none -az containerapp auth update -n "$API_NAME" -g "$RESOURCE_GROUP" \ - --unauthenticated-client-action Return401 --output none +# Final lockdown handled in patch_authconfig globalValidation above. echo " โœ“ Unauthenticated requests: Web โ†’ login, API โ†’ 401" +# Restart active revisions so containers pick up newly-set client secrets. +# (`az containerapp secret set` does NOT trigger a new revision on its own.) +restart_active_revision() { + local ca_name="$1" + local rev + rev="$(az containerapp revision list -n "$ca_name" -g "$RESOURCE_GROUP" \ + --query "[?properties.active] | [0].name" -o tsv 2>/dev/null || true)" + if [[ -n "$rev" && "$rev" != "null" ]]; then + az containerapp revision restart -n "$ca_name" -g "$RESOURCE_GROUP" \ + --revision "$rev" --output none 2>/dev/null || true + fi +} +restart_active_revision "$WEB_NAME" +restart_active_revision "$API_NAME" +echo " โœ“ Restarted Web + API container revisions to apply secrets" + echo "" echo "============================================================" echo "๐Ÿ” Auth configuration complete." diff --git a/infra/scripts/post_deployment.sh b/infra/scripts/post_deployment.sh index 99256487..094f6047 100644 --- a/infra/scripts/post_deployment.sh +++ b/infra/scripts/post_deployment.sh @@ -297,12 +297,9 @@ else # Look up schema ID from registered schemas SCHEMA_ID="" - RIDX=0 - for RID in $REGISTERED_IDS; do - RIDX=$((RIDX + 1)) - RNAME=$(echo "$REGISTERED_NAMES" | tr ' ' '\n' | sed -n "${RIDX}p") - if [ "$RNAME" = "$SCHEMA_CLASS" ]; then - SCHEMA_ID="$RID" + for i in "${!REGISTERED_IDS[@]}"; do + if [ "${REGISTERED_NAMES[$i]}" = "$SCHEMA_CLASS" ]; then + SCHEMA_ID="${REGISTERED_IDS[$i]}" break fi done