asp.netasp.net-identityopeniddict

Custom login pages built/hosted at frontend using OpenIddict


We have a ASP.NET API server which aim to service multiple tenants with front ends written in Angular and React. We have implemented authentication and authorisation using OpenIdDict authorisation code flow and it works well but with one big problem. Our clients want to write their own custom login pages so they blend with the design of their other web pages. While it is possible to override the look and feel of the login pages on the server, we would have to maintain a set for every client and will need to change it every time a client decides to change the look and feel of their web pages. We had considered using Duende Identity Server but it seems to have the same constraint.

I haven't been able to find any documentation of examples which allow a fully customisable login pages with the authorisation code flow. I wonder if the developer community here has managed to find a good solution for this.

Update - 21st Oct 2023 The client is happy to only make use of External Authentication Providers Google and Facebook. However, they want the Login With Google button to be on their web application( written in React). I tried to achieve this by exposing an end-point on ASP.NET Core server auth, setting a redirect URI and returning a Challenge. It achieves partial success as I'm able to see the user coming in authenticated from external provider and can return a SignIn response back to the client. I have based my call back largely on OpenIddict Velusia sample's callback/login/{provider} but not used GoogleDefaults.AuthenticationScheme

React Web Application

                <form method='GET' action={`/api/v1/auth/externalLogin`} >
                <input type="hidden" name="scheme" value="Google"/>
                <input type="hidden" name="returnUrl" value="/info" />
                <input type="hidden" name="tenantId" value="REDACTED" />
                <input type="hidden" name="client_id" value="REDACTED" />

                
                <Button className={styles.googleButton} variant="outlined" type="submit">
                    <div className={styles.googleButtonContent}>
                        <GoogleIcon className={styles.googleIcon} />
                        <Typography className={styles.googleText}>Continue with Google</Typography>
                    </div>
                </Button>
            </form>

Program.cs

builder.Services.AddAuthentication()
.AddGoogle(options =>
{
    IConfigurationSection googleAuthNSection =
        builder.Configuration.GetSection("Authentication:Google");
    options.ClientId = googleAuthNSection["ClientId"];
    options.ClientSecret = googleAuthNSection["ClientSecret"];
    options.SignInScheme = IdentityConstants.ExternalScheme; // OpenIddictServerAspNetCoreDefaults.AuthenticationScheme; // -  A sign-in/Challenge response cannot be returned from this endpoint.
});

Auth Controller - Client Login End Point

 public override async Task<IActionResult> ExternalLogin(string scheme, string returnUrl, string tenantId)
    {
        var properties = new AuthenticationProperties
        {
            RedirectUri = Url.Action(nameof(ExternalLoginCallback)),
            Items =
            {
                { "scheme", scheme },
                { "returnUrl", returnUrl },
                { "tenantId", tenantId },
            }
        }; 

      return Challenge(props, scheme);
    }

