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.
This commit is contained in:
Paul Schneider 2026-06-20 17:49:53 +01:00
commit 7a0944d0f5
3 changed files with 64 additions and 25 deletions

View file

@ -0,0 +1,24 @@
using IdentityModel.OidcClient.Browser;
using PostIt.Services;
namespace PostIt.Desktop;
/// <summary>
/// One-shot platform bootstrap. Called from <c>Program.Main</c> so that
/// the shared <c>LoginPageViewModel</c> sees a working <c>IBrowser</c>
/// (the loopback listener that captures the OIDC redirect) without
/// referencing any platform-specific API from the shared library.
/// </summary>
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();
}
}

View file

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

View file

@ -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<DbContext, bool> EnsureDefaultConfiguration()
private static Action<DbContext, bool> 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.
/// </summary>
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<ClientRedirectUri>().Add(new IdentityServer8.EntityFramework.Entities.ClientRedirectUri
{
@ -450,12 +452,29 @@ public static class HostingExtensions
context.SaveChanges();
}
/// <summary>
/// Compose the full set of redirect URIs for the PostIt client. The base
/// URIs cover the standalone desktop/mobile flows; the value of
/// <c>Site:ExternalUrl</c> is appended so PostIt can also be embedded in
/// a Yavsc.Org web page (e.g. an iframe-launched launcher).
/// </summary>
private static IEnumerable<string> BuildPostItRedirectUris(IConfiguration configuration)
{
foreach (var uri in PostItRedirectUris)
yield return uri;
var externalUrl = configuration["Site:ExternalUrl"];
if (!string.IsNullOrWhiteSpace(externalUrl))
yield return externalUrl;
}
/// <summary>
/// Bring an existing <c>postit</c> client up to the current public-client
/// configuration. Idempotent: each change is applied only when the row is
/// currently in the legacy state.
/// </summary>
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<ClientRedirectUri>()
.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<ClientRedirectUri>().RemoveRange(legacyRedirects);
changed = true;
}
if (changed)
{
context.SaveChanges();