Skip to content

Commit 7b9fd76

Browse files
committed
Revamp the client authentication method negotiation logic and support mTLS token binding in the client, server and validation stacks
1 parent fbb0468 commit 7b9fd76

File tree

80 files changed

+3692
-2349
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

80 files changed

+3692
-2349
lines changed

gen/OpenIddict.Client.WebIntegration.Generators/OpenIddictClientWebIntegrationGenerator.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,18 @@ public sealed partial class {{ provider.name }}
285285
return Set(registration => registration.ClientSecret = secret);
286286
}
287287
288+
/// <summary>
289+
/// Sets the client type (typically, ""public"" or ""confidential"").
290+
/// </summary>
291+
/// <param name=""type"">The client type.</param>
292+
/// <returns>The <see cref=""OpenIddictClientWebIntegrationBuilder.{{ provider.name }}""/> instance.</returns>
293+
public {{ provider.name }} SetClientType(string type)
294+
{
295+
ArgumentException.ThrowIfNullOrEmpty(type);
296+
297+
return Set(registration => registration.ClientType = type);
298+
}
299+
288300
/// <summary>
289301
/// Sets the post-logout redirection URI, if applicable.
290302
/// </summary>

sandbox/OpenIddict.Sandbox.AspNetCore.Client/Controllers/HomeController.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ public async Task<ActionResult> GetMessage(CancellationToken cancellationToken)
3939
// authentication options shouldn't be used, a specific scheme can be specified here.
4040
var token = await HttpContext.GetTokenAsync(Tokens.BackchannelAccessToken);
4141

42-
using var client = _httpClientFactory.CreateClient();
42+
using var client = _httpClientFactory.CreateClient("ApiClient");
4343

44-
using var request = new HttpRequestMessage(HttpMethod.Get, "https://localhost:44395/api/message");
44+
using var request = new HttpRequestMessage(HttpMethod.Get, "api/message");
4545
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
4646

4747
using var response = await client.SendAsync(request, cancellationToken);

sandbox/OpenIddict.Sandbox.AspNetCore.Client/Startup.cs

Lines changed: 195 additions & 180 deletions
Large diffs are not rendered by default.

sandbox/OpenIddict.Sandbox.AspNetCore.Server/Startup.cs

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ public void ConfigureServices(IServiceCollection services)
168168
// to authenticate using either PKI certificates or self-signed certificates.
169169
//
170170
// Note: PKI and self-signed certificate authentication can be enabled independently.
171-
options.EnablePublicKeyInfrastructureClientCertificateAuthentication(
171+
options.EnablePublicKeyInfrastructureTlsClientAuthentication(
172172
[
173173
// Root certificate:
174174
X509Certificate2.CreateFromPem($"""
@@ -239,12 +239,11 @@ public void ConfigureServices(IServiceCollection services)
239239
""")
240240
]);
241241

242-
options.EnableSelfSignedClientCertificateAuthentication();
242+
options.EnableSelfSignedTlsClientAuthentication();
243243

244-
// Note: setting a static issuer is mandatory when using mTLS aliases
245-
// to ensure it is not dynamically computed based on the request URI,
246-
// as this would result in two different issuers being used (one
247-
// pointing to the mTLS domain and one pointing to the regular one).
244+
// Note: setting a static issuer is mandatory when using mTLS aliases to ensure it not
245+
// dynamically computed based on the request URI, as this would result in two different
246+
// issuers being used (one pointing to the mTLS domain and one pointing to the regular one).
248247
options.SetIssuer("https://localhost:44395/");
249248

250249
// Configure the mTLS endpoint aliases that will be used by client applications opting
@@ -260,7 +259,20 @@ public void ConfigureServices(IServiceCollection services)
260259
.SetMtlsIntrospectionEndpointAliasUri("https://mtls.dev.localhost:44395/connect/introspect")
261260
.SetMtlsPushedAuthorizationEndpointAliasUri("https://mtls.dev.localhost:44395/connect/par")
262261
.SetMtlsRevocationEndpointAliasUri("https://mtls.dev.localhost:44395/connect/revoke")
263-
.SetMtlsTokenEndpointAliasUri("https://mtls.dev.localhost:44395/connect/token");
262+
.SetMtlsTokenEndpointAliasUri("https://mtls.dev.localhost:44395/connect/token")
263+
.SetMtlsUserInfoEndpointAliasUri("https://mtls.dev.localhost:44395/connect/userinfo");
264+
265+
// While public client applications cannot use mTLS for client authentication, they can use
266+
// mTLS purely as a token binding mechanism: in this case, the refresh tokens issued to
267+
// public clients sending a client certificate are automatically bound to the certificate,
268+
// which requires sending the same certificate when using them to get new access tokens.
269+
options.UseClientCertificateBoundRefreshTokens();
270+
271+
// Optionally, the server stack can be configured to issue client certificate-bound access tokens.
272+
//
273+
// When doing so, the standard "cnf" claim is automatically added to access tokens to inform
274+
// resource servers that a proof of possession derived from the certificate must be provided.
275+
options.UseClientCertificateBoundAccessTokens();
264276
#endif
265277
})
266278

