WIP PostIt login

This commit is contained in:
Paul Schneider 2026-06-25 00:08:25 +01:00
commit f3a3b63595
13 changed files with 103 additions and 160 deletions

6
.vscode/launch.json vendored
View file

@ -16,6 +16,12 @@
"request": "launch", "request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj" "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj"
}, },
{
"name": "Yavsc.Blogs",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Blogs/Yavsc.Blogs.csproj"
},
{ {
"name": "PostIt", "name": "PostIt",
"type": "dotnet", "type": "dotnet",

View file

@ -132,11 +132,7 @@ d'abord `appsettings-org.json` du serveur ; sinon, laisse-le en place.
seed EF Core d'IdentityServer utilise `Site.ExternalUrl` pour seed EF Core d'IdentityServer utilise `Site.ExternalUrl` pour
autoriser une RedirectUri du client `postit`** : cela permet à PostIt autoriser une RedirectUri du client `postit`** : cela permet à PostIt
d'être lancé depuis une page web de Yavsc.Org (iframe launcher) d'être lancé depuis une page web de Yavsc.Org (iframe launcher)
sans rejet `redirect_uri mismatch` de l'OP. Les RedirectUris sans rejet `redirect_uri mismatch` de l'OP.
« standalone » du client (`http://127.0.0.1:7890/` et
`android://postit-signin`) restent codées en dur dans
`EnsureDefaultConfiguration` car elles sont fixées par la plateforme,
pas par l'URL de déploiement.
- `ConnectionStrings.YavscConnection` — chaîne de connexion PostgreSQL - `ConnectionStrings.YavscConnection` — chaîne de connexion PostgreSQL
(utilisateur, mot de passe, hôte, base). Privilégier (utilisateur, mot de passe, hôte, base). Privilégier
`dotnet user-secrets` ou des variables d'environnement `ASPNETCORE_*` `dotnet user-secrets` ou des variables d'environnement `ASPNETCORE_*`

View file

@ -44,7 +44,7 @@ public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable
// Pick a free loopback port. // Pick a free loopback port.
var port = GetFreePort(); var port = GetFreePort();
var prefix = $"http://127.0.0.1:{port}/"; var prefix = $"http://127.0.0.1:{port}/";
var loopback = "http://127.0.0.1:7890/"; // matches PostIt.Settings.DefaultLoopbackRedirectUri var loopback = "postit://callback"; // matches PostIt.Settings.DefaultLoopbackRedirectUri
var listener = new HttpListener(); var listener = new HttpListener();
listener.Prefixes.Add(prefix); listener.Prefixes.Add(prefix);

View file

@ -1,6 +1,5 @@
using System; using System;
using System.IO; using System.IO;
using System.Threading.Tasks;
using Xunit; using Xunit;
namespace PostIt.Tests; namespace PostIt.Tests;
@ -14,7 +13,7 @@ public class SettingsLoadTests
/// PostIt.dll. /// PostIt.dll.
/// </summary> /// </summary>
[Fact] [Fact]
public async Task Load_falls_back_to_embedded_resource_when_user_file_missing() public void Load_falls_back_to_embedded_resource_when_user_file_missing()
{ {
// Skip if a user-level file exists (CI / different dev machines). // Skip if a user-level file exists (CI / different dev machines).
var userConfigPath = Path.Combine( var userConfigPath = Path.Combine(
@ -27,7 +26,7 @@ public class SettingsLoadTests
} }
var settings = new PostIt.Settings(); var settings = new PostIt.Settings();
await settings.Load(); settings.Load();
// The bundled postit-settings.json points at yavsc.pschneider.fr. // The bundled postit-settings.json points at yavsc.pschneider.fr.
Assert.False(string.IsNullOrWhiteSpace(settings.Authentication?.Authority)); Assert.False(string.IsNullOrWhiteSpace(settings.Authentication?.Authority));

View file

@ -115,12 +115,12 @@ public class YavscApiClientTests
{ {
Authentication = new AuthenticationSettings Authentication = new AuthenticationSettings
{ {
Authority = "http://127.0.0.1:1", Authority = "https://127.0.0.1:5001",
ClientId = "postit-tests", ClientId = "postit-tests",
}, },
RedirectUri = "http://127.0.0.1:7890/", RedirectUri = "postit://callback",
Scopes = new[] { "openid" }, Scopes = new[] { "openid" },
ApiUrl = "http://127.0.0.1:1/", ApiUrl = "https://127.0.0.1:5003/api/v1",
}; };
var client = new YavscApiClient(settings, new TokenStore(Path.Combine( var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json"))); Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));

View file

@ -1,4 +1,5 @@
using System; using System;
using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
@ -31,33 +32,41 @@ public partial class App : Application
{ {
return; return;
} }
var settings = new Settings();
// Synchronous: Settings.Load is intentionally non-async so we
// don't deadlock the Avalonia UI thread. .Wait() on an async
// method would block here forever on the await inside the
// file read.
settings.Load();
var tokenStore = new TokenStore(System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
"PostIt", "tokens.json"));
var client = new BlogApiClient(new YavscApiClient(settings, tokenStore));
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
var blog = BuildBlogClient(out var settings);
desktop.MainWindow = new MainWindow desktop.MainWindow = new MainWindow
{ {
DataContext = new MainPageViewModel(blog, settings) DataContext = new MainPageViewModel(client, settings)
}; };
} }
else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime) else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime)
{ {
singleViewFactoryApplicationLifetime.MainViewFactory = () => singleViewFactoryApplicationLifetime.MainViewFactory = () =>
{ {
var blog = BuildBlogClient(out var settings); return new MainPage { DataContext = new MainPageViewModel(client, settings) };
return new MainPage { DataContext = new MainPageViewModel(blog, settings) };
}; };
} }
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
{ {
var blog = BuildBlogClient(out var settings);
singleViewPlatform.MainView = new MainPage singleViewPlatform.MainView = new MainPage
{ {
DataContext = new MainPageViewModel(blog, settings) DataContext = new MainPageViewModel(client, settings)
}; };
} }
else
throw new NotSupportedException("ApplicationLifetime not supported.");
base.OnFrameworkInitializationCompleted();
} }
private bool TryHandOffCustomSchemeUrl() private bool TryHandOffCustomSchemeUrl()
@ -90,19 +99,4 @@ public partial class App : Application
return false; return false;
} }
/// <summary>
/// Build the (Settings, BlogApiClient) pair used by all UI
/// lifetimes. A single TokenStore is shared so a login performed
/// by the LoginPage is observable to the MainPage (and vice-versa)
/// without going through disk on every API call.
/// </summary>
private static BlogApiClient BuildBlogClient(out Settings settings)
{
settings = new Settings();
try { settings.Load().GetAwaiter().GetResult(); } catch { /* fall back to embedded defaults */ }
var tokenStore = new TokenStore(System.IO.Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.ApplicationData),
"PostIt", "tokens.json"));
return new BlogApiClient(new YavscApiClient(settings, tokenStore));
}
} }

View file

@ -1,5 +1,4 @@
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Runtime.CompilerServices;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
@ -9,7 +8,6 @@ using PostIt.Services;
using System; using System;
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks;
[assembly: InternalsVisibleTo("PostIt.Tests")] [assembly: InternalsVisibleTo("PostIt.Tests")]
@ -69,6 +67,7 @@ public partial class Settings : ObservableObject
[ObservableProperty] [ObservableProperty]
public partial string[] Scopes { get; set; } public partial string[] Scopes { get; set; }
public bool Loaded { get; private set; } = false;
/// <summary> /// <summary>
/// Build OidcClient options configured for Authorization Code + PKCE /// Build OidcClient options configured for Authorization Code + PKCE
@ -77,12 +76,14 @@ public partial class Settings : ObservableObject
/// </summary> /// </summary>
internal OidcClientOptions GetOidcClientOptions(IdentityModel.OidcClient.Browser.IBrowser? browser = null) internal OidcClientOptions GetOidcClientOptions(IdentityModel.OidcClient.Browser.IBrowser? browser = null)
{ {
if (!Loaded) Load();
var options = new OidcClientOptions var options = new OidcClientOptions
{ {
Authority = Authentication.Authority, Authority = Authentication.Authority,
ClientId = Authentication.ClientId, ClientId = Authentication.ClientId,
RedirectUri = RedirectUri, RedirectUri = RedirectUri,
Scope = string.Join(' ', this.Scopes), Scope = string.Join(' ', this.Scopes),
TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody
// PKCE is enabled by default when no client_secret is provided. // PKCE is enabled by default when no client_secret is provided.
}; };
@ -92,8 +93,9 @@ public partial class Settings : ObservableObject
return options; return options;
} }
internal async Task Load() internal void Load()
{ {
if (Loaded) return;
string configDir = Path.Combine( string configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt" "PostIt"
@ -123,10 +125,17 @@ public partial class Settings : ObservableObject
try try
{ {
// Synchronous read on purpose: Settings.Load() is called from
// synchronous startup paths (App.axaml.cs, ViewModel ctors,
// tests) and bridging to async here with .Wait() / .GetAwaiter()
// .GetResult() deadlocks the Avalonia UI thread because the
// continuation can't resume on the same thread. The settings
// file is a few KiB at most; async I/O gains nothing here.
using var stream = configFileInfo.OpenRead(); using var stream = configFileInfo.OpenRead();
using var reader = new StreamReader(stream); using var reader = new StreamReader(stream);
var json = await reader.ReadToEndAsync(); var json = reader.ReadToEnd();
ApplyJson(json, $"user file {configFileInfo.FullName}"); ApplyJson(json, $"user file {configFileInfo.FullName}");
Loaded = true;
} }
catch (Exception ex) catch (Exception ex)
{ {

View file

@ -139,8 +139,10 @@ public partial class LoginPageViewModel : ViewModelBase
{ {
// 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
// before the user clicks Login). // before the user clicks Login). Settings.Load is synchronous
try { Settings.Load().GetAwaiter().GetResult(); } // on purpose; calling .GetAwaiter().GetResult() on it would
// deadlock the UI thread on the await inside the file read.
try { Settings.Load(); }
catch { /* settings may be missing in tests/dev; LoginAsync will surface real errors */ } catch { /* settings may be missing in tests/dev; LoginAsync will surface real errors */ }
} }
@ -173,7 +175,7 @@ public partial class LoginPageViewModel : ViewModelBase
if (SettingsLoadOverride is not null) if (SettingsLoadOverride is not null)
await SettingsLoadOverride().ConfigureAwait(false); await SettingsLoadOverride().ConfigureAwait(false);
else else
await Settings.Load().ConfigureAwait(false); Settings.Load();
// Guard: refuse to call OidcClient when the authority is // Guard: refuse to call OidcClient when the authority is
// empty. IdentityModel would otherwise build a bogus // empty. IdentityModel would otherwise build a bogus

View file

@ -3,12 +3,12 @@
"ClientId": "postit", "ClientId": "postit",
"Authority": "https://yavsc.pschneider.fr/" "Authority": "https://yavsc.pschneider.fr/"
}, },
"RedirectUri": "postit://callback",
"DarkMode": true, "DarkMode": true,
"ApiUrl": "https://blogs.pschneider.fr/api/v1/", "ApiUrl": "https://blogs.pschneider.fr/api/v1/",
"Scopes": [ "Scopes": [
"openid", "openid",
"profile", "profile",
"email",
"offline_access", "offline_access",
"blogs" "blogs"
] ]

View file

@ -32,7 +32,7 @@ internal class Program
{ {
policy policy
.RequireAuthenticatedUser() .RequireAuthenticatedUser()
.RequireClaim(JwtClaimTypes.Scope, new string[] { "blog" }); .RequireClaim(JwtClaimTypes.Scope, new string[] { "blogs" });
}); });
}) })
.AddYavscCors(builder.Configuration) .AddYavscCors(builder.Configuration)
@ -88,7 +88,7 @@ internal class Program
.UseAuthorization() .UseAuthorization()
.UseCors("default") .UseCors("default")
; ;
app.MapIdentityApi<ApplicationUser>().RequireAuthorization("blog"); app.MapIdentityApi<ApplicationUser>().RequireAuthorization("BlogScope");
app.MapGet("/identity", (HttpContext context) => app.MapGet("/identity", (HttpContext context) =>
new JsonResult(context?.User?.Claims.Select(c => new { c.Type, c.Value })) new JsonResult(context?.User?.Claims.Select(c => new { c.Type, c.Value }))

View file

@ -1,5 +1,7 @@
public static class Constants public static class Constants
{ {
public static readonly string[] BuildInApiScopes = { "blog", "admin", "moderation", "performer", "client" }; public static readonly string[] BuildInApiScopes = {
"profile", "openid", "offline_access",
"blogs", "admin", "moderation", "performer", "client" };
} }

View file

@ -44,6 +44,8 @@ using Yavsc.Services.Kyc;
using Yavsc.Settings; using Yavsc.Settings;
using Yavsc.ViewModels.Auth; using Yavsc.ViewModels.Auth;
using static IdentityServer8.IdentityServerConstants; using static IdentityServer8.IdentityServerConstants;
using IdentityServer8.Models;
using IdentityServer8.EntityFramework.Mappers;
namespace Yavsc.Extensions; namespace Yavsc.Extensions;
@ -525,13 +527,39 @@ public static class HostingExtensions
{ {
foreach (String scope in Constants.BuildInApiScopes) foreach (String scope in Constants.BuildInApiScopes)
{ {
var existentScope = context.Set<ApiScope>().FirstOrDefault(b => b.Name == scope); var existentScope = context.Set<IdentityServer8.EntityFramework.Entities.ApiScope>().FirstOrDefault(b => b.Name == scope);
if (existentScope == null) if (existentScope == null)
{ {
context.Set<ApiScope>().Add(new ApiScope { Name = scope }); context.Set<IdentityServer8.EntityFramework.Entities.ApiScope>().Add(new IdentityServer8.EntityFramework.Entities.ApiScope { Name = scope });
context.SaveChanges(); context.SaveChanges();
} }
} }
var identityResources = context.Set<IdentityServer8.EntityFramework.Entities.IdentityResource>();
var apiScopes = context.Set<IdentityServer8.EntityFramework.Entities.ApiScope>();
// IdentityResources standards
if (!identityResources.Any(r => r.Name == "openid"))
{
var openid = new IdentityResources.OpenId().ToEntity();
identityResources.Add(openid);
}
if (!identityResources.Any(r => r.Name == "profile"))
{
var profile = new IdentityResources.Profile().ToEntity();
identityResources.Add(profile);
}
// ApiScope custom
if (!apiScopes.Any(s => s.Name == "blogs"))
{
apiScopes.Add(new IdentityServer8.EntityFramework.Entities.ApiScope
{
Name = "blogs",
DisplayName = "Yavsc Blogs API",
Enabled = true
});
}
}; };
} }
@ -540,10 +568,9 @@ public static class HostingExtensions
private static readonly string[] PostItRedirectUris = new[] private static readonly string[] PostItRedirectUris = new[]
{ {
// Loopback URI for desktop / browser-based PKCE flows. // Loopback URI for desktop / browser-based PKCE flows.
"http://127.0.0.1:7890/", "postit://callback",
// Custom-scheme URI for Android. The matching IntentFilter must be
// declared in PostIt.Android/Properties/AndroidManifest.xml.
"android://postit-signin", "android://postit-signin",
"https://blogs.pschneider.fr"
}; };
private static readonly string[] PostItGrantTypes = new[] private static readonly string[] PostItGrantTypes = new[]
@ -554,9 +581,18 @@ public static class HostingExtensions
private static readonly string[] PostItScopes = new[] private static readonly string[] PostItScopes = new[]
{ {
"blog", // Scopes the PostIt client is allowed to ask for. Must match
// what postit-settings.json (and Constants.BuildInApiScopes on
// the server) actually defines. Notably:
// - "blogs" (plural) is the API scope that gates access to the
// Yavsc.Blogs deployment at https://blogs.pschneider.fr.
// - "offline_access" is required for the YavscApiClient's
// silent refresh path to work; without it IdentityServer
// refuses to issue a refresh_token.
"blogs",
IdentityServer8.IdentityServerConstants.StandardScopes.OpenId, IdentityServer8.IdentityServerConstants.StandardScopes.OpenId,
IdentityServer8.IdentityServerConstants.StandardScopes.Profile, IdentityServer8.IdentityServerConstants.StandardScopes.Profile,
IdentityServer8.IdentityServerConstants.StandardScopes.OfflineAccess,
}; };
private static Action<DbContext, bool> EnsureDefaultConfiguration( private static Action<DbContext, bool> EnsureDefaultConfiguration(
@ -576,7 +612,6 @@ public static class HostingExtensions
return; return;
} }
MigratePostItClientToPublic(configuration, context, existingClient);
}; };
} }
@ -600,7 +635,7 @@ public static class HostingExtensions
RequireConsent = false, RequireConsent = false,
}; };
context.Set<Client>().Add(client); context.Set<IdentityServer8.EntityFramework.Entities.Client>().Add(client);
foreach (var grantType in PostItGrantTypes) foreach (var grantType in PostItGrantTypes)
{ {
@ -649,106 +684,6 @@ public static class HostingExtensions
yield return 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)
{
var changed = false;
// 1. Drop the client secret. PKCE-only clients must not have one.
var secrets = context.Set<ClientSecret>().Where(s => s.Client.Id == client.Id);
if (secrets.Any())
{
context.Set<ClientSecret>().RemoveRange(secrets);
changed = true;
}
// 2. Flip the security flags.
if (client.RequireClientSecret)
{
client.RequireClientSecret = false;
changed = true;
}
if (!client.RequirePkce)
{
client.RequirePkce = true;
changed = true;
}
// 3. Ensure all expected grant types are present (don't remove extras
// that may have been added by hand).
var existingGrantTypes = context.Set<ClientGrantType>()
.Where(g => g.Client.Id == client.Id)
.Select(g => g.GrantType)
.ToHashSet();
foreach (var grantType in PostItGrantTypes)
{
if (!existingGrantTypes.Contains(grantType))
{
context.Set<ClientGrantType>().Add(new IdentityServer8.EntityFramework.Entities.ClientGrantType
{
Client = client,
GrantType = grantType
});
changed = true;
}
}
// 4. Ensure all expected scopes are present.
var existingScopes = context.Set<ClientScope>()
.Where(s => s.Client.Id == client.Id)
.Select(s => s.Scope)
.ToHashSet();
foreach (var scope in PostItScopes)
{
if (!existingScopes.Contains(scope))
{
context.Set<ClientScope>().Add(new IdentityServer8.EntityFramework.Entities.ClientScope
{
Client = client,
Scope = scope
});
changed = true;
}
}
// 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();
var existingRedirectUris = existingRedirects
.Select(r => r.RedirectUri)
.ToHashSet(StringComparer.Ordinal);
foreach (var redirectUri in BuildPostItRedirectUris(configuration))
{
if (!existingRedirectUris.Contains(redirectUri))
{
context.Set<ClientRedirectUri>().Add(new IdentityServer8.EntityFramework.Entities.ClientRedirectUri
{
Client = client,
RedirectUri = redirectUri
});
changed = true;
}
}
if (changed)
{
context.SaveChanges();
}
}
private static void ConfigureRequestLocalization(IServiceCollection services) private static void ConfigureRequestLocalization(IServiceCollection services)
{ {