Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,4 @@ package-lock.json

# ignore JetBrains Rider files
.idea/
/git
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ public string ClientSecret
{
get
{
if (ClientCredential is SecretStringClientCredential secretCred)
if (ClientCredential is ClientSecretCredential secretCred)
{
return secretCred.Secret;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ public ConfidentialClientApplicationBuilder WithClientSecret(string clientSecret
throw new ArgumentNullException(nameof(clientSecret));
}

Config.ClientCredential = new SecretStringClientCredential(clientSecret);
Config.ClientCredential = new ClientSecretCredential(clientSecret);
return this;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Identity.Client.Core;
using Microsoft.Identity.Client.Internal.Requests;
using Microsoft.Identity.Client.Internal;
using Microsoft.Identity.Client.OAuth2;
using Microsoft.Identity.Client.PlatformsCommon.Interfaces;
using Microsoft.Identity.Client.TelemetryCore;
using Microsoft.Identity.Client.Utils;

namespace Microsoft.Identity.Client.Internal.ClientCredential
{
Expand Down Expand Up @@ -48,115 +48,91 @@ public CertificateAndClaimsClientCredential(
Certificate = certificate;
}

public async Task<ClientCredentialApplicationResult> AddConfidentialClientParametersAsync(
OAuth2Client oAuth2Client,
AuthenticationRequestParameters requestParameters,
ICryptographyManager cryptographyManager,
string tokenEndpoint,
public async Task<CredentialMaterial> GetCredentialMaterialAsync(
CredentialContext context,
CancellationToken cancellationToken)
{
string clientId = requestParameters.AppConfig.ClientId;
context.Logger.Verbose(() => $"[CertificateAndClaimsClientCredential] Resolving credential material. " +
$"Mode={context.Mode}, " + $"TokenEndpoint={context.TokenEndpoint}");

// Log the incoming request parameters for diagnostic purposes
requestParameters.RequestContext.Logger.Verbose(
() => $"Building assertion from certificate with clientId: {clientId} at endpoint: {tokenEndpoint}");
// Resolve the certificate via the provider (used both for Regular and MtlsMode paths).
X509Certificate2 certificate = await ResolveCertificateAsync(context, cancellationToken)
.ConfigureAwait(false);

// If mTLS cert is not already set for the request, proceed with JWT bearer client assertion.
if (requestParameters.MtlsCertificate == null)
if (context.Mode == OAuthMode.MtlsMode)
{
requestParameters.RequestContext.Logger.Verbose(
() => "Proceeding with JWT token creation and adding client assertion.");

// Resolve the certificate via the provider
X509Certificate2 certificate =
await ResolveCertificateAsync(requestParameters, tokenEndpoint, cancellationToken)
.ConfigureAwait(false);

// Store the resolved certificate in request parameters for later use (e.g., ExecutionResult)
requestParameters.ResolvedCertificate = certificate;

bool useSha2 = requestParameters.AuthorityManager.Authority.AuthorityInfo.IsSha2CredentialSupported;

JsonWebToken jwtToken;
if (string.IsNullOrEmpty(requestParameters.ExtraClientAssertionClaims))
{
jwtToken = new JsonWebToken(
cryptographyManager,
clientId,
tokenEndpoint,
_claimsToSign,
_appendDefaultClaims);
}
else
{
jwtToken = new JsonWebToken(
cryptographyManager,
clientId,
tokenEndpoint,
requestParameters.ExtraClientAssertionClaims,
_appendDefaultClaims);
}

string assertion = jwtToken.Sign(certificate, requestParameters.SendX5C, useSha2);
context.Logger.Verbose(() => "[CertificateAndClaimsClientCredential] mTLS mode detected. " +
"Using certificate for TLS client authentication; no client_assertion will be added.");

// mTLS path: the certificate authenticates the client at the TLS layer.
// No client_assertion is needed; return an empty parameter set.
return new CredentialMaterial(
CollectionHelpers.GetEmptyDictionary<string, string>(),
certificate);
}

oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer);
oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, assertion);
context.Logger.Verbose(() => "[CertificateAndClaimsClientCredential] Regular mode detected. " +
"Building certificate-based client assertion.");

// No extra outputs for the common case.
return ClientCredentialApplicationResult.None;
// Regular path: build a JWT-bearer client assertion.
JsonWebToken jwtToken;
if (string.IsNullOrEmpty(context.ExtraClientAssertionClaims))
{
jwtToken = new JsonWebToken(
context.CryptographyManager,
context.ClientId,
context.TokenEndpoint,
_claimsToSign,
_appendDefaultClaims);
}
else
{
jwtToken = new JsonWebToken(
context.CryptographyManager,
context.ClientId,
context.TokenEndpoint,
context.ExtraClientAssertionClaims,
_appendDefaultClaims);
}

// mTLS path: a certificate is already set on the request (e.g., mTLS/PoP transport).
requestParameters.RequestContext.Logger.Verbose(
() => "mTLS certificate is set for this request. Skipping JWT client assertion generation.");

requestParameters.ResolvedCertificate = requestParameters.MtlsCertificate;
string assertion = jwtToken.Sign(certificate, context.SendX5C, context.UseSha2);

// Return the mTLS certificate via the result object so the pipeline can use it
// (HTTP handler + policy/region checks).
return new ClientCredentialApplicationResult
var parameters = new Dictionary<string, string>
{
MtlsCertificate = requestParameters.MtlsCertificate,
UseJwtPopClientAssertion = false // no client assertion set here
{ OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer },
{ OAuth2Parameter.ClientAssertion, assertion }
};

context.Logger.Verbose(() => "[CertificateAndClaimsClientCredential] Certificate-based client " +
"assertion created successfully.");

return new CredentialMaterial(parameters, certificate);
}

/// <summary>
/// Resolves the certificate to use for signing the client assertion.
/// Invokes the certificate provider delegate to get the certificate.
/// </summary>
/// <param name="requestParameters">The authentication request parameters containing app config</param>
/// <param name="tokenEndpoint">The token endpoint URL</param>
/// <param name="cancellationToken">Cancellation token for the async operation</param>
/// <returns>The X509Certificate2 to use for signing</returns>
/// <exception cref="MsalClientException">Thrown if the certificate provider returns null or an invalid certificate</exception>
private async Task<X509Certificate2> ResolveCertificateAsync(
AuthenticationRequestParameters requestParameters,
string tokenEndpoint,
CredentialContext context,
CancellationToken cancellationToken)
{
requestParameters.RequestContext.Logger.Verbose(
() => "[CertificateAndClaimsClientCredential] Resolving certificate from provider.");
context.Logger.Verbose(() => "[CertificateAndClaimsClientCredential] Resolving certificate from provider.");

// Create AssertionRequestOptions for the callback
var options = new AssertionRequestOptions(
requestParameters.AppConfig,
tokenEndpoint,
requestParameters.AuthorityManager.Authority.TenantId)
var options = new AssertionRequestOptions
{
Claims = requestParameters.Claims,
ClientCapabilities = requestParameters.AppConfig.ClientCapabilities,
ClientID = context.ClientId,
TokenEndpoint = context.TokenEndpoint,
Claims = context.Claims,
ClientCapabilities = context.ClientCapabilities,
CancellationToken = cancellationToken
};

// Invoke the provider to get the certificate
X509Certificate2 certificate = await _certificateProvider(options).ConfigureAwait(false);

// Validate the certificate returned by the provider
if (certificate == null)
{
requestParameters.RequestContext.Logger.Error(
"[CertificateAndClaimsClientCredential] Certificate provider returned null.");
context.Logger.Error("[CertificateAndClaimsClientCredential] Certificate provider returned null.");

throw new MsalClientException(
MsalError.InvalidClientAssertion,
Expand All @@ -167,8 +143,7 @@ private async Task<X509Certificate2> ResolveCertificateAsync(
{
if (!certificate.HasPrivateKey)
{
requestParameters.RequestContext.Logger.Error(
"[CertificateAndClaimsClientCredential] Certificate from provider does not have a private key.");
context.Logger.Error("[CertificateAndClaimsClientCredential] The certificate does not have a private key.");

throw new MsalClientException(
MsalError.CertWithoutPrivateKey,
Expand All @@ -177,18 +152,15 @@ private async Task<X509Certificate2> ResolveCertificateAsync(
}
catch (System.Security.Cryptography.CryptographicException ex)
{
requestParameters.RequestContext.Logger.Error(
"[CertificateAndClaimsClientCredential] A cryptographic error occurred while accessing the certificate.");
context.Logger.Error("[CertificateAndClaimsClientCredential] A cryptographic error occurred while accessing the certificate.");

throw new MsalClientException(
MsalError.CryptographicError,
MsalErrorMessage.CryptographicError,
ex);
}

requestParameters.RequestContext.Logger.Info(
() => $"[CertificateAndClaimsClientCredential] Successfully resolved certificate from provider. " +
$"Thumbprint: {certificate.Thumbprint}");
context.Logger.Verbose(() => "[CertificateAndClaimsClientCredential] Successfully resolved certificate from provider.");

return certificate;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,18 @@
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Identity.Client.Core;
using Microsoft.Identity.Client.Internal.Requests;
using Microsoft.Identity.Client.OAuth2;
using Microsoft.Identity.Client.PlatformsCommon.Interfaces;
using Microsoft.Identity.Client.TelemetryCore;

namespace Microsoft.Identity.Client.Internal.ClientCredential
{
/// <summary>
/// Handles client assertions supplied via a delegate that returns an
/// <see cref="ClientSignedAssertion"/> (JWT + optional certificate bound for mTLSPoP).
/// Handles client assertions supplied via a delegate that returns a
/// <see cref="ClientSignedAssertion"/> (JWT + optional certificate bound for mTLS-PoP).
/// </summary>
internal sealed class ClientAssertionDelegateCredential : IClientCredential, IClientSignedAssertionProvider
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add tests or a debug assertion to ensure that each credential, and especially this one, is called once and only once per token request?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added test coverage to verify the callback-backed credential path is invoked exactly once per token request.

{
Expand All @@ -26,41 +25,34 @@ internal ClientAssertionDelegateCredential(
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
}

// Private helper for internal readability
private Task<ClientSignedAssertion> GetAssertionAsync(
AssertionRequestOptions options,
CancellationToken cancellationToken) =>
_provider(options, cancellationToken);

// Capability interface (only used where we intentionally cast to check the capability)
Task<ClientSignedAssertion> IClientSignedAssertionProvider.GetAssertionAsync(
AssertionRequestOptions options,
CancellationToken cancellationToken) =>
GetAssertionAsync(options, cancellationToken);
_provider(options, cancellationToken);

public AssertionType AssertionType => AssertionType.ClientAssertion;

// ──────────────────────────────────
// Main hook for token requests
// ──────────────────────────────────
public async Task<ClientCredentialApplicationResult> AddConfidentialClientParametersAsync(
OAuth2Client oAuth2Client,
AuthenticationRequestParameters p,
ICryptographyManager _,
string tokenEndpoint,
CancellationToken ct)
public async Task<CredentialMaterial> GetCredentialMaterialAsync(
CredentialContext context,
CancellationToken cancellationToken)
{
context.Logger.Verbose(() => $"[ClientAssertionDelegateCredential] Resolving client assertion material. " +
$"Mode={context.Mode}, TokenEndpoint={context.TokenEndpoint}");

var opts = new AssertionRequestOptions
{
CancellationToken = ct,
ClientID = p.AppConfig.ClientId,
TokenEndpoint = tokenEndpoint,
ClientCapabilities = p.RequestContext.ServiceBundle.Config.ClientCapabilities,
Claims = p.Claims,
ClientAssertionFmiPath = p.ClientAssertionFmiPath
CancellationToken = cancellationToken,
ClientID = context.ClientId,
TokenEndpoint = context.TokenEndpoint,
ClientCapabilities = context.ClientCapabilities,
Claims = context.Claims,
ClientAssertionFmiPath = context.ClientAssertionFmiPath
};

ClientSignedAssertion resp = await GetAssertionAsync(opts, ct).ConfigureAwait(false);
context.Logger.Verbose(() => "[ClientAssertionDelegateCredential] Invoking client assertion provider delegate.");

ClientSignedAssertion resp = await _provider(opts, cancellationToken).ConfigureAwait(false);

if (string.IsNullOrWhiteSpace(resp?.Assertion))
{
Expand All @@ -71,28 +63,35 @@ public async Task<ClientCredentialApplicationResult> AddConfidentialClientParame

bool hasCert = resp.TokenBindingCertificate != null;

// If PoP was explicitly requested, we must have a certificate.
// (Preflight should enforce this too, but keep this defensive.)
if (p.IsMtlsPopRequested && !hasCert)
context.Logger.Verbose(() => $"[ClientAssertionDelegateCredential] Provider returned assertion. " +
$"TokenBindingCertificatePresent={hasCert}");

if (context.Mode == OAuthMode.MtlsMode && !hasCert)
{
throw new MsalClientException(
MsalError.MtlsCertificateNotProvided,
MsalErrorMessage.MtlsCertificateNotProvidedMessage);
}

// JWT-PoP if explicit PoP was requested OR delegate returned a cert (implicit bearer-over-mTLS)
bool useJwtPop = p.IsMtlsPopRequested || hasCert;
// Select the appropriate assertion type based on the presence of a certificate and the OAuth mode.
string assertionType =
(context.Mode == OAuthMode.MtlsMode || hasCert)
? OAuth2AssertionType.JwtPop
: OAuth2AssertionType.JwtBearer;

context.Logger.Verbose(() => $"[ClientAssertionDelegateCredential] Selected client assertion type: {assertionType}");

oAuth2Client.AddBodyParameter(
OAuth2Parameter.ClientAssertionType,
useJwtPop ? OAuth2AssertionType.JwtPop : OAuth2AssertionType.JwtBearer);
var parameters = new Dictionary<string, string>
{
{ OAuth2Parameter.ClientAssertionType, assertionType },
{ OAuth2Parameter.ClientAssertion, resp.Assertion }
};

oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, resp.Assertion);
context.Logger.Verbose(() => "[ClientAssertionDelegateCredential] Client assertion material created successfully.");

// Only return a cert if we actually have one.
return hasCert
? new ClientCredentialApplicationResult(useJwtPopClientAssertion: useJwtPop, mtlsCertificate: resp.TokenBindingCertificate)
: ClientCredentialApplicationResult.None;
return new CredentialMaterial(
parameters,
hasCert ? resp.TokenBindingCertificate : null);
}
}
}
Loading
Loading