sandbox/OpenIddict.Sandbox.Console.Client/InteractiveService.cs

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
using System.Security.Claims;
1+
using System.Runtime.InteropServices;
2+
using System.Security.Claims;
3+
using System.Security.Cryptography;
4+
using System.Security.Cryptography.X509Certificates;
25
using Microsoft.Extensions.Hosting;
36
using OpenIddict.Abstractions;
47
using OpenIddict.Client;
@@ -148,6 +151,19 @@ await _service.AuthenticateInteractivelyAsync(new()
148151
var type = await GetSelectedGrantTypeAsync(registration, configuration, stoppingToken);
149152
if (type is GrantTypes.DeviceCode)
150153
{
154+
// Note: the OpenIddict server stack supports mTLS-based token binding for public clients:
155+
// while these clients cannot authenticate using a TLS client certificate, the certificate
156+
// can be used to bind the refresh (and access) tokens returned by the authorization server
157+
// to the client application, which prevents such tokens from being used without providing a
158+
// proof-of-possession matching the TLS client certificate used when the token was acquired.
159+
//
160+
// While this sample deliberately doesn't store the generated certificate in a persistent
161+
// location, the certificate used for token binding should typically be stored in the user
162+
// certificate store to be reloaded across application restarts in a real-world application.
163+
var certificate = configuration.TlsClientCertificateBoundAccessTokens is true
164+
? GenerateEphemeralTlsClientCertificate()
165+
: null;
166+
151167
// Ask OpenIddict to send a device authorization request and write
152168
// the complete verification endpoint URI to the console output.
153169
var result = await _service.ChallengeUsingDeviceAsync(new()
@@ -181,7 +197,8 @@ [yellow]Please visit [link]{result.VerificationUri}[/] and enter
181197
DeviceCode = result.DeviceCode,
182198
Interval = result.Interval,
183199
ProviderName = provider,
184-
Timeout = result.ExpiresIn < TimeSpan.FromMinutes(5) ? result.ExpiresIn : TimeSpan.FromMinutes(5)
200+
Timeout = result.ExpiresIn < TimeSpan.FromMinutes(5) ? result.ExpiresIn : TimeSpan.FromMinutes(5),
201+
TokenBindingCertificate = certificate
185202
});
186203

187204
AnsiConsole.MarkupLine("[green]Device authentication successful:[/]");
@@ -223,7 +240,8 @@ await _service.RevokeTokenAsync(new()
223240
{
224241
CancellationToken = stoppingToken,
225242
ProviderName = provider,
226-
RefreshToken = response.RefreshToken
243+
RefreshToken = response.RefreshToken,
244+
TokenBindingCertificate = certificate
227245
})).Principal));
228246
}
229247
}
@@ -232,6 +250,10 @@ await _service.RevokeTokenAsync(new()
232250
{
233251
var (username, password) = (await GetUsernameAsync(stoppingToken), await GetPasswordAsync(stoppingToken));
234252

253+
var certificate = configuration.TlsClientCertificateBoundAccessTokens is true
254+
? GenerateEphemeralTlsClientCertificate()
255+
: null;
256+
235257
AnsiConsole.MarkupLine("[cyan]Sending the token request.[/]");
236258

237259
// Ask OpenIddict to authenticate the user using the resource owner password credentials grant.
@@ -241,7 +263,8 @@ await _service.RevokeTokenAsync(new()
241263
ProviderName = provider,
242264
Username = username,
243265
Password = password,
244-
Scopes = [Scopes.OfflineAccess]
266+
Scopes = [Scopes.OfflineAccess],
267+
TokenBindingCertificate = certificate
245268
});
246269

247270
AnsiConsole.MarkupLine("[green]Resource owner password credentials authentication successful:[/]");
@@ -283,7 +306,8 @@ await _service.RevokeTokenAsync(new()
283306
{
284307
CancellationToken = stoppingToken,
285308
ProviderName = provider,
286-
RefreshToken = response.RefreshToken
309+
RefreshToken = response.RefreshToken,
310+
TokenBindingCertificate = certificate
287311
})).Principal));
288312
}
289313
}
@@ -309,6 +333,10 @@ await GetRequestedTokenTypeAsync(stoppingToken),
309333
await GetSubjectTokenAsync(stoppingToken),
310334
await GetActorTokenAsync(stoppingToken));
311335

336+
var certificate = configuration.TlsClientCertificateBoundAccessTokens is true
337+
? GenerateEphemeralTlsClientCertificate()
338+
: null;
339+
312340
AnsiConsole.MarkupLine("[cyan]Sending the token request.[/]");
313341

