Umbraco-OpenIdConnect-Example icon indicating copy to clipboard operation
Umbraco-OpenIdConnect-Example copied to clipboard

Back Office OpenIdConnect authentication

Open bhavens17 opened this issue 1 year ago • 2 comments

This isn't so much an issue as a question--do you have a working example of how to use an external OpenID Connect provider for back office authentication? I tried modifying your 14+ example for back office user authentication (see below), but after logging in to the external provider, it hits the 'OnTokenValidated' handler as expected but then I end up being redirected back to the standard back office login page. Am I missing something?

UmbracoBuilderExtensions.cs:

namespace Umbraco_OpenIdConnect_Example_v14plus.Extensions;

using System.Net;
using System.Security.Claims;
using Microsoft.Extensions.DependencyInjection;
using Provider;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Security;
using Umbraco.Extensions;

public static class UmbracoBuilderExtensions
{
    public static IUmbracoBuilder AddOpenIdConnectAuthentication(this IUmbracoBuilder builder)
    {
        builder.Services.ConfigureOptions<OpenIdConnectBackOfficeExternalLoginProviderOptions>();

        builder.AddBackOfficeExternalLogins(logins =>
        {
            logins.AddBackOfficeLogin(
                backOfficeAuthenticationBuilder =>
                {
                    backOfficeAuthenticationBuilder.AddOpenIdConnect(
						// The scheme must be set with this method to work for the umbraco members
						Constants.Security.BackOfficeExternalAuthenticationTypePrefix + OpenIdConnectBackOfficeExternalLoginProviderOptions.SchemeName,
                        options =>
                        {
                            var config = builder.Config;
                            options.ResponseType = "code";
                            options.Scope.Add("openid");
                            options.Scope.Add("profile");
                            options.Scope.Add("email");
                            options.Scope.Add("phone");
                            options.Scope.Add("address");
                            options.RequireHttpsMetadata = true;
                            options.MetadataAddress = config["OpenIdConnect:MetadataAddress"];
                            options.ClientId = config["OpenIdConnect:ClientId"];
                            // Normally the ClientSecret should not be in the Github repo.
                            // These settings are valid and only used for this example.
                            // So it's ok these are public.
                            options.ClientSecret = config["OpenIdConnect:ClientSecret"];
                            options.SaveTokens = true;
                            options.TokenValidationParameters.SaveSigninToken = true;
                            options.Events.OnTokenValidated = async context =>
                            {
                                var claims = context?.Principal?.Claims.ToList();
                                var email = claims?.SingleOrDefault(x => x.Type == ClaimTypes.NameIdentifier);
                                if (email != null)
                                {
                                    // The email claim is required for auto linking.
                                    // So get it from another claim and put it in the email claim.
                                    claims?.Add(new Claim(ClaimTypes.Email, email.Value));
                                }

                                var name = claims?.SingleOrDefault(x => x.Type == "user_displayname");
                                if (name != null)
                                {
                                    // The name claim is required for auto linking.
                                    // So get it from another claim and put it in the name claim.
                                    claims?.Add(new Claim(ClaimTypes.Name, name.Value));
                                }
                                else
                                {
                                    name = claims?.SingleOrDefault(x => x.Type == "nickname");
                                    if (name != null)
                                    {
                                        // The name claim is required for auto linking.
                                        // So get it from another claim and put it in the name claim.
                                        claims?.Add(new Claim(ClaimTypes.Name, name.Value));
                                    }    
                                }

                                if (context != null)
                                {
                                    // Since we added new claims create a new principal.
                                    var authenticationType = context.Principal?.Identity?.AuthenticationType;
                                    context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType));
                                }

                                await Task.FromResult(0);
                            };
                            options.Events.OnRedirectToIdentityProviderForSignOut = async notification =>
                            {
                                var protocolMessage = notification.ProtocolMessage;

                                var logoutUrl = config["OpenIdConnect:LogoutUrl"];
                                var returnAfterLogout = config["OpenIdConnect:ReturnAfterLogout"];
                                if (!string.IsNullOrEmpty(logoutUrl) && !string.IsNullOrEmpty(returnAfterLogout))
                                {
                                    // Some external login providers require an IssuerAddress.
                                    // It requires the logout URL on the external login provider.
                                    // It also need the client_id and a URL which it needs to return to after logout.
                                    protocolMessage.IssuerAddress =
                                        $"{config["OpenIdConnect:LogoutUrl"]}" +
                                        $"?client_id={config["OpenIdConnect:ClientId"]}" +
                                        $"&returnTo={WebUtility.UrlEncode(config["OpenIdConnect:ReturnAfterLogout"])}";
                                }

                                // Since we're in a static extension method we need this approach to get the member manager. 
                                var memberManager = notification.HttpContext.RequestServices.GetService<IMemberManager>();
                                if (memberManager != null)
                                {
                                    var currentMember = await memberManager.GetCurrentMemberAsync();
                                    
                                    // On the current member we can find all their login tokens from the external login provider.
                                    // These tokens are stored in the umbracoExternalLoginToken table.
                                    var idToken = currentMember?.LoginTokens.FirstOrDefault(x => x.Name == "id_token");
                                    if (idToken != null && !string.IsNullOrEmpty(idToken.Value))
                                    {
                                        // Some external login providers need the IdTokenHint.
                                        // By setting the IdTokenHint the user can be redirected back from the external login provider to this website. 
                                        protocolMessage.IdTokenHint = idToken.Value;
                                    }
                                }

                                await Task.FromResult(0);
                            };
                        });
                });
        });
        return builder;
    }
}

OpenIdConnectMemberExternalLoginProviderOptions.cs (renamed to OpenIdConnectBackOfficeExternalLoginProviderOptions):

namespace Umbraco_OpenIdConnect_Example_v14plus.Provider;

using System.Collections.Generic;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Web.Common.Security;
using Umbraco.Cms.Core;
using Umbraco.Cms.Api.Management.Security;

public class OpenIdConnectBackOfficeExternalLoginProviderOptions : IConfigureNamedOptions<BackOfficeExternalLoginProviderOptions>
{
	public const string SchemeName = "OpenIdConnect";
	public void Configure(string? name, BackOfficeExternalLoginProviderOptions options)
	{
		if (name != Constants.Security.MemberExternalAuthenticationTypePrefix + SchemeName)
		{
			return;
		}

		Configure(options);
	}

	public void Configure(BackOfficeExternalLoginProviderOptions options)
	{
		options.AutoLinkOptions = new ExternalSignInAutoLinkOptions(
			// Must be true for auto-linking to be enabled
			autoLinkExternalAccount: true,

			// Optionally specify the default culture to create
			// the user as. If null it will use the default
			// culture defined in the web.config, or it can
			// be dynamically assigned in the OnAutoLinking
			// callback.
			defaultCulture: null

		// Optionally specify the default "IsApprove" status. Must be true for auto-linking.
		//defaultIsApproved: true,

		// Optionally specify the member type alias. Default is "Member"
		//defaultMemberTypeAlias: "Member",

		// Optionally specify the member groups names to add the auto-linking user to.
		//defaultMemberGroups: new List<string> { "example-group" }
		)
		{
			// Optional callback
			OnAutoLinking = (autoLinkUser, loginInfo) =>
			{
				// You can customize the user before it's linked.
				// i.e. Modify the user's groups based on the Claims returned
				// in the externalLogin info
				return;
			},
			OnExternalLogin = (user, loginInfo) =>
			{
				// You can customize the user before it's saved whenever they have
				// logged in with the external provider.
				// i.e. Sync the user's name based on the Claims returned
				// in the externalLogin info

				return true; //returns a boolean indicating if sign in should continue or not.
			}
		};
	}
}

App_Plugins/ExternalLoginProviders/umbraco-package.json (new, needed to add external provider button to back office login page):

