Skip to content

Commit 8909553

Browse files
Lozensky/enable managed identity (#2650)
* Add logic to default to using managed identity if provided. * remove blank line * Updated with caching and new design * rearranging methods * made GetOrBuildManagedIdentityApplication async * added unit test for application caching * finished unit test first draft * minor changes * changed according to PR feedback * Add logic to default to using managed identity if provided. * remove blank line * Updated with caching and new design * rearranging methods * made GetOrBuildManagedIdentityApplication async * added unit test for application caching * finished unit test first draft * minor changes * changed according to PR feedback * Rebase onto main * added system-assigned managed identity e2e test * Implemented PR feedback * changing test to use user-assigned managed identity * fixing tests * Added configuration to e2e test * moved build to after identity options config * moving builder back * fixed bug with TokenAcquisitionOptions/DefaultAuthorizationHeaderProvider * simplified e2e test * added concurrency test and removed reflection * addressed PR comments and removed unnecessary code * removed extra space * addressed PR feedback * making changes per PR comments * removing test traces --------- Co-authored-by: Jean-Marc Prieur <[email protected]>
1 parent 59ce6ad commit 8909553

File tree

9 files changed

+363
-45
lines changed

9 files changed

+363
-45
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,3 +350,7 @@ MigrationBackup/
350350
# Ionide (cross platform F# VS Code tools) working folder
351351
.ionide/
352352
/tools/app-provisioning-tool/testwebapp
353+
354+
# Playwright e2e testing trace files
355+
/tests/E2E Tests/PlaywrightTraces
356+
/tests/IntegrationTests/PlaywrightTraces

src/Microsoft.Identity.Web.TokenAcquisition/DefaultAuthorizationHeaderProvider.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ public async Task<string> CreateAuthorizationHeaderForUserAsync(
3737
}
3838

3939
/// <inheritdoc/>
40-
public async Task<string> CreateAuthorizationHeaderForAppAsync(string scopes, AuthorizationHeaderProviderOptions? downstreamApiOptions = null, CancellationToken cancellationToken = default)
40+
public async Task<string> CreateAuthorizationHeaderForAppAsync(
41+
string scopes,
42+
AuthorizationHeaderProviderOptions? downstreamApiOptions = null,
43+
CancellationToken cancellationToken = default)
4144
{
4245
var result = await _tokenAcquisition.GetAuthenticationResultForAppAsync(
4346
scopes,
@@ -47,7 +50,9 @@ public async Task<string> CreateAuthorizationHeaderForAppAsync(string scopes, Au
4750
return result.CreateAuthorizationHeader();
4851
}
4952

50-
private static TokenAcquisitionOptions CreateTokenAcquisitionOptionsFromApiOptions(AuthorizationHeaderProviderOptions? downstreamApiOptions, CancellationToken cancellationToken)
53+
private static TokenAcquisitionOptions CreateTokenAcquisitionOptionsFromApiOptions(
54+
AuthorizationHeaderProviderOptions? downstreamApiOptions,
55+
CancellationToken cancellationToken)
5156
{
5257
return new TokenAcquisitionOptions()
5358
{
@@ -58,6 +63,7 @@ private static TokenAcquisitionOptions CreateTokenAcquisitionOptionsFromApiOptio
5863
ExtraQueryParameters = downstreamApiOptions?.AcquireTokenOptions.ExtraQueryParameters,
5964
ForceRefresh = downstreamApiOptions?.AcquireTokenOptions.ForceRefresh ?? false,
6065
LongRunningWebApiSessionKey = downstreamApiOptions?.AcquireTokenOptions.LongRunningWebApiSessionKey,
66+
ManagedIdentity = downstreamApiOptions?.AcquireTokenOptions.ManagedIdentity,
6167
Tenant = downstreamApiOptions?.AcquireTokenOptions.Tenant,
6268
UserFlow = downstreamApiOptions?.AcquireTokenOptions.UserFlow,
6369
PopPublicKey = downstreamApiOptions?.AcquireTokenOptions.PopPublicKey,
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System.Collections.Concurrent;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Microsoft.Identity.Abstractions;
8+
using Microsoft.Identity.Client;
9+
using Microsoft.Identity.Client.AppConfig;
10+
using Microsoft.IdentityModel.Tokens;
11+
12+
namespace Microsoft.Identity.Web
13+
{
14+
/// <summary>
15+
/// Portion of the TokenAcquisition class that handles logic unique to managed identity.
16+
/// </summary>
17+
internal partial class TokenAcquisition
18+
{
19+
private readonly ConcurrentDictionary<string, IManagedIdentityApplication> _managedIdentityApplicationsByClientId = new();
20+
private readonly SemaphoreSlim _managedIdSemaphore = new(1, 1);
21+
private const string SystemAssignedManagedIdentityKey = "SYSTEM";
22+
23+
/// <summary>
24+
/// Gets a cached ManagedIdentityApplication object or builds a new one if not found.
25+
/// </summary>
26+
/// <param name="mergedOptions">The configuration options for the app.</param>
27+
/// <param name="managedIdentityOptions">The configuration specific to managed identity.</param>
28+
/// <returns>The application object used to request a token with managed identity.</returns>
29+
internal async Task<IManagedIdentityApplication> GetOrBuildManagedIdentityApplication(
30+
MergedOptions mergedOptions,
31+
ManagedIdentityOptions managedIdentityOptions)
32+
{
33+
string key = GetCacheKeyForManagedId(managedIdentityOptions);
34+
35+
// Check if the application is already built, if so return it without grabbing the lock
36+
if (_managedIdentityApplicationsByClientId.TryGetValue(key, out IManagedIdentityApplication? application))
37+
{
38+
return application;
39+
}
40+
41+
// Lock the potential write of the dictionary to prevent multiple threads from creating the same application.
42+
await _managedIdSemaphore.WaitAsync();
43+
try
44+
{
45+
// Check if the application is already built (could happen between previous check and obtaining the key)
46+
if (_managedIdentityApplicationsByClientId.TryGetValue(key, out application))
47+
{
48+
return application;
49+
}
50+
51+
// Set managedIdentityId to the correct value for either system or user assigned
52+
ManagedIdentityId managedIdentityId;
53+
if (key == SystemAssignedManagedIdentityKey)
54+
{
55+
managedIdentityId = ManagedIdentityId.SystemAssigned;
56+
}
57+
else
58+
{
59+
managedIdentityId = ManagedIdentityId.WithUserAssignedClientId(key);
60+
}
61+
62+
// Build the application
63+
application = BuildManagedIdentityApplication(
64+
managedIdentityId,
65+
mergedOptions.ConfidentialClientApplicationOptions.EnablePiiLogging
66+
);
67+
68+
// Add the application to the cache
69+
_managedIdentityApplicationsByClientId.TryAdd(key, application);
70+
}
71+
finally
72+
{
73+
// Now that the dictionary is updated, release the semaphore
74+
_managedIdSemaphore.Release();
75+
}
76+
return application;
77+
}
78+
79+
/// <summary>
80+
/// Creates a managed identity client application.
81+
/// </summary>
82+
/// <param name="managedIdentityId">Indicates if system-assigned or user-assigned managed identity is used.</param>
83+
/// <param name="enablePiiLogging">Indicates if logging that may contain personally identifiable information is enabled.</param>
84+
/// <returns>A managed identity application.</returns>
85+
private IManagedIdentityApplication BuildManagedIdentityApplication(ManagedIdentityId managedIdentityId, bool enablePiiLogging)
86+
{
87+
return ManagedIdentityApplicationBuilder
88+
.Create(managedIdentityId)
89+
.WithLogging(
90+
Log,
91+
ConvertMicrosoftExtensionsLogLevelToMsal(_logger),
92+
enablePiiLogging: enablePiiLogging)
93+
.Build();
94+
}
95+
96+
/// <summary>
97+
/// Gets the key value for the Managed Identity cache, the default key for system-assigned identity is used if there is
98+
/// no clientId for a user-assigned identity specified. The method is internal rather than private for testing purposes.
99+
/// </summary>
100+
/// <param name="managedIdOptions">Holds the clientId for managed identity if none is present.</param>
101+
/// <returns>A key value for the Managed Identity cache.</returns>
102+
internal static string GetCacheKeyForManagedId(ManagedIdentityOptions managedIdOptions)
103+
{
104+
if (managedIdOptions.UserAssignedClientId.IsNullOrEmpty())
105+
{
106+
return SystemAssignedManagedIdentityKey;
107+
}
108+
else
109+
{
110+
return managedIdOptions.UserAssignedClientId!;
111+
}
112+
}
113+
}
114+
}

src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@
1919
using Microsoft.Identity.Client;
2020
using Microsoft.Identity.Client.Advanced;
2121
using Microsoft.Identity.Client.Extensibility;
22+
using Microsoft.Identity.Web.Experimental;
2223
using Microsoft.Identity.Web.TokenCacheProviders;
2324
using Microsoft.Identity.Web.TokenCacheProviders.InMemory;
2425
using Microsoft.IdentityModel.JsonWebTokens;
2526
using Microsoft.IdentityModel.Tokens;
26-
using Microsoft.Identity.Web.Experimental;
2727

2828
namespace Microsoft.Identity.Web
2929
{
@@ -47,9 +47,9 @@ class OAuthConstants
4747
private readonly object _applicationSyncObj = new();
4848

4949
/// <summary>
50-
/// Please call GetOrBuildConfidentialClientApplication instead of accessing this field directly.
50+
/// Please call GetOrBuildConfidentialClientApplication instead of accessing _applicationsByAuthorityClientId directly.
5151
/// </summary>
52-
private readonly ConcurrentDictionary<string, IConfidentialClientApplication?> _applicationsByAuthorityClientId = new ConcurrentDictionary<string, IConfidentialClientApplication?>();
52+
private readonly ConcurrentDictionary<string, IConfidentialClientApplication?> _applicationsByAuthorityClientId = new();
5353
private bool _retryClientCertificate;
5454
protected readonly IMsalHttpClientFactory _httpClientFactory;
5555
protected readonly ILogger _logger;
@@ -115,7 +115,7 @@ public async Task<AcquireTokenResult> AddAccountToCacheFromAuthorizationCodeAsyn
115115
_ = Throws.IfNull(authCodeRedemptionParameters.Scopes);
116116
MergedOptions mergedOptions = _tokenAcquisitionHost.GetOptions(authCodeRedemptionParameters.AuthenticationScheme, out string effectiveAuthenticationScheme);
117117

118-
IConfidentialClientApplication? application=null;
118+
IConfidentialClientApplication? application = null;
119119
try
120120
{
121121
application = GetOrBuildConfidentialClientApplication(mergedOptions);
@@ -321,9 +321,10 @@ private void LogAuthResult(AuthenticationResult? authenticationResult)
321321

322322
/// <summary>
323323
/// Acquires an authentication result from the authority configured in the app, for the confidential client itself (not on behalf of a user)
324-
/// using the client credentials flow. See https://aka.ms/msal-net-client-credentials.
324+
/// using either a client credentials or managed identity flow. See https://aka.ms/msal-net-client-credentials for client credentials or
325+
/// https://aka.ms/Entra/ManagedIdentityOverview for managed identity.
325326
/// </summary>
326-
/// <param name="scope">The scope requested to access a protected API. For this flow (client credentials), the scope
327+
/// <param name="scope">The scope requested to access a protected API. For these flows (client credentials or managed identity), the scope
327328
/// should be of the form "{ResourceIdUri/.default}" for instance <c>https://management.azure.net/.default</c> or, for Microsoft
328329
/// Graph, <c>https://graph.microsoft.com/.default</c> as the requested scopes are defined statically with the application registration
329330
/// in the portal, and cannot be overridden in the application, as you can request a token for only one resource at a time (use
@@ -358,10 +359,28 @@ public async Task<AuthenticationResult> GetAuthenticationResultForAppAsync(
358359
throw new ArgumentException(IDWebErrorMessage.ClientCredentialTenantShouldBeTenanted, nameof(tenant));
359360
}
360361

362+
// If using managed identity
363+
if (tokenAcquisitionOptions != null && tokenAcquisitionOptions.ManagedIdentity != null)
364+
{
365+
try
366+
{
367+
IManagedIdentityApplication managedIdApp = await GetOrBuildManagedIdentityApplication(
368+
mergedOptions,
369+
tokenAcquisitionOptions.ManagedIdentity
370+
);
371+
return await managedIdApp.AcquireTokenForManagedIdentity(scope).ExecuteAsync().ConfigureAwait(false);
372+
}
373+
catch (Exception ex)
374+
{
375+
Logger.TokenAcquisitionError(_logger, ex.Message, ex);
376+
throw;
377+
}
378+
}
379+
361380
// Use MSAL to get the right token to call the API
362381
var application = GetOrBuildConfidentialClientApplication(mergedOptions);
363382

364-
var builder = application
383+
AcquireTokenForClientParameterBuilder builder = application
365384
.AcquireTokenForClient(new[] { scope }.Except(_scopesRequestedByMsal))
366385
.WithSendX5C(mergedOptions.SendX5C);
367386

@@ -585,7 +604,6 @@ private bool IsInvalidClientCertificateOrSignedAssertionError(MsalServiceExcepti
585604
_applicationsByAuthorityClientId.TryAdd(GetApplicationKey(mergedOptions), application);
586605
}
587606
}
588-
589607
return application;
590608
}
591609

@@ -599,7 +617,7 @@ private IConfidentialClientApplication BuildConfidentialClientApplication(Merged
599617

600618
try
601619
{
602-
var builder = ConfidentialClientApplicationBuilder
620+
ConfidentialClientApplicationBuilder builder = ConfidentialClientApplicationBuilder
603621
.CreateWithApplicationOptions(mergedOptions.ConfidentialClientApplicationOptions)
604622
.WithHttpClientFactory(_httpClientFactory)
605623
.WithLogging(
@@ -848,8 +866,10 @@ private static void CheckAssertionsForInjectionAttempt(string assertion, string
848866
if (!assertion.IsNullOrEmpty() && assertion.Contains('&')) throw new ArgumentException(IDWebErrorMessage.InvalidAssertion, nameof(assertion));
849867
if (!subAssertion.IsNullOrEmpty() && subAssertion.Contains('&')) throw new ArgumentException(IDWebErrorMessage.InvalidSubAssertion, nameof(subAssertion));
850868
#else
851-
if (!assertion.IsNullOrEmpty() && assertion.Contains('&', StringComparison.InvariantCultureIgnoreCase)) throw new ArgumentException(IDWebErrorMessage.InvalidAssertion, nameof(assertion));
852-
if (!subAssertion.IsNullOrEmpty() && subAssertion.Contains('&', StringComparison.InvariantCultureIgnoreCase)) throw new ArgumentException(IDWebErrorMessage.InvalidSubAssertion, nameof(subAssertion));
869+
if (!assertion.IsNullOrEmpty() && assertion.Contains('&', StringComparison.InvariantCultureIgnoreCase))
870+
throw new ArgumentException(IDWebErrorMessage.InvalidAssertion, nameof(assertion));
871+
if (!subAssertion.IsNullOrEmpty() && subAssertion.Contains('&', StringComparison.InvariantCultureIgnoreCase))
872+
throw new ArgumentException(IDWebErrorMessage.InvalidSubAssertion, nameof(subAssertion));
853873
#endif
854874
}
855875
}

src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisitionOptions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public class TokenAcquisitionOptions : AcquireTokenOptions
4545
PopClaim = PopClaim,
4646
CancellationToken = CancellationToken,
4747
LongRunningWebApiSessionKey = LongRunningWebApiSessionKey,
48+
ManagedIdentity = ManagedIdentity,
4849
};
4950
}
5051
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using Xunit;
6+
7+
namespace TokenAcquirerTests
8+
{
9+
public sealed class OnlyOnAzureDevopsFactAttribute : FactAttribute
10+
{
11+
public OnlyOnAzureDevopsFactAttribute()
12+
{
13+
if (IgnoreOnAzureDevopsFactAttribute.IsRunningOnAzureDevOps())
14+
{
15+
return;
16+
}
17+
Skip = "Ignored when not on Azure DevOps";
18+
}
19+
}
20+
}

tests/E2E Tests/TokenAcquirerTests/TokenAcquirer.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33

44
using System;
55
using System.Linq;
6+
using System.Net.Http;
67
using System.Security.Cryptography;
78
using System.Security.Cryptography.X509Certificates;
89
using System.Threading.Tasks;
10+
using Microsoft.AspNetCore.Http;
911
using Microsoft.Extensions.DependencyInjection;
1012
using Microsoft.Extensions.Options;
1113
using Microsoft.Graph;
@@ -299,5 +301,46 @@ private static async Task CreateGraphClientAndAssert(TokenAcquirerFactory tokenA
299301
Assert.NotNull(result.AccessToken);
300302
}
301303
}
304+
305+
public class AcquireTokenManagedIdentity
306+
{
307+
[OnlyOnAzureDevopsFact]
308+
//[Fact]
309+
public async Task AcquireTokenWithManagedIdentity_UserAssigned()
310+
{
311+
// Arrange
312+
const string scope = "https://vault.azure.net/.default";
313+
const string baseUrl = "https://vault.azure.net";
314+
const string clientId = "9c5896db-a74a-4b1a-a259-74c5080a3a6a";
315+
TokenAcquirerFactory tokenAcquirerFactory = TokenAcquirerFactory.GetDefaultInstance();
316+
_ = tokenAcquirerFactory.Services;
317+
IServiceProvider serviceProvider = tokenAcquirerFactory.Build();
318+
319+
// Act: Get the authorization header provider and add the options to tell it to use Managed Identity
320+
IAuthorizationHeaderProvider? api = serviceProvider.GetRequiredService<IAuthorizationHeaderProvider>();
321+
Assert.NotNull(api);
322+
string result = await api.CreateAuthorizationHeaderForAppAsync(scope, GetAuthHeaderOptions_ManagedId(baseUrl, clientId));
323+
324+
// Assert: Make sure we got a token
325+
Assert.False(string.IsNullOrEmpty(result));
326+
}
327+
328+
private static AuthorizationHeaderProviderOptions GetAuthHeaderOptions_ManagedId(string baseUrl, string? userAssignedClientId=null)
329+
{
330+
ManagedIdentityOptions managedIdentityOptions = new()
331+
{
332+
UserAssignedClientId = userAssignedClientId
333+
};
334+
AcquireTokenOptions aquireTokenOptions = new()
335+
{
336+
ManagedIdentity = managedIdentityOptions
337+
};
338+
return new AuthorizationHeaderProviderOptions()
339+
{
340+
BaseUrl = baseUrl,
341+
AcquireTokenOptions = aquireTokenOptions
342+
};
343+
}
344+
}
302345
#endif //FROM_GITHUB_ACTION
303346
}

tests/Microsoft.Identity.Web.Test.Common/TestConstants.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System;
55
using System.Collections.Generic;
66
using System.IO;
7+
using System.Reflection;
78

89
namespace Microsoft.Identity.Web.Test.Common
910
{
@@ -186,8 +187,14 @@ public static class TestConstants
186187
public static readonly string s_todoListServicePath = Path.DirectorySeparatorChar.ToString() + "TodoListService";
187188

188189

189-
// TokenAcqusitionOptions
190+
// TokenAcqusitionOptions and ManagedIdentityOptions
190191
public static Guid s_correlationId = new Guid("6347d33d-941a-4c35-9912-a9cf54fb1b3e");
192+
public const string UserAssignedManagedIdentityClientId = "3b57c42c-3201-4295-ae27-d6baec5b7027";
193+
public const string UserAssignedManagedIdentityResourceId = "/subscriptions/c1686c51-b717-4fe0-9af3-24a20a41fb0c/" +
194+
"resourcegroups/MSAL_MSI/providers/Microsoft.ManagedIdentity/userAssignedIdentities/" + "MSAL_MSI_USERID";
195+
public const BindingFlags StaticPrivateFieldFlags = BindingFlags.GetField | BindingFlags.Static | BindingFlags.NonPublic;
196+
public const BindingFlags InstancePrivateFieldFlags = BindingFlags.GetField | BindingFlags.Instance | BindingFlags.NonPublic;
197+
public const BindingFlags StaticPrivateMethodFlags = BindingFlags.InvokeMethod | BindingFlags.Static | BindingFlags.NonPublic;
191198

192199
// AadIssuerValidation
193200
public const string AadAuthority = "aadAuthority";

0 commit comments

Comments
 (0)