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 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; }
|
||||
|
||||
/// <summary>
|
||||
/// 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); }
|
||||
/// <summary>
|
||||
/// 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 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional override used by tests. When set, this factory is called
|
||||
|
|
@ -102,7 +126,16 @@ public partial class LoginPageViewModel : ViewModelBase
|
|||
/// </summary>
|
||||
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
|
||||
// populated as soon as the page renders (XAML bindings fire
|
||||
|
|
@ -112,13 +145,19 @@ public partial class LoginPageViewModel : ViewModelBase
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-friendly constructor: caller supplies pre-loaded <paramref name="settings"/>
|
||||
/// and (optionally) a <paramref name="browserFactoryOverride"/> that bypasses
|
||||
/// the static <see cref="Platform"/> indirection.
|
||||
/// Test-friendly constructor: caller supplies pre-loaded
|
||||
/// <paramref name="settings"/>, an optional pre-built
|
||||
/// <paramref name="apiClient"/>, and an optional
|
||||
/// <paramref name="browserFactoryOverride"/> that bypasses the
|
||||
/// static <see cref="Platform"/> indirection.
|
||||
/// </summary>
|
||||
public LoginPageViewModel(Settings settings, Func<IBrowser?>? browserFactoryOverride = null)
|
||||
public LoginPageViewModel(
|
||||
Settings settings,
|
||||
YavscApiClient? apiClient = null,
|
||||
Func<IBrowser?>? 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}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 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");
|
||||
}
|
||||
|
||||
/// <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