Here is what I have. (ApiVersion is v1.0)
private async Task GetUsersRoles(string accessToken, ClaimsIdentity identity, string userId)
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();
}
}
}
}
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"
}
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):
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,
I had a similar issue and it was just that my token had expired or became invalid.