Linking External Login Provider accounts

    Traditionally when using External login providers (OAuth), a backoffice user will need to exist first and then that user can link their user account to an external login provider in the backoffice.

    In many cases, however, the external login provider you install will be the source of truth for all of your users and you may want to provide a single sign-on (SSO) approach to the backoffice. This is called Auto Linking.

    Configure External Login provider

    To enable auto linking you have to implement a custom named configuration of BackOfficeExternalLoginProviderOptions.

    Example

    This example shows connection to an Open ID Connect Service such as IdentityServer4 or OpenIDDict

    You can first create a OpenIdConnectBackOfficeExternalLoginProviderOptions.cs file which configures the options like

    using Microsoft.Extensions.Options;
    using Umbraco.Cms.Core;
    using Umbraco.Cms.Web.BackOffice.Security;
    
    namespace Umbraco9
    {
        public class OpenIdConnectBackOfficeExternalLoginProviderOptions : IConfigureNamedOptions<BackOfficeExternalLoginProviderOptions>
        {
            public const string SchemeName = "OpenIdConnect";
            public void Configure(string name, BackOfficeExternalLoginProviderOptions options)
            {
                if (name != "Umbraco." + SchemeName)
                {
                    return;
                }
    
                Configure(options);
            }
    
            public void Configure(BackOfficeExternalLoginProviderOptions options)
            {
                options.ButtonStyle = "btn-danger";
                options.Icon = "fa fa-cloud";
                options.AutoLinkOptions = new ExternalSignInAutoLinkOptions(
                    // must be true for auto-linking to be enabled
                    autoLinkExternalAccount: true,
    
                    // Optionally specify default user group, else
                    // assign in the OnAutoLinking callback
                    // (default is editor)
                    defaultUserGroups: new[] { Constants.Security.EditorGroupAlias },
    
                    // 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 you can disable the ability to link/unlink
                    // manually from within the back office. Set this to false
                    // if you don't want the user to unlink from this external
                    // provider.
                    allowManualLinking: false
                )
                {
                    // 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
                    },
                    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.
                    }
                };
    
                // Optionally you can disable the ability for users
                // to login with a username/password. If this is set
                // to true, it will disable username/password login
                // even if there are other external login providers installed.
                options.DenyLocalLogin = false;
    
                // Optionally choose to automatically redirect to the
                // external login provider so the user doesn't have
                // to click the login button. This is
                options.AutoRedirectLoginToExternalProvider = false;
            }
        }
    }
    

    Additionally, there are more advanced properties for BackOfficeExternalLoginProviderOptions:

    • BackOfficeExternalLoginProviderOptions.CustomBackOfficeView
      • Allows for specifying a custom angular HTML view that will render in place of the default external login button. The purpose of this is in case you want to completely change the UI but also in these circumstances:
        • You want to display something different where external login providers are listed: in the login screen vs the backoffice panel vs on the logged-out screen. This same view will render in all of these cases but you can use the current route parameters to customize what is shown.
        • You want to change how the button interacts with the external login provider. For example, instead of having the site redirect on button-click, you want to open a popup window to load the external login provider.
      • The path is a virtual path, for example: "~/App_Plugins/MyPlugin/BackOffice/my-external-login.html"
      • When specifying this view it is 100% up to your angular view and affiliated angular controller to perform all required logic. To get started, the easiest way is to copy what the default angular view does and then implement your angular controller to do what the default controller does.

    To register this configuration class, you can call the following from your startup.cs:

    services.ConfigureOptions<OpenIdConnectBackOfficeExternalLoginProviderOptions>();
    

    We recommend to create an extension method on the IUmbracoBuilder, to add the Open Id Connect Authentication, like this This extension can also handle the configuration of OpenIdConnectBackOfficeExternalLoginProviderOptions:

    public static IUmbracoBuilder AddOpenIdConnectAuthentication(this IUmbracoBuilder builder)
    {
        // Register OpenIdConnectBackOfficeExternalLoginProviderOptions here rather than require it in startup
        builder.Services.ConfigureOptions<OpenIdConnectBackOfficeExternalLoginProviderOptions>();
    
        builder.AddBackOfficeExternalLogins(logins =>
        {
            logins.AddBackOfficeLogin(
                backOfficeAuthenticationBuilder =>
                {
                    backOfficeAuthenticationBuilder.AddOpenIdConnect(
                        // The scheme must be set with this method to work for the back office
                        backOfficeAuthenticationBuilder.SchemeForBackOffice(OpenIdConnectBackOfficeExternalLoginProviderOptions.SchemeName),
                        options =>
                        {
                            // use cookies
                            options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                            // pass configured options along
                            options.Authority = "https://localhost:5000";
                            options.ClientId = "YOURCLIENTID";
                            options.ClientSecret = "YOURCLIENTSECRET";
                            // Use the authorization code flow
                            options.ResponseType = OpenIdConnectResponseType.Code;
                            options.AuthenticationMethod = OpenIdConnectRedirectBehavior.RedirectGet;
                            // map claims
                            options.TokenValidationParameters.NameClaimType = "name";
                            options.TokenValidationParameters.RoleClaimType = "role";
    
    
                            options.RequireHttpsMetadata = true;
                            options.GetClaimsFromUserInfoEndpoint = true;
                            options.SaveTokens = true;
                            // add scopes
                            options.Scope.Add("openid");
                            options.Scope.Add("email");
                            options.Scope.Add("roles");
                            options.UsePkce = true;
                        });
                });
        });
        return builder;
    }
    

    Finally this extension can also be called from the Startup.cs like the example below:

    services.AddUmbraco(_env, _config)
       .AddBackOffice()
       .AddWebsite()
       .AddComposers()
       .AddOpenIdConnectAuthentication()
       .Build();
    

    For some providers, it doesn't make sense to use auto-linking. This is especially true for public providers such as Google or Facebook. In those cases, it would mean that anyone who has a Google or Facebook account can log into your site. For public providers such as this, if auto-linking was needed you would need to limit the access by domain or other information provided in the Claims using the options/callbacks specified in those provider's authentication options.

    Local logins

    If you have configured auto-linking, then any auto-linked user will have an empty password assigned and they will not be able to log in locally (via username and password). In order to log in locally, they will have to assign a password to their account in the backoffice.

    If the DenyLocalLogin option is enabled, then all password changing functionality in the backoffice is also disabled and local login is not possible.

    Transfering Claims from External identities

    In some cases you may want to flow a Claim returned in your external login provider to the Umbraco backoffice identity's Claims (the authentication cookie). This can be done during the OnAutoLinking and OnExternalLogin.

    Reason for this could be to store the external login provider user ID into the backoffice identity cookie. That way it can be retrieved on each request in order to look up some data in another system that needs the current user id from the external login provider.

    Do not flow large amounts of data into the backoffice identity because this information is stored into the backoffice authentication cookie and cookie limits will apply. Data like JWT tokens need to be persisted somewhere to be looked up and not stored within the backoffice identity itself.

    Example

    This is a very simplistic example for brevity, no null checks, etc...

    OnAutoLinking = (user, 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
        var extClaim = externalLogin
            .Principal
            .FindFirst("MyClaim");
        user.Claims.Add(new IdentityUserClaim<string>
        {
            ClaimType = extClaim.Type,
            ClaimValue = extClaim.Value,
            UserId = user.Id
        });
    },
    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
        var extClaim = externalLogin
            .Principal
            .FindFirst("MyClaim");
        user.Claims.Add(new IdentityUserClaim<string>
        {
            ClaimType = extClaim.Type,
            ClaimValue = extClaim.Value,
            UserId = user.Id
        });
        return true;
    }
    

    Storing external login provider data

    In some cases, you may need to persist data from your external login provider like Access Tokens, etc. You can persist this data to the affiliated user's external login data via the IExternalLoginService. The void Save(int userId, IEnumerable<IExternalLogin> logins) overload takes a new model of type IEnumerable<IExternalLogin>. IExternalLogin contains a property called UserData. This is a blob text column so can store any arbitrary data for the external login provider.

    Be aware that the local Umbraco user must already exist and be linked to the external login provider before data can be stored here. In cases where auto-linking occurs and the backoffice user isn't yet created, you will most likely need to store this data in memory. First, during auto-linking and then persist this data to the service once the user is linked and created.