Auth Controller - Callback

    public override async Task<IActionResult> ExternalLoginCallback(string returnUrl = null)
{
    var result = await HttpContext.AuthenticateAsync(GoogleDefaults.AuthenticationScheme);

    if (result.Principal is not ClaimsPrincipal { Identity.IsAuthenticated: true })
    {
        throw new InvalidOperationException("The external authorization data cannot be used for authentication.");
    }

    string returnUri = result.Properties?.Items["returnUrl"];
    string tenantId = result.Properties?.Items["tenantId"];
    string scheme = result.Properties?.Items["scheme"];

    var identity = new ClaimsIdentity(authenticationType: "ExternalLogin");

    identity.SetClaim(ClaimTypes.Email, result.Principal.GetClaim(ClaimTypes.Email))
        .SetClaim(ClaimTypes.Name, result.Principal.GetClaim(ClaimTypes.Name))
        .SetClaim(ClaimTypes.NameIdentifier, result.Principal.GetClaim(ClaimTypes.NameIdentifier));


    
    var info = await _signInManager.GetExternalLoginInfoAsync();
    if (info == null)
    {
         return NotFound();
         return new ObjectResult("No account exists for the user. Please register first!"){StatusCode = StatusCodes.Status418ImATeapot};
         // Error occurred
     }

    // Sign in the user with this external login provider if the user already has a login.
    var extResult = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);
    if (extResult.IsLockedOut)
     {
         return new ForbidResult("User account is locked out");
     }
     else if (!extResult.Succeeded)
     {
         return new ObjectResult("No account exists for the user. Please register first!"){StatusCode = StatusCodes.Status418ImATeapot};
     }
    
         _logger.LogInformation("{Name} logged in with {LoginProvider} provider.", info.Principal.Identity.Name,
             info.LoginProvider);

    var user = await _userManager.FindByLoginAsync(scheme, result.Principal.GetClaim(ClaimTypes.NameIdentifier));
    var user = _userManager.GetUserAsync(new ClaimsPrincipal(identity)).Result;
    if (user == null)
    {
        // User is authenticated ok but needs to register first
        return new ObjectResult("No account exists for the user. Please register first!")
            { StatusCode = StatusCodes.Status418ImATeapot };
    }

    // Build the authentication properties based on the properties that were added when the challenge was triggered.
    var properties = new AuthenticationProperties(result.Properties.Items)
    {
        RedirectUri = result.Properties.RedirectUri ?? "/"
    };

    properties.StoreTokens(result.Properties.GetTokens());
    return SignIn(new ClaimsPrincipal(identity), properties);
}

However, there are a few problems -

  1. I don't know if this way is compromising on authorisation code workflow
  2. Not much of the OpenIddict flow seems to be getting executed. None of the cookies set are any Openiddict ones.
  3. User experience of cases where they don't have an account will be subpar as I will need to redirect them back to the client for actions like register first, consent etc.

Are there any other potential issues the community can see here or a solution to the problem I'm facing. Ideally I would like to use OpenIddictClientAspNetCoreDefaults.AuthenticationScheme in the login end point called, create a Challenge and direct the flow to OpenIddict's "~/connect/authorize" like in their samples.


Solution

  • Unfortunately, it is not possible to allow SPA client to have customised login pages using OAuth2 authorisation code grant workflow which is recommended for new applications. However, we can still customise the login pages which come bundled with ASP.NET Identity. It is possible to completely replace them but I found customising them to be easier. Following are the steps -

    1. Scaffold ASP.NET Identity Pages - https://learn.microsoft.com/en-us/aspnet/core/security/authentication/scaffold-identity?view=aspnetcore-8.0&viewFallbackFrom=aspnetcore-2.1&tabs=visual-studio

    2. Change the look and feel/code as desired in these pages. The default location is at ../Areas/Identity/

    3. Setup openiddict client https://github.com/openiddict/openiddict-samples (I used Velusia Client) and register the external openidconnect providers (external or your own implementation using openiddict). https://andreyka26.com/openid-connect-authorization-code-using-openiddict-and-dot-net gives a pretty good guidance on it.

    4. For multi tenant scenario, I relied on creating multiple layouts. The idea is to have the same css class names and create overrides for each of the tenants. This approach will work if the pages largely differ only in look and feel. Change the code in ../Areas/Identity/Pages/_ViewStart.cshtml to something like below. I am retrieving the layout from the query string for illustration but it can be retrieved by using any other method to discover calling tenant.

       @{
       string layoutRequested = Context.Request.Query.ContainsKey("layout") ? Convert.ToString(Context.Request.Query["layout"]).ToLower() : "default";
      
       switch (layoutRequested)
       {
      
           case "tenant1":
               Layout = "~/Views/Shared/_Layout-t1.cshtml";
               break;
      
           case "tenant2":
           default:
               Layout = "~/Views/Shared/_Layout.cshtml";
               break;
       }
      

      }

    5. Add the layouts for each tenant as desired at ../Views/Shared/