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"
}