postit: wire BlogApiClient through YavscApiClient and unify auth

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<T> 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.
This commit is contained in:
Paul Schneider 2026-06-23 21:25:17 +01:00
commit f96d84dc5b
11 changed files with 505 additions and 179 deletions

View file

@ -6,8 +6,11 @@ namespace PostIt.Desktop;
/// <summary>
/// One-shot platform bootstrap. Called from <c>Program.Main</c> so that
/// the shared <c>LoginPageViewModel</c> sees a working <c>IBrowser</c>
/// (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 <c>postit://</c> scheme is registered
/// with the OS at install time and the browser is whatever the user
/// has configured to open it.
/// </summary>
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";
}
}

View file

@ -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;
}
/// <summary>
/// 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.
/// </summary>
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));
}
}

View file

@ -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
/// <summary>
/// High-level client for the Blog subsystem of the Yavsc API
/// (deployed at <c>https://blogs.pschneider.fr</c>). All transport
/// concerns — base URL, JSON serialisation, Bearer auth, silent
/// refresh on 401, request body shaping — are delegated to
/// <see cref="YavscApiClient"/>. This class is a thin DTO↔path
/// mapper, nothing more.
///
/// The class is intentionally non-IDisposable: it does not own the
/// <see cref="YavscApiClient"/> it depends on. Lifetimes are managed
/// by the consumer (typically a singleton service registered with
/// the application).
/// </summary>
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<List<BlogPost>> GetPostsAsync(int start = 0, int take = 25, CancellationToken ct = default)
=> _api.CallAsync<List<BlogPost>>(
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<BlogPost?> GetPostAsync(long id, CancellationToken ct = default)
=> _api.CallAsync<BlogPost?>(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct);
public async Task<List<BlogPost>> GetPostsAsync(int start = 0, int take = 25)
{
var result = await _httpClient.GetFromJsonAsync<List<BlogPost>>($"api/blog?start={start}&take={take}", _serializerOptions).ConfigureAwait(false);
return result ?? new List<BlogPost>();
}
public Task<BlogPost?> CreatePostAsync(BlogPost post, CancellationToken ct = default)
=> _api.CallAsync<BlogPost?>(HttpMethod.Post, _pathPrefix, body: post, ct: ct);
public Task<BlogPost?> GetPostAsync(long id)
=> _httpClient.GetFromJsonAsync<BlogPost>($"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<BlogPost?> CreatePostAsync(BlogPost post)
{
var response = await _httpClient.PostAsJsonAsync("api/blog", post, _serializerOptions).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<BlogPost>(_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);
}

View file

@ -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<RefreshTokenRecord>(json);
}
}

View file

@ -23,7 +23,7 @@ namespace PostIt.Services;
/// <see cref="BearerTokenHandler"/> only refreshes once even if many
/// concurrent requests are in flight.
/// </summary>
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
}
}
/// <summary>
/// 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).
/// </summary>
public string? CurrentAccessToken => _tokens?.AccessToken;
/// <summary>The current OIDC id_token, or null.</summary>
public string? CurrentIdToken => _tokens?.IdToken;
/// <summary>Force a new interactive login (PKCE). Throws on failure.</summary>
public async Task LoginInteractiveAsync(CancellationToken ct = default)
{
@ -98,7 +109,7 @@ public sealed class YavscApiClient : IAsyncDisposable
}
/// <summary>Call a JSON endpoint, transparently refreshing the token if needed.</summary>
public async Task<T> CallAsync<T>(
public virtual async Task<T> CallAsync<T>(
HttpMethod method,
string path,
object? body = null,

View file

@ -21,9 +21,11 @@ public partial class Settings : ObservableObject
IStorageFolder? folder = null;
/// <summary>
/// Default loopback redirect URI used for interactive PKCE login on desktop
/// platforms. The corresponding <c>RedirectUri</c> 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 (<c>postit://callback</c>).
/// Production builds prefer <see cref="Platform.DefaultRedirectUri"/>
/// which routes through the OS-registered URI scheme (RFC 8252).
/// </summary>
public const string DefaultLoopbackRedirectUri = "http://127.0.0.1:7890/";
@ -33,6 +35,14 @@ public partial class Settings : ObservableObject
/// </summary>
public const string AndroidRedirectUri = "android://postit-signin";
/// <summary>
/// Default custom-scheme redirect URI on Desktop. The OS routes the
/// callback to the running PostIt instance via the named-pipe hand-off
/// in <see cref="PostIt.Services.SingleInstance"/>. Production
/// Desktop builds use this; loopback HTTP is only a fallback.
/// </summary>
public const string DefaultDesktopRedirectUri = "postit://callback";
[ObservableProperty]
public partial AuthenticationSettings Authentication { get; set; } = new();

View file

@ -146,19 +146,20 @@ public partial class LoginPageViewModel : ViewModelBase
/// <summary>
/// Test-friendly constructor: caller supplies pre-loaded
/// <paramref name="settings"/>, an optional pre-built
/// <paramref name="apiClient"/>, and an optional
/// <paramref name="settings"/>, an optional
/// <paramref name="browserFactoryOverride"/> that bypasses the
/// static <see cref="Platform"/> indirection.
/// static <see cref="Platform"/> indirection, and an optional
/// pre-built <paramref name="apiClient"/> for end-to-end
/// scenarios where the test owns the wiring.
/// </summary>
public LoginPageViewModel(
Settings settings,
YavscApiClient? apiClient = null,
Func<IBrowser?>? browserFactoryOverride = null)
Func<IBrowser?>? 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)

View file

@ -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<BlogPost> Posts { get; set; }
@ -47,62 +43,62 @@ public partial class MainPageViewModel : ViewModelBase
[ObservableProperty]
public partial Settings Settings { get; private set; }
/// <summary>
/// API surface that hits the Yavsc.Blogs deployment at
/// <see cref="Settings.ApiUrl"/>. Owned and constructed by
/// <c>App.axaml.cs</c> so the same client (and its token store)
/// is shared with the login flow.
/// </summary>
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()
/// <summary>
/// Test-friendly constructor: caller supplies a pre-built
/// <see cref="BlogApiClient"/>. Production code uses the
/// (Settings, BlogApiClient) overload below.
/// </summary>
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null)
{
SearchText = string.Empty;
Posts = new ObservableCollection<BlogPost>();
FilteredPosts = new ObservableCollection<BlogPost>();
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
}
}
/// <summary>
/// Performs an interactive Authorization Code + PKCE login against the
/// configured authority and stores the resulting access token in
/// <see cref="BearerToken"/>. No client secret is sent; PKCE prevents
/// authorization-code interception by relying on a per-request verifier
/// generated locally and never leaving the device.
/// </summary>
/// <param name="browser">
/// Platform-specific <see cref="IBrowser"/> implementation. On desktop
/// pass a <c>LoopbackBrowser</c>; on Android a custom-scheme
/// deep-link browser is required.
/// </param>
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();