Azure AD and the Un-validatable Access Token
On any project with a backend API, you probably want to secure access to that API so that only authenticated users can call it. If you’re wanting to authenticate users that already exist in an Azure Active Directory tenant, then an Azure AD App Registration will probably be involved.
This post aims to explain a major gotchya with using an Azure AD App Registration for securing access to an API, and assumes you’re reasonably familiar with modern authentication using OAuth and OIDC. In particular, the idea of acquiring an access token for a particular api (or “audience” in OAuth terms).
Inspired by this SO post and a related Github issue
tl;dr
To get the Microsoft Identity Platform to issue access tokens you can pass to your api, you need to set up a custom scope in the App Registration’s Expose an API tab, and request that scope when you acquire the tokens. If you don’t, you will get access tokens that are intended to be presented to the Microsoft Graph API and cannot be validated by vanilla JWT middleware.
… continued …
If you’re reasonably familiar with OAuth and OIDC, you might think that it’s straightforward to;
- set up an App Registration in Azure AD,
- implement a login flow using your chosen authentication flow using the App Registration’s client id,
- request an access token via the
/token
endpoint, and lastly, - present the access token to your api (usually as an
Authorization: Bearer {access_token}
header)
Your API will then validate the token, ensure the subject represented by the token is allowed to access the desired resource, and execute the API call.
Gotchya
…And your API won’t be able to validate the token.
The App Registration blade in the Azure Portal has a tab called Expose an API.
When I first encountered this issue, I didn’t set up any scopes for my API because I thought;
“This only lets me set up scopes - my API doesn’t need any scopes, I only want the verified subject details of a logged in user, which I can get from an access token”
So what do you actually get?
If you go through a login flow and acquire an id and access token with the App Registration set up like this, you’ll get an (decoded) id token that looks like something like this;
ID Token
Header
{
"typ": "JWT",
"alg": "RS256",
"kid": "HlC0R**snip**"
}
Payload
{
"aud": "{app-registration-client-id}",
"iss": "https://login.microsoftonline.com/{azure-ad-tenant-id}/v2.0",
"iat": 1582618197,
"nbf": 1582618197,
"exp": 1582622097,
"email": "anthony.attwood@****",
"nonce": "{nonce-value}",
"preferred_username": "anthony.attwood@***",
"sub": "{subject-id}",
"ver": "2.0",
"**lots-more-claims**": "**snip**"
}
Nothing particularly unexpected in the id token.
The access token on the other hand… When decoded it’ll look something like this;
Access Token
Header
{
"typ": "JWT",
"nonce": "ekY15jb**snip**",
"alg": "RS256",
"x5t": "HlC0R**snip**",
"kid": "HlC0R**snip**"
}
Payload
{
"aud": "00000003-0000-0000-c000-000000000000",
"iss": "https://sts.windows.net/{azure-ad-tenant-id}/",
"iat": 1582618197,
"nbf": 1582618197,
"exp": 1582622097,
"app_displayname": "{app-registration-name}",
"name": "Anthony Attwood",
"scp": "User.Read profile openid email",
"sub": "{different-sub-claim-to-the-id-token}",
"unique_name": "anthony.attwood@***",
"upn": "anthony.attwood@***",
"**lots-more-claims**": "**snip**"
}
Your API won’t be able to validate the access token, indeed, there’s a couple of things wrong with it if you intend to present it to your own api.
- The header has a
nonce
parameter. This breaks validation and is indeed what the SO post I mentioned at the start of the article is about. A regular JWT does not have anonce
parameter in the header. - The
aud
claim (“aud” = audience) is a weird looking GUID that’s mostly zero’s,00000003-0000-0000-c000-000000000000
. You’d probably expect it to be your App Registrations client id.
The funny looking aud
claim indicates that the access token is actually intended to be presented to the Microsoft Graph API, not your API. The Graph API knows how to validate that kind of token.
The missing piece of the puzzle
Remember the Expose an API tab where I didn’t set up any scopes? This is the missing piece of the puzzle.
Even if your API doesn’t need any custom scopes — remember, your API might only care about knowing that the token was issued to a known logged in user — you still need to set up a custom scope and request it in your /token
or /authorize
request.
Set up a custom scope in the Expose an API tab, and you’ll see your new scope in the list, which will always be prefixed with your Application ID URI
.
Now does it work?
Now when you acquire an access token, you request your special scope, and you’ll get back an access token in a different format that you can safely pass to your API.
For simplicity and for when I don’t have a UI hooked up to do an interactive flow, I use an implicit flow to get tokens. So using a call to the /token
,
GET https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize /
?scope=api://thisismyappid/SuperSpecialScope profile email openid /
&response_type=id_token token /
&client_id={client-id} /
&redirect_uri={some-imaginary-but-valid-redirect-uri} /
&nonce={anything} /
&state={anything}
Notice the scopes I’m requesting include the scope I just set up in the Expose an API tab.
You get back an id token that still looks like it did before, but the access token looks different.
Access Token
Header
{
"typ": "JWT",
"alg": "RS256",
"x5t": "HlC0RHlC0R**snip**",
"kid": "HlC0RHlC0R**snip**"
}
Payload
{
"aud": "api://thisismyappid",
"iss": "https://sts.windows.net/{azure-ad-tenant-id}/",
"iat": 1582620705,
"nbf": 1582620705,
"exp": 1582624605,
"appid": "{client-id}",
"name": "Anthony Attwood",
"scp": "SuperSpecialScope",
"sub": "{same-sub-as-the-id-token}",
"unique_name": "anthony.attwood@***",
"upn": "anthony.attwood@***",
"**lots-more-claims**": "**snip**"
}
A few things to notice about this new format of access token include;
- There’s no
nonce
parameter in the header - The audience
aud
claim is now what we’ve configured in the App Registration as the App Id URI, not the special GUID that represents the Graph API
This token will validate correctly using the JWKS signing material we can discover from the OpenIDConnect discovery document. We can get that by appending /.well-known/openid-configuration
to the value of the issuer iss
claim, for example, https://sts.windows.net/{azure-ad-tenant-id}/.well-known/openid-configuration
. Your JWT validation middleware probably does this for you automatically based on the value of the iss
claim.
The solution
By adding a custom claim to the App Registration’s Expose an API tab and requesting that scope when we acquire tokens, the Microsoft Identity Platform will now generate access tokens that we can present to our API, and that will validate correctly by any compliant JWT middleware.
Happy authenticating!