Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,14 @@ The certificate must be uploaded to the App Registration (Certificates & secrets

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.
8 changes: 8 additions & 0 deletions src/Umbraco.Community.AzureSSO/AzureSSOConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using Umbraco.Community.AzureSSO.EasyAuth;

namespace Umbraco.Community.AzureSSO
{
Expand Down Expand Up @@ -100,6 +101,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) &&
Expand Down
Original file line number Diff line number Diff line change
@@ -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.Identity.Web;
using Microsoft.IdentityModel.JsonWebTokens;

namespace Umbraco.Community.AzureSSO.EasyAuth
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public class EasyAuthAuthenticationHandler : RemoteAuthenticationHandler<EasyAuthAuthenticationOptions>, 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<EasyAuthAuthenticationOptions> 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<HandleRequestResult> 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));
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;

namespace Umbraco.Community.AzureSSO.EasyAuth
{
public class EasyAuthAuthenticationOptions : RemoteAuthenticationOptions
{
/// <summary>
/// Where to send the user after Azure App Service Easy Auth ends the site-wide session on backoffice logout.
/// </summary>
public PathString SignedOutCallbackPath { get; set; }
}
}
9 changes: 9 additions & 0 deletions src/Umbraco.Community.AzureSSO/EasyAuth/EasyAuthDetection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Microsoft.Identity.Web;

namespace Umbraco.Community.AzureSSO.EasyAuth
{
public static class EasyAuthDetection
{
public static bool IsEnabled => AppServicesAuthenticationInformation.IsAppServicesAadAuthenticationEnabled;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Microsoft.Identity.Abstractions;
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;
Expand Down Expand Up @@ -46,6 +47,14 @@ internal static IUmbracoBuilder AddMicrosoftAccountAuthenticationInternal(this I
builder.Services.AddSingleton<IPackageManifestReader, AzureSsoManifestReader>();
#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<string>();
builder.AddBackOfficeExternalLogins(logins =>
{
Expand All @@ -56,6 +65,19 @@ internal static IUmbracoBuilder AddMicrosoftAccountAuthenticationInternal(this I
logins.AddBackOfficeLogin(
backOfficeAuthenticationBuilder =>
{
if (easyAuthActive)
{
backOfficeAuthenticationBuilder.AddRemoteScheme<EasyAuthAuthenticationOptions, EasyAuthAuthenticationHandler>(
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);
Expand Down
Loading