diff --git a/src/PostIt.Tests/SchemeUrlDetectorTests.cs b/src/PostIt.Tests/SchemeUrlDetectorTests.cs new file mode 100644 index 00000000..f5983cb3 --- /dev/null +++ b/src/PostIt.Tests/SchemeUrlDetectorTests.cs @@ -0,0 +1,79 @@ +using PostIt.Services; +using Xunit; + +namespace PostIt.Tests; + +/// +/// Tests for the platform-independent scheme-URL detector. The +/// detector is the first guard against the OS launching a fresh +/// PostIt instance with the postit://callback URL — it must match +/// even when Avalonia has not booted, otherwise the 2nd instance +/// flashes its own MainWindow before shutting down. +/// +public class SchemeUrlDetectorTests +{ + [Fact] + public void FindCallbackUrl_returns_null_when_no_args() + { + Assert.Null(SchemeUrlDetector.FindCallbackUrl(System.Array.Empty())); + } + + [Fact] + public void FindCallbackUrl_returns_null_when_no_postit_arg_present() + { + var args = new[] + { + "/usr/bin/postit-desktop", + "--some-flag", + "value", + }; + Assert.Null(SchemeUrlDetector.FindCallbackUrl(args)); + } + + [Fact] + public void FindCallbackUrl_returns_url_when_postit_scheme_present() + { + var args = new[] + { + "/usr/bin/postit-desktop", + "postit://callback?code=abc&state=xyz", + }; + var hit = SchemeUrlDetector.FindCallbackUrl(args); + Assert.Equal("postit://callback?code=abc&state=xyz", hit); + } + + [Fact] + public void FindCallbackUrl_is_case_insensitive_on_scheme() + { + var args = new[] { "POSTIT://callback?code=abc" }; + Assert.Equal("POSTIT://callback?code=abc", SchemeUrlDetector.FindCallbackUrl(args)); + } + + [Fact] + public void FindCallbackUrl_ignores_args_that_mention_scheme_without_prefix() + { + // "postit-something://x" must NOT match — the prefix is the + // scheme followed by "://", nothing else. + var args = new[] { "postit-something://callback?code=abc" }; + Assert.Null(SchemeUrlDetector.FindCallbackUrl(args)); + } + + [Fact] + public void FindCallbackUrl_returns_first_match_when_multiple_present() + { + // Defensive: an OS shouldn't hand us two URLs in argv, but if + // it ever does we want a deterministic answer (first). + var args = new[] + { + "postit://callback?code=first", + "postit://callback?code=second", + }; + Assert.Equal("postit://callback?code=first", SchemeUrlDetector.FindCallbackUrl(args)); + } + + [Fact] + public void FindCallbackUrl_returns_null_for_null_args() + { + Assert.Null(SchemeUrlDetector.FindCallbackUrl(null!)); + } +} diff --git a/src/PostIt.Tests/YavscApiClientTests.cs b/src/PostIt.Tests/YavscApiClientTests.cs index 09df1967..1617c1b6 100644 --- a/src/PostIt.Tests/YavscApiClientTests.cs +++ b/src/PostIt.Tests/YavscApiClientTests.cs @@ -213,6 +213,236 @@ public class YavscApiClientTests : null); File.WriteAllText(tokensPath, JsonSerializer.Serialize(record)); } + + // --- OidcLoginPhase progress tests --------------------------------- + + /// + /// Collecting Progress is documented to capture reports + /// synchronously inside the awaiter when called on the same + /// thread, but our LoginInteractiveAsync awaits across threads; + /// we use the post-await snapshot to keep this test deterministic. + /// + [Fact] + public async Task LoginInteractiveAsync_reports_Discovering_then_Success() + { + using var authority = await OidcStubAuthority.StartAsync(); + using var apiServer = new StubApiServer(); + await apiServer.StartAsync(); + + var settings = BuildSettings(authority, apiServer.BaseUrl); + var tokensPath = TokensPath(); + var client = new YavscApiClient(settings, new TokenStore(tokensPath)); + var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri); + + var reported = new System.Collections.Generic.List(); + var progress = new SyncProgress(reported); + + try + { + await LoginWithBrowserAsync(client, browser.CreateBrowser(), progress); + // SyncProgress captures reports synchronously — no flush needed. + + Assert.Contains(OidcLoginPhase.Discovering, reported); + Assert.Contains(OidcLoginPhase.OpeningBrowser, reported); + Assert.Contains(OidcLoginPhase.ExchangingCode, reported); + Assert.Equal(OidcLoginPhase.Success, Last(reported)); + } + finally + { + if (File.Exists(tokensPath)) File.Delete(tokensPath); + } + } + + [Fact] + public async Task LoginInteractiveAsync_reports_Error_when_browser_missing() + { + using var authority = await OidcStubAuthority.StartAsync(); + using var apiServer = new StubApiServer(); + await apiServer.StartAsync(); + + var settings = BuildSettings(authority, apiServer.BaseUrl); + var client = new YavscApiClient(settings, new TokenStore(TokensPath())); + var reported = new System.Collections.Generic.List(); + var progress = new SyncProgress(reported); + + var original = Platform.CreateBrowser; + try + { + Platform.CreateBrowser = () => null; // simulate no browser wired up + await Assert.ThrowsAsync( + () => client.LoginInteractiveAsync(progress)); + // SyncProgress captures reports synchronously — no flush needed. + + Assert.Equal(OidcLoginPhase.Error, Last(reported)); + } + finally + { + Platform.CreateBrowser = original; + } + } + + [Fact] + public async Task TrySilentLoginAsync_returns_false_when_no_bundle_on_disk() + { + using var authority = await OidcStubAuthority.StartAsync(); + using var apiServer = new StubApiServer(); + await apiServer.StartAsync(); + + var settings = BuildSettings(authority, apiServer.BaseUrl); + var tokensPath = TokensPath(); + // Tokens file deliberately doesn't exist. + var client = new YavscApiClient(settings, new TokenStore(tokensPath)); + + var ok = await client.TrySilentLoginAsync(); + Assert.False(ok); + Assert.False(client.HasValidSession); + } + + [Fact] + public async Task TrySilentLoginAsync_returns_true_when_access_token_still_valid() + { + using var authority = await OidcStubAuthority.StartAsync(); + using var apiServer = new StubApiServer(); + await apiServer.StartAsync(); + + var settings = BuildSettings(authority, apiServer.BaseUrl); + var tokensPath = TokensPath(); + var client = new YavscApiClient(settings, new TokenStore(tokensPath)); + var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri); + + try + { + await LoginWithBrowserAsync(client, browser.CreateBrowser()); + // Login fresh → access token is far from expiry. + var ok = await client.TrySilentLoginAsync(); + Assert.True(ok); + Assert.True(client.HasValidSession); + } + finally + { + if (File.Exists(tokensPath)) File.Delete(tokensPath); + } + } + + [Fact] + public async Task TrySilentLoginAsync_returns_true_when_refresh_succeeds() + { + using var authority = await OidcStubAuthority.StartAsync(); + using var apiServer = new StubApiServer(); + await apiServer.StartAsync(); + + var settings = BuildSettings(authority, apiServer.BaseUrl); + var tokensPath = TokensPath(); + var store = new TokenStore(tokensPath); + var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri); + + try + { + // Bootstrap: login through one client to persist the + // bundle, then expire it on disk so the silent refresh + // path has to engage. + var firstClient = new YavscApiClient(settings, store); + await LoginWithBrowserAsync(firstClient, browser.CreateBrowser()); + ExpireCachedAccessToken(tokensPath); + + // Build a second API client to mirror the real boot + // path (YavscApiClient loads from the store in its + // constructor). Its in-memory _tokens snapshot now + // matches the disk: access expired, refresh still good. + var client = new YavscApiClient(settings, store); + + var reported = new System.Collections.Generic.List(); + var progress = new SyncProgress(reported); + + var ok = await client.TrySilentLoginAsync(progress); + Assert.True(ok, "silent refresh should succeed via the stub authority."); + Assert.Contains(OidcLoginPhase.ExchangingCode, reported); + Assert.Equal(OidcLoginPhase.Success, Last(reported)); + } + finally + { + if (File.Exists(tokensPath)) File.Delete(tokensPath); + } + } + + // SKIPPED — see comment. + // + // We can't cover "TrySilentLoginAsync purges the store when the + // refresh token is rejected" with OidcStubAuthority: the stub's + // /connect/token endpoint is unconditional and hands out a fresh + // refresh token regardless of what the caller sends. To exercise + // the RefreshFailedException path we'd need an authority option + // to fail on a specific refresh-token string; until then the + // production refresh-failure path is covered manually (and by + // the structural guarantee that _store.Clear() runs in the catch + // block of ForceRefreshAsync when result.IsError). + // + // [Fact] + // public async Task TrySilentLoginAsync_purges_store_when_refresh_fails_permanently() { ... } + + private static T Last(System.Collections.Generic.List list) + { + lock (list) + { + if (list.Count == 0) + throw new InvalidOperationException( + $"IProgress<{typeof(T).Name}> never received any reports before the assertion."); + return list[list.Count - 1]; + } + } + + /// + /// Synchronous for tests. The BCL + /// Progress<T> posts via , + /// which xUnit only drains between awaits in the test method — + /// long enough that two rapid Report calls in the same + /// await chain can produce an empty / partial list. A synchronous + /// proxy captures every report in the order it was made, which + /// is exactly the contract YavscApiClient relies on (it + /// never inspects the progress sink, it just calls Report). + /// + private sealed class SyncProgress : IProgress + { + private readonly System.Collections.Generic.List _items; + private readonly object _gate = new(); + public SyncProgress(System.Collections.Generic.List sink) { _items = sink; } + public void Report(T value) { lock (_gate) _items.Add(value); } + } + + + private static void CorruptRefreshToken(string tokensPath) + { + // Kept as a helper even though the test that exercised it is + // currently disabled — see SKIPPED note above. + var json = File.ReadAllText(tokensPath); + var doc = JsonDocument.Parse(json); + var record = new RefreshTokenRecord( + AccessToken: doc.RootElement.GetProperty("AccessToken").GetString()!, + RefreshToken: "definitely-not-a-valid-refresh-token", + AccessTokenExpiresAt: DateTimeOffset.UtcNow.AddMinutes(-5), + IdToken: doc.RootElement.TryGetProperty("IdToken", out var idt) ? idt.GetString() : null); + File.WriteAllText(tokensPath, JsonSerializer.Serialize(record)); + } + + /// + /// LoginWithBrowserAsync overload that also forwards a progress + /// sink to LoginInteractiveAsync. The default (no-progress) + /// overload stays for tests that don't care about phase events. + /// + private static async Task LoginWithBrowserAsync( + YavscApiClient client, IBrowser browser, IProgress? progress = null) + { + var original = Platform.CreateBrowser; + try + { + Platform.CreateBrowser = () => browser; + await client.LoginInteractiveAsync(progress); + } + finally + { + Platform.CreateBrowser = original; + } + } } /// diff --git a/src/PostIt/PostIt.Desktop/Program.cs b/src/PostIt/PostIt.Desktop/Program.cs index fb3802eb..23c4ef62 100644 --- a/src/PostIt/PostIt.Desktop/Program.cs +++ b/src/PostIt/PostIt.Desktop/Program.cs @@ -1,5 +1,7 @@ using System; +using System.Threading; using Avalonia; +using PostIt.Services; namespace PostIt.Desktop; @@ -12,10 +14,57 @@ sealed class Program public static void Main(string[] args) { PlatformBootstrap.EnsureInitialized(); + + // Short-circuit 2nd-instance launches (OS handing us the + // postit://callback URL) BEFORE Avalonia spins up a window. + // If we let Avalonia initialise, the new MainWindow flashes + // open for a frame before OnFrameworkInitializationCompleted + // detects the scheme and shuts down — visible to the user as + // a second window with "Déconnecté" while the original + // instance is still waiting on the named pipe. + // + // We only need the scheme prefix and the OS-supplied URL, + // both of which are plain System.* / PostIt.Services — + // nothing Avalonia-specific is touched here, so the rule + // above is not violated. + if (TryHandOffCustomSchemeUrl(args)) return; + BuildAvaloniaApp() .StartWithClassicDesktopLifetime(args); } + /// + /// Detect a 2nd-instance launch (OS dispatching the postit:// URL + /// after the user completed login in the system browser), forward + /// the URL to the running instance over the named pipe, and exit + /// before Avalonia can open a window. Returns true when the + /// process should terminate without booting Avalonia. + /// + private static bool TryHandOffCustomSchemeUrl(string[] args) + { + var url = SchemeUrlDetector.FindCallbackUrl(args); + if (url is null) return false; + + // Best-effort: try to send the URL to the running + // instance via the named pipe. If the pipe isn't + // answering (user double-clicked the link after closing + // PostIt), there's no 1st instance to forward to — we + // exit cleanly anyway rather than booting a stray + // PostIt window that would just confuse the user. + try + { + SingleInstance.TryHandOffAsync(url).GetAwaiter().GetResult(); + } + catch + { + // Pipe errors are non-fatal for the 2nd-instance + // hand-off — we still want to exit cleanly. + } + + Environment.Exit(0); + return true; + } + // Avalonia configuration, don't remove; also used by visual designer. public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 9457c8b0..341d6d9d 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -5,7 +5,6 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; -using Microsoft.Extensions.DependencyInjection; using PostIt.Services; using PostIt.ViewModels; using PostIt.Views; @@ -25,6 +24,14 @@ public partial class App : Application public override void OnFrameworkInitializationCompleted() { + // Belt-and-braces 2nd-instance guard. The primary check now + // lives in PostIt.Desktop.Program.Main and exits before + // Avalonia boots — preventing a flash of the MainWindow on + // every postit://callback launch. This block is kept for any + // entry point that bypasses Program.Main (PostIt.Browser, + // PostIt.Android's process lifecycle, ad-hoc tests that build + // App directly) and as defence-in-depth in case the Desktop + // build is ever reconfigured to skip the early check. if (TryHandOffCustomSchemeUrl()) return; var settings = new Settings(); @@ -37,7 +44,6 @@ public partial class App : Application var api = new YavscApiClient(settings, tokenStore); var client = new BlogApiClient(api); - // Configure DI var services = new ServiceCollection(); // Vues @@ -54,53 +60,112 @@ public partial class App : Application services.AddTransient(); services.AddTransient(); services.AddTransient(); + + // Persistent session banner: one instance for the lifetime of + // the app so the same VM survives page navigation. + var sessionStatus = new SessionStatusViewModel { Api = api }; + sessionStatus.Refresh(); + services.AddSingleton(sessionStatus); + services.AddTransient(); + var provider = services.BuildServiceProvider(); - // Injecter le ViewLocator avec le provider DataTemplates.Clear(); DataTemplates.Add(new ViewLocator(provider)); - // Page de départ - var homeVm = provider.GetRequiredService(); - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - desktop.MainWindow = new MainWindow { DataContext = homeVm }; + var homePage = provider.GetRequiredService(); + homePage.DataContext = provider.GetRequiredService(); + + var window = new MainWindow(); + window.SessionBanner.DataContext = sessionStatus; + + // Build the navigation stack from scratch: HomePage is the + // root in both cases. App.BootAsync will push MainPage on + // top if the silent refresh succeeds. + window.DataContext = homePage.DataContext; + desktop.MainWindow = window; + _ = window.NavRoot.PushAsync(homePage); + + // When the user logs out, route back to HomePage. We + // ReplaceAsync the current top so we don't grow the stack + // on every logout — otherwise repeated login/logout would + // eventually balloon the back history. + sessionStatus.LogoutCompleted += () => + { + var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!; + var nav = w.NavRoot; + var hp = provider.GetRequiredService(); + hp.DataContext = provider.GetRequiredService(); + _ = nav.PopToRootAsync(); + }; + + window.Opened += async (_, _) => await BootAsync(provider, api, window); } else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) { - singleView.MainView = new MainWindow { DataContext = homeVm }; + singleView.MainView = new MainWindow + { + DataContext = provider.GetRequiredService() + }; } } + /// + /// Run once after the main window is shown: try to refresh the + /// cached OIDC tokens silently; on success, push MainPage on top + /// of HomePage so the user lands on the blog editor already + /// authenticated. On failure (refresh token rejected, no bundle + /// on disk), leave them on HomePage and the Login button is the + /// next step. + /// + private static async Task BootAsync( + IServiceProvider provider, + YavscApiClient api, + MainWindow window) + { + var refreshed = await api.TrySilentLoginAsync().ConfigureAwait(true); + var sessionStatus = provider.GetRequiredService(); + sessionStatus.Refresh(); + if (!refreshed) return; + + var mainVm = provider.GetRequiredService(); + var mainPage = provider.GetRequiredService(); + mainPage.DataContext = mainVm; + await window.NavRoot.PushAsync(mainPage); + } + private bool TryHandOffCustomSchemeUrl() { - var args = Environment.GetCommandLineArgs(); - var scheme = Platform.CustomScheme; - foreach (var arg in args) - { - if (arg.StartsWith(scheme + "://", StringComparison.OrdinalIgnoreCase)) - { - // Best-effort: try to send the URL to the running - // instance via the named pipe. If the pipe isn't - // answering, just exit — there's no 1st instance - // to forward to (e.g. user double-clicked the link - // after closing PostIt). Falling through with a - // normal startup would be confusing. - SingleInstance.TryHandOffAsync(arg).GetAwaiter().GetResult(); + var url = SchemeUrlDetector.FindCallbackUrl(Environment.GetCommandLineArgs()); + if (url is null) return false; - if (ApplicationLifetime is IControlledApplicationLifetime lifetime) - { - lifetime.Shutdown(0); - } - else - { - Environment.Exit(0); - } - return true; - } + // Best-effort: try to send the URL to the running + // instance via the named pipe. If the pipe isn't + // answering, just exit — there's no 1st instance + // to forward to (e.g. user double-clicked the link + // after closing PostIt). Falling through with a + // normal startup would be confusing. + try + { + SingleInstance.TryHandOffAsync(url).GetAwaiter().GetResult(); } - return false; + catch + { + // Pipe errors are non-fatal for the 2nd-instance + // hand-off. + } + + if (ApplicationLifetime is IControlledApplicationLifetime lifetime) + { + lifetime.Shutdown(0); + } + else + { + Environment.Exit(0); + } + return true; } } diff --git a/src/PostIt/PostIt/Services/OidcLoginPhase.cs b/src/PostIt/PostIt/Services/OidcLoginPhase.cs new file mode 100644 index 00000000..0add3d76 --- /dev/null +++ b/src/PostIt/PostIt/Services/OidcLoginPhase.cs @@ -0,0 +1,42 @@ +namespace PostIt.Services; + +/// +/// Discrete phases of the OIDC Authorization Code + PKCE flow, +/// surfaced through on +/// YavscApiClient.LoginInteractiveAsync so the UI can show +/// exactly where we are — including the parts that happen out of +/// process (the 2nd-instance hand-off via +/// when the OS routes the +/// custom scheme callback to a fresh PostIt process). +/// +/// The set is deliberately small: each value is a milestone an +/// operator can grep for in logs / StatusMessage, not a heartbeat. +/// +public enum OidcLoginPhase +{ + /// No login in flight (or login has settled). + Idle, + + /// Fetching the OIDC discovery document from the OP. + Discovering, + + /// Handing the authorize URL to the system browser (or + /// Chrome Custom Tabs on Android). + OpeningBrowser, + + /// The browser is on the IdP's login page; we are waiting + /// for the OS to deliver postit://callback?code=… back to a + /// running PostIt instance. On desktop this is the time window + /// during which the named-pipe server is listening. + AwaitingCallback, + + /// Exchanging the authorization code + PKCE verifier at + /// the token endpoint and persisting the bundle. + ExchangingCode, + + /// Login succeeded; tokens are on disk and in memory. + Success, + + /// Login failed; check StatusMessage for details. + Error, +} diff --git a/src/PostIt/PostIt/Services/SchemeUrlDetector.cs b/src/PostIt/PostIt/Services/SchemeUrlDetector.cs new file mode 100644 index 00000000..ee1cf3d1 --- /dev/null +++ b/src/PostIt/PostIt/Services/SchemeUrlDetector.cs @@ -0,0 +1,42 @@ +using System; + +namespace PostIt.Services; + +/// +/// Pure detection helper for the custom URI scheme used by the OIDC +/// callback hand-off (RFC 8252 §7.1). Extracted from the platform +/// entry points (PostIt.Desktop.Program.Main, +/// PostIt.App.OnFrameworkInitializationCompleted) so the matching +/// logic can be unit-tested without dragging in Avalonia or +/// performing the actual Environment.Exit side effect. +/// +/// The check is a single string prefix match: +/// is scanned for the first entry that starts with +/// scheme + "://" (case-insensitive). The scheme itself is +/// exposed as so callers don't +/// have to know whether we're on the postit:// (Desktop), android:// +/// (Android), or a third scheme (a future Web variant). +/// +public static class SchemeUrlDetector +{ + /// + /// Returns the first command-line argument that looks like a + /// custom-scheme callback URL (e.g. postit://callback?code=***), + /// or null when none is present. Pure: no I/O, no process + /// exit. Safe to call from any platform entry point. + /// + public static string? FindCallbackUrl(string[] args) + { + if (args is null) return null; + var scheme = Platform.CustomScheme; + if (string.IsNullOrEmpty(scheme)) return null; + var prefix = scheme + "://"; + foreach (var arg in args) + { + if (arg is null) continue; + if (arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return arg; + } + return null; + } +} diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index e3e83adb..6710ebb8 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -84,20 +84,53 @@ public class YavscApiClient : IAsyncDisposable public string? CurrentIdToken => _tokens?.IdToken; /// Force a new interactive login (PKCE). Throws on failure. - public async Task LoginInteractiveAsync(CancellationToken ct = default) + /// Optional sink for the discrete phases of + /// the flow; the UI uses this to render a debug-friendly status + /// (Discovering → OpeningBrowser → AwaitingCallback → ExchangingCode + /// → Success / Error). The same caller can also rely on + /// for the human + /// text (URLs, error detail). + public async Task LoginInteractiveAsync( + IProgress? progress = null, + CancellationToken ct = default) { + progress?.Report(OidcLoginPhase.Discovering); + var browser = Platform.CreateBrowser?.Invoke(); if (browser is null) + { + progress?.Report(OidcLoginPhase.Error); 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); + + // OidcClient.LoginAsync builds the authorize URL, calls + // IBrowser.InvokeAsync (which on desktop hands the user off + // to the system browser and waits on the named pipe), then + // posts the code at the token endpoint. We can't hook each + // milestone individually without subclassing OidcClient, so + // we bracket the call with the two phases the UI cares about: + // the moment we ask the browser to open (covers the entire + // user-driven window including the AwaitingCallback wait), and + // the moment we trade the code for tokens. + progress?.Report(OidcLoginPhase.OpeningBrowser); + var result = await client.LoginAsync(new LoginRequest(), ct).ConfigureAwait(false); + if (result.IsError) + { + progress?.Report(OidcLoginPhase.Error); throw new InvalidOperationException($"OIDC login failed: {result.Error}"); + } + + progress?.Report(OidcLoginPhase.ExchangingCode); if (string.IsNullOrEmpty(result.RefreshToken)) + { + progress?.Report(OidcLoginPhase.Error); throw new InvalidOperationException( "Missing refresh_token — vérifie le scope 'offline_access'."); + } _tokens = new RefreshTokenRecord( AccessToken: result.AccessToken, @@ -106,6 +139,54 @@ public class YavscApiClient : IAsyncDisposable IdToken: result.IdentityToken); _store.Save(_tokens); + progress?.Report(OidcLoginPhase.Success); + } + + /// + /// Best-effort silent refresh used at boot when a token bundle is + /// already on disk. Returns true when the access token is + /// usable (either because it was still valid, or because the + /// refresh succeeded); false when the refresh token is + /// gone / rejected and the caller should route to the login page. + /// Never throws on refresh failure — it logs via the returned + /// phase and returns false so the UI can keep going. + /// + public async Task TrySilentLoginAsync( + IProgress? progress = null, + CancellationToken ct = default) + { + if (!HasValidSession) return false; + if (_tokens is null) return false; + + // Access token still has plenty of life — nothing to do. + if (_tokens.AccessTokenExpiresAt - DateTimeOffset.UtcNow > RefreshSkew) + { + progress?.Report(OidcLoginPhase.Success); + return true; + } + + // Access token expired but we have a refresh token: try the + // silent refresh once. If the OP rejects (revoked, rotation + // theft, network down), the refresh path already purges the + // store and throws RefreshFailedException; we catch and route + // the user back to the login page. + try + { + progress?.Report(OidcLoginPhase.ExchangingCode); + await ForceRefreshAsync(ct).ConfigureAwait(false); + progress?.Report(OidcLoginPhase.Success); + return true; + } + catch (RefreshFailedException) + { + progress?.Report(OidcLoginPhase.Idle); + return false; + } + catch + { + progress?.Report(OidcLoginPhase.Idle); + return false; + } } /// Call a JSON endpoint, transparently refreshing the token if needed. diff --git a/src/PostIt/PostIt/Settings/Settings.cs b/src/PostIt/PostIt/Settings/Settings.cs index ddac7199..d69188b6 100644 --- a/src/PostIt/PostIt/Settings/Settings.cs +++ b/src/PostIt/PostIt/Settings/Settings.cs @@ -83,7 +83,8 @@ public partial class Settings : ObservableObject ClientId = Authentication.ClientId, RedirectUri = RedirectUri, Scope = string.Join(' ', this.Scopes), - TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody + TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody, + PostLogoutRedirectUri = "https//yavsc.pschneider.fr", // PKCE is enabled by default when no client_secret is provided. }; diff --git a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs b/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs index cf8c27a7..5c847beb 100644 --- a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs @@ -97,6 +97,41 @@ public partial class LoginPageViewModel : ViewModelBase public Settings Settings { get; } + /// + /// Discrete phase of the OIDC flow the LoginPage is currently + /// showing. Surfaced in the UI as a one-line status (Discovering / + /// OpeningBrowser / AwaitingCallback / ExchangingCode / Success / + /// Error). Operators use this to debug the custom-scheme + /// callback hand-off: when AwaitingCallback never resolves, + /// the OS never re-launched PostIt with the postit:// URL. + /// + private OidcLoginPhase _phase = OidcLoginPhase.Idle; + public OidcLoginPhase Phase + { + get => _phase; + private set + { + if (this.SetProperty(ref _phase, value)) + OnPropertyChanged(nameof(PhaseLabel)); + } + } + + /// + /// Human-readable label for . French to match + /// the rest of the UI. Computed once per phase change. + /// + public string PhaseLabel => _phase switch + { + OidcLoginPhase.Idle => "En attente", + OidcLoginPhase.Discovering => "Découverte OIDC…", + OidcLoginPhase.OpeningBrowser => "Ouverture du navigateur…", + OidcLoginPhase.AwaitingCallback => "En attente du callback postit://…", + OidcLoginPhase.ExchangingCode => "Échange du code contre les jetons…", + OidcLoginPhase.Success => "Connecté", + OidcLoginPhase.Error => "Erreur", + _ => _phase.ToString(), + }; + private string _statusMessage = "Ready"; public string StatusMessage { @@ -222,7 +257,12 @@ public partial class LoginPageViewModel : ViewModelBase // 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); + // + // The progress sink drives Phase / PhaseLabel; StatusMessage + // keeps the text detail (URLs, error messages). Same + // underlying flow, two views. + var progress = new Progress(p => Phase = p); + await LoginInteractiveCoreAsync(_api, progress).ConfigureAwait(false); IsBusy = false; AccessToken = _api.CurrentAccessToken; @@ -243,7 +283,9 @@ public partial class LoginPageViewModel : ViewModelBase /// 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) + private async Task LoginInteractiveCoreAsync( + YavscApiClient api, + IProgress? progress = null) { var original = Platform.CreateBrowser; try @@ -251,7 +293,7 @@ public partial class LoginPageViewModel : ViewModelBase if (BrowserFactoryOverride is not null) Platform.CreateBrowser = BrowserFactoryOverride; - await api.LoginInteractiveAsync().ConfigureAwait(false); + await api.LoginInteractiveAsync(progress).ConfigureAwait(false); } finally { diff --git a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs new file mode 100644 index 00000000..0ebe73a9 --- /dev/null +++ b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs @@ -0,0 +1,64 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PostIt.Services; + +namespace PostIt.ViewModels; + +/// +/// Persistent session banner VM, hosted in +/// MainWindow.axaml. Mirrors 's +/// session state ("Connecté" / "Déconnecté") and exposes a +/// Logout command that purges the token store and asks the +/// navigation owner to route the user back to HomePage. +/// +/// Construction is deferred until the API client exists; the +/// App.axaml.cs wiring sets after building both, +/// so the banner reflects reality from frame zero. +/// +public partial class SessionStatusViewModel : ViewModelBase +{ + /// Raised after has purged the store; + /// App.axaml.cs listens and swaps the navigation root. + public event System.Action? LogoutCompleted; + + [ObservableProperty] + public partial bool IsLoggedIn { get; private set; } + + [ObservableProperty] + public partial string SessionLabel { get; private set; } = "Déconnecté"; + + /// The API client backing the banner. Set once at startup; + /// the banner polls HasValidSession on demand rather than + /// subscribing to a stream — the session state only changes at + /// login, logout, and silent refresh, all of which already + /// re-evaluate from the same _tokens snapshot. + public YavscApiClient? Api { get; set; } + + public override bool CanNavigateNext + { + get => throw new System.NotImplementedException(); + protected set => throw new System.NotImplementedException(); + } + + public override bool CanNavigatePrevious + { + get => throw new System.NotImplementedException(); + protected set => throw new System.NotImplementedException(); + } + + public void Refresh() + { + var has = Api?.HasValidSession ?? false; + IsLoggedIn = has; + SessionLabel = has ? "Connecté" : "Déconnecté"; + } + + [RelayCommand] + public async System.Threading.Tasks.Task LogoutAsync() + { + if (Api is null) return; + await Api.LogoutAsync().ConfigureAwait(false); + Refresh(); + LogoutCompleted?.Invoke(); + } +} diff --git a/src/PostIt/PostIt/Views/LoginPage.axaml b/src/PostIt/PostIt/Views/LoginPage.axaml index fe0f714b..df8d86e3 100644 --- a/src/PostIt/PostIt/Views/LoginPage.axaml +++ b/src/PostIt/PostIt/Views/LoginPage.axaml @@ -60,6 +60,26 @@ IsReadOnly="True" BorderThickness="0" Background="Transparent"/> + + + + + + diff --git a/src/PostIt/PostIt/Views/MainWindow.axaml b/src/PostIt/PostIt/Views/MainWindow.axaml index f8f4317f..79a931c4 100644 --- a/src/PostIt/PostIt/Views/MainWindow.axaml +++ b/src/PostIt/PostIt/Views/MainWindow.axaml @@ -6,9 +6,22 @@ x:Class="PostIt.Views.MainWindow" > - - - - + + + + + diff --git a/src/PostIt/PostIt/Views/SessionStatusBanner.axaml b/src/PostIt/PostIt/Views/SessionStatusBanner.axaml new file mode 100644 index 00000000..84fb3b83 --- /dev/null +++ b/src/PostIt/PostIt/Views/SessionStatusBanner.axaml @@ -0,0 +1,22 @@ + + + + +