Repository: WildGums/Blorc.OpenIdConnect
Pull request #773: Implements access token expiration delegating handler
Author: login: alexfdezsauco
Created: 2025-03-18T14:37:18Z | Merged: 2025-03-18T20:21:49Z

This pull request introduces a delegating handler that manages access token expiration for HTTP requests. The handler performs the following:

- Validates the token's expiration timestamp before processing requests.
- Automatically redirects to a sign-out process when the token has expired.
- Ensures a seamless and secure user experience for API interactions.


Diff:
diff --git a/README.md b/README.md
index 95670c5d..caff5539 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,8 @@ The right way to use OpenIdConnect on Blazor.
 var baseUrl = builder.HostEnvironment.BaseAddress;
 builder.Services
     .AddHttpClient<WeatherForecastHttpClient>(client => client.BaseAddress = new Uri(baseUrl))
-    .AddAccessToken();
+    .AddAccessToken()
+    .AddAccessTokenExpiration();
 
 // Registering required services
 builder.Services.AddBlorcCore();
diff --git a/src/Blorc.OpenIdConnect.DemoApp.Server/Controllers/WeatherForecastController.cs b/src/Blorc.OpenIdConnect.DemoApp.Server/Controllers/WeatherForecastController.cs
index d1ba9eba..f35e7a2e 100644
--- a/src/Blorc.OpenIdConnect.DemoApp.Server/Controllers/WeatherForecastController.cs
+++ b/src/Blorc.OpenIdConnect.DemoApp.Server/Controllers/WeatherForecastController.cs
@@ -8,7 +8,6 @@
 
     using Microsoft.AspNetCore.Authorization;
     using Microsoft.AspNetCore.Mvc;
-    using Microsoft.Extensions.Logging;
 
     [ApiController]
     [Route("[controller]")]
diff --git a/src/Blorc.OpenIdConnect.DemoApp/Program.cs b/src/Blorc.OpenIdConnect.DemoApp/Program.cs
index fdeb756e..35fa36e2 100644
--- a/src/Blorc.OpenIdConnect.DemoApp/Program.cs
+++ b/src/Blorc.OpenIdConnect.DemoApp/Program.cs
@@ -14,7 +14,8 @@
 builder.Services
     .AddHttpClient<WeatherForecastHttpClient>(client => client.BaseAddress = new Uri(baseUrl))
     // Add access token using this
-    .AddAccessToken();
+    .AddAccessToken()
+    .AddAccessTokenExpiration();
 
     // Or using this 
     // .CustomizeHttpRequestMessage(
diff --git a/src/Blorc.OpenIdConnect.Tests/Services/AccessTokenDelegatingHandlerFacts.cs b/src/Blorc.OpenIdConnect.Tests/Services/AccessTokenDelegatingHandlerFacts.cs
new file mode 100644
index 00000000..7cadf02a
--- /dev/null
+++ b/src/Blorc.OpenIdConnect.Tests/Services/AccessTokenDelegatingHandlerFacts.cs
@@ -0,0 +1,74 @@
+﻿namespace Blorc.OpenIdConnect.Tests.Services
+{
+    using System;
+    using System.Net;
+    using System.Net.Http;
+    using System.Text.Json;
+    using System.Threading;
+    using System.Threading.Tasks;
+    using Moq;
+    using Moq.Protected;
+    using NUnit.Framework;
+
+    [TestFixture]
+    public class AccessTokenDelegatingHandlerFact
+    {
+        [TestFixture]
+        public class The_SendAsync_Method
+        {
+            [Test]
+            public async Task Sets_The_Access_Token_As_Authorization_Header_Async()
+            {
+                // Arrange
+                var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
+
+                using var httpResponseMessage = new HttpResponseMessage
+                {
+                    StatusCode = HttpStatusCode.OK,
+                    Content = new StringContent("Fake response")
+                };
+
+                mockHttpMessageHandler.Protected()
+                    .Setup<Task<HttpResponseMessage>>("SendAsync",
+                        ItExpr.IsAny<HttpRequestMessage>(),
+                        ItExpr.IsAny<CancellationToken>())
+                    .ReturnsAsync(httpResponseMessage);
+
+                var userManagerMock = new Mock<IUserManager>();
+                var user = new User<Profile>
+                {
+                    AccessToken = Guid.NewGuid().ToString(),
+                };
+
+                userManagerMock.Setup(userManager => userManager.GetUserAsync<User<Profile>>(It.IsAny<bool>(), It.IsAny<JsonSerializerOptions>())).ReturnsAsync(user);
+
+                using var handler = new TestableAccessTokenDelegatingHandler(userManagerMock.Object);
+                handler.InnerHandler = mockHttpMessageHandler.Object;
+
+                using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com");
+
+                // Act
+                await handler.SendAsync(request, CancellationToken.None);
+
+                // Assert
+                Assert.That(request.Headers.Authorization, Is.Not.Null);
+                Assert.That(request.Headers.Authorization.Scheme, Is.EqualTo("Bearer"));
+                Assert.That(request.Headers.Authorization.Parameter, Is.EqualTo(user.AccessToken));
+            }
+
+            private sealed class TestableAccessTokenDelegatingHandler : AccessTokenDelegatingHandler
+            {
+                public TestableAccessTokenDelegatingHandler(IUserManager userManager)
+                    : base(userManager) { }
+
+                // NOTE: The 'new' keyword is used here to define the 'SendAsync'
+                // method with the same name as the protected method in the base class,
+                // but to make it publicly accessible within this testable implementation.
+                public new Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+                {
+                    return base.SendAsync(request, cancellationToken);
+                }
+            }
+        }
+    }
+}
diff --git a/src/Blorc.OpenIdConnect.Tests/Services/AccessTokenExpirationDelegatingHandlerFacts.cs b/src/Blorc.OpenIdConnect.Tests/Services/AccessTokenExpirationDelegatingHandlerFacts.cs
new file mode 100644
index 00000000..01373f60
--- /dev/null
+++ b/src/Blorc.OpenIdConnect.Tests/Services/AccessTokenExpirationDelegatingHandlerFacts.cs
@@ -0,0 +1,105 @@
+﻿namespace Blorc.OpenIdConnect.Tests.Services
+{
+    using System;
+    using System.Net;
+    using System.Net.Http;
+    using System.Text.Json;
+    using System.Threading;
+    using System.Threading.Tasks;
+    using Moq;
+    using Moq.Protected;
+    using NUnit.Framework;
+
+    [TestFixture]
+    public class AccessTokenExpirationDelegatingHandlerFacts
+    {
+        [TestFixture]
+        public class The_SendAsync_Method
+        {
+            [Test]
+            public async Task Calls_SignOutRedirectAsync_When_The_Token_Is_Expired_Async()
+            {
+                // Arrange
+                var userManagerMock = new Mock<IUserManager>();
+                var now = DateTimeOffset.UtcNow;
+                var user = new User<Profile>
+                {
+                    ExpiresAt = now.AddSeconds(-10).ToUnixTimeSeconds()  // Expired token
+                };
+
+                userManagerMock.Setup(userManager => userManager.GetUserAsync<User<Profile>>(It.IsAny<bool>(), It.IsAny<JsonSerializerOptions>())).ReturnsAsync(user);
+
+                var timeProviderMock = new Mock<TimeProvider>();
+                timeProviderMock.Setup(provider => provider.GetUtcNow()).Returns(now);
+
+                using var handler = new TestableAccessTokenExpirationDelegatingHandler(userManagerMock.Object, timeProviderMock.Object);
+
+                using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com");
+
+                // Act
+                using var response = await handler.SendAsync(request, CancellationToken.None);
+
+                // Assert
+                userManagerMock.Verify(um => um.SignOutRedirectAsync(), Times.Once);
+                Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
+                Assert.That(response.ReasonPhrase, Is.EqualTo("Access token expired"));
+            }
+
+            [Test]
+            public async Task Does_Not_Call_SignOutRedirectAsync_When_The_Token_Is_Not_Expired_Async()
+            {
+                // Arrange
+                var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
+
+                using var httpResponseMessage = new HttpResponseMessage
+                {
+                    StatusCode = HttpStatusCode.OK,
+                    Content = new StringContent("Fake response")
+                };
+
+                mockHttpMessageHandler.Protected()
+                .Setup<Task<HttpResponseMessage>>("SendAsync",
+                    ItExpr.IsAny<HttpRequestMessage>(),
+                    ItExpr.IsAny<CancellationToken>())
+                    .ReturnsAsync(httpResponseMessage);
+
+                var userManagerMock = new Mock<IUserManager>();
+                var now = DateTimeOffset.UtcNow;
+                var user = new User<Profile>
+                {
+                    ExpiresAt = now.AddSeconds(10).ToUnixTimeSeconds() 
+                };
+
+                userManagerMock.Setup(userManager => userManager.GetUserAsync<User<Profile>>(It.IsAny<bool>(), It.IsAny<JsonSerializerOptions>())).ReturnsAsync(user);
+
+                var timeProviderMock = new Mock<TimeProvider>();
+                timeProviderMock.Setup(provider => provider.GetUtcNow()).Returns(now);
+
+                using var handler = new TestableAccessTokenExpirationDelegatingHandler(userManagerMock.Object, timeProviderMock.Object);
+                handler.InnerHandler = mockHttpMessageHandler.Object;
+
+                using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com");
+
+                // Act
+                await handler.SendAsync(request, CancellationToken.None);
+
+                // Assert
+                userManagerMock.Verify(um => um.SignOutRedirectAsync(), Times.Never);
+            }
+
+            private sealed class TestableAccessTokenExpirationDelegatingHandler : AccessTokenExpirationDelegatingHandler
+            {
+                public TestableAccessTokenExpirationDelegatingHandler(IUserManager userManager, TimeProvider timeProvider)
+                    : base(userManager, timeProvider) { }
+
+                // NOTE: The 'new' keyword is used here to define the 'SendAsync'
+                // method with the same name as the protected method in the base class,
+                // but to make it publicly accessible within this testable implementation.
+                public new Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+                {
+                    return base.SendAsync(request, cancellationToken);
+                }
+            }
+        }
+    }
+}
diff --git a/src/Blorc.OpenIdConnect/Services/AccessTokenExpirationDelegatingHandler.cs b/src/Blorc.OpenIdConnect/Services/AccessTokenExpirationDelegatingHandler.cs
new file mode 100644
index 00000000..0c77fda9
--- /dev/null
+++ b/src/Blorc.OpenIdConnect/Services/AccessTokenExpirationDelegatingHandler.cs
@@ -0,0 +1,43 @@
+﻿namespace Blorc.OpenIdConnect;
+
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+public class AccessTokenExpirationDelegatingHandler : DelegatingHandler
+{
+    private readonly IUserManager _userManager;
+    private readonly TimeProvider _timeProvider;
+
+    public AccessTokenExpirationDelegatingHandler(IUserManager userManager, TimeProvider timeProvider)
+    {
+        ArgumentNullException.ThrowIfNull(userManager);
+        ArgumentNullException.ThrowIfNull(timeProvider);
+
+        _userManager = userManager;
+        _timeProvider = timeProvider;
+    }
+
+    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+    {
+        ArgumentNullException.ThrowIfNull(request);
+
+        var user = await _userManager.GetUserAsync<User<Profile>>();
+        if (user?.ExpiresAt is not null)
+        {
+            var expiryDateTime = DateTimeOffset.FromUnixTimeSeconds(user.ExpiresAt).UtcDateTime;
+            if (_timeProvider.GetUtcNow() > expiryDateTime)
+            {
+                await _userManager.SignOutRedirectAsync();
+                return new HttpResponseMessage(HttpStatusCode.Unauthorized)
+                {
+                    ReasonPhrase = "Access token expired"
+                };
+            }
+        }
+
+        return await base.SendAsync(request, cancellationToken);
+    }
+}
diff --git a/src/Blorc.OpenIdConnect/Services/Extensions/HttpClientBuilderExtensions.cs b/src/Blorc.OpenIdConnect/Services/Extensions/HttpClientBuilderExtensions.cs
index 75130900..dce486fe 100644
--- a/src/Blorc.OpenIdConnect/Services/Extensions/HttpClientBuilderExtensions.cs
+++ b/src/Blorc.OpenIdConnect/Services/Extensions/HttpClientBuilderExtensions.cs
@@ -7,19 +7,26 @@
 
     public static class HttpClientBuilderExtensions
     {
-        public static void AddAccessToken(this IHttpClientBuilder @this)
+        public static IHttpClientBuilder AddAccessToken(this IHttpClientBuilder @this)
         {
             ArgumentNullException.ThrowIfNull(@this);
 
-            @this.AddHttpMessageHandler<AccessTokenDelegatingHandler>();
+            return @this.AddHttpMessageHandler<AccessTokenDelegatingHandler>();
         }
 
-        public static void CustomizeHttpRequestMessage(this IHttpClientBuilder @this, Func<IServiceProvider, HttpRequestMessage, Task> customizationRequest)
+        public static IHttpClientBuilder AddAccessTokenExpiration(this IHttpClientBuilder @this)
+        {
+            ArgumentNullException.ThrowIfNull(@this);
+
+            return @this.AddHttpMessageHandler<AccessTokenExpirationDelegatingHandler>();
+        }
+
+        public static IHttpClientBuilder CustomizeHttpRequestMessage(this IHttpClientBuilder @this, Func<IServiceProvider, HttpRequestMessage, Task> customizationRequest)
         {
             ArgumentNullException.ThrowIfNull(@this);
             ArgumentNullException.ThrowIfNull(customizationRequest);
 
-            @this.AddHttpMessageHandler(provider => ActivatorUtilities.CreateInstance<CustomizeHttpRequestMessageDelegatingHandler>(provider, customizationRequest));
+            return @this.AddHttpMessageHandler(provider => ActivatorUtilities.CreateInstance<CustomizeHttpRequestMessageDelegatingHandler>(provider, customizationRequest));
         }
     }
 }
