You have a web API and you’d like to restrict who can access it. You decide to use OpenID Connect (OIDC) as your authorization/authentication framework, and Azure Active Directory (AAD) as the identity provider. You’ll need to:
- Set up the clients that call the web API to be able to acquire tokens from AAD and include the tokens in the headers of any requests to the web API.
- Validate the tokens included in the requests when they make it to the web API.
This tutorial shows how you can validate tokens issued by AAD in a Java application. It uses AAD as the identity provider that issues the tokens, but the steps should be similar for any other identity provider that is OIDC compliant.
The full sample is available at https://github.com/sangonzal/jwt-validation-java.
Overview
Validating an AAD token is made up of multiple steps:
- Get the OIDC configuration metadata from the OIDC configuration endpoint.
- Go to the JSON Web Key Set’s (JWKS) URI and retrieve the public keys to validate the signature on the token.
Step 1: Download OIDC metadata from the OIDC configuration endpoint
OIDC providers make metadata describing their configuration available on the discovery endpoint: https://OPENID_PROVIDER_DOMAIN/.well-known/openid-configuration
. This data contains the authorization endpoint, token endpoint, issuer, JWKS, among other things. For this tutorial, the only value we will need is the JWKS URI. The JWKS URI contains the public keys we will use to validate signatures on the access tokens. If you’re curious about what all the other fields in the metadata are, take a look at the OIDC Spec.
You can see what the OIDC Configuration data looks like by going to https://login.microsoftonline.com/common/.well-known/openid-configuration (you can also replace “common” with the value of your AAD tenant id):
{
"token_endpoint":"https://login.microsoftonline.com/common/oauth2/token",
"token_endpoint_auth_methods_supported":[
"client_secret_post",
"private_key_jwt",
"client_secret_basic"
],
"jwks_uri":"https://login.microsoftonline.com/common/discovery/keys",
"response_modes_supported":[
"query",
"fragment",
"form_post"
],
"subject_types_supported":[
"pairwise"
],
"id_token_signing_alg_values_supported":[
"RS256"
],
"response_types_supported":[
"code",
"id_token",
"code id_token",
"token id_token",
"token"
],
"scopes_supported":[
"openid"
],
"issuer":"https://sts.windows.net/{tenantid}/",
"microsoft_multi_refresh_token":true,
"authorization_endpoint":"https://login.microsoftonline.com/common/oauth2/authorize",
"http_logout_supported":true,
"frontchannel_logout_supported":true,
"end_session_endpoint":"https://login.microsoftonline.com/common/oauth2/logout",
"claims_supported":[
"sub",
"iss",
"cloud_instance_name",
"cloud_instance_host_name",
"cloud_graph_host_name",
"msgraph_host",
"aud",
"exp",
"iat",
"auth_time",
"acr",
"amr",
"nonce",
"email",
"given_name",
"family_name",
"nickname"
],
"check_session_iframe":"https://login.microsoftonline.com/common/oauth2/checksession",
"userinfo_endpoint":"https://login.microsoftonline.com/common/openid/userinfo",
"tenant_region_scope":null,
"cloud_instance_name":"microsoftonline.com",
"cloud_graph_host_name":"graph.windows.net",
"msgraph_host":"graph.microsoft.com",
"rbac_url":"https://pas.windows.net"
}
Step 2. Download the signing keys from the JWKS endpoint
AAD uses asymmetric key cryptography so that resources can trust that:
- the access token was issued by AAD.
- the access token was not tampered with.
AAD signs access tokens with a set of private keys, and makes the corresponding public keys available at the JWKS URI. If you open https://login.microsoftonline.com/common/discovery/keys
, which we got from step 1, you will see:
{
"keys":[
{
"kty":"RSA",
"use":"sig",
"kid":"YMELHT0gvb0mxoSDoYfomjqfjYU",
"x5t":"YMELHT0gvb0mxoSDoYfomjqfjYU",
"n":"ni9SAyu9EsltQlV7Jo3wMUvcpYb4mmfHzV4IsDZ6NQvJjtQJuhsfqiG86VntMd76R44kCmkfMGvtQRA2_UmnVBSSLxQKvcGUqNodH7YaMYOTmHlbOSoVpi3Ox2wj6cWvhaTTm_4xzJ3F0yF0Y_aRBMxSCIwLv3nTMRNe74k4zdBnhL7k5ObOY_vUGt_5-sPo6BXoV7oov4Ps6jeyUdRKtqVZSp5_kzz16kPh1Ng_2tn4vpQimNbHRralq8rNM_gOLPAar6v7mL_qsqpgx-48e5ENFxikbB-NzAmLll1QSkzciu2rCjFGH4j_-bCHr7FxUNDL_E0vMFVDFw8SUlYMgQ",
"e":"AQAB",
"x5c":[
"MIIDBTCCAe2gAwIBAgIQG4GFMDOjD7lKSdsgshqQ/DANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTIwMDIwNTAwMDAwMFoXDTI1MDIwNDAwMDAwMFowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJ4vUgMrvRLJbUJVeyaN8DFL3KWG+Jpnx81eCLA2ejULyY7UCbobH6ohvOlZ7THe+keOJAppHzBr7UEQNv1Jp1QUki8UCr3BlKjaHR+2GjGDk5h5WzkqFaYtzsdsI+nFr4Wk05v+McydxdMhdGP2kQTMUgiMC7950zETXu+JOM3QZ4S+5OTmzmP71Brf+frD6OgV6Fe6KL+D7Oo3slHUSralWUqef5M89epD4dTYP9rZ+L6UIpjWx0a2pavKzTP4DizwGq+r+5i/6rKqYMfuPHuRDRcYpGwfjcwJi5ZdUEpM3IrtqwoxRh+I//mwh6+xcVDQy/xNLzBVQxcPElJWDIECAwEAAaMhMB8wHQYDVR0OBBYEFHssLV3w8SFEdZk03/TJwDfWQ6mRMA0GCSqGSIb3DQEBCwUAA4IBAQAh9iGtY+wKAMrYYLCU8uRZnUY9f+s936HhZdnJfVCuJM7y3fIbzvPO0T0dMHLz++ba0rkptoe+HjZaNA7vVwzdEtAdNff0wFef470sb+kxPi64PZK/IhtqBEwEvy090ZwGsZqM/Ut9QxFH21/t/wcz0wUBc6QGGxgWr1T/Qfzlemnz5DxuHaKQdiafz6yrwGyVjmaRkjMqeqhQy3J0nNoJNbofopSnnGH0g5IWBJBJPBk7k8RaliY0i+GwTliCgiI59ZPt1dS1+EXfNS06v1+TjTe1tPHyGot03i+iIA3WJk3REgT14y7Rhl94htzmMFmrlGNioXlfLFx9fDJQkJfz"
]
},
{
"kty":"RSA",
"use":"sig",
"kid":"CtTuhMJmD5M7DLdzD2v2x3QKSRY",
"x5t":"CtTuhMJmD5M7DLdzD2v2x3QKSRY",
"n":"18uZ3P3IgOySlnOsxeIN5WUKzvlm6evPDMFbmXPtTF0GMe7tD2JPfai2UGn74s7AFwqxWO5DQZRu6VfQUux8uMR4J7nxm1Kf__7pVEVJJyDuL5a8PARRYQtH68w-0IZxcFOkgsSdhtIzPQ2jj4mmRzWXIwh8M_8pJ6qiOjvjF9bhEq0CC_f27BnljPaFn8hxY69pCoxenWWqFcsUhFZvCMthhRubAbBilDr74KaXS5xCgySBhPzwekD9_NdCUuCsdqavd4T-VWnbplbB8YsC-R00FptBFKuTyT9zoGZjWZilQVmj7v3k8jXqYB2nWKgTAfwjmiyKz78FHkaE-nCIDw",
"e":"AQAB",
"x5c":[
"MIIDBTCCAe2gAwIBAgIQXVogj9BAf49IpuOSIvztNDANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTIwMDMxNzAwMDAwMFoXDTI1MDMxNzAwMDAwMFowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANfLmdz9yIDskpZzrMXiDeVlCs75ZunrzwzBW5lz7UxdBjHu7Q9iT32otlBp++LOwBcKsVjuQ0GUbulX0FLsfLjEeCe58ZtSn//+6VRFSScg7i+WvDwEUWELR+vMPtCGcXBTpILEnYbSMz0No4+Jpkc1lyMIfDP/KSeqojo74xfW4RKtAgv39uwZ5Yz2hZ/IcWOvaQqMXp1lqhXLFIRWbwjLYYUbmwGwYpQ6++Cml0ucQoMkgYT88HpA/fzXQlLgrHamr3eE/lVp26ZWwfGLAvkdNBabQRSrk8k/c6BmY1mYpUFZo+795PI16mAdp1ioEwH8I5osis+/BR5GhPpwiA8CAwEAAaMhMB8wHQYDVR0OBBYEFF8MDGklOGhGNVJvsHHRCaqtzexcMA0GCSqGSIb3DQEBCwUAA4IBAQCKkegw/mdpCVl1lOpgU4G9RT+1gtcPqZK9kpimuDggSJju6KUQlOCi5/lIH5DCzpjFdmG17TjWVBNve5kowmrhLzovY0Ykk7+6hYTBK8dNNSmd4SK7zY++0aDIuOzHP2Cur+kgFC0gez50tPzotLDtMmp40gknXuzltwJfezNSw3gLgljDsGGcDIXK3qLSYh44qSuRGwulcN2EJUZBI9tIxoODpaWHIN8+z2uZvf8JBYFjA3+n9FRQn51X16CTcjq4QRTbNVpgVuQuyaYnEtx0ZnDvguB3RjGSPIXTRBkLl2x7e8/6uAZ6tchw8rhcOtPsFgJuoJokGjvcUSR/6Eqd"
]
},
{
"kty":"RSA",
"use":"sig",
"kid":"M6pX7RHoraLsprfJeRCjSxuURhc",
"x5t":"M6pX7RHoraLsprfJeRCjSxuURhc",
"n":"xHScZMPo8FifoDcrgncWQ7mGJtiKhrsho0-uFPXg-OdnRKYudTD7-Bq1MDjcqWRf3IfDVjFJixQS61M7wm9wALDj--lLuJJ9jDUAWTA3xWvQLbiBM-gqU0sj4mc2lWm6nPfqlyYeWtQcSC0sYkLlayNgX4noKDaXivhVOp7bwGXq77MRzeL4-9qrRYKjuzHfZL7kNBCsqO185P0NI2Jtmw-EsqYsrCaHsfNRGRrTvUHUq3hWa859kK_5uNd7TeY2ZEwKVD8ezCmSfR59ZzyxTtuPpkCSHS9OtUvS3mqTYit73qcvprjl3R8hpjXLb8oftfpWr3hFRdpxrwuoQEO4QQ",
"e":"AQAB",
"x5c":[
"MIIC8TCCAdmgAwIBAgIQfEWlTVc1uINEc9RBi6qHMjANBgkqhkiG9w0BAQsFADAjMSEwHwYDVQQDExhsb2dpbi5taWNyb3NvZnRvbmxpbmUudXMwHhcNMTgxMDE0MDAwMDAwWhcNMjAxMDE0MDAwMDAwWjAjMSEwHwYDVQQDExhsb2dpbi5taWNyb3NvZnRvbmxpbmUudXMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDEdJxkw+jwWJ+gNyuCdxZDuYYm2IqGuyGjT64U9eD452dEpi51MPv4GrUwONypZF/ch8NWMUmLFBLrUzvCb3AAsOP76Uu4kn2MNQBZMDfFa9AtuIEz6CpTSyPiZzaVabqc9+qXJh5a1BxILSxiQuVrI2BfiegoNpeK+FU6ntvAZervsxHN4vj72qtFgqO7Md9kvuQ0EKyo7Xzk/Q0jYm2bD4SypiysJoex81EZGtO9QdSreFZrzn2Qr/m413tN5jZkTApUPx7MKZJ9Hn1nPLFO24+mQJIdL061S9LeapNiK3vepy+muOXdHyGmNctvyh+1+laveEVF2nGvC6hAQ7hBAgMBAAGjITAfMB0GA1UdDgQWBBQ5TKadw06O0cvXrQbXW0Nb3M3h/DANBgkqhkiG9w0BAQsFAAOCAQEAI48JaFtwOFcYS/3pfS5+7cINrafXAKTL+/+he4q+RMx4TCu/L1dl9zS5W1BeJNO2GUznfI+b5KndrxdlB6qJIDf6TRHh6EqfA18oJP5NOiKhU4pgkF2UMUw4kjxaZ5fQrSoD9omjfHAFNjradnHA7GOAoF4iotvXDWDBWx9K4XNZHWvD11Td66zTg5IaEQDIZ+f8WS6nn/98nAVMDtR9zW7Te5h9kGJGfe6WiHVaGRPpBvqC4iypGHjbRwANwofZvmp5wP08hY1CsnKY5tfP+E2k/iAQgKKa6QoxXToYvP7rsSkglak8N5g/+FJGnq4wP6cOzgZpjdPMwaVt5432GA=="
]
}
]
}
You can see that AAD uses RSA (kty=RSA) to sign (use=sig) their tokens. They provide a key id (kid), which will also be present in the access token. We will use the kid
to find the exact key from the set that was used to sign the token. Lastly, we see that they make available the RSA public modulus n
and the exponent e
, which we will use to build the public key later in the tutorial. For more detailed information about any of the claims, take a look at he JWK spec.
Step 3: Validate the signature and claims
Now that we have the public keys, we can finally proceed to validating the access token. We will use JJWT to do most of the heavy lifting for us. We will use the Jwts.parser
object to construct a JWT validator.
We pass the public key
and the access token jwsString
. AAd exposes 4 keys, so how do we know which key was used to sign the token that we have? The access token has a claim kid
, which is the key identifier that was used to sign the token. We will have to grab this claim from the token, and try to match it to one of the keys in the JWKS.
Having to find the right key from a set is common enough that JJWT provides an abstraction that makes finding the right signing key more efficient. We will extend SigningKeyResolverAdapter
and @Override
the implementation for resolveSigningKey()
.
resolveSigningKey()
expects a Java.Security.Key
as the return value, so we will have to build it from the data available in the JWKS. If you take a look at JWKS metadata that we grabbed from the JWKS URI, you’ll see two claims, e
and n
. These are the RSA public exponent and the RSA public modulus. We can use both of these values to build the PublicKey
that will be returned.
We have done most of the legwork and can finally get to what we have been trying to all along, which is to validate the signature and claims on the token. We use Jwts.parser()
, set the SignatureKeyResolver
which we have already defined, and add any claims we would like to validate. For example, we could ensure that the token was issued by the right identity provider by checking the issuer claim, and check that it is for the right API by checking the audience. The parser will check that the token is signed and that it is not expired by default, so we don’t need to explicitly add those checks.
We are finally done. We have validated a token and parsed the claims. You can hand off the request (and any of the claims that you parsed if you need them) to the business logic of your API.
Performance
Making two network calls every time we validate a token is inefficient. The OIDC configuration and JWKS do not change very often, so we should be able to cache this data. This is not shown in the sample in the interest of keeping it short, but it should be straight-forward - you can just keep this data in your preferred data store and reference it whenever needed.
Note that although the keys don’t get rolled over often, we have to assume that they could change at any moment. Frameworks like the ASP.NET core OIDC stack cache this data for 24 hours, and then fallback to fetching it again in the case that token validation fails for reasons that could be related to key rollover (for example, not being able to find a key with a matching kid
).