314342
// Ask OpenIddict to send the specified subject token (and actor token, if available).
@@ -320,7 +348,8 @@ await GetSubjectTokenAsync(stoppingToken),
320348
ProviderName = provider,
321349
RequestedTokenType = identifier,
322350
SubjectToken = subject.Token,
323-
SubjectTokenType = subject.TokenType
351+
SubjectTokenType = subject.TokenType,
352+
TokenBindingCertificate = certificate
324353
});
325354

326355
AnsiConsole.MarkupLine("[green]Token exchange authentication successful:[/]");
@@ -368,7 +397,8 @@ await RefreshTokenAsync(stoppingToken))
368397
{
369398
CancellationToken = stoppingToken,
370399
ProviderName = provider,
371-
RefreshToken = response.IssuedToken
400+
RefreshToken = response.IssuedToken,
401+
TokenBindingCertificate = certificate
372402
})).Principal));
373403
}
374404

@@ -381,7 +411,8 @@ await RefreshTokenAsync(stoppingToken))
381411
{
382412
CancellationToken = stoppingToken,
383413
ProviderName = provider,
384-
RefreshToken = response.RefreshToken
414+
RefreshToken = response.RefreshToken,
415+
TokenBindingCertificate = certificate
385416
})).Principal));
386417
}
387418
}
@@ -800,5 +831,44 @@ static bool Prompt() => AnsiConsole.Prompt(new ConfirmationPrompt(
800831

801832
return Task.Run(Prompt, cancellationToken).WaitAsync(cancellationToken);
802833
}
834+
835+
#if SUPPORTS_CERTIFICATE_GENERATION
836+
static X509Certificate2 GenerateEphemeralTlsClientCertificate()
837+
{
838+
using var algorithm = RSA.Create(keySizeInBits: 4096);
839+
840+
var subject = new X500DistinguishedName("CN=Self-signed certificate");
841+
var request = new CertificateRequest(subject, algorithm, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
842+
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, critical: true));
843+
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension([new Oid("1.3.6.1.5.5.7.3.2")], critical: true));
844+
845+
var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(2));
846+
847+
// On Windows, a certificate loaded from PEM-encoded material is ephemeral and
848+
// cannot be directly used with TLS, as Schannel cannot access it in this case.
849+
//
850+
// To work this limitation, the certificate is exported and re-imported from a
851+
// PFX blob to ensure the private key is persisted in a way that Schannel can use.
852+
//
853+
// In a real world application, the certificate wouldn't be embedded in the source code
854+
// and would be installed in the certificate store, making this workaround unnecessary.
855+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
856+
{
857+
#if SUPPORTS_CERTIFICATE_LOADER
858+
certificate = X509CertificateLoader.LoadPkcs12(
859+
data: certificate.Export(X509ContentType.Pfx, string.Empty),
860+
password: string.Empty,
861+
keyStorageFlags: X509KeyStorageFlags.DefaultKeySet);
862+
#else
863+
certificate = new X509Certificate2(
864+
rawData: certificate.Export(X509ContentType.Pfx, string.Empty),
865+
password: string.Empty,
866+
keyStorageFlags: X509KeyStorageFlags.DefaultKeySet);
867+
#endif
868+
}
869+
870+
return certificate;
871+
}
872+
#endif
803873
}
804874
}

shared/OpenIddict.Extensions/OpenIddictHelpers.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,22 @@ public static bool IsSelfIssuedCertificate(X509Certificate2 certificate)
11041104
return certificate.SubjectName.RawData.AsSpan().SequenceEqual(certificate.IssuerName.RawData);
11051105
}
11061106

1107+
/// <summary>
1108+
/// Determines whether the specified <paramref name="certificate"/> is suitable for client authentication.
1109+
/// </summary>
1110+
/// <param name="certificate">The <see cref="X509Certificate2"/>.</param>
1111+
/// <returns>
1112+
/// <see langword="true"/> if the certificate is suitable for client authentication, <see langword="false"/> otherwise.
1113+
/// </returns>
1114+
public static bool IsClientAuthenticationCertificate(X509Certificate2 certificate)
1115+
{
1116+
ArgumentNullException.ThrowIfNull(certificate);
1117+
1118+
return certificate.Version is >= 3 &&
1119+
OpenIddictHelpers.HasKeyUsage(certificate, X509KeyUsageFlags.DigitalSignature) &&
1120+
OpenIddictHelpers.HasExtendedKeyUsage(certificate, ObjectIdentifiers.ExtendedKeyUsages.ClientAuthentication);
1121+
}
1122+
11071123
/// <summary>
11081124
/// Determines whether the items contained in <paramref name="element"/>
11091125
/// are of the specified <paramref name="kind"/>.

shared/OpenIddict.Extensions/OpenIddictPolyfills.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
using System.Runtime.CompilerServices;
99
using System.Runtime.InteropServices;
1010
using System.Runtime.Versioning;
11-
using System.Security.Cryptography;
1211
using System.Security.Cryptography.X509Certificates;
1312

1413
namespace OpenIddict.Extensions;

0 commit comments

Comments
 (0)