OAuth : App development working with Microsoft Entra ID (Azure AD)
- Build Standard Web or Native Application
- Build JavaScript Application
- Build Backend Application (Daemon app, Service app)
- How to Verify Token
- Build Your Custom API Application
- Workload Identity Federation
So far (in my series of previous posts), we used token for calling API, such as Microsoft Graph, and so on in Microsoft Identity Platform v2.0 endpoint (formerly, Azure AD v2.0 endpoint).
However,
- How to check whether the token is valid ?
- How to get the logged-in user’s claims ?
In this post, I show you how to verify whether the user has logged-in correctly or not, with claim handling.
When you’re using SDKs or libraries (such as Microsoft Authentication Library (MSAL), OWIN, and so on), these are all done by these libraries and you don’t need to know details to build your application. However, it’s important to understand this mechanism for building secure applications for all developers who works with Microsoft Identity Platform.
Token format in Entra ID
Entra ID returns the following token format.
id token | access token | |
---|---|---|
organization account (Entra ID) | JWT | JWT |
consumer account (MSA) | JWT | Compact Tickets |
In this post, I’ll dive into the details about JWT (Json Web Token) later.
The compact tickets is a specific format for only Microsoft consumer services, such as Outlook.com, OneDrive, so on and so forth. This token can be resolved by these Microsoft services and you cannot analyze this token in your own custom application. (Your application just uses this token for calling Microsoft services.)
For this reason, if you want to verify token in consumer account, you should use id token (JWT), instead of using access token.
Note : Also in the IETF OAuth standard specification (RFC 6749), access token can have different formats and structures for each services. (On the contrary, id token should be JWT format.)
For this reason, in the real production application, you should extract id token (by specifying “id_token+code
” or “id_token+token
” as response_type
) to verify whether the authentication is correctly succeeded.
OAuth JSON Web Token (JWT)
JWT (Json Web Token) is the format defined by IETF (Internet Engineering Task Force) as follows.
- JWT has 3 string tokens delimited by the dot (.) character.
- Each delimited tokens (each 3 tokens) consists of :
- Information about certificate :
e.g, the type of key, key id (X.509 Thumprint), and so on.
These values doesn’t change for each authentication. (Fixed values) - Claims :
e.g, user principal, user name, scope, tenant id, token expiration, and so on - Digital signature :
This is a byte code, not UTF string.
- Information about certificate :
- Each delimited tokens are the base64 URL encoded string (encoded by RFC 4686).
Note : As I mentioned in “OAuth flow in Entra ID“, client_assertion (which is used for requesting access token, instead client_secret) is also the same JWT format.
Base64 URL encoded format (RFC 4648 format) is the Base64 string replaced with : “+” to “-“, “/” to “_” and removed all “=” characters in termination.
Claims
When you retrieve information, such as user name and so on, you can use 2nd token in JWT (id token). Not needed to call Microsoft Graph API for retrieving these information.
The following is PHP sample code to dump claims in 2nd token.
When you push “Login !” button in Web page, the page is redirected for authentication. After the user logged-in, the page is redirected back to this page and parses id token (JWT). Here we ignore 1st token and 3rd token, and only 2nd token (claims) are dumped in the Web page.
<?php if(isset($_POST['id_token'])) {$token_arr = explode('.', $_POST['id_token']);$claims_enc = $token_arr[1];$claims_arr = json_decode(base64_url_decode($claims_enc), TRUE); } // Helper functions function base64_url_decode($arg) {$res = $arg;$res = str_replace('-', '+', $res);$res = str_replace('_', '/', $res);switch (strlen($res) % 4) { case 0:break; case 2:$res .= "==";break; case 3:$res .= "=";break; default:break;}$res = base64_decode($res);return $res; } ?><!DOCTYPE html><html><head> <meta charset="utf-8" /> <title>Test page</title></head><body> <button id="btnLog">Login !</button> <pre> <?php var_dump($claims_arr); ?> </pre> <script>(function () { document.getElementById('btnLog').onclick = function() {location.href = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?response_type=id_token+code&response_mode=form_post&client_id=7822587c-fed4-4dd3-8e68-165334eb7c92&scope=openid+https%3a%2f%2fgraph.microsoft.com%2fmail.read&redirect_uri=https%3A%2F%2Flocalhost%2Ftest.php&nonce=abcdef'; };}()); </script></body></html>
The output result is below. (Here I used a consumer account for authentication.) :
In short, the decoded string of 2nd token (in id_token
) is the following Json.
{ "ver": "2.0", "iss": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0", "aud": "7822587c-fed4-4dd3-8e68-165334eb7c92", "exp": 1457414781, "iat": 1457328381, "c_hash": "CF8iD7zXmjcGoJe_9ru9Hg", "nonce": "abcdef", "sub": "AAAAAAAAAAAAAAAAAAAAAI4mp8lGZNTP1pnIVwMXM70", "tid": "9188040d-6c67-4c5b-b112-36a304b66dad"}
Your application can use these values for checking validity, such as token expiration (exp
), application id (aud
) and so on. If there’s anything wrong, your application will notify to the user and stop to proceed.
For instance, your application can check whether the token is not expired. If your application wants to check whether the user is in the licensed tenant (organization) or not, your application will retrieve tenant id (tid
) and check if licensed or not. (The licensed tenant id would be stored in your own database for checking.) If your application wants to identify the user, your application can use sub
value.
The nonce
is used for security reason. This value is the same as one in the requested URL (https://login.microsoftonline.com/common/oauth2/v2.0/authorize?...&nonce=abcdef
). A malicious program might redirect on purpose to run your program. (This type of attacks is called reply attacks.) In order to prevent this kind of attacks, your program can retrieve nonce
in token and check whether the value is the same as one you requested.
These claim’s confirmation depends on your application’s requirements.
Note : The
sub
is a pairwise id by application and user. Therefore this value changes, when the application changes. (Even when the user is the same.) Hence, this value can be used for user’s identifier in the range of an application, and you cannot use this value across multiple applications.
As you can see, the claims don’t include the basic information, such as user name, e-mail address, and so on.
If you want to retrieve user name, you should add profile
in scope
as follows. If you want to retrieve e-mail address, you also add email
in scope
.
<?php if(isset($_POST['id_token'])) {$token_arr = explode('.', $_POST['id_token']);$claims_enc = $token_arr[1];$claims_arr = json_decode(base64_url_decode($claims_enc), TRUE); } // Helper functions function base64_url_decode($arg) {$res = $arg;$res = str_replace('-', '+', $res);$res = str_replace('_', '/', $res);switch (strlen($res) % 4) { case 0:break; case 2:$res .= "==";break; case 3:$res .= "=";break; default:break;}$res = base64_decode($res);return $res; } ?><!DOCTYPE html><html><head> <meta charset="utf-8" /> <title>Test page</title></head><body> <button id="btnLog">Login !</button> <pre> <?php var_dump($claims_arr); ?> </pre> <script>(function () { document.getElementById('btnLog').onclick = function() {location.href = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?response_type=id_token+code&response_mode=form_post&client_id=7822587c-fed4-4dd3-8e68-165334eb7c92&scope=openid+profile+https%3a%2f%2fgraph.microsoft.com%2fmail.read&redirect_uri=https%3A%2F%2Flocalhost%2Ftest.php&nonce=abcdef'; };}()); </script></body></html>
Following is the output claims. (The claims with bold fonts are added ones.) :
{ "ver": "2.0", "iss": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0", "aud": "7822587c-fed4-4dd3-8e68-165334eb7c92", "exp": 1457416091, "iat": 1457329691, "c_hash": "SJIA5xc5f4ryvCRYP2Lkew", "nonce": "abcdef", "name": "Tsuyoshi Matsuzaki", "preferred_username": "xxxxx@outlook.com", "sub": "AAAAAAAAAAAAAAAAAAAAAI4mp8lGZNTP1pnIVwMXM70", "tid": "9188040d-6c67-4c5b-b112-36a304b66dad"}
In this case, please be sure to securely communicate, because it includes personal information.
In my previous sample in “OAuth flow in Entra ID“, we just set code
in response_type
. The authorization code doesn’t include sensitive information and the value is passed using a query string in HTTP communication.
However, when you specify id_token
, the token shouldn’t be passed using a query string. (Since the query string is not protected by https and passed by a plain text without encryption. This information is also recorded in the server’s log.) In this case, please set response_mode=form_post
and pass using HTTP POST body.
Note (Dec 2019) : Now you can add optional claims (you can specify which claims you want) in both id token and access token.
See team’s blog post “Now available: Azure AD App registrations Token configuration (preview) simplifies management of optional claims“.
Verify Token
So far, we’re assuming that the token is correct and not tampered.
Now let’s consider what if some malicious user has changed this token. For example, if you are a developer, you can easily interrupt and change the token string with Fiddler or other developer tools. As a result, you might be able to login to the critical corporate applications with other user’s credential. (You might login using your boss’s credential in HR system, and can reach any sensitive data in your corporate.)
Lastly, I explain about the digital signature, which is 3rd token in JWT and works against this kind of attacks.
The digital signature is generated using a private key in Microsoft Identity Platform (Entra ID), and you can verify using a public key, which is paired with this private key. This private key is owned by Microsoft Identity Platform and everybody cannot touch this key. On the contrary, the public key can be extracted by all developers.
Moreover this digital signature is generated using {1st token in JWT}.{2nd token in JWT} string as a seed.
For instance, if you change the claims (2nd token) in id token, the digital signature should totally be changed. As I mentioned above, only Microsoft Identity Platform (Entra ID) can create this digital signature. (The malicious user cannot create this digital signature, because he doesn’t have a private key.)
Hence, all you have to do is to check whether this digital signature is valid using a public key. If the digital signature is valid, it means that the claims are generated in Microsoft Identity Platform and is not tampered.
Let’s see how to do that.
For retrieving a public key, first you should go to https://{issuer URL}/.well-known/openid-configuration
. (This is also a rule by standard specification, OpenID Connect Discovery specification.) Note that the issuer URL equals to the “iss
” value in the claim (2nd token).
In our case, this is the following URL.
GET https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0/.well-known/openid-configuration
The following is the returned response from above URL.
{ "authorization_endpoint": "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize", "token_endpoint": "https://login.microsoftonline.com/consumers/oauth2/v2.0/token", "token_endpoint_auth_methods_supported": ["client_secret_post","private_key_jwt" ], "jwks_uri": "https://login.microsoftonline.com/consumers/discovery/v2.0/keys", "response_modes_supported": ["query","fragment","form_post" ], "subject_types_supported": ["pairwise" ], "id_token_signing_alg_values_supported": ["RS256" ], "http_logout_supported": true, "response_types_supported": ["code","id_token","code id_token","id_token token" ], "scopes_supported": ["openid","profile","email","offline_access" ], . . .}
Next, you extract jwks_uri
(above) and get the content (Json) of this location. Then you can take a list of public keys.
Finally, you can find an appropriate key from a list by matching “kid
” (key id) attribute with that in 1st token in JWT. (See above picture for JWT format.)
I show you 1st token (decoded string) in JWT as follows for your reference.
{ "typ": "JWT", "nonce": "GgRVSHx-ajo9JYRhgG5smHJihl5RbVb36wTJeaXKk_Y", "alg": "RS256", "x5t": "BB8CeFVqyaGrGNuehJIiL4dfjzw", "kid": "BB8CeFVqyaGrGNuehJIiL4dfjzw"}
Once you found a public key, you can verify the signature (3rd token in JWT) with that key.
The following code is PHP sample for verifying the signature in id token.
Here we’re checking whether the signature ($sig) is valid for a seed {1st token}.{2nd token} using openssl_verify() function in PHP. ($pkey_txt is the retrieved public key.)
In the actual production application, it’s better to check the chain and issuer for certificates, too.
<?php $token_valid = 0; // 0:Invalid, 1:Valid if(isset($_POST['id_token'])) {// 1 create array from token separated by dot (.)$token_arr = explode('.', $_POST['id_token']);$headers_enc = $token_arr[0];$claims_enc = $token_arr[1];$sig_enc = $token_arr[2];// 2 base 64 url decoding$headers_arr = json_decode(base64_url_decode($headers_enc), TRUE);$claims_arr = json_decode(base64_url_decode($claims_enc), TRUE);$sig = base64_url_decode($sig_enc);// 3 get key list$keylist = file_get_contents('https://login.microsoftonline.com/consumers/discovery/v2.0/keys');$keylist_arr = json_decode($keylist, TRUE);foreach($keylist_arr['keys'] as $key => $value) { // 4 select one key if($value['kid'] == $headers_arr['kid']) {// 5 get public key from key info$cert_txt = '-----BEGIN CERTIFICATE-----' . "\n" . chunk_split($value['x5c'][0], 64) . '-----END CERTIFICATE-----';$cert_obj = openssl_x509_read($cert_txt);$pkey_obj = openssl_pkey_get_public($cert_obj);$pkey_arr = openssl_pkey_get_details($pkey_obj);$pkey_txt = $pkey_arr['key'];// 6 validate signature$token_valid = openssl_verify($headers_enc . '.' . $claims_enc, $sig, $pkey_txt, OPENSSL_ALGO_SHA256); }} } $result_txt = 'Token is Invalid (or not authenticated) ...'; if($token_valid == 1)$result_txt = 'Token is Valid !'; // Helper functions function base64_url_decode($arg) {$res = $arg;$res = str_replace('-', '+', $res);$res = str_replace('_', '/', $res);switch (strlen($res) % 4) { case 0:break; case 2:$res .= "==";break; case 3:$res .= "=";break; default:break;}$res = base64_decode($res);return $res; } ?><!DOCTYPE html><html><head> <meta charset="utf-8" /> <title>Test page</title></head><body> <button id="btnLog">Login !</button> <pre> <?php echo($result_txt); ?> </pre> <script>(function () { document.getElementById('btnLog').onclick = function() {location.href = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?response_type=id_token+code&response_mode=form_post&client_id=7822587c-fed4-4dd3-8e68-165334eb7c92&scope=openid+https%3a%2f%2fgraph.microsoft.com%2fmail.read&redirect_uri=https%3A%2F%2Flocalhost%2Ftest.php&nonce=abcdef'; };}()); </script></body></html>
Following is the output for this sample program.
Once you found that the signature is valid, now you can trust all claims (user info, tenant id, token expiration, …) in id token.
In this post, we don’t use any SDKs or libraries (such as MSAL, OWIN, and so on), but you can significantly accelerate your productivity with these libraries because this verification is internally done by libraries.
Update History :
Dec 04, 2019 Update App Registration Portal (https://apps.dev.microsoft.com) to Azure Portal, because of App Registration Portal deprecation
Dec 04, 2019 Converted into English
Categories: Uncategorized
I just want to say that after months of battling with this, your signature verification logic finally gave me something that i can work with. Thanks and excellent post!
LikeLike