2

I have a .NET 6 web API project that calls Graph as the app (rather than on behalf of the user).

I'm using the Microsoft.Identity.Web package, and my Program.cs has this code to initialise it:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"))
    .EnableTokenAcquisitionToCallDownstreamApi()
    .AddMicrosoftGraphAppOnly(auth => new Microsoft.Graph.GraphServiceClient(auth))
    .AddInMemoryTokenCaches();

I've given the Azure AD App Registration the necessary Graph app permissions (User.Read.All in this example) and granted admin consent.

When I'm debugging locally, I've set a ClientSecret in my User Secrets and the following call works a treat:

var photo = await _graph.Users[id].Photo.Content.Request().GetAsync();

However, when I'm deployed to an Azure App Service, I don't want to use a ClientSecret. I want to use the System-Assigned Managed Identity of my App Service.

Microsoft.Identity.Web apparently supports a UserAssignedManagedIdentityClientId option, so I have set an environment variable to the ClientID of my App Service:

UserAssignedManagedIdentityClientID environment variable

... and that does seem to be getting picked up by the code.

However, when I try to execute the graph call, I get an error. The stack trace looks like this (sensitive info redacted):

An unhandled exception has occurred while executing the request.

Exception: 
Status Code: 0
Microsoft.Graph.ServiceException: Code: generalException
Message: An error occurred sending the request.

 ---> MSAL.NetCore.4.42.0.0.MsalServiceException: 
    ErrorCode: invalid_request
Microsoft.Identity.Client.MsalServiceException: AADSTS70021: No matching federated identity record found for presented assertion. Assertion Issuer: 'https://login.microsoftonline.com/{tenant}/v2.0'. Assertion Subject: '{msi-objectid}'. Assertion Audience: 'fb60f99c-7a34-4190-8149-302f77469936'. https://learn.microsoft.com/en-us/azure/active-directory/develop/workload-identity-federation
Trace ID: 7862bb35-0bdd-40c9-9c7b-cb12a8a0f200
Correlation ID: 97f749c8-f91d-434e-9ecd-111c65037399
Timestamp: 2022-03-23 05:11:08Z
   at Microsoft.Identity.Client.Internal.Requests.RequestBase.HandleTokenRefreshErrorAsync(MsalServiceException e, MsalAccessTokenCacheItem cachedAccessTokenItem)
   at Microsoft.Identity.Client.Internal.Requests.ClientCredentialRequest.ExecuteAsync(CancellationToken cancellationToken)
   at Microsoft.Identity.Client.Internal.Requests.RequestBase.RunAsync(CancellationToken cancellationToken)
   at Microsoft.Identity.Client.ApiConfig.Executors.ConfidentialClientExecutor.ExecuteAsync(AcquireTokenCommonParameters commonParameters, AcquireTokenForClientParameters clientParameters, CancellationToken cancellationToken)
   at Microsoft.Identity.Web.TokenAcquisitionAuthenticationProvider.AuthenticateRequestAsync(HttpRequestMessage request)
   at Microsoft.Graph.AuthenticationHandler.SendAsync(HttpRequestMessage httpRequestMessage, CancellationToken cancellationToken)
   at System.Net.Http.HttpClient.<SendAsync>g__Core|83_0(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationTokenSource cts, Boolean disposeCts, CancellationTokenSource pendingRequestsCts, CancellationToken originalCancellationToken)
   at Microsoft.Graph.HttpProvider.SendRequestAsync(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationToken cancellationToken)
    StatusCode: 400 
    ResponseBody: {"error":"invalid_request","error_description":"AADSTS70021: No matching federated identity record found for presented assertion. Assertion Issuer: 'https://login.microsoftonline.com/{tenant}/v2.0'. Assertion Subject: '{msi-objectid}'. Assertion Audience: 'fb60f99c-7a34-4190-8149-302f77469936'. https://learn.microsoft.com/en-us/azure/active-directory/develop/workload-identity-federation\r\nTrace ID: 7862bb35-0bdd-40c9-9c7b-cb12a8a0f200\r\nCorrelation ID: 97f749c8-f91d-434e-9ecd-111c65037399\r\nTimestamp: 2022-03-23 05:11:08Z","error_codes":[70021],"timestamp":"2022-03-23 05:11:08Z","trace_id":"7862bb35-0bdd-40c9-9c7b-cb12a8a0f200","correlation_id":"97f749c8-f91d-434e-9ecd-111c65037399","error_uri":"https://login.microsoftonline.com/error?code=70021"} 
    Headers: Cache-Control: no-store, no-cache

The interesting thing here is that the "Assertion Subject" in the stack trace erorr is the ObjectID of my managed identity, not the ClientID. So it seems like it has found the App Service correctly but hitting a wall.

Has anyone been able to pull this off? Can I call Graph using the system managed identity of my app service?

Matt Hamilton
  • 200,371
  • 61
  • 386
  • 320
  • I think this doc https://learn.microsoft.com/en-us/azure/app-service/scenario-secure-app-access-microsoft-graph-as-app?tabs=azure-powershell#call-microsoft-graph shows the "right" way to spin up a GraphServiceClient using a ManagedIdentityCredential, but I don't know how to use this code with the simple "AddMicrosoftGraphAppOnly()" call I'm doing in my Program.cs. – Matt Hamilton Mar 24 '22 at 02:43

1 Answers1

1

OK, I have code here that works. Posting in case it helps others or in case someone can critique it and make it better.

Essentially I have moved away from using .AddMicrosoftGraphAppOnly() to add the GraphServiceClient, and am instead spinning up a ChainedTokenCredential which checks for a ClientSecret in the settings but also tries to use a Managed Identity if one exists.

I'm using that credential object to fetch an access token, and caching it for its lifetime.

This is working when running from Visual Studio with a ClientSecret in the user secrets, and when running in an Azure App Service with no ClientSecret set.

Hope this helps someone! Let me know if you think the code can be improved!

builder.Services.AddSingleton<TokenCredential>(services =>
{
    var options = new MicrosoftIdentityOptions();
    builder.Configuration.Bind("AzureAd", options);

    var creds = new List<TokenCredential> { new ManagedIdentityCredential() };
    if (!string.IsNullOrEmpty(options.ClientSecret))
    {
        creds.Add(new ClientSecretCredential(options.TenantId, options.ClientId, options.ClientSecret));
    }
    return new ChainedTokenCredential(creds.ToArray());
});

// Add services to the container.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"))
    .EnableTokenAcquisitionToCallDownstreamApi()
    .AddInMemoryTokenCaches();

builder.Services.AddScoped(services => new GraphServiceClient(services.GetRequiredService<TokenCredential>()));

(Edit - I've gotten rid of the token caching code. I think the TokenCredential objects do that for me.)

Matt Hamilton
  • 200,371
  • 61
  • 386
  • 320
  • 1
    Only ClientSecretCredential is able to cache the access token. Imo you don't need ClientSecretCredential, but should use AzureCliCredential. With this you don't have to take care about any client secret. – Rookian Jul 06 '22 at 22:34
  • @Rookian My app doesn't access Graph on behalf of the user though - it's accessing as the app. So I don't think Azure CLI would work, because I would be signing into the Azure CLI as myself, right? – Matt Hamilton Jul 19 '22 at 05:40