yavsc/src/Yavsc.Org/Extensions/HostingExtensions.cs
2026-07-04 15:28:07 +01:00

1072 lines
45 KiB
C#

using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Reflection;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Google.Apis.Util.Store;
using IdentityModel;
using IdentityServer8;
using IdentityServer8.EntityFramework.Entities;
using IdentityServer8.EntityFramework.Services;
using IdentityServer8.EntityFramework.Stores;
using IdentityServer8.Stores;
using IdentityServer8.Validation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection.Extensions;
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;
using Microsoft.Extensions.Localization;
using Microsoft.Net.Http.Headers;
using Newtonsoft.Json;
using Yavsc.Helpers;
using Yavsc.Interface;
using Yavsc.Interfaces;
using Yavsc.Models;
using Yavsc.Server.Helpers;
using Yavsc.Services;
using Yavsc.Services.Kyc;
using Yavsc.Settings;
using Yavsc.ViewModels.Auth;
using IdentityServer8.Models;
using IdentityServer8.EntityFramework.Mappers;
using Yavsc.Server.Hubs;
namespace Yavsc.Extensions;
public static class HostingExtensions
{
private const string InMemoryProviderName = "InMemory";
public static WebApplication ConfigureWebAppServices(this WebApplicationBuilder builder)
{
builder.Services.AddSwaggerGen();
IServiceCollection services = LoadConfiguration(builder);
services.AddSession();
// TODO .AddServerSideSessionStore<YavscServerSideSessionStore>()
// Add the system clock service
_ = services.AddSingleton<IConnexionManager, HubConnectionManager>();
_ = services.AddSingleton<ILiveProcessor, LiveProcessor>();
_ = services.AddTransient<IFileSystemAuthManager, FileSystemAuthManager>();
AddIdentityDBAndStores(builder)
.AddDefaultTokenProviders();
AddIdentityServer(builder);
services.AddSignalR(o =>
{
o.EnableDetailedErrors = true;
});
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();
services.AddTransient<ITrueEmailSender, MailSender>()
.AddTransient<Microsoft.AspNetCore.Identity.UI.Services.IEmailSender, MailSender>();
services.TryAddSingleton<ISmtpClientFactory, SmtpClientFactory>();
services.AddTransient<IYavscMessageSender, YavscMessageSender>()
.AddTransient<IBillingService, BillingService>()
.AddTransient<IDataStore, FileDataStore>((sp) => new FileDataStore("googledatastore", false))
.AddTransient<ICalendarManager, CalendarManager>()
.AddTransient<BlogSpotService>()
.AddTransient<ValidatingClientStore<ClientStore>>();
// TODO for SMS: services.AddTransient<ISmsSender, AuthMessageSender>();
_ = services.AddLocalization(options =>
{
options.ResourcesPath = "Resources";
});
var dataDirConfig = builder.Configuration["Site:DataDir"] ?? "DataDir";
var dataDir = new DirectoryInfo(dataDirConfig);
// Add session related services.
services.AddDataProtection().PersistKeysToFileSystem(dataDir);
AddYavscPolicies(services, builder.Configuration);
services.AddScoped<IAuthorizationHandler, PermissionHandler>();
services.AddTransient<IExternalIdentityManager, ExternalIdentityManager>();
services.AddAuthentication("Bearer")
.AddYavscJwtBearer(builder.Configuration,
configure: o => o.Audience = builder.Configuration.GetSection("Site")["ExternalUrl"]);
services.AddTransient<RoleManager<IdentityRole>>();
services.AddTransient<IRoleStore<IdentityRole>, RoleStore<IdentityRole, ApplicationDbContext>>();
services.Configure<KycOptions>(builder.Configuration.GetSection("Kyc"));
services.AddScoped<ITrustTokenService, TrustTokenService>();
return builder.Build();
}
public static IdentityBuilder AddIdentityDBAndStores(this WebApplicationBuilder builder)
{
IServiceCollection services = builder.Services;
var connectionString = builder.Configuration.GetConnectionString(YavscConstants.YavscConnectionStringName);
services.AddDbContext<ApplicationDbContext>(options =>
{
if (UsesInMemoryProvider(connectionString))
{
options.UseInMemoryDatabase(connectionString);
}
else
{
options.UseNpgsql(connectionString,
options => options.MigrationsAssembly(typeof(Program).Assembly));
}
});
var identityBuilder = services.AddIdentity<ApplicationUser, IdentityRole>(
options =>
{
options.SignIn.RequireConfirmedAccount = builder.Environment.IsEnvironment(
builder.Environment.EnvironmentName);
options.ClaimsIdentity.UserNameClaimType = JwtClaimTypes.PreferredUserName;
options.ClaimsIdentity.RoleClaimType = YavscConstants.RoleClaimType;
}
)
.AddEntityFrameworkStores<ApplicationDbContext>();
services.AddScoped<IUserClaimsPrincipalFactory<ApplicationUser>, UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>>();
// 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;
});
}
return identityBuilder;
}
private static void AddYavscPolicies(IServiceCollection services, IConfiguration configuration)
{
services.AddAuthorization(options =>
{
options.AddPolicy("ApiScope", policy =>
{
policy.RequireAuthenticatedUser()
.RequireClaim("scope", "scope2");
});
options.AddPolicy("Performer", policy =>
{
policy
.RequireAuthenticatedUser()
.RequireClaim(YavscConstants.RoleClaimType,
new string[] { YavscConstants.PerformerGroupName, YavscConstants.AdminGroupName })
;
});
options.AddPolicy("AdministratorOnly", policy =>
{
_ = policy
.RequireAuthenticatedUser()
.RequireClaim(YavscConstants.RoleClaimType, YavscConstants.AdminGroupName);
});
options.AddPolicy("FrontOffice", policy => policy.RequireRole(YavscConstants.FrontOfficeGroupName));
// options.AddPolicy("EmployeeId", policy => policy.RequireClaim("EmployeeId", "123", "456"));
// options.AddPolicy("BuildingEntry", policy => policy.Requirements.Add(new OfficeEntryRequirement()));
options.AddPolicy("Authenticated", policy => policy.RequireAuthenticatedUser());
options.AddPolicy("TheAuthor", policy => policy.Requirements.Add(new EditPermission()));
});
services.AddYavscCors(configuration);
}
public static IServiceCollection LoadConfiguration(this WebApplicationBuilder builder)
{
var siteSection = builder.Configuration.GetSection("Site");
var smtpSection = builder.Configuration.GetSection("Smtp");
var paypalSection = builder.Configuration.GetSection("Authentication:PayPal");
// OAuth2AppSettings
var googleAuthSettings = builder.Configuration.GetSection("Authentication:Google");
LoadGoogleConfig(builder.Configuration);
var services = builder.Services;
_ = services.AddControllersWithViews()
.AddNewtonsoftJson();
services.Configure<SiteSettings>(siteSection);
services.Configure<SmtpSettings>(smtpSection);
services.Configure<PayPalSettings>(paypalSection);
services.Configure<GoogleAuthSettings>(googleAuthSettings);
ConfigureRequestLocalization(services);
return services;
}
private static void AddAuthentication(WebApplicationBuilder builder)
{
IServiceCollection services = builder.Services;
IConfigurationRoot configurationRoot = builder.Configuration;
string? googleClientId = configurationRoot["Authentication:Google:ClientId"];
string? googleClientSecret = configurationRoot["Authentication:Google:ClientSecret"];
var authenticationBuilder = services.AddAuthentication();
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;
});
}
public static IIdentityServerBuilder AddIdentityServer(WebApplicationBuilder builder)
{
builder.Services.Configure<IdentityOptions>(options =>
{
options.ClaimsIdentity.UserIdClaimType = JwtClaimTypes.Subject;
options.ClaimsIdentity.UserNameClaimType = JwtClaimTypes.Name;
options.ClaimsIdentity.RoleClaimType = YavscConstants.RoleClaimType;
});
var migrationsAssembly = typeof(Program).GetTypeInfo().Assembly.GetName().Name;
var connectionString = builder.Configuration.GetConnectionString(YavscConstants.YavscConnectionStringName);
string sqliteConnectionString = $"Data Source={Path.Combine(Path.GetTempPath(), "yavsc_test.db")}";
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;
options.UserInteraction.LoginUrl = "/signin";
})
.AddAspNetIdentity<ApplicationUser>()
.AddClientStore<ClientStore>()
.AddClientConfigurationValidator<DefaultClientConfigurationValidator>()
.AddCorsPolicyService<CorsPolicyService>()
.AddResourceStore<ResourceStore>()
.AddConfigurationStore(options =>
{
options.ConfigureDbContext = b =>
{
if (UsesInMemoryProvider(connectionString))
{
b.UseInMemoryDatabase(connectionString);
}
else
{
b.UseNpgsql(connectionString,
sql => sql.MigrationsAssembly(migrationsAssembly));
}
// 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.
};
})
.AddOperationalStore(options =>
{
options.ConfigureDbContext = b =>
{
if (UsesInMemoryProvider(connectionString))
{
b.UseInMemoryDatabase(connectionString);
}
else
{
b.UseNpgsql(connectionString,
sql => sql.MigrationsAssembly(migrationsAssembly));
}
};
});
// Skip the production signing-cert requirement when running with
// 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)
{
identityServerBuilder.AddDeveloperSigningCredential();
}
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}.");
}
// 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);
}
// 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).
return identityServerBuilder;
}
/// <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).
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:
{
#pragma warning disable CA1416 // Valider la compatibilité de la plateforme
var rsaDotNet = DotNetUtilities.ToRSA(rsa);
#pragma warning restore CA1416 // Valider la compatibilité de la plateforme
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."),
};
}
private static bool UsesInMemoryProvider(string connectionString)
{
return string.Equals(connectionString, InMemoryProviderName, StringComparison.OrdinalIgnoreCase);
}
private static Action<DbContext, bool> EnsureDefaultApplicationScopes()
{
return (context, _) =>
{
foreach (String scope in Org.Constants.BuildInApiScopes)
{
var existentScope = context.Set<IdentityServer8.EntityFramework.Entities.ApiScope>().FirstOrDefault(b => b.Name == scope);
if (existentScope == null)
{
context.Set<IdentityServer8.EntityFramework.Entities.ApiScope>().Add(new IdentityServer8.EntityFramework.Entities.ApiScope { Name = scope });
context.SaveChanges();
}
}
var identityResources = context.Set<IdentityServer8.EntityFramework.Entities.IdentityResource>();
var apiScopes = context.Set<IdentityServer8.EntityFramework.Entities.ApiScope>();
// IdentityResources standards (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.
if (!identityResources.Any(r => r.Name == "openid"))
{
var openid = new IdentityResources.OpenId().ToEntity();
identityResources.Add(openid);
}
if (!identityResources.Any(r => r.Name == "profile"))
{
var profile = new IdentityResources.Profile().ToEntity();
identityResources.Add(profile);
}
// 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.
foreach (var scopeSpec in Org.Constants.ApiResourcesScopes)
{
if (!apiScopes.Any(s => s.Name == scopeSpec.ScopeName))
{
apiScopes.Add(new IdentityServer8.EntityFramework.Entities.ApiScope
{
Name = scopeSpec.ScopeName,
DisplayName = scopeSpec.Description,
// 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,
});
}
}
// 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.
foreach (var resourceGroup in Org.Constants.ApiResourcesScopes
.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,
// 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,
});
}
}
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.
//
// 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.
var wantedResourceNames = Org.Constants.ApiResourcesScopes
.Select(s => s.ResourceName)
.ToHashSet();
var resourceByName = apiResources
.Where(r => wantedResourceNames.Contains(r.Name))
.ToDictionary(r => r.Name);
foreach (var scopeSpec in Org.Constants.ApiResourcesScopes)
{
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;
apiResourceScopes.Add(new ApiResourceScope
{
ApiResource = resource,
ApiResourceId = resource.Id,
Scope = scopeSpec.ScopeName,
});
}
context.SaveChanges();
};
}
private const string PostItClientId = "postit";
private static readonly string[] PostItRedirectUris = new[]
{
// Loopback URI for desktop / browser-based PKCE flows.
"postit://callback",
"android://postit-signin",
"https://blogs.pschneider.fr"
};
private static readonly string[] PostItGrantTypes = new[]
{
"authorization_code",
"client_credentials",
};
private static readonly string[] PostItScopes = new[]
{
// Scopes the PostIt client is allowed to ask for. Must match
// what postit-settings.json (and Constants.BuildInApiScopes on
// the server) actually defines. Notably:
// - "blogs" (plural) is the API scope that gates access to the
// Yavsc.Blogs deployment at https://blogs.pschneider.fr.
// - "offline_access" is required for the YavscApiClient's
// silent refresh path to work; without it IdentityServer
// refuses to issue a refresh_token.
"blogs",
IdentityServer8.IdentityServerConstants.StandardScopes.OpenId,
IdentityServer8.IdentityServerConstants.StandardScopes.Profile,
IdentityServer8.IdentityServerConstants.StandardScopes.OfflineAccess,
};
private static Action<DbContext, bool> EnsureDefaultConfiguration(
IConfiguration configuration
)
{
return (context, _) =>
{
EnsureDefaultApplicationScopes()(context, _);
var clients = context.Set<IdentityServer8.EntityFramework.Entities.Client>();
var existingClient = clients.FirstOrDefault(c => c.ClientId == PostItClientId);
if (existingClient is null)
{
SeedNewPostItClient(configuration, context);
return;
}
// 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);
};
}
/// <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>
private static void SeedNewPostItClient(IConfiguration configuration, DbContext context)
{
// 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,
};
context.Set<IdentityServer8.EntityFramework.Entities.Client>().Add(client);
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
});
}
foreach (var redirectUri in BuildPostItRedirectUris(configuration))
{
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();
}
/// <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();
}
}
/// <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;
}
private static void ConfigureRequestLocalization(IServiceCollection services)
{
services.Configure<RequestLocalizationOptions>(options =>
{
CultureInfo[] supportedCultures = new[]
{
new CultureInfo("en"),
new CultureInfo("fr"),
new CultureInfo("pt")
};
CultureInfo[] supportedUICultures = new[]
{
new CultureInfo("fr"),
new CultureInfo("en"),
new CultureInfo("pt")
};
// 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>
{
new QueryStringRequestCultureProvider { Options = options },
new CookieRequestCultureProvider { Options = options, CookieName="ASPNET_CULTURE" },
new AcceptLanguageHeaderRequestCultureProvider { Options = options }
};
});
}
public async static Task<WebApplication> ConfigurePipeline(this WebApplication app, string staticAssetsManifestPath = null)
{
ILoggerFactory loggerFactory = app.Services.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger<Program>();
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
JwtSecurityTokenHandler.DefaultMapInboundClaims = true;
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
app.MigrateDatabase();
}
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/robots.txt"))
{
var robotsTxtPath = System.IO.Path.Combine(app.Environment.WebRootPath, $"robots.txt");
string output = "User-agent: * \nDisallow: /";
if (File.Exists(robotsTxtPath))
{
output = await File.ReadAllTextAsync(robotsTxtPath);
}
context.Response.ContentType = "text/plain";
await context.Response.WriteAsync(output);
}
else await next();
});
app.UseSwagger();
app.UseSwaggerUI();
app.UseStaticFiles();
app.UseRouting();
app.UseIdentityServer();
app.UseAuthorization();
app.UseCors("default");
app.MapDefaultControllerRoute();
app.MapStaticAssets(staticAssetsManifestPath);
//app.MapRazorPages();
app.MapHub<ChatHub>("/chatHub");
WorkflowHelpers.ConfigureBillingService();
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>>();
var localization = services.GetRequiredService<IStringLocalizer<Startup>>();
Startup.Configure(app, siteSettings, smtpSettings,
payPalSettings, googleAuthSettings, localization, loggerFactory,
app.Environment.EnvironmentName);
app.ConfigureFileServerApp();
app.UseSession();
return app;
}
private static void MigrateDatabase(this IApplicationBuilder app)
{
using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope())
{
try
{
foreach (Type contextType in new Type[]
{
typeof(ApplicationDbContext)
})
{
((DbContext)serviceScope.ServiceProvider
.GetRequiredService(contextType))
.Database.Migrate();
}
}
catch (InvalidOperationException ex)
{
app.Properties["DegradedDBContext"] = ex.Message;
}
}
// 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.");
}
}
static void LoadGoogleConfig(IConfigurationRoot configuration)
{
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());
}
}
public static IApplicationBuilder ConfigureFileServerApp(this IApplicationBuilder app,
bool enableDirectoryBrowsing = false)
{
var userFilesDirInfo = new DirectoryInfo(Config.SiteSetup.Blog);
AbstractFileSystemHelpers.UserFilesDirName = userFilesDirInfo.FullName;
if (!userFilesDirInfo.Exists) userFilesDirInfo.Create();
Config.UserFilesOptions = new FileServerOptions()
{
FileProvider = new PhysicalFileProvider(AbstractFileSystemHelpers.UserFilesDirName),
RequestPath = PathString.FromUriComponent(YavscConstants.UserFilesPath),
EnableDirectoryBrowsing = enableDirectoryBrowsing,
};
Config.UserFilesOptions.EnableDefaultFiles = true;
Config.UserFilesOptions.StaticFileOptions.ServeUnknownFileTypes = true;
var avatarsDirInfo = new DirectoryInfo(Config.SiteSetup.Avatars);
if (!avatarsDirInfo.Exists) avatarsDirInfo.Create();
Config.AvatarsDirName = avatarsDirInfo.FullName;
Config.AvatarsOptions = new FileServerOptions()
{
FileProvider = new PhysicalFileProvider(Config.AvatarsDirName),
RequestPath = PathString.FromUriComponent(YavscConstants.AvatarsPath),
EnableDirectoryBrowsing = enableDirectoryBrowsing
};
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),
RequestPath = PathString.FromUriComponent(YavscConstants.GitPath),
EnableDirectoryBrowsing = enableDirectoryBrowsing,
};
Config.GitOptions.DefaultFilesOptions.DefaultFileNames.Add("index.md");
Config.GitOptions.StaticFileOptions.ServeUnknownFileTypes = true;
app.UseFileServer(Config.UserFilesOptions);
app.UseFileServer(Config.AvatarsOptions);
app.UseFileServer(Config.GitOptions);
app.UseStaticFiles();
return app;
}
}