Skip to content

feat(azure-openai): wire AAD (Entra ID) client_credentials Bearer auth (#302 Phase F D6.6) - #388

Merged
moonming merged 2 commits into
mainfrom
feat/azure-aad-auth
May 24, 2026
Merged

feat(azure-openai): wire AAD (Entra ID) client_credentials Bearer auth (#302 Phase F D6.6)#388
moonming merged 2 commits into
mainfrom
feat/azure-aad-auth

Conversation

@moonming

@moonming moonming commented May 24, 2026

Copy link
Copy Markdown
Member

Summary

Adds the second Azure auth scheme to `aisix-provider-azure-openai`. Today the bridge supports only the resource-key scheme (`api-key:` header). This PR adds AAD client_credentials (Entra ID), so an operator can configure a ProviderKey backed by a service-principal app registration instead of pasting the resource's master api-key.

Backward-compatible. Existing api-key deployments keep working unchanged. The auth scheme is autodetected from the secret shape:

  • Secret starts with `{` → JSON-parse as AAD credentials `{tenant_id, client_id, client_secret}`. Bridge mints a token via the client_credentials grant, caches it, sends `Authorization: Bearer `.
  • Otherwise → verbatim string, used as the resource api-key (sent via the `api-key:` header per the existing path).

Wire shape (AAD branch)

```
POST https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=
&client_secret=
&scope=https://cognitiveservices.azure.com/.default
```

Unlike Vertex SA OAuth (#387), AAD client_credentials is a straight form-encoded POST — NO JWT signing on the gateway side. No `jsonwebtoken` dep added; pure reqwest + serde.

Cache

Keyed by `(tenant_id, client_id)` — multiple ProviderKeys backed by the same AAD app share a slot, but distinct apps under the same tenant don't collide. Refresh 60s before upstream-reported expiry. Pattern mirrors Vertex `token_mint`.

Error classification (audit-aware)

Mirrors the audit MEDIUM fix from #387 right out of the gate — no need for a separate audit cycle:

  • AAD 5xx → `BridgeError::UpstreamStatus` + `Retry-After` propagated (transient backend → cooldown layer, not 500 operator-must-fix).
  • AAD 4xx → `BridgeError::Config` (invalid_client / revoked secret / wrong scope IS operator-actionable).

Files

  • `aad_token_mint.rs` (new, ~290 lines + tests): TokenMinter, AadCredentials, RwLock-backed cache. 7 unit tests covering happy mint, cache reuse, cache separation across distinct apps, 5xx/4xx classification, empty/URL-injection rejection at validate().
  • `bridge.rs`:
    • Added `AzureSecret` discriminated parse + `AzureAuth` resolved-header pair.
    • Bridge struct carries an `Arc` for the AAD path.
    • `resolve_auth(ctx)` called BEFORE the chat / chat_stream future so AAD mint failures surface as direct `Err` returns.
    • `build_request_headers` signature changed from `&str` to `&AzureAuth`; emits either `api-key:` (legacy) or `Authorization: Bearer` (AAD).
    • Added test-only `with_aad_token_endpoint_override` seam.
    • 7 new tests: secret-parse (api-key / AAD / empty / bad JSON), end-to-end chat with AAD bearer header set, cache reuse across 3 chats, AAD 4xx surfaces before Azure call.
  • `lib.rs`: declares aad_token_mint module, ticks D6.6 in status block.

Test plan

  • `cargo test -p aisix-provider-azure-openai` → 53/53 PASS (was 46; +7 AAD)
  • `cargo clippy -p aisix-provider-azure-openai --all-targets -- -D warnings` clean
  • `cargo fmt --all` applied
  • CI
  • Independent audit (will spawn immediately per CLAUDE.md §8)

References (CLAUDE.md §7)

Unblocks

AC.12 hardening in api7/AISIX-Cloud#302. Azure-OpenAI was ~70% done (chat + stream + filter tolerance via #319); the AAD auth path was the explicit D6.6 gap the audit called out. Phase F is complete after this PR. Live e2e against the Step 0.1 mock-llm Azure profile is the next sub-step (separate PR).

Summary by CodeRabbit

  • New Features

    • Support for per-request Azure auth via either legacy API key or JSON AAD credentials (client_credentials -> Bearer token)
    • Automatic token minting with in-process caching and reuse; streaming requests use Bearer auth when applicable
  • Bug Fixes

    • AAD credential/validation failures surface immediately before outbound requests
  • Documentation

    • Updated Azure provider docs describing both auth modes
  • Tests

    • Added end-to-end and unit tests covering AAD minting, caching, error handling, and header behavior

Review Change Stack

#302 Phase F D6.6)

Adds the second Azure auth scheme to aisix-provider-azure-openai.
Today the bridge supports only the resource-key scheme (`api-key:`
header). This PR adds AAD client_credentials (Entra ID) so an
operator can configure a ProviderKey backed by a service-principal
app registration instead of pasting the resource's master api-key.

Backward-compatible: existing api-key deployments keep working
unchanged. The auth scheme is autodetected from the secret shape:

  - Secret starts with `{` → JSON-parse as AAD credentials
    {tenant_id, client_id, client_secret}. Bridge mints a token
    via the client_credentials grant, caches it, and sends
    Authorization: Bearer <minted-token>.
  - Otherwise → verbatim string, used as the resource api-key
    (sent via the api-key: header per the existing path).

## Wire shape (AAD branch)

```
POST https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token
  Content-Type: application/x-www-form-urlencoded

  grant_type=client_credentials
  &client_id=<app-registration-uuid>
  &client_secret=<rotation-managed-secret>
  &scope=https://cognitiveservices.azure.com/.default
```

Unlike Vertex SA OAuth (#387), AAD client_credentials is a straight
form-encoded POST — NO JWT signing on the gateway side. No
`jsonwebtoken` dep added; pure reqwest + serde.

## Cache

Keyed by `(tenant_id, client_id)` — multiple ProviderKeys backed
by the same AAD app share a slot, but distinct apps under the same
tenant don't collide. Refresh 60s before upstream-reported expiry.

## Error classification (audit-aware)

Mirrors the Vertex audit MEDIUM fix from ai-gateway#387:
  - AAD 5xx → BridgeError::UpstreamStatus + Retry-After propagated
    (transient backend should hit cooldown layer, not 500
    operator-must-fix).
  - AAD 4xx → BridgeError::Config (invalid_client / revoked secret
    / wrong scope IS operator-actionable).

## Files

- `aad_token_mint.rs` (new, ~290 lines + tests): TokenMinter,
  AadCredentials, RwLock-backed cache. 7 unit tests covering
  happy mint, cache reuse, cache separation across distinct apps,
  5xx/4xx classification, empty/URL-injection rejection at validate().
- `bridge.rs`:
    - Added `AzureSecret` discriminated parse (api-key verbatim
      vs AAD JSON), and `AzureAuth` resolved-header pair.
    - Bridge struct carries an Arc<TokenMinter> for the AAD path.
    - `resolve_auth(ctx)` is called BEFORE the chat / chat_stream
      future so AAD mint failures surface as direct Err returns
      (matches existing 4xx/timeout error semantics).
    - `build_request_headers` signature changed from `&str` to
      `&AzureAuth`; emits either `api-key:` (legacy) or
      `Authorization: Bearer` (AAD) based on which is set.
    - Added test-only `with_aad_token_endpoint_override` seam
      mirroring the existing `with_url_override` pattern.
    - Removed the now-unused `fn api_key()` helper (replaced by
      `AzureSecret::parse`).
    - 7 new tests: secret-parse (api-key / AAD / empty / bad JSON),
      end-to-end chat with AAD bearer header set, cache reuse
      across 3 chats, AAD 4xx surfaces before Azure call.
- `lib.rs`: declares aad_token_mint module, ticks D6.6 in status block.

`cargo test -p aisix-provider-azure-openai` → 53/53 PASS (+7).
`cargo clippy -p aisix-provider-azure-openai --all-targets -- -D warnings` clean.
`cargo fmt --all` applied.

## References (CLAUDE.md §7)

- Microsoft identity platform — client credentials grant flow:
  https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow
- Azure OpenAI Entra ID auth:
  https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/managed-identity
- OAuth2 RFC 6749 §4.4 (client_credentials):
  https://www.rfc-editor.org/rfc/rfc6749#section-4.4
- Mirrors the audit-corrected pattern from
  `aisix-provider-vertex::token_mint` (ai-gateway#387).

## Unblocks

AC.12 hardening in api7/AISIX-Cloud#302: Azure-OpenAI was already
~70% done (chat + stream + filter tolerance via #319); the AAD
auth path was the explicit D6.6 gap called out in the audit. With
this PR Phase F is complete. Live e2e against the Step 0.1
mock-llm Azure profile is the next sub-step (separate PR).
Copilot AI review requested due to automatic review settings May 24, 2026 12:12
@coderabbitai

coderabbitai Bot commented May 24, 2026

Copy link
Copy Markdown
ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 4a7a9c25-d99a-43c6-9087-e2e6a0537a14

📥 Commits

Reviewing files that changed from the base of the PR and between 57d76ea and 9fac7e5.

📒 Files selected for processing (1)
  • crates/aisix-provider-azure-openai/src/bridge.rs

📝 Walkthrough

Walkthrough

This PR adds Azure Entra ID client_credentials token minting and caching, secret parsing to choose API-key vs AAD flows, credential validation, refactors header construction to emit api-key or Authorization: Bearer, and integrates the TokenMinter with tests and docs.

Changes

Azure Entra ID Token Minting

Layer / File(s) Summary
AAD Token Minting Core
crates/aisix-provider-azure-openai/src/aad_token_mint.rs
New module: AadCredentials with validation, TokenMinter with async cache keyed by (tenant_id, client_id), tenant endpoint resolution, form-POST minting, error mapping (5xx→UpstreamStatus with Retry-After, 4xx→Config), safety-adjusted expiry caching, and wiremock-based unit tests covering POST fields, caching, cache isolation, error mapping, and credential validation.
Secret Parsing and Auth Resolution
crates/aisix-provider-azure-openai/src/bridge.rs (lines 289–343, 123–144)
AzureSecret parser detects legacy API-key vs JSON AAD credentials and returns audit-safe errors; resolve_auth() validates and produces AzureAuth, invoking TokenMinter::get_token() for AAD secrets.
Bridge Wiring
crates/aisix-provider-azure-openai/src/bridge.rs (lines 42–102)
Adds token_minter: Arc<TokenMinter> to AzureOpenAiBridge, initializes it in with_client(), and provides with_aad_token_endpoint_override() test seam plus test helper for sample AAD ProviderKey.
Request Header Refactoring & Entry Points
crates/aisix-provider-azure-openai/src/bridge.rs (lines 492–659, 586–659)
build_request_headers() now accepts &AzureAuth and emits either api-key or Authorization: Bearer with validation; chat() and chat_stream() resolve auth before building request futures so AAD token errors surface immediately.
Header Unit Tests
crates/aisix-provider-azure-openai/src/bridge.rs (lines 1007–1156)
Header tests updated to the new &AzureAuth API: added api_key_auth helper and adapted tests for API-key, SSE accept, default-reserved headers, and invalid character checks.
AAD Integration Tests
crates/aisix-provider-azure-openai/src/bridge.rs (lines 1821–2090)
Adds/extends async tests verifying AzureSecret parsing, non-echoing validation errors, end-to-end bearer header emission, token minting and caching across calls, per-registration cache isolation, and AAD 4xx failing before any Azure OpenAI request.
Documentation
crates/aisix-provider-azure-openai/src/lib.rs (lines 7–32, 71)
Clarifies D6.1 that Azure uses api-key header (not Bearer), documents the AAD (Entra ID) Bearer auth detection and minting/caching behavior, and adds mod aad_token_mint;.

Sequence Diagram

sequenceDiagram
  participant Client as Bridge Call-site
  participant ResolveAuth as resolve_auth()
  participant TokenMinter
  participant Cache as In-Memory Cache
  participant AzureTokenEndpoint as login.microsoftonline.com
  participant AzureUpstream as Azure OpenAI Upstream

  Client->>ResolveAuth: provider_key.secret
  ResolveAuth->>ResolveAuth: parse AzureSecret (API key vs AAD JSON)
  alt API-key
    ResolveAuth-->>Client: AzureAuth { api_key }
    Client->>AzureUpstream: request with header `api-key: ...`
  else AAD
    ResolveAuth->>TokenMinter: get_token(&AadCredentials)
    TokenMinter->>Cache: lookup (tenant, client)
    alt cached
      Cache-->>TokenMinter: token
      TokenMinter-->>ResolveAuth: access_token
    else mint
      TokenMinter->>AzureTokenEndpoint: POST client_credentials form
      AzureTokenEndpoint-->>TokenMinter: {access_token, expires_in} / 4xx / 5xx
      TokenMinter->>Cache: store token (on 2xx)
      TokenMinter-->>ResolveAuth: access_token or BridgeError
    end
    ResolveAuth-->>Client: AzureAuth { bearer_token }
    Client->>AzureUpstream: request with header `Authorization: Bearer ...`
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes


Note

🎁 Summarized by CodeRabbit Free

Your organization has reached its limit of developer seats under the Pro Plan. For new users, CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please add seats to your subscription by visiting https://app.coderabbit.ai/login.If you believe this is a mistake and have available seats, please assign one to the pull request author through the subscription management page using the link above.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Azure OpenAI Entra ID (AAD) client_credentials authentication to aisix-provider-azure-openai, alongside the existing resource api-key scheme, by autodetecting the auth mode from provider_key.secret and minting/caching Bearer tokens in-process when AAD credentials are provided.

Changes:

  • Introduces aad_token_mint module with AadCredentials validation, token minting, and (tenant_id, client_id)-keyed cache.
  • Updates Azure bridge to parse/discriminate secrets, resolve auth early, and emit either api-key or Authorization: Bearer headers.
  • Adds unit tests for secret parsing, AAD minting behavior, caching, and error classification.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
crates/aisix-provider-azure-openai/src/lib.rs Updates status docs and wires in the new aad_token_mint module.
crates/aisix-provider-azure-openai/src/bridge.rs Adds secret parsing + per-request auth resolution; updates header construction to support Bearer auth; adds AAD-related tests.
crates/aisix-provider-azure-openai/src/aad_token_mint.rs Implements AAD client-credentials token minting with cache, validation, and error classification + tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +314 to +332
let trimmed = secret.trim();
if trimmed.is_empty() {
return Err(BridgeError::Config("provider_key.secret is empty".into()));
}
if trimmed.starts_with('{') {
let creds: crate::aad_token_mint::AadCredentials = serde_json::from_str(trimmed)
.map_err(|_e| {
BridgeError::Config(
"azure provider_key.secret looks JSON-shaped but failed to parse \
as AAD client_credentials \
{tenant_id, client_id, client_secret}"
.into(),
)
})?;
creds.validate()?;
Ok(AzureSecret::Aad(creds))
} else {
Ok(AzureSecret::ApiKey(trimmed.to_string()))
}
Comment on lines +94 to +111
for (name, value) in [
("tenant_id", &self.tenant_id),
("client_id", &self.client_id),
] {
if value.contains('/')
|| value.contains('?')
|| value.contains('#')
|| value.contains(' ')
|| value.contains('\t')
|| value.contains('\n')
|| value.contains("..")
{
return Err(BridgeError::Config(format!(
"azure aad credentials.{name} {value:?} contains URL-control \
characters — reject `/`, `?`, `#`, whitespace, `..`"
)));
}
}
Comment on lines +153 to +156
/// Test-only seam: replace the `login.microsoftonline.com` host
/// with this URL. Tenant id is still interpolated into the path
/// (so the request URL shape is verifiable end-to-end against
/// wiremock matchers).
…t LOW on #388)

audit-aigw-388-azure-aad flagged that chat_stream() calls the same
resolve_auth helper as chat() but had no test pinning the AAD →
Authorization: Bearer flow on the streaming path. A future refactor
that accidentally skipped resolve_auth in chat_stream (e.g.
moved auth resolution into the chat() future and forgot to mirror
it on the stream side) would slip past every existing test.

Mirrors the same gap noted in audit-aigw-387 (Vertex SA OAuth)
which was deferred there as non-blocking; applying the equivalent
guard here while the cost is one short test function.

The test pins:
  - Authorization: Bearer <minted-token> set on the upstream
    stream request
  - api-key: NOT set (mutex with bearer path)
  - Accept: text/event-stream set (matches existing chat_stream
    contract regardless of auth scheme)

cargo test -p aisix-provider-azure-openai → 54/54 PASS (was 53; +1).
@moonming

Copy link
Copy Markdown
Member Author

Audit response — addressed

Independent audit-aigw-388-azure-aad returned APPROVE, no HIGH/MEDIUM findings. Audit-verified against Microsoft docs:

  • Token endpoint URL + form body + scope correct
  • Bearer / api-key header mutex correct
  • Backward compat with verbatim-string api-key preserved
  • 5xx → UpstreamStatus + Retry-After classification correct (lifted from feat(vertex): in-process SA JSON → JWT → OAuth + token cache (#302 Phase E D5.1) #387 audit)
  • No client_secret leakage in any error/log path (validate() only quotes tenant_id/client_id values)
  • Test fixtures use placeholder UUIDs / fake secrets

LOW addressed in code (commit `9fac7e5`)

The single LOW finding was no chat_stream-side AAD test (same gap noted in audit-aigw-387 on Vertex). Added `chat_stream_with_aad_secret_sets_authorization_bearer_header` per the audit's suggested code — pins:

  • `Authorization: Bearer ` set on stream-path request
  • `api-key:` header NOT set (bearer/api-key mutex)
  • `Accept: text/event-stream` set

`cargo test -p aisix-provider-azure-openai` → 54/54 PASS (was 53; +1).

All audit findings addressed. Awaiting fresh CI green.

@moonming
moonming merged commit 62038e0 into main May 24, 2026
8 checks passed
@moonming
moonming deleted the feat/azure-aad-auth branch May 24, 2026 12:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants