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 01
team may not want to share the ownership of the Azure AD App with the teamAPI 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 :
- grant_type : the value must be
urn:ietf:params:oauth:grant-type:jwt-bearer
- client_id : The ClientId of
APIM
App - client_secret : The ClientSecret generated from the
APIM
App - assertion : The
Access Token
from the original request (The ClientId of this token is theFRONTEND
one, and the audience is the ClientId ofAPIM
App) - scope : One of the scope defined in the underlying backend service (
API_01
orAPI_02
). Example :api://1234567a-7564-4b01-9cba-2f662835a791/Read.All
- 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 :
- The
<set-variable />
is used to get the current Assertion from theAuthorize
Header, and take only the Access Token without theBearer
prefix. - The
<send-request />
will build and send the request to Azure AD to request the OAuth2 token, and store the response in a variable namedbearerToken
- The second
<set-variable />
is used to read the response and store The Access Token in therequestResponseToken
- 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 :
- Retrieve the
unique_name
of the caller - Build the cache key to lookup the cache for an existing token
- 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.