We have an SPA, written in React together with ASP.net core for hosting.
To authenticate the app, we are using IdentityServer4 and use a cookie. The client is configured according to the sample, described here: https://github.com/IdentityServer/IdentityServer4/tree/main/samples/Quickstarts/4_JavaScriptClient/src
For authenticating a user, everythings works fine. It will be redirected to the login page. After signing in, redirection to the SPA is done. The authentiation cookie is set as expected with:
The cookie is used also in other MVC (.net core and MVC 5) applications for authentication reasons. In the SPA, we are also using SignalR, which needs the cookie for authentication.
Our issue:
After about 30 minutes of idle time in the browser and either doing a refresh or navigating, the authentication cookie (and only that, other remains) is disappearing from the browser automatically. Then the user has to sign in again. Why does this happen together with the SPA?
Code
Complete code can be found in github
Client
Snippets of UserService.ts
const openIdConnectConfig: UserManagerSettings = {
authority: baseUrls.person,
client_id: "js",
redirect_uri: joinUrl(baseUrls.spa, "signincallback"),
response_type: "code",
scope: "openid offline_access profile Person.Api Translation.Api",
post_logout_redirect_uri: baseUrls.spa,
automaticSilentRenew: true
};
export const getUserService = asFactory(() => {
const userManager = new UserManager(openIdConnectConfig);
return createInstance(createStateHandler(defaultUserState), userManager, createSignInProcess(userManager));
}, sameInstancePerSameArguments());
Server
Snipped of Startup.cs
public void ConfigureServices(IServiceCollection services)
{
Log.Information($"Start configuring services. Environment: {_environment.EnvironmentName}");
services.AddControllersWithViews();
services.AddIdentity<LoginInputModel, RoleDto>()
.AddDefaultTokenProviders();
var certificate = LoadSigningCertificate();
var identityServerBuilder = services.AddIdentityServer(options =>
{
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseSuccessEvents = true;
})
.AddSigningCredential(certificate)
.AddProfileService<ProfileService>()
.AddInMemoryIdentityResources(Config.Ids)
.AddInMemoryApiResources(Config.Apis)
.AddInMemoryClients(new ClientConfigLoader().LoadClients(Configuration));
if (_environment.IsDevelopment())
{
identityServerBuilder.AddDeveloperSigningCredential();
}
services.Configure<CookiePolicyOptions>(options =>
{
options.CheckConsentNeeded = context => false;
options.MinimumSameSitePolicy = SameSiteMode.None;
});
services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(_sharedAuthTicketKeys))
.SetApplicationName("SharedCookieApp");
services.AddAsposeMailLicense(Configuration);
var optionalStartupSettings = SetupStartupSettings();
if (optionalStartupSettings.IsSome)
{
var settings = optionalStartupSettings.Value;
services.ConfigureApplicationCookie(options =>
{
options.AccessDeniedPath = new PathString("/Account/AccessDenied");
options.Cookie.Name = ".AspNetCore.Auth.Cookie";
options.Cookie.Path = "/";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.LoginPath = new PathString("/account/login");
options.Cookie.SameSite = SameSiteMode.None;
});
var authBuilder = services.AddAuthentication(options => { options.DefaultAuthenticateScheme = "Identity.Application"; });
authBuilder = ConfigureSaml2(authBuilder, settings);
authBuilder = ConfigureGoogle(authBuilder);
authBuilder.AddCookie();
}
else
{
throw new InvalidOperationException($"Startup settings are not configured in appsettings.json.");
}
SetupEntityFramework(services);
}
Snippet of identity server client config from appsettings.json
{
"Enabled": true,
"ClientId": "js",
"ClientName": "JavaScript Client",
"AllowedGrantTypes": [ "authorization_code" ],
"RequirePkce": true,
"RequireClientSecret": false,
"RedirectUris": [ "https://dev.myCompany.ch/i/signincallback", "https://dev.myCompany.com/i/signincallback", "https://dev.myCompany.de/i/signincallback" ],
"PostLogoutRedirectUris": [ "https://dev.myCompany.ch/i/", "https://dev.myCompany.com/i/", "https://dev.myCompany.de/i/" ],
"AllowedCorsOrigins": [],
"AllowedScopes": [ "openid", "offline_access", "profile", "Translation.Api", "Person.Api" ],
"RequireConsent": false,
"AllowOfflineAccess": true
}
Update
In the meantime I discovered that the cookie, while requesting https://ourdomain/.well-known/openid-configuration
after 30 minutes idle time, has lost the values of Domain, Path, Expires/Max-Age, HttpOnly, Secure and SameSite.None. Those values have definitely been set after signing in.
The response cookie has the value of Expires/Max-Age set to a time in the past and therefore the cookie will be dropped by the browser.
Has anyone an idea, why those values got lost after some time?
But, we can explicitly modify Cookie headers to make them protected against such attacks. For example, Cookies can be easily protected against XSS attacks by using HttpOnly attribute when setting the Cookie headers. Also, we can use SameSite attribute in the cookie header to prevent CSRF attacks effectively.
Getting the cookie with React hooks First, import the CookiesProvider component from the react-cookie package and wrap your root app component with it. import React from "react"; import ReactDOM from "react-dom"; import { CookiesProvider } from "react-cookie";import App from "./App"; const rootElement = document.
What is Cookie-based Authentication? Cookies are pieces of data used to identify the user and their preferences. The browser returns the cookie to the server every time the page is requested. Specific cookies like HTTP cookies are used to perform cookie-based authentication to maintain the session for each user.
Cookie authentication uses HTTP cookies to authenticate client requests and maintain session information. It works as follows: The client sends a login request to the server.
The React application will hit the Express server for all endpoints. With this method, your front end app is on the same domain, and has a server, allowing you to secure cookies with HttpOnly, Secure, and Same Site options.
The Express server will serve the React SPA from all routes, except those that begin with /api. The React application will hit the Express server for all endpoints. With this method, your front end app is on the same domain, and has a server, allowing you to secure cookies with HttpOnly, Secure, and Same Site options.
A real-world example of the setup: The Express server will serve the React SPA from all routes, except those that begin with /api. The React application will hit the Express server for all endpoints.
Finally, I figured out how to tackle this.
It had to do with the configuration of IdentityServer. The missing part was the method AddAspNetIdentity<LoginInputModel>()
.
Before:
var certificate = LoadSigningCertificate();
var identityServerBuilder = services.AddIdentityServer(options =>
{
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseSuccessEvents = true;
})
.AddSigningCredential(certificate)
.AddProfileService<ProfileService>()
.AddInMemoryIdentityResources(Config.Ids)
.AddInMemoryApiResources(Config.Apis)
.AddInMemoryClients(new ClientConfigLoader().LoadClients(Configuration));
Now:
var certificate = LoadSigningCertificate();
var identityServerBuilder = services.AddIdentityServer(options =>
{
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseSuccessEvents = true;
})
.AddSigningCredential(certificate)
.AddAspNetIdentity<LoginInputModel>()
.AddProfileService<ProfileService>()
.AddInMemoryIdentityResources(Config.Ids)
.AddInMemoryApiResources(Config.Apis)
.AddInMemoryClients(new ClientConfigLoader().LoadClients(Configuration));
With that additional configuration line, Identity Server is handling the cookie correctly.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With