Microsoft-identity-web: [Feature Request] It should be possible to use GraphServiceClient to call graph APIs requiring app only permissions

Created on 5 Oct 2020  路  20Comments  路  Source: AzureAD/microsoft-identity-web

Is your feature request related to a problem? Please describe.
When developers want to call graph to call an app only method, the current GraphServiceClient cannot be used as it uses GetTokenForUserAsync. See https://github.com/AzureAD/microsoft-identity-web/blob/f609eeef9e69cee9c997bb73fb98d7c180d845af/src/Microsoft.Identity.Web.MicrosoftGraph/TokenAcquisitionCredentialProvider.cs#L39

number of customers hitting this issue so far: 2

Describe the solution you'd like
Have a way to call app only methods (corresponding to App-only scopes)

Describe alternatives you've considered

Additional context
See the ASP.NET Core Graph web hooks sample where the GraphServiceClient can only be used for delegated scopes:

Spec'd enhancement fixed

Most helpful comment

Included in 1.2.0 release.

cc: @darrelmiller @baywet @pschaeflein @1iveowl

All 20 comments

Maybe little bit of topic; but I want to avoid using an app account as they very often (if not always) need Admin consent from the tenant. For client applications it can be helpful is the gateway way api to the graph will do some background work

So imagine:
native client app -> user signs in and goes with token to gateway api

gateway api -> with the token from the native client is able to exchange it to a token for the graph api. If the offline scope is present this new access token will have a refrsh token too. Now want to 'store' it somewhere so a background process is able to get new access tokens to do work in the background for this specific user (in my case it was updating webhook subscription)

Currently the api takes so many concerns out of your hands that you don't see/ have access to this refresh token at all. Making it hard to get a background process alive for a user

@davesmits, so I guess Microsoft.identity.Web should enable background processes .... :)

Proposed API

Given that we cannot intervene in the GraphServiceClient class, and we want to enable GraphServiceClient properties/methods/queries that return information requesting app-only permissions (client credentials), the idea is to intervene at the authentication provider level (this is what uses ITokenAcquisition).

In order to have a local effect, I propose to have a new class MicrosoftGraphTokenAcquisitionOptions in the Microsoft.Identity.Web.MicrosoftGraph project, and that would implement IDisposable, so that its effect is effectively contained into a method or a block through the using instruction

App-only scopes

To call a method/property (like Applications) that requires app-only-permissions, the developer experience would be something like the following.

 /// <summary>
/// Illustrates how to call MicrosoftGraph using the Graph SDK and request app-only permissions.
/// These permissions must have been pre-consented by the admin.
/// </summary>
/// <returns></returns>
public async Task<IEnumerable<string>> Get100FirstApplications()
{
  using var graphOptions = MicrosoftGraphTokenAcquisitionOptions.UseAppOnlyPermissions(_graphServiceClient);

  var apps = await _graphServiceClient.Applications.Request().GetAsync();
  return apps.Select(a => a.DisplayName);
}

in this method the using instruction applies until the end of the method, but a using() {} block could be used to have more fine grain control.

Additional scopes

As we are solving this problem, we could also enable overriding the default scopes defined in authentication provider, writing something like the following

using (var graphTokenAcquisitionOptions = new MicrosoftGraphTokenAcquisitionOptions(_graphServiceClient, "mail.read"))
{
   var mails = _graphServiceClient.Me.MailFolders.Inbox.Messages.Request().GetAsync();
   // do something with mails
}

Discussion

Why a static method for the app-only permissions?

I had initially thought of exposing a constructor like:

 public MicrosoftGraphTokenAcquisitionOptions(GraphServiceClient graphServiceClient,
            bool appOnlyPermissions = false, params string[] scopes)

however, in the case where appOnlyPermissions is true, no need to pass-in the scopes as they should be https://graph.microsoft.com/.default (client creds), so a static method MicrosoftGraphTokenAcquisitionOptions.GetAppOnlyPermissions(_graphServiceClient)) is better.

We could expose more:

Not requested by anybody yet, but we might want to also expose a constructor with the tenantId (for multi-tenant applications), and the TokenAcquisitionOptions.

It would actually be useful in the scenario show cased in this test app: https://github.com/AzureAD/microsoft-identity-web/blob/3411f8a2d91d979eecbad833ac045a66680eb41f/tests/WebAppCallsWebApiCallsGraph/TodoListService/Controllers/TodoListController.cs#L51

Question

Would it be better to pass-in a delegate? probably not, as this is a different mechanism than with IDownstreamWebApi (where the options passed-in are local to the call. In the case of GraphService client this is for the lifetime of the MicrosoftGraphTokenAcquisitionOptions instance (until it's disposed), that is a block.

Proposed design:

MicrosoftGraphTokenAcquisitionOptions class

Something like the following?

using System;
using Microsoft.Graph;

namespace Microsoft.Identity.Web.MicrosoftGraph
{
    /// <summary>
    /// Token acquisition options to apply to the Microsoft Graph SDK's GraphServiceClient
    /// </summary>
    public class MicrosoftGraphTokenAcquisitionOptions
        : IDisposable
    {
        /// <summary>
        /// Builds a MicrosoftGraphTokenAcquisitionOptions instance allowing for app only permissions
        /// </summary>
        /// <param name="graphServiceClient">Graph service client, injected by dependency injection
        /// for which we want to use app only permissions in the scope of the lifetime of the created
        /// `MicrosoftGraphTokenAcquisitionOptions`</param>
        /// <returns></returns>
        public static MicrosoftGraphTokenAcquisitionOptions UseAppOnlyPermissions(GraphServiceClient graphServiceClient)
        {
            return new MicrosoftGraphTokenAcquisitionOptions(graphServiceClient, true, "https://graph.microsoft.com/.default");
        }

        /// <summary>
        /// Builds a MicrosoftGraphTokenAcquisitionOptions instance allowing to override delegated permission scopes
        /// </summary>
        /// <param name="graphServiceClient"></param>
        /// <param name="scopes"></param>
        public MicrosoftGraphTokenAcquisitionOptions(GraphServiceClient graphServiceClient,
           params string[] scopes) : this(graphServiceClient, false, scopes)
        {
        }

        private MicrosoftGraphTokenAcquisitionOptions(GraphServiceClient graphServiceClient,
            bool appOnlyPermissions = false, params string[] scopes)
        {
#pragma warning disable CS8601 // Possible null reference assignment.
            tokenAcquisitionCredentialProvider = graphServiceClient.AuthenticationProvider as TokenAcquisitionCredentialProvider;
#pragma warning restore CS8601 // Possible null reference assignment.
            if (tokenAcquisitionCredentialProvider == null)
            {
                throw new ArgumentException("graphServiceClient should be injected with dependency injection after you use .AddMicrosoftGraph in the Startup.cs");
            }

            // Keeps the previous values of tokenAcquisitionCredentialProvider and sets the new value
            PreviousAppOnlyPermissions = tokenAcquisitionCredentialProvider.RequiresAppPermissions;
            PreviousScopes = tokenAcquisitionCredentialProvider.Scopes;

            tokenAcquisitionCredentialProvider.RequiresAppPermissions = appOnlyPermissions;
            tokenAcquisitionCredentialProvider.Scopes = scopes;

            // We could also have:
            // - TenantId
            // - TokenAcquisitionOptions
        }


        private bool PreviousAppOnlyPermissions { get; set; }

        private string[] PreviousScopes { get; set; }

        private TokenAcquisitionCredentialProvider tokenAcquisitionCredentialProvider;

        /// <summary>
        /// Restores the previous value of the token acquisition properties in the tokenAcquisitionCredentialProvider
        /// </summary>
        public void Dispose()
        {
            tokenAcquisitionCredentialProvider.Scopes = PreviousScopes;
            tokenAcquisitionCredentialProvider.RequiresAppPermissions = PreviousAppOnlyPermissions;
        }
    }
}

Update TokenAcquisitionCredentialProvider

Add internal properties

for instance:

        internal string[] Scopes { get; set; }

        internal bool RequiresAppPermissions { get; set; }

Modify AuthenticateRequestAsync()

Something like the following?

  • compute the scopes (with a default being the initiali scopes)
  • get the access token based on whether this is an app only or a delegated permission.
       public async Task AuthenticateRequestAsync(HttpRequestMessage request)
        {
            IEnumerable<string> effectiveScopes = (Scopes != null && Scopes.Any()) ? Scopes : _initialScopes;

            string accessToken = RequiresAppPermissions
                ? await _tokenAcquisition.GetAccessTokenForAppAsync("https://graph.microsoft.com/.default").ConfigureAwait(false)
                : await _tokenAcquisition.GetAccessTokenForUserAsync(effectiveScopes).ConfigureAwait(false);

            request.Headers.Add(
                Constants.Authorization,
                string.Format(
                    CultureInfo.InvariantCulture,
                    "{0} {1}",
                    Constants.Bearer,
                    accessToken
                    ));
        }

Tests

We could add a controller in the WebAppCallsGraph test to get the list of all applications (or all users). this would require an admin consent.

@baywet and @darrelmiller: what do you think of the design?

@1iveowl, what do you think of the design:

/// <summary>
/// Illustrates how to call MicrosoftGraph using the Graph SDK and request app-only permissions.
/// These permissions must have been pre-consented by the admin.
/// </summary>
/// <returns></returns>
public async Task<IEnumerable<string>> Get100FirstApplications()
{
  using var graphOptions = MicrosoftGraphTokenAcquisitionOptions.UseAppOnlyPermissions(_graphServiceClient);

  var apps = await _graphServiceClient.Applications.Request().GetAsync();
  return apps.Select(a => a.DisplayName);
}

Thank you @jmprieur. This design makes good sense to me.

How/where are the app credentials specified?

They don't need to be, @1iveowl: applications that require tokens on behalf of themselves (not on behalf of the user), that is which use the Client Credentials flow, need to use a scope which is of the form "{resource}/.default", and for Microsoft.Graph the resource is "https://graph.microsoft.com", and therefore it's not even needed to pass-in scopes. The permissions need to be granted by the tenant admin, of course.

Of course. Thank you 馃憤

@jmprieur thanks for taking a stab at that. It feels a bit complex to use and "dangerous" (in the case the consumer forgets the using statement, it derails the whole application after the use of app only context)

MS.ID.Web depends on the asp.net infrastructure, what about injecting a second graphserviceclient (wrapped) and providing a fluent API to add it to the services?

In the startup they'd do something like

services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
                .AddMicrosoftIdentityWebApp(configuration.GetSection("AzureAd"))
                .EnableTokenAcquisitionToCallDownstreamApi( new string[] { configuration.GetValue<string>("SubscriptionSettings:Scope") })
                .AddMicrosoftGraph(configuration.GetSection("DownstreamApi"))
                .AddAppOnlyContext() // <---- this is new
                .AddInMemoryTokenCaches();

As glue

public interface IAppOnlyGraphServiceClient {
GraphServiceClient Value;
}

In the controller

public NotificationController(ISubscriptionStore subscriptionStore,
                                      IHubContext<NotificationHub> notificationHub,
                                      ILogger<NotificationController> logger,
                                      ITokenAcquisition tokenAcquisition,
                                      IOptions<MicrosoftIdentityOptions> identityOptions,
                                      KeyVaultManager keyVaultManager,
                                      IOptions<DownstreamApiSettings> appSettings,
                                      IOptions<SubscriptionOptions> subscriptionOptions,
                                      GraphServiceClient graphServiceClient,
                                      IAppOnlyGraphServiceClient<GraphServiceClient> appOnlyClient)
//... do something

Thanks @baywet

This would solve the issue of the app-only permission, but not the scopes.
@darrelmiller: when can we benefit from .WithScopes()?

Decided with @henrik-me and @jennyf19

We are going to offer an API to both call graph with app-permissions, or graph with specific delegated permissions, and possibly tenants, and claims.

IEnumerable<string> appNames;

_graphServiceClient.WithAppPermissions(graphServiceClient =>
{
  var apps = await  graphServiceClient.Applications.Request().GetAsync();
  return apps.Select(a => a.DisplayName);
});

and:

_graphServiceClient.WithDelegatedPermissions(new[]{"mail.read", "mail.write"}, graphServiceClient =>
{
  await  graphServiceClient.Applications.Request().Me.Folder
});

For the implementation, WithAppPermissions and WithDelegatedPermissions will be extension methods (of GraphServiceClient), and will use internally the using mechanism, which won't be exposed to developers, and therefore won't be complex nor dangerous.

WithDelegatedPermissions will also have optional parameters (tenant and claims), which will allow more complex scenarios.

cc: @baywet what do you think?

I'm not sure I'm a big fan of that. The client is supposed to be already configured and ready to use. Opening the door to regenerating a client from the client itself can be a slippery slope. For example: what if the customer already had customized the client's custom handlers (retry, compression...) ? How are you going to copy those over?

This is not creating a new client, @baywet. It's using the same. Just applying different MSAL parameters temporarily.

How do you replace the authentication provider?

We already have a pattern that we use for passing configuration parameters to middleware objects.

var apps = await  graphServiceClient.Applications.Request().WithAppOnlyPermissions().GetAsync();
  return apps.Select(a => a.DisplayName);

This is the same pattern we use for passing Retry configuration information to our RetryHandler. Andrew is just finishing up some work on ChangeLog and will be able to start on this soon.

@darrelmiller : do you mean you'll provide a solution to this issue quickly?
(App only permissions and scopes)

In the past 2 weeks we had 5 customers and partners asking for it ... it becomes pretty urgent.
no pressure ....

Also, please could you send me some documentation on the pattern to pass configuration to middleware objects, and also to get them from the authentication provider? (or just links to code)

I don't think relying on this additional method should be the main option.

If a customer has a business service that does a bunch of calls to Microsoft graph with that approach they need to update all my service calls and muddy the business service with the concern of the auth.

Relying on service dependency injection would allow them to configure things at the moment they configure ms id web and then swap/pass graph service clients to depending services based on the scenario.

Additionally the addGraph methods should accept a Func so people can fully configure the graph service client as they see fit without placing a burden on ms id web to expose additional methods.

Sorry for the badly formatted comment, I'm on my phone

Additionally ms id web via the aspnet core DI is controlling the flow of creating the different objects and services. Going the other way around would probably be painful for both our teams to maintain and confusing for consumers

The IHttpClient interface allows for differing configurations as I need in my application. Seems to me that this same pattern should work with Graph Clients.

The GraphClientFactory has overrides to specify the AuthProvider and middleware, so I can get both delegated and app-only setup. I'm not 100% sure how to setup the DI in ASP.NET, but I image that pattern used for named HttpClients should work.

This would require, I think, a clearer separation of authorization and token acquisition. AddAuthentication and AddMicrosoftIdentityWeb go on one line, and AddMicrosoftGraph goes on a different line. I call AddMicrosoftGraph twice with different names and different configured IAuthenticationProviders.

Included in 1.2.0 release.

cc: @darrelmiller @baywet @pschaeflein @1iveowl

Was this page helpful?
0 / 5 - 0 ratings