WIP PostIt login
This commit is contained in:
parent
18ce58e84a
commit
f3a3b63595
13 changed files with 103 additions and 160 deletions
6
.vscode/launch.json
vendored
6
.vscode/launch.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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_*`
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue