diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 801d2460..7f7ed5ad 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -87,3 +87,71 @@ If the file doesn't exist, create it and document any important lessons learned - When a change requires modifications outside the immediate scope of what was requested, ask first. - When the user says "undo", revert ALL changes from the last action, not just some. + +## definition of a gold standard document: + - A POC doc is complete when: A new user can run it in <5 minutes , Behavior is visible and verifiable, No section requires rereading to understand + - The reader can explain:what happened, why it happened, how to reproduce it + +## when writing POCs' +POC docs must prioritize runnable usability over completeness; use direct engineer-to-engineer tone; no marketing language; always include TLDR with <5 min steps and expected outcome; include “what you will observe” as pure behavior bullets; separate sections strictly into setup (minimal prereqs), run (exact commands), verify (checklist mapping signals→meaning), deep dive (step-by-step execution flow), optional variants, and troubleshooting; prefer bullets over prose; avoid repetition and narrative phrasing; every config section must start with “what matters” and highlight only critical knobs (timeout, retry, backend behavior); always define observable signals (headers/logs/state changes) and map them explicitly; include execution cycles (cycle 1 fail, cycle 2 retry, final result); include mental model as simple state machine (select→fail→throttle→retry→recover); include minimal flow diagram (client→proxy→backend A fail→backend B success); verification must be checklist not table; troubleshooting must map symptom→cause→check; all claims must be reproducible and observable; avoid vague phrasing; front-load value in first 30%; reader must be able to run, see, and explain behavior without reading entire document + +## When generating a Reference Document, enforce the following rules: +1. Purpose: +- Produce the authoritative, canonical, single source of truth for the topic. +- Output must be complete, deterministic, and audit‑ready. +2. Language Rules: +- Use mandatory language: MUST, REQUIRED, MANDATORY, SHALL. +- Avoid ambiguity: do not use should, could, might, typically, generally. +- Use exact values, configurations, constraints, and specifications. +- No conversational tone. No filler. No speculation. +3. Required Document Structure (always in this order): +A. Document Metadata (Title, Version, Last Updated, Owner, Review Cycle, Compliance Tags) +B. Summary (what this defines, why it exists, who must follow it) +C. Scope & Applicability (in-scope, out-of-scope, dependencies) +D. Authoritative Specification (architecture, configurations, patterns, constraints, SLAs/SLOs, security requirements) +E. Reference Implementation (canonical diagrams, workflows, configuration blocks, API contracts) +F. Validation & Compliance (required tests, checks, evidence, audit artifacts) +G. Version History (changes, rationale, approvers) +4. Behavioral Rules: +- Never invent facts. Request missing parameters before finalizing. +- Ensure internal consistency across all sections. +- All examples must be canonical, valid, and copy‑paste‑ready. +- All diagrams, tables, and configs must be deterministic and aligned with the specification. +- No placeholders unless explicitly allowed by the user. +- No contradictions across sections. +5. Output Requirements: +- Produce a fully structured, complete document. +- Enforce strict formatting and section order. +- Ensure the document is suitable for governance, compliance, and long‑term reference. + +## When generating an Overview Document, enforce the following rules: +1. Purpose: +- Provide a high‑clarity, high‑signal summary of a system, solution, or domain. +- Communicate essential concepts, architecture, flows, and rationale without deep implementation detail. +- Serve as the onboarding and orientation artifact for new readers. +2. Language Rules: +- Use concise, precise, high‑signal language. +- Avoid ambiguity, filler, marketing language, or conversational tone. +- Use factual, neutral, technically accurate statements. +- Avoid mandatory language unless describing non‑negotiable constraints. +3. Required Document Structure (always in this order): +A. Document Metadata (Title, Version, Last Updated, Owner) +B. Overview Summary (what the system is, what problem it solves, why it exists) +C. Key Objectives (primary goals, outcomes, and value) +D. High‑Level Architecture (major components, interactions, boundaries) +E. Core Concepts (definitions, domain terms, key abstractions) +F. High‑Level Workflows (end‑to‑end flows, sequence summaries) +G. Key Constraints & Assumptions (technical, operational, business) +H. Integration Points (external systems, APIs, dependencies) +I. Non‑Goals (what is intentionally excluded) +J. Future Considerations (roadmap‑level items only) +4. Behavioral Rules: +- Never invent facts. Request missing parameters before finalizing. +- Ensure internal consistency across all sections. +- Keep all diagrams and workflows high‑level; no implementation detail. +- Do not include configuration, SLAs, or compliance details unless explicitly requested. +- No placeholders unless explicitly allowed by the user. +5. Output Requirements: +- Produce a complete, structured overview document. +- Maintain strict section order and formatting. +- Ensure the document is suitable for onboarding, orientation, and executive‑level understanding. diff --git a/ReleaseNotes/version2.2.md b/ReleaseNotes/version2.2.md index 6828a9a1..99aa1c50 100644 --- a/ReleaseNotes/version2.2.md +++ b/ReleaseNotes/version2.2.md @@ -3,6 +3,8 @@ Proxy: * Add status checker +* Add ability to validate requests via api-key +* Add ability to call backend via api-key RequestAPI: * Add status checker diff --git a/deployment/POC/readme.md b/deployment/POC/readme.md new file mode 100644 index 00000000..56e07791 --- /dev/null +++ b/deployment/POC/readme.md @@ -0,0 +1,194 @@ +### Step 1 — Set Variables + +Set `APP_NAME`, `CONTAINER_APP_NAME`, `RG` to match your environment. + +```bash +export ENTRA_APP_NAME="aca-proxy" # Display name for the Entra app registration +export CONTAINER_APP_NAME="" # Container App name +export RG="" # Container App resource group +``` + +> [!NOTE] +> In Windows/WSL environments, sanitize `-o tsv` outputs with `tr -d '\r\n'` before reusing values in later CLI calls. + +### Step 2 — Create the Entra App Registration and enable EasyAuth + +Save the generated `APP_ID` and `CLIENT_SECRET` variables for troubleshooting. + +```bash + +# Lookup tenant and app fqdn +export TENANT_ID="$(az account show --query tenantId -o tsv | tr -d '\r\n')" +export APP_FQDN="https://$(az containerapp show --name "$CONTAINER_APP_NAME" --resource-group "$RG" --query properties.configuration.ingress.fqdn -o tsv | tr -d '\r\n')" +export HEALTH_URL="$APP_FQDN/health" + +export APP_ID=$(az ad app create \ + --display-name "$ENTRA_APP_NAME" \ + --sign-in-audience AzureADMyOrg \ + --query appId -o tsv | tr -d '\r\n') +echo "APP_ID=$APP_ID" + +# Required so az token requests to api://$APP_ID can resolve the resource principal. +az ad app update --id "$APP_ID" --identifier-uris "api://$APP_ID" + +# Create service principal +az ad sp create --id "$APP_ID" 1>/dev/null + +# Create delegated scope +if [ -z "$APP_ID" ]; then + echo "APP_ID is empty. Re-run Step 2 app creation or app lookup first." + exit 1 +fi + +SCOPE_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')" +API_OBJ="$(az ad app show --id "$APP_ID" --query api -o json)" +UPDATED_API_OBJ="$(echo "$API_OBJ" | jq --arg id "$SCOPE_ID" '.oauth2PermissionScopes = [{ + adminConsentDescription: "Access the API", + adminConsentDisplayName: "Admin Access", + id: $id, + isEnabled: true, + type: "Admin", + userConsentDescription: "Access the API", + userConsentDisplayName: "User Access", + value: "api.access" +}]')" +az ad app update --id "$APP_ID" --set api="$UPDATED_API_OBJ" + +# Enable ID token issuance +az ad app update --id "$APP_ID" --enable-id-token-issuance true + +# Create client secret +export CLIENT_SECRET=$(az ad app credential reset \ + --id "$APP_ID" \ + --display-name "proxy-auth-secret" \ + --end-date "$(date -d '+30 days' '+%Y-%m-%d')" \ + --query password -o tsv | tr -d '\r\n') + +# Do not print or commit secret values. Keep them in memory only. + + +# Enable EazyAuth +az containerapp auth microsoft update \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RG" \ + --client-id "$APP_ID" \ + --client-secret "$CLIENT_SECRET" \ + --tenant-id "$TENANT_ID" \ + --yes + +# Verify identifier URI is set correctly. +az ad app show --id "$APP_ID" --query "{appId:appId,identifierUris:identifierUris,scopes:api.oauth2PermissionScopes[].value}" -o table + +# Optional hygiene: clear secret from shell after auth configuration is complete. +# unset CLIENT_SECRET +``` + +> [!WARNING] +> You may need to grant admin consent in the Azure portal before token acquisition works. +> If `az account get-access-token --resource "api://$APP_ID"` returns `AADSTS65001` (`consent_required`), ask a tenant admin to grant consent for your client app/API scope in Entra ID: +> **App registrations** -> your client app -> **API permissions** -> **Grant admin consent**. +> +> For better secret hygiene, avoid sharing terminal output that includes auth commands and never paste secret values into tickets, PR comments, or chat logs. + +### Step 3 — Verify Container App + +Run these checks to ensure auth is enabled and the Microsoft identity provider is registered. + +```bash +ENABLED="$(az containerapp auth show \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RG" \ + --query "platform.enabled" -o tsv | tr -d '\r\n')" + +AAD_CLIENT_ID="$(az containerapp auth show \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RG" \ + --query "identityProviders.azureActiveDirectory.registration.clientId" -o tsv | tr -d '\r\n')" + +AUDIENCE="$(az containerapp auth show \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RG" \ + --query "identityProviders.azureActiveDirectory.validation.allowedAudiences[0]" -o tsv | tr -d '\r\n')" + +echo "enabled=$ENABLED" +echo "aad_client_id=$AAD_CLIENT_ID" +echo "allowed_audience=$AUDIENCE" +``` + +Expected: + +- enabled=true +- aad_client_id= +- allowed_audience=api:// + +If `aad_client_id` or `allowed_audience` is empty, or `enabled` is not `true`, re-run the auth microsoft update command above and do not continue to Step 4. + +### Step 4 — Set the Unauthenticated Action + +Rejects unauthenticated requests outright — callers get a `401` with no redirect. + +```bash +az containerapp auth update \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RG" \ + --enabled true \ + --unauthenticated-client-action Return401 +``` + +### Step 5 — Align Allowed Audiences with v2.0 Tokens + +The default Microsoft provider configuration registers `api://` as the allowed audience. Entra v2.0 access tokens (issued by `https://login.microsoftonline.com//v2.0`) carry the **bare GUID** in their `aud` claim — not the `api://` form. Without this step, valid tokens are rejected with `403`. + +Replace the allowed audience with the bare GUID: + +```bash +az containerapp auth microsoft update \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RG" \ + --allowed-audiences "$APP_ID" +``` + +Verify: + +```bash +az containerapp auth show \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RG" \ + --query "identityProviders.azureActiveDirectory.validation.allowedAudiences" +``` + +Expected output: + +```json +[ + "" +] +``` + +> [!NOTE] +> `--allowed-audiences` **replaces** the existing list and only accepts one value per invocation. If you also need to accept v1-style `api://` audiences (for legacy callers), patch the list directly via `az containerapp auth update --set identityProviders.azureActiveDirectory.validation.allowedAudiences=...` with a JSON array, taking care with shell quoting. + +### Step 6 — Restrict to Trusted Client Applications + +Even with a valid token, EasyAuth's `defaultAuthorizationPolicy.allowedApplications` decides which client apps may call the proxy. An empty list combined with EasyAuth's MISE evaluation results in `403` with `"this principal does not match any of the allowed applications"`. + +For this POC we explicitly trust the Microsoft Azure CLI client (`04b07795-8ddb-461a-bbee-02f9e1bf7b46`) so you can verify with `az account get-access-token`. In production, replace this with the client app IDs of the real callers (your console SP, APIM, another service, etc.). + +```bash +az containerapp auth update \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RG" \ + --set identityProviders.azureActiveDirectory.validation.defaultAuthorizationPolicy.allowedApplications='["04b07795-8ddb-461a-bbee-02f9e1bf7b46"]' +``` + +Verify: + +```bash +az containerapp auth show \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RG" \ + --query "identityProviders.azureActiveDirectory.validation.defaultAuthorizationPolicy.allowedApplications" +``` + +> [!NOTE] +> EasyAuth caches authorization decisions per principal for roughly 60 seconds. After changing `allowedApplications` or `allowedAudiences`, wait a minute (or acquire a fresh token) before re-testing — otherwise you'll see a cached deny. diff --git a/deployment/POC/secureProxy.sh b/deployment/POC/secureProxy.sh new file mode 100644 index 00000000..b484226f --- /dev/null +++ b/deployment/POC/secureProxy.sh @@ -0,0 +1,884 @@ +#!/usr/bin/env bash +# secureProxy.sh +# +# Configure Entra ID app registration for a proxy Container App. +# Idempotent: safe to re-run. Each step checks current state before modifying. +# +# Three modes: +# +# -m ACA (default) +# Creates app registration + delegated scope 'api.access' + client secret +# and enables Container Apps EasyAuth (Microsoft provider) on the proxy. +# Use when the platform should enforce auth on the proxy's ingress. +# +# -m APIM +# Creates app registration + app role 'API.Caller' and assigns that role +# to the proxy Container App's managed identity. ACA auth is NOT modified. +# Use when the proxy calls APIM with its managed identity and APIM +# validates the token via policy. +# +# -m ADDCLIENT (or pass -z with no -m) +# Adds an arbitrary client app ID to the API app's preAuthorizedApplications +# for the 'api.access' scope. If -n and -g are also given, also adds the +# client ID to the Container App's EasyAuth allowedApplications so the +# client's tokens are accepted at runtime (otherwise the proxy returns 403). +# +# Usage: +# secureProxy.sh -a [-n ] [-g ] [-m ] [-z ] + +set -euo pipefail + +# ---------------------------------------------------------------------------- +# Logging helpers +# ---------------------------------------------------------------------------- +log() { printf '\033[0;36m[%s]\033[0m %s\n' "$(date +%H:%M:%S)" "$*" >&2; } +ok() { printf '\033[0;32m[ OK ]\033[0m %s\n' "$*" >&2; } +warn() { printf '\033[0;33m[WARN]\033[0m %s\n' "$*" >&2; } +err() { printf '\033[0;31m[FAIL]\033[0m %s\n' "$*" >&2; } + +# die [remediation hint...] +# Prints a clearly formatted failure block and exits 1. +die() { + err "$1" + shift + if [[ $# -gt 0 ]]; then + printf '\033[0;31m \u2192 %s\033[0m\n' "$@" >&2 + fi + exit 1 +} + +# run_az +# Captures stderr so the real Azure error is surfaced verbatim when something fails. +run_az() { + local description="$1"; shift + local stderr_file + stderr_file="$(mktemp)" + if ! "$@" 2>"$stderr_file"; then + err "$description failed" + printf '\033[0;31m command: %s\033[0m\n' "$*" >&2 + printf '\033[0;31m azure error:\033[0m\n' >&2 + sed 's/^/ /' "$stderr_file" >&2 + rm -f "$stderr_file" + exit 1 + fi + rm -f "$stderr_file" +} + +trap 'err "Aborted at line $LINENO while running: $BASH_COMMAND"' ERR + +# ============================================================================ +# Global state — populated by functions; intentionally module-scoped so each +# step is small and readable. Keep this list as the single source of truth. +# ============================================================================ + +# Inputs (set by parse_args) +CONTAINER_APP_NAME="" +RG="" +ENTRA_APP_NAME="" +MODE="ACA" +EXTRA_CLIENT_ID="" # set by -z / --authorize; client app ID to pre-authorize + +# Azure context (set by require_logged_in) +CURRENT_SUB="" +TENANT_ID="" + +# Container App facts (set by discover_container_app) +APP_FQDN="" +HEALTH_URL="" +MI_PRINCIPAL_ID="" +MI_TYPE="" + +# App registration (set by ensure_app_registration / ensure_service_principal) +APP_ID="" +SP_OID="" + +# ACA-only state +CLIENT_SECRET="" + +# APIM-only state +readonly APP_ROLE_VALUE="API.Caller" +readonly APP_ROLE_DISPLAY="API Caller" +readonly APP_ROLE_DESC="Applications assigned this role may invoke the proxy via APIM." +APP_ROLE_ID="" +API_SP_OID="" + +# ============================================================================ +# CLI plumbing +# ============================================================================ + +usage() { + cat <&2 +Configures an Entra ID app registration that secures a SimpleL7Proxy Container App. +Pick a mode to: + - secure inbound traffic to the proxy via ACA EasyAuth (ACA mode) + - secure outbound calls from the proxy to APIM, where the proxy authenticates + with its managed identity and APIM validates the token (APIM mode) + - pre-authorize additional client apps to call the proxy (ADDCLIENT mode) +Idempotent: safe to re-run. + +Usage: + $(basename "$0") -a [-n ] [-g ] + [-m ] [-z ] + +Options: + -a, --app-name Display name for the Entra app registration to create/reuse. + -n, --container-app Container App name (the proxy). Required for ACA and APIM. + -g, --resource-group Resource group of the Container App. Required for ACA and APIM. + -m, --mode One of: ACA (default), APIM, ADDCLIENT. + ACA configure ACA EasyAuth (Microsoft provider) on the proxy. + APIM create app role and assign it to the proxy managed identity; + ACA auth is left untouched (APIM validates the token). + ADDCLIENT add a client app ID to the API app's preAuthorizedApplications + for the 'api.access' scope, and (if -n/-g are also given) to + the Container App's EasyAuth allowedApplications list. + -z, --authorize Client app ID (GUID) to pre-authorize. Implies --mode ADDCLIENT. + -h, --help Show this help. +EOF + exit 1 +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + -n|--container-app) CONTAINER_APP_NAME="${2:-}"; shift 2 ;; + -g|--resource-group) RG="${2:-}"; shift 2 ;; + -a|--app-name) ENTRA_APP_NAME="${2:-}"; shift 2 ;; + -m|--mode) MODE="${2:-}"; shift 2 ;; + -z|--authorize) EXTRA_CLIENT_ID="${2:-}"; shift 2 ;; + -h|--help) usage ;; + --) shift; break ;; + -*) err "Unknown option: $1"; usage ;; + *) err "Unexpected positional argument: $1"; usage ;; + esac + done + + # -z implies ADDCLIENT mode when -m wasn't explicitly given a non-default value. + if [[ -n "$EXTRA_CLIENT_ID" && "$MODE" == "ACA" ]]; then + MODE="ADDCLIENT" + fi + + local script_name + script_name="$(basename "$0")" + # Each line below becomes its own '→ ...' remediation hint. + local ex_desc1="Configures an Entra ID app registration that secures a SimpleL7Proxy Container App." \ + ex_desc2="Pick a mode:" \ + ex_desc3=" ACA - secure inbound traffic to the proxy via EasyAuth." \ + ex_desc4=" APIM - secure outbound calls from the proxy to APIM via managed identity." \ + ex_desc5=" ADDCLIENT - pre-authorize additional client apps to call the proxy." \ + ex_blank="" \ + ex_header="Examples:" \ + ex_aca=" ACA : $script_name -a -n -g " \ + ex_apimmi=" APIM : $script_name -a -n -g -m APIM" \ + ex_authorize=" ADDCLIENT : $script_name -a -z " \ + ex_help="Run '$script_name -h' for the full option reference." + + local desc_args=("$ex_desc1" "$ex_desc2" "$ex_desc3" "$ex_desc4" "$ex_desc5" "$ex_blank") + + [[ -z "$ENTRA_APP_NAME" ]] && die "Missing required argument: -a " \ + "${desc_args[@]}" "$ex_header" "$ex_aca" "$ex_apimmi" "$ex_authorize" "$ex_help" + + case "$MODE" in + ACA|APIM) + [[ -z "$CONTAINER_APP_NAME" ]] && die "Missing required argument: -n (mode=$MODE)" \ + "${desc_args[@]}" "$ex_header" "$ex_aca" "$ex_apimmi" "$ex_help" + [[ -z "$RG" ]] && die "Missing required argument: -g (mode=$MODE)" \ + "${desc_args[@]}" "$ex_header" "$ex_aca" "$ex_apimmi" "$ex_help" + ;; + ADDCLIENT) + [[ -z "$EXTRA_CLIENT_ID" ]] && die "Mode 'ADDCLIENT' requires -z " \ + "${desc_args[@]}" "$ex_header" "$ex_authorize" "$ex_help" + if [[ ! "$EXTRA_CLIENT_ID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then + die "--authorize value is not a GUID: '$EXTRA_CLIENT_ID'" \ + "Pass the client's appId (a GUID), not its display name." \ + "$ex_authorize" + fi + ;; + *) die "Invalid mode: '$MODE'" "Valid values: ACA, APIM, ADDCLIENT" ;; + esac + log "Mode: $MODE" +} + +# ============================================================================ +# Environment / context +# ============================================================================ + +check_prerequisites() { + command -v az >/dev/null || die \ + "Azure CLI ('az') is not installed or not on PATH." \ + "Install from https://learn.microsoft.com/cli/azure/install-azure-cli" + + command -v jq >/dev/null || die \ + "'jq' is required to manipulate app-registration JSON but was not found." \ + "Install with: sudo apt-get install jq (or: brew install jq)" + + command -v uuidgen >/dev/null || die \ + "'uuidgen' is required to generate scope IDs but was not found." \ + "Install the 'uuid-runtime' package (Debian/Ubuntu) or use a host that ships it." +} + +require_logged_in() { + if ! az account show >/dev/null 2>&1; then + die "Not logged in to Azure (or token expired)." \ + "Run: az login" \ + "If you have multiple subscriptions: az account set --subscription " + fi + CURRENT_SUB="$(az account show --query name -o tsv | tr -d '\r\n')" + TENANT_ID="$(az account show --query tenantId -o tsv | tr -d '\r\n')" + [[ -z "$TENANT_ID" ]] && die \ + "Could not read tenantId from the current az context." \ + "Try: az logout && az login" + log "Active subscription: $CURRENT_SUB" +} + +require_resource_group() { + [[ -z "$RG" ]] && return # ADDCLIENT mode does not need a resource group + if ! az group show --name "$RG" >/dev/null 2>&1; then + die "Resource group '$RG' not found in subscription '$CURRENT_SUB'." \ + "List available groups: az group list --query \"[].name\" -o tsv" \ + "Switch subscription: az account set --subscription " + fi +} + +# Sets APP_FQDN, HEALTH_URL, MI_PRINCIPAL_ID, MI_TYPE. +discover_container_app() { + if [[ "$MODE" == "ADDCLIENT" ]]; then + log "Skipping Container App discovery (mode=ADDCLIENT)" + return + fi + log "Looking up Container App '$CONTAINER_APP_NAME'..." + local stderr_file ca_json fqdn user_assigned_first + stderr_file="$(mktemp)" + ca_json="$(az containerapp show \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RG" \ + -o json 2>"$stderr_file" || true)" + + if [[ -z "$ca_json" || "$ca_json" == "null" ]]; then + local ca_err; ca_err="$(cat "$stderr_file")"; rm -f "$stderr_file" + die "Container App '$CONTAINER_APP_NAME' does not exist in resource group '$RG'." \ + "List Container Apps in this group: az containerapp list -g '$RG' --query \"[].name\" -o tsv" \ + "Azure error: $ca_err" + fi + rm -f "$stderr_file" + + fqdn="$(echo "$ca_json" | jq -r '.properties.configuration.ingress.fqdn // empty')" + MI_PRINCIPAL_ID="$(echo "$ca_json" | jq -r '.identity.principalId // empty')" + MI_TYPE="$(echo "$ca_json" | jq -r '.identity.type // "None"')" + user_assigned_first="$(echo "$ca_json" | jq -r '(.identity.userAssignedIdentities // {}) | to_entries | .[0].value.principalId // empty')" + + if [[ -n "$fqdn" ]]; then + APP_FQDN="https://$fqdn" + HEALTH_URL="$APP_FQDN/health" + ok "Container App FQDN: $APP_FQDN" + elif [[ "$MODE" == "ACA" ]]; then + die "Container App '$CONTAINER_APP_NAME' has no ingress FQDN configured." \ + "EasyAuth requires ingress. Enable it and re-run, e.g.:" \ + " az containerapp ingress enable -n '$CONTAINER_APP_NAME' -g '$RG' --type external --target-port " + else + warn "Container App has no ingress FQDN (OK for APIM mode; proxy is callee-only)." + fi + + # In APIM mode, prefer system-assigned identity; fall back to first user-assigned. + if [[ "$MODE" == "APIM" ]]; then + [[ -z "$MI_PRINCIPAL_ID" ]] && MI_PRINCIPAL_ID="$user_assigned_first" + if [[ -z "$MI_PRINCIPAL_ID" ]]; then + die "Container App '$CONTAINER_APP_NAME' has no managed identity (identity.type='$MI_TYPE')." \ + "Enable a managed identity and re-run, e.g.:" \ + " az containerapp identity assign -n '$CONTAINER_APP_NAME' -g '$RG' --system-assigned" + fi + ok "Proxy managed identity principalId: $MI_PRINCIPAL_ID (identity.type=$MI_TYPE)" + fi +} + +# ============================================================================ +# Entra app registration (shared by both modes) +# ============================================================================ + +# Sets APP_ID. Creates the app registration if it does not exist. +ensure_app_registration() { + log "Looking up Entra app registration '$ENTRA_APP_NAME'..." + + local match_count + match_count="$(az ad app list --display-name "$ENTRA_APP_NAME" --query "length(@)" -o tsv 2>/dev/null | tr -d '\r\n' || echo 0)" + if [[ "$match_count" -gt 1 ]]; then + die "Multiple ($match_count) Entra app registrations are named '$ENTRA_APP_NAME'." \ + "This script cannot safely pick one. Resolve the ambiguity in the portal" \ + "(Entra ID \u2192 App registrations) or pass a different -a value." + fi + + APP_ID="$(az ad app list --display-name "$ENTRA_APP_NAME" --query "[0].appId" -o tsv 2>/dev/null | tr -d '\r\n' || true)" + if [[ -n "$APP_ID" ]]; then + ok "Reusing existing app registration. APP_ID=$APP_ID" + return + fi + + log "Creating Entra app registration '$ENTRA_APP_NAME'..." + local stderr_file err_msg + stderr_file="$(mktemp)" + APP_ID="$(az ad app create \ + --display-name "$ENTRA_APP_NAME" \ + --sign-in-audience AzureADMyOrg \ + --query appId -o tsv 2>"$stderr_file" | tr -d '\r\n' || true)" + if [[ -z "$APP_ID" ]]; then + err_msg="$(cat "$stderr_file")"; rm -f "$stderr_file" + die "Failed to create Entra app registration '$ENTRA_APP_NAME'." \ + "You likely lack 'Application.ReadWrite.All' or equivalent in this tenant." \ + "Ask a tenant admin to either create the app or grant you the role." \ + "Azure error: $err_msg" + fi + rm -f "$stderr_file" + ok "Created app registration. APP_ID=$APP_ID" +} + +ensure_identifier_uri() { + local expected="api://$APP_ID" + local current + current="$(az ad app show --id "$APP_ID" --query "identifierUris" -o json)" + if echo "$current" | jq -e --arg u "$expected" 'index($u)' >/dev/null; then + ok "Identifier URI already set: $expected" + return + fi + log "Setting identifier URI: $expected" + run_az "Setting identifier URI on $APP_ID" \ + az ad app update --id "$APP_ID" --identifier-uris "$expected" + ok "Identifier URI set" +} + +# Sets SP_OID. Creates the service principal if missing. +ensure_service_principal() { + SP_OID="$(az ad sp list --filter "appId eq '$APP_ID'" --query "[0].id" -o tsv 2>/dev/null | tr -d '\r\n' || true)" + if [[ -n "$SP_OID" ]]; then + ok "Service principal already exists ($SP_OID)" + return + fi + log "Creating service principal for $APP_ID..." + run_az "Creating service principal for $APP_ID" \ + az ad sp create --id "$APP_ID" --output none + SP_OID="$(az ad sp list --filter "appId eq '$APP_ID'" --query "[0].id" -o tsv 2>/dev/null | tr -d '\r\n' || true)" + ok "Service principal created ($SP_OID)" +} + +# ============================================================================ +# ACA mode steps +# ============================================================================ + +ensure_delegated_scope() { + local api_obj has_scope scope_id updated + api_obj="$(az ad app show --id "$APP_ID" --query api -o json)" + has_scope="$(echo "$api_obj" | jq -r '[.oauth2PermissionScopes[]? | select(.value == "api.access")] | length')" + if [[ "$has_scope" != "0" ]]; then + ok "Scope 'api.access' already exists" + return + fi + + log "Adding 'api.access' delegated scope..." + scope_id="$(uuidgen | tr '[:upper:]' '[:lower:]')" + updated="$(echo "$api_obj" | jq --arg id "$scope_id" '.oauth2PermissionScopes = [{ + adminConsentDescription: "Access the API", + adminConsentDisplayName: "Admin Access", + id: $id, + isEnabled: true, + type: "Admin", + userConsentDescription: "Access the API", + userConsentDisplayName: "User Access", + value: "api.access" + }]')" + run_az "Adding 'api.access' scope to $APP_ID" \ + az ad app update --id "$APP_ID" --set api="$updated" + ok "Scope 'api.access' added" +} + +# Ensure the app registration issues v2.0 access tokens. +# +# Without requestedAccessTokenVersion=2, Entra issues v1.0 tokens when a caller +# requests them via `--resource api://`, with aud="api://". The ACA +# EasyAuth config we set uses the bare GUID as allowedAudiences, which only +# matches v2.0 tokens (aud=). Setting v2 here aligns the two so a token +# acquired via either `--resource api://` or `--scope api:///.default` +# is accepted by EasyAuth. +ensure_v2_access_tokens() { + local current api_obj updated + current="$(az ad app show --id "$APP_ID" --query "api.requestedAccessTokenVersion" -o tsv 2>/dev/null || echo "")" + if [[ "$current" == "2" ]]; then + ok "App registration already issues v2.0 access tokens" + return + fi + + log "Setting requestedAccessTokenVersion=2 on app registration..." + api_obj="$(az ad app show --id "$APP_ID" --query api -o json)" + updated="$(echo "$api_obj" | jq '.requestedAccessTokenVersion = 2')" + run_az "Setting requestedAccessTokenVersion=2 on $APP_ID" \ + az ad app update --id "$APP_ID" --set api="$updated" + ok "App registration set to issue v2.0 access tokens (aud will be bare GUID)" +} + +# Pre-authorize a client app ID on the API's 'api.access' delegated scope. +# Used by ACA mode (for Azure CLI) and by ADDCLIENT mode (for arbitrary clients). +preauthorize_client() { + local client_id="$1" + local client_label="${2:-$client_id}" + local api_obj scope_id existing updated + api_obj="$(az ad app show --id "$APP_ID" --query api -o json)" + scope_id="$(echo "$api_obj" | jq -r '[.oauth2PermissionScopes[]? | select(.value == "api.access")][0].id')" + if [[ -z "$scope_id" || "$scope_id" == "null" ]]; then + die "Cannot pre-authorize $client_label: 'api.access' scope not found on $APP_ID" \ + "Re-run ensure_delegated_scope, then retry" + fi + existing="$(echo "$api_obj" | jq -r --arg cli "$client_id" --arg sid "$scope_id" \ + '[.preAuthorizedApplications[]? | select(.appId == $cli) | .delegatedPermissionIds[]? | select(. == $sid)] | length')" + if [[ "$existing" != "0" ]]; then + ok "$client_label already pre-authorized for 'api.access'" + return + fi + log "Pre-authorizing $client_label for 'api.access' scope..." + updated="$(echo "$api_obj" | jq --arg cli "$client_id" --arg sid "$scope_id" ' + .preAuthorizedApplications = ((.preAuthorizedApplications // []) | map(select(.appId != $cli))) + [{ + appId: $cli, + delegatedPermissionIds: [$sid] + }]')" + run_az "Pre-authorizing $client_label on $APP_ID" \ + az ad app update --id "$APP_ID" --set api="$updated" + ok "$client_label pre-authorized for 'api.access'" +} + +# Well-known Azure CLI public client ID. Used both as a pre-authorized client +# on the app registration (so `az account get-access-token` works without +# interactive consent) and as an allowed calling application on EasyAuth (so +# the bearer token's azp claim is accepted at authorization time). +AZURE_CLI_CLIENT_ID="04b07795-8ddb-461a-bbee-02f9e1bf7b46" + +ensure_azure_cli_preauthorized() { + # Pre-authorize Azure CLI on the api.access scope so token acquisition does + # not trigger interactive consent (which fails with AADSTS650057 on a fresh + # app reg). + preauthorize_client "$AZURE_CLI_CLIENT_ID" "Azure CLI" +} + +authorize_extra_client() { + preauthorize_client "$EXTRA_CLIENT_ID" "client $EXTRA_CLIENT_ID" +} + +ensure_id_token_issuance() { + local enabled + enabled="$(az ad app show --id "$APP_ID" --query "web.implicitGrantSettings.enableIdTokenIssuance" -o tsv | tr -d '\r\n')" + if [[ "$enabled" == "true" ]]; then + ok "ID token issuance already enabled" + return + fi + log "Enabling ID token issuance..." + run_az "Enabling ID token issuance on $APP_ID" \ + az ad app update --id "$APP_ID" --enable-id-token-issuance true + ok "ID token issuance enabled" +} + +# Sets CLIENT_SECRET. Intentionally NOT idempotent — credential reset --append +# always creates a new secret value; existing secrets are preserved. +create_client_secret() { + log "Creating a new client secret (valid 30 days)..." + local stderr_file err_msg + stderr_file="$(mktemp)" + CLIENT_SECRET="$(az ad app credential reset \ + --id "$APP_ID" \ + --display-name "proxy-auth-secret" \ + --append \ + --end-date "$(date -d '+30 days' '+%Y-%m-%d')" \ + --query password -o tsv 2>"$stderr_file" | tr -d '\r\n' || true)" + if [[ -z "$CLIENT_SECRET" ]]; then + err_msg="$(cat "$stderr_file")"; rm -f "$stderr_file" + die "Failed to create client secret for $APP_ID." \ + "Common causes: insufficient permissions on the app, or the tenant blocks secret creation." \ + "Azure error: $err_msg" + fi + rm -f "$stderr_file" + ok "Client secret created (held in memory only)" +} + +# Step 3 in the POC doc: register Microsoft provider with bare-GUID audience. +configure_aca_provider() { + log "Enabling EasyAuth (Microsoft provider) on Container App..." + run_az "Enabling EasyAuth on Container App '$CONTAINER_APP_NAME'" \ + az containerapp auth microsoft update \ + --name "$CONTAINER_APP_NAME" \ + --resource-group "$RG" \ + --client-id "$APP_ID" \ + --client-secret "$CLIENT_SECRET" \ + --tenant-id "$TENANT_ID" \ + --allowed-audiences "$APP_ID" \ + --yes --output none + ok "EasyAuth Microsoft provider configured (audience=$APP_ID)" +} + +# After EasyAuth validates the bearer token, it enforces an authorization +# policy: by default only the registered clientId itself is an allowed +# 'calling application' (azp claim). Tokens minted by other clients (e.g. the +# Azure CLI, or an arbitrary client added via ADDCLIENT mode) are rejected +# with HTTP 403 / SubStatusCode 76 even though authentication succeeded, +# unless their app ID is on this list. +# +# Idempotent merge: reads the existing list, appends the given client ID if +# absent, dedupes, and writes back. Existing entries are preserved across +# repeated invocations (e.g. ACA mode adds the CLI, ADDCLIENT mode then adds +# another client without erasing the CLI). +# +# Args: [