Azure,  Tips and Tricks

Securing Backend APIs Using Azure API Management Policies (Part 2)

Continuing from Part 1 of this multi-part blog post series, let’s use the concepts we covered from the previous post to further explore Azure API Management’s capabilities to secure our backend APIs.

In this second post we are going to cover the following:

  • What are Azure API Management policy expressions?
  • Demo scenario! Let’s govern API access using token claims
  • Use application authorised access token
  • Use user authorised access token

 

What are Azure API Management Policy Expressions?

Along with the out-of-the-box policies, we can also utilise policy expressions to access the runtime API context and various APIM service configurations to execute additional logic in our inbound and outbound API processes. The policy expressions can be written using C# syntax and it supports a subset of .NET Framework types. This makes it easier to triage certain rules and complexities at the APIM level instead of handling them in the backend API code. We will explore usages of policy expressions in the sections below.

 

Demo Scenario! Let’s Govern API Access Using Token Claims

In the previous post, we covered what the validate-jwt policy does in a nutshell. We will build on that and inspect the token claims to apply additional logic before calling the backend APIs.

To set the scene, I was recently investigating third-party OData endpoints hosted as iPaaS. On this platform, the level of access and privileges permitted on each endpoint is defined by the API users assigned to it. The API requests are made using the Basic Authentication scheme with the base64-encoded username:password string of the API user. To consume these API endpoints, the client application must know which user credentials to use to make the requests. A couple of concerns here:

  1. How do we manage the user credentials without it becoming unruly when there are many clients consuming the API endpoints?
  2. How do we securely call the API endpoints from those clients which have no secure way to store and expose these user credentials?

We will look at a possible way to address these concerns by configuring custom App roles and API scopes on our Azure app registration that represents the API.

Let’s set up the following API Users and OData endpoints to demonstrate this scenario:

API UserPrivilegesCredentials (Base64-Encoded)
api_adminCreate, Read, Update, DeleteYWRtaW46UEBzc3cwcmQ=
api_userCreate, Read, UpdatedXNlcjpQQHNzdzByZA==
api_guestReadZ3Vlc3Q6UEBzc3cwcmQ=
Allowed MethodsOData Endpoint
GET, POSThttps://<odata-api>/api/customers
PUT, DELETEhttps://<odata-api>/api/customers/{id}
GET, POSThttps://<odata-api>/api/products
PUT, DELETEhttps://<odata-api>/api/products/{id}

We want to look at two types of client access:

TypeComment
Using the identity of an application Server-to-server interactions made using daemon or backend service applications to call the API
Using the identity of a user Users signing in to authenticate, authorise and give consent to the application to access the protected API on their behalf

 

šŸ’» Use Application Authorised Access Token

On the Azure app registration representing the API, we can configure custom app roles to allow claim-based authorisation on the application. When you assign app roles to an application, application permissions are created. It is usually up to the developers of the API to verify and implement authorisation logic in code using these token claims. Since we do not have access to the third-party API code, we will perform this logic in the APIM policies instead. In this scenario, we specifically look at using the identity of an application to call the API and authorise appropriate access depending on the role assigned to the application. To set up this scenario we will do the following:

  1. Create an Azure app registration that represents the API
  2. Create an Azure app registration for the client console app that calls the API
  3. Validate and acquire an access token for the console app using Postman
  4. Set up APIM with the API and policies

 

#1 Create an Azure app registration that represents the API

  1. Navigate to the App Registration section of the Azure Portal and select + New Registration
  2. On the Register an Application page, enter the following information:
    • Name: api-odata-app
    • Supported account types: Accounts in this organizational directory only
    • Redirect URI: leave it blank
  3. On the Overview page, copy and note down the Application (client) ID
  4. On the Expose an API page, click the Set link next to the Application ID URI and Save the suggested Application ID URI. Copy and note down this Application ID URI
  5. On the App roles page, click + Create app role and on the Create app role blade, create the following app roles and enable them:
Display NameAllowed Member TypesValueDescription
AdminApplicationsAllAdmin can create, read, update, delete records
UserApplications ReadWriteUsers can create, read and update records
GuestApplicationsReadOnlyGuest can read records only

 

#2 Create an Azure app registration for the client console app that calls the API

  1. Navigate to the App Registration section of the Azure Portal and select + New Registration
  2. On the Register an Application page, enter the following information:
    • Name: client-console-app
    • Supported account types: Accounts in this organizational directory only
    • Redirect URI: leave it blank
  3. On the Overview page, copy and note down the Application (client) ID
  4. On the Certificates & secrets page, click + New client secret and create a secret then note down the Value once created
  5. On the API permissions page, click + Add a permission and on My APIs tab, select the api-odata-app application created for the backend API
  6. On the Request API permissions blade, select the Application permissions type and select ReadWrite (User) permission and click Add permissions
  7. After adding the permission, click āœ” Grant admin consent for <tenant name>. This will consent to the permissions configured for the application
Note: Notice how the app roles are assigned to the client app as application permissions. Unlike delegated permissions, the administrator must grant consent to these permissions.  

 

#3 Validate and acquire an access token for the console app using Postman

Same way as we did in the previous post, we are going to use Postman to acquire the access token. Since we are using the identity of an application, we will be using the client credentials grant flow:

The access token acquired from the above request can be examined using jwt.ms. We are already familiar with the claims – iss (issuer), aud (audience) and exp (expiry), as we have used them to validate the token using the validate-jwt policy. This time we will be using the roles claim which contains the app roles assigned to the client app:

{
  "typ": "JWT",
  ...
}.{
  ...
  "aud": "api://cc384180-c8be-4a63-b7a7-3ca72523f843",
  "iss": "https://sts.windows.net/<tenant-id>/",
  "exp": 1628166953,
  "roles": [
    "ReadWrite"
  ],
  ...
}.[Signature]

 

#4 Set up APIM with the API and policies

In the APIM instance, let’s add the third-party OData API as follows:

Also, add the following OData endpoints as API operations:

Before we start configuring the inbound policy, let’s reiterate the objective. We would like the inbound policies to:

  1. Check the validity of the access token (JWT token)
  2. Check that the client application has been granted any of these app roles – Admin (All), User (ReadWrite) or Guest (ReadOnly)
  3. Retrieve the API user credential required to call the backend API by performing a conditional check based on the assigned app role
  4. If any of the above conditions are not met, respond to the API request with 4xx error with an error message

Since these policies apply to all operations for this API, we will be configuring this at the All operations level:

Add the following inbound policies:

<policies>
  <inbound>
    <base />
    <validate-jwt header-name="Authorization" 
      failed-validation-httpcode="401" 
      failed-validation-error-message="Unauthorized. Access token is missing or invalid." 
      require-expiration-time="true" 
      require-scheme="Bearer" 
      require-signed-tokens="true" 
      output-token-variable-name="jwt">
      <openid-config url="https://login.microsoftonline.com/{{tenant-id}}/v2.0/.well-known/openid-configuration" />
        <audiences>
          <audience>api://cc384180-c8be-4a63-b7a7-3ca72523f843</audience>
        </audiences>
        <issuers>
          <issuer>https://sts.windows.net/{{tenant-id}}/</issuer>
        </issuers>
        <required-claims>
          <claim name="roles" match="any">
            <value>All</value>
            <value>ReadWrite</value>
            <value>ReadOnly</value>
          </claim>
        </required-claims>
    </validate-jwt>
    <choose>
      <when 
        condition="@(((Jwt)context.Variables["jwt"]).Claims["roles"].Contains("All"))">
        <set-variable name="credentials" value="{{api-admin-credentials}}" />
      </when>
      <when 
        condition="@(((Jwt)context.Variables["jwt"]).Claims["roles"].Contains("ReadWrite"))">
        <set-variable name="credentials" value="{{api-user-credentials}}" />
      </when>
      <when 
        condition="@(((Jwt)context.Variables["jwt"]).Claims["roles"].Contains("ReadOnly"))">
        <set-variable name="credentials" value="{{api-guest-credentials}}" />
      </when>
    </choose>
    <choose>
      <when 
        condition="@(context.Variables.GetValueOrDefault<string>("credentials","") == "")">
        <return-response>
          <set-status code="401" reason="Unauthorized" />
        </return-response>
      </when>
      <otherwise>
        <set-header name="x-thirdparty-token" exists-action="override">
          <value>@(context.Variables.GetValueOrDefault<string>("credentials"))</value>
        </set-header>
      </otherwise>
    </choose>
    <set-header name="Ocp-Apim-Subscription-Key" exists-action="delete" />
    <set-header name="Authorization" exists-action="delete" />
  </inbound>
  <backend>
    <base />
  </backend>
  <outbound>
    <base />
  </outbound>
  <on-error>
    <base />
  </on-error>
</policies>
Note: I've highlighted the areas we haven't covered in the previous post!

In the validate-jwtĀ policy, we check explicitly for the roles claim to be present in the token and make sure that the claim contains one of the app roles specified. If the token validation fails, we will return a 401 response with an error message, but if the validation succeeds, we will store the token value in a context variable named "jwt" as an object of type Jwt.

The choose policy, also known as the control flow policy, allows evaluation of Boolean expressions to be used in an “if, else-if and else”-like condition. As you can see, we are using single statement expressions by enclosing the C# expression between the @(expression). In the case of multi-statement expressions, we can use curly braces @{expression} to enclose the expressions.

Let’s examine one of the expressions we used for the <when/> element:

<when condition="@(((Jwt)context.Variables["jwt"]).Claims["roles"].Contains("ReadWrite"))">
    <set-variable name="credentials" value="{{api-user-credentials}}" />
</when>

Here, the "jwt" context variable is retrieved and cast as a Jwt object to access its Claims property of type IReadOnlyDictionary<string, string[]>. We then check if the app role value, ReadWrite, exists in the Claims dictionary and it if does, we use the set-variable policy to store the API user credential to the "credentials" context variable. The api-user-credentials variable is set in the Named values global collection of the APIM instance. In this case, the value itself is retrieved explicitly from the nominated Key Vault as configured for this named value.

In the second choose policy, we check whether a value is set for the "credentials" context variable and, if not, the return-response policy is used to return a custom 401 response. Otherwise, the set-header policy is used to set the custom auth header needed for the third party OData API to authorise the request.

We can now test this configuration using Postman with the information gathered when creating the client-console-app:

We managed to successfully create a customer! Since the client-console-app is not assigned with the Admin app role we shouldn’t be able to delete the customer we’ve just created. Instead, we should get a 403 response from the API like so:

 

šŸ‘©šŸ»ā€šŸ’» Use User Authorised Access Token

When using the identity of a user to call the API, we can further extend the app roles to be assigned to specific users or groups. We can also define custom scopes to further refine the permissions required to access specific API resources. These scopes will be created as delegated permissions on the application. To set up this scenario we will do the following:

  1. Update the Azure app registration that represents the API
  2. Explicitly assign users to app roles
  3. Create an Azure app registration for the client web app that calls the API
  4. Validate and acquire an access token for the web app using Postman
  5. Update the APIM policies

 

#1 Update the Azure app registration that represents the API

  1. Navigate to the App Registration section of the Azure Portal and select the api-odata-app app registeration we created earlier
  2. On the Expose an API page, click + Add a scope to add a custom scope. On the Add a scope blade, enter the following and click Save:
    • Scope name: Products
    • Who can consent: Admins only
    • Admin consent display name: Access to products
    • Admin consent description: Allows the app to access products
  3. On the App roles page, click on each of the app roles we created previously and update the Allowed member types to Both (Users/Groups + Applications)

 

#2 Explicitly assign users to app roles

  1. Navigate to the Enterprise Applications section of the Azure Portal and select the api-odata-app from the list
  2. Under Manage section, on the Users and groups page, click + Add user/group to assign a user to an app role:

    For demonstration purposes, I have added two users:
    1. taerim.han@<tenant-name>.onmicrosoft.com with the User app role
    2. tom.smith@<tenant-name>.onmicrosoft.com with the Guest app role

 

#3 Create an Azure app registration for the web app that calls the backend API

  1. Navigate to theĀ App RegistrationĀ section of the Azure Portal and selectĀ + New Registration
  2. On theĀ Register an ApplicationĀ page, enter the following information:
    • Name:Ā client-web-app
    • Supported account types:Ā Accounts in this organizational directory only
    • Redirect URI:Ā leave it blank for now
  3. On theĀ OverviewĀ page, copy and note down theĀ Application (client) ID
  4. On theĀ API permissionsĀ page, clickĀ + Add a permissionĀ and onĀ My APIsĀ tab, select the api-odata-app application created for the backend API
  5. On theĀ Request API permissionsĀ blade, select the Delegated permissions type and select Products (Access to products) permission and click Add permissions
  6. After adding the permission, click āœ” Grant admin consent for <tenant name>. This will consent to the permissions configured for the application
  7. Preempting for Postman in step #4, on the Authentication page, click + Add a platform and select Mobile and desktop applications option. In Custom redirect URIs textbox, enter https://oauth.pstmn.io/v1/callback and click Configure

 

#4 Validate and acquire an access token for the client app using Postman

We want to use the Authorization Code (With PKCE) grant type on the client web app to authenticate and authorize the user. To acquire the access token, we are going to set up the Authorisation section of our Postman collection as follows:

Clicking on the Get New Access Token button will launch a browser with a sign-in page. When the user successfully signs in, it will take care of the authentication flow that consists of exchanging the authorisation code for tokens using the value of the Callback URL as the redirect URI. Once an access token is acquired, click Use Token to use it for all requests in that Postman collection:

Each request must explicitly select the type Inherit auth from parent under the Authorisation section to use the access token acquired.

To examine the access token, I have authenticated as taerim.han@<tenant-name>.onmicrosoft.com, who is assigned with the ReadWrite (User) app role:

{
  "typ": "JWT",
  ...
}.{
  ...
  "aud": "api://cc384180-c8be-4a63-b7a7-3ca72523f843",
  "iss": "https://sts.windows.net/<tenant-id>/",
  "exp": 1628248008,
  "amr": [
    "pwd"
  ],
  "appid": "<web-app-client-id>",
  "appidacr": "0",
  "family_name": "Han",
  "given_name": "Tae Rim",
  "name": "Tae Rim Han",
  "oid": "<user-object-id>",
  "roles": [
    "ReadWrite"
  ],
  "scp": "Products",
  "tid": "<tenant-id>",
  "unique_name": "taerim.han@<tenant-name>.onmicrosoft.com",
  "upn": "taerim.han@<tenant-name>.onmicrosoft.com",
  ...
}.[Signature]
Note: I have bolded the claims we will be using in our APIM policies

By assigning the app roles to the user, the assigned roles are returned in the roles claim. If I authenticate as tom.smith@<tenant-name>.onmicrosoft.com, who is assigned with the ReadOnly (Guest) app role, the roles claim will return the value ReadOnly.

 

#5 Update the APIM policies

Now we need to update the inbound APIM policies to cater for the user authorised API requests. Here are the additional updates we are going to make:

  1. Check if the client is authenticated using a public client (value = “0”) or using a client ID and client secrets (value = “1”)
  2. Check if the Products scope has been requested and consented to. Any app that does not have the Products scope will not be allowed to access any /products API operations
  3. Check if all conditions are met before setting the x-thirdparty-token header, otherwise return a 401 response

Here are the changes:

<policies>
  <inbound>
    ...
    <validate-jwt><!-- validate JWT token --></validate>
    <set-variable name="isPublicClient" 
      value="@(((Jwt)context.Variables["jwt"]).Claims.GetValueOrDefault("appidacr", "") == "0")" />
    <choose>
      <when condition="@(context.Variables.GetValueOrDefault<bool>("isPublicClient"))">
        <set-variable name="hasRequiredScope" value="@{
            var jwt = (Jwt)context.Variables["jwt"];
            var scopes = jwt.Claims.GetValueOrDefault("scp","").Split(' ');
            if (scopes.Contains("Products") &&
                context.Operation.UrlTemplate.StartsWith("/Products")) {
                return true;
            }
            return false;
        }" />
      </when>
      <otherwise>
        <set-variable name="hasRequiredScope" value="@(true)" />
      </otherwise>
    </choose>
    <choose><!-- set credentials context variable --></choose>
    <choose>
      <when 
        condition="@(context.Variables.GetValueOrDefault<string>("credentials","") == "" || context.Variables.GetValueOrDefault<bool>("hasRequiredScope") == false)">
        <return-response>
            <set-status code="401" reason="Unauthorized" />
        </return-response>
      </when>
      <otherwise>
        <set-header name="x-thirdparty-token" exists-action="override">
            <value>@(context.Variables.GetValueOrDefault<string>("credentials"))</value>
        </set-header>
      </otherwise>
    </choose>
    ...
  </inbound>
  ...
</policies>

In order for us to call the API from a single-page application or even from a PCF control in a model-driven app, we must also configure the cors policy in the inbound policies. If you want to try this out in action, I have previously written a post about using authorization code grant flow from a PCF control here. We can easily swap out the MSAL config values and scopes for that PCF demo with values we have obtained from this post to call the API. All we have to do on the APIM for that demo to work is to include the cors policy as follows:

<policies>
  <inbound>
    <base />
    <cors>
      <allowed-origins>
          <origin>https://<powerapps-instance-url>.crm6.dynamics.com</origin>
      </allowed-origins>
      <allowed-methods>
          <method>*</method>
      </allowed-methods>
      <allowed-headers>
          <header>*</header>
      </allowed-headers>
    </cors>
    <validate-jwt>...</validate-jwt>
    ...
  </inbound>
  ...
</policies>

Also, make sure to add the Power Apps instance URI as the redirect URI for the client app:

As you can see, we can implement some powerful and flexible custom logic in the APIM policies using policy expressions together with the out-of-the-box policies!

Here is the full snippet of the inbound policies used for this demo:

<policies>
  <inbound>
    <base />
    <cors>
      <allowed-origins>
        <origin>https://powerapps-instance-url.crm6.dynamics.com</origin>
      </allowed-origins>
      <allowed-methods>
        <method>*</method>
      </allowed-methods>
      <allowed-headers>
        <header>*</header>
      </allowed-headers>
    </cors>
    <validate-jwt header-name="Authorization" 
      failed-validation-httpcode="401" 
      failed-validation-error-message="Unauthorized. Access token is missing or invalid." 
      require-expiration-time="true" 
      require-scheme="Bearer" 
      require-signed-tokens="true" 
      output-token-variable-name="jwt">
        <openid-config 
          url="https://login.microsoftonline.com/{{tenant-id}}/v2.0/.well-known/openid-configuration" />
        <audiences>
          <audience>api://cc384180-c8be-4a63-b7a7-3ca72523f843</audience>
        </audiences>
        <issuers>
          <issuer>https://sts.windows.net/{{tenant-id}}/</issuer>
        </issuers>
        <required-claims>
          <claim name="roles" match="any">
            <value>All</value>
            <value>ReadWrite</value>
            <value>ReadOnly</value>
          </claim>
        </required-claims>
    </validate-jwt>
    <set-variable name="isPublicClient" 
      value="@(((Jwt)context.Variables["jwt"]).Claims.GetValueOrDefault("appidacr", "") == "0")" />
    <choose>
      <when 
        condition="@(context.Variables.GetValueOrDefault<bool>("isPublicClient"))">
        <set-variable name="hasRequiredScope" value="@{
          var jwt = (Jwt)context.Variables["jwt"];
          var scopes = jwt.Claims.GetValueOrDefault("scp","").Split(' ');
          if (scopes.Contains("Products") && 
              context.Operation.UrlTemplate.StartsWith("/Products")) {
              return true;
          }
          return false;
        }" />
      </when>
      <otherwise>
          <set-variable name="hasRequiredScope" value="@(true)" />
      </otherwise>
    </choose>
    <choose>
      <when condition="@(((Jwt)context.Variables["jwt"]).Claims["roles"].Contains("All"))">
        <set-variable name="credentials" value="{{api-admin-credentials}}" />
      </when>
      <when condition="@(((Jwt)context.Variables["jwt"]).Claims["roles"].Contains("ReadWrite"))">
        <set-variable name="credentials" value="{{api-user-credentials}}" />
      </when>
      <when condition="@(((Jwt)context.Variables["jwt"]).Claims["roles"].Contains("ReadOnly"))">
        <set-variable name="credentials" value="{{api-guest-credentials}}" />
      </when>
    </choose>
    <choose>
      <when 
        condition="@(context.Variables.GetValueOrDefault<string>("credentials","") == "" || context.Variables.GetValueOrDefault<bool>("hasRequiredScope") == false)">
        <return-response>
          <set-status code="401" reason="Unauthorized" />
        </return-response>
      </when>
      <otherwise>
        <set-header name="x-thirdparty-token" exists-action="override">
          <value>@(context.Variables.GetValueOrDefault<string>("credentials"))</value>
        </set-header>
      </otherwise>
    </choose>
    <set-header name="Ocp-Apim-Subscription-Key" exists-action="delete" />
    <set-header name="Authorization" exists-action="delete" />
  </inbound>
  <backend>
      <base />
  </backend>
  <outbound>
      <base />
  </outbound>
  <on-error>
      <base />
  </on-error>
</policies>

Hope you enjoyed the post, let me know your thoughts in the comments below or any of my contact channels šŸ˜Ž