Azure API Management and OAuth tokens for multiple backend services

The Problem

Azure API Management helps you organize and publish your APIs. This post focus on handling OAuth2 tokens for a backend that is composed of multiple services, each having a different ClientId (and therefore, requiring a different Access Token).

In this case, the FrontEnd (or anything wanting to call API 01 or API 02) needs to know the ClientId for both API 01 and API 02. The APIM lose its interest because while you have a single Entry Point for your API, you still need to know the architecture of the underlying backend.

The easy solution is to use the same ClientId for all your APIs :

Here, the FrontEnd only needs one Access Token to call all the APIs, and doesn't need to know what's behind the API Management. It's a lot easier than the first scenario, but, it may not be the best solution if :

  • The APIs are completely independents and managed by different teams (API 01team may not want to share the ownership of the Azure AD App with the team API 02)
  • The APIs have their own roles, scopes, and permissions (it may be acceptable for 2 APIs, but it won't scale nicely if you end up with many more APIs)

That's why we'll see how to implement the third scenario which looks like this :

We create an App dedicated to the API Management called APIM, and the FrontEnd will only request tokens with APIM as the audience. Then, the goal is for the API Management to request tokens for API_01 or API_02 using the OAuth 2 On-Behalf-Of flow (OBO).

API Management Policies

The Azure API Management has a feature called policies which is, according to the official documentation, a powerful capability of the system that allow the publisher to change the behavior of the API through configuration. It allows you to do some basic operations like validate a header, cache some responses, set quota usages, etc... but it can also be used for more advanced scenarios.

We will use that to request Access Tokens for backend APIs, using the On-Behalf-Of flow.

I won't go into details about the setup of APIs in the API Management, since there's a lot of documentation on that. I'll start directly with the policies, assuming you already built the setup of the third scenario in both API Management and Azure AD.

Policy to request an Oauth2 token using OBO flow

You can choose to configure policies at 3 different levels :

  • For all backend services
  • For a single backend service
  • For each endpoint within a backend service

Since the OBO flow will be different for each services, we will deploy the policy for requesting OBO Oauth Access Token at the backend service level. According to the Azure AD documentation, in addition of the TenantId, there's 6 parameters needed for requestion an Access Token :

  1. grant_type : the value must be urn:ietf:params:oauth:grant-type:jwt-bearer
  2. client_id : The ClientId of APIM App
  3. client_secret : The ClientSecret generated from the APIM App
  4. assertion : The Access Token from the original request (The ClientId of this token is the FRONTEND one, and the audience is the ClientId of APIM App)
  5. scope : One of the scope defined in the underlying backend service (API_01or API_02). Example : api://1234567a-7564-4b01-9cba-2f662835a791/Read.All
  6. requested_token_use : the value must be on_behalf_of

To send this request, and to include the result in the request to the backend service, the following policy must be added in the Inbound section :

<policies>  
    <inbound>
        <base />
        <set-variable name="originBearer" value="@(context.Request.Headers.GetValueOrDefault("Authorization", "empty_token").Split(' ')[1].ToString())" />
        <send-request ignore-error="true" timeout="20" response-variable-name="bearerToken" mode="new">
            <set-url>https://login.microsoftonline.com/{{tenantId}}/oauth2/v2.0/token</set-url>
            <set-method>POST</set-method>
            <set-header name="Content-Type" exists-action="override">
                <value>application/x-www-form-urlencoded</value>
            </set-header>
            <set-body>@{
                    return "client_id={{clientId-api01}}&scope={{scope-api01}}&client_secret={{clientSecret}}&assertion="+(string)context.Variables["originBearer"]+"&grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&requested_token_use=on_behalf_of";
            }</set-body>
        </send-request>
        <set-variable name="requestResponseToken" value="@((String)((IResponse)context.Variables["bearerToken"]).Body.As<JObject>()["access_token"])" />
        <set-header name="Authorization" exists-action="override">
            <value>@("Bearer " + (string)context.Variables["requestResponseToken"])</value>
        </set-header>
    </inbound>
    <!-- others section have been omitted -->
</policies>  

There's 4 part in this policy :

  1. The <set-variable /> is used to get the current Assertion from the Authorize Header, and take only the Access Token without the Bearer prefix.
  2. The <send-request /> will build and send the request to Azure AD to request the OAuth2 token, and store the response in a variable named bearerToken
  3. The second <set-variable /> is used to read the response and store The Access Token in the requestResponseToken
  4. Finally, the Authorization header of the request is overridden with the new Access Token before the request is forwarded to the backend service (API_01 in our example)

All the variables enclosed in curly braces like {{tenantId}} are defined in the Named values section of API Management

Now we have a policy for a backend service, that take the incoming Access Token to request one with the right audience and scope using the OAuth2 OBO flow, and append the token to the request. When calling the API, we do not need to know anything about the CliendId of the API_01 service.
In order to fully finish what was described in the scenario 3, we just need to add the same policy for API_02 and change the scope (we have the variable {{scope-api01}} in the policy sample, we just need another variable called {{scope-api02}} and replace it in the policy).

Caching the Access Token

The previous policy works fine. However, the request for the OAuth2 Access Token using the OBO flow is adding around 250ms to the request. Without the policy, you would still need to request the token one way or another, but most of the libraries use a local cache for managing tokens. If your FrontEnd needs to make many calls to the API for loading a page, there's no point requesting a new token each time.
Azure API Management can cache objects using an Internal or External cache (Redis). We can use the default internal cache to store the Access Token up to 1 hour (the validity of the Access Token).
Since the Access Token is strictly personal, we have to cache a token for each user sending a request to a backend service. The key we can use to store each Access Token must contain the unique id of the user, and the audience. For example clientId;unique_name

The updated policy will add the following steps :

  1. Retrieve the unique_name of the caller
  2. Build the cache key to lookup the cache for an existing token
  3. If the token doesn't exist. Send the request to Azure AD to retrieve the token and store it in the cache for less than 3600 seconds.
<policies>  
    <inbound>
        <base />
        <set-variable name="originBearer" value="@(context.Request.Headers.GetValueOrDefault("Authorization", "empty_token").Split(' ')[1].ToString())" />
        <set-variable name="userId" value="@{
            var jwt = context.Request.Headers.GetValueOrDefault("Authorization").AsJwt();
            return jwt?.Claims.GetValueOrDefault("unique_name") ?? "empty";
        }" />
        <set-variable name="cacheKey" value="@{
            return "{{clientId-api01}}"+";"+(string)context.Variables["userId"];
        }" />
        <cache-lookup-value key="@((string)context.Variables["cacheKey"])" default-value="empty" variable-name="bearerTokenCache" />
        <choose>
            <when condition="@((string)context.Variables["bearerTokenCache"] == "empty")">
                <send-request ignore-error="true" timeout="20" response-variable-name="bearerToken" mode="new">
                    <set-url>https://login.microsoftonline.com/{{tenantId}}/oauth2/v2.0/token</set-url>
                    <set-method>POST</set-method>
                    <set-header name="Content-Type" exists-action="override">
                        <value>application/x-www-form-urlencoded</value>
                    </set-header>
                    <set-body>@{
                            return "client_id={{clientId-api01}}&scope={{scope-api01}}&client_secret={{clientSecret}}&assertion="+(string)context.Variables["originBearer"]+"&grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&requested_token_use=on_behalf_of";
                    }</set-body>
                </send-request>
                <set-variable name="requestResponseToken" value="@((String)((IResponse)context.Variables["bearerToken"]).Body.As<JObject>()["access_token"])" />
                <set-header name="Authorization" exists-action="override">
                    <value>@("Bearer " + (string)context.Variables["requestResponseToken"])</value>
                </set-header>
                <cache-store-value key="@((string)context.Variables["cacheKey"])" value="@((string)context.Variables["requestResponseToken"])" duration="3300" />
            </when>
            <otherwise>
                <set-header name="Authorization" exists-action="override">
                    <value>@("Bearer " + (String)context.Variables["bearerTokenCache"])</value>
                </set-header>
            </otherwise>
        </choose>
    </inbound>
    <!-- others section have been omitted -->
</policies>  

Note: I've also added clientId-api01 as a new Named Value, but we could also have retrieved it from the scope-api01 Named Value.

To go further

The thing we can do to avoid Internal Errors from the API Management is to check the Access Token sent by the FrontEnd using the Validate JWT policy. If not, the policy may want to read a unique_name from a malformed or missing Access Token and throw a 500 Internal Error. It's not really a security issue since Azure AD will throw an error if you request an Access Token with the OBO flow using a non-valid assertion, but it's better to respond 401 Unauthorized instead of an Internal Error.