In the previous part, we added integration tests to the project that allow us to test the API locally with requests that are authenticated exactly like in production. Check that if you missed it: https://joonasw.net/view/testing-azure-ad-protected-apis-part-3-automated-integration-tests.

This time we will create a GitHub Actions workflow and make the tests run on each new commit to the master branch.

GitHub Actions

In case you are not familiar with GitHub Actions, they are essentially workflows that you can setup in a GitHub repository and have them trigger on different events. For example, a new commit to a specific branch, a new issue being created, and so on. You can check the page on Actions for more info: https://github.com/features/actions.

This time we want to make a workflow that triggers on commits to the master branch. Our tests will need access to an Azure Key Vault during the run, so we will need some credentials to do that. And we don't want to store them in the workflow file. Luckily Actions support usage of secrets.

Giving Actions access to Key Vault

We need some credentials that the tests can use to access the Azure Key Vault created in the previous part of this article series. The Key Vault contains user and app credentials that the tests can use to authenticate as a user or as an app.

To create the credentials and give access to the Azure Key Vault, we will use the cross-platform Azure CLI.

First, we need to login.

az login
az account set -s "id-of-subscription"

The second command sets the default subscription for commands. Here we use the id of the subscription containing the Key Vault. Then we can create an Azure AD service principal.

az ad sp create-for-rbac -n "GitHubActionsAADTestingDemo" --sdk-auth --skip-assignment

There's a few parameters here, so let's break them down. You can get help on any command with e.g. az ad sp create-for-rbac -h by the way.

  • n: Name for the service principal
  • sdk-auth: Return the service principal credentials in JSON form, which we want
  • skip-assignment: Don't assign an RBAC role to this service principal, it does not need it

After running the command, you should get JSON output similar to this in the console:

{
  "clientId": "",
  "clientSecret": "",
  "subscriptionId": "",
  "tenantId": "",
  "activeDirectoryEndpointUrl": "https://login.microsoftonline.com",
  "resourceManagerEndpointUrl": "https://management.azure.com/",
  "activeDirectoryGraphResourceId": "https://graph.windows.net/",
  "sqlManagementEndpointUrl": "https://management.core.windows.net:8443/",
  "galleryEndpointUrl": "https://gallery.azure.com/",
  "managementEndpointUrl": "https://management.core.windows.net/"
}

You will need this JSON in a bit, so don't lose it. The principal needs access to the Key Vault, so let's add that.

az keyvault set-policy -n "key-vault-name" --secret-permissions get list --spn "clientId-from-previous-output"

As mentioned in the command, use the clientId from the JSON output of az ad sp create-for-rbac as the --spn parameter value. This gives the service principal read access to all secrets in the specified Key Vault.

Building the workflow

First, let's define the secrets the workflow will use. We need the following 6 secrets:

Secrets in GitHub repo settings

AZURE_CREDENTIALS contains the JSON output of az ad sp create-for-rbac from earlier. API_APP_ID_URI is the application ID URI for the API app registration. This app registration is registered in a test Azure AD tenant. API_CLIENT_ID is the client id for the API app registration. API_AUTHORITY identifies the Azure AD tenant the API will accept tokens for. API_AUTHORIZATION_URL is the URL for the authorization endpoint used in the Swagger UI. It's not used in tests, but I felt it's better to configure the app properly here as well. KEY_VAULT_URL is the base URL for the Key Vault containing the user and app credentials :)

After these are setup, creating the workflow is relatively straightforward. I went to my GitHub repo, clicked Actions, and then New workflow.

In the YAML file, we need to ensure the .NET Core SDK is available:

- name: Setup .NET Core
  uses: actions/setup-dotnet@v1
  with:
    dotnet-version: 3.1.100

Then we can run a restore and build on the solution:

- name: Build with dotnet
  run: dotnet build --configuration Release

And then run the tests:

- name: Test with dotnet
  run: dotnet test --configuration Release
  working-directory: Joonasw.AadTestingDemo.IntegrationTests
  env:
    IntegrationTest__KeyVaultUrl: ${{ secrets.KEY_VAULT_URL }}
    Authentication__Authority: ${{ secrets.API_AUTHORITY }}
    Authentication__AuthorizationUrl: ${{ secrets.API_AUTHORIZATION_URL }}
    Authentication__ClientId: ${{ secrets.API_CLIENT_ID }}
    Authentication__ApplicationIdUri: ${{ secrets.API_APP_ID_URI }}
    AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }}

Note the working directory was changed to the IntegrationTests project folder so we don't need to specify the target project in the command.

We specify the 5 settings we specified as user secrets when running locally in the previous part. In addition, we add the credentials JSON as an environment variable so the tests can use them to access Key Vault.

Here is the complete workflow:

name: Build and test

on:
  push:
    branches:
      - master

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v1

      - name: Setup .NET Core
        uses: actions/setup-dotnet@v1
        with:
          dotnet-version: 3.1.100

      - name: Build with dotnet
        run: dotnet build --configuration Release

      - name: Test with dotnet
        run: dotnet test --configuration Release
        working-directory: Joonasw.AadTestingDemo.IntegrationTests
        env:
          IntegrationTest__KeyVaultUrl: ${{ secrets.KEY_VAULT_URL }}
          Authentication__Authority: ${{ secrets.API_AUTHORITY }}
          Authentication__AuthorizationUrl: ${{ secrets.API_AUTHORIZATION_URL }}
          Authentication__ClientId: ${{ secrets.API_CLIENT_ID }}
          Authentication__ApplicationIdUri: ${{ secrets.API_APP_ID_URI }}
          AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }}

Now if this was run like this on the app from the previous part, it would not work. We need to make a slight modification to the test project to accept the AZURE_CREDENTIALS variable we add in the workflow.

So let's modify the AppFixture configuration setup to use client id and secret if credentials are present.

builder.ConfigureAppConfiguration(configBuilder =>
{
    // Adds user secrets for the integration test project
    // Contains the Key Vault URL and API authentication settings for me
    configBuilder.AddUserSecrets<AppFixture>();

    // Build temporary config, get Key Vault URL, add Key Vault as config source
    var config = configBuilder.Build();
    string keyVaultUrl = config["IntegrationTest:KeyVaultUrl"];
    if (!string.IsNullOrEmpty(keyVaultUrl))
    {
        // CI / CD pipeline sets up a credentials environment variable to use
        string credentialsJson = Environment.GetEnvironmentVariable("AZURE_CREDENTIALS");
        // If it is not present, we are running locally
        if (string.IsNullOrEmpty(credentialsJson))
        {
            // Use local user authentication
            configBuilder.AddAzureKeyVault(keyVaultUrl);
        }
        else
        {
            // Use credentials in JSON object
            var credentials = (JObject)JsonConvert.DeserializeObject(credentialsJson);
            string clientId = credentials?.Value<string>("clientId");
            string clientSecret = credentials?.Value<string>("clientSecret");
            configBuilder.AddAzureKeyVault(keyVaultUrl, clientId, clientSecret);
        }

        config = configBuilder.Build();
    }

    Settings = config.GetSection("IntegrationTest").Get<IntegrationTestSettings>();
});

You can see the workflow file in the repo as well: https://github.com/juunas11/testing-aad-protected-apis/blob/master/.github/workflows/dotnetcore.yml.

After this modification, we can successfully run integration tests on every commit :)

Workflow successful run results

Summary

Being able to run the integration tests in a GitHub Actions workflow required quite minimal changes to the project itself. The main obstacle was learning how to write the YAML, and how to use secrets there.

In the next part, we will look at doing the same thing in Azure DevOps :)

Links