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)
{