ℹ️ OIDC = OpenID Connect
The problem
This story starts with a dual bug report.
-
When users submit a form, their input is sometimes lost.
-
After a user logs in, they sometimes get the following error message. If they then reload the page, then they’re logged in just fine.
IDX21323: RequireNonce is ‘[PII is hidden]’. OpenIdConnectProtocolValidationContext.Nonce was null, OpenIdConnectProtocol.ValidatedIdToken.Payload.Nonce was not null. The nonce cannot be validated. If you don’t need to check the nonce, set OpenIdConnectProtocolValidator.RequireNonce to ‘false’. Note if a ‘nonce’ is found it will be evaluated.
The reporter of the bug already expected these problems to be related. Therefore, we quickly suspected that #1 was caused by the user’s session expiring while they were filling out their form.
A bug’s not actionable if you can’t reproduce it, so the first step was to find steps to reproduce.
Reproducing the bug
With the help of the reporter, we were able to clearly reproduce problem #1.
- Go to one the forms on the site.
- Enter some data. Don’t submit yet.
- Wait 5 minutes.
- Submit.
Expected: The user’s input is saved.
Actual: I see my browser flashing with a few redirects and I see the form again, without my changes. No error messages.
The cutoff point wasn’t always exactly 5 minutes, sometimes my form post already failed after waiting slightly shorter than 5 mins. But after waiting 5 mins or more, the form post is lost.
Problem #2 was more elusive. I did see the error message myself, but I was unable to trigger it on command using the site normally. Nor was anyone else able to trigger the problem on command. We therefore decided to park problem #2 and focus on #1.
Our setup
So this is what we were working with. The intended behaviour was that users would need to sign in again, if they left for 30 minutes or more.
Application server: ASP.NET, running on .NET Framework, using Episerver and the OpenID Connect client that comes with OWIN.
Identity provider: ASP.NET, running on .NET Core, using the IdentityServer4 library and a MS Navision backend.
OIDC client & IdentityServer4 settings: anything obviously session-related was left at default values.
ASP.NET session duration: 30 minutes.
Narrowing down the cause
The above all seemed fine, nothing strange-looking there. So we started gathering more info. We were especially on the hunt for something that expires after 5 minutes, since that would very likely be the trigger.
- Find-in-files for
5
(minutes) or300
(seconds) in the application server & identity server code & config
→ No hits. - Maybe the browser’s session cookie expires / gets lost somehow?
→ Nope, it has ‘session’ lifetime, so that’s fine.
- Maybe the application gets recycled in IIS, thereby dropping any session state?
→ Inspected IIS config & attached event listeners to the ‘recycle’ event. It’s not being recycled.
Hm, no leads yet.
Let’s look at what’s happening at the request level, in detail, using generic knowledge of the OIDC protocol.
(green is app server, blue is identity server)
- The browser submits my form data.
→ The app server decides I’m not logged and redirects me to the login URL (/
), discarding my form data. - The login url (
/
) redirects me to the identity server, and passes some OIDC parameters along. - The browser gives the OIDC params to the identity server (nonce, response mode, etc.).
→ The identity server decides I’m still logged in, and instantly sends me back to the application server, passing an authorizationcode
andid_token
along as proof.
Since we’re apparently using theform_post
response mode, the identity server does this by returning a 200 OK with a hidden form as content. This form is automatically submitted, I never actually see it. - The browser POSTs the
code
andid_token
to the application server.
→ The application server verifies these tokens and accepts them. It now considers me logged in. It gives me acustSSO
cookie as proof, and redirects me back where I came from. - (Intermediate redirection step)
- I’m back at the form where I came from. The application has completely forgotten that I even tried to submit something.
For the happy path, i.e. when I submit my data sooner, the data that my browser POSTs is identical. Same form data, same cookies.
Conclusion: So it’s the application server that decides that we’re logged out after 5 mins. We don’t need to look at the identity server or at the back end authentication provider for now; the problem occurs before they even come into play. There’s also nothing weird about what the browser sends, it doesn’t lose cookies or anything.
Let’s dig deeper into the application server.
Application server: scouring for details
We’ve already established (using find-in-files) that there isn’t any explicit setting that causes users to be logged out after 5 mins, but there must be an expiry time somewhere. Let’s debug.
Let’s see if we can’t capture a ‘session end’ event somewhere. I added some event listeners to Global.asax.cs.
Turns out that this captures lots of events. It took some time to sift through the output.
The result:
Nothing remotely related to a session ending after 5 minutes.
I did see the Session_OnEnd
-event getting fired a few times, but I wasn’t able to trigger it on command, so it certainly wasn’t tied to the session expiring after 5 mins.
While inspecting authentication/session-related variables, I did find a few that were set to 5 minutes. Bingo! One of these must be it.
AuthorizationCodeReceivedNotification.JwtSecurityToken.ValidFrom
/ ValidTo
AuthenticationTicket.Properties.IssuedUtc
/ ExpiresUtc
AuthenticationTicket.Identity.Claims
nbf
/ exp
Let’s see what we’ve got here.
- The JWT (JSON Web Token) is valid for 5 mins.
→ This is fine; it only needs to be valid for however long the user needs to return from the ID-server to the App-server. This typically takes under a second, not minutes. It doesn’t need to be valid for longer. - The AuthenticationTicket expires after 5 minutes.
→ This seems highly relevant. - One of the identity claims is exp with a value 5 minutes in the future.
→ It’s related, but I don’t expect anything to actually look at this value. I think it’s just there, and does nothing.
So the ExpiresUtc
of the AuthenticationTicket
is probably what users to be logged out. Where does it get its value from? We’re not setting it to 5 minutes.
…some googling later…
https://docs.microsoft.com/en-us/previous-versions/aspnet/mt180971(v%3Dvs.113)
OpenIdConnectAuthenticationOptions.UseTokenLifetime Property
Indicates that the authentication session lifetime (e.g. cookies) should match that of the authentication token. (…) This is enabled by default.
Aha! So the 5 minutes of the ID-server’s JWT get copied onto the app-server’s AuthenticationTicket. Yeah, that explains it.
Solution
So all we need to do is disable that flag.
app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
{
UseTokenLifetime = false,
A bit disappointing to only change a single line of code after all this work, but oh well. Less is better.
Tested the change. With this disabled, I can indeed keep a form open for longer than 5 minutes and still not lose my changes.
Problem solved. 👍
A few days later
…or so I thought. This came in:
Hi, it seems that user sessions don’t expire at all anymore, not even after 30 minutes.
Whooops. 😬
Let’s verify. Waiting 30 minutes for each test is no fun, so let’s start by dropping the session duration to 1 minute locally. It should then kick us out after 1 minute, let’s see if it does.
<sessionState timeout="1" cookieSameSite="None" />
It doesn’t. The app-server still considers us logged in after a minute.
Bug confirmed.
So what is the AuthenticationTicket’s expiry now, then, if not the same as the session timeout? And if the session timeout doesn’t log the user out, then what does it even do?
Thus far, I’ve only inspected the AuthenticationTicket’s expiry value as the user is being logged in. Now, I’d quite like to see what happens to this value as I use the website. Does it get refreshed? What is the AuthenticationTicket expiry value just before and just after being logged out (with UseTokenLifetime
turned back on)?
It turns out that the AuthenticationTicket is really hard to get a hold of, after sign-in. I eventually got it to work with some custom code.
// Startup.cs
public static ISecureDataFormat<AuthenticationTicket> TicketDataFormat;
(…)
var cookieAuthenticationOptions = new CookieAuthenticationOptions
{
CookieManager = new SameSiteCookieManager(new SystemWebCookieManager()),
CookieName = "custSSO"
};
TicketDataFormat = new TicketDataFormat(app.CreateDataProtector(
typeof(CookieAuthenticationMiddleware).FullName, cookieAuthenticationOptions.AuthenticationType, "v1"
));
// MySitePageViewModel.cs
// https://docs.microsoft.com/en-us/archive/blogs/wushuai/a-look-at-katana-cookie-authentication
public AuthenticationTicket AuthenticationTicket
{
get
{
var cookie = HttpContext.Current.Request.Cookies["custSSO"].Value;
AuthenticationTicket ticket = Startup.TicketDataFormat.Unprotect(cookie);
return ticket;
}
}
Using this debug code, I was able to see the following.
- UseTokenLifetime = false
During sign-in (SecurityTokenValidated), AuthenticationTicket.Properties.ExpiresUtc is null.
While browsing the site, it is set to 14 days in the future.
→ Whoops, so my assumption that it’d default to the session lifetime was false. - UseTokenLifetime = false
During sign-in, AuthenticationTicket.Properties.ExpiresUtc is 5 minutes in the future.
While browsing, it counts down, regardless of whether I’m active or not.
The following it possible:
T=0 sign in
T=4m55s open a form
T=5m05s submit the form
→ redirect to ID-server, form data dropped.
As for the ASP.NET session, I dusted off the public void Session_OnEnd() { LogSession(); }
. It does indeed get called 1 minute after logging in, even without a request. I don’t notice any effect in the browser though - I can just continue using the site, no re-login required.
So none of the options I’ve seen thus far does what we want - require users to log in again if they’re inactive for 30 minutes. Looks like we’ll have to explicitly set the AuthenticationTicket lifetime ourselves.
Solution (real)
// Startup.cs, on SecurityTokenValidated
// By default, OWIN copies the identity token lifetime onto the authentication ticket (UseTokenLifetime = true).
// https://docs.microsoft.com/en-us/previous-versions/aspnet/mt180971(v%3Dvs.113)
// IdentityServer4's default identity token lifetime is 5 minutes without refresh.
// https://docs.identityserver.io/en/dev/reference/client.html#token
// We want to have instead: 30 minutes plus refresh.
var sessionSection = (SessionStateSection) WebConfigurationManager.GetSection("system.web/sessionState");
context.AuthenticationTicket.Properties.ExpiresUtc = DateTimeOffset.UtcNow + sessionSection.Timeout;
context.AuthenticationTicket.Properties.AllowRefresh = true;
This finally solved the problem.
As for problem #2 (IDX21323: RequireNonce), we never fixed that one. Since we didn’t have proper steps to reproduce, we jointly decided that it wasn’t worth the effort.
References
https://stackoverflow.com/questions/2557191/asp-net-2-0-session-timeout