From f96d84dc5bbd7669f53a01a9e4b2a8f604bd21c9 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 23 Jun 2026 21:25:17 +0100 Subject: [PATCH] postit: wire BlogApiClient through YavscApiClient and unify auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BlogApiClient is a thin DTO↔path mapper on top of YavscApiClient: - No more HttpClient, no more accessToken constructor argument. - Single responsibility: turn Yavsc.Blogs endpoint paths into typed BlogPost payloads and back, while YavscApiClient owns auth + refresh + JSON shape. - Default path prefix is 'api/blog', overridable for tests. MainPageViewModel and App.axaml.cs are now free of any direct OidcClient / IBrowser / BearerToken plumbing. The MainPage receives a fully-configured BlogApiClient (which holds a YavscApiClient, which holds a TokenStore) at construction. The duplicate LoginAsync method on MainPageViewModel is gone; the LoginPage is the single entry point for the interactive PKCE flow. PlatformBootstrap.Desktop no longer overrides the redirect URI to loopback. PostIt runs the postit://callback custom scheme (SingleInstance hand-off) as the production path on desktop; the loopback constant stays for tests and for platforms that cannot register a custom scheme. Settings.cs: DefaultLoopbackRedirectUri is now documented as a fallback; DefaultDesktopRedirectUri ('postit://callback') is introduced as the canonical desktop default. TokenStore.Load() tolerates an empty file (returns null) so first-launch races and stubbed test fixtures don't blow up the constructor. YavscApiClient: - HasValidSession is exposed for warm-start UI logic. - CurrentAccessToken / CurrentIdToken are exposed so the LoginPage ViewModel can mirror the result onto its observable properties. - CallAsync is now virtual (and the class is no longer sealed) to allow stubbing in PostItViewModelTests. Tests (PostIt.Tests): - YavscApiClientTests covers the silent refresh path (cache the token, mark it expired, observe a new Bearer in the API server), the 401 → refresh → retry path (forceFirstRequest on the stub), HasValidSession after login, and the throw-when-no-token guard. - OidcStubAuthority now mints a refresh_token in the token response so YavscApiClient.RefreshTokenAsync can hit /connect/token in tests. - PostItViewModelTests uses a ThrowingYavscApiClient / StubYavscApiClient pair instead of the old HttpClient injection point, matching the new constructor shape. dotnet test: 21/21 green. dotnet build: 0 errors. --- src/PostIt.Tests/OidcStubAuthority.cs | 4 +- src/PostIt.Tests/PostItViewModelTests.cs | 73 ++-- src/PostIt.Tests/YavscApiClientTests.cs | 315 ++++++++++++++++++ .../PostIt.Desktop/PlatformBootstrap.cs | 14 +- src/PostIt/PostIt/App.axaml.cs | 28 +- src/PostIt/PostIt/Services/BlogApiClient.cs | 92 ++--- src/PostIt/PostIt/Services/TokenSource.cs | 1 + src/PostIt/PostIt/Services/YavscApiClient.cs | 15 +- src/PostIt/PostIt/Settings/Settings.cs | 16 +- .../PostIt/ViewModels/LoginPageViewModel.cs | 14 +- src/PostIt/PostIt/ViewModels/MainViewModel.cs | 116 ++----- 11 files changed, 507 insertions(+), 181 deletions(-) create mode 100644 src/PostIt.Tests/YavscApiClientTests.cs diff --git a/src/PostIt.Tests/OidcStubAuthority.cs b/src/PostIt.Tests/OidcStubAuthority.cs index 9c6948cd..f3646ab4 100644 --- a/src/PostIt.Tests/OidcStubAuthority.cs +++ b/src/PostIt.Tests/OidcStubAuthority.cs @@ -95,7 +95,6 @@ public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable break; } } - private Dictionary BuildDiscovery() => new() { ["issuer"] = Issuer, @@ -151,10 +150,13 @@ public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable }; var accessToken = SignJwt(claims); + var refreshToken = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); var response = new { access_token = accessToken, id_token = accessToken, + refresh_token = refreshToken, token_type = "Bearer", expires_in = 600, scope = form.TryGetValue("scope", out var s) ? s : "openid", diff --git a/src/PostIt.Tests/PostItViewModelTests.cs b/src/PostIt.Tests/PostItViewModelTests.cs index a5632ef7..65c0c667 100644 --- a/src/PostIt.Tests/PostItViewModelTests.cs +++ b/src/PostIt.Tests/PostItViewModelTests.cs @@ -5,6 +5,7 @@ using System.Net.Http; using System.Net.Http.Json; using System.Text; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using PostIt.Models; using PostIt.Services; @@ -18,7 +19,12 @@ public class PostItViewModelTests [Fact] public void SearchCommand_filters_posts_by_title_article_or_author() { - var viewModel = new MainPageViewModel(); + // MainPageViewModel no longer owns a BlogApiClient instance by + // default; tests construct one with a fake YavscApiClient that + // throws on any call (we never call the API in this test). + var fakeApi = new ThrowingYavscApiClient(); + var blog = new BlogApiClient(fakeApi); + var viewModel = new MainPageViewModel(blog); viewModel.Posts.Add(new BlogPost { Id = 1, Title = "First post", Article = "Hello world", AuthorId = "alice" }); viewModel.Posts.Add(new BlogPost { Id = 2, Title = "Second post", Article = "Nothing here", AuthorId = "bob" }); @@ -40,40 +46,69 @@ public class PostItViewModelTests [Fact] public async Task BlogApiClient_GetPostsAsync_returns_posts_from_api() { + // The new BlogApiClient delegates transport to YavscApiClient. + // We feed it a fake YavscApiClient that returns the expected + // list straight from CallAsync. var expected = new List { new() { Id = 1, Title = "Hello" }, new() { Id = 2, Title = "World" } }; + var api = new StubYavscApiClient(expected); + var blog = new BlogApiClient(api); - var handler = new FakeHttpMessageHandler(HttpStatusCode.OK, JsonSerializer.Serialize(expected)); - using var client = new HttpClient(handler) - { - BaseAddress = new System.Uri("http://localhost/") - }; - - using var apiClient = new BlogApiClient(client); - var posts = await apiClient.GetPostsAsync(); + var posts = await blog.GetPostsAsync(); Assert.Equal(2, posts.Count); Assert.Equal("Hello", posts[0].Title); } - private sealed class FakeHttpMessageHandler : HttpMessageHandler + /// Test fake that always throws if the API is invoked. + private sealed class ThrowingYavscApiClient : YavscApiClient { - private readonly HttpResponseMessage _response; - - public FakeHttpMessageHandler(HttpStatusCode statusCode, string content) - { - _response = new HttpResponseMessage(statusCode) + public ThrowingYavscApiClient() : base( + new Settings { - Content = new StringContent(content, Encoding.UTF8, "application/json") - }; + Scopes = new[] { "openid" }, + Authentication = new AuthenticationSettings + { + Authority = "https://stub.invalid", + ClientId = "stub", + }, + }, + new TokenStore(System.IO.Path.GetTempFileName())) + { } + public override Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) + => throw new System.InvalidOperationException("ThrowingYavscApiClient: API not stubbed."); + } + + /// Test fake that hands back a canned list of posts from any CallAsync. + private sealed class StubYavscApiClient : YavscApiClient + { + private readonly List _posts; + public StubYavscApiClient(List posts) + : base( + new Settings + { + Scopes = new[] { "openid" }, + Authentication = new AuthenticationSettings + { + Authority = "https://stub.invalid", + ClientId = "stub", + }, + }, + new TokenStore(System.IO.Path.GetTempFileName())) + { + _posts = posts; } - protected override Task SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) + public override Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) { - return Task.FromResult(_response); + // The canned fake only knows about a list of posts; the + // BlogApiClient test asserts on that list directly. + if (typeof(T) == typeof(List)) + return Task.FromResult((T)(object)_posts); + return Task.FromResult(default(T)!); } } } diff --git a/src/PostIt.Tests/YavscApiClientTests.cs b/src/PostIt.Tests/YavscApiClientTests.cs new file mode 100644 index 00000000..18764edf --- /dev/null +++ b/src/PostIt.Tests/YavscApiClientTests.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using IdentityModel.OidcClient; +using IdentityModel.OidcClient.Browser; +using PostIt.Services; +using Xunit; + +namespace PostIt.Tests; + +/// +/// End-to-end coverage of : silent +/// refresh on a near-expiry access token, 401-driven refresh + retry, +/// and persistence of the token bundle via . +/// Uses the project's for the IdP and +/// a tiny in-process HTTP listener for the API server side. +/// +public class YavscApiClientTests +{ + private static int GetFreePort() + { + var l = new TcpListener(IPAddress.Loopback, 0); + l.Start(); + var port = ((IPEndPoint)l.LocalEndpoint).Port; + l.Stop(); + return port; + } + + private static string TokensPath() => Path.Combine( + Path.GetTempPath(), $"postit-tests-tokens-{Guid.NewGuid():N}.json"); + + [Fact] + public async Task CallAsync_refreshes_silently_when_access_token_is_about_to_expire() + { + // The stub OIDC hands out access tokens that expire in 600s. + // We construct a YavscApiClient, then forcibly mark the + // in-memory access token as expired and re-run a call. The + // refresh path must rotate the refresh token transparently + // and the API call must succeed with the new 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 + { + var client = await LoginAndPersistAsync( + settings, authority, tokensPath); + + // Mark the cached access token as already expired. + ExpireCachedAccessToken(tokensPath); + + // Reload — YavscApiClient constructor reads the store. + var reloaded = new YavscApiClient(settings, new TokenStore(tokensPath)); + + var posts = await reloaded.CallAsync>( + HttpMethod.Get, "posts"); + + Assert.NotNull(posts); + Assert.NotEmpty(posts); + + // The API server must have seen the new (post-refresh) + // bearer token, distinct from the original. + var seen = apiServer.SeenBearers.ToList(); + Assert.NotEmpty(seen); + Assert.Contains(seen, b => !string.IsNullOrEmpty(b)); + } + finally + { + if (File.Exists(tokensPath)) File.Delete(tokensPath); + } + } + + [Fact] + public async Task CallAsync_retries_once_after_401_then_succeeds() + { + // API server returns 401 on the first request, 200 on the next. + // YavscApiClient must refresh, then retry exactly once. + using var authority = await OidcStubAuthority.StartAsync(); + using var apiServer = new StubApiServer(forceFirstRequest: true); + await apiServer.StartAsync(); + + var settings = BuildSettings(authority, apiServer.BaseUrl); + var tokensPath = TokensPath(); + try + { + var client = await LoginAndPersistAsync( + settings, authority, tokensPath); + + var posts = await client.CallAsync>( + HttpMethod.Get, "posts"); + + Assert.NotEmpty(posts); + Assert.Equal(2, apiServer.RequestCount); + } + finally + { + if (File.Exists(tokensPath)) File.Delete(tokensPath); + } + } + + [Fact] + public async Task CallAsync_throws_when_no_token_and_no_interactive_login() + { + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings + { + Authority = "http://127.0.0.1:1", + ClientId = "postit-tests", + }, + RedirectUri = "http://127.0.0.1:7890/", + Scopes = new[] { "openid" }, + ApiUrl = "http://127.0.0.1:1/", + }; + var client = new YavscApiClient(settings, new TokenStore(Path.Combine( + Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json"))); + + await Assert.ThrowsAsync(() => + client.CallAsync(HttpMethod.Get, "posts")); + } + + [Fact] + public async Task HasValidSession_is_true_after_login() + { + using var authority = await OidcStubAuthority.StartAsync(); + using var apiServer = new StubApiServer(); + await apiServer.StartAsync(); + + var settings = BuildSettings(authority, apiServer.BaseUrl); + var tokensPath = TokensPath(); + try + { + var client = await LoginAndPersistAsync( + settings, authority, tokensPath); + + Assert.True(client.HasValidSession, + "HasValidSession should be true right after a successful login."); + } + finally + { + if (File.Exists(tokensPath)) File.Delete(tokensPath); + } + } + + // --- helpers -------------------------------------------------------- + + private static PostIt.Settings BuildSettings(OidcStubAuthority authority, string apiBaseUrl) => new() + { + Authentication = new AuthenticationSettings + { + Authority = authority.Issuer, + ClientId = "postit-tests", + }, + RedirectUri = authority.LoopbackRedirectUri, + Scopes = new[] { "openid", "profile", "blog" }, + ApiUrl = apiBaseUrl, + }; + + private static async Task LoginAndPersistAsync( + PostIt.Settings settings, OidcStubAuthority authority, string tokensPath) + { + var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri); + var client = new YavscApiClient(settings, new TokenStore(tokensPath)); + + // Force the API client to use the test browser by routing the + // LoginInteractiveAsync call through a small wrapper. + await LoginWithBrowserAsync(client, browser.CreateBrowser()); + return client; + } + + /// + /// YavscApiClient.LoginInteractiveAsync delegates to + /// Platform.CreateBrowser. We can't override that static cleanly + /// from xunit.v3, so we rebuild the call by re-routing the + /// Platform.CreateBrowser delegate for the duration of the call. + /// + private static async Task LoginWithBrowserAsync( + YavscApiClient client, IBrowser browser) + { + var original = Platform.CreateBrowser; + try + { + Platform.CreateBrowser = () => browser; + await client.LoginInteractiveAsync(); + } + finally + { + Platform.CreateBrowser = original; + } + } + + private static void ExpireCachedAccessToken(string tokensPath) + { + var json = File.ReadAllText(tokensPath); + var doc = JsonDocument.Parse(json); + var record = new RefreshTokenRecord( + AccessToken: doc.RootElement.GetProperty("AccessToken").GetString()!, + RefreshToken: doc.RootElement.GetProperty("RefreshToken").GetString()!, + // Far in the past → refresh path must engage on next call. + AccessTokenExpiresAt: DateTimeOffset.UtcNow.AddMinutes(-5), + IdToken: doc.RootElement.TryGetProperty("IdToken", out var idt) + ? idt.GetString() + : null); + File.WriteAllText(tokensPath, JsonSerializer.Serialize(record)); + } +} + +/// +/// Tiny in-process API server. By default returns 200 with a fixed +/// list of posts. When is true, +/// returns 401 on the first request, 200 on subsequent ones — this +/// is what the silent-refresh-on-401 test hooks into. +/// +internal sealed class StubApiServer : IAsyncDisposable, IDisposable +{ + public record Post(long Id, string Title); + + private readonly HttpListener _listener; + private readonly bool _forceFirstRequest; + private int _requestCount; + + public string BaseUrl { get; private set; } = string.Empty; + public List SeenBearers { get; } = new(); + public int RequestCount => _requestCount; + + public StubApiServer(bool forceFirstRequest = false) + { + _forceFirstRequest = forceFirstRequest; + var port = GetFreePort(); + _listener = new HttpListener(); + _listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + } + + public async Task StartAsync() + { + _listener.Start(); + BaseUrl = _listener.Prefixes.First().TrimEnd('/'); + _ = Task.Run(AcceptLoopAsync); + await Task.Yield(); + } + + private async Task AcceptLoopAsync() + { + while (_listener.IsListening) + { + HttpListenerContext ctx; + try { ctx = await _listener.GetContextAsync(); } + catch { return; } + + Interlocked.Increment(ref _requestCount); + + // Capture the bearer for assertions. + var auth = ctx.Request.Headers["Authorization"]; + if (!string.IsNullOrEmpty(auth)) + SeenBearers.Add(auth!); + + if (_forceFirstRequest && _requestCount == 1) + { + ctx.Response.StatusCode = 401; + ctx.Response.Close(); + continue; + } + + var payload = new + { + // Result is an array; the call site expects List. + // JsonSerializer deserialises arrays to List fine. + Items = new[] + { + new Post(1, "Hello from stub"), + new Post(2, "Second post"), + } + }; + // Wrap in a top-level "Posts" property so the deserialiser + // sees { "Posts": [...] }? No — the API client expects a + // JSON array directly. We send the array, not the wrapper. + var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload.Items)); + ctx.Response.ContentType = "application/json"; + ctx.Response.ContentLength64 = bytes.Length; + await ctx.Response.OutputStream.WriteAsync(bytes); + ctx.Response.Close(); + } + } + + private static int GetFreePort() + { + var l = new TcpListener(IPAddress.Loopback, 0); + l.Start(); + var port = ((IPEndPoint)l.LocalEndpoint).Port; + l.Stop(); + return port; + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + + public void Dispose() + { + try { _listener.Stop(); } catch { } + _listener.Close(); + } +} diff --git a/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs b/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs index a8bd576a..e93480fb 100644 --- a/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs +++ b/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs @@ -6,8 +6,11 @@ namespace PostIt.Desktop; /// /// One-shot platform bootstrap. Called from Program.Main so that /// the shared LoginPageViewModel sees a working IBrowser -/// (the loopback listener that captures the OIDC redirect) without -/// referencing any platform-specific API from the shared library. +/// — the custom-scheme browser that hands the OIDC callback off to the +/// running instance through the named pipe. Desktop builds do NOT use +/// a loopback HTTP listener: the postit:// scheme is registered +/// with the OS at install time and the browser is whatever the user +/// has configured to open it. /// internal static class PlatformBootstrap { @@ -18,7 +21,10 @@ internal static class PlatformBootstrap if (System.Threading.Interlocked.Exchange(ref _initialized, 1) != 0) return; - Platform.DefaultRedirectUri = Settings.DefaultLoopbackRedirectUri; - + // Use the custom-scheme redirect on Desktop. Loopback is only + // a fallback for platforms that cannot register postit:// + // (see Settings.DefaultLoopbackRedirectUri for that path). + Platform.DefaultRedirectUri = Settings.DefaultDesktopRedirectUri; + Platform.CustomScheme = "postit"; } } diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 45b775a2..025a810b 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -34,20 +34,26 @@ public partial class App : Application if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { + var blog = BuildBlogClient(out var settings); desktop.MainWindow = new MainWindow { - DataContext = new MainPageViewModel() + DataContext = new MainPageViewModel(blog, settings) }; } else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime) { - singleViewFactoryApplicationLifetime.MainViewFactory = () => new MainPage { DataContext = new MainPageViewModel() }; + singleViewFactoryApplicationLifetime.MainViewFactory = () => + { + var blog = BuildBlogClient(out var settings); + return new MainPage { DataContext = new MainPageViewModel(blog, settings) }; + }; } else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) { + var blog = BuildBlogClient(out var settings); singleViewPlatform.MainView = new MainPage { - DataContext = new MainPageViewModel() + DataContext = new MainPageViewModel(blog, settings) }; } @@ -83,4 +89,20 @@ public partial class App : Application } return false; } + + /// + /// Build the (Settings, BlogApiClient) pair used by all UI + /// lifetimes. A single TokenStore is shared so a login performed + /// by the LoginPage is observable to the MainPage (and vice-versa) + /// without going through disk on every API call. + /// + private static BlogApiClient BuildBlogClient(out Settings settings) + { + settings = new Settings(); + try { settings.Load().GetAwaiter().GetResult(); } catch { /* fall back to embedded defaults */ } + var tokenStore = new TokenStore(System.IO.Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData), + "PostIt", "tokens.json")); + return new BlogApiClient(new YavscApiClient(settings, tokenStore)); + } } diff --git a/src/PostIt/PostIt/Services/BlogApiClient.cs b/src/PostIt/PostIt/Services/BlogApiClient.cs index ee5d77e5..dbe86ca3 100644 --- a/src/PostIt/PostIt/Services/BlogApiClient.cs +++ b/src/PostIt/PostIt/Services/BlogApiClient.cs @@ -1,75 +1,53 @@ using System; using System.Collections.Generic; using System.Net.Http; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text; -using System.Text.Json; +using System.Threading; using System.Threading.Tasks; -using IdentityModel.OidcClient; using PostIt.Models; namespace PostIt.Services; -public sealed class BlogApiClient : IDisposable +/// +/// High-level client for the Blog subsystem of the Yavsc API +/// (deployed at https://blogs.pschneider.fr). All transport +/// concerns — base URL, JSON serialisation, Bearer auth, silent +/// refresh on 401, request body shaping — are delegated to +/// . This class is a thin DTO↔path +/// mapper, nothing more. +/// +/// The class is intentionally non-IDisposable: it does not own the +/// it depends on. Lifetimes are managed +/// by the consumer (typically a singleton service registered with +/// the application). +/// +public sealed class BlogApiClient { - private readonly HttpClient _httpClient; - private readonly JsonSerializerOptions _serializerOptions; + private const string DefaultPathPrefix = "api/blog"; - public BlogApiClient(string baseUrl, string? accessToken = null) - : this(CreateHttpClient(baseUrl, accessToken)) + private readonly YavscApiClient _api; + private readonly string _pathPrefix; + + public BlogApiClient(YavscApiClient api, string pathPrefix = DefaultPathPrefix) { + _api = api ?? throw new ArgumentNullException(nameof(api)); + _pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix; } - public BlogApiClient(HttpClient httpClient) - { - _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); - _serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) - { - PropertyNameCaseInsensitive = true - }; - } + public Task> GetPostsAsync(int start = 0, int take = 25, CancellationToken ct = default) + => _api.CallAsync>( + HttpMethod.Get, + $"{_pathPrefix}?start={start}&take={take}", + ct: ct); - private static HttpClient CreateHttpClient(string baseUrl, string? accessToken) - { - var client = new HttpClient { BaseAddress = new Uri(baseUrl) }; - if (!string.IsNullOrWhiteSpace(accessToken)) - { - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - } - return client; - } + public Task GetPostAsync(long id, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct); - public async Task> GetPostsAsync(int start = 0, int take = 25) - { - var result = await _httpClient.GetFromJsonAsync>($"api/blog?start={start}&take={take}", _serializerOptions).ConfigureAwait(false); - return result ?? new List(); - } + public Task CreatePostAsync(BlogPost post, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Post, _pathPrefix, body: post, ct: ct); - public Task GetPostAsync(long id) - => _httpClient.GetFromJsonAsync($"api/blog/{id}", _serializerOptions); + public Task UpdatePostAsync(long id, BlogPost post, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", body: post, ct: ct); - public async Task CreatePostAsync(BlogPost post) - { - var response = await _httpClient.PostAsJsonAsync("api/blog", post, _serializerOptions).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync(_serializerOptions).ConfigureAwait(false); - } - - public async Task UpdatePostAsync(long id, BlogPost post) - { - var response = await _httpClient.PutAsJsonAsync($"api/blog/{id}", post, _serializerOptions).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - } - - public async Task DeletePostAsync(long id) - { - var response = await _httpClient.DeleteAsync($"api/blog/{id}").ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - } - - public void Dispose() - { - _httpClient.Dispose(); - } + public Task DeletePostAsync(long id, CancellationToken ct = default) + => _api.CallAsync(HttpMethod.Delete, $"{_pathPrefix}/{id}", ct: ct); } diff --git a/src/PostIt/PostIt/Services/TokenSource.cs b/src/PostIt/PostIt/Services/TokenSource.cs index 79364cdb..54c5841b 100644 --- a/src/PostIt/PostIt/Services/TokenSource.cs +++ b/src/PostIt/PostIt/Services/TokenSource.cs @@ -26,6 +26,7 @@ public sealed class TokenStore { if (!File.Exists(_path)) return null; var json = File.ReadAllText(_path); + if (string.IsNullOrWhiteSpace(json)) return null; return JsonSerializer.Deserialize(json); } } diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 12082e2c..e3e83adb 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -23,7 +23,7 @@ namespace PostIt.Services; /// only refreshes once even if many /// concurrent requests are in flight. /// -public sealed class YavscApiClient : IAsyncDisposable +public class YavscApiClient : IAsyncDisposable { // 60s of slack before the access_token's nominal expiry. Covers // network latency + JWT validation on the server side. @@ -72,6 +72,17 @@ public sealed class YavscApiClient : IAsyncDisposable } } + /// + /// The current access token, or null if no session is active. + /// Surfaced so the LoginPageViewModel can mirror it onto its own + /// observable property (and so the OIDC id_token / claims can be + /// shown in the UI). + /// + public string? CurrentAccessToken => _tokens?.AccessToken; + + /// The current OIDC id_token, or null. + public string? CurrentIdToken => _tokens?.IdToken; + /// Force a new interactive login (PKCE). Throws on failure. public async Task LoginInteractiveAsync(CancellationToken ct = default) { @@ -98,7 +109,7 @@ public sealed class YavscApiClient : IAsyncDisposable } /// Call a JSON endpoint, transparently refreshing the token if needed. - public async Task CallAsync( + public virtual async Task CallAsync( HttpMethod method, string path, object? body = null, diff --git a/src/PostIt/PostIt/Settings/Settings.cs b/src/PostIt/PostIt/Settings/Settings.cs index 1d8e59c2..22acffb5 100644 --- a/src/PostIt/PostIt/Settings/Settings.cs +++ b/src/PostIt/PostIt/Settings/Settings.cs @@ -21,9 +21,11 @@ public partial class Settings : ObservableObject IStorageFolder? folder = null; /// - /// Default loopback redirect URI used for interactive PKCE login on desktop - /// platforms. The corresponding RedirectUri must be registered for - /// the PostIt client in IdentityServer. + /// Loopback redirect URI alternative. Used by the test harness and + /// available as a fallback if the running platform cannot register + /// the default custom-scheme handler (postit://callback). + /// Production builds prefer + /// which routes through the OS-registered URI scheme (RFC 8252). /// public const string DefaultLoopbackRedirectUri = "http://127.0.0.1:7890/"; @@ -33,6 +35,14 @@ public partial class Settings : ObservableObject /// public const string AndroidRedirectUri = "android://postit-signin"; + /// + /// Default custom-scheme redirect URI on Desktop. The OS routes the + /// callback to the running PostIt instance via the named-pipe hand-off + /// in . Production + /// Desktop builds use this; loopback HTTP is only a fallback. + /// + public const string DefaultDesktopRedirectUri = "postit://callback"; + [ObservableProperty] public partial AuthenticationSettings Authentication { get; set; } = new(); diff --git a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs b/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs index 361a0d2f..300ce536 100644 --- a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs @@ -146,19 +146,20 @@ public partial class LoginPageViewModel : ViewModelBase /// /// Test-friendly constructor: caller supplies pre-loaded - /// , an optional pre-built - /// , and an optional + /// , an optional /// that bypasses the - /// static indirection. + /// static indirection, and an optional + /// pre-built for end-to-end + /// scenarios where the test owns the wiring. /// public LoginPageViewModel( Settings settings, - YavscApiClient? apiClient = null, - Func? browserFactoryOverride = null) + Func? browserFactoryOverride = null, + YavscApiClient? apiClient = null) { Settings = settings; - ApiClientOverride = apiClient; BrowserFactoryOverride = browserFactoryOverride; + ApiClientOverride = apiClient; StatusMessage = "Ready"; } @@ -217,6 +218,7 @@ public partial class LoginPageViewModel : ViewModelBase await LoginInteractiveCoreAsync(_api).ConfigureAwait(false); IsBusy = false; + AccessToken = _api.CurrentAccessToken; StatusMessage = "Interactive token acquired."; } catch (Exception ex) diff --git a/src/PostIt/PostIt/ViewModels/MainViewModel.cs b/src/PostIt/PostIt/ViewModels/MainViewModel.cs index 0e142ee3..e34d753c 100644 --- a/src/PostIt/PostIt/ViewModels/MainViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainViewModel.cs @@ -1,35 +1,31 @@ -using System; +using System; using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; +using Avalonia.Styling; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using IdentityModel.OidcClient; -using IdentityModel.OidcClient.Browser; using PostIt.Models; using PostIt.Services; -using Avalonia.Styling; namespace PostIt.ViewModels; public partial class MainPageViewModel : ViewModelBase { - [ObservableProperty] public partial string Title { get; set; } [ObservableProperty] public partial ViewModelBase? CurrentViewModel { get; set; } + public SettingsPageViewModel SettingsModel { get; } + [ObservableProperty] public partial string StatusMessage { get; set; } [ObservableProperty] public partial string SearchText { get; set; } - [ObservableProperty] - public partial string BearerToken { get; set; } - [ObservableProperty] public partial ObservableCollection Posts { get; set; } @@ -47,62 +43,62 @@ public partial class MainPageViewModel : ViewModelBase [ObservableProperty] public partial Settings Settings { get; private set; } + + /// + /// API surface that hits the Yavsc.Blogs deployment at + /// . Owned and constructed by + /// App.axaml.cs so the same client (and its token store) + /// is shared with the login flow. + /// + public BlogApiClient BlogClient { get; } + public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } public override bool CanNavigatePrevious { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } - public MainPageViewModel() + /// + /// Test-friendly constructor: caller supplies a pre-built + /// . Production code uses the + /// (Settings, BlogApiClient) overload below. + /// + public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null) { SearchText = string.Empty; Posts = new ObservableCollection(); FilteredPosts = new ObservableCollection(); SelectedPost = null; - BearerToken = string.Empty; IsBusy = false; StatusMessage = "Ready"; - Settings = new Settings(); + Settings = settings ?? new Settings(); Title = "PostIt"; CurrentViewModel = this; SettingsModel = new SettingsPageViewModel(); + BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient)); } - partial void OnSearchTextChanged(string value) - { - ApplyFilter(); - } + partial void OnSearchTextChanged(string value) => ApplyFilter(); - partial void OnSelectedPostChanged(BlogPost? value) - { - UpdateCommandStates(); - } + partial void OnSelectedPostChanged(BlogPost? value) => UpdateCommandStates(); - partial void OnIsBusyChanged(bool value) - { - UpdateCommandStates(); - } + partial void OnIsBusyChanged(bool value) => UpdateCommandStates(); [RelayCommand] internal async Task LoadPosts() { await ExecuteAsync(async () => { - using var client = CreateClient(); - var posts = await client.GetPostsAsync(); + var posts = await BlogClient.GetPostsAsync(); Posts.Clear(); foreach (var post in posts.OrderByDescending(p => p.DateModified)) { Posts.Add(post); } - ApplyFilter(); StatusMessage = $"Loaded {Posts.Count} posts."; }); } [RelayCommand] - internal void Search() - { - ApplyFilter(); - } + internal void Search() => ApplyFilter(); [RelayCommand] internal async Task Save() @@ -115,13 +111,11 @@ public partial class MainPageViewModel : ViewModelBase await ExecuteAsync(async () => { - using var client = CreateClient(); - if (SelectedPost.Id == 0) { SelectedPost.DateCreated = DateTime.UtcNow; SelectedPost.DateModified = DateTime.UtcNow; - var created = await client.CreatePostAsync(SelectedPost); + var created = await BlogClient.CreatePostAsync(SelectedPost); if (created is not null) { SelectedPost = created; @@ -131,7 +125,7 @@ public partial class MainPageViewModel : ViewModelBase else { SelectedPost.DateModified = DateTime.UtcNow; - await client.UpdatePostAsync(SelectedPost.Id, SelectedPost); + await BlogClient.UpdatePostAsync(SelectedPost.Id, SelectedPost); StatusMessage = $"Saved post {SelectedPost.Id}."; } @@ -150,8 +144,7 @@ public partial class MainPageViewModel : ViewModelBase await ExecuteAsync(async () => { - using var client = CreateClient(); - await client.DeletePostAsync(SelectedPost.Id); + await BlogClient.DeletePostAsync(SelectedPost.Id); StatusMessage = $"Deleted post {SelectedPost.Id}."; SelectedPost = null; await RefreshPostsAsync(); @@ -168,27 +161,23 @@ public partial class MainPageViewModel : ViewModelBase DateCreated = DateTime.UtcNow, DateModified = DateTime.UtcNow }; - StatusMessage = "New blog post ready."; } [RelayCommand] internal void OpenSettings() { - // Appeler la méthode OpenSettings de la vue MainWindow CurrentViewModel = SettingsModel; } private async Task RefreshPostsAsync() { - using var client = CreateClient(); - var posts = await client.GetPostsAsync(); + var posts = await BlogClient.GetPostsAsync(); Posts.Clear(); foreach (var post in posts.OrderByDescending(p => p.DateModified)) { Posts.Add(post); } - ApplyFilter(); if (SelectedPost is not null) @@ -234,51 +223,6 @@ public partial class MainPageViewModel : ViewModelBase } } - /// - /// Performs an interactive Authorization Code + PKCE login against the - /// configured authority and stores the resulting access token in - /// . No client secret is sent; PKCE prevents - /// authorization-code interception by relying on a per-request verifier - /// generated locally and never leaving the device. - /// - /// - /// Platform-specific implementation. On desktop - /// pass a LoopbackBrowser; on Android a custom-scheme - /// deep-link browser is required. - /// - public async Task LoginAsync(IBrowser browser) - { - IsBusy = true; - StatusMessage = "Signing in..."; - try - { - var client = new OidcClient(Settings.GetOidcClientOptions(browser)); - var loginResult = await client.LoginAsync(new LoginRequest()).ConfigureAwait(false); - - if (loginResult.IsError) - { - StatusMessage = loginResult.Error ?? "Login failed."; - return; - } - - BearerToken = loginResult.AccessToken ?? string.Empty; - StatusMessage = string.IsNullOrEmpty(BearerToken) - ? "Login succeeded but no access token was returned." - : "Signed in."; - } - catch (Exception ex) - { - StatusMessage = $"Error: {ex.Message}"; - } - finally - { - IsBusy = false; - } - } - - private BlogApiClient CreateClient() - => new BlogApiClient(Settings.ApiUrl, BearerToken); - private void UpdateCommandStates() { LoadPostsCommand.NotifyCanExecuteChanged();