diff --git a/introspection/src/AspNetCore.Authentication.OAuth2Introspection/OAuth2IntrospectionHandler.cs b/introspection/src/AspNetCore.Authentication.OAuth2Introspection/OAuth2IntrospectionHandler.cs index 7ee5bd26..a648283f 100644 --- a/introspection/src/AspNetCore.Authentication.OAuth2Introspection/OAuth2IntrospectionHandler.cs +++ b/introspection/src/AspNetCore.Authentication.OAuth2Introspection/OAuth2IntrospectionHandler.cs @@ -112,9 +112,22 @@ Lazy> GetTokenIntrospectionResponseLazy(string // "If the response contains the "exp" parameter (expiration), the response MUST NOT be cached beyond the time indicated therein." // so we need to cache items ourselves here. There is discussion of adding this to hybrid cache: // https://github.com/dotnet/extensions/issues/6434, https://github.com/dotnet/aspnetcore/issues/56483 - var response = await IntrospectionDictionary - .GetOrAdd(token, GetTokenIntrospectionResponseLazy) - .Value; + TokenIntrospectionResponse response; + try + { + response = await IntrospectionDictionary + .GetOrAdd(token, GetTokenIntrospectionResponseLazy) + .Value; + } + catch + { + // The shared task faulted (e.g. timeout, cancellation, network error). + // Evict the faulted entry so future requests start fresh, then retry + // once with a direct call — bypassing the dictionary — so this request + // is not affected by another request's failure. + IntrospectionDictionary.TryRemove(token, out _); + response = await LoadClaimsForToken(token, Context, Scheme, Events, Options); + } if (response.IsError) { diff --git a/introspection/test/AspNetCore.Authentication.OAuth2Introspection.Tests/Introspection.cs b/introspection/test/AspNetCore.Authentication.OAuth2Introspection.Tests/Introspection.cs index 87a2730c..32d79e0c 100644 --- a/introspection/test/AspNetCore.Authentication.OAuth2Introspection.Tests/Introspection.cs +++ b/introspection/test/AspNetCore.Authentication.OAuth2Introspection.Tests/Introspection.cs @@ -167,6 +167,114 @@ public async Task ActiveToken_WithTwoConcurrentCalls_FirstCancelled_SecondShould result2.StatusCode.ShouldBe(HttpStatusCode.OK); } + [Fact] + public async Task TwoConcurrentCalls_FirstIntrospectFaults_SecondShouldRetryAndSucceed() + { + const string token = "sometoken"; + var waitForFirstIntrospectionToStart = new ManualResetEvent(initialState: false); + var waitForSecondRequestToStart = new ManualResetEvent(initialState: false); + var handler = new IntrospectionEndpointHandler(IntrospectionEndpointHandler.Behavior.Active); + + var requestCount = 0; + + var messageHandler = await PipelineFactory.CreateHandler(o => + { + _options(o); + + o.Events.OnSendingRequest = async context => + { + var count = Interlocked.Increment(ref requestCount); + + if (count == 1) + { + // Signal R2 it can proceed, then wait for R2 to be waiting on the dictionary entry + waitForSecondRequestToStart.WaitOne(); + waitForFirstIntrospectionToStart.Set(); + await Task.Delay(200); // wait for R2 to reach IntrospectionDictionary + throw new HttpRequestException("Simulated network error"); + } + // Subsequent calls (retries) succeed normally + }; + }, handler); + + var client1 = new HttpClient(messageHandler); + var request1 = Task.Run(async () => + { + client1.SetBearerToken(token); + return await client1.GetAsync("http://test"); + }); + + var client2 = new HttpClient(messageHandler); + var request2 = Task.Run(async () => + { + waitForSecondRequestToStart.Set(); + waitForFirstIntrospectionToStart.WaitOne(); + client2.SetBearerToken(token); + return await client2.GetAsync("http://test"); + }); + + await Task.WhenAll(request1, request2); + + var result1 = await request1; + result1.StatusCode.ShouldBe(HttpStatusCode.OK); + + var result2 = await request2; + result2.StatusCode.ShouldBe(HttpStatusCode.OK); + } + + [Fact] + public async Task TwoConcurrentCalls_FirstIntrospectFaults_RetryAlsoFaults_ShouldPropagateException() + { + const string token = "sometoken"; + var waitForFirstIntrospectionToStart = new ManualResetEvent(initialState: false); + var waitForSecondRequestToStart = new ManualResetEvent(initialState: false); + var handler = new IntrospectionEndpointHandler(IntrospectionEndpointHandler.Behavior.Active); + + var requestCount = 0; + + var messageHandler = await PipelineFactory.CreateHandler(o => + { + _options(o); + + o.Events.OnSendingRequest = async context => + { + var count = Interlocked.Increment(ref requestCount); + + if (count == 1) + { + // Signal R2, wait for it to reach the dictionary, then fault + waitForSecondRequestToStart.WaitOne(); + waitForFirstIntrospectionToStart.Set(); + await Task.Delay(200); // wait for R2 to reach IntrospectionDictionary + } + + // All calls (initial + retries) throw — simulating persistent failure + throw new HttpRequestException("Persistent network error"); + }; + }, handler); + + var client1 = new HttpClient(messageHandler); + var request1 = Task.Run(async () => + { + client1.SetBearerToken(token); + var doRequest = () => client1.GetAsync("http://test"); + await doRequest.ShouldThrowAsync(); + }); + + var client2 = new HttpClient(messageHandler); + var request2 = Task.Run(async () => + { + waitForSecondRequestToStart.Set(); + waitForFirstIntrospectionToStart.WaitOne(); + client2.SetBearerToken(token); + var doRequest = () => client2.GetAsync("http://test"); + await doRequest.ShouldThrowAsync(); + }); + + // Both requests should complete (not hang) — exceptions propagate as HttpRequestException + await Task.WhenAll(request1, request2); + } + [Theory] [InlineData(5000, "testAssertion1", "testAssertion1")] [InlineData(-5000, "testAssertion1", "testAssertion2")]