From b570517bdd8e5137de55a72d2b6ddbc3ecfc3ea5 Mon Sep 17 00:00:00 2001 From: Fabricio Ruch Date: Mon, 3 Aug 2026 15:11:41 +0200 Subject: [PATCH 1/2] Document LTS-only TFM uplifts instead of referring to net11. Package majors still track the target framework major for supported LTS lines. Co-authored-by: Cursor --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b63cb19..b07f450 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ dotnet add package WebGate.Azure.FunctionsUtils The **NuGet package major version matches the .NET target framework major version**. - `net10.0` → package version `10.x.x` -- A future uplift to `net11.0` would start at package version `11.0.0` +- Future uplifts follow **.NET LTS** releases only (e.g. the next LTS after .NET 10); the package major then matches that TFM major. Within a major line, use minor/patch for library changes that stay on the same TFM. From 436d7a417beeb67e46550a0fffb84d6731ddc12c Mon Sep 17 00:00:00 2001 From: Fabricio Ruch Date: Tue, 4 Aug 2026 09:50:36 +0200 Subject: [PATCH 2/2] Use FUNCTIONS_UTILS_LOCAL_DEVELOPMENT flag for local Bearer JWT auth. Co-authored-by: Cursor --- .../AzureFunctionsUtils.csproj | 2 +- .../Internal/AzureFunctionsEnvironment.cs | 14 ++-- .../BearerJwtClaimsPrincipalParser.cs | 23 +++++- README.md | 71 ++++++++----------- 4 files changed, 58 insertions(+), 52 deletions(-) diff --git a/AzureFunctionsUtils/AzureFunctionsUtils.csproj b/AzureFunctionsUtils/AzureFunctionsUtils.csproj index 5729005..2ffbd08 100644 --- a/AzureFunctionsUtils/AzureFunctionsUtils.csproj +++ b/AzureFunctionsUtils/AzureFunctionsUtils.csproj @@ -7,7 +7,7 @@ WebGate.Azure.FunctionsUtils - 10.0.0 + 10.0.1 WebGate Consulting AG WebGate Consulting AG Apache-2.0 diff --git a/AzureFunctionsUtils/FunctionRunContext/Internal/AzureFunctionsEnvironment.cs b/AzureFunctionsUtils/FunctionRunContext/Internal/AzureFunctionsEnvironment.cs index 9e47a67..7f296b7 100644 --- a/AzureFunctionsUtils/FunctionRunContext/Internal/AzureFunctionsEnvironment.cs +++ b/AzureFunctionsUtils/FunctionRunContext/Internal/AzureFunctionsEnvironment.cs @@ -2,18 +2,16 @@ namespace WebGate.Azure.FunctionsUtils.Internal; internal static class AzureFunctionsEnvironment { - private const string AZURE_FUNCTIONS_ENVIRONMENT_VARIABLE = "AZURE_FUNCTIONS_ENVIRONMENT"; - private const string LOCAL_DEVELOPMENT_ENVIRONMENT_NAME = "LocalDevelopment"; + private const string LOCAL_DEVELOPMENT_VARIABLE = "FUNCTIONS_UTILS_LOCAL_DEVELOPMENT"; /// - /// True when functions run on a developer machine. - /// Azure cloud environments use other values (e.g. Development, Staging, Production). + /// True when FUNCTIONS_UTILS_LOCAL_DEVELOPMENT is exactly true (case-insensitive). + /// Set only in local.settings.json — never in Azure. + /// Enables and Bearer JWT validation. /// public static bool IsLocalDevelopment() { - return string.Equals( - Environment.GetEnvironmentVariable(AZURE_FUNCTIONS_ENVIRONMENT_VARIABLE, EnvironmentVariableTarget.Process), - LOCAL_DEVELOPMENT_ENVIRONMENT_NAME, - StringComparison.OrdinalIgnoreCase); + var value = Environment.GetEnvironmentVariable(LOCAL_DEVELOPMENT_VARIABLE, EnvironmentVariableTarget.Process); + return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); } } diff --git a/AzureFunctionsUtils/FunctionRunContext/Internal/BearerJwtClaimsPrincipalParser.cs b/AzureFunctionsUtils/FunctionRunContext/Internal/BearerJwtClaimsPrincipalParser.cs index daa1ac0..10fdad0 100644 --- a/AzureFunctionsUtils/FunctionRunContext/Internal/BearerJwtClaimsPrincipalParser.cs +++ b/AzureFunctionsUtils/FunctionRunContext/Internal/BearerJwtClaimsPrincipalParser.cs @@ -20,6 +20,11 @@ internal static class BearerJwtClaimsPrincipalParser public static ClaimsPrincipal? TryParse(HttpRequest request) { + if (!AzureFunctionsEnvironment.IsLocalDevelopment()) + { + return null; + } + if (!TryGetBearerToken(request, out var token)) { return null; @@ -44,7 +49,7 @@ internal static class BearerJwtClaimsPrincipalParser $"https://sts.windows.net/{tenantId}/" ], ValidateAudience = true, - ValidAudiences = [audience], + ValidAudiences = BuildValidAudiences(audience), ValidateIssuerSigningKey = true, IssuerSigningKeys = openIdConfig.SigningKeys, ValidateLifetime = true, @@ -85,6 +90,22 @@ private static bool TryGetBearerToken(HttpRequest request, out string token) return token.Length > 0; } + private static string[] BuildValidAudiences(string audience) + { + var audiences = new HashSet(StringComparer.OrdinalIgnoreCase) { audience }; + + if (audience.StartsWith("api://", StringComparison.OrdinalIgnoreCase)) + { + audiences.Add(audience["api://".Length..]); + } + else + { + audiences.Add($"api://{audience}"); + } + + return [.. audiences]; + } + private static OpenIdConnectConfiguration GetOpenIdConnectConfiguration(string tenantId) { var metadataAddress = diff --git a/README.md b/README.md index b07f450..822bfa6 100644 --- a/README.md +++ b/README.md @@ -25,17 +25,28 @@ Within a major line, use minor/patch for library changes that stay on the same T --- -## Environments +## Local development flag -`AZURE_FUNCTIONS_ENVIRONMENT` distinguishes **where** the functions run: +Local vs cloud is controlled by one setting: -| Value | Meaning | Auth behavior | `IsDev()` | -|---|---|---|---| -| `LocalDevelopment` | Functions on a developer machine | Easy Auth if present, otherwise validated Bearer JWT | `true` | -| `Development` | Azure cloud **DEV** environment | Easy Auth only | `false` | -| `Staging` / `Production` / other | Azure cloud environments | Easy Auth only | `false` | +| Variable | Value | Effect | +|---|---|---| +| `FUNCTIONS_UTILS_LOCAL_DEVELOPMENT` | `true` | `IsDev() == true`, Bearer JWT allowed | +| unset / other | — | Cloud mode: Easy Auth only, no Bearer | -Important: Azure’s usual `Development` value means the cloud DEV slot, **not** local execution. For local runs set `AZURE_FUNCTIONS_ENVIRONMENT=LocalDevelopment` in `local.settings.json`. +Set it **only** in `local.settings.json`. Do not set it in Azure App Settings. + +```json +{ + "Values": { + "FUNCTIONS_UTILS_LOCAL_DEVELOPMENT": "true", + "FUNCTIONS_UTILS_AAD_TENANT_ID": "{tenant-id}", + "FUNCTIONS_UTILS_AAD_AUDIENCE": "api://{api-app-id}" + } +} +``` + +Do not use `AZURE_FUNCTIONS_ENVIRONMENT` for this — Core Tools overwrites it to `Development`. --- @@ -47,45 +58,21 @@ Headers alone are not trusted. Protect cloud Function Apps like this: Internet → Azure API Management (validate-jwt) → Function App (Easy Auth Required) → UserFunctionRunContext ``` -1. **Easy Auth required (cloud environments)** - On the Function App, enable App Service Authentication / Easy Auth and set unauthenticated requests to **Return HTTP 401**. Easy Auth strips client-supplied `x-ms-client-principal*` headers and replaces them after a successful Entra ID login. - Whenever the environment is **not** `LocalDevelopment`, this library accepts **only** Easy Auth (`x-ms-client-principal`). Bearer fallback is disabled. +1. **Easy Auth required (cloud)** + Enable App Service Authentication / Easy Auth and set unauthenticated requests to **Return HTTP 401**. Without `FUNCTIONS_UTILS_LOCAL_DEVELOPMENT`, this library accepts **only** Easy Auth (`x-ms-client-principal`). 2. **Do not expose the Function App publicly** - Prefer private networking and put **Azure API Management** in front. Use an APIM `validate-jwt` policy against Entra ID (issuer, audience, signing keys) before traffic reaches the Function App. - - Example APIM fragment: - - ```xml - - - - {api-app-id-or-uri} - - - ``` - -3. **Bearer JWT cryptographic validation (`LocalDevelopment` only)** - On a developer machine (`AZURE_FUNCTIONS_ENVIRONMENT=LocalDevelopment`), if Easy Auth is absent, the library may fall back to `Authorization: Bearer`. That token is validated (signature, issuer, audience, lifetime) via Entra OpenID metadata — decode-only is not used. - - Example `local.settings.json`: - - ```json - { - "Values": { - "AZURE_FUNCTIONS_ENVIRONMENT": "LocalDevelopment", - "FUNCTIONS_UTILS_AAD_TENANT_ID": "{tenant-id}", - "FUNCTIONS_UTILS_AAD_AUDIENCE": "{api-app-id-or-uri}" - } - } - ``` + Prefer private networking and put **Azure API Management** in front with `validate-jwt`. + +3. **Bearer JWT (local only)** + When `FUNCTIONS_UTILS_LOCAL_DEVELOPMENT=true` and Easy Auth is absent, `Authorization: Bearer` is validated (signature, issuer, audience, lifetime) via Entra OpenID metadata. | Variable | Purpose | |---|---| | `FUNCTIONS_UTILS_AAD_TENANT_ID` | Entra tenant ID | - | `FUNCTIONS_UTILS_AAD_AUDIENCE` | API audience (app ID or Application ID URI) | + | `FUNCTIONS_UTILS_AAD_AUDIENCE` | API audience (GUID or `api://{app-id}`; both accepted) | - If either variable is missing, Bearer fallback fails closed (`IsAuthenticated() == false`). + If tenant/audience are missing, Bearer fails closed. The SPA must send an API access token (Expose an API), e.g. MSAL scope `api://{clientId}/access_as_user`. --- @@ -107,14 +94,14 @@ Shared API (`IFunctionRunContext`): - `GetEnvironmentVariable(name)` `UserFunctionRunContext` also exposes `GetClaimsPrincipal()`. -`IsDev()` follows the [Environments](#environments) table (`LocalDevelopment` only). +`IsDev()` is `true` when `FUNCTIONS_UTILS_LOCAL_DEVELOPMENT` is enabled. ### UserFunctionRunContext Identity is resolved in this order: 1. Azure Easy Auth payload from `x-ms-client-principal` (all environments) -2. Validated JWT from `Authorization: Bearer ` (`LocalDevelopment` only; requires tenant/audience env vars) +2. Validated JWT from `Authorization: Bearer ` (only when `FUNCTIONS_UTILS_LOCAL_DEVELOPMENT=true`) Claim mapping: