Skip to content

Commit

Permalink
Add support for multiple profiles (#33)
Browse files Browse the repository at this point in the history
* Add support for multiple profiles

* Tidy up and update JSON schema

* Tidy up and configuration validation

* Added extra validation healthcheck and tidy up

* Todos for config validation and NotImplmentedException for unused options method
  • Loading branch information
stevetemple authored Aug 6, 2024
1 parent 53e788a commit 71b7b75
Show file tree
Hide file tree
Showing 8 changed files with 357 additions and 97 deletions.
92 changes: 81 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,26 @@ You'll need to configure the package by adding the following section to the root
```
"AzureSSO": {
"Credentials": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "<domain>",
"TenantId": "<tenantId>",
"ClientId": "<clientId>",
"CallbackPath": "/umbraco-microsoft-signin/",
"SignedOutCallbackPath ": "/umbraco-microsoft-signout/",
"ClientSecret": "<clientSecret>"
"Instance": "https://login.microsoftonline.com/",
"Domain": "<domain>",
"TenantId": "<tenantId>",
"ClientId": "<clientId>",
"CallbackPath": "/umbraco-microsoft-signin/",
"SignedOutCallbackPath ": "/umbraco-microsoft-signout/",
"ClientSecret": "<clientSecret>"
},
"DisplayName": "Azure AD",
"DenyLocalLogin": true,
"AutoRedirectLoginToExternalProvider": true,
"TokenCacheType": "InMemory",
"GroupBindings": {
"<AD group>": "<umbraco group>",
"<another AD group>": "<umbraco group>"
"<AD group>": "<umbraco group>",
"<another AD group>": "<umbraco group>"
},
"SetGroupsOnLogin": true,
"DefaultGroups": [
"editor"
],
"editor"
],
"Icon": "fa fa-lock",
"ButtonStyle": "btn-microsoft",
},
Expand Down Expand Up @@ -82,7 +82,77 @@ If you are having problems with NET BIOS group names, you can set the groups cla

You can now use the guid format for the Group Id like: `"xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxx": "admin", "44a38651-xxxx-4c92-b1b6-51cf26ff9bab": "editor"`

# Advanced usage

## Multiple tenants

If you'd like to use more than one tenant, or app registration then you can change the configuration to use profiles, see below.
This could be used for having one SSO option for agency users and another for client users.

```
"AzureSSO": {
"Profiles": [
{
"Name": "InternalAccount",
"Credentials": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "<domain>",
"TenantId": "<tenantId>",
"ClientId": "<clientId>",
"CallbackPath": "/umbraco-microsoft-signin/",
"SignedOutCallbackPath ": "/umbraco-microsoft-signout/",
"ClientSecret": "<clientSecret>"
},
"DisplayName": "My AD",
"DenyLocalLogin": true,
"AutoRedirectLoginToExternalProvider": false,
"TokenCacheType": "InMemory",
"GroupBindings": {
"<AD group>": "<umbraco group>",
"<another AD group>": "<umbraco group>"
},
"SetGroupsOnLogin": true,
"DefaultGroups": [
"editor"
],
"Icon": "fa fa-lock",
"ButtonStyle": "btn-microsoft",
},
{
"Name": "AlternateAccount",
"Credentials": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "<domain>",
"TenantId": "<tenantId>",
"ClientId": "<clientId>",
"CallbackPath": "/umbraco-microsoft-alt-signin/",
"SignedOutCallbackPath ": "/umbraco-microsoft-alt-signout/",
"ClientSecret": "<clientSecret>"
},
"DisplayName": "My Client AD",
"DenyLocalLogin": true,
"AutoRedirectLoginToExternalProvider": false,
"TokenCacheType": "InMemory",
"GroupBindings": {
"<AD group>": "<umbraco group>",
"<another AD group>": "<umbraco group>"
},
"SetGroupsOnLogin": true,
"DefaultGroups": [
"editor"
],
"Icon": "fa fa-lock",
"ButtonStyle": "btn-microsoft",
},
]
},
```

Each ClientId and ClientSecret should be different, also TentantId and domain should be different if using a different tenant.

Please ensure that the CallbackPath and SignedOutCallbackPath are different for each profile.

Note you cannot use AutoRedirectLoginToExternalProvider if you'd like 2 profiles.



79 changes: 70 additions & 9 deletions src/Umbraco.Community.AzureSSO/AzureSSOConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.IdentityModel.Tokens;

namespace Umbraco.Community.AzureSSO
{
public class AzureSSOConfiguration
{
public const string AzureSsoSectionName = "AzureSSO";
public const string AzureSsoCredentialSectionName = "AzureSSO:Credentials";

public AzureSSOConfiguration()
{
GroupBindings = new Dictionary<string, string>();
}
public string? Name { get; set; }

public string? DisplayName { get; set; }

public string? ButtonStyle { get; set; }

public string? Icon { get; set; }

public Dictionary<string, string> GroupBindings { get; set; }
public Dictionary<string, string> GroupBindings { get; set; } = new();

public bool? SetGroupsOnLogin { get; set; }

Expand All @@ -27,7 +26,69 @@ public AzureSSOConfiguration()
public bool? DenyLocalLogin { get; set; }

public TokenCacheType TokenCacheType { get; set; } = TokenCacheType.InMemory;

public bool? AutoRedirectLoginToExternalProvider { get; set; }
}

public bool? AutoRedirectLoginToExternalProvider { get; set; }

public AzureSSOCredentials? Credentials { get; set; }

public AzureSSOConfiguration[]? Profiles { get; set; }

public bool IsValid()
{
// TODO : Make this give or log specific feedback before we do anything like prevent booting if misconfigured
// and to make it more useful for the HealthCheck
return (Profiles != null && Profiles.Any() && AllValuesEmpty() && AllProfilesUnique() && AllProfilesHaveName()) ||
(Profiles.IsNullOrEmpty() && Credentials != null && Credentials.IsValid());
}

public bool AllValuesEmpty()
{
return string.IsNullOrEmpty(Name) &&
string.IsNullOrEmpty(DisplayName) &&
string.IsNullOrEmpty(ButtonStyle) &&
string.IsNullOrEmpty(Icon) &&
!GroupBindings.Any() &&
SetGroupsOnLogin == null &&
(DefaultGroups == null || !DefaultGroups.Any()) &&
DenyLocalLogin == null &&
AutoRedirectLoginToExternalProvider == null &&
Credentials == null;
}

public bool AllProfilesHaveName()
{
return Profiles != null && Profiles.All(x => !string.IsNullOrEmpty(x.Name));
}

public bool AllProfilesUnique()
{
return Profiles != null &&
Profiles.Select(x => x.Name).Distinct().Count() == Profiles.Count() &&
Profiles.Select(x => x.Credentials?.CallbackPath).Distinct().Count() == Profiles.Count() &&
Profiles.Select(x => x.Credentials?.SignedOutCallbackPath).Distinct().Count() == Profiles.Count() &&
Profiles.Select(x => x.DisplayName).Distinct().Count() == Profiles.Count();
}
}

public class AzureSSOCredentials
{
public string Instance { get; set; } = "";
public string Domain { get; set; } = "";
public string TenantId { get; set; } = "";
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
public string CallbackPath { get; set; } = "";
public string SignedOutCallbackPath { get; set; } = "";

public bool IsValid()
{
return !string.IsNullOrEmpty(Instance) &&
!string.IsNullOrEmpty(Domain) &&
!string.IsNullOrEmpty(TenantId) &&
!string.IsNullOrEmpty(ClientId) &&
!string.IsNullOrEmpty(ClientSecret) &&
!string.IsNullOrEmpty(CallbackPath) &&
!string.IsNullOrEmpty(SignedOutCallbackPath);
}
}
}
52 changes: 52 additions & 0 deletions src/Umbraco.Community.AzureSSO/HealthChecks/AzureSSOHealthCheck.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Umbraco.Cms.Core.HealthChecks;

namespace Umbraco.Community.AzureSSO.HealthChecks
{
[HealthCheck(HealthCheckId, HealthCheckName, Description = "Checks the Azure SSO config to ensure it is valid.", Group = "Configuration")]
public class AzureSSOHealthCheck : HealthCheck
{
private const string HealthCheckId = "07F7DA0A-D351-4347-92B3-9B607E1D38BB";
private const string HealthCheckName = "Azure SSO";

private AzureSSOConfiguration _configuration;

public AzureSSOHealthCheck(AzureSSOConfiguration configuration)
{
_configuration = configuration;
}

public override async Task<IEnumerable<HealthCheckStatus>> GetStatus()
{
var statuses = new List<HealthCheckStatus>();

if (!_configuration.IsValid())
{
// TODO : We really need specific feedback for this to be useful
statuses.Add(new HealthCheckStatus("Configuration Invalid.")
{
Description = "Check AzureSSO configuration",
ResultType = StatusResultType.Error
});
}

if(!statuses.Any())
{
statuses.Add(new HealthCheckStatus("Configuration valid.")
{
ResultType = StatusResultType.Success
});
}

return statuses;
}

public override HealthCheckStatus ExecuteAction(HealthCheckAction action) => new("How did you get here?")
{
ResultType = StatusResultType.Info
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Identity.Client;
using Microsoft.Identity.Web;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Community.AzureSSO.Settings;
Expand All @@ -15,32 +16,63 @@ internal static IUmbracoBuilder AddMicrosoftAccountAuthentication(this IUmbracoB
{
var azureSsoConfiguration = new AzureSSOConfiguration();
builder.Config.Bind(AzureSSOConfiguration.AzureSsoSectionName, azureSsoConfiguration);

builder.Services.AddSingleton<AzureSsoSettings>(conf => new AzureSsoSettings(azureSsoConfiguration));
builder.Services.ConfigureOptions<MicrosoftAccountBackOfficeExternalLoginProviderOptions>();
builder.Services.AddSingleton<AzureSSOConfiguration>(conf => azureSsoConfiguration);

var settings = new AzureSsoSettings(azureSsoConfiguration);
builder.Services.AddSingleton<AzureSsoSettings>(conf => settings);
builder.Services.ConfigureOptions<MicrosoftAccountBackOfficeExternalLoginProviderOptions>();

var initialScopes = Array.Empty<string>();
builder.AddBackOfficeExternalLogins(logins =>
{
logins.AddBackOfficeLogin(
backOfficeAuthenticationBuilder =>
{
foreach (var profile in settings.Profiles)
{
backOfficeAuthenticationBuilder.AddMicrosoftIdentityWebApp(options =>
{
builder.Config.Bind(AzureSSOConfiguration.AzureSsoCredentialSectionName, options);
options.SignInScheme = backOfficeAuthenticationBuilder.SchemeForBackOffice(MicrosoftAccountBackOfficeExternalLoginProviderOptions.SchemeName);
options.Events = new OpenIdConnectEvents();
},
options => { builder.Config.Bind(AzureSSOConfiguration.AzureSsoCredentialSectionName, options); },
displayName: azureSsoConfiguration.DisplayName ?? "Azure Active Directory",
openIdConnectScheme: backOfficeAuthenticationBuilder.SchemeForBackOffice(MicrosoftAccountBackOfficeExternalLoginProviderOptions.SchemeName) ?? String.Empty)
.EnableTokenAcquisitionToCallDownstreamApi(options => builder.Config.Bind(AzureSSOConfiguration.AzureSsoCredentialSectionName, options), initialScopes)
.AddTokenCaches(azureSsoConfiguration.TokenCacheType);
});
});
logins.AddBackOfficeLogin(
backOfficeAuthenticationBuilder =>
{
backOfficeAuthenticationBuilder.AddMicrosoftIdentityWebApp(options =>
{
CopyCredentials(options, profile.Credentials);
options.SignInScheme = backOfficeAuthenticationBuilder.SchemeForBackOffice(profile.Name);
options.Events = new OpenIdConnectEvents();

},
displayName: profile.DisplayName ?? "Microsoft Entra ID",
cookieScheme: $"{profile.Name}Cookies",
openIdConnectScheme: backOfficeAuthenticationBuilder.SchemeForBackOffice(profile.Name) ??
String.Empty)
.EnableTokenAcquisitionToCallDownstreamApi(
options => CopyCredentials(options, profile.Credentials),
initialScopes)
.AddTokenCaches(profile.TokenCacheType);
});
}
}

);

return builder;
}

private static void CopyCredentials(MicrosoftIdentityOptions options, AzureSsoCredentialSettings settings)
{
options.Instance = settings.Instance;
options.Domain = settings.Domain;
options.TenantId = settings.TenantId;
options.ClientId = settings.ClientId;
options.ClientSecret = settings.ClientSecret;
options.SignedOutCallbackPath = settings.SignedOutCallbackPath;
options.CallbackPath = settings.CallbackPath;
}

private static void CopyCredentials(ConfidentialClientApplicationOptions options, AzureSsoCredentialSettings settings)
{
options.Instance = settings.Instance;
options.TenantId = settings.TenantId;
options.ClientId = settings.ClientId;
options.ClientSecret = settings.ClientSecret;
}

private static MicrosoftIdentityAppCallsWebApiAuthenticationBuilder AddTokenCaches(this MicrosoftIdentityAppCallsWebApiAuthenticationBuilder builder, TokenCacheType tokenCacheType)
{
switch (tokenCacheType)
Expand Down
Loading

0 comments on commit 71b7b75

Please sign in to comment.