Files
myoffice_public/MyOffice.Web/Identity/OpenIddict/OpenIddictServiceCollectionExtensions.cs

202 lines
6.5 KiB
C#

namespace MyOffice.Web.Auth;
using System.Security.Cryptography;
using DbContext;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using OpenIddict.Server;
using OpenIddict.Validation.AspNetCore;
using static OpenIddict.Abstractions.OpenIddictConstants;
using static OpenIddict.Server.OpenIddictServerEvents;
public static class OpenIddictServiceCollectionExtensions
{
public static IServiceCollection AddMyOfficeOpenIddict(
this IServiceCollection services,
IConfiguration configuration,
IHostEnvironment environment
)
{
services.AddOpenIddict()
.AddCore(options =>
{
options.UseEntityFrameworkCore()
.UseDbContext<AppDbContext>();
})
.AddServer(options =>
{
options.SetAuthorizationEndpointUris("/connect/authorize")
.SetTokenEndpointUris("/connect/token")
.SetUserInfoEndpointUris("/connect/userinfo")
.SetEndSessionEndpointUris("/connect/logout");
options.AllowAuthorizationCodeFlow()
.AllowPasswordFlow()
.AllowRefreshTokenFlow()
.AllowCustomFlow(OpenIddictAuthConstants.ExternalGrantType);
options.RegisterScopes(
Scopes.OpenId,
Scopes.Email,
Scopes.Profile,
Scopes.Roles,
Scopes.OfflineAccess,
OpenIddictAuthConstants.ApiScope,
OpenIddictAuthConstants.RolesScope);
ConfigureCryptography(options, configuration, environment);
// Public URL behind nginx — discovery issuer must match SPA window.location.origin,
// not the CT LAN address Kestrel sees on the wire.
var publicHost = configuration.GetValue<string>("FrontEnd:Host")?.Trim().TrimEnd('/');
if (!string.IsNullOrWhiteSpace(publicHost)
&& Uri.TryCreate(publicHost + "/", UriKind.Absolute, out var issuerUri))
{
options.SetIssuer(issuerUri);
}
var aspNetCoreBuilder = options.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableUserInfoEndpointPassthrough()
.EnableEndSessionEndpointPassthrough();
// Local HTTP only. In production nginx terminates TLS and
// ForwardedHeaders restores https:// for OpenIddict.
// Docker demo also serves plain HTTP (OpenIddict:AllowHttp / Environment=Docker).
if (environment.IsDevelopment()
|| IsDockerEnvironment(environment)
|| configuration.GetValue("OpenIddict:AllowHttp", false))
{
aspNetCoreBuilder.DisableTransportSecurityRequirement();
}
// Password and external grants need custom handlers (separate registrations —
// a second UseScopedHandler() on the same builder replaces the first).
options.AddEventHandler<HandleTokenRequestContext>(builder =>
builder.UseScopedHandler<PasswordGrantHandler>());
options.AddEventHandler<HandleTokenRequestContext>(builder =>
builder.UseScopedHandler<ExternalGrantHandler>());
})
.AddValidation(options =>
{
options.UseLocalServer();
options.UseAspNetCore();
});
services.AddAuthentication(options =>
{
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
});
services.AddHostedService<OpenIddictSeeder>();
return services;
}
private static void ConfigureCryptography(
OpenIddictServerBuilder options,
IConfiguration configuration,
IHostEnvironment environment
)
{
// Dev: certs in the user profile. Docker/demo: ephemeral keys (no cert store in containers).
if (string.IsNullOrWhiteSpace(configuration["OpenIddict:SigningKeyPath"])
&& (environment.IsDevelopment()
|| IsDockerEnvironment(environment)
|| configuration.GetValue("OpenIddict:UseEphemeralKeys", false)))
{
if (IsDockerEnvironment(environment)
|| configuration.GetValue("OpenIddict:UseEphemeralKeys", false))
{
options.AddEphemeralEncryptionKey()
.AddEphemeralSigningKey();
}
else
{
options.AddDevelopmentEncryptionCertificate()
.AddDevelopmentSigningCertificate();
}
return;
}
var contentRoot = configuration.GetValue<string>(WebHostDefaults.ContentRootKey)
?? throw new InvalidOperationException("Content root path is not configured.");
var signingPath = ResolveKeyPath(
configuration["OpenIddict:SigningKeyPath"],
contentRoot,
"keys/signing.pem");
var encryptionPath = ResolveKeyPath(
configuration["OpenIddict:EncryptionKeyPath"],
contentRoot,
"keys/encryption.pem");
if (!File.Exists(signingPath))
{
throw new InvalidOperationException(
$"OpenIddict signing key not found at '{signingPath}'. " +
"Set OpenIddict:SigningKeyPath or place keys/signing.pem under the content root.");
}
options.AddSigningKey(LoadRsaSecurityKey(signingPath));
if (File.Exists(encryptionPath) &&
!string.Equals(encryptionPath, signingPath, StringComparison.OrdinalIgnoreCase))
{
options.AddEncryptionKey(LoadRsaSecurityKey(encryptionPath));
}
else
{
// Prefer a dedicated encryption key. Fallback keeps older single-key setups working.
options.AddEncryptionKey(LoadRsaSecurityKey(signingPath));
}
}
private static bool IsDockerEnvironment(IHostEnvironment environment) =>
string.Equals(environment.EnvironmentName, "Docker", StringComparison.OrdinalIgnoreCase);
private static string ResolveKeyPath(string? configuredPath, string contentRoot, string defaultRelative)
{
if (!string.IsNullOrWhiteSpace(configuredPath))
{
return Path.IsPathRooted(configuredPath)
? configuredPath
: Path.GetFullPath(Path.Combine(contentRoot, configuredPath));
}
return Path.GetFullPath(Path.Combine(contentRoot, defaultRelative));
}
private static RsaSecurityKey LoadRsaSecurityKey(string path)
{
var privateKey = File.ReadAllText(path)
.Replace("-----BEGIN RSA PRIVATE KEY-----", string.Empty, StringComparison.Ordinal)
.Replace("-----END RSA PRIVATE KEY-----", string.Empty, StringComparison.Ordinal)
.Replace("-----BEGIN PRIVATE KEY-----", string.Empty, StringComparison.Ordinal)
.Replace("-----END PRIVATE KEY-----", string.Empty, StringComparison.Ordinal)
.Replace("\r", string.Empty, StringComparison.Ordinal)
.Replace("\n", string.Empty, StringComparison.Ordinal);
var rsa = RSA.Create();
var keyBytes = Convert.FromBase64String(privateKey);
try
{
rsa.ImportRSAPrivateKey(keyBytes, out _);
}
catch (CryptographicException)
{
rsa.ImportPkcs8PrivateKey(keyBytes, out _);
}
return new RsaSecurityKey(rsa);
}
}