From 166ebd0a8d926f89dbad2360e4d63315cb892784 Mon Sep 17 00:00:00 2001 From: Steve Temple Date: Mon, 27 Jul 2026 14:42:35 +0100 Subject: [PATCH 1/4] Update editorconfig --- src/.editorconfig | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/.editorconfig b/src/.editorconfig index 2162dd5..9c3f60c 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -6,7 +6,7 @@ root = true ############################################################################### # Set default behavior to: # a UTF-8 encoding, -# Unix-style line endings, +# Windows-style line endings, # a newline ending the file, # 2 space indentation, and # trimming of trailing whitespace @@ -80,7 +80,6 @@ dotnet_naming_rule.type_parameters_should_be_pascal_case_prefixed_with_t.symbols [*.cs] - # Define the 'private_fields' symbol group: dotnet_naming_symbols.private_fields.applicable_kinds = field dotnet_naming_symbols.private_fields.applicable_accessibilities = private From 2dd0ecea2968b14a84a3953a66d0a826638e09ff Mon Sep 17 00:00:00 2001 From: Steve Temple Date: Mon, 27 Jul 2026 16:30:07 +0100 Subject: [PATCH 2/4] Add support for easy auth --- ISSUE-57-EASYAUTH-PLAN.md | 83 ++++++++++++ README.md | 11 ++ .../AzureSSOConfiguration.cs | 8 ++ .../EasyAuth/EasyAuthAuthenticationHandler.cs | 127 ++++++++++++++++++ .../EasyAuth/EasyAuthAuthenticationOptions.cs | 13 ++ .../EasyAuth/EasyAuthDetection.cs | 9 ++ ...icrosoftAccountAuthenticationExtensions.cs | 22 +++ 7 files changed, 273 insertions(+) create mode 100644 ISSUE-57-EASYAUTH-PLAN.md create mode 100644 src/Umbraco.Community.AzureSSO/EasyAuth/EasyAuthAuthenticationHandler.cs create mode 100644 src/Umbraco.Community.AzureSSO/EasyAuth/EasyAuthAuthenticationOptions.cs create mode 100644 src/Umbraco.Community.AzureSSO/EasyAuth/EasyAuthDetection.cs diff --git a/ISSUE-57-EASYAUTH-PLAN.md b/ISSUE-57-EASYAUTH-PLAN.md new file mode 100644 index 0000000..c878a36 --- /dev/null +++ b/ISSUE-57-EASYAUTH-PLAN.md @@ -0,0 +1,83 @@ +# Support Azure App Service Easy Auth (Issue #57) + +## Context + +[Issue #57](https://github.com/Gibe/Umbraco.Community.AzureSSO/issues/57) asks for support logging into the Umbraco backoffice when the site is deployed to Azure App Service with **Easy Auth** (platform-level authentication) turned on for the AAD provider. Today, enabling Easy Auth breaks backoffice login entirely. + +I traced the actual root cause by reading the real source of `Microsoft.Identity.Web` (this package's dependency) and `Umbraco-CMS` (the two libraries whose interaction matters here), rather than guessing: + +**Root cause**: `MicrosoftAccountAuthenticationExtensions.cs:58` calls `AddMicrosoftIdentityWebApp(..., cookieScheme: $"{profile.Name}Cookies", openIdConnectScheme: SchemeForBackOffice(profile.Name, ...))` once per enabled profile. Internally, Microsoft.Identity.Web checks `AppServicesAuthenticationInformation.IsAppServicesAadAuthenticationEnabled` (true when the App Service env vars `WEBSITE_AUTH_ENABLED=True` and `WEBSITE_AUTH_DEFAULT_PROVIDER=AzureActiveDirectory|AAD` are present — i.e. Easy Auth is on). When true, it **ignores the `openIdConnectScheme`/`cookieScheme` parameters completely** and instead does: +```csharp +builder.Services.AddAuthentication(AppServicesAuthenticationDefaults.AuthenticationScheme) + .AddAppServicesAuthentication(); +``` +`AddAppServicesAuthentication()` is hardcoded to register its handler under the fixed scheme name `"AppServicesAuthentication"` — never under the per-profile scheme name Umbraco's backoffice `Challenge()`/`SignIn()` actually targets. Result: no handler exists under the expected scheme name → login fails outright (and with >1 enabled profile, the second call throws "Scheme already exists"). + +Even ignoring the scheme-name mismatch, Microsoft.Identity.Web's `AppServicesAuthenticationHandler` only implements `HandleAuthenticateAsync` (a passive per-request header read of `X-MS-TOKEN-AAD-ID-TOKEN`/`X-MS-CLIENT-PRINCIPAL-IDP`) — it has no challenge/redirect/callback behaviour, so it can never be driven through Umbraco's expected "click login button → redirect → provider → callback → auto-link" flow even if the scheme name matched. + +**The fix is not "make Microsoft.Identity.Web's Easy Auth path work"** — it can't, by design, integrate with Umbraco's backoffice external-login machinery. Instead, this package should implement its **own** minimal remote-authentication handler that: +- Is challenged by redirecting to App Service's built-in `/.auth/login/aad` (which performs the real AAD sign-in and sets the App Service session). +- Completes when the browser lands back on our own callback path, at which point App Service is already attaching the `X-MS-TOKEN-AAD-ID-TOKEN` header to every request — we just read it there. + +I confirmed via `Umbraco.Cms.Web.BackOffice.Security.BackOfficeAuthenticationBuilder` (net6-8) and `Umbraco.Cms.Api.Management.Security.BackOfficeAuthenticationBuilder` (net9-10) source — both override **only** `AddRemoteScheme() where TOptions : RemoteAuthenticationOptions`, and that override is what registers the scheme in Umbraco's `BackOfficeExternalLoginProvider` registry (making the login button appear) and force-sets `options.SignInScheme` to Umbraco's backoffice external cookie. Plain `AddScheme` bypasses all of that. So the new handler must derive from `RemoteAuthenticationHandler` and be registered via `AddRemoteScheme`, exactly like OIDC is today — this is fully supported by both backoffice generations, requires **zero changes** to the existing claims-mapping/auto-link code (`MicrosoftAccountBackOfficeExternalLoginProviderOptions.cs`), since that code already only depends on `loginInfo.Principal` (a plain `ClaimsPrincipal`), not on anything OIDC-specific. + +## Design + +### New files — `src/Umbraco.Community.AzureSSO/EasyAuth/` + +**`EasyAuthDetection.cs`** — thin static wrapper: +```csharp +public static class EasyAuthDetection +{ + public static bool IsEnabled => AppServicesAuthenticationInformation.IsAppServicesAadAuthenticationEnabled; +} +``` + +**`EasyAuthAuthenticationOptions.cs`** — `class EasyAuthAuthenticationOptions : RemoteAuthenticationOptions { }` (uses the inherited `CallbackPath`/`SignInScheme`; `SignInScheme` gets force-set by Umbraco's `EnsureBackOfficeScheme` post-configure, same as OIDC today). + +**`EasyAuthAuthenticationHandler.cs`** — `class EasyAuthAuthenticationHandler : RemoteAuthenticationHandler`: +- Constructor: mirror the exact `#if NET8_0_OR_GREATER` / else split already used by Microsoft.Identity.Web's own `AppServicesAuthenticationHandler` (3-arg ctor on net8+, 4-arg with `ISystemClock` on net6/net7), since this project targets both. +- `HandleRemoteAuthenticateAsync()`: if `!EasyAuthDetection.IsEnabled`, return `HandleRequestResult.Fail("Easy Auth is not active on this host")`. Otherwise call `AppServicesAuthenticationInformation.GetUser(Context.Request.Headers)`; if `null`, fail (App Service hasn't attached the headers yet — shouldn't normally happen on this path). If the resulting `ClaimsPrincipal` has no `ClaimTypes.NameIdentifier` claim (the AAD ID token carries `oid`/`sub`, not that URI), add one copied from the `ClaimConstants.Oid` (or `Sub`) claim — ASP.NET Core Identity's `SignInManager.GetExternalLoginInfoAsync()` reads `ClaimTypes.NameIdentifier` as the external provider key, so without this the user can never be linked. Build an `AuthenticationTicket(principal, properties, Scheme.Name)` with `properties.RedirectUri` restored from a `returnUrl` query string param (see challenge, below) and return `HandleRequestResult.Success(ticket)`. +- `HandleChallengeAsync(AuthenticationProperties properties)`: redirect to `/.auth/login/aad?post_login_redirect_uri=`. This is necessary because Easy Auth's own login round-trip has no concept of carrying arbitrary `AuthenticationProperties`/state — we thread the eventual return URL through as a plain query string on our callback path instead. +- Also implement `IAuthenticationSignOutHandler.SignOutAsync(AuthenticationProperties? properties)` (the same interface `OpenIdConnectHandler` implements alongside `RemoteAuthenticationHandler`, confirmed from ASP.NET Core source): redirect to `AppServicesAuthenticationInformation.LogoutUrl` (defaults to `/.auth/logout`) with `post_logout_redirect_uri` pointing at the profile's existing `SignedOutCallbackPath` setting. Umbraco's backoffice logout already calls `SignOutAsync` against every linked external provider's scheme generically (this is how it works for OIDC today via `SignedOutCallbackPath`) — implementing this interface is enough for our scheme to be included in that same generic flow, no new Umbraco-side hook needed. This fully ends the Easy Auth/App-Service session on logout, not just the local Umbraco cookie — otherwise the user would be silently signed back in on their next visit since the App Service session cookie would still be valid. + +No middleware, no `UmbracoPipelineOptions`, no bypassing `ExternalSignInAutoLinkOptions` — once `HandleRequestAsync` (inherited, unmodified, from `RemoteAuthenticationHandler`) matches our `CallbackPath`, it calls `HandleRemoteAuthenticateAsync()`, signs the resulting principal into `SignInScheme` (Umbraco's backoffice external cookie), and redirects — from there Umbraco's existing `SignInManager.GetExternalLoginInfoAsync()` → `OnAutoLinking`/`OnExternalLogin` → `SetGroups`/`SetName` runs completely unchanged. + +### `MicrosoftAccountAuthenticationExtensions.cs` + +Compute `var easyAuthActive = EasyAuthDetection.IsEnabled;` once, before the profile loop. If active and more than one profile is `Enabled`, throw a clear `InvalidOperationException` at startup (Easy Auth is one site-wide identity — it cannot be split across multiple tenants/profiles the way the OIDC flow can). + +Inside the loop, branch: +```csharp +if (easyAuthActive) +{ + backOfficeAuthenticationBuilder.AddRemoteScheme( + SchemeForBackOffice(profile.Name, backOfficeAuthenticationBuilder), + profile.DisplayName ?? "Microsoft Entra ID", + options => options.CallbackPath = profile.Credentials.CallbackPath); +} +else +{ + // existing AddMicrosoftIdentityWebApp(...).EnableTokenAcquisitionToCallDownstreamApi(...).AddTokenCaches(...) — unchanged +} +``` +The Easy Auth branch reuses the existing `Credentials.CallbackPath` setting (it's just our own dispatch path now, not an OIDC redirect_uri) — no new required config field. `EnableTokenAcquisitionToCallDownstreamApi`/token caches are skipped for this branch (not chainable off `AddRemoteScheme`'s plain `AuthenticationBuilder` return type anyway — downstream Graph calls via the App-Service-forwarded access token are a distinct, separate enhancement, out of scope here). + +### `AzureSSOConfiguration.cs` / `AzureSSOCredentials.IsValid()` + +When Easy Auth is active, `ClientId`/`ClientSecret`/`TenantId`/`Domain`/`Instance` are never used, but `AzureSSOCredentials.IsValid()` currently requires all of them non-empty — this would make the health check permanently report "invalid" for a correctly-configured Easy Auth deployment that only sets `CallbackPath`. Relax validation: when `EasyAuthDetection.IsEnabled`, only require `CallbackPath` to be non-empty. + +### No changes needed + +- `MicrosoftAccountBackOfficeExternalLoginProviderOptions.cs` — already claims-shape-agnostic. +- `AzureSsoManifestReader.cs` — already driven purely by `profile.Name`/`DisplayName`/`Icon`/`ButtonStyle`, not by the underlying scheme type. +- `Settings/AzureSSOSettings.cs` — existing shape covers everything needed. + +### Docs + +Add a short "Azure App Service Easy Auth" section to `README.md` under Advanced usage: how it's auto-detected (no config needed beyond the normal `CallbackPath`), that it only supports a single enabled profile, and that `ClientId`/`ClientSecret`/`TenantId`/`Domain`/`Instance` aren't required in that mode. + +## Verification + +- Multi-target build across all TFMs: `dotnet build src/Umbraco.Community.AzureSSO/Umbraco.Community.AzureSSO.csproj` (net6.0/net7.0/net8.0/net9.0/net10.0) to confirm the `#if NET8_0_OR_GREATER` handler constructor split compiles cleanly on every target and `TreatWarningsAsErrors` stays clean. +- No test project exists in this repo currently — verification is build + manual. To smoke-test the Easy Auth path without deploying to real App Service: temporarily set `WEBSITE_AUTH_ENABLED=True` and `WEBSITE_AUTH_DEFAULT_PROVIDER=AzureActiveDirectory` as local environment variables, and inject a fake `X-MS-TOKEN-AAD-ID-TOKEN` + `X-MS-CLIENT-PRINCIPAL-IDP` header via a trivial local test middleware (or a browser extension / curl) on a locally-running Umbraco 13 or 15 site using this package, then hit the backoffice login button and confirm it redirects, "completes", and creates/links the Umbraco user with correct group mapping — this manual check should be run for at least one OLD_BACKOFFICE (net8, Umbraco 13) and one NEW_BACKOFFICE (net9, Umbraco 15) target to confirm both `BackOfficeAuthenticationBuilder` variants behave identically as verified in source. diff --git a/README.md b/README.md index cf7daaa..cc21f23 100644 --- a/README.md +++ b/README.md @@ -36,3 +36,14 @@ In which case you'll need to add AddMicrosoftAccountAuthentication() to your Con LogUnmappedRolesAsWarning When SetGroupsOnLogin is set to true, if LogUnmappedRolesAsWarning is also set to true this will log as warning for unmapped Entra ID groups, where the Entra ID name has a slash \ in it. By design it does not log everything to prevent logging of email addresses and so on. + +### Azure App Service Easy Auth + +If your site is hosted on Azure App Service with [built-in authentication (Easy Auth)](https://learn.microsoft.com/en-us/azure/app-service/overview-authentication-authorization) enabled for the Microsoft/Entra ID provider, this package automatically detects it (via the `WEBSITE_AUTH_ENABLED`/`WEBSITE_AUTH_DEFAULT_PROVIDER` environment variables App Service sets) and signs backoffice users in using the App Service session instead of performing its own OpenID Connect handshake. + +In this mode: + +- `Credentials.ClientId`, `ClientSecret`, `TenantId`, `Domain` and `Instance` aren't used - App Service already owns the AAD app registration and token exchange. Only `CallbackPath` and `SignedOutCallbackPath` are required (they're used as internal dispatch routes, not as OIDC redirect URIs). +- `GroupBindings`, `DefaultGroups`, `SetGroupsOnLogin`, `DenyLocalLogin` and the other backoffice settings all work exactly as they do with the normal OIDC flow. +- Only a single `Enabled` profile is supported - Easy Auth is one site-wide identity, so it can't be split across multiple profiles/tenants. The site will fail to start with a clear error if more than one profile is enabled while Easy Auth is active. +- Logging out of the Umbraco backoffice also ends the App Service Easy Auth session, so users aren't silently signed back in on their next visit. diff --git a/src/Umbraco.Community.AzureSSO/AzureSSOConfiguration.cs b/src/Umbraco.Community.AzureSSO/AzureSSOConfiguration.cs index 8011d79..2be83fb 100644 --- a/src/Umbraco.Community.AzureSSO/AzureSSOConfiguration.cs +++ b/src/Umbraco.Community.AzureSSO/AzureSSOConfiguration.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using Umbraco.Community.AzureSSO.EasyAuth; namespace Umbraco.Community.AzureSSO { @@ -86,6 +87,13 @@ public class AzureSSOCredentials public bool IsValid() { + // When running behind Azure App Service Easy Auth, App Service performs the AAD handshake itself - + // only the callback paths (used as our own dispatch routes, not OIDC redirect URIs) are required. + if (EasyAuthDetection.IsEnabled) + { + return !string.IsNullOrEmpty(CallbackPath) && !string.IsNullOrEmpty(SignedOutCallbackPath); + } + return !string.IsNullOrEmpty(Instance) && !string.IsNullOrEmpty(Domain) && !string.IsNullOrEmpty(TenantId) && diff --git a/src/Umbraco.Community.AzureSSO/EasyAuth/EasyAuthAuthenticationHandler.cs b/src/Umbraco.Community.AzureSSO/EasyAuth/EasyAuthAuthenticationHandler.cs new file mode 100644 index 0000000..3cc3894 --- /dev/null +++ b/src/Umbraco.Community.AzureSSO/EasyAuth/EasyAuthAuthenticationHandler.cs @@ -0,0 +1,127 @@ +using System; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.Identity.Web; + +namespace Umbraco.Community.AzureSSO.EasyAuth +{ + /// + /// Drives the Umbraco backoffice external-login flow (challenge/callback/sign-out) against Azure App Service's + /// built-in Easy Auth session, instead of performing an OpenID Connect handshake ourselves. + /// + /// + /// Microsoft.Identity.Web's own AppServicesAuthenticationInformation.GetUser(...) does exactly this, but it's + /// internal to that assembly, so the token/claims handling below is a small reimplementation of it against the + /// documented, stable Azure App Service authentication headers. + /// + public class EasyAuthAuthenticationHandler : RemoteAuthenticationHandler, IAuthenticationSignOutHandler + { + private const string ReturnUrlParameter = "returnUrl"; + private const string IdTokenHeader = "X-MS-TOKEN-AAD-ID-TOKEN"; + private const string ClientPrincipalIdpHeader = "X-MS-CLIENT-PRINCIPAL-IDP"; + + public EasyAuthAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, +#if NET8_0_OR_GREATER + UrlEncoder encoder) + : base(options, logger, encoder) +#else + UrlEncoder encoder, + ISystemClock clock) + : base(options, logger, encoder, clock) +#endif + { + } + + protected override Task HandleRemoteAuthenticateAsync() + { + if (!EasyAuthDetection.IsEnabled) + { + return Task.FromResult(HandleRequestResult.Fail("Azure App Service Easy Auth is not enabled on this host.")); + } + + var principal = GetUserFromHeaders(); + if (principal == null) + { + return Task.FromResult(HandleRequestResult.Fail("Azure App Service did not attach an authenticated user to this request.")); + } + + EnsureNameIdentifierClaim(principal); + + string? returnUrl = Request.Query[ReturnUrlParameter]; + var properties = new AuthenticationProperties + { + RedirectUri = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl + }; + + var ticket = new AuthenticationTicket(principal, properties, Scheme.Name); + return Task.FromResult(HandleRequestResult.Success(ticket)); + } + + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + var returnUrl = properties.RedirectUri ?? "/umbraco"; + var callbackPath = Options.CallbackPath.HasValue ? Options.CallbackPath.Value! : "/"; + var callbackUrl = BuildAbsoluteUrl(QueryHelpers.AddQueryString(callbackPath, ReturnUrlParameter, returnUrl)); + + Response.Redirect(QueryHelpers.AddQueryString("/.auth/login/aad", "post_login_redirect_uri", callbackUrl)); + return Task.CompletedTask; + } + + public Task SignOutAsync(AuthenticationProperties? properties) + { + var logoutUrl = AppServicesAuthenticationInformation.LogoutUrl ?? "/.auth/logout"; + var returnUrl = properties?.RedirectUri; + if (string.IsNullOrEmpty(returnUrl)) + { + returnUrl = Options.SignedOutCallbackPath.HasValue ? Options.SignedOutCallbackPath.Value! : "/umbraco"; + } + + Response.Redirect(QueryHelpers.AddQueryString(logoutUrl, "post_logout_redirect_uri", BuildAbsoluteUrl(returnUrl))); + return Task.CompletedTask; + } + + private string BuildAbsoluteUrl(string path) + { + return Uri.IsWellFormedUriString(path, UriKind.Absolute) ? path : $"{Request.Scheme}://{Request.Host}{path}"; + } + + private ClaimsPrincipal? GetUserFromHeaders() + { + string? idToken = Context.Request.Headers[IdTokenHeader]; + string? idp = Context.Request.Headers[ClientPrincipalIdpHeader]; + if (string.IsNullOrEmpty(idToken) || string.IsNullOrEmpty(idp)) + { + return null; + } + + var jsonWebToken = new JsonWebToken(idToken); + var identity = new ClaimsIdentity(jsonWebToken.Claims, idp, ClaimConstants.PreferredUserName, ClaimConstants.Roles); + return new ClaimsPrincipal(identity); + } + + private static void EnsureNameIdentifierClaim(ClaimsPrincipal principal) + { + if (principal.Identity is not ClaimsIdentity identity || identity.HasClaim(c => c.Type == ClaimTypes.NameIdentifier)) + { + return; + } + + var providerKey = principal.FindFirst(ClaimConstants.Oid)?.Value + ?? principal.FindFirst(ClaimConstants.ObjectId)?.Value + ?? principal.FindFirst(ClaimConstants.Sub)?.Value; + + if (!string.IsNullOrEmpty(providerKey)) + { + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, providerKey)); + } + } + } +} diff --git a/src/Umbraco.Community.AzureSSO/EasyAuth/EasyAuthAuthenticationOptions.cs b/src/Umbraco.Community.AzureSSO/EasyAuth/EasyAuthAuthenticationOptions.cs new file mode 100644 index 0000000..7e1e219 --- /dev/null +++ b/src/Umbraco.Community.AzureSSO/EasyAuth/EasyAuthAuthenticationOptions.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace Umbraco.Community.AzureSSO.EasyAuth +{ + public class EasyAuthAuthenticationOptions : RemoteAuthenticationOptions + { + /// + /// Where to send the user after Azure App Service Easy Auth ends the site-wide session on backoffice logout. + /// + public PathString SignedOutCallbackPath { get; set; } + } +} diff --git a/src/Umbraco.Community.AzureSSO/EasyAuth/EasyAuthDetection.cs b/src/Umbraco.Community.AzureSSO/EasyAuth/EasyAuthDetection.cs new file mode 100644 index 0000000..d89b7e4 --- /dev/null +++ b/src/Umbraco.Community.AzureSSO/EasyAuth/EasyAuthDetection.cs @@ -0,0 +1,9 @@ +using Microsoft.Identity.Web; + +namespace Umbraco.Community.AzureSSO.EasyAuth +{ + public static class EasyAuthDetection + { + public static bool IsEnabled => AppServicesAuthenticationInformation.IsAppServicesAadAuthenticationEnabled; + } +} diff --git a/src/Umbraco.Community.AzureSSO/MicrosoftAccountAuthenticationExtensions.cs b/src/Umbraco.Community.AzureSSO/MicrosoftAccountAuthenticationExtensions.cs index 3c31e8a..9154bb9 100644 --- a/src/Umbraco.Community.AzureSSO/MicrosoftAccountAuthenticationExtensions.cs +++ b/src/Umbraco.Community.AzureSSO/MicrosoftAccountAuthenticationExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.Identity.Client; using Microsoft.Identity.Web; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Community.AzureSSO.EasyAuth; using Umbraco.Community.AzureSSO.Settings; using Umbraco.Extensions; using System.Linq; @@ -45,6 +46,14 @@ internal static IUmbracoBuilder AddMicrosoftAccountAuthenticationInternal(this I builder.Services.AddSingleton(); #endif + var easyAuthActive = EasyAuthDetection.IsEnabled; + if (easyAuthActive && settings.Profiles.Count(x => x.Enabled) > 1) + { + throw new InvalidOperationException( + "AzureSSO: Azure App Service Easy Auth was detected, but more than one AzureSSO profile is Enabled. " + + "Easy Auth is a single site-wide identity and cannot be split across multiple profiles/tenants - disable all but one profile."); + } + var initialScopes = Array.Empty(); builder.AddBackOfficeExternalLogins(logins => { @@ -55,6 +64,19 @@ internal static IUmbracoBuilder AddMicrosoftAccountAuthenticationInternal(this I logins.AddBackOfficeLogin( backOfficeAuthenticationBuilder => { + if (easyAuthActive) + { + backOfficeAuthenticationBuilder.AddRemoteScheme( + SchemeForBackOffice(profile.Name, backOfficeAuthenticationBuilder) ?? String.Empty, + profile.DisplayName ?? "Microsoft Entra ID", + options => + { + options.CallbackPath = profile.Credentials.CallbackPath; + options.SignedOutCallbackPath = profile.Credentials.SignedOutCallbackPath; + }); + return; + } + backOfficeAuthenticationBuilder.AddMicrosoftIdentityWebApp(options => { CopyCredentials(options, profile.Credentials); From 7b33ec211d496aa2d8f5e8d41007543f3702d681 Mon Sep 17 00:00:00 2001 From: Steve Temple Date: Thu, 30 Jul 2026 14:56:05 +0100 Subject: [PATCH 3/4] dotnet format --- .../EasyAuth/EasyAuthAuthenticationHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Community.AzureSSO/EasyAuth/EasyAuthAuthenticationHandler.cs b/src/Umbraco.Community.AzureSSO/EasyAuth/EasyAuthAuthenticationHandler.cs index 3cc3894..e4cb259 100644 --- a/src/Umbraco.Community.AzureSSO/EasyAuth/EasyAuthAuthenticationHandler.cs +++ b/src/Umbraco.Community.AzureSSO/EasyAuth/EasyAuthAuthenticationHandler.cs @@ -6,8 +6,8 @@ using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.Identity.Web; +using Microsoft.IdentityModel.JsonWebTokens; namespace Umbraco.Community.AzureSSO.EasyAuth { From 1fbb6bcf7a5856a5949b1aed27fa488de12420ec Mon Sep 17 00:00:00 2001 From: Steve Temple Date: Thu, 30 Jul 2026 14:58:37 +0100 Subject: [PATCH 4/4] Remove claude plan --- ISSUE-57-EASYAUTH-PLAN.md | 83 --------------------------------------- 1 file changed, 83 deletions(-) delete mode 100644 ISSUE-57-EASYAUTH-PLAN.md diff --git a/ISSUE-57-EASYAUTH-PLAN.md b/ISSUE-57-EASYAUTH-PLAN.md deleted file mode 100644 index c878a36..0000000 --- a/ISSUE-57-EASYAUTH-PLAN.md +++ /dev/null @@ -1,83 +0,0 @@ -# Support Azure App Service Easy Auth (Issue #57) - -## Context - -[Issue #57](https://github.com/Gibe/Umbraco.Community.AzureSSO/issues/57) asks for support logging into the Umbraco backoffice when the site is deployed to Azure App Service with **Easy Auth** (platform-level authentication) turned on for the AAD provider. Today, enabling Easy Auth breaks backoffice login entirely. - -I traced the actual root cause by reading the real source of `Microsoft.Identity.Web` (this package's dependency) and `Umbraco-CMS` (the two libraries whose interaction matters here), rather than guessing: - -**Root cause**: `MicrosoftAccountAuthenticationExtensions.cs:58` calls `AddMicrosoftIdentityWebApp(..., cookieScheme: $"{profile.Name}Cookies", openIdConnectScheme: SchemeForBackOffice(profile.Name, ...))` once per enabled profile. Internally, Microsoft.Identity.Web checks `AppServicesAuthenticationInformation.IsAppServicesAadAuthenticationEnabled` (true when the App Service env vars `WEBSITE_AUTH_ENABLED=True` and `WEBSITE_AUTH_DEFAULT_PROVIDER=AzureActiveDirectory|AAD` are present — i.e. Easy Auth is on). When true, it **ignores the `openIdConnectScheme`/`cookieScheme` parameters completely** and instead does: -```csharp -builder.Services.AddAuthentication(AppServicesAuthenticationDefaults.AuthenticationScheme) - .AddAppServicesAuthentication(); -``` -`AddAppServicesAuthentication()` is hardcoded to register its handler under the fixed scheme name `"AppServicesAuthentication"` — never under the per-profile scheme name Umbraco's backoffice `Challenge()`/`SignIn()` actually targets. Result: no handler exists under the expected scheme name → login fails outright (and with >1 enabled profile, the second call throws "Scheme already exists"). - -Even ignoring the scheme-name mismatch, Microsoft.Identity.Web's `AppServicesAuthenticationHandler` only implements `HandleAuthenticateAsync` (a passive per-request header read of `X-MS-TOKEN-AAD-ID-TOKEN`/`X-MS-CLIENT-PRINCIPAL-IDP`) — it has no challenge/redirect/callback behaviour, so it can never be driven through Umbraco's expected "click login button → redirect → provider → callback → auto-link" flow even if the scheme name matched. - -**The fix is not "make Microsoft.Identity.Web's Easy Auth path work"** — it can't, by design, integrate with Umbraco's backoffice external-login machinery. Instead, this package should implement its **own** minimal remote-authentication handler that: -- Is challenged by redirecting to App Service's built-in `/.auth/login/aad` (which performs the real AAD sign-in and sets the App Service session). -- Completes when the browser lands back on our own callback path, at which point App Service is already attaching the `X-MS-TOKEN-AAD-ID-TOKEN` header to every request — we just read it there. - -I confirmed via `Umbraco.Cms.Web.BackOffice.Security.BackOfficeAuthenticationBuilder` (net6-8) and `Umbraco.Cms.Api.Management.Security.BackOfficeAuthenticationBuilder` (net9-10) source — both override **only** `AddRemoteScheme() where TOptions : RemoteAuthenticationOptions`, and that override is what registers the scheme in Umbraco's `BackOfficeExternalLoginProvider` registry (making the login button appear) and force-sets `options.SignInScheme` to Umbraco's backoffice external cookie. Plain `AddScheme` bypasses all of that. So the new handler must derive from `RemoteAuthenticationHandler` and be registered via `AddRemoteScheme`, exactly like OIDC is today — this is fully supported by both backoffice generations, requires **zero changes** to the existing claims-mapping/auto-link code (`MicrosoftAccountBackOfficeExternalLoginProviderOptions.cs`), since that code already only depends on `loginInfo.Principal` (a plain `ClaimsPrincipal`), not on anything OIDC-specific. - -## Design - -### New files — `src/Umbraco.Community.AzureSSO/EasyAuth/` - -**`EasyAuthDetection.cs`** — thin static wrapper: -```csharp -public static class EasyAuthDetection -{ - public static bool IsEnabled => AppServicesAuthenticationInformation.IsAppServicesAadAuthenticationEnabled; -} -``` - -**`EasyAuthAuthenticationOptions.cs`** — `class EasyAuthAuthenticationOptions : RemoteAuthenticationOptions { }` (uses the inherited `CallbackPath`/`SignInScheme`; `SignInScheme` gets force-set by Umbraco's `EnsureBackOfficeScheme` post-configure, same as OIDC today). - -**`EasyAuthAuthenticationHandler.cs`** — `class EasyAuthAuthenticationHandler : RemoteAuthenticationHandler`: -- Constructor: mirror the exact `#if NET8_0_OR_GREATER` / else split already used by Microsoft.Identity.Web's own `AppServicesAuthenticationHandler` (3-arg ctor on net8+, 4-arg with `ISystemClock` on net6/net7), since this project targets both. -- `HandleRemoteAuthenticateAsync()`: if `!EasyAuthDetection.IsEnabled`, return `HandleRequestResult.Fail("Easy Auth is not active on this host")`. Otherwise call `AppServicesAuthenticationInformation.GetUser(Context.Request.Headers)`; if `null`, fail (App Service hasn't attached the headers yet — shouldn't normally happen on this path). If the resulting `ClaimsPrincipal` has no `ClaimTypes.NameIdentifier` claim (the AAD ID token carries `oid`/`sub`, not that URI), add one copied from the `ClaimConstants.Oid` (or `Sub`) claim — ASP.NET Core Identity's `SignInManager.GetExternalLoginInfoAsync()` reads `ClaimTypes.NameIdentifier` as the external provider key, so without this the user can never be linked. Build an `AuthenticationTicket(principal, properties, Scheme.Name)` with `properties.RedirectUri` restored from a `returnUrl` query string param (see challenge, below) and return `HandleRequestResult.Success(ticket)`. -- `HandleChallengeAsync(AuthenticationProperties properties)`: redirect to `/.auth/login/aad?post_login_redirect_uri=`. This is necessary because Easy Auth's own login round-trip has no concept of carrying arbitrary `AuthenticationProperties`/state — we thread the eventual return URL through as a plain query string on our callback path instead. -- Also implement `IAuthenticationSignOutHandler.SignOutAsync(AuthenticationProperties? properties)` (the same interface `OpenIdConnectHandler` implements alongside `RemoteAuthenticationHandler`, confirmed from ASP.NET Core source): redirect to `AppServicesAuthenticationInformation.LogoutUrl` (defaults to `/.auth/logout`) with `post_logout_redirect_uri` pointing at the profile's existing `SignedOutCallbackPath` setting. Umbraco's backoffice logout already calls `SignOutAsync` against every linked external provider's scheme generically (this is how it works for OIDC today via `SignedOutCallbackPath`) — implementing this interface is enough for our scheme to be included in that same generic flow, no new Umbraco-side hook needed. This fully ends the Easy Auth/App-Service session on logout, not just the local Umbraco cookie — otherwise the user would be silently signed back in on their next visit since the App Service session cookie would still be valid. - -No middleware, no `UmbracoPipelineOptions`, no bypassing `ExternalSignInAutoLinkOptions` — once `HandleRequestAsync` (inherited, unmodified, from `RemoteAuthenticationHandler`) matches our `CallbackPath`, it calls `HandleRemoteAuthenticateAsync()`, signs the resulting principal into `SignInScheme` (Umbraco's backoffice external cookie), and redirects — from there Umbraco's existing `SignInManager.GetExternalLoginInfoAsync()` → `OnAutoLinking`/`OnExternalLogin` → `SetGroups`/`SetName` runs completely unchanged. - -### `MicrosoftAccountAuthenticationExtensions.cs` - -Compute `var easyAuthActive = EasyAuthDetection.IsEnabled;` once, before the profile loop. If active and more than one profile is `Enabled`, throw a clear `InvalidOperationException` at startup (Easy Auth is one site-wide identity — it cannot be split across multiple tenants/profiles the way the OIDC flow can). - -Inside the loop, branch: -```csharp -if (easyAuthActive) -{ - backOfficeAuthenticationBuilder.AddRemoteScheme( - SchemeForBackOffice(profile.Name, backOfficeAuthenticationBuilder), - profile.DisplayName ?? "Microsoft Entra ID", - options => options.CallbackPath = profile.Credentials.CallbackPath); -} -else -{ - // existing AddMicrosoftIdentityWebApp(...).EnableTokenAcquisitionToCallDownstreamApi(...).AddTokenCaches(...) — unchanged -} -``` -The Easy Auth branch reuses the existing `Credentials.CallbackPath` setting (it's just our own dispatch path now, not an OIDC redirect_uri) — no new required config field. `EnableTokenAcquisitionToCallDownstreamApi`/token caches are skipped for this branch (not chainable off `AddRemoteScheme`'s plain `AuthenticationBuilder` return type anyway — downstream Graph calls via the App-Service-forwarded access token are a distinct, separate enhancement, out of scope here). - -### `AzureSSOConfiguration.cs` / `AzureSSOCredentials.IsValid()` - -When Easy Auth is active, `ClientId`/`ClientSecret`/`TenantId`/`Domain`/`Instance` are never used, but `AzureSSOCredentials.IsValid()` currently requires all of them non-empty — this would make the health check permanently report "invalid" for a correctly-configured Easy Auth deployment that only sets `CallbackPath`. Relax validation: when `EasyAuthDetection.IsEnabled`, only require `CallbackPath` to be non-empty. - -### No changes needed - -- `MicrosoftAccountBackOfficeExternalLoginProviderOptions.cs` — already claims-shape-agnostic. -- `AzureSsoManifestReader.cs` — already driven purely by `profile.Name`/`DisplayName`/`Icon`/`ButtonStyle`, not by the underlying scheme type. -- `Settings/AzureSSOSettings.cs` — existing shape covers everything needed. - -### Docs - -Add a short "Azure App Service Easy Auth" section to `README.md` under Advanced usage: how it's auto-detected (no config needed beyond the normal `CallbackPath`), that it only supports a single enabled profile, and that `ClientId`/`ClientSecret`/`TenantId`/`Domain`/`Instance` aren't required in that mode. - -## Verification - -- Multi-target build across all TFMs: `dotnet build src/Umbraco.Community.AzureSSO/Umbraco.Community.AzureSSO.csproj` (net6.0/net7.0/net8.0/net9.0/net10.0) to confirm the `#if NET8_0_OR_GREATER` handler constructor split compiles cleanly on every target and `TreatWarningsAsErrors` stays clean. -- No test project exists in this repo currently — verification is build + manual. To smoke-test the Easy Auth path without deploying to real App Service: temporarily set `WEBSITE_AUTH_ENABLED=True` and `WEBSITE_AUTH_DEFAULT_PROVIDER=AzureActiveDirectory` as local environment variables, and inject a fake `X-MS-TOKEN-AAD-ID-TOKEN` + `X-MS-CLIENT-PRINCIPAL-IDP` header via a trivial local test middleware (or a browser extension / curl) on a locally-running Umbraco 13 or 15 site using this package, then hit the backoffice login button and confirm it redirects, "completes", and creates/links the Umbraco user with correct group mapping — this manual check should be run for at least one OLD_BACKOFFICE (net8, Umbraco 13) and one NEW_BACKOFFICE (net9, Umbraco 15) target to confirm both `BackOfficeAuthenticationBuilder` variants behave identically as verified in source.