Automatic post-registration sign-in with Identity Server

Identity Server is an open source framework that allows implementing Single sign-on and supports a number of modern authentication protocols such as OpenID Connect and OAuth2.

Identity Server was created by the guys at Thinktecture and has now become the Microsoft recommended approach for providing centralised authentication and access-control in ASP.NET.

A few months ago I started to investigate replacing our hand-rolled auth system with Identity Server. We had a number of services in our platform and were already making use of OAuth2 to authenticate client applications in our API. We were using a domain level authentication cookie to share authenticated sessions between 2 of our apps but as more services were introduced, each with their own set of authentication requirements, this was no longer a viable solution.

Registering Users

Identity Server does not perform user registration so the typical flow when registering users is:

  1. User registers on your web site (store user in DB)
  2. After registration user is redirected to Identity Server to sign in
  3. User is redirected back to your web site

Identity Server provides support for ASP.NET Identity and Membership reboot and if you're not using one of these frameworks, you can provide your own custom services.

In our system we wanted a slightly different flow, whereby our customers were not required to sign in again following registration:

  1. User registers on our marketing site
  2. User is automatically signed in to Identity Server
  3. User is redirected to our Dashboard and automatically signed in

Once the user is signed into Identity Server we can transparently sign the user into the Dashboard application by disabling the IdSrv consent screen. Here's our client configuration:

return new List<Client>
{
    new Client
    {
        ClientName = "Marketing",
        ClientId = "marketing",
        Enabled = true,
        Flow = Flows.Implicit,
        AccessTokenType = AccessTokenType.Reference,
        RedirectUris = new List<string>
        {
            "http://localhost:51962/",
        },
        AllowAccessToAllScopes = true,
        RequireConsent = false
    },
    new Client
    {
        ClientName = "Dashboard",
        ClientId = "dashboard",
        Enabled = true,
        Flow = Flows.Implicit, 
        AccessTokenType = AccessTokenType.Reference,
        RedirectUris = new List<string>
        {
            "http://localhost:49902/"
        },
        PostLogoutRedirectUris = new List<string>
        {
            "http://localhost:49902/"
        },
        AccessTokenLifetime = 36000, // 10 hours
        AllowAccessToAllScopes = true,
        RequireConsent = false
    }
}

Implementing automatic sign-in

To implement automatic sign-in we need to do the following:

  1. During registration generate a One-Time-Access-Code (OTAC) and store this against our new user along with an expiry date.
  2. Redirect the user to the Dashboard including the OTAC in the URL (if you want to sign-in to the same app you can skip this step).
  3. Authenticate the user (redirects to Identity Server) sending the OTAC in the acr_values parameter (more info).
  4. Identity Server validates the token and signs the user in transparently (no consent screen).
  5. User is redirected back to the dashboard.

Generating the OTAC

I'm using the default ASP.NET MVC template with ASP.NET Identity and have updated my Register action as below:

public async Task<ActionResult> Register(RegisterViewModel model)
{
    if (ModelState.IsValid)
    {
        var user = new ApplicationUser { UserName = model.Email, Email = model.Email };
        var result = await UserManager.CreateAsync(user, model.Password);
        if (result.Succeeded)
        {
            var otac = user.GenerateOTAC(TimeSpan.FromMinutes(1));
            UserManager.Update(user);

            // Redirect to dashboard providing OTAC
            return Redirect("http://localhost:49902/auth/login?otac=" + Url.Encode(otac));
        }

        AddErrors(result);
    }

    // If we got this far, something failed, redisplay form
    return View(model);
}

Here we create the new user and set the OTAC. The OTAC generation is handled directly inside my user class:

public class ApplicationUser : IdentityUser
{
    public string OTAC { get; set; }
    public DateTime? OTACExpires { get; set; }

    public string GenerateOTAC(TimeSpan validFor)
    {
        var otac = CryptoRandom.CreateUniqueId();
        var hashed = Crypto.Hash(otac);
        OTAC = hashed;
        OTACExpires = DateTime.UtcNow.Add(validFor);

        return otac;
    }

    // ... ommitted for brevity
}

This makes use of some of the helpers from the IdentityModel package to generate a unique identifier and hash the value before it is stored. The unhashed value is returned to our controller and passed in the URL when redirecting to the dashboard.

Sending the OTAC to Identity Server

Identity Server provides the acr_values parameter to provide additional authentication information to the user service. We'll use this to send our OTAC.

After registration the user is redirected to the Dashboard login page. Here we check to see if an OTAC is provided and if so, add it to the OWIN context. This will be later retrieved before sending the authentication request to Identity Server:

public void LogIn(string otac = null)
{
    var ctx = HttpContext.GetOwinContext();

    if (!string.IsNullOrEmpty(otac))
    {
        ctx.Set("otac", otac);
    }

    var properties = new AuthenticationProperties
    {
        RedirectUri = Url.Action("index", "home", null, Request.Url.Scheme)
    };

    ctx.Authentication.Challenge(properties);
}

To set the acr_values parameter we need to hook into the RedirectToIdentityProvider notification hook provided by the Open ID Connect middleware. In startup.cs:

app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
    Authority = "http://localhost:49788/",
    ClientId = "dashboard",
    RedirectUri = "http://localhost:49902/",
    ResponseType = "id_token token",
    Scope = "openid profile email api.read api.write",
    SignInAsAuthenticationType = "Cookies",
    PostLogoutRedirectUri = "http://localhost:49902/",

    Notifications = new OpenIdConnectAuthenticationNotifications
    {
        RedirectToIdentityProvider = n =>
        {
            if (n.ProtocolMessage.RequestType == OpenIdConnectRequestType.AuthenticationRequest)
            {
                var otac = n.OwinContext.Get<string>("otac");
                if (otac != null)
                {
                    n.ProtocolMessage.AcrValues = otac;
                }
            }

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

RedirectToIdentityProvider is invoked just before we redirect to Identity Server. This is where we are able to customise the request. In the above code we retrieve the OTAC from the Owin Context and set the AcrValues property.

Validating the token and signing the user in

The next step involves customising the default authentication behaviour of Identity Server. Normal authentication requests should work as before, but in the case of post-registration requests, we need to jump in before the default authentication behaviour is executed.

Identity Server defines the IUserService interface to abstract the underlying identity management system being used for users. Rather than implementing this from scratch, and since we're using ASP.NET Identity, we can instead create a class that derives from AspNetIdentityUserService<TUser, TKey>.

To change the default login behaviour we need to override PreAuthenticateAsync:

This method is called before the login page is shown. This allows the user service to determine if the user is already authenticated by some out of band mechanism (e.g. client certificates or trusted headers) and prevent the login page from being shown.

Here is my complete implementation:

public class UserService : AspNetIdentityUserService<ApplicationUser, string>
{
    public UserService(UserManager userManager) : base(userManager)
    {
    }

    public override async Task PreAuthenticateAsync(PreAuthenticationContext context)
    {
        var otac = context.SignInMessage.AcrValues.FirstOrDefault();
        if (otac != null && context.SignInMessage.ClientId == "dashboard")
        {
            var hashed = Crypto.Hash(otac);
            var user = FindUserByOTAC(hashed);

            if (user != null && user.ValidateOTAC(hashed))
            {
                var claims = await GetClaimsFromAccount(user);
                context.AuthenticateResult = new AuthenticateResult(user.Id, user.UserName, claims: claims, authenticationMethod: "oidc");

                // Revoke token
                user.RevokeOTAC();
                await userManager.UpdateAsync(user);

                return;
            }
        }


        await base.PreAuthenticateAsync(context);
    }

    protected async override Task<IEnumerable<Claim>> GetClaimsFromAccount(ApplicationUser user)
    {
        var claims = (await base.GetClaimsFromAccount(user)).ToList();

        if (!string.IsNullOrWhiteSpace(user.UserName))
        {
            claims.Add(new System.Security.Claims.Claim("name", user.UserName));
        }

        return claims;
    }

    private ApplicationUser FindUserByOTAC(string otac)
    {
        return userManager.Users.FirstOrDefault(u => u.OTAC.Equals(otac));
    }
}

In the PreAuthenticateAsync method we check to see if an OTAC is provided and whether the request came from our dashboard. We then attempt to load the user with the provided OTAC and if the code is valid, revoke it, set the AuthenticateResult and short-circuit the request.

OTAC validation and revocation is handled by our User class:

public bool ValidateOTAC(string otac)
{
    if (string.IsNullOrEmpty(otac) || string.IsNullOrEmpty(OTAC))
    {
        return false;
    }

    return OTAC.Equals(otac)
        && OTACExpires != null
        && OTACExpires > DateTime.UtcNow;
}

public void RevokeOTAC()
{
    OTAC = null;
    OTACExpires = null;
}

In order for Identity Server to use our custom user service we need to register it with the service factory. In startup.cs:

var factory = new IdentityServerServiceFactory()
.UseInMemoryClients(Clients.Get())
.UseInMemoryScopes(Scopes.Get());

// Wire up ASP.NET Identity 
factory.Register(new Registration<UserManager>());
factory.Register(new Registration<UserStore>());
factory.Register(new Registration<ApplicationDbContext>());

// Custom User Service
factory.UserService = new Registration<IUserService, UserService>();

The user is transparently signed-in and redirected back to the dashboard.

Demo

To prove that everything is working as described, here's a short demo I recorded. It demonstrates the normal login flow to the dashboard, registration with consent screen disabled and registration with consent screen enabled (just so the flow is more obvious).

Thanks

Special thanks to Dominick Baier, who helped significantly with the above implementation. Sorry it took so long for the blog post!


Ben Foster

About Me

I'm a software engineer and aspiring entrepreneur with 12+ years experience in the tech industry and have worked with startups and SMB’s in areas such as healthcare, recruitment and e-commerce (I even worked in enterprise, once). I founded my first startup Fabrik in 2011.

I'm available for consulting. Just drop me a message on Twitter.

Creative Commons Licence