Skip to content

Commit

Permalink
Expose client capabilities in AssertionRequestOptions for MSI FIC sce…
Browse files Browse the repository at this point in the history
…narios (#5140)

* initial

* pr comments

* pr comments

* attempt to increase test coverage

---------

Co-authored-by: Gladwin Johnson <[email protected]>
  • Loading branch information
gladjohn and GladwinJohnson authored Feb 14, 2025
1 parent e9aa448 commit 651b71c
Show file tree
Hide file tree
Showing 9 changed files with 258 additions and 17 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System.Collections.Generic;
using System.Threading;
namespace Microsoft.Identity.Client
{
Expand Down Expand Up @@ -30,5 +31,12 @@ public class AssertionRequestOptions {
/// Claims to be included in the client assertion
/// </summary>
public string Claims { get; set; }

/// <summary>
/// Capabilities that the client application has declared.
/// If the callback implementer calls the token issuer using another client application object
/// (e.g. ManagedIdentityApplication or ConfidentialClientApplication), the same capabilities should be used there.
/// </summary>
public IEnumerable<string> ClientCapabilities { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Identity.Client.Core;
Expand All @@ -26,7 +27,8 @@ public SignedAssertionDelegateClientCredential(Func<CancellationToken, Task<stri

public SignedAssertionDelegateClientCredential(Func<AssertionRequestOptions, Task<string>> signedAssertionDelegate)
{
_signedAssertionWithInfoDelegate = signedAssertionDelegate;
_signedAssertionWithInfoDelegate = signedAssertionDelegate ?? throw new ArgumentNullException(nameof(signedAssertionDelegate),
"Signed assertion delegate cannot be null.");
}

public async Task AddConfidentialClientParametersAsync(
Expand All @@ -36,17 +38,41 @@ public async Task AddConfidentialClientParametersAsync(
string tokenEndpoint,
CancellationToken cancellationToken)
{
string signedAssertion = await (_signedAssertionDelegate != null
? _signedAssertionDelegate(cancellationToken).ConfigureAwait(false)
: _signedAssertionWithInfoDelegate(new AssertionRequestOptions {
if (_signedAssertionDelegate != null)
{
// If no "AssertionRequestOptions" delegate is supplied
string signedAssertion = await _signedAssertionDelegate(cancellationToken).ConfigureAwait(false);
oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer);
oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, signedAssertion);
}
else
{
// Build the AssertionRequestOptions and conditionally set ClientCapabilities
var assertionOptions = new AssertionRequestOptions
{
CancellationToken = cancellationToken,
ClientID = requestParameters.AppConfig.ClientId,
TokenEndpoint = tokenEndpoint
}).ConfigureAwait(false));
};

oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer);
oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, signedAssertion);
}
// Only set client capabilities if they exist and are not empty
var configuredCapabilities = requestParameters
.RequestContext
.ServiceBundle
.Config
.ClientCapabilities;

if (configuredCapabilities != null && configuredCapabilities.Any())
{
assertionOptions.ClientCapabilities = configuredCapabilities;
}

// Delegate that uses AssertionRequestOptions
string signedAssertion = await _signedAssertionWithInfoDelegate(assertionOptions).ConfigureAwait(false);

oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertionType, OAuth2AssertionType.JwtBearer);
oAuth2Client.AddBodyParameter(OAuth2Parameter.ClientAssertion, signedAssertion);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.get -> System.Collections.Generic.IEnumerable<string>
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.set -> void
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.get -> System.Collections.Generic.IEnumerable<string>
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.set -> void
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.get -> System.Collections.Generic.IEnumerable<string>
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.set -> void
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.get -> System.Collections.Generic.IEnumerable<string>
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.set -> void
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.get -> System.Collections.Generic.IEnumerable<string>
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.set -> void
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.get -> System.Collections.Generic.IEnumerable<string>
Microsoft.Identity.Client.AssertionRequestOptions.ClientCapabilities.set -> void
Original file line number Diff line number Diff line change
Expand Up @@ -493,12 +493,18 @@ private enum CredentialType
private (ConfidentialClientApplication app, MockHttpMessageHandler handler) CreateConfidentialClient(
MockHttpManager httpManager,
X509Certificate2 cert,
CredentialType credentialType = CredentialType.Certificate)
CredentialType credentialType = CredentialType.Certificate,
bool withClientCapability = false)
{
var builder = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
.WithRedirectUri(TestConstants.RedirectUri)
.WithHttpManager(httpManager);

if (withClientCapability)
{
builder.WithClientCapabilities(TestConstants.ClientCapabilities);
}

ConfidentialClientApplication app;

switch (credentialType)
Expand Down Expand Up @@ -526,15 +532,38 @@ private enum CredentialType
Assert.IsNull(app.Certificate);
break;
case CredentialType.SignedAssertionWithAssertionRequestOptionsAsyncDelegate:
builder = builder.WithClientAssertion((options) =>
{
Assert.IsNotNull(options.ClientID);
Assert.IsNotNull(options.TokenEndpoint);
return Task.FromResult(TestConstants.DefaultClientAssertion);
});
app = builder.BuildConcrete();
Assert.IsNull(app.Certificate);
break;
bool localWithClientCapability = withClientCapability;

builder = builder.WithClientAssertion((options) =>
{
// Basic checks
Assert.IsNotNull(options.ClientID);
Assert.IsNotNull(options.TokenEndpoint);

// Conditionally check ClientCapabilities
if (localWithClientCapability)
{
Assert.IsNotNull(options.ClientCapabilities, "Expected ClientCapabilities to be set.");
CollectionAssert.AreEqual(
TestConstants.ClientCapabilities,
options.ClientCapabilities.ToList(),
"ClientCapabilities should match what was configured."
);
}
else
{
Assert.IsNull(options.ClientCapabilities, "ClientCapabilities should not be set if not requested.");
}

return Task.FromResult(TestConstants.DefaultClientAssertion);
});

app = builder.BuildConcrete();
Assert.IsNull(app.Certificate);

break;
}
case CredentialType.Certificate:
builder = builder.WithCertificate(cert);
app = builder.BuildConcrete();
Expand Down Expand Up @@ -768,6 +797,35 @@ public async Task ConfidentialClientUsingSignedClientAssertion_AsyncDelegateWith
}
}

[TestMethod]
public async Task SignedAssertionWithClientCapabilitiesTestAsync()
{
using (var httpManager = new MockHttpManager())
{
httpManager.AddInstanceDiscoveryMockHandler();

(ConfidentialClientApplication App, MockHttpMessageHandler Handler) setup =
CreateConfidentialClient(httpManager,
null,
CredentialType.SignedAssertionWithAssertionRequestOptionsAsyncDelegate,
true);

var result = await setup.App.AcquireTokenForClient(TestConstants.s_scope.ToArray())
.ExecuteAsync(CancellationToken.None).ConfigureAwait(false);
Assert.IsNotNull(result);
Assert.IsNotNull("header.payload.signature", result.AccessToken);
Assert.AreEqual(TestConstants.s_scope.AsSingleString(), result.Scopes.AsSingleString());

Assert.AreEqual(
TestConstants.DefaultClientAssertion,
setup.Handler.ActualRequestPostData["client_assertion"]);

Assert.AreEqual(
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
setup.Handler.ActualRequestPostData["client_assertion_type"]);
}
}

[TestMethod]
public async Task ConfidentialClientUsingSignedClientAssertion_AsyncDelegate_CancellationTestAsync()
{
Expand Down Expand Up @@ -799,6 +857,68 @@ await AssertException.TaskThrowsAsync<OperationCanceledException>(
}
}

[TestMethod]
public async Task SignedAssertionWithSingleClientCapabilityTestAsync()
{
using (var httpManager = new MockHttpManager())
{
// 1. Instance discovery
httpManager.AddInstanceDiscoveryMockHandler();

// 2. Mock the token endpoint response
httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage();

var builder = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
.WithHttpManager(httpManager)
.WithClientCapabilities(new[] { "cp1" }) // Single capability
.WithClientAssertion((options) =>
{
// Assert - only one capability
Assert.IsNotNull(options.ClientCapabilities);
CollectionAssert.AreEquivalent(
new[] { "cp1" },
options.ClientCapabilities.ToList());

return Task.FromResult(TestConstants.DefaultClientAssertion);
});

var app = builder.BuildConcrete();

// Act - calls the token endpoint
var result = await app.AcquireTokenForClient(TestConstants.s_scope.ToArray())
.ExecuteAsync()
.ConfigureAwait(false);

// Basic validations
Assert.IsNotNull(result);
Assert.AreEqual(TestConstants.s_scope.AsSingleString(), result.Scopes.AsSingleString());
}
}

[TestMethod]
public void Constructor_NullDelegate_ThrowsArgumentNullException()
{
// Arrange
Func<AssertionRequestOptions, Task<string>> nullDelegate = null;

// Act & Assert
Assert.ThrowsException<ArgumentNullException>(() =>
new SignedAssertionDelegateClientCredential(nullDelegate));
}

[TestMethod]
public void Constructor_ValidDelegate_DoesNotThrow()
{
// Arrange
Func<AssertionRequestOptions, Task<string>> validDelegate =
(options) => Task.FromResult("fake_assertion");

// Act & Assert
// Should not throw
var credential = new SignedAssertionDelegateClientCredential(validDelegate);
Assert.IsNotNull(credential);
}

[TestMethod]
public async Task GetAuthorizationRequestUrlNoRedirectUriTestAsync()
{
Expand Down Expand Up @@ -1858,5 +1978,80 @@ public void AssertionInputIsMutable()
options.CancellationToken = CancellationToken.None;
options.Claims = TestConstants.Claims;
}

[TestMethod]
public void ConfidentialClient_WithEmptyClientSecret_ThrowsException()
{
Assert.ThrowsException<ArgumentNullException>(() =>
{
ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
.WithClientSecret(string.Empty) // or null
.Build();
});
}

[TestMethod]
public async Task ConfidentialClient_WithClaims_TestAsync()
{
using (var httpManager = new MockHttpManager())
{
httpManager.AddInstanceDiscoveryMockHandler();

// Mock success with verifying we got the extra claims in the request
var handler = httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage();
handler.ExpectedPostData = new Dictionary<string, string>()
{
{ "claims", "{\"extra_claim\":\"value\"}" }
};

var app = ConfidentialClientApplicationBuilder
.Create(TestConstants.ClientId)
.WithClientSecret(TestConstants.ClientSecret)
.WithHttpManager(httpManager)
.BuildConcrete();

var result = await app.AcquireTokenForClient(TestConstants.s_scope)
.WithClaims("{\"extra_claim\":\"value\"}")
.ExecuteAsync()
.ConfigureAwait(false);

Assert.IsNotNull(result);
}
}

[TestMethod]
public async Task AcquireTokenByAuthorizationCode_NullOrEmptyCode_ThrowsAsync()
{
using (var httpManager = new MockHttpManager())
{
// Arrange
var app = ConfidentialClientApplicationBuilder
.Create(TestConstants.ClientId)
.WithClientSecret(TestConstants.ClientSecret)
.WithHttpManager(httpManager)
.BuildConcrete();

// Act & Assert
await AssertException.TaskThrowsAsync<ArgumentException>(
() => app.AcquireTokenByAuthorizationCode(TestConstants.s_scope, null).ExecuteAsync()
).ConfigureAwait(false);

await AssertException.TaskThrowsAsync<ArgumentException>(
() => app.AcquireTokenByAuthorizationCode(TestConstants.s_scope, string.Empty).ExecuteAsync()
).ConfigureAwait(false);
}
}

[TestMethod]
public void ConfidentialClient_WithInvalidAuthority_ThrowsArgumentException()
{
Assert.ThrowsException<ArgumentException>(() =>
{
ConfidentialClientApplicationBuilder
.Create(TestConstants.ClientId)
.WithAuthority("NotAValidAuthority")
.Build();
});
}
}
}

0 comments on commit 651b71c

Please sign in to comment.