This proof of concept runs SPIRE Server, SPIRE Agent, the OIDC discovery provider, FastAPI, and a browser SPA in one Azure Container App image.
Warning
SPIRE state is intentionally ephemeral. Every container start generates a new
CA journal, signing key, and JWT kid. Microsoft Entra can cache federated
issuer metadata and JWKS, so token exchange can fail after a restart until
its cache refreshes. Do not use this architecture in production.
flowchart LR
user(["User browser"])
model[["Microsoft Foundry AI Services<br/>gpt-5.4-nano Responses API"]]
mcp["Microsoft MCP Server<br/>for enterprise"]
subgraph azure["Azure Container App: spiffe"]
spa[/"SPA<br/>/spa/"/]
api{{"FastAPI<br/>/invoke"}}
spire[["SPIRE Server + Agent"]]
oidc(["OIDC discovery + JWKS<br/>/spiffe-oidc"])
end
entra["Microsoft Entra ID<br/>SPA registration<br/>Agent Blueprint + Principal<br/>Agent Identity"]
%% 1. Use an invisible link to force horizontal side-by-side placement
model ~~~ mcp
%% 2. Define top-level connections first to push them UP
api <-->|"prompt & response"| model
api <-->|"call using Entra Agent ID authentication"| mcp
%% 3. Define middle flow
user --> spa
spa -->|"same-origin request"| api
api -->|"fetch JWT-SVID"| spire
spire --> oidc
%% 4. Define bottom-level connections last to push them DOWN
spa -->|"sign-in and agent.invoke token"| entra
api -->|"JWT-SVID, FIC/FMI, and OBO"| entra
entra -->|"validate issuer metadata and keys"| oidc
| Purpose | Path |
|---|---|
| SPA | /spa/ |
| Invoke API | /invoke |
| Liveness | /healthz |
| Readiness | /readyz |
| OIDC metadata | /spiffe-oidc/.well-known/openid-configuration |
| OIDC metadata, RFC 8414 form | /.well-known/openid-configuration/spiffe-oidc |
| JWKS | /spiffe-oidc/keys |
The SPA and API share one origin, so CORS and a separate API URL are not
required. FastAPI serves src/spa/index.html and src/spa/app.js, and
generates /spa/env-config.js dynamically from runtime settings.
Copy the example env file:
Copy-Item .env.example .envThe root .env is the source of truth for both local Docker and Azure:
ENTRA_TENANT_IDBLUEPRINT_CLIENT_IDAGENT_IDENTITY_CLIENT_IDSPA_CLIENT_IDDOWNSTREAM_SCOPEEXPECTED_DELEGATED_SCOPEMCP_SERVER_URLSPIFFE_JWT_AUDIENCEAGENT_MODEAZURE_OPENAI_ENDPOINTAZURE_OPENAI_DEPLOYMENTAI_AGENT_SYSTEM_PROMPT
Important! Use the tenant guid for the
ENTRA_TENANT_ID
The backend derives:
- inbound token audience from
BLUEPRINT_CLIENT_ID - SPA Blueprint scope from
BLUEPRINT_CLIENT_IDandEXPECTED_DELEGATED_SCOPE - SPA API URL as
/invoke - SPA tenant from
ENTRA_TENANT_ID
Compose derives the local issuer as http://localhost:8000/spiffe-oidc; Azure
derives it from the generated ACA origin and the same fixed path.
Before provisioning, the interactive AZD preprovision hook creates the Entra
objects when the generated IDs are absent, updates .env, and imports it into
the selected AZD environment. When all generated IDs are already present, it
reuses them and skips object creation. infra/main.parameters.json maps the
deployment values to required Bicep parameters. Azure computes and overrides
the public OIDC issuer with the generated ACA origin plus /spiffe-oidc.
The normal Azure workflow does not require running the bootstrap separately.
azd up invokes it through preprovision. Run it directly only when creating
the Entra objects before local development:
.\scripts\setup-entra.ps1 -TenantId "<tenant-id>"The bootstrap creates .env from .env.example when needed and updates
ENTRA_TENANT_ID, BLUEPRINT_CLIENT_ID, AGENT_IDENTITY_CLIENT_ID,
SPA_CLIENT_ID, and EXPECTED_DELEGATED_SCOPE. Existing unrelated values are
preserved. Complete any remaining placeholders before deployment. The script
does not create a FIC because the public issuer is an Azure deployment output.
Important: You must be
Global Adminsitratorfor the Entra bootstrap script to succeed. The reason is that it creates an agent identity blueprint, agent identity blueprint principal, agent identity, an app registration for the SPA app, grantsadmin consentfor the SPA app to the agent blueprint and for the agent identity to the Microsoft MCP Server for Enterprise. And it also expects that the Microsoft MCP Server for Enterprise is already provisioned into the tenant.
FastAPI hosts the stateless agent loop using the Azure OpenAI Responses API.
The deployed gpt-5.4-nano model receives the configured system prompt and the
Microsoft MCP Server for enterprise as a native remote MCP tool. For each
request, the backend supplies the Agent Identity's user-scoped token as MCP
authorization. Only read scopes are granted in DOWNSTREAM_SCOPE.
Infrastructure deploys one keyless Microsoft Foundry AIServices resource and
one gpt-5.4-nano model deployment (GlobalStandard, version 2026-03-17). A
model deployment is used instead of an implicit serverless endpoint because it
is the smallest deterministic Bicep contract and works directly with managed
identity. The Container App identity receives Cognitive Services OpenAI User;
no API key is enabled or stored.
AI_AGENT_SYSTEM_PROMPT is the tuning surface for the agent instructions.
Azure supplies AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT to the
Container App. For local Compose use after deployment, copy the endpoint from
azd env get-value AZURE_OPENAI_ENDPOINT into root .env.
docker compose up --build --waitOpen http://localhost:8000/spa/.
The SPA app registration must include this local redirect URI:
http://localhost:8000/spa/
Stop local resources:
docker compose down --remove-orphansSelect or create an AZD environment and set its Azure target:
azd env new poc-spiffe --no-prompt
azd env set AZURE_SUBSCRIPTION_ID <subscription-id>
azd env set AZURE_LOCATION westeurope
azd env set AZURE_RESOURCE_GROUP ai-spiffeOptionally run the complete preprovision phase independently. It can prompt for Microsoft Graph sign-in and creates Entra objects when IDs are absent:
azd hooks run preprovisionDeploy the complete workflow. ACR Tasks builds the image remotely:
azd upThe azd up order is:
preprovision: bootstrap or reuse Entra objects, update.env, and import it into AZD.- Provision and deploy the Azure Container App.
postdeploy: add the generated SPA redirect, upsert the Blueprint FIC, and grant tenant-wide delegated permissions for the SPA and Agent Identity.
After deployment, the interactive postdeploy hook reads the generated AZD
outputs and updates Entra automatically. It preserves the local SPA redirect,
adds SPA_URL, and upserts the named Blueprint FIC from FIC_ISSUER,
FIC_SUBJECT, and FIC_AUDIENCE. It also grants the SPA agent.invoke on the
Blueprint and grants the Agent Identity the claim values from
DOWNSTREAM_SCOPE. The hook extracts the resource appId from those api://
scope URIs and queries Microsoft Graph for the tenant-local service principal
object ID before creating the grant. For Microsoft MCP Server for enterprise,
the well-known appId is e8c77dc2-69b3-43f4-bc51-3213c9d915b4. The Graph
context established during bootstrap is retained for this hook.
Inspect the generated values when troubleshooting:
azd env get-values | Select-String 'FIC_ISSUER|FIC_SUBJECT|FIC_AUDIENCE|BACKEND_URL|SPA_URL'Rerun only the Entra post-deployment configuration when needed:
azd hooks run postdeployThe hook is idempotent: it does not duplicate the SPA redirect URI and it
creates or updates the FIC named spire-jwt-svid-blueprint on the Blueprint
application, never on the Agent Identity. Matching delegated grants are reused;
new grants use a six-month expiry.
Remove the complete AZD-managed resource group:
azd down --purgeAfter successful Azure teardown, the interactive postdown hook removes the Agent
Identity and its grants, the SPA principal/application and grants, and the
Blueprint. Blueprint deletion cascades to the Blueprint Principal and any
remaining child Agent Identities. It then resets the three generated client
IDs in root .env, allowing a later azd up to bootstrap fresh objects.
Microsoft Entra deletions are soft deletions and remain recoverable for 30
days. --purge removes the AZD-managed Azure environment; it does not hard
delete objects from the Entra recycle bin.
src/
Dockerfile
entrypoint.sh
backend/
main.py
requirements.txt
test_main.py
spa/
index.html
app.js
spire/
server.conf
agent.conf
oidc-discovery-provider.conf
infra/
main.bicep
main.parameters.json
scripts/
setup-entra.ps1
configure-pre-deployment.ps1
configure-postdeploy.ps1
configure-postdown.ps1
.env.example
azure.yaml
docker-compose.yml
- The SPA signs in and requests the Blueprint's delegated
agent.invokescope. - It calls same-origin
/invokewith the user access token. - The backend validates the token and exchanges its SPIFFE JWT-SVID for an FMI
token scoped to the Agent Identity through
fmi_path. - The Agent Identity performs the configured app-only or on-behalf-of exchange for the downstream read-only MCP scopes.
- The backend sends the prompt, bounded conversation history, and Agent
Identity MCP token to
gpt-5.4-nanothrough the Responses API. - The model discovers and calls the enterprise MCP tools, then returns a final answer to the SPA.
The full JWT-SVID assertion is intentionally logged for troubleshooting.