diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index e7de806..5de4ecf 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -28,6 +28,7 @@ updates:
patterns:
- "xunit*"
- "Microsoft.NET.Test.Sdk"
+ - "Microsoft.Testing.*"
- "NSubstitute"
- "Shouldly"
- "Microsoft.AspNetCore.Mvc.Testing"
diff --git a/.github/workflows/cd-cleanup.yml b/.github/workflows/cd-cleanup.yml
index 39c34f6..4ce37da 100644
--- a/.github/workflows/cd-cleanup.yml
+++ b/.github/workflows/cd-cleanup.yml
@@ -29,13 +29,27 @@ jobs:
matrix:
env: [ci, ppe]
steps:
- - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0
+ - name: Skip if tier never bootstrapped
+ id: precheck
+ env:
+ AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
+ run: |
+ if [ -z "${AZURE_CLIENT_ID}" ]; then
+ echo "::notice title=Cleanup skipped (${{ matrix.env }})::AZURE_CLIENT_ID not set on this GitHub Environment; tier never bootstrapped."
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "skip=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - if: steps.precheck.outputs.skip == 'false'
+ uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- - name: Reset min/max replicas in rg-ftgo-${{ matrix.env }}-${{ vars.LOCATION || 'eastus' }}
+ - if: steps.precheck.outputs.skip == 'false'
+ name: Reset min/max replicas in rg-ftgo-${{ matrix.env }}-${{ vars.LOCATION || 'eastus' }}
env:
LOCATION: ${{ vars.LOCATION || 'eastus' }}
run: |
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
index a4b31b3..adb3630 100644
--- a/.github/workflows/cd.yml
+++ b/.github/workflows/cd.yml
@@ -141,7 +141,7 @@ jobs:
- name: Upload Trivy SARIF
if: always()
- uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
+ uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
sarif_file: trivy-${{ steps.meta.outputs.SHORT_NAME }}.sarif
category: trivy-${{ steps.meta.outputs.SHORT_NAME }}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b9e166c..8d5aafa 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -188,7 +188,7 @@ jobs:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - uses: DavidAnson/markdownlint-cli2-action@992badcdf24e3b8eb7e87ff9287fe931bcb00c6e # v20.0.0
+ - uses: DavidAnson/markdownlint-cli2-action@6b51ade7a9e4a75a7ad929842dd298a3804ebe8b # v23.1.0
with:
globs: |
**/*.md
@@ -203,7 +203,7 @@ jobs:
issues: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 # v2.7.0
+ - uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2.7.0
with:
args: --no-progress --max-concurrency 4 --exclude-mail './**/*.md'
fail: false
diff --git a/.github/workflows/drift-detection.yml b/.github/workflows/drift-detection.yml
new file mode 100644
index 0000000..b2310c0
--- /dev/null
+++ b/.github/workflows/drift-detection.yml
@@ -0,0 +1,70 @@
+name: drift-detection
+
+# Weekly Bicep what-if against ci/ppe to surface infrastructure drift between
+# what main says is deployed and what's actually live in Azure. Output is a
+# job summary annotation; no auto-remediation.
+
+on:
+ schedule:
+ - cron: '0 6 * * 1' # Mondays 06:00 UTC
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: drift-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ what-if:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ permissions:
+ id-token: write
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix:
+ env: [ci, ppe]
+ environment: ${{ matrix.env }}
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Azure login
+ uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0
+ with:
+ client-id: ${{ vars.AZURE_CLIENT_ID }}
+ tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
+
+ - name: Skip if RG missing (tier never provisioned)
+ id: rg-check
+ env:
+ RG: rg-ftgo-${{ matrix.env }}-${{ vars.LOCATION || 'eastus' }}
+ run: |
+ if ! az group show --name "$RG" --only-show-errors --output none 2>/dev/null; then
+ echo "::notice title=Drift skipped::$RG does not exist; tier never provisioned."
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "skip=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Bicep what-if
+ if: steps.rg-check.outputs.skip == 'false'
+ env:
+ RG: rg-ftgo-${{ matrix.env }}-${{ vars.LOCATION || 'eastus' }}
+ AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
+ run: |
+ set -euo pipefail
+ out=$(az deployment group what-if \
+ --resource-group "$RG" \
+ --template-file infra/bicep/azure.bicep \
+ --parameters infra/bicep/azure.${{ matrix.env }}.bicepparam \
+ --no-pretty-print --result-format ResourceIdOnly 2>&1) || true
+ echo "$out"
+ if echo "$out" | grep -qE '^(\+|\-|\~|=) '; then
+ echo "::warning title=Drift detected (${{ matrix.env }})::Bicep what-if reports differences against $RG. Review the run log."
+ else
+ echo "::notice title=No drift (${{ matrix.env }})::Live state matches main."
+ fi
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index 8bc763f..ab30f55 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -51,6 +51,6 @@ jobs:
retention-days: 5
- name: Upload to code-scanning
- uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
+ uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
sarif_file: results.sarif
diff --git a/.markdownlint.yaml b/.markdownlint.yaml
index 5781946..17dfbb8 100644
--- a/.markdownlint.yaml
+++ b/.markdownlint.yaml
@@ -11,3 +11,4 @@ MD040: false
MD028: false
MD036: false
MD031: false
+MD060: false
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 019e298..fce1f51 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -7,7 +7,7 @@
-
+
@@ -19,8 +19,8 @@
-
-
+
+
@@ -39,19 +39,19 @@
-
+
-
-
+
+
-
+
diff --git a/SCOPE.md b/SCOPE.md
index 90c2a98..67d534a 100644
--- a/SCOPE.md
+++ b/SCOPE.md
@@ -66,16 +66,16 @@ Call them out so readers don't expect them.
## 5. "If you are X, this guide is Y for you"
-| Reader archetype | Recommendation |
-|---|---|
-| Staff .NET engineer at a 200-person SaaS, multi-team, cloud-native on Azure | **Primary reader.** Read [`docs/decision-trees.md`](./docs/decision-trees.md) → SCOPE → [`docs/matrix.md`](./docs/matrix.md) → [`docs/best-practices.md`](./docs/best-practices.md). |
-| Platform / auth-library owner (`EntraAuth.*` shared NuGet, multi-product) | **Primary reader.** Start at [`docs/aks-shared-infra.md`](./docs/aks-shared-infra.md), then [`docs/acquisition.md`](./docs/acquisition.md) and [`docs/validation.md`](./docs/validation.md). |
-| Architect picking credential type for a new service | **Primary reader.** [`docs/decision-trees.md`](./docs/decision-trees.md) Tree 2 + Tree 5 → [`docs/credential-patterns/index.md`](./docs/credential-patterns/index.md). |
-| Solo developer, one app, pre-PMF | **Skim only.** [`docs/run-locally.md`](./docs/run-locally.md) and [`docs/best-practices.md`](./docs/best-practices.md) are worth an hour; the platform / shared-infra chapters are premature. |
-| ASP.NET Core engineer needing JWT validation defaults only | **Targeted reader.** [`docs/validation.md`](./docs/validation.md) is the chapter; the rest is background. |
-| Mobile / SPA developer | **Wrong guide.** Read MSAL.js or MSAL mobile docs first; come back when you own a server-side .NET API. |
-| IGA / access-review engineer | **Wrong guide.** Use [Microsoft Entra ID Governance](https://learn.microsoft.com/entra/id-governance/identity-governance-overview); this guide is for app developers, not directory operators. |
-| Compliance / GRC reviewer mapping controls | **Reference, not source of truth.** Use this as the engineering-side counterpart to your control catalog; mapping work is yours. |
+| Reader archetype | Recommendation |
+| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Staff .NET engineer at a 200-person SaaS, multi-team, cloud-native on Azure | **Primary reader.** Read [`docs/decision-trees.md`](./docs/decision-trees.md) → SCOPE → [`docs/matrix.md`](./docs/matrix.md) → [`docs/best-practices.md`](./docs/best-practices.md). |
+| Platform / auth-library owner (`EntraAuth.*` shared NuGet, multi-product) | **Primary reader.** Start at [`docs/aks-shared-infra.md`](./docs/aks-shared-infra.md), then [`docs/acquisition.md`](./docs/acquisition.md) and [`docs/validation.md`](./docs/validation.md). |
+| Architect picking credential type for a new service | **Primary reader.** [`docs/decision-trees.md`](./docs/decision-trees.md) Tree 2 + Tree 5 → [`docs/credential-patterns/index.md`](./docs/credential-patterns/index.md). |
+| Solo developer, one app, pre-PMF | **Skim only.** [`docs/run-locally.md`](./docs/run-locally.md) and [`docs/best-practices.md`](./docs/best-practices.md) are worth an hour; the platform / shared-infra chapters are premature. |
+| ASP.NET Core engineer needing JWT validation defaults only | **Targeted reader.** [`docs/validation.md`](./docs/validation.md) is the chapter; the rest is background. |
+| Mobile / SPA developer | **Wrong guide.** Read MSAL.js or MSAL mobile docs first; come back when you own a server-side .NET API. |
+| IGA / access-review engineer | **Wrong guide.** Use [Microsoft Entra ID Governance](https://learn.microsoft.com/entra/id-governance/identity-governance-overview); this guide is for app developers, not directory operators. |
+| Compliance / GRC reviewer mapping controls | **Reference, not source of truth.** Use this as the engineering-side counterpart to your control catalog; mapping work is yours. |
---
diff --git a/docs/acquisition.md b/docs/acquisition.md
index 1b23ee1..928beda 100644
--- a/docs/acquisition.md
+++ b/docs/acquisition.md
@@ -2,10 +2,10 @@
Two flows you ever acquire on the server:
-| Flow | Who is the token *for* | Claim shape | OAuth grant |
-|---|---|---|---|
-| **App token** | The calling app/workload | `roles` *(when app roles are assigned/required)*, no `scp`; `idtyp=app` may not be present (defense-in-depth only — rely on `roles` + `azp` allow-list for enforcement, see [validation.md §4 App-token specific checks](validation.md#4-app-token-specific-checks)) | `client_credentials` (or FIC assertion) |
-| **User token** | The signed-in user | `scp` (delegated scopes); identity in `oid` + `tid` (use these for decisions); `name` / `preferred_username` for display | `authorization_code` (client) → API → **OBO** for downstream |
+| Flow | Who is the token *for* | Claim shape | OAuth grant |
+| -------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
+| **App token** | The calling app/workload | `roles` *(when app roles are assigned/required)*, no `scp`; `idtyp=app` may not be present (defense-in-depth only — rely on `roles` + `azp` allow-list for enforcement, see [validation.md §4 App-token specific checks](validation.md#4-app-token-specific-checks)) | `client_credentials` (or FIC assertion) |
+| **User token** | The signed-in user | `scp` (delegated scopes); identity in `oid` + `tid` (use these for decisions); `name` / `preferred_username` for display | `authorization_code` (client) → API → **OBO** for downstream |
> Rule of thumb: if there is no human in the request, you want an **app token**. If there is, propagate the user identity via **OBO**, don't fall back to an app token.
@@ -93,11 +93,11 @@ Same as cert but `.WithClientSecret("…")`. Rotate ≤ 6 months, store only in
### 1e. Picking the library
-| You are doing… | Use |
-|---|---|
-| Calling an **Azure resource SDK** | `Azure.Identity` (`TokenCredential`) |
-| Calling **your own / a 3rd-party Entra-protected API** from a worker | `MSAL.NET` (`ConfidentialClientApplication`) |
-| Calling a downstream API **from inside an ASP.NET Core API** | `Microsoft.Identity.Web` → `ITokenAcquisition` / `IDownstreamApi` |
+| You are doing… | Use |
+| -------------------------------------------------------------------- | ----------------------------------------------------------------- |
+| Calling an **Azure resource SDK** | `Azure.Identity` (`TokenCredential`) |
+| Calling **your own / a 3rd-party Entra-protected API** from a worker | `MSAL.NET` (`ConfidentialClientApplication`) |
+| Calling a downstream API **from inside an ASP.NET Core API** | `Microsoft.Identity.Web` → `ITokenAcquisition` / `IDownstreamApi` |
---
@@ -155,14 +155,14 @@ The credential the API uses to authenticate **itself** to the token endpoint dur
## 3. Single-tenant vs multi-tenant — at acquisition time
-| | Single-tenant | Multi-tenant |
-|---|---|---|
-| Authority | `https://login.microsoftonline.com/` | `https://login.microsoftonline.com/organizations` (work/school — default for Entra-only APIs). Only use `/common` (work + MSA) if you truly accept personal accounts and filter explicitly. |
-| App registration | `signInAudience: AzureADMyOrg` | `AzureADMultipleOrgs` (or `…AndPersonalMicrosoftAccount`) |
-| Admin consent | Once, in your tenant | Per-tenant; expose via admin-consent URL |
-| App token (`/.default`) | Issued by your tenant | Issued by the **calling tenant** — the app must be provisioned there |
-| OBO | Same-tenant | Token is issued in the **user's** home tenant; downstream must accept that issuer. The downstream API **must** enforce a `tid` allow-list via `IssuerValidator` — see [validation.md §3 Issuer & audience](validation.md#3-issuer-audience-v1-vs-v2-single-vs-multi-tenant). |
-| MI / FIC | Same | MI is per-resource in your tenant — to call into other tenants you need a **multi-tenant app reg** + cert/FIC, not raw MI |
+| | Single-tenant | Multi-tenant |
+| ----------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Authority | `https://login.microsoftonline.com/` | `https://login.microsoftonline.com/organizations` (work/school — default for Entra-only APIs). Only use `/common` (work + MSA) if you truly accept personal accounts and filter explicitly. |
+| App registration | `signInAudience: AzureADMyOrg` | `AzureADMultipleOrgs` (or `…AndPersonalMicrosoftAccount`) |
+| Admin consent | Once, in your tenant | Per-tenant; expose via admin-consent URL |
+| App token (`/.default`) | Issued by your tenant | Issued by the **calling tenant** — the app must be provisioned there |
+| OBO | Same-tenant | Token is issued in the **user's** home tenant; downstream must accept that issuer. The downstream API **must** enforce a `tid` allow-list via `IssuerValidator` — see [validation.md §3 Issuer & audience](validation.md#3-issuer-audience-v1-vs-v2-single-vs-multi-tenant). |
+| MI / FIC | Same | MI is per-resource in your tenant — to call into other tenants you need a **multi-tenant app reg** + cert/FIC, not raw MI |
Key takeaway: **MI is single-tenant by nature.** For cross-tenant S2S, use a multi-tenant app registration with a cert or FIC, or use MI to call your own multi-tenant app reg's federated credential.
diff --git a/docs/aks-shared-infra.md b/docs/aks-shared-infra.md
index 7798090..3e28bf9 100644
--- a/docs/aks-shared-infra.md
+++ b/docs/aks-shared-infra.md
@@ -71,18 +71,18 @@ Token-forging helpers (signed by a test JWKS) and ready-made negative-test fixtu
## Library vs Service — decision rationale
-| Concern | Shared library + IaC (recommended) | Shared auth service (the proposal) |
-|---|---|---|
-| Solves duplication across 20 services | ✅ One implementation, semver'd | ✅ Only if every service migrates *and stays migrated* |
-| Solves drift across 10 products | ✅ Defaults + lint enforce standards | ⚠️ Standards leak into bespoke proxy logic |
-| Runtime cost | None — in-process, JWKS cached | +1 hop per request; p99 hit; SPOF when it hiccups |
-| Operational cost | Owned like any internal NuGet | New tier-0 service: capacity, on-call, regional HA, DR |
-| Conway's-law risk | Low — teams self-serve via package upgrade | High — every product's auth change queues behind one team |
-| Token re-issuance temptation | None | Inevitable — you become an IdP |
-| OBO / delegated downstream | Lives in the calling service, supported by lib helpers | **Cannot** be centralized — needs the calling service's credential & audience |
-| Cross-product S2S | App tokens with `roles`, validated locally; lib handles boilerplate | The proxy adds nothing |
-| Failure blast radius | Bad release rolled back per-service, gradual canary | Auth service down ⇒ every product down |
-| Rollout | Canary one service, fan out; pin versions per product | Big-bang or risky dual-stack |
+| Concern | Shared library + IaC (recommended) | Shared auth service (the proposal) |
+| ------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
+| Solves duplication across 20 services | ✅ One implementation, semver'd | ✅ Only if every service migrates *and stays migrated* |
+| Solves drift across 10 products | ✅ Defaults + lint enforce standards | ⚠️ Standards leak into bespoke proxy logic |
+| Runtime cost | None — in-process, JWKS cached | +1 hop per request; p99 hit; SPOF when it hiccups |
+| Operational cost | Owned like any internal NuGet | New tier-0 service: capacity, on-call, regional HA, DR |
+| Conway's-law risk | Low — teams self-serve via package upgrade | High — every product's auth change queues behind one team |
+| Token re-issuance temptation | None | Inevitable — you become an IdP |
+| OBO / delegated downstream | Lives in the calling service, supported by lib helpers | **Cannot** be centralized — needs the calling service's credential & audience |
+| Cross-product S2S | App tokens with `roles`, validated locally; lib handles boilerplate | The proxy adds nothing |
+| Failure blast radius | Bad release rolled back per-service, gradual canary | Auth service down ⇒ every product down |
+| Rollout | Canary one service, fan out; pin versions per product | Big-bang or risky dual-stack |
The library approach gives you everything you wanted (one authoritative implementation) without inheriting the downsides of a runtime auth service.
diff --git a/docs/credential-patterns/index.md b/docs/credential-patterns/index.md
index 11e45cb..33de7ef 100644
--- a/docs/credential-patterns/index.md
+++ b/docs/credential-patterns/index.md
@@ -13,13 +13,13 @@ the credential type is part of the policy, not an operational detail.
## Decision matrix
-| Where does your workload run? | Credential |
-|---------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
-| Azure (App Service, ACA, AKS, Functions, …) | **must** use [Managed Identity](managed-identity.md) |
-| GitHub Actions | **must** use [Workload Identity Federation](federated-identity.md) |
-| GKE / EKS / on-prem K8s with SPIFFE / OIDC | **must** use [Workload Identity Federation](federated-identity.md) |
-| On-prem service with no IdP / HSM-bound key | **should** use [Certificate](cert.md) |
-| Anything else | you almost certainly don't need a [client secret](client-secret.md) — see that page for the three documented exceptions; **strongly prefer** MI or FIC |
+| Where does your workload run? | Credential |
+| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Azure (App Service, ACA, AKS, Functions, …) | **must** use [Managed Identity](managed-identity.md) |
+| GitHub Actions | **must** use [Workload Identity Federation](federated-identity.md) |
+| GKE / EKS / on-prem K8s with SPIFFE / OIDC | **must** use [Workload Identity Federation](federated-identity.md) |
+| On-prem service with no IdP / HSM-bound key | **should** use [Certificate](cert.md) |
+| Anything else | you almost certainly don't need a [client secret](client-secret.md) — see that page for the three documented exceptions; **strongly prefer** MI or FIC |
Cross-references:
diff --git a/docs/decision-trees.md b/docs/decision-trees.md
index fe68fc3..9f48683 100644
--- a/docs/decision-trees.md
+++ b/docs/decision-trees.md
@@ -73,13 +73,13 @@ References: [`docs/validation.md#3-issuer-audience-v1-vs-v2-single-vs-multi-tena
- Trigger: protecting a new endpoint that may be hit by signed-in users (`scp`), app-only callers (`roles` + `azp`), or both.
- Cost of wrong call: a single OR-claims policy that lets an app-only token reach a user-only endpoint (no `azp` allow-list, no `scp` check), or a user token satisfy an app-only endpoint by carrying an unrelated `scp`. Both are real privilege-escalation bugs in production APIs. Mirrors dotnet-guide [ch02 §10](https://github.com/mghabin/dotnet-engineering-guide/blob/main/docs/02-aspnetcore.md#10-authnauthz).
-- Default per [`validation.md`](../docs/validation.md) §4 + dotnet-guide ch02 §10: **two separate named policies on two separate authorisation attributes**, never an OR-claims policy. Delegated → `[Authorize] + [RequiredScope("orders.read")]`. App-only → `[Authorize(Roles="Orders.Process")] + RequireClientApp` (azp allow-list). Both → list both attributes; each enforces its own invariants.
+- Default per [`validation.md`](../docs/validation.md) §4 + dotnet-guide ch02 §10: **two separate named policies on two separate authorisation attributes**, never an OR-claims policy. Delegated → `[Authorize] + [RequiredScope("orders.read")]`. App-only → `[Authorize(Policy = OrdersAuthorizationPolicies.App)]` (one named policy combining role + `azp` allow-list + `scp`-rejection). Both → list both attributes; each enforces its own invariants. **Never `[Authorize(Roles=...)]`** — it silently no-ops on `MapInboundClaims=false` schemes; see [DOCTRINE.md](../DOCTRINE.md#authorization-policy-doctrine-canonical).
```mermaid
flowchart TD
A[New protected endpoint] --> B{Caller identity model?}
B -->|Delegated user only| C["[Authorize] + [RequiredScope(scope)]
reject if roles present without scp — validation.md §4"]
- B -->|App-only / daemon only| D["[Authorize(Roles=app-role)]
+ RequireClientApp azp allow-list
+ reject if scp present — validation.md §4"]
+ B -->|App-only / daemon only| D["[Authorize(Policy=*App)]
via AddAppPolicy: role + azp allow-list
+ reject if scp present — validation.md §4"]
B -->|Both flows in scope| E[Two separate named policies
on two separate authorizations
NEVER one OR-claims policy — validation.md §4]
C --> F[Each request: presence of scp
+ specific scope value — validation.md §4]
D --> G[Each request: presence of roles
+ azp / appid in allow-list
+ absence of scp — validation.md §4]
diff --git a/docs/operations.md b/docs/operations.md
index 9fa7fcb..7e676fd 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -181,17 +181,21 @@ the workflow still fails with that code, check that
4. The script writes `vars.ENTRA_CONFIG_JSON` for the env so future CD
runs are fully declarative.
-## Renaming a deployment tier (one-shot live cutover)
+## Renaming a deployment tier (template — historical example: `dev` → `ci`)
-This runbook is the **only safe order** for renaming a tier (e.g.
-`dev` → `ci`, as the `local + ci + ppe + prod` rename did). The
+> **Status: template, not active.** This is a generic runbook. The `dev` →
+> `ci` rename it walks through happened in **PR #103** and is **complete**
+> — current main has no `dev` tier. Reuse this procedure (substituting your
+> source/target names) if you ever need to rename another tier.
+
+This runbook is the **only safe order** for renaming a tier. The
non-obvious bit is OIDC: the GitHub OIDC token's `sub` claim is
`repo:OWNER/REPO:environment:`, which is matched verbatim
against the federated-identity-credential subject on the bootstrap
app reg. Rename the GH environment **before** the FIC and the next
`azure/login@…` call fails with `AADSTS70021`.
-Worked example: cut over from `dev` → `ci`.
+Worked example (historical): cut over from `dev` → `ci`.
1. **Codebase first** (no live changes). Land the rename PR (see
the Phase A + B commits on `refactor/env-rename-dev-to-ci`).
diff --git a/docs/validation.md b/docs/validation.md
index 23dd6d8..c6af822 100644
--- a/docs/validation.md
+++ b/docs/validation.md
@@ -42,22 +42,24 @@ builder.Services.Configure(JwtBearerDefaults.AuthenticationSch
## 2. Authorization — `scp` vs `roles`
-| Token type | Claim | Source |
-|---|---|---|
-| User (delegated) | `scp` (space-separated) | Scopes the user consented to |
-| App (S2S) | `roles` (array) | App roles you defined on the API and granted to the client app |
+| Token type | Claim | Source |
+| ---------------- | ----------------------- | -------------------------------------------------------------- |
+| User (delegated) | `scp` (space-separated) | Scopes the user consented to |
+| App (S2S) | `roles` (array) | App roles you defined on the API and granted to the client app |
-Endpoint policy:
+Endpoint policy (FTGO sample):
```csharp
[Authorize]
-[RequiredScope("Files.Read")] // user token must carry scp=Files.Read
+[RequiredScope("orders.read")] // user token must carry scp=orders.read
public IActionResult ReadAsUser() => …
-[Authorize(Roles = "Tasks.Process.All")] // app token must carry that role
+[Authorize(Policy = OrdersAuthorizationPolicies.App)] // app token: roles=Orders.Process AND azp ∈ allow-list
public IActionResult ProcessAsApp() => …
```
+> **Never** `[Authorize(Roles = "...")]` on a Microsoft.Identity.Web JWT scheme. Those schemes set `MapInboundClaims = false` (defense-in-depth), so the framework's role check looks for `ClaimTypes.Role` while Entra emits the role claim under the short name `roles`. The check silently fails. Always use a named policy via `AddAppPolicy(...)` — see [DOCTRINE.md § "Authorization-policy doctrine"](../DOCTRINE.md#authorization-policy-doctrine-canonical) for the canonical statement.
+
Distinguish them in code (when an endpoint accepts both):
```csharp
@@ -153,8 +155,8 @@ There is nothing magical to validate about MI tokens; treat them like any S2S ca
## 7. Quick checklist per endpoint
- [ ] `[Authorize]` present.
-- [ ] `[RequiredScope]` (user) or `[Authorize(Roles=…)]` (app) — not both implicit.
-- [ ] If endpoint accepts both: explicit branching on `idtyp`/`scp`/`roles`.
+- [ ] `[RequiredScope]` (user) or `[Authorize(Policy="...")]` (app) — never `[Authorize(Roles=...)]` on a JWT scheme with `MapInboundClaims=false` (it silently no-ops; see [DOCTRINE.md](../DOCTRINE.md#authorization-policy-doctrine-canonical)).
+- [ ] Mixed-claims policies: each named policy rejects tokens that carry the *other* claim type (so a user token never satisfies an app policy and vice versa).
- [ ] App-only endpoints additionally allow-list `azp`/`appid`.
- [ ] Multi-tenant: tenant allow-list enforced in `IssuerValidator`.
- [ ] Audience set explicitly to App ID URI (and GUID during v1↔v2 migration).
diff --git a/infra/bicep/azure.bicep b/infra/bicep/azure.bicep
index dee4f3a..952034e 100644
--- a/infra/bicep/azure.bicep
+++ b/infra/bicep/azure.bicep
@@ -42,6 +42,16 @@ param keyVaultEnablePurgeProtection bool = environmentName == 'prod'
@maxLength(24)
param keyVaultNameOverride string = ''
+@description('Monthly cost ceiling in USD. Forecasted+Actual notifications fire at 80%/100%. ci/ppe defaults are deliberately tiny (this sample is $0-idle).')
+@minValue(1)
+param monthlyBudgetUsd int = environmentName == 'prod' ? 50 : 10
+
+@description('Email recipients for budget alerts. Empty array disables email notifications. Wire from each tier bicepparam.')
+param budgetContactEmails array = []
+
+@description('First-of-month UTC start date (yyyy-MM-01). Pinned in bicepparam for reproducible deployments.')
+param budgetStartDate string = '${utcNow('yyyy-MM')}-01'
+
// Region → short token folded into resource names. Falls back to first 3 chars for unmapped regions.
var regionShortMap = {
eastus: 'eus'
@@ -63,9 +73,10 @@ var kvName = empty(keyVaultNameOverride) ? 'kv-ftgo-${environmentName}-${t
module logAnalytics 'modules/log-analytics.bicep' = {
name: 'law'
params: {
- name: lawName
- location: location
- tags: tags
+ name: lawName
+ location: location
+ tags: tags
+ retentionInDays: environmentName == 'prod' ? 90 : 30
}
}
@@ -145,6 +156,20 @@ module kvRbac 'modules/key-vault-rbac.bicep' = {
]
}
+// Cost guardrail: monthly budget on this RG. ci/ppe defaults are tiny because
+// the sample is $0-idle (ACA scale-to-zero); prod allows a higher ceiling with
+// the same Forecasted+Actual alert structure.
+module budget 'modules/budget.bicep' = {
+ name: 'budget'
+ scope: resourceGroup()
+ params: {
+ name: 'ftgo-${environmentName}-budget'
+ monthlyAmount: monthlyBudgetUsd
+ contactEmails: budgetContactEmails
+ startDate: budgetStartDate
+ }
+}
+
// Prod safety net: prevent accidental `az group delete` / portal-delete of the
// entire RG. CanNotDelete still lets ARM perform in-place updates but blocks
// destructive operations until the lock is removed. ci/PPE intentionally have
diff --git a/infra/bicep/modules/budget.bicep b/infra/bicep/modules/budget.bicep
new file mode 100644
index 0000000..f985bf7
--- /dev/null
+++ b/infra/bicep/modules/budget.bicep
@@ -0,0 +1,56 @@
+metadata name = 'budget'
+metadata description = 'RG-scoped monthly cost budget with Forecasted+Actual notifications. Defense against runaway spend.'
+
+extension az
+
+@description('Budget name (resource-scoped, must be unique within the RG).')
+param name string
+
+@description('Monthly amount in subscription currency (USD by default).')
+@minValue(1)
+@maxValue(100000)
+param monthlyAmount int
+
+@description('Email addresses notified on threshold breach. Empty = no email channel.')
+param contactEmails array = []
+
+@description('Start of the budget window (yyyy-MM-01 UTC). Must be the first of a month.')
+param startDate string
+
+resource budget 'Microsoft.Consumption/budgets@2024-08-01' = {
+ name: name
+ properties: {
+ category: 'Cost'
+ amount: monthlyAmount
+ timeGrain: 'Monthly'
+ timePeriod: {
+ startDate: startDate
+ }
+ notifications: empty(contactEmails) ? {} : {
+ actualAt80: {
+ enabled: true
+ operator: 'GreaterThan'
+ threshold: 80
+ thresholdType: 'Actual'
+ contactEmails: contactEmails
+ }
+ actualAt100: {
+ enabled: true
+ operator: 'GreaterThanOrEqualTo'
+ threshold: 100
+ thresholdType: 'Actual'
+ contactEmails: contactEmails
+ }
+ forecastAt100: {
+ enabled: true
+ operator: 'GreaterThan'
+ threshold: 100
+ thresholdType: 'Forecasted'
+ contactEmails: contactEmails
+ }
+ }
+ }
+}
+
+@description('Resource ID of the budget.')
+output budgetId string = budget.id
diff --git a/infra/bicep/modules/container-app.bicep b/infra/bicep/modules/container-app.bicep
index f8be212..c6c827b 100644
--- a/infra/bicep/modules/container-app.bicep
+++ b/infra/bicep/modules/container-app.bicep
@@ -63,7 +63,7 @@ resource app 'Microsoft.App/containerApps@2025-07-01' = {
ingress: {
external: true
targetPort: 8080
- transport: 'auto'
+ transport: environmentName == 'prod' ? 'https' : 'auto'
allowInsecure: false
traffic: [
{
diff --git a/infra/bicep/modules/log-analytics.bicep b/infra/bicep/modules/log-analytics.bicep
index e5ebf4d..8174737 100644
--- a/infra/bicep/modules/log-analytics.bicep
+++ b/infra/bicep/modules/log-analytics.bicep
@@ -12,6 +12,11 @@ param location string
@description('Tags applied to the workspace.')
param tags object = {}
+@description('Log retention in days. 30 covers ci/ppe ops; prod uses 90+ for the audit-log compliance floor.')
+@minValue(30)
+@maxValue(730)
+param retentionInDays int = 30
+
resource law 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
name: name
location: location
@@ -20,7 +25,7 @@ resource law 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
sku: {
name: 'PerGB2018'
}
- retentionInDays: 30
+ retentionInDays: retentionInDays
workspaceCapping: {
dailyQuotaGb: 1
}
diff --git a/src/Ftgo.ApiGateway/packages.lock.json b/src/Ftgo.ApiGateway/packages.lock.json
index 2eb022d..ee44b37 100644
--- a/src/Ftgo.ApiGateway/packages.lock.json
+++ b/src/Ftgo.ApiGateway/packages.lock.json
@@ -10,9 +10,9 @@
},
"Meziantou.Analyzer": {
"type": "Direct",
- "requested": "[3.0.54, )",
- "resolved": "3.0.54",
- "contentHash": "fybpNsTg4ZT+oiQWkrkTvvy1dowdqjfkefFa/7OIPFPmTkHd9eSEjPCpoK1c5oYdp68QjpLTbeCDpZB2TOUQ4A=="
+ "requested": "[3.0.58, )",
+ "resolved": "3.0.58",
+ "contentHash": "EWprZh8ONB5W4T5lajB9H3/ThznWgEVbb8sWkIZe2ZOCRPPzAdLxkFfM8z57SosJtUYGxPXvxlqGmClxw8mA9g=="
},
"Microsoft.AspNetCore.OpenApi": {
"type": "Direct",
@@ -25,14 +25,14 @@
},
"Microsoft.Identity.Web": {
"type": "Direct",
- "requested": "[4.8.0, )",
- "resolved": "4.8.0",
- "contentHash": "rqMufh64Woj/kc39d9iCb12BTtBj9H40haLUemRAwoqvqSomYPlbMOwt1UD1Y5aMqmc8aYF06U5ATY8Qnw2iFg==",
- "dependencies": {
- "Microsoft.Identity.Web.Certificate": "4.8.0",
- "Microsoft.Identity.Web.Certificateless": "4.8.0",
- "Microsoft.Identity.Web.TokenAcquisition": "4.8.0",
- "Microsoft.Identity.Web.TokenCache": "4.8.0",
+ "requested": "[4.9.0, )",
+ "resolved": "4.9.0",
+ "contentHash": "SshLuFsW0VqCP18O1YUAqPh4KZ6ogu9yYrhlYdGxf/KSRR0wHO1MFlyxh2aOkHdw462XKjbzm36NTspJo1r58g==",
+ "dependencies": {
+ "Microsoft.Identity.Web.Certificate": "4.9.0",
+ "Microsoft.Identity.Web.Certificateless": "4.9.0",
+ "Microsoft.Identity.Web.TokenAcquisition": "4.9.0",
+ "Microsoft.Identity.Web.TokenCache": "4.9.0",
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.15.0",
"Microsoft.IdentityModel.Validators": "8.15.0",
"System.IdentityModel.Tokens.Jwt": "8.15.0"
@@ -40,11 +40,11 @@
},
"Microsoft.Identity.Web.DownstreamApi": {
"type": "Direct",
- "requested": "[4.8.0, )",
- "resolved": "4.8.0",
- "contentHash": "MXCBVZO9Oj2hSZkPvEFcRg1/fyKNjqJQzAVxUjdCOzno3rd1BeVhuMddk9aYNJ3r79GD5lA3xJeneXo+SKlKCA==",
+ "requested": "[4.9.0, )",
+ "resolved": "4.9.0",
+ "contentHash": "E9RjjTmAiHR7Z8HDtfEDbioW6NOvoYABGlyIhwOPkMpECTP7M69SwO5DJki+QA0pHpIongdUC856GH/lnqyxSA==",
"dependencies": {
- "Microsoft.Identity.Web.TokenAcquisition": "4.8.0"
+ "Microsoft.Identity.Web.TokenAcquisition": "4.9.0"
}
},
"Microsoft.SourceLink.GitHub": {
@@ -66,14 +66,14 @@
},
"SonarAnalyzer.CSharp": {
"type": "Direct",
- "requested": "[10.24.0.138807, )",
- "resolved": "10.24.0.138807",
- "contentHash": "+ZEa1+KhNSulMSatpffgHnovbLGtYL9oukMh8uQp8RBDnPOjEFzvECSa4kfu1D4PJxErksiGOQQ+kDeao1x9jQ=="
+ "requested": "[10.25.0.139117, )",
+ "resolved": "10.25.0.139117",
+ "contentHash": "2iBZIkcgsaUNyIPFqR9ECv5PLbAwV9I/v/PeEiMdkfhWEAnqD7VyNXzukQDlL/D24f5OoUc4MbWF5SgYI7x3CA=="
},
"Azure.Core": {
"type": "Transitive",
- "resolved": "1.53.0",
- "contentHash": "x9c/toFMOtRrlTdFuE7rlGCVAduQzWVfKmLz5juj41zJAXEhYD5hluiUyyAEzJ6OxpBnKtiaBztzwpZITAVjtg==",
+ "resolved": "1.54.0",
+ "contentHash": "m6hHbx1q9+GCBZ5A9ykzFylPdTwscX2APH7PlnqV+yu+DH3RRtuIDJMRqdU17cMyinv0hCPofpegoyQ6qWPW7g==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.3",
"Microsoft.Identity.Client": "4.83.1",
@@ -171,21 +171,21 @@
},
"Microsoft.Identity.Web.Certificate": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "G4nkrs1pKg6NxuSvyoRzokLfsfd2v5pXpXS1XUvHstvdWkQRBw8kTbSwRCvzdRFA1MW7Ct14zcp1P4kej7dB+g==",
+ "resolved": "4.9.0",
+ "contentHash": "kr6ZpxNbWm1+eI3pn6sdgIlduYZiEmoC0TbiiR1gmcREuI87E963xjBMmvXf9+CnHcjbsqhkBcK4huKauif0yA==",
"dependencies": {
- "Azure.Identity": "1.11.4",
+ "Azure.Identity": "1.17.2",
"Azure.Security.KeyVault.Certificates": "4.6.0",
"Azure.Security.KeyVault.Secrets": "4.6.0",
"Microsoft.Identity.Abstractions": "12.0.0",
- "Microsoft.Identity.Web.Certificateless": "4.8.0",
- "Microsoft.Identity.Web.Diagnostics": "4.8.0"
+ "Microsoft.Identity.Web.Certificateless": "4.9.0",
+ "Microsoft.Identity.Web.Diagnostics": "4.9.0"
}
},
"Microsoft.Identity.Web.Certificateless": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "TegoXg/SX1mca1ygTZWTgLTXIZPQudmg5SLr9f50A8fzC4oFGkNOUfa9wii9SRikBGg4Sc31QxUeM/MMFyIX6A==",
+ "resolved": "4.9.0",
+ "contentHash": "hlt8KV1V0JLjyxncmSNAugyqxVr65wQlzcsks6TgqvUyllkQvWAxqgM+Dh6OB8sgJWMoCOmJuKJ6SejAgYjJ/w==",
"dependencies": {
"Microsoft.Identity.Client": "4.83.1",
"Microsoft.IdentityModel.JsonWebTokens": "8.15.0"
@@ -193,20 +193,20 @@
},
"Microsoft.Identity.Web.Diagnostics": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "oWYVlq2h86yAmqJHcDK0JrJfhDZTnF1M1vyY9mGx+x5E5GpNml7hTI1N1Kq4Z9JUtaPvz4dN8MgfUn7QyHZ13Q=="
+ "resolved": "4.9.0",
+ "contentHash": "5x4Tyg1xFr8vgTAuos5OyCNAXcnq9U+6QWA3pRLOgUF5Am7Bn/CM+rmOjffoyfP6ymQdQ0/zxtNOtyRjTlpiBg=="
},
"Microsoft.Identity.Web.TokenAcquisition": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "Nm4YJxUQuIThoRWprg9bv2vCDs2zzlOo7B4nfHoG3Spw5Ege2edFLY+iEtRh+GYW2Fhn97rYmwCgmaQ2IhYZxA==",
+ "resolved": "4.9.0",
+ "contentHash": "ap6EFz9Nn3pWlfYQ8AqnRM7UsrH5pAXzDMF/aayKEfcAxLgXe23XKb1jBEzWNYkg5NNrbiLVWjKDIWbPqMrn1w==",
"dependencies": {
"Microsoft.AspNetCore.Authentication.JwtBearer": "10.0.0",
"Microsoft.AspNetCore.Authentication.OpenIdConnect": "10.0.0",
"Microsoft.Identity.Abstractions": "12.0.0",
- "Microsoft.Identity.Web.Certificate": "4.8.0",
- "Microsoft.Identity.Web.Certificateless": "4.8.0",
- "Microsoft.Identity.Web.TokenCache": "4.8.0",
+ "Microsoft.Identity.Web.Certificate": "4.9.0",
+ "Microsoft.Identity.Web.Certificateless": "4.9.0",
+ "Microsoft.Identity.Web.TokenCache": "4.9.0",
"Microsoft.IdentityModel.Logging": "8.15.0",
"Microsoft.IdentityModel.LoggingExtensions": "8.15.0",
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.15.0",
@@ -215,12 +215,12 @@
},
"Microsoft.Identity.Web.TokenCache": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "2TL0zev2SREJoIzZi9dQ+vv/Us6Q1ZpZD02ti0LhFDpFg/vKGD98pHmcz+f216PbWHzQpERzS3tMgcIGt2lywQ==",
+ "resolved": "4.9.0",
+ "contentHash": "3eHtmFu6HJU334bLyOjG/PvWyYQaoGEh9HyomGQ1p/aUV4EiGkdiU4R5yuyOIHdxRs8DKMFPN0Wnqp2nvWayjg==",
"dependencies": {
"Microsoft.Identity.Client": "4.83.1",
- "Microsoft.Identity.Web.Diagnostics": "4.8.0",
- "System.Security.Cryptography.Pkcs": "10.0.6"
+ "Microsoft.Identity.Web.Diagnostics": "4.9.0",
+ "System.Security.Cryptography.Pkcs": "10.0.7"
}
},
"Microsoft.IdentityModel.Abstractions": {
@@ -289,15 +289,15 @@
},
"OpenTelemetry.PersistentStorage.Abstractions": {
"type": "Transitive",
- "resolved": "1.0.2",
- "contentHash": "QuBc6e7M4Skvbc+eTQGSmrcoho7lSkHLT5ngoSsVeeT8OXLpSUETNcuRPW8F5drTPTzzTKQ98C5AhKO/pjpTJg=="
+ "resolved": "1.0.3",
+ "contentHash": "7nSE1uLY0KOHRssg1xQydDZJ/s9zECOzFT/gSDIp1KTxbgd1yziuMTsdBKZ/N1BbpUxfH6Pjq5OMd7lYogwReA=="
},
"OpenTelemetry.PersistentStorage.FileSystem": {
"type": "Transitive",
- "resolved": "1.0.2",
- "contentHash": "ys0l9vL0/wOV9p/iuyDeemjX+d8iH4yjaYA1IcmyQUw0xsxx0I3hQm7tN3FnuRPsmPtrohiLtp31hO1BcrhQ+A==",
+ "resolved": "1.0.3",
+ "contentHash": "3k+yCroJcGBtZIia+0LgzMlBuD0U95BzcWNEGWedusBaRAD8EmkaCPAUwaCtFbb/Mg7HywbIG2zuenANPYFJ0g==",
"dependencies": {
- "OpenTelemetry.PersistentStorage.Abstractions": "1.0.2"
+ "OpenTelemetry.PersistentStorage.Abstractions": "1.0.3"
}
},
"Polly.Core": {
@@ -350,8 +350,8 @@
},
"System.Security.Cryptography.Pkcs": {
"type": "Transitive",
- "resolved": "10.0.6",
- "contentHash": "88tquaGJ1htm4DHWS6x9jwER7sFET2SVRN7HqO1FYZwE0diDcUmz0ajhVa8ZD2HGhDJBueSPjP/gqyP3gXtT2A=="
+ "resolved": "10.0.7",
+ "contentHash": "dbdKfF3eA5l+CXiAbDxiCxdezoxeanbue1ck8m49ih1L9uZG6ry8Ul8On6vpragyMDJJP4rQHUY/SWgk66tCYA=="
},
"System.Security.Cryptography.ProtectedData": {
"type": "Transitive",
@@ -363,17 +363,17 @@
"dependencies": {
"Ftgo.Auth.Client": "[1.0.0, )",
"Microsoft.AspNetCore.OpenApi": "[10.0.7, )",
- "Microsoft.Identity.Web": "[4.8.0, )",
- "Microsoft.Identity.Web.DownstreamApi": "[4.8.0, )",
+ "Microsoft.Identity.Web": "[4.9.0, )",
+ "Microsoft.Identity.Web.DownstreamApi": "[4.9.0, )",
"Microsoft.IdentityModel.Validators": "[8.17.0, )",
"OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )",
- "Scalar.AspNetCore": "[2.14.4, )"
+ "Scalar.AspNetCore": "[2.14.9, )"
}
},
"ftgo.auth.client": {
"type": "Project",
"dependencies": {
- "Azure.Monitor.OpenTelemetry.Exporter": "[1.7.0, )",
+ "Azure.Monitor.OpenTelemetry.Exporter": "[1.8.0, )",
"Microsoft.Extensions.Http.Resilience": "[10.5.0, )",
"OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
"OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
@@ -392,13 +392,13 @@
},
"Azure.Monitor.OpenTelemetry.Exporter": {
"type": "CentralTransitive",
- "requested": "[1.7.0, )",
- "resolved": "1.7.0",
- "contentHash": "fexzK+HM06C3tmBL6DLNVAcgutBMY7hQ7tGcdpCyq6HaXvbNz6cFtenrJUAdXM8Y4G+3QmZ6PIAL1hFrVe4Mpw==",
+ "requested": "[1.8.0, )",
+ "resolved": "1.8.0",
+ "contentHash": "JbNUQIK7uZ7Mg2fZzdsjqC/eso3Ag/tMwCqYvGvedyE1fObe6JAwCaCSMZDMrhrAaFn8x8+ZaKJelfcEK5MCcg==",
"dependencies": {
- "Azure.Core": "1.52.0",
- "OpenTelemetry.Extensions.Hosting": "1.15.1",
- "OpenTelemetry.PersistentStorage.FileSystem": "1.0.2"
+ "Azure.Core": "1.54.0",
+ "OpenTelemetry.Extensions.Hosting": "1.15.3",
+ "OpenTelemetry.PersistentStorage.FileSystem": "1.0.3"
}
},
"Azure.Security.KeyVault.Certificates": {
@@ -536,9 +536,9 @@
},
"Scalar.AspNetCore": {
"type": "CentralTransitive",
- "requested": "[2.14.4, )",
- "resolved": "2.14.4",
- "contentHash": "PyuqLRi7JXyTJm/rh+IWCA4Fct1SlZuWrdYiui5hiaBuSY88+roc4eWKfYaE6zrupBIwnEP8pJLe4ifvU7HtZA=="
+ "requested": "[2.14.9, )",
+ "resolved": "2.14.9",
+ "contentHash": "bDc8NjI2JSIAX0C1+02WO11zCJ1SJQhWdMC0s7yi2Nh2atoNRpNRPiYeyIhWSerkc1SkDdJVOeU9QSY6jb0/zQ=="
}
}
}
diff --git a/src/Ftgo.Auth.Client/DownstreamApiClient.cs b/src/Ftgo.Auth.Client/DownstreamApiClient.cs
index 2566c57..ce0d76e 100644
--- a/src/Ftgo.Auth.Client/DownstreamApiClient.cs
+++ b/src/Ftgo.Auth.Client/DownstreamApiClient.cs
@@ -22,13 +22,13 @@ public sealed partial class DownstreamApiClient(
string scope,
CancellationToken cancellationToken)
{
- var token = await tokenProvider.GetAccessTokenAsync(scope, cancellationToken);
+ var token = await tokenProvider.GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
using var request = new HttpRequestMessage(HttpMethod.Get, path);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
- using var response = await http.SendAsync(request, cancellationToken);
- var body = await response.Content.ReadAsStringAsync(cancellationToken);
+ using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
LogProbe(logger, (int)response.StatusCode, path);
diff --git a/src/Ftgo.Auth.Client/packages.lock.json b/src/Ftgo.Auth.Client/packages.lock.json
index 6b83f36..aa7559c 100644
--- a/src/Ftgo.Auth.Client/packages.lock.json
+++ b/src/Ftgo.Auth.Client/packages.lock.json
@@ -10,20 +10,20 @@
},
"Azure.Monitor.OpenTelemetry.Exporter": {
"type": "Direct",
- "requested": "[1.7.0, )",
- "resolved": "1.7.0",
- "contentHash": "fexzK+HM06C3tmBL6DLNVAcgutBMY7hQ7tGcdpCyq6HaXvbNz6cFtenrJUAdXM8Y4G+3QmZ6PIAL1hFrVe4Mpw==",
+ "requested": "[1.8.0, )",
+ "resolved": "1.8.0",
+ "contentHash": "JbNUQIK7uZ7Mg2fZzdsjqC/eso3Ag/tMwCqYvGvedyE1fObe6JAwCaCSMZDMrhrAaFn8x8+ZaKJelfcEK5MCcg==",
"dependencies": {
- "Azure.Core": "1.52.0",
- "OpenTelemetry.Extensions.Hosting": "1.15.1",
- "OpenTelemetry.PersistentStorage.FileSystem": "1.0.2"
+ "Azure.Core": "1.54.0",
+ "OpenTelemetry.Extensions.Hosting": "1.15.3",
+ "OpenTelemetry.PersistentStorage.FileSystem": "1.0.3"
}
},
"Meziantou.Analyzer": {
"type": "Direct",
- "requested": "[3.0.54, )",
- "resolved": "3.0.54",
- "contentHash": "fybpNsTg4ZT+oiQWkrkTvvy1dowdqjfkefFa/7OIPFPmTkHd9eSEjPCpoK1c5oYdp68QjpLTbeCDpZB2TOUQ4A=="
+ "requested": "[3.0.58, )",
+ "resolved": "3.0.58",
+ "contentHash": "EWprZh8ONB5W4T5lajB9H3/ThznWgEVbb8sWkIZe2ZOCRPPzAdLxkFfM8z57SosJtUYGxPXvxlqGmClxw8mA9g=="
},
"Microsoft.Extensions.Hosting": {
"type": "Direct",
@@ -158,16 +158,20 @@
},
"SonarAnalyzer.CSharp": {
"type": "Direct",
- "requested": "[10.24.0.138807, )",
- "resolved": "10.24.0.138807",
- "contentHash": "+ZEa1+KhNSulMSatpffgHnovbLGtYL9oukMh8uQp8RBDnPOjEFzvECSa4kfu1D4PJxErksiGOQQ+kDeao1x9jQ=="
+ "requested": "[10.25.0.139117, )",
+ "resolved": "10.25.0.139117",
+ "contentHash": "2iBZIkcgsaUNyIPFqR9ECv5PLbAwV9I/v/PeEiMdkfhWEAnqD7VyNXzukQDlL/D24f5OoUc4MbWF5SgYI7x3CA=="
},
"Azure.Core": {
"type": "Transitive",
- "resolved": "1.52.0",
- "contentHash": "If2gP0B4kDAwOw3kvMFs7gEounDhLyeleoWMih0xPdGAvhKpcWQwoPI3L/L0gmcQt0hrtqDnRni1jaIaxwdL7w==",
+ "resolved": "1.54.0",
+ "contentHash": "m6hHbx1q9+GCBZ5A9ykzFylPdTwscX2APH7PlnqV+yu+DH3RRtuIDJMRqdU17cMyinv0hCPofpegoyQ6qWPW7g==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.3",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.3",
+ "Microsoft.Extensions.Hosting.Abstractions": "10.0.3",
+ "Microsoft.Identity.Client": "4.83.1",
+ "Microsoft.Identity.Client.Extensions.Msal": "4.83.1",
"System.ClientModel": "1.10.0",
"System.Memory.Data": "10.0.3"
}
@@ -500,6 +504,20 @@
"Microsoft.Extensions.Options": "10.0.6"
}
},
+ "Microsoft.Identity.Client.Extensions.Msal": {
+ "type": "Transitive",
+ "resolved": "4.83.1",
+ "contentHash": "I3k4J4Hj4KbLEFanjeUzzDOVecukETaTgEkJ7h2pP/Yazs6SLp6TVUTo/Eo+ptPXMwvc+iX7rBFtMSUrA7R+Mg==",
+ "dependencies": {
+ "Microsoft.Identity.Client": "4.83.1",
+ "System.Security.Cryptography.ProtectedData": "4.5.0"
+ }
+ },
+ "Microsoft.IdentityModel.Abstractions": {
+ "type": "Transitive",
+ "resolved": "8.14.0",
+ "contentHash": "iwbCpSjD3ehfTwBhtSNEtKPK0ICun6ov7Ibx6ISNA9bfwIyzI2Siwyi9eJFCJBwxowK9xcA1mj+jBWiigeqgcQ=="
+ },
"Microsoft.SourceLink.Common": {
"type": "Transitive",
"resolved": "10.0.203",
@@ -526,15 +544,15 @@
},
"OpenTelemetry.PersistentStorage.Abstractions": {
"type": "Transitive",
- "resolved": "1.0.2",
- "contentHash": "QuBc6e7M4Skvbc+eTQGSmrcoho7lSkHLT5ngoSsVeeT8OXLpSUETNcuRPW8F5drTPTzzTKQ98C5AhKO/pjpTJg=="
+ "resolved": "1.0.3",
+ "contentHash": "7nSE1uLY0KOHRssg1xQydDZJ/s9zECOzFT/gSDIp1KTxbgd1yziuMTsdBKZ/N1BbpUxfH6Pjq5OMd7lYogwReA=="
},
"OpenTelemetry.PersistentStorage.FileSystem": {
"type": "Transitive",
- "resolved": "1.0.2",
- "contentHash": "ys0l9vL0/wOV9p/iuyDeemjX+d8iH4yjaYA1IcmyQUw0xsxx0I3hQm7tN3FnuRPsmPtrohiLtp31hO1BcrhQ+A==",
+ "resolved": "1.0.3",
+ "contentHash": "3k+yCroJcGBtZIia+0LgzMlBuD0U95BzcWNEGWedusBaRAD8EmkaCPAUwaCtFbb/Mg7HywbIG2zuenANPYFJ0g==",
"dependencies": {
- "OpenTelemetry.PersistentStorage.Abstractions": "1.0.2"
+ "OpenTelemetry.PersistentStorage.Abstractions": "1.0.3"
}
},
"Polly.Core": {
@@ -587,6 +605,11 @@
"resolved": "10.0.3",
"contentHash": "MaGhRfGunmrj/nHjtsi9XkhlYJ/ERGWrbA+BiSKNtGnAjc9XlG5EhAvak6VRcX5LYzPF6pBO8nJ613dTgzabig=="
},
+ "System.Security.Cryptography.ProtectedData": {
+ "type": "Transitive",
+ "resolved": "4.5.0",
+ "contentHash": "wLBKzFnDCxP12VL9ANydSYhk59fC4cvOr9ypYQLPnAj48NQIhqnjdD2yhP8yEKyBJEjERWS9DisKL7rX5eU25Q=="
+ },
"System.Threading.RateLimiting": {
"type": "Transitive",
"resolved": "8.0.0",
@@ -604,6 +627,15 @@
"Microsoft.Extensions.FileProviders.Abstractions": "10.0.7"
}
},
+ "Microsoft.Identity.Client": {
+ "type": "CentralTransitive",
+ "requested": "[4.83.3, )",
+ "resolved": "4.83.3",
+ "contentHash": "XNJJn5uctuGvl3u6qzAof2TNysAZ/PPVKzkAglxvTO5XHgff/Ibs1+yi2G26Xrf6X/f780kS4fMKKdHcGb96hQ==",
+ "dependencies": {
+ "Microsoft.IdentityModel.Abstractions": "8.14.0"
+ }
+ },
"OpenTelemetry.Api": {
"type": "CentralTransitive",
"requested": "[1.15.3, )",
diff --git a/src/Ftgo.Auth/EntraAuthRateLimiterExtensions.cs b/src/Ftgo.Auth/EntraAuthRateLimiterExtensions.cs
index 062c231..82ac6ff 100644
--- a/src/Ftgo.Auth/EntraAuthRateLimiterExtensions.cs
+++ b/src/Ftgo.Auth/EntraAuthRateLimiterExtensions.cs
@@ -47,13 +47,16 @@ public static IServiceCollection AddEntraAuthRateLimiter(
services.AddRateLimiter(rl =>
{
rl.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
- rl.OnRejected = static (ctx, _) =>
+ var fallbackRetryAfterSeconds = ((int)opts.Window.TotalSeconds).ToString(CultureInfo.InvariantCulture);
+ rl.OnRejected = (ctx, _) =>
{
- if (ctx.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
- {
- ctx.HttpContext.Response.Headers.RetryAfter =
- ((int)retryAfter.TotalSeconds).ToString(CultureInfo.InvariantCulture);
- }
+ // RFC 6585 §3 mandates Retry-After on 429. Sliding/fixed-window limiters only
+ // populate the lease metadata when there's a queue; fall back to the window so
+ // the header is present unconditionally.
+ var seconds = ctx.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)
+ ? ((int)retryAfter.TotalSeconds).ToString(CultureInfo.InvariantCulture)
+ : fallbackRetryAfterSeconds;
+ ctx.HttpContext.Response.Headers.RetryAfter = seconds;
return ValueTask.CompletedTask;
};
diff --git a/src/Ftgo.Auth/packages.lock.json b/src/Ftgo.Auth/packages.lock.json
index ad288c2..e963fe2 100644
--- a/src/Ftgo.Auth/packages.lock.json
+++ b/src/Ftgo.Auth/packages.lock.json
@@ -10,9 +10,9 @@
},
"Meziantou.Analyzer": {
"type": "Direct",
- "requested": "[3.0.54, )",
- "resolved": "3.0.54",
- "contentHash": "fybpNsTg4ZT+oiQWkrkTvvy1dowdqjfkefFa/7OIPFPmTkHd9eSEjPCpoK1c5oYdp68QjpLTbeCDpZB2TOUQ4A=="
+ "requested": "[3.0.58, )",
+ "resolved": "3.0.58",
+ "contentHash": "EWprZh8ONB5W4T5lajB9H3/ThznWgEVbb8sWkIZe2ZOCRPPzAdLxkFfM8z57SosJtUYGxPXvxlqGmClxw8mA9g=="
},
"Microsoft.AspNetCore.OpenApi": {
"type": "Direct",
@@ -25,14 +25,14 @@
},
"Microsoft.Identity.Web": {
"type": "Direct",
- "requested": "[4.8.0, )",
- "resolved": "4.8.0",
- "contentHash": "rqMufh64Woj/kc39d9iCb12BTtBj9H40haLUemRAwoqvqSomYPlbMOwt1UD1Y5aMqmc8aYF06U5ATY8Qnw2iFg==",
- "dependencies": {
- "Microsoft.Identity.Web.Certificate": "4.8.0",
- "Microsoft.Identity.Web.Certificateless": "4.8.0",
- "Microsoft.Identity.Web.TokenAcquisition": "4.8.0",
- "Microsoft.Identity.Web.TokenCache": "4.8.0",
+ "requested": "[4.9.0, )",
+ "resolved": "4.9.0",
+ "contentHash": "SshLuFsW0VqCP18O1YUAqPh4KZ6ogu9yYrhlYdGxf/KSRR0wHO1MFlyxh2aOkHdw462XKjbzm36NTspJo1r58g==",
+ "dependencies": {
+ "Microsoft.Identity.Web.Certificate": "4.9.0",
+ "Microsoft.Identity.Web.Certificateless": "4.9.0",
+ "Microsoft.Identity.Web.TokenAcquisition": "4.9.0",
+ "Microsoft.Identity.Web.TokenCache": "4.9.0",
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.15.0",
"Microsoft.IdentityModel.Validators": "8.15.0",
"System.IdentityModel.Tokens.Jwt": "8.15.0"
@@ -40,11 +40,11 @@
},
"Microsoft.Identity.Web.DownstreamApi": {
"type": "Direct",
- "requested": "[4.8.0, )",
- "resolved": "4.8.0",
- "contentHash": "MXCBVZO9Oj2hSZkPvEFcRg1/fyKNjqJQzAVxUjdCOzno3rd1BeVhuMddk9aYNJ3r79GD5lA3xJeneXo+SKlKCA==",
+ "requested": "[4.9.0, )",
+ "resolved": "4.9.0",
+ "contentHash": "E9RjjTmAiHR7Z8HDtfEDbioW6NOvoYABGlyIhwOPkMpECTP7M69SwO5DJki+QA0pHpIongdUC856GH/lnqyxSA==",
"dependencies": {
- "Microsoft.Identity.Web.TokenAcquisition": "4.8.0"
+ "Microsoft.Identity.Web.TokenAcquisition": "4.9.0"
}
},
"Microsoft.IdentityModel.Validators": {
@@ -87,20 +87,20 @@
},
"Scalar.AspNetCore": {
"type": "Direct",
- "requested": "[2.14.4, )",
- "resolved": "2.14.4",
- "contentHash": "PyuqLRi7JXyTJm/rh+IWCA4Fct1SlZuWrdYiui5hiaBuSY88+roc4eWKfYaE6zrupBIwnEP8pJLe4ifvU7HtZA=="
+ "requested": "[2.14.9, )",
+ "resolved": "2.14.9",
+ "contentHash": "bDc8NjI2JSIAX0C1+02WO11zCJ1SJQhWdMC0s7yi2Nh2atoNRpNRPiYeyIhWSerkc1SkDdJVOeU9QSY6jb0/zQ=="
},
"SonarAnalyzer.CSharp": {
"type": "Direct",
- "requested": "[10.24.0.138807, )",
- "resolved": "10.24.0.138807",
- "contentHash": "+ZEa1+KhNSulMSatpffgHnovbLGtYL9oukMh8uQp8RBDnPOjEFzvECSa4kfu1D4PJxErksiGOQQ+kDeao1x9jQ=="
+ "requested": "[10.25.0.139117, )",
+ "resolved": "10.25.0.139117",
+ "contentHash": "2iBZIkcgsaUNyIPFqR9ECv5PLbAwV9I/v/PeEiMdkfhWEAnqD7VyNXzukQDlL/D24f5OoUc4MbWF5SgYI7x3CA=="
},
"Azure.Core": {
"type": "Transitive",
- "resolved": "1.53.0",
- "contentHash": "x9c/toFMOtRrlTdFuE7rlGCVAduQzWVfKmLz5juj41zJAXEhYD5hluiUyyAEzJ6OxpBnKtiaBztzwpZITAVjtg==",
+ "resolved": "1.54.0",
+ "contentHash": "m6hHbx1q9+GCBZ5A9ykzFylPdTwscX2APH7PlnqV+yu+DH3RRtuIDJMRqdU17cMyinv0hCPofpegoyQ6qWPW7g==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.3",
"Microsoft.Identity.Client": "4.83.1",
@@ -198,21 +198,21 @@
},
"Microsoft.Identity.Web.Certificate": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "G4nkrs1pKg6NxuSvyoRzokLfsfd2v5pXpXS1XUvHstvdWkQRBw8kTbSwRCvzdRFA1MW7Ct14zcp1P4kej7dB+g==",
+ "resolved": "4.9.0",
+ "contentHash": "kr6ZpxNbWm1+eI3pn6sdgIlduYZiEmoC0TbiiR1gmcREuI87E963xjBMmvXf9+CnHcjbsqhkBcK4huKauif0yA==",
"dependencies": {
- "Azure.Identity": "1.11.4",
+ "Azure.Identity": "1.17.2",
"Azure.Security.KeyVault.Certificates": "4.6.0",
"Azure.Security.KeyVault.Secrets": "4.6.0",
"Microsoft.Identity.Abstractions": "12.0.0",
- "Microsoft.Identity.Web.Certificateless": "4.8.0",
- "Microsoft.Identity.Web.Diagnostics": "4.8.0"
+ "Microsoft.Identity.Web.Certificateless": "4.9.0",
+ "Microsoft.Identity.Web.Diagnostics": "4.9.0"
}
},
"Microsoft.Identity.Web.Certificateless": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "TegoXg/SX1mca1ygTZWTgLTXIZPQudmg5SLr9f50A8fzC4oFGkNOUfa9wii9SRikBGg4Sc31QxUeM/MMFyIX6A==",
+ "resolved": "4.9.0",
+ "contentHash": "hlt8KV1V0JLjyxncmSNAugyqxVr65wQlzcsks6TgqvUyllkQvWAxqgM+Dh6OB8sgJWMoCOmJuKJ6SejAgYjJ/w==",
"dependencies": {
"Microsoft.Identity.Client": "4.83.1",
"Microsoft.IdentityModel.JsonWebTokens": "8.15.0"
@@ -220,20 +220,20 @@
},
"Microsoft.Identity.Web.Diagnostics": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "oWYVlq2h86yAmqJHcDK0JrJfhDZTnF1M1vyY9mGx+x5E5GpNml7hTI1N1Kq4Z9JUtaPvz4dN8MgfUn7QyHZ13Q=="
+ "resolved": "4.9.0",
+ "contentHash": "5x4Tyg1xFr8vgTAuos5OyCNAXcnq9U+6QWA3pRLOgUF5Am7Bn/CM+rmOjffoyfP6ymQdQ0/zxtNOtyRjTlpiBg=="
},
"Microsoft.Identity.Web.TokenAcquisition": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "Nm4YJxUQuIThoRWprg9bv2vCDs2zzlOo7B4nfHoG3Spw5Ege2edFLY+iEtRh+GYW2Fhn97rYmwCgmaQ2IhYZxA==",
+ "resolved": "4.9.0",
+ "contentHash": "ap6EFz9Nn3pWlfYQ8AqnRM7UsrH5pAXzDMF/aayKEfcAxLgXe23XKb1jBEzWNYkg5NNrbiLVWjKDIWbPqMrn1w==",
"dependencies": {
"Microsoft.AspNetCore.Authentication.JwtBearer": "10.0.0",
"Microsoft.AspNetCore.Authentication.OpenIdConnect": "10.0.0",
"Microsoft.Identity.Abstractions": "12.0.0",
- "Microsoft.Identity.Web.Certificate": "4.8.0",
- "Microsoft.Identity.Web.Certificateless": "4.8.0",
- "Microsoft.Identity.Web.TokenCache": "4.8.0",
+ "Microsoft.Identity.Web.Certificate": "4.9.0",
+ "Microsoft.Identity.Web.Certificateless": "4.9.0",
+ "Microsoft.Identity.Web.TokenCache": "4.9.0",
"Microsoft.IdentityModel.Logging": "8.15.0",
"Microsoft.IdentityModel.LoggingExtensions": "8.15.0",
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.15.0",
@@ -242,12 +242,12 @@
},
"Microsoft.Identity.Web.TokenCache": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "2TL0zev2SREJoIzZi9dQ+vv/Us6Q1ZpZD02ti0LhFDpFg/vKGD98pHmcz+f216PbWHzQpERzS3tMgcIGt2lywQ==",
+ "resolved": "4.9.0",
+ "contentHash": "3eHtmFu6HJU334bLyOjG/PvWyYQaoGEh9HyomGQ1p/aUV4EiGkdiU4R5yuyOIHdxRs8DKMFPN0Wnqp2nvWayjg==",
"dependencies": {
"Microsoft.Identity.Client": "4.83.1",
- "Microsoft.Identity.Web.Diagnostics": "4.8.0",
- "System.Security.Cryptography.Pkcs": "10.0.6"
+ "Microsoft.Identity.Web.Diagnostics": "4.9.0",
+ "System.Security.Cryptography.Pkcs": "10.0.7"
}
},
"Microsoft.IdentityModel.Abstractions": {
@@ -316,15 +316,15 @@
},
"OpenTelemetry.PersistentStorage.Abstractions": {
"type": "Transitive",
- "resolved": "1.0.2",
- "contentHash": "QuBc6e7M4Skvbc+eTQGSmrcoho7lSkHLT5ngoSsVeeT8OXLpSUETNcuRPW8F5drTPTzzTKQ98C5AhKO/pjpTJg=="
+ "resolved": "1.0.3",
+ "contentHash": "7nSE1uLY0KOHRssg1xQydDZJ/s9zECOzFT/gSDIp1KTxbgd1yziuMTsdBKZ/N1BbpUxfH6Pjq5OMd7lYogwReA=="
},
"OpenTelemetry.PersistentStorage.FileSystem": {
"type": "Transitive",
- "resolved": "1.0.2",
- "contentHash": "ys0l9vL0/wOV9p/iuyDeemjX+d8iH4yjaYA1IcmyQUw0xsxx0I3hQm7tN3FnuRPsmPtrohiLtp31hO1BcrhQ+A==",
+ "resolved": "1.0.3",
+ "contentHash": "3k+yCroJcGBtZIia+0LgzMlBuD0U95BzcWNEGWedusBaRAD8EmkaCPAUwaCtFbb/Mg7HywbIG2zuenANPYFJ0g==",
"dependencies": {
- "OpenTelemetry.PersistentStorage.Abstractions": "1.0.2"
+ "OpenTelemetry.PersistentStorage.Abstractions": "1.0.3"
}
},
"Polly.Core": {
@@ -377,8 +377,8 @@
},
"System.Security.Cryptography.Pkcs": {
"type": "Transitive",
- "resolved": "10.0.6",
- "contentHash": "88tquaGJ1htm4DHWS6x9jwER7sFET2SVRN7HqO1FYZwE0diDcUmz0ajhVa8ZD2HGhDJBueSPjP/gqyP3gXtT2A=="
+ "resolved": "10.0.7",
+ "contentHash": "dbdKfF3eA5l+CXiAbDxiCxdezoxeanbue1ck8m49ih1L9uZG6ry8Ul8On6vpragyMDJJP4rQHUY/SWgk66tCYA=="
},
"System.Security.Cryptography.ProtectedData": {
"type": "Transitive",
@@ -388,7 +388,7 @@
"ftgo.auth.client": {
"type": "Project",
"dependencies": {
- "Azure.Monitor.OpenTelemetry.Exporter": "[1.7.0, )",
+ "Azure.Monitor.OpenTelemetry.Exporter": "[1.8.0, )",
"Microsoft.Extensions.Http.Resilience": "[10.5.0, )",
"OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
"OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
@@ -407,13 +407,13 @@
},
"Azure.Monitor.OpenTelemetry.Exporter": {
"type": "CentralTransitive",
- "requested": "[1.7.0, )",
- "resolved": "1.7.0",
- "contentHash": "fexzK+HM06C3tmBL6DLNVAcgutBMY7hQ7tGcdpCyq6HaXvbNz6cFtenrJUAdXM8Y4G+3QmZ6PIAL1hFrVe4Mpw==",
+ "requested": "[1.8.0, )",
+ "resolved": "1.8.0",
+ "contentHash": "JbNUQIK7uZ7Mg2fZzdsjqC/eso3Ag/tMwCqYvGvedyE1fObe6JAwCaCSMZDMrhrAaFn8x8+ZaKJelfcEK5MCcg==",
"dependencies": {
- "Azure.Core": "1.52.0",
- "OpenTelemetry.Extensions.Hosting": "1.15.1",
- "OpenTelemetry.PersistentStorage.FileSystem": "1.0.2"
+ "Azure.Core": "1.54.0",
+ "OpenTelemetry.Extensions.Hosting": "1.15.3",
+ "OpenTelemetry.PersistentStorage.FileSystem": "1.0.3"
}
},
"Azure.Security.KeyVault.Certificates": {
diff --git a/src/Ftgo.Kitchen.Worker/packages.lock.json b/src/Ftgo.Kitchen.Worker/packages.lock.json
index 8faead8..cbd9fe9 100644
--- a/src/Ftgo.Kitchen.Worker/packages.lock.json
+++ b/src/Ftgo.Kitchen.Worker/packages.lock.json
@@ -19,9 +19,9 @@
},
"Meziantou.Analyzer": {
"type": "Direct",
- "requested": "[3.0.54, )",
- "resolved": "3.0.54",
- "contentHash": "fybpNsTg4ZT+oiQWkrkTvvy1dowdqjfkefFa/7OIPFPmTkHd9eSEjPCpoK1c5oYdp68QjpLTbeCDpZB2TOUQ4A=="
+ "requested": "[3.0.58, )",
+ "resolved": "3.0.58",
+ "contentHash": "EWprZh8ONB5W4T5lajB9H3/ThznWgEVbb8sWkIZe2ZOCRPPzAdLxkFfM8z57SosJtUYGxPXvxlqGmClxw8mA9g=="
},
"Microsoft.Extensions.Configuration.Json": {
"type": "Direct",
@@ -93,14 +93,14 @@
},
"SonarAnalyzer.CSharp": {
"type": "Direct",
- "requested": "[10.24.0.138807, )",
- "resolved": "10.24.0.138807",
- "contentHash": "+ZEa1+KhNSulMSatpffgHnovbLGtYL9oukMh8uQp8RBDnPOjEFzvECSa4kfu1D4PJxErksiGOQQ+kDeao1x9jQ=="
+ "requested": "[10.25.0.139117, )",
+ "resolved": "10.25.0.139117",
+ "contentHash": "2iBZIkcgsaUNyIPFqR9ECv5PLbAwV9I/v/PeEiMdkfhWEAnqD7VyNXzukQDlL/D24f5OoUc4MbWF5SgYI7x3CA=="
},
"Azure.Core": {
"type": "Transitive",
- "resolved": "1.53.0",
- "contentHash": "x9c/toFMOtRrlTdFuE7rlGCVAduQzWVfKmLz5juj41zJAXEhYD5hluiUyyAEzJ6OxpBnKtiaBztzwpZITAVjtg==",
+ "resolved": "1.54.0",
+ "contentHash": "m6hHbx1q9+GCBZ5A9ykzFylPdTwscX2APH7PlnqV+yu+DH3RRtuIDJMRqdU17cMyinv0hCPofpegoyQ6qWPW7g==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.3",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.3",
@@ -479,15 +479,15 @@
},
"OpenTelemetry.PersistentStorage.Abstractions": {
"type": "Transitive",
- "resolved": "1.0.2",
- "contentHash": "QuBc6e7M4Skvbc+eTQGSmrcoho7lSkHLT5ngoSsVeeT8OXLpSUETNcuRPW8F5drTPTzzTKQ98C5AhKO/pjpTJg=="
+ "resolved": "1.0.3",
+ "contentHash": "7nSE1uLY0KOHRssg1xQydDZJ/s9zECOzFT/gSDIp1KTxbgd1yziuMTsdBKZ/N1BbpUxfH6Pjq5OMd7lYogwReA=="
},
"OpenTelemetry.PersistentStorage.FileSystem": {
"type": "Transitive",
- "resolved": "1.0.2",
- "contentHash": "ys0l9vL0/wOV9p/iuyDeemjX+d8iH4yjaYA1IcmyQUw0xsxx0I3hQm7tN3FnuRPsmPtrohiLtp31hO1BcrhQ+A==",
+ "resolved": "1.0.3",
+ "contentHash": "3k+yCroJcGBtZIia+0LgzMlBuD0U95BzcWNEGWedusBaRAD8EmkaCPAUwaCtFbb/Mg7HywbIG2zuenANPYFJ0g==",
"dependencies": {
- "OpenTelemetry.PersistentStorage.Abstractions": "1.0.2"
+ "OpenTelemetry.PersistentStorage.Abstractions": "1.0.3"
}
},
"Polly.Core": {
@@ -553,7 +553,7 @@
"ftgo.auth.client": {
"type": "Project",
"dependencies": {
- "Azure.Monitor.OpenTelemetry.Exporter": "[1.7.0, )",
+ "Azure.Monitor.OpenTelemetry.Exporter": "[1.8.0, )",
"Microsoft.Extensions.Hosting": "[10.0.7, )",
"Microsoft.Extensions.Http": "[10.0.7, )",
"Microsoft.Extensions.Http.Resilience": "[10.5.0, )",
@@ -567,13 +567,13 @@
},
"Azure.Monitor.OpenTelemetry.Exporter": {
"type": "CentralTransitive",
- "requested": "[1.7.0, )",
- "resolved": "1.7.0",
- "contentHash": "fexzK+HM06C3tmBL6DLNVAcgutBMY7hQ7tGcdpCyq6HaXvbNz6cFtenrJUAdXM8Y4G+3QmZ6PIAL1hFrVe4Mpw==",
+ "requested": "[1.8.0, )",
+ "resolved": "1.8.0",
+ "contentHash": "JbNUQIK7uZ7Mg2fZzdsjqC/eso3Ag/tMwCqYvGvedyE1fObe6JAwCaCSMZDMrhrAaFn8x8+ZaKJelfcEK5MCcg==",
"dependencies": {
- "Azure.Core": "1.52.0",
- "OpenTelemetry.Extensions.Hosting": "1.15.1",
- "OpenTelemetry.PersistentStorage.FileSystem": "1.0.2"
+ "Azure.Core": "1.54.0",
+ "OpenTelemetry.Extensions.Hosting": "1.15.3",
+ "OpenTelemetry.PersistentStorage.FileSystem": "1.0.3"
}
},
"Microsoft.Extensions.Http": {
diff --git a/src/Ftgo.Orders.Api/packages.lock.json b/src/Ftgo.Orders.Api/packages.lock.json
index 2eb022d..ee44b37 100644
--- a/src/Ftgo.Orders.Api/packages.lock.json
+++ b/src/Ftgo.Orders.Api/packages.lock.json
@@ -10,9 +10,9 @@
},
"Meziantou.Analyzer": {
"type": "Direct",
- "requested": "[3.0.54, )",
- "resolved": "3.0.54",
- "contentHash": "fybpNsTg4ZT+oiQWkrkTvvy1dowdqjfkefFa/7OIPFPmTkHd9eSEjPCpoK1c5oYdp68QjpLTbeCDpZB2TOUQ4A=="
+ "requested": "[3.0.58, )",
+ "resolved": "3.0.58",
+ "contentHash": "EWprZh8ONB5W4T5lajB9H3/ThznWgEVbb8sWkIZe2ZOCRPPzAdLxkFfM8z57SosJtUYGxPXvxlqGmClxw8mA9g=="
},
"Microsoft.AspNetCore.OpenApi": {
"type": "Direct",
@@ -25,14 +25,14 @@
},
"Microsoft.Identity.Web": {
"type": "Direct",
- "requested": "[4.8.0, )",
- "resolved": "4.8.0",
- "contentHash": "rqMufh64Woj/kc39d9iCb12BTtBj9H40haLUemRAwoqvqSomYPlbMOwt1UD1Y5aMqmc8aYF06U5ATY8Qnw2iFg==",
- "dependencies": {
- "Microsoft.Identity.Web.Certificate": "4.8.0",
- "Microsoft.Identity.Web.Certificateless": "4.8.0",
- "Microsoft.Identity.Web.TokenAcquisition": "4.8.0",
- "Microsoft.Identity.Web.TokenCache": "4.8.0",
+ "requested": "[4.9.0, )",
+ "resolved": "4.9.0",
+ "contentHash": "SshLuFsW0VqCP18O1YUAqPh4KZ6ogu9yYrhlYdGxf/KSRR0wHO1MFlyxh2aOkHdw462XKjbzm36NTspJo1r58g==",
+ "dependencies": {
+ "Microsoft.Identity.Web.Certificate": "4.9.0",
+ "Microsoft.Identity.Web.Certificateless": "4.9.0",
+ "Microsoft.Identity.Web.TokenAcquisition": "4.9.0",
+ "Microsoft.Identity.Web.TokenCache": "4.9.0",
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.15.0",
"Microsoft.IdentityModel.Validators": "8.15.0",
"System.IdentityModel.Tokens.Jwt": "8.15.0"
@@ -40,11 +40,11 @@
},
"Microsoft.Identity.Web.DownstreamApi": {
"type": "Direct",
- "requested": "[4.8.0, )",
- "resolved": "4.8.0",
- "contentHash": "MXCBVZO9Oj2hSZkPvEFcRg1/fyKNjqJQzAVxUjdCOzno3rd1BeVhuMddk9aYNJ3r79GD5lA3xJeneXo+SKlKCA==",
+ "requested": "[4.9.0, )",
+ "resolved": "4.9.0",
+ "contentHash": "E9RjjTmAiHR7Z8HDtfEDbioW6NOvoYABGlyIhwOPkMpECTP7M69SwO5DJki+QA0pHpIongdUC856GH/lnqyxSA==",
"dependencies": {
- "Microsoft.Identity.Web.TokenAcquisition": "4.8.0"
+ "Microsoft.Identity.Web.TokenAcquisition": "4.9.0"
}
},
"Microsoft.SourceLink.GitHub": {
@@ -66,14 +66,14 @@
},
"SonarAnalyzer.CSharp": {
"type": "Direct",
- "requested": "[10.24.0.138807, )",
- "resolved": "10.24.0.138807",
- "contentHash": "+ZEa1+KhNSulMSatpffgHnovbLGtYL9oukMh8uQp8RBDnPOjEFzvECSa4kfu1D4PJxErksiGOQQ+kDeao1x9jQ=="
+ "requested": "[10.25.0.139117, )",
+ "resolved": "10.25.0.139117",
+ "contentHash": "2iBZIkcgsaUNyIPFqR9ECv5PLbAwV9I/v/PeEiMdkfhWEAnqD7VyNXzukQDlL/D24f5OoUc4MbWF5SgYI7x3CA=="
},
"Azure.Core": {
"type": "Transitive",
- "resolved": "1.53.0",
- "contentHash": "x9c/toFMOtRrlTdFuE7rlGCVAduQzWVfKmLz5juj41zJAXEhYD5hluiUyyAEzJ6OxpBnKtiaBztzwpZITAVjtg==",
+ "resolved": "1.54.0",
+ "contentHash": "m6hHbx1q9+GCBZ5A9ykzFylPdTwscX2APH7PlnqV+yu+DH3RRtuIDJMRqdU17cMyinv0hCPofpegoyQ6qWPW7g==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.3",
"Microsoft.Identity.Client": "4.83.1",
@@ -171,21 +171,21 @@
},
"Microsoft.Identity.Web.Certificate": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "G4nkrs1pKg6NxuSvyoRzokLfsfd2v5pXpXS1XUvHstvdWkQRBw8kTbSwRCvzdRFA1MW7Ct14zcp1P4kej7dB+g==",
+ "resolved": "4.9.0",
+ "contentHash": "kr6ZpxNbWm1+eI3pn6sdgIlduYZiEmoC0TbiiR1gmcREuI87E963xjBMmvXf9+CnHcjbsqhkBcK4huKauif0yA==",
"dependencies": {
- "Azure.Identity": "1.11.4",
+ "Azure.Identity": "1.17.2",
"Azure.Security.KeyVault.Certificates": "4.6.0",
"Azure.Security.KeyVault.Secrets": "4.6.0",
"Microsoft.Identity.Abstractions": "12.0.0",
- "Microsoft.Identity.Web.Certificateless": "4.8.0",
- "Microsoft.Identity.Web.Diagnostics": "4.8.0"
+ "Microsoft.Identity.Web.Certificateless": "4.9.0",
+ "Microsoft.Identity.Web.Diagnostics": "4.9.0"
}
},
"Microsoft.Identity.Web.Certificateless": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "TegoXg/SX1mca1ygTZWTgLTXIZPQudmg5SLr9f50A8fzC4oFGkNOUfa9wii9SRikBGg4Sc31QxUeM/MMFyIX6A==",
+ "resolved": "4.9.0",
+ "contentHash": "hlt8KV1V0JLjyxncmSNAugyqxVr65wQlzcsks6TgqvUyllkQvWAxqgM+Dh6OB8sgJWMoCOmJuKJ6SejAgYjJ/w==",
"dependencies": {
"Microsoft.Identity.Client": "4.83.1",
"Microsoft.IdentityModel.JsonWebTokens": "8.15.0"
@@ -193,20 +193,20 @@
},
"Microsoft.Identity.Web.Diagnostics": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "oWYVlq2h86yAmqJHcDK0JrJfhDZTnF1M1vyY9mGx+x5E5GpNml7hTI1N1Kq4Z9JUtaPvz4dN8MgfUn7QyHZ13Q=="
+ "resolved": "4.9.0",
+ "contentHash": "5x4Tyg1xFr8vgTAuos5OyCNAXcnq9U+6QWA3pRLOgUF5Am7Bn/CM+rmOjffoyfP6ymQdQ0/zxtNOtyRjTlpiBg=="
},
"Microsoft.Identity.Web.TokenAcquisition": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "Nm4YJxUQuIThoRWprg9bv2vCDs2zzlOo7B4nfHoG3Spw5Ege2edFLY+iEtRh+GYW2Fhn97rYmwCgmaQ2IhYZxA==",
+ "resolved": "4.9.0",
+ "contentHash": "ap6EFz9Nn3pWlfYQ8AqnRM7UsrH5pAXzDMF/aayKEfcAxLgXe23XKb1jBEzWNYkg5NNrbiLVWjKDIWbPqMrn1w==",
"dependencies": {
"Microsoft.AspNetCore.Authentication.JwtBearer": "10.0.0",
"Microsoft.AspNetCore.Authentication.OpenIdConnect": "10.0.0",
"Microsoft.Identity.Abstractions": "12.0.0",
- "Microsoft.Identity.Web.Certificate": "4.8.0",
- "Microsoft.Identity.Web.Certificateless": "4.8.0",
- "Microsoft.Identity.Web.TokenCache": "4.8.0",
+ "Microsoft.Identity.Web.Certificate": "4.9.0",
+ "Microsoft.Identity.Web.Certificateless": "4.9.0",
+ "Microsoft.Identity.Web.TokenCache": "4.9.0",
"Microsoft.IdentityModel.Logging": "8.15.0",
"Microsoft.IdentityModel.LoggingExtensions": "8.15.0",
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.15.0",
@@ -215,12 +215,12 @@
},
"Microsoft.Identity.Web.TokenCache": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "2TL0zev2SREJoIzZi9dQ+vv/Us6Q1ZpZD02ti0LhFDpFg/vKGD98pHmcz+f216PbWHzQpERzS3tMgcIGt2lywQ==",
+ "resolved": "4.9.0",
+ "contentHash": "3eHtmFu6HJU334bLyOjG/PvWyYQaoGEh9HyomGQ1p/aUV4EiGkdiU4R5yuyOIHdxRs8DKMFPN0Wnqp2nvWayjg==",
"dependencies": {
"Microsoft.Identity.Client": "4.83.1",
- "Microsoft.Identity.Web.Diagnostics": "4.8.0",
- "System.Security.Cryptography.Pkcs": "10.0.6"
+ "Microsoft.Identity.Web.Diagnostics": "4.9.0",
+ "System.Security.Cryptography.Pkcs": "10.0.7"
}
},
"Microsoft.IdentityModel.Abstractions": {
@@ -289,15 +289,15 @@
},
"OpenTelemetry.PersistentStorage.Abstractions": {
"type": "Transitive",
- "resolved": "1.0.2",
- "contentHash": "QuBc6e7M4Skvbc+eTQGSmrcoho7lSkHLT5ngoSsVeeT8OXLpSUETNcuRPW8F5drTPTzzTKQ98C5AhKO/pjpTJg=="
+ "resolved": "1.0.3",
+ "contentHash": "7nSE1uLY0KOHRssg1xQydDZJ/s9zECOzFT/gSDIp1KTxbgd1yziuMTsdBKZ/N1BbpUxfH6Pjq5OMd7lYogwReA=="
},
"OpenTelemetry.PersistentStorage.FileSystem": {
"type": "Transitive",
- "resolved": "1.0.2",
- "contentHash": "ys0l9vL0/wOV9p/iuyDeemjX+d8iH4yjaYA1IcmyQUw0xsxx0I3hQm7tN3FnuRPsmPtrohiLtp31hO1BcrhQ+A==",
+ "resolved": "1.0.3",
+ "contentHash": "3k+yCroJcGBtZIia+0LgzMlBuD0U95BzcWNEGWedusBaRAD8EmkaCPAUwaCtFbb/Mg7HywbIG2zuenANPYFJ0g==",
"dependencies": {
- "OpenTelemetry.PersistentStorage.Abstractions": "1.0.2"
+ "OpenTelemetry.PersistentStorage.Abstractions": "1.0.3"
}
},
"Polly.Core": {
@@ -350,8 +350,8 @@
},
"System.Security.Cryptography.Pkcs": {
"type": "Transitive",
- "resolved": "10.0.6",
- "contentHash": "88tquaGJ1htm4DHWS6x9jwER7sFET2SVRN7HqO1FYZwE0diDcUmz0ajhVa8ZD2HGhDJBueSPjP/gqyP3gXtT2A=="
+ "resolved": "10.0.7",
+ "contentHash": "dbdKfF3eA5l+CXiAbDxiCxdezoxeanbue1ck8m49ih1L9uZG6ry8Ul8On6vpragyMDJJP4rQHUY/SWgk66tCYA=="
},
"System.Security.Cryptography.ProtectedData": {
"type": "Transitive",
@@ -363,17 +363,17 @@
"dependencies": {
"Ftgo.Auth.Client": "[1.0.0, )",
"Microsoft.AspNetCore.OpenApi": "[10.0.7, )",
- "Microsoft.Identity.Web": "[4.8.0, )",
- "Microsoft.Identity.Web.DownstreamApi": "[4.8.0, )",
+ "Microsoft.Identity.Web": "[4.9.0, )",
+ "Microsoft.Identity.Web.DownstreamApi": "[4.9.0, )",
"Microsoft.IdentityModel.Validators": "[8.17.0, )",
"OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )",
- "Scalar.AspNetCore": "[2.14.4, )"
+ "Scalar.AspNetCore": "[2.14.9, )"
}
},
"ftgo.auth.client": {
"type": "Project",
"dependencies": {
- "Azure.Monitor.OpenTelemetry.Exporter": "[1.7.0, )",
+ "Azure.Monitor.OpenTelemetry.Exporter": "[1.8.0, )",
"Microsoft.Extensions.Http.Resilience": "[10.5.0, )",
"OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
"OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
@@ -392,13 +392,13 @@
},
"Azure.Monitor.OpenTelemetry.Exporter": {
"type": "CentralTransitive",
- "requested": "[1.7.0, )",
- "resolved": "1.7.0",
- "contentHash": "fexzK+HM06C3tmBL6DLNVAcgutBMY7hQ7tGcdpCyq6HaXvbNz6cFtenrJUAdXM8Y4G+3QmZ6PIAL1hFrVe4Mpw==",
+ "requested": "[1.8.0, )",
+ "resolved": "1.8.0",
+ "contentHash": "JbNUQIK7uZ7Mg2fZzdsjqC/eso3Ag/tMwCqYvGvedyE1fObe6JAwCaCSMZDMrhrAaFn8x8+ZaKJelfcEK5MCcg==",
"dependencies": {
- "Azure.Core": "1.52.0",
- "OpenTelemetry.Extensions.Hosting": "1.15.1",
- "OpenTelemetry.PersistentStorage.FileSystem": "1.0.2"
+ "Azure.Core": "1.54.0",
+ "OpenTelemetry.Extensions.Hosting": "1.15.3",
+ "OpenTelemetry.PersistentStorage.FileSystem": "1.0.3"
}
},
"Azure.Security.KeyVault.Certificates": {
@@ -536,9 +536,9 @@
},
"Scalar.AspNetCore": {
"type": "CentralTransitive",
- "requested": "[2.14.4, )",
- "resolved": "2.14.4",
- "contentHash": "PyuqLRi7JXyTJm/rh+IWCA4Fct1SlZuWrdYiui5hiaBuSY88+roc4eWKfYaE6zrupBIwnEP8pJLe4ifvU7HtZA=="
+ "requested": "[2.14.9, )",
+ "resolved": "2.14.9",
+ "contentHash": "bDc8NjI2JSIAX0C1+02WO11zCJ1SJQhWdMC0s7yi2Nh2atoNRpNRPiYeyIhWSerkc1SkDdJVOeU9QSY6jb0/zQ=="
}
}
}
diff --git a/src/Ftgo.Restaurants.Api/packages.lock.json b/src/Ftgo.Restaurants.Api/packages.lock.json
index 2eb022d..ee44b37 100644
--- a/src/Ftgo.Restaurants.Api/packages.lock.json
+++ b/src/Ftgo.Restaurants.Api/packages.lock.json
@@ -10,9 +10,9 @@
},
"Meziantou.Analyzer": {
"type": "Direct",
- "requested": "[3.0.54, )",
- "resolved": "3.0.54",
- "contentHash": "fybpNsTg4ZT+oiQWkrkTvvy1dowdqjfkefFa/7OIPFPmTkHd9eSEjPCpoK1c5oYdp68QjpLTbeCDpZB2TOUQ4A=="
+ "requested": "[3.0.58, )",
+ "resolved": "3.0.58",
+ "contentHash": "EWprZh8ONB5W4T5lajB9H3/ThznWgEVbb8sWkIZe2ZOCRPPzAdLxkFfM8z57SosJtUYGxPXvxlqGmClxw8mA9g=="
},
"Microsoft.AspNetCore.OpenApi": {
"type": "Direct",
@@ -25,14 +25,14 @@
},
"Microsoft.Identity.Web": {
"type": "Direct",
- "requested": "[4.8.0, )",
- "resolved": "4.8.0",
- "contentHash": "rqMufh64Woj/kc39d9iCb12BTtBj9H40haLUemRAwoqvqSomYPlbMOwt1UD1Y5aMqmc8aYF06U5ATY8Qnw2iFg==",
- "dependencies": {
- "Microsoft.Identity.Web.Certificate": "4.8.0",
- "Microsoft.Identity.Web.Certificateless": "4.8.0",
- "Microsoft.Identity.Web.TokenAcquisition": "4.8.0",
- "Microsoft.Identity.Web.TokenCache": "4.8.0",
+ "requested": "[4.9.0, )",
+ "resolved": "4.9.0",
+ "contentHash": "SshLuFsW0VqCP18O1YUAqPh4KZ6ogu9yYrhlYdGxf/KSRR0wHO1MFlyxh2aOkHdw462XKjbzm36NTspJo1r58g==",
+ "dependencies": {
+ "Microsoft.Identity.Web.Certificate": "4.9.0",
+ "Microsoft.Identity.Web.Certificateless": "4.9.0",
+ "Microsoft.Identity.Web.TokenAcquisition": "4.9.0",
+ "Microsoft.Identity.Web.TokenCache": "4.9.0",
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.15.0",
"Microsoft.IdentityModel.Validators": "8.15.0",
"System.IdentityModel.Tokens.Jwt": "8.15.0"
@@ -40,11 +40,11 @@
},
"Microsoft.Identity.Web.DownstreamApi": {
"type": "Direct",
- "requested": "[4.8.0, )",
- "resolved": "4.8.0",
- "contentHash": "MXCBVZO9Oj2hSZkPvEFcRg1/fyKNjqJQzAVxUjdCOzno3rd1BeVhuMddk9aYNJ3r79GD5lA3xJeneXo+SKlKCA==",
+ "requested": "[4.9.0, )",
+ "resolved": "4.9.0",
+ "contentHash": "E9RjjTmAiHR7Z8HDtfEDbioW6NOvoYABGlyIhwOPkMpECTP7M69SwO5DJki+QA0pHpIongdUC856GH/lnqyxSA==",
"dependencies": {
- "Microsoft.Identity.Web.TokenAcquisition": "4.8.0"
+ "Microsoft.Identity.Web.TokenAcquisition": "4.9.0"
}
},
"Microsoft.SourceLink.GitHub": {
@@ -66,14 +66,14 @@
},
"SonarAnalyzer.CSharp": {
"type": "Direct",
- "requested": "[10.24.0.138807, )",
- "resolved": "10.24.0.138807",
- "contentHash": "+ZEa1+KhNSulMSatpffgHnovbLGtYL9oukMh8uQp8RBDnPOjEFzvECSa4kfu1D4PJxErksiGOQQ+kDeao1x9jQ=="
+ "requested": "[10.25.0.139117, )",
+ "resolved": "10.25.0.139117",
+ "contentHash": "2iBZIkcgsaUNyIPFqR9ECv5PLbAwV9I/v/PeEiMdkfhWEAnqD7VyNXzukQDlL/D24f5OoUc4MbWF5SgYI7x3CA=="
},
"Azure.Core": {
"type": "Transitive",
- "resolved": "1.53.0",
- "contentHash": "x9c/toFMOtRrlTdFuE7rlGCVAduQzWVfKmLz5juj41zJAXEhYD5hluiUyyAEzJ6OxpBnKtiaBztzwpZITAVjtg==",
+ "resolved": "1.54.0",
+ "contentHash": "m6hHbx1q9+GCBZ5A9ykzFylPdTwscX2APH7PlnqV+yu+DH3RRtuIDJMRqdU17cMyinv0hCPofpegoyQ6qWPW7g==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.3",
"Microsoft.Identity.Client": "4.83.1",
@@ -171,21 +171,21 @@
},
"Microsoft.Identity.Web.Certificate": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "G4nkrs1pKg6NxuSvyoRzokLfsfd2v5pXpXS1XUvHstvdWkQRBw8kTbSwRCvzdRFA1MW7Ct14zcp1P4kej7dB+g==",
+ "resolved": "4.9.0",
+ "contentHash": "kr6ZpxNbWm1+eI3pn6sdgIlduYZiEmoC0TbiiR1gmcREuI87E963xjBMmvXf9+CnHcjbsqhkBcK4huKauif0yA==",
"dependencies": {
- "Azure.Identity": "1.11.4",
+ "Azure.Identity": "1.17.2",
"Azure.Security.KeyVault.Certificates": "4.6.0",
"Azure.Security.KeyVault.Secrets": "4.6.0",
"Microsoft.Identity.Abstractions": "12.0.0",
- "Microsoft.Identity.Web.Certificateless": "4.8.0",
- "Microsoft.Identity.Web.Diagnostics": "4.8.0"
+ "Microsoft.Identity.Web.Certificateless": "4.9.0",
+ "Microsoft.Identity.Web.Diagnostics": "4.9.0"
}
},
"Microsoft.Identity.Web.Certificateless": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "TegoXg/SX1mca1ygTZWTgLTXIZPQudmg5SLr9f50A8fzC4oFGkNOUfa9wii9SRikBGg4Sc31QxUeM/MMFyIX6A==",
+ "resolved": "4.9.0",
+ "contentHash": "hlt8KV1V0JLjyxncmSNAugyqxVr65wQlzcsks6TgqvUyllkQvWAxqgM+Dh6OB8sgJWMoCOmJuKJ6SejAgYjJ/w==",
"dependencies": {
"Microsoft.Identity.Client": "4.83.1",
"Microsoft.IdentityModel.JsonWebTokens": "8.15.0"
@@ -193,20 +193,20 @@
},
"Microsoft.Identity.Web.Diagnostics": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "oWYVlq2h86yAmqJHcDK0JrJfhDZTnF1M1vyY9mGx+x5E5GpNml7hTI1N1Kq4Z9JUtaPvz4dN8MgfUn7QyHZ13Q=="
+ "resolved": "4.9.0",
+ "contentHash": "5x4Tyg1xFr8vgTAuos5OyCNAXcnq9U+6QWA3pRLOgUF5Am7Bn/CM+rmOjffoyfP6ymQdQ0/zxtNOtyRjTlpiBg=="
},
"Microsoft.Identity.Web.TokenAcquisition": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "Nm4YJxUQuIThoRWprg9bv2vCDs2zzlOo7B4nfHoG3Spw5Ege2edFLY+iEtRh+GYW2Fhn97rYmwCgmaQ2IhYZxA==",
+ "resolved": "4.9.0",
+ "contentHash": "ap6EFz9Nn3pWlfYQ8AqnRM7UsrH5pAXzDMF/aayKEfcAxLgXe23XKb1jBEzWNYkg5NNrbiLVWjKDIWbPqMrn1w==",
"dependencies": {
"Microsoft.AspNetCore.Authentication.JwtBearer": "10.0.0",
"Microsoft.AspNetCore.Authentication.OpenIdConnect": "10.0.0",
"Microsoft.Identity.Abstractions": "12.0.0",
- "Microsoft.Identity.Web.Certificate": "4.8.0",
- "Microsoft.Identity.Web.Certificateless": "4.8.0",
- "Microsoft.Identity.Web.TokenCache": "4.8.0",
+ "Microsoft.Identity.Web.Certificate": "4.9.0",
+ "Microsoft.Identity.Web.Certificateless": "4.9.0",
+ "Microsoft.Identity.Web.TokenCache": "4.9.0",
"Microsoft.IdentityModel.Logging": "8.15.0",
"Microsoft.IdentityModel.LoggingExtensions": "8.15.0",
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.15.0",
@@ -215,12 +215,12 @@
},
"Microsoft.Identity.Web.TokenCache": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "2TL0zev2SREJoIzZi9dQ+vv/Us6Q1ZpZD02ti0LhFDpFg/vKGD98pHmcz+f216PbWHzQpERzS3tMgcIGt2lywQ==",
+ "resolved": "4.9.0",
+ "contentHash": "3eHtmFu6HJU334bLyOjG/PvWyYQaoGEh9HyomGQ1p/aUV4EiGkdiU4R5yuyOIHdxRs8DKMFPN0Wnqp2nvWayjg==",
"dependencies": {
"Microsoft.Identity.Client": "4.83.1",
- "Microsoft.Identity.Web.Diagnostics": "4.8.0",
- "System.Security.Cryptography.Pkcs": "10.0.6"
+ "Microsoft.Identity.Web.Diagnostics": "4.9.0",
+ "System.Security.Cryptography.Pkcs": "10.0.7"
}
},
"Microsoft.IdentityModel.Abstractions": {
@@ -289,15 +289,15 @@
},
"OpenTelemetry.PersistentStorage.Abstractions": {
"type": "Transitive",
- "resolved": "1.0.2",
- "contentHash": "QuBc6e7M4Skvbc+eTQGSmrcoho7lSkHLT5ngoSsVeeT8OXLpSUETNcuRPW8F5drTPTzzTKQ98C5AhKO/pjpTJg=="
+ "resolved": "1.0.3",
+ "contentHash": "7nSE1uLY0KOHRssg1xQydDZJ/s9zECOzFT/gSDIp1KTxbgd1yziuMTsdBKZ/N1BbpUxfH6Pjq5OMd7lYogwReA=="
},
"OpenTelemetry.PersistentStorage.FileSystem": {
"type": "Transitive",
- "resolved": "1.0.2",
- "contentHash": "ys0l9vL0/wOV9p/iuyDeemjX+d8iH4yjaYA1IcmyQUw0xsxx0I3hQm7tN3FnuRPsmPtrohiLtp31hO1BcrhQ+A==",
+ "resolved": "1.0.3",
+ "contentHash": "3k+yCroJcGBtZIia+0LgzMlBuD0U95BzcWNEGWedusBaRAD8EmkaCPAUwaCtFbb/Mg7HywbIG2zuenANPYFJ0g==",
"dependencies": {
- "OpenTelemetry.PersistentStorage.Abstractions": "1.0.2"
+ "OpenTelemetry.PersistentStorage.Abstractions": "1.0.3"
}
},
"Polly.Core": {
@@ -350,8 +350,8 @@
},
"System.Security.Cryptography.Pkcs": {
"type": "Transitive",
- "resolved": "10.0.6",
- "contentHash": "88tquaGJ1htm4DHWS6x9jwER7sFET2SVRN7HqO1FYZwE0diDcUmz0ajhVa8ZD2HGhDJBueSPjP/gqyP3gXtT2A=="
+ "resolved": "10.0.7",
+ "contentHash": "dbdKfF3eA5l+CXiAbDxiCxdezoxeanbue1ck8m49ih1L9uZG6ry8Ul8On6vpragyMDJJP4rQHUY/SWgk66tCYA=="
},
"System.Security.Cryptography.ProtectedData": {
"type": "Transitive",
@@ -363,17 +363,17 @@
"dependencies": {
"Ftgo.Auth.Client": "[1.0.0, )",
"Microsoft.AspNetCore.OpenApi": "[10.0.7, )",
- "Microsoft.Identity.Web": "[4.8.0, )",
- "Microsoft.Identity.Web.DownstreamApi": "[4.8.0, )",
+ "Microsoft.Identity.Web": "[4.9.0, )",
+ "Microsoft.Identity.Web.DownstreamApi": "[4.9.0, )",
"Microsoft.IdentityModel.Validators": "[8.17.0, )",
"OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )",
- "Scalar.AspNetCore": "[2.14.4, )"
+ "Scalar.AspNetCore": "[2.14.9, )"
}
},
"ftgo.auth.client": {
"type": "Project",
"dependencies": {
- "Azure.Monitor.OpenTelemetry.Exporter": "[1.7.0, )",
+ "Azure.Monitor.OpenTelemetry.Exporter": "[1.8.0, )",
"Microsoft.Extensions.Http.Resilience": "[10.5.0, )",
"OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
"OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
@@ -392,13 +392,13 @@
},
"Azure.Monitor.OpenTelemetry.Exporter": {
"type": "CentralTransitive",
- "requested": "[1.7.0, )",
- "resolved": "1.7.0",
- "contentHash": "fexzK+HM06C3tmBL6DLNVAcgutBMY7hQ7tGcdpCyq6HaXvbNz6cFtenrJUAdXM8Y4G+3QmZ6PIAL1hFrVe4Mpw==",
+ "requested": "[1.8.0, )",
+ "resolved": "1.8.0",
+ "contentHash": "JbNUQIK7uZ7Mg2fZzdsjqC/eso3Ag/tMwCqYvGvedyE1fObe6JAwCaCSMZDMrhrAaFn8x8+ZaKJelfcEK5MCcg==",
"dependencies": {
- "Azure.Core": "1.52.0",
- "OpenTelemetry.Extensions.Hosting": "1.15.1",
- "OpenTelemetry.PersistentStorage.FileSystem": "1.0.2"
+ "Azure.Core": "1.54.0",
+ "OpenTelemetry.Extensions.Hosting": "1.15.3",
+ "OpenTelemetry.PersistentStorage.FileSystem": "1.0.3"
}
},
"Azure.Security.KeyVault.Certificates": {
@@ -536,9 +536,9 @@
},
"Scalar.AspNetCore": {
"type": "CentralTransitive",
- "requested": "[2.14.4, )",
- "resolved": "2.14.4",
- "contentHash": "PyuqLRi7JXyTJm/rh+IWCA4Fct1SlZuWrdYiui5hiaBuSY88+roc4eWKfYaE6zrupBIwnEP8pJLe4ifvU7HtZA=="
+ "requested": "[2.14.9, )",
+ "resolved": "2.14.9",
+ "contentHash": "bDc8NjI2JSIAX0C1+02WO11zCJ1SJQhWdMC0s7yi2Nh2atoNRpNRPiYeyIhWSerkc1SkDdJVOeU9QSY6jb0/zQ=="
}
}
}
diff --git a/tests/Ftgo.Auth.Tests/DownstreamApiClientClaimsChallengeTests.cs b/tests/Ftgo.Auth.Tests/DownstreamApiClientClaimsChallengeTests.cs
index b457c9e..d2890fd 100644
--- a/tests/Ftgo.Auth.Tests/DownstreamApiClientClaimsChallengeTests.cs
+++ b/tests/Ftgo.Auth.Tests/DownstreamApiClientClaimsChallengeTests.cs
@@ -89,4 +89,60 @@ public async Task ProbeAsync_ReturnsBody_OnSuccess()
status.ShouldBe(200);
body.ShouldBe("ok");
}
+
+ [Theory]
+ [InlineData("error=\"insufficient_claims\", claims=\"abc\", error_description=\"step-up\"", "abc")]
+ [InlineData("error=insufficient_claims, claims=abc, error_description=\"step-up\"", "abc")]
+ [InlineData("CLAIMS=\"abc\"", "abc")]
+ [InlineData("Claims=\"abc\", error=\"insufficient_claims\"", "abc")]
+ [InlineData("error=\"x\", claims=\"value, with, commas\"", "value, with, commas")]
+ public async Task ProbeAsync_Parses_Claims_From_Various_HeaderShapes(string parameter, string expected)
+ {
+ var resp = new HttpResponseMessage(HttpStatusCode.Unauthorized);
+ resp.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("Bearer", parameter));
+
+ var client = BuildClient(resp);
+
+ var ex = await Should.ThrowAsync(async () =>
+ await client.ProbeAsync("/probe", "scope", TestContext.Current.CancellationToken));
+ ex.Claims.ShouldBe(expected);
+ }
+
+ [Fact]
+ public async Task ProbeAsync_DoesNotThrow_When_Bearer_Has_No_Parameter()
+ {
+ var resp = new HttpResponseMessage(HttpStatusCode.Unauthorized) { Content = new StringContent("nope") };
+ resp.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("Bearer"));
+
+ var client = BuildClient(resp);
+ var (status, _) = await client.ProbeAsync("/probe", "scope", TestContext.Current.CancellationToken);
+
+ status.ShouldBe(401);
+ }
+
+ [Fact]
+ public async Task ProbeAsync_FirstWins_When_Multiple_Bearer_Challenges()
+ {
+ var resp = new HttpResponseMessage(HttpStatusCode.Unauthorized);
+ resp.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("Bearer", "error=\"insufficient_claims\", claims=\"first\""));
+ resp.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("Bearer", "error=\"insufficient_claims\", claims=\"second\""));
+
+ var client = BuildClient(resp);
+
+ var ex = await Should.ThrowAsync(async () =>
+ await client.ProbeAsync("/probe", "scope", TestContext.Current.CancellationToken));
+ ex.Claims.ShouldBe("first");
+ }
+
+ [Fact]
+ public async Task ProbeAsync_DoesNotThrow_When_Scheme_Is_Not_Bearer()
+ {
+ var resp = new HttpResponseMessage(HttpStatusCode.Unauthorized) { Content = new StringContent("basic-realm") };
+ resp.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("Basic", "realm=\"test\""));
+
+ var client = BuildClient(resp);
+ var (status, _) = await client.ProbeAsync("/probe", "scope", TestContext.Current.CancellationToken);
+
+ status.ShouldBe(401);
+ }
}
diff --git a/tests/Ftgo.Auth.Tests/EntraAuthForwardedHeadersExtensionsTests.cs b/tests/Ftgo.Auth.Tests/EntraAuthForwardedHeadersExtensionsTests.cs
new file mode 100644
index 0000000..f0ab5f9
--- /dev/null
+++ b/tests/Ftgo.Auth.Tests/EntraAuthForwardedHeadersExtensionsTests.cs
@@ -0,0 +1,107 @@
+using Ftgo.Auth;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+using Shouldly;
+using Xunit;
+
+namespace Ftgo.Auth.Tests;
+
+public sealed class EntraAuthForwardedHeadersExtensionsTests
+{
+ [Fact]
+ public void Honours_XForwardedFor_Proto_And_Host()
+ {
+ var options = ConfigureAndResolve();
+
+ options.ForwardedHeaders.HasFlag(ForwardedHeaders.XForwardedFor).ShouldBeTrue();
+ options.ForwardedHeaders.HasFlag(ForwardedHeaders.XForwardedProto).ShouldBeTrue();
+ options.ForwardedHeaders.HasFlag(ForwardedHeaders.XForwardedHost).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Caps_ForwardLimit_To_2_To_Limit_TunnelDepth()
+ {
+ var options = ConfigureAndResolve();
+ options.ForwardLimit.ShouldBe(2);
+ }
+
+ [Fact]
+ public async Task Middleware_Updates_RemoteIp_From_XForwardedFor()
+ {
+ using var host = await BuildHost();
+ var client = host.GetTestClient();
+
+ var req = new HttpRequestMessage(HttpMethod.Get, "/probe");
+ req.Headers.Add("X-Forwarded-For", "203.0.113.42");
+ req.Headers.Add("X-Forwarded-Proto", "https");
+ req.Headers.Add("X-Forwarded-Host", "api.contoso.com");
+
+ var response = await client.SendAsync(req, TestContext.Current.CancellationToken);
+
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+ body.ShouldContain("ip=203.0.113.42");
+ body.ShouldContain("scheme=https");
+ body.ShouldContain("host=api.contoso.com");
+ }
+
+ [Fact]
+ public async Task Middleware_Honours_ForwardLimit_When_Chain_Too_Long()
+ {
+ using var host = await BuildHost();
+ var client = host.GetTestClient();
+
+ var req = new HttpRequestMessage(HttpMethod.Get, "/probe");
+ // 3 entries; ForwardLimit=2 means the middleware processes only the last 2 (rightmost).
+ // The resulting RemoteIp is the deepest *trusted* hop, which is the 2nd-from-right.
+ req.Headers.Add("X-Forwarded-For", "198.51.100.1, 198.51.100.2, 203.0.113.42");
+
+ var response = await client.SendAsync(req, TestContext.Current.CancellationToken);
+
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+ // 198.51.100.1 (the first / least-trusted entry) MUST NOT win — that would be the bug
+ // ForwardLimit guards against. Either of the other two is acceptable per ForwardedHeaders semantics.
+ body.ShouldNotContain("ip=198.51.100.1");
+ }
+
+ private static ForwardedHeadersOptions ConfigureAndResolve()
+ {
+ var services = new ServiceCollection();
+ services.AddEntraAuthForwardedHeaders();
+ var sp = services.BuildServiceProvider();
+ return sp.GetRequiredService>().Value;
+ }
+
+ private static async Task BuildHost()
+ {
+ var host = await new HostBuilder()
+ .ConfigureWebHost(web => web
+ .UseTestServer()
+ .ConfigureServices(services =>
+ {
+ services.AddEntraAuthForwardedHeaders();
+ services.PostConfigure(o =>
+ {
+ o.KnownIPNetworks.Clear();
+ o.KnownProxies.Clear();
+ });
+ })
+ .Configure(app =>
+ {
+ app.UseForwardedHeaders();
+ app.Run(async ctx =>
+ {
+ var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown";
+ await ctx.Response.WriteAsync(
+ $"ip={ip} scheme={ctx.Request.Scheme} host={ctx.Request.Host}");
+ });
+ }))
+ .StartAsync(TestContext.Current.CancellationToken);
+ return host;
+ }
+}
diff --git a/tests/Ftgo.Auth.Tests/EntraAuthRateLimiterTests.cs b/tests/Ftgo.Auth.Tests/EntraAuthRateLimiterTests.cs
index 52eed70..1584b22 100644
--- a/tests/Ftgo.Auth.Tests/EntraAuthRateLimiterTests.cs
+++ b/tests/Ftgo.Auth.Tests/EntraAuthRateLimiterTests.cs
@@ -102,6 +102,95 @@ public void Partition_key_treats_roles_only_token_as_app_even_without_idtyp()
isApp.ShouldBeTrue();
}
+ [Theory]
+ [InlineData("tid", "oid", "u|tenant-x|user-y")]
+ [InlineData("http://schemas.microsoft.com/identity/claims/tenantid", "http://schemas.microsoft.com/identity/claims/objectidentifier", "u|tenant-x|user-y")]
+ [InlineData("tid", "sub", "u|tenant-x|user-y")]
+ public void Partition_key_resolves_long_form_user_claim_variants(string tidClaim, string oidClaim, string expected)
+ {
+ var ctx = new DefaultHttpContext();
+ ctx.User = new ClaimsPrincipal(new ClaimsIdentity(new[]
+ {
+ new Claim(tidClaim, "tenant-x"),
+ new Claim(oidClaim, "user-y"),
+ }, authenticationType: "test"));
+
+ EntraAuthRateLimiterExtensions.PartitionKey(ctx).ShouldBe(expected);
+ }
+
+ [Theory]
+ [InlineData("azp", "client-x")]
+ [InlineData("appid", "client-x")]
+ [InlineData("http://schemas.microsoft.com/identity/claims/appid", "client-x")]
+ public void Partition_key_resolves_app_appid_claim_variants(string claimType, string value)
+ {
+ var ctx = new DefaultHttpContext();
+ ctx.User = new ClaimsPrincipal(new ClaimsIdentity(new[]
+ {
+ new Claim("tid", "tenant-x"),
+ new Claim("idtyp", "app"),
+ new Claim(claimType, value),
+ new Claim("roles", "Orders.Process"),
+ }, authenticationType: "test"));
+
+ EntraAuthRateLimiterExtensions.PartitionKey(ctx).ShouldBe($"a|tenant-x|{value}");
+ }
+
+ [Fact]
+ public void Partition_key_falls_back_to_unknown_when_claims_missing_but_authenticated()
+ {
+ var ctx = new DefaultHttpContext();
+ ctx.User = new ClaimsPrincipal(new ClaimsIdentity(authenticationType: "test"));
+
+ var key = EntraAuthRateLimiterExtensions.PartitionKey(ctx);
+
+ key.ShouldStartWith("u|unknown-tid|");
+ key.ShouldEndWith("|unknown-oid");
+ }
+
+ [Fact]
+ public async Task Default_policy_isolates_partitions_across_distinct_users()
+ {
+ using var host = await BuildHostAsync(permitLimit: 2);
+ using var client = host.GetTestClient();
+
+ // User A exhausts their budget.
+ await SendAsAsync(client, tid: "tenant-a", oid: "user-a");
+ await SendAsAsync(client, tid: "tenant-a", oid: "user-a");
+ var aRejected = await SendAsAsync(client, tid: "tenant-a", oid: "user-a");
+
+ // User B in same tenant is unaffected.
+ var bAllowed = await SendAsAsync(client, tid: "tenant-a", oid: "user-b");
+
+ aRejected.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests);
+ bAllowed.StatusCode.ShouldBe(HttpStatusCode.OK);
+ }
+
+ [Fact]
+ public async Task Default_policy_emits_RetryAfter_header_on_429()
+ {
+ using var host = await BuildHostAsync(permitLimit: 1);
+ using var client = host.GetTestClient();
+
+ await SendAsAsync(client, tid: "tenant-r", oid: "user-r");
+ var rejected = await SendAsAsync(client, tid: "tenant-r", oid: "user-r");
+
+ rejected.StatusCode.ShouldBe(HttpStatusCode.TooManyRequests);
+ // RFC 6585 §3 requires Retry-After on 429. The OnRejected hook in
+ // EntraAuthRateLimiterExtensions writes it from the lease metadata.
+ rejected.Headers.RetryAfter.ShouldNotBeNull();
+ rejected.Headers.RetryAfter!.Delta.ShouldNotBeNull();
+ rejected.Headers.RetryAfter.Delta!.Value.ShouldBeGreaterThan(TimeSpan.Zero);
+ }
+
+ private static Task SendAsAsync(HttpClient client, string tid, string oid)
+ {
+ var req = new HttpRequestMessage(HttpMethod.Get, "/");
+ req.Headers.Add("X-Test-Tid", tid);
+ req.Headers.Add("X-Test-Oid", oid);
+ return client.SendAsync(req, TestContext.Current.CancellationToken);
+ }
+
private static async Task BuildHostAsync(int permitLimit)
{
var builder = new HostBuilder().ConfigureWebHost(web =>
@@ -118,6 +207,22 @@ private static async Task BuildHostAsync(int permitLimit)
});
web.Configure(app =>
{
+ // Synthesize a ClaimsPrincipal from headers so partition-isolation tests can
+ // exercise multiple identities through one HttpClient.
+ app.Use(async (ctx, next) =>
+ {
+ if (ctx.Request.Headers.TryGetValue("X-Test-Tid", out var tid) &&
+ ctx.Request.Headers.TryGetValue("X-Test-Oid", out var oid))
+ {
+ ctx.User = new ClaimsPrincipal(new ClaimsIdentity(new[]
+ {
+ new Claim("tid", tid!),
+ new Claim("oid", oid!),
+ }, authenticationType: "test"));
+ }
+ await next();
+ });
+
app.UseRouting();
app.UseRateLimiter();
app.UseEndpoints(e =>
diff --git a/tests/Ftgo.Auth.Tests/packages.lock.json b/tests/Ftgo.Auth.Tests/packages.lock.json
index ce4c8be..02191af 100644
--- a/tests/Ftgo.Auth.Tests/packages.lock.json
+++ b/tests/Ftgo.Auth.Tests/packages.lock.json
@@ -16,9 +16,9 @@
},
"Meziantou.Analyzer": {
"type": "Direct",
- "requested": "[3.0.54, )",
- "resolved": "3.0.54",
- "contentHash": "fybpNsTg4ZT+oiQWkrkTvvy1dowdqjfkefFa/7OIPFPmTkHd9eSEjPCpoK1c5oYdp68QjpLTbeCDpZB2TOUQ4A=="
+ "requested": "[3.0.58, )",
+ "resolved": "3.0.58",
+ "contentHash": "EWprZh8ONB5W4T5lajB9H3/ThznWgEVbb8sWkIZe2ZOCRPPzAdLxkFfM8z57SosJtUYGxPXvxlqGmClxw8mA9g=="
},
"Microsoft.AspNetCore.TestHost": {
"type": "Direct",
@@ -28,12 +28,12 @@
},
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
- "requested": "[18.4.0, )",
- "resolved": "18.4.0",
- "contentHash": "w49iZdL4HL6V25l41NVQLXWQ+e71GvSkKVteMrOL02gP/PUkcnO/1yEb2s9FntU4wGmJWfKnyrRAhcMHd9ZZNA==",
+ "requested": "[18.5.1, )",
+ "resolved": "18.5.1",
+ "contentHash": "SfqVaLiIqAbRWuPg5BP4QFwBIirQj/YIL8Dhxl6zntBKbXp0cQykoV480SmwG+yRMiWptxEI6NbHQuGSZ8b97w==",
"dependencies": {
- "Microsoft.CodeCoverage": "18.4.0",
- "Microsoft.TestPlatform.TestHost": "18.4.0"
+ "Microsoft.CodeCoverage": "18.5.1",
+ "Microsoft.TestPlatform.TestHost": "18.5.1"
}
},
"Microsoft.SourceLink.GitHub": {
@@ -74,9 +74,9 @@
},
"SonarAnalyzer.CSharp": {
"type": "Direct",
- "requested": "[10.24.0.138807, )",
- "resolved": "10.24.0.138807",
- "contentHash": "+ZEa1+KhNSulMSatpffgHnovbLGtYL9oukMh8uQp8RBDnPOjEFzvECSa4kfu1D4PJxErksiGOQQ+kDeao1x9jQ=="
+ "requested": "[10.25.0.139117, )",
+ "resolved": "10.25.0.139117",
+ "contentHash": "2iBZIkcgsaUNyIPFqR9ECv5PLbAwV9I/v/PeEiMdkfhWEAnqD7VyNXzukQDlL/D24f5OoUc4MbWF5SgYI7x3CA=="
},
"xunit.runner.visualstudio": {
"type": "Direct",
@@ -95,8 +95,8 @@
},
"Azure.Core": {
"type": "Transitive",
- "resolved": "1.53.0",
- "contentHash": "x9c/toFMOtRrlTdFuE7rlGCVAduQzWVfKmLz5juj41zJAXEhYD5hluiUyyAEzJ6OxpBnKtiaBztzwpZITAVjtg==",
+ "resolved": "1.54.0",
+ "contentHash": "m6hHbx1q9+GCBZ5A9ykzFylPdTwscX2APH7PlnqV+yu+DH3RRtuIDJMRqdU17cMyinv0hCPofpegoyQ6qWPW7g==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "10.0.3",
"Microsoft.Extensions.Configuration.Abstractions": "10.0.3",
@@ -167,8 +167,8 @@
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
- "resolved": "18.4.0",
- "contentHash": "9O0BtCfzCWrkAmK187ugKdq72HHOXoOUjuWFDVc2LsZZ0pOnA9bTt+Sg9q4cF+MoAaUU+MuWtvBuFsnduviJow=="
+ "resolved": "18.5.1",
+ "contentHash": "vMFDR1ZjqzzgKmM0zrPie7Gv9Y+ZppjODB5Quzu9Eq0TlIusUfUCYFPEawO91zQuqwzvdFbJSU7WHNtjStffJQ=="
},
"Microsoft.Extensions.AmbientMetadata.Application": {
"type": "Transitive",
@@ -516,45 +516,45 @@
},
"Microsoft.Identity.Web.Certificate": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "G4nkrs1pKg6NxuSvyoRzokLfsfd2v5pXpXS1XUvHstvdWkQRBw8kTbSwRCvzdRFA1MW7Ct14zcp1P4kej7dB+g==",
+ "resolved": "4.9.0",
+ "contentHash": "kr6ZpxNbWm1+eI3pn6sdgIlduYZiEmoC0TbiiR1gmcREuI87E963xjBMmvXf9+CnHcjbsqhkBcK4huKauif0yA==",
"dependencies": {
- "Azure.Identity": "1.11.4",
+ "Azure.Identity": "1.17.2",
"Azure.Security.KeyVault.Certificates": "4.6.0",
"Azure.Security.KeyVault.Secrets": "4.6.0",
"Microsoft.Extensions.Logging.Abstractions": "10.0.0",
"Microsoft.Identity.Abstractions": "12.0.0",
- "Microsoft.Identity.Web.Certificateless": "4.8.0",
- "Microsoft.Identity.Web.Diagnostics": "4.8.0"
+ "Microsoft.Identity.Web.Certificateless": "4.9.0",
+ "Microsoft.Identity.Web.Diagnostics": "4.9.0"
}
},
"Microsoft.Identity.Web.Certificateless": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "TegoXg/SX1mca1ygTZWTgLTXIZPQudmg5SLr9f50A8fzC4oFGkNOUfa9wii9SRikBGg4Sc31QxUeM/MMFyIX6A==",
+ "resolved": "4.9.0",
+ "contentHash": "hlt8KV1V0JLjyxncmSNAugyqxVr65wQlzcsks6TgqvUyllkQvWAxqgM+Dh6OB8sgJWMoCOmJuKJ6SejAgYjJ/w==",
"dependencies": {
- "Microsoft.Extensions.Logging.Abstractions": "2.1.0",
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.3",
"Microsoft.Identity.Client": "4.83.1",
"Microsoft.IdentityModel.JsonWebTokens": "8.15.0"
}
},
"Microsoft.Identity.Web.Diagnostics": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "oWYVlq2h86yAmqJHcDK0JrJfhDZTnF1M1vyY9mGx+x5E5GpNml7hTI1N1Kq4Z9JUtaPvz4dN8MgfUn7QyHZ13Q=="
+ "resolved": "4.9.0",
+ "contentHash": "5x4Tyg1xFr8vgTAuos5OyCNAXcnq9U+6QWA3pRLOgUF5Am7Bn/CM+rmOjffoyfP6ymQdQ0/zxtNOtyRjTlpiBg=="
},
"Microsoft.Identity.Web.TokenAcquisition": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "Nm4YJxUQuIThoRWprg9bv2vCDs2zzlOo7B4nfHoG3Spw5Ege2edFLY+iEtRh+GYW2Fhn97rYmwCgmaQ2IhYZxA==",
+ "resolved": "4.9.0",
+ "contentHash": "ap6EFz9Nn3pWlfYQ8AqnRM7UsrH5pAXzDMF/aayKEfcAxLgXe23XKb1jBEzWNYkg5NNrbiLVWjKDIWbPqMrn1w==",
"dependencies": {
"Microsoft.AspNetCore.Authentication.JwtBearer": "10.0.0",
"Microsoft.AspNetCore.Authentication.OpenIdConnect": "10.0.0",
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0",
"Microsoft.Identity.Abstractions": "12.0.0",
- "Microsoft.Identity.Web.Certificate": "4.8.0",
- "Microsoft.Identity.Web.Certificateless": "4.8.0",
- "Microsoft.Identity.Web.TokenCache": "4.8.0",
+ "Microsoft.Identity.Web.Certificate": "4.9.0",
+ "Microsoft.Identity.Web.Certificateless": "4.9.0",
+ "Microsoft.Identity.Web.TokenCache": "4.9.0",
"Microsoft.IdentityModel.Logging": "8.15.0",
"Microsoft.IdentityModel.LoggingExtensions": "8.15.0",
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.15.0",
@@ -563,16 +563,16 @@
},
"Microsoft.Identity.Web.TokenCache": {
"type": "Transitive",
- "resolved": "4.8.0",
- "contentHash": "2TL0zev2SREJoIzZi9dQ+vv/Us6Q1ZpZD02ti0LhFDpFg/vKGD98pHmcz+f216PbWHzQpERzS3tMgcIGt2lywQ==",
+ "resolved": "4.9.0",
+ "contentHash": "3eHtmFu6HJU334bLyOjG/PvWyYQaoGEh9HyomGQ1p/aUV4EiGkdiU4R5yuyOIHdxRs8DKMFPN0Wnqp2nvWayjg==",
"dependencies": {
- "Microsoft.AspNetCore.DataProtection": "10.0.0",
+ "Microsoft.AspNetCore.DataProtection": "10.0.7",
"Microsoft.Extensions.Caching.Memory": "10.0.0",
"Microsoft.Extensions.Logging": "10.0.0",
"Microsoft.Identity.Client": "4.83.1",
- "Microsoft.Identity.Web.Diagnostics": "4.8.0",
- "System.Security.Cryptography.Pkcs": "10.0.6",
- "System.Security.Cryptography.Xml": "10.0.6"
+ "Microsoft.Identity.Web.Diagnostics": "4.9.0",
+ "System.Security.Cryptography.Pkcs": "10.0.7",
+ "System.Security.Cryptography.Xml": "10.0.7"
}
},
"Microsoft.IdentityModel.Abstractions": {
@@ -656,15 +656,15 @@
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
- "resolved": "18.4.0",
- "contentHash": "4L6m2kS2pY5uJ9cpeRxzW22opr6ttScIRqsOpMDQpgENp/ZwxkkQCcmc6LRSURo2dFaaSW5KVflQZvroiJ7Wzg=="
+ "resolved": "18.5.1",
+ "contentHash": "KNZd+M0S0rz5eNAln0pbZX+A/RbokYZCbGKx4fN4CkhtWhkz6nSJDO+9LGYjRE4d0WPVriJ2JnVubkjt3+PpMg=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
- "resolved": "18.4.0",
- "contentHash": "gZsCHI+zOmZCcKZieIL4Jg14qKD2OGZOmX5DehuIk1EA9BN6Crm0+taXQNEuajOH1G9CCyBxw8VWR4t5tumcng==",
+ "resolved": "18.5.1",
+ "contentHash": "RM+3JNHEoHOCFXzVntUcIiYxzPjzBN0N8wto6HYXi76YyBTZ/3CeRL8U+Pk5zx3AUrOmHxDvKJwGUCdElU9bJg==",
"dependencies": {
- "Microsoft.TestPlatform.ObjectModel": "18.4.0",
+ "Microsoft.TestPlatform.ObjectModel": "18.5.1",
"Newtonsoft.Json": "13.0.3"
}
},
@@ -699,15 +699,15 @@
},
"OpenTelemetry.PersistentStorage.Abstractions": {
"type": "Transitive",
- "resolved": "1.0.2",
- "contentHash": "QuBc6e7M4Skvbc+eTQGSmrcoho7lSkHLT5ngoSsVeeT8OXLpSUETNcuRPW8F5drTPTzzTKQ98C5AhKO/pjpTJg=="
+ "resolved": "1.0.3",
+ "contentHash": "7nSE1uLY0KOHRssg1xQydDZJ/s9zECOzFT/gSDIp1KTxbgd1yziuMTsdBKZ/N1BbpUxfH6Pjq5OMd7lYogwReA=="
},
"OpenTelemetry.PersistentStorage.FileSystem": {
"type": "Transitive",
- "resolved": "1.0.2",
- "contentHash": "ys0l9vL0/wOV9p/iuyDeemjX+d8iH4yjaYA1IcmyQUw0xsxx0I3hQm7tN3FnuRPsmPtrohiLtp31hO1BcrhQ+A==",
+ "resolved": "1.0.3",
+ "contentHash": "3k+yCroJcGBtZIia+0LgzMlBuD0U95BzcWNEGWedusBaRAD8EmkaCPAUwaCtFbb/Mg7HywbIG2zuenANPYFJ0g==",
"dependencies": {
- "OpenTelemetry.PersistentStorage.Abstractions": "1.0.2"
+ "OpenTelemetry.PersistentStorage.Abstractions": "1.0.3"
}
},
"Polly.Core": {
@@ -869,17 +869,17 @@
"dependencies": {
"Ftgo.Auth.Client": "[1.0.0, )",
"Microsoft.AspNetCore.OpenApi": "[10.0.7, )",
- "Microsoft.Identity.Web": "[4.8.0, )",
- "Microsoft.Identity.Web.DownstreamApi": "[4.8.0, )",
+ "Microsoft.Identity.Web": "[4.9.0, )",
+ "Microsoft.Identity.Web.DownstreamApi": "[4.9.0, )",
"Microsoft.IdentityModel.Validators": "[8.17.0, )",
"OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )",
- "Scalar.AspNetCore": "[2.14.4, )"
+ "Scalar.AspNetCore": "[2.14.9, )"
}
},
"ftgo.auth.client": {
"type": "Project",
"dependencies": {
- "Azure.Monitor.OpenTelemetry.Exporter": "[1.7.0, )",
+ "Azure.Monitor.OpenTelemetry.Exporter": "[1.8.0, )",
"Microsoft.Extensions.Hosting": "[10.0.7, )",
"Microsoft.Extensions.Http": "[10.0.7, )",
"Microsoft.Extensions.Http.Resilience": "[10.5.0, )",
@@ -902,13 +902,13 @@
},
"Azure.Monitor.OpenTelemetry.Exporter": {
"type": "CentralTransitive",
- "requested": "[1.7.0, )",
- "resolved": "1.7.0",
- "contentHash": "fexzK+HM06C3tmBL6DLNVAcgutBMY7hQ7tGcdpCyq6HaXvbNz6cFtenrJUAdXM8Y4G+3QmZ6PIAL1hFrVe4Mpw==",
+ "requested": "[1.8.0, )",
+ "resolved": "1.8.0",
+ "contentHash": "JbNUQIK7uZ7Mg2fZzdsjqC/eso3Ag/tMwCqYvGvedyE1fObe6JAwCaCSMZDMrhrAaFn8x8+ZaKJelfcEK5MCcg==",
"dependencies": {
- "Azure.Core": "1.52.0",
- "OpenTelemetry.Extensions.Hosting": "1.15.1",
- "OpenTelemetry.PersistentStorage.FileSystem": "1.0.2"
+ "Azure.Core": "1.54.0",
+ "OpenTelemetry.Extensions.Hosting": "1.15.3",
+ "OpenTelemetry.PersistentStorage.FileSystem": "1.0.3"
}
},
"Azure.Security.KeyVault.Certificates": {
@@ -1066,14 +1066,14 @@
},
"Microsoft.Identity.Web": {
"type": "CentralTransitive",
- "requested": "[4.8.0, )",
- "resolved": "4.8.0",
- "contentHash": "rqMufh64Woj/kc39d9iCb12BTtBj9H40haLUemRAwoqvqSomYPlbMOwt1UD1Y5aMqmc8aYF06U5ATY8Qnw2iFg==",
- "dependencies": {
- "Microsoft.Identity.Web.Certificate": "4.8.0",
- "Microsoft.Identity.Web.Certificateless": "4.8.0",
- "Microsoft.Identity.Web.TokenAcquisition": "4.8.0",
- "Microsoft.Identity.Web.TokenCache": "4.8.0",
+ "requested": "[4.9.0, )",
+ "resolved": "4.9.0",
+ "contentHash": "SshLuFsW0VqCP18O1YUAqPh4KZ6ogu9yYrhlYdGxf/KSRR0wHO1MFlyxh2aOkHdw462XKjbzm36NTspJo1r58g==",
+ "dependencies": {
+ "Microsoft.Identity.Web.Certificate": "4.9.0",
+ "Microsoft.Identity.Web.Certificateless": "4.9.0",
+ "Microsoft.Identity.Web.TokenAcquisition": "4.9.0",
+ "Microsoft.Identity.Web.TokenCache": "4.9.0",
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "8.15.0",
"Microsoft.IdentityModel.Validators": "8.15.0",
"System.IdentityModel.Tokens.Jwt": "8.15.0"
@@ -1081,12 +1081,12 @@
},
"Microsoft.Identity.Web.DownstreamApi": {
"type": "CentralTransitive",
- "requested": "[4.8.0, )",
- "resolved": "4.8.0",
- "contentHash": "MXCBVZO9Oj2hSZkPvEFcRg1/fyKNjqJQzAVxUjdCOzno3rd1BeVhuMddk9aYNJ3r79GD5lA3xJeneXo+SKlKCA==",
+ "requested": "[4.9.0, )",
+ "resolved": "4.9.0",
+ "contentHash": "E9RjjTmAiHR7Z8HDtfEDbioW6NOvoYABGlyIhwOPkMpECTP7M69SwO5DJki+QA0pHpIongdUC856GH/lnqyxSA==",
"dependencies": {
"Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0",
- "Microsoft.Identity.Web.TokenAcquisition": "4.8.0"
+ "Microsoft.Identity.Web.TokenAcquisition": "4.9.0"
}
},
"Microsoft.IdentityModel.JsonWebTokens": {
@@ -1176,9 +1176,9 @@
},
"Scalar.AspNetCore": {
"type": "CentralTransitive",
- "requested": "[2.14.4, )",
- "resolved": "2.14.4",
- "contentHash": "PyuqLRi7JXyTJm/rh+IWCA4Fct1SlZuWrdYiui5hiaBuSY88+roc4eWKfYaE6zrupBIwnEP8pJLe4ifvU7HtZA=="
+ "requested": "[2.14.9, )",
+ "resolved": "2.14.9",
+ "contentHash": "bDc8NjI2JSIAX0C1+02WO11zCJ1SJQhWdMC0s7yi2Nh2atoNRpNRPiYeyIhWSerkc1SkDdJVOeU9QSY6jb0/zQ=="
},
"System.Security.Cryptography.Xml": {
"type": "CentralTransitive",