fix(online-inbox): invalidate cached access token when the signed-in user changes
ZitadelAuthProvider cached the access token in memory and only re-read the refresh token when the cache expired. Re-signing as a different user saved a new refresh token but the worker kept serving the previous user's cached access token until it expired — so sync (and ownerId stamping) continued under the old identity. Track the refresh token that minted the cached token and invalidate the cache when the stored refresh token changes (user switch or sign-out). Switching users now takes effect on the next sync without a worker restart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,9 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider
|
|||||||
// Cached access token state.
|
// Cached access token state.
|
||||||
private string? _cachedAccessToken;
|
private string? _cachedAccessToken;
|
||||||
private DateTimeOffset _cacheExpiry;
|
private DateTimeOffset _cacheExpiry;
|
||||||
|
// The refresh token that minted the cached access token. When the stored refresh token
|
||||||
|
// changes (sign-out, or signing in as a different user), the cache is no longer valid.
|
||||||
|
private string? _refreshTokenUsed;
|
||||||
|
|
||||||
// Cached token endpoint URL (discovered once).
|
// Cached token endpoint URL (discovered once).
|
||||||
private string? _tokenEndpoint;
|
private string? _tokenEndpoint;
|
||||||
@@ -41,27 +44,28 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider
|
|||||||
|
|
||||||
public async Task<string?> GetAccessTokenAsync(bool forceRefresh, CancellationToken ct = default)
|
public async Task<string?> GetAccessTokenAsync(bool forceRefresh, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// Fast path: check cache without acquiring the lock.
|
var refreshToken = _tokenStore.Read();
|
||||||
if (!forceRefresh && _cachedAccessToken is not null && DateTimeOffset.UtcNow < _cacheExpiry)
|
|
||||||
|
// Fast path: cached token is valid, not forced, and was minted from the still-current
|
||||||
|
// refresh token (i.e. the signed-in user hasn't changed).
|
||||||
|
if (IsCacheUsable(forceRefresh, refreshToken))
|
||||||
return _cachedAccessToken;
|
return _cachedAccessToken;
|
||||||
|
|
||||||
await _lock.WaitAsync(ct);
|
await _lock.WaitAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Re-check inside the lock (double-checked locking).
|
// Re-read + re-check inside the lock (double-checked locking).
|
||||||
if (!forceRefresh && _cachedAccessToken is not null && DateTimeOffset.UtcNow < _cacheExpiry)
|
refreshToken = _tokenStore.Read();
|
||||||
|
if (IsCacheUsable(forceRefresh, refreshToken))
|
||||||
return _cachedAccessToken;
|
return _cachedAccessToken;
|
||||||
|
|
||||||
if (forceRefresh)
|
// Drop any stale access token so a fresh one is minted for the current user.
|
||||||
{
|
_cachedAccessToken = null;
|
||||||
// Drop the stale access token so the refresh-token grant mints a fresh one.
|
_cacheExpiry = default;
|
||||||
_cachedAccessToken = null;
|
|
||||||
_cacheExpiry = default;
|
|
||||||
}
|
|
||||||
|
|
||||||
var refreshToken = _tokenStore.Read();
|
|
||||||
if (refreshToken is null)
|
if (refreshToken is null)
|
||||||
{
|
{
|
||||||
|
_refreshTokenUsed = null;
|
||||||
_logger.LogDebug("No refresh token stored; skipping token refresh.");
|
_logger.LogDebug("No refresh token stored; skipping token refresh.");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -74,6 +78,12 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsCacheUsable(bool forceRefresh, string? storedRefreshToken) =>
|
||||||
|
!forceRefresh
|
||||||
|
&& _cachedAccessToken is not null
|
||||||
|
&& DateTimeOffset.UtcNow < _cacheExpiry
|
||||||
|
&& storedRefreshToken == _refreshTokenUsed;
|
||||||
|
|
||||||
private async Task<string?> RefreshAsync(string refreshToken, CancellationToken ct)
|
private async Task<string?> RefreshAsync(string refreshToken, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var tokenEndpoint = await GetTokenEndpointAsync(ct);
|
var tokenEndpoint = await GetTokenEndpointAsync(ct);
|
||||||
@@ -123,15 +133,19 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If Zitadel rotated the refresh token, persist the new one.
|
// If Zitadel rotated the refresh token, persist the new one.
|
||||||
|
var persistedRefreshToken = refreshToken;
|
||||||
if (tokenResponse.RefreshToken is not null && tokenResponse.RefreshToken != refreshToken)
|
if (tokenResponse.RefreshToken is not null && tokenResponse.RefreshToken != refreshToken)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Refresh token rotated; persisting new token.");
|
_logger.LogDebug("Refresh token rotated; persisting new token.");
|
||||||
_tokenStore.Save(tokenResponse.RefreshToken);
|
_tokenStore.Save(tokenResponse.RefreshToken);
|
||||||
|
persistedRefreshToken = tokenResponse.RefreshToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the access token (subtract 60 s safety margin; minimum 0 to avoid far-future expiry on zero).
|
// Cache the access token (subtract 60 s safety margin; minimum 0 to avoid far-future expiry on zero).
|
||||||
|
// Remember which refresh token it was minted from so the cache invalidates on a user switch.
|
||||||
_cachedAccessToken = tokenResponse.AccessToken;
|
_cachedAccessToken = tokenResponse.AccessToken;
|
||||||
_cacheExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn - 60);
|
_cacheExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn - 60);
|
||||||
|
_refreshTokenUsed = persistedRefreshToken;
|
||||||
|
|
||||||
return _cachedAccessToken;
|
return _cachedAccessToken;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,37 @@ public sealed class ZitadelAuthProviderTests : IDisposable
|
|||||||
Assert.Equal(2, handler.Requests.Count);
|
Assert.Equal(2, handler.Requests.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangedRefreshToken_InvalidatesCache_AndRefreshesForNewUser()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows()) return;
|
||||||
|
|
||||||
|
var (provider, handler, store) = Build();
|
||||||
|
store.Save("admin-refresh");
|
||||||
|
|
||||||
|
// First user (admin): discovery + token.
|
||||||
|
handler.Enqueue(".well-known", HttpStatusCode.OK,
|
||||||
|
DiscoveryJson("https://auth.example.com/oauth/token"));
|
||||||
|
handler.Enqueue("oauth/token", HttpStatusCode.OK,
|
||||||
|
TokenJson("admin-access", expiresIn: 3600));
|
||||||
|
|
||||||
|
var adminToken = await provider.GetAccessTokenAsync();
|
||||||
|
Assert.Equal("admin-access", adminToken);
|
||||||
|
|
||||||
|
// Re-sign-in as a different user writes a new refresh token to the store.
|
||||||
|
store.Save("normal-refresh");
|
||||||
|
|
||||||
|
// Even though the cached admin token is still within its expiry window, the changed
|
||||||
|
// refresh token must force a new exchange (no second discovery — it's cached).
|
||||||
|
handler.Enqueue("oauth/token", HttpStatusCode.OK,
|
||||||
|
TokenJson("normal-access", expiresIn: 3600));
|
||||||
|
|
||||||
|
var normalToken = await provider.GetAccessTokenAsync();
|
||||||
|
|
||||||
|
Assert.Equal("normal-access", normalToken);
|
||||||
|
Assert.Equal(3, handler.Requests.Count); // discovery + admin token + normal token
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RotatedRefreshToken_IsPersisted()
|
public async Task RotatedRefreshToken_IsPersisted()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user