If you have a website that uses Open ID Connect for login, you may want to allow the user to be logged in directly after having validated their e-mail address and having created their password.
If you are using IdentityServer 4 you may be confused by the hits you get on the interwebs. I was, so I shall – mostly for my own sake – write down what is what, should I stumble upon this again.
OIDC login flow primer
There are several Open ID authentication flows depending on if you are protecting an API, a mobile native app or a browser-based web app. Most flows basically work in such a way that you navigate to the site that you need to be logged in to access. It discovers that you aren’t logged in (most often – you don’t have the cookie set) and redirects you to its STS, IdentityServer4 in this case, and with this request it tells identityserver4 what site it is (client_id), the scopes it wants and how it wants to receive the tokens. IdentityServer4 will either just return the token (the user was already logged in elsewhere) or get the information it needs from the end user (username, password, biometrics, whatever you want to support) and eventually if this authentication is successful, the IdentityServer will return some tokens and the original website will happily set an authentication token and let you in.
The point is – you have to first go where you want, you can’t just navigate to the login screen, you need the context of having been redirected from the app you want to use for the login flow to work. As a sidenote, this means your end users can wreak havoc unto themselves with favourites/ bookmarks capturing login context that has long expired.
Registration
You want to give users a simple on-boarding procedure, a few textboxes where they can type in email and password, or maybe invite people via e-mail and let them set up their password and then become logged in. How do we make that work with the above flows?
The canonical blog post on this topic seems to be this one: https://benfoster.io/blog/identity-server-post-registration-sign-in/. Although brilliant, it is only partially helpful as it covers IdentityServer3, and the newer one is a lot different. Based on ASP.NET Core, for instance.
- The core idea is sound – generate a cryptographically random one-time access code and map against the user after the user has been created in the registration page. (In IdentityServer4)
- Create an anonymous endpoint in a controller in one of the apps the user will be allowed to use, in it, ascertain that you have been sent one of those codes, then Challenge the OIDC authentication flow, adding this code as an AcrValue as the request goes back to the IdentityServer4
- Extend the authentication system to allow these temporary codes to log you in.
To address the IdentityServer3-ness, people have tried all over the internet, here is somebody who get’s it sorted: https://stackoverflow.com/questions/51457213/identity-server-4-auto-login-after-registration-not-working
Concretely you need a few things – the function that creates OTACs, which you can lift from Ben Foster’s blog post. A sidenote, do remember that if you use a cooler password hashing algorithm you have to use special validators rather than rely on applying the hash onto the the same plaintext to validate. I e, you need to fetch the hash from whatever storage you use and use the specific methods the library offers to validate that the hashes are equivalent.
After the OTAC is created, you need to redirect to a controller action in one of the protected websites, passing the OTAC along.
The next job is therefore to create the action.
[AllowAnonymous] public async Task LogIn(string otac) { if (otac is null) Response.Redirect("/Home/Index"); var properties = new AuthenticationProperties { Items = { new KeyValuePair<string, string>("otac", otac) }, RedirectUri = Url.Action("Index", "Home", null, Request.Scheme) }; await Request.HttpContext.ChallengeAsync(ClassLibrary.Middleware.AuthenticationScheme.Oidc, properties); }
After storing the OTAC in the HttpContext, it’s time to actually send the code over the wire, and to do that you need to intercept the calls when the authentication middleware is about to send the request over to IdentityServer. This is done where the call to AddOpenIdConnect happens (maybe yours is in Startup.cs?), where you get to configure options, among which are some event handlers.
OnRedirectToIdentityProvider = async n =>{ n.ProtocolMessage.RedirectUri = redirectUri; if ((n.ProtocolMessage.RequestType == OpenIdConnectRequestType.Authentication) && n.Properties.Items.ContainsKey("otac")) { // Trying to autologin after registration n.ProtocolMessage.AcrValues = n.Properties.Items["otac"]; } await Task.FromResult(0); }
After this – you need to override the AuthorizeInteractionResponseGenerator, get the AcrValues from the request, and – if successful – log the user in, and respond accordingly. Register this class using services.AddAuthorizeInteractionResponseGenerator();
in Startup.cs
Unfortunately, I was still mystified as to how to log things in, in IdentityServer4 as I could not find a SignIn manager used widely in the source code, but then I found this blog post:
https://stackoverflow.com/questions/56216001/login-after-signup-in-identity-server4, and it became clear that using an IHttpContextAccessor was “acceptable”.
public override async Task<InteractionResponse> ProcessInteractionAsync(ValidatedAuthorizeRequest request, ConsentResponse consent = null) { var acrValues = request.GetAcrValues().ToList(); var otac = acrValues.SingleOrDefault(); if (otac != null && request.ClientId == "client") { var user = await _userStore.FindByOtac(otac, CancellationToken.None); if (user is object) { await _userStore.ClearOtac(user.Guid); var svr = new IdentityServerUser(user.SubjectId) { AuthenticationTime = _clock.UtcNow.DateTime }; var claimsPrincipal = svr.CreatePrincipal(); request.Subject = claimsPrincipal; request.RemovePrompt(); await _httpContextAccessor.HttpContext.SignInAsync(claimsPrincipal); return new InteractionResponse { IsLogin = false, IsConsent = false, }; } } return await base.ProcessInteractionAsync(request, consent); }
Anyway, after ironing out the kinks the perceived inconvenience of the flow was greatly reduced. Happy coding!