2024-02-25 18:05:10 +00:00
|
|
|
using System.Globalization;
|
2026-04-19 16:02:50 +01:00
|
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
|
|
|
using System.Reflection;
|
postit: trailing-slash regression + loopback browser cleanup; identityserver: BC cert loader + SecurityKey
PostIt
- LoginPage renders StatusMessage as a read-only TextBox so the
text is mouse-selectable and copyable (no copy button).
- LoginPageViewModel exposes ExternalUrl (Authentication.Authority
with trailing slash stripped) and DiscoveryUrl
(ExternalUrl + '/.well-known/openid-configuration'). LoginAsync
surfaces the discovery URL before the call and suffixes it onto
every error message, so reachability issues are diagnosable by
pasting the URL into a browser.
- LoopbackBrowser (used for the OIDC redirect listener on desktop)
now bounds the GetContextAsync wait at 5 minutes and calls both
Stop() and Close() in the finally, so the listener is always
released even if the user abandons the flow. Without this, the
next PostIt launch fails with 'Failed to listen on prefix
http://127.0.0.1:7890/ because it conflicts with an existing
registration on the machine.'
- Add LoginPageViewModelTests.LoginAsync_works_when_authority_has_trailing_slash
to lock in the discovery URL contract.
- Add LoopbackBrowserTests covering both timeout and happy-path
listener cleanup.
Yavsc.Org
- Drop the CustomEntries['jwks_uri'] override from commit 84160f07.
IdentityServer8 reserves that key and rejects the override with
'Discovery custom entry jwks_uri cannot be added, because it
already exists.' The default /.well-known/openid-configuration/jwks
endpoint is what DiscoveryKeyEndpoint actually serves.
- Replace X509Certificate2.CreateFromPemFile + the 3-arg
X509Certificate2(path, key, X509KeyStorageFlags) ctor with a
BouncyCastle-backed loader. The BCL path raised
InvalidOperationException during AddSigningCredential and aborted
the runtime with SIGABRT (code=6/ABRT, libcoreclr.so stack) on
the production EC Let's Encrypt cert. BouncyCastle 2.6.2
PemReader accepts PKCS#1 + PKCS#8 EC/RSA PEMs uniformly; RSA
path uses DotNetUtilities.ToRSA, EC path rebuilds ECDsa from
ECParameters with the curve dispatched by NIST order bit length
(256/384/521).
- Switch the signing credential handed to IdentityServer8 from
X509Certificate2 to a SigningCredentials built from a SecurityKey
(RsaSecurityKey / ECDsaSecurityKey). The cert loaded fine but
IdentityServer8's key material service reads cert.PrivateKey at
runtime — on Linux that handle is not retained across the
X509Certificate2 / runtime boundary, so CreateJwkDocumentAsync
raised NullReferenceException on the first GET /jwks. The
SecurityKey is a pure managed object whose Key is a live
AsymmetricAlgorithm, which survives every read IdentityServer
does.
- Add BouncyCastle.Cryptography 2.6.2 to src/Yavsc.Org/Yavsc.Org.csproj
and the matching PackageVersion in Directory.Packages props.
- Wrap the loader in a try/catch that prints the full managed
stack to stderr on failure, so future PEM-format issues surface
in journalctl instead of being hidden behind SIGABRT.
2026-06-21 07:30:11 +01:00
|
|
|
using System.Security.Cryptography;
|
2026-06-21 03:55:32 +01:00
|
|
|
using System.Security.Cryptography.X509Certificates;
|
2024-02-25 18:05:10 +00:00
|
|
|
using Google.Apis.Util.Store;
|
2026-04-19 16:02:50 +01:00
|
|
|
using IdentityModel;
|
2025-02-08 20:06:24 +00:00
|
|
|
using IdentityServer8;
|
2026-04-19 16:02:50 +01:00
|
|
|
using IdentityServer8.EntityFramework.Entities;
|
|
|
|
|
using IdentityServer8.EntityFramework.Services;
|
|
|
|
|
using IdentityServer8.EntityFramework.Stores;
|
2025-08-24 16:07:53 +01:00
|
|
|
using IdentityServer8.Stores;
|
2026-04-19 16:02:50 +01:00
|
|
|
using IdentityServer8.Validation;
|
2024-02-25 18:05:10 +00:00
|
|
|
using Microsoft.AspNetCore.Authorization;
|
|
|
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
|
|
|
using Microsoft.AspNetCore.Identity;
|
2026-04-19 16:02:50 +01:00
|
|
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
2024-02-25 18:05:10 +00:00
|
|
|
using Microsoft.AspNetCore.Localization;
|
|
|
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
|
using Microsoft.AspNetCore.Mvc.Razor;
|
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
2026-06-22 01:44:24 +01:00
|
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
postit: trailing-slash regression + loopback browser cleanup; identityserver: BC cert loader + SecurityKey
PostIt
- LoginPage renders StatusMessage as a read-only TextBox so the
text is mouse-selectable and copyable (no copy button).
- LoginPageViewModel exposes ExternalUrl (Authentication.Authority
with trailing slash stripped) and DiscoveryUrl
(ExternalUrl + '/.well-known/openid-configuration'). LoginAsync
surfaces the discovery URL before the call and suffixes it onto
every error message, so reachability issues are diagnosable by
pasting the URL into a browser.
- LoopbackBrowser (used for the OIDC redirect listener on desktop)
now bounds the GetContextAsync wait at 5 minutes and calls both
Stop() and Close() in the finally, so the listener is always
released even if the user abandons the flow. Without this, the
next PostIt launch fails with 'Failed to listen on prefix
http://127.0.0.1:7890/ because it conflicts with an existing
registration on the machine.'
- Add LoginPageViewModelTests.LoginAsync_works_when_authority_has_trailing_slash
to lock in the discovery URL contract.
- Add LoopbackBrowserTests covering both timeout and happy-path
listener cleanup.
Yavsc.Org
- Drop the CustomEntries['jwks_uri'] override from commit 84160f07.
IdentityServer8 reserves that key and rejects the override with
'Discovery custom entry jwks_uri cannot be added, because it
already exists.' The default /.well-known/openid-configuration/jwks
endpoint is what DiscoveryKeyEndpoint actually serves.
- Replace X509Certificate2.CreateFromPemFile + the 3-arg
X509Certificate2(path, key, X509KeyStorageFlags) ctor with a
BouncyCastle-backed loader. The BCL path raised
InvalidOperationException during AddSigningCredential and aborted
the runtime with SIGABRT (code=6/ABRT, libcoreclr.so stack) on
the production EC Let's Encrypt cert. BouncyCastle 2.6.2
PemReader accepts PKCS#1 + PKCS#8 EC/RSA PEMs uniformly; RSA
path uses DotNetUtilities.ToRSA, EC path rebuilds ECDsa from
ECParameters with the curve dispatched by NIST order bit length
(256/384/521).
- Switch the signing credential handed to IdentityServer8 from
X509Certificate2 to a SigningCredentials built from a SecurityKey
(RsaSecurityKey / ECDsaSecurityKey). The cert loaded fine but
IdentityServer8's key material service reads cert.PrivateKey at
runtime — on Linux that handle is not retained across the
X509Certificate2 / runtime boundary, so CreateJwkDocumentAsync
raised NullReferenceException on the first GET /jwks. The
SecurityKey is a pure managed object whose Key is a live
AsymmetricAlgorithm, which survives every read IdentityServer
does.
- Add BouncyCastle.Cryptography 2.6.2 to src/Yavsc.Org/Yavsc.Org.csproj
and the matching PackageVersion in Directory.Packages props.
- Wrap the loader in a try/catch that prints the full managed
stack to stderr on failure, so future PEM-format issues surface
in journalctl instead of being hidden behind SIGABRT.
2026-06-21 07:30:11 +01:00
|
|
|
using Microsoft.Extensions.FileProviders;
|
|
|
|
|
using Microsoft.Extensions.Options;
|
|
|
|
|
using Org.BouncyCastle.Crypto;
|
|
|
|
|
using Org.BouncyCastle.Crypto.Parameters;
|
|
|
|
|
using Org.BouncyCastle.OpenSsl;
|
|
|
|
|
using Microsoft.IdentityModel.Tokens;
|
|
|
|
|
using Org.BouncyCastle.Security;
|
2024-02-25 18:05:10 +00:00
|
|
|
using Microsoft.Extensions.Localization;
|
|
|
|
|
using Microsoft.Net.Http.Headers;
|
|
|
|
|
using Newtonsoft.Json;
|
|
|
|
|
using Yavsc.Helpers;
|
|
|
|
|
using Yavsc.Interface;
|
2026-04-19 16:02:50 +01:00
|
|
|
using Yavsc.Interfaces;
|
2024-02-25 18:05:10 +00:00
|
|
|
using Yavsc.Models;
|
2026-04-19 16:02:50 +01:00
|
|
|
using Yavsc.Server.Helpers;
|
2024-02-25 18:05:10 +00:00
|
|
|
using Yavsc.Services;
|
2026-05-24 19:35:35 +01:00
|
|
|
using Yavsc.Services.Kyc;
|
2024-02-25 18:05:10 +00:00
|
|
|
using Yavsc.Settings;
|
2024-11-10 23:12:02 +00:00
|
|
|
using Yavsc.ViewModels.Auth;
|
2026-06-25 00:08:25 +01:00
|
|
|
using IdentityServer8.Models;
|
|
|
|
|
using IdentityServer8.EntityFramework.Mappers;
|
2026-07-04 15:28:07 +01:00
|
|
|
using Yavsc.Server.Hubs;
|
2024-02-25 18:05:10 +00:00
|
|
|
|
2024-11-06 13:00:34 +00:00
|
|
|
namespace Yavsc.Extensions;
|
|
|
|
|
|
2024-02-25 18:05:10 +00:00
|
|
|
|
2025-02-14 00:20:35 +00:00
|
|
|
public static class HostingExtensions
|
2024-02-25 18:05:10 +00:00
|
|
|
{
|
2026-05-28 22:18:26 +01:00
|
|
|
private const string InMemoryProviderName = "InMemory";
|
2025-08-18 09:22:09 +01:00
|
|
|
|
2025-07-14 18:58:04 +01:00
|
|
|
public static WebApplication ConfigureWebAppServices(this WebApplicationBuilder builder)
|
2024-02-25 18:05:10 +00:00
|
|
|
{
|
2026-02-07 23:47:12 +00:00
|
|
|
builder.Services.AddSwaggerGen();
|
|
|
|
|
|
2025-02-14 00:20:35 +00:00
|
|
|
IServiceCollection services = LoadConfiguration(builder);
|
2025-02-09 12:04:14 +00:00
|
|
|
|
2024-11-06 13:00:34 +00:00
|
|
|
services.AddSession();
|
2024-11-12 08:32:15 +00:00
|
|
|
|
2025-02-08 20:06:24 +00:00
|
|
|
// TODO .AddServerSideSessionStore<YavscServerSideSessionStore>()
|
2024-02-25 18:05:10 +00:00
|
|
|
|
|
|
|
|
|
2025-02-08 20:06:24 +00:00
|
|
|
// Add the system clock service
|
|
|
|
|
_ = services.AddSingleton<IConnexionManager, HubConnectionManager>();
|
|
|
|
|
_ = services.AddSingleton<ILiveProcessor, LiveProcessor>();
|
|
|
|
|
_ = services.AddTransient<IFileSystemAuthManager, FileSystemAuthManager>();
|
2025-07-31 11:44:02 +01:00
|
|
|
|
|
|
|
|
AddIdentityDBAndStores(builder)
|
|
|
|
|
.AddDefaultTokenProviders();
|
2025-02-16 22:40:51 +00:00
|
|
|
AddIdentityServer(builder);
|
2024-02-25 18:05:10 +00:00
|
|
|
|
2025-02-16 22:40:51 +00:00
|
|
|
services.AddSignalR(o =>
|
|
|
|
|
{
|
|
|
|
|
o.EnableDetailedErrors = true;
|
|
|
|
|
});
|
2025-08-18 09:22:09 +01:00
|
|
|
|
2025-02-08 20:06:24 +00:00
|
|
|
services.AddMvc(config =>
|
|
|
|
|
{
|
|
|
|
|
/* var policy = new AuthorizationPolicyBuilder()
|
|
|
|
|
.RequireAuthenticatedUser()
|
|
|
|
|
.Build();
|
|
|
|
|
config.Filters.Add(new AuthorizeFilter(policy)); */
|
|
|
|
|
config.Filters.Add(new ProducesAttribute("application/json"));
|
|
|
|
|
// config.ModelBinders.Insert(0,new MyDateTimeModelBinder());
|
|
|
|
|
// config.ModelBinders.Insert(0,new MyDecimalModelBinder());
|
|
|
|
|
config.EnableEndpointRouting = true;
|
|
|
|
|
}).AddFormatterMappings(
|
|
|
|
|
config => config.SetMediaTypeMappingForFormat("text/pdf",
|
|
|
|
|
new MediaTypeHeaderValue("text/pdf"))
|
|
|
|
|
).AddFormatterMappings(
|
|
|
|
|
config => config.SetMediaTypeMappingForFormat("text/x-tex",
|
|
|
|
|
new MediaTypeHeaderValue("text/x-tex"))
|
|
|
|
|
)
|
|
|
|
|
.AddViewLocalization(LanguageViewLocationExpanderFormat.Suffix,
|
|
|
|
|
options =>
|
|
|
|
|
{
|
|
|
|
|
options.ResourcesPath = "Resources";
|
|
|
|
|
}).AddDataAnnotationsLocalization();
|
|
|
|
|
|
2026-06-25 20:22:41 +01:00
|
|
|
services.AddTransient<ITrueEmailSender, MailSender>()
|
|
|
|
|
.AddTransient<Microsoft.AspNetCore.Identity.UI.Services.IEmailSender, MailSender>();
|
2026-06-21 21:14:20 +01:00
|
|
|
|
2026-06-25 20:22:41 +01:00
|
|
|
services.TryAddSingleton<ISmtpClientFactory, SmtpClientFactory>();
|
2026-06-22 01:44:24 +01:00
|
|
|
|
2026-04-19 14:40:40 +01:00
|
|
|
|
|
|
|
|
services.AddTransient<IYavscMessageSender, YavscMessageSender>()
|
2025-02-14 00:20:35 +00:00
|
|
|
.AddTransient<IBillingService, BillingService>()
|
|
|
|
|
.AddTransient<IDataStore, FileDataStore>((sp) => new FileDataStore("googledatastore", false))
|
2025-06-29 16:12:16 +01:00
|
|
|
.AddTransient<ICalendarManager, CalendarManager>()
|
2026-03-09 02:07:09 +00:00
|
|
|
.AddTransient<BlogSpotService>()
|
|
|
|
|
.AddTransient<ValidatingClientStore<ClientStore>>();
|
2024-02-25 18:05:10 +00:00
|
|
|
|
2025-02-08 20:06:24 +00:00
|
|
|
// TODO for SMS: services.AddTransient<ISmsSender, AuthMessageSender>();
|
2024-02-25 18:05:10 +00:00
|
|
|
|
2025-02-08 20:06:24 +00:00
|
|
|
_ = services.AddLocalization(options =>
|
|
|
|
|
{
|
|
|
|
|
options.ResourcesPath = "Resources";
|
|
|
|
|
});
|
2025-02-14 00:20:35 +00:00
|
|
|
var dataDirConfig = builder.Configuration["Site:DataDir"] ?? "DataDir";
|
|
|
|
|
|
|
|
|
|
var dataDir = new DirectoryInfo(dataDirConfig);
|
2025-02-08 20:06:24 +00:00
|
|
|
// Add session related services.
|
2024-02-25 18:05:10 +00:00
|
|
|
|
2025-02-08 20:06:24 +00:00
|
|
|
services.AddDataProtection().PersistKeysToFileSystem(dataDir);
|
2026-06-19 13:15:21 +01:00
|
|
|
AddYavscPolicies(services, builder.Configuration);
|
2025-02-14 00:20:35 +00:00
|
|
|
|
2025-02-23 20:23:23 +00:00
|
|
|
services.AddScoped<IAuthorizationHandler, PermissionHandler>();
|
2025-07-10 15:19:28 +01:00
|
|
|
services.AddTransient<IExternalIdentityManager, ExternalIdentityManager>();
|
2025-02-14 00:20:35 +00:00
|
|
|
|
|
|
|
|
|
2026-02-17 21:50:17 +00:00
|
|
|
services.AddAuthentication("Bearer")
|
2026-06-19 13:15:21 +01:00
|
|
|
.AddYavscJwtBearer(builder.Configuration,
|
|
|
|
|
configure: o => o.Audience = builder.Configuration.GetSection("Site")["ExternalUrl"]);
|
2025-08-18 09:22:09 +01:00
|
|
|
|
2025-07-31 11:44:02 +01:00
|
|
|
services.AddTransient<RoleManager<IdentityRole>>();
|
|
|
|
|
services.AddTransient<IRoleStore<IdentityRole>, RoleStore<IdentityRole, ApplicationDbContext>>();
|
2026-05-24 19:35:35 +01:00
|
|
|
services.Configure<KycOptions>(builder.Configuration.GetSection("Kyc"));
|
|
|
|
|
services.AddScoped<ITrustTokenService, TrustTokenService>();
|
2025-02-14 00:20:35 +00:00
|
|
|
return builder.Build();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static IdentityBuilder AddIdentityDBAndStores(this WebApplicationBuilder builder)
|
|
|
|
|
{
|
|
|
|
|
IServiceCollection services = builder.Services;
|
2026-05-30 19:34:22 +01:00
|
|
|
var connectionString = builder.Configuration.GetConnectionString(YavscConstants.YavscConnectionStringName);
|
2026-06-21 21:14:20 +01:00
|
|
|
|
2026-04-20 00:35:51 +01:00
|
|
|
services.AddDbContext<ApplicationDbContext>(options =>
|
2026-04-19 14:40:40 +01:00
|
|
|
{
|
2026-05-28 22:18:26 +01:00
|
|
|
if (UsesInMemoryProvider(connectionString))
|
|
|
|
|
{
|
|
|
|
|
options.UseInMemoryDatabase(connectionString);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
options.UseNpgsql(connectionString,
|
|
|
|
|
options => options.MigrationsAssembly(typeof(Program).Assembly));
|
|
|
|
|
}
|
2026-04-20 00:35:51 +01:00
|
|
|
});
|
2025-02-14 00:20:35 +00:00
|
|
|
|
2026-04-19 16:02:50 +01:00
|
|
|
var identityBuilder = services.AddIdentity<ApplicationUser, IdentityRole>(
|
2025-07-11 13:27:31 +01:00
|
|
|
options =>
|
|
|
|
|
{
|
2026-02-17 19:24:30 +00:00
|
|
|
options.SignIn.RequireConfirmedAccount = builder.Environment.IsEnvironment(
|
|
|
|
|
builder.Environment.EnvironmentName);
|
2025-07-31 11:44:02 +01:00
|
|
|
options.ClaimsIdentity.UserNameClaimType = JwtClaimTypes.PreferredUserName;
|
2026-05-30 19:34:22 +01:00
|
|
|
options.ClaimsIdentity.RoleClaimType = YavscConstants.RoleClaimType;
|
2025-07-11 13:27:31 +01:00
|
|
|
}
|
|
|
|
|
)
|
2025-07-31 11:44:02 +01:00
|
|
|
.AddEntityFrameworkStores<ApplicationDbContext>();
|
2026-04-19 16:02:50 +01:00
|
|
|
|
|
|
|
|
services.AddScoped<IUserClaimsPrincipalFactory<ApplicationUser>, UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>>();
|
|
|
|
|
|
2026-06-14 16:27:54 +01:00
|
|
|
// Dev-only: Chromium rejects SameSite=None without Secure on http://
|
|
|
|
|
// (e.g. http://localhost:5000). The default Identity cookie policy
|
|
|
|
|
// sets SameSite=None, which is invalid without Secure. Force Lax in
|
|
|
|
|
// dev. In production (https://) the default SameSite=None is fine.
|
|
|
|
|
if (builder.Environment.IsDevelopment())
|
|
|
|
|
{
|
|
|
|
|
services.ConfigureApplicationCookie(options =>
|
|
|
|
|
{
|
|
|
|
|
options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax;
|
|
|
|
|
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
|
|
|
|
});
|
|
|
|
|
services.ConfigureExternalCookie(options =>
|
|
|
|
|
{
|
|
|
|
|
options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax;
|
|
|
|
|
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-19 16:02:50 +01:00
|
|
|
return identityBuilder;
|
2025-02-14 00:20:35 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-19 13:15:21 +01:00
|
|
|
private static void AddYavscPolicies(IServiceCollection services, IConfiguration configuration)
|
2025-02-14 00:20:35 +00:00
|
|
|
{
|
2025-02-08 20:06:24 +00:00
|
|
|
services.AddAuthorization(options =>
|
|
|
|
|
{
|
2025-02-08 21:58:23 +00:00
|
|
|
options.AddPolicy("ApiScope", policy =>
|
2025-02-11 04:45:05 +00:00
|
|
|
{
|
|
|
|
|
policy.RequireAuthenticatedUser()
|
|
|
|
|
.RequireClaim("scope", "scope2");
|
|
|
|
|
});
|
2025-08-25 13:32:31 +01:00
|
|
|
|
2025-02-09 16:57:10 +00:00
|
|
|
options.AddPolicy("Performer", policy =>
|
|
|
|
|
{
|
|
|
|
|
policy
|
|
|
|
|
.RequireAuthenticatedUser()
|
2026-05-30 19:34:22 +01:00
|
|
|
.RequireClaim(YavscConstants.RoleClaimType,
|
|
|
|
|
new string[] { YavscConstants.PerformerGroupName, YavscConstants.AdminGroupName })
|
2025-08-18 11:27:13 +01:00
|
|
|
;
|
2025-02-09 16:57:10 +00:00
|
|
|
});
|
2025-02-08 20:06:24 +00:00
|
|
|
options.AddPolicy("AdministratorOnly", policy =>
|
2024-02-25 18:05:10 +00:00
|
|
|
{
|
2025-07-07 07:49:18 +01:00
|
|
|
_ = policy
|
|
|
|
|
.RequireAuthenticatedUser()
|
2026-05-30 19:34:22 +01:00
|
|
|
.RequireClaim(YavscConstants.RoleClaimType, YavscConstants.AdminGroupName);
|
2024-02-25 18:05:10 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-30 19:34:22 +01:00
|
|
|
options.AddPolicy("FrontOffice", policy => policy.RequireRole(YavscConstants.FrontOfficeGroupName));
|
2025-02-14 00:20:35 +00:00
|
|
|
|
2025-02-08 20:06:24 +00:00
|
|
|
// options.AddPolicy("EmployeeId", policy => policy.RequireClaim("EmployeeId", "123", "456"));
|
|
|
|
|
// options.AddPolicy("BuildingEntry", policy => policy.Requirements.Add(new OfficeEntryRequirement()));
|
|
|
|
|
options.AddPolicy("Authenticated", policy => policy.RequireAuthenticatedUser());
|
2025-06-29 16:20:37 +01:00
|
|
|
options.AddPolicy("TheAuthor", policy => policy.Requirements.Add(new EditPermission()));
|
2025-02-08 20:06:24 +00:00
|
|
|
});
|
2026-06-19 13:15:21 +01:00
|
|
|
|
|
|
|
|
services.AddYavscCors(configuration);
|
2025-02-14 00:20:35 +00:00
|
|
|
}
|
2025-02-08 20:06:24 +00:00
|
|
|
|
2025-02-14 00:20:35 +00:00
|
|
|
public static IServiceCollection LoadConfiguration(this WebApplicationBuilder builder)
|
|
|
|
|
{
|
|
|
|
|
var siteSection = builder.Configuration.GetSection("Site");
|
2026-02-17 21:50:17 +00:00
|
|
|
|
2025-02-14 00:20:35 +00:00
|
|
|
var smtpSection = builder.Configuration.GetSection("Smtp");
|
|
|
|
|
var paypalSection = builder.Configuration.GetSection("Authentication:PayPal");
|
|
|
|
|
// OAuth2AppSettings
|
|
|
|
|
var googleAuthSettings = builder.Configuration.GetSection("Authentication:Google");
|
|
|
|
|
|
2026-02-17 19:24:30 +00:00
|
|
|
LoadGoogleConfig(builder.Configuration);
|
2025-02-14 00:20:35 +00:00
|
|
|
|
2025-08-18 09:22:09 +01:00
|
|
|
|
2025-02-14 00:20:35 +00:00
|
|
|
var services = builder.Services;
|
|
|
|
|
_ = services.AddControllersWithViews()
|
|
|
|
|
.AddNewtonsoftJson();
|
2025-08-18 09:22:09 +01:00
|
|
|
|
2025-02-14 00:20:35 +00:00
|
|
|
services.Configure<SiteSettings>(siteSection);
|
|
|
|
|
services.Configure<SmtpSettings>(smtpSection);
|
|
|
|
|
services.Configure<PayPalSettings>(paypalSection);
|
|
|
|
|
services.Configure<GoogleAuthSettings>(googleAuthSettings);
|
|
|
|
|
ConfigureRequestLocalization(services);
|
2024-11-10 23:12:02 +00:00
|
|
|
|
2025-02-14 00:20:35 +00:00
|
|
|
return services;
|
|
|
|
|
}
|
2025-02-11 04:45:05 +00:00
|
|
|
|
2025-02-16 22:40:51 +00:00
|
|
|
private static void AddAuthentication(WebApplicationBuilder builder)
|
2025-02-15 14:46:40 +00:00
|
|
|
{
|
2025-08-18 09:22:09 +01:00
|
|
|
IServiceCollection services = builder.Services;
|
|
|
|
|
IConfigurationRoot configurationRoot = builder.Configuration;
|
2025-02-16 22:40:51 +00:00
|
|
|
string? googleClientId = configurationRoot["Authentication:Google:ClientId"];
|
2025-02-15 14:46:40 +00:00
|
|
|
string? googleClientSecret = configurationRoot["Authentication:Google:ClientSecret"];
|
2025-08-18 09:22:09 +01:00
|
|
|
|
2025-02-16 22:40:51 +00:00
|
|
|
var authenticationBuilder = services.AddAuthentication();
|
2025-02-15 14:46:40 +00:00
|
|
|
|
2025-08-18 09:22:09 +01:00
|
|
|
if (googleClientId != null && googleClientSecret != null)
|
|
|
|
|
authenticationBuilder.AddGoogle(options =>
|
|
|
|
|
{
|
|
|
|
|
options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
|
|
|
|
|
|
|
|
|
|
// register your IdentityServer with Google at https://console.developers.google.com
|
|
|
|
|
// enable the Google+ API
|
|
|
|
|
// set the redirect URI to https://localhost:5001/signin-google
|
|
|
|
|
options.ClientId = googleClientId;
|
|
|
|
|
options.ClientSecret = googleClientSecret;
|
|
|
|
|
|
|
|
|
|
});
|
2025-02-15 14:46:40 +00:00
|
|
|
}
|
2026-04-19 16:02:50 +01:00
|
|
|
public static IIdentityServerBuilder AddIdentityServer(WebApplicationBuilder builder)
|
2025-02-14 00:20:35 +00:00
|
|
|
{
|
2026-02-21 20:51:35 +00:00
|
|
|
builder.Services.Configure<IdentityOptions>(options =>
|
|
|
|
|
{
|
|
|
|
|
options.ClaimsIdentity.UserIdClaimType = JwtClaimTypes.Subject;
|
|
|
|
|
options.ClaimsIdentity.UserNameClaimType = JwtClaimTypes.Name;
|
2026-05-30 19:34:22 +01:00
|
|
|
options.ClaimsIdentity.RoleClaimType = YavscConstants.RoleClaimType;
|
2026-02-21 20:51:35 +00:00
|
|
|
});
|
2026-02-22 23:39:56 +00:00
|
|
|
var migrationsAssembly = typeof(Program).GetTypeInfo().Assembly.GetName().Name;
|
2026-05-30 19:34:22 +01:00
|
|
|
var connectionString = builder.Configuration.GetConnectionString(YavscConstants.YavscConnectionStringName);
|
2026-06-21 21:14:20 +01:00
|
|
|
|
2026-04-20 00:35:51 +01:00
|
|
|
string sqliteConnectionString = $"Data Source={Path.Combine(Path.GetTempPath(), "yavsc_test.db")}";
|
2025-08-24 16:07:53 +01:00
|
|
|
|
2025-02-16 22:40:51 +00:00
|
|
|
var identityServerBuilder = builder.Services.AddIdentityServer(options =>
|
|
|
|
|
{
|
|
|
|
|
options.Events.RaiseErrorEvents = true;
|
|
|
|
|
options.Events.RaiseInformationEvents = true;
|
|
|
|
|
options.Events.RaiseFailureEvents = true;
|
|
|
|
|
options.Events.RaiseSuccessEvents = true;
|
|
|
|
|
|
|
|
|
|
// see https://IdentityServer8.readthedocs.io/en/latest/topics/resources.html
|
|
|
|
|
options.EmitStaticAudienceClaim = true;
|
2026-06-26 01:45:21 +01:00
|
|
|
options.UserInteraction.LoginUrl = "/signin";
|
2025-08-18 09:22:09 +01:00
|
|
|
|
2025-02-16 22:40:51 +00:00
|
|
|
})
|
2026-02-09 01:03:33 +00:00
|
|
|
.AddAspNetIdentity<ApplicationUser>()
|
|
|
|
|
.AddClientStore<ClientStore>()
|
2026-03-09 02:07:09 +00:00
|
|
|
.AddClientConfigurationValidator<DefaultClientConfigurationValidator>()
|
2026-02-09 01:03:33 +00:00
|
|
|
.AddCorsPolicyService<CorsPolicyService>()
|
|
|
|
|
.AddResourceStore<ResourceStore>()
|
2025-08-24 16:07:53 +01:00
|
|
|
.AddConfigurationStore(options =>
|
|
|
|
|
{
|
2026-05-28 22:18:26 +01:00
|
|
|
options.ConfigureDbContext = b =>
|
|
|
|
|
{
|
|
|
|
|
if (UsesInMemoryProvider(connectionString))
|
|
|
|
|
{
|
|
|
|
|
b.UseInMemoryDatabase(connectionString);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
b.UseNpgsql(connectionString,
|
|
|
|
|
sql => sql.MigrationsAssembly(migrationsAssembly));
|
|
|
|
|
}
|
|
|
|
|
|
Seed ApiResources + ApiResourceScopes, run seeder on every startup
The previous commit (37440171) added ApiScope rows for the
application scopes (admin, moderation, performer, client, blogs).
It was a partial fix: an ApiScope alone is not a valid scope from
DefaultResourceValidator's point of view. The validator only
recognises a scope if it can find an ApiResource that exposes it
(via ApiResourceScopes). Without that link, /connect/authorize
rejects the request with 'Scope X not found in store', even
though the scope row exists. This is what killed the PostIt login
in production.
This commit:
1. Extends Constants.ApiResourcesScopes with ResourceName +
ResourceDisplayName. Topology: one ApiResource per scope
('admin' resource exposes 'admin' scope, 'blogs' resource
exposes 'blogs' scope, etc.) — keeps each scope's audience
specific if/when we split products across separate audiences.
2. Ensures EnsureDefaultApplicationScopes also inserts the
matching ApiResource rows (deduped on Name) and ApiResourceScope
rows linking each resource to its scope. Idempotent: missing
rows are added, nothing is removed.
3. Removes the b.UseSeeding(...) call inside AddConfigurationStore.
EF Core's UseSeeding callback only fires when the database is
empty, so on a live ConfigurationDb (which already had Clients
and ClientScopes) it never ran — that is why the previous commit
had no visible effect on production. The seeder is now invoked
explicitly from MigrateDatabase via SeedConfigurationDatabase,
which resolves ConfigurationDbContext from the DI and runs
EnsureDefaultConfiguration on every startup, regardless of
whether the database was fresh.
Seeding failures are caught and logged (best-effort) so a
misconfigured seeder cannot prevent the host from booting.
Live data on yavsc.pschneider.fr is still missing the
ApiResource/ApiResourceScope rows; a one-shot SQL or a redeploy
with this commit is needed before PostIt can log in. Production
fix to follow.
2026-06-25 21:28:13 +01:00
|
|
|
// NOTE: don't b.UseSeeding(...) here — EF Core's UseSeeding
|
|
|
|
|
// only runs when the database is empty, so on a live
|
|
|
|
|
// configuration store (clients/scopes already present)
|
|
|
|
|
// it never fires and missing scopes are never inserted.
|
|
|
|
|
// We call the seeder explicitly from MigrateDatabase after
|
|
|
|
|
// migrations, so it runs on every startup regardless of
|
|
|
|
|
// whether the database is fresh.
|
2026-05-28 22:18:26 +01:00
|
|
|
};
|
2025-08-24 16:07:53 +01:00
|
|
|
})
|
|
|
|
|
.AddOperationalStore(options =>
|
|
|
|
|
{
|
2026-05-28 22:18:26 +01:00
|
|
|
options.ConfigureDbContext = b =>
|
|
|
|
|
{
|
|
|
|
|
if (UsesInMemoryProvider(connectionString))
|
|
|
|
|
{
|
|
|
|
|
b.UseInMemoryDatabase(connectionString);
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
b.UseNpgsql(connectionString,
|
|
|
|
|
sql => sql.MigrationsAssembly(migrationsAssembly));
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-06-21 21:14:20 +01:00
|
|
|
|
2026-02-09 01:03:33 +00:00
|
|
|
});
|
2025-08-18 09:22:09 +01:00
|
|
|
|
2026-06-25 20:22:41 +01:00
|
|
|
// Skip the production signing-cert requirement when running with
|
2026-06-21 21:14:20 +01:00
|
|
|
// an in-memory database (test fixtures) or in the Development
|
|
|
|
|
// environment. In those cases IdentityServer8 falls back to
|
|
|
|
|
// AddDeveloperSigningCredential which mints an ephemeral key
|
|
|
|
|
// at startup; signing real tokens against it would fail, but
|
|
|
|
|
// the test fixtures only use the discovery/JWKS endpoints.
|
|
|
|
|
var useDevSigning = builder.Environment.IsDevelopment()
|
|
|
|
|
|| UsesInMemoryProvider(connectionString);
|
|
|
|
|
if (useDevSigning)
|
2025-02-14 00:20:35 +00:00
|
|
|
{
|
|
|
|
|
identityServerBuilder.AddDeveloperSigningCredential();
|
|
|
|
|
}
|
2026-06-21 03:55:32 +01:00
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
// Production: reuse the Let's Encrypt certificate that Kestrel
|
|
|
|
|
// already loads for TLS so IdentityServer has a stable signing
|
|
|
|
|
// key (and a JWKS endpoint). The cert is renewed by the ACME
|
|
|
|
|
// hook and a service restart picks up the new key automatically.
|
|
|
|
|
//
|
|
|
|
|
// The path comes from Kestrel:Endpoints:Https:Certificate to
|
|
|
|
|
// avoid maintaining a separate setting; fullchain.pem bundles
|
|
|
|
|
// the leaf + chain, which X509Certificate2 needs for chain
|
|
|
|
|
// validation by relying parties.
|
|
|
|
|
var certPath = builder.Configuration["Kestrel:Endpoints:Https:Certificate:Path"];
|
|
|
|
|
var keyPath = builder.Configuration["Kestrel:Endpoints:Https:Certificate:KeyPath"];
|
|
|
|
|
if (string.IsNullOrWhiteSpace(certPath) || string.IsNullOrWhiteSpace(keyPath))
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
"Production IdentityServer requires a signing certificate. " +
|
|
|
|
|
"Configure Kestrel:Endpoints:Https:Certificate:{Path,KeyPath}.");
|
|
|
|
|
}
|
postit: trailing-slash regression + loopback browser cleanup; identityserver: BC cert loader + SecurityKey
PostIt
- LoginPage renders StatusMessage as a read-only TextBox so the
text is mouse-selectable and copyable (no copy button).
- LoginPageViewModel exposes ExternalUrl (Authentication.Authority
with trailing slash stripped) and DiscoveryUrl
(ExternalUrl + '/.well-known/openid-configuration'). LoginAsync
surfaces the discovery URL before the call and suffixes it onto
every error message, so reachability issues are diagnosable by
pasting the URL into a browser.
- LoopbackBrowser (used for the OIDC redirect listener on desktop)
now bounds the GetContextAsync wait at 5 minutes and calls both
Stop() and Close() in the finally, so the listener is always
released even if the user abandons the flow. Without this, the
next PostIt launch fails with 'Failed to listen on prefix
http://127.0.0.1:7890/ because it conflicts with an existing
registration on the machine.'
- Add LoginPageViewModelTests.LoginAsync_works_when_authority_has_trailing_slash
to lock in the discovery URL contract.
- Add LoopbackBrowserTests covering both timeout and happy-path
listener cleanup.
Yavsc.Org
- Drop the CustomEntries['jwks_uri'] override from commit 84160f07.
IdentityServer8 reserves that key and rejects the override with
'Discovery custom entry jwks_uri cannot be added, because it
already exists.' The default /.well-known/openid-configuration/jwks
endpoint is what DiscoveryKeyEndpoint actually serves.
- Replace X509Certificate2.CreateFromPemFile + the 3-arg
X509Certificate2(path, key, X509KeyStorageFlags) ctor with a
BouncyCastle-backed loader. The BCL path raised
InvalidOperationException during AddSigningCredential and aborted
the runtime with SIGABRT (code=6/ABRT, libcoreclr.so stack) on
the production EC Let's Encrypt cert. BouncyCastle 2.6.2
PemReader accepts PKCS#1 + PKCS#8 EC/RSA PEMs uniformly; RSA
path uses DotNetUtilities.ToRSA, EC path rebuilds ECDsa from
ECParameters with the curve dispatched by NIST order bit length
(256/384/521).
- Switch the signing credential handed to IdentityServer8 from
X509Certificate2 to a SigningCredentials built from a SecurityKey
(RsaSecurityKey / ECDsaSecurityKey). The cert loaded fine but
IdentityServer8's key material service reads cert.PrivateKey at
runtime — on Linux that handle is not retained across the
X509Certificate2 / runtime boundary, so CreateJwkDocumentAsync
raised NullReferenceException on the first GET /jwks. The
SecurityKey is a pure managed object whose Key is a live
AsymmetricAlgorithm, which survives every read IdentityServer
does.
- Add BouncyCastle.Cryptography 2.6.2 to src/Yavsc.Org/Yavsc.Org.csproj
and the matching PackageVersion in Directory.Packages props.
- Wrap the loader in a try/catch that prints the full managed
stack to stderr on failure, so future PEM-format issues surface
in journalctl instead of being hidden behind SIGABRT.
2026-06-21 07:30:11 +01:00
|
|
|
// Load the leaf cert and extract its private key for signing.
|
|
|
|
|
// The previous attempts (X509Certificate2.CreateFromPemFile,
|
|
|
|
|
// the 3-arg ctor with X509KeyStorageFlags, and BC + CopyWithPrivateKey)
|
|
|
|
|
// all loaded the cert successfully, but CreateJwkDocumentAsync
|
|
|
|
|
// still raised NullReferenceException on the first GET /jwks
|
|
|
|
|
// request: IdentityServer8's key material service reads the
|
|
|
|
|
// private key off the X509Certificate2 at runtime, and on Linux
|
|
|
|
|
// the key handle is not retained across that boundary.
|
|
|
|
|
//
|
|
|
|
|
// The reliable pattern is to pass a SigningCredentials that
|
|
|
|
|
// wraps a SecurityKey built directly from the BC-parsed key
|
|
|
|
|
// parameters. The SecurityKey is a managed object whose Key
|
|
|
|
|
// property is a live AsymmetricAlgorithm, which survives every
|
|
|
|
|
// read IdentityServer does (token signing, JWKS publish).
|
|
|
|
|
var signingCredentials = LoadSigningCredentials(certPath, keyPath);
|
|
|
|
|
identityServerBuilder.AddSigningCredential(signingCredentials);
|
2026-06-21 03:55:32 +01:00
|
|
|
}
|
|
|
|
|
|
postit: trailing-slash regression + loopback browser cleanup; identityserver: BC cert loader + SecurityKey
PostIt
- LoginPage renders StatusMessage as a read-only TextBox so the
text is mouse-selectable and copyable (no copy button).
- LoginPageViewModel exposes ExternalUrl (Authentication.Authority
with trailing slash stripped) and DiscoveryUrl
(ExternalUrl + '/.well-known/openid-configuration'). LoginAsync
surfaces the discovery URL before the call and suffixes it onto
every error message, so reachability issues are diagnosable by
pasting the URL into a browser.
- LoopbackBrowser (used for the OIDC redirect listener on desktop)
now bounds the GetContextAsync wait at 5 minutes and calls both
Stop() and Close() in the finally, so the listener is always
released even if the user abandons the flow. Without this, the
next PostIt launch fails with 'Failed to listen on prefix
http://127.0.0.1:7890/ because it conflicts with an existing
registration on the machine.'
- Add LoginPageViewModelTests.LoginAsync_works_when_authority_has_trailing_slash
to lock in the discovery URL contract.
- Add LoopbackBrowserTests covering both timeout and happy-path
listener cleanup.
Yavsc.Org
- Drop the CustomEntries['jwks_uri'] override from commit 84160f07.
IdentityServer8 reserves that key and rejects the override with
'Discovery custom entry jwks_uri cannot be added, because it
already exists.' The default /.well-known/openid-configuration/jwks
endpoint is what DiscoveryKeyEndpoint actually serves.
- Replace X509Certificate2.CreateFromPemFile + the 3-arg
X509Certificate2(path, key, X509KeyStorageFlags) ctor with a
BouncyCastle-backed loader. The BCL path raised
InvalidOperationException during AddSigningCredential and aborted
the runtime with SIGABRT (code=6/ABRT, libcoreclr.so stack) on
the production EC Let's Encrypt cert. BouncyCastle 2.6.2
PemReader accepts PKCS#1 + PKCS#8 EC/RSA PEMs uniformly; RSA
path uses DotNetUtilities.ToRSA, EC path rebuilds ECDsa from
ECParameters with the curve dispatched by NIST order bit length
(256/384/521).
- Switch the signing credential handed to IdentityServer8 from
X509Certificate2 to a SigningCredentials built from a SecurityKey
(RsaSecurityKey / ECDsaSecurityKey). The cert loaded fine but
IdentityServer8's key material service reads cert.PrivateKey at
runtime — on Linux that handle is not retained across the
X509Certificate2 / runtime boundary, so CreateJwkDocumentAsync
raised NullReferenceException on the first GET /jwks. The
SecurityKey is a pure managed object whose Key is a live
AsymmetricAlgorithm, which survives every read IdentityServer
does.
- Add BouncyCastle.Cryptography 2.6.2 to src/Yavsc.Org/Yavsc.Org.csproj
and the matching PackageVersion in Directory.Packages props.
- Wrap the loader in a try/catch that prints the full managed
stack to stderr on failure, so future PEM-format issues surface
in journalctl instead of being hidden behind SIGABRT.
2026-06-21 07:30:11 +01:00
|
|
|
// Note: IdentityServer8 does NOT expose the JWKS at /.well-known/jwks.
|
|
|
|
|
// The default jwks_uri is /.well-known/openid-configuration/jwks,
|
|
|
|
|
// which is what DiscoveryKeyEndpoint serves. Earlier revisions of
|
|
|
|
|
// this file tried to override CustomEntries["jwks_uri"], but
|
|
|
|
|
// IdentityServer8 reserves that key and rejects the override with
|
|
|
|
|
// "Discovery custom entry jwks_uri cannot be added, because it
|
|
|
|
|
// already exists." The default endpoint works once the signing
|
|
|
|
|
// credential's private key is attached (see above).
|
2025-02-14 00:20:35 +00:00
|
|
|
return identityServerBuilder;
|
2024-02-25 18:05:10 +00:00
|
|
|
}
|
2025-02-14 00:20:35 +00:00
|
|
|
|
postit: trailing-slash regression + loopback browser cleanup; identityserver: BC cert loader + SecurityKey
PostIt
- LoginPage renders StatusMessage as a read-only TextBox so the
text is mouse-selectable and copyable (no copy button).
- LoginPageViewModel exposes ExternalUrl (Authentication.Authority
with trailing slash stripped) and DiscoveryUrl
(ExternalUrl + '/.well-known/openid-configuration'). LoginAsync
surfaces the discovery URL before the call and suffixes it onto
every error message, so reachability issues are diagnosable by
pasting the URL into a browser.
- LoopbackBrowser (used for the OIDC redirect listener on desktop)
now bounds the GetContextAsync wait at 5 minutes and calls both
Stop() and Close() in the finally, so the listener is always
released even if the user abandons the flow. Without this, the
next PostIt launch fails with 'Failed to listen on prefix
http://127.0.0.1:7890/ because it conflicts with an existing
registration on the machine.'
- Add LoginPageViewModelTests.LoginAsync_works_when_authority_has_trailing_slash
to lock in the discovery URL contract.
- Add LoopbackBrowserTests covering both timeout and happy-path
listener cleanup.
Yavsc.Org
- Drop the CustomEntries['jwks_uri'] override from commit 84160f07.
IdentityServer8 reserves that key and rejects the override with
'Discovery custom entry jwks_uri cannot be added, because it
already exists.' The default /.well-known/openid-configuration/jwks
endpoint is what DiscoveryKeyEndpoint actually serves.
- Replace X509Certificate2.CreateFromPemFile + the 3-arg
X509Certificate2(path, key, X509KeyStorageFlags) ctor with a
BouncyCastle-backed loader. The BCL path raised
InvalidOperationException during AddSigningCredential and aborted
the runtime with SIGABRT (code=6/ABRT, libcoreclr.so stack) on
the production EC Let's Encrypt cert. BouncyCastle 2.6.2
PemReader accepts PKCS#1 + PKCS#8 EC/RSA PEMs uniformly; RSA
path uses DotNetUtilities.ToRSA, EC path rebuilds ECDsa from
ECParameters with the curve dispatched by NIST order bit length
(256/384/521).
- Switch the signing credential handed to IdentityServer8 from
X509Certificate2 to a SigningCredentials built from a SecurityKey
(RsaSecurityKey / ECDsaSecurityKey). The cert loaded fine but
IdentityServer8's key material service reads cert.PrivateKey at
runtime — on Linux that handle is not retained across the
X509Certificate2 / runtime boundary, so CreateJwkDocumentAsync
raised NullReferenceException on the first GET /jwks. The
SecurityKey is a pure managed object whose Key is a live
AsymmetricAlgorithm, which survives every read IdentityServer
does.
- Add BouncyCastle.Cryptography 2.6.2 to src/Yavsc.Org/Yavsc.Org.csproj
and the matching PackageVersion in Directory.Packages props.
- Wrap the loader in a try/catch that prints the full managed
stack to stderr on failure, so future PEM-format issues surface
in journalctl instead of being hidden behind SIGABRT.
2026-06-21 07:30:11 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Load the signing credentials (algorithm + private key) from the
|
|
|
|
|
/// configured PEM files. Returns a <see cref="SigningCredentials"/>
|
|
|
|
|
/// whose <c>Key</c> is a managed <see cref="SecurityKey"/> built
|
|
|
|
|
/// directly from the BouncyCastle-parsed key parameters — which keeps
|
|
|
|
|
/// the private key alive for every read IdentityServer8 does (token
|
|
|
|
|
/// signing, JWKS publish), unlike <c>X509Certificate2.CopyWithPrivateKey</c>
|
|
|
|
|
/// which loses the handle on Linux when IdentityServer8's key material
|
|
|
|
|
/// service reads it back at runtime.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static SigningCredentials LoadSigningCredentials(string certPath, string keyPath)
|
|
|
|
|
{
|
|
|
|
|
// Pre-flight read so permission / missing-file errors surface with
|
|
|
|
|
// the actual path instead of being wrapped as an opaque
|
|
|
|
|
// InvalidOperationException by the cert / key parsers.
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
return LoadSigningCredentialsInner(certPath, keyPath);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
Console.Error.WriteLine(
|
|
|
|
|
$"[yavsc] Failed to load signing credentials from {certPath} / {keyPath}:");
|
|
|
|
|
Console.Error.WriteLine(ex.ToString());
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
$"Failed to load signing credentials from {certPath} / {keyPath}. " +
|
|
|
|
|
"See stderr for the underlying managed exception (likely a " +
|
|
|
|
|
"PEM format mismatch between the cert and private key).",
|
|
|
|
|
ex);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static SigningCredentials LoadSigningCredentialsInner(string certPath, string keyPath)
|
|
|
|
|
{
|
|
|
|
|
// Validate the cert is readable (used downstream for token
|
|
|
|
|
// audience/subject validation; signing itself uses the key).
|
2026-06-25 20:22:41 +01:00
|
|
|
|
postit: trailing-slash regression + loopback browser cleanup; identityserver: BC cert loader + SecurityKey
PostIt
- LoginPage renders StatusMessage as a read-only TextBox so the
text is mouse-selectable and copyable (no copy button).
- LoginPageViewModel exposes ExternalUrl (Authentication.Authority
with trailing slash stripped) and DiscoveryUrl
(ExternalUrl + '/.well-known/openid-configuration'). LoginAsync
surfaces the discovery URL before the call and suffixes it onto
every error message, so reachability issues are diagnosable by
pasting the URL into a browser.
- LoopbackBrowser (used for the OIDC redirect listener on desktop)
now bounds the GetContextAsync wait at 5 minutes and calls both
Stop() and Close() in the finally, so the listener is always
released even if the user abandons the flow. Without this, the
next PostIt launch fails with 'Failed to listen on prefix
http://127.0.0.1:7890/ because it conflicts with an existing
registration on the machine.'
- Add LoginPageViewModelTests.LoginAsync_works_when_authority_has_trailing_slash
to lock in the discovery URL contract.
- Add LoopbackBrowserTests covering both timeout and happy-path
listener cleanup.
Yavsc.Org
- Drop the CustomEntries['jwks_uri'] override from commit 84160f07.
IdentityServer8 reserves that key and rejects the override with
'Discovery custom entry jwks_uri cannot be added, because it
already exists.' The default /.well-known/openid-configuration/jwks
endpoint is what DiscoveryKeyEndpoint actually serves.
- Replace X509Certificate2.CreateFromPemFile + the 3-arg
X509Certificate2(path, key, X509KeyStorageFlags) ctor with a
BouncyCastle-backed loader. The BCL path raised
InvalidOperationException during AddSigningCredential and aborted
the runtime with SIGABRT (code=6/ABRT, libcoreclr.so stack) on
the production EC Let's Encrypt cert. BouncyCastle 2.6.2
PemReader accepts PKCS#1 + PKCS#8 EC/RSA PEMs uniformly; RSA
path uses DotNetUtilities.ToRSA, EC path rebuilds ECDsa from
ECParameters with the curve dispatched by NIST order bit length
(256/384/521).
- Switch the signing credential handed to IdentityServer8 from
X509Certificate2 to a SigningCredentials built from a SecurityKey
(RsaSecurityKey / ECDsaSecurityKey). The cert loaded fine but
IdentityServer8's key material service reads cert.PrivateKey at
runtime — on Linux that handle is not retained across the
X509Certificate2 / runtime boundary, so CreateJwkDocumentAsync
raised NullReferenceException on the first GET /jwks. The
SecurityKey is a pure managed object whose Key is a live
AsymmetricAlgorithm, which survives every read IdentityServer
does.
- Add BouncyCastle.Cryptography 2.6.2 to src/Yavsc.Org/Yavsc.Org.csproj
and the matching PackageVersion in Directory.Packages props.
- Wrap the loader in a try/catch that prints the full managed
stack to stderr on failure, so future PEM-format issues surface
in journalctl instead of being hidden behind SIGABRT.
2026-06-21 07:30:11 +01:00
|
|
|
string keyPem = File.ReadAllText(keyPath);
|
|
|
|
|
|
|
|
|
|
// BouncyCastle's PemReader accepts every flavour of unencrypted
|
|
|
|
|
// private key PEM that ACME clients produce (PKCS#1 with
|
|
|
|
|
// BEGIN EC/RSA PRIVATE KEY, PKCS#8 with BEGIN PRIVATE KEY, both
|
|
|
|
|
// EC and RSA), and returns the right AsymmetricKeyParameter
|
|
|
|
|
// subtype without the SIGABRTs we saw when forcing the
|
|
|
|
|
// System.Security.Cryptography path on the production EC Let's
|
|
|
|
|
// Encrypt cert.
|
|
|
|
|
using var sr = new StringReader(keyPem);
|
|
|
|
|
var pemReader = new PemReader(sr);
|
|
|
|
|
var keyObj = pemReader.ReadObject()
|
|
|
|
|
?? throw new InvalidOperationException(
|
|
|
|
|
$"PEM reader returned null for {keyPath}");
|
|
|
|
|
|
|
|
|
|
AsymmetricKeyParameter bcKey = keyObj switch
|
|
|
|
|
{
|
|
|
|
|
AsymmetricCipherKeyPair pair => pair.Private,
|
|
|
|
|
AsymmetricKeyParameter param => param,
|
|
|
|
|
_ => throw new InvalidOperationException(
|
|
|
|
|
$"Unexpected PEM object type '{keyObj.GetType().FullName}' " +
|
|
|
|
|
$"in {keyPath}; expected a private key."),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Build the SecurityKey + SigningCredentials. RsaSecurityKey /
|
|
|
|
|
// ECDsaSecurityKey wrap managed AsymmetricAlgorithm objects whose
|
|
|
|
|
// Key is the live private key — IdentityServer8 can call Sign on
|
|
|
|
|
// these repeatedly without losing the key handle.
|
|
|
|
|
switch (bcKey)
|
|
|
|
|
{
|
|
|
|
|
case RsaPrivateCrtKeyParameters rsa:
|
|
|
|
|
{
|
2026-06-25 20:22:41 +01:00
|
|
|
#pragma warning disable CA1416 // Valider la compatibilité de la plateforme
|
postit: trailing-slash regression + loopback browser cleanup; identityserver: BC cert loader + SecurityKey
PostIt
- LoginPage renders StatusMessage as a read-only TextBox so the
text is mouse-selectable and copyable (no copy button).
- LoginPageViewModel exposes ExternalUrl (Authentication.Authority
with trailing slash stripped) and DiscoveryUrl
(ExternalUrl + '/.well-known/openid-configuration'). LoginAsync
surfaces the discovery URL before the call and suffixes it onto
every error message, so reachability issues are diagnosable by
pasting the URL into a browser.
- LoopbackBrowser (used for the OIDC redirect listener on desktop)
now bounds the GetContextAsync wait at 5 minutes and calls both
Stop() and Close() in the finally, so the listener is always
released even if the user abandons the flow. Without this, the
next PostIt launch fails with 'Failed to listen on prefix
http://127.0.0.1:7890/ because it conflicts with an existing
registration on the machine.'
- Add LoginPageViewModelTests.LoginAsync_works_when_authority_has_trailing_slash
to lock in the discovery URL contract.
- Add LoopbackBrowserTests covering both timeout and happy-path
listener cleanup.
Yavsc.Org
- Drop the CustomEntries['jwks_uri'] override from commit 84160f07.
IdentityServer8 reserves that key and rejects the override with
'Discovery custom entry jwks_uri cannot be added, because it
already exists.' The default /.well-known/openid-configuration/jwks
endpoint is what DiscoveryKeyEndpoint actually serves.
- Replace X509Certificate2.CreateFromPemFile + the 3-arg
X509Certificate2(path, key, X509KeyStorageFlags) ctor with a
BouncyCastle-backed loader. The BCL path raised
InvalidOperationException during AddSigningCredential and aborted
the runtime with SIGABRT (code=6/ABRT, libcoreclr.so stack) on
the production EC Let's Encrypt cert. BouncyCastle 2.6.2
PemReader accepts PKCS#1 + PKCS#8 EC/RSA PEMs uniformly; RSA
path uses DotNetUtilities.ToRSA, EC path rebuilds ECDsa from
ECParameters with the curve dispatched by NIST order bit length
(256/384/521).
- Switch the signing credential handed to IdentityServer8 from
X509Certificate2 to a SigningCredentials built from a SecurityKey
(RsaSecurityKey / ECDsaSecurityKey). The cert loaded fine but
IdentityServer8's key material service reads cert.PrivateKey at
runtime — on Linux that handle is not retained across the
X509Certificate2 / runtime boundary, so CreateJwkDocumentAsync
raised NullReferenceException on the first GET /jwks. The
SecurityKey is a pure managed object whose Key is a live
AsymmetricAlgorithm, which survives every read IdentityServer
does.
- Add BouncyCastle.Cryptography 2.6.2 to src/Yavsc.Org/Yavsc.Org.csproj
and the matching PackageVersion in Directory.Packages props.
- Wrap the loader in a try/catch that prints the full managed
stack to stderr on failure, so future PEM-format issues surface
in journalctl instead of being hidden behind SIGABRT.
2026-06-21 07:30:11 +01:00
|
|
|
var rsaDotNet = DotNetUtilities.ToRSA(rsa);
|
2026-06-25 20:22:41 +01:00
|
|
|
#pragma warning restore CA1416 // Valider la compatibilité de la plateforme
|
postit: trailing-slash regression + loopback browser cleanup; identityserver: BC cert loader + SecurityKey
PostIt
- LoginPage renders StatusMessage as a read-only TextBox so the
text is mouse-selectable and copyable (no copy button).
- LoginPageViewModel exposes ExternalUrl (Authentication.Authority
with trailing slash stripped) and DiscoveryUrl
(ExternalUrl + '/.well-known/openid-configuration'). LoginAsync
surfaces the discovery URL before the call and suffixes it onto
every error message, so reachability issues are diagnosable by
pasting the URL into a browser.
- LoopbackBrowser (used for the OIDC redirect listener on desktop)
now bounds the GetContextAsync wait at 5 minutes and calls both
Stop() and Close() in the finally, so the listener is always
released even if the user abandons the flow. Without this, the
next PostIt launch fails with 'Failed to listen on prefix
http://127.0.0.1:7890/ because it conflicts with an existing
registration on the machine.'
- Add LoginPageViewModelTests.LoginAsync_works_when_authority_has_trailing_slash
to lock in the discovery URL contract.
- Add LoopbackBrowserTests covering both timeout and happy-path
listener cleanup.
Yavsc.Org
- Drop the CustomEntries['jwks_uri'] override from commit 84160f07.
IdentityServer8 reserves that key and rejects the override with
'Discovery custom entry jwks_uri cannot be added, because it
already exists.' The default /.well-known/openid-configuration/jwks
endpoint is what DiscoveryKeyEndpoint actually serves.
- Replace X509Certificate2.CreateFromPemFile + the 3-arg
X509Certificate2(path, key, X509KeyStorageFlags) ctor with a
BouncyCastle-backed loader. The BCL path raised
InvalidOperationException during AddSigningCredential and aborted
the runtime with SIGABRT (code=6/ABRT, libcoreclr.so stack) on
the production EC Let's Encrypt cert. BouncyCastle 2.6.2
PemReader accepts PKCS#1 + PKCS#8 EC/RSA PEMs uniformly; RSA
path uses DotNetUtilities.ToRSA, EC path rebuilds ECDsa from
ECParameters with the curve dispatched by NIST order bit length
(256/384/521).
- Switch the signing credential handed to IdentityServer8 from
X509Certificate2 to a SigningCredentials built from a SecurityKey
(RsaSecurityKey / ECDsaSecurityKey). The cert loaded fine but
IdentityServer8's key material service reads cert.PrivateKey at
runtime — on Linux that handle is not retained across the
X509Certificate2 / runtime boundary, so CreateJwkDocumentAsync
raised NullReferenceException on the first GET /jwks. The
SecurityKey is a pure managed object whose Key is a live
AsymmetricAlgorithm, which survives every read IdentityServer
does.
- Add BouncyCastle.Cryptography 2.6.2 to src/Yavsc.Org/Yavsc.Org.csproj
and the matching PackageVersion in Directory.Packages props.
- Wrap the loader in a try/catch that prints the full managed
stack to stderr on failure, so future PEM-format issues surface
in journalctl instead of being hidden behind SIGABRT.
2026-06-21 07:30:11 +01:00
|
|
|
var key = new RsaSecurityKey(rsaDotNet);
|
|
|
|
|
return new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
|
|
|
|
|
}
|
|
|
|
|
case ECPrivateKeyParameters ec:
|
|
|
|
|
{
|
|
|
|
|
var ecParams = new ECParameters
|
|
|
|
|
{
|
|
|
|
|
Curve = LoadEcCurve(ec.Parameters),
|
|
|
|
|
D = ec.D.ToByteArrayUnsigned(),
|
|
|
|
|
};
|
|
|
|
|
var ecdsa = ECDsa.Create();
|
|
|
|
|
ecdsa.ImportParameters(ecParams);
|
|
|
|
|
var key = new ECDsaSecurityKey(ecdsa);
|
|
|
|
|
return new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
$"Unsupported private key algorithm '{bcKey.GetType().Name}' " +
|
|
|
|
|
$"in {keyPath}; expected RSA or EC.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Map a BouncyCastle <see cref="ECDomainParameters"/> to a
|
|
|
|
|
/// <see cref="ECCurve"/> that <see cref="ECDsa.ImportParameters"/>
|
|
|
|
|
/// understands. Handles the curves Let's Encrypt issues (P-256,
|
|
|
|
|
/// P-384, P-521); other curves throw.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static ECCurve LoadEcCurve(ECDomainParameters bcCurve)
|
|
|
|
|
{
|
|
|
|
|
// bcCurve.N is the order of the generator; its bit length is the
|
|
|
|
|
// canonical fingerprint for NIST curves (256, 384, 521 bits).
|
|
|
|
|
var orderBits = bcCurve.N.BitLength;
|
|
|
|
|
return orderBits switch
|
|
|
|
|
{
|
|
|
|
|
256 => ECCurve.NamedCurves.nistP256,
|
|
|
|
|
384 => ECCurve.NamedCurves.nistP384,
|
|
|
|
|
521 => ECCurve.NamedCurves.nistP521,
|
|
|
|
|
_ => throw new InvalidOperationException(
|
|
|
|
|
$"Unsupported EC curve with order bit length {orderBits}; " +
|
|
|
|
|
"expected P-256, P-384 or P-521."),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 22:18:26 +01:00
|
|
|
private static bool UsesInMemoryProvider(string connectionString)
|
|
|
|
|
{
|
|
|
|
|
return string.Equals(connectionString, InMemoryProviderName, StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 00:35:51 +01:00
|
|
|
private static Action<DbContext, bool> EnsureDefaultApplicationScopes()
|
|
|
|
|
{
|
|
|
|
|
return (context, _) =>
|
|
|
|
|
{
|
2026-06-25 23:55:52 +01:00
|
|
|
foreach (String scope in Org.Constants.BuildInApiScopes)
|
2026-04-20 00:35:51 +01:00
|
|
|
{
|
2026-06-25 00:08:25 +01:00
|
|
|
var existentScope = context.Set<IdentityServer8.EntityFramework.Entities.ApiScope>().FirstOrDefault(b => b.Name == scope);
|
2026-04-20 00:35:51 +01:00
|
|
|
if (existentScope == null)
|
|
|
|
|
{
|
2026-06-25 00:08:25 +01:00
|
|
|
context.Set<IdentityServer8.EntityFramework.Entities.ApiScope>().Add(new IdentityServer8.EntityFramework.Entities.ApiScope { Name = scope });
|
2026-04-20 00:35:51 +01:00
|
|
|
context.SaveChanges();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-25 20:22:41 +01:00
|
|
|
var identityResources = context.Set<IdentityServer8.EntityFramework.Entities.IdentityResource>();
|
|
|
|
|
var apiScopes = context.Set<IdentityServer8.EntityFramework.Entities.ApiScope>();
|
2026-06-25 00:08:25 +01:00
|
|
|
|
Seed ApiScopes for ApiResourcesScopes, align PostIt client
EnsureDefaultApplicationScopes was inserting every entry of
Constants.ApiResourcesScopes (admin, moderation, performer, client,
blogs) into the IdentityResources table, as Profile-derived rows.
That made them visible to /connect/discovery's scopes_supported
under the identity section, but no API resource would ever issue a
token bearing them — IdentityServer then rejected clients that
requested any of these scopes with 'invalid_scope' at the token
endpoint.
The most visible casualty was PostIt, a public PKCE client whose
postit-settings.json asks for scope=openid profile offline_access
blogs. 'blogs' is the scope that gates the Yavsc.Blogs deployment
(blogs.pschneider.fr), so the login flow died at the token step.
Fix:
- Constants.ApiResourcesScopes entries are now seeded as ApiScope
rows (with Name + DisplayName). IdentityResources stays limited
to the actual OpenID Connect profile (openid, profile).
- EnsureDefaultConfiguration gains an idempotent
AlignPostItClientScopes pass that adds any missing scope from
PostItScopes to the existing 'postit' client's AllowedScopes.
Nothing is removed — manual revocation stays manual.
Existing live databases pick up both changes on next startup:
missing ApiScope rows are inserted, and the postit client's
ClientScope rows catch up.
2026-06-25 20:46:58 +01:00
|
|
|
// IdentityResources standards (OpenId + Profile only).
|
|
|
|
|
// Application-defined API scopes from Constants.ApiResourcesScopes
|
|
|
|
|
// are NOT identity resources — they belong to the ApiScopes table
|
|
|
|
|
// and are seeded as such further down.
|
2026-06-25 20:22:41 +01:00
|
|
|
if (!identityResources.Any(r => r.Name == "openid"))
|
|
|
|
|
{
|
|
|
|
|
var openid = new IdentityResources.OpenId().ToEntity();
|
|
|
|
|
identityResources.Add(openid);
|
|
|
|
|
}
|
2026-06-25 00:08:25 +01:00
|
|
|
|
2026-06-25 20:22:41 +01:00
|
|
|
if (!identityResources.Any(r => r.Name == "profile"))
|
2026-06-25 00:08:25 +01:00
|
|
|
{
|
2026-06-25 20:22:41 +01:00
|
|
|
var profile = new IdentityResources.Profile().ToEntity();
|
|
|
|
|
identityResources.Add(profile);
|
|
|
|
|
}
|
|
|
|
|
|
Seed ApiScopes for ApiResourcesScopes, align PostIt client
EnsureDefaultApplicationScopes was inserting every entry of
Constants.ApiResourcesScopes (admin, moderation, performer, client,
blogs) into the IdentityResources table, as Profile-derived rows.
That made them visible to /connect/discovery's scopes_supported
under the identity section, but no API resource would ever issue a
token bearing them — IdentityServer then rejected clients that
requested any of these scopes with 'invalid_scope' at the token
endpoint.
The most visible casualty was PostIt, a public PKCE client whose
postit-settings.json asks for scope=openid profile offline_access
blogs. 'blogs' is the scope that gates the Yavsc.Blogs deployment
(blogs.pschneider.fr), so the login flow died at the token step.
Fix:
- Constants.ApiResourcesScopes entries are now seeded as ApiScope
rows (with Name + DisplayName). IdentityResources stays limited
to the actual OpenID Connect profile (openid, profile).
- EnsureDefaultConfiguration gains an idempotent
AlignPostItClientScopes pass that adds any missing scope from
PostItScopes to the existing 'postit' client's AllowedScopes.
Nothing is removed — manual revocation stays manual.
Existing live databases pick up both changes on next startup:
missing ApiScope rows are inserted, and the postit client's
ClientScope rows catch up.
2026-06-25 20:46:58 +01:00
|
|
|
// Application-defined API scopes (admin, moderation, performer,
|
|
|
|
|
// client, blogs, …). Seeded into ApiScopes, not IdentityResources:
|
|
|
|
|
// these gate access to API resources (e.g. the Yavsc.Blogs
|
|
|
|
|
// deployment requires the "blogs" scope) and must therefore be
|
|
|
|
|
// discoverable through /connect/discovery's
|
|
|
|
|
// scopes_supported of type resource, not identity.
|
|
|
|
|
//
|
|
|
|
|
// NOTE: prior versions inserted these into IdentityResources,
|
|
|
|
|
// which made them visible to /connect/authorize but unfulfillable
|
|
|
|
|
// (no API resource recognises an identity-scoped consent as
|
|
|
|
|
// access to a downstream resource). Clients like PostIt that
|
|
|
|
|
// request one of these scopes were rejected with "invalid_scope"
|
|
|
|
|
// at the token endpoint. Keep this in ApiScopes.
|
2026-06-25 23:55:52 +01:00
|
|
|
foreach (var scopeSpec in Org.Constants.ApiResourcesScopes)
|
Seed ApiScopes for ApiResourcesScopes, align PostIt client
EnsureDefaultApplicationScopes was inserting every entry of
Constants.ApiResourcesScopes (admin, moderation, performer, client,
blogs) into the IdentityResources table, as Profile-derived rows.
That made them visible to /connect/discovery's scopes_supported
under the identity section, but no API resource would ever issue a
token bearing them — IdentityServer then rejected clients that
requested any of these scopes with 'invalid_scope' at the token
endpoint.
The most visible casualty was PostIt, a public PKCE client whose
postit-settings.json asks for scope=openid profile offline_access
blogs. 'blogs' is the scope that gates the Yavsc.Blogs deployment
(blogs.pschneider.fr), so the login flow died at the token step.
Fix:
- Constants.ApiResourcesScopes entries are now seeded as ApiScope
rows (with Name + DisplayName). IdentityResources stays limited
to the actual OpenID Connect profile (openid, profile).
- EnsureDefaultConfiguration gains an idempotent
AlignPostItClientScopes pass that adds any missing scope from
PostItScopes to the existing 'postit' client's AllowedScopes.
Nothing is removed — manual revocation stays manual.
Existing live databases pick up both changes on next startup:
missing ApiScope rows are inserted, and the postit client's
ClientScope rows catch up.
2026-06-25 20:46:58 +01:00
|
|
|
{
|
|
|
|
|
if (!apiScopes.Any(s => s.Name == scopeSpec.ScopeName))
|
2026-06-25 20:22:41 +01:00
|
|
|
{
|
Seed ApiScopes for ApiResourcesScopes, align PostIt client
EnsureDefaultApplicationScopes was inserting every entry of
Constants.ApiResourcesScopes (admin, moderation, performer, client,
blogs) into the IdentityResources table, as Profile-derived rows.
That made them visible to /connect/discovery's scopes_supported
under the identity section, but no API resource would ever issue a
token bearing them — IdentityServer then rejected clients that
requested any of these scopes with 'invalid_scope' at the token
endpoint.
The most visible casualty was PostIt, a public PKCE client whose
postit-settings.json asks for scope=openid profile offline_access
blogs. 'blogs' is the scope that gates the Yavsc.Blogs deployment
(blogs.pschneider.fr), so the login flow died at the token step.
Fix:
- Constants.ApiResourcesScopes entries are now seeded as ApiScope
rows (with Name + DisplayName). IdentityResources stays limited
to the actual OpenID Connect profile (openid, profile).
- EnsureDefaultConfiguration gains an idempotent
AlignPostItClientScopes pass that adds any missing scope from
PostItScopes to the existing 'postit' client's AllowedScopes.
Nothing is removed — manual revocation stays manual.
Existing live databases pick up both changes on next startup:
missing ApiScope rows are inserted, and the postit client's
ClientScope rows catch up.
2026-06-25 20:46:58 +01:00
|
|
|
apiScopes.Add(new IdentityServer8.EntityFramework.Entities.ApiScope
|
|
|
|
|
{
|
|
|
|
|
Name = scopeSpec.ScopeName,
|
|
|
|
|
DisplayName = scopeSpec.Description,
|
2026-06-25 21:30:56 +01:00
|
|
|
// The corresponding Postgres columns are NOT NULL
|
|
|
|
|
// with no DB default — EF Core ships the C# default
|
|
|
|
|
// (false) unless we set them explicitly. A scope
|
|
|
|
|
// inserted with Enabled=false is invisible to
|
|
|
|
|
// DefaultResourceValidator, which would silently
|
|
|
|
|
// reproduce the bug we're fixing here.
|
|
|
|
|
Enabled = true,
|
|
|
|
|
Required = false,
|
|
|
|
|
Emphasize = false,
|
|
|
|
|
ShowInDiscoveryDocument = true,
|
Seed ApiScopes for ApiResourcesScopes, align PostIt client
EnsureDefaultApplicationScopes was inserting every entry of
Constants.ApiResourcesScopes (admin, moderation, performer, client,
blogs) into the IdentityResources table, as Profile-derived rows.
That made them visible to /connect/discovery's scopes_supported
under the identity section, but no API resource would ever issue a
token bearing them — IdentityServer then rejected clients that
requested any of these scopes with 'invalid_scope' at the token
endpoint.
The most visible casualty was PostIt, a public PKCE client whose
postit-settings.json asks for scope=openid profile offline_access
blogs. 'blogs' is the scope that gates the Yavsc.Blogs deployment
(blogs.pschneider.fr), so the login flow died at the token step.
Fix:
- Constants.ApiResourcesScopes entries are now seeded as ApiScope
rows (with Name + DisplayName). IdentityResources stays limited
to the actual OpenID Connect profile (openid, profile).
- EnsureDefaultConfiguration gains an idempotent
AlignPostItClientScopes pass that adds any missing scope from
PostItScopes to the existing 'postit' client's AllowedScopes.
Nothing is removed — manual revocation stays manual.
Existing live databases pick up both changes on next startup:
missing ApiScope rows are inserted, and the postit client's
ClientScope rows catch up.
2026-06-25 20:46:58 +01:00
|
|
|
});
|
2026-06-25 20:22:41 +01:00
|
|
|
}
|
|
|
|
|
}
|
Seed ApiResources + ApiResourceScopes, run seeder on every startup
The previous commit (37440171) added ApiScope rows for the
application scopes (admin, moderation, performer, client, blogs).
It was a partial fix: an ApiScope alone is not a valid scope from
DefaultResourceValidator's point of view. The validator only
recognises a scope if it can find an ApiResource that exposes it
(via ApiResourceScopes). Without that link, /connect/authorize
rejects the request with 'Scope X not found in store', even
though the scope row exists. This is what killed the PostIt login
in production.
This commit:
1. Extends Constants.ApiResourcesScopes with ResourceName +
ResourceDisplayName. Topology: one ApiResource per scope
('admin' resource exposes 'admin' scope, 'blogs' resource
exposes 'blogs' scope, etc.) — keeps each scope's audience
specific if/when we split products across separate audiences.
2. Ensures EnsureDefaultApplicationScopes also inserts the
matching ApiResource rows (deduped on Name) and ApiResourceScope
rows linking each resource to its scope. Idempotent: missing
rows are added, nothing is removed.
3. Removes the b.UseSeeding(...) call inside AddConfigurationStore.
EF Core's UseSeeding callback only fires when the database is
empty, so on a live ConfigurationDb (which already had Clients
and ClientScopes) it never ran — that is why the previous commit
had no visible effect on production. The seeder is now invoked
explicitly from MigrateDatabase via SeedConfigurationDatabase,
which resolves ConfigurationDbContext from the DI and runs
EnsureDefaultConfiguration on every startup, regardless of
whether the database was fresh.
Seeding failures are caught and logged (best-effort) so a
misconfigured seeder cannot prevent the host from booting.
Live data on yavsc.pschneider.fr is still missing the
ApiResource/ApiResourceScope rows; a one-shot SQL or a redeploy
with this commit is needed before PostIt can log in. Production
fix to follow.
2026-06-25 21:28:13 +01:00
|
|
|
|
|
|
|
|
// ApiResources — one per application scope, linked to its scope
|
|
|
|
|
// via ApiResourceScopes. IdentityServer8's DefaultResourceValidator
|
|
|
|
|
// rejects any scope that isn't backed by an ApiResource at
|
|
|
|
|
// /connect/authorize time ("Scope X not found in store"), even
|
|
|
|
|
// when the ApiScope row itself exists. Seeding the scope without
|
|
|
|
|
// the resource is what caused the PostIt login to die in the
|
|
|
|
|
// first place.
|
|
|
|
|
//
|
|
|
|
|
// The mapping is taken from Constants.ApiResourcesScopes
|
|
|
|
|
// (ScopeName ↔ ResourceName). We dedupe on resource name so a
|
|
|
|
|
// future spec that re-uses an existing resource doesn't insert
|
|
|
|
|
// duplicates.
|
|
|
|
|
var apiResources = context.Set<IdentityServer8.EntityFramework.Entities.ApiResource>();
|
|
|
|
|
var apiResourceScopes = context.Set<IdentityServer8.EntityFramework.Entities.ApiResourceScope>();
|
|
|
|
|
|
|
|
|
|
// Make sure every resource row referenced by the spec exists.
|
2026-06-25 23:55:52 +01:00
|
|
|
foreach (var resourceGroup in Org.Constants.ApiResourcesScopes
|
Seed ApiResources + ApiResourceScopes, run seeder on every startup
The previous commit (37440171) added ApiScope rows for the
application scopes (admin, moderation, performer, client, blogs).
It was a partial fix: an ApiScope alone is not a valid scope from
DefaultResourceValidator's point of view. The validator only
recognises a scope if it can find an ApiResource that exposes it
(via ApiResourceScopes). Without that link, /connect/authorize
rejects the request with 'Scope X not found in store', even
though the scope row exists. This is what killed the PostIt login
in production.
This commit:
1. Extends Constants.ApiResourcesScopes with ResourceName +
ResourceDisplayName. Topology: one ApiResource per scope
('admin' resource exposes 'admin' scope, 'blogs' resource
exposes 'blogs' scope, etc.) — keeps each scope's audience
specific if/when we split products across separate audiences.
2. Ensures EnsureDefaultApplicationScopes also inserts the
matching ApiResource rows (deduped on Name) and ApiResourceScope
rows linking each resource to its scope. Idempotent: missing
rows are added, nothing is removed.
3. Removes the b.UseSeeding(...) call inside AddConfigurationStore.
EF Core's UseSeeding callback only fires when the database is
empty, so on a live ConfigurationDb (which already had Clients
and ClientScopes) it never ran — that is why the previous commit
had no visible effect on production. The seeder is now invoked
explicitly from MigrateDatabase via SeedConfigurationDatabase,
which resolves ConfigurationDbContext from the DI and runs
EnsureDefaultConfiguration on every startup, regardless of
whether the database was fresh.
Seeding failures are caught and logged (best-effort) so a
misconfigured seeder cannot prevent the host from booting.
Live data on yavsc.pschneider.fr is still missing the
ApiResource/ApiResourceScope rows; a one-shot SQL or a redeploy
with this commit is needed before PostIt can log in. Production
fix to follow.
2026-06-25 21:28:13 +01:00
|
|
|
.GroupBy(s => s.ResourceName))
|
|
|
|
|
{
|
|
|
|
|
var spec = resourceGroup.First();
|
|
|
|
|
if (!apiResources.Any(r => r.Name == spec.ResourceName))
|
|
|
|
|
{
|
|
|
|
|
apiResources.Add(new IdentityServer8.EntityFramework.Entities.ApiResource
|
|
|
|
|
{
|
|
|
|
|
Name = spec.ResourceName,
|
|
|
|
|
DisplayName = spec.ResourceDisplayName,
|
|
|
|
|
Enabled = true,
|
2026-06-25 21:30:56 +01:00
|
|
|
// Created is NOT NULL with no DB default. Without
|
|
|
|
|
// this EF will send DateTime.MinValue (0001-01-01)
|
|
|
|
|
// which Postgres rejects with
|
|
|
|
|
// "null value in column 'Created' violates
|
|
|
|
|
// not-null constraint" once the seeder runs.
|
|
|
|
|
Created = DateTime.UtcNow,
|
|
|
|
|
ShowInDiscoveryDocument = true,
|
|
|
|
|
NonEditable = false,
|
Seed ApiResources + ApiResourceScopes, run seeder on every startup
The previous commit (37440171) added ApiScope rows for the
application scopes (admin, moderation, performer, client, blogs).
It was a partial fix: an ApiScope alone is not a valid scope from
DefaultResourceValidator's point of view. The validator only
recognises a scope if it can find an ApiResource that exposes it
(via ApiResourceScopes). Without that link, /connect/authorize
rejects the request with 'Scope X not found in store', even
though the scope row exists. This is what killed the PostIt login
in production.
This commit:
1. Extends Constants.ApiResourcesScopes with ResourceName +
ResourceDisplayName. Topology: one ApiResource per scope
('admin' resource exposes 'admin' scope, 'blogs' resource
exposes 'blogs' scope, etc.) — keeps each scope's audience
specific if/when we split products across separate audiences.
2. Ensures EnsureDefaultApplicationScopes also inserts the
matching ApiResource rows (deduped on Name) and ApiResourceScope
rows linking each resource to its scope. Idempotent: missing
rows are added, nothing is removed.
3. Removes the b.UseSeeding(...) call inside AddConfigurationStore.
EF Core's UseSeeding callback only fires when the database is
empty, so on a live ConfigurationDb (which already had Clients
and ClientScopes) it never ran — that is why the previous commit
had no visible effect on production. The seeder is now invoked
explicitly from MigrateDatabase via SeedConfigurationDatabase,
which resolves ConfigurationDbContext from the DI and runs
EnsureDefaultConfiguration on every startup, regardless of
whether the database was fresh.
Seeding failures are caught and logged (best-effort) so a
misconfigured seeder cannot prevent the host from booting.
Live data on yavsc.pschneider.fr is still missing the
ApiResource/ApiResourceScope rows; a one-shot SQL or a redeploy
with this commit is needed before PostIt can log in. Production
fix to follow.
2026-06-25 21:28:13 +01:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
context.SaveChanges();
|
|
|
|
|
|
|
|
|
|
// Link each scope to its resource. We re-query both sets after
|
|
|
|
|
// the SaveChanges above so the newly inserted resources have
|
|
|
|
|
// their generated Ids.
|
2026-06-25 23:20:43 +01:00
|
|
|
//
|
|
|
|
|
// Note: Constants.ApiResourcesScopes is a static readonly array
|
|
|
|
|
// (not IQueryable), so we have to materialise the names into a
|
|
|
|
|
// local list before letting EF try to translate the Where into
|
|
|
|
|
// SQL — otherwise EF throws "The LINQ expression … could not be
|
|
|
|
|
// translated" at runtime.
|
2026-06-25 23:55:52 +01:00
|
|
|
var wantedResourceNames = Org.Constants.ApiResourcesScopes
|
2026-06-25 23:20:43 +01:00
|
|
|
.Select(s => s.ResourceName)
|
|
|
|
|
.ToHashSet();
|
|
|
|
|
|
Seed ApiResources + ApiResourceScopes, run seeder on every startup
The previous commit (37440171) added ApiScope rows for the
application scopes (admin, moderation, performer, client, blogs).
It was a partial fix: an ApiScope alone is not a valid scope from
DefaultResourceValidator's point of view. The validator only
recognises a scope if it can find an ApiResource that exposes it
(via ApiResourceScopes). Without that link, /connect/authorize
rejects the request with 'Scope X not found in store', even
though the scope row exists. This is what killed the PostIt login
in production.
This commit:
1. Extends Constants.ApiResourcesScopes with ResourceName +
ResourceDisplayName. Topology: one ApiResource per scope
('admin' resource exposes 'admin' scope, 'blogs' resource
exposes 'blogs' scope, etc.) — keeps each scope's audience
specific if/when we split products across separate audiences.
2. Ensures EnsureDefaultApplicationScopes also inserts the
matching ApiResource rows (deduped on Name) and ApiResourceScope
rows linking each resource to its scope. Idempotent: missing
rows are added, nothing is removed.
3. Removes the b.UseSeeding(...) call inside AddConfigurationStore.
EF Core's UseSeeding callback only fires when the database is
empty, so on a live ConfigurationDb (which already had Clients
and ClientScopes) it never ran — that is why the previous commit
had no visible effect on production. The seeder is now invoked
explicitly from MigrateDatabase via SeedConfigurationDatabase,
which resolves ConfigurationDbContext from the DI and runs
EnsureDefaultConfiguration on every startup, regardless of
whether the database was fresh.
Seeding failures are caught and logged (best-effort) so a
misconfigured seeder cannot prevent the host from booting.
Live data on yavsc.pschneider.fr is still missing the
ApiResource/ApiResourceScope rows; a one-shot SQL or a redeploy
with this commit is needed before PostIt can log in. Production
fix to follow.
2026-06-25 21:28:13 +01:00
|
|
|
var resourceByName = apiResources
|
2026-06-25 23:20:43 +01:00
|
|
|
.Where(r => wantedResourceNames.Contains(r.Name))
|
Seed ApiResources + ApiResourceScopes, run seeder on every startup
The previous commit (37440171) added ApiScope rows for the
application scopes (admin, moderation, performer, client, blogs).
It was a partial fix: an ApiScope alone is not a valid scope from
DefaultResourceValidator's point of view. The validator only
recognises a scope if it can find an ApiResource that exposes it
(via ApiResourceScopes). Without that link, /connect/authorize
rejects the request with 'Scope X not found in store', even
though the scope row exists. This is what killed the PostIt login
in production.
This commit:
1. Extends Constants.ApiResourcesScopes with ResourceName +
ResourceDisplayName. Topology: one ApiResource per scope
('admin' resource exposes 'admin' scope, 'blogs' resource
exposes 'blogs' scope, etc.) — keeps each scope's audience
specific if/when we split products across separate audiences.
2. Ensures EnsureDefaultApplicationScopes also inserts the
matching ApiResource rows (deduped on Name) and ApiResourceScope
rows linking each resource to its scope. Idempotent: missing
rows are added, nothing is removed.
3. Removes the b.UseSeeding(...) call inside AddConfigurationStore.
EF Core's UseSeeding callback only fires when the database is
empty, so on a live ConfigurationDb (which already had Clients
and ClientScopes) it never ran — that is why the previous commit
had no visible effect on production. The seeder is now invoked
explicitly from MigrateDatabase via SeedConfigurationDatabase,
which resolves ConfigurationDbContext from the DI and runs
EnsureDefaultConfiguration on every startup, regardless of
whether the database was fresh.
Seeding failures are caught and logged (best-effort) so a
misconfigured seeder cannot prevent the host from booting.
Live data on yavsc.pschneider.fr is still missing the
ApiResource/ApiResourceScope rows; a one-shot SQL or a redeploy
with this commit is needed before PostIt can log in. Production
fix to follow.
2026-06-25 21:28:13 +01:00
|
|
|
.ToDictionary(r => r.Name);
|
|
|
|
|
|
2026-06-25 23:55:52 +01:00
|
|
|
foreach (var scopeSpec in Org.Constants.ApiResourcesScopes)
|
Seed ApiResources + ApiResourceScopes, run seeder on every startup
The previous commit (37440171) added ApiScope rows for the
application scopes (admin, moderation, performer, client, blogs).
It was a partial fix: an ApiScope alone is not a valid scope from
DefaultResourceValidator's point of view. The validator only
recognises a scope if it can find an ApiResource that exposes it
(via ApiResourceScopes). Without that link, /connect/authorize
rejects the request with 'Scope X not found in store', even
though the scope row exists. This is what killed the PostIt login
in production.
This commit:
1. Extends Constants.ApiResourcesScopes with ResourceName +
ResourceDisplayName. Topology: one ApiResource per scope
('admin' resource exposes 'admin' scope, 'blogs' resource
exposes 'blogs' scope, etc.) — keeps each scope's audience
specific if/when we split products across separate audiences.
2. Ensures EnsureDefaultApplicationScopes also inserts the
matching ApiResource rows (deduped on Name) and ApiResourceScope
rows linking each resource to its scope. Idempotent: missing
rows are added, nothing is removed.
3. Removes the b.UseSeeding(...) call inside AddConfigurationStore.
EF Core's UseSeeding callback only fires when the database is
empty, so on a live ConfigurationDb (which already had Clients
and ClientScopes) it never ran — that is why the previous commit
had no visible effect on production. The seeder is now invoked
explicitly from MigrateDatabase via SeedConfigurationDatabase,
which resolves ConfigurationDbContext from the DI and runs
EnsureDefaultConfiguration on every startup, regardless of
whether the database was fresh.
Seeding failures are caught and logged (best-effort) so a
misconfigured seeder cannot prevent the host from booting.
Live data on yavsc.pschneider.fr is still missing the
ApiResource/ApiResourceScope rows; a one-shot SQL or a redeploy
with this commit is needed before PostIt can log in. Production
fix to follow.
2026-06-25 21:28:13 +01:00
|
|
|
{
|
|
|
|
|
if (!resourceByName.TryGetValue(scopeSpec.ResourceName, out var resource))
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
bool alreadyLinked = apiResourceScopes.Any(link =>
|
|
|
|
|
link.ApiResourceId == resource.Id && link.Scope == scopeSpec.ScopeName);
|
|
|
|
|
|
|
|
|
|
if (alreadyLinked)
|
|
|
|
|
continue;
|
|
|
|
|
|
2026-06-25 23:55:52 +01:00
|
|
|
apiResourceScopes.Add(new ApiResourceScope
|
Seed ApiResources + ApiResourceScopes, run seeder on every startup
The previous commit (37440171) added ApiScope rows for the
application scopes (admin, moderation, performer, client, blogs).
It was a partial fix: an ApiScope alone is not a valid scope from
DefaultResourceValidator's point of view. The validator only
recognises a scope if it can find an ApiResource that exposes it
(via ApiResourceScopes). Without that link, /connect/authorize
rejects the request with 'Scope X not found in store', even
though the scope row exists. This is what killed the PostIt login
in production.
This commit:
1. Extends Constants.ApiResourcesScopes with ResourceName +
ResourceDisplayName. Topology: one ApiResource per scope
('admin' resource exposes 'admin' scope, 'blogs' resource
exposes 'blogs' scope, etc.) — keeps each scope's audience
specific if/when we split products across separate audiences.
2. Ensures EnsureDefaultApplicationScopes also inserts the
matching ApiResource rows (deduped on Name) and ApiResourceScope
rows linking each resource to its scope. Idempotent: missing
rows are added, nothing is removed.
3. Removes the b.UseSeeding(...) call inside AddConfigurationStore.
EF Core's UseSeeding callback only fires when the database is
empty, so on a live ConfigurationDb (which already had Clients
and ClientScopes) it never ran — that is why the previous commit
had no visible effect on production. The seeder is now invoked
explicitly from MigrateDatabase via SeedConfigurationDatabase,
which resolves ConfigurationDbContext from the DI and runs
EnsureDefaultConfiguration on every startup, regardless of
whether the database was fresh.
Seeding failures are caught and logged (best-effort) so a
misconfigured seeder cannot prevent the host from booting.
Live data on yavsc.pschneider.fr is still missing the
ApiResource/ApiResourceScope rows; a one-shot SQL or a redeploy
with this commit is needed before PostIt can log in. Production
fix to follow.
2026-06-25 21:28:13 +01:00
|
|
|
{
|
|
|
|
|
ApiResource = resource,
|
|
|
|
|
ApiResourceId = resource.Id,
|
|
|
|
|
Scope = scopeSpec.ScopeName,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-25 20:22:41 +01:00
|
|
|
context.SaveChanges();
|
2026-06-10 11:10:15 +01:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 17:16:07 +01:00
|
|
|
private const string PostItClientId = "postit";
|
|
|
|
|
|
|
|
|
|
private static readonly string[] PostItRedirectUris = new[]
|
|
|
|
|
{
|
|
|
|
|
// Loopback URI for desktop / browser-based PKCE flows.
|
2026-06-25 00:08:25 +01:00
|
|
|
"postit://callback",
|
2026-06-20 17:16:07 +01:00
|
|
|
"android://postit-signin",
|
2026-06-25 00:08:25 +01:00
|
|
|
"https://blogs.pschneider.fr"
|
2026-06-20 17:16:07 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private static readonly string[] PostItGrantTypes = new[]
|
|
|
|
|
{
|
|
|
|
|
"authorization_code",
|
|
|
|
|
"client_credentials",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private static readonly string[] PostItScopes = new[]
|
|
|
|
|
{
|
2026-06-25 00:08:25 +01:00
|
|
|
// 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",
|
2026-06-20 17:16:07 +01:00
|
|
|
IdentityServer8.IdentityServerConstants.StandardScopes.OpenId,
|
|
|
|
|
IdentityServer8.IdentityServerConstants.StandardScopes.Profile,
|
2026-06-25 00:08:25 +01:00
|
|
|
IdentityServer8.IdentityServerConstants.StandardScopes.OfflineAccess,
|
2026-06-20 17:16:07 +01:00
|
|
|
};
|
|
|
|
|
|
2026-06-20 17:49:53 +01:00
|
|
|
private static Action<DbContext, bool> EnsureDefaultConfiguration(
|
|
|
|
|
IConfiguration configuration
|
|
|
|
|
)
|
2026-06-10 11:10:15 +01:00
|
|
|
{
|
|
|
|
|
return (context, _) =>
|
|
|
|
|
{
|
|
|
|
|
EnsureDefaultApplicationScopes()(context, _);
|
2026-04-20 00:35:51 +01:00
|
|
|
|
2026-06-20 17:16:07 +01:00
|
|
|
var clients = context.Set<IdentityServer8.EntityFramework.Entities.Client>();
|
|
|
|
|
var existingClient = clients.FirstOrDefault(c => c.ClientId == PostItClientId);
|
|
|
|
|
|
|
|
|
|
if (existingClient is null)
|
2026-06-10 11:10:15 +01:00
|
|
|
{
|
2026-06-20 17:49:53 +01:00
|
|
|
SeedNewPostItClient(configuration, context);
|
2026-06-20 17:16:07 +01:00
|
|
|
return;
|
|
|
|
|
}
|
2026-06-10 11:10:15 +01:00
|
|
|
|
Seed ApiScopes for ApiResourcesScopes, align PostIt client
EnsureDefaultApplicationScopes was inserting every entry of
Constants.ApiResourcesScopes (admin, moderation, performer, client,
blogs) into the IdentityResources table, as Profile-derived rows.
That made them visible to /connect/discovery's scopes_supported
under the identity section, but no API resource would ever issue a
token bearing them — IdentityServer then rejected clients that
requested any of these scopes with 'invalid_scope' at the token
endpoint.
The most visible casualty was PostIt, a public PKCE client whose
postit-settings.json asks for scope=openid profile offline_access
blogs. 'blogs' is the scope that gates the Yavsc.Blogs deployment
(blogs.pschneider.fr), so the login flow died at the token step.
Fix:
- Constants.ApiResourcesScopes entries are now seeded as ApiScope
rows (with Name + DisplayName). IdentityResources stays limited
to the actual OpenID Connect profile (openid, profile).
- EnsureDefaultConfiguration gains an idempotent
AlignPostItClientScopes pass that adds any missing scope from
PostItScopes to the existing 'postit' client's AllowedScopes.
Nothing is removed — manual revocation stays manual.
Existing live databases pick up both changes on next startup:
missing ApiScope rows are inserted, and the postit client's
ClientScope rows catch up.
2026-06-25 20:46:58 +01:00
|
|
|
// The PostIt client was already seeded in a previous run.
|
|
|
|
|
// Make sure its AllowedScopes still match what the server
|
|
|
|
|
// actually exposes — for instance, "blogs" only exists as an
|
|
|
|
|
// ApiScope since we fixed the seed (see EnsureDefaultApplicationScopes
|
|
|
|
|
// above). Without this pass, a client created before the fix
|
|
|
|
|
// would still ask for a scope the server no longer recognises
|
|
|
|
|
// and IdentityServer would answer "invalid_scope" at the token
|
|
|
|
|
// endpoint. Idempotent: missing scopes are added, nothing is
|
|
|
|
|
// removed (manual revocation stays manual).
|
|
|
|
|
AlignPostItClientScopes(context, existingClient);
|
2026-06-20 17:16:07 +01:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Insert a brand new <c>postit</c> client configured as a public OIDC
|
|
|
|
|
/// client using Authorization Code + PKCE. Used the first time the
|
|
|
|
|
/// ConfigurationDb is seeded.
|
|
|
|
|
/// </summary>
|
2026-06-20 17:49:53 +01:00
|
|
|
private static void SeedNewPostItClient(IConfiguration configuration, DbContext context)
|
2026-06-20 17:16:07 +01:00
|
|
|
{
|
|
|
|
|
// PostIt is a public client (Authorization Code + PKCE).
|
|
|
|
|
// No client secret is stored or transmitted; PKCE binds the
|
|
|
|
|
// authorization code to the requesting device.
|
|
|
|
|
var client = new IdentityServer8.EntityFramework.Entities.Client
|
|
|
|
|
{
|
|
|
|
|
ClientId = PostItClientId,
|
|
|
|
|
Enabled = true,
|
|
|
|
|
RequireClientSecret = false,
|
|
|
|
|
RequirePkce = true,
|
|
|
|
|
ProtocolType = "oidc",
|
|
|
|
|
RequireConsent = false,
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-25 00:08:25 +01:00
|
|
|
context.Set<IdentityServer8.EntityFramework.Entities.Client>().Add(client);
|
2026-06-20 17:16:07 +01:00
|
|
|
|
|
|
|
|
foreach (var grantType in PostItGrantTypes)
|
|
|
|
|
{
|
|
|
|
|
context.Set<ClientGrantType>().Add(new IdentityServer8.EntityFramework.Entities.ClientGrantType
|
|
|
|
|
{
|
|
|
|
|
Client = client,
|
|
|
|
|
GrantType = grantType
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach (var scope in PostItScopes)
|
|
|
|
|
{
|
|
|
|
|
context.Set<ClientScope>().Add(new IdentityServer8.EntityFramework.Entities.ClientScope
|
|
|
|
|
{
|
|
|
|
|
Client = client,
|
|
|
|
|
Scope = scope
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 17:49:53 +01:00
|
|
|
foreach (var redirectUri in BuildPostItRedirectUris(configuration))
|
2026-06-20 17:16:07 +01:00
|
|
|
{
|
|
|
|
|
context.Set<ClientRedirectUri>().Add(new IdentityServer8.EntityFramework.Entities.ClientRedirectUri
|
|
|
|
|
{
|
|
|
|
|
Client = client,
|
|
|
|
|
RedirectUri = redirectUri
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// No ClientSecret row: PKCE-only clients don't need one.
|
|
|
|
|
context.SaveChanges();
|
|
|
|
|
}
|
|
|
|
|
|
Seed ApiScopes for ApiResourcesScopes, align PostIt client
EnsureDefaultApplicationScopes was inserting every entry of
Constants.ApiResourcesScopes (admin, moderation, performer, client,
blogs) into the IdentityResources table, as Profile-derived rows.
That made them visible to /connect/discovery's scopes_supported
under the identity section, but no API resource would ever issue a
token bearing them — IdentityServer then rejected clients that
requested any of these scopes with 'invalid_scope' at the token
endpoint.
The most visible casualty was PostIt, a public PKCE client whose
postit-settings.json asks for scope=openid profile offline_access
blogs. 'blogs' is the scope that gates the Yavsc.Blogs deployment
(blogs.pschneider.fr), so the login flow died at the token step.
Fix:
- Constants.ApiResourcesScopes entries are now seeded as ApiScope
rows (with Name + DisplayName). IdentityResources stays limited
to the actual OpenID Connect profile (openid, profile).
- EnsureDefaultConfiguration gains an idempotent
AlignPostItClientScopes pass that adds any missing scope from
PostItScopes to the existing 'postit' client's AllowedScopes.
Nothing is removed — manual revocation stays manual.
Existing live databases pick up both changes on next startup:
missing ApiScope rows are inserted, and the postit client's
ClientScope rows catch up.
2026-06-25 20:46:58 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Idempotent reconciliation of the PostIt client's AllowedScopes
|
|
|
|
|
/// against the scopes the server actually publishes. Adds any missing
|
|
|
|
|
/// scope as a ClientScope row; never removes anything (revocation is a
|
|
|
|
|
/// manual operation, not a seed concern). Called on every startup from
|
|
|
|
|
/// <see cref="EnsureDefaultConfiguration"/> so a client created before
|
|
|
|
|
/// a scope was introduced (or before the seed was corrected) catches up
|
|
|
|
|
/// automatically.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static void AlignPostItClientScopes(
|
|
|
|
|
DbContext context,
|
|
|
|
|
IdentityServer8.EntityFramework.Entities.Client postitClient
|
|
|
|
|
)
|
|
|
|
|
{
|
|
|
|
|
var clientScopes = context.Set<ClientScope>();
|
|
|
|
|
var existingScopeNames = clientScopes
|
|
|
|
|
.Where(s => s.Client == postitClient || s.ClientId == postitClient.Id)
|
|
|
|
|
.Select(s => s.Scope)
|
|
|
|
|
.ToHashSet();
|
|
|
|
|
|
|
|
|
|
bool changed = false;
|
|
|
|
|
foreach (var scope in PostItScopes)
|
|
|
|
|
{
|
|
|
|
|
if (existingScopeNames.Contains(scope))
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
clientScopes.Add(new IdentityServer8.EntityFramework.Entities.ClientScope
|
|
|
|
|
{
|
|
|
|
|
Client = postitClient,
|
|
|
|
|
Scope = scope
|
|
|
|
|
});
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (changed)
|
|
|
|
|
{
|
|
|
|
|
context.SaveChanges();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 17:49:53 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Compose the full set of redirect URIs for the PostIt client. The base
|
|
|
|
|
/// URIs cover the standalone desktop/mobile flows; the value of
|
|
|
|
|
/// <c>Site:ExternalUrl</c> is appended so PostIt can also be embedded in
|
|
|
|
|
/// a Yavsc.Org web page (e.g. an iframe-launched launcher).
|
|
|
|
|
/// </summary>
|
|
|
|
|
private static IEnumerable<string> BuildPostItRedirectUris(IConfiguration configuration)
|
|
|
|
|
{
|
|
|
|
|
foreach (var uri in PostItRedirectUris)
|
|
|
|
|
yield return uri;
|
|
|
|
|
|
|
|
|
|
var externalUrl = configuration["Site:ExternalUrl"];
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(externalUrl))
|
|
|
|
|
yield return externalUrl;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 00:35:51 +01:00
|
|
|
|
2025-02-14 00:20:35 +00:00
|
|
|
private static void ConfigureRequestLocalization(IServiceCollection services)
|
|
|
|
|
{
|
|
|
|
|
services.Configure<RequestLocalizationOptions>(options =>
|
|
|
|
|
{
|
|
|
|
|
CultureInfo[] supportedCultures = new[]
|
|
|
|
|
{
|
2026-02-17 19:24:30 +00:00
|
|
|
new CultureInfo("en"),
|
|
|
|
|
new CultureInfo("fr"),
|
|
|
|
|
new CultureInfo("pt")
|
2025-02-14 00:20:35 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
CultureInfo[] supportedUICultures = new[]
|
|
|
|
|
{
|
2026-02-17 19:24:30 +00:00
|
|
|
new CultureInfo("fr"),
|
|
|
|
|
new CultureInfo("en"),
|
|
|
|
|
new CultureInfo("pt")
|
2025-02-14 00:20:35 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// You must explicitly state which cultures your application supports.
|
|
|
|
|
// These are the cultures the app supports for formatting numbers, dates, etc.
|
|
|
|
|
options.SupportedCultures = supportedCultures;
|
|
|
|
|
|
|
|
|
|
// These are the cultures the app supports for UI strings, i.e. we have localized resources for.
|
|
|
|
|
options.SupportedUICultures = supportedUICultures;
|
|
|
|
|
|
|
|
|
|
options.RequestCultureProviders = new List<IRequestCultureProvider>
|
2026-02-17 19:24:30 +00:00
|
|
|
{
|
|
|
|
|
new QueryStringRequestCultureProvider { Options = options },
|
|
|
|
|
new CookieRequestCultureProvider { Options = options, CookieName="ASPNET_CULTURE" },
|
|
|
|
|
new AcceptLanguageHeaderRequestCultureProvider { Options = options }
|
|
|
|
|
};
|
2025-02-14 00:20:35 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 20:22:41 +01:00
|
|
|
public async static Task<WebApplication> ConfigurePipeline(this WebApplication app, string staticAssetsManifestPath = null)
|
2025-02-08 20:06:24 +00:00
|
|
|
{
|
2025-08-18 09:30:22 +01:00
|
|
|
ILoggerFactory loggerFactory = app.Services.GetRequiredService<ILoggerFactory>();
|
|
|
|
|
var logger = loggerFactory.CreateLogger<Program>();
|
2025-02-08 20:06:24 +00:00
|
|
|
|
2025-09-14 00:41:35 +01:00
|
|
|
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
|
|
|
|
|
|
2025-09-14 23:52:21 +01:00
|
|
|
JwtSecurityTokenHandler.DefaultMapInboundClaims = true;
|
2024-02-25 18:05:10 +00:00
|
|
|
if (app.Environment.IsDevelopment())
|
|
|
|
|
{
|
2026-02-07 23:47:12 +00:00
|
|
|
app.UseDeveloperExceptionPage();
|
2024-02-25 18:05:10 +00:00
|
|
|
}
|
2025-02-08 20:06:24 +00:00
|
|
|
else
|
2024-11-12 08:32:15 +00:00
|
|
|
{
|
|
|
|
|
app.UseExceptionHandler("/Home/Error");
|
2026-03-01 20:14:54 +00:00
|
|
|
app.MigrateDatabase();
|
2024-11-12 08:32:15 +00:00
|
|
|
}
|
2025-08-18 09:22:09 +01:00
|
|
|
|
|
|
|
|
app.Use(async (context, next) =>
|
|
|
|
|
{
|
|
|
|
|
if (context.Request.Path.StartsWithSegments("/robots.txt"))
|
|
|
|
|
{
|
2025-07-10 18:32:58 +01:00
|
|
|
var robotsTxtPath = System.IO.Path.Combine(app.Environment.WebRootPath, $"robots.txt");
|
|
|
|
|
string output = "User-agent: * \nDisallow: /";
|
2025-08-18 09:22:09 +01:00
|
|
|
if (File.Exists(robotsTxtPath))
|
|
|
|
|
{
|
2025-07-10 18:32:58 +01:00
|
|
|
output = await File.ReadAllTextAsync(robotsTxtPath);
|
|
|
|
|
}
|
|
|
|
|
context.Response.ContentType = "text/plain";
|
|
|
|
|
await context.Response.WriteAsync(output);
|
2025-08-18 09:22:09 +01:00
|
|
|
}
|
|
|
|
|
else await next();
|
2025-07-10 18:32:58 +01:00
|
|
|
});
|
2026-02-07 23:47:12 +00:00
|
|
|
|
2026-02-09 01:03:33 +00:00
|
|
|
app.UseSwagger();
|
2026-02-07 23:47:12 +00:00
|
|
|
app.UseSwaggerUI();
|
2024-02-25 18:05:10 +00:00
|
|
|
app.UseStaticFiles();
|
|
|
|
|
app.UseRouting();
|
|
|
|
|
app.UseIdentityServer();
|
|
|
|
|
app.UseAuthorization();
|
2025-02-12 20:41:14 +00:00
|
|
|
app.UseCors("default");
|
2025-02-16 22:40:51 +00:00
|
|
|
app.MapDefaultControllerRoute();
|
2026-06-21 21:14:20 +01:00
|
|
|
app.MapStaticAssets(staticAssetsManifestPath);
|
2025-07-07 07:49:18 +01:00
|
|
|
//app.MapRazorPages();
|
2024-03-04 01:02:19 +00:00
|
|
|
app.MapHub<ChatHub>("/chatHub");
|
2025-08-18 09:22:09 +01:00
|
|
|
|
2025-02-26 18:59:08 +00:00
|
|
|
WorkflowHelpers.ConfigureBillingService();
|
2025-08-18 09:22:09 +01:00
|
|
|
|
2024-02-25 18:05:10 +00:00
|
|
|
var services = app.Services;
|
|
|
|
|
var siteSettings = services.GetRequiredService<IOptions<SiteSettings>>();
|
|
|
|
|
var smtpSettings = services.GetRequiredService<IOptions<SmtpSettings>>();
|
|
|
|
|
var payPalSettings = services.GetRequiredService<IOptions<PayPalSettings>>();
|
|
|
|
|
var googleAuthSettings = services.GetRequiredService<IOptions<GoogleAuthSettings>>();
|
2025-08-31 18:27:53 +01:00
|
|
|
var localization = services.GetRequiredService<IStringLocalizer<Startup>>();
|
2025-02-23 20:23:23 +00:00
|
|
|
Startup.Configure(app, siteSettings, smtpSettings,
|
2024-02-25 18:05:10 +00:00
|
|
|
payPalSettings, googleAuthSettings, localization, loggerFactory,
|
2025-02-08 20:06:24 +00:00
|
|
|
app.Environment.EnvironmentName);
|
2024-02-25 18:05:10 +00:00
|
|
|
app.ConfigureFileServerApp();
|
2026-02-09 01:03:33 +00:00
|
|
|
app.UseSession();
|
2024-02-25 18:05:10 +00:00
|
|
|
return app;
|
|
|
|
|
}
|
2026-03-16 23:16:12 +00:00
|
|
|
|
2026-03-01 20:14:54 +00:00
|
|
|
private static void MigrateDatabase(this IApplicationBuilder app)
|
2025-08-24 16:07:53 +01:00
|
|
|
{
|
2026-02-07 23:47:12 +00:00
|
|
|
using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope())
|
2025-08-24 16:07:53 +01:00
|
|
|
{
|
2025-08-25 13:32:31 +01:00
|
|
|
|
2025-08-25 14:18:26 +01:00
|
|
|
try
|
2025-08-24 16:07:53 +01:00
|
|
|
{
|
2026-02-28 14:22:33 +00:00
|
|
|
foreach (Type contextType in new Type[]
|
|
|
|
|
{
|
|
|
|
|
typeof(ApplicationDbContext)
|
|
|
|
|
})
|
|
|
|
|
{
|
|
|
|
|
((DbContext)serviceScope.ServiceProvider
|
|
|
|
|
.GetRequiredService(contextType))
|
|
|
|
|
.Database.Migrate();
|
|
|
|
|
}
|
2025-09-14 23:52:21 +01:00
|
|
|
}
|
2025-08-25 14:18:26 +01:00
|
|
|
catch (InvalidOperationException ex)
|
|
|
|
|
{
|
|
|
|
|
app.Properties["DegradedDBContext"] = ex.Message;
|
2025-08-24 16:07:53 +01:00
|
|
|
}
|
|
|
|
|
}
|
Seed ApiResources + ApiResourceScopes, run seeder on every startup
The previous commit (37440171) added ApiScope rows for the
application scopes (admin, moderation, performer, client, blogs).
It was a partial fix: an ApiScope alone is not a valid scope from
DefaultResourceValidator's point of view. The validator only
recognises a scope if it can find an ApiResource that exposes it
(via ApiResourceScopes). Without that link, /connect/authorize
rejects the request with 'Scope X not found in store', even
though the scope row exists. This is what killed the PostIt login
in production.
This commit:
1. Extends Constants.ApiResourcesScopes with ResourceName +
ResourceDisplayName. Topology: one ApiResource per scope
('admin' resource exposes 'admin' scope, 'blogs' resource
exposes 'blogs' scope, etc.) — keeps each scope's audience
specific if/when we split products across separate audiences.
2. Ensures EnsureDefaultApplicationScopes also inserts the
matching ApiResource rows (deduped on Name) and ApiResourceScope
rows linking each resource to its scope. Idempotent: missing
rows are added, nothing is removed.
3. Removes the b.UseSeeding(...) call inside AddConfigurationStore.
EF Core's UseSeeding callback only fires when the database is
empty, so on a live ConfigurationDb (which already had Clients
and ClientScopes) it never ran — that is why the previous commit
had no visible effect on production. The seeder is now invoked
explicitly from MigrateDatabase via SeedConfigurationDatabase,
which resolves ConfigurationDbContext from the DI and runs
EnsureDefaultConfiguration on every startup, regardless of
whether the database was fresh.
Seeding failures are caught and logged (best-effort) so a
misconfigured seeder cannot prevent the host from booting.
Live data on yavsc.pschneider.fr is still missing the
ApiResource/ApiResourceScope rows; a one-shot SQL or a redeploy
with this commit is needed before PostIt can log in. Production
fix to follow.
2026-06-25 21:28:13 +01:00
|
|
|
|
|
|
|
|
// Run the IdentityServer configuration seeder explicitly, after
|
|
|
|
|
// migrations. EF Core's UseSeeding callback only fires when the
|
|
|
|
|
// database is empty — on a live ConfigurationDb that's been used
|
|
|
|
|
// for months, the seeder never runs and missing scopes/resources
|
|
|
|
|
// are never inserted. Calling EnsureDefaultConfiguration here makes
|
|
|
|
|
// the seed idempotent across restarts.
|
|
|
|
|
SeedConfigurationDatabase(app);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void SeedConfigurationDatabase(IApplicationBuilder app)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
using var scope = app.ApplicationServices
|
|
|
|
|
.GetRequiredService<IServiceScopeFactory>()
|
|
|
|
|
.CreateScope();
|
|
|
|
|
|
|
|
|
|
var configurationDb = scope.ServiceProvider
|
|
|
|
|
.GetRequiredService<IdentityServer8.EntityFramework.DbContexts.ConfigurationDbContext>();
|
|
|
|
|
|
|
|
|
|
var configuration = scope.ServiceProvider
|
|
|
|
|
.GetRequiredService<IConfiguration>();
|
|
|
|
|
|
|
|
|
|
EnsureDefaultConfiguration(configuration)(configurationDb, true);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
// Seeding is best-effort: a missing scope row will just leave
|
|
|
|
|
// the same login failure as before, no worse than today. Don't
|
|
|
|
|
// crash the host over it. Log so the operator sees something.
|
|
|
|
|
var logger = app.ApplicationServices
|
|
|
|
|
.GetRequiredService<ILoggerFactory>()
|
|
|
|
|
.CreateLogger("Yavsc.Org.Seeding");
|
|
|
|
|
logger.LogError(ex, "ConfigurationDb seeding failed.");
|
|
|
|
|
}
|
2025-08-24 16:07:53 +01:00
|
|
|
}
|
2024-02-25 18:05:10 +00:00
|
|
|
|
2025-02-08 20:06:24 +00:00
|
|
|
static void LoadGoogleConfig(IConfigurationRoot configuration)
|
2024-02-25 18:05:10 +00:00
|
|
|
{
|
|
|
|
|
string? googleClientFile = configuration["Authentication:Google:GoogleWebClientJson"];
|
|
|
|
|
string? googleServiceAccountJsonFile = configuration["Authentication:Google:GoogleServiceAccountJson"];
|
|
|
|
|
if (googleClientFile != null)
|
|
|
|
|
{
|
|
|
|
|
Config.GoogleWebClientConfiguration = new ConfigurationBuilder().AddJsonFile(googleClientFile).Build();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (googleServiceAccountJsonFile != null)
|
|
|
|
|
{
|
|
|
|
|
FileInfo safile = new FileInfo(googleServiceAccountJsonFile);
|
|
|
|
|
Config.GServiceAccount = JsonConvert.DeserializeObject<GoogleServiceAccount>(safile.OpenText().ReadToEnd());
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-18 09:22:09 +01:00
|
|
|
|
2025-07-10 09:16:58 +01:00
|
|
|
public static IApplicationBuilder ConfigureFileServerApp(this IApplicationBuilder app,
|
|
|
|
|
bool enableDirectoryBrowsing = false)
|
2025-07-07 07:49:18 +01:00
|
|
|
{
|
|
|
|
|
|
2025-07-10 09:16:58 +01:00
|
|
|
var userFilesDirInfo = new DirectoryInfo(Config.SiteSetup.Blog);
|
|
|
|
|
AbstractFileSystemHelpers.UserFilesDirName = userFilesDirInfo.FullName;
|
2025-07-07 07:49:18 +01:00
|
|
|
|
2025-07-10 09:16:58 +01:00
|
|
|
if (!userFilesDirInfo.Exists) userFilesDirInfo.Create();
|
2025-07-07 07:49:18 +01:00
|
|
|
|
2025-07-10 09:16:58 +01:00
|
|
|
Config.UserFilesOptions = new FileServerOptions()
|
|
|
|
|
{
|
|
|
|
|
FileProvider = new PhysicalFileProvider(AbstractFileSystemHelpers.UserFilesDirName),
|
2026-05-30 19:34:22 +01:00
|
|
|
RequestPath = PathString.FromUriComponent(YavscConstants.UserFilesPath),
|
2025-07-10 09:16:58 +01:00
|
|
|
EnableDirectoryBrowsing = enableDirectoryBrowsing,
|
|
|
|
|
};
|
|
|
|
|
Config.UserFilesOptions.EnableDefaultFiles = true;
|
|
|
|
|
Config.UserFilesOptions.StaticFileOptions.ServeUnknownFileTypes = true;
|
2025-07-07 07:49:18 +01:00
|
|
|
|
2025-07-10 09:16:58 +01:00
|
|
|
var avatarsDirInfo = new DirectoryInfo(Config.SiteSetup.Avatars);
|
|
|
|
|
if (!avatarsDirInfo.Exists) avatarsDirInfo.Create();
|
|
|
|
|
Config.AvatarsDirName = avatarsDirInfo.FullName;
|
2025-07-07 07:49:18 +01:00
|
|
|
|
2025-07-10 09:16:58 +01:00
|
|
|
Config.AvatarsOptions = new FileServerOptions()
|
|
|
|
|
{
|
|
|
|
|
FileProvider = new PhysicalFileProvider(Config.AvatarsDirName),
|
2026-05-30 19:34:22 +01:00
|
|
|
RequestPath = PathString.FromUriComponent(YavscConstants.AvatarsPath),
|
2025-07-10 09:16:58 +01:00
|
|
|
EnableDirectoryBrowsing = enableDirectoryBrowsing
|
|
|
|
|
};
|
2025-07-07 07:49:18 +01:00
|
|
|
|
|
|
|
|
|
2025-07-10 09:16:58 +01:00
|
|
|
var gitdirinfo = new DirectoryInfo(Config.SiteSetup.GitRepository);
|
|
|
|
|
Config.GitDirName = gitdirinfo.FullName;
|
|
|
|
|
if (!gitdirinfo.Exists) gitdirinfo.Create();
|
|
|
|
|
Config.GitOptions = new FileServerOptions()
|
|
|
|
|
{
|
|
|
|
|
FileProvider = new PhysicalFileProvider(Config.GitDirName),
|
2026-05-30 19:34:22 +01:00
|
|
|
RequestPath = PathString.FromUriComponent(YavscConstants.GitPath),
|
2025-07-10 09:16:58 +01:00
|
|
|
EnableDirectoryBrowsing = enableDirectoryBrowsing,
|
|
|
|
|
};
|
|
|
|
|
Config.GitOptions.DefaultFilesOptions.DefaultFileNames.Add("index.md");
|
|
|
|
|
Config.GitOptions.StaticFileOptions.ServeUnknownFileTypes = true;
|
2025-07-07 07:49:18 +01:00
|
|
|
|
2025-07-10 09:16:58 +01:00
|
|
|
app.UseFileServer(Config.UserFilesOptions);
|
2025-07-07 07:49:18 +01:00
|
|
|
|
2025-07-10 09:16:58 +01:00
|
|
|
app.UseFileServer(Config.AvatarsOptions);
|
2025-07-07 07:49:18 +01:00
|
|
|
|
2025-07-10 09:16:58 +01:00
|
|
|
app.UseFileServer(Config.GitOptions);
|
|
|
|
|
app.UseStaticFiles();
|
|
|
|
|
return app;
|
2025-07-07 07:49:18 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
}
|