From f3a3b635955c014307385ad3d797712adbd196e6 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Thu, 25 Jun 2026 00:08:25 +0100 Subject: [PATCH] WIP PostIt login --- .vscode/launch.json | 6 + README.md | 14 +- src/PostIt.Tests/OidcStubAuthority.cs | 4 +- src/PostIt.Tests/SettingsLoadTests.cs | 5 +- src/PostIt.Tests/YavscApiClientTests.cs | 6 +- src/PostIt/PostIt/App.axaml.cs | 38 ++--- src/PostIt/PostIt/Settings/Settings.cs | 19 ++- .../PostIt/ViewModels/LoginPageViewModel.cs | 8 +- src/PostIt/PostIt/postit-settings sample.json | 2 +- src/PostIt/PostIt/postit-settings.json | 2 +- src/Yavsc.Blogs/Program.cs | 4 +- src/Yavsc.Org/Contants.cs | 4 +- src/Yavsc.Org/Extensions/HostingExtensions.cs | 151 +++++------------- 13 files changed, 103 insertions(+), 160 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 05251ded..c374bc6b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -16,6 +16,12 @@ "request": "launch", "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", "type": "dotnet", diff --git a/README.md b/README.md index b87f8ce8..2f1a5fec 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Utilisez GNU/Makefile (et visitez le code, dans le dossier `test` ): make test ``` -## Installation / Déploient / Développement +## Installation / Déploient / Développement ### les services et l'API @@ -78,7 +78,7 @@ Dans le cas des arrhes, à tout moment, jusqu'avant la date et l'heure de la pre * Le client peut le faire, en perdant les arrhes. * Le prestataire peut déléguer à une équipe de son choix un filtrage des demandes des clients. -## Limitations +## Limitations * à une commande, une prestation * Dans le cas de l'avance, une fois le paiement client autorisé, pour le moment, aucune annulation de la prestation n'est supportée. @@ -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 autoriser une RedirectUri du client `postit`** : cela permet à PostIt d'être lancé depuis une page web de Yavsc.Org (iframe launcher) - sans rejet `redirect_uri mismatch` de l'OP. Les RedirectUris - « 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. + sans rejet `redirect_uri mismatch` de l'OP. - `ConnectionStrings.YavscConnection` — chaîne de connexion PostgreSQL (utilisateur, mot de passe, hôte, base). Privilégier `dotnet user-secrets` ou des variables d'environnement `ASPNETCORE_*` @@ -150,11 +146,11 @@ d'abord `appsettings-org.json` du serveur ; sinon, laisse-le en place. ### Administration Une fois le service disponible, s'enregistrer, et -Visiter l'url `/Administration/Take` +Visiter l'url `/Administration/Take` ## Une nouvelle activité -On gère les activité en faisant partie du groupe des commerciaux (`FrontOffice`), +On gère les activité en faisant partie du groupe des commerciaux (`FrontOffice`), on crée des activités en y associant des formulaires de commande et une classe de paramétrage de profiles professionnels. diff --git a/src/PostIt.Tests/OidcStubAuthority.cs b/src/PostIt.Tests/OidcStubAuthority.cs index f3646ab4..552db6b0 100644 --- a/src/PostIt.Tests/OidcStubAuthority.cs +++ b/src/PostIt.Tests/OidcStubAuthority.cs @@ -44,7 +44,7 @@ public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable // Pick a free loopback port. var port = GetFreePort(); 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(); listener.Prefixes.Add(prefix); @@ -251,4 +251,4 @@ internal static class Base64UrlEncoder .Replace('+', '-') .Replace('/', '_'); } -} \ No newline at end of file +} diff --git a/src/PostIt.Tests/SettingsLoadTests.cs b/src/PostIt.Tests/SettingsLoadTests.cs index 7e9f396c..eb05af38 100644 --- a/src/PostIt.Tests/SettingsLoadTests.cs +++ b/src/PostIt.Tests/SettingsLoadTests.cs @@ -1,6 +1,5 @@ using System; using System.IO; -using System.Threading.Tasks; using Xunit; namespace PostIt.Tests; @@ -14,7 +13,7 @@ public class SettingsLoadTests /// PostIt.dll. /// [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). var userConfigPath = Path.Combine( @@ -27,7 +26,7 @@ public class SettingsLoadTests } var settings = new PostIt.Settings(); - await settings.Load(); + settings.Load(); // The bundled postit-settings.json points at yavsc.pschneider.fr. Assert.False(string.IsNullOrWhiteSpace(settings.Authentication?.Authority)); diff --git a/src/PostIt.Tests/YavscApiClientTests.cs b/src/PostIt.Tests/YavscApiClientTests.cs index 18764edf..09df1967 100644 --- a/src/PostIt.Tests/YavscApiClientTests.cs +++ b/src/PostIt.Tests/YavscApiClientTests.cs @@ -115,12 +115,12 @@ public class YavscApiClientTests { Authentication = new AuthenticationSettings { - Authority = "http://127.0.0.1:1", + Authority = "https://127.0.0.1:5001", ClientId = "postit-tests", }, - RedirectUri = "http://127.0.0.1:7890/", + RedirectUri = "postit://callback", 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( Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json"))); diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 025a810b..204ce0eb 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; @@ -31,33 +32,41 @@ public partial class App : Application { 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) { - var blog = BuildBlogClient(out var settings); desktop.MainWindow = new MainWindow { - DataContext = new MainPageViewModel(blog, settings) + DataContext = new MainPageViewModel(client, settings) }; } else if (ApplicationLifetime is IActivityApplicationLifetime singleViewFactoryApplicationLifetime) { singleViewFactoryApplicationLifetime.MainViewFactory = () => { - var blog = BuildBlogClient(out var settings); - return new MainPage { DataContext = new MainPageViewModel(blog, settings) }; + return new MainPage { DataContext = new MainPageViewModel(client, settings) }; }; } else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) { - var blog = BuildBlogClient(out var settings); 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() @@ -90,19 +99,4 @@ public partial class App : Application return false; } - /// - /// 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. - /// - 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)); - } } diff --git a/src/PostIt/PostIt/Settings/Settings.cs b/src/PostIt/PostIt/Settings/Settings.cs index d51c5ee5..3da729ec 100644 --- a/src/PostIt/PostIt/Settings/Settings.cs +++ b/src/PostIt/PostIt/Settings/Settings.cs @@ -1,5 +1,4 @@ using System.Runtime.CompilerServices; -using System.Runtime.CompilerServices; using Avalonia; using Avalonia.Controls; using Avalonia.Platform.Storage; @@ -9,7 +8,6 @@ using PostIt.Services; using System; using System.IO; using System.Text.Json; -using System.Threading.Tasks; [assembly: InternalsVisibleTo("PostIt.Tests")] @@ -69,6 +67,7 @@ public partial class Settings : ObservableObject [ObservableProperty] public partial string[] Scopes { get; set; } + public bool Loaded { get; private set; } = false; /// /// Build OidcClient options configured for Authorization Code + PKCE @@ -77,12 +76,14 @@ public partial class Settings : ObservableObject /// internal OidcClientOptions GetOidcClientOptions(IdentityModel.OidcClient.Browser.IBrowser? browser = null) { + if (!Loaded) Load(); var options = new OidcClientOptions { Authority = Authentication.Authority, ClientId = Authentication.ClientId, RedirectUri = RedirectUri, Scope = string.Join(' ', this.Scopes), + TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody // PKCE is enabled by default when no client_secret is provided. }; @@ -92,8 +93,9 @@ public partial class Settings : ObservableObject return options; } - internal async Task Load() + internal void Load() { + if (Loaded) return; string configDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "PostIt" @@ -123,10 +125,17 @@ public partial class Settings : ObservableObject 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 reader = new StreamReader(stream); - var json = await reader.ReadToEndAsync(); + var json = reader.ReadToEnd(); ApplyJson(json, $"user file {configFileInfo.FullName}"); + Loaded = true; } catch (Exception ex) { @@ -182,4 +191,4 @@ public partial class Settings : ObservableObject Console.Error.WriteLine($"🩎 Error applying settings from {source}: {ex.Message}"); } } -} \ No newline at end of file +} diff --git a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs b/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs index 300ce536..84c0ba30 100644 --- a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs @@ -139,8 +139,10 @@ public partial class LoginPageViewModel : ViewModelBase { // Load settings eagerly so RegisterUrl / ForgotPasswordUrl are // populated as soon as the page renders (XAML bindings fire - // before the user clicks Login). - try { Settings.Load().GetAwaiter().GetResult(); } + // before the user clicks Login). Settings.Load is synchronous + // 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 */ } } @@ -173,7 +175,7 @@ public partial class LoginPageViewModel : ViewModelBase if (SettingsLoadOverride is not null) await SettingsLoadOverride().ConfigureAwait(false); else - await Settings.Load().ConfigureAwait(false); + Settings.Load(); // Guard: refuse to call OidcClient when the authority is // empty. IdentityModel would otherwise build a bogus diff --git a/src/PostIt/PostIt/postit-settings sample.json b/src/PostIt/PostIt/postit-settings sample.json index b435d8d5..6d8da8fa 100644 --- a/src/PostIt/PostIt/postit-settings sample.json +++ b/src/PostIt/PostIt/postit-settings sample.json @@ -12,4 +12,4 @@ "offline_access", "blogs" ] -} \ No newline at end of file +} diff --git a/src/PostIt/PostIt/postit-settings.json b/src/PostIt/PostIt/postit-settings.json index c8ca3a16..07947951 100644 --- a/src/PostIt/PostIt/postit-settings.json +++ b/src/PostIt/PostIt/postit-settings.json @@ -3,12 +3,12 @@ "ClientId": "postit", "Authority": "https://yavsc.pschneider.fr/" }, + "RedirectUri": "postit://callback", "DarkMode": true, "ApiUrl": "https://blogs.pschneider.fr/api/v1/", "Scopes": [ "openid", "profile", - "email", "offline_access", "blogs" ] diff --git a/src/Yavsc.Blogs/Program.cs b/src/Yavsc.Blogs/Program.cs index 8139049c..47cd3f38 100644 --- a/src/Yavsc.Blogs/Program.cs +++ b/src/Yavsc.Blogs/Program.cs @@ -32,7 +32,7 @@ internal class Program { policy .RequireAuthenticatedUser() - .RequireClaim(JwtClaimTypes.Scope, new string[] { "blog" }); + .RequireClaim(JwtClaimTypes.Scope, new string[] { "blogs" }); }); }) .AddYavscCors(builder.Configuration) @@ -88,7 +88,7 @@ internal class Program .UseAuthorization() .UseCors("default") ; - app.MapIdentityApi().RequireAuthorization("blog"); + app.MapIdentityApi().RequireAuthorization("BlogScope"); app.MapGet("/identity", (HttpContext context) => new JsonResult(context?.User?.Claims.Select(c => new { c.Type, c.Value })) diff --git a/src/Yavsc.Org/Contants.cs b/src/Yavsc.Org/Contants.cs index 6ad5e4ca..e28597c0 100644 --- a/src/Yavsc.Org/Contants.cs +++ b/src/Yavsc.Org/Contants.cs @@ -1,5 +1,7 @@ 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" }; } diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index f2ae820e..b4fbfb40 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -44,6 +44,8 @@ using Yavsc.Services.Kyc; using Yavsc.Settings; using Yavsc.ViewModels.Auth; using static IdentityServer8.IdentityServerConstants; +using IdentityServer8.Models; +using IdentityServer8.EntityFramework.Mappers; namespace Yavsc.Extensions; @@ -525,13 +527,39 @@ public static class HostingExtensions { foreach (String scope in Constants.BuildInApiScopes) { - var existentScope = context.Set().FirstOrDefault(b => b.Name == scope); + var existentScope = context.Set().FirstOrDefault(b => b.Name == scope); if (existentScope == null) { - context.Set().Add(new ApiScope { Name = scope }); + context.Set().Add(new IdentityServer8.EntityFramework.Entities.ApiScope { Name = scope }); context.SaveChanges(); } } + var identityResources = context.Set(); + var apiScopes = context.Set(); + + // 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[] { // Loopback URI for desktop / browser-based PKCE flows. - "http://127.0.0.1:7890/", - // Custom-scheme URI for Android. The matching IntentFilter must be - // declared in PostIt.Android/Properties/AndroidManifest.xml. + "postit://callback", "android://postit-signin", + "https://blogs.pschneider.fr" }; private static readonly string[] PostItGrantTypes = new[] @@ -554,9 +581,18 @@ public static class HostingExtensions 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.Profile, + IdentityServer8.IdentityServerConstants.StandardScopes.OfflineAccess, }; private static Action EnsureDefaultConfiguration( @@ -576,7 +612,6 @@ public static class HostingExtensions return; } - MigratePostItClientToPublic(configuration, context, existingClient); }; } @@ -600,7 +635,7 @@ public static class HostingExtensions RequireConsent = false, }; - context.Set().Add(client); + context.Set().Add(client); foreach (var grantType in PostItGrantTypes) { @@ -649,106 +684,6 @@ public static class HostingExtensions 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) - { - var changed = false; - - // 1. Drop the client secret. PKCE-only clients must not have one. - var secrets = context.Set().Where(s => s.Client.Id == client.Id); - if (secrets.Any()) - { - context.Set().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() - .Where(g => g.Client.Id == client.Id) - .Select(g => g.GrantType) - .ToHashSet(); - foreach (var grantType in PostItGrantTypes) - { - if (!existingGrantTypes.Contains(grantType)) - { - context.Set().Add(new IdentityServer8.EntityFramework.Entities.ClientGrantType - { - Client = client, - GrantType = grantType - }); - changed = true; - } - } - - // 4. Ensure all expected scopes are present. - var existingScopes = context.Set() - .Where(s => s.Client.Id == client.Id) - .Select(s => s.Scope) - .ToHashSet(); - foreach (var scope in PostItScopes) - { - if (!existingScopes.Contains(scope)) - { - context.Set().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() - .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().Add(new IdentityServer8.EntityFramework.Entities.ClientRedirectUri - { - Client = client, - RedirectUri = redirectUri - }); - changed = true; - } - } - - if (changed) - { - context.SaveChanges(); - } - } private static void ConfigureRequestLocalization(IServiceCollection services) {