Minimal "Azure AD Workload identity federation"

The Azure Active Directory team recently released a new preview feature, called workload identity federation, which "allows you to access Azure AD-protected resources, without needing to manage secrets (for supported scenarios)". Not having secrets sounds cool, but how does it work?

I'm using the terms "Azure Active Directory", "Azure AD" and "AAD" synonymously in this article.

My colleague Arsen Vladimirsky published a great blog article with a 30-min video to describe more how it works in a production environment, using an external (non-AAD) Identity Provides (Auth0 in his sample). The article features many moving parts, Postman, the Azure portal, the Auth0 web site, showing that in production, a lot of things just happen 'in the back'.

I wanted to understand how the absolute minimal external setup would have to look like for this to work, explore along the way how workload identity federation works, and do some funky bash command line things.

What you will find in this article

  • A simple demo for Azure Active Directory Workload Identity Federation

  • Some simple OpenID Connect interactions

  • See how you can create JSON Web Tokens (JWT) in the command line in bash

  • See one (IMHO) good pattern how to create JSON structures in the command line

  • A bunch of REST calls against Azure APIs

Overview

In this demo, we will simulate to be our own IdP (identity provider), by generating our own JSON Web Tokens, which (hopefully) AzureAD would be accepting. So we need to become proficient using OpenSSL on the command line for cryptographic operations, just know enough about JWT (JSON Web Tokens) and JWS (JSON Web Signing) to be dangerous, use jq to manipulate JSON locally, and of course our all-time friend cURL to finally talk to Azure AD.

Concretely, we'll do this:

  1. Use OpenSSL to generate a plain RSA key. Of course, in today's world, X.509 certificates are the hotness, but maybe just a dumb RSA key will be enough 😏. This RSA key should be enough to pretend to be a proper identity provider, so we can generate tokens, which AAD is willing to accept.

  2. Spin up an 'Identity Provider' in Azure Blob Storage. Wait, what, an IdP in STORAGE??? Yes. We will obviously not run any code in storage, but we can upload just enough bits into blob storage, to convince AAD that there's a real token-issuing IDP running on https://whatever.blob.core.windows.net 🤯.

  3. Configure an existing application in Azure AD to allow sign-in via 'Workload Identity Federation'. We'll be adding a federatedIdentityCredential to the application's credential section, telling Azure AD that this application signs in with a bearer token from our external IdP.

  4. Pretend to be an IdP, and generate a minimal (and hopefully valid) self-issued JWT.

  5. Do a client-credential grant dance with AAD, exchanging our self-issued JWT against a real production token, which we can e.g. use to talk to the Azure Management API or some other service.

Follow step-by-step

The rest of this article interleaves various bash shell commands, which you can 1:1 put into practice yourself, by copy/pasting (and executing 🤓) them in your own environment.

Follow-along at your own risk...

You will see various places, where I define variables, such as storage_account="chgeuer". You will obviously need to tweak the values, to match your own environment.

The more complex commands are split across multiple lines, so please note the \ symbol at the end of the lines as indicator for continuation in the next line.

Packages needed

You will need a few packages installed, namely

  • OpenSSL, curl, sed, bash, iconv, which are usually pre-installed on your Unix/Linux system of choice.

  • jq, in order to manipulate JSON, which you can install using sudo apt-get install jq

  • For quickly peeking into JWT tokens, I'm calling into cmd.exe to launch a web browser on the Windows site, which is primarily for convenience, and isn't strictly necessary.

Azure setup

You need a few Azure resources existing:

  • A storage account, with a container with public access enabled, so we can upload some files

  • An Azure AD application, for which you know the client_id

  • We'll be using the Azure CLI az, for which you ideally are already signed-in, so I don't need to have credentials (like storage account keys etc.) visible in the commands below.

With all that, let's rock a bit...

Use OpenSSL to generate a plain RSA key

First, we have to create an RSA key, consisting of the key pair in the private_key_file. This is your simulated identity provider's most sensitive token-issuance credential.

#!/bin/bash

# Generate an RSA key
private_key_file="key.pem"
public_key_file="key.pub"

openssl genrsa -out "${private_key_file}" 2048

openssl rsa -in "${private_key_file}" -pubout > "${public_key_file}"

From now on, I'll be omitting the #!/bin/bash part from shell scripts... As these commands build on top of each other, you need to execute things in the same shell session, otherwise your variable values might be lost.

Pretend there's an 'Identity Provider' in Azure Blob Storage

The OIDC well-known configuration

What is an 'Identity Provider'? Usually, an IdP is a full-blown service, with user management, web site, and many moving parts. For our demo, we just need enough to convince Azure Active Directory that we're having an OpenID-Connect (OIDC) compliant IdP. For this discovery to happen, Azure AD expects to be able to download metadata about our IdP, from some well-known location, and this is the .well-known/openid-configuration file. We'll be hosting this file in Azure Blob Storage.

You might check the openid-connect-discovery-1_0 spec for further details...

We need to give our IdP a name ('https://chgeuer.blob.core.windows.net/public'), which will be used by AAD as a base URL to find the IdP's configuration in 'https://chgeuer.blob.core.windows.net/public/.well-known/openid-configuration':

{
  "issuer":         "https://chgeuer.blob.core.windows.net/public",
  "jwks_uri":       "https://chgeuer.blob.core.windows.net/public/jwks_uri/keys",
  "token_endpoint": "https://chgeuer.blob.core.windows.net/public",
  "id_token_signing_alg_values_supported": ["RS256"],
  "token_endpoint_auth_methods_supported": ["client_secret_post"],
  "response_modes_supported": ["form_post"],
  "response_types_supported": ["id_token"],
  "scopes_supported": ["openid"],
  "claims_supported": ["sub","iss","aud","exp","iat","name"]
}

In a real production IdP, this information is much more detailed. For example, check the AAD configuration for my own AAD tenant here.

The two very relevant parts of this are the issuer name, and the jwks_uri location, where Azure AD can find the cryptograhic key material to validate the authenticity of our self-issued tokens. So let's create and upload that file.

Again, as mentioned previously, please change variables like storage_account and container_name to reflect your own environment.

This long pipeline within the openid_config_json="$( echo '{..}' | jq ... | jq ... )" part of the script below is an incremental build-up of the JSON structure, in which I'm passing JSON-formatted strings along the pipeline, and each jq --arg x "..." '.foo=$x' step essentially tweaks one property at a time.

Check this little recipe here, if you like that.

After writing the JSON to a local file, we upload it to ".well-known/openid-configuration" location. Unfortunately, the az command has currently no piping contents to be uploaded via STDIN, so we use a local temporary file.

storage_account="chgeuer"
container_name="public"

issuer_path="https://${storage_account}.blob.core.windows.net/${container_name}"
jwks_keys="${issuer_path}/jwks_uri/keys"

openid_config_json="$( \
  echo '{"issuer":"","token_endpoint":"","jwks_uri":"","id_token_signing_alg_values_supported":["RS256"],"token_endpoint_auth_methods_supported":["client_secret_post"],"response_modes_supported":["form_post"],"response_types_supported":["id_token"],"scopes_supported":["openid"],"claims_supported":["sub","iss","aud","exp","iat","name"]}' | \
  jq --arg x "${issuer_path}"  '.issuer=$x'         | \
  jq --arg x "${issuer_path}"  '.token_endpoint=$x' | \
  jq --arg x "${jwks_keys}"    '.jwks_uri=$x'       | \
  jq -c -M "."                                      | \
  iconv --from-code=ascii --to-code=utf-8 )"

echo "${openid_config_json}" > openid-configuration.json

az storage blob upload                       \
   --account-name "${storage_account}"       \
   --container-name "${container_name}"      \
   --content-type "application/json"         \
   --file openid-configuration.json          \
   --name ".well-known/openid-configuration"

IdP key material

For Azure AD to validate our fake tokens, it needs to know our IdP's cryptographic identity, contained in the URL in the jwks_uri property of the metadata. This is a JSON file, containing a list of all cryptographic keys valid at the current point in time. For example, if you look at Azure AD itself, you can see that the keys array contains multiple X.509 certificates (and their literal public keys, extracted for convenience).

In our demo, we won't use a real production X.509 certificate, primarily because I don't know where to quickly get a cert which is allowed for the purpose of signing data (issuing tokens). Getting some cert from LetsEncrypt is cool for peer-entity authentication (TLS authN against your web site), but token issuance is a different story. So let's see if AAD lets us get away with just putting a self-cooked RSA key into the JWKS structure.

An RSA public key has two components, the exponent and the modulus. OpenSSL seems to usually select the integer value 2^16+1 (65537) as exponent, so this is hard-coded below. For the modulus, we extract it out of the key file, trim away some stuff, convert it to Base64.

You also need to define a key ID (key_id in the script). As you could see, a production IdP might have many valid keys, and in the JWT, the IdP can indicate which key it used for issuing a token. The JWKT kid property on the key is that key ID. We're just using key1 here as value.

After generating the JSON structure, we need to upload it to the location previously configured in your ".well-known/openid-configuration" file.

#
# Get the modulus out ('n' in jwks lingo)
#
modulus="$( openssl rsa -in "${private_key_file}" -modulus -pubout -noout | \
      sed 's/Modulus=//' | \
      xxd -r -p | \
      base64 --wrap=0 )"

## If the public exponent is 65537, it is "AQAB" base64-encoded
# You can run the command below to see the exponent:
#
# openssl rsa -in "${private_key_file}" -text -noout | grep publicExponent | sed 's/publicExponent: //'
#
exponent="AQAB"

key_id="key1"

jwks_keys_json="$( echo "{}"                         | \
  jq --arg x "RSA"             '.keys[0].kty=$x'     | \
  jq --arg x "${issuer_path}"  '.keys[0].issuer=$x'  | \
  jq --arg x "${key_id}"       '.keys[0].kid=$x'     | \
  jq --arg x "${exponent}"     '.keys[0].e=$x'       | \
  jq --arg x "${modulus}"      '.keys[0].n=$x'       | \
  iconv --from-code=ascii --to-code=utf-8 )"

echo "${jwks_keys_json}" | jq . > keys.json

az storage blob upload                       \
   --account-name "${storage_account}"       \
   --container-name "${container_name}"      \
   --content-type "application/json"         \
   --file keys.json                          \
   --name "jwks_uri/keys"

# az storage blob upload --account-name chgeuer --container-name public --content-type "application/json" --file openid-configuration.json --name ".well-known/openid-configuration"
# az storage blob upload --account-name chgeuer --container-name public --content-type "application/json" --file keys.json                 --name "jwks_uri/keys"

After this step, we have a key pair on our computer to issue tokens, and we uploaded the metadata to blob storage, to convince AAD that we're a live-operating IdP STS. This is how the jwks_uri JSON looks like (key value trimmed for brevity):

{
  "keys": [
    {
      "kty": "RSA",
      "issuer": "https://chgeuer.blob.core.windows.net/public",
      "kid": "key1",
      "e": "AQAB",
      "n": "ytazPuyVvkHY/ZwmFl+hdVuU//someVeryLongValueWithManyDigitsBecause2048bitWantToLiveSomewhere//...59LutVLQ=="
    }
  ]
}

Configure an existing application in Azure AD to allow sign-in via 'Workload Identity Federation'

We're now ready to add a federatedIdentityCredential to our existing application's credential section, telling Azure AD that this application signs in with a bearer token, issued by our external IdP.

This newly introduced federatedIdentityCredentials section in Azure AD consists of three important parts:

  • The issuer is the name (or base URL) of the IdP. In your case, that's our storage container, relative to where the ".well-known/openid-configuration" path will be resolved.

  • The audiences array lists acceptable URIs under which AAD might be known to the external IdP. Simply speaking, AAD needs to defend against an attacker taking a token which was issued to be used at a completely different service, and then just presented to AAD to obtain a token. The Azure AD docs suggest to use an audience value of "api://AzureADTokenExchange", so we're just using this here.

  • The subject' field is the name under which our application would be known to the external IdP, and which the external IdP will put as sub` claim into the JWT.

First, we query AAD (using az ad app show) to retrieve the object ID for our application, then we POST the plain REST request with the new federatedIdentityCredential (using the az rest call against the Graph API).

# federation
appId="2f40da7e-e023-4ae0-928e-cb906cd8ec49"
objId="$( az ad app show --id "${appId}" | jq -r ".objectId" )"
audience="api://AzureADTokenExchange"
subject="subject"

# https://docs.microsoft.com/en-us/azure/active-directory/develop/workload-identity-federation-create-trust

#
# Add the credential to the application
#
az rest \
  --method POST \
  --uri "https://graph.microsoft.com/beta/applications/${objId}/federatedIdentityCredentials" \
  --body "{\"name\":\"Testing\",\"issuer\":\"${issuer_path}\",\"subject\":\"${subject}\",\"description\":\"Testing\",\"audiences\":[\"${audience}\"]}"

# 
# Just read it again to see what was uploaded
#
az rest \
  --method GET \
  --uri "https://graph.microsoft.com/beta/applications/${objId}/federatedIdentityCredentials" \
  | jq '.'

Pretend to be an IdP, and generate a minimal and valid self-issued JWT

After all that Azure setup, we can have some local fun, massaging bits and bytes into cryptographically acceptable tokens. All the sed stuff in the create_base64_url bash function essentially replaces plus symbols with dashes, slashes with underscores, and trims away trailing equal signs, to minimize base64-encoded information, and make it possible to use it in URLs. Read the frickin spec if you really care...

We now want to create a JWT, pointing to our key1 in the header, use RSA with SHA256 and PKCS1.5 padding, to sign the payload, which claims that we are indeed the expected subject, and that the token is intended to be used by AAD (audience = "api://AzureADTokenExchange").

Given that we're now an IdP, we can choose how long our token is valid (when it expires), using the exp property in the JWT. We're using 10 minutes here.

# https://tools.ietf.org/html/rfc7515#appendix-C
# base64 --wrap=0
# openssl base64
#
# tr -d '\n' | tr '/+' '_-' | tr -d '=' 
# sed 's/\+/-/g'  | sed 's/\//_/g' sed 's/=//g'
# sed -E s%=+$%% | sed s%\+%-%g | sed -E s%/%_%g 
function create_base64_url {
    local base64text="$1"
    echo -n "${base64text}" | sed -E s%=+$%% | sed s%\+%-%g | sed -E s%/%_%g 
}

function json_to_base64 {
    local jsonText="$1"
    create_base64_url "$( echo -n "${jsonText}" | base64 --wrap=0 )"
}

# `jq -c -M` gives a condensed/Monochome(no ANSI codes) representation
header="$( echo "{}"                | \
  jq --arg x "JWT"        '.typ=$x' | \
  jq --arg x "RS256"      '.alg=$x' | \
  jq --arg x "${key_id}"  '.kid=$x' | \
  jq -c -M "."                      | \
  iconv --from-code=ascii --to-code=utf-8 )"

token_validity_duration="+10 minute"

payload="$( echo "{}" | \
  jq --arg x "${issuer_path}"                                    '.iss=$x'              | \
  jq --arg x "${audience}"                                       '.aud=$x'              | \
  jq --arg x "${subject}"                                        '.sub=$x'              | \
  jq --arg x "$( date +%s )"                                     '.iat=($x | fromjson)' | \
  jq --arg x "$( date --date="${token_validity_duration}" +%s )" '.exp=($x | fromjson)' | \
  jq -c -M "."                                                                          | \
  iconv --from-code=ascii --to-code=utf-8 )"

toBeSigned="$( echo -n "$( json_to_base64 "${header}" ).$( json_to_base64 "${payload}" )" | iconv --to-code=ascii )"

# RSASSA-PKCS1-v1_5 using SHA-256 
signature="$( echo -n "${toBeSigned}"                         | \
    openssl dgst -sha256 --binary -sign "${private_key_file}" | \
    base64 --wrap=0                                           | \
    sed    s%\+%-%g                                           | \
    sed -E s%/%_%g                                            | \
    sed -E s%=+$%% )"                             

self_issued_jwt="${toBeSigned}.${signature}"

cmd.exe /C "start $( echo "https://jwt.ms/#access_token=${self_issued_jwt}" )"

The last command contains two funny tricks:

  • On WSL (Windows Subsystem for Linux), cmd.exe /C "start $( echo "https://foo" )" launches the user's default web browser with the given URL.

  • The URL https://jwt.ms/#access_token=... points to Microsoft's pretty awesome jwt.ms page, which is a single-page application to display JWT tokens locally in your browser. The #access_token fragment identifier essentially handles the JWT payload to the client-side JavaScript, i.e. the token is not sent over the Internet to the server, which is kind-of cool.

Click here to see how this looks in practice... You can essentially see the JWT, and the decoded payload:

{
  "typ": "JWT",
  "alg": "RS256",
  "kid": "key1"
}.{
  "iss": "https://chgeuer.blob.core.windows.net/public",
  "aud": "api://AzureADTokenExchange",
  "sub": "subject",
  "iat": 1644446435,
  "exp": 1644447035
}.[Signature]

Do a client-credential grant dance with AAD, exchanging our self-issued JWT against a real production token

Our last and final step in the journey is to try, whether our self-issued JWT will finally be accepted by Azure AD, and can be used to retrieve a 'real' AAD token, which we can then use to talk to other applications. So we're asking AAD to exchange our self-issued JWT access token with an AAD-issued access token.

We'll be running a client-credentials grant call, a capability supported by AAD since a long time already. The new aspect, workload identity federation-related difference is that we now can indicate that we have a client_assertion (our self-issued token), and that the client_assertion_type is a jwt-bearer token.

In the example below, I ask 'my' own AAD tenant (chgeuerfte.onmicrosoft.com) to issue me a token, so I can talk to the Azure ARM API:

resource="https://management.azure.com/.default"
aadTenant="chgeuerfte.onmicrosoft.com"

token_response="$( curl \
    --silent \
    --request POST \
    --url "https://login.microsoftonline.com/${aadTenant}/oauth2/v2.0/token" \
    --data-urlencode "response_type=token" \
    --data-urlencode "grant_type=client_credentials" \
    --data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
    --data-urlencode "client_id=${appId}" \
    --data-urlencode "client_assertion=${self_issued_jwt}" \
    --data-urlencode "scope=${resource}" \
    )"

echo "${token_response}" | jq .

access_token="$( echo "${token_response}" | jq -r ".access_token" )"

cmd.exe /C "start $( echo "https://jwt.ms/#access_token=${access_token}" )"

The token which AAD now gives back contains much more information, containing the resource we want to talk to (aud), the issuer being our AAD tenant, and the "appid" being our original application's client_id for which we enabled the workload identity federation.

{
  "typ": "JWT", "alg": "RS256",
  "x5t": "Mr5-AUibfBii7Nd1jBebaxboXW0",
  "kid": "Mr5-AUibfBii7Nd1jBebaxboXW0"
}.{
  "aud": "https://management.azure.com",
  "iss": "https://sts.windows.net/942023a6-efbe-4d97-a72d-532ef7337595/",
  "idp": "https://sts.windows.net/942023a6-efbe-4d97-a72d-532ef7337595/",
  "tid":                         "942023a6-efbe-4d97-a72d-532ef7337595",
  "iat": 1644446379,
  "nbf": 1644446379,
  "exp": 1644450279,
  "appidacr": "2",
  "idtyp": "app",
  "aio":   "...",
  "appid": "2f40da7e-e023-4ae0-928e-cb906cd8ec49",
  "oid":   "e03a9fa1-525e-4a7a-884f-d598fc0fae86",
  "sub":   "e03a9fa1-525e-4a7a-884f-d598fc0fae86",
  "rh": "...",
  "uti": "...",
  "ver": "1.0"
}.[Signature]

So you might compare and contrast these two tokens:

cmd.exe /C "start $( echo "https://jwt.ms/#access_token=${self_issued_jwt}" )"
cmd.exe /C "start $( echo "https://jwt.ms/#access_token=${access_token}" )"

Invoke some real service

Last, we can of course use our real access_token to invoke the service, like listing resource groups in an Azure subscription (assuming our app has the right to do so), or whatnot...

subscriptionId="724467b5-...."

curl --silent \
    --get \
    --url "https://management.azure.com/subscriptions/${subscriptionId}/resourcegroups" \
    --data-urlencode "api-version=2018-05-01" \
    --header "Authorization: Bearer ${access_token}" \
    | jq -r '.value[].name'

That's it. That's "running" an IdP in Blob Storage, hand-crafting and RSA-signing JSON web tokens in bash (don't do this in production, kids), and working with AAD, to play with the Workload Identity Federation preview feature...

If you like what you saw, feel free to ping me on Twitter (@chgeuer), or use the same alias @microsoft.com to shoot me an e-mail.

Last updated