From 7c247bd1020135932cbb572fb946fd0931c6baa3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:19:38 +0000 Subject: [PATCH 1/3] Initial plan From f94ee6125489280f25bea36c847b48f4815ac71d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:05:03 +0000 Subject: [PATCH 2/3] feat: implement credential system refactor with CredentialContext, CredentialMaterial, and CredentialMaterialResolver Co-authored-by: gladjohn <90415114+gladjohn@users.noreply.github.com> --- .../ClientCredential/CREDENTIAL_MATRIX.cs | 62 +++ .../CertificateAndClaimsClientCredential.cs | 142 +++---- .../ClientAssertionDelegateCredential.cs | 74 ++-- ...ClientAssertionStringDelegateCredential.cs | 52 ++- .../ClientCredential/ClientAuthMode.cs | 24 ++ .../ClientCredentialApplicationResult.cs | 43 +- .../ClientCredential/CredentialContext.cs | 55 +++ .../ClientCredential/CredentialMaterial.cs | 65 +++ .../CredentialMaterialResolver.cs | 91 +++++ .../ClientCredential/IClientCredential.cs | 27 +- .../SecretStringClientCredential.cs | 30 +- .../SignedAssertionClientCredential.cs | 34 +- .../Microsoft.Identity.Client/MsalError.cs | 10 + .../OAuth2/TokenClient.cs | 23 +- .../PublicApi/net462/PublicAPI.Unshipped.txt | 2 +- .../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 | 384 ++++++++++++++++++ 21 files changed, 877 insertions(+), 246 deletions(-) create mode 100644 src/client/Microsoft.Identity.Client/Internal/ClientCredential/CREDENTIAL_MATRIX.cs create mode 100644 src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAuthMode.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 tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CREDENTIAL_MATRIX.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CREDENTIAL_MATRIX.cs new file mode 100644 index 0000000000..ce67cfa578 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CREDENTIAL_MATRIX.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// ───────────────────────────────────────────────────────────────────────────── +// CREDENTIAL MATRIX – canonical mapping of (credential, mode) → output +// +// This file is purely documentary. It contains no executable code. +// It exists so that reviewers and future maintainers can see, in one place, +// every supported input/mode combination and what each produces. +// +// Legend +// ------ +// Mode: Regular = standard JWT-bearer / client-secret flow +// MtlsMode = certificate-bound mTLS Proof-of-Possession flow +// +// Output columns +// ────────────── +// TokenRequestParameters – key/value pairs added to the token-request body +// ResolvedCertificate – X509Certificate2 stored on the request for +// transport binding / logging; null = not applicable +// +// Row │ Credential type │ Mode │ TokenRequestParameters │ ResolvedCertificate +// ────┼─────────────────────────┼───────────┼─────────────────────────────────────────────────┼───────────────────── +// 1 │ X509Certificate │ Regular │ client_assertion_type=jwt-bearer │ certificate +// │ │ │ client_assertion= │ +// ────┼─────────────────────────┼───────────┼─────────────────────────────────────────────────┼───────────────────── +// 2 │ X509Certificate │ MtlsMode │ (empty – TLS layer authenticates the client) │ certificate +// ────┼─────────────────────────┼───────────┼─────────────────────────────────────────────────┼───────────────────── +// 3 │ ClientSecret │ Regular │ client_secret= │ null +// ────┼─────────────────────────┼───────────┼─────────────────────────────────────────────────┼───────────────────── +// 4 │ ClientSecret │ MtlsMode │ ── UNSUPPORTED → MsalClientException ── │ n/a +// ────┼─────────────────────────┼───────────┼─────────────────────────────────────────────────┼───────────────────── +// 5 │ SignedAssertion (static) │ Regular │ client_assertion_type=jwt-bearer │ null +// │ │ │ client_assertion= │ +// ────┼─────────────────────────┼───────────┼─────────────────────────────────────────────────┼───────────────────── +// 6 │ SignedAssertion (static) │ MtlsMode │ ── UNSUPPORTED → MsalClientException ── │ n/a +// ────┼─────────────────────────┼───────────┼─────────────────────────────────────────────────┼───────────────────── +// 7 │ JWT callback (string) │ Regular │ client_assertion_type=jwt-bearer │ null +// │ │ │ client_assertion= │ +// ────┼─────────────────────────┼───────────┼─────────────────────────────────────────────────┼───────────────────── +// 8 │ JWT callback (string) │ MtlsMode │ ── UNSUPPORTED → MsalClientException ── │ n/a +// ────┼─────────────────────────┼───────────┼─────────────────────────────────────────────────┼───────────────────── +// 9 │ JWT+cert callback │ Regular │ client_assertion_type=jwt-pop (cert bound) │ certificate +// │ │ │ client_assertion= │ +// ────┼─────────────────────────┼───────────┼─────────────────────────────────────────────────┼───────────────────── +// 10 │ JWT+cert callback │ MtlsMode │ client_assertion_type=urn:ietf:params: │ certificate +// │ │ │ oauth:client-assertion-type:jwt-pop │ +// │ │ │ client_assertion= │ +// ────┴─────────────────────────┴───────────┴─────────────────────────────────────────────────┴───────────────────── +// +// Notes +// ───── +// • Rows 4, 6, 8 throw MsalClientException(MsalError.InvalidCredentialMaterial) because +// the credential type cannot supply a certificate for mTLS transport. +// • Row 9 (bearer-over-mTLS): when the callback returns a certificate in Regular mode, the +// credential uses JWT-PoP (not jwt-bearer) so the token is bound to the presented certificate. +// The TLS certificate has already been set on the OAuth2Client by MtlsPopParametersInitializer. +// • Row 10 (JWT-PoP): the assertion uses the JWT-PoP client_assertion_type so the +// authorization server can verify the token is bound to the presented certificate. +// • "Static" sources (rows 1–3, 5) set CredentialSource.Static. +// Callback sources (rows 7–10) set CredentialSource.Callback. +// ───────────────────────────────────────────────────────────────────────────── diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs index 9922770a1e..1f4c2979a8 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CertificateAndClaimsClientCredential.cs @@ -6,10 +6,8 @@ using System.Security.Cryptography.X509Certificates; 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,116 +46,77 @@ 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; + // Resolve the certificate via the provider (used both for Regular and MtlsMode paths). + X509Certificate2 certificate = await ResolveCertificateAsync(context, cancellationToken) + .ConfigureAwait(false); - // Log the incoming request parameters for diagnostic purposes - requestParameters.RequestContext.Logger.Verbose( - () => $"Building assertion from certificate with clientId: {clientId} at endpoint: {tokenEndpoint}"); - - // 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); - - oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer); - oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, assertion); - - // No extra outputs for the common case. - return ClientCredentialApplicationResult.None; + // 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); } - // 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."); + // 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); + } - 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 } }; + + 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."); - // 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."); - throw new MsalClientException( MsalError.InvalidClientAssertion, "The certificate provider callback returned null. Ensure the callback returns a valid X509Certificate2 instance."); @@ -167,9 +126,6 @@ private async Task ResolveCertificateAsync( { if (!certificate.HasPrivateKey) { - requestParameters.RequestContext.Logger.Error( - "[CertificateAndClaimsClientCredential] Certificate from provider does not have a private key."); - throw new MsalClientException( MsalError.CertWithoutPrivateKey, MsalErrorMessage.CertMustHavePrivateKey(certificate.FriendlyName)); @@ -177,20 +133,14 @@ private async Task ResolveCertificateAsync( } catch (System.Security.Cryptography.CryptographicException ex) { - requestParameters.RequestContext.Logger.Error( - "[CertificateAndClaimsClientCredential] A cryptographic error occurred while accessing the certificate."); - throw new MsalClientException( MsalError.CryptographicError, MsalErrorMessage.CryptographicError, ex); } - requestParameters.RequestContext.Logger.Info( - () => $"[CertificateAndClaimsClientCredential] Successfully resolved certificate from provider. " + - $"Thumbprint: {certificate.Thumbprint}"); - 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..c83a00d1ea 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionDelegateCredential.cs @@ -1,20 +1,18 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // 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,41 +24,29 @@ 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) { 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); + ClientSignedAssertion resp = await _provider(opts, cancellationToken).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(resp?.Assertion)) { @@ -71,28 +57,30 @@ 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) + if (context.Mode == ClientAuthMode.MtlsMode && !hasCert) { throw new MsalClientException( MsalError.MtlsCertificateNotProvided, MsalErrorMessage.MtlsCertificateNotProvidedMessage); } - // JWT-PoP if explicit PoP was requested OR delegate returned a cert (implicit bearer-over-mTLS) - bool useJwtPop = p.IsMtlsPopRequested || hasCert; + // 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); - - oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, resp.Assertion); + var parameters = new Dictionary + { + { + OAuth2Parameter.ClientAssertionType, + useJwtPop ? OAuth2AssertionType.JwtPop : OAuth2AssertionType.JwtBearer + }, + { OAuth2Parameter.ClientAssertion, resp.Assertion } + }; - // 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..5c81bc4535 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientAssertionStringDelegateCredential.cs @@ -1,19 +1,19 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // 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,24 +27,30 @@ 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) { + if (context.Mode == ClientAuthMode.MtlsMode) + { + 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."); + } + 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); + string assertion = await _provider(opts, cancellationToken).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(assertion)) { @@ -53,10 +59,14 @@ public async Task AddConfidentialClientParame MsalErrorMessage.InvalidClientAssertionEmpty); } - oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer); - oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, assertion); + var parameters = new Dictionary + { + { OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer }, + { OAuth2Parameter.ClientAssertion, assertion } + }; - 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..bd3b276229 --- /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 index d6b4cff86e..74d6fb18df 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientCredentialApplicationResult.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/ClientCredentialApplicationResult.cs @@ -1,41 +1,6 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// 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; } - } -} +// This class has been replaced by CredentialMaterial. +// It is kept as an empty file to preserve git history. +// See CredentialMaterial.cs for the replacement. 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..8a528c5f57 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialContext.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +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; } + } +} 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..808ff6d508 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterial.cs @@ -0,0 +1,65 @@ +// 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 +{ + /// + /// 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 + } + + /// + /// 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..dc1cc78726 --- /dev/null +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/CredentialMaterialResolver.cs @@ -0,0 +1,91 @@ +// 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.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) + { + var context = BuildContext(requestParams, tokenEndpoint); + + CredentialMaterial material = await credential + .GetCredentialMaterialAsync(context, cancellationToken) + .ConfigureAwait(false); + + if (material == null) + { + 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) + { + throw new InvalidOperationException( + $"Credential '{credential.GetType().Name}' returned CredentialMaterial with null " + + "TokenRequestParameters. TokenRequestParameters must not be 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 + }; + } + } +} diff --git a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/IClientCredential.cs b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/IClientCredential.cs index fd189c39be..fdfd9bf1bf 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,16 @@ 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..df22bb5bd4 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SecretStringClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SecretStringClientCredential.cs @@ -1,14 +1,11 @@ // 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 +20,26 @@ 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); + if (context.Mode == ClientAuthMode.MtlsMode) + { + 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 } + }; + + 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..98b8a0bdaf 100644 --- a/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs +++ b/src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionClientCredential.cs @@ -1,14 +1,11 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// 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 +20,27 @@ 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); + if (context.Mode == ClientAuthMode.MtlsMode) + { + 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 } + }; + + return Task.FromResult(new CredentialMaterial(parameters, CredentialSource.Static)); } } } + 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..25420cdf54 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; @@ -129,22 +130,32 @@ private async Task AddBodyParamsAndHeadersAsync( { _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"); + () => "[TokenClient] Before resolving credential material"); var tokenEndpoint = await _requestParams.Authority.GetTokenEndpointAsync(_requestParams.RequestContext).ConfigureAwait(false); - await _serviceBundle.Config.ClientCredential.AddConfidentialClientParametersAsync( - _oAuth2Client, + CredentialMaterial material = await Internal.ClientCredential.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); 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 5f1ab1006d..9809ed93e5 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -1 +1 @@ -Microsoft.Identity.Client.AuthScheme.MsalCacheValidationData.cancellationToken.get -> System.Threading.CancellationToken \ No newline at end of file +Microsoft.Identity.Client.AuthScheme.MsalCacheValidationData.cancellationToken.get -> System.Threading.CancellationTokenconst Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string 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 49f0d092b6..1ac4ce81c5 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ Microsoft.Identity.Client.AuthScheme.MsalCacheValidationData.cancellationToken.get -> System.Threading.CancellationToken +const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string 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 49f0d092b6..1ac4ce81c5 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 +1,2 @@ Microsoft.Identity.Client.AuthScheme.MsalCacheValidationData.cancellationToken.get -> System.Threading.CancellationToken +const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string 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 49f0d092b6..1ac4ce81c5 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 +1,2 @@ Microsoft.Identity.Client.AuthScheme.MsalCacheValidationData.cancellationToken.get -> System.Threading.CancellationToken +const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string 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 49f0d092b6..1ac4ce81c5 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 +1,2 @@ Microsoft.Identity.Client.AuthScheme.MsalCacheValidationData.cancellationToken.get -> System.Threading.CancellationToken +const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string 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 49f0d092b6..1ac4ce81c5 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 +1,2 @@ Microsoft.Identity.Client.AuthScheme.MsalCacheValidationData.cancellationToken.get -> System.Threading.CancellationToken +const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string 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..0d2343cee4 --- /dev/null +++ b/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs @@ -0,0 +1,384 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#if !ANDROID && !iOS +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.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; + +namespace Microsoft.Identity.Test.Unit.RequestsTests +{ + /// + /// Tests all rows of the canonical credential matrix (CREDENTIAL_MATRIX.cs): + /// 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, + 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, + 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 async Task CredentialMaterial_NullTokenRequestParameters_ThrowsInvalidOperationExceptionAsync() + { + // This tests that CredentialMaterial rejects null TokenRequestParameters at construction time. + await Task.CompletedTask.ConfigureAwait(false); // keep async pattern + + Assert.ThrowsException( + () => new CredentialMaterial(null, CredentialSource.Static)); + } + + [TestMethod] + public async Task CredentialMaterial_EmptyTokenRequestParameters_IsValidAsync() + { + await Task.CompletedTask.ConfigureAwait(false); // keep async pattern + + // 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); + } + } +} +#endif From 0ff8f62902bea28fa3f5ad12be724fed4e786f22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:07:17 +0000 Subject: [PATCH 3/3] fix: address code review comments - fix PublicAPI.Unshipped.txt newlines and convert synchronous tests to non-async Co-authored-by: gladjohn <90415114+gladjohn@users.noreply.github.com> --- .../PublicApi/net462/PublicAPI.Unshipped.txt | 3 ++- .../RequestsTests/CredentialMatrixTests.cs | 10 +++------- 2 files changed, 5 insertions(+), 8 deletions(-) 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 9809ed93e5..1ac4ce81c5 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ -Microsoft.Identity.Client.AuthScheme.MsalCacheValidationData.cancellationToken.get -> System.Threading.CancellationTokenconst Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string +Microsoft.Identity.Client.AuthScheme.MsalCacheValidationData.cancellationToken.get -> System.Threading.CancellationToken +const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string diff --git a/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs b/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs index 0d2343cee4..69382bbfc8 100644 --- a/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/RequestsTests/CredentialMatrixTests.cs @@ -342,20 +342,16 @@ public async Task EdgeCase_DelegateCredential_CallbackReturnsNullAssertion_Throw } [TestMethod] - public async Task CredentialMaterial_NullTokenRequestParameters_ThrowsInvalidOperationExceptionAsync() + public void CredentialMaterial_NullTokenRequestParameters_ThrowsInvalidOperationException() { - // This tests that CredentialMaterial rejects null TokenRequestParameters at construction time. - await Task.CompletedTask.ConfigureAwait(false); // keep async pattern - + // CredentialMaterial rejects null TokenRequestParameters at construction time. Assert.ThrowsException( () => new CredentialMaterial(null, CredentialSource.Static)); } [TestMethod] - public async Task CredentialMaterial_EmptyTokenRequestParameters_IsValidAsync() + public void CredentialMaterial_EmptyTokenRequestParameters_IsValid() { - await Task.CompletedTask.ConfigureAwait(false); // keep async pattern - // Empty dictionary (e.g. for mTLS cert credential in MtlsMode) is explicitly allowed. var material = new CredentialMaterial( new Dictionary(),