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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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_*`
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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")));
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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 }))
|
||||||
|
|
|
||||||
|
|
@ -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" };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue