Skip to content

Commit 59a137c

Browse files
committed
Allow using mTLS client authentication with the client credentials grant and support mTLS token binding for anonymous clients
1 parent eb65bfb commit 59a137c

File tree

10 files changed

+345
-41
lines changed

10 files changed

+345
-41
lines changed

src/OpenIddict.Abstractions/OpenIddictResources.resx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1860,6 +1860,12 @@ To use a custom policy relying on the system store, set 'OpenIddictServerOptions
18601860
<data name="ID0514" xml:space="preserve">
18611861
<value>TLS client certificates must contain a private key.</value>
18621862
</data>
1863+
<data name="ID0515" xml:space="preserve">
1864+
<value>An existing '{0}' instance is already attached to the execution context.</value>
1865+
</data>
1866+
<data name="ID0516" xml:space="preserve">
1867+
<value>The '{0}' attached to the execution context could not be resolved.</value>
1868+
</data>
18631869
<data name="ID2000" xml:space="preserve">
18641870
<value>The security token is missing.</value>
18651871
</data>
@@ -2462,16 +2468,21 @@ To use a custom policy relying on the system store, set 'OpenIddictServerOptions
24622468
</data>
24632469
<data name="ID2201" xml:space="preserve">
24642470
<value>An existing '{0}' instance is already attached to the execution context.</value>
2471+
<comment>This resource is no longer used and will be removed in a future version.</comment>
24652472
</data>
24662473
<data name="ID2202" xml:space="preserve">
24672474
<value>The '{0}' attached to the execution context could not be resolved.</value>
2475+
<comment>This resource is no longer used and will be removed in a future version.</comment>
24682476
</data>
24692477
<data name="ID2203" xml:space="preserve">
24702478
<value>A certificate-based proof-of-possession is required to use this token.</value>
24712479
</data>
24722480
<data name="ID2204" xml:space="preserve">
24732481
<value>The specified certificate-based proof-of-possession is not valid.</value>
24742482
</data>
2483+
<data name="ID2205" xml:space="preserve">
2484+
<value>The specified TLS client certificate is not allowed or valid for this operation.</value>
2485+
</data>
24752486
<data name="ID4000" xml:space="preserve">
24762487
<value>The '{0}' parameter shouldn't be null or empty at this point.</value>
24772488
</data>
@@ -3315,6 +3326,9 @@ This may indicate that the hashed entry is corrupted or malformed.</value>
33153326
<data name="ID6292" xml:space="preserve">
33163327
<value>The payload of the '{0}' reference token was used instead of its reference identifier, which may indicate that the payload stored in the database has leaked and is being used as a regular token.</value>
33173328
</data>
3329+
<data name="ID6293" xml:space="preserve">
3330+
<value>Certificate validation failed because the token binding certificate provided by an anonymous client was not valid: {Errors}.</value>
3331+
</data>
33183332
<data name="ID8000" xml:space="preserve">
33193333
<value>https://documentation.openiddict.com/errors/{0}</value>
33203334
</data>

src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpConfiguration.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ public void Configure(string? name, HttpClientFactoryOptions options)
7878
// an async-local context to flow per-instance properties and uses dynamic client
7979
// names to ensure the inner HttpClientHandler is not reused if the context differs.
8080
var context = OpenIddictClientSystemNetHttpContext.Current ??
81-
throw new InvalidOperationException(SR.FormatID2202(nameof(OpenIddictClientSystemNetHttpContext)));
81+
throw new InvalidOperationException(SR.FormatID0516(nameof(OpenIddictClientSystemNetHttpContext)));
8282

8383
var settings = _provider.GetRequiredService<IOptionsMonitor<OpenIddictClientSystemNetHttpOptions>>().CurrentValue;
8484

src/OpenIddict.Client.SystemNetHttp/OpenIddictClientSystemNetHttpHandlers.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ public ValueTask HandleAsync(TContext context)
200200

201201
if (OpenIddictClientSystemNetHttpContext.Current is not null)
202202
{
203-
throw new InvalidOperationException(SR.FormatID2201(nameof(OpenIddictClientSystemNetHttpContext)));
203+
throw new InvalidOperationException(SR.FormatID0515(nameof(OpenIddictClientSystemNetHttpContext)));
204204
}
205205

206206
try

src/OpenIddict.Server/OpenIddictServerBuilder.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,11 @@ public OpenIddictServerBuilder Configure(Action<OpenIddictServerOptions> configu
116116
/// <summary>
117117
/// Makes client identification optional so that token, introspection and revocation
118118
/// requests that don't specify a client_id are not automatically rejected.
119-
/// Enabling this option is NOT recommended.
120119
/// </summary>
120+
/// <remarks>
121+
/// Enabling this option is NOT recommended and should only be used for
122+
/// backward compatibility with legacy authorization server deployments.
123+
/// </remarks>
121124
/// <returns>The <see cref="OpenIddictServerBuilder"/> instance.</returns>
122125
public OpenIddictServerBuilder AcceptAnonymousClients()
123126
=> Configure(options => options.AcceptAnonymousClients = true);

src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -412,30 +412,18 @@ public ValueTask HandleAsync(ValidateTokenRequestContext context)
412412
{
413413
ArgumentNullException.ThrowIfNull(context);
414414

415-
// Reject grant_type=authorization_code requests that don't specify a client_id or a client_assertion,
416-
// as the client identifier MUST be sent by the client application in the request body if it cannot
417-
// be inferred from the client authentication method (e.g the username when using basic).
418-
//
419-
// See https://tools.ietf.org/html/rfc6749#section-4.1.3 for more information.
420-
if (context.Request.IsAuthorizationCodeGrantType() &&
421-
string.IsNullOrEmpty(context.Request.ClientId) &&
422-
string.IsNullOrEmpty(context.Request.ClientAssertion))
415+
if (!context.Request.IsAuthorizationCodeGrantType() && !context.Request.IsClientCredentialsGrantType())
423416
{
424-
context.Logger.LogInformation(6077, SR.GetResourceString(SR.ID6077), Parameters.ClientId);
425-
426-
context.Reject(
427-
error: Errors.InvalidRequest,
428-
description: SR.FormatID2029(Parameters.ClientId),
429-
uri: SR.FormatID8000(SR.ID2029));
430-
431417
return ValueTask.CompletedTask;
432418
}
433419

434-
// Reject grant_type=client_credentials requests that don't specify a client_id or a client_assertion.
420+
// Reject grant_type=authorization_code and grant_type=client_credentials requests that
421+
// don't specify a client_id or a client_assertion, as the client identity MUST be sent
422+
// by the client application (even when using mTLS OAuth 2.0 client authentication).
435423
//
436-
// See https://tools.ietf.org/html/rfc6749#section-4.4.1 for more information.
437-
if (context.Request.IsClientCredentialsGrantType() &&
438-
string.IsNullOrEmpty(context.Request.ClientId) &&
424+
// See https://tools.ietf.org/html/rfc6749#section-4.1.3
425+
// and https://tools.ietf.org/html/rfc6749#section-4.4.1 for more information.
426+
if (string.IsNullOrEmpty(context.Request.ClientId) &&
439427
string.IsNullOrEmpty(context.Request.ClientAssertion))
440428
{
441429
context.Logger.LogInformation(6077, SR.GetResourceString(SR.ID6077), Parameters.ClientId);
@@ -568,7 +556,8 @@ public ValueTask HandleAsync(ValidateTokenRequestContext context)
568556
// See https://tools.ietf.org/html/rfc6749#section-4.4.1 for more information.
569557
if (context.Request.IsClientCredentialsGrantType() &&
570558
string.IsNullOrEmpty(context.Request.ClientAssertion) &&
571-
string.IsNullOrEmpty(context.Request.ClientSecret))
559+
string.IsNullOrEmpty(context.Request.ClientSecret) &&
560+
context.Transaction.RemoteCertificate is null)
572561
{
573562
context.Reject(
574563
error: Errors.InvalidRequest,

src/OpenIddict.Server/OpenIddictServerHandlers.cs

Lines changed: 133 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using System.Diagnostics;
1010
using System.Globalization;
1111
using System.Security.Claims;
12+
using System.Security.Cryptography;
1213
using System.Security.Cryptography.X509Certificates;
1314
using System.Text;
1415
using System.Text.Json;
@@ -1004,7 +1005,13 @@ public async ValueTask HandleAsync(ProcessAuthenticationContext context)
10041005

10051006
case OpenIddictServerEndpointType.Introspection when context.Options.AcceptAnonymousClients:
10061007
case OpenIddictServerEndpointType.Revocation when context.Options.AcceptAnonymousClients:
1007-
case OpenIddictServerEndpointType.Token when context.Options.AcceptAnonymousClients:
1008+
return;
1009+
1010+
// Note: the authorization code and client credentials grant types never
1011+
// allow anonymous clients, even if the corresponding option is enabled.
1012+
case OpenIddictServerEndpointType.Token when context.Options.AcceptAnonymousClients &&
1013+
!context.Request.IsAuthorizationCodeGrantType() &&
1014+
!context.Request.IsClientCredentialsGrantType():
10081015
return;
10091016

10101017
// Note: despite being conceptually similar to the token endpoint, the pushed authorization
@@ -1240,9 +1247,9 @@ OpenIddictServerEndpointType.EndUserVerification or
12401247
/// </summary>
12411248
public sealed class ValidateClientCertificate : IOpenIddictServerHandler<ProcessAuthenticationContext>
12421249
{
1243-
private readonly IOpenIddictApplicationManager _applicationManager;
1250+
private readonly IOpenIddictApplicationManager? _applicationManager;
12441251

1245-
public ValidateClientCertificate() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016));
1252+
public ValidateClientCertificate() { }
12461253

12471254
public ValidateClientCertificate(IOpenIddictApplicationManager applicationManager)
12481255
=> _applicationManager = applicationManager ?? throw new ArgumentNullException(nameof(applicationManager));
@@ -1252,9 +1259,18 @@ public ValidateClientCertificate(IOpenIddictApplicationManager applicationManage
12521259
/// </summary>
12531260
public static OpenIddictServerHandlerDescriptor Descriptor { get; }
12541261
= OpenIddictServerHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
1255-
.AddFilter<RequireClientIdParameter>()
12561262
.AddFilter<RequireClientCertificate>()
1257-
.AddFilter<RequireDegradedModeDisabled>()
1263+
.UseScopedHandler(static provider =>
1264+
{
1265+
// Note: the application manager is only resolved if the degraded mode was not enabled to ensure
1266+
// invalid core configuration exceptions are not thrown even if the managers were registered.
1267+
var options = provider.GetRequiredService<IOptionsMonitor<OpenIddictServerOptions>>().CurrentValue;
1268+
1269+
return options.EnableDegradedMode ?
1270+
new ValidateClientCertificate() :
1271+
new ValidateClientCertificate(provider.GetService<IOpenIddictApplicationManager>() ??
1272+
throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)));
1273+
})
12581274
.UseScopedHandler<ValidateClientCertificate>()
12591275
.SetOrder(ValidateClientSecret.Descriptor.Order + 1_000)
12601276
.SetType(OpenIddictServerHandlerType.BuiltIn)
@@ -1265,7 +1281,6 @@ public async ValueTask HandleAsync(ProcessAuthenticationContext context)
12651281
{
12661282
ArgumentNullException.ThrowIfNull(context);
12671283

1268-
Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId));
12691284
Debug.Assert(context.Transaction.RemoteCertificate is not null, SR.GetResourceString(SR.ID4020));
12701285

12711286
// Don't validate the client certificate on endpoints that don't support client authentication/token binding.
@@ -1277,6 +1292,106 @@ OpenIddictServerEndpointType.EndUserVerification or
12771292
return;
12781293
}
12791294

1295+
// If the client is anonymous, assume the provided certificate will exclusively be used for mTLS token
1296+
// binding, validate the certificate using the base chain policy specified in the options and ensure
1297+
// the certificate is self-signed as PKI certificates cannot be used for token binding exclusively.
1298+
if (string.IsNullOrEmpty(context.ClientId))
1299+
{
1300+
// Note: to avoid building and introspecting a X.509 certificate chain and reduce the cost
1301+
// of this check, a certificate is always assumed to be self-signed when it is self-issued.
1302+
//
1303+
// A second pass is performed once the chain is built to validate whether the certificate is self-signed or not.
1304+
if (context.Options.SelfSignedTlsClientAuthenticationPolicy is not X509ChainPolicy policy ||
1305+
!OpenIddictHelpers.IsSelfIssuedCertificate(context.Transaction.RemoteCertificate))
1306+
{
1307+
context.Reject(
1308+
error: Errors.InvalidRequest,
1309+
description: SR.GetResourceString(SR.ID2205),
1310+
uri: SR.FormatID8000(SR.ID2205));
1311+
1312+
return;
1313+
}
1314+
1315+
// Always clone the X.509 chain policy to ensure the original instance is never mutated.
1316+
policy = policy.Clone();
1317+
1318+
// Note: to allow validating certificates that are exclusively used for mTLS token binding, the chain policy
1319+
// is amended to consider the specified self-signed certificate as a trusted root and basically disable chain
1320+
// validation while still validating the other aspects of the certificate (e.g expiration date, key usage, etc).
1321+
#if SUPPORTS_X509_CHAIN_POLICY_CUSTOM_TRUST_STORE
1322+
policy.CustomTrustStore.Add(context.Transaction.RemoteCertificate);
1323+
#else
1324+
policy.ExtraStore.Add(context.Transaction.RemoteCertificate);
1325+
#endif
1326+
1327+
using var chain = new X509Chain()
1328+
{
1329+
ChainPolicy = policy
1330+
};
1331+
1332+
try
1333+
{
1334+
// Ensure the specified certificate is valid based on the chain policy.
1335+
if (!chain.Build(context.Transaction.RemoteCertificate))
1336+
{
1337+
context.Logger.LogInformation(6293, SR.GetResourceString(SR.ID6293),
1338+
chain.ChainStatus.Select(static status => status.Status).ToArray());
1339+
1340+
context.Reject(
1341+
error: Errors.InvalidRequest,
1342+
description: SR.GetResourceString(SR.ID2197),
1343+
uri: SR.FormatID8000(SR.ID2197));
1344+
1345+
return;
1346+
}
1347+
1348+
if (chain.ChainElements is not [X509ChainElement])
1349+
{
1350+
context.Reject(
1351+
error: Errors.InvalidRequest,
1352+
description: SR.GetResourceString(SR.ID2205),
1353+
uri: SR.FormatID8000(SR.ID2205));
1354+
1355+
return;
1356+
}
1357+
}
1358+
1359+
catch (CryptographicException exception) when (!OpenIddictHelpers.IsFatal(exception))
1360+
{
1361+
context.Logger.LogWarning(6288, exception, SR.GetResourceString(SR.ID6288));
1362+
1363+
context.Reject(
1364+
error: Errors.InvalidRequest,
1365+
description: SR.GetResourceString(SR.ID2197),
1366+
uri: SR.FormatID8000(SR.ID2197));
1367+
1368+
return;
1369+
}
1370+
1371+
finally
1372+
{
1373+
// Dispose the certificates instantiated internally while building the chain.
1374+
for (var index = 0; index < chain.ChainElements.Count; index++)
1375+
{
1376+
chain.ChainElements[index].Certificate.Dispose();
1377+
}
1378+
}
1379+
1380+
return;
1381+
}
1382+
1383+
// Note: when the degraded mode is enabled, the application is responsible for manually
1384+
// validating the client certificate provided by the client using a custom event handler.
1385+
if (context.Options.EnableDegradedMode)
1386+
{
1387+
return;
1388+
}
1389+
1390+
if (_applicationManager is null)
1391+
{
1392+
throw new InvalidOperationException(SR.GetResourceString(SR.ID0016));
1393+
}
1394+
12801395
var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ??
12811396
throw new InvalidOperationException(SR.GetResourceString(SR.ID0032));
12821397

@@ -1291,7 +1406,12 @@ OpenIddictServerEndpointType.EndUserVerification or
12911406
{
12921407
if (context.Options.SelfSignedTlsClientAuthenticationPolicy is null)
12931408
{
1294-
throw new InvalidOperationException(SR.GetResourceString(SR.ID0506));
1409+
context.Reject(
1410+
error: Errors.InvalidRequest,
1411+
description: SR.GetResourceString(SR.ID2205),
1412+
uri: SR.FormatID8000(SR.ID2205));
1413+
1414+
return;
12951415
}
12961416

12971417
if (await _applicationManager.GetSelfSignedTlsClientAuthenticationPolicyAsync(
@@ -1346,7 +1466,12 @@ OpenIddictServerEndpointType.EndUserVerification or
13461466
{
13471467
if (context.Options.PublicKeyInfrastructureTlsClientAuthenticationPolicy is null)
13481468
{
1349-
throw new InvalidOperationException(SR.GetResourceString(SR.ID0505));
1469+
context.Reject(
1470+
error: Errors.InvalidRequest,
1471+
description: SR.GetResourceString(SR.ID2205),
1472+
uri: SR.FormatID8000(SR.ID2205));
1473+
1474+
return;
13501475
}
13511476

13521477
if (await _applicationManager.GetPublicKeyInfrastructureTlsClientAuthenticationPolicyAsync(

0 commit comments

Comments
 (0)