Skip to content

Commit 651b71c

Browse files
Expose client capabilities in AssertionRequestOptions for MSI FIC scenarios (#5140)
* initial * pr comments * pr comments * attempt to increase test coverage --------- Co-authored-by: Gladwin Johnson <[email protected]>
1 parent e9aa448 commit 651b71c

File tree

9 files changed

+258
-17
lines changed

9 files changed

+258
-17
lines changed

src/client/Microsoft.Identity.Client/AppConfig/AssertionRequestOptions.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4+
using System.Collections.Generic;
45
using System.Threading;
56
namespace Microsoft.Identity.Client
67
{
@@ -30,5 +31,12 @@ public class AssertionRequestOptions {
3031
/// Claims to be included in the client assertion
3132
/// </summary>
3233
public string Claims { get; set; }
34+
35+
/// <summary>
36+
/// Capabilities that the client application has declared.
37+
/// If the callback implementer calls the token issuer using another client application object
38+
/// (e.g. ManagedIdentityApplication or ConfidentialClientApplication), the same capabilities should be used there.
39+
/// </summary>
40+
public IEnumerable<string> ClientCapabilities { get; set; }
3341
}
3442
}

src/client/Microsoft.Identity.Client/Internal/ClientCredential/SignedAssertionDelegateClientCredential.cs

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
using System;
5+
using System.Linq;
56
using System.Threading;
67
using System.Threading.Tasks;
78
using Microsoft.Identity.Client.Core;
@@ -26,7 +27,8 @@ public SignedAssertionDelegateClientCredential(Func<CancellationToken, Task<stri
2627

2728
public SignedAssertionDelegateClientCredential(Func<AssertionRequestOptions, Task<string>> signedAssertionDelegate)
2829
{
29-
_signedAssertionWithInfoDelegate = signedAssertionDelegate;
30+
_signedAssertionWithInfoDelegate = signedAssertionDelegate ?? throw new ArgumentNullException(nameof(signedAssertionDelegate),
31+
"Signed assertion delegate cannot be null.");
3032
}
3133

3234
public async Task AddConfidentialClientParametersAsync(
@@ -36,17 +38,41 @@ public async Task AddConfidentialClientParametersAsync(
3638
string tokenEndpoint,
3739
CancellationToken cancellationToken)
3840
{
39-
string signedAssertion = await (_signedAssertionDelegate != null
40-
? _signedAssertionDelegate(cancellationToken).ConfigureAwait(false)
41-
: _signedAssertionWithInfoDelegate(new AssertionRequestOptions {
41+
if (_signedAssertionDelegate != null)
42+
{
43+
// If no "AssertionRequestOptions" delegate is supplied
44+
string signedAssertion = await _signedAssertionDelegate(cancellationToken).ConfigureAwait(false);
45+
oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer);
46+
oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, signedAssertion);
47+
}
48+
else
49+
{
50+
// Build the AssertionRequestOptions and conditionally set ClientCapabilities
51+
var assertionOptions = new AssertionRequestOptions
52+
{
4253
CancellationToken = cancellationToken,
4354
ClientID = requestParameters.AppConfig.ClientId,
4455
TokenEndpoint = tokenEndpoint
45-
}).ConfigureAwait(false));
56+
};
4657

47-
oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer);
48-
oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, signedAssertion);
49-
}
58+
// Only set client capabilities if they exist and are not empty
59+
var configuredCapabilities = requestParameters
60+
.RequestContext
61+
.ServiceBundle
62+
.Config
63+
.ClientCapabilities;
64+
65+
if (configuredCapabilities != null && configuredCapabilities.Any())
66+
{
67+
assertionOptions.ClientCapabilities = configuredCapabilities;
68+
}
5069

70+
// Delegate that uses AssertionRequestOptions
71+
string signedAssertion = await _signedAssertionWithInfoDelegate(assertionOptions).ConfigureAwait(false);
72+
73+
oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer);
74+
oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, signedAssertion);
75+
}
76+
}
5177
}
5278
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.get -> System.Collections.Generic.IEnumerable<string>
2+
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.set -> void
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.get -> System.Collections.Generic.IEnumerable<string>
2+
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.set -> void
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.get -> System.Collections.Generic.IEnumerable<string>
2+
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.set -> void
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.get -> System.Collections.Generic.IEnumerable<string>
2+
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.set -> void
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.get -> System.Collections.Generic.IEnumerable<string>
2+
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.set -> void
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.get -> System.Collections.Generic.IEnumerable<string>
2+
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.set -> void

tests/Microsoft.Identity.Test.Unit/PublicApiTests/ConfidentialClientApplicationTests.cs

Lines changed: 204 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -493,12 +493,18 @@ private enum CredentialType
493493
private (ConfidentialClientApplication app, MockHttpMessageHandler handler) CreateConfidentialClient(
494494
MockHttpManager httpManager,
495495
X509Certificate2 cert,
496-
CredentialType credentialType = CredentialType.Certificate)
496+
CredentialType credentialType = CredentialType.Certificate,
497+
bool withClientCapability = false)
497498
{
498499
var builder = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
499500
.WithRedirectUri(TestConstants.RedirectUri)
500501
.WithHttpManager(httpManager);
501502

503+
if (withClientCapability)
504+
{
505+
builder.WithClientCapabilities(TestConstants.ClientCapabilities);
506+
}
507+
502508
ConfidentialClientApplication app;
503509

504510
switch (credentialType)
@@ -526,15 +532,38 @@ private enum CredentialType
526532
Assert.IsNull(app.Certificate);
527533
break;
528534
case CredentialType.SignedAssertionWithAssertionRequestOptionsAsyncDelegate:
529-
builder = builder.WithClientAssertion((options) =>
530535
{
531-
Assert.IsNotNull(options.ClientID);
532-
Assert.IsNotNull(options.TokenEndpoint);
533-
return Task.FromResult(TestConstants.DefaultClientAssertion);
534-
});
535-
app = builder.BuildConcrete();
536-
Assert.IsNull(app.Certificate);
537-
break;
536+
bool localWithClientCapability = withClientCapability;
537+
538+
builder = builder.WithClientAssertion((options) =>
539+
{
540+
// Basic checks
541+
Assert.IsNotNull(options.ClientID);
542+
Assert.IsNotNull(options.TokenEndpoint);
543+
544+
// Conditionally check ClientCapabilities
545+
if (localWithClientCapability)
546+
{
547+
Assert.IsNotNull(options.ClientCapabilities, "Expected ClientCapabilities to be set.");
548+
CollectionAssert.AreEqual(
549+
TestConstants.ClientCapabilities,
550+
options.ClientCapabilities.ToList(),
551+
"ClientCapabilities should match what was configured."
552+
);
553+
}
554+
else
555+
{
556+
Assert.IsNull(options.ClientCapabilities, "ClientCapabilities should not be set if not requested.");
557+
}
558+
559+
return Task.FromResult(TestConstants.DefaultClientAssertion);
560+
});
561+
562+
app = builder.BuildConcrete();
563+
Assert.IsNull(app.Certificate);
564+
565+
break;
566+
}
538567
case CredentialType.Certificate:
539568
builder = builder.WithCertificate(cert);
540569
app = builder.BuildConcrete();
@@ -768,6 +797,35 @@ public async Task ConfidentialClientUsingSignedClientAssertion_AsyncDelegateWith
768797
}
769798
}
770799

800+
[TestMethod]
801+
public async Task SignedAssertionWithClientCapabilitiesTestAsync()
802+
{
803+
using (var httpManager = new MockHttpManager())
804+
{
805+
httpManager.AddInstanceDiscoveryMockHandler();
806+
807+
(ConfidentialClientApplication App, MockHttpMessageHandler Handler) setup =
808+
CreateConfidentialClient(httpManager,
809+
null,
810+
CredentialType.SignedAssertionWithAssertionRequestOptionsAsyncDelegate,
811+
true);
812+
813+
var result = await setup.App.AcquireTokenForClient(TestConstants.s_scope.ToArray())
814+
.ExecuteAsync(CancellationToken.None).ConfigureAwait(false);
815+
Assert.IsNotNull(result);
816+
Assert.IsNotNull("header.payload.signature", result.AccessToken);
817+
Assert.AreEqual(TestConstants.s_scope.AsSingleString(), result.Scopes.AsSingleString());
818+
819+
Assert.AreEqual(
820+
TestConstants.DefaultClientAssertion,
821+
setup.Handler.ActualRequestPostData["client_assertion"]);
822+
823+
Assert.AreEqual(
824+
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
825+
setup.Handler.ActualRequestPostData["client_assertion_type"]);
826+
}
827+
}
828+
771829
[TestMethod]
772830
public async Task ConfidentialClientUsingSignedClientAssertion_AsyncDelegate_CancellationTestAsync()
773831
{
@@ -799,6 +857,68 @@ await AssertException.TaskThrowsAsync<OperationCanceledException>(
799857
}
800858
}
801859

860+
[TestMethod]
861+
public async Task SignedAssertionWithSingleClientCapabilityTestAsync()
862+
{
863+
using (var httpManager = new MockHttpManager())
864+
{
865+
// 1. Instance discovery
866+
httpManager.AddInstanceDiscoveryMockHandler();
867+
868+
// 2. Mock the token endpoint response
869+
httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage();
870+
871+
var builder = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
872+
.WithHttpManager(httpManager)
873+
.WithClientCapabilities(new[] { "cp1" }) // Single capability
874+
.WithClientAssertion((options) =>
875+
{
876+
// Assert - only one capability
877+
Assert.IsNotNull(options.ClientCapabilities);
878+
CollectionAssert.AreEquivalent(
879+
new[] { "cp1" },
880+
options.ClientCapabilities.ToList());
881+
882+
return Task.FromResult(TestConstants.DefaultClientAssertion);
883+
});
884+
885+
var app = builder.BuildConcrete();
886+
887+
// Act - calls the token endpoint
888+
var result = await app.AcquireTokenForClient(TestConstants.s_scope.ToArray())
889+
.ExecuteAsync()
890+
.ConfigureAwait(false);
891+
892+
// Basic validations
893+
Assert.IsNotNull(result);
894+
Assert.AreEqual(TestConstants.s_scope.AsSingleString(), result.Scopes.AsSingleString());
895+
}
896+
}
897+
898+
[TestMethod]
899+
public void Constructor_NullDelegate_ThrowsArgumentNullException()
900+
{
901+
// Arrange
902+
Func<AssertionRequestOptions, Task<string>> nullDelegate = null;
903+
904+
// Act & Assert
905+
Assert.ThrowsException<ArgumentNullException>(() =>
906+
new SignedAssertionDelegateClientCredential(nullDelegate));
907+
}
908+
909+
[TestMethod]
910+
public void Constructor_ValidDelegate_DoesNotThrow()
911+
{
912+
// Arrange
913+
Func<AssertionRequestOptions, Task<string>> validDelegate =
914+
(options) => Task.FromResult("fake_assertion");
915+
916+
// Act & Assert
917+
// Should not throw
918+
var credential = new SignedAssertionDelegateClientCredential(validDelegate);
919+
Assert.IsNotNull(credential);
920+
}
921+
802922
[TestMethod]
803923
public async Task GetAuthorizationRequestUrlNoRedirectUriTestAsync()
804924
{
@@ -1858,5 +1978,80 @@ public void AssertionInputIsMutable()
18581978
options.CancellationToken = CancellationToken.None;
18591979
options.Claims = TestConstants.Claims;
18601980
}
1981+
1982+
[TestMethod]
1983+
public void ConfidentialClient_WithEmptyClientSecret_ThrowsException()
1984+
{
1985+
Assert.ThrowsException<ArgumentNullException>(() =>
1986+
{
1987+
ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
1988+
.WithClientSecret(string.Empty) // or null
1989+
.Build();
1990+
});
1991+
}
1992+
1993+
[TestMethod]
1994+
public async Task ConfidentialClient_WithClaims_TestAsync()
1995+
{
1996+
using (var httpManager = new MockHttpManager())
1997+
{
1998+
httpManager.AddInstanceDiscoveryMockHandler();
1999+
2000+
// Mock success with verifying we got the extra claims in the request
2001+
var handler = httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage();
2002+
handler.ExpectedPostData = new Dictionary<string, string>()
2003+
{
2004+
{ "claims", "{\"extra_claim\":\"value\"}" }
2005+
};
2006+
2007+
var app = ConfidentialClientApplicationBuilder
2008+
.Create(TestConstants.ClientId)
2009+
.WithClientSecret(TestConstants.ClientSecret)
2010+
.WithHttpManager(httpManager)
2011+
.BuildConcrete();
2012+
2013+
var result = await app.AcquireTokenForClient(TestConstants.s_scope)
2014+
.WithClaims("{\"extra_claim\":\"value\"}")
2015+
.ExecuteAsync()
2016+
.ConfigureAwait(false);
2017+
2018+
Assert.IsNotNull(result);
2019+
}
2020+
}
2021+
2022+
[TestMethod]
2023+
public async Task AcquireTokenByAuthorizationCode_NullOrEmptyCode_ThrowsAsync()
2024+
{
2025+
using (var httpManager = new MockHttpManager())
2026+
{
2027+
// Arrange
2028+
var app = ConfidentialClientApplicationBuilder
2029+
.Create(TestConstants.ClientId)
2030+
.WithClientSecret(TestConstants.ClientSecret)
2031+
.WithHttpManager(httpManager)
2032+
.BuildConcrete();
2033+
2034+
// Act & Assert
2035+
await AssertException.TaskThrowsAsync<ArgumentException>(
2036+
() => app.AcquireTokenByAuthorizationCode(TestConstants.s_scope, null).ExecuteAsync()
2037+
).ConfigureAwait(false);
2038+
2039+
await AssertException.TaskThrowsAsync<ArgumentException>(
2040+
() => app.AcquireTokenByAuthorizationCode(TestConstants.s_scope, string.Empty).ExecuteAsync()
2041+
).ConfigureAwait(false);
2042+
}
2043+
}
2044+
2045+
[TestMethod]
2046+
public void ConfidentialClient_WithInvalidAuthority_ThrowsArgumentException()
2047+
{
2048+
Assert.ThrowsException<ArgumentException>(() =>
2049+
{
2050+
ConfidentialClientApplicationBuilder
2051+
.Create(TestConstants.ClientId)
2052+
.WithAuthority("NotAValidAuthority")
2053+
.Build();
2054+
});
2055+
}
18612056
}
18622057
}

0 commit comments

Comments
 (0)