Piotr
Piotr

Reputation: 1197

Query for files in OneDrive using Microsoft Graph API: Unable to retrieve user's mysite URL

At the beginning - little background. I have access to Azure on my company email (let's call it john.smith@nice_company.com)

I want to create console application (for now - later maybe an Azure function) which will be using some predefined account (on account john.smith@nice_company.com) to get some file from my OneDrive and update its content (basing on some external API)

That is the whole idea.

At the beginning it sounded trivial :) How hard could it be? For sure there is some API to connect with.

I even found a post with something kinda similar:

https://ithoughthecamewithyou.com/post/reading-and-writing-office-365-excel-from-a-console-app-using-the-microsoftgraph-c-client-api

Unfortunately, it turned out, the code is already quite legacy (even it is not even 2 years)

And what is most important from my perspective (so how to manage access) has been shortened in few words like:

You'll also need to register an application at https://portal.azure.com/. You want a Native application and you'll need the Application ID and the redirect URL (just make up some non-routable URL for this). Under Required Permissions for the app, you should add read and write files delegated permissions for the Microsoft Graph API.

Yeah...

So then I've started digging, found information about MSAL, tons of documentation (good job MS :) ) Even found a remotely similar sample on MS site:

https://github.com/Azure-Samples/active-directory-dotnetcore-daemon-v2

But the example is not working. There is an error:

Failed to call the Web Api: InternalServerError Content: { "error": { "code": "BadRequest", "message": "Unable to retrieve user's mysite URL.",

I think I may know what is the issue. The issue is (probably) with the API permissions (that is my working theory)

enter image description here

And that is because the Type is "Delegated permission" and if I read the documentation correctly it has to be "Application permissions"

But the latter assumes, that with such application I will be able to query ALL users in the domain. And Domain administrator must approve it. Whereas I don't want it (don't want to wait for the approval and don't want to have such broad access) What I really need is to have this access ONLY for my user.

So the question is - how can I achieve this?

The whole code is in the example in the URL I did attach - but I will copy paste the code from it (i only did change the path in CallWebApiAndProcessResultAsync to get onedrive resource)

private static async Task RunAsync()
{
    AuthenticationConfig config = AuthenticationConfig.ReadFromJsonFile("appsettings.json");

    // You can run this sample using ClientSecret or Certificate. The code will differ only when instantiating the IConfidentialClientApplication
    bool isUsingClientSecret = AppUsesClientSecret(config);

    // Even if this is a console application here, a daemon application is a confidential client application
    IConfidentialClientApplication app;

    if (isUsingClientSecret)
    {
        app = ConfidentialClientApplicationBuilder.Create(config.ClientId)
            .WithClientSecret(config.ClientSecret)
            .WithAuthority(new Uri(config.Authority))
            .Build();
    }

    else
    {
        X509Certificate2 certificate = ReadCertificate(config.CertificateName);
        app = ConfidentialClientApplicationBuilder.Create(config.ClientId)
            .WithCertificate(certificate)
            .WithAuthority(new Uri(config.Authority))
            .Build();
    }

    // With client credentials flows the scopes is ALWAYS of the shape "resource/.default", as the 
    // application permissions need to be set statically (in the portal or by PowerShell), and then granted by
    // a tenant administrator. 
    string[] scopes = new string[] { $"{config.ApiUrl}.default" }; 
    
    AuthenticationResult result = null;
    try
    {
        result = await app.AcquireTokenForClient(scopes)
            .ExecuteAsync();
        Console.ForegroundColor = ConsoleColor.Green;
        Console.WriteLine("Token acquired");
        Console.ResetColor();
    }
    catch (MsalServiceException ex) when (ex.Message.Contains("AADSTS70011"))
    {
        // Invalid scope. The scope has to be of the form "https://resourceurl/.default"
        // Mitigation: change the scope to be as expected
        Console.ForegroundColor = ConsoleColor.Red;
        Console.WriteLine("Scope provided is not supported");
        Console.ResetColor();
    }

    if (result != null)
    {
        var httpClient = new HttpClient();
        var apiCaller = new ProtectedApiCallHelper(httpClient);
        await apiCaller.CallWebApiAndProcessResultASync($"{config.ApiUrl}v1.0/me/drive/root/children", result.AccessToken, Display);
    }
}

public class AuthenticationConfig
{
    /// <summary>
    /// instance of Azure AD, for example public Azure or a Sovereign cloud (Azure China, Germany, US government, etc ...)
    /// </summary>
    public string Instance { get; set; } = "https://login.microsoftonline.com/{0}";
   
    /// <summary>
    /// Graph API endpoint, could be public Azure (default) or a Sovereign cloud (US government, etc ...)
    /// </summary>
    public string ApiUrl { get; set; } = "https://graph.microsoft.com/";

    /// <summary>
    /// The Tenant is:
    /// - either the tenant ID of the Azure AD tenant in which this application is registered (a guid)
    /// or a domain name associated with the tenant
    /// - or 'organizations' (for a multi-tenant application)
    /// </summary>
    public string Tenant { get; set; }

    /// <summary>
    /// Guid used by the application to uniquely identify itself to Azure AD
    /// </summary>
    public string ClientId { get; set; }

    /// <summary>
    /// URL of the authority
    /// </summary>
    public string Authority
    {
        get
        {
            return String.Format(CultureInfo.InvariantCulture, Instance, Tenant);
        }
    }

    /// <summary>
    /// Client secret (application password)
    /// </summary>
    /// <remarks>Daemon applications can authenticate with AAD through two mechanisms: ClientSecret
    /// (which is a kind of application password: this property)
    /// or a certificate previously shared with AzureAD during the application registration 
    /// (and identified by the CertificateName property belows)
    /// <remarks> 
    public string ClientSecret { get; set; }

    /// <summary>
    /// Name of a certificate in the user certificate store
    /// </summary>
    /// <remarks>Daemon applications can authenticate with AAD through two mechanisms: ClientSecret
    /// (which is a kind of application password: the property above)
    /// or a certificate previously shared with AzureAD during the application registration 
    /// (and identified by this CertificateName property)
    /// <remarks> 
    public string CertificateName { get; set; }

    /// <summary>
    /// Reads the configuration from a json file
    /// </summary>
    /// <param name="path">Path to the configuration json file</param>
    /// <returns>AuthenticationConfig read from the json file</returns>
    public static AuthenticationConfig ReadFromJsonFile(string path)
    {
        IConfigurationRoot Configuration;

        var builder = new ConfigurationBuilder()
         .SetBasePath(Directory.GetCurrentDirectory())
        .AddJsonFile(path);

        Configuration = builder.Build();
        return Configuration.Get<AuthenticationConfig>();
    }
}

public class ProtectedApiCallHelper
{
    /// <summary>
    /// Constructor
    /// </summary>
    /// <param name="httpClient">HttpClient used to call the protected API</param>
    public ProtectedApiCallHelper(HttpClient httpClient)
    {
        HttpClient = httpClient;
    }

    protected HttpClient HttpClient { get; private set; }


    /// <summary>
    /// Calls the protected Web API and processes the result
    /// </summary>
    /// <param name="webApiUrl">Url of the Web API to call (supposed to return Json)</param>
    /// <param name="accessToken">Access token used as a bearer security token to call the Web API</param>
    /// <param name="processResult">Callback used to process the result of the call to the Web API</param>
    public async Task CallWebApiAndProcessResultASync(string webApiUrl, string accessToken, Action<JObject> processResult)
    {
        if (!string.IsNullOrEmpty(accessToken))
        {
            var defaultRequestHeaders = HttpClient.DefaultRequestHeaders;
            if (defaultRequestHeaders.Accept == null || !defaultRequestHeaders.Accept.Any(m => m.MediaType == "application/json"))
            {
                HttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
            }
            defaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", accessToken);

            HttpResponseMessage response = await HttpClient.GetAsync(webApiUrl);
            if (response.IsSuccessStatusCode)
            {
                string json = await response.Content.ReadAsStringAsync();
                JObject result = JsonConvert.DeserializeObject(json) as JObject;
                Console.ForegroundColor = ConsoleColor.Gray;
                processResult(result);
            }
            else
            {
                Console.ForegroundColor = ConsoleColor.Red;
                Console.WriteLine($"Failed to call the Web Api: {response.StatusCode}");
                string content = await response.Content.ReadAsStringAsync();

                // Note that if you got reponse.Code == 403 and reponse.content.code == "Authorization_RequestDenied"
                // this is because the tenant admin as not granted consent for the application to call the Web API
                Console.WriteLine($"Content: {content}");
            }
            Console.ResetColor();
        }
    }
}

I've checked. When i change the path in CallWebApiAndProcessResultASync back to original $"{config.ApiUrl}v1.0/me" the error is different.

Then the error is: Failed to call the Web Api: Forbidden

Content: { "error": { "code": "Authorization_RequestDenied", "message": "Insufficient privileges to complete the operation.",

Here is my Authentication settings currently:

enter image description here

[UPDATE 26.06.2020 10:05]

There was a suggestion that, there is no OneDrive connected for me. But as shown on this printscreen below, i can query my oneDrive on GrapExplorer:

enter image description here

Other question was about what authorization method do i use. As visible on the code - it is Client Credentials flow I did create Client secret on "Certificate & secrets" menu. Once it is working, i will switch to certificate (which is advised)

[UPDATE 26.06.2020 10:25]

As @JimXu suggested, I've changed the endpoint. I should be using https://graph.microsoft.com/users/<object id or upn>/drive/root/children I've changed that but now I have an error:

Failed to call the Web Api: Forbidden Content: { "error": { "code": "AccessDenied", "message": "Either scp or roles claim need to be present in the token.",

Here are my configuration settings (visible in code)

"Instance": "https://login.microsoftonline.com/{0}", "ApiUrl": "https://graph.microsoft.com/",

[UPDATE 26.06.2020 11:40]

@JimXu suggested i should go with device code flow Downloaded the example from:

https://github.com/azure-samples/active-directory-dotnetcore-devicecodeflow-v2

And as always, every devil lies in config files. Good if you know what to put where. But the information in Tenant is so confusing (hard to guess what to put there) Here is my config file:

{
    "Authentication": 
    {
    // Azure Cloud instance among:
    // - AzurePublic (see https://aka.ms/aaddevv2). This is the default value
    // - AzureUsGovernment (see https://learn.microsoft.com/azure/azure-government/documentation-government-developer-guide)
    // - AzureChina (see https://learn.microsoft.com/azure/china/china-get-started-developer-guide)
    // - AzureGermany (See https://learn.microsoft.com/azure/germany/germany-developer-guide)
    "AzureCloudInstance": "AzurePublic",

    // Azure AD Audience among:
    // - AzureAdMyOrg (single tenant: you need to also provide the TenantId
    // - AzureAdMultipleOrgs (multi-tenant): Any work and school accounts
    // - AzureAdAndPersonalMicrosoftAccount (any work and school account or Microsoft personal account)
    // - PersonalMicrosoftAccount (Microsoft personal account only)
    // "AadAuthorityAudience": "AzureAdMultipleOrgs",
    "Tenant": "561********************", //value obtained from Azure AAD field: Tenant ID

    // ClientId (ApplicationId) as copied from the application registration (which depends on the cloud instance)
    // See docs referenced in the AzureCloudInstance section above
    "ClientId": "886775c7**************" //value obtained from App registrations --> My App --> and field: `Application (client) ID`
    },

    // Web API. Here Microsoft Graph. The endpoint is different depending on the cloud instance
    // (See docs referenced in the "AzureCloudInstance" section above.
    "WebAPI": {
    "MicrosoftGraphBaseEndpoint": "https://graph.microsoft.com"
    }
}

But as always - it simply cannot work "out of the box" :) The error is:

AADSTS50059: No tenant-identifying information found in either the request or implied by any provided credentials.

One small change to the Authentication section has been made for this "Device code flow" I've disabled all return urls apart of the one mentioned in the documentation (https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/wiki/Device-Code-Flow) so i've enabled only: https://login.microsoftonline.com/common/oauth2/nativeclient as a Redirect URL's

Upvotes: 2

Views: 1570

Answers (1)

Piotr
Piotr

Reputation: 1197

I wanted to have an answer to anyone struggling with messages:

  • Unable to retrieve user's mysite URL

This is because of trying to use delegated permissions while querying https://login.microsoftonline.com/v1.0/me

You have to use endpoint with user specified, eg:

https://graph.microsoft.com/users/<object id or upn>/drive/root/children

  • Either scp or roles claim need to be present in the token

The error was showing because I was trying to access personal data without application permissions (which was for me important to avoid) The only option in such scenario is device code flow

  • AADSTS50059: No tenant-identifying information found in either the request or implied by any provided credentials.

Surprise from MS :) In the demo app located here: https://github.com/azure-samples/active-directory-dotnetcore-devicecodeflow-v2 you have to enter your tenant as TenantId (by default, there is only entry Tenant) ... Thank you MS for this! :D

I do hope it will save somebody time for looking :)

Upvotes: 0

Related Questions