diff --git a/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs b/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs index ddf2836..3cee47e 100644 --- a/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs +++ b/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs @@ -20,6 +20,9 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider // Cached access token state. private string? _cachedAccessToken; 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). private string? _tokenEndpoint; @@ -41,27 +44,28 @@ public sealed class ZitadelAuthProvider : IOnlineAuthProvider public async Task GetAccessTokenAsync(bool forceRefresh, CancellationToken ct = default) { - // Fast path: check cache without acquiring the lock. - if (!forceRefresh && _cachedAccessToken is not null && DateTimeOffset.UtcNow < _cacheExpiry) + var refreshToken = _tokenStore.Read(); + + // 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; await _lock.WaitAsync(ct); try { - // Re-check inside the lock (double-checked locking). - if (!forceRefresh && _cachedAccessToken is not null && DateTimeOffset.UtcNow < _cacheExpiry) + // Re-read + re-check inside the lock (double-checked locking). + refreshToken = _tokenStore.Read(); + if (IsCacheUsable(forceRefresh, refreshToken)) return _cachedAccessToken; - if (forceRefresh) - { - // Drop the stale access token so the refresh-token grant mints a fresh one. - _cachedAccessToken = null; - _cacheExpiry = default; - } + // Drop any stale access token so a fresh one is minted for the current user. + _cachedAccessToken = null; + _cacheExpiry = default; - var refreshToken = _tokenStore.Read(); if (refreshToken is null) { + _refreshTokenUsed = null; _logger.LogDebug("No refresh token stored; skipping token refresh."); 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 RefreshAsync(string refreshToken, CancellationToken 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. + var persistedRefreshToken = refreshToken; if (tokenResponse.RefreshToken is not null && tokenResponse.RefreshToken != refreshToken) { _logger.LogDebug("Refresh token rotated; persisting new token."); _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). + // Remember which refresh token it was minted from so the cache invalidates on a user switch. _cachedAccessToken = tokenResponse.AccessToken; _cacheExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn - 60); + _refreshTokenUsed = persistedRefreshToken; return _cachedAccessToken; } diff --git a/tests/ClaudeDo.Worker.Tests/Online/ZitadelAuthProviderTests.cs b/tests/ClaudeDo.Worker.Tests/Online/ZitadelAuthProviderTests.cs index 6f8987c..c8c502d 100644 --- a/tests/ClaudeDo.Worker.Tests/Online/ZitadelAuthProviderTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Online/ZitadelAuthProviderTests.cs @@ -152,6 +152,37 @@ public sealed class ZitadelAuthProviderTests : IDisposable 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] public async Task RotatedRefreshToken_IsPersisted() {