Search code examples
c#.netasp.net-mvcmicrosoft-graph-apiadal

Calling Graph API through ViewModel in MVC Web App


I'm trying to use Graph API to create my own "User Profile" section of the navbar of my web app. To do this I have an AJAX call to a GetUser Action of my UserProfile Controller:

        $.ajax({
        type: "GET",
        url: "@Url.Action("GetUser", "UserProfile", null)",
        dataType: "json",
        success: function (data, status, xhr) {
            console.log("in AJAX");
            $(".img-circle, .user-image").attr("src", data.Picture);
            $("#user-menu-expanded").text(data.User.DisplayName + " - " + data.User.JobTitle);
            $("#user-menu-spinner").remove();
            console.log(data);
        },
        error: function (ex) {
            console.log(ex);
            }
        });

The controller returns my UserProfileViewModel as a Json which I use to replace the above elements as shown in my AJAX success function.

UserProfile Controller:

    public JsonResult GetUser()
    {
        var model = new UserProfileViewModel();
        return Json(model, JsonRequestBehavior.AllowGet); 
    }

My UserProfileViewModel looks like this:

    public UserProfileViewModel()
    { 
            var graphClient = GetAuthGraphClient();
            GetPicture(graphClient);
            GetUserProfile(graphClient); 
     }
    public GraphServiceClient GetAuthGraphClient()
    {
        string graphResourceID = "https://graph.microsoft.com/";

        return new GraphServiceClient(
            new DelegateAuthenticationProvider((requestMessage) =>
            {
                string accessToken =  GetTokenForApplication(graphResourceID);
                requestMessage.Headers.Authorization = new AuthenticationHeaderValue("bearer", accessToken);
                return Task.FromResult(0);
            }
            ));
    }
    public string GetTokenForApplication(string graphResourceID)
    {
        string signedInUserID = ClaimsPrincipal.Current.FindFirst(ClaimTypes.NameIdentifier).Value;
        string tenantID = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid").Value;
        string userObjectID = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier").Value;
        string authority = "https://login.microsoftonline.com/" + tenantID;

        try {
            ClientCredential clientcred = new ClientCredential(clientId, appKey);
            // initialize AuthenticationContext with the token cache of the currently signed in user, as kept in the app's database
            AuthenticationContext authenticationContext = new AuthenticationContext(authority);
            var token = authenticationContext.AcquireTokenAsync(graphResourceID, clientcred).Result.AccessToken;
            return token;
        }
        catch (Exception e)
        {
                // Capture error for handling outside of catch block
                ErrorMessage = e.Message;

            return null;
        }

    }
    public void GetPicture(GraphServiceClient graphClient)
    { 
        Stream photo = Task.Run(async () => { return await graphClient.Me.Photo.Content.Request().GetAsync(); }).Result;

        using (var memoryStream = new MemoryStream())
        {
            photo.CopyTo(memoryStream);
            var base64pic = Convert.ToBase64String(memoryStream.ToArray());
            this.Picture = "data:image;base64," + base64pic;
            HttpContext.Current.Cache.Add("Pic", this.Picture, null, DateTime.Now.AddHours(5), Cache.NoSlidingExpiration, CacheItemPriority.AboveNormal, null);
        }
    }

    public void GetUserProfile(GraphServiceClient graphClient)
    {       
        this.User = Task.Run(async () => { return await graphClient.Me.Request().GetAsync(); }).Result;
    }

I am successfully getting an access token, however my AJAX call is not returning any data.
Access Token from IIS Log Console Log

I have two questions (possibly 3):

  1. What am I doing wrong?
  2. Is it possible to use the access token from my Startup.Auth to create an authenticated Graph Client? If so, how would I go about doing that?

        // This is the resource ID of the AAD Graph API.  We'll need this to request a token to call the Graph API.
        string graphResourceId = "https://graph.microsoft.com"; //https://graph.windows.net
    
        public void ConfigureAuth(IAppBuilder app)
        {
            ApplicationDbContext db = new ApplicationDbContext();
    
            app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
            app.UseKentorOwinCookieSaver();
            app.UseCookieAuthentication(new CookieAuthenticationOptions());
    
            app.UseOpenIdConnectAuthentication(
                new OpenIdConnectAuthenticationOptions
                {
                    ClientId = clientId,
                    Authority = Authority,
                    PostLogoutRedirectUri = postLogoutRedirectUri,
    
                    Notifications = new OpenIdConnectAuthenticationNotifications()
                    {
                        // If there is a code in the OpenID Connect response, redeem it for an access token and refresh token, and store those away.
                        AuthorizationCodeReceived = (context) =>
                        {
                            var code = context.Code;                            
                            ClientCredential credential = new ClientCredential(clientId, appKey);
                            string signedInUserID = context.AuthenticationTicket.Identity.FindFirst(ClaimTypes.NameIdentifier).Value;
                            AuthenticationContext authContext = new AuthenticationContext(Authority, new ADALTokenCache(signedInUserID));
                            AuthenticationResult result = authContext.AcquireTokenByAuthorizationCode(
                            code, new Uri(HttpContext.Current.Request.Url.GetLeftPart(UriPartial.Path)), credential, graphResourceId);
                            HttpContext.Current.Cache.Add("Token", result.AccessToken, null, DateTime.Now.AddHours(5), Cache.NoSlidingExpiration, CacheItemPriority.AboveNormal, null);
    
                            return Task.FromResult(0);
                        }
                    }
                });
        }
    }
    

