From 7a0944d0f54fcf179db54c932b800feecc02f643 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sat, 20 Jun 2026 17:49:53 +0100 Subject: [PATCH] PostIt.Desktop: wire the loopback browser, parameterise PostIt RedirectUris The previous commit set Platform.CreateBrowser to null on the desktop side, so LoginAsync would still fail with 'No browser is available'. Close that loop with an explicit desktop bootstrap. PostIt.Desktop/PlatformBootstrap.cs mirrors the Android side: it populates Platform.DefaultRedirectUri and Platform.CreateBrowser once at startup. Program.Main calls EnsureInitialized before BuildAvaloniaApp so the LoginPageViewModel sees a working browser before any login attempt. The Yavsc.Org seed now reads Site:ExternalUrl from configuration so the RedirectUri list for the PostIt client follows the same setting as the rest of the application (same value used in Administration/ClientController, AccountController, etc.). Without this, an embedded 'launch PostIt from a Yavsc.Org page' scenario would be rejected by IdentityServer (redirect_uri mismatch). BuildPostItRedirectUris is a small helper that yields the constant PostItRedirectUris (loopback + Android custom scheme) followed by Site:ExternalUrl when set. Both SeedNewPostItClient (fresh db) and MigratePostItClientToPublic (existing db) consume it. The legacy cleanup block (which used to remove https://yavsc.pschneider.fr/ and yavsc://callback) is dropped: Site:ExternalUrl is now the canonical way to authorise that path and may legitimately equal that value. --- .../PostIt.Desktop/PlatformBootstrap.cs | 24 +++++++++ src/PostIt/PostIt.Desktop/Program.cs | 12 +++-- src/Yavsc.Org/Extensions/HostingExtensions.cs | 53 +++++++++++-------- 3 files changed, 64 insertions(+), 25 deletions(-) create mode 100644 src/PostIt/PostIt.Desktop/PlatformBootstrap.cs diff --git a/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs b/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs new file mode 100644 index 00000000..eb021646 --- /dev/null +++ b/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs @@ -0,0 +1,24 @@ +using IdentityModel.OidcClient.Browser; +using PostIt.Services; + +namespace PostIt.Desktop; + +/// +/// One-shot platform bootstrap. Called from Program.Main so that +/// the shared LoginPageViewModel sees a working IBrowser +/// (the loopback listener that captures the OIDC redirect) without +/// referencing any platform-specific API from the shared library. +/// +internal static class PlatformBootstrap +{ + private static int _initialized; + + internal static void EnsureInitialized() + { + if (System.Threading.Interlocked.Exchange(ref _initialized, 1) != 0) + return; + + Platform.DefaultRedirectUri = Settings.DefaultLoopbackRedirectUri; + Platform.CreateBrowser = () => new LoopbackBrowser(); + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt.Desktop/Program.cs b/src/PostIt/PostIt.Desktop/Program.cs index 17776559..fb3802eb 100644 --- a/src/PostIt/PostIt.Desktop/Program.cs +++ b/src/PostIt/PostIt.Desktop/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using Avalonia; namespace PostIt.Desktop; @@ -9,8 +9,12 @@ sealed class Program // SynchronizationContext-reliant code before AppMain is called: things aren't initialized // yet and stuff might break. [STAThread] - public static void Main(string[] args) => BuildAvaloniaApp() - .StartWithClassicDesktopLifetime(args); + public static void Main(string[] args) + { + PlatformBootstrap.EnsureInitialized(); + BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + } // Avalonia configuration, don't remove; also used by visual designer. public static AppBuilder BuildAvaloniaApp() @@ -21,4 +25,4 @@ sealed class Program #endif .WithInterFont() .LogToTrace(); -} +} \ No newline at end of file diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index 8c483d39..e68e1ba1 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -306,7 +306,7 @@ public static class HostingExtensions sql => sql.MigrationsAssembly(migrationsAssembly)); } - b.UseSeeding(EnsureDefaultConfiguration()); + b.UseSeeding(EnsureDefaultConfiguration(builder.Configuration)); }; }) .AddOperationalStore(options => @@ -378,7 +378,9 @@ public static class HostingExtensions IdentityServer8.IdentityServerConstants.StandardScopes.Profile, }; - private static Action EnsureDefaultConfiguration() + private static Action EnsureDefaultConfiguration( + IConfiguration configuration + ) { return (context, _) => { @@ -389,11 +391,11 @@ public static class HostingExtensions if (existingClient is null) { - SeedNewPostItClient(context); + SeedNewPostItClient(configuration, context); return; } - MigratePostItClientToPublic(context, existingClient); + MigratePostItClientToPublic(configuration, context, existingClient); }; } @@ -402,7 +404,7 @@ public static class HostingExtensions /// client using Authorization Code + PKCE. Used the first time the /// ConfigurationDb is seeded. /// - private static void SeedNewPostItClient(DbContext context) + private static void SeedNewPostItClient(IConfiguration configuration, DbContext context) { // PostIt is a public client (Authorization Code + PKCE). // No client secret is stored or transmitted; PKCE binds the @@ -437,7 +439,7 @@ public static class HostingExtensions }); } - foreach (var redirectUri in PostItRedirectUris) + foreach (var redirectUri in BuildPostItRedirectUris(configuration)) { context.Set().Add(new IdentityServer8.EntityFramework.Entities.ClientRedirectUri { @@ -450,12 +452,29 @@ public static class HostingExtensions context.SaveChanges(); } + /// + /// Compose the full set of redirect URIs for the PostIt client. The base + /// URIs cover the standalone desktop/mobile flows; the value of + /// Site:ExternalUrl is appended so PostIt can also be embedded in + /// a Yavsc.Org web page (e.g. an iframe-launched launcher). + /// + private static IEnumerable BuildPostItRedirectUris(IConfiguration configuration) + { + foreach (var uri in PostItRedirectUris) + yield return uri; + + var externalUrl = configuration["Site:ExternalUrl"]; + if (!string.IsNullOrWhiteSpace(externalUrl)) + yield return externalUrl; + } + /// /// Bring an existing postit client up to the current public-client /// configuration. Idempotent: each change is applied only when the row is /// currently in the legacy state. /// private static void MigratePostItClientToPublic( + IConfiguration configuration, DbContext context, IdentityServer8.EntityFramework.Entities.Client client) { @@ -518,10 +537,12 @@ public static class HostingExtensions } } - // 5. Ensure all expected redirect URIs are present. Legacy entries - // pointing at the OP itself (e.g. https://yavsc.pschneider.fr/) - // are removed — they redirect back into IdentityServer's own home - // page and create a login loop. + // 5. Ensure all expected redirect URIs are present. The expected set + // is built by BuildPostItRedirectUris: the standalone URIs from + // PostItRedirectUris (desktop loopback + Android custom scheme) + // plus Site:ExternalUrl so PostIt can be embedded in a Yavsc.Org + // web page. Any pre-existing rows that are no longer in this set + // are removed. var existingRedirects = context.Set() .Where(r => r.Client.Id == client.Id) .ToList(); @@ -529,7 +550,7 @@ public static class HostingExtensions .Select(r => r.RedirectUri) .ToHashSet(StringComparer.Ordinal); - foreach (var redirectUri in PostItRedirectUris) + foreach (var redirectUri in BuildPostItRedirectUris(configuration)) { if (!existingRedirectUris.Contains(redirectUri)) { @@ -542,16 +563,6 @@ public static class HostingExtensions } } - var legacyRedirects = existingRedirects - .Where(r => r.RedirectUri == "https://yavsc.pschneider.fr/" - || r.RedirectUri == "yavsc://callback") - .ToList(); - if (legacyRedirects.Count > 0) - { - context.Set().RemoveRange(legacyRedirects); - changed = true; - } - if (changed) { context.SaveChanges();