diff --git a/src/Blorc.OpenIdConnect/Services/Extensions/ServiceCollectionExtensions.cs b/src/Blorc.OpenIdConnect/Services/Extensions/ServiceCollectionExtensions.cs
index 19c7784b..f7622be3 100644
--- a/src/Blorc.OpenIdConnect/Services/Extensions/ServiceCollectionExtensions.cs
+++ b/src/Blorc.OpenIdConnect/Services/Extensions/ServiceCollectionExtensions.cs
@@ -1,9 +1,10 @@
 ﻿namespace Blorc.OpenIdConnect
 {
     using System;
-
+    using Blorc.OpenIdConnect;
     using Microsoft.AspNetCore.Components.Authorization;
     using Microsoft.Extensions.DependencyInjection;
+    using Microsoft.Extensions.DependencyInjection.Extensions;
 
     public static class ServiceCollectionExtensions
     {
@@ -11,9 +12,11 @@ public static void AddBlorcOpenIdConnect(this IServiceCollection services, Actio
         {
             ArgumentNullException.ThrowIfNull(services);
 
+            services.TryAddSingleton(TimeProvider.System);
             services.AddSingleton<IUserManager, UserManager>();
             services.AddSingleton<AuthenticationStateProvider, OpenIdConnectAuthenticationStateProvider>();
             services.AddScoped<AccessTokenDelegatingHandler>();
+            services.AddScoped<AccessTokenExpirationDelegatingHandler>();
 
             if (configure is not null)
             {


Reviews:
user: login: GeertvanHorrik
body: **Excellent** PR, thank you very much! Please see the (minor) comments. If done, I will release a new version ASAP.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-18T15:33:25Z
user: login: alexfdezsauco
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T17:02:12Z
user: login: GeertvanHorrik
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T17:07:17Z
user: login: alexfdezsauco
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T17:12:03Z
user: login: GeertvanHorrik
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-18T19:06:21Z
user: login: alexfdezsauco
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T19:34:21Z
user: login: alexfdezsauco
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T19:38:46Z
user: login: alexfdezsauco
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T20:12:51Z
user: login: GeertvanHorrik
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T20:21:23Z
user: login: GeertvanHorrik
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-18T20:21:40Z

Review comments:
diff_hunk: @@ -0,0 +1,35 @@
+﻿namespace Blorc.OpenIdConnect;
+
+using System;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+public class AccessTokenExpirationDelegatingHandler : DelegatingHandler
+{
+    private readonly IUserManager _userManager;
+
+    public AccessTokenExpirationDelegatingHandler(IUserManager userManager)
+    {
+        ArgumentNullException.ThrowIfNull(userManager);
+
+        _userManager = userManager;
+    }
+
+    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+    {
+        ArgumentNullException.ThrowIfNull(request);
+
+        var user = await _userManager.GetUserAsync<User<Profile>>();
+        if (user?.ExpiresAt is not null)
+        {
+            var expiryDateTime = DateTimeOffset.FromUnixTimeSeconds(user.ExpiresAt).UtcDateTime;
+            if (DateTime.UtcNow > expiryDateTime)
path: src/Blorc.OpenIdConnect/Services/AccessTokenExpirationDelegatingHandler.cs
user: login: GeertvanHorrik
body: Please use ITimeProvider:

https://learn.microsoft.com/en-us/dotnet/api/system.timeprovider?view=net-9.0

If it's not yet available in older fx, we need to consider dropping these since the only supported fx are net 8 and 9.
created_at: 2025-03-18T15:29:42Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,56 @@
+﻿namespace Blorc.OpenIdConnect.Tests.Services
+{
+    using System;
+    using System.Collections.Generic;
+    using System.Linq;
+    using System.Net.Http;
+    using System.Text;
+    using System.Text.Json;
+    using System.Threading;
+    using System.Threading.Tasks;
+    using Moq;
+    using NUnit.Framework;
+
+    [TestFixture]
+    public class AccessTokenExpirationDelegatingHandlerFacts
+    {
+        [TestFixture]
+        public class The_SendAsync_Method
+        {
+            [Test]
+            public async Task Calls_SignOutRedirectAsync_When_The_Token_Is_Expired_Async()
+            {
+                // Arrange
+                var userManagerMock = new Mock<IUserManager>();
+                var user = new User<Profile>
+                {
+                    ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(-10).ToUnixTimeSeconds()  // Expired token
+                };
+
+                userManagerMock.Setup(userManager => userManager.GetUserAsync<User<Profile>>(It.IsAny<bool>(), It.IsAny<JsonSerializerOptions>())).ReturnsAsync(user);
+
+                using var handler = new TestableAccessTokenExpirationDelegatingHandler(userManagerMock.Object);
+                handler.InnerHandler = new HttpClientHandler();
+
+                using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com");
+
+                // Act
+                await handler.SendAsync(request, CancellationToken.None);
+
+                // Assert
+                userManagerMock.Verify(um => um.SignOutRedirectAsync(), Times.Once);
+            }
+
+            private class TestableAccessTokenExpirationDelegatingHandler : AccessTokenExpirationDelegatingHandler
+            {
+                public TestableAccessTokenExpirationDelegatingHandler(IUserManager userManager)
+                    : base(userManager) { }
+
+                public new Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
path: src/Blorc.OpenIdConnect.Tests/Services/AccessTokenExpirationDelegatingHandlerFacts.cs
user: login: GeertvanHorrik
body: Please add a note here to explain why the new (probably to make it public vs protected)
created_at: 2025-03-18T15:31:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,56 @@
+﻿namespace Blorc.OpenIdConnect.Tests.Services
+{
+    using System;
+    using System.Collections.Generic;
+    using System.Linq;
+    using System.Net.Http;
+    using System.Text;
+    using System.Text.Json;
+    using System.Threading;
+    using System.Threading.Tasks;
+    using Moq;
+    using NUnit.Framework;
+
+    [TestFixture]
+    public class AccessTokenExpirationDelegatingHandlerFacts
+    {
+        [TestFixture]
+        public class The_SendAsync_Method
+        {
+            [Test]
+            public async Task Calls_SignOutRedirectAsync_When_The_Token_Is_Expired_Async()
path: src/Blorc.OpenIdConnect.Tests/Services/AccessTokenExpirationDelegatingHandlerFacts.cs
user: login: GeertvanHorrik
body: This is a *great* unit test. Also make sure to add one when the token is not expired so we never introduce a "signout issue" with non-expired tokens.
created_at: 2025-03-18T15:32:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,56 @@
+﻿namespace Blorc.OpenIdConnect.Tests.Services
+{
+    using System;
+    using System.Net.Http;
+    using System.Text.Json;
+    using System.Threading;
+    using System.Threading.Tasks;
+    using Moq;
+    using NUnit.Framework;
+
+    [TestFixture]
+    public class AccessTokenDelegatingHandlerFact
+    {
+        [TestFixture]
+        public class The_SendAsync_Method
+        {
+            [Test]
+            public async Task Sets_The_Access_Token_As_Authorization_Header_Async()
+            {
+                // Arrange
+                var userManagerMock = new Mock<IUserManager>();
+                var user = new User<Profile>
+                {
+                    AccessToken = Guid.NewGuid().ToString(),
+                };
+
+                userManagerMock.Setup(userManager => userManager.GetUserAsync<User<Profile>>(It.IsAny<bool>(), It.IsAny<JsonSerializerOptions>())).ReturnsAsync(user);
+
+                using var handler = new TestableAccessTokenDelegatingHandler(userManagerMock.Object);
+                handler.InnerHandler = new HttpClientHandler();
+
+                using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com");
+
+                // Act
+                await handler.SendAsync(request, CancellationToken.None);
+
+                // Assert
+
+                Assert.That(request.Headers.Authorization, Is.Not.Null);
+                Assert.That(request.Headers.Authorization.Scheme, Is.EqualTo("Bearer"));
+                Assert.That(request.Headers.Authorization.Parameter, Is.EqualTo(user.AccessToken));
+            }
+
+            private class TestableAccessTokenDelegatingHandler : AccessTokenDelegatingHandler
+            {
+                public TestableAccessTokenDelegatingHandler(IUserManager userManager)
+                    : base(userManager) { }
+
+                public new Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
path: src/Blorc.OpenIdConnect.Tests/Services/AccessTokenDelegatingHandlerFacts.cs
user: login: GeertvanHorrik
body: Explain why the new, see other comment
created_at: 2025-03-18T15:32:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,56 @@
+﻿namespace Blorc.OpenIdConnect.Tests.Services
+{
+    using System;
+    using System.Collections.Generic;
+    using System.Linq;
+    using System.Net.Http;
+    using System.Text;
+    using System.Text.Json;
+    using System.Threading;
+    using System.Threading.Tasks;
+    using Moq;
+    using NUnit.Framework;
+
+    [TestFixture]
+    public class AccessTokenExpirationDelegatingHandlerFacts
+    {
+        [TestFixture]
+        public class The_SendAsync_Method
+        {
+            [Test]
+            public async Task Calls_SignOutRedirectAsync_When_The_Token_Is_Expired_Async()
path: src/Blorc.OpenIdConnect.Tests/Services/AccessTokenExpirationDelegatingHandlerFacts.cs
user: login: alexfdezsauco
body: I don't think it's necessary. 

We currently have two handlers: one for appending the token and another for checking its expiration. This separation of concerns ensures clear distinctions in responsibilities and prevents any unintended behavior.

To add the handlers we have to do this 

    builder.Services
        .AddHttpClient<WeatherForecastHttpClient>(client => client.BaseAddress = new Uri(baseUrl))
        .AddAccessToken()
        .AddAccessTokenExpiration();

Will also update the readme.md


created_at: 2025-03-18T17:02:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,56 @@
+﻿namespace Blorc.OpenIdConnect.Tests.Services
+{
+    using System;
+    using System.Collections.Generic;
+    using System.Linq;
+    using System.Net.Http;
+    using System.Text;
+    using System.Text.Json;
+    using System.Threading;
+    using System.Threading.Tasks;
+    using Moq;
+    using NUnit.Framework;
+
+    [TestFixture]
+    public class AccessTokenExpirationDelegatingHandlerFacts
+    {
+        [TestFixture]
+        public class The_SendAsync_Method
+        {
+            [Test]
+            public async Task Calls_SignOutRedirectAsync_When_The_Token_Is_Expired_Async()
path: src/Blorc.OpenIdConnect.Tests/Services/AccessTokenExpirationDelegatingHandlerFacts.cs
user: login: GeertvanHorrik
body: But if a token is *not* expired, it should not sign out. That part is not covered by tests.
created_at: 2025-03-18T17:07:17Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,77 @@
+﻿namespace Blorc.OpenIdConnect.Tests.Services
+{
+    using System;
+    using System.Net;
+    using System.Net.Http;
+    using System.Text.Json;
+    using System.Threading;
+    using System.Threading.Tasks;
+    using Moq;
+    using Moq.Protected;
+    using NUnit.Framework;
+
+    [TestFixture]
+    public class AccessTokenDelegatingHandlerFact
+    {
+        [TestFixture]
+        public class The_SendAsync_Method
+        {
+            [Test]
+            public async Task Sets_The_Access_Token_As_Authorization_Header_Async()
+            {
+                // Arrange
+                var mockHttpMessageHandler = new Mock<HttpMessageHandler>();
+
+                using var httpResponseMessage = new HttpResponseMessage
+                {
+                    StatusCode = HttpStatusCode.OK,
+                    Content = new StringContent("Fake response")
+                };
+
+                mockHttpMessageHandler.Protected()
+                    .Setup<Task<HttpResponseMessage>>("SendAsync",
+                        ItExpr.IsAny<HttpRequestMessage>(),
+                        ItExpr.IsAny<CancellationToken>())
+                    .ReturnsAsync(httpResponseMessage);
+
+                var userManagerMock = new Mock<IUserManager>();
+                var user = new User<Profile>
+                {
+                    AccessToken = Guid.NewGuid().ToString(),
+                };
+
+                userManagerMock.Setup(userManager => userManager.GetUserAsync<User<Profile>>(It.IsAny<bool>(), It.IsAny<JsonSerializerOptions>())).ReturnsAsync(user);
+
+                using var handler = new TestableAccessTokenDelegatingHandler(userManagerMock.Object);
+                handler.InnerHandler = mockHttpMessageHandler.Object;
+
+                using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com");
+
+                // Act
+                await handler.SendAsync(request, CancellationToken.None);
+
+                // Assert
+
+                Assert.That(request.Headers.Authorization, Is.Not.Null);
+                Assert.That(request.Headers.Authorization.Scheme, Is.EqualTo("Bearer"));
+                Assert.That(request.Headers.Authorization.Parameter, Is.EqualTo(user.AccessToken));
+            }
+
+            private sealed class TestableAccessTokenDelegatingHandler : AccessTokenDelegatingHandler
+            {
+                public TestableAccessTokenDelegatingHandler(IUserManager userManager)
+                    : base(userManager) { }
+
+                // NOTE: The 'new' keyword is used here to define the 'SendAsync'
+                // method with the same name as the protected method in the base class,
+                // but to make it publicly accessible within this testable implementation.
+                public new Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+                {
+                    return base.SendAsync(request, cancellationToken);
+                }
+            }
+        }
+    }
+
path: src/Blorc.OpenIdConnect.Tests/Services/AccessTokenDelegatingHandlerFacts.cs
user: login: GeertvanHorrik
body: Remove empty lines
created_at: 2025-03-18T17:08:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,56 @@
+﻿namespace Blorc.OpenIdConnect.Tests.Services
+{
+    using System;
+    using System.Collections.Generic;
+    using System.Linq;
+    using System.Net.Http;
+    using System.Text;
+    using System.Text.Json;
+    using System.Threading;
+    using System.Threading.Tasks;
+    using Moq;
+    using NUnit.Framework;
+
+    [TestFixture]
+    public class AccessTokenExpirationDelegatingHandlerFacts
+    {
+        [TestFixture]
+        public class The_SendAsync_Method
+        {
+            [Test]
+            public async Task Calls_SignOutRedirectAsync_When_The_Token_Is_Expired_Async()
path: src/Blorc.OpenIdConnect.Tests/Services/AccessTokenExpirationDelegatingHandlerFacts.cs
user: login: alexfdezsauco
body: My mistake, sorry
created_at: 2025-03-18T17:12:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -19,6 +19,7 @@
     </PackageReference>
     <PackageReference Include="Keycloak.Net" Version="1.0.18" />
     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
+    <PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="8.10.0" />
path: src/Blorc.OpenIdConnect.Tests/Blorc.OpenIdConnect.Tests.csproj
user: login: GeertvanHorrik
body: Is it worth adding this package? Or would Mock<ITimeProvider> work as well? I'll leave it up to your expertise.
created_at: 2025-03-18T18:41:50Z
author_association: MEMBER
diff_hunk: @@ -11,9 +11,11 @@ public static void AddBlorcOpenIdConnect(this IServiceCollection services, Actio
         {
             ArgumentNullException.ThrowIfNull(services);
 
+            services.AddSingleton(TimeProvider.System);
path: src/Blorc.OpenIdConnect/Services/Extensions/ServiceCollectionExtensions.cs
user: login: GeertvanHorrik
body: TryAddSingleton (only add if it doesn't exist)
created_at: 2025-03-18T19:05:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,39 @@
+﻿namespace Blorc.OpenIdConnect;
+
+using System;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+public class AccessTokenExpirationDelegatingHandler : DelegatingHandler
+{
+    private readonly IUserManager _userManager;
+
path: src/Blorc.OpenIdConnect/Services/AccessTokenExpirationDelegatingHandler.cs
user: login: GeertvanHorrik
body: remove empty line
created_at: 2025-03-18T19:05:17Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,39 @@
+﻿namespace Blorc.OpenIdConnect;
+
+using System;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+public class AccessTokenExpirationDelegatingHandler : DelegatingHandler
+{
+    private readonly IUserManager _userManager;
+
+    private readonly TimeProvider _timeProvider;
+
+    public AccessTokenExpirationDelegatingHandler(IUserManager userManager, TimeProvider timeProvider)
+    {
+        ArgumentNullException.ThrowIfNull(userManager);
+        ArgumentNullException.ThrowIfNull(timeProvider);
+
+        _userManager = userManager;
+        _timeProvider = timeProvider;
+    }
+
+    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+    {
+        ArgumentNullException.ThrowIfNull(request);
+
+        var user = await _userManager.GetUserAsync<User<Profile>>();
+        if (user?.ExpiresAt is not null)
+        {
+            var expiryDateTime = DateTimeOffset.FromUnixTimeSeconds(user.ExpiresAt).UtcDateTime;
+            if (_timeProvider.GetUtcNow() > expiryDateTime)
+            {
+                await _userManager.SignOutRedirectAsync();
path: src/Blorc.OpenIdConnect/Services/AccessTokenExpirationDelegatingHandler.cs
user: login: GeertvanHorrik
body: should we return here? or continue processing?
created_at: 2025-03-18T19:05:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,39 @@
+﻿namespace Blorc.OpenIdConnect;
+
+using System;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+public class AccessTokenExpirationDelegatingHandler : DelegatingHandler
+{
+    private readonly IUserManager _userManager;
+
+    private readonly TimeProvider _timeProvider;
+
+    public AccessTokenExpirationDelegatingHandler(IUserManager userManager, TimeProvider timeProvider)
+    {
+        ArgumentNullException.ThrowIfNull(userManager);
+        ArgumentNullException.ThrowIfNull(timeProvider);
+
+        _userManager = userManager;
+        _timeProvider = timeProvider;
+    }
+
+    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+    {
+        ArgumentNullException.ThrowIfNull(request);
+
+        var user = await _userManager.GetUserAsync<User<Profile>>();
+        if (user?.ExpiresAt is not null)
+        {
+            var expiryDateTime = DateTimeOffset.FromUnixTimeSeconds(user.ExpiresAt).UtcDateTime;
+            if (_timeProvider.GetUtcNow() > expiryDateTime)
+            {
+                await _userManager.SignOutRedirectAsync();
path: src/Blorc.OpenIdConnect/Services/AccessTokenExpirationDelegatingHandler.cs
user: login: alexfdezsauco
body: Good catch! I had the same doubt, but since the return will never be executed (redirect), So, I think it's 'fine' in this way.
created_at: 2025-03-18T19:34:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,39 @@
+﻿namespace Blorc.OpenIdConnect;
+
+using System;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+public class AccessTokenExpirationDelegatingHandler : DelegatingHandler
+{
+    private readonly IUserManager _userManager;
+
+    private readonly TimeProvider _timeProvider;
+
+    public AccessTokenExpirationDelegatingHandler(IUserManager userManager, TimeProvider timeProvider)
+    {
+        ArgumentNullException.ThrowIfNull(userManager);
+        ArgumentNullException.ThrowIfNull(timeProvider);
+
+        _userManager = userManager;
+        _timeProvider = timeProvider;
+    }
+
+    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+    {
+        ArgumentNullException.ThrowIfNull(request);
+
+        var user = await _userManager.GetUserAsync<User<Profile>>();
+        if (user?.ExpiresAt is not null)
+        {
+            var expiryDateTime = DateTimeOffset.FromUnixTimeSeconds(user.ExpiresAt).UtcDateTime;
+            if (_timeProvider.GetUtcNow() > expiryDateTime)
+            {
+                await _userManager.SignOutRedirectAsync();
path: src/Blorc.OpenIdConnect/Services/AccessTokenExpirationDelegatingHandler.cs
user: login: alexfdezsauco
body: Let me try something
created_at: 2025-03-18T19:38:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -19,6 +19,7 @@
     </PackageReference>
     <PackageReference Include="Keycloak.Net" Version="1.0.18" />
     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
+    <PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="8.10.0" />
path: src/Blorc.OpenIdConnect.Tests/Blorc.OpenIdConnect.Tests.csproj
user: login: alexfdezsauco
body: Removed
created_at: 2025-03-18T20:12:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,43 @@
+﻿namespace Blorc.OpenIdConnect;
+
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+public class AccessTokenExpirationDelegatingHandler : DelegatingHandler
+{
+    private readonly IUserManager _userManager;
+    private readonly TimeProvider _timeProvider;
+
+    public AccessTokenExpirationDelegatingHandler(IUserManager userManager, TimeProvider timeProvider)
+    {
+        ArgumentNullException.ThrowIfNull(userManager);
+        ArgumentNullException.ThrowIfNull(timeProvider);
+
+        _userManager = userManager;
+        _timeProvider = timeProvider;
+    }
+
+    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+    {
+        ArgumentNullException.ThrowIfNull(request);
+
+        var user = await _userManager.GetUserAsync<User<Profile>>();
+        if (user?.ExpiresAt is not null)
+        {
+            var expiryDateTime = DateTimeOffset.FromUnixTimeSeconds(user.ExpiresAt).UtcDateTime;
+            if (_timeProvider.GetUtcNow() > expiryDateTime)
+            {
+                await _userManager.SignOutRedirectAsync();
+                return new HttpResponseMessage(HttpStatusCode.Unauthorized)
path: src/Blorc.OpenIdConnect/Services/AccessTokenExpirationDelegatingHandler.cs
user: login: GeertvanHorrik
body: Perfect!
created_at: 2025-03-18T20:21:23Z
author_association: MEMBER

PR comments:
user: login: alexfdezsauco
created_at: 2025-03-18T14:56:47Z
body: @GeertvanHorrik there is something wrong in the build action 

Take a look

          ERROR: (,): System.Func`1[System.String]: Fody: Failed to update schema for (D:\a\Blorc.OpenIdConnect\Blorc.OpenIdConnect\src\Blorc.OpenIdConnect\FodyWeavers.xml). Exception message: The process cannot access the file 'D:\a\Blorc.OpenIdConnect\Blorc.OpenIdConnect\src\Blorc.OpenIdConnect\FodyWeavers.xsd' because it is being used by another process.
author_association: CONTRIBUTOR
user: login: GeertvanHorrik
created_at: 2025-03-18T15:25:01Z
body: > @GeertvanHorrik there is something wrong in the build action
> 
> Take a look
> 
> ```
>       ERROR: (,): System.Func`1[System.String]: Fody: Failed to update schema for (D:\a\Blorc.OpenIdConnect\Blorc.OpenIdConnect\src\Blorc.OpenIdConnect\FodyWeavers.xml). Exception message: The process cannot access the file 'D:\a\Blorc.OpenIdConnect\Blorc.OpenIdConnect\src\Blorc.OpenIdConnect\FodyWeavers.xsd' because it is being used by another process.
> ```

I think this requires a restart of Visual Studio / PC. Probably MSBuild is locking the file. Will review the PR ASAP.
author_association: MEMBER
user: login: alexfdezsauco
created_at: 2025-03-18T17:04:03Z
body: > > @GeertvanHorrik there is something wrong in the build action
> > Take a look
> > ```
> >       ERROR: (,): System.Func`1[System.String]: Fody: Failed to update schema for (D:\a\Blorc.OpenIdConnect\Blorc.OpenIdConnect\src\Blorc.OpenIdConnect\FodyWeavers.xml). Exception message: The process cannot access the file 'D:\a\Blorc.OpenIdConnect\Blorc.OpenIdConnect\src\Blorc.OpenIdConnect\FodyWeavers.xsd' because it is being used by another process.
> > ```
> 
> I think this requires a restart of Visual Studio / PC. Probably MSBuild is locking the file. Will review the PR ASAP.

Is falling again
author_association: CONTRIBUTOR