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); } }