diff --git a/src/PostIt/PostIt.Tests/OidcStubAuthority.cs b/src/PostIt/PostIt.Tests/OidcStubAuthority.cs index 4bb250977..6de94e090 100644 --- a/src/PostIt/PostIt.Tests/OidcStubAuthority.cs +++ b/src/PostIt/PostIt.Tests/OidcStubAuthority.cs @@ -21,9 +21,11 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable private readonly RSA _rsa; private readonly string _kid; private readonly CancellationTokenSource _cts = new(); + private readonly HashSet _refreshTokens = new(StringComparer.Ordinal); public string Issuer { get; } public string LoopbackRedirectUri { get; } + public int RefreshRequestCount { get; private set; } private OIDCStubAuthority(HttpListener listener, RSA rsa, string kid, string issuer, string loopback) { @@ -100,7 +102,7 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable ["response_types_supported"] = new[] { "code" }, ["subject_types_supported"] = new[] { "public" }, ["id_token_signing_alg_values_supported"] = new[] { "RS256" }, - ["grant_types_supported"] = new[] { "authorization_code" }, + ["grant_types_supported"] = new[] { "authorization_code", "refresh_token" }, ["code_challenge_methods_supported"] = new[] { "S256" }, }; @@ -132,6 +134,16 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable body = await reader.ReadToEndAsync(); var form = ParseForm(body); + if (form.TryGetValue("grant_type", out var grantType) && grantType == "refresh_token") + { + RefreshRequestCount++; + if (!form.TryGetValue("refresh_token", out var suppliedToken) + || !_refreshTokens.Remove(suppliedToken)) + { + await WriteJsonAsync(ctx.Response, new { error = "invalid_grant" }, 400); + return; + } + } // We accept any code and don't validate PKCE on the stub side; // the OidcClient itself validates the redirect_uri match. var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); @@ -142,11 +154,13 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable ["aud"] = form.TryGetValue("client_id", out var cid) ? cid : "postit-tests", ["exp"] = now + 600, ["iat"] = now, + ["jti"] = Guid.NewGuid().ToString("N"), }; var accessToken = SignJwt(claims); var refreshToken = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)) .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + _refreshTokens.Add(refreshToken); var response = new { access_token = accessToken, @@ -193,10 +207,10 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable return dict; } - private static async Task WriteJsonAsync(HttpListenerResponse response, object payload) + private static async Task WriteJsonAsync(HttpListenerResponse response, object payload, int statusCode = 200) { response.ContentType = "application/json"; - response.StatusCode = 200; + response.StatusCode = statusCode; var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload)); await response.OutputStream.WriteAsync(bytes); response.Close(); diff --git a/src/PostIt/PostIt.Tests/YavscApiClientTests.cs b/src/PostIt/PostIt.Tests/YavscApiClientTests.cs index b074de666..29f6bdea1 100644 --- a/src/PostIt/PostIt.Tests/YavscApiClientTests.cs +++ b/src/PostIt/PostIt.Tests/YavscApiClientTests.cs @@ -370,20 +370,105 @@ public class YavscApiClientTests } } - // SKIPPED — see comment. - // - // We can't cover "TrySilentLoginAsync purges the store when the - // refresh token is rejected" with OidcStubAuthority: the stub's - // /connect/token endpoint is unconditional and hands out a fresh - // refresh token regardless of what the caller sends. To exercise - // the RefreshFailedException path we'd need an authority option - // to fail on a specific refresh-token string; until then the - // production refresh-failure path is covered manually (and by - // the structural guarantee that _store.Clear() runs in the catch - // block of ForceRefreshAsync when result.IsError). - // - // [Fact] - // public async Task TrySilentLoginAsync_purges_store_when_refresh_fails_permanently() { ... } + [Fact] + public async Task Concurrent_silent_login_and_api_calls_share_one_refresh() + { + using var authority = await OIDCStubAuthority.StartAsync(); + using var apiServer = new StubApiServer(); + await apiServer.StartAsync(); + var settings = BuildSettings(authority, apiServer.BaseUrl); + var tokensPath = TokensPath(); + try + { + await using var original = await LoginAndPersistAsync(settings, authority, tokensPath); + ExpireCachedAccessToken(tokensPath); + var store = new TokenStore(tokensPath); + var previousRefreshToken = store.Load()!.RefreshToken; + await using var client = new YavscApiClient(settings, store); + client.Http.BaseAddress = new Uri(settings.ApiUrl); + var ct = TestContext.Current.CancellationToken; + + var silentLogins = Enumerable.Range(0, 8) + .Select(_ => client.TrySilentLoginAsync(null, ct)).ToArray(); + var calls = Enumerable.Range(0, 8) + .Select(_ => client.CallAsync>(HttpMethod.Get, "posts", ct)).ToArray(); + await Task.WhenAll(silentLogins.Cast().Concat(calls)); + + Assert.All(silentLogins, login => Assert.True(login.Result)); + Assert.All(calls, call => Assert.NotEmpty(call.Result)); + Assert.Equal(1, authority.RefreshRequestCount); + Assert.NotEqual(previousRefreshToken, store.Load()!.RefreshToken); + Assert.True(client.HasValidSession); + } + finally + { + if (File.Exists(tokensPath)) File.Delete(tokensPath); + } + } + + [Fact] + public async Task Concurrent_401_responses_share_one_refresh() + { + using var authority = await OIDCStubAuthority.StartAsync(); + using var apiServer = new StubApiServer(); + await apiServer.StartAsync(); + var settings = BuildSettings(authority, apiServer.BaseUrl); + var tokensPath = TokensPath(); + try + { + await using var client = await LoginAndPersistAsync(settings, authority, tokensPath); + apiServer.RejectedBearer = $"Bearer {client.CurrentAccessToken}"; + var ct = TestContext.Current.CancellationToken; + + var calls = Enumerable.Range(0, 8) + .Select(_ => client.CallAsync>(HttpMethod.Get, "posts", ct)).ToArray(); + await Task.WhenAll(calls); + + Assert.All(calls, call => Assert.NotEmpty(call.Result)); + Assert.Equal(1, authority.RefreshRequestCount); + Assert.True(client.HasValidSession); + } + finally + { + if (File.Exists(tokensPath)) File.Delete(tokensPath); + } + } + + [Fact] + public async Task CallAsync_reports_expired_session_and_clears_rejected_refresh_token() + { + using var authority = await OIDCStubAuthority.StartAsync(); + using var apiServer = new StubApiServer(); + await apiServer.StartAsync(); + var settings = BuildSettings(authority, apiServer.BaseUrl); + var tokensPath = TokensPath(); + try + { + await using var original = await LoginAndPersistAsync(settings, authority, tokensPath); + var store = new TokenStore(tokensPath); + var tokens = store.Load()!; + store.Save(tokens with + { + RefreshToken = "revoked-refresh-token", + AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddMinutes(-5) + }); + await using var client = new YavscApiClient(settings, store); + client.Http.BaseAddress = new Uri(settings.ApiUrl); + + var error = await Assert.ThrowsAsync(() => + client.CallAsync(HttpMethod.Get, "posts", TestContext.Current.CancellationToken)); + + Assert.True(error.IsPermanent); + Assert.Contains("reconnecter", error.Message); + Assert.False(client.HasValidSession); + Assert.Null(store.Load()); + Assert.Equal(0, apiServer.RequestCount); + } + finally + { + if (File.Exists(tokensPath)) File.Delete(tokensPath); + } + } private static T Last(System.Collections.Generic.List list) { @@ -467,6 +552,7 @@ internal sealed class StubApiServer : IAsyncDisposable, IDisposable public string BaseUrl { get; private set; } = string.Empty; public List SeenBearers { get; } = new(); public int RequestCount => _requestCount; + public string? RejectedBearer { get; set; } public StubApiServer(bool forceFirstRequest = false) { @@ -499,7 +585,8 @@ internal sealed class StubApiServer : IAsyncDisposable, IDisposable if (!string.IsNullOrEmpty(auth)) SeenBearers.Add(auth!); - if (_forceFirstRequest && _requestCount == 1) + if ((_forceFirstRequest && _requestCount == 1) + || (RejectedBearer is not null && auth == RejectedBearer)) { ctx.Response.StatusCode = 401; ctx.Response.Close(); diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index dcc618438..7dfdb3bae 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -314,7 +314,7 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable // Server rejected: could be revocation, clock skew, audience mismatch. // Force a refresh and retry exactly once. response.Dispose(); - await ForceRefreshAsync(ct).ConfigureAwait(false); + await ForceRefreshAsync(ct, req.Headers.Authorization?.Parameter).ConfigureAwait(false); using var retry = new HttpRequestMessage(method, path); if (contentFactory is not null) @@ -379,13 +379,46 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew) return; + await ForceRefreshAsync(ct).ConfigureAwait(false); + } + + private async Task ForceRefreshAsync(CancellationToken ct, string? rejectedAccessToken = null) + { await _refreshGate.WaitAsync(ct).ConfigureAwait(false); try { - if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew) + var tokens = _tokens; + if (tokens is null || string.IsNullOrEmpty(tokens.RefreshToken)) + throw new InvalidOperationException("Session expirée. Veuillez vous reconnecter."); + + if (rejectedAccessToken is null + ? tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew + : !string.Equals(tokens.AccessToken, rejectedAccessToken, StringComparison.Ordinal)) return; - await ForceRefreshAsync(ct).ConfigureAwait(false); + var result = await _oidc.RefreshTokenAsync(tokens.RefreshToken, cancellationToken: ct).ConfigureAwait(false); + if (result.IsError) + { + var isPermanent = result.Error is "invalid_grant" or "invalid_client"; + if (isPermanent) + { + _store.Clear(); + _tokens = null; + } + throw new RefreshFailedException( + isPermanent + ? $"Session expirée. Veuillez vous reconnecter ({result.Error})." + : $"Impossible de renouveler la session : {result.Error ?? "refresh failed"}.", + isPermanent); + } + + _tokens = new RefreshTokenRecord( + AccessToken: result.AccessToken!, + RefreshToken: result.RefreshToken ?? tokens.RefreshToken, + AccessTokenExpiresAt: ComputeExpiry(result.AccessTokenExpiration, result.AccessToken), + IdToken: result.IdentityToken ?? tokens.IdToken); + + _store.Save(_tokens); } finally { @@ -393,30 +426,6 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable } } - private async Task ForceRefreshAsync(CancellationToken ct) - { - if (_tokens is null || string.IsNullOrEmpty(_tokens.RefreshToken)) - throw new InvalidOperationException("No refresh token available."); - - var result = await _oidc.RefreshTokenAsync(_tokens.RefreshToken, cancellationToken: ct).ConfigureAwait(false); - if (result.IsError) - { - // Refresh token dead: revoked, expired, or rotation-theft - // detected. Purge and force an interactive re-login. - _store.Clear(); - _tokens = null; - throw new RefreshFailedException(result.Error ?? "refresh failed", isPermanent: true); - } - - _tokens = new RefreshTokenRecord( - AccessToken: result.AccessToken!, - RefreshToken: result.RefreshToken ?? _tokens.RefreshToken, - AccessTokenExpiresAt: ComputeExpiry(result.AccessTokenExpiration, result.AccessToken), - IdToken: result.IdentityToken ?? _tokens.IdToken); - - _store.Save(_tokens); - } - /// /// Upload a user avatar to the Yavsc API. The server expects a /// single multipart file named file and validates the image @@ -459,8 +468,9 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable var response = await attemptUpload().ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.Unauthorized) { + var rejectedAccessToken = response.RequestMessage?.Headers.Authorization?.Parameter; response.Dispose(); - await ForceRefreshAsync(ct).ConfigureAwait(false); + await ForceRefreshAsync(ct, rejectedAccessToken).ConfigureAwait(false); response = await attemptUpload().ConfigureAwait(false); } @@ -514,8 +524,9 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable var response = await sendOnce().ConfigureAwait(false); if (response.StatusCode == HttpStatusCode.Unauthorized) { + var rejectedAccessToken = response.RequestMessage?.Headers.Authorization?.Parameter; response.Dispose(); - await ForceRefreshAsync(ct).ConfigureAwait(false); + await ForceRefreshAsync(ct, rejectedAccessToken).ConfigureAwait(false); response = await sendOnce().ConfigureAwait(false); }