I've set up the following,
- Duende IdentityServer (IDP)
- API
- Blazor Web App (.Net 8)The Web App consists of a Server and Client project.
When logging in I get authenticated and able to retrieve data from the API. All is good. The problem is that I get logged out if the user is idle for about 5 minutes.
I've set up offline_access for the use of reqest token, but whatever changes I try - I still get logged off having 5 min of idle time.
I've even set the access token lifetime to 2 hours on the IDP config as a workaround, but that seems to have no effect. A little bit of idle time and I'm routed back to the login page of the IDP.
AccessTokenLifetime = 7200
IdentityTokenLifetime = 7200
Not quite sure where to look anymore. Anyone who can shed some light on what I am missing?
Here's some of the most relevant setup
--------- Duende IDP Config --------------------------
new Client{ ClientId = "test.blazor.webapp", ClientName = "TestApp", RequireClientSecret = true, AllowedGrantTypes = GrantTypes.Code, RedirectUris = configuration.GetSection("BlazorWebApp:RedirectUris").Get<string[]>(), FrontChannelLogoutUri = configuration.GetSection("BlazorWebApp:FrontChannelLogoutUri").Get<string>(), PostLogoutRedirectUris = configuration.GetSection("BlazorWebApp:PostLogoutRedirectUris").Get<string[]>(), AccessTokenLifetime = 7200, IdentityTokenLifetime = 7200, AllowOfflineAccess = true, AllowedScopes = { "openid", "profile", "roles", "testapi" }},------------Server Blazor-----------------------------------
builder.Services.AddRazorComponents() .AddInteractiveServerComponents() .AddInteractiveWebAssemblyComponents();builder.Services.ConfigureAuthentication(builder);builder.Services.ConfigureCookieOidcRefresh(CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme);builder.Services.AddDistributedMemoryCache();builder.Services.AddCascadingAuthenticationState();builder.Services.AddScoped<AuthenticationStateProvider, PersistingAuthenticationStateProvider>();builder.Services.AddAuthorization();------------Server Blazor (ConfigureAuthentication)-----------------------------------
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>{ options.Authority = builder.Configuration.GetSection("IDP:Authority").Get<string>(); options.ClientId = "test.blazor.webapp"; options.GetClaimsFromUserInfoEndpoint = true; options.MapInboundClaims = false; options.TokenValidationParameters.NameClaimType = JwtRegisteredClaimNames.Name; options.TokenValidationParameters.RoleClaimType = "role"; options.ResponseType = OpenIdConnectResponseType.Code; options.SaveTokens = false; options.Scope.Add(OpenIdConnectScope.OfflineAccess); options.Scope.Add(OpenIdConnectScope.OpenIdProfile); options.Scope.Add("roles"); options.Scope.Add("testapi"); options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.CallbackPath = new PathString("/signin-oidc"); options.SignedOutCallbackPath = new PathString("/signout-callback-oidc"); options.RemoteSignOutPath = new PathString("/signout-oidc");}); builder.Services.AddOpenIdConnectAccessTokenManagement(); return services;}----------------Client Blazor -------------------------------
var builder = WebAssemblyHostBuilder.CreateDefault(args);builder.Services.AddAuthorizationCore();builder.Services.AddCascadingAuthenticationState();builder.Services.AddSingleton<AuthenticationStateProvider, PersistentAuthenticationStateProvider>();builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });await builder.Build().RunAsync();--- PersistentAuthenticationStateProvider.cs (blazor client) ---
internal sealed class PersistentAuthenticationStateProvider : AuthenticationStateProvider{ private static readonly Task<AuthenticationState> defaultUnauthenticatedTask = Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()))); private readonly Task<AuthenticationState> authenticationStateTask = defaultUnauthenticatedTask; public PersistentAuthenticationStateProvider(PersistentComponentState state) { if (!state.TryTakeFromJson<UserInfo>(nameof(UserInfo), out var userInfo) || userInfo is null) { return; } authenticationStateTask = Task.FromResult(new AuthenticationState(userInfo.ToClaimsPrincipal())); }public override Task GetAuthenticationStateAsync() => authenticationStateTask;}
--- PersistingAuthenticationStateProvider.cs (blazor server) -----
internal sealed class PersistingAuthenticationStateProvider : AuthenticationStateProvider, IHostEnvironmentAuthenticationStateProvider, IDisposable{ private const string CLAIM_NAME = "name"; private const string CLAIM_NAMEIDENTIFIER = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier"; private readonly IAppUsersApiService _appUsersApiService; private readonly HttpClient _httpClient; private readonly IUserApiService _userApiService; private readonly PersistentComponentState persistentComponentState; private readonly PersistingComponentStateSubscription subscription; private Task<AuthenticationState>? authenticationStateTask; public PersistingAuthenticationStateProvider(PersistentComponentState state, HttpClient httpClient, IUserApiService userApiService, IAppUsersApiService appUsersApiService) { persistentComponentState = state; subscription = state.RegisterOnPersisting(OnPersistingAsync, RenderMode.InteractiveWebAssembly); _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _userApiService = userApiService ?? throw new ArgumentNullException(nameof(userApiService)); _appUsersApiService = appUsersApiService ?? throw new ArgumentNullException(nameof(appUsersApiService)); } public async override Task<AuthenticationState> GetAuthenticationStateAsync() { if (authenticationStateTask == null) { throw new InvalidOperationException( $"Do not call {nameof(GetAuthenticationStateAsync)} " +"outside of the DI scope for a Razor component. " +"Typically, this means you can call it only within a Razor component or inside another DI service that is resolved for a Razor component." ); } else { if (authenticationStateTask.Result?.User?.Identity?.IsAuthenticated == true) { // Check if user exists, if not create it try { var user = await _appUsersApiService.Get(); } catch (HttpRequestException e) { if (e.StatusCode == System.Net.HttpStatusCode.NotFound) { //User does not exist, create it var email = authenticationStateTask.Result.User.Claims.FirstOrDefault(c => c.Type == CLAIM_NAME)?.Value; var userId = (authenticationStateTask.Result.User.Claims.FirstOrDefault(c => c.Type == CLAIM_NAMEIDENTIFIER)?.Value); if(userId == null) { userId = (authenticationStateTask.Result.User.Claims.FirstOrDefault(c => c.Type == "sub")?.Value); } if(userId == null) { throw new Exception("User id is null or empty"); } var newUser = new AppUserForCreateDto { Email = email, IdentityId = Guid.Parse(userId) }; await _userApiService.CreateUser(newUser); } else { throw; } } } } return authenticationStateTask.Result; } //public override Task<AuthenticationState> GetAuthenticationStateAsync() => // authenticationStateTask ?? throw new InvalidOperationException( // $"Do not call {nameof(GetAuthenticationStateAsync)} " + // "outside of the DI scope for a Razor component. " + // "Typically, this means you can call it only within a Razor component or inside another DI service that is resolved for a Razor component." // ); public void SetAuthenticationState(Task<AuthenticationState> task) { authenticationStateTask = task; } private async Task OnPersistingAsync() { var authenticationState = await GetAuthenticationStateAsync(); var principal = authenticationState.User; if (principal.Identity?.IsAuthenticated == true) { persistentComponentState.PersistAsJson(nameof(UserInfo), UserInfo.FromClaimsPrincipal(principal)); } } public void Dispose() { subscription.Dispose(); }}After more examination, it seems to happen when the server shutdown due to user idle time (which is set to 5 minutes). When restarting on the next request it does not seem to have it's refresh_token available anymore.