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",
"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",

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
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_*`

View file

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

View file

@ -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.
/// </summary>
[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));

View file

@ -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")));

View file

@ -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;
}
/// <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 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;
/// <summary>
/// Build OidcClient options configured for Authorization Code + PKCE
@ -77,12 +76,14 @@ public partial class Settings : ObservableObject
/// </summary>
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)
{

View file

@ -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

View file

@ -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"
]

View file

@ -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<ApplicationUser>().RequireAuthorization("blog");
app.MapIdentityApi<ApplicationUser>().RequireAuthorization("BlogScope");
app.MapGet("/identity", (HttpContext context) =>
new JsonResult(context?.User?.Claims.Select(c => new { c.Type, c.Value }))

View file

@ -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" };
}

View file

@ -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,12 +527,38 @@ public static class HostingExtensions
{
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)
{
context.Set<ApiScope>().Add(new ApiScope { Name = scope });
context.Set<IdentityServer8.EntityFramework.Entities.ApiScope>().Add(new IdentityServer8.EntityFramework.Entities.ApiScope { Name = scope });
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[]
{
// 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<DbContext, bool> 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<Client>().Add(client);
context.Set<IdentityServer8.EntityFramework.Entities.Client>().Add(client);
foreach (var grantType in PostItGrantTypes)
{
@ -649,106 +684,6 @@ public static class HostingExtensions
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)
{