diff --git a/CHANGELOG.md b/CHANGELOG.md index 16ae2b264..8f77b68af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,67 +1,5 @@ # Changelog - -## [1.0.8-rc23] - unstable - -### Added - -* [PostIt] Gestion native du compte inspirée de `/Manage` : modification - du nom d'utilisateur, du nom complet et de l'adresse, téléversement - d'avatar, confirmation e-mail, préférence de courriel mensuel, - création ou changement de mot de passe et activation/désactivation - de l'authentification à deux facteurs. -* [PostIt][Yavsc.Api] Sélection d'un agenda Google, gestion séparée des - comptes bancaires, dissociation des connexions externes et suppression - du compte avec confirmation du nom d'utilisateur. -* [PostIt] Configuration du profil professionnel et des activités, - navigation vers les cercles et les publications depuis le compte. -* [PostIt][Yavsc.Api] Administration des utilisateurs avec recherche - dans les métadonnées du profil et consultation de la consommation - de ressources. -* [Yavsc.Server][Yavsc.Api][Yavsc.Blogs] Suivi de la consommation de - ressources, API de synthèse et migration `ResourceUsageRecords`. -* [Tests] Couverture des parcours profil et professionnel, de l'API de - consommation de ressources et du renouvellement des jetons : - rotation à usage unique, appels simultanés, réponses 401 concurrentes - et rejet d'un jeton de renouvellement révoqué. - -### Changed - -* [PostIt] Réorganisation de la page de compte en sections de profil, - sécurité et préférences ; actions de modification désactivées tant - que le profil n'est pas chargé ou qu'une opération est en cours. -* [PostIt] Utilisation de `PlaceholderText` à la place de `Watermark` - dans les pages de profil et de configuration professionnelle. -* [Yavsc.Api] `GET account/me` renvoie le DTO partagé attendu par - PostIt, avec l'état de sécurité, les connexions externes, les comptes - bancaires et les compteurs du compte. - -### Fixed - -* [Yavsc.Api] Enregistrement des services ASP.NET Identity et des stores - EF nécessaires à `UserManager` : leur absence - empêchait l'activation du contrôleur de compte et provoquait une 500. -* [Yavsc.Api] Chargement du profil : réponse adaptée lorsqu'un jeton - ne contient pas d'identifiant utilisateur ou que le compte n'existe - plus, au lieu d'une exception serveur. -* [PostIt] Synchronisation de tous les chemins de renouvellement des - jetons, y compris après une 401 et au démarrage : les appels - concurrents ne réutilisent plus le même `refresh_token`. Un rejet - `invalid_grant` efface la session et invite à se reconnecter. -* [PostIt] Effacement des informations obsolètes après un échec de - rechargement du profil et normalisation des valeurs de calendrier - lors de la sauvegarde. -* [PostIt][Yavsc.Api] Préservation de l'identité du profil professionnel - lors de son enregistrement. -* [Yavsc.Api] Les coordonnées bancaires ne sont plus modifiées à partir - d'un résumé agrégé : opérations dédiées avec contrôle de propriété - et validation de longueur des champs. - -### Known limitations - -* [PostIt] L'ajout d'un nouveau fournisseur OAuth et la gestion des - crédits ne sont pas encore disponibles dans l'interface native. - ## [1.0.8-rc22] - unstable ### Fixed diff --git a/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs b/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs index e002e534d..21d532cd1 100644 --- a/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs +++ b/src/PostIt/PostIt.Tests/HomePageProviderFlowTests.cs @@ -68,19 +68,6 @@ public class HomePageProviderFlowTests Assert.Equal("alice", vm.FilteredUsers[0].UserName); } - [Fact] - public void Administration_page_tracks_selected_user() - { - var vm = new AdministrationPageViewModel(); - var user = new Yavsc.Abstract.Identity.UserInfo { UserId = "1", UserName = "alice", FullName = "Alice Example", Email = "alice@example.com", DedicatedGoogleCalendar = "calendar-123" }; - - vm.Users.Add(user); - vm.SelectedUser = user; - - Assert.Same(user, vm.SelectedUser); - Assert.Equal("alice", vm.SelectedUser?.UserName); - } - [Fact] public void UserProfilePage_exposes_full_name_and_address_fields() { diff --git a/src/PostIt/PostIt.Tests/OidcStubAuthority.cs b/src/PostIt/PostIt.Tests/OidcStubAuthority.cs index 6de94e090..4bb250977 100644 --- a/src/PostIt/PostIt.Tests/OidcStubAuthority.cs +++ b/src/PostIt/PostIt.Tests/OidcStubAuthority.cs @@ -21,11 +21,9 @@ 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) { @@ -102,7 +100,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", "refresh_token" }, + ["grant_types_supported"] = new[] { "authorization_code" }, ["code_challenge_methods_supported"] = new[] { "S256" }, }; @@ -134,16 +132,6 @@ 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(); @@ -154,13 +142,11 @@ 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, @@ -207,10 +193,10 @@ public sealed class OIDCStubAuthority : IAsyncDisposable, IDisposable return dict; } - private static async Task WriteJsonAsync(HttpListenerResponse response, object payload, int statusCode = 200) + private static async Task WriteJsonAsync(HttpListenerResponse response, object payload) { response.ContentType = "application/json"; - response.StatusCode = statusCode; + response.StatusCode = 200; 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 29f6bdea1..b074de666 100644 --- a/src/PostIt/PostIt.Tests/YavscApiClientTests.cs +++ b/src/PostIt/PostIt.Tests/YavscApiClientTests.cs @@ -370,105 +370,20 @@ public class YavscApiClientTests } } - [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); - } - } + // 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() { ... } private static T Last(System.Collections.Generic.List list) { @@ -552,7 +467,6 @@ 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) { @@ -585,8 +499,7 @@ internal sealed class StubApiServer : IAsyncDisposable, IDisposable if (!string.IsNullOrEmpty(auth)) SeenBearers.Add(auth!); - if ((_forceFirstRequest && _requestCount == 1) - || (RejectedBearer is not null && auth == RejectedBearer)) + if (_forceFirstRequest && _requestCount == 1) { ctx.Response.StatusCode = 401; ctx.Response.Close(); diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 7dfdb3bae..dcc618438 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, req.Headers.Authorization?.Parameter).ConfigureAwait(false); + await ForceRefreshAsync(ct).ConfigureAwait(false); using var retry = new HttpRequestMessage(method, path); if (contentFactory is not null) @@ -379,46 +379,13 @@ 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 { - 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)) + if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew) return; - 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); + await ForceRefreshAsync(ct).ConfigureAwait(false); } finally { @@ -426,6 +393,30 @@ 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 @@ -468,9 +459,8 @@ 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, rejectedAccessToken).ConfigureAwait(false); + await ForceRefreshAsync(ct).ConfigureAwait(false); response = await attemptUpload().ConfigureAwait(false); } @@ -524,9 +514,8 @@ 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, rejectedAccessToken).ConfigureAwait(false); + await ForceRefreshAsync(ct).ConfigureAwait(false); response = await sendOnce().ConfigureAwait(false); } diff --git a/src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.cs b/src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.cs index 85841bfbc..7ef626c56 100644 --- a/src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/AdministrationPageViewModel.cs @@ -27,9 +27,6 @@ public partial class AdministrationPageViewModel : ViewModelBase [ObservableProperty] public partial string StatusMessage { get; set; } = "Chargement…"; - [ObservableProperty] - public partial UserInfo? SelectedUser { get; set; } - public IReadOnlyList FilteredUsers => string.IsNullOrWhiteSpace(SearchText) ? Users.ToList() diff --git a/src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml b/src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml index db1d3b490..692b4972c 100644 --- a/src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml +++ b/src/PostIt/PostIt/Views/Layout/AdministrationPage.axaml @@ -24,36 +24,21 @@ TextWrapping="Wrap"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + +