Blog Post

Azure PaaS Blog
6 MIN READ

API Management Policy for Access Token Acquisition, Caching and Renewal

willzhan's avatar
willzhan
Icon for Microsoft rankMicrosoft
Mar 08, 2021

Co-authors (alphabetical order):

Dan Balma, Maarten Van De Bospoort, Vishnu Naga Praveen Deepthimahanthi, Nick Drouin, Kreig DuBose, David Giard, Michael Green, Binay Kumar, Hao Luo, Shubhaangi Mahajan, Maggie Marxen, Andres Robinet, Jatin Sharma, Taru Sinha, David Triana, Jeremy Woo-Sam, Franco Zuccarelli

 

Introduction

API Management can acquire access tokens from backend before forwarding calls with the access token to the backend. This document shows how to acquire access token from Azure AD thru client credentials flow. Here we present an API Management policy which can not only acquire access token, but also cache and renew upon its expiration.

In addition, we assume the backend service is not necessarily protected by Azure AD. If backend is one or multiple different vendors’ services protected by different Identity Providers and token issuers, we can use API Management as a gateway to achieve the following goals:

  1. Replacing multiple different backend identity providers/token issuers by a single one: Azure AD, to protect the list of backend REST API services. An Azure application can use any of the OAuth2 grant flows with a single Azure-native Identity Provider: Azure AD and its token issuer to access the backend services.
  2. Shielding an Azure application and its security from backend (vendor specific) security schemes. In case any of the backend (vendor) systems is replaced, what needs to be changed is limited to API Management policy, instead of Azure application code. The same Azure AD tenant, users, groups, managed identities, service principals, roles and RBAC can stay intact.

 These goals are described by the following diagram.

 

 

Assumptions

Since OAuth2 and JSON Web Token (JWT) are today's default choices in implementing authorization, this API Management policy is built on the following assumptions:

  1. Access token is of JWT format;
  2. In this API Management policy, we assume the backend uses ROPC (Resource Owner Password Credentials) grant flow. If the backend uses another flow (such as client credentials), corresponding code change is needed but the code change is limited to token acquisition. The code for token caching and expiration can stay intact. This document provides a sample policy for acquiring access token from Azure AD using client credentials flow.

Design Decisions

  1. We have chosen to use API Management internal cache for caching token. But switching to external cache requires only minor change. Except for Consumption tier, all other tiers of API Management support internal cache. See here for details.
  2. We have chosen to cache both access token and its expiration time. With this decision, during cache hit (most of the time), there is no need to parse the JWT for its expiration (exp claim value). The exp claim value is parsed only once for each token upon token acquisition from token endpoint.
  3. We have chosen to set maximum token cache duration to 60 minutes (see details below). This can be changed easily.

The API Management Policy

The API Management policy is shown below. The basic flow:

  • In case of cache miss or cache hit but token has expired, an access token is acquired (in this case, via Resource Owner Password Credentials flow). Then the expiration time is parsed. Both the access token and its expiration are added into cache.
  • In case of cache hit and the cached token has not expired, the cached token is used.
  • In either case, the access token is set in Authorization header as a bearer token before forwarding the call to the backend specified by {{svc_base_url}}.
  • The API Management subscription key header is removed in case it is present.

 

 

 

 

<policies>
    <inbound>
        <base />
        <set-backend-service base-url="{{svc_base_url}}" />
        <cache-lookup-value key="{{svc_base_url}}-token-key" variable-name="token" caching-type="internal" />
        <cache-lookup-value key="{{svc_base_url}}-token-exp-key" variable-name="token-exp" caching-type="internal" />
        <choose>
            <when condition="@(!context.Variables.ContainsKey("token") || 
                               !context.Variables.ContainsKey("token-exp") ||
                               (context.Variables.ContainsKey("token") && 
                                context.Variables.ContainsKey("token-exp") && 
                                (DateTime.Parse((String)context.Variables["token-exp"]).AddMinutes(-1.0) 
                                 <= DateTime.UtcNow) 
                               )
                            )">
                <send-request ignore-error="false" timeout="{{svc_token_acquisition_timeout}}" response-variable-name="jwt" mode="new">
                    <set-url>{{svc_token_endpoint}}</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-header name="Authorization" exists-action="override">
                        <value>@("Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("{{svc_client_id}}:{{svc_client_secret}}")))</value>
                    </set-header>
                    <set-body>@("username={{svc_username}}&password={{svc_password}}&grant_type=password")</set-body>
                </send-request>
                <set-variable name="token" value="@((String)((IResponse)context.Variables["jwt"]).Body.As<JObject>()["access_token"])" />
                <set-variable name="token-exp" value="@{
                    string jwt = (String)context.Variables["token"];
                    string base64 = jwt.Split('.')[1].Replace("-", "+").Replace("_", "/");
                    int mod4 = base64.Length % 4;
                    if (mod4 > 0)
                    {
                        base64 += new String('=', 4 - mod4);
                    }
                    string base64_encoded = System.Text.Encoding.ASCII.GetString(Convert.FromBase64String(base64));
                    double exp_num = (double)JObject.Parse(base64_encoded)["exp"];
                    DateTime exp = (new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc)).AddSeconds(exp_num);
                    return exp.ToString("MM-dd-yyyy HH:mm:ss");
                }" />
                <cache-store-value key="{{svc_base_url}}-token-key" value="@((String)context.Variables["token"])" duration="3600" caching-type="internal" />
                <cache-store-value key="{{svc_base_url}}-token-exp-key" value="@((String)context.Variables["token-exp"])" duration="3600" caching-type="internal" />
            </when>
        </choose>
        <set-header name="Authorization" exists-action="override">
            <value>@{
                return $"Bearer {(String)context.Variables["token"]}";
            }</value>
        </set-header>
        <set-header name="Ocp-Apim-Subscription-Key" exists-action="delete" />
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

 

 

 

 

Features

The API Management policy has the following features:

  1. For each incoming REST call, API Management acquires access token from backend on its behalf and replaces or adds the Authorization header with the access token as a bearer token before forwarding the call to the backend service.
  2. All backend system security credentials are stored in an Azure Key Vault and API Management retrieves them for token acquisition thru API Management Named Value feature. Note: today Terraform does not support API Management Named Value directly linked to a Key Vault. Hence we should consider “moving” credential value (from Key Vault) into API Management Named Value (secret type) via Terraform during deployment. In any case, the policy stays the same regardless whether a credential is in Named Value as a secret or linked to Key Vault secret.
  3. Access token is cached, which could improve performance by 60% or more as observed;
  4. Every JWT access token expires. Upon token expiration, expired token will be replaced by a new one.
  5. Cache duration cap: some token issuers set very long token lifetime which is not a recommended security practice. We put a cap on token lifetime thru API Management policy, so that cached token never ages over, say one hour, like what Azure AD does, regardless the expiration settings of tokens.
  6. By design, API Management cache key is scoped to the whole API Management instance including all APIs deployed in the instance. We have made sure that token cache key is scoped to an API in an API Management instance, avoiding any possible cache key conflict among APIs deployed within an API Management instance.
Updated Apr 18, 2021
Version 3.0
  • DibyaRanjan1420's avatar
    DibyaRanjan1420
    Copper Contributor

    Hi There. Thank you for the nice articles. I have few edge cases where the above code will not work. 

     

    Edge case 1:

    When the signing key from OAuth Server changes, old token will still be there in the cache and the above code will return old token which is not correct. If the signing key changes, the code should delete all the cached token as they are no longer valid token and It should initiate a new fresh token & cache it.

     

     

    Let me know if you have any idea for this.

  • Amin_Aissous's avatar
    Amin_Aissous
    Copper Contributor

    Hello and thank you for your explanation: We can still optimize the policy of token expiration using the AsJwT Object as bellow : 

    <set-variable name="token-exp" value="@{
    // Extract the JWT (JSON Web Token) from the 'token' variable
    Jwt jwt = ((String)context.Variables["token"]).AsJwt();
    
    // Get the expiration time as a DateTime
    DateTime exp = (DateTime)jwt.ExpirationTime;
    
    // Return the expiration time formatted as "MM-dd-yyyy HH:mm:ss"
    return exp.ToString("MM-dd-yyyy HH:mm:ss");
    }" />


    We can also set the cache expiration to the token validity time for better optimization : 

    <!-- Calculate the time left until token expiration in seconds -->
                    <set-variable name="tokenExpTimeLeftInSeconds" value="@((int)(Math.Round((DateTime.Parse((String)context.Variables["token-exp"]) - DateTime.UtcNow).TotalSeconds)))" />
                    <!-- Cache the token and its expiration time -->
                    <cache-store-value key="{{dawinci-ao-backend-url}}-token-key" value="@((String)context.Variables["token"])" duration="@((int)context.Variables["tokenExpTimeLeftInSeconds"])" caching-type="internal" />
                    <cache-store-value key="{{dawinci-ao-backend-url}}-token-exp-key" value="@((String)context.Variables["token-exp"])" duration="@((int)context.Variables["tokenExpTimeLeftInSeconds"])" caching-type="internal" />


    For better resilience, I suggest handling errors in case of token retrieval failure, like bellow : 

    <choose>
                <when condition="@(!context.Variables.ContainsKey("token") || 
                                   !context.Variables.ContainsKey("token-exp") ||
                                   (context.Variables.ContainsKey("token") && 
                                    context.Variables.ContainsKey("token-exp") && 
                                    (DateTime.Parse((String)context.Variables["token-exp"]).AddMinutes(-1.0) 
                                     <= DateTime.UtcNow) 
                                   )
                                )">
                    <!-- Send a request to obtain a new token -->
                    <send-request mode="new" response-variable-name="oauth_response" timeout="10" ignore-error="true">
                        <set-url>{{auth-url}}</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>@{
                            string grant_type = "client_credentials";
                            string scope = "scope";
                            string client_id = "client_id";
                            string client_secret = "{{client-secret}}";
    
                            return $"grant_type={grant_type}&scope={scope}&client_id={client_id}&client_secret={client_secret}";
                        }</set-body>
                    </send-request>
                    <choose>
                        <when condition="@(((IResponse)context.Variables["oauth_response"]).StatusCode != 200)">
                            <!-- Handle error when obtaining the token -->
                            <return-response>
                                <set-status code="500" />
                                <set-header name="Content-Type" exists-action="override">
                                    <value>application/json</value>
                                </set-header>
                                <set-body>{
                                    "error_code": "InternalServerError",
                                    "error_message": "Internal server error occurred, Please try again later.",
                                    "details": {
                                        "source": "Token Exchange"
                                    }
                                }</set-body>
                            </return-response>
                        </when>
                    </choose>
  • For the two protected APIs behind API Management, you can enable client_credentials flow for API authorization (not authorization code flow with PKCE). The API Management policy can just get access token of a backend API, similarly as in this article.

  • dcpavelescu's avatar
    dcpavelescu
    Copper Contributor

    Hello guys, thanks for the article. Maybe you can help me with a suggestion: wondering which is the alternative to this architecture if we are in the following situation: SPA client, 2 APIs protected by 2 IdPs (OpenId Connect), authorization code flow with PCKY. Which is the solution to authenticate users of the SPA client once and to authorize twice (get tokens from 2 separate servers)?

    Many thanks