Search code examples
c#microsoft-graph-apiazure-devops-rest-apiazure-sdk-.netazure-fluent-api

Using Microsoft Graph to obtain Access Token for Azure Web App Continuous Integration Deployment


I am attempting to wire up source control with continuous integration to an Azure Web App programmatically via C#. Historically I've used Azure PowerShell to do the same.

On the C# side, I am using the Microsoft.Azure.Management.Fluent libraries. The code to wire up source control is pretty straight forward:

await webApp.Update().DefineSourceControl().WithContinuouslyIntegratedGitHubRepository(GIT_URL).WithBranch(GIT_BRANCH).WithGitHubAccessToken(GIT_TOKEN).Attach().ApplyAsync();

This code runs for about 5 minutes and then returns the error:

{"Code":"BadRequest","Message":"Parameter x-ms-client-principal-name is null or empty."}

I initially interpreted this to mean the Fluent libraries weren't passing a necessary value to the API, so I attempted to hit the API directly using Fluent libraries to abstract the authorization piece:

var credentials = SdkContext.AzureCredentialsFactory.FromServicePrincipal( CLIENT_ID, CLIENT_SECRET, TENANT_ID, AzureEnvironment.AzureGlobalCloud);

var client = RestClient.Configure().WithEnvironment(AzureEnvironment.AzureGlobalCloud).WithCredentials(credentials).Build();

CancellationToken cancellationToken = new CancellationToken();

var request = new HttpRequestMessage(HttpMethod.Get, $"https://management.azure.com/subscriptions/{SUBSCRIPTION_ID}/resourcegroups/{RESOURCE_GROUP}?api-version=2019-10-01");
request.Headers.Add("x-ms-client-principal-name", USERNAME);
client.Credentials.ProcessHttpRequestAsync(request, cancellationToken).GetAwaiter().GetResult();
var httpClient = new HttpClient();
var response = httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).GetAwaiter().GetResult();

var result = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();

Several variations of this resulted in the same error referencing x-ms-client-principal-name. This lead me to believe the problem was with the token being used and the associated permissions. To test this, I ran the PowerShell script mentioned above, watched it run through Fiddler, and grabbed the token it used to complete. Using that token with basically the same code, it worked fine:

var token = "<THE TOKEN I COPIED FROM FIDDLER>";
CancellationToken cancellationToken = new CancellationToken();

var request = new HttpRequestMessage(requestType, $"https://management.azure.com/subscriptions/{SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}/providers/Microsoft.Web/sites/{WEB_APP}/sourcecontrols/web?api-version=2015-08-01");

request.Headers.Add("Authorization", $"Bearer {token}");

if ((requestType == HttpMethod.Put || requestType == HttpMethod.Post) && !string.IsNullOrEmpty(postData))
{
    request.Content = new StringContent(postData, Encoding.UTF8, "application/json");
}

var httpClient = new HttpClient();
var response = httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).GetAwaiter().GetResult();

var result = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();

So now, it seems to just be a matter of getting the access token myself. This is where things have become difficult. If I obtain the token as the service principal, I end up with the same x-ms-client-principal-name that I started with:

public static string GetAuthToken(string tenantId, string clientId, string clientSecret)
{
    var request = new HttpRequestMessage(HttpMethod.Post, $"https://login.microsoftonline.com/{tenantId}/oauth2/token");
    request.Content = new StringContent($"grant_type=client_credentials&client_id={clientId}&client_secret={clientSecret}&resource=https://management.azure.com", Encoding.UTF8, "application/x-www-form-urlencoded");
    CancellationToken cancellationToken = new CancellationToken();
    var httpClient = new HttpClient();
    var response = httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).GetAwaiter().GetResult();

    var result = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();

    var auth = JsonConvert.DeserializeObject<AuthResponse>(result);

    return auth.AccessToken;
}

When I attempt to obtain token using my username and password, I get an error back telling me:

AADSTS90002: Tenant '' not found. This may happen if there are no active subscriptions for the tenant. Check to make sure you have the correct tenant ID. Check with your subscription administrator.

Here's the code I use for that:

public static string GetAuthToken(string tenantId, string username, string password, string clientId, string clientSecret)
{
    var request = new HttpRequestMessage(HttpMethod.Post, $"https://login.microsoftonline.com/{tenantId}/oauth2/token");
    request.Content = new StringContent($"grant_type=password&username={username}&password={password}&client_id={clientId}&client_secret={clientSecret}&resource=https://management.azure.com", Encoding.UTF8, "application/x-www-form-urlencoded");
    CancellationToken cancellationToken = new CancellationToken();
    var httpClient = new HttpClient();
    var response = httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).GetAwaiter().GetResult();

    var result = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();

    var auth = JsonConvert.DeserializeObject<AuthResponse>(result);

    return auth.AccessToken;
}

I cannot have the C# code invoke the PS script as a work-around as it requires Azure PowerShell which is not guaranteed to be on the machine running the code (Azure Web App) and cannot be installed due to Admin restrictions.

I need to be able to obtain an access token that also has permissions for azure dev ops (formally VSTS) so I can bind/wire-up source control for continuous integration. Any guidance that can help me get past this is much appreciated.


Solution

  • I was finally able to get it working. First, I had to create a new Azure AD User and grant it the necessary permissions; this would not work with my regular Azure Login. With the new user and permissions, I am able to successfully obtain token like so:

    public static async Task<string> GetAccessToken(string clientId, string userName, string password)
    {
        AuthenticationContext authenticationContext = new AuthenticationContext("https://login.microsoftonline.com/<TENANT_ID>");
        var resourceId = "https://management.azure.com";
        var result = await authenticationContext.AcquireTokenAsync(resourceId, clientId, new UserPasswordCredential(userName, password));
        return result.AccessToken;
    }
    

    This token then works for wiring up source control and continuous integration.