Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,14 @@ cancellation, pagination) is passed. **Every consumer needs code changes** — s
surfaced two ways, and both fire:
- `IEsiTokenRefreshSink` — implement it, register one
(`services.AddScoped<IEsiTokenRefreshSink, YourSink>()`), 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<AuthorizedCharacterData, Task>`
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`.
Expand Down
23 changes: 21 additions & 2 deletions ESI.NET/Http/EsiTokenRefreshHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ namespace ESI.NET.Http
/// <see cref="EsiRequest"/>.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
/// <see cref="IEsiTokenRefreshSink"/> is invoked so the rotated refresh token can be persisted
/// once, centrally.
/// once, centrally. If the refresh itself throws, the registered sink's
/// <see cref="IEsiTokenRefreshSink.OnRefreshFailedAsync"/> is invoked instead (still only when a
/// DI scope is available) and the exception is rethrown — the triggering call fails either way.
/// </summary>
public sealed class EsiTokenRefreshHandler : DelegatingHandler
{
Expand All @@ -39,7 +41,24 @@ protected override async Task<HttpResponseMessage> 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<IEsiTokenRefreshSink>();
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);
Expand Down
9 changes: 9 additions & 0 deletions ESI.NET/Http/IEsiTokenRefreshSink.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using ESI.NET.Models.SSO;
using System;
using System.Threading.Tasks;

namespace ESI.NET.Http
Expand All @@ -16,5 +17,13 @@ public interface IEsiTokenRefreshSink
/// <c>RefreshToken</c> and <c>ExpiresOn</c> already updated in place.
/// </summary>
Task OnRefreshedAsync(AuthorizedCharacterData character);

/// <summary>
/// Called when a refresh attempt itself throws (the refresh token was revoked, expired, or
/// scopes changed). <paramref name="character"/> 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.
/// </summary>
Task OnRefreshFailedAsync(AuthorizedCharacterData character, Exception exception);
}
}
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<IEsiTokenRefreshSink, DbTokenSink>();
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.
Expand Down
31 changes: 30 additions & 1 deletion tests/ESI.NET.Tests/TokenRefreshTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "{}";

/// <summary>When set, the token endpoint returns this instead of a fresh token — simulates EVE rejecting the refresh (revoked/expired/rescoped).</summary>
public HttpStatusCode? TokenFailureStatus;
public string TokenFailureBody = InvalidGrantJson;

protected override Task<HttpResponseMessage> 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;
Expand All @@ -46,7 +54,10 @@ protected override Task<HttpResponseMessage> 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
Expand Down Expand Up @@ -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<IEsiTokenRefreshSink>(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<IServiceScopeFactory>());

await Assert.ThrowsAsync<ArgumentException>(() => 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()
{
Expand Down