{
  "$schema": "../../umbraco-package-schema.json",
  "name": "My Auth Package",
  "allowPublicAccess": true,
  "extensions": [
    {
      "type": "authProvider",
      "alias": "My.AuthProvider.Okta",
      "name": "My Okta Auth Provider",
      "forProviderName": "Umbraco.OpenIdConnect",
      "meta": {
        "label": "External Account",
        "defaultView": {
          "icon": "icon-cloud"
        },
        "behavior": {
          "autoRedirect": false
        },
        "linking": {
          "allowManualLinking": true
        }
      }
    }
  ]
}

bhavens17 avatar Sep 21 '24 17:09 bhavens17

Can it be that you're trying to login with an account that has not been enabled or has insufficient rights in the backend? The behaviour is that it then redirects to login. If not, please find my working code attached.

 public static IUmbracoBuilder AddOpenIdConnectAuthentication(this IUmbracoBuilder builder, IConfiguration configuration)
    {
        builder.Services.ConfigureOptions<OpenIdConnectUserExternalLoginProviderOptions>();

        builder.AddBackOfficeExternalLogins(logins =>
        {
			logins.AddBackOfficeLogin(
				backOfficeAuthenticationBuilder =>
				{
					
					backOfficeAuthenticationBuilder.AddOpenIdConnect(
						$"Umbraco.{OpenIdConnectUserExternalLoginProviderOptions.SchemeName}",
						options =>
						{
							options.Authority = $"https://login.microsoftonline.com/{configuration["Dutchbreeze:Entra:TenantId"]}/v2.0";
                            options.ClientId = configuration["Dutchbreeze:Entra:ClientId"];
                            options.ClientSecret = configuration["Dutchbreeze:Entra:ClientSecret"];
                            //options.CallbackPath = "/signin-oidc"; // "/signin-entraid";

							options.GetClaimsFromUserInfoEndpoint = true;
							options.TokenValidationParameters.NameClaimType = "name";
							options.Scope.Add("email");
						});
				});
		});
        return builder;
    }
public class OpenIdConnectUserExternalLoginProviderOptions : IConfigureNamedOptions<BackOfficeExternalLoginProviderOptions>
{
    public const string SchemeName = "OpenIdConnect";
    public void Configure(string? name, BackOfficeExternalLoginProviderOptions options)
    {
        if (name != Constants.Security.BackOfficeExternalAuthenticationTypePrefix + SchemeName)
        {
            return;
        }

        Configure(options);
    }

    public void Configure(BackOfficeExternalLoginProviderOptions options)
    {
		options.AutoLinkOptions = new ExternalSignInAutoLinkOptions(

            autoLinkExternalAccount: true,
            defaultUserGroups: new[] { Constants.Security.EditorGroupKey.ToString() },
            defaultCulture: null,
            allowManualLinking: true
        )
        {
            OnAutoLinking = (autoLinkUser, loginInfo) =>
            {
                // Customize the user before it's linked.
                // Modify the User's groups based on the Claims returned
                // in the external login info.
            },

            // [OPTIONAL] Callback
            OnExternalLogin = (user, loginInfo) =>
            {
                // Customize the User before it is saved whenever they have
                // logged in with the external provider.
                // Sync the Users name based on the Claims returned
                // in the external login info

                // Returns a boolean indicating if sign-in should continue or not.
                return true;
            }
        };

        options.DenyLocalLogin = false;

    }
}

umbraco-dev avatar Sep 23 '24 06:09 umbraco-dev

Ok, after digging into the source code I realized the issue had to do with the following code in my OpenIdConnectBackOfficeExternalLoginProviderOptions class:

image

The issue was that it should have been using the 'BackOfficeExternalAuthenticationTypePrefix', not the 'MemberExternalAuthenticationTypePrefix':

	public void Configure(string? name, BackOfficeExternalLoginProviderOptions options)
	{
		if (name != Constants.Security.BackOfficeExternalAuthenticationTypePrefix + SchemeName)
		{
			return;
		}

		Configure(options);
	}

My mistake--thanks for your help!

bhavens17 avatar Sep 26 '24 16:09 bhavens17