From fe0fe88920e208880cbdab0ec56514e97e58f09e Mon Sep 17 00:00:00 2001 From: Prajwal-Microsoft Date: Mon, 28 Sep 2026 12:02:41 +0530 Subject: [PATCH 1/5] fix: Added token validation for API calls and added script for authentication set up --- docs/ConfigureAppAuthentication.md | 49 +++ docs/DeploymentGuide.md | 40 ++- .../scripts/configure_app_authentication.ps1 | 309 ++++++++++++++++++ infra/scripts/configure_app_authentication.sh | 252 ++++++++++++++ infra/scripts/post_deployment.ps1 | 32 +- infra/scripts/post_deployment.sh | 31 +- .../samples/upload_files.ps1 | 14 +- .../samples/upload_files.sh | 14 +- 8 files changed, 726 insertions(+), 15 deletions(-) create mode 100644 infra/scripts/configure_app_authentication.ps1 create mode 100644 infra/scripts/configure_app_authentication.sh diff --git a/docs/ConfigureAppAuthentication.md b/docs/ConfigureAppAuthentication.md index 8de1c105..440b839b 100644 --- a/docs/ConfigureAppAuthentication.md +++ b/docs/ConfigureAppAuthentication.md @@ -15,6 +15,55 @@ This document provides step-by-step instructions to configure Azure App Registra - Access to **Microsoft Entra ID** - Necessary permissions to create and manage **App Registrations** +- Azure CLI (`az`) signed in to the deployment subscription (for the automated script) + +## Automated configuration (recommended) + +Authentication is configured by a standalone script, +[`infra/scripts/configure_app_authentication`](../infra/scripts/configure_app_authentication.ps1), +which you run as a **manual post-deployment step** (it is intentionally not run +during `azd up` provisioning, to avoid deployment failures). Run it immediately +after the post-deployment schema-registration step. The script performs every +step in this document without the manual portal clicks: it creates (or reuses) +the API and Web app registrations, exposes the `user_impersonation` scope, +enables Container Apps authentication (the **API is set to return HTTP 401** for +unauthenticated callers — fail closed), allows the Web client on the API, and +updates the Web container environment variables. + +Run it from the project root: + +```powershell +# PowerShell (Windows) +.\infra\scripts\configure_app_authentication.ps1 +``` + +```bash +# Bash (Linux/macOS/WSL) +bash infra/scripts/configure_app_authentication.sh +``` + +To reuse existing app registrations instead of creating new ones, pass their +client ids: + +```powershell +.\infra\scripts\configure_app_authentication.ps1 -ApiClientId -WebClientId +``` + +> **Permissions:** Creating app registrations and granting admin consent +> requires the **Application Administrator** role (or equivalent). If the script +> cannot grant admin consent automatically it prints a warning; a tenant +> administrator must then consent to the API permission for the Web app. See the +> admin-consent note in [Step 2](#step-2-configure-application-registration---web-application). + +> **Web interactive login:** The script configures the Web app's identity +> provider. If your tenant requires a client secret for the browser sign-in +> (authorization-code) flow, add one with +> `az containerapp auth microsoft update --name --resource-group --client-secret `, +> or complete Step 1 for the Web app through the portal. The **API** protection +> (HTTP 401 for unauthenticated callers) does not require a secret. + +The remaining sections describe the equivalent **manual portal steps**, kept as +a fallback for environments where the script cannot be run. ## Step 1: Add Authentication Provider diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index 695cbfe1..4f4e7d5e 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -331,6 +331,12 @@ azd up .\infra\scripts\post_deployment.ps1 ``` + > **Note:** Run this schema-registration step **before** configuring + > authentication (next step). At this point the API is still open, so no token + > is required. If you re-run it **after** enabling authentication, the script + > authenticates automatically using the deploying user's token — just ensure + > you are signed in with `az login`. + ### 5.2 Schema Registration (Automatic) > Want to customize the schemas for your own documents? [Learn more about adding your own schemas here.](./CustomizeSchemaData.md) @@ -391,12 +397,38 @@ Schema registration process completed. ✅ Schema registration complete. ``` -### 5.2 Configure Authentication (Required) +### 5.2 Configure Authentication (Required Manual Step) + +**This step is mandatory.** Until it is completed the API has external ingress +and is reachable without authentication, so run it immediately after the +post-deployment script. Authentication is configured by a standalone script. + +Run the configuration script from the project root: + +- For Bash (Linux/macOS/WSL): + + ```bash + bash infra/scripts/configure_app_authentication.sh + ``` + +- For PowerShell (Windows): + + ```powershell + .\infra\scripts\configure_app_authentication.ps1 + ``` -**This step is mandatory for application access:** +This enables Microsoft Entra ID (Easy Auth) on the API and Web container apps and +sets the **API to return HTTP 401** for unauthenticated callers (fail closed). +To reuse existing app registrations, pass their client ids +(`-ApiClientId` / `-WebClientId` in PowerShell). For details, options, the +admin-consent requirement, and the manual portal fallback, see +[App Authentication Configuration](./ConfigureAppAuthentication.md). -1. Follow [App Authentication Configuration](./ConfigureAppAuthentication.md). -2. Wait up to 10 minutes for authentication changes to take effect. +> **Note:** Allow up to 10 minutes for authentication changes to take effect. +> Creating app registrations and granting admin consent requires the +> **Application Administrator** role; if admin consent cannot be granted +> automatically, a tenant administrator must consent to the API permission for +> the Web app. ### 5.3 Verify Deployment diff --git a/infra/scripts/configure_app_authentication.ps1 b/infra/scripts/configure_app_authentication.ps1 new file mode 100644 index 00000000..0e4e3252 --- /dev/null +++ b/infra/scripts/configure_app_authentication.ps1 @@ -0,0 +1,309 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# +.SYNOPSIS + Configures Microsoft Entra ID (Easy Auth) authentication on the deployed + API and Web Container Apps, automating the manual portal steps described in + docs/ConfigureAppAuthentication.md. + +.DESCRIPTION + This script is intended to run AFTER `azd up` (or `azd provision`). It: + + 1. Reads Container App / resource-group values from the azd environment. + 2. Creates (or reuses) two Entra app registrations: one for the API + (resource server) and one for the Web SPA (client). + 3. Exposes a `user_impersonation` scope on the API app registration. + 4. Grants the Web app permission to call the API and attempts admin + consent (best effort). + 5. Enables Container Apps authentication: + - API -> unauthenticated requests receive HTTP 401 (fail closed). + - Web -> unauthenticated requests are redirected to login. + 6. Adds the Web client id to the API's allowed client applications. + 7. Updates the Web container app environment variables + (APP_WEB_CLIENT_ID, APP_WEB_SCOPE, APP_API_SCOPE). + + The script is idempotent: existing registrations and settings are reused. + + NOTE ON TIMING: This is a manual post-deployment step. Run it immediately + after the post-deployment schema-registration script. Until it completes, the + API has external ingress and is reachable without authentication, so do not + defer it. It is intentionally not wired into the azd provisioning hooks, to + avoid deployment-time failures. + +.PARAMETER ApiClientId + Optional. Reuse an existing API app registration client id instead of + creating a new one. + +.PARAMETER WebClientId + Optional. Reuse an existing Web app registration client id instead of + creating a new one. + +.PARAMETER TenantId + Optional. Entra tenant id. Defaults to the current `az account` tenant. + +.EXAMPLE + ./configure_app_authentication.ps1 + +.EXAMPLE + ./configure_app_authentication.ps1 -ApiClientId -WebClientId +#> + +[CmdletBinding()] +param( + [string]$ApiClientId, + [string]$WebClientId, + [string]$TenantId +) + +$ErrorActionPreference = "Stop" + +function Write-Step { + param([string]$Message) + Write-Host "" + Write-Host ("=" * 70) + Write-Host $Message + Write-Host ("=" * 70) +} + +function Get-AzdValue { + param([string]$Key) + try { + $value = azd env get-value $Key 2>$null + if ($LASTEXITCODE -eq 0 -and $value -and $value -notmatch "not found") { + return $value.Trim() + } + } catch { + # fall through + } + return $null +} + +# --------------------------------------------------------------------------- +# Step 0: Resolve context from the azd environment +# --------------------------------------------------------------------------- +Write-Step "Step 0: Resolving deployment context from azd environment" + +$ResourceGroup = Get-AzdValue "AZURE_RESOURCE_GROUP" +$SubscriptionId = Get-AzdValue "AZURE_SUBSCRIPTION_ID" +$ApiAppName = Get-AzdValue "CONTAINER_API_APP_NAME" +$ApiAppFqdn = Get-AzdValue "CONTAINER_API_APP_FQDN" +$WebAppName = Get-AzdValue "CONTAINER_WEB_APP_NAME" +$WebAppFqdn = Get-AzdValue "CONTAINER_WEB_APP_FQDN" + +if (-not $TenantId) { + $TenantId = Get-AzdValue "AZURE_TENANT_ID" +} +if (-not $TenantId) { + $TenantId = az account show --query tenantId --output tsv +} + +foreach ($pair in @( + @{ Name = "AZURE_RESOURCE_GROUP"; Value = $ResourceGroup }, + @{ Name = "CONTAINER_API_APP_NAME"; Value = $ApiAppName }, + @{ Name = "CONTAINER_API_APP_FQDN"; Value = $ApiAppFqdn }, + @{ Name = "CONTAINER_WEB_APP_NAME"; Value = $WebAppName }, + @{ Name = "CONTAINER_WEB_APP_FQDN"; Value = $WebAppFqdn } +)) { + if (-not $pair.Value) { + throw "Required value '$($pair.Name)' could not be resolved from the azd environment. Run 'azd provision' first." + } +} + +if ($SubscriptionId) { + az account set --subscription $SubscriptionId | Out-Null +} + +$ApiUri = "https://$ApiAppFqdn" +$WebUri = "https://$WebAppFqdn" + +Write-Host " Resource group : $ResourceGroup" +Write-Host " Tenant : $TenantId" +Write-Host " API app : $ApiAppName ($ApiUri)" +Write-Host " Web app : $WebAppName ($WebUri)" + +# --------------------------------------------------------------------------- +# Step 1: API app registration (resource server) + exposed scope +# --------------------------------------------------------------------------- +Write-Step "Step 1: Configuring API app registration" + +if (-not $ApiClientId) { + $ApiClientId = Get-AzdValue "API_CLIENT_ID" +} + +if (-not $ApiClientId) { + Write-Host " Creating API app registration '$ApiAppName'..." + $ApiClientId = az ad app create ` + --display-name $ApiAppName ` + --sign-in-audience AzureADMyOrg ` + --query appId --output tsv +} else { + Write-Host " Reusing API app registration: $ApiClientId" +} + +# Ensure the Application ID URI is api:// so tokens carry a stable audience. +$ApiIdentifierUri = "api://$ApiClientId" +az ad app update --id $ApiClientId --identifier-uris $ApiIdentifierUri | Out-Null + +# Expose a user_impersonation scope (idempotent: skip if already present). +$existingScopes = az ad app show --id $ApiClientId --query "api.oauth2PermissionScopes[].value" --output tsv +if ($existingScopes -notcontains "user_impersonation") { + Write-Host " Exposing 'user_impersonation' scope on the API app..." + $scopeId = [guid]::NewGuid().ToString() + $apiScopes = @{ + oauth2PermissionScopes = @( + @{ + id = $scopeId + adminConsentDescription = "Allow the application to access the Content Processing API on behalf of the signed-in user." + adminConsentDisplayName = "Access Content Processing API" + userConsentDescription = "Allow the application to access the Content Processing API on your behalf." + userConsentDisplayName = "Access Content Processing API" + value = "user_impersonation" + type = "User" + isEnabled = $true + } + ) + } + $apiScopesJson = ($apiScopes | ConvertTo-Json -Depth 10 -Compress) + $tmp = New-TemporaryFile + Set-Content -Path $tmp -Value $apiScopesJson -Encoding utf8 + az ad app update --id $ApiClientId --set "api=@$tmp" | Out-Null + Remove-Item $tmp -Force +} else { + Write-Host " 'user_impersonation' scope already exposed." +} + +# Ensure a service principal exists for the API app. +$apiSp = az ad sp show --id $ApiClientId --query id --output tsv 2>$null +if (-not $apiSp) { + az ad sp create --id $ApiClientId | Out-Null +} + +$ApiScope = "$ApiIdentifierUri/user_impersonation" +Write-Host " API scope: $ApiScope" + +# --------------------------------------------------------------------------- +# Step 2: Web app registration (SPA client) +# --------------------------------------------------------------------------- +Write-Step "Step 2: Configuring Web app registration" + +if (-not $WebClientId) { + $WebClientId = Get-AzdValue "WEB_CLIENT_ID" +} + +if (-not $WebClientId) { + Write-Host " Creating Web app registration '$WebAppName'..." + $WebClientId = az ad app create ` + --display-name $WebAppName ` + --sign-in-audience AzureADMyOrg ` + --query appId --output tsv +} else { + Write-Host " Reusing Web app registration: $WebClientId" +} + +# Register the SPA redirect URI so MSAL can complete the login flow. +Write-Host " Setting SPA redirect URI: $WebUri" +az ad app update --id $WebClientId --set "spa={`"redirectUris`":[`"$WebUri`"]}" | Out-Null + +# Grant the Web app permission to call the API's user_impersonation scope. +$scopeGuid = az ad app show --id $ApiClientId --query "api.oauth2PermissionScopes[?value=='user_impersonation'].id | [0]" --output tsv +Write-Host " Adding API permission to the Web app..." +az ad app permission add ` + --id $WebClientId ` + --api $ApiClientId ` + --api-permissions "$scopeGuid=Scope" | Out-Null + +$webSp = az ad sp show --id $WebClientId --query id --output tsv 2>$null +if (-not $webSp) { + az ad sp create --id $WebClientId | Out-Null +} + +Write-Host " Attempting admin consent (best effort)..." +try { + az ad app permission admin-consent --id $WebClientId | Out-Null + Write-Host " Admin consent granted." +} catch { + Write-Warning " Could not grant admin consent automatically. A tenant administrator must consent to the API permission for '$WebAppName'. See docs/ConfigureAppAuthentication.md." +} + +# --------------------------------------------------------------------------- +# Step 3: Enable Container Apps authentication +# --------------------------------------------------------------------------- +Write-Step "Step 3: Enabling Container Apps authentication (Easy Auth)" + +$Issuer = "https://sts.windows.net/$TenantId/" + +# API: fail closed. Unauthenticated callers get HTTP 401 (no login redirect). +Write-Host " Configuring API authentication (Return401)..." +az containerapp auth microsoft update ` + --name $ApiAppName ` + --resource-group $ResourceGroup ` + --client-id $ApiClientId ` + --issuer $Issuer ` + --allowed-audiences $ApiIdentifierUri ` + --yes | Out-Null + +az containerapp auth update ` + --name $ApiAppName ` + --resource-group $ResourceGroup ` + --unauthenticated-client-action Return401 ` + --redirect-provider AzureActiveDirectory ` + --yes | Out-Null + +# Web: redirect unauthenticated browser users to the login page. +Write-Host " Configuring Web authentication (RedirectToLoginPage)..." +az containerapp auth microsoft update ` + --name $WebAppName ` + --resource-group $ResourceGroup ` + --client-id $WebClientId ` + --issuer $Issuer ` + --yes | Out-Null + +az containerapp auth update ` + --name $WebAppName ` + --resource-group $ResourceGroup ` + --unauthenticated-client-action RedirectToLoginPage ` + --redirect-provider AzureActiveDirectory ` + --yes | Out-Null + +# --------------------------------------------------------------------------- +# Step 4: Allow the Web client to call the API +# --------------------------------------------------------------------------- +Write-Step "Step 4: Allowing the Web client on the API" + +Write-Host " Adding Web client id to the API allowed client applications..." +az containerapp auth microsoft update ` + --name $ApiAppName ` + --resource-group $ResourceGroup ` + --client-id $ApiClientId ` + --issuer $Issuer ` + --allowed-audiences $ApiIdentifierUri ` + --allowed-client-applications $WebClientId ` + --yes | Out-Null + +# --------------------------------------------------------------------------- +# Step 5: Update Web container environment variables +# --------------------------------------------------------------------------- +Write-Step "Step 5: Updating Web container environment variables" + +az containerapp update ` + --name $WebAppName ` + --resource-group $ResourceGroup ` + --set-env-vars ` + "APP_WEB_CLIENT_ID=$WebClientId" ` + "APP_WEB_SCOPE=$ApiScope" ` + "APP_API_SCOPE=$ApiScope" | Out-Null + +# Persist the resulting client ids back into the azd environment for reuse. +if (Get-Command azd -ErrorAction SilentlyContinue) { + azd env set API_CLIENT_ID $ApiClientId 2>$null | Out-Null + azd env set WEB_CLIENT_ID $WebClientId 2>$null | Out-Null +} + +Write-Step "Authentication configuration complete" +Write-Host " API client id : $ApiClientId" +Write-Host " Web client id : $WebClientId" +Write-Host " API scope : $ApiScope" +Write-Host "" +Write-Host " The API now returns HTTP 401 to unauthenticated callers." +Write-Host " Note: post-deployment data ingestion scripts must send a bearer token." diff --git a/infra/scripts/configure_app_authentication.sh b/infra/scripts/configure_app_authentication.sh new file mode 100644 index 00000000..d0e30004 --- /dev/null +++ b/infra/scripts/configure_app_authentication.sh @@ -0,0 +1,252 @@ +#!/bin/bash +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# Configures Microsoft Entra ID (Easy Auth) authentication on the deployed API +# and Web Container Apps, automating the manual portal steps described in +# docs/ConfigureAppAuthentication.md. +# +# Run AFTER `azd up` (or `azd provision`). Idempotent: existing registrations +# and settings are reused. +# +# TIMING: This is a manual post-deployment step. Run it immediately after the +# post-deployment schema-registration script. Until it completes, the API has +# external ingress and is reachable without authentication, so do not defer it. +# It is intentionally not wired into the azd provisioning hooks, to avoid +# deployment-time failures. +# +# Usage: +# ./configure_app_authentication.sh +# API_CLIENT_ID= WEB_CLIENT_ID= ./configure_app_authentication.sh + +set -euo pipefail + +step() { + echo "" + echo "======================================================================" + echo "$1" + echo "======================================================================" +} + +get_azd_value() { + local key="$1" + local value + value=$(azd env get-value "$key" 2>/dev/null || echo "") + if [ -n "$value" ] && [[ "$value" != *"not found"* ]]; then + echo "$value" + fi +} + +# --------------------------------------------------------------------------- +# Step 0: Resolve context from the azd environment +# --------------------------------------------------------------------------- +step "Step 0: Resolving deployment context from azd environment" + +RESOURCE_GROUP=$(get_azd_value "AZURE_RESOURCE_GROUP") +SUBSCRIPTION_ID=$(get_azd_value "AZURE_SUBSCRIPTION_ID") +API_APP_NAME=$(get_azd_value "CONTAINER_API_APP_NAME") +API_APP_FQDN=$(get_azd_value "CONTAINER_API_APP_FQDN") +WEB_APP_NAME=$(get_azd_value "CONTAINER_WEB_APP_NAME") +WEB_APP_FQDN=$(get_azd_value "CONTAINER_WEB_APP_FQDN") + +TENANT_ID="${TENANT_ID:-$(get_azd_value "AZURE_TENANT_ID")}" +if [ -z "$TENANT_ID" ]; then + TENANT_ID=$(az account show --query tenantId --output tsv) +fi + +for pair in \ + "AZURE_RESOURCE_GROUP=$RESOURCE_GROUP" \ + "CONTAINER_API_APP_NAME=$API_APP_NAME" \ + "CONTAINER_API_APP_FQDN=$API_APP_FQDN" \ + "CONTAINER_WEB_APP_NAME=$WEB_APP_NAME" \ + "CONTAINER_WEB_APP_FQDN=$WEB_APP_FQDN"; do + name="${pair%%=*}" + value="${pair#*=}" + if [ -z "$value" ]; then + echo "Error: required value '$name' could not be resolved from the azd environment. Run 'azd provision' first." >&2 + exit 1 + fi +done + +if [ -n "$SUBSCRIPTION_ID" ]; then + az account set --subscription "$SUBSCRIPTION_ID" +fi + +API_URI="https://$API_APP_FQDN" +WEB_URI="https://$WEB_APP_FQDN" + +echo " Resource group : $RESOURCE_GROUP" +echo " Tenant : $TENANT_ID" +echo " API app : $API_APP_NAME ($API_URI)" +echo " Web app : $WEB_APP_NAME ($WEB_URI)" + +# --------------------------------------------------------------------------- +# Step 1: API app registration (resource server) + exposed scope +# --------------------------------------------------------------------------- +step "Step 1: Configuring API app registration" + +API_CLIENT_ID="${API_CLIENT_ID:-$(get_azd_value "API_CLIENT_ID")}" + +if [ -z "$API_CLIENT_ID" ]; then + echo " Creating API app registration '$API_APP_NAME'..." + API_CLIENT_ID=$(az ad app create \ + --display-name "$API_APP_NAME" \ + --sign-in-audience AzureADMyOrg \ + --query appId --output tsv) +else + echo " Reusing API app registration: $API_CLIENT_ID" +fi + +API_IDENTIFIER_URI="api://$API_CLIENT_ID" +az ad app update --id "$API_CLIENT_ID" --identifier-uris "$API_IDENTIFIER_URI" + +EXISTING_SCOPES=$(az ad app show --id "$API_CLIENT_ID" --query "api.oauth2PermissionScopes[].value" --output tsv || echo "") +if ! echo "$EXISTING_SCOPES" | grep -q "user_impersonation"; then + echo " Exposing 'user_impersonation' scope on the API app..." + SCOPE_ID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || python -c "import uuid;print(uuid.uuid4())") + TMP_API=$(mktemp) + cat > "$TMP_API" </dev/null 2>&1; then + az ad sp create --id "$API_CLIENT_ID" +fi + +API_SCOPE="$API_IDENTIFIER_URI/user_impersonation" +echo " API scope: $API_SCOPE" + +# --------------------------------------------------------------------------- +# Step 2: Web app registration (SPA client) +# --------------------------------------------------------------------------- +step "Step 2: Configuring Web app registration" + +WEB_CLIENT_ID="${WEB_CLIENT_ID:-$(get_azd_value "WEB_CLIENT_ID")}" + +if [ -z "$WEB_CLIENT_ID" ]; then + echo " Creating Web app registration '$WEB_APP_NAME'..." + WEB_CLIENT_ID=$(az ad app create \ + --display-name "$WEB_APP_NAME" \ + --sign-in-audience AzureADMyOrg \ + --query appId --output tsv) +else + echo " Reusing Web app registration: $WEB_CLIENT_ID" +fi + +echo " Setting SPA redirect URI: $WEB_URI" +az ad app update --id "$WEB_CLIENT_ID" --set "spa={\"redirectUris\":[\"$WEB_URI\"]}" + +SCOPE_GUID=$(az ad app show --id "$API_CLIENT_ID" --query "api.oauth2PermissionScopes[?value=='user_impersonation'].id | [0]" --output tsv) +echo " Adding API permission to the Web app..." +az ad app permission add \ + --id "$WEB_CLIENT_ID" \ + --api "$API_CLIENT_ID" \ + --api-permissions "$SCOPE_GUID=Scope" + +if ! az ad sp show --id "$WEB_CLIENT_ID" --query id --output tsv >/dev/null 2>&1; then + az ad sp create --id "$WEB_CLIENT_ID" +fi + +echo " Attempting admin consent (best effort)..." +if az ad app permission admin-consent --id "$WEB_CLIENT_ID" 2>/dev/null; then + echo " Admin consent granted." +else + echo " WARNING: Could not grant admin consent automatically. A tenant administrator must consent to the API permission for '$WEB_APP_NAME'. See docs/ConfigureAppAuthentication.md." >&2 +fi + +# --------------------------------------------------------------------------- +# Step 3: Enable Container Apps authentication +# --------------------------------------------------------------------------- +step "Step 3: Enabling Container Apps authentication (Easy Auth)" + +ISSUER="https://sts.windows.net/$TENANT_ID/" + +echo " Configuring API authentication (Return401)..." +az containerapp auth microsoft update \ + --name "$API_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --client-id "$API_CLIENT_ID" \ + --issuer "$ISSUER" \ + --allowed-audiences "$API_IDENTIFIER_URI" \ + --yes + +az containerapp auth update \ + --name "$API_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --unauthenticated-client-action Return401 \ + --redirect-provider AzureActiveDirectory \ + --yes + +echo " Configuring Web authentication (RedirectToLoginPage)..." +az containerapp auth microsoft update \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --client-id "$WEB_CLIENT_ID" \ + --issuer "$ISSUER" \ + --yes + +az containerapp auth update \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --unauthenticated-client-action RedirectToLoginPage \ + --redirect-provider AzureActiveDirectory \ + --yes + +# --------------------------------------------------------------------------- +# Step 4: Allow the Web client to call the API +# --------------------------------------------------------------------------- +step "Step 4: Allowing the Web client on the API" + +echo " Adding Web client id to the API allowed client applications..." +az containerapp auth microsoft update \ + --name "$API_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --client-id "$API_CLIENT_ID" \ + --issuer "$ISSUER" \ + --allowed-audiences "$API_IDENTIFIER_URI" \ + --allowed-client-applications "$WEB_CLIENT_ID" \ + --yes + +# --------------------------------------------------------------------------- +# Step 5: Update Web container environment variables +# --------------------------------------------------------------------------- +step "Step 5: Updating Web container environment variables" + +az containerapp update \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --set-env-vars \ + "APP_WEB_CLIENT_ID=$WEB_CLIENT_ID" \ + "APP_WEB_SCOPE=$API_SCOPE" \ + "APP_API_SCOPE=$API_SCOPE" + +if command -v azd >/dev/null 2>&1; then + azd env set API_CLIENT_ID "$API_CLIENT_ID" 2>/dev/null || true + azd env set WEB_CLIENT_ID "$WEB_CLIENT_ID" 2>/dev/null || true +fi + +step "Authentication configuration complete" +echo " API client id : $API_CLIENT_ID" +echo " Web client id : $WEB_CLIENT_ID" +echo " API scope : $API_SCOPE" +echo "" +echo " The API now returns HTTP 401 to unauthenticated callers." +echo " Note: post-deployment data ingestion scripts must send a bearer token." diff --git a/infra/scripts/post_deployment.ps1 b/infra/scripts/post_deployment.ps1 index fb4c3a5f..44a631f1 100644 --- a/infra/scripts/post_deployment.ps1 +++ b/infra/scripts/post_deployment.ps1 @@ -57,9 +57,30 @@ $RetryInterval = 15 $ApiBaseUrl = "https://$CONTAINER_API_APP_FQDN" $ApiReady = $false +# Acquire a bearer token for the API when authentication has been configured. +# API_CLIENT_ID is written to the azd environment by configure_app_authentication. +# On the first deployment (before auth is configured) it is absent, so this +# script proceeds unauthenticated against the still-open API. After auth is +# configured, subsequent runs authenticate with the deploying user's token. +$AuthHeaders = @{} +$ApiClientId = azd env get-value API_CLIENT_ID 2>$null +if ($LASTEXITCODE -eq 0 -and $ApiClientId -and $ApiClientId -notmatch "not found") { + $ApiClientId = $ApiClientId.Trim() + Write-Host " [Auth] Acquiring access token for API (api://$ApiClientId)..." + $AccessToken = az account get-access-token --resource "api://$ApiClientId" --query accessToken --output tsv 2>$null + if ($LASTEXITCODE -eq 0 -and $AccessToken) { + $AuthHeaders = @{ Authorization = "Bearer $($AccessToken.Trim())" } + Write-Host " [Auth] Access token acquired." + } else { + Write-Host " [Auth] Warning: could not acquire an access token. Proceeding without authentication." + } +} + for ($i = 1; $i -le $MaxRetries; $i++) { try { - $response = Invoke-WebRequest -Uri "$ApiBaseUrl/schemavault/" -Method GET -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop + # Probe the anonymous startup endpoint so readiness works regardless of + # whether authentication has been configured on the API. + $response = Invoke-WebRequest -Uri "$ApiBaseUrl/startup" -Method GET -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop if ($response.StatusCode -eq 200) { Write-Host " [OK] API is ready." $ApiReady = $true @@ -92,7 +113,7 @@ if (-not $ApiReady) { # Fetch existing schemas $ExistingSchemas = @() try { - $ExistingSchemas = Invoke-RestMethod -Uri $SchemaVaultUrl -Method GET -TimeoutSec 30 -ErrorAction Stop + $ExistingSchemas = Invoke-RestMethod -Uri $SchemaVaultUrl -Method GET -Headers $AuthHeaders -TimeoutSec 30 -ErrorAction Stop Write-Host "Fetched $($ExistingSchemas.Count) existing schema(s)." } catch { Write-Host "Warning: Could not fetch existing schemas. Proceeding..." @@ -154,6 +175,7 @@ if (-not $ApiReady) { try { $resp = Invoke-RestMethod -Uri $SchemaVaultUrl -Method POST ` -ContentType "multipart/form-data; boundary=$boundary" ` + -Headers $AuthHeaders ` -Body $bodyLines -TimeoutSec 60 -ErrorAction Stop $schemaId = $resp.Id Write-Host " Successfully registered: $Description's Schema Id - $schemaId" @@ -174,7 +196,7 @@ if (-not $ApiReady) { $ExistingSets = @() try { - $ExistingSets = Invoke-RestMethod -Uri $SchemaSetVaultUrl -Method GET -TimeoutSec 30 -ErrorAction Stop + $ExistingSets = Invoke-RestMethod -Uri $SchemaSetVaultUrl -Method GET -Headers $AuthHeaders -TimeoutSec 30 -ErrorAction Stop Write-Host "Fetched $($ExistingSets.Count) existing schema set(s)." } catch { Write-Host "Warning: Could not fetch existing schema sets. Proceeding..." @@ -190,6 +212,7 @@ if (-not $ApiReady) { try { $setResp = Invoke-RestMethod -Uri $SchemaSetVaultUrl -Method POST ` -ContentType "application/json" ` + -Headers $AuthHeaders ` -Body (@{ Name = $SetName; Description = $SetDesc } | ConvertTo-Json) ` -TimeoutSec 30 -ErrorAction Stop $SchemaSetId = $setResp.Id @@ -210,7 +233,7 @@ if (-not $ApiReady) { $AlreadyInSet = @() try { - $AlreadyInSet = Invoke-RestMethod -Uri "$SchemaSetVaultUrl$SchemaSetId/schemas" -Method GET -TimeoutSec 30 -ErrorAction Stop + $AlreadyInSet = Invoke-RestMethod -Uri "$SchemaSetVaultUrl$SchemaSetId/schemas" -Method GET -Headers $AuthHeaders -TimeoutSec 30 -ErrorAction Stop } catch { } $AlreadyInSetIds = $AlreadyInSet | ForEach-Object { $_.Id } @@ -224,6 +247,7 @@ if (-not $ApiReady) { try { Invoke-RestMethod -Uri "$SchemaSetVaultUrl$SchemaSetId/schemas" -Method POST ` -ContentType "application/json" ` + -Headers $AuthHeaders ` -Body (@{ SchemaId = $schemaId } | ConvertTo-Json) ` -TimeoutSec 30 -ErrorAction Stop | Out-Null Write-Host " Added '$className' ($schemaId) to schema set" diff --git a/infra/scripts/post_deployment.sh b/infra/scripts/post_deployment.sh index 43f23f14..3c94a09b 100755 --- a/infra/scripts/post_deployment.sh +++ b/infra/scripts/post_deployment.sh @@ -66,8 +66,28 @@ MAX_RETRIES=10 RETRY_INTERVAL=15 API_BASE_URL="https://$CONTAINER_API_APP_FQDN" +# Acquire a bearer token for the API when authentication has been configured. +# API_CLIENT_ID is written to the azd environment by configure_app_authentication. +# On the first deployment (before auth is configured) it is absent, so this +# script proceeds unauthenticated against the still-open API. After auth is +# configured, subsequent runs authenticate with the deploying user's token. +AUTH_ARGS=() +API_CLIENT_ID=$(azd env get-value API_CLIENT_ID 2>/dev/null || echo "") +if [ -n "$API_CLIENT_ID" ] && [[ "$API_CLIENT_ID" != *"not found"* ]]; then + echo " [Auth] Acquiring access token for API (api://$API_CLIENT_ID)..." + ACCESS_TOKEN=$(az account get-access-token --resource "api://$API_CLIENT_ID" --query accessToken --output tsv 2>/dev/null || echo "") + if [ -n "$ACCESS_TOKEN" ]; then + AUTH_ARGS=(-H "Authorization: Bearer $ACCESS_TOKEN") + echo " [Auth] Access token acquired." + else + echo " [Auth] Warning: could not acquire an access token. Proceeding without authentication." + fi +fi + for i in $(seq 1 $MAX_RETRIES); do - STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$API_BASE_URL/schemavault/" 2>/dev/null || echo "000") + # Probe the anonymous startup endpoint so readiness works regardless of + # whether authentication has been configured on the API. + STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$API_BASE_URL/startup" 2>/dev/null || echo "000") if [ "$STATUS" = "200" ]; then echo " ✅ API is ready." break @@ -92,7 +112,7 @@ else echo "============================================================" # Fetch existing schemas - EXISTING_SCHEMAS=$(curl -s "$SCHEMAVAULT_URL" 2>/dev/null || echo "[]") + EXISTING_SCHEMAS=$(curl -s "${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"}" "$SCHEMAVAULT_URL" 2>/dev/null || echo "[]") EXISTING_COUNT=$(echo "$EXISTING_SCHEMAS" | grep -o '"Id"' | wc -l) echo "Fetched $EXISTING_COUNT existing schema(s)." @@ -146,6 +166,7 @@ else CONTENT_TYPE="application/json" RESPONSE=$(curl -s -w "\n%{http_code}" \ + "${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"}" \ -X POST "$SCHEMAVAULT_URL" \ -F "data=$DATA_PAYLOAD" \ -F "file=@$SCHEMA_FILE;type=$CONTENT_TYPE" \ @@ -176,7 +197,7 @@ else SET_DESC=$(cat "$SCHEMA_INFO_FILE" | grep -A3 '"schemaset"' | grep '"Description"' | sed 's/.*"Description"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/') # Fetch existing schema sets - EXISTING_SETS=$(curl -s "$SCHEMASETVAULT_URL" 2>/dev/null || echo "[]") + EXISTING_SETS=$(curl -s "${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"}" "$SCHEMASETVAULT_URL" 2>/dev/null || echo "[]") SCHEMASET_ID="" if echo "$EXISTING_SETS" | grep -q "\"Name\"[[:space:]]*:[[:space:]]*\"$SET_NAME\""; then @@ -185,6 +206,7 @@ else else echo " Creating schema set '$SET_NAME'..." RESPONSE=$(curl -s -w "\n%{http_code}" \ + "${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"}" \ -X POST "$SCHEMASETVAULT_URL" \ -H "Content-Type: application/json" \ -d "{\"Name\": \"$SET_NAME\", \"Description\": \"$SET_DESC\"}" \ @@ -211,7 +233,7 @@ else echo "Step 3: Add schemas to schema set" echo "============================================================" - ALREADY_IN_SET=$(curl -s "${SCHEMASETVAULT_URL}${SCHEMASET_ID}/schemas" 2>/dev/null || echo "[]") + ALREADY_IN_SET=$(curl -s "${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"}" "${SCHEMASETVAULT_URL}${SCHEMASET_ID}/schemas" 2>/dev/null || echo "[]") # Iterate over registered schemas for i in "${!REGISTERED_IDS[@]}"; do @@ -224,6 +246,7 @@ else fi RESPONSE=$(curl -s -w "\n%{http_code}" \ + "${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"}" \ -X POST "${SCHEMASETVAULT_URL}${SCHEMASET_ID}/schemas" \ -H "Content-Type: application/json" \ -d "{\"SchemaId\": \"$SCHEMA_ID\"}" \ diff --git a/src/ContentProcessorAPI/samples/upload_files.ps1 b/src/ContentProcessorAPI/samples/upload_files.ps1 index e5feda97..d78ed3e6 100644 --- a/src/ContentProcessorAPI/samples/upload_files.ps1 +++ b/src/ContentProcessorAPI/samples/upload_files.ps1 @@ -6,7 +6,12 @@ param ( [string]$FolderPath, [Parameter(Mandatory = $true)] - [string]$SchemaId + [string]$SchemaId, + + # Optional bearer token used when the API has authentication enabled. + # Acquire with: az account get-access-token --resource api:// --query accessToken -o tsv + [Parameter(Mandatory = $false)] + [string]$AccessToken ) # Validate if the folder exists @@ -21,6 +26,13 @@ Add-Type -AssemblyName System.Net.Http # Create an HttpClient instance $httpClient = New-Object System.Net.Http.HttpClient +# Attach the bearer token when provided so uploads succeed against an +# authentication-protected API. +if ($AccessToken) { + $httpClient.DefaultRequestHeaders.Authorization = ` + New-Object System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", $AccessToken) +} + # Function to determine the MIME type based on file extension function Get-MimeType { param ([string]$FileName) diff --git a/src/ContentProcessorAPI/samples/upload_files.sh b/src/ContentProcessorAPI/samples/upload_files.sh index 480ff1f2..fff9e832 100755 --- a/src/ContentProcessorAPI/samples/upload_files.sh +++ b/src/ContentProcessorAPI/samples/upload_files.sh @@ -1,8 +1,8 @@ #!/bin/bash # Check if the correct number of arguments is provided -if [ "$#" -ne 3 ]; then - echo "Usage: $0 " +if [ "$#" -lt 3 ] || [ "$#" -gt 4 ]; then + echo "Usage: $0 [ACCESS_TOKEN]" exit 1 fi @@ -10,6 +10,15 @@ fi API_ENDPOINT_URL=$1 FOLDER_PATH=$2 SCHEMA_ID=$3 +# Optional bearer token, from the 4th positional arg or the ACCESS_TOKEN env var, +# used when the API has authentication enabled. Acquire with: +# az account get-access-token --resource api:// --query accessToken -o tsv +ACCESS_TOKEN="${4:-${ACCESS_TOKEN:-}}" + +AUTH_ARGS=() +if [ -n "$ACCESS_TOKEN" ]; then + AUTH_ARGS=(-H "Authorization: Bearer $ACCESS_TOKEN") +fi # Validate if the folder exists if [ ! -d "$FOLDER_PATH" ]; then @@ -34,6 +43,7 @@ for FILE in "$FOLDER_PATH"/*; do # Invoke the API with multipart/form-data RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X POST "$API_ENDPOINT_URL" \ + "${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"}" \ -H "Content-Type: multipart/form-data" \ -F "file=@$FILE;filename=$FILENAME" \ -F "data=$DATA_JSON") From 75f24e75bbcd0979b5620e4cc1bba36cba58d391 Mon Sep 17 00:00:00 2001 From: Vamshi-Microsoft Date: Mon, 28 Sep 2026 20:59:41 +0530 Subject: [PATCH 2/5] fix: Improve SPA redirect URI registration and enable ID token issuance for Easy Auth --- .../scripts/configure_app_authentication.ps1 | 38 ++++++++++++++++++- infra/scripts/configure_app_authentication.sh | 29 +++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/infra/scripts/configure_app_authentication.ps1 b/infra/scripts/configure_app_authentication.ps1 index 0e4e3252..14e3aee5 100644 --- a/infra/scripts/configure_app_authentication.ps1 +++ b/infra/scripts/configure_app_authentication.ps1 @@ -202,8 +202,44 @@ if (-not $WebClientId) { } # Register the SPA redirect URI so MSAL can complete the login flow. +# Use a Microsoft Graph PATCH via `az rest`; `az ad app update --set spa=...` +# is unreliable and fails with "Property spa in payload does not match schema". +# The JSON body is written to a temp file (--body @file) because passing inline +# JSON to az on Windows gets mangled by shell quoting. Write-Host " Setting SPA redirect URI: $WebUri" -az ad app update --id $WebClientId --set "spa={`"redirectUris`":[`"$WebUri`"]}" | Out-Null +$webObjectId = az ad app show --id $WebClientId --query id --output tsv +$spaBody = @{ spa = @{ redirectUris = @($WebUri) } } | ConvertTo-Json -Compress -Depth 5 +$spaBodyFile = New-TemporaryFile +Set-Content -Path $spaBodyFile -Value $spaBody -Encoding utf8 -NoNewline +try { + az rest ` + --method PATCH ` + --uri "https://graph.microsoft.com/v1.0/applications/$webObjectId" ` + --headers "Content-Type=application/json" ` + --body "@$spaBodyFile" | Out-Null +} finally { + Remove-Item -Path $spaBodyFile -ErrorAction SilentlyContinue +} + +# Enable ID token issuance for the implicit grant. Container Apps Easy Auth +# requests an id_token during the login redirect; without this Entra returns +# AADSTS700054 "response_type 'id_token' is not enabled for the application". +# The Easy Auth callback (/.auth/login/aad/callback) must also be registered as +# a Web-platform redirect URI, otherwise login fails with AADSTS50011. +Write-Host " Enabling ID token issuance and Web redirect URI on the Web app..." +$easyAuthRedirect = "$WebUri/.auth/login/aad/callback" +$implicitBody = @{ web = @{ redirectUris = @($easyAuthRedirect); implicitGrantSettings = @{ enableIdTokenIssuance = $true } } } | ConvertTo-Json -Compress -Depth 5 +$implicitBodyFile = New-TemporaryFile +Set-Content -Path $implicitBodyFile -Value $implicitBody -Encoding utf8 -NoNewline +try { + az rest ` + --method PATCH ` + --uri "https://graph.microsoft.com/v1.0/applications/$webObjectId" ` + --headers "Content-Type=application/json" ` + --body "@$implicitBodyFile" | Out-Null +} finally { + Remove-Item -Path $implicitBodyFile -ErrorAction SilentlyContinue +} # Grant the Web app permission to call the API's user_impersonation scope. $scopeGuid = az ad app show --id $ApiClientId --query "api.oauth2PermissionScopes[?value=='user_impersonation'].id | [0]" --output tsv diff --git a/infra/scripts/configure_app_authentication.sh b/infra/scripts/configure_app_authentication.sh index d0e30004..dd6e7d2b 100644 --- a/infra/scripts/configure_app_authentication.sh +++ b/infra/scripts/configure_app_authentication.sh @@ -151,8 +151,35 @@ else echo " Reusing Web app registration: $WEB_CLIENT_ID" fi +# Register the SPA redirect URI via a Microsoft Graph PATCH; `az ad app update +# --set spa=...` is unreliable and fails with "Property spa in payload does not +# match schema". The JSON body is written to a temp file (--body @file) to avoid +# shell-quoting issues. echo " Setting SPA redirect URI: $WEB_URI" -az ad app update --id "$WEB_CLIENT_ID" --set "spa={\"redirectUris\":[\"$WEB_URI\"]}" +WEB_OBJECT_ID=$(az ad app show --id "$WEB_CLIENT_ID" --query id --output tsv) +SPA_BODY_FILE=$(mktemp) +printf '{"spa":{"redirectUris":["%s"]}}' "$WEB_URI" > "$SPA_BODY_FILE" +az rest \ + --method PATCH \ + --uri "https://graph.microsoft.com/v1.0/applications/$WEB_OBJECT_ID" \ + --headers "Content-Type=application/json" \ + --body "@$SPA_BODY_FILE" +rm -f "$SPA_BODY_FILE" + +# Enable ID token issuance for the implicit grant. Container Apps Easy Auth +# requests an id_token during the login redirect; without this Entra returns +# AADSTS700054 "response_type 'id_token' is not enabled for the application". +# The Easy Auth callback (/.auth/login/aad/callback) must also be registered as +# a Web-platform redirect URI, otherwise login fails with AADSTS50011. +echo " Enabling ID token issuance and Web redirect URI on the Web app..." +IMPLICIT_BODY_FILE=$(mktemp) +printf '{"web":{"redirectUris":["%s/.auth/login/aad/callback"],"implicitGrantSettings":{"enableIdTokenIssuance":true}}}' "$WEB_URI" > "$IMPLICIT_BODY_FILE" +az rest \ + --method PATCH \ + --uri "https://graph.microsoft.com/v1.0/applications/$WEB_OBJECT_ID" \ + --headers "Content-Type=application/json" \ + --body "@$IMPLICIT_BODY_FILE" +rm -f "$IMPLICIT_BODY_FILE" SCOPE_GUID=$(az ad app show --id "$API_CLIENT_ID" --query "api.oauth2PermissionScopes[?value=='user_impersonation'].id | [0]" --output tsv) echo " Adding API permission to the Web app..." From 5cf1eedac9b84fd0728feaf98df0b7d6cac60b44 Mon Sep 17 00:00:00 2001 From: Vamshi-Microsoft Date: Mon, 28 Sep 2026 21:16:10 +0530 Subject: [PATCH 3/5] fix: Update Web client application configuration for API authentication --- .../scripts/configure_app_authentication.ps1 | 33 ++++++++++++++----- infra/scripts/configure_app_authentication.sh | 24 +++++++++----- 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/infra/scripts/configure_app_authentication.ps1 b/infra/scripts/configure_app_authentication.ps1 index 14e3aee5..775494dd 100644 --- a/infra/scripts/configure_app_authentication.ps1 +++ b/infra/scripts/configure_app_authentication.ps1 @@ -307,15 +307,32 @@ az containerapp auth update ` # --------------------------------------------------------------------------- Write-Step "Step 4: Allowing the Web client on the API" +# The `--allowed-client-applications` flag is not available in older containerapp +# CLI extensions. The authConfigs resource does not support PATCH, so GET the +# current config, merge in the allowed application, and PUT it back. Write-Host " Adding Web client id to the API allowed client applications..." -az containerapp auth microsoft update ` - --name $ApiAppName ` - --resource-group $ResourceGroup ` - --client-id $ApiClientId ` - --issuer $Issuer ` - --allowed-audiences $ApiIdentifierUri ` - --allowed-client-applications $WebClientId ` - --yes | Out-Null +$authConfigUri = "https://management.azure.com/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.App/containerApps/$ApiAppName/authConfigs/current?api-version=2024-03-01" +$authConfig = az rest --method GET --uri $authConfigUri | ConvertFrom-Json +$aad = $authConfig.properties.identityProviders.azureActiveDirectory +if (-not $aad.validation) { + $aad | Add-Member -NotePropertyName validation -NotePropertyValue ([pscustomobject]@{}) -Force +} +if (-not $aad.validation.defaultAuthorizationPolicy) { + $aad.validation | Add-Member -NotePropertyName defaultAuthorizationPolicy -NotePropertyValue ([pscustomobject]@{}) -Force +} +$aad.validation.defaultAuthorizationPolicy | Add-Member -NotePropertyName allowedApplications -NotePropertyValue @($WebClientId) -Force +$allowedAppsBody = @{ properties = $authConfig.properties } | ConvertTo-Json -Compress -Depth 20 +$allowedAppsFile = New-TemporaryFile +Set-Content -Path $allowedAppsFile -Value $allowedAppsBody -Encoding utf8 -NoNewline +try { + az rest ` + --method PUT ` + --uri $authConfigUri ` + --headers "Content-Type=application/json" ` + --body "@$allowedAppsFile" | Out-Null +} finally { + Remove-Item -Path $allowedAppsFile -ErrorAction SilentlyContinue +} # --------------------------------------------------------------------------- # Step 5: Update Web container environment variables diff --git a/infra/scripts/configure_app_authentication.sh b/infra/scripts/configure_app_authentication.sh index dd6e7d2b..83f7e11c 100644 --- a/infra/scripts/configure_app_authentication.sh +++ b/infra/scripts/configure_app_authentication.sh @@ -242,15 +242,23 @@ az containerapp auth update \ # --------------------------------------------------------------------------- step "Step 4: Allowing the Web client on the API" +# The --allowed-client-applications flag is not available in older containerapp +# CLI extensions. The authConfigs resource does not support PATCH, so GET the +# current config, merge in the allowed application with jq, and PUT it back. echo " Adding Web client id to the API allowed client applications..." -az containerapp auth microsoft update \ - --name "$API_APP_NAME" \ - --resource-group "$RESOURCE_GROUP" \ - --client-id "$API_CLIENT_ID" \ - --issuer "$ISSUER" \ - --allowed-audiences "$API_IDENTIFIER_URI" \ - --allowed-client-applications "$WEB_CLIENT_ID" \ - --yes +AUTH_CONFIG_URI="https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.App/containerApps/$API_APP_NAME/authConfigs/current?api-version=2024-03-01" +CURRENT_AUTH_FILE=$(mktemp) +ALLOWED_APPS_FILE=$(mktemp) +az rest --method GET --uri "$AUTH_CONFIG_URI" > "$CURRENT_AUTH_FILE" +jq --arg app "$WEB_CLIENT_ID" \ + '{properties: (.properties | .identityProviders.azureActiveDirectory.validation.defaultAuthorizationPolicy.allowedApplications = [$app])}' \ + "$CURRENT_AUTH_FILE" > "$ALLOWED_APPS_FILE" +az rest \ + --method PUT \ + --uri "$AUTH_CONFIG_URI" \ + --headers "Content-Type=application/json" \ + --body "@$ALLOWED_APPS_FILE" +rm -f "$CURRENT_AUTH_FILE" "$ALLOWED_APPS_FILE" # --------------------------------------------------------------------------- # Step 5: Update Web container environment variables From 1abd1efef22a4a091e6811bbd27764da6dfc648a Mon Sep 17 00:00:00 2001 From: Prajwal-Microsoft Date: Wed, 30 Sep 2026 09:50:19 +0530 Subject: [PATCH 4/5] fix: Copilot comments --- docs/DeploymentGuide.md | 6 +- .../scripts/configure_app_authentication.ps1 | 70 ++++++++++++------- infra/scripts/configure_app_authentication.sh | 54 ++++++++------ infra/scripts/post_deployment.ps1 | 22 ++++-- infra/scripts/post_deployment.sh | 25 +++++-- 5 files changed, 113 insertions(+), 64 deletions(-) diff --git a/docs/DeploymentGuide.md b/docs/DeploymentGuide.md index 4f4e7d5e..163a1553 100644 --- a/docs/DeploymentGuide.md +++ b/docs/DeploymentGuide.md @@ -397,7 +397,7 @@ Schema registration process completed. ✅ Schema registration complete. ``` -### 5.2 Configure Authentication (Required Manual Step) +### 5.3 Configure Authentication (Required Manual Step) **This step is mandatory.** Until it is completed the API has external ingress and is reachable without authentication, so run it immediately after the @@ -430,13 +430,13 @@ admin-consent requirement, and the manual portal fallback, see > automatically, a tenant administrator must consent to the API permission for > the Web app. -### 5.3 Verify Deployment +### 5.4 Verify Deployment 1. Access your application using the **Web App Endpoint** from the deployment output. 2. Confirm the application loads successfully. 3. Verify you can sign in with your authenticated account. -### 5.4 Test the Application +### 5.5 Test the Application **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. diff --git a/infra/scripts/configure_app_authentication.ps1 b/infra/scripts/configure_app_authentication.ps1 index 775494dd..805539da 100644 --- a/infra/scripts/configure_app_authentication.ps1 +++ b/infra/scripts/configure_app_authentication.ps1 @@ -146,29 +146,39 @@ $ApiIdentifierUri = "api://$ApiClientId" az ad app update --id $ApiClientId --identifier-uris $ApiIdentifierUri | Out-Null # Expose a user_impersonation scope (idempotent: skip if already present). -$existingScopes = az ad app show --id $ApiClientId --query "api.oauth2PermissionScopes[].value" --output tsv -if ($existingScopes -notcontains "user_impersonation") { +# Merge the new scope into the existing api object so that reusing an existing +# registration preserves any other scopes and api settings already configured. +$apiObjectId = az ad app show --id $ApiClientId --query id --output tsv +$apiObj = az ad app show --id $ApiClientId --query api --output json | ConvertFrom-Json +if (-not $apiObj) { $apiObj = [pscustomobject]@{ oauth2PermissionScopes = @() } } +$existingScopeList = @() +if ($apiObj.oauth2PermissionScopes) { $existingScopeList = @($apiObj.oauth2PermissionScopes) } +$existingScopeValues = @($existingScopeList | ForEach-Object { $_.value }) +if ($existingScopeValues -notcontains "user_impersonation") { Write-Host " Exposing 'user_impersonation' scope on the API app..." - $scopeId = [guid]::NewGuid().ToString() - $apiScopes = @{ - oauth2PermissionScopes = @( - @{ - id = $scopeId - adminConsentDescription = "Allow the application to access the Content Processing API on behalf of the signed-in user." - adminConsentDisplayName = "Access Content Processing API" - userConsentDescription = "Allow the application to access the Content Processing API on your behalf." - userConsentDisplayName = "Access Content Processing API" - value = "user_impersonation" - type = "User" - isEnabled = $true - } - ) + $newScope = [pscustomobject]@{ + id = [guid]::NewGuid().ToString() + adminConsentDescription = "Allow the application to access the Content Processing API on behalf of the signed-in user." + adminConsentDisplayName = "Access Content Processing API" + userConsentDescription = "Allow the application to access the Content Processing API on your behalf." + userConsentDisplayName = "Access Content Processing API" + value = "user_impersonation" + type = "User" + isEnabled = $true + } + $apiObj | Add-Member -NotePropertyName oauth2PermissionScopes -NotePropertyValue (@($existingScopeList) + $newScope) -Force + $apiPatchBody = @{ api = $apiObj } | ConvertTo-Json -Depth 20 -Compress + $apiPatchFile = New-TemporaryFile + Set-Content -Path $apiPatchFile -Value $apiPatchBody -Encoding utf8 -NoNewline + try { + az rest ` + --method PATCH ` + --uri "https://graph.microsoft.com/v1.0/applications/$apiObjectId" ` + --headers "Content-Type=application/json" ` + --body "@$apiPatchFile" | Out-Null + } finally { + Remove-Item -Path $apiPatchFile -ErrorAction SilentlyContinue } - $apiScopesJson = ($apiScopes | ConvertTo-Json -Depth 10 -Compress) - $tmp = New-TemporaryFile - Set-Content -Path $tmp -Value $apiScopesJson -Encoding utf8 - az ad app update --id $ApiClientId --set "api=@$tmp" | Out-Null - Remove-Item $tmp -Force } else { Write-Host " 'user_impersonation' scope already exposed." } @@ -208,7 +218,11 @@ if (-not $WebClientId) { # JSON to az on Windows gets mangled by shell quoting. Write-Host " Setting SPA redirect URI: $WebUri" $webObjectId = az ad app show --id $WebClientId --query id --output tsv -$spaBody = @{ spa = @{ redirectUris = @($WebUri) } } | ConvertTo-Json -Compress -Depth 5 +$existingSpaUris = az ad app show --id $WebClientId --query "spa.redirectUris" --output json | ConvertFrom-Json +$spaUris = @() +if ($existingSpaUris) { $spaUris = @($existingSpaUris) } +if ($spaUris -notcontains $WebUri) { $spaUris += $WebUri } +$spaBody = @{ spa = @{ redirectUris = $spaUris } } | ConvertTo-Json -Compress -Depth 5 $spaBodyFile = New-TemporaryFile Set-Content -Path $spaBodyFile -Value $spaBody -Encoding utf8 -NoNewline try { @@ -228,7 +242,11 @@ try { # a Web-platform redirect URI, otherwise login fails with AADSTS50011. Write-Host " Enabling ID token issuance and Web redirect URI on the Web app..." $easyAuthRedirect = "$WebUri/.auth/login/aad/callback" -$implicitBody = @{ web = @{ redirectUris = @($easyAuthRedirect); implicitGrantSettings = @{ enableIdTokenIssuance = $true } } } | ConvertTo-Json -Compress -Depth 5 +$existingWebUris = az ad app show --id $WebClientId --query "web.redirectUris" --output json | ConvertFrom-Json +$webUris = @() +if ($existingWebUris) { $webUris = @($existingWebUris) } +if ($webUris -notcontains $easyAuthRedirect) { $webUris += $easyAuthRedirect } +$implicitBody = @{ web = @{ redirectUris = $webUris; implicitGrantSettings = @{ enableIdTokenIssuance = $true } } } | ConvertTo-Json -Compress -Depth 5 $implicitBodyFile = New-TemporaryFile Set-Content -Path $implicitBodyFile -Value $implicitBody -Encoding utf8 -NoNewline try { @@ -255,10 +273,10 @@ if (-not $webSp) { } Write-Host " Attempting admin consent (best effort)..." -try { - az ad app permission admin-consent --id $WebClientId | Out-Null +az ad app permission admin-consent --id $WebClientId 2>$null | Out-Null +if ($LASTEXITCODE -eq 0) { Write-Host " Admin consent granted." -} catch { +} else { Write-Warning " Could not grant admin consent automatically. A tenant administrator must consent to the API permission for '$WebAppName'. See docs/ConfigureAppAuthentication.md." } diff --git a/infra/scripts/configure_app_authentication.sh b/infra/scripts/configure_app_authentication.sh index 83f7e11c..eb592251 100644 --- a/infra/scripts/configure_app_authentication.sh +++ b/infra/scripts/configure_app_authentication.sh @@ -104,25 +104,30 @@ EXISTING_SCOPES=$(az ad app show --id "$API_CLIENT_ID" --query "api.oauth2Permis if ! echo "$EXISTING_SCOPES" | grep -q "user_impersonation"; then echo " Exposing 'user_impersonation' scope on the API app..." SCOPE_ID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || python -c "import uuid;print(uuid.uuid4())") - TMP_API=$(mktemp) - cat > "$TMP_API" < "$CURRENT_API_FILE" + jq --arg id "$SCOPE_ID" \ + '{api: (. + {oauth2PermissionScopes: ((.oauth2PermissionScopes // []) + [{ + id: $id, + adminConsentDescription: "Allow the application to access the Content Processing API on behalf of the signed-in user.", + adminConsentDisplayName: "Access Content Processing API", + userConsentDescription: "Allow the application to access the Content Processing API on your behalf.", + userConsentDisplayName: "Access Content Processing API", + value: "user_impersonation", + type: "User", + isEnabled: true + }])})}' \ + "$CURRENT_API_FILE" > "$MERGED_API_FILE" + az rest \ + --method PATCH \ + --uri "https://graph.microsoft.com/v1.0/applications/$API_OBJECT_ID" \ + --headers "Content-Type=application/json" \ + --body "@$MERGED_API_FILE" + rm -f "$CURRENT_API_FILE" "$MERGED_API_FILE" else echo " 'user_impersonation' scope already exposed." fi @@ -158,7 +163,10 @@ fi echo " Setting SPA redirect URI: $WEB_URI" WEB_OBJECT_ID=$(az ad app show --id "$WEB_CLIENT_ID" --query id --output tsv) SPA_BODY_FILE=$(mktemp) -printf '{"spa":{"redirectUris":["%s"]}}' "$WEB_URI" > "$SPA_BODY_FILE" +# Merge with existing SPA redirect URIs (deduped) so reusing a registration does +# not remove previously configured URIs. +az ad app show --id "$WEB_CLIENT_ID" --query "spa.redirectUris" --output json \ + | jq --arg u "$WEB_URI" '{spa: {redirectUris: ((. // []) + [$u] | unique)}}' > "$SPA_BODY_FILE" az rest \ --method PATCH \ --uri "https://graph.microsoft.com/v1.0/applications/$WEB_OBJECT_ID" \ @@ -173,7 +181,11 @@ rm -f "$SPA_BODY_FILE" # a Web-platform redirect URI, otherwise login fails with AADSTS50011. echo " Enabling ID token issuance and Web redirect URI on the Web app..." IMPLICIT_BODY_FILE=$(mktemp) -printf '{"web":{"redirectUris":["%s/.auth/login/aad/callback"],"implicitGrantSettings":{"enableIdTokenIssuance":true}}}' "$WEB_URI" > "$IMPLICIT_BODY_FILE" +# Merge with existing Web redirect URIs (deduped) so reusing a registration does +# not remove previously configured URIs. +az ad app show --id "$WEB_CLIENT_ID" --query "web.redirectUris" --output json \ + | jq --arg u "$WEB_URI/.auth/login/aad/callback" \ + '{web: {redirectUris: ((. // []) + [$u] | unique), implicitGrantSettings: {enableIdTokenIssuance: true}}}' > "$IMPLICIT_BODY_FILE" az rest \ --method PATCH \ --uri "https://graph.microsoft.com/v1.0/applications/$WEB_OBJECT_ID" \ diff --git a/infra/scripts/post_deployment.ps1 b/infra/scripts/post_deployment.ps1 index 44a631f1..9c7d6361 100644 --- a/infra/scripts/post_deployment.ps1 +++ b/infra/scripts/post_deployment.ps1 @@ -59,12 +59,16 @@ $ApiReady = $false # Acquire a bearer token for the API when authentication has been configured. # API_CLIENT_ID is written to the azd environment by configure_app_authentication. -# On the first deployment (before auth is configured) it is absent, so this -# script proceeds unauthenticated against the still-open API. After auth is -# configured, subsequent runs authenticate with the deploying user's token. +# - First deployment (before auth is configured): API_CLIENT_ID is absent, so +# this script proceeds unauthenticated against the still-open API. +# - After auth is configured: API_CLIENT_ID is present and the API returns 401 to +# unauthenticated callers. A token is then REQUIRED; if it cannot be acquired we +# fail fast rather than silently 401 through every schema operation. $AuthHeaders = @{} +$AuthRequired = $false $ApiClientId = azd env get-value API_CLIENT_ID 2>$null if ($LASTEXITCODE -eq 0 -and $ApiClientId -and $ApiClientId -notmatch "not found") { + $AuthRequired = $true $ApiClientId = $ApiClientId.Trim() Write-Host " [Auth] Acquiring access token for API (api://$ApiClientId)..." $AccessToken = az account get-access-token --resource "api://$ApiClientId" --query accessToken --output tsv 2>$null @@ -72,15 +76,16 @@ if ($LASTEXITCODE -eq 0 -and $ApiClientId -and $ApiClientId -notmatch "not found $AuthHeaders = @{ Authorization = "Bearer $($AccessToken.Trim())" } Write-Host " [Auth] Access token acquired." } else { - Write-Host " [Auth] Warning: could not acquire an access token. Proceeding without authentication." + throw "Authentication is configured (API_CLIENT_ID='$ApiClientId') but an access token for 'api://$ApiClientId' could not be acquired. Sign in with 'az login' as a principal permitted to call the API and re-run. Refusing to continue unauthenticated because every schema operation would fail with HTTP 401." } } for ($i = 1; $i -le $MaxRetries; $i++) { try { - # Probe the anonymous startup endpoint so readiness works regardless of - # whether authentication has been configured on the API. - $response = Invoke-WebRequest -Uri "$ApiBaseUrl/startup" -Method GET -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop + # Probe the startup endpoint to confirm readiness. When authentication is + # enabled the API returns 401 to anonymous callers (Return401 does not + # exclude /startup), so send the acquired auth headers on the probe. + $response = Invoke-WebRequest -Uri "$ApiBaseUrl/startup" -Method GET -Headers $AuthHeaders -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop if ($response.StatusCode -eq 200) { Write-Host " [OK] API is ready." $ApiReady = $true @@ -94,6 +99,9 @@ for ($i = 1; $i -le $MaxRetries; $i++) { } if (-not $ApiReady) { + if ($AuthRequired) { + throw "API did not become ready after $MaxRetries authenticated attempts. Aborting schema registration (the API is authentication-protected; verify the deploying principal is permitted to call it)." + } Write-Host " API did not become ready after $MaxRetries attempts. Skipping schema registration." Write-Host " Run manually after the API is ready." } else { diff --git a/infra/scripts/post_deployment.sh b/infra/scripts/post_deployment.sh index 3c94a09b..28ab5945 100755 --- a/infra/scripts/post_deployment.sh +++ b/infra/scripts/post_deployment.sh @@ -68,26 +68,33 @@ API_BASE_URL="https://$CONTAINER_API_APP_FQDN" # Acquire a bearer token for the API when authentication has been configured. # API_CLIENT_ID is written to the azd environment by configure_app_authentication. -# On the first deployment (before auth is configured) it is absent, so this -# script proceeds unauthenticated against the still-open API. After auth is -# configured, subsequent runs authenticate with the deploying user's token. +# - First deployment (before auth is configured): API_CLIENT_ID is absent, so +# this script proceeds unauthenticated against the still-open API. +# - After auth is configured: API_CLIENT_ID is present and the API returns 401 to +# unauthenticated callers. A token is then REQUIRED; if it cannot be acquired we +# fail fast rather than silently 401 through every schema operation. AUTH_ARGS=() +AUTH_REQUIRED=false API_CLIENT_ID=$(azd env get-value API_CLIENT_ID 2>/dev/null || echo "") if [ -n "$API_CLIENT_ID" ] && [[ "$API_CLIENT_ID" != *"not found"* ]]; then + AUTH_REQUIRED=true echo " [Auth] Acquiring access token for API (api://$API_CLIENT_ID)..." ACCESS_TOKEN=$(az account get-access-token --resource "api://$API_CLIENT_ID" --query accessToken --output tsv 2>/dev/null || echo "") if [ -n "$ACCESS_TOKEN" ]; then AUTH_ARGS=(-H "Authorization: Bearer $ACCESS_TOKEN") echo " [Auth] Access token acquired." else - echo " [Auth] Warning: could not acquire an access token. Proceeding without authentication." + echo " [Auth] ERROR: authentication is configured (API_CLIENT_ID='$API_CLIENT_ID') but an access token for 'api://$API_CLIENT_ID' could not be acquired." >&2 + echo " Sign in with 'az login' as a principal permitted to call the API and re-run. Refusing to continue unauthenticated because every schema operation would fail with HTTP 401." >&2 + exit 1 fi fi for i in $(seq 1 $MAX_RETRIES); do - # Probe the anonymous startup endpoint so readiness works regardless of - # whether authentication has been configured on the API. - STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$API_BASE_URL/startup" 2>/dev/null || echo "000") + # Probe the startup endpoint to confirm readiness. When authentication is + # enabled the API returns 401 to anonymous callers (Return401 does not exclude + # /startup), so send the acquired auth headers on the probe. + STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH_ARGS[@]+"${AUTH_ARGS[@]}"}" "$API_BASE_URL/startup" 2>/dev/null || echo "000") if [ "$STATUS" = "200" ]; then echo " ✅ API is ready." break @@ -97,6 +104,10 @@ for i in $(seq 1 $MAX_RETRIES); do done if [ "$STATUS" != "200" ]; then + if [ "$AUTH_REQUIRED" = true ]; then + echo " ERROR: API did not become ready after $MAX_RETRIES authenticated attempts. Aborting schema registration (the API is authentication-protected; verify the deploying principal is permitted to call it)." >&2 + exit 1 + fi echo " API did not become ready after $MAX_RETRIES attempts. Skipping schema registration." echo " Run manually after the API is ready." else From 05dc0d53e76300a7f71b6e6ba55d16298890eeb1 Mon Sep 17 00:00:00 2001 From: Vamshi-Microsoft Date: Thu, 1 Oct 2026 12:15:41 +0530 Subject: [PATCH 5/5] fix: normalize configure_app_authentication.sh to LF line endings The committed blob used CRLF, causing 'bash infra/scripts/configure_app_authentication.sh' to fail on Linux/macOS/WSL/CI with \$'\r': command not found before any Azure command runs. Convert to LF so the documented invocation works cross-platform. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- infra/scripts/configure_app_authentication.sh | 598 +++++++++--------- 1 file changed, 299 insertions(+), 299 deletions(-) diff --git a/infra/scripts/configure_app_authentication.sh b/infra/scripts/configure_app_authentication.sh index eb592251..e2dbaefb 100644 --- a/infra/scripts/configure_app_authentication.sh +++ b/infra/scripts/configure_app_authentication.sh @@ -1,299 +1,299 @@ -#!/bin/bash -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. -# -# Configures Microsoft Entra ID (Easy Auth) authentication on the deployed API -# and Web Container Apps, automating the manual portal steps described in -# docs/ConfigureAppAuthentication.md. -# -# Run AFTER `azd up` (or `azd provision`). Idempotent: existing registrations -# and settings are reused. -# -# TIMING: This is a manual post-deployment step. Run it immediately after the -# post-deployment schema-registration script. Until it completes, the API has -# external ingress and is reachable without authentication, so do not defer it. -# It is intentionally not wired into the azd provisioning hooks, to avoid -# deployment-time failures. -# -# Usage: -# ./configure_app_authentication.sh -# API_CLIENT_ID= WEB_CLIENT_ID= ./configure_app_authentication.sh - -set -euo pipefail - -step() { - echo "" - echo "======================================================================" - echo "$1" - echo "======================================================================" -} - -get_azd_value() { - local key="$1" - local value - value=$(azd env get-value "$key" 2>/dev/null || echo "") - if [ -n "$value" ] && [[ "$value" != *"not found"* ]]; then - echo "$value" - fi -} - -# --------------------------------------------------------------------------- -# Step 0: Resolve context from the azd environment -# --------------------------------------------------------------------------- -step "Step 0: Resolving deployment context from azd environment" - -RESOURCE_GROUP=$(get_azd_value "AZURE_RESOURCE_GROUP") -SUBSCRIPTION_ID=$(get_azd_value "AZURE_SUBSCRIPTION_ID") -API_APP_NAME=$(get_azd_value "CONTAINER_API_APP_NAME") -API_APP_FQDN=$(get_azd_value "CONTAINER_API_APP_FQDN") -WEB_APP_NAME=$(get_azd_value "CONTAINER_WEB_APP_NAME") -WEB_APP_FQDN=$(get_azd_value "CONTAINER_WEB_APP_FQDN") - -TENANT_ID="${TENANT_ID:-$(get_azd_value "AZURE_TENANT_ID")}" -if [ -z "$TENANT_ID" ]; then - TENANT_ID=$(az account show --query tenantId --output tsv) -fi - -for pair in \ - "AZURE_RESOURCE_GROUP=$RESOURCE_GROUP" \ - "CONTAINER_API_APP_NAME=$API_APP_NAME" \ - "CONTAINER_API_APP_FQDN=$API_APP_FQDN" \ - "CONTAINER_WEB_APP_NAME=$WEB_APP_NAME" \ - "CONTAINER_WEB_APP_FQDN=$WEB_APP_FQDN"; do - name="${pair%%=*}" - value="${pair#*=}" - if [ -z "$value" ]; then - echo "Error: required value '$name' could not be resolved from the azd environment. Run 'azd provision' first." >&2 - exit 1 - fi -done - -if [ -n "$SUBSCRIPTION_ID" ]; then - az account set --subscription "$SUBSCRIPTION_ID" -fi - -API_URI="https://$API_APP_FQDN" -WEB_URI="https://$WEB_APP_FQDN" - -echo " Resource group : $RESOURCE_GROUP" -echo " Tenant : $TENANT_ID" -echo " API app : $API_APP_NAME ($API_URI)" -echo " Web app : $WEB_APP_NAME ($WEB_URI)" - -# --------------------------------------------------------------------------- -# Step 1: API app registration (resource server) + exposed scope -# --------------------------------------------------------------------------- -step "Step 1: Configuring API app registration" - -API_CLIENT_ID="${API_CLIENT_ID:-$(get_azd_value "API_CLIENT_ID")}" - -if [ -z "$API_CLIENT_ID" ]; then - echo " Creating API app registration '$API_APP_NAME'..." - API_CLIENT_ID=$(az ad app create \ - --display-name "$API_APP_NAME" \ - --sign-in-audience AzureADMyOrg \ - --query appId --output tsv) -else - echo " Reusing API app registration: $API_CLIENT_ID" -fi - -API_IDENTIFIER_URI="api://$API_CLIENT_ID" -az ad app update --id "$API_CLIENT_ID" --identifier-uris "$API_IDENTIFIER_URI" - -EXISTING_SCOPES=$(az ad app show --id "$API_CLIENT_ID" --query "api.oauth2PermissionScopes[].value" --output tsv || echo "") -if ! echo "$EXISTING_SCOPES" | grep -q "user_impersonation"; then - echo " Exposing 'user_impersonation' scope on the API app..." - SCOPE_ID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || python -c "import uuid;print(uuid.uuid4())") - # Merge the new scope into the existing api object so that reusing an existing - # registration preserves any other scopes and api settings already configured. - API_OBJECT_ID=$(az ad app show --id "$API_CLIENT_ID" --query id --output tsv) - CURRENT_API_FILE=$(mktemp) - MERGED_API_FILE=$(mktemp) - az ad app show --id "$API_CLIENT_ID" --query api --output json > "$CURRENT_API_FILE" - jq --arg id "$SCOPE_ID" \ - '{api: (. + {oauth2PermissionScopes: ((.oauth2PermissionScopes // []) + [{ - id: $id, - adminConsentDescription: "Allow the application to access the Content Processing API on behalf of the signed-in user.", - adminConsentDisplayName: "Access Content Processing API", - userConsentDescription: "Allow the application to access the Content Processing API on your behalf.", - userConsentDisplayName: "Access Content Processing API", - value: "user_impersonation", - type: "User", - isEnabled: true - }])})}' \ - "$CURRENT_API_FILE" > "$MERGED_API_FILE" - az rest \ - --method PATCH \ - --uri "https://graph.microsoft.com/v1.0/applications/$API_OBJECT_ID" \ - --headers "Content-Type=application/json" \ - --body "@$MERGED_API_FILE" - rm -f "$CURRENT_API_FILE" "$MERGED_API_FILE" -else - echo " 'user_impersonation' scope already exposed." -fi - -if ! az ad sp show --id "$API_CLIENT_ID" --query id --output tsv >/dev/null 2>&1; then - az ad sp create --id "$API_CLIENT_ID" -fi - -API_SCOPE="$API_IDENTIFIER_URI/user_impersonation" -echo " API scope: $API_SCOPE" - -# --------------------------------------------------------------------------- -# Step 2: Web app registration (SPA client) -# --------------------------------------------------------------------------- -step "Step 2: Configuring Web app registration" - -WEB_CLIENT_ID="${WEB_CLIENT_ID:-$(get_azd_value "WEB_CLIENT_ID")}" - -if [ -z "$WEB_CLIENT_ID" ]; then - echo " Creating Web app registration '$WEB_APP_NAME'..." - WEB_CLIENT_ID=$(az ad app create \ - --display-name "$WEB_APP_NAME" \ - --sign-in-audience AzureADMyOrg \ - --query appId --output tsv) -else - echo " Reusing Web app registration: $WEB_CLIENT_ID" -fi - -# Register the SPA redirect URI via a Microsoft Graph PATCH; `az ad app update -# --set spa=...` is unreliable and fails with "Property spa in payload does not -# match schema". The JSON body is written to a temp file (--body @file) to avoid -# shell-quoting issues. -echo " Setting SPA redirect URI: $WEB_URI" -WEB_OBJECT_ID=$(az ad app show --id "$WEB_CLIENT_ID" --query id --output tsv) -SPA_BODY_FILE=$(mktemp) -# Merge with existing SPA redirect URIs (deduped) so reusing a registration does -# not remove previously configured URIs. -az ad app show --id "$WEB_CLIENT_ID" --query "spa.redirectUris" --output json \ - | jq --arg u "$WEB_URI" '{spa: {redirectUris: ((. // []) + [$u] | unique)}}' > "$SPA_BODY_FILE" -az rest \ - --method PATCH \ - --uri "https://graph.microsoft.com/v1.0/applications/$WEB_OBJECT_ID" \ - --headers "Content-Type=application/json" \ - --body "@$SPA_BODY_FILE" -rm -f "$SPA_BODY_FILE" - -# Enable ID token issuance for the implicit grant. Container Apps Easy Auth -# requests an id_token during the login redirect; without this Entra returns -# AADSTS700054 "response_type 'id_token' is not enabled for the application". -# The Easy Auth callback (/.auth/login/aad/callback) must also be registered as -# a Web-platform redirect URI, otherwise login fails with AADSTS50011. -echo " Enabling ID token issuance and Web redirect URI on the Web app..." -IMPLICIT_BODY_FILE=$(mktemp) -# Merge with existing Web redirect URIs (deduped) so reusing a registration does -# not remove previously configured URIs. -az ad app show --id "$WEB_CLIENT_ID" --query "web.redirectUris" --output json \ - | jq --arg u "$WEB_URI/.auth/login/aad/callback" \ - '{web: {redirectUris: ((. // []) + [$u] | unique), implicitGrantSettings: {enableIdTokenIssuance: true}}}' > "$IMPLICIT_BODY_FILE" -az rest \ - --method PATCH \ - --uri "https://graph.microsoft.com/v1.0/applications/$WEB_OBJECT_ID" \ - --headers "Content-Type=application/json" \ - --body "@$IMPLICIT_BODY_FILE" -rm -f "$IMPLICIT_BODY_FILE" - -SCOPE_GUID=$(az ad app show --id "$API_CLIENT_ID" --query "api.oauth2PermissionScopes[?value=='user_impersonation'].id | [0]" --output tsv) -echo " Adding API permission to the Web app..." -az ad app permission add \ - --id "$WEB_CLIENT_ID" \ - --api "$API_CLIENT_ID" \ - --api-permissions "$SCOPE_GUID=Scope" - -if ! az ad sp show --id "$WEB_CLIENT_ID" --query id --output tsv >/dev/null 2>&1; then - az ad sp create --id "$WEB_CLIENT_ID" -fi - -echo " Attempting admin consent (best effort)..." -if az ad app permission admin-consent --id "$WEB_CLIENT_ID" 2>/dev/null; then - echo " Admin consent granted." -else - echo " WARNING: Could not grant admin consent automatically. A tenant administrator must consent to the API permission for '$WEB_APP_NAME'. See docs/ConfigureAppAuthentication.md." >&2 -fi - -# --------------------------------------------------------------------------- -# Step 3: Enable Container Apps authentication -# --------------------------------------------------------------------------- -step "Step 3: Enabling Container Apps authentication (Easy Auth)" - -ISSUER="https://sts.windows.net/$TENANT_ID/" - -echo " Configuring API authentication (Return401)..." -az containerapp auth microsoft update \ - --name "$API_APP_NAME" \ - --resource-group "$RESOURCE_GROUP" \ - --client-id "$API_CLIENT_ID" \ - --issuer "$ISSUER" \ - --allowed-audiences "$API_IDENTIFIER_URI" \ - --yes - -az containerapp auth update \ - --name "$API_APP_NAME" \ - --resource-group "$RESOURCE_GROUP" \ - --unauthenticated-client-action Return401 \ - --redirect-provider AzureActiveDirectory \ - --yes - -echo " Configuring Web authentication (RedirectToLoginPage)..." -az containerapp auth microsoft update \ - --name "$WEB_APP_NAME" \ - --resource-group "$RESOURCE_GROUP" \ - --client-id "$WEB_CLIENT_ID" \ - --issuer "$ISSUER" \ - --yes - -az containerapp auth update \ - --name "$WEB_APP_NAME" \ - --resource-group "$RESOURCE_GROUP" \ - --unauthenticated-client-action RedirectToLoginPage \ - --redirect-provider AzureActiveDirectory \ - --yes - -# --------------------------------------------------------------------------- -# Step 4: Allow the Web client to call the API -# --------------------------------------------------------------------------- -step "Step 4: Allowing the Web client on the API" - -# The --allowed-client-applications flag is not available in older containerapp -# CLI extensions. The authConfigs resource does not support PATCH, so GET the -# current config, merge in the allowed application with jq, and PUT it back. -echo " Adding Web client id to the API allowed client applications..." -AUTH_CONFIG_URI="https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.App/containerApps/$API_APP_NAME/authConfigs/current?api-version=2024-03-01" -CURRENT_AUTH_FILE=$(mktemp) -ALLOWED_APPS_FILE=$(mktemp) -az rest --method GET --uri "$AUTH_CONFIG_URI" > "$CURRENT_AUTH_FILE" -jq --arg app "$WEB_CLIENT_ID" \ - '{properties: (.properties | .identityProviders.azureActiveDirectory.validation.defaultAuthorizationPolicy.allowedApplications = [$app])}' \ - "$CURRENT_AUTH_FILE" > "$ALLOWED_APPS_FILE" -az rest \ - --method PUT \ - --uri "$AUTH_CONFIG_URI" \ - --headers "Content-Type=application/json" \ - --body "@$ALLOWED_APPS_FILE" -rm -f "$CURRENT_AUTH_FILE" "$ALLOWED_APPS_FILE" - -# --------------------------------------------------------------------------- -# Step 5: Update Web container environment variables -# --------------------------------------------------------------------------- -step "Step 5: Updating Web container environment variables" - -az containerapp update \ - --name "$WEB_APP_NAME" \ - --resource-group "$RESOURCE_GROUP" \ - --set-env-vars \ - "APP_WEB_CLIENT_ID=$WEB_CLIENT_ID" \ - "APP_WEB_SCOPE=$API_SCOPE" \ - "APP_API_SCOPE=$API_SCOPE" - -if command -v azd >/dev/null 2>&1; then - azd env set API_CLIENT_ID "$API_CLIENT_ID" 2>/dev/null || true - azd env set WEB_CLIENT_ID "$WEB_CLIENT_ID" 2>/dev/null || true -fi - -step "Authentication configuration complete" -echo " API client id : $API_CLIENT_ID" -echo " Web client id : $WEB_CLIENT_ID" -echo " API scope : $API_SCOPE" -echo "" -echo " The API now returns HTTP 401 to unauthenticated callers." -echo " Note: post-deployment data ingestion scripts must send a bearer token." +#!/bin/bash +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# Configures Microsoft Entra ID (Easy Auth) authentication on the deployed API +# and Web Container Apps, automating the manual portal steps described in +# docs/ConfigureAppAuthentication.md. +# +# Run AFTER `azd up` (or `azd provision`). Idempotent: existing registrations +# and settings are reused. +# +# TIMING: This is a manual post-deployment step. Run it immediately after the +# post-deployment schema-registration script. Until it completes, the API has +# external ingress and is reachable without authentication, so do not defer it. +# It is intentionally not wired into the azd provisioning hooks, to avoid +# deployment-time failures. +# +# Usage: +# ./configure_app_authentication.sh +# API_CLIENT_ID= WEB_CLIENT_ID= ./configure_app_authentication.sh + +set -euo pipefail + +step() { + echo "" + echo "======================================================================" + echo "$1" + echo "======================================================================" +} + +get_azd_value() { + local key="$1" + local value + value=$(azd env get-value "$key" 2>/dev/null || echo "") + if [ -n "$value" ] && [[ "$value" != *"not found"* ]]; then + echo "$value" + fi +} + +# --------------------------------------------------------------------------- +# Step 0: Resolve context from the azd environment +# --------------------------------------------------------------------------- +step "Step 0: Resolving deployment context from azd environment" + +RESOURCE_GROUP=$(get_azd_value "AZURE_RESOURCE_GROUP") +SUBSCRIPTION_ID=$(get_azd_value "AZURE_SUBSCRIPTION_ID") +API_APP_NAME=$(get_azd_value "CONTAINER_API_APP_NAME") +API_APP_FQDN=$(get_azd_value "CONTAINER_API_APP_FQDN") +WEB_APP_NAME=$(get_azd_value "CONTAINER_WEB_APP_NAME") +WEB_APP_FQDN=$(get_azd_value "CONTAINER_WEB_APP_FQDN") + +TENANT_ID="${TENANT_ID:-$(get_azd_value "AZURE_TENANT_ID")}" +if [ -z "$TENANT_ID" ]; then + TENANT_ID=$(az account show --query tenantId --output tsv) +fi + +for pair in \ + "AZURE_RESOURCE_GROUP=$RESOURCE_GROUP" \ + "CONTAINER_API_APP_NAME=$API_APP_NAME" \ + "CONTAINER_API_APP_FQDN=$API_APP_FQDN" \ + "CONTAINER_WEB_APP_NAME=$WEB_APP_NAME" \ + "CONTAINER_WEB_APP_FQDN=$WEB_APP_FQDN"; do + name="${pair%%=*}" + value="${pair#*=}" + if [ -z "$value" ]; then + echo "Error: required value '$name' could not be resolved from the azd environment. Run 'azd provision' first." >&2 + exit 1 + fi +done + +if [ -n "$SUBSCRIPTION_ID" ]; then + az account set --subscription "$SUBSCRIPTION_ID" +fi + +API_URI="https://$API_APP_FQDN" +WEB_URI="https://$WEB_APP_FQDN" + +echo " Resource group : $RESOURCE_GROUP" +echo " Tenant : $TENANT_ID" +echo " API app : $API_APP_NAME ($API_URI)" +echo " Web app : $WEB_APP_NAME ($WEB_URI)" + +# --------------------------------------------------------------------------- +# Step 1: API app registration (resource server) + exposed scope +# --------------------------------------------------------------------------- +step "Step 1: Configuring API app registration" + +API_CLIENT_ID="${API_CLIENT_ID:-$(get_azd_value "API_CLIENT_ID")}" + +if [ -z "$API_CLIENT_ID" ]; then + echo " Creating API app registration '$API_APP_NAME'..." + API_CLIENT_ID=$(az ad app create \ + --display-name "$API_APP_NAME" \ + --sign-in-audience AzureADMyOrg \ + --query appId --output tsv) +else + echo " Reusing API app registration: $API_CLIENT_ID" +fi + +API_IDENTIFIER_URI="api://$API_CLIENT_ID" +az ad app update --id "$API_CLIENT_ID" --identifier-uris "$API_IDENTIFIER_URI" + +EXISTING_SCOPES=$(az ad app show --id "$API_CLIENT_ID" --query "api.oauth2PermissionScopes[].value" --output tsv || echo "") +if ! echo "$EXISTING_SCOPES" | grep -q "user_impersonation"; then + echo " Exposing 'user_impersonation' scope on the API app..." + SCOPE_ID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || python -c "import uuid;print(uuid.uuid4())") + # Merge the new scope into the existing api object so that reusing an existing + # registration preserves any other scopes and api settings already configured. + API_OBJECT_ID=$(az ad app show --id "$API_CLIENT_ID" --query id --output tsv) + CURRENT_API_FILE=$(mktemp) + MERGED_API_FILE=$(mktemp) + az ad app show --id "$API_CLIENT_ID" --query api --output json > "$CURRENT_API_FILE" + jq --arg id "$SCOPE_ID" \ + '{api: (. + {oauth2PermissionScopes: ((.oauth2PermissionScopes // []) + [{ + id: $id, + adminConsentDescription: "Allow the application to access the Content Processing API on behalf of the signed-in user.", + adminConsentDisplayName: "Access Content Processing API", + userConsentDescription: "Allow the application to access the Content Processing API on your behalf.", + userConsentDisplayName: "Access Content Processing API", + value: "user_impersonation", + type: "User", + isEnabled: true + }])})}' \ + "$CURRENT_API_FILE" > "$MERGED_API_FILE" + az rest \ + --method PATCH \ + --uri "https://graph.microsoft.com/v1.0/applications/$API_OBJECT_ID" \ + --headers "Content-Type=application/json" \ + --body "@$MERGED_API_FILE" + rm -f "$CURRENT_API_FILE" "$MERGED_API_FILE" +else + echo " 'user_impersonation' scope already exposed." +fi + +if ! az ad sp show --id "$API_CLIENT_ID" --query id --output tsv >/dev/null 2>&1; then + az ad sp create --id "$API_CLIENT_ID" +fi + +API_SCOPE="$API_IDENTIFIER_URI/user_impersonation" +echo " API scope: $API_SCOPE" + +# --------------------------------------------------------------------------- +# Step 2: Web app registration (SPA client) +# --------------------------------------------------------------------------- +step "Step 2: Configuring Web app registration" + +WEB_CLIENT_ID="${WEB_CLIENT_ID:-$(get_azd_value "WEB_CLIENT_ID")}" + +if [ -z "$WEB_CLIENT_ID" ]; then + echo " Creating Web app registration '$WEB_APP_NAME'..." + WEB_CLIENT_ID=$(az ad app create \ + --display-name "$WEB_APP_NAME" \ + --sign-in-audience AzureADMyOrg \ + --query appId --output tsv) +else + echo " Reusing Web app registration: $WEB_CLIENT_ID" +fi + +# Register the SPA redirect URI via a Microsoft Graph PATCH; `az ad app update +# --set spa=...` is unreliable and fails with "Property spa in payload does not +# match schema". The JSON body is written to a temp file (--body @file) to avoid +# shell-quoting issues. +echo " Setting SPA redirect URI: $WEB_URI" +WEB_OBJECT_ID=$(az ad app show --id "$WEB_CLIENT_ID" --query id --output tsv) +SPA_BODY_FILE=$(mktemp) +# Merge with existing SPA redirect URIs (deduped) so reusing a registration does +# not remove previously configured URIs. +az ad app show --id "$WEB_CLIENT_ID" --query "spa.redirectUris" --output json \ + | jq --arg u "$WEB_URI" '{spa: {redirectUris: ((. // []) + [$u] | unique)}}' > "$SPA_BODY_FILE" +az rest \ + --method PATCH \ + --uri "https://graph.microsoft.com/v1.0/applications/$WEB_OBJECT_ID" \ + --headers "Content-Type=application/json" \ + --body "@$SPA_BODY_FILE" +rm -f "$SPA_BODY_FILE" + +# Enable ID token issuance for the implicit grant. Container Apps Easy Auth +# requests an id_token during the login redirect; without this Entra returns +# AADSTS700054 "response_type 'id_token' is not enabled for the application". +# The Easy Auth callback (/.auth/login/aad/callback) must also be registered as +# a Web-platform redirect URI, otherwise login fails with AADSTS50011. +echo " Enabling ID token issuance and Web redirect URI on the Web app..." +IMPLICIT_BODY_FILE=$(mktemp) +# Merge with existing Web redirect URIs (deduped) so reusing a registration does +# not remove previously configured URIs. +az ad app show --id "$WEB_CLIENT_ID" --query "web.redirectUris" --output json \ + | jq --arg u "$WEB_URI/.auth/login/aad/callback" \ + '{web: {redirectUris: ((. // []) + [$u] | unique), implicitGrantSettings: {enableIdTokenIssuance: true}}}' > "$IMPLICIT_BODY_FILE" +az rest \ + --method PATCH \ + --uri "https://graph.microsoft.com/v1.0/applications/$WEB_OBJECT_ID" \ + --headers "Content-Type=application/json" \ + --body "@$IMPLICIT_BODY_FILE" +rm -f "$IMPLICIT_BODY_FILE" + +SCOPE_GUID=$(az ad app show --id "$API_CLIENT_ID" --query "api.oauth2PermissionScopes[?value=='user_impersonation'].id | [0]" --output tsv) +echo " Adding API permission to the Web app..." +az ad app permission add \ + --id "$WEB_CLIENT_ID" \ + --api "$API_CLIENT_ID" \ + --api-permissions "$SCOPE_GUID=Scope" + +if ! az ad sp show --id "$WEB_CLIENT_ID" --query id --output tsv >/dev/null 2>&1; then + az ad sp create --id "$WEB_CLIENT_ID" +fi + +echo " Attempting admin consent (best effort)..." +if az ad app permission admin-consent --id "$WEB_CLIENT_ID" 2>/dev/null; then + echo " Admin consent granted." +else + echo " WARNING: Could not grant admin consent automatically. A tenant administrator must consent to the API permission for '$WEB_APP_NAME'. See docs/ConfigureAppAuthentication.md." >&2 +fi + +# --------------------------------------------------------------------------- +# Step 3: Enable Container Apps authentication +# --------------------------------------------------------------------------- +step "Step 3: Enabling Container Apps authentication (Easy Auth)" + +ISSUER="https://sts.windows.net/$TENANT_ID/" + +echo " Configuring API authentication (Return401)..." +az containerapp auth microsoft update \ + --name "$API_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --client-id "$API_CLIENT_ID" \ + --issuer "$ISSUER" \ + --allowed-audiences "$API_IDENTIFIER_URI" \ + --yes + +az containerapp auth update \ + --name "$API_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --unauthenticated-client-action Return401 \ + --redirect-provider AzureActiveDirectory \ + --yes + +echo " Configuring Web authentication (RedirectToLoginPage)..." +az containerapp auth microsoft update \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --client-id "$WEB_CLIENT_ID" \ + --issuer "$ISSUER" \ + --yes + +az containerapp auth update \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --unauthenticated-client-action RedirectToLoginPage \ + --redirect-provider AzureActiveDirectory \ + --yes + +# --------------------------------------------------------------------------- +# Step 4: Allow the Web client to call the API +# --------------------------------------------------------------------------- +step "Step 4: Allowing the Web client on the API" + +# The --allowed-client-applications flag is not available in older containerapp +# CLI extensions. The authConfigs resource does not support PATCH, so GET the +# current config, merge in the allowed application with jq, and PUT it back. +echo " Adding Web client id to the API allowed client applications..." +AUTH_CONFIG_URI="https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.App/containerApps/$API_APP_NAME/authConfigs/current?api-version=2024-03-01" +CURRENT_AUTH_FILE=$(mktemp) +ALLOWED_APPS_FILE=$(mktemp) +az rest --method GET --uri "$AUTH_CONFIG_URI" > "$CURRENT_AUTH_FILE" +jq --arg app "$WEB_CLIENT_ID" \ + '{properties: (.properties | .identityProviders.azureActiveDirectory.validation.defaultAuthorizationPolicy.allowedApplications = [$app])}' \ + "$CURRENT_AUTH_FILE" > "$ALLOWED_APPS_FILE" +az rest \ + --method PUT \ + --uri "$AUTH_CONFIG_URI" \ + --headers "Content-Type=application/json" \ + --body "@$ALLOWED_APPS_FILE" +rm -f "$CURRENT_AUTH_FILE" "$ALLOWED_APPS_FILE" + +# --------------------------------------------------------------------------- +# Step 5: Update Web container environment variables +# --------------------------------------------------------------------------- +step "Step 5: Updating Web container environment variables" + +az containerapp update \ + --name "$WEB_APP_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --set-env-vars \ + "APP_WEB_CLIENT_ID=$WEB_CLIENT_ID" \ + "APP_WEB_SCOPE=$API_SCOPE" \ + "APP_API_SCOPE=$API_SCOPE" + +if command -v azd >/dev/null 2>&1; then + azd env set API_CLIENT_ID "$API_CLIENT_ID" 2>/dev/null || true + azd env set WEB_CLIENT_ID "$WEB_CLIENT_ID" 2>/dev/null || true +fi + +step "Authentication configuration complete" +echo " API client id : $API_CLIENT_ID" +echo " Web client id : $WEB_CLIENT_ID" +echo " API scope : $API_SCOPE" +echo "" +echo " The API now returns HTTP 401 to unauthenticated callers." +echo " Note: post-deployment data ingestion scripts must send a bearer token."