From 5b6d76654d461261809e010732f188c9be39b1a6 Mon Sep 17 00:00:00 2001 From: seraphx2 Date: Sat, 12 Sep 2026 14:24:16 -0400 Subject: [PATCH] feat(sso): IEsiTokenRefreshSink.OnRefreshFailedAsync Fires when a transparent refresh itself throws (refresh token revoked, expired, or rescoped) - character is unchanged, the triggering call still fails, but a registered sink now gets a chance to react (e.g. flag the character so other jobs stop querying it) before that exception propagates. Fixes the test build: FakeSink predated this interface member and didn't implement it. Adds a dedicated failure-path test and documents the hook in the README's DbTokenSink example and the CHANGELOG. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 9 +++++-- ESI.NET/Http/EsiTokenRefreshHandler.cs | 23 ++++++++++++++++-- ESI.NET/Http/IEsiTokenRefreshSink.cs | 9 +++++++ README.md | 21 +++++++++++++++- tests/ESI.NET.Tests/TokenRefreshTests.cs | 31 +++++++++++++++++++++++- 5 files changed, 87 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f12a1ef..0c398fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,9 +161,14 @@ cancellation, pagination) is passed. **Every consumer needs code changes** — s surfaced two ways, and both fire: - `IEsiTokenRefreshSink` — implement it, register one (`services.AddScoped()`), and it covers every - authenticated call. This is the DI-friendly "persist once" hook. + authenticated call. This is the DI-friendly "persist once" hook. It also + carries `OnRefreshFailedAsync(character, exception)`, called when the + refresh token itself is rejected (revoked/expired/rescoped) — the triggering + call still throws, but this is where you'd flag the character so other jobs + stop querying it until it's re-authorized. - `EsiCallOptions.OnTokenRefreshed` — a per-call `Func` - for one-offs or non-DI use. + for one-offs or non-DI use. There's no per-call failure equivalent: a + one-off caller already gets the exception directly. - `EsiErrorLimitHandler` — reads `X-Esi-Error-Limit-*` and blocks further sends on the client until the window resets; throws `EsiErrorLimitException` on `420`. Wired by `AddEsi`. diff --git a/ESI.NET/Http/EsiTokenRefreshHandler.cs b/ESI.NET/Http/EsiTokenRefreshHandler.cs index f5ac412..2e614c4 100644 --- a/ESI.NET/Http/EsiTokenRefreshHandler.cs +++ b/ESI.NET/Http/EsiTokenRefreshHandler.cs @@ -15,7 +15,9 @@ namespace ESI.NET.Http /// .Execute); after a refresh the request's bearer header is swapped, /// the per-call callback runs, and — when a DI scope is available — a registered /// is invoked so the rotated refresh token can be persisted - /// once, centrally. + /// once, centrally. If the refresh itself throws, the registered sink's + /// is invoked instead (still only when a + /// DI scope is available) and the exception is rethrown — the triggering call fails either way. /// public sealed class EsiTokenRefreshHandler : DelegatingHandler { @@ -43,7 +45,24 @@ protected override async Task SendAsync(HttpRequestMessage && character.ExpiresOn != default && character.ExpiresOn <= DateTime.UtcNow.Add(Skew)) { - await SsoLogic.RefreshAccessTokenAsync((r, ct) => base.SendAsync(r, ct), _config, character, cancellationToken).ConfigureAwait(false); + try + { + await SsoLogic.RefreshAccessTokenAsync((r, ct) => base.SendAsync(r, ct), _config, character, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + if (_scopeFactory != null) + { + using (var scope = _scopeFactory.CreateScope()) + { + var failureSink = scope.ServiceProvider.GetService(); + if (failureSink != null) + await failureSink.OnRefreshFailedAsync(character, ex).ConfigureAwait(false); + } + } + + throw; + } // the request was built with the stale token request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", character.Token); diff --git a/ESI.NET/Http/IEsiTokenRefreshSink.cs b/ESI.NET/Http/IEsiTokenRefreshSink.cs index 117f2af..aab8820 100644 --- a/ESI.NET/Http/IEsiTokenRefreshSink.cs +++ b/ESI.NET/Http/IEsiTokenRefreshSink.cs @@ -1,4 +1,5 @@ using ESI.NET.Models.SSO; +using System; using System.Threading.Tasks; namespace ESI.NET.Http @@ -16,5 +17,13 @@ public interface IEsiTokenRefreshSink /// RefreshToken and ExpiresOn already updated in place. /// Task OnRefreshedAsync(AuthorizedCharacterData character); + + /// + /// Called when a refresh attempt itself throws (the refresh token was revoked, expired, or + /// scopes changed). is unchanged from before the attempt. + /// The triggering call still fails — this is fired just before that exception propagates — + /// so use it to react (e.g. flag the character as needing re-authorization), not to recover. + /// + Task OnRefreshFailedAsync(AuthorizedCharacterData character, Exception exception); } } diff --git a/README.md b/README.md index 3aae91b..7d79c21 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,11 @@ An authenticated call whose access token is within a minute of expiry is refreshed automatically before the request goes out, and `authChar` is updated in place. **EVE rotates the refresh token, so you must persist the new value.** -Handle that once, for every call, with an `IEsiTokenRefreshSink`: +Handle that once, for every call, with an `IEsiTokenRefreshSink`. The interface +also has an `OnRefreshFailedAsync`, called when the refresh token itself is no +longer good (revoked, expired, or the app's scopes changed) — the request still +fails, but this is your one place to notice *why* and flag the character before +some other background job wastes a call on it: ```csharp public class DbTokenSink : IEsiTokenRefreshSink @@ -195,12 +199,27 @@ public class DbTokenSink : IEsiTokenRefreshSink _db.Characters.Update(c); await _db.SaveChangesAsync(); } + + // Refresh token was rejected - stop other jobs from querying this character + // until it's re-authorized. + public async Task OnRefreshFailedAsync(AuthorizedCharacterData c, Exception ex) + { + await _db.Characters + .Where(x => x.CharacterId == c.CharacterID) + .ExecuteUpdateAsync(x => x.SetProperty(row => row.CanQueryEsi, false)); + } } services.AddScoped(); services.AddEsi(builder.Configuration.GetSection("EsiConfig")); ``` +`OnRefreshFailedAsync` fires just before the triggering call's exception +propagates, so it's for reacting (flag it, log it, alert on it), not recovering +— the call in flight still throws either way. It has no per-call +`EsiCallOptions` equivalent: a one-off caller already gets the exception +directly from the failed call, so there's nothing to add there. + The handler resolves the sink in a fresh scope each time it fires, so a scoped `DbContext` is safe. For one-offs or non-DI code, set `OnTokenRefreshed` on the call's `EsiCallOptions` instead. diff --git a/tests/ESI.NET.Tests/TokenRefreshTests.cs b/tests/ESI.NET.Tests/TokenRefreshTests.cs index d77e503..2cb32f8 100644 --- a/tests/ESI.NET.Tests/TokenRefreshTests.cs +++ b/tests/ESI.NET.Tests/TokenRefreshTests.cs @@ -24,18 +24,26 @@ public class TokenRefreshTests private const string NewTokenJson = @"{ ""access_token"": ""NEW-ACCESS"", ""token_type"": ""Bearer"", ""expires_in"": 1200, ""refresh_token"": ""NEW-REFRESH"" }"; + private const string InvalidGrantJson = @"{ ""error"": ""invalid_grant"", ""error_description"": ""The refresh token is invalid or expired."" }"; + private sealed class RoutingHandler : HttpMessageHandler { public HttpRequestMessage LastApiRequest; public int TokenCalls; public string ApiBody = "{}"; + /// When set, the token endpoint returns this instead of a fresh token — simulates EVE rejecting the refresh (revoked/expired/rescoped). + public HttpStatusCode? TokenFailureStatus; + public string TokenFailureBody = InvalidGrantJson; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { if (request.RequestUri.AbsolutePath == "/v2/oauth/token") { TokenCalls++; - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(NewTokenJson) }); + return Task.FromResult(TokenFailureStatus.HasValue + ? new HttpResponseMessage(TokenFailureStatus.Value) { Content = new StringContent(TokenFailureBody) } + : new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(NewTokenJson) }); } LastApiRequest = request; @@ -46,7 +54,10 @@ protected override Task SendAsync(HttpRequestMessage reques private sealed class FakeSink : IEsiTokenRefreshSink { public AuthorizedCharacterData Received; + public AuthorizedCharacterData FailedCharacter; + public Exception FailedException; public Task OnRefreshedAsync(AuthorizedCharacterData character) { Received = character; return Task.CompletedTask; } + public Task OnRefreshFailedAsync(AuthorizedCharacterData character, Exception exception) { FailedCharacter = character; FailedException = exception; return Task.CompletedTask; } } private static readonly EsiConfig Config = new EsiConfig @@ -138,6 +149,24 @@ public async Task Registered_sink_fires_on_refresh() Assert.Same(character, sink.Received); } + [Fact] + public async Task Failed_refresh_notifies_the_sink_and_still_throws() + { + var sink = new FakeSink(); + var provider = new ServiceCollection().AddSingleton(sink).BuildServiceProvider(); + var routing = new RoutingHandler { TokenFailureStatus = HttpStatusCode.BadRequest }; + var character = Character(DateTime.UtcNow.AddMinutes(-5)); + var handler = new EsiTokenRefreshHandler(Options.Create(Config), provider.GetRequiredService()); + + await Assert.ThrowsAsync(() => SendThrough(handler, routing, AuthedRequest(character))); + + Assert.Same(character, sink.FailedCharacter); + Assert.NotNull(sink.FailedException); + Assert.Null(sink.Received); // OnRefreshedAsync must not fire on a failed refresh + Assert.Equal("OLD-ACCESS", character.Token); // unchanged - the failed exchange never got to mutate it + Assert.Equal("OLD-REFRESH", character.RefreshToken); + } + [Fact] public async Task AddEsi_plus_one_sink_registration_covers_every_authenticated_call() {