diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/Endpoints/IdentityWebHooks/GetAnIdentityEndpoints.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/Endpoints/IdentityWebHooks/GetAnIdentityEndpoints.cs index d92af633c..f795487ad 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/Endpoints/IdentityWebHooks/GetAnIdentityEndpoints.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/Endpoints/IdentityWebHooks/GetAnIdentityEndpoints.cs @@ -41,9 +41,9 @@ static GetAnIdentityEndpoints() public static IEndpointConventionBuilder MapIdentityEndpoints(this IEndpointRouteBuilder builder) { - return builder.MapGroup("/identity") + return builder .MapPost( - "", + "/webhooks/identity", async ( HttpContext context, IOptions identityOptions, diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/Endpoints/IdentityWebHooks/WebHookEndpoints.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/Endpoints/IdentityWebHooks/WebHookEndpoints.cs index c979e7b50..4bfdbd1f9 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/Endpoints/IdentityWebHooks/WebHookEndpoints.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/Endpoints/IdentityWebHooks/WebHookEndpoints.cs @@ -4,7 +4,6 @@ public static class WebHookEndpoints { public static IEndpointConventionBuilder MapWebHookEndpoints(this IEndpointRouteBuilder builder) { - return builder.MapGroup("/webhooks") - .MapIdentityEndpoints(); + return builder.MapIdentityEndpoints(); } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/Endpoints/WebhookJwks.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/Endpoints/WebhookJwks.cs new file mode 100644 index 000000000..aa0578c98 --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/Endpoints/WebhookJwks.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Options; +using TeachingRecordSystem.Core.Services.Webhooks; + +namespace TeachingRecordSystem.Api.Endpoints; + +public static class WebhookJwks +{ + public static IEndpointConventionBuilder MapWebhookJwks(this IEndpointRouteBuilder endpoints) + { + return endpoints.MapGet("/webhook-jwks", async ctx => + { + var webhookOptions = ctx.RequestServices.GetRequiredService>(); + var jsonWebKeySet = webhookOptions.Value.GetJsonWebKeySet(); + await ctx.Response.WriteAsJsonAsync(jsonWebKeySet); + }); + } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/Program.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/Program.cs index a959472b5..bca56bf5d 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/Program.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/Program.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.PowerPlatform.Dataverse.Client; using Optional; +using TeachingRecordSystem.Api.Endpoints; using TeachingRecordSystem.Api.Endpoints.IdentityWebHooks; using TeachingRecordSystem.Api.Infrastructure.ApplicationModel; using TeachingRecordSystem.Api.Infrastructure.Filters; @@ -30,6 +31,7 @@ using TeachingRecordSystem.Core.Services.GetAnIdentityApi; using TeachingRecordSystem.Core.Services.NameSynonyms; using TeachingRecordSystem.Core.Services.TrnGenerationApi; +using TeachingRecordSystem.Core.Services.Webhooks; using TeachingRecordSystem.ServiceDefaults; using TeachingRecordSystem.ServiceDefaults.Infrastructure.Logging; @@ -221,7 +223,8 @@ public static void Main(string[] args) .AddDistributedLocks() .AddIdentityApi() .AddNameSynonyms() - .AddDqtOutboxMessageSerializer(); + .AddDqtOutboxMessageSerializer() + .AddWebhookOptions(); services.AddAccessYourTeachingQualificationsOptions(configuration, env); services.AddCertificateGeneration(); @@ -274,6 +277,7 @@ public static void Main(string[] args) }); app.MapWebHookEndpoints(); + app.MapWebhookJwks(); app.MapControllers(); diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/appsettings.Development.json b/TeachingRecordSystem/src/TeachingRecordSystem.Api/appsettings.Development.json index 600ea340b..782e8b314 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/appsettings.Development.json +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/appsettings.Development.json @@ -11,5 +11,8 @@ "Username": "admin", "Password": "test" }, - "StorageConnectionString": "UseDevelopmentStorage=true" + "StorageConnectionString": "UseDevelopmentStorage=true", + "Webhooks": { + "CanonicalDomain": "https://localhost:5001" + } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/Webhooks/ApplicationBuilderExtensions.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/Webhooks/ApplicationBuilderExtensions.cs new file mode 100644 index 000000000..f9a9b9e83 --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/Webhooks/ApplicationBuilderExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace TeachingRecordSystem.Core.Services.Webhooks; + +public static class HostApplicationBuilderExtensions +{ + public static IHostApplicationBuilder AddWebhookOptions(this IHostApplicationBuilder builder) + { + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection("Webhooks")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + return builder; + } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/Webhooks/WebhookOptions.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/Webhooks/WebhookOptions.cs index 028519fc9..39beed7e8 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/Webhooks/WebhookOptions.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/Webhooks/WebhookOptions.cs @@ -1,4 +1,6 @@ using System.ComponentModel.DataAnnotations; +using System.Security.Cryptography.X509Certificates; +using Microsoft.IdentityModel.Tokens; namespace TeachingRecordSystem.Core.Services.Webhooks; @@ -12,6 +14,35 @@ public class WebhookOptions [Required] public required WebhookOptionsKey[] Keys { get; set; } + + public JsonWebKeySet GetJsonWebKeySet() + { + // FUTURE memoize this + + var keySet = new JsonWebKeySet(); + + foreach (var key in Keys) + { + using var certificate = X509Certificate2.CreateFromPem(key.CertificatePem); + var securityKey = new ECDsaSecurityKey(certificate.GetECDsaPublicKey()); + + var jsonWebKey = JsonWebKeyConverter.ConvertFromECDsaSecurityKey(securityKey); + jsonWebKey.Use = "sig"; + jsonWebKey.Alg = "ES384"; + jsonWebKey.KeyId = key.KeyId; + + var certChain = certificate.ExportCertificatePem().Split("\n") + .Skip(1) // Remove -----BEGIN CERTIFICATE----- + .SkipLast(1) // Remove -----END CERTIFICATE----- + .Aggregate((l, r) => l + r); + + jsonWebKey.X5c.Add(certChain); + + keySet.Keys.Add(jsonWebKey); + } + + return keySet; + } } public class WebhookOptionsKey diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/Webhooks/WebhookSender.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/Webhooks/WebhookSender.cs index d0303a760..04bc4d223 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/Webhooks/WebhookSender.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/Webhooks/WebhookSender.cs @@ -82,7 +82,7 @@ IOptions GetMessageSigningOptions(IServiceProvider servic .WithCreatedNow() .WithExpires(DateTimeOffset.UtcNow.AddMinutes(5)) .WithAlgorithm(SignatureAlgorithm.EcdsaP384Sha384) - .WithKeyId("test") + .WithKeyId(keyId) .WithNonce(Guid.NewGuid().ToString("N")); return Options.Create(options); diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/HostFixture.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/HostFixture.cs index 629210f8f..712d99b84 100644 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/HostFixture.cs +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/HostFixture.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using JustEat.HttpClientInterception; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc.Testing; @@ -13,6 +14,7 @@ using TeachingRecordSystem.Core.Services.GetAnIdentityApi; using TeachingRecordSystem.Core.Services.TrnGenerationApi; using TeachingRecordSystem.Core.Services.TrsDataSync; +using TeachingRecordSystem.Core.Services.Webhooks; namespace TeachingRecordSystem.Api.Tests; @@ -95,6 +97,25 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) options.WebHookClientSecret = "dummy"; }); + services.Configure(options => + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP384); + var certRequest = new CertificateRequest("CN=Teaching Record System Tests", key, HashAlgorithmName.SHA384); + using var cert = certRequest.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddDays(1)); + var certPem = cert.ExportCertificatePem(); + var keyPem = key.ExportECPrivateKeyPem(); + + options.CanonicalDomain = "http://localhost"; + options.SigningKeyId = "testkey"; + options.Keys = [ + new WebhookOptionsKey() + { + KeyId = "testkey", + CertificatePem = certPem, + PrivateKeyPem = keyPem, + }]; + }); + services.AddHttpClient("EvidenceFiles") .AddHttpMessageHandler(_ => EvidenceFilesHttpClientInterceptorOptions.CreateHttpMessageHandler()) .ConfigurePrimaryHttpMessageHandler(_ => new NotFoundHandler());