From 7052fedc637b3b8747d5ef288d9e288eefbc9656 Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:28:16 -0700 Subject: [PATCH 1/7] initial --- .../CertificateAndClaimsClientCredential.cs | 148 +++---- .../ClientAssertionDelegateCredential.cs | 86 ++-- ...ClientAssertionStringDelegateCredential.cs | 66 ++- .../ClientCredential/ClientAuthMode.cs | 24 ++ .../ClientCredentialApplicationResult.cs | 41 -- .../ClientCredential/CredentialContext.cs | 59 +++ .../ClientCredential/CredentialMaterial.cs | 53 +++ .../CredentialMaterialResolver.cs | 107 +++++ .../ClientCredential/CredentialSource.cs | 18 + .../ClientCredential/IClientCredential.cs | 26 +- .../SecretStringClientCredential.cs | 35 +- .../SignedAssertionClientCredential.cs | 37 +- .../Microsoft.Identity.Client.csproj | 9 + .../Microsoft.Identity.Client/MsalError.cs | 10 + .../OAuth2/TokenClient.cs | 31 +- .../PublicApi/net462/PublicAPI.Unshipped.txt | 1 + .../PublicApi/net472/PublicAPI.Unshipped.txt | 1 + .../net8.0-android/PublicAPI.Unshipped.txt | 1 + .../net8.0-ios/PublicAPI.Unshipped.txt | 1 + .../PublicApi/net8.0/PublicAPI.Unshipped.txt | 1 + .../netstandard2.0/PublicAPI.Unshipped.txt | 1 + .../RequestsTests/CredentialMatrixTests.cs | 382 ++++++++++++++++++ 22 files changed, 903 insertions(+), 235 deletions(-) create mode 100644 src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAuthMode.cs delete mode 100644 src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientCredentialApplicationResult.cs create mode 100644 src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs create mode 100644 src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterial.cs create mode 100644 src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs create mode 100644 src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialSource.cs create mode 100644 tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs index 9922770a1e..633a0dd951 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs @@ -7,9 +7,8 @@ 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; namespace Microsoft.Identity.Client.Internal.ClientCredential @@ -48,115 +47,92 @@ public CertificateAndClaimsClientCredential( Certificate = certificate; } - public async Task AddConfidentialClientParametersAsync( - OAuth2Client oAuth2Client, - AuthenticationRequestParameters requestParameters, - ICryptographyManager cryptographyManager, - string tokenEndpoint, + public async Task 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 == ClientAuthMode.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( + new Dictionary(), + CredentialSource.Static, + 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 { - 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, CredentialSource.Static, certificate); } /// /// Resolves the certificate to use for signing the client assertion. - /// Invokes the certificate provider delegate to get the certificate. /// - /// The authentication request parameters containing app config - /// The token endpoint URL - /// Cancellation token for the async operation - /// The X509Certificate2 to use for signing - /// Thrown if the certificate provider returns null or an invalid certificate private async Task 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, @@ -167,8 +143,7 @@ private async Task 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, @@ -177,8 +152,7 @@ private async Task 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, @@ -186,9 +160,7 @@ private async Task ResolveCertificateAsync( 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; } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs index d14aedf88e..dad6e0bc66 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs @@ -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 { /// - /// Handles client assertions supplied via a delegate that returns an - /// (JWT + optional certificate bound for mTLS‑PoP). + /// Handles client assertions supplied via a delegate that returns a + /// (JWT + optional certificate bound for mTLS-PoP). /// internal sealed class ClientAssertionDelegateCredential : IClientCredential, IClientSignedAssertionProvider { @@ -26,44 +25,39 @@ internal ClientAssertionDelegateCredential( _provider = provider ?? throw new ArgumentNullException(nameof(provider)); } - // Private helper for internal readability - private Task GetAssertionAsync( - AssertionRequestOptions options, - CancellationToken cancellationToken) => - _provider(options, cancellationToken); - // Capability interface (only used where we intentionally cast to check the capability) Task 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 AddConfidentialClientParametersAsync( - OAuth2Client oAuth2Client, - AuthenticationRequestParameters p, - ICryptographyManager _, - string tokenEndpoint, - CancellationToken ct) + public async Task 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)) { + context.Logger.Error("[ClientAssertionDelegateCredential] Client assertion provider returned a null or empty assertion."); + throw new MsalClientException( MsalError.InvalidClientAssertion, MsalErrorMessage.InvalidClientAssertionEmpty); @@ -71,28 +65,40 @@ public async Task 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 == ClientAuthMode.MtlsMode && !hasCert) { + context.Logger.Error("[ClientAssertionDelegateCredential] mTLS mode requires a token-binding certificate, " + + "but the provider did not return one."); + 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; + // Use JWT-PoP when in MtlsMode or when the callback returned a certificate (implicit bearer-over-mTLS). + bool useJwtPop = context.Mode == ClientAuthMode.MtlsMode || hasCert; - oAuth2Client.AddBodyParameter( - OAuth2Parameter.ClientAssertionType, - useJwtPop ? OAuth2AssertionType.JwtPop : OAuth2AssertionType.JwtBearer); + context.Logger.Verbose(() => $"[ClientAssertionDelegateCredential] Selected client assertion type: " + + $"{(useJwtPop ? OAuth2AssertionType.JwtPop : OAuth2AssertionType.JwtBearer)}"); + + var parameters = new Dictionary + { + { + OAuth2Parameter.ClientAssertionType, + useJwtPop ? OAuth2AssertionType.JwtPop : OAuth2AssertionType.JwtBearer + }, + { 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, + CredentialSource.Callback, + hasCert ? resp.TokenBindingCertificate : null); } } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs index 90ed8fb71b..5a763ba808 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs @@ -2,18 +2,19 @@ // 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 { /// - /// Client assertion provided as a string JWT. Cannot return TokenBindingCertificate (no mTLS preflight). + /// Client assertion provided as a string JWT via a delegate. + /// Cannot return a and therefore + /// is incompatible with mTLS Proof-of-Possession. /// internal sealed class ClientAssertionStringDelegateCredential : IClientCredential { @@ -27,36 +28,63 @@ internal ClientAssertionStringDelegateCredential( public AssertionType AssertionType => AssertionType.ClientAssertion; - public async Task AddConfidentialClientParametersAsync( - OAuth2Client oAuth2Client, - AuthenticationRequestParameters p, - ICryptographyManager _, - string tokenEndpoint, - CancellationToken ct) + public async Task GetCredentialMaterialAsync( + CredentialContext context, + CancellationToken cancellationToken) { + context.Logger.Verbose(() => $"[ClientAssertionStringDelegateCredential] Resolving client assertion material. " + + $"Mode={context.Mode}, TokenEndpoint={context.TokenEndpoint}"); + + if (context.Mode == ClientAuthMode.MtlsMode) + { + context.Logger.Error("[ClientAssertionStringDelegateCredential] String-returning assertion delegate " + + "cannot be used with mTLS Proof-of-Possession because no token-binding certificate can be supplied."); + + throw new MsalClientException( + MsalError.InvalidCredentialMaterial, + "A string-returning delegate credential cannot be used with mTLS Proof-of-Possession " + + "because it cannot supply a certificate for TLS transport binding. " + + "Use a delegate that returns a ClientSignedAssertion with a TokenBindingCertificate."); + } + + context.Logger.Verbose(() => "[ClientAssertionStringDelegateCredential] Building assertion request " + + "options for delegate invocation."); + 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 }; - string assertion = await _provider(opts, ct).ConfigureAwait(false); + context.Logger.Verbose(() => "[ClientAssertionStringDelegateCredential] Invoking string assertion provider delegate."); + + string assertion = await _provider(opts, cancellationToken).ConfigureAwait(false); + + context.Logger.Verbose(() => "[ClientAssertionStringDelegateCredential] Assertion delegate returned a response. " + + "Validating that it is not null or empty."); if (string.IsNullOrWhiteSpace(assertion)) { + context.Logger.Error("[ClientAssertionStringDelegateCredential] Assertion delegate returned a null or empty assertion."); + throw new MsalClientException( MsalError.InvalidClientAssertion, MsalErrorMessage.InvalidClientAssertionEmpty); } - oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer); - oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, assertion); + var parameters = new Dictionary + { + { OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer }, + { OAuth2Parameter.ClientAssertion, assertion } + }; + + context.Logger.Verbose(() => "[ClientAssertionStringDelegateCredential] Client assertion material created successfully using JwtBearer."); - return ClientCredentialApplicationResult.None; + return new CredentialMaterial(parameters, CredentialSource.Callback); } } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAuthMode.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAuthMode.cs new file mode 100644 index 0000000000..e06ea5ccbf --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAuthMode.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Identity.Client.Internal.ClientCredential +{ + /// + /// Determines how the client authenticates when acquiring tokens. + /// Replaces the confusing pair of boolean flags previously used to signal mTLS vs. regular flows. + /// + internal enum ClientAuthMode + { + /// + /// Standard client authentication: client secret, JWT bearer assertion, or JWT-PoP assertion. + /// + Regular, + + /// + /// mTLS Proof-of-Possession mode: the credential must supply a certificate for binding to the + /// TLS transport layer. No client_secret is valid here; JWT-PoP assertions are issued when + /// a certificate-bound delegate credential is used. + /// + MtlsMode + } +} diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientCredentialApplicationResult.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientCredentialApplicationResult.cs deleted file mode 100644 index d6b4cff86e..0000000000 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientCredentialApplicationResult.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.Security.Cryptography.X509Certificates; - -namespace Microsoft.Identity.Client.Internal.ClientCredential -{ - /// - /// Result returned by . - /// Contains extra data about how the client credential was applied. - /// Returns the information needed to set JWT-PoP and mTLS transport. - /// Internal and can be extended in the future to return more data as needed. - /// - internal sealed class ClientCredentialApplicationResult - { - /// - /// Shared default instance for the common case where the credential has no extra data to return. - /// - public static ClientCredentialApplicationResult None { get; } = new ClientCredentialApplicationResult(); - - public ClientCredentialApplicationResult() { } - - public ClientCredentialApplicationResult( - bool useJwtPopClientAssertion, - X509Certificate2 mtlsCertificate) - { - UseJwtPopClientAssertion = useJwtPopClientAssertion; - MtlsCertificate = mtlsCertificate; - } - - /// - /// Indicates whether the client_assertion_type was set to JWT-PoP. - /// - internal bool UseJwtPopClientAssertion { get; set; } - - /// - /// Optional certificate that should be used for mTLS transport / token binding. - /// - internal X509Certificate2 MtlsCertificate { get; set; } - } -} diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs new file mode 100644 index 0000000000..d0347a7260 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Microsoft.Identity.Client.Core; +using Microsoft.Identity.Client.PlatformsCommon.Interfaces; + +namespace Microsoft.Identity.Client.Internal.ClientCredential +{ + /// + /// Immutable input context passed to . + /// Consolidates all credential-resolution inputs into a single object, eliminating + /// the direct coupling to and + /// that existed in the previous API. + /// + internal readonly struct CredentialContext + { + /// Application (client) identifier. + public string ClientId { get; init; } + + /// Full token endpoint URL for the current request. + public string TokenEndpoint { get; init; } + + /// + /// Whether this is a standard (JWT / secret) request or an mTLS-bound request. + /// + public ClientAuthMode Mode { get; init; } + + /// User-provided claims string (may be null). + public string Claims { get; init; } + + /// Client capabilities configured on the application. + public IEnumerable ClientCapabilities { get; init; } + + /// Platform cryptography manager used for JWT signing. + public ICryptographyManager CryptographyManager { get; init; } + + /// Whether the x5c (certificate chain) claim should be included in the assertion. + public bool SendX5C { get; init; } + + /// Whether to use SHA-2 for certificate-based assertions (authority-driven). + public bool UseSha2 { get; init; } + + /// Extra claims to embed in the client assertion (request-level override). + public string ExtraClientAssertionClaims { get; init; } + + /// FMI path used to embed a subject suffix in the client assertion. + public string ClientAssertionFmiPath { get; init; } + + /// Type of authority (AAD, ADFS, B2C, …). Used for mode-constraint checks. + public AuthorityType AuthorityType { get; init; } + + /// Azure region configured on the application (null when not configured). + public string AzureRegion { get; init; } + + /// Logger for credential resolution diagnostics. + public ILoggerAdapter Logger { get; init; } + } +} diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterial.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterial.cs new file mode 100644 index 0000000000..986d411c32 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterial.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Security.Cryptography.X509Certificates; + +namespace Microsoft.Identity.Client.Internal.ClientCredential +{ + + /// + /// Normalized output of . + /// Replaces the former ClientCredentialApplicationResult and decouples "what credentials + /// produce" from "how the token client applies them". + /// + internal sealed class CredentialMaterial + { + /// + /// Key/value pairs to be added to the token-request body. + /// Never ; may be empty (e.g., for pure mTLS-transport mode where the + /// certificate authenticates the client at the TLS layer and no assertion is needed). + /// + public IReadOnlyDictionary TokenRequestParameters { get; } + + /// Whether the credential was resolved statically or via a runtime callback. + public CredentialSource Source { get; } + + /// + /// Optional certificate returned by the credential. + /// Present when: + /// + /// A certificate credential was used and its certificate was resolved. + /// A delegate credential returned a with a . + /// + /// when no certificate is involved (secret, plain JWT assertion). + /// + public X509Certificate2 ResolvedCertificate { get; } + + /// Body parameters to add to the token request. Must not be null. + /// Where the credential came from. + /// Optional certificate for mTLS transport or logging. + public CredentialMaterial( + IReadOnlyDictionary tokenRequestParameters, + CredentialSource source, + X509Certificate2 resolvedCertificate = null) + { + TokenRequestParameters = tokenRequestParameters + ?? throw new InvalidOperationException("TokenRequestParameters must not be null."); + Source = source; + ResolvedCertificate = resolvedCertificate; + } + } +} diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs new file mode 100644 index 0000000000..dab2a9fa72 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client.Core; +using Microsoft.Identity.Client.Internal.Requests; +using Microsoft.Identity.Client.OAuth2; + +namespace Microsoft.Identity.Client.Internal.ClientCredential +{ + /// + /// Central authority for credential invocation. + /// Builds a from the active request, invokes the credential + /// exactly once, and validates the returned before handing + /// it back to the . + /// + internal static class CredentialMaterialResolver + { + /// + /// Resolves credential material for the given request. + /// + /// The credential implementation to invoke. + /// Current authentication request parameters. + /// Resolved token endpoint URL. + /// Cancellation token. + /// Validated . + /// + /// Thrown when the credential returns or when + /// is . + /// + /// + /// Thrown when the credential/mode combination is not supported + /// (e.g., with a secret credential). + /// + internal static async Task ResolveAsync( + IClientCredential credential, + AuthenticationRequestParameters requestParams, + string tokenEndpoint, + CancellationToken cancellationToken) + { + requestParams.RequestContext.Logger.Verbose(() => $"[CredentialMaterialResolver] Building credential context " + + $"for credential type '{credential.GetType().Name}'."); + + var context = BuildContext(requestParams, tokenEndpoint); + + requestParams.RequestContext.Logger.Verbose(() => $"[CredentialMaterialResolver] Invoking GetCredentialMaterialAsync " + + $"on credential type '{credential.GetType().Name}'."); + + CredentialMaterial material = await credential + .GetCredentialMaterialAsync(context, cancellationToken) + .ConfigureAwait(false); + + if (material == null) + { + requestParams.RequestContext.Logger.Error($"[CredentialMaterialResolver] Credential '{credential.GetType().Name}' returned null CredentialMaterial."); + + throw new InvalidOperationException( + $"Credential '{credential.GetType().Name}' returned null from GetCredentialMaterialAsync. " + + "This is an internal error; credential implementations must never return null."); + } + + // TokenRequestParameters is validated inside CredentialMaterial's constructor, + // but add an explicit guard here to surface a clear message if a future refactor + // allows a null reference to slip through. + if (material.TokenRequestParameters == null) + { + requestParams.RequestContext.Logger.Error($"[CredentialMaterialResolver] Credential " + + $"'{credential.GetType().Name}' returned CredentialMaterial with null TokenRequestParameters."); + + throw new InvalidOperationException( + $"Credential '{credential.GetType().Name}' returned CredentialMaterial with null " + + "TokenRequestParameters. TokenRequestParameters must not be null."); + } + + requestParams.RequestContext.Logger.Verbose(() => $"[CredentialMaterialResolver] Credential material " + + $"resolved successfully. Source={material.Source}, HasResolvedCertificate={material.ResolvedCertificate != null}"); + + return material; + } + + private static CredentialContext BuildContext( + AuthenticationRequestParameters requestParams, + string tokenEndpoint) + { + return new CredentialContext + { + ClientId = requestParams.AppConfig.ClientId, + TokenEndpoint = tokenEndpoint, + Mode = requestParams.MtlsCertificate != null || requestParams.IsMtlsPopRequested + ? ClientAuthMode.MtlsMode + : ClientAuthMode.Regular, + Claims = requestParams.Claims, + ClientCapabilities = requestParams.AppConfig.ClientCapabilities, + CryptographyManager = requestParams.RequestContext.ServiceBundle.PlatformProxy.CryptographyManager, + SendX5C = requestParams.SendX5C, + UseSha2 = requestParams.AuthorityManager.Authority.AuthorityInfo.IsSha2CredentialSupported, + ExtraClientAssertionClaims = requestParams.ExtraClientAssertionClaims, + ClientAssertionFmiPath = requestParams.ClientAssertionFmiPath, + AuthorityType = requestParams.AppConfig.Authority.AuthorityInfo.AuthorityType, + AzureRegion = requestParams.AppConfig.AzureRegion, + Logger = requestParams.RequestContext.Logger + }; + } + } +} diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialSource.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialSource.cs new file mode 100644 index 0000000000..395605e28b --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialSource.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Identity.Client.Internal.ClientCredential +{ + /// + /// Indicates whether credential material was resolved from a static (compile-time) source + /// or from a runtime callback. + /// + internal enum CredentialSource + { + /// The credential was supplied directly (e.g., a certificate or secret passed at build time). + Static, + + /// The credential was obtained by invoking a user-supplied delegate at request time. + Callback + } +} diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/IClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/IClientCredential.cs index fd189c39be..d49d918131 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/IClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/IClientCredential.cs @@ -1,19 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography.X509Certificates; -using System.Text; 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; -using Microsoft.Identity.Client.Utils; namespace Microsoft.Identity.Client.Internal.ClientCredential { @@ -21,11 +11,15 @@ internal interface IClientCredential { AssertionType AssertionType { get; } - Task AddConfidentialClientParametersAsync( - OAuth2Client oAuth2Client, - AuthenticationRequestParameters authenticationRequestParameters, - ICryptographyManager cryptographyManager, - string tokenEndpoint, - CancellationToken cancellationToken); + /// + /// Resolves credential material for a single token request. + /// The returned contains the body parameters to add to + /// the token request and, optionally, a certificate to use for mTLS transport. + /// + /// Immutable context describing the current request. + /// Cancellation token; by convention the last parameter. + Task GetCredentialMaterialAsync( + CredentialContext context, + CancellationToken cancellationToken); } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SecretStringClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SecretStringClientCredential.cs index 6d9825ded3..d641ac65ad 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SecretStringClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SecretStringClientCredential.cs @@ -1,14 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +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; -using Microsoft.Identity.Client.Utils; namespace Microsoft.Identity.Client.Internal.ClientCredential { @@ -23,15 +21,32 @@ public SecretStringClientCredential(string secret) Secret = secret; } - public Task AddConfidentialClientParametersAsync( - OAuth2Client oAuth2Client, - AuthenticationRequestParameters requestParameters, - ICryptographyManager cryptographyManager, - string tokenEndpoint, + public Task GetCredentialMaterialAsync( + CredentialContext context, CancellationToken cancellationToken) { - oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientSecret, Secret); - return Task.FromResult(ClientCredentialApplicationResult.None); + context.Logger.Verbose(() => $"[SecretStringClientCredential] Resolving credential material. " + + $"Mode={context.Mode}"); + + if (context.Mode == ClientAuthMode.MtlsMode) + { + context.Logger.Error("[SecretStringClientCredential] Client secret cannot be used with mTLS Proof-of-Possession."); + + throw new MsalClientException( + MsalError.InvalidCredentialMaterial, + "A client secret cannot be used with mTLS Proof-of-Possession. " + + "Use a certificate-based credential or a delegate that returns a ClientSignedAssertion " + + "with a TokenBindingCertificate."); + } + + var parameters = new Dictionary + { + { OAuth2Parameter.ClientSecret, Secret } + }; + + context.Logger.Verbose(() => "[SecretStringClientCredential] Secret-based credential material created successfully."); + + return Task.FromResult(new CredentialMaterial(parameters, CredentialSource.Static)); } } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs index 3e407e4a1d..c8ed28a1a6 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs @@ -1,14 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +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; -using Microsoft.Identity.Client.Utils; namespace Microsoft.Identity.Client.Internal.ClientCredential { @@ -23,16 +21,33 @@ public SignedAssertionClientCredential(string signedAssertion) _signedAssertion = signedAssertion; } - public Task AddConfidentialClientParametersAsync( - OAuth2Client oAuth2Client, - AuthenticationRequestParameters requestParameters, - ICryptographyManager cryptographyManager, - string tokenEndpoint, + public Task GetCredentialMaterialAsync( + CredentialContext context, CancellationToken cancellationToken) { - oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer); - oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, _signedAssertion); - return Task.FromResult(ClientCredentialApplicationResult.None); + context.Logger.Verbose(() => $"[SignedAssertionClientCredential] Resolving credential material. " + + $"Mode={context.Mode}"); + + if (context.Mode == ClientAuthMode.MtlsMode) + { + context.Logger.Error("[SignedAssertionClientCredential] Static signed assertion cannot be used with mTLS Proof-of-Possession."); + + throw new MsalClientException( + MsalError.InvalidCredentialMaterial, + "A static signed assertion cannot be used with mTLS Proof-of-Possession because it " + + "cannot supply a certificate for TLS transport binding. " + + "Use a delegate credential that returns a ClientSignedAssertion with a TokenBindingCertificate."); + } + + var parameters = new Dictionary + { + { OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer }, + { OAuth2Parameter.ClientAssertion, _signedAssertion } + }; + + context.Logger.Verbose(() => "[SignedAssertionClientCredential] Signed assertion credential material created successfully."); + + return Task.FromResult(new CredentialMaterial(parameters, CredentialSource.Static)); } } } diff --git a/src/client/Microsoft.Identity.Client/Microsoft.Identity.Client.csproj b/src/client/Microsoft.Identity.Client/Microsoft.Identity.Client.csproj index 7c08fe3762..e572e16fa7 100644 --- a/src/client/Microsoft.Identity.Client/Microsoft.Identity.Client.csproj +++ b/src/client/Microsoft.Identity.Client/Microsoft.Identity.Client.csproj @@ -80,8 +80,11 @@ + + + @@ -162,4 +165,10 @@ + + + + + + \ No newline at end of file diff --git a/src/client/Microsoft.Identity.Client/MsalError.cs b/src/client/Microsoft.Identity.Client/MsalError.cs index 97949237c5..35e23163c8 100644 --- a/src/client/Microsoft.Identity.Client/MsalError.cs +++ b/src/client/Microsoft.Identity.Client/MsalError.cs @@ -1255,5 +1255,15 @@ public static class MsalError /// Represents the error code returned when an IMDS operation fails. /// public const string ImdsServiceError = "imds_service_error"; + + /// + /// What happened? The configured credential type is not compatible with the + /// requested authentication mode. For example, a client secret cannot be used with mTLS + /// Proof-of-Possession because mTLS requires a certificate to bind the token to the TLS transport. + /// Mitigation: Use a certificate-based credential or a delegate that returns a + /// with a + /// when mTLS Proof-of-Possession is required. + /// + public const string InvalidCredentialMaterial = "invalid_credential_material"; } } diff --git a/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs b/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs index 2753726580..7b95c69f77 100644 --- a/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs +++ b/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs @@ -12,6 +12,7 @@ using Microsoft.Identity.Client.Core; using Microsoft.Identity.Client.Instance; using Microsoft.Identity.Client.Internal; +using Microsoft.Identity.Client.Internal.ClientCredential; using Microsoft.Identity.Client.Internal.Requests; using Microsoft.Identity.Client.Kerberos; using Microsoft.Identity.Client.OAuth2.Throttling; @@ -62,7 +63,8 @@ public async Task SendTokenRequestAsync( string scopes = !string.IsNullOrEmpty(scopeOverride) ? scopeOverride : GetDefaultScopes(_requestParams.Scope); - await AddBodyParamsAndHeadersAsync(additionalBodyParameters, scopes, cancellationToken).ConfigureAwait(false); + await AddBodyParamsAndHeadersAsync(additionalBodyParameters, scopes, tokenEndpoint, cancellationToken).ConfigureAwait(false); + AddThrottlingHeader(); _serviceBundle.ThrottlingManager.TryThrottle(_requestParams, _oAuth2Client.GetBodyParameters()); @@ -125,26 +127,35 @@ private void AddThrottlingHeader() private async Task AddBodyParamsAndHeadersAsync( IDictionary additionalBodyParameters, string scopes, + string tokenEndpoint, CancellationToken cancellationToken) { _oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientId, _requestParams.AppConfig.ClientId); - if (_serviceBundle.Config.ClientCredential != null) + IClientCredential credentialToUse = _requestParams.RequestContext.ServiceBundle.Config.ClientCredential; + if (credentialToUse != null) { _requestParams.RequestContext.Logger.Verbose( - () => "[TokenClient] Before adding the client assertion / secret"); - - var tokenEndpoint = await _requestParams.Authority.GetTokenEndpointAsync(_requestParams.RequestContext).ConfigureAwait(false); + () => "[TokenClient] Before resolving credential material"); - await _serviceBundle.Config.ClientCredential.AddConfidentialClientParametersAsync( - _oAuth2Client, + CredentialMaterial material = await CredentialMaterialResolver.ResolveAsync( + credentialToUse, _requestParams, - _serviceBundle.PlatformProxy.CryptographyManager, tokenEndpoint, cancellationToken).ConfigureAwait(false); + foreach (var kvp in material.TokenRequestParameters) + { + _oAuth2Client.AddBodyParameter(kvp.Key, kvp.Value); + } + + if (material.ResolvedCertificate != null) + { + _requestParams.ResolvedCertificate = material.ResolvedCertificate; + } + _requestParams.RequestContext.Logger.Verbose( - () => "[TokenClient] After adding the client assertion / secret"); + () => "[TokenClient] After resolving credential material"); } _oAuth2Client.AddBodyParameter(OAuth2Parameter.Scope, scopes); @@ -238,7 +249,7 @@ await _oAuth2Client } throw; - } + } } private static string GetDefaultScopes(ISet inputScope) diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt index 101adba535..25e2826e67 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ +const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt index 101adba535..25e2826e67 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ +const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt index 101adba535..25e2826e67 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ +const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt index 101adba535..25e2826e67 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ +const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt index 101adba535..25e2826e67 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ +const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt index 101adba535..25e2826e67 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ +const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithForceRefresh(bool forceRefresh) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder.WithSendX5C(bool withSendX5C) -> Microsoft.Identity.Client.AcquireTokenByUserFederatedIdentityCredentialParameterBuilder diff --git a/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs b/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs new file mode 100644 index 0000000000..80f4ea3a96 --- /dev/null +++ b/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs @@ -0,0 +1,382 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.Core; +using Microsoft.Identity.Client.Internal.ClientCredential; +using Microsoft.Identity.Client.OAuth2; +using Microsoft.Identity.Client.PlatformsCommon.Shared; +using Microsoft.Identity.Test.Common.Core.Helpers; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NSubstitute; + +namespace Microsoft.Identity.Test.Unit.RequestsTests +{ + /// + /// Tests all rows of the canonical credential matrix: + /// Row 1 – X509Cert + Regular → JWT-bearer assertion + ResolvedCertificate + /// Row 2 – X509Cert + MtlsMode → empty params + ResolvedCertificate + /// Row 3 – Secret + Regular → client_secret + /// Row 4 – Secret + MtlsMode → MsalClientException + /// Row 5 – SignedAssertion (static) + Regular → JWT-bearer assertion + /// Row 6 – SignedAssertion (static) + MtlsMode → MsalClientException + /// Row 7 – JWT callback (string) + Regular → JWT-bearer assertion + /// Row 8 – JWT callback (string) + MtlsMode → MsalClientException + /// Row 9 – JWT+cert callback + Regular → JWT-bearer assertion + ResolvedCertificate + /// Row 10 – JWT+cert callback + MtlsMode → JWT-PoP assertion + ResolvedCertificate + /// Plus additional edge-case tests: null certificate, empty assertion, null-result validation. + /// + [TestClass] + public class CredentialMatrixTests + { + private static X509Certificate2 s_cert; + private static CommonCryptographyManager s_crypto; + + [ClassInitialize] + public static void ClassInitialize(TestContext _) + { + s_cert = CertHelper.GetOrCreateTestCert(); + s_crypto = new CommonCryptographyManager(); + } + + [ClassCleanup] + public static void ClassCleanup() + { + s_cert?.Dispose(); + } + + // ────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────── + + private static CredentialContext RegularContext() => new CredentialContext + { + ClientId = "client-id", + TokenEndpoint = "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", + Mode = ClientAuthMode.Regular, + Claims = null, + ClientCapabilities = null, + CryptographyManager = s_crypto, + Logger = Substitute.For(), + SendX5C = false, + UseSha2 = true, + ExtraClientAssertionClaims = null, + ClientAssertionFmiPath = null + }; + + private static CredentialContext MtlsContext() => new CredentialContext + { + ClientId = "client-id", + TokenEndpoint = "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", + Mode = ClientAuthMode.MtlsMode, + Claims = null, + ClientCapabilities = null, + CryptographyManager = s_crypto, + Logger = Substitute.For(), + SendX5C = false, + UseSha2 = true, + ExtraClientAssertionClaims = null, + ClientAssertionFmiPath = null + }; + + // ────────────────────────────────────────────── + // Row 1 – X509Cert + Regular + // ────────────────────────────────────────────── + + [TestMethod] + public async Task Row1_CertificateCredential_Regular_ReturnsJwtBearerAndCertAsync() + { + var credential = new CertificateClientCredential(s_cert); + CredentialMaterial material = await credential + .GetCredentialMaterialAsync(RegularContext(), CancellationToken.None) + .ConfigureAwait(false); + + Assert.IsNotNull(material); + Assert.AreEqual(CredentialSource.Static, material.Source); + Assert.IsNotNull(material.ResolvedCertificate); + Assert.IsNotNull(material.TokenRequestParameters); + + Assert.IsTrue(material.TokenRequestParameters.ContainsKey(OAuth2Parameter.ClientAssertionType)); + Assert.IsTrue(material.TokenRequestParameters.ContainsKey(OAuth2Parameter.ClientAssertion)); + Assert.AreEqual(OAuth2AssertionType.JwtBearer, material.TokenRequestParameters[OAuth2Parameter.ClientAssertionType]); + Assert.IsFalse(string.IsNullOrWhiteSpace(material.TokenRequestParameters[OAuth2Parameter.ClientAssertion])); + } + + // ────────────────────────────────────────────── + // Row 2 – X509Cert + MtlsMode + // ────────────────────────────────────────────── + + [TestMethod] + public async Task Row2_CertificateCredential_MtlsMode_ReturnsEmptyParamsAndCertAsync() + { + var credential = new CertificateClientCredential(s_cert); + CredentialMaterial material = await credential + .GetCredentialMaterialAsync(MtlsContext(), CancellationToken.None) + .ConfigureAwait(false); + + Assert.IsNotNull(material); + Assert.AreEqual(CredentialSource.Static, material.Source); + Assert.IsNotNull(material.ResolvedCertificate); + Assert.IsNotNull(material.TokenRequestParameters); + Assert.AreEqual(0, material.TokenRequestParameters.Count, + "MtlsMode certificate credential should not add any token request parameters."); + } + + // ────────────────────────────────────────────── + // Row 3 – Secret + Regular + // ────────────────────────────────────────────── + + [TestMethod] + public async Task Row3_SecretCredential_Regular_ReturnsClientSecretAsync() + { + const string secret = "my-secret"; + var credential = new SecretStringClientCredential(secret); + CredentialMaterial material = await credential + .GetCredentialMaterialAsync(RegularContext(), CancellationToken.None) + .ConfigureAwait(false); + + Assert.IsNotNull(material); + Assert.AreEqual(CredentialSource.Static, material.Source); + Assert.IsNull(material.ResolvedCertificate); + Assert.IsTrue(material.TokenRequestParameters.ContainsKey(OAuth2Parameter.ClientSecret)); + Assert.AreEqual(secret, material.TokenRequestParameters[OAuth2Parameter.ClientSecret]); + } + + // ────────────────────────────────────────────── + // Row 4 – Secret + MtlsMode (unsupported) + // ────────────────────────────────────────────── + + [TestMethod] + public async Task Row4_SecretCredential_MtlsMode_ThrowsMsalClientExceptionAsync() + { + var credential = new SecretStringClientCredential("my-secret"); + MsalClientException ex = await Assert.ThrowsExceptionAsync( + () => credential.GetCredentialMaterialAsync(MtlsContext(), CancellationToken.None)) + .ConfigureAwait(false); + + Assert.AreEqual(MsalError.InvalidCredentialMaterial, ex.ErrorCode); + } + + // ────────────────────────────────────────────── + // Row 5 – SignedAssertion (static) + Regular + // ────────────────────────────────────────────── + + [TestMethod] + public async Task Row5_StaticSignedAssertion_Regular_ReturnsJwtBearerAsync() + { + const string jwt = "header.payload.signature"; + var credential = new SignedAssertionClientCredential(jwt); + CredentialMaterial material = await credential + .GetCredentialMaterialAsync(RegularContext(), CancellationToken.None) + .ConfigureAwait(false); + + Assert.IsNotNull(material); + Assert.AreEqual(CredentialSource.Static, material.Source); + Assert.IsNull(material.ResolvedCertificate); + Assert.AreEqual(OAuth2AssertionType.JwtBearer, material.TokenRequestParameters[OAuth2Parameter.ClientAssertionType]); + Assert.AreEqual(jwt, material.TokenRequestParameters[OAuth2Parameter.ClientAssertion]); + } + + // ────────────────────────────────────────────── + // Row 6 – SignedAssertion (static) + MtlsMode (unsupported) + // ────────────────────────────────────────────── + + [TestMethod] + public async Task Row6_StaticSignedAssertion_MtlsMode_ThrowsMsalClientExceptionAsync() + { + var credential = new SignedAssertionClientCredential("header.payload.signature"); + MsalClientException ex = await Assert.ThrowsExceptionAsync( + () => credential.GetCredentialMaterialAsync(MtlsContext(), CancellationToken.None)) + .ConfigureAwait(false); + + Assert.AreEqual(MsalError.InvalidCredentialMaterial, ex.ErrorCode); + } + + // ────────────────────────────────────────────── + // Row 7 – JWT callback (string) + Regular + // ────────────────────────────────────────────── + + [TestMethod] + public async Task Row7_StringCallbackCredential_Regular_ReturnsJwtBearerAsync() + { + const string callbackJwt = "cb.header.payload.signature"; + var credential = new ClientAssertionStringDelegateCredential( + (_, __) => Task.FromResult(callbackJwt)); + + CredentialMaterial material = await credential + .GetCredentialMaterialAsync(RegularContext(), CancellationToken.None) + .ConfigureAwait(false); + + Assert.IsNotNull(material); + Assert.AreEqual(CredentialSource.Callback, material.Source); + Assert.IsNull(material.ResolvedCertificate); + Assert.AreEqual(OAuth2AssertionType.JwtBearer, material.TokenRequestParameters[OAuth2Parameter.ClientAssertionType]); + Assert.AreEqual(callbackJwt, material.TokenRequestParameters[OAuth2Parameter.ClientAssertion]); + } + + // ────────────────────────────────────────────── + // Row 8 – JWT callback (string) + MtlsMode (unsupported) + // ────────────────────────────────────────────── + + [TestMethod] + public async Task Row8_StringCallbackCredential_MtlsMode_ThrowsMsalClientExceptionAsync() + { + var credential = new ClientAssertionStringDelegateCredential( + (_, __) => Task.FromResult("some-jwt")); + + MsalClientException ex = await Assert.ThrowsExceptionAsync( + () => credential.GetCredentialMaterialAsync(MtlsContext(), CancellationToken.None)) + .ConfigureAwait(false); + + Assert.AreEqual(MsalError.InvalidCredentialMaterial, ex.ErrorCode); + } + + // ────────────────────────────────────────────── + // Row 9 – JWT+cert callback + Regular (bearer-over-mTLS) + // ────────────────────────────────────────────── + + [TestMethod] + public async Task Row9_AssertionWithCertCallback_Regular_ReturnsJwtPopAndCertAsync() + { + // When the callback returns a cert in Regular mode (implicit bearer-over-mTLS), + // the credential still uses jwt-pop so the token is bound to the certificate. + const string jwt = "signed.jwt.for.test"; + var credential = new ClientAssertionDelegateCredential( + (_, __) => Task.FromResult(new ClientSignedAssertion { Assertion = jwt, TokenBindingCertificate = s_cert })); + + CredentialMaterial material = await credential + .GetCredentialMaterialAsync(RegularContext(), CancellationToken.None) + .ConfigureAwait(false); + + Assert.IsNotNull(material); + Assert.AreEqual(CredentialSource.Callback, material.Source); + Assert.IsNotNull(material.ResolvedCertificate); + // Even in Regular mode, returning a cert from the callback triggers JWT-PoP binding. + Assert.AreEqual(OAuth2AssertionType.JwtPop, material.TokenRequestParameters[OAuth2Parameter.ClientAssertionType]); + Assert.AreEqual(jwt, material.TokenRequestParameters[OAuth2Parameter.ClientAssertion]); + } + + // ────────────────────────────────────────────── + // Row 10 – JWT+cert callback + MtlsMode (JWT-PoP) + // ────────────────────────────────────────────── + + [TestMethod] + public async Task Row10_AssertionWithCertCallback_MtlsMode_ReturnsJwtPopAndCertAsync() + { + const string jwt = "signed.jwt.pop.for.test"; + var credential = new ClientAssertionDelegateCredential( + (_, __) => Task.FromResult(new ClientSignedAssertion { Assertion = jwt, TokenBindingCertificate = s_cert })); + + CredentialMaterial material = await credential + .GetCredentialMaterialAsync(MtlsContext(), CancellationToken.None) + .ConfigureAwait(false); + + Assert.IsNotNull(material); + Assert.AreEqual(CredentialSource.Callback, material.Source); + Assert.IsNotNull(material.ResolvedCertificate); + Assert.AreEqual(OAuth2AssertionType.JwtPop, material.TokenRequestParameters[OAuth2Parameter.ClientAssertionType]); + Assert.AreEqual(jwt, material.TokenRequestParameters[OAuth2Parameter.ClientAssertion]); + } + + // ────────────────────────────────────────────── + // Edge cases + // ────────────────────────────────────────────── + + [TestMethod] + public async Task EdgeCase_AssertionCallbackReturnsNullCert_Regular_StillSucceedsAsync() + { + // When the callback returns a ClientSignedAssertion without a cert, regular mode is fine. + const string jwt = "jwt.without.cert"; + var credential = new ClientAssertionDelegateCredential( + (_, __) => Task.FromResult(new ClientSignedAssertion { Assertion = jwt, TokenBindingCertificate = null })); + + CredentialMaterial material = await credential + .GetCredentialMaterialAsync(RegularContext(), CancellationToken.None) + .ConfigureAwait(false); + + Assert.IsNotNull(material); + Assert.IsNull(material.ResolvedCertificate); + Assert.AreEqual(OAuth2AssertionType.JwtBearer, material.TokenRequestParameters[OAuth2Parameter.ClientAssertionType]); + } + + [TestMethod] + public async Task EdgeCase_AssertionCallbackReturnsNullCert_MtlsMode_ThrowsMsalClientExceptionAsync() + { + // MtlsMode without a cert should throw. + var credential = new ClientAssertionDelegateCredential( + (_, __) => Task.FromResult(new ClientSignedAssertion { Assertion = "jwt", TokenBindingCertificate = null })); + + MsalClientException ex = await Assert.ThrowsExceptionAsync( + () => credential.GetCredentialMaterialAsync(MtlsContext(), CancellationToken.None)) + .ConfigureAwait(false); + + Assert.AreEqual(MsalError.MtlsCertificateNotProvided, ex.ErrorCode); + } + + [TestMethod] + public async Task EdgeCase_AssertionCallbackReturnsEmptyAssertion_ThrowsMsalClientExceptionAsync() + { + var credential = new ClientAssertionStringDelegateCredential( + (_, __) => Task.FromResult(string.Empty)); + + MsalClientException ex = await Assert.ThrowsExceptionAsync( + () => credential.GetCredentialMaterialAsync(RegularContext(), CancellationToken.None)) + .ConfigureAwait(false); + + Assert.AreEqual(MsalError.InvalidClientAssertion, ex.ErrorCode); + } + + [TestMethod] + public async Task EdgeCase_DelegateCredential_CallbackReturnsNullAssertion_ThrowsMsalClientExceptionAsync() + { + var credential = new ClientAssertionDelegateCredential( + (_, __) => Task.FromResult(new ClientSignedAssertion { Assertion = null, TokenBindingCertificate = null })); + + MsalClientException ex = await Assert.ThrowsExceptionAsync( + () => credential.GetCredentialMaterialAsync(RegularContext(), CancellationToken.None)) + .ConfigureAwait(false); + + Assert.AreEqual(MsalError.InvalidClientAssertion, ex.ErrorCode); + } + + [TestMethod] + public void CredentialMaterial_NullTokenRequestParameters_ThrowsInvalidOperationException() + { + // CredentialMaterial rejects null TokenRequestParameters at construction time. + Assert.ThrowsException( + () => new CredentialMaterial(null, CredentialSource.Static)); + } + + [TestMethod] + public void CredentialMaterial_EmptyTokenRequestParameters_IsValid() + { + // Empty dictionary (e.g. for mTLS cert credential in MtlsMode) is explicitly allowed. + var material = new CredentialMaterial( + new Dictionary(), + CredentialSource.Static, + s_cert); + + Assert.IsNotNull(material.TokenRequestParameters); + Assert.AreEqual(0, material.TokenRequestParameters.Count); + Assert.AreEqual(CredentialSource.Static, material.Source); + Assert.IsNotNull(material.ResolvedCertificate); + } + + [TestMethod] + public void ClientAuthMode_RegularAndMtlsMode_DistinctValues() + { + Assert.AreNotEqual(ClientAuthMode.Regular, ClientAuthMode.MtlsMode); + } + + [TestMethod] + public void CredentialSource_StaticAndCallback_DistinctValues() + { + Assert.AreNotEqual(CredentialSource.Static, CredentialSource.Callback); + } + } +} From 22cb7d260ea0e68a8527b687cf5d472f03fbeaf8 Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:10:04 -0700 Subject: [PATCH 2/7] address CoPilot comments --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index db8bf68815..d5228a65a7 100644 --- a/.gitignore +++ b/.gitignore @@ -259,3 +259,4 @@ package-lock.json # ignore JetBrains Rider files .idea/ +/git From 7ef8d96ed558ea9a54ce2c1414640b7ed0f677ea Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:11:12 -0700 Subject: [PATCH 3/7] address comments --- .../ClientCredential/CredentialMaterialResolver.cs | 9 +++++---- .../Microsoft.Identity.Client.csproj | 9 --------- .../RequestsTests/CredentialMatrixTests.cs | 2 +- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs index dab2a9fa72..cf9b7b1a69 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs @@ -11,10 +11,11 @@ namespace Microsoft.Identity.Client.Internal.ClientCredential { /// - /// Central authority for credential invocation. - /// Builds a from the active request, invokes the credential - /// exactly once, and validates the returned before handing - /// it back to the . + /// Central authority for invoking . + /// Builds a from the active request, invokes + /// exactly once, and validates + /// the returned before handing it back to the + /// . /// internal static class CredentialMaterialResolver { diff --git a/src/client/Microsoft.Identity.Client/Microsoft.Identity.Client.csproj b/src/client/Microsoft.Identity.Client/Microsoft.Identity.Client.csproj index e572e16fa7..7c08fe3762 100644 --- a/src/client/Microsoft.Identity.Client/Microsoft.Identity.Client.csproj +++ b/src/client/Microsoft.Identity.Client/Microsoft.Identity.Client.csproj @@ -80,11 +80,8 @@ - - - @@ -165,10 +162,4 @@ - - - - - - \ No newline at end of file diff --git a/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs b/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs index 80f4ea3a96..ad7a9292f3 100644 --- a/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs @@ -27,7 +27,7 @@ namespace Microsoft.Identity.Test.Unit.RequestsTests /// Row 6 – SignedAssertion (static) + MtlsMode → MsalClientException /// Row 7 – JWT callback (string) + Regular → JWT-bearer assertion /// Row 8 – JWT callback (string) + MtlsMode → MsalClientException - /// Row 9 – JWT+cert callback + Regular → JWT-bearer assertion + ResolvedCertificate + /// Row 9 – JWT+cert callback + Regular → JWT-PoP assertion + ResolvedCertificate /// Row 10 – JWT+cert callback + MtlsMode → JWT-PoP assertion + ResolvedCertificate /// Plus additional edge-case tests: null certificate, empty assertion, null-result validation. /// From 1503f8e3824de7eb1165f20c2a9431d3db0adc9c Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:42:32 -0700 Subject: [PATCH 4/7] Address PR comments --- .../AppConfig/ApplicationConfiguration.cs | 2 +- .../ConfidentialClientApplicationBuilder.cs | 2 +- .../CertificateAndClaimsClientCredential.cs | 8 +- .../ClientAssertionDelegateCredential.cs | 27 ++-- ...ClientAssertionStringDelegateCredential.cs | 9 +- ...redential.cs => ClientSecretCredential.cs} | 20 +-- .../ClientCredential/CredentialContext.cs | 8 +- .../ClientCredential/CredentialMaterial.cs | 10 +- .../CredentialMaterialResolver.cs | 10 +- .../ClientCredential/CredentialSource.cs | 18 --- .../{ClientAuthMode.cs => OAuthMode.cs} | 6 +- .../SignedAssertionClientCredential.cs | 10 +- .../OAuth2/TokenClient.cs | 34 +++-- .../RequestsTests/CredentialMatrixTests.cs | 127 ++++++++++++------ 14 files changed, 152 insertions(+), 139 deletions(-) rename src/client/Microsoft.Identity.Client/Internal/ClientCredential/{SecretStringClientCredential.cs => ClientSecretCredential.cs} (57%) delete mode 100644 src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialSource.cs rename src/client/Microsoft.Identity.Client/Internal/ClientCredential/{ClientAuthMode.cs => OAuthMode.cs} (74%) diff --git a/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs b/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs index 4364ad143f..d91be5f9d0 100644 --- a/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs +++ b/src/client/Microsoft.Identity.Client/AppConfig/ApplicationConfiguration.cs @@ -158,7 +158,7 @@ public string ClientSecret { get { - if (ClientCredential is SecretStringClientCredential secretCred) + if (ClientCredential is ClientSecretCredential secretCred) { return secretCred.Secret; } diff --git a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs index 4c2f86198b..befcf7779b 100644 --- a/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs +++ b/src/client/Microsoft.Identity.Client/AppConfig/ConfidentialClientApplicationBuilder.cs @@ -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; } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs index 633a0dd951..9190c530a2 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs @@ -10,6 +10,7 @@ using Microsoft.Identity.Client.Internal; using Microsoft.Identity.Client.OAuth2; using Microsoft.Identity.Client.TelemetryCore; +using Microsoft.Identity.Client.Utils; namespace Microsoft.Identity.Client.Internal.ClientCredential { @@ -58,7 +59,7 @@ public async Task GetCredentialMaterialAsync( X509Certificate2 certificate = await ResolveCertificateAsync(context, cancellationToken) .ConfigureAwait(false); - if (context.Mode == ClientAuthMode.MtlsMode) + if (context.Mode == OAuthMode.MtlsMode) { context.Logger.Verbose(() => "[CertificateAndClaimsClientCredential] mTLS mode detected. " + "Using certificate for TLS client authentication; no client_assertion will be added."); @@ -66,8 +67,7 @@ public async Task GetCredentialMaterialAsync( // mTLS path: the certificate authenticates the client at the TLS layer. // No client_assertion is needed; return an empty parameter set. return new CredentialMaterial( - new Dictionary(), - CredentialSource.Static, + CollectionHelpers.GetEmptyDictionary(), certificate); } @@ -106,7 +106,7 @@ public async Task GetCredentialMaterialAsync( context.Logger.Verbose(() => "[CertificateAndClaimsClientCredential] Certificate-based client " + "assertion created successfully."); - return new CredentialMaterial(parameters, CredentialSource.Static, certificate); + return new CredentialMaterial(parameters, certificate); } /// diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs index dad6e0bc66..bd690d2064 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs @@ -38,7 +38,7 @@ public async Task GetCredentialMaterialAsync( CancellationToken cancellationToken) { context.Logger.Verbose(() => $"[ClientAssertionDelegateCredential] Resolving client assertion material. " + - $"Mode={context.Mode}, TokenEndpoint={context.TokenEndpoint}"); + $"Mode={context.Mode}, TokenEndpoint={context.TokenEndpoint}"); var opts = new AssertionRequestOptions { @@ -56,8 +56,6 @@ public async Task GetCredentialMaterialAsync( if (string.IsNullOrWhiteSpace(resp?.Assertion)) { - context.Logger.Error("[ClientAssertionDelegateCredential] Client assertion provider returned a null or empty assertion."); - throw new MsalClientException( MsalError.InvalidClientAssertion, MsalErrorMessage.InvalidClientAssertionEmpty); @@ -66,30 +64,26 @@ public async Task GetCredentialMaterialAsync( bool hasCert = resp.TokenBindingCertificate != null; context.Logger.Verbose(() => $"[ClientAssertionDelegateCredential] Provider returned assertion. " + - $"TokenBindingCertificatePresent={hasCert}"); + $"TokenBindingCertificatePresent={hasCert}"); - if (context.Mode == ClientAuthMode.MtlsMode && !hasCert) + if (context.Mode == OAuthMode.MtlsMode && !hasCert) { - context.Logger.Error("[ClientAssertionDelegateCredential] mTLS mode requires a token-binding certificate, " + - "but the provider did not return one."); - throw new MsalClientException( MsalError.MtlsCertificateNotProvided, MsalErrorMessage.MtlsCertificateNotProvidedMessage); } - // Use JWT-PoP when in MtlsMode or when the callback returned a certificate (implicit bearer-over-mTLS). - bool useJwtPop = context.Mode == ClientAuthMode.MtlsMode || 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: " + - $"{(useJwtPop ? OAuth2AssertionType.JwtPop : OAuth2AssertionType.JwtBearer)}"); + context.Logger.Verbose(() => $"[ClientAssertionDelegateCredential] Selected client assertion type: {assertionType}"); var parameters = new Dictionary { - { - OAuth2Parameter.ClientAssertionType, - useJwtPop ? OAuth2AssertionType.JwtPop : OAuth2AssertionType.JwtBearer - }, + { OAuth2Parameter.ClientAssertionType, assertionType }, { OAuth2Parameter.ClientAssertion, resp.Assertion } }; @@ -97,7 +91,6 @@ public async Task GetCredentialMaterialAsync( return new CredentialMaterial( parameters, - CredentialSource.Callback, hasCert ? resp.TokenBindingCertificate : null); } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs index 5a763ba808..86efaf941f 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs @@ -35,16 +35,15 @@ public async Task GetCredentialMaterialAsync( context.Logger.Verbose(() => $"[ClientAssertionStringDelegateCredential] Resolving client assertion material. " + $"Mode={context.Mode}, TokenEndpoint={context.TokenEndpoint}"); - if (context.Mode == ClientAuthMode.MtlsMode) + if (context.Mode == OAuthMode.MtlsMode) { context.Logger.Error("[ClientAssertionStringDelegateCredential] String-returning assertion delegate " + "cannot be used with mTLS Proof-of-Possession because no token-binding certificate can be supplied."); throw new MsalClientException( MsalError.InvalidCredentialMaterial, - "A string-returning delegate credential cannot be used with mTLS Proof-of-Possession " + - "because it cannot supply a certificate for TLS transport binding. " + - "Use a delegate that returns a ClientSignedAssertion with a TokenBindingCertificate."); + "A string-returning client assertion callback cannot be used over mTLS. " + + "Use a ClientSignedAssertion callback that can return a token-binding certificate."); } context.Logger.Verbose(() => "[ClientAssertionStringDelegateCredential] Building assertion request " + @@ -84,7 +83,7 @@ public async Task GetCredentialMaterialAsync( context.Logger.Verbose(() => "[ClientAssertionStringDelegateCredential] Client assertion material created successfully using JwtBearer."); - return new CredentialMaterial(parameters, CredentialSource.Callback); + return new CredentialMaterial(parameters); } } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SecretStringClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientSecretCredential.cs similarity index 57% rename from src/client/Microsoft.Identity.Client/Internal/ClientCredential/SecretStringClientCredential.cs rename to src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientSecretCredential.cs index d641ac65ad..6b06edac5b 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SecretStringClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientSecretCredential.cs @@ -10,13 +10,13 @@ namespace Microsoft.Identity.Client.Internal.ClientCredential { - internal class SecretStringClientCredential : IClientCredential + internal class ClientSecretCredential : IClientCredential { internal string Secret { get; } public AssertionType AssertionType => AssertionType.Secret; - public SecretStringClientCredential(string secret) + public ClientSecretCredential(string secret) { Secret = secret; } @@ -25,18 +25,18 @@ public Task GetCredentialMaterialAsync( CredentialContext context, CancellationToken cancellationToken) { - context.Logger.Verbose(() => $"[SecretStringClientCredential] Resolving credential material. " + + context.Logger.Verbose(() => $"[ClientSecretCredential] Resolving credential material. " + $"Mode={context.Mode}"); - if (context.Mode == ClientAuthMode.MtlsMode) + if (context.Mode == OAuthMode.MtlsMode) { - context.Logger.Error("[SecretStringClientCredential] Client secret cannot be used with mTLS Proof-of-Possession."); + context.Logger.Error("[ClientSecretCredential] Client secret cannot be used with mTLS Proof-of-Possession."); throw new MsalClientException( MsalError.InvalidCredentialMaterial, - "A client secret cannot be used with mTLS Proof-of-Possession. " + - "Use a certificate-based credential or a delegate that returns a ClientSignedAssertion " + - "with a TokenBindingCertificate."); + "A client secret cannot be used over mTLS. " + + "Use a certificate credential or a ClientSignedAssertion callback " + + "that can return a token-binding certificate."); } var parameters = new Dictionary @@ -44,9 +44,9 @@ public Task GetCredentialMaterialAsync( { OAuth2Parameter.ClientSecret, Secret } }; - context.Logger.Verbose(() => "[SecretStringClientCredential] Secret-based credential material created successfully."); + context.Logger.Verbose(() => "[ClientSecretCredential] Secret-based credential material created successfully."); - return Task.FromResult(new CredentialMaterial(parameters, CredentialSource.Static)); + return Task.FromResult(new CredentialMaterial(parameters)); } } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs index d0347a7260..e7c4d26c83 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs @@ -24,7 +24,7 @@ internal readonly struct CredentialContext /// /// Whether this is a standard (JWT / secret) request or an mTLS-bound request. /// - public ClientAuthMode Mode { get; init; } + public OAuthMode Mode { get; init; } /// User-provided claims string (may be null). public string Claims { get; init; } @@ -47,12 +47,6 @@ internal readonly struct CredentialContext /// FMI path used to embed a subject suffix in the client assertion. public string ClientAssertionFmiPath { get; init; } - /// Type of authority (AAD, ADFS, B2C, …). Used for mode-constraint checks. - public AuthorityType AuthorityType { get; init; } - - /// Azure region configured on the application (null when not configured). - public string AzureRegion { get; init; } - /// Logger for credential resolution diagnostics. public ILoggerAdapter Logger { get; init; } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterial.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterial.cs index 986d411c32..949ae7cd8b 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterial.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterial.cs @@ -22,10 +22,11 @@ internal sealed class CredentialMaterial /// public IReadOnlyDictionary TokenRequestParameters { get; } - /// Whether the credential was resolved statically or via a runtime callback. - public CredentialSource Source { get; } - /// + /// The client certificate resolved by the selected credential, if any. + /// In regular certificate-auth flows this is the certificate used by the credential. + /// In mTLS / bound-credential flows this is the certificate attached to transport. + /// Null for secret-based and plain string-assertion credentials. /// Optional certificate returned by the credential. /// Present when: /// @@ -37,16 +38,13 @@ internal sealed class CredentialMaterial public X509Certificate2 ResolvedCertificate { get; } /// Body parameters to add to the token request. Must not be null. - /// Where the credential came from. /// Optional certificate for mTLS transport or logging. public CredentialMaterial( IReadOnlyDictionary tokenRequestParameters, - CredentialSource source, X509Certificate2 resolvedCertificate = null) { TokenRequestParameters = tokenRequestParameters ?? throw new InvalidOperationException("TokenRequestParameters must not be null."); - Source = source; ResolvedCertificate = resolvedCertificate; } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs index cf9b7b1a69..bbbf1b853c 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs @@ -33,7 +33,7 @@ internal static class CredentialMaterialResolver /// /// /// Thrown when the credential/mode combination is not supported - /// (e.g., with a secret credential). + /// (e.g., with a secret credential). /// internal static async Task ResolveAsync( IClientCredential credential, @@ -76,7 +76,7 @@ internal static async Task ResolveAsync( } requestParams.RequestContext.Logger.Verbose(() => $"[CredentialMaterialResolver] Credential material " + - $"resolved successfully. Source={material.Source}, HasResolvedCertificate={material.ResolvedCertificate != null}"); + $"resolved successfully. HasResolvedCertificate={material.ResolvedCertificate != null}"); return material; } @@ -90,8 +90,8 @@ private static CredentialContext BuildContext( ClientId = requestParams.AppConfig.ClientId, TokenEndpoint = tokenEndpoint, Mode = requestParams.MtlsCertificate != null || requestParams.IsMtlsPopRequested - ? ClientAuthMode.MtlsMode - : ClientAuthMode.Regular, + ? OAuthMode.MtlsMode + : OAuthMode.Regular, Claims = requestParams.Claims, ClientCapabilities = requestParams.AppConfig.ClientCapabilities, CryptographyManager = requestParams.RequestContext.ServiceBundle.PlatformProxy.CryptographyManager, @@ -99,8 +99,6 @@ private static CredentialContext BuildContext( UseSha2 = requestParams.AuthorityManager.Authority.AuthorityInfo.IsSha2CredentialSupported, ExtraClientAssertionClaims = requestParams.ExtraClientAssertionClaims, ClientAssertionFmiPath = requestParams.ClientAssertionFmiPath, - AuthorityType = requestParams.AppConfig.Authority.AuthorityInfo.AuthorityType, - AzureRegion = requestParams.AppConfig.AzureRegion, Logger = requestParams.RequestContext.Logger }; } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialSource.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialSource.cs deleted file mode 100644 index 395605e28b..0000000000 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialSource.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -namespace Microsoft.Identity.Client.Internal.ClientCredential -{ - /// - /// Indicates whether credential material was resolved from a static (compile-time) source - /// or from a runtime callback. - /// - internal enum CredentialSource - { - /// The credential was supplied directly (e.g., a certificate or secret passed at build time). - Static, - - /// The credential was obtained by invoking a user-supplied delegate at request time. - Callback - } -} diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAuthMode.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/OAuthMode.cs similarity index 74% rename from src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAuthMode.cs rename to src/client/Microsoft.Identity.Client/Internal/ClientCredential/OAuthMode.cs index e06ea5ccbf..7811552102 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAuthMode.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/OAuthMode.cs @@ -7,7 +7,7 @@ namespace Microsoft.Identity.Client.Internal.ClientCredential /// Determines how the client authenticates when acquiring tokens. /// Replaces the confusing pair of boolean flags previously used to signal mTLS vs. regular flows. /// - internal enum ClientAuthMode + internal enum OAuthMode { /// /// Standard client authentication: client secret, JWT bearer assertion, or JWT-PoP assertion. @@ -15,9 +15,11 @@ internal enum ClientAuthMode Regular, /// - /// mTLS Proof-of-Possession mode: the credential must supply a certificate for binding to the + /// mTLS Authentication mode: the credential must supply a certificate for binding to the /// TLS transport layer. No client_secret is valid here; JWT-PoP assertions are issued when /// a certificate-bound delegate credential is used. + /// (JWT plus an optional client certificate + /// for mTLS / bound-credential flows). /// MtlsMode } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs index c8ed28a1a6..cd991b8c4d 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs @@ -28,15 +28,15 @@ public Task GetCredentialMaterialAsync( context.Logger.Verbose(() => $"[SignedAssertionClientCredential] Resolving credential material. " + $"Mode={context.Mode}"); - if (context.Mode == ClientAuthMode.MtlsMode) + if (context.Mode == OAuthMode.MtlsMode) { context.Logger.Error("[SignedAssertionClientCredential] Static signed assertion cannot be used with mTLS Proof-of-Possession."); throw new MsalClientException( MsalError.InvalidCredentialMaterial, - "A static signed assertion cannot be used with mTLS Proof-of-Possession because it " + - "cannot supply a certificate for TLS transport binding. " + - "Use a delegate credential that returns a ClientSignedAssertion with a TokenBindingCertificate."); + "A precomputed client assertion string cannot be used over mTLS. " + + "Use a certificate credential or a ClientSignedAssertion callback " + + "that can return a token-binding certificate."); } var parameters = new Dictionary @@ -47,7 +47,7 @@ public Task GetCredentialMaterialAsync( context.Logger.Verbose(() => "[SignedAssertionClientCredential] Signed assertion credential material created successfully."); - return Task.FromResult(new CredentialMaterial(parameters, CredentialSource.Static)); + return Task.FromResult(new CredentialMaterial(parameters)); } } } diff --git a/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs b/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs index 7b95c69f77..c890757c94 100644 --- a/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs +++ b/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs @@ -133,29 +133,27 @@ private async Task AddBodyParamsAndHeadersAsync( _oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientId, _requestParams.AppConfig.ClientId); IClientCredential credentialToUse = _requestParams.RequestContext.ServiceBundle.Config.ClientCredential; + if (credentialToUse != null) { - _requestParams.RequestContext.Logger.Verbose( - () => "[TokenClient] Before resolving credential material"); - - CredentialMaterial material = await CredentialMaterialResolver.ResolveAsync( - credentialToUse, - _requestParams, - tokenEndpoint, - cancellationToken).ConfigureAwait(false); - - foreach (var kvp in material.TokenRequestParameters) + using (_requestParams.RequestContext.Logger.LogBlockDuration("[TokenClient] Resolving credential material")) { - _oAuth2Client.AddBodyParameter(kvp.Key, kvp.Value); - } + CredentialMaterial material = await CredentialMaterialResolver.ResolveAsync( + credentialToUse, + _requestParams, + tokenEndpoint, + cancellationToken).ConfigureAwait(false); - if (material.ResolvedCertificate != null) - { - _requestParams.ResolvedCertificate = material.ResolvedCertificate; - } + foreach (var kvp in material.TokenRequestParameters) + { + _oAuth2Client.AddBodyParameter(kvp.Key, kvp.Value); + } - _requestParams.RequestContext.Logger.Verbose( - () => "[TokenClient] After resolving credential material"); + if (material.ResolvedCertificate != null) + { + _requestParams.ResolvedCertificate = material.ResolvedCertificate; + } + } } _oAuth2Client.AddBodyParameter(OAuth2Parameter.Scope, scopes); diff --git a/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs b/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs index ad7a9292f3..1e09a88cde 100644 --- a/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs @@ -32,7 +32,7 @@ namespace Microsoft.Identity.Test.Unit.RequestsTests /// Plus additional edge-case tests: null certificate, empty assertion, null-result validation. /// [TestClass] - public class CredentialMatrixTests + public class CredentialMatrixTests : TestBase { private static X509Certificate2 s_cert; private static CommonCryptographyManager s_crypto; @@ -47,7 +47,6 @@ public static void ClassInitialize(TestContext _) [ClassCleanup] public static void ClassCleanup() { - s_cert?.Dispose(); } // ────────────────────────────────────────────── @@ -58,7 +57,7 @@ public static void ClassCleanup() { ClientId = "client-id", TokenEndpoint = "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", - Mode = ClientAuthMode.Regular, + Mode = OAuthMode.Regular, Claims = null, ClientCapabilities = null, CryptographyManager = s_crypto, @@ -73,7 +72,7 @@ public static void ClassCleanup() { ClientId = "client-id", TokenEndpoint = "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", - Mode = ClientAuthMode.MtlsMode, + Mode = OAuthMode.MtlsMode, Claims = null, ClientCapabilities = null, CryptographyManager = s_crypto, @@ -97,7 +96,6 @@ public async Task Row1_CertificateCredential_Regular_ReturnsJwtBearerAndCertAsyn .ConfigureAwait(false); Assert.IsNotNull(material); - Assert.AreEqual(CredentialSource.Static, material.Source); Assert.IsNotNull(material.ResolvedCertificate); Assert.IsNotNull(material.TokenRequestParameters); @@ -107,6 +105,34 @@ public async Task Row1_CertificateCredential_Regular_ReturnsJwtBearerAndCertAsyn Assert.IsFalse(string.IsNullOrWhiteSpace(material.TokenRequestParameters[OAuth2Parameter.ClientAssertion])); } + [TestMethod] + public async Task Row1b_DynamicCertificateCredential_Regular_InvokesProviderOnce_AndReturnsJwtBearerAndCertAsync() + { + int callCount = 0; + + var credential = new CertificateAndClaimsClientCredential( + certificateProvider: _ => + { + Interlocked.Increment(ref callCount); + return Task.FromResult(s_cert); + }, + claimsToSign: null, + appendDefaultClaims: true); + + CredentialMaterial material = await credential + .GetCredentialMaterialAsync(RegularContext(), CancellationToken.None) + .ConfigureAwait(false); + + Assert.AreEqual(1, callCount); + Assert.IsNotNull(material); + Assert.AreSame(s_cert, material.ResolvedCertificate); + Assert.IsNotNull(material.TokenRequestParameters); + Assert.AreEqual( + OAuth2AssertionType.JwtBearer, + material.TokenRequestParameters[OAuth2Parameter.ClientAssertionType]); + Assert.IsTrue(material.TokenRequestParameters.ContainsKey(OAuth2Parameter.ClientAssertion)); + } + // ────────────────────────────────────────────── // Row 2 – X509Cert + MtlsMode // ────────────────────────────────────────────── @@ -120,13 +146,37 @@ public async Task Row2_CertificateCredential_MtlsMode_ReturnsEmptyParamsAndCertA .ConfigureAwait(false); Assert.IsNotNull(material); - Assert.AreEqual(CredentialSource.Static, material.Source); Assert.IsNotNull(material.ResolvedCertificate); Assert.IsNotNull(material.TokenRequestParameters); - Assert.AreEqual(0, material.TokenRequestParameters.Count, + Assert.IsEmpty(material.TokenRequestParameters, "MtlsMode certificate credential should not add any token request parameters."); } + [TestMethod] + public async Task Row2b_DynamicCertificateCredential_MtlsMode_InvokesProviderOnce_AndReturnsEmptyParamsAndCertAsync() + { + int callCount = 0; + + var credential = new CertificateAndClaimsClientCredential( + certificateProvider: _ => + { + Interlocked.Increment(ref callCount); + return Task.FromResult(s_cert); + }, + claimsToSign: null, + appendDefaultClaims: true); + + CredentialMaterial material = await credential + .GetCredentialMaterialAsync(MtlsContext(), CancellationToken.None) + .ConfigureAwait(false); + + Assert.AreEqual(1, callCount); + Assert.IsNotNull(material); + Assert.AreSame(s_cert, material.ResolvedCertificate); + Assert.IsNotNull(material.TokenRequestParameters); + Assert.IsEmpty(material.TokenRequestParameters); + } + // ────────────────────────────────────────────── // Row 3 – Secret + Regular // ────────────────────────────────────────────── @@ -135,13 +185,12 @@ public async Task Row2_CertificateCredential_MtlsMode_ReturnsEmptyParamsAndCertA public async Task Row3_SecretCredential_Regular_ReturnsClientSecretAsync() { const string secret = "my-secret"; - var credential = new SecretStringClientCredential(secret); + var credential = new ClientSecretCredential(secret); CredentialMaterial material = await credential .GetCredentialMaterialAsync(RegularContext(), CancellationToken.None) .ConfigureAwait(false); Assert.IsNotNull(material); - Assert.AreEqual(CredentialSource.Static, material.Source); Assert.IsNull(material.ResolvedCertificate); Assert.IsTrue(material.TokenRequestParameters.ContainsKey(OAuth2Parameter.ClientSecret)); Assert.AreEqual(secret, material.TokenRequestParameters[OAuth2Parameter.ClientSecret]); @@ -154,8 +203,8 @@ public async Task Row3_SecretCredential_Regular_ReturnsClientSecretAsync() [TestMethod] public async Task Row4_SecretCredential_MtlsMode_ThrowsMsalClientExceptionAsync() { - var credential = new SecretStringClientCredential("my-secret"); - MsalClientException ex = await Assert.ThrowsExceptionAsync( + var credential = new ClientSecretCredential("my-secret"); + MsalClientException ex = await Assert.ThrowsExactlyAsync( () => credential.GetCredentialMaterialAsync(MtlsContext(), CancellationToken.None)) .ConfigureAwait(false); @@ -176,7 +225,6 @@ public async Task Row5_StaticSignedAssertion_Regular_ReturnsJwtBearerAsync() .ConfigureAwait(false); Assert.IsNotNull(material); - Assert.AreEqual(CredentialSource.Static, material.Source); Assert.IsNull(material.ResolvedCertificate); Assert.AreEqual(OAuth2AssertionType.JwtBearer, material.TokenRequestParameters[OAuth2Parameter.ClientAssertionType]); Assert.AreEqual(jwt, material.TokenRequestParameters[OAuth2Parameter.ClientAssertion]); @@ -190,7 +238,7 @@ public async Task Row5_StaticSignedAssertion_Regular_ReturnsJwtBearerAsync() public async Task Row6_StaticSignedAssertion_MtlsMode_ThrowsMsalClientExceptionAsync() { var credential = new SignedAssertionClientCredential("header.payload.signature"); - MsalClientException ex = await Assert.ThrowsExceptionAsync( + MsalClientException ex = await Assert.ThrowsExactlyAsync( () => credential.GetCredentialMaterialAsync(MtlsContext(), CancellationToken.None)) .ConfigureAwait(false); @@ -205,15 +253,21 @@ public async Task Row6_StaticSignedAssertion_MtlsMode_ThrowsMsalClientExceptionA public async Task Row7_StringCallbackCredential_Regular_ReturnsJwtBearerAsync() { const string callbackJwt = "cb.header.payload.signature"; + int callCount = 0; + var credential = new ClientAssertionStringDelegateCredential( - (_, __) => Task.FromResult(callbackJwt)); + (_, __) => + { + Interlocked.Increment(ref callCount); + return Task.FromResult(callbackJwt); + }); CredentialMaterial material = await credential .GetCredentialMaterialAsync(RegularContext(), CancellationToken.None) .ConfigureAwait(false); + Assert.AreEqual(1, callCount); Assert.IsNotNull(material); - Assert.AreEqual(CredentialSource.Callback, material.Source); Assert.IsNull(material.ResolvedCertificate); Assert.AreEqual(OAuth2AssertionType.JwtBearer, material.TokenRequestParameters[OAuth2Parameter.ClientAssertionType]); Assert.AreEqual(callbackJwt, material.TokenRequestParameters[OAuth2Parameter.ClientAssertion]); @@ -229,7 +283,7 @@ public async Task Row8_StringCallbackCredential_MtlsMode_ThrowsMsalClientExcepti var credential = new ClientAssertionStringDelegateCredential( (_, __) => Task.FromResult("some-jwt")); - MsalClientException ex = await Assert.ThrowsExceptionAsync( + MsalClientException ex = await Assert.ThrowsExactlyAsync( () => credential.GetCredentialMaterialAsync(MtlsContext(), CancellationToken.None)) .ConfigureAwait(false); @@ -254,7 +308,6 @@ public async Task Row9_AssertionWithCertCallback_Regular_ReturnsJwtPopAndCertAsy .ConfigureAwait(false); Assert.IsNotNull(material); - Assert.AreEqual(CredentialSource.Callback, material.Source); Assert.IsNotNull(material.ResolvedCertificate); // Even in Regular mode, returning a cert from the callback triggers JWT-PoP binding. Assert.AreEqual(OAuth2AssertionType.JwtPop, material.TokenRequestParameters[OAuth2Parameter.ClientAssertionType]); @@ -269,15 +322,25 @@ public async Task Row9_AssertionWithCertCallback_Regular_ReturnsJwtPopAndCertAsy public async Task Row10_AssertionWithCertCallback_MtlsMode_ReturnsJwtPopAndCertAsync() { const string jwt = "signed.jwt.pop.for.test"; + int callCount = 0; + var credential = new ClientAssertionDelegateCredential( - (_, __) => Task.FromResult(new ClientSignedAssertion { Assertion = jwt, TokenBindingCertificate = s_cert })); + (_, __) => + { + Interlocked.Increment(ref callCount); + return Task.FromResult(new ClientSignedAssertion + { + Assertion = jwt, + TokenBindingCertificate = s_cert + }); + }); CredentialMaterial material = await credential .GetCredentialMaterialAsync(MtlsContext(), CancellationToken.None) .ConfigureAwait(false); + Assert.AreEqual(1, callCount); Assert.IsNotNull(material); - Assert.AreEqual(CredentialSource.Callback, material.Source); Assert.IsNotNull(material.ResolvedCertificate); Assert.AreEqual(OAuth2AssertionType.JwtPop, material.TokenRequestParameters[OAuth2Parameter.ClientAssertionType]); Assert.AreEqual(jwt, material.TokenRequestParameters[OAuth2Parameter.ClientAssertion]); @@ -311,7 +374,7 @@ public async Task EdgeCase_AssertionCallbackReturnsNullCert_MtlsMode_ThrowsMsalC var credential = new ClientAssertionDelegateCredential( (_, __) => Task.FromResult(new ClientSignedAssertion { Assertion = "jwt", TokenBindingCertificate = null })); - MsalClientException ex = await Assert.ThrowsExceptionAsync( + MsalClientException ex = await Assert.ThrowsExactlyAsync( () => credential.GetCredentialMaterialAsync(MtlsContext(), CancellationToken.None)) .ConfigureAwait(false); @@ -324,7 +387,7 @@ public async Task EdgeCase_AssertionCallbackReturnsEmptyAssertion_ThrowsMsalClie var credential = new ClientAssertionStringDelegateCredential( (_, __) => Task.FromResult(string.Empty)); - MsalClientException ex = await Assert.ThrowsExceptionAsync( + MsalClientException ex = await Assert.ThrowsExactlyAsync( () => credential.GetCredentialMaterialAsync(RegularContext(), CancellationToken.None)) .ConfigureAwait(false); @@ -337,7 +400,7 @@ public async Task EdgeCase_DelegateCredential_CallbackReturnsNullAssertion_Throw var credential = new ClientAssertionDelegateCredential( (_, __) => Task.FromResult(new ClientSignedAssertion { Assertion = null, TokenBindingCertificate = null })); - MsalClientException ex = await Assert.ThrowsExceptionAsync( + MsalClientException ex = await Assert.ThrowsExactlyAsync( () => credential.GetCredentialMaterialAsync(RegularContext(), CancellationToken.None)) .ConfigureAwait(false); @@ -348,8 +411,8 @@ public async Task EdgeCase_DelegateCredential_CallbackReturnsNullAssertion_Throw public void CredentialMaterial_NullTokenRequestParameters_ThrowsInvalidOperationException() { // CredentialMaterial rejects null TokenRequestParameters at construction time. - Assert.ThrowsException( - () => new CredentialMaterial(null, CredentialSource.Static)); + Assert.ThrowsExactly( + () => new CredentialMaterial(null)); } [TestMethod] @@ -358,25 +421,11 @@ public void CredentialMaterial_EmptyTokenRequestParameters_IsValid() // Empty dictionary (e.g. for mTLS cert credential in MtlsMode) is explicitly allowed. var material = new CredentialMaterial( new Dictionary(), - CredentialSource.Static, s_cert); Assert.IsNotNull(material.TokenRequestParameters); - Assert.AreEqual(0, material.TokenRequestParameters.Count); - Assert.AreEqual(CredentialSource.Static, material.Source); + Assert.IsEmpty(material.TokenRequestParameters); Assert.IsNotNull(material.ResolvedCertificate); } - - [TestMethod] - public void ClientAuthMode_RegularAndMtlsMode_DistinctValues() - { - Assert.AreNotEqual(ClientAuthMode.Regular, ClientAuthMode.MtlsMode); - } - - [TestMethod] - public void CredentialSource_StaticAndCallback_DistinctValues() - { - Assert.AreNotEqual(CredentialSource.Static, CredentialSource.Callback); - } } } From 1697e9fb2e6c6afe4cc96e4e500a2d0e6bdfcd81 Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:09:07 -0700 Subject: [PATCH 5/7] pr comments --- .gitignore | 1 - .../CertificateAndClaimsClientCredential.cs | 19 +--- .../ClientAssertionDelegateCredential.cs | 16 +--- ...ClientAssertionStringDelegateCredential.cs | 24 ++--- .../ClientSecretCredential.cs | 7 +- .../ClientCredential/CredentialContext.cs | 6 ++ .../ClientCredential/CredentialMaterial.cs | 14 +-- .../CredentialMaterialResolver.cs | 10 +-- .../Internal/ClientCredential/OAuthMode.cs | 4 +- .../SignedAssertionClientCredential.cs | 7 +- .../RequestsTests/CredentialMatrixTests.cs | 90 +++++++++++++++++-- 11 files changed, 113 insertions(+), 85 deletions(-) diff --git a/.gitignore b/.gitignore index d5228a65a7..db8bf68815 100644 --- a/.gitignore +++ b/.gitignore @@ -259,4 +259,3 @@ package-lock.json # ignore JetBrains Rider files .idea/ -/git diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs index 9190c530a2..5ef761ba1f 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs @@ -52,8 +52,7 @@ public async Task GetCredentialMaterialAsync( CredentialContext context, CancellationToken cancellationToken) { - context.Logger.Verbose(() => $"[CertificateAndClaimsClientCredential] Resolving credential material. " + - $"Mode={context.Mode}, " + $"TokenEndpoint={context.TokenEndpoint}"); + context.Logger.Verbose(() => $"[CertificateAndClaimsClientCredential] Mode={context.Mode}"); // Resolve the certificate via the provider (used both for Regular and MtlsMode paths). X509Certificate2 certificate = await ResolveCertificateAsync(context, cancellationToken) @@ -61,9 +60,6 @@ public async Task GetCredentialMaterialAsync( if (context.Mode == OAuthMode.MtlsMode) { - 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( @@ -71,9 +67,6 @@ public async Task GetCredentialMaterialAsync( certificate); } - context.Logger.Verbose(() => "[CertificateAndClaimsClientCredential] Regular mode detected. " + - "Building certificate-based client assertion."); - // Regular path: build a JWT-bearer client assertion. JsonWebToken jwtToken; if (string.IsNullOrEmpty(context.ExtraClientAssertionClaims)) @@ -103,9 +96,6 @@ public async Task GetCredentialMaterialAsync( { OAuth2Parameter.ClientAssertion, assertion } }; - context.Logger.Verbose(() => "[CertificateAndClaimsClientCredential] Certificate-based client " + - "assertion created successfully."); - return new CredentialMaterial(parameters, certificate); } @@ -116,15 +106,14 @@ private async Task ResolveCertificateAsync( CredentialContext context, CancellationToken cancellationToken) { - context.Logger.Verbose(() => "[CertificateAndClaimsClientCredential] Resolving certificate from provider."); - - // Create AssertionRequestOptions for the callback var options = new AssertionRequestOptions { ClientID = context.ClientId, TokenEndpoint = context.TokenEndpoint, Claims = context.Claims, ClientCapabilities = context.ClientCapabilities, + Authority = context.Authority, + TenantId = context.TenantId, CancellationToken = cancellationToken }; @@ -160,7 +149,7 @@ private async Task ResolveCertificateAsync( ex); } - context.Logger.Verbose(() => "[CertificateAndClaimsClientCredential] Successfully resolved certificate from provider."); + context.Logger.Verbose(() => "[CertificateAndClaimsClientCredential] Certificate resolved."); return certificate; } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs index bd690d2064..5be42d5ef7 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs @@ -37,8 +37,7 @@ public async Task GetCredentialMaterialAsync( CredentialContext context, CancellationToken cancellationToken) { - context.Logger.Verbose(() => $"[ClientAssertionDelegateCredential] Resolving client assertion material. " + - $"Mode={context.Mode}, TokenEndpoint={context.TokenEndpoint}"); + context.Logger.Verbose(() => $"[ClientAssertionDelegateCredential] Mode={context.Mode}"); var opts = new AssertionRequestOptions { @@ -47,11 +46,11 @@ public async Task GetCredentialMaterialAsync( TokenEndpoint = context.TokenEndpoint, ClientCapabilities = context.ClientCapabilities, Claims = context.Claims, - ClientAssertionFmiPath = context.ClientAssertionFmiPath + ClientAssertionFmiPath = context.ClientAssertionFmiPath, + Authority = context.Authority, + TenantId = context.TenantId }; - context.Logger.Verbose(() => "[ClientAssertionDelegateCredential] Invoking client assertion provider delegate."); - ClientSignedAssertion resp = await _provider(opts, cancellationToken).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(resp?.Assertion)) @@ -63,9 +62,6 @@ public async Task GetCredentialMaterialAsync( bool hasCert = resp.TokenBindingCertificate != null; - context.Logger.Verbose(() => $"[ClientAssertionDelegateCredential] Provider returned assertion. " + - $"TokenBindingCertificatePresent={hasCert}"); - if (context.Mode == OAuthMode.MtlsMode && !hasCert) { throw new MsalClientException( @@ -79,16 +75,12 @@ public async Task GetCredentialMaterialAsync( ? OAuth2AssertionType.JwtPop : OAuth2AssertionType.JwtBearer; - context.Logger.Verbose(() => $"[ClientAssertionDelegateCredential] Selected client assertion type: {assertionType}"); - var parameters = new Dictionary { { OAuth2Parameter.ClientAssertionType, assertionType }, { OAuth2Parameter.ClientAssertion, resp.Assertion } }; - context.Logger.Verbose(() => "[ClientAssertionDelegateCredential] Client assertion material created successfully."); - return new CredentialMaterial( parameters, hasCert ? resp.TokenBindingCertificate : null); diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs index 86efaf941f..2fa9d441e3 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs @@ -14,7 +14,7 @@ namespace Microsoft.Identity.Client.Internal.ClientCredential /// /// Client assertion provided as a string JWT via a delegate. /// Cannot return a and therefore - /// is incompatible with mTLS Proof-of-Possession. + /// is incompatible with mTLS. /// internal sealed class ClientAssertionStringDelegateCredential : IClientCredential { @@ -32,23 +32,16 @@ public async Task GetCredentialMaterialAsync( CredentialContext context, CancellationToken cancellationToken) { - context.Logger.Verbose(() => $"[ClientAssertionStringDelegateCredential] Resolving client assertion material. " + - $"Mode={context.Mode}, TokenEndpoint={context.TokenEndpoint}"); + context.Logger.Verbose(() => $"[ClientAssertionStringDelegateCredential] Mode={context.Mode}"); if (context.Mode == OAuthMode.MtlsMode) { - context.Logger.Error("[ClientAssertionStringDelegateCredential] String-returning assertion delegate " + - "cannot be used with mTLS Proof-of-Possession because no token-binding certificate can be supplied."); - throw new MsalClientException( MsalError.InvalidCredentialMaterial, "A string-returning client assertion callback cannot be used over mTLS. " + "Use a ClientSignedAssertion callback that can return a token-binding certificate."); } - context.Logger.Verbose(() => "[ClientAssertionStringDelegateCredential] Building assertion request " + - "options for delegate invocation."); - var opts = new AssertionRequestOptions { CancellationToken = cancellationToken, @@ -56,20 +49,15 @@ public async Task GetCredentialMaterialAsync( TokenEndpoint = context.TokenEndpoint, ClientCapabilities = context.ClientCapabilities, Claims = context.Claims, - ClientAssertionFmiPath = context.ClientAssertionFmiPath + ClientAssertionFmiPath = context.ClientAssertionFmiPath, + Authority = context.Authority, + TenantId = context.TenantId }; - context.Logger.Verbose(() => "[ClientAssertionStringDelegateCredential] Invoking string assertion provider delegate."); - string assertion = await _provider(opts, cancellationToken).ConfigureAwait(false); - context.Logger.Verbose(() => "[ClientAssertionStringDelegateCredential] Assertion delegate returned a response. " + - "Validating that it is not null or empty."); - if (string.IsNullOrWhiteSpace(assertion)) { - context.Logger.Error("[ClientAssertionStringDelegateCredential] Assertion delegate returned a null or empty assertion."); - throw new MsalClientException( MsalError.InvalidClientAssertion, MsalErrorMessage.InvalidClientAssertionEmpty); @@ -80,8 +68,6 @@ public async Task GetCredentialMaterialAsync( { OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer }, { OAuth2Parameter.ClientAssertion, assertion } }; - - context.Logger.Verbose(() => "[ClientAssertionStringDelegateCredential] Client assertion material created successfully using JwtBearer."); return new CredentialMaterial(parameters); } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientSecretCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientSecretCredential.cs index 6b06edac5b..e567dca7af 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientSecretCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientSecretCredential.cs @@ -25,13 +25,10 @@ public Task GetCredentialMaterialAsync( CredentialContext context, CancellationToken cancellationToken) { - context.Logger.Verbose(() => $"[ClientSecretCredential] Resolving credential material. " + - $"Mode={context.Mode}"); + context.Logger.Verbose(() => $"[ClientSecretCredential] Mode={context.Mode}"); if (context.Mode == OAuthMode.MtlsMode) { - context.Logger.Error("[ClientSecretCredential] Client secret cannot be used with mTLS Proof-of-Possession."); - throw new MsalClientException( MsalError.InvalidCredentialMaterial, "A client secret cannot be used over mTLS. " + @@ -43,8 +40,6 @@ public Task GetCredentialMaterialAsync( { { OAuth2Parameter.ClientSecret, Secret } }; - - context.Logger.Verbose(() => "[ClientSecretCredential] Secret-based credential material created successfully."); return Task.FromResult(new CredentialMaterial(parameters)); } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs index e7c4d26c83..b072f667b3 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs @@ -47,6 +47,12 @@ internal readonly struct CredentialContext /// FMI path used to embed a subject suffix in the client assertion. public string ClientAssertionFmiPath { get; init; } + /// Canonical authority URL (e.g., https://login.microsoftonline.com/{tenantId}). + public string Authority { get; init; } + + /// Tenant ID from the runtime authority. + public string TenantId { get; init; } + /// Logger for credential resolution diagnostics. public ILoggerAdapter Logger { get; init; } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterial.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterial.cs index 949ae7cd8b..bc68e2c033 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterial.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterial.cs @@ -23,16 +23,10 @@ internal sealed class CredentialMaterial public IReadOnlyDictionary TokenRequestParameters { get; } /// - /// The client certificate resolved by the selected credential, if any. - /// In regular certificate-auth flows this is the certificate used by the credential. - /// In mTLS / bound-credential flows this is the certificate attached to transport. - /// Null for secret-based and plain string-assertion credentials. /// Optional certificate returned by the credential. - /// Present when: - /// - /// A certificate credential was used and its certificate was resolved. - /// A delegate credential returned a with a . - /// + /// Present when a certificate credential was used (regular or mTLS) or a delegate credential + /// returned a with a + /// . /// when no certificate is involved (secret, plain JWT assertion). /// public X509Certificate2 ResolvedCertificate { get; } @@ -44,7 +38,7 @@ public CredentialMaterial( X509Certificate2 resolvedCertificate = null) { TokenRequestParameters = tokenRequestParameters - ?? throw new InvalidOperationException("TokenRequestParameters must not be null."); + ?? throw new ArgumentNullException(nameof(tokenRequestParameters)); ResolvedCertificate = resolvedCertificate; } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs index bbbf1b853c..532357ce3c 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs @@ -41,14 +41,8 @@ internal static async Task ResolveAsync( string tokenEndpoint, CancellationToken cancellationToken) { - requestParams.RequestContext.Logger.Verbose(() => $"[CredentialMaterialResolver] Building credential context " + - $"for credential type '{credential.GetType().Name}'."); - var context = BuildContext(requestParams, tokenEndpoint); - requestParams.RequestContext.Logger.Verbose(() => $"[CredentialMaterialResolver] Invoking GetCredentialMaterialAsync " + - $"on credential type '{credential.GetType().Name}'."); - CredentialMaterial material = await credential .GetCredentialMaterialAsync(context, cancellationToken) .ConfigureAwait(false); @@ -76,7 +70,7 @@ internal static async Task ResolveAsync( } requestParams.RequestContext.Logger.Verbose(() => $"[CredentialMaterialResolver] Credential material " + - $"resolved successfully. HasResolvedCertificate={material.ResolvedCertificate != null}"); + $"resolved. HasCertificate={material.ResolvedCertificate != null}"); return material; } @@ -99,6 +93,8 @@ private static CredentialContext BuildContext( UseSha2 = requestParams.AuthorityManager.Authority.AuthorityInfo.IsSha2CredentialSupported, ExtraClientAssertionClaims = requestParams.ExtraClientAssertionClaims, ClientAssertionFmiPath = requestParams.ClientAssertionFmiPath, + Authority = requestParams.AuthorityManager.Authority.AuthorityInfo.CanonicalAuthority?.ToString(), + TenantId = requestParams.AuthorityManager.Authority.TenantId, Logger = requestParams.RequestContext.Logger }; } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/OAuthMode.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/OAuthMode.cs index 7811552102..19a37e3d60 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/OAuthMode.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/OAuthMode.cs @@ -15,11 +15,9 @@ internal enum OAuthMode Regular, /// - /// mTLS Authentication mode: the credential must supply a certificate for binding to the + /// mTLS authentication mode: the credential must supply a certificate for binding to the /// TLS transport layer. No client_secret is valid here; JWT-PoP assertions are issued when /// a certificate-bound delegate credential is used. - /// (JWT plus an optional client certificate - /// for mTLS / bound-credential flows). /// MtlsMode } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs index cd991b8c4d..cffc02d451 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs @@ -25,13 +25,10 @@ public Task GetCredentialMaterialAsync( CredentialContext context, CancellationToken cancellationToken) { - context.Logger.Verbose(() => $"[SignedAssertionClientCredential] Resolving credential material. " + - $"Mode={context.Mode}"); + context.Logger.Verbose(() => $"[SignedAssertionClientCredential] Mode={context.Mode}"); if (context.Mode == OAuthMode.MtlsMode) { - context.Logger.Error("[SignedAssertionClientCredential] Static signed assertion cannot be used with mTLS Proof-of-Possession."); - throw new MsalClientException( MsalError.InvalidCredentialMaterial, "A precomputed client assertion string cannot be used over mTLS. " + @@ -45,8 +42,6 @@ public Task GetCredentialMaterialAsync( { OAuth2Parameter.ClientAssertion, _signedAssertion } }; - context.Logger.Verbose(() => "[SignedAssertionClientCredential] Signed assertion credential material created successfully."); - return Task.FromResult(new CredentialMaterial(parameters)); } } diff --git a/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs b/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs index 1e09a88cde..724883d541 100644 --- a/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs @@ -34,6 +34,9 @@ namespace Microsoft.Identity.Test.Unit.RequestsTests [TestClass] public class CredentialMatrixTests : TestBase { + private const string TestTokenEndpoint = "https://login.microsoftonline.com/tenant/oauth2/v2.0/token"; + private const string TestAuthority = "https://login.microsoftonline.com/test-tenant-id/"; + private const string TestTenantId = "test-tenant-id"; private static X509Certificate2 s_cert; private static CommonCryptographyManager s_crypto; @@ -56,7 +59,7 @@ public static void ClassCleanup() private static CredentialContext RegularContext() => new CredentialContext { ClientId = "client-id", - TokenEndpoint = "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", + TokenEndpoint = TestTokenEndpoint, Mode = OAuthMode.Regular, Claims = null, ClientCapabilities = null, @@ -65,13 +68,15 @@ public static void ClassCleanup() SendX5C = false, UseSha2 = true, ExtraClientAssertionClaims = null, - ClientAssertionFmiPath = null + ClientAssertionFmiPath = null, + Authority = TestAuthority, + TenantId = TestTenantId }; private static CredentialContext MtlsContext() => new CredentialContext { ClientId = "client-id", - TokenEndpoint = "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", + TokenEndpoint = TestTokenEndpoint, Mode = OAuthMode.MtlsMode, Claims = null, ClientCapabilities = null, @@ -80,7 +85,9 @@ public static void ClassCleanup() SendX5C = false, UseSha2 = true, ExtraClientAssertionClaims = null, - ClientAssertionFmiPath = null + ClientAssertionFmiPath = null, + Authority = TestAuthority, + TenantId = TestTenantId }; // ────────────────────────────────────────────── @@ -408,10 +415,10 @@ public async Task EdgeCase_DelegateCredential_CallbackReturnsNullAssertion_Throw } [TestMethod] - public void CredentialMaterial_NullTokenRequestParameters_ThrowsInvalidOperationException() + public void CredentialMaterial_NullTokenRequestParameters_ThrowsArgumentNullException() { // CredentialMaterial rejects null TokenRequestParameters at construction time. - Assert.ThrowsExactly( + Assert.ThrowsExactly( () => new CredentialMaterial(null)); } @@ -427,5 +434,76 @@ public void CredentialMaterial_EmptyTokenRequestParameters_IsValid() Assert.IsEmpty(material.TokenRequestParameters); Assert.IsNotNull(material.ResolvedCertificate); } + + // ────────────────────────────────────────────── + // Authority / TenantId propagation + // ────────────────────────────────────────────── + + [TestMethod] + public async Task DelegateCredential_PropagatesAuthorityAndTenantId_ToCallbackOptionsAsync() + { + AssertionRequestOptions capturedOptions = null; + + var credential = new ClientAssertionDelegateCredential( + (opts, _) => + { + capturedOptions = opts; + return Task.FromResult(new ClientSignedAssertion { Assertion = "jwt", TokenBindingCertificate = s_cert }); + }); + + await credential + .GetCredentialMaterialAsync(RegularContext(), CancellationToken.None) + .ConfigureAwait(false); + + Assert.IsNotNull(capturedOptions); + Assert.AreEqual(TestAuthority, capturedOptions.Authority); + Assert.AreEqual(TestTenantId, capturedOptions.TenantId); + Assert.AreEqual("client-id", capturedOptions.ClientID); + Assert.AreEqual(TestTokenEndpoint, capturedOptions.TokenEndpoint); + } + + [TestMethod] + public async Task StringDelegateCredential_PropagatesAuthorityAndTenantId_ToCallbackOptionsAsync() + { + AssertionRequestOptions capturedOptions = null; + + var credential = new ClientAssertionStringDelegateCredential( + (opts, _) => + { + capturedOptions = opts; + return Task.FromResult("signed.jwt"); + }); + + await credential + .GetCredentialMaterialAsync(RegularContext(), CancellationToken.None) + .ConfigureAwait(false); + + Assert.IsNotNull(capturedOptions); + Assert.AreEqual(TestAuthority, capturedOptions.Authority); + Assert.AreEqual(TestTenantId, capturedOptions.TenantId); + } + + [TestMethod] + public async Task DynamicCertCredential_PropagatesAuthorityAndTenantId_ToCertProviderAsync() + { + AssertionRequestOptions capturedOptions = null; + + var credential = new CertificateAndClaimsClientCredential( + certificateProvider: opts => + { + capturedOptions = opts; + return Task.FromResult(s_cert); + }, + claimsToSign: null, + appendDefaultClaims: true); + + await credential + .GetCredentialMaterialAsync(RegularContext(), CancellationToken.None) + .ConfigureAwait(false); + + Assert.IsNotNull(capturedOptions); + Assert.AreEqual(TestAuthority, capturedOptions.Authority); + Assert.AreEqual(TestTenantId, capturedOptions.TenantId); + } } } From aa2f18732d6aef5bd3ba9ed7bbbd234101d2a6c9 Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Wed, 22 Apr 2026 06:03:08 -0700 Subject: [PATCH 6/7] Address new review comments from bgavrilMS - Rename OAuthMode to CredentialTransportProtocol (values: OAuth, Mtls) - Add 'Usually client_assertion.' to TokenRequestParameters doc - Remove null guards from CredentialMaterialResolver; credentials are responsible for never returning null (use Debug.Assert instead) - Remove pre-throw Logger.Error calls from resolver - Use _serviceBundle.Config.ClientCredential consistently in TokenClient Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CertificateAndClaimsClientCredential.cs | 4 +- .../ClientAssertionDelegateCredential.cs | 6 +-- ...ClientAssertionStringDelegateCredential.cs | 4 +- .../ClientSecretCredential.cs | 4 +- .../ClientCredential/CredentialContext.cs | 4 +- .../ClientCredential/CredentialMaterial.cs | 2 +- .../CredentialMaterialResolver.cs | 38 ++++--------------- ...Mode.cs => CredentialTransportProtocol.cs} | 12 +++--- .../SignedAssertionClientCredential.cs | 4 +- .../OAuth2/TokenClient.cs | 2 +- .../RequestsTests/CredentialMatrixTests.cs | 6 +-- 11 files changed, 32 insertions(+), 54 deletions(-) rename src/client/Microsoft.Identity.Client/Internal/ClientCredential/{OAuthMode.cs => CredentialTransportProtocol.cs} (63%) diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs index 84cd6ed31d..59abb29937 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System; @@ -58,7 +58,7 @@ public async Task GetCredentialMaterialAsync( X509Certificate2 certificate = await ResolveCertificateAsync(context, cancellationToken) .ConfigureAwait(false); - if (context.Mode == OAuthMode.MtlsMode) + if (context.Mode == CredentialTransportProtocol.Mtls) { // mTLS path: the certificate authenticates the client at the TLS layer. // No client_assertion is needed; return an empty parameter set. diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs index 511b2acf05..675a4ae355 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System; @@ -63,7 +63,7 @@ public async Task GetCredentialMaterialAsync( bool hasCert = resp.TokenBindingCertificate != null; - if (context.Mode == OAuthMode.MtlsMode && !hasCert) + if (context.Mode == CredentialTransportProtocol.Mtls && !hasCert) { throw new MsalClientException( MsalError.MtlsCertificateNotProvided, @@ -72,7 +72,7 @@ public async Task GetCredentialMaterialAsync( // Select the appropriate assertion type based on the presence of a certificate and the OAuth mode. string assertionType = - (context.Mode == OAuthMode.MtlsMode || hasCert) + (context.Mode == CredentialTransportProtocol.Mtls || hasCert) ? OAuth2AssertionType.JwtPop : OAuth2AssertionType.JwtBearer; diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs index 602def7a33..7793878a22 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System; @@ -34,7 +34,7 @@ public async Task GetCredentialMaterialAsync( { context.Logger.Verbose(() => $"[ClientAssertionStringDelegateCredential] Mode={context.Mode}"); - if (context.Mode == OAuthMode.MtlsMode) + if (context.Mode == CredentialTransportProtocol.Mtls) { throw new MsalClientException( MsalError.InvalidCredentialMaterial, diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientSecretCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientSecretCredential.cs index e567dca7af..71dc1cf3ee 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientSecretCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientSecretCredential.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System.Collections.Generic; @@ -27,7 +27,7 @@ public Task GetCredentialMaterialAsync( { context.Logger.Verbose(() => $"[ClientSecretCredential] Mode={context.Mode}"); - if (context.Mode == OAuthMode.MtlsMode) + if (context.Mode == CredentialTransportProtocol.Mtls) { throw new MsalClientException( MsalError.InvalidCredentialMaterial, diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs index 69dd2a4ebf..2373f4f7ca 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System; @@ -25,7 +25,7 @@ internal readonly struct CredentialContext /// /// Whether this is a standard (JWT / secret) request or an mTLS-bound request. /// - public OAuthMode Mode { get; init; } + public CredentialTransportProtocol Mode { get; init; } /// User-provided claims string (may be null). public string Claims { get; init; } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterial.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterial.cs index bc68e2c033..3086f357f1 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterial.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterial.cs @@ -16,7 +16,7 @@ namespace Microsoft.Identity.Client.Internal.ClientCredential internal sealed class CredentialMaterial { /// - /// Key/value pairs to be added to the token-request body. + /// Key/value pairs to be added to the token-request body. Usually client_assertion. /// Never ; may be empty (e.g., for pure mTLS-transport mode where the /// certificate authenticates the client at the TLS layer and no assertion is needed). /// diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs index 70937102c5..f535f13392 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs @@ -1,7 +1,8 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using Microsoft.Identity.Client.Core; @@ -26,14 +27,10 @@ internal static class CredentialMaterialResolver /// Current authentication request parameters. /// Resolved token endpoint URL. /// Cancellation token. - /// Validated . - /// - /// Thrown when the credential returns or when - /// is . - /// + /// produced by the credential. /// /// Thrown when the credential/mode combination is not supported - /// (e.g., with a secret credential). + /// (e.g., with a secret credential). /// internal static async Task ResolveAsync( IClientCredential credential, @@ -47,27 +44,8 @@ internal static async Task ResolveAsync( .GetCredentialMaterialAsync(context, cancellationToken) .ConfigureAwait(false); - if (material == null) - { - requestParams.RequestContext.Logger.Error($"[CredentialMaterialResolver] Credential '{credential.GetType().Name}' returned null CredentialMaterial."); - - throw new InvalidOperationException( - $"Credential '{credential.GetType().Name}' returned null from GetCredentialMaterialAsync. " + - "This is an internal error; credential implementations must never return null."); - } - - // TokenRequestParameters is validated inside CredentialMaterial's constructor, - // but add an explicit guard here to surface a clear message if a future refactor - // allows a null reference to slip through. - if (material.TokenRequestParameters == null) - { - requestParams.RequestContext.Logger.Error($"[CredentialMaterialResolver] Credential " + - $"'{credential.GetType().Name}' returned CredentialMaterial with null TokenRequestParameters."); - - throw new InvalidOperationException( - $"Credential '{credential.GetType().Name}' returned CredentialMaterial with null " + - "TokenRequestParameters. TokenRequestParameters must not be null."); - } + Debug.Assert(material != null, $"Credential '{credential.GetType().Name}' returned null CredentialMaterial."); + Debug.Assert(material?.TokenRequestParameters != null, $"Credential '{credential.GetType().Name}' returned null TokenRequestParameters."); requestParams.RequestContext.Logger.Verbose(() => $"[CredentialMaterialResolver] Credential material " + $"resolved. HasCertificate={material.ResolvedCertificate != null}"); @@ -84,8 +62,8 @@ private static CredentialContext BuildContext( ClientId = requestParams.AppConfig.ClientId, TokenEndpoint = tokenEndpoint, Mode = requestParams.MtlsCertificate != null || requestParams.IsMtlsPopRequested - ? OAuthMode.MtlsMode - : OAuthMode.Regular, + ? CredentialTransportProtocol.Mtls + : CredentialTransportProtocol.OAuth, Claims = requestParams.Claims, ClientCapabilities = requestParams.AppConfig.ClientCapabilities, CryptographyManager = requestParams.RequestContext.ServiceBundle.PlatformProxy.CryptographyManager, diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/OAuthMode.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialTransportProtocol.cs similarity index 63% rename from src/client/Microsoft.Identity.Client/Internal/ClientCredential/OAuthMode.cs rename to src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialTransportProtocol.cs index 19a37e3d60..908bbff677 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/OAuthMode.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialTransportProtocol.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. namespace Microsoft.Identity.Client.Internal.ClientCredential @@ -7,18 +7,18 @@ namespace Microsoft.Identity.Client.Internal.ClientCredential /// Determines how the client authenticates when acquiring tokens. /// Replaces the confusing pair of boolean flags previously used to signal mTLS vs. regular flows. /// - internal enum OAuthMode + internal enum CredentialTransportProtocol { /// - /// Standard client authentication: client secret, JWT bearer assertion, or JWT-PoP assertion. + /// Standard OAuth client authentication: client secret, JWT bearer assertion, or JWT-PoP assertion. /// - Regular, + OAuth, /// - /// mTLS authentication mode: the credential must supply a certificate for binding to the + /// mTLS authentication: the credential must supply a certificate for binding to the /// TLS transport layer. No client_secret is valid here; JWT-PoP assertions are issued when /// a certificate-bound delegate credential is used. /// - MtlsMode + Mtls } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs index cffc02d451..e22f5088f7 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System.Collections.Generic; @@ -27,7 +27,7 @@ public Task GetCredentialMaterialAsync( { context.Logger.Verbose(() => $"[SignedAssertionClientCredential] Mode={context.Mode}"); - if (context.Mode == OAuthMode.MtlsMode) + if (context.Mode == CredentialTransportProtocol.Mtls) { throw new MsalClientException( MsalError.InvalidCredentialMaterial, diff --git a/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs b/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs index c890757c94..f07be3e290 100644 --- a/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs +++ b/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs @@ -132,7 +132,7 @@ private async Task AddBodyParamsAndHeadersAsync( { _oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientId, _requestParams.AppConfig.ClientId); - IClientCredential credentialToUse = _requestParams.RequestContext.ServiceBundle.Config.ClientCredential; + IClientCredential credentialToUse = _serviceBundle.Config.ClientCredential; if (credentialToUse != null) { diff --git a/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs b/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs index 724883d541..ca7e50ba45 100644 --- a/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using System; @@ -60,7 +60,7 @@ public static void ClassCleanup() { ClientId = "client-id", TokenEndpoint = TestTokenEndpoint, - Mode = OAuthMode.Regular, + Mode = CredentialTransportProtocol.OAuth, Claims = null, ClientCapabilities = null, CryptographyManager = s_crypto, @@ -77,7 +77,7 @@ public static void ClassCleanup() { ClientId = "client-id", TokenEndpoint = TestTokenEndpoint, - Mode = OAuthMode.MtlsMode, + Mode = CredentialTransportProtocol.Mtls, Claims = null, ClientCapabilities = null, CryptographyManager = s_crypto, From 4701f106ea78a05012d07d24ec2ca4fa08c86e36 Mon Sep 17 00:00:00 2001 From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com> Date: Sun, 26 Apr 2026 07:01:09 -0700 Subject: [PATCH 7/7] pr comments --- .../CertificateAndClaimsClientCredential.cs | 29 ++--- .../ClientAssertionDelegateCredential.cs | 13 +-- ...ClientAssertionStringDelegateCredential.cs | 21 +--- .../ClientCredentialGuards.cs | 31 ++++++ .../ClientSecretCredential.cs | 9 +- .../ClientCredential/CredentialContext.cs | 23 +++- .../CredentialMaterialResolver.cs | 2 +- .../SignedAssertionClientCredential.cs | 9 +- .../OAuth2/TokenClient.cs | 1 + .../RequestsTests/CredentialMatrixTests.cs | 100 ++++++++++++++++++ 10 files changed, 177 insertions(+), 61 deletions(-) create mode 100644 src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientCredentialGuards.cs diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs index 59abb29937..d7a7b5b5f6 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs @@ -101,25 +101,28 @@ public async Task GetCredentialMaterialAsync( /// /// Resolves the certificate to use for signing the client assertion. + /// Invokes the certificate provider delegate to get the certificate. /// + /// Immutable context describing the current request. + /// Cancellation token for the async operation. + /// The X509Certificate2 to use for signing. + /// + /// Thrown if the certificate provider returns null or a certificate without a private key. + /// private async Task ResolveCertificateAsync( CredentialContext context, CancellationToken cancellationToken) { - var options = new AssertionRequestOptions - { - ClientID = context.ClientId, - TokenEndpoint = context.TokenEndpoint, - Claims = context.Claims, - ClientCapabilities = context.ClientCapabilities, - Authority = context.Authority, - TenantId = context.TenantId, - CorrelationId = context.CorrelationId, - CancellationToken = cancellationToken - }; + context.Logger.Verbose( + () => "[CertificateAndClaimsClientCredential] Resolving certificate from provider."); + + // Create AssertionRequestOptions from the credential context for the callback + var options = context.ToAssertionRequestOptions(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) { context.Logger.Error("[CertificateAndClaimsClientCredential] Certificate provider returned null."); @@ -150,7 +153,9 @@ private async Task ResolveCertificateAsync( ex); } - context.Logger.Verbose(() => "[CertificateAndClaimsClientCredential] Certificate resolved."); + context.Logger.Verbose( + () => $"[CertificateAndClaimsClientCredential] Certificate resolved. " + + $"Thumbprint: {certificate.Thumbprint}"); return certificate; } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs index 675a4ae355..4e328acb56 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs @@ -39,18 +39,7 @@ public async Task GetCredentialMaterialAsync( { context.Logger.Verbose(() => $"[ClientAssertionDelegateCredential] Mode={context.Mode}"); - var opts = new AssertionRequestOptions - { - CancellationToken = cancellationToken, - ClientID = context.ClientId, - TokenEndpoint = context.TokenEndpoint, - ClientCapabilities = context.ClientCapabilities, - Claims = context.Claims, - ClientAssertionFmiPath = context.ClientAssertionFmiPath, - Authority = context.Authority, - TenantId = context.TenantId, - CorrelationId = context.CorrelationId - }; + var opts = context.ToAssertionRequestOptions(cancellationToken); ClientSignedAssertion resp = await _provider(opts, cancellationToken).ConfigureAwait(false); diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs index 7793878a22..26129494e5 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs @@ -34,26 +34,9 @@ public async Task GetCredentialMaterialAsync( { context.Logger.Verbose(() => $"[ClientAssertionStringDelegateCredential] Mode={context.Mode}"); - if (context.Mode == CredentialTransportProtocol.Mtls) - { - throw new MsalClientException( - MsalError.InvalidCredentialMaterial, - "A string-returning client assertion callback cannot be used over mTLS. " + - "Use a ClientSignedAssertion callback that can return a token-binding certificate."); - } + ClientCredentialGuards.ThrowIfMtlsNotSupported(context, "A string-returning client assertion callback"); - var opts = new AssertionRequestOptions - { - CancellationToken = cancellationToken, - ClientID = context.ClientId, - TokenEndpoint = context.TokenEndpoint, - ClientCapabilities = context.ClientCapabilities, - Claims = context.Claims, - ClientAssertionFmiPath = context.ClientAssertionFmiPath, - Authority = context.Authority, - TenantId = context.TenantId, - CorrelationId = context.CorrelationId - }; + var opts = context.ToAssertionRequestOptions(cancellationToken); string assertion = await _provider(opts, cancellationToken).ConfigureAwait(false); diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientCredentialGuards.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientCredentialGuards.cs new file mode 100644 index 0000000000..6a2f250c24 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientCredentialGuards.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Identity.Client.Internal.ClientCredential +{ + /// + /// Shared guard methods for credential implementations. + /// + internal static class ClientCredentialGuards + { + /// + /// Throws when a credential that cannot supply + /// a token-binding certificate is used in mTLS mode. + /// + /// The current credential context. + /// + /// Human-readable description of the credential type (e.g., "A client secret"). + /// + internal static void ThrowIfMtlsNotSupported(CredentialContext context, string credentialDescription) + { + if (context.Mode == CredentialTransportProtocol.Mtls) + { + throw new MsalClientException( + MsalError.InvalidCredentialMaterial, + $"{credentialDescription} cannot be used over mTLS. " + + "Use a certificate credential or a ClientSignedAssertion callback " + + "that can return a token-binding certificate."); + } + } + } +} diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientSecretCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientSecretCredential.cs index 71dc1cf3ee..122170279e 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientSecretCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientSecretCredential.cs @@ -27,14 +27,7 @@ public Task GetCredentialMaterialAsync( { context.Logger.Verbose(() => $"[ClientSecretCredential] Mode={context.Mode}"); - if (context.Mode == CredentialTransportProtocol.Mtls) - { - throw new MsalClientException( - MsalError.InvalidCredentialMaterial, - "A client secret cannot be used over mTLS. " + - "Use a certificate credential or a ClientSignedAssertion callback " + - "that can return a token-binding certificate."); - } + ClientCredentialGuards.ThrowIfMtlsNotSupported(context, "A client secret"); var parameters = new Dictionary { diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs index 2373f4f7ca..d87bceab67 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Threading; using Microsoft.Identity.Client.Core; using Microsoft.Identity.Client.PlatformsCommon.Interfaces; @@ -14,7 +15,7 @@ namespace Microsoft.Identity.Client.Internal.ClientCredential /// the direct coupling to and /// that existed in the previous API. /// - internal readonly struct CredentialContext + internal sealed class CredentialContext { /// Application (client) identifier. public string ClientId { get; init; } @@ -59,5 +60,25 @@ internal readonly struct CredentialContext /// Logger for credential resolution diagnostics. public ILoggerAdapter Logger { get; init; } + + /// + /// Creates an from this context. + /// Centralizes the mapping so credential implementations don't duplicate it. + /// + internal AssertionRequestOptions ToAssertionRequestOptions(CancellationToken cancellationToken) + { + return new AssertionRequestOptions + { + ClientID = ClientId, + TokenEndpoint = TokenEndpoint, + Claims = Claims, + ClientCapabilities = ClientCapabilities, + Authority = Authority, + TenantId = TenantId, + CorrelationId = CorrelationId, + ClientAssertionFmiPath = ClientAssertionFmiPath, + CancellationToken = cancellationToken + }; + } } } diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs index f535f13392..7a819160c1 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs @@ -45,7 +45,7 @@ internal static async Task ResolveAsync( .ConfigureAwait(false); Debug.Assert(material != null, $"Credential '{credential.GetType().Name}' returned null CredentialMaterial."); - Debug.Assert(material?.TokenRequestParameters != null, $"Credential '{credential.GetType().Name}' returned null TokenRequestParameters."); + Debug.Assert(material.TokenRequestParameters != null, $"Credential '{credential.GetType().Name}' returned null TokenRequestParameters."); requestParams.RequestContext.Logger.Verbose(() => $"[CredentialMaterialResolver] Credential material " + $"resolved. HasCertificate={material.ResolvedCertificate != null}"); diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs index e22f5088f7..de635ede9d 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs @@ -27,14 +27,7 @@ public Task GetCredentialMaterialAsync( { context.Logger.Verbose(() => $"[SignedAssertionClientCredential] Mode={context.Mode}"); - if (context.Mode == CredentialTransportProtocol.Mtls) - { - throw new MsalClientException( - MsalError.InvalidCredentialMaterial, - "A precomputed client assertion string cannot be used over mTLS. " + - "Use a certificate credential or a ClientSignedAssertion callback " + - "that can return a token-binding certificate."); - } + ClientCredentialGuards.ThrowIfMtlsNotSupported(context, "A precomputed client assertion string"); var parameters = new Dictionary { diff --git a/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs b/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs index f07be3e290..14b17c72cc 100644 --- a/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs +++ b/src/client/Microsoft.Identity.Client/OAuth2/TokenClient.cs @@ -132,6 +132,7 @@ private async Task AddBodyParamsAndHeadersAsync( { _oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientId, _requestParams.AppConfig.ClientId); + // credentialToUse can be null, e.g. for public client apps (no client credential configured). IClientCredential credentialToUse = _serviceBundle.Config.ClientCredential; if (credentialToUse != null) diff --git a/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs b/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs index ca7e50ba45..971dbca047 100644 --- a/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs @@ -505,5 +505,105 @@ await credential Assert.AreEqual(TestAuthority, capturedOptions.Authority); Assert.AreEqual(TestTenantId, capturedOptions.TenantId); } + + // ────────────────────────────────────────────── + // Dynamic certificate + mTLS tests + // ────────────────────────────────────────────── + + [TestMethod] + public async Task DynamicCert_MtlsMode_ReturnsEmptyParamsAndCert_ViaProviderAsync() + { + // Dynamic certificate provider in mTLS mode should return empty params + cert, + // same as static cert but through the provider delegate path. + int callCount = 0; + AssertionRequestOptions capturedOptions = null; + + var credential = new CertificateAndClaimsClientCredential( + certificateProvider: opts => + { + capturedOptions = opts; + Interlocked.Increment(ref callCount); + return Task.FromResult(s_cert); + }, + claimsToSign: null, + appendDefaultClaims: true); + + CredentialMaterial material = await credential + .GetCredentialMaterialAsync(MtlsContext(), CancellationToken.None) + .ConfigureAwait(false); + + Assert.AreEqual(1, callCount, "Provider should be invoked exactly once."); + Assert.IsNotNull(material); + Assert.AreSame(s_cert, material.ResolvedCertificate); + Assert.IsEmpty(material.TokenRequestParameters, + "mTLS mode should not add any token request parameters."); + + // Verify context was properly propagated to the provider + Assert.IsNotNull(capturedOptions); + Assert.AreEqual(TestAuthority, capturedOptions.Authority); + Assert.AreEqual(TestTenantId, capturedOptions.TenantId); + } + + [TestMethod] + public async Task DynamicCert_MtlsMode_NullCertFromProvider_ThrowsMsalClientExceptionAsync() + { + // Dynamic provider returning null in mTLS mode should throw. + var credential = new CertificateAndClaimsClientCredential( + certificateProvider: _ => Task.FromResult(null), + claimsToSign: null, + appendDefaultClaims: true); + + MsalClientException ex = await Assert.ThrowsExactlyAsync( + () => credential.GetCredentialMaterialAsync(MtlsContext(), CancellationToken.None)) + .ConfigureAwait(false); + + Assert.AreEqual(MsalError.InvalidClientAssertion, ex.ErrorCode); + } + + [TestMethod] + public async Task DynamicCert_Regular_ProviderCalledOnce_ReturnsJwtBearerAsync() + { + // Verify the provider path works for regular mode with claims + int callCount = 0; + + var credential = new CertificateAndClaimsClientCredential( + certificateProvider: _ => + { + Interlocked.Increment(ref callCount); + return Task.FromResult(s_cert); + }, + claimsToSign: new Dictionary { { "custom_claim", "value" } }, + appendDefaultClaims: true); + + CredentialMaterial material = await credential + .GetCredentialMaterialAsync(RegularContext(), CancellationToken.None) + .ConfigureAwait(false); + + Assert.AreEqual(1, callCount, "Provider should be invoked exactly once."); + Assert.IsNotNull(material); + Assert.AreSame(s_cert, material.ResolvedCertificate); + Assert.AreEqual( + OAuth2AssertionType.JwtBearer, + material.TokenRequestParameters[OAuth2Parameter.ClientAssertionType]); + } + + [TestMethod] + public async Task DynamicCert_MtlsMode_WithClaims_ReturnsEmptyParamsAndCertAsync() + { + // Even with claimsToSign, mTLS mode should return empty params (no assertion). + var credential = new CertificateAndClaimsClientCredential( + certificateProvider: _ => Task.FromResult(s_cert), + claimsToSign: new Dictionary { { "custom_claim", "value" } }, + appendDefaultClaims: true); + + CredentialMaterial material = await credential + .GetCredentialMaterialAsync(MtlsContext(), CancellationToken.None) + .ConfigureAwait(false); + + Assert.IsNotNull(material); + Assert.AreSame(s_cert, material.ResolvedCertificate); + Assert.IsEmpty(material.TokenRequestParameters, + "mTLS mode should not add any token request parameters, even with claimsToSign."); + } } }