Updated Code per Comment Below

    public string GetTokenForApplication(string graphResourceID)
    {
        string signedInUserID = ClaimsPrincipal.Current.FindFirst(ClaimTypes.NameIdentifier).Value;
        string tenantID = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid").Value;
        string userObjectID = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier").Value;
        string authority = "https://login.microsoftonline.com/" + tenantID;


        try {
            // get a token for the Graph without triggering any user interaction (from the cache, via multi-resource refresh token, etc)
            ClientCredential clientcred = new ClientCredential(clientId, appKey);
            // initialize AuthenticationContext with the token cache of the currently signed in user, as kept in the app's database
            AuthenticationContext authenticationContext = new AuthenticationContext(Startup.Authority, new ADALTokenCache(userObjectID));
            var result = authenticationContext.AcquireTokenSilent(graphResourceID, clientcred, new UserIdentifier(userObjectID, UserIdentifierType.UniqueId));
            return result.AccessToken;
        }
        catch (Exception e)
        {
                // Capture error for handling outside of catch block
                ErrorMessage = e.Message;

            return null;
        }

    }

Update 2: The Fix.. Kind of

Thanks to @Fei Xue I figured out the problem.. kind of. This fixes my problem when running locally, but I still fail to acquire the token silently when publishing to my stage application.. When I first created the application, I included Work/School authentication that was Azure AD. This created a local DB Context that it used for the ADAL token cache. While developing the application, I created another DB Context for the Azure SQL DB I created for the app. I had to update my AdalTokenCache.cs to reflect my app's DB context and the new model. I updated the line:

private ApplicationDbContext db = new ApplicationDbContext();

with my own context and updated the UserTokenCache model to my new context's UserTokenCache model. In this case I changed:

private UserTokenCache Cache;

to:

private UserTokenCach Cache;

I then updated the rest of the CS to match the UserTokenCach from the app's DB context.

I then just used the AcquireToken method that came OOB in the UserProfile controller to get a token. This is what it wound up looking like (Note: I also updated the strings in my startup.auth from private to public so I could use them in my viewmodel):

    public string GetTokenForApplication(string graphResourceID)
    {
        string signedInUserID = ClaimsPrincipal.Current.FindFirst(ClaimTypes.NameIdentifier).Value;
        string tenantID = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid").Value;
        string userObjectID = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier").Value;
        string authority = "https://login.microsoftonline.com/" + tenantID;


        try {
            // get a token for the Graph without triggering any user interaction (from the cache, via multi-resource refresh token, etc)
            ClientCredential clientcred = new ClientCredential(Startup.clientId, Startup.appKey);
            // initialize AuthenticationContext with the token cache of the currently signed in user, as kept in the app's database
            AuthenticationContext authenticationContext = new AuthenticationContext(Startup.Authority, new ADALTokenCache(signedInUserID));
            var result = authenticationContext.AcquireTokenSilent(graphResourceID, clientcred, new UserIdentifier(userObjectID, UserIdentifierType.UniqueId));
            return result.AccessToken;
        }
        catch (Exception e)
        {
                // Capture error for handling outside of catch block
                ErrorMessage = e.Message;

            return null;
        }

    }

I'll update as I play around some more.


Solution

  • There are two kinds of access token issued by Azure Active Directory.

    The first one is delegate-token which used to delegate the user to operate user's resource.

    And the other one is application token which usually used to perform the operation for the resource of all organization and there is no user context in this token. So we shouldn't use this token to perform the resource as me which required the user context.

    The code in the post is acquire the access token using client credentials flow which is application token. So you will get error when you get the user or picture using this kind of token based on the user's context.

    In this scenario, you should acquire the access token using the AuthorizationCodeReceived event as you post. This event uses the authorization code grant flow to acquire the delegate-token for the user. Then in the controller, you can get the token using the method AcquireTokenSilentAsync which will get the access token from catch.

    The code sample below is very helpful for the scenario calling Microsoft Graph in a web app to delegate the sign-in user:

    active-directory-dotnet-graphapi-web