From 76fcba801b68eda487397bda21aa5af2bc691643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Chalet?= Date: Fri, 13 Mar 2026 13:29:53 +0100 Subject: [PATCH] Bump OpenIddict to 7.4.0 and update all the samples to use mTLS or client assertions --- Directory.Packages.props | 28 ++-- README.md | 6 +- samples/Aridka/Aridka.Client/Program.cs | 135 +++++++++++++++++- samples/Aridka/Aridka.Server/Program.cs | 118 ++++++++++++++- .../Properties/launchSettings.json | 4 +- samples/Balosar/Balosar.Server/Program.cs | 3 +- samples/Contruum/Contruum.Server/Program.cs | 2 - samples/Dantooine/Dantooine.Api/Program.cs | 23 ++- .../Properties/launchSettings.json | 4 +- samples/Dantooine/Dantooine.Server/Program.cs | 47 +++++- .../Properties/launchSettings.json | 4 +- .../Dantooine.WebAssembly.Server/Program.cs | 31 +++- .../Properties/launchSettings.json | 2 +- samples/Geonosis/Geonosis.Auth/Program.cs | 71 ++++----- .../Geonosis.ServiceDefaults/Extensions.cs | 1 - .../Geonosis.Ui/Geonosis.Ui/Program.cs | 26 +++- .../Weather/ServerWeatherForecaster.cs | 5 +- samples/Hollastin/Hollastin.Client/Program.cs | 73 +++++++++- samples/Hollastin/Hollastin.Server/Program.cs | 73 +++++++++- .../Properties/launchSettings.json | 4 +- samples/Imynusoph/Imynusoph.Client/Program.cs | 9 +- samples/Imynusoph/Imynusoph.Server/Program.cs | 3 +- .../Properties/launchSettings.json | 4 +- samples/Kalarba/Kalarba.Client/Program.cs | 17 ++- samples/Matty/Matty.Server/Program.cs | 1 - .../Properties/launchSettings.json | 4 +- .../Mimban.Client/InteractiveService.cs | 12 +- samples/Mimban/Mimban.Client/Program.cs | 5 + samples/Mimban/Mimban.Server/Program.cs | 6 +- .../Properties/launchSettings.json | 4 +- .../Controllers/HomeController.cs | 12 +- samples/Mortis/Mortis.Client/Startup.cs | 15 +- .../Properties/launchSettings.json | 2 +- samples/Mortis/Mortis.Server/Startup.cs | 2 +- .../Controllers/HomeController.cs | 11 +- samples/Velusia/Velusia.Client/Program.cs | 32 ++++- samples/Velusia/Velusia.Server/Program.cs | 29 +++- .../Properties/launchSettings.json | 4 +- samples/Weytta/Weytta.Client/Program.cs | 6 +- .../Properties/launchSettings.json | 4 +- samples/Zirku/Zirku.Api1/Program.cs | 75 +++++++++- .../Zirku.Api1/Properties/launchSettings.json | 4 +- samples/Zirku/Zirku.Api2/Program.cs | 54 ++++++- .../Zirku.Api2/Properties/launchSettings.json | 4 +- .../Zirku/Zirku.Client1/InteractiveService.cs | 98 ++++++++++--- samples/Zirku/Zirku.Server/Program.cs | 120 ++++++++++++++-- .../Properties/launchSettings.json | 4 +- 47 files changed, 1012 insertions(+), 189 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index e900aa8b..96a6fa6c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -44,11 +44,11 @@ - - - - - + + + + + @@ -89,15 +89,15 @@ - - - - - - - - - + + + + + + + + + diff --git a/README.md b/README.md index ce40d08c..70b765ce 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,18 @@ This repository contains samples demonstrating **how to use [OpenIddict](https:/ ## ASP.NET Core samples - - [Aridka](samples/Aridka): client credentials demo, with a .NET console acting as the client. + - [Aridka](samples/Aridka): client credentials demo using mTLS client authentication, with a .NET console acting as the client. - [Balosar](samples/Balosar): authorization code flow demo, with a Blazor WASM application acting as the client. - [Contruum](samples/Contruum): conformance tests project using Razor Pages and 2 hardcoded user identities, meant to be used with [the OIDC certification suite](https://www.certification.openid.net/). - [Dantooine](samples/Dantooine): backend-for-frontend (BFF) Blazor WASM application hosted in ASP.NET Core with Microsoft YARP for downstream API. - [Geonosis](sample/Geonosis): Blazor Web with InteractiveAuto mode, backend-for-frontend (BFF) with YARP and token exchange flow to call a downstream API. - - [Hollastin](samples/Hollastin): resource owner password credentials demo, with a .NET console acting as the client. + - [Hollastin](samples/Hollastin): resource owner password credentials demo, with a .NET console using mTLS token binding acting as the client. - [Imynusoph](samples/Imynusoph): refresh token grant demo, with a .NET console acting as the client. - [Matty](samples/Matty): device authorization flow demo, with a .NET console acting as the client. - [Mimban](samples/Mimban): authorization code flow demo using minimal APIs and GitHub delegation for user authentication, with a .NET console acting as the client. - [Velusia](samples/Velusia): authorization code flow demo, with an ASP.NET Core application acting as the client. - [Weytta](samples/Weytta): authorization code flow with Integrated Windows Authentication support and a .NET console acting as the client. - - [Zirku](samples/Zirku): authorization code flow demo using minimal APIs with 2 hard-coded user identities, a .NET console and a SPA acting as the clients and two API projects using introspection (Api1) and local validation (Api2). + - [Zirku](samples/Zirku): authorization code flow demo using minimal APIs with 2 hard-coded user identities, a .NET console using mTLS token binding and a SPA acting as the clients and two API projects using introspection (Api1) and local validation (Api2). ## .NET samples diff --git a/samples/Aridka/Aridka.Client/Program.cs b/samples/Aridka/Aridka.Client/Program.cs index 9f09a15c..b0c54797 100644 --- a/samples/Aridka/Aridka.Client/Program.cs +++ b/samples/Aridka/Aridka.Client/Program.cs @@ -1,5 +1,7 @@ using System.Net.Http.Headers; +using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; using OpenIddict.Client; var services = new ServiceCollection(); @@ -28,10 +30,27 @@ Issuer = new Uri("https://localhost:44385/", UriKind.Absolute), ClientId = "console", - ClientSecret = "388D45FA-B36B-4988-BA59-B187D329C207" + + // Note: instead of sending a client secret, this application authenticates by using + // a self-signed client authentication certificate during the TLS handshake. + SigningCredentials = { GetSelfSignedCertificate() } }); }); +// Register a named HTTP client that will be used to call the demo resource API. +// +// Note: since the authorization server is configured to issue certificate-bound +// access tokens, the client certificate MUST be attached to outgoing HTTP requests +// and the mTLS subdomain (for which TLS client authentication is enabled) MUST be used. +services.AddHttpClient("ApiClient") + .AddAsKeyed() + .ConfigureHttpClient(static client => client.BaseAddress = new Uri("https://mtls.dev.localhost:44385/")) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + ClientCertificateOptions = ClientCertificateOption.Manual, + ClientCertificates = { GetSelfSignedCertificate().Certificate } + }); + await using var provider = services.BuildServiceProvider(); var token = await GetTokenAsync(provider); @@ -52,8 +71,8 @@ static async Task GetTokenAsync(IServiceProvider provider) static async Task GetResourceAsync(IServiceProvider provider, string token) { - using var client = provider.GetRequiredService(); - using var request = new HttpRequestMessage(HttpMethod.Get, "https://localhost:44385/api/message"); + var client = provider.GetRequiredKeyedService("ApiClient"); + using var request = new HttpRequestMessage(HttpMethod.Get, "api/message"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); using var response = await client.SendAsync(request); @@ -61,3 +80,113 @@ static async Task GetResourceAsync(IServiceProvider provider, string tok return await response.Content.ReadAsStringAsync(); } + +static X509SigningCredentials GetSelfSignedCertificate() +{ + // Note: OpenIddict only negotiates PKI-based or self-signed mutual + // TLS authentication if the certificate explicitly contains the + // "digitalSignature" key usage and the "clientAuth" extended key usage. + var certificate = X509Certificate2.CreateFromPem( + certPem: $""" + -----BEGIN CERTIFICATE----- + MIIE8zCCAtugAwIBAgIJAI/egicFvVmsMA0GCSqGSIb3DQEBCwUAMCIxIDAeBgNV + BAMTF1NlbGYtc2lnbmVkIGNlcnRpZmljYXRlMCAXDTI2MDMxMTE0MDkwMloYDzIx + MjYwMzExMTQwOTAyWjAiMSAwHgYDVQQDExdTZWxmLXNpZ25lZCBjZXJ0aWZpY2F0 + ZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAL6lxgz4nzb9B/ajfjy+ + DKYJR3isyZ3kJg8f5BvKYFKptQTZWk4TJhChYzNrePBq7I/AW20z4Zt4TZvnAC29 + z54EZ/9auxXswHN1tNujc1OPtADRlJmWYf/fI+y4cwoPegaPdSS+WRgeigz6BmJu + Oq5J6/IM/eRBqByaZgYU8Acvjxec9fzoKooK94ZoGeoM6q2GVipHzKLgPp4AsqA/ + aY9Uz/sb6mjp/wQX4KcIFZLChGcYetuTkPuh6OcnCwApH67NvZB4HwxXDEMKx89V + JkUh8iOt+1Q3s76yfRl3JGcthrrhzojE/3teQpU3F/aKfou4Hagys3WXQv9q8V6m + s0rXEBF2WuXTF5kCdBhvCnFClrokm20ev8T4aSsI8FDXanMhTamHsFYE5eIgZXZy + q4fYcwnfHENGwvxp3w1tsdSqdtRXBUoJQYNbGyVsZeEADFlLERoqe6FRlnvZVtkZ + tThlYKUwWitOdivVuI5h+64HtJbhBvvdGPDVl3WQVDmQ4Z17HpyPCL6uILNlSxIu + Df+CdOZv3/iElSWUkR3WjO0qohv5TEraCwvhXlCyIK37WyzAXr7XVmZUrpmqU2aa + U4tVmt3o5zDorb7MCit9Mn731nfpjC6wSnHD90JGi7fSHUP7+GH0PfWNDSvjRtrY + bBThQA55vqVzcqHe5M6uCCwVAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNV + HSUBAf8EDDAKBggrBgEFBQcDAjANBgkqhkiG9w0BAQsFAAOCAgEAvCjsrSZQ1iKM + B42rDuE/IBbw5BUn12RzX608uG7CnTgiywwKSokspAi+3N7HEH4+8T+urQDQxCv8 + aFZ4SpkDM8xXCh0zF9WIRK3kQYF+crNfrsCJaduCQjCozh1NyZe3oFTXxpHuKh7V + ellexvahLJid9a1bVADAIx5cKLEFhkSVh15hcWlphKMkVsA+cI0D22gbMwO2TSkL + +X2C0YQYd0yxrDjZKU2Y5P8vunDCMPS04UsexERRuUCRzFBp7+mt2c1rT0gxta9w + NeuW7ooUIAeMGNUY2FCrUI4OwMlre6knYZST+sfKyLY6r95PtHgXQB5pZ9G6iHu6 + FGdqbvdqcqr79l989z6sOo7p4CzX3dxp3rAuBzgY023bTnNZAnEEYSNYd5AJePD2 + 1ycEXKEh1+GGkF0t5HX3FVe7VC/AEqCpNwaHzW0KQ6wunuqAJNvGa4gpZVqWGw7f + dBhkg+W5itWbAn3giXQQD8yi/0CJzBSj/GFVPWCax3n3dV404DTAqINq1Koix/1i + oOY+/PQEwlk+QZrtvBpPaDIjX7wVMnu7lF6q/d5gws3kHVPW90+8Nk/pXTeXU6mo + hO6dOCwfN1IrRSn1pQ35UsSKPE9/g5gN77hi0v9AK9jtPfLLJQGYvBAjlU7Y3p7R + Sq+yMXEPCIhq0DMdISeTf1Ajy7rYJcI= + -----END CERTIFICATE----- + """, + keyPem: $""" + -----BEGIN RSA PRIVATE KEY----- + MIIJKAIBAAKCAgEAvqXGDPifNv0H9qN+PL4MpglHeKzJneQmDx/kG8pgUqm1BNla + ThMmEKFjM2t48Grsj8BbbTPhm3hNm+cALb3PngRn/1q7FezAc3W026NzU4+0ANGU + mZZh/98j7LhzCg96Bo91JL5ZGB6KDPoGYm46rknr8gz95EGoHJpmBhTwBy+PF5z1 + /Ogqigr3hmgZ6gzqrYZWKkfMouA+ngCyoD9pj1TP+xvqaOn/BBfgpwgVksKEZxh6 + 25OQ+6Ho5ycLACkfrs29kHgfDFcMQwrHz1UmRSHyI637VDezvrJ9GXckZy2GuuHO + iMT/e15ClTcX9op+i7gdqDKzdZdC/2rxXqazStcQEXZa5dMXmQJ0GG8KcUKWuiSb + bR6/xPhpKwjwUNdqcyFNqYewVgTl4iBldnKrh9hzCd8cQ0bC/GnfDW2x1Kp21FcF + SglBg1sbJWxl4QAMWUsRGip7oVGWe9lW2Rm1OGVgpTBaK052K9W4jmH7rge0luEG + +90Y8NWXdZBUOZDhnXsenI8Ivq4gs2VLEi4N/4J05m/f+ISVJZSRHdaM7SqiG/lM + StoLC+FeULIgrftbLMBevtdWZlSumapTZppTi1Wa3ejnMOitvswKK30yfvfWd+mM + LrBKccP3QkaLt9IdQ/v4YfQ99Y0NK+NG2thsFOFADnm+pXNyod7kzq4ILBUCAwEA + AQKCAgBgLvKEiMqKy43A+SsvKhLnkbblQwdVCU3KQ6SqAKgoDEavc5kD2tVRfpq1 + znrtkIRY4gs+RPaFoWRGS3zjluewKTjus6+/l/pgRfpA9W2xssZ1w0bdVemLVeCi + BUzEvpopxSasqvv4FzA+68Vc04/3boQDUlqlVhqik6L1XoralTv0BdR1DAyqKG5I + +SxZ0Lp1YVkHa8HqSohM3r0/674t+fQUFDlnRObMAd/tZT69FDYIbWlOblyvFziR + pjj+k8DQSCxjPrcrWp9tE3tLNwJfzoiDR7uM+a1NgG9s8ZcEFwvqLRIuHnVmoF+n + OGx2jdjaVMFhonK32OCMTEAKKMA7GqspNnHErKvxTsO4bb/cj0WG+6nuv0fN0IGc + qK5LoO3+AH6pUttpQj/xPR5Xvpv54fme2AD8r2xqPGyiKjxAp8O1p3eDLsau1Dtd + 3PFuY/jiEGkRu+7HbmFnYDST9+ZZZ+du0/80M32jbN8+uDhQq7XXTph4GDaCA6tq + v4GWF9JGKXiPiy0B5HzOrBZbncgutbqOKmX+5Cy/5vL6OhwT2el6KnFH8HNd7RSG + 0n+6cnaT0yIpmCnWZPeszZENMNvCWLoPS2qck9+KGNUEG6jnwDvqeWBAuqdTQsZD + L9TQCXt5n3sdc2YkkfIG25dLd8riSwxjQdjrFAdTQxJMWngu4QKCAQEA0rSCsj4N + 4qIoPdcg+li/svoBYZMrJTT7CDgkk8gFKa8ZO9iwPDa5PHFfuq9N1pxNyLXfoZKe + /Ev9TnRqy/m7vbYG1TrDLrZqmpNHTvcX9PoL4Mq+BM9fXDhKqU7slg1QBcq4qR5x + QiTK7EtC65r12luTVUOcwbLRzjgqCdK4tedsYcPpeulj39wDzTQm5R1Gk0wrq/fc + 1pL5A2p6rv/VQojfjq/KmJsD+5J2lB6xhJBae2Bp1nSlRCaYC0S+cXvpwVhYy8Yw + SNtCLVM/sGOgV+/zWeX9zSOCKrlDZSiEAhCKoQamkuJXF+C8+CbNCOBzdpS3/7U+ + 8F9rk5W/5pfqiwKCAQEA56F1W6hu5ntHTxK4wryjoY8L4HJnr+f4RG9b+s1VaMeP + y9bjO3Uuy3scKAWnDcDeSBPgFcXve8LMfscfkw0uYxZsq2AH07cnqDuqJP61jVaD + mmDal0SLEfpciJNB9RznNcTyUviFEXHYAQFtuDpfE1gfkhU0fJgm4N164yKhDbQn + GP4XNRn9FAX6SZJuQcmqRT8fMcHByYs3gHd15gf/w/Iuksi1Q2Tk5dbY4s3LRC3Q + xQo//NRDJhn8gGnP2T3O4oS7813EHsSwlc1OKAHxsYZtWrscTPItxUUicvKYeqsk + zJGADYZL1BPGVb00SGdw7uwkAkYAhQndVD9Ecc033wKCAQBplr/wJpy6t9xGsSn7 + isH2JMbQaPm0GYq7Ibdiv1em/fI9RWd7pUjKe14npXXyWD26mTnKNDmr4UC9MiXa + tflZJoDiFiJ9pDhj4e5YKgc9YpjVO4Rh0LHO+v6fPcfdoio53M8RIQpMxTdTlpug + ifUuSbnZfpptjvkIyKh4Z7rcnW54x76XM6IzKoRVLw9WvYcChadU9E8c0GYtSgzU + 6aurPgAZ9wol03j5dvopXABFmDlfnn8rUyUGs/h5nSd6o0gO9gD5jQXhXM8a+57s + +9/8cWiX4mN/i43Nby3Q4a7VggiWjUioTviqJJtOF9Oj4Sa7g+d5IxC5UHgOa3rR + ScvlAoIBAQDeKYQwd2p28cLBWsmPLfMb3+GaUuCUXT9IFC76bLsAlnebIO4tdwV8 + 8QVedZ12mYgZRcbl20UJRRtydXYZSsk1DKsJ7D9VlxQYTbGxbgOgHlx3U3IVKA7j + HWhnLiZS/HfeoJlzbx3iT3jH7iDYVFQgb6NIL8J5xk1z27oj5HDofeQKGpsTuWt9 + KwaWTjYmL1B6vkIjLR27OyXut6WDDiUIQV7eNld03m6U6+52CsBtEixs8JnS25vU + DZSbbeGHEbs+k+TZVRPoFurvo0zVHpg8lxyHq3NHcfjofpi9+2S4MzJGaz+QuUA9 + lwHh9mkREPXGkwMukwmokH+ScGQrapOtAoIBADV+5ktQZIyPxX1SywAgsOQpNF6J + ytbTRwVMcS9L2QrvH1wqLZs9ATYRHNTvbALdKHTKOi2ohaKufnG58Qss2lvB/UvI + lJmSkLp9aJKP+6N5/YvjTOhGdRS2jo5jIZO1a27m34c965ivmCtF9aIYtzpni96z + T8tIl9JodIjD6gXAzqpx1XRTnLes6DAmMh3J2ZztzDGSuJqhZgtFeyg4AR7sgCiP + 3Bbt+6G1U6jzJ2t85NIgHzWMnmrykhxU58nW2NFgCv8AdfEpO8gtPEQ832Mu3eKY + /P3CcMl2yCoD786/YdSnsLnO+hD50Crc98YK19M1edCsJfTaL8RZzU63AGM= + -----END RSA PRIVATE KEY----- + """); + + // On Windows, a certificate loaded from PEM-encoded material is ephemeral and + // cannot be directly used with TLS, as Schannel cannot access it in this case. + // + // To work this limitation, the certificate is exported and re-imported from a + // PFX blob to ensure the private key is persisted in a way that Schannel can use. + // + // In a real world application, the certificate wouldn't be embedded in the source code + // and would be installed in the certificate store, making this workaround unnecessary. + if (OperatingSystem.IsWindows()) + { + certificate = X509CertificateLoader.LoadPkcs12( + data: certificate.Export(X509ContentType.Pfx, string.Empty), + password: string.Empty, + keyStorageFlags: X509KeyStorageFlags.DefaultKeySet); + } + + return new X509SigningCredentials(certificate); +} diff --git a/samples/Aridka/Aridka.Server/Program.cs b/samples/Aridka/Aridka.Server/Program.cs index 4164ef4d..1c331507 100644 --- a/samples/Aridka/Aridka.Server/Program.cs +++ b/samples/Aridka/Aridka.Server/Program.cs @@ -1,5 +1,10 @@ -using Aridka.Server.Models; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Aridka.Server.Models; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; using OpenIddict.Abstractions; using Quartz; using static OpenIddict.Abstractions.OpenIddictConstants; @@ -57,6 +62,26 @@ options.AddDevelopmentEncryptionCertificate() .AddDevelopmentSigningCertificate(); + // Enable self_signed_tls_client_auth to allow clients to authenticate using self-signed certificates. + options.EnableSelfSignedTlsClientAuthentication(); + + // Note: setting a static issuer is mandatory when using mTLS aliases to ensure it not + // dynamically computed based on the request URI, as this would result in two different + // issuers being used (one pointing to the mTLS domain and one pointing to the regular one). + options.SetIssuer("https://localhost:44385/"); + + // Configure the mTLS endpoint aliases that will be used by client applications opting + // for TLS-based client authentication to communicate with the authorization server: + // the configured URIs MUST point to a domain for which the HTTPS server is configured + // to require the use of client certificates when receiving TLS handshakes from clients. + options.SetMtlsTokenEndpointAliasUri("https://mtls.dev.localhost:44385/connect/token"); + + // Optionally, the server stack can be configured to issue client certificate-bound access tokens. + // + // When doing so, the standard "cnf" claim is automatically added to access tokens to inform + // resource servers that a proof of possession derived from the certificate must be provided. + options.UseClientCertificateBoundAccessTokens(); + // Register the ASP.NET Core host and configure the ASP.NET Core-specific options. options.UseAspNetCore() .EnableTokenEndpointPassthrough(); @@ -72,6 +97,54 @@ options.UseAspNetCore(); }); +// Configure Kestrel to listen on the 44385 port and configure it to enforce mTLS. +// +// Note: depending on the operating system, the mtls.dev.localhost +// subdomain MAY have to be manually mapped to 127.0.0.1 or ::1. +builder.Services.Configure(options => options.ListenAnyIP(44385, options => +{ + options.UseHttps(new TlsHandshakeCallbackOptions + { + OnConnection = static context => + { + using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadOnly); + + return ValueTask.FromResult(new SslServerAuthenticationOptions + { + // Require a client certificate for all the requests pointing to the mTLS subdomain. + ClientCertificateRequired = string.Equals(context.ClientHelloInfo.ServerName, + "mtls.dev.localhost", StringComparison.OrdinalIgnoreCase), + + // Ignore all the client certificate errors for requests pointing to + // the mTLS-specific domain, even if they indicate that the chain is + // invalid: this is necessary to allow OpenIddict to validate the PKI + // and self-signed certificates using its own per-client chain policies. + RemoteCertificateValidationCallback = (sender, certificate, chain, errors) => + { + if (string.Equals(context.ClientHelloInfo.ServerName, + "mtls.dev.localhost", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return errors is SslPolicyErrors.None or SslPolicyErrors.RemoteCertificateNotAvailable; + }, + + // Use the development certificate generated and stored by ASP.NET Core in the user store. + ServerCertificate = store.Certificates + .Find(X509FindType.FindByExtension, "1.3.6.1.4.1.311.84.1.1", validOnly: false) + .Cast() + .Where(static certificate => certificate.NotBefore < TimeProvider.System.GetLocalNow()) + .Where(static certificate => certificate.NotAfter > TimeProvider.System.GetLocalNow()) + .OrderByDescending(static certificate => certificate.NotAfter) + .FirstOrDefault() ?? + throw new InvalidOperationException("The ASP.NET Core HTTPS development certificate was not found.") + }); + } + }); +})); + var app = builder.Build(); app.UseDeveloperExceptionPage(); @@ -101,8 +174,49 @@ await manager.CreateAsync(new OpenIddictApplicationDescriptor { ClientId = "console", - ClientSecret = "388D45FA-B36B-4988-BA59-B187D329C207", DisplayName = "My client application", + JsonWebKeySet = new JsonWebKeySet + { + Keys = + { + // This application authenticates by using a self-signed client authentication + // certificate during the TLS handshake. While the client needs access to the + // private key, the server only needs to know the public part - included by the + // ExportCertificatePem() API - to be able to validate the certificates it receives. + JsonWebKeyConverter.ConvertFromX509SecurityKey(new X509SecurityKey( + X509Certificate2.CreateFromPem($""" + -----BEGIN CERTIFICATE----- + MIIE8zCCAtugAwIBAgIJAI/egicFvVmsMA0GCSqGSIb3DQEBCwUAMCIxIDAeBgNV + BAMTF1NlbGYtc2lnbmVkIGNlcnRpZmljYXRlMCAXDTI2MDMxMTE0MDkwMloYDzIx + MjYwMzExMTQwOTAyWjAiMSAwHgYDVQQDExdTZWxmLXNpZ25lZCBjZXJ0aWZpY2F0 + ZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAL6lxgz4nzb9B/ajfjy+ + DKYJR3isyZ3kJg8f5BvKYFKptQTZWk4TJhChYzNrePBq7I/AW20z4Zt4TZvnAC29 + z54EZ/9auxXswHN1tNujc1OPtADRlJmWYf/fI+y4cwoPegaPdSS+WRgeigz6BmJu + Oq5J6/IM/eRBqByaZgYU8Acvjxec9fzoKooK94ZoGeoM6q2GVipHzKLgPp4AsqA/ + aY9Uz/sb6mjp/wQX4KcIFZLChGcYetuTkPuh6OcnCwApH67NvZB4HwxXDEMKx89V + JkUh8iOt+1Q3s76yfRl3JGcthrrhzojE/3teQpU3F/aKfou4Hagys3WXQv9q8V6m + s0rXEBF2WuXTF5kCdBhvCnFClrokm20ev8T4aSsI8FDXanMhTamHsFYE5eIgZXZy + q4fYcwnfHENGwvxp3w1tsdSqdtRXBUoJQYNbGyVsZeEADFlLERoqe6FRlnvZVtkZ + tThlYKUwWitOdivVuI5h+64HtJbhBvvdGPDVl3WQVDmQ4Z17HpyPCL6uILNlSxIu + Df+CdOZv3/iElSWUkR3WjO0qohv5TEraCwvhXlCyIK37WyzAXr7XVmZUrpmqU2aa + U4tVmt3o5zDorb7MCit9Mn731nfpjC6wSnHD90JGi7fSHUP7+GH0PfWNDSvjRtrY + bBThQA55vqVzcqHe5M6uCCwVAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNV + HSUBAf8EDDAKBggrBgEFBQcDAjANBgkqhkiG9w0BAQsFAAOCAgEAvCjsrSZQ1iKM + B42rDuE/IBbw5BUn12RzX608uG7CnTgiywwKSokspAi+3N7HEH4+8T+urQDQxCv8 + aFZ4SpkDM8xXCh0zF9WIRK3kQYF+crNfrsCJaduCQjCozh1NyZe3oFTXxpHuKh7V + ellexvahLJid9a1bVADAIx5cKLEFhkSVh15hcWlphKMkVsA+cI0D22gbMwO2TSkL + +X2C0YQYd0yxrDjZKU2Y5P8vunDCMPS04UsexERRuUCRzFBp7+mt2c1rT0gxta9w + NeuW7ooUIAeMGNUY2FCrUI4OwMlre6knYZST+sfKyLY6r95PtHgXQB5pZ9G6iHu6 + FGdqbvdqcqr79l989z6sOo7p4CzX3dxp3rAuBzgY023bTnNZAnEEYSNYd5AJePD2 + 1ycEXKEh1+GGkF0t5HX3FVe7VC/AEqCpNwaHzW0KQ6wunuqAJNvGa4gpZVqWGw7f + dBhkg+W5itWbAn3giXQQD8yi/0CJzBSj/GFVPWCax3n3dV404DTAqINq1Koix/1i + oOY+/PQEwlk+QZrtvBpPaDIjX7wVMnu7lF6q/d5gws3kHVPW90+8Nk/pXTeXU6mo + hO6dOCwfN1IrRSn1pQ35UsSKPE9/g5gN77hi0v9AK9jtPfLLJQGYvBAjlU7Y3p7R + Sq+yMXEPCIhq0DMdISeTf1Ajy7rYJcI= + -----END CERTIFICATE----- + """))) + } + }, Permissions = { Permissions.Endpoints.Token, diff --git a/samples/Aridka/Aridka.Server/Properties/launchSettings.json b/samples/Aridka/Aridka.Server/Properties/launchSettings.json index 8afd5ef5..2fa901de 100644 --- a/samples/Aridka/Aridka.Server/Properties/launchSettings.json +++ b/samples/Aridka/Aridka.Server/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "Kestrel": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "https://localhost:44385/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -20,7 +20,7 @@ "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/samples/Balosar/Balosar.Server/Program.cs b/samples/Balosar/Balosar.Server/Program.cs index 09aadeef..fd4767ff 100644 --- a/samples/Balosar/Balosar.Server/Program.cs +++ b/samples/Balosar/Balosar.Server/Program.cs @@ -1,5 +1,4 @@ -using Balosar.Server; -using Balosar.Server.Data; +using Balosar.Server.Data; using Balosar.Server.Models; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; diff --git a/samples/Contruum/Contruum.Server/Program.cs b/samples/Contruum/Contruum.Server/Program.cs index dead26d9..e35f2688 100644 --- a/samples/Contruum/Contruum.Server/Program.cs +++ b/samples/Contruum/Contruum.Server/Program.cs @@ -1,10 +1,8 @@ using System.Globalization; using System.Text.Json.Nodes; -using Contruum.Server; using Contruum.Server.Models; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; using OpenIddict.Abstractions; using Quartz; using static OpenIddict.Abstractions.OpenIddictConstants; diff --git a/samples/Dantooine/Dantooine.Api/Program.cs b/samples/Dantooine/Dantooine.Api/Program.cs index 29592188..5859ce87 100644 --- a/samples/Dantooine/Dantooine.Api/Program.cs +++ b/samples/Dantooine/Dantooine.Api/Program.cs @@ -1,4 +1,6 @@ -using Microsoft.AspNetCore.Authorization; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Authorization; +using Microsoft.IdentityModel.Tokens; using OpenIddict.Validation.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -14,9 +16,18 @@ // Configure the validation handler to use introspection and register the client // credentials used when communicating with the remote introspection endpoint. + // + // Note: instead of sending a client secret, this application authenticates by + // generating client assertions that are signed using an ECDSA signing key. options.UseIntrospection() .SetClientId("resource_server_1") - .SetClientSecret("846B62D0-DEF9-4215-A99D-86E6B8DAB342"); + .AddSigningKey(GetECDsaSigningKey($""" + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEIFV0jPBUM8yaqQCmbgJ3IYmebIk5maW7XJCWUSZ8N2lEoAoGCCqGSM49 + AwEHoUQDQgAElrZTesJa18s6LuknPtM/Kg5veUCEp6YBF03eLBkapNe+P6u5zFaf + jm3mL5yFV7dGaxlDEe0TtXdjSUkQATtq1g== + -----END EC PRIVATE KEY----- + """)); // Register the System.Net.Http integration. options.UseSystemNetHttp(); @@ -38,3 +49,11 @@ app.MapGet("api/DantooineApi", [Authorize] () => new string[] { "data1", "data2" }); app.Run(); + +static ECDsaSecurityKey GetECDsaSigningKey(ReadOnlySpan key) +{ + var algorithm = ECDsa.Create(); + algorithm.ImportFromPem(key); + + return new ECDsaSecurityKey(algorithm); +} diff --git a/samples/Dantooine/Dantooine.Api/Properties/launchSettings.json b/samples/Dantooine/Dantooine.Api/Properties/launchSettings.json index 15bf2b2c..ecf00c84 100644 --- a/samples/Dantooine/Dantooine.Api/Properties/launchSettings.json +++ b/samples/Dantooine/Dantooine.Api/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "Kestrel": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "https://localhost:44343/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -20,7 +20,7 @@ "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/samples/Dantooine/Dantooine.Server/Program.cs b/samples/Dantooine/Dantooine.Server/Program.cs index dc5e9cfe..9f2d22dc 100644 --- a/samples/Dantooine/Dantooine.Server/Program.cs +++ b/samples/Dantooine/Dantooine.Server/Program.cs @@ -1,8 +1,9 @@ using System.Globalization; -using Dantooine.Server; +using System.Security.Cryptography; using Dantooine.Server.Data; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; using OpenIddict.Abstractions; using Quartz; using static OpenIddict.Abstractions.OpenIddictConstants; @@ -147,7 +148,23 @@ static async Task RegisterApplicationsAsync(IServiceProvider provider) var descriptor = new OpenIddictApplicationDescriptor { ClientId = "resource_server_1", - ClientSecret = "846B62D0-DEF9-4215-A99D-86E6B8DAB342", + JsonWebKeySet = new JsonWebKeySet + { + Keys = + { + // Note: instead of sending a client secret, this application authenticates by + // generating client assertions that are signed using an ECDSA signing key. + // + // Note: while the client needs access to the private key, the server only needs + // to know the public key to be able to validate the client assertions it receives. + JsonWebKeyConverter.ConvertFromECDsaSecurityKey(GetECDsaSigningKey($""" + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAElrZTesJa18s6LuknPtM/Kg5veUCE + p6YBF03eLBkapNe+P6u5zFafjm3mL5yFV7dGaxlDEe0TtXdjSUkQATtq1g== + -----END PUBLIC KEY----- + """)) + } + }, Permissions = { Permissions.Endpoints.Introspection @@ -165,6 +182,23 @@ await manager.CreateAsync(new OpenIddictApplicationDescriptor ClientId = "blazorcodeflowpkceclient", ConsentType = ConsentTypes.Explicit, DisplayName = "Blazor code PKCE", + JsonWebKeySet = new JsonWebKeySet + { + Keys = + { + // Note: instead of sending a client secret, this application authenticates by + // generating client assertions that are signed using an ECDSA signing key. + // + // Note: while the client needs access to the private key, the server only needs + // to know the public key to be able to validate the client assertions it receives. + JsonWebKeyConverter.ConvertFromECDsaSecurityKey(GetECDsaSigningKey($""" + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEuXiljSpKKFtkfE+PniYWGCtPczBH + bnLkag0aLFN5IJss/lKz0TIKdX09suFW+/fqdT/RF5/2PI72xZ4Q5Ty+uw== + -----END PUBLIC KEY----- + """)) + } + }, PostLogoutRedirectUris = { new Uri("https://localhost:44348/callback/logout/local") @@ -173,7 +207,6 @@ await manager.CreateAsync(new OpenIddictApplicationDescriptor { new Uri("https://localhost:44348/callback/login/local") }, - ClientSecret = "codeflow_pkce_client_secret", Permissions = { Permissions.Endpoints.Authorization, @@ -219,3 +252,11 @@ await manager.CreateAsync(new OpenIddictScopeDescriptor } await app.RunAsync(); + +static ECDsaSecurityKey GetECDsaSigningKey(ReadOnlySpan key) +{ + var algorithm = ECDsa.Create(); + algorithm.ImportFromPem(key); + + return new ECDsaSecurityKey(algorithm); +} diff --git a/samples/Dantooine/Dantooine.Server/Properties/launchSettings.json b/samples/Dantooine/Dantooine.Server/Properties/launchSettings.json index 9e07113c..59fb3559 100644 --- a/samples/Dantooine/Dantooine.Server/Properties/launchSettings.json +++ b/samples/Dantooine/Dantooine.Server/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "Kestrel": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "https://localhost:44319/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -20,7 +20,7 @@ "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/samples/Dantooine/Dantooine.WebAssembly.Server/Program.cs b/samples/Dantooine/Dantooine.WebAssembly.Server/Program.cs index 342aec46..8b5775e2 100644 --- a/samples/Dantooine/Dantooine.WebAssembly.Server/Program.cs +++ b/samples/Dantooine/Dantooine.WebAssembly.Server/Program.cs @@ -1,13 +1,12 @@ -using System.Configuration; -using System.Globalization; -using Dantooine.WebAssembly.Server; +using System.Globalization; +using System.Security.Cryptography; using Dantooine.WebAssembly.Server.Helpers; using Dantooine.WebAssembly.Server.Models; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection.Extensions; -using OpenIddict.Abstractions; +using Microsoft.IdentityModel.Tokens; using OpenIddict.Client; using Quartz; using Yarp.ReverseProxy.Forwarder; @@ -112,9 +111,21 @@ Issuer = new Uri("https://localhost:44319/", UriKind.Absolute), ClientId = "blazorcodeflowpkceclient", - ClientSecret = "codeflow_pkce_client_secret", Scopes = { Scopes.OfflineAccess, Scopes.Profile, "api1" }, + // Note: instead of sending a client secret, this application authenticates by + // generating client assertions that are signed using an ECDSA signing key. + SigningCredentials = + { + new SigningCredentials(GetECDsaSigningKey($""" + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEIGrtraQHuQbfaSlK4j6Ny+i5IntdhUk1yrYUAqK4fYWkoAoGCCqGSM49 + AwEHoUQDQgAEuXiljSpKKFtkfE+PniYWGCtPczBHbnLkag0aLFN5IJss/lKz0TIK + dX09suFW+/fqdT/RF5/2PI72xZ4Q5Ty+uw== + -----END EC PRIVATE KEY----- + """), SecurityAlgorithms.EcdsaSha256, SecurityAlgorithms.Sha256) + }, + // Note: to mitigate mix-up attacks, it's recommended to use a unique redirection endpoint // URI per provider, unless all the registered providers support returning a special "iss" // parameter containing their URL as part of authorization responses. For more information, @@ -284,4 +295,12 @@ static void ConfigureProxyPipeline(IReverseProxyApplicationBuilder app) await context.ChallengeAsync(CookieAuthenticationDefaults.AuthenticationScheme); } }); -} \ No newline at end of file +} + +static ECDsaSecurityKey GetECDsaSigningKey(ReadOnlySpan key) +{ + var algorithm = ECDsa.Create(); + algorithm.ImportFromPem(key); + + return new ECDsaSecurityKey(algorithm); +} diff --git a/samples/Fornax/Fornax.Server/Properties/launchSettings.json b/samples/Fornax/Fornax.Server/Properties/launchSettings.json index 93b50c30..55f09009 100644 --- a/samples/Fornax/Fornax.Server/Properties/launchSettings.json +++ b/samples/Fornax/Fornax.Server/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/samples/Geonosis/Geonosis.Auth/Program.cs b/samples/Geonosis/Geonosis.Auth/Program.cs index 27070f33..c98dc671 100644 --- a/samples/Geonosis/Geonosis.Auth/Program.cs +++ b/samples/Geonosis/Geonosis.Auth/Program.cs @@ -1,7 +1,9 @@ using System.Globalization; +using System.Security.Cryptography; using Geonosis.Auth.Data; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; using OpenIddict.Abstractions; using static OpenIddict.Abstractions.OpenIddictConstants; @@ -197,33 +199,40 @@ static async Task SeedClientsAsync(IServiceProvider provider) // Create the client application representing the UI if it doesn't exist. if (await manager.FindByClientIdAsync("geonosis-ui") is null) { - await manager.CreateAsync(new OpenIddictApplicationDescriptor + var descriptor = new OpenIddictApplicationDescriptor { ClientId = "geonosis-ui", - ClientSecret = "super-secret-client-secret", - DisplayName = "Monarch UI Application", - - // Web application using the BFF authentication model is considered a confidential client because the server-side BFF - // component can securely store the client secret and it handle all the interactions with the authorization server on behalf - // of the client application, including token management and refreshing. ClientType = ClientTypes.Confidential, - - // Implicit consent type for public clients, no need to prompt the user for consent in this sample, - // but in a production application, you should consider the appropriate consent type based on your application's requirements - // and user experience goals. ConsentType = ConsentTypes.Implicit, - + DisplayName = "Monarch UI Application", + JsonWebKeySet = new JsonWebKeySet + { + Keys = + { + // Note: instead of sending a client secret, this application authenticates by + // generating client assertions that are signed using an ECDSA signing key. + // + // Note: while the client needs access to the private key, the server only needs + // to know the public key to be able to validate the client assertions it receives. + JsonWebKeyConverter.ConvertFromECDsaSecurityKey(GetECDsaSigningKey($""" + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFXmvZRv1zOogKS8JP/qlGxNC+Ghr + UpYIGykTeHPrvrY3HFpHnQ7hvNQzLULWxLkuzsu95cMzJIuITdr7e1i8cg== + -----END PUBLIC KEY----- + """)) + } + }, // RedirectUris must match the URLs used by the Blazor Web application during the authentication process // These URLs are where the authorization server will redirect the user after login/logout back to the client application RedirectUris = { new Uri("http://localhost:5027/authentication/login-callback/local"), - new Uri("https://localhost:7073/authentication/login-callback/local"), + new Uri("https://localhost:7073/authentication/login-callback/local") }, PostLogoutRedirectUris = { new Uri("http://localhost:5027/authentication/logout-callback/local"), - new Uri("https://localhost:7073/authentication/logout-callback/local"), + new Uri("https://localhost:7073/authentication/logout-callback/local") }, Permissions = { @@ -233,37 +242,23 @@ await manager.CreateAsync(new OpenIddictApplicationDescriptor Permissions.GrantTypes.AuthorizationCode, Permissions.GrantTypes.RefreshToken, - // The token exchange grant type is required for the UI to exchange the access token it receives - // from the authorization server for a new access token that can be used to call the API. Permissions.GrantTypes.TokenExchange, Permissions.ResponseTypes.Code, Permissions.Scopes.Email, Permissions.Scopes.Profile, - Permissions.Scopes.Roles, - - // Custom scope representing the API scope that the UI will request access to using the token exchange flow. - Permissions.Prefixes.Scope + "Weather.Read", + Permissions.Scopes.Roles }, Requirements = { - Requirements.Features.ProofKeyForCodeExchange, + Requirements.Features.ProofKeyForCodeExchange } - }); - } + }; - // Create the client application representing the API if it doesn't exist. - if (await manager.FindByClientIdAsync("geonosis-api") is null) - { - await manager.CreateAsync(new OpenIddictApplicationDescriptor - { - ClientId = "geonosis-api", - ClientSecret = "super-secret-client-secret-2", - DisplayName = "Geonosis API Application", - ClientType = ClientTypes.Confidential, - ConsentType = ConsentTypes.Implicit, - }); + descriptor.AddScopePermissions("Weather.Read"); + + await manager.CreateAsync(descriptor); } } @@ -292,3 +287,11 @@ await manager.CreateAsync(new OpenIddictScopeDescriptor } await app.RunAsync(); + +static ECDsaSecurityKey GetECDsaSigningKey(ReadOnlySpan key) +{ + var algorithm = ECDsa.Create(); + algorithm.ImportFromPem(key); + + return new ECDsaSecurityKey(algorithm); +} diff --git a/samples/Geonosis/Geonosis.ServiceDefaults/Extensions.cs b/samples/Geonosis/Geonosis.ServiceDefaults/Extensions.cs index b72c8753..224d3d90 100644 --- a/samples/Geonosis/Geonosis.ServiceDefaults/Extensions.cs +++ b/samples/Geonosis/Geonosis.ServiceDefaults/Extensions.cs @@ -3,7 +3,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.ServiceDiscovery; using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; diff --git a/samples/Geonosis/Geonosis.Ui/Geonosis.Ui/Program.cs b/samples/Geonosis/Geonosis.Ui/Geonosis.Ui/Program.cs index e259eee7..0fa9a905 100644 --- a/samples/Geonosis/Geonosis.Ui/Geonosis.Ui/Program.cs +++ b/samples/Geonosis/Geonosis.Ui/Geonosis.Ui/Program.cs @@ -1,4 +1,5 @@ using System.Net.Http.Headers; +using System.Security.Cryptography; using Geonosis.Ui; using Geonosis.Ui.Client; using Geonosis.Ui.Client.Weather; @@ -6,6 +7,7 @@ using Geonosis.Ui.Weather; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.IdentityModel.Tokens; using OpenIddict.Client; using OpenIddict.Client.AspNetCore; using Yarp.ReverseProxy.Transforms; @@ -53,8 +55,20 @@ Issuer = new Uri(issuerUrl, UriKind.Absolute), ClientId = "geonosis-ui", - ClientSecret = "super-secret-client-secret", - // OfflineAccess is required to get refresh tokens + + // Note: instead of sending a client secret, this application authenticates by + // generating client assertions that are signed using an ECDSA signing key. + SigningCredentials = + { + new SigningCredentials(GetECDsaSigningKey($""" + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEIAySayLGEX8781cE7W8HaJsNTqb9Ucym6SApQgIVdFZvoAoGCCqGSM49 + AwEHoUQDQgAEFXmvZRv1zOogKS8JP/qlGxNC+GhrUpYIGykTeHPrvrY3HFpHnQ7h + vNQzLULWxLkuzsu95cMzJIuITdr7e1i8cg== + -----END EC PRIVATE KEY----- + """), SecurityAlgorithms.EcdsaSha256, SecurityAlgorithms.Sha256) + }, + Scopes = { Scopes.OfflineAccess, Scopes.Email, Scopes.Profile, Scopes.Roles }, // Note: to mitigate mix-up attacks, it's recommended to use a unique redirection endpoint @@ -167,3 +181,11 @@ .RequireAuthorization(); app.Run(); + +static ECDsaSecurityKey GetECDsaSigningKey(ReadOnlySpan key) +{ + var algorithm = ECDsa.Create(); + algorithm.ImportFromPem(key); + + return new ECDsaSecurityKey(algorithm); +} diff --git a/samples/Geonosis/Geonosis.Ui/Geonosis.Ui/Weather/ServerWeatherForecaster.cs b/samples/Geonosis/Geonosis.Ui/Geonosis.Ui/Weather/ServerWeatherForecaster.cs index 4e57a404..0c7b60c0 100644 --- a/samples/Geonosis/Geonosis.Ui/Geonosis.Ui/Weather/ServerWeatherForecaster.cs +++ b/samples/Geonosis/Geonosis.Ui/Geonosis.Ui/Weather/ServerWeatherForecaster.cs @@ -1,8 +1,5 @@ -using System.Net; -using System.Net.Http.Headers; -using Geonosis.Ui.Client.Weather; +using Geonosis.Ui.Client.Weather; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; using OpenIddict.Client; using OpenIddict.Client.AspNetCore; using static OpenIddict.Abstractions.OpenIddictConstants; diff --git a/samples/Hollastin/Hollastin.Client/Program.cs b/samples/Hollastin/Hollastin.Client/Program.cs index 336760ff..37e83b17 100644 --- a/samples/Hollastin/Hollastin.Client/Program.cs +++ b/samples/Hollastin/Hollastin.Client/Program.cs @@ -1,9 +1,23 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.DependencyInjection; using OpenIddict.Client; +// Note: the OpenIddict server stack supports mTLS-based token binding for public clients: +// while these clients cannot authenticate using a TLS client certificate, the certificate +// can be used to bind the refresh (and access) tokens returned by the authorization server +// to the client application, which prevents such tokens from being used without providing a +// proof-of-possession matching the TLS client certificate used when the token was acquired. +// +// While this sample deliberately doesn't store the generated certificate in a persistent +// location, the certificate used for token binding should typically be stored in the user +// certificate store to be reloaded across application restarts in a real-world application. +var certificate = GenerateEphemeralTlsClientCertificate(); + var services = new ServiceCollection(); services.AddOpenIddict() @@ -31,13 +45,27 @@ }); }); +// Register a named HTTP client that will be used to call the demo resource API. +// +// Note: since the authorization server is configured to issue certificate-bound +// access tokens, the client certificate MUST be attached to outgoing HTTP requests +// and the mTLS subdomain (for which TLS client authentication is enabled) MUST be used. +services.AddHttpClient("ApiClient") + .AddAsKeyed() + .ConfigureHttpClient(static client => client.BaseAddress = new Uri("https://mtls.dev.localhost:44360/")) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + ClientCertificateOptions = ClientCertificateOption.Manual, + ClientCertificates = { certificate } + }); + await using var provider = services.BuildServiceProvider(); const string email = "bob@le-magnifique.com", password = "}s>EWG@f4g;_v7nB"; await CreateAccountAsync(provider, email, password); -var token = await GetTokenAsync(provider, email, password); +var token = await GetTokenAsync(provider, email, password, certificate); Console.WriteLine("Access token: {0}", token); Console.WriteLine(); @@ -48,8 +76,8 @@ static async Task CreateAccountAsync(IServiceProvider provider, string email, string password) { - using var client = provider.GetRequiredService(); - var response = await client.PostAsJsonAsync("https://localhost:44360/Account/Register", new { email, password }); + var client = provider.GetRequiredKeyedService("ApiClient"); + var response = await client.PostAsJsonAsync("Account/Register", new { email, password }); // Ignore 409 responses, as they indicate that the account already exists. if (response.StatusCode == HttpStatusCode.Conflict) @@ -60,14 +88,45 @@ static async Task CreateAccountAsync(IServiceProvider provider, string email, st response.EnsureSuccessStatusCode(); } -static async Task GetTokenAsync(IServiceProvider provider, string email, string password) +static X509Certificate2 GenerateEphemeralTlsClientCertificate() +{ + using var algorithm = RSA.Create(keySizeInBits: 4096); + + var subject = new X500DistinguishedName("CN=Self-signed certificate"); + var request = new CertificateRequest(subject, algorithm, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension([new Oid("1.3.6.1.5.5.7.3.2")], critical: true)); + + var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(2)); + + // On Windows, a certificate loaded from PEM-encoded material is ephemeral and + // cannot be directly used with TLS, as Schannel cannot access it in this case. + // + // To work this limitation, the certificate is exported and re-imported from a + // PFX blob to ensure the private key is persisted in a way that Schannel can use. + // + // In a real world application, the certificate wouldn't be embedded in the source code + // and would be installed in the certificate store, making this workaround unnecessary. + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + certificate = X509CertificateLoader.LoadPkcs12( + data: certificate.Export(X509ContentType.Pfx, string.Empty), + password: string.Empty, + keyStorageFlags: X509KeyStorageFlags.DefaultKeySet); + } + + return certificate; +} + +static async Task GetTokenAsync(IServiceProvider provider, string email, string password, X509Certificate2 certificate) { var service = provider.GetRequiredService(); var result = await service.AuthenticateWithPasswordAsync(new() { Username = email, - Password = password + Password = password, + TokenBindingCertificate = certificate }); return result.AccessToken; @@ -75,8 +134,8 @@ static async Task GetTokenAsync(IServiceProvider provider, string email, static async Task GetResourceAsync(IServiceProvider provider, string token) { - using var client = provider.GetRequiredService(); - using var request = new HttpRequestMessage(HttpMethod.Get, "https://localhost:44360/api/message"); + var client = provider.GetRequiredKeyedService("ApiClient"); + using var request = new HttpRequestMessage(HttpMethod.Get, "api/message"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); using var response = await client.SendAsync(request); diff --git a/samples/Hollastin/Hollastin.Server/Program.cs b/samples/Hollastin/Hollastin.Server/Program.cs index 04f2c37b..bd68013b 100644 --- a/samples/Hollastin/Hollastin.Server/Program.cs +++ b/samples/Hollastin/Hollastin.Server/Program.cs @@ -1,6 +1,9 @@ -using Hollastin.Server; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; using Hollastin.Server.Models; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.EntityFrameworkCore; using Quartz; @@ -65,6 +68,26 @@ options.AddDevelopmentEncryptionCertificate() .AddDevelopmentSigningCertificate(); + // Note: setting a static issuer is mandatory when using mTLS aliases to ensure it not + // dynamically computed based on the request URI, as this would result in two different + // issuers being used (one pointing to the mTLS domain and one pointing to the regular one). + options.SetIssuer("https://localhost:44360/"); + + // Enable self_signed_tls_client_auth to allow clients to use mTLS-based token binding. + options.EnableSelfSignedTlsClientAuthentication(); + + // Configure the mTLS endpoint aliases that will be used by client applications opting + // for TLS-based client authentication to communicate with the authorization server: + // the configured URIs MUST point to a domain for which the HTTPS server is configured + // to require the use of client certificates when receiving TLS handshakes from clients. + options.SetMtlsTokenEndpointAliasUri("https://mtls.dev.localhost:44360/connect/token"); + + // Optionally, the server stack can be configured to issue client certificate-bound access tokens. + // + // When doing so, the standard "cnf" claim is automatically added to access tokens to inform + // resource servers that a proof of possession derived from the certificate must be provided. + options.UseClientCertificateBoundAccessTokens(); + // Register the ASP.NET Core host and configure the ASP.NET Core-specific options. options.UseAspNetCore() .EnableTokenEndpointPassthrough(); @@ -80,6 +103,54 @@ options.UseAspNetCore(); }); +// Configure Kestrel to listen on the 44360 port and configure it to enforce mTLS. +// +// Note: depending on the operating system, the mtls.dev.localhost +// subdomain MAY have to be manually mapped to 127.0.0.1 or ::1. +builder.Services.Configure(options => options.ListenAnyIP(44360, options => +{ + options.UseHttps(new TlsHandshakeCallbackOptions + { + OnConnection = static context => + { + using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadOnly); + + return ValueTask.FromResult(new SslServerAuthenticationOptions + { + // Require a client certificate for all the requests pointing to the mTLS subdomain. + ClientCertificateRequired = string.Equals(context.ClientHelloInfo.ServerName, + "mtls.dev.localhost", StringComparison.OrdinalIgnoreCase), + + // Ignore all the client certificate errors for requests pointing to + // the mTLS-specific domain, even if they indicate that the chain is + // invalid: this is necessary to allow OpenIddict to validate the PKI + // and self-signed certificates using its own per-client chain policies. + RemoteCertificateValidationCallback = (sender, certificate, chain, errors) => + { + if (string.Equals(context.ClientHelloInfo.ServerName, + "mtls.dev.localhost", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return errors is SslPolicyErrors.None or SslPolicyErrors.RemoteCertificateNotAvailable; + }, + + // Use the development certificate generated and stored by ASP.NET Core in the user store. + ServerCertificate = store.Certificates + .Find(X509FindType.FindByExtension, "1.3.6.1.4.1.311.84.1.1", validOnly: false) + .Cast() + .Where(static certificate => certificate.NotBefore < TimeProvider.System.GetLocalNow()) + .Where(static certificate => certificate.NotAfter > TimeProvider.System.GetLocalNow()) + .OrderByDescending(static certificate => certificate.NotAfter) + .FirstOrDefault() ?? + throw new InvalidOperationException("The ASP.NET Core HTTPS development certificate was not found.") + }); + } + }); +})); + var app = builder.Build(); app.UseDeveloperExceptionPage(); diff --git a/samples/Hollastin/Hollastin.Server/Properties/launchSettings.json b/samples/Hollastin/Hollastin.Server/Properties/launchSettings.json index 61073e68..0f9eb29d 100644 --- a/samples/Hollastin/Hollastin.Server/Properties/launchSettings.json +++ b/samples/Hollastin/Hollastin.Server/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "Kestrel": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "https://localhost:44360/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -20,7 +20,7 @@ "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/samples/Imynusoph/Imynusoph.Client/Program.cs b/samples/Imynusoph/Imynusoph.Client/Program.cs index 06702d35..f6d9649b 100644 --- a/samples/Imynusoph/Imynusoph.Client/Program.cs +++ b/samples/Imynusoph/Imynusoph.Client/Program.cs @@ -32,6 +32,11 @@ }); }); +// Register a named HTTP client that will be used to call the demo resource API. +services.AddHttpClient("ApiClient") + .AddAsKeyed() + .ConfigureHttpClient(static client => client.BaseAddress = new Uri("https://localhost:44382/")); + await using var provider = services.BuildServiceProvider(); const string email = "bob@le-magnifique.com", password = "}s>EWG@f4g;_v7nB"; @@ -56,8 +61,8 @@ static async Task CreateAccountAsync(IServiceProvider provider, string email, string password) { - using var client = provider.GetRequiredService(); - var response = await client.PostAsJsonAsync("https://localhost:44382/Account/Register", new { email, password }); + var client = provider.GetRequiredKeyedService("ApiClient"); + var response = await client.PostAsJsonAsync("Account/Register", new { email, password }); // Ignore 409 responses, as they indicate that the account already exists. if (response.StatusCode == HttpStatusCode.Conflict) diff --git a/samples/Imynusoph/Imynusoph.Server/Program.cs b/samples/Imynusoph/Imynusoph.Server/Program.cs index 303dbfc0..d0c2c7b6 100644 --- a/samples/Imynusoph/Imynusoph.Server/Program.cs +++ b/samples/Imynusoph/Imynusoph.Server/Program.cs @@ -1,5 +1,4 @@ -using Imynusoph.Server; -using Imynusoph.Server.Models; +using Imynusoph.Server.Models; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Quartz; diff --git a/samples/Imynusoph/Imynusoph.Server/Properties/launchSettings.json b/samples/Imynusoph/Imynusoph.Server/Properties/launchSettings.json index 8ca2c747..3c37279e 100644 --- a/samples/Imynusoph/Imynusoph.Server/Properties/launchSettings.json +++ b/samples/Imynusoph/Imynusoph.Server/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "Kestrel": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "https://localhost:44382/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -20,7 +20,7 @@ "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/samples/Kalarba/Kalarba.Client/Program.cs b/samples/Kalarba/Kalarba.Client/Program.cs index 7092d6ca..f562a13b 100644 --- a/samples/Kalarba/Kalarba.Client/Program.cs +++ b/samples/Kalarba/Kalarba.Client/Program.cs @@ -30,7 +30,17 @@ }); }); -using var provider = services.BuildServiceProvider(); +// Register a named HTTP client that will be used to call the demo resource API. +services.AddHttpClient("ApiClient") + .ConfigureHttpClient(static client => client.BaseAddress = new Uri("http://localhost:58779/")); + +services.AddKeyedScoped("ApiClient", static (provider, name) => +{ + var factory = provider.GetRequiredService(); + return factory.CreateClient((string) name!); +}); + +await using var provider = services.BuildServiceProvider(); var token = await GetTokenAsync(provider, "alice@wonderland.com", "P@ssw0rd"); Console.WriteLine("Access token: {0}", token); @@ -56,9 +66,8 @@ static async Task GetTokenAsync(IServiceProvider provider, string email, static async Task GetResourceAsync(IServiceProvider provider, string token) { - var factory = provider.GetRequiredService(); - using var client = factory.CreateClient(); - using var request = new HttpRequestMessage(HttpMethod.Get, "http://localhost:58779/api/message"); + var client = provider.GetRequiredKeyedService("ApiClient"); + using var request = new HttpRequestMessage(HttpMethod.Get, "api/message"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); using var response = await client.SendAsync(request); diff --git a/samples/Matty/Matty.Server/Program.cs b/samples/Matty/Matty.Server/Program.cs index bcc70ddc..795022e2 100644 --- a/samples/Matty/Matty.Server/Program.cs +++ b/samples/Matty/Matty.Server/Program.cs @@ -1,4 +1,3 @@ -using Matty.Server; using Matty.Server.Data; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; diff --git a/samples/Matty/Matty.Server/Properties/launchSettings.json b/samples/Matty/Matty.Server/Properties/launchSettings.json index 19406d48..1f2c9521 100644 --- a/samples/Matty/Matty.Server/Properties/launchSettings.json +++ b/samples/Matty/Matty.Server/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "Kestrel": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "https://localhost:44321/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -20,7 +20,7 @@ "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/samples/Mimban/Mimban.Client/InteractiveService.cs b/samples/Mimban/Mimban.Client/InteractiveService.cs index 9b313631..2758c672 100644 --- a/samples/Mimban/Mimban.Client/InteractiveService.cs +++ b/samples/Mimban/Mimban.Client/InteractiveService.cs @@ -1,4 +1,5 @@ using System.Net.Http.Headers; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using OpenIddict.Client; using static OpenIddict.Abstractions.OpenIddictConstants; @@ -8,13 +9,16 @@ namespace Mimban.Client; public class InteractiveService : BackgroundService { + private readonly HttpClient _client; private readonly IHostApplicationLifetime _lifetime; private readonly OpenIddictClientService _service; public InteractiveService( + [FromKeyedServices("ApiClient")] HttpClient client, IHostApplicationLifetime lifetime, OpenIddictClientService service) { + _client = client; _lifetime = lifetime; _service = service; } @@ -66,14 +70,12 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) Console.WriteLine("An error occurred while trying to authenticate the user."); } - static async Task GetResourceAsync(string token, CancellationToken cancellationToken) + async Task GetResourceAsync(string token, CancellationToken cancellationToken) { - using var client = new HttpClient(); - - using var request = new HttpRequestMessage(HttpMethod.Get, "https://localhost:44383/api"); + using var request = new HttpRequestMessage(HttpMethod.Get, "api"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - using var response = await client.SendAsync(request, cancellationToken); + using var response = await _client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(cancellationToken); diff --git a/samples/Mimban/Mimban.Client/Program.cs b/samples/Mimban/Mimban.Client/Program.cs index cb25dc8e..d8afac62 100644 --- a/samples/Mimban/Mimban.Client/Program.cs +++ b/samples/Mimban/Mimban.Client/Program.cs @@ -59,6 +59,11 @@ }); }); +// Register a named HTTP client that will be used to call the demo resource API. +builder.Services.AddHttpClient("ApiClient") + .AddAsKeyed() + .ConfigureHttpClient(static client => client.BaseAddress = new Uri("https://localhost:44383/")); + // Register the background service responsible for handling the console interactions. builder.Services.AddHostedService(); diff --git a/samples/Mimban/Mimban.Server/Program.cs b/samples/Mimban/Mimban.Server/Program.cs index ddaa945a..73690af5 100644 --- a/samples/Mimban/Mimban.Server/Program.cs +++ b/samples/Mimban/Mimban.Server/Program.cs @@ -80,8 +80,8 @@ .AddServer(options => { // Enable the authorization and token endpoints. - options.SetAuthorizationEndpointUris("authorize") - .SetTokenEndpointUris("token"); + options.SetAuthorizationEndpointUris("connect/authorize") + .SetTokenEndpointUris("connect/token"); // Note: this sample only uses the authorization code flow but you can enable // the other flows if you need to support implicit, password or client credentials. @@ -148,7 +148,7 @@ return Results.SignIn(new ClaimsPrincipal(identity), properties); }); -app.MapMethods("authorize", [HttpMethods.Get, HttpMethods.Post], async (HttpContext context) => +app.MapMethods("connect/authorize", [HttpMethods.Get, HttpMethods.Post], async (HttpContext context) => { // Resolve the claims stored in the cookie created after the GitHub authentication dance. // If the principal cannot be found, trigger a new challenge to redirect the user to GitHub. diff --git a/samples/Mimban/Mimban.Server/Properties/launchSettings.json b/samples/Mimban/Mimban.Server/Properties/launchSettings.json index b44c0b03..6f95fc40 100644 --- a/samples/Mimban/Mimban.Server/Properties/launchSettings.json +++ b/samples/Mimban/Mimban.Server/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "Kestrel": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "https://localhost:44383/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -20,7 +20,7 @@ "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/samples/Mortis/Mortis.Client/Controllers/HomeController.cs b/samples/Mortis/Mortis.Client/Controllers/HomeController.cs index 697a27e5..117b2b40 100644 --- a/samples/Mortis/Mortis.Client/Controllers/HomeController.cs +++ b/samples/Mortis/Mortis.Client/Controllers/HomeController.cs @@ -2,18 +2,14 @@ using System.Net.Http.Headers; using System.Web; using System.Web.Mvc; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Owin.Security.Cookies; using static OpenIddict.Client.Owin.OpenIddictClientOwinConstants; namespace Mortis.Client.Controllers; -public class HomeController : Controller +public class HomeController([FromKeyedServices("ApiClient")] HttpClient client) : Controller { - private readonly IHttpClientFactory _httpClientFactory; - - public HomeController(IHttpClientFactory httpClientFactory) - => _httpClientFactory = httpClientFactory; - [HttpGet, Route("~/")] public ActionResult Index() => View(); @@ -26,9 +22,7 @@ public async Task Index(CancellationToken cancellationToken) var result = await context.Authentication.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationType); var token = result.Properties.Dictionary[Tokens.BackchannelAccessToken]; - using var client = _httpClientFactory.CreateClient(); - - using var request = new HttpRequestMessage(HttpMethod.Get, "https://localhost:44349/api/message"); + using var request = new HttpRequestMessage(HttpMethod.Get, "api/message"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); using var response = await client.SendAsync(request, cancellationToken); diff --git a/samples/Mortis/Mortis.Client/Startup.cs b/samples/Mortis/Mortis.Client/Startup.cs index b73eb74a..85a0d0ea 100644 --- a/samples/Mortis/Mortis.Client/Startup.cs +++ b/samples/Mortis/Mortis.Client/Startup.cs @@ -1,4 +1,5 @@ -using System.Web.Mvc; +using System.Net.Http; +using System.Web.Mvc; using Autofac; using Autofac.Extensions.DependencyInjection; using Autofac.Integration.Mvc; @@ -66,7 +67,7 @@ public void Configuration(IAppBuilder app) ProviderDisplayName = "Local OIDC server", ClientId = "mvc", - ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654", + ClientSecret = "ApsgjdK59hozhsNpt2kqkZ3cBaCPSLxVa1X22FsDzlk=", Scopes = { Scopes.Email, Scopes.Profile }, // Note: to mitigate mix-up attacks, it's recommended to use a unique redirection endpoint @@ -81,6 +82,16 @@ public void Configuration(IAppBuilder app) // Register the Entity Framework context needed by the OpenIddict stores. services.AddScoped(static provider => ApplicationDbContext.Create()); + // Register a named HTTP client that will be used to call the demo resource API. + services.AddHttpClient("ApiClient") + .ConfigureHttpClient(static client => client.BaseAddress = new Uri("https://localhost:44349/")); + + services.AddKeyedScoped("ApiClient", static (provider, name) => + { + var factory = provider.GetRequiredService(); + return factory.CreateClient((string) name!); + }); + // Create a new Autofac container and import the OpenIddict services. var builder = new ContainerBuilder(); builder.Populate(services); diff --git a/samples/Mortis/Mortis.Server/Properties/launchSettings.json b/samples/Mortis/Mortis.Server/Properties/launchSettings.json index 93d30247..0872a879 100644 --- a/samples/Mortis/Mortis.Server/Properties/launchSettings.json +++ b/samples/Mortis/Mortis.Server/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/samples/Mortis/Mortis.Server/Startup.cs b/samples/Mortis/Mortis.Server/Startup.cs index 0ae1ee2d..e3fe25ad 100644 --- a/samples/Mortis/Mortis.Server/Startup.cs +++ b/samples/Mortis/Mortis.Server/Startup.cs @@ -158,7 +158,7 @@ public void Configuration(IAppBuilder app) await manager.CreateAsync(new OpenIddictApplicationDescriptor { ClientId = "mvc", - ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654", + ClientSecret = "ApsgjdK59hozhsNpt2kqkZ3cBaCPSLxVa1X22FsDzlk=", ConsentType = ConsentTypes.Explicit, DisplayName = "MVC client application", RedirectUris = diff --git a/samples/Velusia/Velusia.Client/Controllers/HomeController.cs b/samples/Velusia/Velusia.Client/Controllers/HomeController.cs index 3cb87eab..911d8e41 100644 --- a/samples/Velusia/Velusia.Client/Controllers/HomeController.cs +++ b/samples/Velusia/Velusia.Client/Controllers/HomeController.cs @@ -6,13 +6,8 @@ namespace Velusia.Client.Controllers; -public class HomeController : Controller +public class HomeController([FromKeyedServices("ApiClient")] HttpClient client) : Controller { - private readonly IHttpClientFactory _httpClientFactory; - - public HomeController(IHttpClientFactory httpClientFactory) - => _httpClientFactory = httpClientFactory; - [HttpGet("~/")] public ActionResult Index() => View(); @@ -23,9 +18,7 @@ public async Task Index(CancellationToken cancellationToken) // authentication options shouldn't be used, a specific scheme can be specified here. var token = await HttpContext.GetTokenAsync(OpenIddictClientAspNetCoreConstants.Tokens.BackchannelAccessToken); - using var client = _httpClientFactory.CreateClient(); - - using var request = new HttpRequestMessage(HttpMethod.Get, "https://localhost:44313/api/message"); + using var request = new HttpRequestMessage(HttpMethod.Get, "api/message"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); using var response = await client.SendAsync(request, cancellationToken); diff --git a/samples/Velusia/Velusia.Client/Program.cs b/samples/Velusia/Velusia.Client/Program.cs index a33eed8e..04296913 100644 --- a/samples/Velusia/Velusia.Client/Program.cs +++ b/samples/Velusia/Velusia.Client/Program.cs @@ -1,8 +1,9 @@ -using Microsoft.AspNetCore.Authentication.Cookies; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; using OpenIddict.Client; using Quartz; -using Velusia.Client; using Velusia.Client.Models; using static OpenIddict.Abstractions.OpenIddictConstants; @@ -91,9 +92,21 @@ Issuer = new Uri("https://localhost:44313/", UriKind.Absolute), ClientId = "mvc", - ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654", Scopes = { Scopes.Email, Scopes.Profile }, + // Note: instead of sending a client secret, this application authenticates by + // generating client assertions that are signed using an ECDSA signing key. + SigningCredentials = + { + new SigningCredentials(GetECDsaSigningKey($""" + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEINEo+oyUvdFzYmEAB/z5x3loRAkMl/r/oyjko+RiVJ8KoAoGCCqGSM49 + AwEHoUQDQgAEhlS5driPAQZJ3GnRMeEF+d9BBBzq/3nv8HxzS9zjgO26jPNCNYCT + hpeJ5l6TbyhbxlYligILa6Dt+iz074n/JA== + -----END EC PRIVATE KEY----- + """), SecurityAlgorithms.EcdsaSha256, SecurityAlgorithms.Sha256) + }, + // Note: to mitigate mix-up attacks, it's recommended to use a unique redirection endpoint // URI per provider, unless all the registered providers support returning a special "iss" // parameter containing their URL as part of authorization responses. For more information, @@ -103,7 +116,10 @@ }); }); -builder.Services.AddHttpClient(); +// Register a named HTTP client that will be used to call the demo resource API. +builder.Services.AddHttpClient("ApiClient") + .AddAsKeyed() + .ConfigureHttpClient(static client => client.BaseAddress = new Uri("https://localhost:44313/")); builder.Services.AddControllersWithViews(); @@ -131,3 +147,11 @@ } await app.RunAsync(); + +static ECDsaSecurityKey GetECDsaSigningKey(ReadOnlySpan key) +{ + var algorithm = ECDsa.Create(); + algorithm.ImportFromPem(key); + + return new ECDsaSecurityKey(algorithm); +} diff --git a/samples/Velusia/Velusia.Server/Program.cs b/samples/Velusia/Velusia.Server/Program.cs index c71bade7..ddb42d5a 100644 --- a/samples/Velusia/Velusia.Server/Program.cs +++ b/samples/Velusia/Velusia.Server/Program.cs @@ -1,8 +1,9 @@ +using System.Security.Cryptography; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; using OpenIddict.Abstractions; using Quartz; -using Velusia.Server; using Velusia.Server.Data; using static OpenIddict.Abstractions.OpenIddictConstants; @@ -173,9 +174,25 @@ await manager.CreateAsync(new OpenIddictApplicationDescriptor { ClientId = "mvc", - ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654", ConsentType = ConsentTypes.Explicit, DisplayName = "MVC client application", + JsonWebKeySet = new JsonWebKeySet + { + Keys = + { + // Note: instead of sending a client secret, this application authenticates by + // generating client assertions that are signed using an ECDSA signing key. + // + // Note: while the client needs access to the private key, the server only needs + // to know the public key to be able to validate the client assertions it receives. + JsonWebKeyConverter.ConvertFromECDsaSecurityKey(GetECDsaSigningKey($""" + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhlS5driPAQZJ3GnRMeEF+d9BBBzq + /3nv8HxzS9zjgO26jPNCNYCThpeJ5l6TbyhbxlYligILa6Dt+iz074n/JA== + -----END PUBLIC KEY----- + """)) + } + }, RedirectUris = { new Uri("https://localhost:44338/callback/login/local") @@ -204,3 +221,11 @@ await manager.CreateAsync(new OpenIddictApplicationDescriptor } await app.RunAsync(); + +static ECDsaSecurityKey GetECDsaSigningKey(ReadOnlySpan key) +{ + var algorithm = ECDsa.Create(); + algorithm.ImportFromPem(key); + + return new ECDsaSecurityKey(algorithm); +} diff --git a/samples/Velusia/Velusia.Server/Properties/launchSettings.json b/samples/Velusia/Velusia.Server/Properties/launchSettings.json index 5be8577e..e7bed1fe 100644 --- a/samples/Velusia/Velusia.Server/Properties/launchSettings.json +++ b/samples/Velusia/Velusia.Server/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "Kestrel": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "https://localhost:44313/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -20,7 +20,7 @@ "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/samples/Weytta/Weytta.Client/Program.cs b/samples/Weytta/Weytta.Client/Program.cs index d3ee869a..54c282ad 100644 --- a/samples/Weytta/Weytta.Client/Program.cs +++ b/samples/Weytta/Weytta.Client/Program.cs @@ -33,12 +33,12 @@ // Note: this sample uses the authorization code flow, // but you can enable the other flows if necessary. options.AllowAuthorizationCodeFlow() - .AllowRefreshTokenFlow(); + .AllowRefreshTokenFlow(); // Register the signing and encryption credentials used to protect // sensitive data like the state tokens produced by OpenIddict. options.AddDevelopmentEncryptionCertificate() - .AddDevelopmentSigningCertificate(); + .AddDevelopmentSigningCertificate(); // Add the operating system integration. options.UseSystemIntegration(); @@ -47,7 +47,7 @@ // assembly as a more specific user agent, which can be useful when dealing with // providers that use the user agent as a way to throttle requests (e.g Reddit). options.UseSystemNetHttp() - .SetProductInformation(typeof(Program).Assembly); + .SetProductInformation(typeof(Program).Assembly); // Add a client registration matching the client application definition in the server project. options.AddRegistration(new OpenIddictClientRegistration diff --git a/samples/Weytta/Weytta.Server/Properties/launchSettings.json b/samples/Weytta/Weytta.Server/Properties/launchSettings.json index c0eca145..e9ba27dc 100644 --- a/samples/Weytta/Weytta.Server/Properties/launchSettings.json +++ b/samples/Weytta/Weytta.Server/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "Kestrel": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "https://localhost:44319/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -20,7 +20,7 @@ "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/samples/Zirku/Zirku.Api1/Program.cs b/samples/Zirku/Zirku.Api1/Program.cs index 21fd3589..5fdf818b 100644 --- a/samples/Zirku/Zirku.Api1/Program.cs +++ b/samples/Zirku/Zirku.Api1/Program.cs @@ -1,5 +1,11 @@ -using System.Security.Claims; +using System.Net.Security; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Https; +using Microsoft.IdentityModel.Tokens; using OpenIddict.Validation.AspNetCore; var builder = WebApplication.CreateBuilder(args); @@ -15,9 +21,18 @@ // Configure the validation handler to use introspection and register the client // credentials used when communicating with the remote introspection endpoint. + // + // Note: instead of sending a client secret, this application authenticates by + // generating client assertions that are signed using an ECDSA signing key. options.UseIntrospection() .SetClientId("resource_server_1") - .SetClientSecret("846B62D0-DEF9-4215-A99D-86E6B8DAB342"); + .AddSigningKey(GetECDsaSigningKey($""" + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEIHne9S22XGV8Dp6DrwZ/x0m0Z617u4MVGcPgqfhvizMxoAoGCCqGSM49 + AwEHoUQDQgAExEBWSim0vOd/397ejnxjXGhlMG8dO+JAMsx3054Tuf/ogyvfhUE8 + COGfMZvKv5lcsyDw9YwwwJThZny5qs4vGw== + -----END EC PRIVATE KEY----- + """)); // Register the System.Net.Http integration. options.UseSystemNetHttp(); @@ -34,6 +49,54 @@ builder.Services.AddAuthentication(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); builder.Services.AddAuthorization(); +// Configure Kestrel to listen on the 44342 port and configure it to enforce mTLS. +// +// Note: depending on the operating system, the mtls.dev.localhost +// subdomain MAY have to be manually mapped to 127.0.0.1 or ::1. +builder.Services.Configure(options => options.ListenAnyIP(44342, options => +{ + options.UseHttps(new TlsHandshakeCallbackOptions + { + OnConnection = static context => + { + using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadOnly); + + return ValueTask.FromResult(new SslServerAuthenticationOptions + { + // Require a client certificate for all the requests pointing to the mTLS subdomain. + ClientCertificateRequired = string.Equals(context.ClientHelloInfo.ServerName, + "mtls.dev.localhost", StringComparison.OrdinalIgnoreCase), + + // Ignore all the client certificate errors for requests pointing to + // the mTLS-specific domain, even if they indicate that the chain is + // invalid: this is necessary to allow OpenIddict to validate the PKI + // and self-signed certificates using its own per-client chain policies. + RemoteCertificateValidationCallback = (sender, certificate, chain, errors) => + { + if (string.Equals(context.ClientHelloInfo.ServerName, + "mtls.dev.localhost", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return errors is SslPolicyErrors.None or SslPolicyErrors.RemoteCertificateNotAvailable; + }, + + // Use the development certificate generated and stored by ASP.NET Core in the user store. + ServerCertificate = store.Certificates + .Find(X509FindType.FindByExtension, "1.3.6.1.4.1.311.84.1.1", validOnly: false) + .Cast() + .Where(static certificate => certificate.NotBefore < TimeProvider.System.GetLocalNow()) + .Where(static certificate => certificate.NotAfter > TimeProvider.System.GetLocalNow()) + .OrderByDescending(static certificate => certificate.NotAfter) + .FirstOrDefault() ?? + throw new InvalidOperationException("The ASP.NET Core HTTPS development certificate was not found.") + }); + } + }); +})); + var app = builder.Build(); app.UseCors(); @@ -47,3 +110,11 @@ app.UseWelcomePage("/"); app.Run(); + +static ECDsaSecurityKey GetECDsaSigningKey(ReadOnlySpan key) +{ + var algorithm = ECDsa.Create(); + algorithm.ImportFromPem(key); + + return new ECDsaSecurityKey(algorithm); +} diff --git a/samples/Zirku/Zirku.Api1/Properties/launchSettings.json b/samples/Zirku/Zirku.Api1/Properties/launchSettings.json index 8bbf0e53..b3726fb3 100644 --- a/samples/Zirku/Zirku.Api1/Properties/launchSettings.json +++ b/samples/Zirku/Zirku.Api1/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "Kestrel": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "https://localhost:44342/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -20,7 +20,7 @@ "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/samples/Zirku/Zirku.Api2/Program.cs b/samples/Zirku/Zirku.Api2/Program.cs index 90098852..9941ed07 100644 --- a/samples/Zirku/Zirku.Api2/Program.cs +++ b/samples/Zirku/Zirku.Api2/Program.cs @@ -1,5 +1,9 @@ -using System.Security.Claims; +using System.Net.Security; +using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.IdentityModel.Tokens; using OpenIddict.Validation.AspNetCore; @@ -38,6 +42,54 @@ builder.Services.AddAuthentication(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); builder.Services.AddAuthorization(); +// Configure Kestrel to listen on the 44379 port and configure it to enforce mTLS. +// +// Note: depending on the operating system, the mtls.dev.localhost +// subdomain MAY have to be manually mapped to 127.0.0.1 or ::1. +builder.Services.Configure(options => options.ListenAnyIP(44379, options => +{ + options.UseHttps(new TlsHandshakeCallbackOptions + { + OnConnection = static context => + { + using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadOnly); + + return ValueTask.FromResult(new SslServerAuthenticationOptions + { + // Require a client certificate for all the requests pointing to the mTLS subdomain. + ClientCertificateRequired = string.Equals(context.ClientHelloInfo.ServerName, + "mtls.dev.localhost", StringComparison.OrdinalIgnoreCase), + + // Ignore all the client certificate errors for requests pointing to + // the mTLS-specific domain, even if they indicate that the chain is + // invalid: this is necessary to allow OpenIddict to validate the PKI + // and self-signed certificates using its own per-client chain policies. + RemoteCertificateValidationCallback = (sender, certificate, chain, errors) => + { + if (string.Equals(context.ClientHelloInfo.ServerName, + "mtls.dev.localhost", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return errors is SslPolicyErrors.None or SslPolicyErrors.RemoteCertificateNotAvailable; + }, + + // Use the development certificate generated and stored by ASP.NET Core in the user store. + ServerCertificate = store.Certificates + .Find(X509FindType.FindByExtension, "1.3.6.1.4.1.311.84.1.1", validOnly: false) + .Cast() + .Where(static certificate => certificate.NotBefore < TimeProvider.System.GetLocalNow()) + .Where(static certificate => certificate.NotAfter > TimeProvider.System.GetLocalNow()) + .OrderByDescending(static certificate => certificate.NotAfter) + .FirstOrDefault() ?? + throw new InvalidOperationException("The ASP.NET Core HTTPS development certificate was not found.") + }); + } + }); +})); + var app = builder.Build(); app.UseCors(); diff --git a/samples/Zirku/Zirku.Api2/Properties/launchSettings.json b/samples/Zirku/Zirku.Api2/Properties/launchSettings.json index 27ef2f64..77d5d4dc 100644 --- a/samples/Zirku/Zirku.Api2/Properties/launchSettings.json +++ b/samples/Zirku/Zirku.Api2/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "Kestrel": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "https://localhost:44379/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -20,7 +20,7 @@ "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/samples/Zirku/Zirku.Client1/InteractiveService.cs b/samples/Zirku/Zirku.Client1/InteractiveService.cs index 1b2b3a74..333f855e 100644 --- a/samples/Zirku/Zirku.Client1/InteractiveService.cs +++ b/samples/Zirku/Zirku.Client1/InteractiveService.cs @@ -1,5 +1,8 @@ using System.Net; using System.Net.Http.Headers; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.Hosting; using OpenIddict.Client; using static OpenIddict.Abstractions.OpenIddictConstants; @@ -32,6 +35,17 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) Console.WriteLine("Press any key to start the authentication process."); await Task.Run(Console.ReadKey).WaitAsync(stoppingToken); + // Note: the OpenIddict server stack supports mTLS-based token binding for public clients: + // while these clients cannot authenticate using a TLS client certificate, the certificate + // can be used to bind the refresh (and access) tokens returned by the authorization server + // to the client application, which prevents such tokens from being used without providing a + // proof-of-possession matching the TLS client certificate used when the token was acquired. + // + // While this sample deliberately doesn't store the generated certificate in a persistent + // location, the certificate used for token binding should typically be stored in the user + // certificate store to be reloaded across application restarts in a real-world application. + var certificate = GenerateEphemeralTlsClientCertificate(); + try { // Ask OpenIddict to initiate the authentication flow (typically, by starting the system browser). @@ -49,13 +63,23 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) // Wait for the user to complete the authorization process. var response = await _service.AuthenticateInteractivelyAsync(new() { - Nonce = result.Nonce + Nonce = result.Nonce, + TokenBindingCertificate = certificate }); + // Note: since the authorization server is configured to issue certificate-bound + // access tokens, the client certificate MUST be attached to outgoing HTTP requests + // and the mTLS subdomain (for which TLS client authentication is enabled) MUST be used. + using var handler = new HttpClientHandler + { + ClientCertificateOptions = ClientCertificateOption.Manual, + ClientCertificates = { certificate } + }; + Console.WriteLine("Response from Api1: {0}", await GetResourceFromApi1Async( - (response.BackchannelAccessToken ?? response.FrontchannelAccessToken)!, stoppingToken)); + (response.BackchannelAccessToken ?? response.FrontchannelAccessToken)!, handler, stoppingToken)); Console.WriteLine("Response from Api2: {0}", await GetResourceFromApi2Async( - (response.BackchannelAccessToken ?? response.FrontchannelAccessToken)!, stoppingToken)); + (response.BackchannelAccessToken ?? response.FrontchannelAccessToken)!, handler, stoppingToken)); } catch (OperationCanceledException) @@ -68,42 +92,78 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) Console.WriteLine("The authorization was denied by the end user."); } + catch (HttpRequestException exception) when (exception.StatusCode is HttpStatusCode.Forbidden) + { + Console.WriteLine("The user is not allowed to perform the requested action."); + } + + catch (HttpRequestException exception) when (exception.StatusCode is HttpStatusCode.Unauthorized) + { + Console.WriteLine("The access token is invalid, has expired or has been revoked."); + } + catch { Console.WriteLine("An error occurred while trying to authenticate the user."); } - static async Task GetResourceFromApi1Async(string token, CancellationToken cancellationToken) + static X509Certificate2 GenerateEphemeralTlsClientCertificate() { - using var client = new HttpClient(); + using var algorithm = RSA.Create(keySizeInBits: 4096); + + var subject = new X500DistinguishedName("CN=Self-signed certificate"); + var request = new CertificateRequest(subject, algorithm, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension([new Oid("1.3.6.1.5.5.7.3.2")], critical: true)); + + var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(2)); + + // On Windows, a certificate loaded from PEM-encoded material is ephemeral and + // cannot be directly used with TLS, as Schannel cannot access it in this case. + // + // To work this limitation, the certificate is exported and re-imported from a + // PFX blob to ensure the private key is persisted in a way that Schannel can use. + // + // In a real world application, the certificate wouldn't be embedded in the source code + // and would be installed in the certificate store, making this workaround unnecessary. + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + certificate = X509CertificateLoader.LoadPkcs12( + data: certificate.Export(X509ContentType.Pfx, string.Empty), + password: string.Empty, + keyStorageFlags: X509KeyStorageFlags.DefaultKeySet); + } - using var request = new HttpRequestMessage(HttpMethod.Get, "https://localhost:44342/api"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + return certificate; + } - using var response = await client.SendAsync(request, cancellationToken); - if (response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized) + async Task GetResourceFromApi1Async(string token, HttpClientHandler handler, CancellationToken cancellationToken) + { + using var client = new HttpClient(handler, disposeHandler: false) { - return "The user represented by the access token is not allowed to access Api1."; - } + BaseAddress = new Uri("https://mtls.dev.localhost:44342/", UriKind.Absolute) + }; + + using var request = new HttpRequestMessage(HttpMethod.Get, "api"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + using var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(cancellationToken); } - static async Task GetResourceFromApi2Async(string token, CancellationToken cancellationToken) + async Task GetResourceFromApi2Async(string token, HttpClientHandler handler, CancellationToken cancellationToken) { - using var client = new HttpClient(); + using var client = new HttpClient(handler, disposeHandler: false) + { + BaseAddress = new Uri("https://mtls.dev.localhost:44379/", UriKind.Absolute) + }; - using var request = new HttpRequestMessage(HttpMethod.Get, "https://localhost:44379/api"); + using var request = new HttpRequestMessage(HttpMethod.Get, "api"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); using var response = await client.SendAsync(request, cancellationToken); - if (response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized) - { - return "The user represented by the access token is not allowed to access Api2."; - } - response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(cancellationToken); diff --git a/samples/Zirku/Zirku.Server/Program.cs b/samples/Zirku/Zirku.Server/Program.cs index 40e07e34..95be3b70 100644 --- a/samples/Zirku/Zirku.Server/Program.cs +++ b/samples/Zirku/Zirku.Server/Program.cs @@ -1,8 +1,13 @@ using System.Globalization; +using System.Net.Security; using System.Security.Claims; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using OpenIddict.Abstractions; @@ -48,15 +53,15 @@ .AddServer(options => { // Enable the authorization, introspection and token endpoints. - options.SetAuthorizationEndpointUris("authorize") - .SetIntrospectionEndpointUris("introspect") - .SetTokenEndpointUris("token"); + options.SetAuthorizationEndpointUris("connect/authorize") + .SetIntrospectionEndpointUris("connect/introspect") + .SetTokenEndpointUris("connect/token"); // Note: this sample only uses the authorization code and refresh token // flows but you can enable the other flows if you need to support implicit, // password or client credentials. options.AllowAuthorizationCodeFlow() - .AllowRefreshTokenFlow(); + .AllowRefreshTokenFlow(); // Register the encryption credentials. This sample uses a symmetric // encryption key that is shared between the server and the Api2 sample @@ -70,6 +75,33 @@ // Register the signing credentials. options.AddDevelopmentSigningCertificate(); + // Note: setting a static issuer is mandatory when using mTLS aliases to ensure it not + // dynamically computed based on the request URI, as this would result in two different + // issuers being used (one pointing to the mTLS domain and one pointing to the regular one). + options.SetIssuer("https://localhost:44319/"); + + // Enable self_signed_tls_client_auth to allow clients to use mTLS-based token binding. + options.EnableSelfSignedTlsClientAuthentication(); + + // Configure the mTLS endpoint aliases that will be used by client applications opting + // for TLS-based client authentication to communicate with the authorization server: + // the configured URIs MUST point to a domain for which the HTTPS server is configured + // to require the use of client certificates when receiving TLS handshakes from clients. + options.SetMtlsIntrospectionEndpointAliasUri("https://mtls.dev.localhost:44319/connect/introspect") + .SetMtlsTokenEndpointAliasUri("https://mtls.dev.localhost:44319/connect/token"); + + // While public client applications cannot use mTLS for client authentication, they can use + // mTLS purely as a token binding mechanism: in this case, the refresh tokens issued to + // public clients sending a client certificate are automatically bound to the certificate, + // which requires sending the same certificate when using them to get new access tokens. + options.UseClientCertificateBoundRefreshTokens(); + + // Optionally, the server stack can be configured to issue client certificate-bound access tokens. + // + // When doing so, the standard "cnf" claim is automatically added to access tokens to inform + // resource servers that a proof of possession derived from the certificate must be provided. + options.UseClientCertificateBoundAccessTokens(); + // Register the ASP.NET Core host and configure the ASP.NET Core-specific options. // // Note: unlike other samples, this sample doesn't use token endpoint pass-through @@ -91,6 +123,54 @@ options.UseAspNetCore(); }); +// Configure Kestrel to listen on the 44319 port and configure it to enforce mTLS. +// +// Note: depending on the operating system, the mtls.dev.localhost +// subdomain MAY have to be manually mapped to 127.0.0.1 or ::1. +builder.Services.Configure(options => options.ListenAnyIP(44319, options => +{ + options.UseHttps(new TlsHandshakeCallbackOptions + { + OnConnection = static context => + { + using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadOnly); + + return ValueTask.FromResult(new SslServerAuthenticationOptions + { + // Require a client certificate for all the requests pointing to the mTLS subdomain. + ClientCertificateRequired = string.Equals(context.ClientHelloInfo.ServerName, + "mtls.dev.localhost", StringComparison.OrdinalIgnoreCase), + + // Ignore all the client certificate errors for requests pointing to + // the mTLS-specific domain, even if they indicate that the chain is + // invalid: this is necessary to allow OpenIddict to validate the PKI + // and self-signed certificates using its own per-client chain policies. + RemoteCertificateValidationCallback = (sender, certificate, chain, errors) => + { + if (string.Equals(context.ClientHelloInfo.ServerName, + "mtls.dev.localhost", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return errors is SslPolicyErrors.None or SslPolicyErrors.RemoteCertificateNotAvailable; + }, + + // Use the development certificate generated and stored by ASP.NET Core in the user store. + ServerCertificate = store.Certificates + .Find(X509FindType.FindByExtension, "1.3.6.1.4.1.311.84.1.1", validOnly: false) + .Cast() + .Where(static certificate => certificate.NotBefore < TimeProvider.System.GetLocalNow()) + .Where(static certificate => certificate.NotAfter > TimeProvider.System.GetLocalNow()) + .OrderByDescending(static certificate => certificate.NotAfter) + .FirstOrDefault() ?? + throw new InvalidOperationException("The ASP.NET Core HTTPS development certificate was not found.") + }); + } + }); +})); + builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy.AllowAnyHeader() .AllowAnyMethod() @@ -106,10 +186,10 @@ app.UseAuthentication(); app.UseAuthorization(); -app.MapGet("api", [Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] -(ClaimsPrincipal user) => user.Identity!.Name); +app.MapGet("api", + [Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)] (ClaimsPrincipal user) => user.Identity!.Name); -app.MapMethods("authorize", [HttpMethods.Get, HttpMethods.Post], async (HttpContext context, IOpenIddictScopeManager manager) => +app.MapMethods("connect/authorize", [HttpMethods.Get, HttpMethods.Post], async (HttpContext context, IOpenIddictScopeManager manager) => { // Retrieve the OpenIddict server request from the HTTP context. var request = context.GetOpenIddictServerRequest() ?? @@ -248,7 +328,23 @@ await manager.CreateAsync(new OpenIddictApplicationDescriptor await manager.CreateAsync(new OpenIddictApplicationDescriptor { ClientId = "resource_server_1", - ClientSecret = "846B62D0-DEF9-4215-A99D-86E6B8DAB342", + JsonWebKeySet = new JsonWebKeySet + { + Keys = + { + // Note: instead of sending a client secret, this application authenticates by + // generating client assertions that are signed using an ECDSA signing key. + // + // Note: while the client needs access to the private key, the server only needs + // to know the public key to be able to validate the client assertions it receives. + JsonWebKeyConverter.ConvertFromECDsaSecurityKey(GetECDsaSigningKey($""" + -----BEGIN PUBLIC KEY----- + MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAExEBWSim0vOd/397ejnxjXGhlMG8d + O+JAMsx3054Tuf/ogyvfhUE8COGfMZvKv5lcsyDw9YwwwJThZny5qs4vGw== + -----END PUBLIC KEY----- + """)) + } + }, Permissions = { Permissions.Endpoints.Introspection @@ -291,3 +387,11 @@ await manager.CreateAsync(new OpenIddictScopeDescriptor } await app.RunAsync(); + +static ECDsaSecurityKey GetECDsaSigningKey(ReadOnlySpan key) +{ + var algorithm = ECDsa.Create(); + algorithm.ImportFromPem(key); + + return new ECDsaSecurityKey(algorithm); +} diff --git a/samples/Zirku/Zirku.Server/Properties/launchSettings.json b/samples/Zirku/Zirku.Server/Properties/launchSettings.json index 9e07113c..59fb3559 100644 --- a/samples/Zirku/Zirku.Server/Properties/launchSettings.json +++ b/samples/Zirku/Zirku.Server/Properties/launchSettings.json @@ -11,7 +11,7 @@ "profiles": { "Kestrel": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "applicationUrl": "https://localhost:44319/", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" @@ -20,7 +20,7 @@ "IIS Express": { "commandName": "IISExpress", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }