diff --git a/src/PostIt/PostIt/Services/TokenSource.cs b/src/PostIt/PostIt/Services/TokenSource.cs
new file mode 100644
index 00000000..79364cdb
--- /dev/null
+++ b/src/PostIt/PostIt/Services/TokenSource.cs
@@ -0,0 +1,70 @@
+using System;
+using System.IO;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace PostIt.Services;
+
+///
+/// On-disk persistence for the OIDC token bundle (access + refresh + id).
+///
+/// Layout: a single JSON file. The file is written with 0600 on POSIX
+/// systems; on Windows the OS-level DACL inherits from the user's
+/// profile. Future hardening: route writes through libsecret / DPAPI
+/// so the file itself is never readable in cleartext on disk.
+///
+public sealed class TokenStore
+{
+ private readonly string _path;
+ private readonly object _gate = new();
+
+ public TokenStore(string path) => _path = path;
+
+ public RefreshTokenRecord? Load()
+ {
+ lock (_gate)
+ {
+ if (!File.Exists(_path)) return null;
+ var json = File.ReadAllText(_path);
+ return JsonSerializer.Deserialize(json);
+ }
+ }
+
+ public void Save(RefreshTokenRecord record)
+ {
+ lock (_gate)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
+ var json = JsonSerializer.Serialize(record, new JsonSerializerOptions
+ {
+ WriteIndented = false,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ });
+ File.WriteAllText(_path, json);
+ if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
+ File.SetUnixFileMode(_path,
+ UnixFileMode.UserRead | UnixFileMode.UserWrite);
+ }
+ }
+
+ public void Clear()
+ {
+ lock (_gate)
+ {
+ if (File.Exists(_path)) File.Delete(_path);
+ }
+ }
+}
+
+///
+/// Snapshot of the tokens the API client needs to keep a session alive
+/// across process restarts. is the
+/// absolute UTC time the access token is no longer valid; the API
+/// client compares it to before
+/// every call to decide whether to refresh.
+///
+public sealed record RefreshTokenRecord(
+ string AccessToken,
+ string RefreshToken,
+ DateTimeOffset AccessTokenExpiresAt,
+ string? IdToken = null);
diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs
new file mode 100644
index 00000000..12082e2c
--- /dev/null
+++ b/src/PostIt/PostIt/Services/YavscApiClient.cs
@@ -0,0 +1,299 @@
+using System;
+using System.Net;
+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;
+
+namespace PostIt.Services;
+
+///
+/// Thin HTTP client for the Yavsc API with a transparent, silent
+/// refresh-on-401 strategy. Callers just call CallAsync; the
+/// client takes care of (a) attaching the Bearer access token, (b)
+/// refreshing it silently via the OidcClient when it's about to
+/// expire or when the server rejects it, and (c) persisting the new
+/// token bundle so a relaunch of PostIt picks up where it left off.
+///
+/// Threading: the refresh path is serialised by a semaphore. The
+/// only refreshes once even if many
+/// concurrent requests are in flight.
+///
+public sealed class YavscApiClient : IAsyncDisposable
+{
+ // 60s of slack before the access_token's nominal expiry. Covers
+ // network latency + JWT validation on the server side.
+ private static readonly TimeSpan RefreshSkew = TimeSpan.FromSeconds(60);
+
+ private readonly Settings _settings;
+ private readonly OidcClient _oidc;
+ private readonly TokenStore _store;
+ private readonly HttpClient _http;
+ private readonly BearerTokenHandler _bearer;
+ private readonly SemaphoreSlim _refreshGate = new(1, 1);
+
+ private RefreshTokenRecord? _tokens;
+
+ public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
+ {
+ _settings = settings;
+ _store = store;
+ _oidc = oidc ?? new OidcClient(settings.GetOidcClientOptions());
+
+ _bearer = new BearerTokenHandler(this);
+ _http = new HttpClient(_bearer, disposeHandler: true)
+ {
+ // ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
+ // trailing slash so relative paths ("posts") resolve correctly.
+ BaseAddress = new Uri(settings.ApiUrl)
+ };
+
+ _tokens = store.Load();
+ }
+
+ ///
+ /// True if a non-expired access token (or a refreshable bundle) is
+ /// already in memory. UI uses this to skip the LoginPage on warm
+ /// starts.
+ ///
+ public bool HasValidSession
+ {
+ get
+ {
+ if (_tokens is null) return false;
+ if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > TimeSpan.Zero)
+ return true;
+ // Access token expired but a refresh token is still around.
+ return !string.IsNullOrEmpty(_tokens.RefreshToken);
+ }
+ }
+
+ /// Force a new interactive login (PKCE). Throws on failure.
+ public async Task LoginInteractiveAsync(CancellationToken ct = default)
+ {
+ var browser = Platform.CreateBrowser?.Invoke();
+ if (browser is null)
+ throw new InvalidOperationException("No browser is available on this platform.");
+
+ var client = new OidcClient(_settings.GetOidcClientOptions(browser));
+ var result = await client.LoginAsync(new LoginRequest(), ct);
+ if (result.IsError)
+ throw new InvalidOperationException($"OIDC login failed: {result.Error}");
+
+ if (string.IsNullOrEmpty(result.RefreshToken))
+ throw new InvalidOperationException(
+ "Missing refresh_token — vérifie le scope 'offline_access'.");
+
+ _tokens = new RefreshTokenRecord(
+ AccessToken: result.AccessToken,
+ RefreshToken: result.RefreshToken,
+ AccessTokenExpiresAt: ComputeExpiry(result.AccessTokenExpiration, result.AccessToken),
+ IdToken: result.IdentityToken);
+
+ _store.Save(_tokens);
+ }
+
+ /// Call a JSON endpoint, transparently refreshing the token if needed.
+ public async Task CallAsync(
+ HttpMethod method,
+ string path,
+ object? body = null,
+ CancellationToken ct = default)
+ {
+ using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
+ var dto = await JsonSerializer.DeserializeAsync(stream,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct).ConfigureAwait(false);
+ return dto!;
+ }
+
+ /// Call an endpoint that returns no useful body (DELETE, etc.).
+ public async Task CallAsync(
+ HttpMethod method,
+ string path,
+ object? body = null,
+ CancellationToken ct = default)
+ {
+ using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+ }
+
+ private async Task SendAsync(
+ HttpMethod method, string path, object? body, CancellationToken ct)
+ {
+ if (_tokens is null)
+ throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
+
+ await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
+
+ using var req = new HttpRequestMessage(method, path);
+ if (body is not null)
+ req.Content = JsonContent.Create(body);
+ var response = await _http.SendAsync(req, ct).ConfigureAwait(false);
+
+ if (response.StatusCode == HttpStatusCode.Unauthorized)
+ {
+ // Server rejected: could be revocation, clock skew, audience mismatch.
+ // Force a refresh and retry exactly once.
+ response.Dispose();
+ await ForceRefreshAsync(ct).ConfigureAwait(false);
+
+ using var retry = new HttpRequestMessage(method, path);
+ if (body is not null)
+ retry.Content = JsonContent.Create(body);
+ response = await _http.SendAsync(retry, ct).ConfigureAwait(false);
+ }
+
+ return response;
+ }
+
+ ///
+ /// Lock the refresh path so concurrent callers don't each rotate
+ /// the refresh token (which Auth0 invalidates on first use).
+ ///
+ private async Task EnsureFreshTokenAsync(CancellationToken ct)
+ {
+ if (_tokens is null)
+ throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
+
+ if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
+ return;
+
+ await _refreshGate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew)
+ return;
+
+ await ForceRefreshAsync(ct).ConfigureAwait(false);
+ }
+ finally
+ {
+ _refreshGate.Release();
+ }
+ }
+
+ 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);
+ }
+
+ public async Task LogoutAsync()
+ {
+ _store.Clear();
+ _tokens = null;
+ // Optional: clear the IdP session in the browser too.
+ // await _oidc.LogoutAsync(new LogoutRequest());
+ await Task.CompletedTask;
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ _http.Dispose();
+ _refreshGate.Dispose();
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ /// Compute the absolute expiry of an access token. Prefer the
+ /// IdP-provided expiration when present (TimeSpan or DateTimeOffset
+ /// depending on IdentityModel version), fall back to the JWT 'exp'
+ /// claim, finally to a conservative 23h default.
+ ///
+ private static DateTimeOffset ComputeExpiry(object? provided, string? jwt)
+ {
+ // IdentityModel.OidcClient 5.x: AccessTokenExpiration is a TimeSpan.
+ if (provided is TimeSpan ts) return DateTimeOffset.UtcNow.Add(ts);
+ if (provided is DateTimeOffset dto) return dto;
+ if (provided is int seconds) return DateTimeOffset.UtcNow.AddSeconds(seconds);
+
+ var fromJwt = ParseJwtExpiry(jwt);
+ return fromJwt ?? DateTimeOffset.UtcNow.AddHours(23);
+ }
+
+ ///
+ /// Decode the 'exp' claim of a JWT without verifying the signature
+ /// (the server verifies). Read-only fallback.
+ ///
+ private static DateTimeOffset? ParseJwtExpiry(string? jwt)
+ {
+ if (string.IsNullOrEmpty(jwt)) return null;
+ var parts = jwt.Split('.');
+ if (parts.Length != 3) return null;
+
+ var payload = parts[1].Replace('-', '+').Replace('_', '/');
+ switch (payload.Length % 4)
+ {
+ case 2: payload += "=="; break;
+ case 3: payload += "="; break;
+ }
+
+ try
+ {
+ using var doc = JsonDocument.Parse(Convert.FromBase64String(payload));
+ if (!doc.RootElement.TryGetProperty("exp", out var expEl)) return null;
+ return DateTimeOffset.FromUnixTimeSeconds(expEl.GetInt64());
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Internal that injects the
+ /// current Bearer token on every outbound request. Delegating
+ /// handlers can't easily retry, so the 401 handling lives in
+ /// above; this handler only attaches the
+ /// header.
+ ///
+ private sealed class BearerTokenHandler : DelegatingHandler
+ {
+ private readonly YavscApiClient _owner;
+ public BearerTokenHandler(YavscApiClient owner) : base(new HttpClientHandler())
+ {
+ _owner = owner;
+ }
+
+ protected override Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ if (_owner._tokens is not null)
+ request.Headers.Authorization = new AuthenticationHeaderValue(
+ "Bearer", _owner._tokens.AccessToken);
+ return base.SendAsync(request, cancellationToken);
+ }
+ }
+}
+
+public sealed class RefreshFailedException : Exception
+{
+ public bool IsPermanent { get; }
+ public RefreshFailedException(string message, bool isPermanent)
+ : base(message) => IsPermanent = isPermanent;
+}
diff --git a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs b/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs
index 800be830..361a0d2f 100644
--- a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs
@@ -1,16 +1,24 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Input;
-using IdentityModel.OidcClient;
using IdentityModel.OidcClient.Browser;
using PostIt.Services;
-using System;
-using System.Threading.Tasks;
namespace PostIt.ViewModels;
public partial class LoginPageViewModel : ViewModelBase
{
- public string UserEmail { get; set; }
- public string Password { get; set; }
+ private const string SettingsFileName = "postit-settings.json";
+
+ [Obsolete("Password grant is not used; IdentityModel.OidcClient performs PKCE.")]
+ public string Password { get; set; } = string.Empty;
+
+ [Obsolete("User-entered email is not used; the IdP login UI collects it.")]
+ public string UserEmail { get; set; } = string.Empty;
+
+ [Obsolete("No local credential persistence in the current build.")]
+ public bool RememberMe { get; set; }
///
/// URL of the Yavsc.Org account-registration page.
@@ -72,20 +80,36 @@ public partial class LoginPageViewModel : ViewModelBase
: authority + path;
}
- private string _AccessToken;
- public string AccessToken { get => _AccessToken; private set => this.SetProperty(ref _AccessToken, value); }
+ ///
+ /// The access token of the most recent successful login, or null.
+ /// Kept on the VM so views can show "logged in as …" feedback; the
+ /// authoritative copy lives in the .
+ ///
+ private string? _accessToken;
+ public string? AccessToken
+ {
+ get => _accessToken;
+ private set => this.SetProperty(ref _accessToken, value);
+ }
- public bool RememberMe { get; set; }
- public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
- public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
+ public override bool CanNavigateNext { get => false; protected set => throw new NotImplementedException(); }
+ public override bool CanNavigatePrevious { get => true; protected set => throw new NotImplementedException(); }
public Settings Settings { get; }
- private string _StatusMessage;
- public string StatusMessage { get => _StatusMessage; private set => this.SetProperty(ref _StatusMessage, value); }
+ private string _statusMessage = "Ready";
+ public string StatusMessage
+ {
+ get => _statusMessage;
+ private set => this.SetProperty(ref _statusMessage, value);
+ }
- private bool _IsBusy;
- public bool IsBusy { get=> _IsBusy; private set=> this.SetProperty(ref _IsBusy, value); }
+ private bool _isBusy;
+ public bool IsBusy
+ {
+ get => _isBusy;
+ private set => this.SetProperty(ref _isBusy, value);
+ }
///
/// Optional override used by tests. When set, this factory is called
@@ -102,7 +126,16 @@ public partial class LoginPageViewModel : ViewModelBase
///
public Func? SettingsLoadOverride { get; set; }
- public LoginPageViewModel() : this(new Settings(), browserFactoryOverride: null)
+ ///
+ /// Optional override used by tests. When set, the VM hands this
+ /// pre-built to itself instead of
+ /// constructing a fresh one.
+ ///
+ public YavscApiClient? ApiClientOverride { get; set; }
+
+ private YavscApiClient? _api;
+
+ public LoginPageViewModel() : this(new Settings(), apiClient: null, browserFactoryOverride: null)
{
// Load settings eagerly so RegisterUrl / ForgotPasswordUrl are
// populated as soon as the page renders (XAML bindings fire
@@ -112,13 +145,19 @@ public partial class LoginPageViewModel : ViewModelBase
}
///
- /// Test-friendly constructor: caller supplies pre-loaded
- /// and (optionally) a that bypasses
- /// the static indirection.
+ /// Test-friendly constructor: caller supplies pre-loaded
+ /// , an optional pre-built
+ /// , and an optional
+ /// that bypasses the
+ /// static indirection.
///
- public LoginPageViewModel(Settings settings, Func? browserFactoryOverride = null)
+ public LoginPageViewModel(
+ Settings settings,
+ YavscApiClient? apiClient = null,
+ Func? browserFactoryOverride = null)
{
Settings = settings;
+ ApiClientOverride = apiClient;
BrowserFactoryOverride = browserFactoryOverride;
StatusMessage = "Ready";
}
@@ -128,76 +167,87 @@ public partial class LoginPageViewModel : ViewModelBase
{
try
{
- this.IsBusy = true;
- (SettingsLoadOverride ?? Settings.Load)().Wait();
+ IsBusy = true;
- // Guard: if the authority is empty (no user settings file and
- // the embedded default couldn't be loaded for any reason),
- // refuse to call OidcClient. IdentityModel would otherwise
- // build a bogus authorize URL like "http://127.0.0.1:1/"
- // from an empty Authority, which the browser then refuses to
- // open with a confusing "Cette adresse est interdite"
- // (or equivalent) message. Tell the operator exactly what
- // to fix instead.
+ if (SettingsLoadOverride is not null)
+ await SettingsLoadOverride().ConfigureAwait(false);
+ else
+ await Settings.Load().ConfigureAwait(false);
+
+ // Guard: refuse to call OidcClient when the authority is
+ // empty. IdentityModel would otherwise build a bogus
+ // authorize URL like "http://127.0.0.1:1/" from an empty
+ // Authority, which the browser refuses with a confusing
+ // "Cette adresse est interdite"-style message. Tell the
+ // operator exactly what to fix instead.
if (string.IsNullOrWhiteSpace(Settings.Authentication?.Authority))
{
- this.IsBusy = false;
- StatusMessage = $"Configuration manquante — édite {SettingsFileHint()} et renseigne Authentication.Authority";
+ IsBusy = false;
+ StatusMessage =
+ $"Configuration manquante — édite {SettingsFileHint()} et renseigne Authentication.Authority";
return;
}
- // The platform project picks the right redirect URI and browser
- // implementation; we don't reference any UI toolkit from here.
+ // The platform project picks the right redirect URI and
+ // browser implementation; we don't reference any UI
+ // toolkit from here.
Settings.RedirectUri = string.IsNullOrWhiteSpace(Settings.RedirectUri)
? Platform.DefaultRedirectUri
: Settings.RedirectUri;
- // Surface the discovery URL the client is about to call, so a
- // failure (DNS, TLS, 404) can be diagnosed by pasting the URL
- // straight into a browser. OidcClient computes the discovery
- // URL as `Authority + /.well-known/openid-configuration`; we
+ // Surface the discovery URL the client is about to call,
+ // so a failure (DNS, TLS, 404) can be diagnosed by
+ // pasting the URL straight into a browser. OidcClient
+ // computes the discovery URL as
+ // `Authority + /.well-known/openid-configuration`; we
// normalise the trailing slash here so the printed URL is
// exactly what IdentityModel will fetch.
if (!string.IsNullOrEmpty(DiscoveryUrl))
StatusMessage = $"Discovering {DiscoveryUrl}";
- var browser = BrowserFactoryOverride is not null
- ? BrowserFactoryOverride.Invoke()
- : Platform.CreateBrowser?.Invoke();
- if (browser is null)
- {
- StatusMessage = $"No browser is available on this platform. (discovery: {DiscoveryUrl})";
- return;
- }
+ // Build (or reuse) the API client. The browser override
+ // takes precedence: tests want to inject a fake browser
+ // and the production path uses Platform.CreateBrowser.
+ _api ??= ApiClientOverride ?? new YavscApiClient(Settings, BuildTokenStore());
- var client = new OidcClient(Settings.GetOidcClientOptions(browser));
- var loginResult = await client.LoginAsync(new LoginRequest());
-
- if (loginResult.IsError)
- {
- StatusMessage = $"{loginResult.Error} (discovery: {DiscoveryUrl})";
- return;
- }
+ // Platform.CreateBrowser may still want to be customised
+ // per-call (e.g. between desktop and android), so route
+ // the interactive login through a callback that reuses
+ // BrowserFactoryOverride when present.
+ await LoginInteractiveCoreAsync(_api).ConfigureAwait(false);
+ IsBusy = false;
StatusMessage = "Interactive token acquired.";
-
- this.IsBusy = false;
- AccessToken = loginResult.AccessToken;
- /* TODO save or not user log and password
- await Task.Run(() =>
- {
- Settings.Save().Wait();
- });*/
-
}
catch (Exception ex)
{
- this.IsBusy = false;
+ IsBusy = false;
var suffix = !string.IsNullOrEmpty(DiscoveryUrl) ? $" (discovery: {DiscoveryUrl})" : string.Empty;
StatusMessage = $"Error: {ex.Message}{suffix}";
}
}
+ ///
+ /// Single entry point for the OIDC login: YavscApiClient owns the
+ /// browser choice, the OidcClient instance, the token persistence
+ /// and the refresh path. The VM is just a thin coordinator.
+ ///
+ private async Task LoginInteractiveCoreAsync(YavscApiClient api)
+ {
+ var original = Platform.CreateBrowser;
+ try
+ {
+ if (BrowserFactoryOverride is not null)
+ Platform.CreateBrowser = BrowserFactoryOverride;
+
+ await api.LoginInteractiveAsync().ConfigureAwait(false);
+ }
+ finally
+ {
+ Platform.CreateBrowser = original;
+ }
+ }
+
///
/// XDG-compliant path to the user settings file. Surfaced in the
/// "Configuration manquante" message so the operator knows exactly
@@ -206,6 +256,19 @@ public partial class LoginPageViewModel : ViewModelBase
private static string SettingsFileHint()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
- return System.IO.Path.Combine(appData, "PostIt", "postit-settings.json");
+ return Path.Combine(appData, "PostIt", "postit-settings.json");
+ }
+
+ ///
+ /// Build the on-disk used by
+ /// . The token bundle lives in
+ /// ~/.config/PostIt/tokens.json on Linux; the same path
+ /// layout is used on every platform for predictability.
+ ///
+ private static TokenStore BuildTokenStore()
+ {
+ var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
+ var path = Path.Combine(appData, "PostIt", "tokens.json");
+ return new TokenStore(path);
}
}