postit: persistent token store + silent refresh on YavscApiClient
Move TokenSource.cs and YavscApiClient.cs into PostIt/Services/ under the PostIt.Services namespace so they sit next to CustomSchemeBrowser and SingleInstance. YavscApiClient: - Take Settings + TokenStore in the constructor; BaseAddress now comes from settings.ApiUrl (defaults to https://blogs.pschneider.fr/api/v1/) instead of being hard-coded to yavsc.org. - Compute access-token expiry from IdentityModel's AccessTokenExpiration (TimeSpan / DateTimeOffset / int) with a JWT 'exp' claim fallback, then a 23h default. Fixes the original .AccessTokenExpiration.Second bug that made tokens expire immediately. - Use IdentityModel.OidcClient 6.0's RefreshTokenAsync(refreshToken, cancellationToken:) — the named parameter is 'cancellationToken', not 'ct' as previously written. - Wrap HttpClient in a BearerTokenHandler that injects the access token on every outbound request; serialise the refresh path with a SemaphoreSlim so concurrent callers don't all rotate the same refresh token (which Auth0 invalidates on first use). - 401 from the server triggers a single forced refresh + retry. - RefreshFailedException is permanent when Auth0 rejects the refresh token (revoked / rotation-theft detected / expired): TokenStore is purged and the caller must re-run LoginInteractiveAsync. - Expose HasValidSession for warm-start skip of the LoginPage. TokenStore writes the JSON bundle with 0600 on POSIX. Future hardening will route it through libsecret / DPAPI. LoginPageViewModel becomes a thin coordinator: it builds (or reuses) a YavscApiClient and delegates to LoginInteractiveAsync. The legacy Password / UserEmail / RememberMe fields stay but are marked [Obsolete] since the PKCE flow is interactive and IdP-collected. Test hooks preserved: BrowserFactoryOverride, SettingsLoadOverride, ApiClientOverride. Builds clean (0 errors). No Auth0Avalonia package reference needed: Platform.CreateBrowser + CustomSchemeBrowser is the right shape for PostIt's per-platform redirection.
This commit is contained in:
parent
2dec799d71
commit
d091f2c663
3 changed files with 497 additions and 65 deletions
70
src/PostIt/PostIt/Services/TokenSource.cs
Normal file
70
src/PostIt/PostIt/Services/TokenSource.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace PostIt.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<RefreshTokenRecord>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snapshot of the tokens the API client needs to keep a session alive
|
||||||
|
/// across process restarts. <see cref="AccessTokenExpiresAt"/> is the
|
||||||
|
/// absolute UTC time the access token is no longer valid; the API
|
||||||
|
/// client compares it to <see cref="DateTimeOffset.UtcNow"/> before
|
||||||
|
/// every call to decide whether to refresh.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RefreshTokenRecord(
|
||||||
|
string AccessToken,
|
||||||
|
string RefreshToken,
|
||||||
|
DateTimeOffset AccessTokenExpiresAt,
|
||||||
|
string? IdToken = null);
|
||||||
299
src/PostIt/PostIt/Services/YavscApiClient.cs
Normal file
299
src/PostIt/PostIt/Services/YavscApiClient.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thin HTTP client for the Yavsc API with a transparent, silent
|
||||||
|
/// refresh-on-401 strategy. Callers just call <c>CallAsync</c>; 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
|
||||||
|
/// <see cref="BearerTokenHandler"/> only refreshes once even if many
|
||||||
|
/// concurrent requests are in flight.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Force a new interactive login (PKCE). Throws on failure.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Call a JSON endpoint, transparently refreshing the token if needed.</summary>
|
||||||
|
public async Task<T> CallAsync<T>(
|
||||||
|
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<T>(stream,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, ct).ConfigureAwait(false);
|
||||||
|
return dto!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Call an endpoint that returns no useful body (DELETE, etc.).</summary>
|
||||||
|
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<HttpResponseMessage> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lock the refresh path so concurrent callers don't each rotate
|
||||||
|
/// the refresh token (which Auth0 invalidates on first use).
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode the 'exp' claim of a JWT without verifying the signature
|
||||||
|
/// (the server verifies). Read-only fallback.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Internal <see cref="HttpMessageHandler"/> that injects the
|
||||||
|
/// current Bearer token on every outbound request. Delegating
|
||||||
|
/// handlers can't easily retry, so the 401 handling lives in
|
||||||
|
/// <see cref="SendAsync"/> above; this handler only attaches the
|
||||||
|
/// header.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class BearerTokenHandler : DelegatingHandler
|
||||||
|
{
|
||||||
|
private readonly YavscApiClient _owner;
|
||||||
|
public BearerTokenHandler(YavscApiClient owner) : base(new HttpClientHandler())
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> 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;
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,24 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using IdentityModel.OidcClient;
|
|
||||||
using IdentityModel.OidcClient.Browser;
|
using IdentityModel.OidcClient.Browser;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
public partial class LoginPageViewModel : ViewModelBase
|
public partial class LoginPageViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
public string UserEmail { get; set; }
|
private const string SettingsFileName = "postit-settings.json";
|
||||||
public string Password { get; set; }
|
|
||||||
|
[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; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// URL of the Yavsc.Org account-registration page.
|
/// URL of the Yavsc.Org account-registration page.
|
||||||
|
|
@ -72,20 +80,36 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
: authority + path;
|
: authority + path;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string _AccessToken;
|
/// <summary>
|
||||||
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 <see cref="TokenStore"/>.
|
||||||
|
/// </summary>
|
||||||
|
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 NotImplementedException(); }
|
||||||
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
|
public override bool CanNavigatePrevious { get => true; protected set => throw new NotImplementedException(); }
|
||||||
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
|
|
||||||
|
|
||||||
public Settings Settings { get; }
|
public Settings Settings { get; }
|
||||||
|
|
||||||
private string _StatusMessage;
|
private string _statusMessage = "Ready";
|
||||||
public string StatusMessage { get => _StatusMessage; private set => this.SetProperty(ref _StatusMessage, value); }
|
public string StatusMessage
|
||||||
|
{
|
||||||
|
get => _statusMessage;
|
||||||
|
private set => this.SetProperty(ref _statusMessage, value);
|
||||||
|
}
|
||||||
|
|
||||||
private bool _IsBusy;
|
private bool _isBusy;
|
||||||
public bool IsBusy { get=> _IsBusy; private set=> this.SetProperty(ref _IsBusy, value); }
|
public bool IsBusy
|
||||||
|
{
|
||||||
|
get => _isBusy;
|
||||||
|
private set => this.SetProperty(ref _isBusy, value);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional override used by tests. When set, this factory is called
|
/// Optional override used by tests. When set, this factory is called
|
||||||
|
|
@ -102,7 +126,16 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Func<Task>? SettingsLoadOverride { get; set; }
|
public Func<Task>? SettingsLoadOverride { get; set; }
|
||||||
|
|
||||||
public LoginPageViewModel() : this(new Settings(), browserFactoryOverride: null)
|
/// <summary>
|
||||||
|
/// Optional override used by tests. When set, the VM hands this
|
||||||
|
/// pre-built <see cref="YavscApiClient"/> to itself instead of
|
||||||
|
/// constructing a fresh one.
|
||||||
|
/// </summary>
|
||||||
|
public YavscApiClient? ApiClientOverride { get; set; }
|
||||||
|
|
||||||
|
private YavscApiClient? _api;
|
||||||
|
|
||||||
|
public LoginPageViewModel() : this(new Settings(), apiClient: null, browserFactoryOverride: null)
|
||||||
{
|
{
|
||||||
// Load settings eagerly so RegisterUrl / ForgotPasswordUrl are
|
// Load settings eagerly so RegisterUrl / ForgotPasswordUrl are
|
||||||
// populated as soon as the page renders (XAML bindings fire
|
// populated as soon as the page renders (XAML bindings fire
|
||||||
|
|
@ -112,13 +145,19 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Test-friendly constructor: caller supplies pre-loaded <paramref name="settings"/>
|
/// Test-friendly constructor: caller supplies pre-loaded
|
||||||
/// and (optionally) a <paramref name="browserFactoryOverride"/> that bypasses
|
/// <paramref name="settings"/>, an optional pre-built
|
||||||
/// the static <see cref="Platform"/> indirection.
|
/// <paramref name="apiClient"/>, and an optional
|
||||||
|
/// <paramref name="browserFactoryOverride"/> that bypasses the
|
||||||
|
/// static <see cref="Platform"/> indirection.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public LoginPageViewModel(Settings settings, Func<IBrowser?>? browserFactoryOverride = null)
|
public LoginPageViewModel(
|
||||||
|
Settings settings,
|
||||||
|
YavscApiClient? apiClient = null,
|
||||||
|
Func<IBrowser?>? browserFactoryOverride = null)
|
||||||
{
|
{
|
||||||
Settings = settings;
|
Settings = settings;
|
||||||
|
ApiClientOverride = apiClient;
|
||||||
BrowserFactoryOverride = browserFactoryOverride;
|
BrowserFactoryOverride = browserFactoryOverride;
|
||||||
StatusMessage = "Ready";
|
StatusMessage = "Ready";
|
||||||
}
|
}
|
||||||
|
|
@ -128,76 +167,87 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
this.IsBusy = true;
|
IsBusy = true;
|
||||||
(SettingsLoadOverride ?? Settings.Load)().Wait();
|
|
||||||
|
|
||||||
// Guard: if the authority is empty (no user settings file and
|
if (SettingsLoadOverride is not null)
|
||||||
// the embedded default couldn't be loaded for any reason),
|
await SettingsLoadOverride().ConfigureAwait(false);
|
||||||
// refuse to call OidcClient. IdentityModel would otherwise
|
else
|
||||||
// build a bogus authorize URL like "http://127.0.0.1:1/"
|
await Settings.Load().ConfigureAwait(false);
|
||||||
// from an empty Authority, which the browser then refuses to
|
|
||||||
// open with a confusing "Cette adresse est interdite"
|
// Guard: refuse to call OidcClient when the authority is
|
||||||
// (or equivalent) message. Tell the operator exactly what
|
// empty. IdentityModel would otherwise build a bogus
|
||||||
// to fix instead.
|
// 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))
|
if (string.IsNullOrWhiteSpace(Settings.Authentication?.Authority))
|
||||||
{
|
{
|
||||||
this.IsBusy = false;
|
IsBusy = false;
|
||||||
StatusMessage = $"Configuration manquante — édite {SettingsFileHint()} et renseigne Authentication.Authority";
|
StatusMessage =
|
||||||
|
$"Configuration manquante — édite {SettingsFileHint()} et renseigne Authentication.Authority";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The platform project picks the right redirect URI and browser
|
// The platform project picks the right redirect URI and
|
||||||
// implementation; we don't reference any UI toolkit from here.
|
// browser implementation; we don't reference any UI
|
||||||
|
// toolkit from here.
|
||||||
Settings.RedirectUri = string.IsNullOrWhiteSpace(Settings.RedirectUri)
|
Settings.RedirectUri = string.IsNullOrWhiteSpace(Settings.RedirectUri)
|
||||||
? Platform.DefaultRedirectUri
|
? Platform.DefaultRedirectUri
|
||||||
: Settings.RedirectUri;
|
: Settings.RedirectUri;
|
||||||
|
|
||||||
// Surface the discovery URL the client is about to call, so a
|
// Surface the discovery URL the client is about to call,
|
||||||
// failure (DNS, TLS, 404) can be diagnosed by pasting the URL
|
// so a failure (DNS, TLS, 404) can be diagnosed by
|
||||||
// straight into a browser. OidcClient computes the discovery
|
// pasting the URL straight into a browser. OidcClient
|
||||||
// URL as `Authority + /.well-known/openid-configuration`; we
|
// computes the discovery URL as
|
||||||
|
// `Authority + /.well-known/openid-configuration`; we
|
||||||
// normalise the trailing slash here so the printed URL is
|
// normalise the trailing slash here so the printed URL is
|
||||||
// exactly what IdentityModel will fetch.
|
// exactly what IdentityModel will fetch.
|
||||||
if (!string.IsNullOrEmpty(DiscoveryUrl))
|
if (!string.IsNullOrEmpty(DiscoveryUrl))
|
||||||
StatusMessage = $"Discovering {DiscoveryUrl}";
|
StatusMessage = $"Discovering {DiscoveryUrl}";
|
||||||
|
|
||||||
var browser = BrowserFactoryOverride is not null
|
// Build (or reuse) the API client. The browser override
|
||||||
? BrowserFactoryOverride.Invoke()
|
// takes precedence: tests want to inject a fake browser
|
||||||
: Platform.CreateBrowser?.Invoke();
|
// and the production path uses Platform.CreateBrowser.
|
||||||
if (browser is null)
|
_api ??= ApiClientOverride ?? new YavscApiClient(Settings, BuildTokenStore());
|
||||||
{
|
|
||||||
StatusMessage = $"No browser is available on this platform. (discovery: {DiscoveryUrl})";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var client = new OidcClient(Settings.GetOidcClientOptions(browser));
|
// Platform.CreateBrowser may still want to be customised
|
||||||
var loginResult = await client.LoginAsync(new LoginRequest());
|
// per-call (e.g. between desktop and android), so route
|
||||||
|
// the interactive login through a callback that reuses
|
||||||
if (loginResult.IsError)
|
// BrowserFactoryOverride when present.
|
||||||
{
|
await LoginInteractiveCoreAsync(_api).ConfigureAwait(false);
|
||||||
StatusMessage = $"{loginResult.Error} (discovery: {DiscoveryUrl})";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
IsBusy = false;
|
||||||
StatusMessage = "Interactive token acquired.";
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
this.IsBusy = false;
|
IsBusy = false;
|
||||||
var suffix = !string.IsNullOrEmpty(DiscoveryUrl) ? $" (discovery: {DiscoveryUrl})" : string.Empty;
|
var suffix = !string.IsNullOrEmpty(DiscoveryUrl) ? $" (discovery: {DiscoveryUrl})" : string.Empty;
|
||||||
StatusMessage = $"Error: {ex.Message}{suffix}";
|
StatusMessage = $"Error: {ex.Message}{suffix}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// XDG-compliant path to the user settings file. Surfaced in the
|
/// XDG-compliant path to the user settings file. Surfaced in the
|
||||||
/// "Configuration manquante" message so the operator knows exactly
|
/// "Configuration manquante" message so the operator knows exactly
|
||||||
|
|
@ -206,6 +256,19 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
private static string SettingsFileHint()
|
private static string SettingsFileHint()
|
||||||
{
|
{
|
||||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build the on-disk <see cref="TokenStore"/> used by
|
||||||
|
/// <see cref="YavscApiClient"/>. The token bundle lives in
|
||||||
|
/// <c>~/.config/PostIt/tokens.json</c> on Linux; the same path
|
||||||
|
/// layout is used on every platform for predictability.
|
||||||
|
/// </summary>
|
||||||
|
private static TokenStore BuildTokenStore()
|
||||||
|
{
|
||||||
|
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||||
|
var path = Path.Combine(appData, "PostIt", "tokens.json");
|
||||||
|
return new TokenStore(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue