Skip to content

Commit 5a4a146

Browse files
Merge pull request #9 from Azure-Samples/build-edits
Deep analysis of the Entra JWT policy and its implementation in the A…
2 parents 868a1ce + 4d3dbf1 commit 5a4a146

1 file changed

Lines changed: 232 additions & 0 deletions

File tree

policies/entra-jwt-policy.md

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
# APIM Policy Analysis: `entra-jwt-policy.xml`
2+
3+
This is an **Azure API Management (APIM) policy** that acts as a gateway layer between client applications and Azure OpenAI. It implements authentication, authorization, quota enforcement, request transformation, usage logging, and error handling. The policy executes across all four APIM policy sections: **inbound**, **backend**, **outbound**, and **on-error**.
4+
5+
---
6+
7+
## 1. `<inbound>` — Request Processing (Pre-Backend)
8+
9+
The inbound section performs six distinct operations before the request reaches Azure OpenAI:
10+
11+
### 1.1 JWT Validation
12+
13+
```xml
14+
<validate-jwt header-name="Authorization" ...>
15+
<openid-config url="https://login.microsoftonline.com/common/.well-known/openid-configuration" />
16+
<required-claims>
17+
<claim name="aud" match="all">
18+
<value>{{ExpectedAudience}}</value>
19+
</claim>
20+
</required-claims>
21+
</validate-jwt>
22+
```
23+
24+
- Validates the Bearer token from the `Authorization` header against Entra ID's OpenID Connect metadata.
25+
- Uses `/common/` endpoint — this means it's **multi-tenant**, accepting tokens from any Entra directory.
26+
- Requires the `aud` (audience) claim to match `{{ExpectedAudience}}` (a named value configured in APIM). This ensures tokens are scoped to the correct resource.
27+
- Returns **HTTP 401** on failure with a configurable error message.
28+
29+
### 1.2 Claim Extraction
30+
31+
Extracts three JWT claims into APIM context variables:
32+
33+
| Variable | Claim | Purpose |
34+
|----------|-------|---------|
35+
| `tenantId` | `tid` | Identifies the caller's Entra tenant/organization |
36+
| `clientAppId` | `azp` (delegated) or `appid` (client-credentials) | Identifies the calling application — handles both user-interactive and service-to-service flows |
37+
| `audience` | `aud` | Captured for logging purposes |
38+
39+
The `clientAppId` extraction is notable: it prefers `azp` (set in delegated/on-behalf-of tokens) and falls back to `appid` (set in client-credentials tokens). This supports both authentication flows.
40+
41+
### 1.3 Deployment ID Resolution
42+
43+
```
44+
deploymentId = path segment from /deployments/{id}/...
45+
OR request body "model" field
46+
OR last URL segment
47+
```
48+
49+
Three-tier fallback:
50+
51+
1. **URL path** — extracts from `/deployments/{id}/...` (standard Azure OpenAI chat/completions route)
52+
2. **Request body** — reads the `model` field from JSON (for routes like `/responses`, `/models` that use the Responses API)
53+
3. **Last URL segment** — generic fallback
54+
55+
This `deploymentId` is used for per-model quota tracking and usage attribution.
56+
57+
### 1.4 Pre-authorization Check (Precheck)
58+
59+
```xml
60+
<authentication-managed-identity resource="{{ContainerAppAudience}}" ... />
61+
<send-request ... response-variable-name="precheckResponse">
62+
<set-url>{{ContainerAppUrl}}/api/precheck/{clientAppId}/{tenantId}?deploymentId={deploymentId}</set-url>
63+
</send-request>
64+
```
65+
66+
**This is the core rate-limiting and quota gate.** Before the request reaches OpenAI:
67+
68+
1. APIM acquires a token via its **managed identity** for the Container App audience — no secrets are exchanged.
69+
2. Calls the Chargeback API's `/api/precheck` endpoint, which checks:
70+
- Client is registered and has a plan assigned
71+
- Monthly token quota is not exhausted
72+
- RPM/TPM rate limits are within bounds
73+
3. Response handling:
74+
- **401** → Client not authorized (no plan assigned) → returns 401 to caller
75+
- **429** → Quota/rate limit exceeded → returns 429 to caller
76+
- **Non-200** → Returns 500 to caller
77+
- **200** → Request proceeds to OpenAI
78+
79+
This **blocks unauthorized or over-quota requests before they incur OpenAI costs** — a key architectural decision.
80+
81+
### 1.5 Backend Configuration & Authentication
82+
83+
```xml
84+
<set-backend-service backend-id="openAiBackend" />
85+
<authentication-managed-identity resource="https://cognitiveservices.azure.com/" />
86+
```
87+
88+
- Routes the request to the pre-configured `openAiBackend` backend (defined in `infra/bicep/apimOaiApi.bicep`).
89+
- Authenticates to Azure OpenAI via APIM's **managed identity** with the Cognitive Services resource scope — **no API keys are used**.
90+
91+
### 1.6 Request Body Transformation
92+
93+
```xml
94+
<set-body>@{
95+
...
96+
if (requestBody["stream"] == true) {
97+
requestBody["stream_options"] = { "include_usage": true };
98+
}
99+
return requestBody.ToString();
100+
}</set-body>
101+
```
102+
103+
If the request has `"stream": true`, it injects `"stream_options": {"include_usage": true}`. This forces Azure OpenAI to include token usage data in the final streaming chunk, which is essential for accurate billing of streaming responses.
104+
105+
---
106+
107+
## 2. `<backend>` — Pass-Through
108+
109+
```xml
110+
<backend>
111+
<base />
112+
</backend>
113+
```
114+
115+
No custom logic — inherits default behavior. The request is forwarded to Azure OpenAI as configured in the inbound section.
116+
117+
---
118+
119+
## 3. `<outbound>` — Response Processing (Post-Backend)
120+
121+
### 3.1 Response Parsing
122+
123+
The policy handles both **non-streaming** and **streaming (SSE)** responses:
124+
125+
- **Non-streaming**: Parses the entire response body as JSON.
126+
- **Streaming**: Identifies `data:` prefixed lines (Server-Sent Events), finds the **last chunk** containing a JSON payload (excluding `[DONE]`), and parses it. This last chunk is where Azure OpenAI places the `usage` object (enabled by the inbound `stream_options` injection).
127+
128+
### 3.2 Fire-and-Forget Usage Logging
129+
130+
```xml
131+
<send-one-way-request mode="new">
132+
<set-url>{{ContainerAppUrl}}/api/log</set-url>
133+
...
134+
</send-one-way-request>
135+
```
136+
137+
Sends a **one-way (fire-and-forget)** POST to the Chargeback API's `/api/log` endpoint with:
138+
139+
| Field | Source |
140+
|-------|--------|
141+
| `tenantId` | JWT `tid` claim |
142+
| `clientAppId` | JWT `azp`/`appid` claim |
143+
| `audience` | JWT `aud` claim |
144+
| `requestBody` | Original client request |
145+
| `responseBody` | Parsed OpenAI response (with usage data) |
146+
| `deploymentId` | Extracted model/deployment name |
147+
148+
**Key design choice**: `send-one-way-request` means the client gets the OpenAI response immediately without waiting for logging to complete. This eliminates latency overhead on the response path.
149+
150+
---
151+
152+
## 4. `<on-error>` — Error Handling
153+
154+
Captures detailed error information into HTTP response headers:
155+
156+
| Header | Value |
157+
|--------|-------|
158+
| `ErrorSource` | Which component failed |
159+
| `ErrorReason` | Error reason code |
160+
| `ErrorMessage` | Human-readable error message |
161+
| `ErrorScope` | Policy scope where error occurred |
162+
| `ErrorSection` | Which section (inbound/outbound/backend) |
163+
| `ErrorPath` | URL path that triggered the error |
164+
| `ErrorPolicyId` | Specific policy element that failed |
165+
| `ErrorStatusCode` | HTTP status code |
166+
167+
---
168+
169+
## 5. Security Analysis
170+
171+
| Aspect | Assessment |
172+
|--------|-----------|
173+
| **Authentication** | Strong — Entra ID JWT validation with audience restriction |
174+
| **Multi-tenant** | `/common` endpoint supports any Entra tenant; tenant isolation is enforced at the data layer via `tenantId` |
175+
| **No API keys** | All backend calls use managed identity — no secrets in policy or config |
176+
| **Pre-authorization** | Requests are gated before reaching OpenAI, preventing cost abuse |
177+
| **APIM-to-backend auth** | Managed identity token for Container App audience — zero-trust between components |
178+
179+
### Considerations
180+
181+
- The `on-error` section exposes detailed error metadata in HTTP headers (source, path, policyId). This is useful for debugging but in production you may want to limit this to avoid leaking internal implementation details to external callers.
182+
- The fire-and-forget log payload is constructed via string interpolation (`$"{{...}}"`). If `requestBody` or `parsedResponseString` contain unescaped JSON, the payload is well-formed because they're already JSON strings. However, if either is empty or malformed, the `/api/log` endpoint receives invalid JSON — the backend handles this gracefully with a `BadRequest` response.
183+
- The `validate-jwt` policy does not restrict `iss` (issuer) claims beyond what `/common` provides. Any Entra tenant can authenticate — access control is delegated to the precheck endpoint which verifies the `clientAppId:tenantId` pair is registered.
184+
185+
---
186+
187+
## 6. Named Values (APIM Configuration)
188+
189+
The policy references the following `{{named values}}` that must be configured in the APIM instance:
190+
191+
| Named Value | Purpose |
192+
|-------------|---------|
193+
| `{{ExpectedAudience}}` | The `aud` claim value required in JWT tokens (typically the APIM app registration's Application ID URI) |
194+
| `{{ContainerAppAudience}}` | The resource/audience used when acquiring a managed identity token for the Container App |
195+
| `{{ContainerAppUrl}}` | Base URL of the Chargeback API running on Azure Container Apps |
196+
197+
---
198+
199+
## 7. Flow Summary
200+
201+
```
202+
Client Request
203+
204+
205+
┌─────────────────────────────────────────┐
206+
│ INBOUND │
207+
│ 1. Validate JWT (Entra ID, audience) │
208+
│ 2. Extract claims (tid, appid/azp) │
209+
│ 3. Resolve deploymentId │
210+
│ 4. Precheck → Container App │
211+
│ ├─ 401 → block (not authorized) │
212+
│ ├─ 429 → block (over quota) │
213+
│ └─ 200 → continue │
214+
│ 5. Set backend → openAiBackend │
215+
│ 6. Auth via managed identity │
216+
│ 7. Inject stream_options if streaming │
217+
└───────────────┬─────────────────────────┘
218+
219+
Azure OpenAI
220+
221+
222+
┌─────────────────────────────────────────┐
223+
│ OUTBOUND │
224+
│ 1. Parse response (SSE or JSON) │
225+
│ 2. Fire-and-forget POST to /api/log │
226+
│ (tenantId, clientAppId, usage) │
227+
└───────────────┬─────────────────────────┘
228+
229+
Response to Client (unmodified)
230+
```
231+
232+
This is a chargeback/metering gateway policy. The inbound precheck blocks unauthorized or over-quota requests before they incur OpenAI costs, and the outbound fire-and-forget logging ensures usage tracking doesn't add latency to client responses.

0 commit comments

Comments
 (0)