Web, Windows, and Whatnot

 TwitterGitHubAbout Me

Kubernetes, DAPR, and Azure Identity Example - Part IV

February 11, 2021

This post will step through setting up simple back end services, including a configuration service as well as services that require user authentication. It will also include a service that calls the Microsoft Graph on behalf of the authenticated user.

We will be using .NET Core v5 application to implement the Web API.

The source related to this post is contained in the adding-users-api branch of the repo.

Adding an API Scope

To protect our API, we'll need a scope that is related to our application and the services that it exposes. Details about this are included in these links:

We will also need some additional setup to allow the Web API to call the Microsoft Graph "Me" method on behalf of the authenticated user. Additional details about configuring on-behalf-of flow are included in these links:

  • On-Behalf-Of Flow - details about the on-behalf-of flow.

  • .NET Core On-Behalf-Of - details about features available in .NET Core that can be used to make web API calls on behalf of the authenticated user.

  • Adding a Client Secret - details about creating a client secret that will be used in the on-behalf-of flow.

Specifically, to configure the example application:

  • Update the app-config.json file to include the API scope that you created and the azureAD configuration.
{
  "authentication": {
    "clientId": "f7edc002-e261-42e5-9140-8dde2e83260c",
    "authority": "https://login.microsoftonline.com/fa1ee923-839f-4da5-a453-6eefaf3c9699/",
    "apiScope": "api://f7edc002-e261-42e5-9140-8dde2e83260c/access_as_user"
  },
  "azureAD": {
    "clientId": "f7edc002-e261-42e5-9140-8dde2e83260c",
    "tenantId": "fa1ee923-839f-4da5-a453-6eefaf3c9699",
    "instance": "https://login.microsoftonline.com/"
  }
}
  • Create a client secret and also an additional file, app-config.secrets.json, where you record this secret.
{
  "azureAD": {
    "clientSecret": "...client secret..."
  }
}

The Users API

The API includes three endpoints:

  • /api/config/auth - retreives configuration for signing in the user.

  • /api/ping - a simple service that sends back a response with a timestamp and the current authenticated user's name.

  • /api/current-user/me - a service that calls the Microsoft Graph "Me" method on behalf of the authenticated user.

/api/config/auth

Previously we had coded the authentication configuration directly into the web application. Now we've changed the application to pull this information from the API.

[HttpGet]
[AllowAnonymous]
[Route("auth")]
public async Task<AuthConfig> Get()
{
    return await AppConfig.GetAuthenticationConfiguration();
}

Which just pulls the data from our app configuration:

public Task<AuthConfig> GetAuthenticationConfiguration()
{
    return Task.FromResult(new AuthConfig
    {
        ClientId = Config["authentication:clientId"],
        Authority = Config["authentication:authority"],
        ApiScope = Config["authentication:apiScope"],
    });
}

/api/ping

The ping service just returns a response with a timestamp and the authenticated user's name (from the claims provided by the authentication token).

[HttpGet]
[Route("ping")]
public PingResponse Ping()
{
    return new PingResponse
    {
        Timestamp = DateTime.UtcNow,
        UserName = User.Claims
            .Where(c => c.Type == "name")
            .Select(c => c.Value)
            .FirstOrDefault()
    };
}

/api/current-user/me

To authenticate users and call the Microsoft Graph on behalf of the user, middleware is included to add configuration and acquire tokens for the downstream API.

services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(Configuration, "AzureAd")
    .EnableTokenAcquisitionToCallDownstreamApi()
    .AddInMemoryTokenCaches();

The user.read permission has delegated access so the web API can request access on behalf of the user. The middleware handles the on-behalf-of flow behind the scenes and provides an access token that is used when calling the Microsoft Graph API.

private static readonly string[] requiredScopes = new string[] { "access_as_user" };
private static readonly string[] apiScopes = new string[] { "user.read" };

[HttpGet]
[Route("me")]
public async Task<IActionResult> Me(CancellationToken cancellationToken)
{
    HttpContext.VerifyUserHasAnyAcceptedScope(requiredScopes);
    string accessToken = await Tokens.GetAccessTokenForUserAsync(apiScopes);

    using var reqMsg = new HttpRequestMessage(HttpMethod.Get, "https://graph.microsoft.com/v1.0/me");
    reqMsg.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
    using var rspMsg = await Client.SendAsync(reqMsg, HttpCompletionOption.ResponseContentRead, cancellationToken);
    rspMsg.EnsureSuccessStatusCode();
    return new ContentResult
    {
        Content = await rspMsg.Content.ReadAsStringAsync(),
        ContentType = MediaTypeNames.Application.Json,
        StatusCode = (int)HttpStatusCode.OK,
    };
}

We are using a simple HTTP Client request rather than the more full-featured Microsoft Graph client in order to show the details of the operation.

Running the Web API Server Locally

We can still run the web application and the users API on our local machine outside of Kubernetes. This requires updating the packages/browser-frontend/src/setupProxy.js file so that the requests will be proxied to our users API server.

const { createProxyMiddleware } = require("http-proxy-middleware");

module.exports = function (app) {
  app.use(
    "/api",
    createProxyMiddleware({
      target: "http://localhost:31003",
      changeOrigin: true,
    })
  );
};

Then run two commands:

  • npm start (in the packages/browser-frontend path)
  • dotnet start (in the packages/users-api path)

Navigate to https://testing.local:3000/ and sign into the application and test the API calls to verify everything is working.

It's worth mentioning here that if you are running on a Windows 10 box, I highly recommend using Microsoft Terminal for your command-line work. You can even script it to open multiple tabs in different directories, each running its own command.

Building the Users API Container Image

Like we did for the static web assets, we need to build another image for the users API. The Dockerfile that describes this image uses a .NET v5 runtime image where the artifacts of the users API build will be copied. The ENTRYPOINT then specifies how the web API server is started.

FROM mcr.microsoft.com/dotnet/aspnet:5.0
COPY ./build/ App/
WORKDIR /App
ENTRYPOINT ["dotnet", "ExampleApp.Users.dll"]

To build the User API web application and the container image, run the script:

packages/users-api/deploy/build-users-api-image.ps1

This script builds the application then runs docker build to build the container image. An alternative approach is to build the application within a container itself, which then subequently builds the container image.

Updating the Ingress

Before adding the new image to the Kubernetes cluster, we need to update the ingress definitions so that incoming requests are routed to both the NGINX server for the static web assets and the new Users API web server.

We could add an additional path to our Ingress definition we created previously. But then we have a single definition that must always change as new services are added.

A better solution is to use Mergable Ingress resources in the NGINX Ingress Controller definition. This allows us to split the definition into a "master" definition and multiple "minion" definitions.

In our example, the "master" definition will define the TLS termination and the host name:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: example-application-ingress-master
  annotations:
    nginx.org/mergeable-ingress-type: master
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - testing.local
      secretName: example-app-ingress-nginx-ingress-default-server-tls
  rules:
    - host: testing.local

There are two "minion" definitions, one for the static web assets:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: frontend-ingress
  annotations:
    nginx.org/mergeable-ingress-type: minion
spec:
  ingressClassName: nginx
  rules:
    - host: testing.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: frontend-service
                port:
                  number: 80

And another for the new Users API:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: users-api-ingress
  annotations:
    nginx.org/mergeable-ingress-type: minion
spec:
  ingressClassName: nginx
  rules:
    - host: testing.local
      http:
        paths:
          - path: /api
            pathType: Prefix
            backend:
              service:
                name: users-api-service
                port:
                  number: 80

The users API gets all requests starting with the path /api and the front end (NGINX) application gets everything else. To the front end application, this ingress definition makes it appear to be a single web site even though it is running separate services within the cluster. So there is no CORS configuration required either.

Of course, the front end application could access other web APIs outside of this system (as it does querying the Microsoft Graph API, for example). So it is not a requirement to configure your application this way.

In the next post we will show how DAPR can provide an abstraction around these endpoints so that we do not have to declare the routing explicitly.

Updating the Kubernetes Cluster

Now that all the pieces are in place, we can update the Kubernetes cluster. First the users API:

packages/users-api/deploy/build-users-api-image.ps1
packages/users-api/deploy/initialize-users-api.ps1

Then the front end application:

packages/browser-frontend/deploy/build-frontend-image.ps1
packages/browser-frontend/deploy/initialize-frontend.ps1

And finally the ingress (from the packages/nginx-ingress path):

kubectl apply -f ingress-master.yaml -n azure-dapr-identity-example

Navigate to https://testing.local:31001/ to access the application within the Kubernetes cluster.

Next up, we will incorporate the DAPR runtime into our application.