6d9c79ad5a
Feature work: - Certificate (app-only) auth per profile: cert store, context/Graph client factories, automated app-registration provisioning (delegated + application permissions, admin consent), and a SessionManager seam that resolves the auth model per profile. - Scheduled reports: repositories, hosted service/runner/coordinator, report pages, and email delivery (app-only Mail.Send). - Tenant-wide user-access audit when no site is selected. Audit fixes: - Site enumeration: app-only discovery used Graph getAllSites (needs Graph Sites.Read.All the cert app lacks) and silently returned empty. Switched to the admin-host CSOM TenantSiteEnumerator, matching the scheduler; both auth models now share one enumeration path. - Group expansion: the scan records a SharePoint group as a single principal, so user-centric audits found nothing for group-granted access. Resolve group membership (shared by audit + scheduler) and attribute it to the target user. - M365 group claims: the resolver only recognized AAD security groups (c:0t.c|). Group-connected/Teams sites grant via the M365 group claim (c:0o.c|…|<guid>[_o]); now expanded too, resolving owners for the "_o" claim. - Provision Directory.Read.All as an application permission so M365/AAD group expansion works under the cert identity. Also: ignore data/appcerts/ (encrypted certificate key material). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
58 lines
2.9 KiB
C#
58 lines
2.9 KiB
C#
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using SharepointToolbox.Web.Infrastructure.Auth;
|
|
|
|
namespace SharepointToolbox.Web.Services.Auth;
|
|
|
|
/// <summary>
|
|
/// Creates a 2048-bit RSA self-signed certificate valid for two years, persists its private
|
|
/// key (PFX) through <see cref="IAppOnlyCertStore"/>, and returns the public certificate so
|
|
/// the caller can attach it to the Entra app registration as a sign-in credential.
|
|
/// </summary>
|
|
public class CertProvisioningService : ICertProvisioningService
|
|
{
|
|
private readonly IAppOnlyCertStore _certStore;
|
|
|
|
public CertProvisioningService(IAppOnlyCertStore certStore) { _certStore = certStore; }
|
|
|
|
public async Task<CertProvisioningResult> GenerateAndStoreAsync(
|
|
string profileId, string subjectName, CancellationToken ct = default)
|
|
{
|
|
// X.509 validity is stored at whole-second precision (ASN.1 has no sub-second field).
|
|
// Truncate here so the keyCredential start/endDateTime we send to Graph match the
|
|
// certificate's embedded validity exactly — otherwise the JSON endDateTime carries
|
|
// a fractional second that lands *after* the cert's NotAfter and Graph rejects it
|
|
// with KeyCredentialsInvalidEndDate.
|
|
var notBefore = TruncateToSecond(DateTimeOffset.UtcNow.AddMinutes(-5));
|
|
var notAfter = notBefore.AddYears(2);
|
|
|
|
using var rsa = RSA.Create(2048);
|
|
var req = new CertificateRequest(
|
|
$"CN={Sanitize(subjectName)}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
|
|
req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false));
|
|
|
|
using var cert = req.CreateSelfSigned(notBefore, notAfter);
|
|
|
|
// Transient password only protects the in-memory PFX handoff to the store, which
|
|
// re-exports it password-less and encrypts at rest with Data Protection.
|
|
var transientPwd = Convert.ToBase64String(RandomNumberGenerator.GetBytes(24));
|
|
var pfxBytes = cert.Export(X509ContentType.Pkcs12, transientPwd);
|
|
|
|
var thumbprint = await _certStore.SaveAsync(profileId, pfxBytes, transientPwd, ct);
|
|
|
|
var publicBase64 = Convert.ToBase64String(cert.Export(X509ContentType.Cert));
|
|
return new CertProvisioningResult(thumbprint, publicBase64, notBefore, notAfter);
|
|
}
|
|
|
|
private static DateTimeOffset TruncateToSecond(DateTimeOffset value) =>
|
|
new(value.Ticks - (value.Ticks % TimeSpan.TicksPerSecond), value.Offset);
|
|
|
|
// CN cannot contain characters that break the X.500 distinguished name.
|
|
private static string Sanitize(string name)
|
|
{
|
|
var cleaned = name.Replace(",", " ").Replace("=", " ").Replace("\"", " ").Trim();
|
|
return string.IsNullOrEmpty(cleaned) ? "SP Toolbox" : cleaned;
|
|
}
|
|
}
|