Umbraco frontend membership SSO using OpenID Connect

Scott Brady
Scott Brady
Umbraco

Umbraco has built-in support for membership, where you can allow end-users of your Umbraco site to authenticate and gain access to protected pages. However, if you have more than one website, it’s unlikely that you will want your users to manage yet another set of credentials. Instead, Umbraco can use your existing SSO solution.

In this article, you’re going to see how to configure Umbraco 9 to use an external identity provider for frontend membership authentication. This approach will work for any OpenID Connect identity provider, including IdentityServer, Auth0, Azure AD, or Google.

This tutorial assumes that you already have an Umbraco 9 (or above) installation with a members login page and that you can lock down Umbraco content based on member groups. If you don’t have a members setup, then I recommend first following this Umbraco tutorial.

Check out my other article if you’re looking for Umbraco backoffice SSO.

Membership account linking architecture

Your requirements for membership (how they can register, authenticate, and how you match local to external accounts) will vary based on the project you’re working on. As a result, this tutorial will use a process where the external identity provider won’t automatically sign the member into Umbraco. Instead, you will sign the member into a temporary cookie and cart them off to a controller action that can handle any custom account linking logic you might have.

This controller action could automatically match the external user to a local member using their email address or return an error page for an unknown user. It could even make the external user provide more identity information to complete local registration & login. It’s entirely up to you!

In this tutorial, you will automatically match users by email address, auto-creating any unknown users. This approach works okay for a single, highly trusted identity provider; however, you may want to consider a more secure approach if you integrate with multiple identity providers.

Umbraco OpenID Connect authentication for members

Let’s start by installing your required dependency, Microsoft’s OpenID Connect authentication handler:

dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect

Now you’ll need to register an OpenID Connect authentication handler, which you can do in the ConfigureServices method of your Startup class. Below are the current best practices as suggested by OAuth 2.1 and OpenID Connect, using the authorization code flow and PKCE.

services.AddAuthentication()
  .AddCookie("temp-cookie", options => options.ExpireTimeSpan = TimeSpan.FromMinutes(5))
  .AddOpenIdConnect("oidc", "OpenID Connect", options =>
  {
    options.Authority = "https://demo.identityserver.io";
    options.ClientId = "interactive.confidential";
    options.ClientSecret = "secret";

    options.CallbackPath = "/signin-oidc";
    options.Scope.Add("email");

    options.ResponseType = "code";
    options.ResponseMode = "query";
    options.UsePkce = true;

    options.GetClaimsFromUserInfoEndpoint = true;

    options.SignInScheme = "temp-cookie";
  });

Here you are asking for the openid, profile, and email scopes, which should give you everything you typically need to match accounts or attempt to create a new account.

Once the OpenID Connect authentication handler is happy with the security side of things, we’re telling it to dump the user data it receives into a short-lived temporary cookie. This cookie’s sole purpose is to securely transmit the identity data from the authentication handler to our account linking logic.

At the time of writing, if you are using AddBackOfficeExternalLogins with an OpenID Connect authentication handler, you will need to use Umbraco’s backoffice external cookie due to this registration overriding the SignInScheme. To use this cookie, you will need to set the SignInScheme to Constants.Security.BackOfficeExternalAuthenticationType. I currently have a PR open to remove this requirement.

IdentityServer configuration

The above sample uses a demo OpenID Connect identity provider running on demo.identityserver.io. If you are using your own IdentityServer implementation, you will need a client configuration record such as the following (don’t forget to use the correct client ID, secret, and redirect URIs).

new Client 
{
  ClientId = "umbraco-web",
  ClientName = "Umbraco membership area",
  ClientSecrets = { new Secret("secret".Sha256()) },

  AllowedGrantTypes = GrantTypes.Code,
  RedirectUris = { "https://localhost:44302/signin-oidc" },
  AllowedScopes = { "openid", "profile", "email" },
  RequirePkce = true
}

Challenging external authentication and Umbraco member linking

For this approach, you’ll use a custom controller dedicated to initiating the authentication challenge and account linking. This technique is very similar to the method used in the IdentityServer quickstart UI.

public class LoginController : SurfaceController
{
  // surface controller boilerplate omitted for brevity

  [HttpGet]
  public IActionResult ExternalLogin(string returnUrl)
  {
    // TODO: challenge OIDC scheme
  }

  [HttpGet]
  public async Task<IActionResult> ExternalLoginCallback()
  {
    // TODO: account linking & sign in
  }
}

Challenge

Let’s start with the challenge action. This action will return the base Challenge result, calling our authentication handler explicitly (the “oidc” key) and set the challenge’s ReturnUri to our account linking action while still remembering the original page that the end-user was trying to access.

[HttpGet]
public IActionResult ExternalLogin(string returnUrl)
{
  return Challenge(
    new AuthenticationProperties
    {
      RedirectUri = Url.Action(nameof(ExternalLoginCallback)), 
      Items = {{ "returnUrl", returnUrl }}
    }, "oidc");
}

If you are using multiple external identity providers, you will also want to support a “scheme” parameter that you’ll need to remember for later.

Account linking & role assignment

For account linking, here’s something cheap and nasty as an example:

// IMemberManager memberManager
// IMemberService memberService
// IMemberSignInManager signInManager

[HttpGet]
public async Task<IActionResult> ExternalLoginCallback()
{
  // load & validate the temporary cookie
  var result = await HttpContext.AuthenticateAsync("temp-cookie");
  if (!result.Succeeded) throw new Exception("Missing external cookie");

  // auto-create account using email address
  var email = result.Principal.FindFirstValue(ClaimTypes.Email)
              ?? result.Principal.FindFirstValue("email")
              ?? throw new Exception("Missing email claim");

  var user = await memberManager.FindByEmailAsync(email);
  if (user == null)
  {
    memberService.CreateMemberWithIdentity(email, email, email, Constants.Security.DefaultMemberTypeAlias);

    user = await memberManager.FindByNameAsync(email);
    await memberManager.AddToRolesAsync(user, new[] {"User"});
  }

  // create the full membership session and cleanup the temporary cookie
  await HttpContext.SignOutAsync("temp-cookie");
  await signInManager.SignInAsync(user, false);

  // basic open redirect defense
  var returnUrl = result.Properties?.Items["returnUrl"];
  if (returnUrl == null || !Url.IsLocalUrl(returnUrl)) returnUrl = "~/";

  return new RedirectResult(returnUrl);
}

The above requires you to inject IMemberManager, IMemberService, and IMemberSignInManager into your already large constructor.

Here, you check for the temporary cookie containing the identity data from your OpenID Connect identity provider (such as IdentityServer) and search for the user’s email claim using the XML naming and the OIDC naming.

You are then using the member-specific implementation of ASP.NET Core Identity to lookup the member by their email address. If they don’t exist, you create them using Umbraco’s member service, which will handle all of the Umbraco specifics (and also ensure a member ID that is an integer). You’re then adding them to the User role, a member role that I created within Umbraco to authorize access based on roles rather than individual users.

Once the user is either linked or created, you clean up the temporary cookie and then use the member-specific version of ASP.NET Core Identity’s SignInManager to log in the member.

Once the member is signed in, you redirect the user to the original page they were trying to access.

Log in button

All that’s left is to update your login page to trigger the above code. You can do this using an ASP.NET Core tag helper:

<a asp-controller="Login" asp-action="ExternalLogin" 
   asp-route-returnUrl="@Context.Request.Path" asp-route-scheme="oidc-fe">
    Login with OpenID Connect
</a>

Sumary & next steps

This is a super basic implementation designed to show the building blocks. How you trigger the OpenID Connect challenge and handle the Umbraco part is entirely up to you!

Just like with the Umbraco backoffice, the above approach works with any OpenID Connect provider or any remote authentication handler such as SAML and WS-Federation. Again, remember to keep your debugging search terms to just “ASP.NET Core” rather than including Umbraco, and you’ll find a lot more help on how to use these authentication handlers.

If I recall correctly, Umbraco membership is a part of Umbraco that’s due for some upgrades. So you never know, there may soon be an abstraction similar to the Umbraco backoffice users!