1
votes

Here is what I have. (ApiVersion is v1.0)

private async Task<ClaimsIdentity> GetUsersRoles(string accessToken, ClaimsIdentity identity, string userId)
{
           string resource = GraphResourceId + ApiVersion + "/me/memberOf";

            var client = new HttpClient();

            var request = new HttpRequestMessage(HttpMethod.Get, new Uri(resource));

            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

            var response = await client.SendAsync(request);

        return identity;
    }

Essentially what I am attempting to do is get all of the groups that the authenticated user is a member of, then I am creating group and role claims from that. I have left some of that out above, but the code is there and it works with the following delegated permissions User.Read.All and Directory.Read.All. I can not get it to work with Application Specific Permissions (returns Forbidden response). The reason this is a problem is because, in order to consent to the delegated permissions, it requires Global Administrator. So, I am trying to do App Only permissions to allow me to consent for the entire organization. I realize that this is fairly close to some known issues https://graph.microsoft.io/en-us/docs/overview/release_notes , but they also list alternative permission scopes, and I have tried all of those with absolutely no success. (Note: Authentication works just fine and other requests work as they should)

Could someone please give me some insight into this?

4

4 Answers

1
votes

Ok after a bunch of reading and some just plain Luck, I have this figured out. So, I figured I would share what I have learned since it is so confusing. Also, I found out that the permission that I was missing in azure was under Microsoft Graph : the Sign in and Read User profile.... which was checked in the windows azure permissions but I guess it needs to be checked in the Microsoft Graph permissions also... That is the User.Read permission for the people that mess with the manifest... Pay close attention to the GetUsersRoles Task it has been commented to help out, but you can't call "/me/memberOf", you have to call "/users/< userId >/memberOf". I really hope this helps somebody, because this Api has given me a headache everyday since I started adding it to my project.

Startup.cs

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Clients.ActiveDirectory;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authentication;
using MyApp.Utils;
using Microsoft.Graph;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.Authorization;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;

namespace MyApp
{
    public class Startup
    {
        public static string ClientId;
        public static string ClientSecret;
        public static string Authority;
        public static string GraphResourceId;
        public static string ApiVersion;
        public Startup(IHostingEnvironment env)
        {
            var builder = new ConfigurationBuilder()
                .SetBasePath(env.ContentRootPath)
                .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
                .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true);

            if (env.IsDevelopment())
            {
                // For more details on using the user secret store see http://go.microsoft.com/fwlink/?LinkID=532709
                builder.AddUserSecrets();
            }
            builder.AddEnvironmentVariables();
            Configuration = builder.Build();
        }

        public IConfigurationRoot Configuration { get; set; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            // Add Session services
            services.AddSession();

            // Add Auth
            services.AddAuthentication(
                SharedOptions => SharedOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme);

            services.AddMvc(config =>
            {
                var policy = new AuthorizationPolicyBuilder()
                                .RequireAuthenticatedUser()
                                .Build();

                config.Filters.Add(new AuthorizeFilter(policy));
            });

        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
        {
            // Configure session middleware.
            app.UseSession();

            loggerFactory.AddConsole(Configuration.GetSection("Logging"));
            loggerFactory.AddDebug();

            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
                app.UseBrowserLink();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
            }

            app.UseStaticFiles();

            // Populate AzureAd Configuration Values 

            ClientId = Configuration["AzureAd:ClientId"];
            ClientSecret = Configuration["AzureAd:ClientSecret"];
            GraphResourceId = Configuration["AzureAd:GraphResourceId"];
            Authority = Configuration["AzureAd:AadInstance"] + Configuration["AzureAd:TenantId"];
            ApiVersion = Configuration["AzureAd:ApiVersion"];

            // Implement Cookie Middleware For OpenId
            app.UseCookieAuthentication();
            // Set up the OpenId options
            app.UseOpenIdConnectAuthentication(new OpenIdConnectOptions
            {
                ClientId = Configuration["AzureAd:ClientId"],
                ClientSecret = Configuration["AzureAd:ClientSecret"],
                Authority = Configuration["AzureAd:AadInstance"] + Configuration["AzureAd:TenantId"],
                CallbackPath = Configuration["AzureAd:CallbackPath"],
                ResponseType = OpenIdConnectResponseType.CodeIdToken,
                Events = new OpenIdConnectEvents
                {
                    OnRemoteFailure = OnAuthenticationFailed,
                    OnAuthorizationCodeReceived = OnAuthorizationCodeReceived,
                },

                TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
                {
                    NameClaimType = "name",
                },
                GetClaimsFromUserInfoEndpoint = true,
                SaveTokens = true
            });

            app.UseMvc(routes =>
            {
                routes.MapRoute(
                    name: "default",
                    template: "{controller=Home}/{action=Index}/{id?}");
            });

        }

        private async Task OnAuthorizationCodeReceived(AuthorizationCodeReceivedContext context)
        {
            // Acquire a Token for the Graph API and cache it using ADAL.
            string userObjectId = (context.Ticket.Principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier"))?.Value;
            ClientCredential clientCred = new ClientCredential(ClientId, ClientSecret);

            // Gets Authentication Tokens From Azure
            AuthenticationContext authContext = new AuthenticationContext(Authority, new NaiveSessionCache(userObjectId, context.HttpContext.Session));

            // Gets the Access Token To Graph API
            AuthenticationResult authResult = await authContext.AcquireTokenByAuthorizationCodeAsync(
                context.ProtocolMessage.Code, new Uri(context.Properties.Items[OpenIdConnectDefaults.RedirectUriForCodePropertiesKey]), clientCred, GraphResourceId);

            // Gets the Access Token for Application Only Permissions
            AuthenticationResult clientAuthResult = await authContext.AcquireTokenAsync(GraphResourceId, clientCred);

            // The user's unique identifier from the signin event
            string userId = authResult.UserInfo.UniqueId;

            // Get the users roles and groups from the Graph Api. Then return the roles and groups in a new identity
            ClaimsIdentity identity = await GetUsersRoles(clientAuthResult.AccessToken, userId);

            // Add the roles to the Principal User
            context.Ticket.Principal.AddIdentity(identity);

            // Notify the OIDC middleware that we already took care of code redemption.
            context.HandleCodeRedemption();
        }

        // Handle sign-in errors differently than generic errors.
        private Task OnAuthenticationFailed(FailureContext context)
        {
            context.HandleResponse();

            context.Response.Redirect("/Home/Error?message=" + context.Failure.Message);
            return Task.FromResult(0);
        }

        // Get user's roles as the Application
        /// <summary>
        /// Returns user's roles and groups as a ClaimsIdentity
        /// </summary>
        /// <param name="accessToken">accessToken retrieved using the client credentials and the resource (Hint: NOT the accessToken from the signin event)</param>
        /// <param name="userId">The user's unique identifier from the signin event</param>
        /// <returns>ClaimsIdentity</returns>
        private async Task<ClaimsIdentity> GetUsersRoles(string accessToken, string userId)
        {
            ClaimsIdentity identity = new ClaimsIdentity("LocalIds");

            var serializer = new Serializer();

            string resource = GraphResourceId + ApiVersion + "/users/" + userId + "/memberOf";

            var client = new HttpClient();

            var request = new HttpRequestMessage(HttpMethod.Get, new Uri(resource));

            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

            var response = await client.SendAsync(request);

            if (response.IsSuccessStatusCode)
            {
                var responseString = await response.Content.ReadAsStringAsync();

                var claims = new List<Claim>();

                var responseClaims = serializer.DeserializeObject<Microsoft.Graph.UserMemberOfCollectionWithReferencesResponse>(responseString);
                if (responseClaims.Value != null)
                {
                    foreach (var item in responseClaims.Value)
                    {
                        if (item.ODataType == "#microsoft.graph.group")
                        {
                            // Serialize the Directory Object
                            var gr = serializer.SerializeObject(item);
                            // Deserialize into a Group
                            var group = serializer.DeserializeObject<Microsoft.Graph.Group>(gr);
                            if (group.SecurityEnabled == true)
                            {
                                claims.Add(new Claim(ClaimTypes.Role, group.DisplayName));
                            }
                            else
                            {
                                claims.Add(new Claim("group", group.DisplayName));
                            }
                        }
                    }
                }
                identity.AddClaims(claims);
            }
            return identity;
        }

    }
}

NaiveSessionCache.cs

// This is actually in a directory named Utils

using Microsoft.AspNetCore.Http;
using Microsoft.IdentityModel.Clients.ActiveDirectory;

namespace MyApp.Utils
{
    public class NaiveSessionCache : TokenCache
    {
        private static readonly object FileLock = new object();
        string UserObjectId = string.Empty;
        string CacheId = string.Empty;
        ISession Session = null;

        public NaiveSessionCache(string userId, ISession session)
        {
            UserObjectId = userId;
            CacheId = UserObjectId + "_TokenCache";
            Session = session;
            this.AfterAccess = AfterAccessNotification;
            this.BeforeAccess = BeforeAccessNotification;
            Load();
        }

        public void Load()
        {
            lock (FileLock)
            {
                Deserialize(Session.Get(CacheId));

            }
        }

        public void Persist()
        {
            lock (FileLock)
            {
                // reflect changes in the persistent store
                Session.Set(CacheId, this.Serialize());
                // once the write operation took place, restore the HasStateChanged bit to false
                this.HasStateChanged = false;
            }
        }

        // Empties the persistent store.
        public override void Clear()
        {
            base.Clear();
            Session.Remove(CacheId);
        }

        public override void DeleteItem(TokenCacheItem item)
        {
            base.DeleteItem(item);
            Persist();
        }

        // Triggered right before ADAL needs to access the cache.
        // Reload the cache from the persistent store in case it changed since the last access.
        void BeforeAccessNotification(TokenCacheNotificationArgs args)
        {
            Load();
        }

        // Triggered right after ADAL accessed the cache.
        void AfterAccessNotification(TokenCacheNotificationArgs args)
        {
            // if the access operation resulted in a cache update
            if (this.HasStateChanged)
            {
                Persist();
            }
        }
    }
}
0
votes

Please take another read of this Microsoft Graph topic on permissions here: https://graph.microsoft.io/en-us/docs/authorization/permission_scopes. There are a couple of concepts here that might help clarify things (although our docs can certainly be improved in this area):

  1. There are 2 types of permission: application and delegated permissions
  2. Some delegated permissions can be consented by end users (generally when the permission is scoped to requesting the signed-in user's data - like their profile, their mail, their files.
  3. Other delegated permissions that provide access to more data than is scoped to the signed-in user generally requires an administrator to consent.
  4. Application permissions ALWAYS require an administrator to consent. These are by definition tenant-wide (since there is no user context).
  5. It is possible for admins to consent to delegated permissions on behalf of the organization (thus suppressing any consent experience for end users). Again there are more topics on this available.

If you always have a signed-in user present (which it looks like), I would strongly recommend that you use delegated permissions over application permissions.

I also noticed that you are creating claims using group display names. The group display name is NOT immutable and can be changed... Not sure if this could lead to some interesting security issues if apps are making authz decisions based on the value of these claims.

Hope this helps,

0
votes

We're also authenticating with AAD and in our case we needed to force the user to consent to the application permissions again.

We solved this for a single user by adding the prompt=consent parameter to the AAD login request. For ADAL.js there is an example here:

Microsoft Graph API - 403 Forbidden for v1.0/me/events

Relevant code sample from post:

window.config = {
    tenant: variables.azureAD,
    clientId: variables.clientId,
    postLogoutRedirectUri: window.location.origin,
    endpoints: {
        graphApiUri: "https://graph.microsoft.com",
        sharePointUri: "https://" + variables.sharePointTenant + ".sharepoint.com",
    },
    cacheLocation: "localStorage",
    extraQueryParameter: "prompt=consent"
}
0
votes

I had a similar issue and it was just that my token had expired or became invalid.