Did you know that you can acquire an access token for an Azure user-assigned identity using PowerShell and an Azure Storage account? You can achieve this by hosting a minimal OpenID Provider and delegating trust via workload identity federation. This post will also help you better understand the technical details of how federated credentials work.
Note
This only gives you the access token. There might also be network access control or Entra conditional access for workload identities which limit calling resources with the token.
The process in brief:
- Create an RSA signing key
- Publish a minimal OpenID Provider Metadata Document as well as the public key of the previously created RSA key as a JSON Web Key Set on a public Azure Storage blob container (so that Entra ID can read the key material)
- Configure the user-assigned identity to trust a token issuer based on the previously published Azure Storage endpoint
- Create a JSON Web Token (JWT) to serve as a client assertion signed with the RSA key
- Exchange the JWT for an Entra ID access token using the client credentials flow, providing the JWT as a client assertion. Alternatively you can also login to Azure CLI using the JWT token and the Azure CLI will handle token exchange for you.
sequenceDiagram
autonumber
box darkgreen Local
actor Local as Local Script
participant RSA as Local RSA Key
end
participant Storage as Azure Storage (OIDC Issuer)
participant EID as Entra ID
participant Identity as Entra ID workload identity
participant Resource as Entra ID secured resource
Local->>RSA: Generate local RSA key
RSA->>Storage: Publish RSA public key (JWKS)
Local->>Storage: Publish OpenID metadata (.well-known) referencing public key
Local->>Identity: Configure federated credentials to trust Azure Storage (OIDC Issuer)
Local->>RSA: Generate JWT and sign with RSA key
rect navy
Local->>+EID: Exchange JWT for Entra ID access token (scope = Entra ID secured resource)
EID->>Identity: Look up identity (clientid and subject)
EID->>Storage: Fetch OpenID metadata (.well-known)
EID->>Storage: Fetch RSA public key (JWKS)
EID->>EID: Validate JWT signature & claims
EID-->>-Local: Return access token
end
Local->>Resource: Call Entra ID secured resource with access token
Load some helper functions:
ConvertTo-Base64Url
to convert a byte array or string to Base64 URL format.ConvertTo-Jwk
to convert an RSA key to a JSON Web Key. The JWK includes a key id calculated as the hash of the key parameters which is used to look up the correct key based onkid
included in the JWT header.New-Jwt
to create a new JSON Web Token for specified parameters and signed with the provided RSA key. Entra ID requires that the JWT header include the correctkid
, and that the body includes claims for the issuance dateiat
and expiryexp
.Get-AccessToken
to exchange our JWT for an Entra ID access token for the desired resource scope.
# convert byte array or string utf8 bytes to base64 url encoding
function ConvertTo-Base64Url($item){
$bytes=if($item -is [byte[]]){$item}elseif($item -is [string]){[text.encoding]::utf8.getbytes($item)}else{throw}
return [convert]::tobase64string($bytes).replace('+','-').replace('/','_').trimend('=')
}
# convert rsa key to json web key
function ConvertTo-Jwk([security.cryptography.rsa]$rsa){
$rsa_param = $rsa.exportparameters($false)
$rsa_modulus = convertto-base64url $rsa_param.modulus
$rsa_exponent = convertto-base64url $rsa_param.exponent
$kid = convertto-base64url [security.cryptography.sha256]::hashdata([text.encoding]::utf8.getbytes(([ordered]@{kty=$rsa.signaturealgorithm;n=$rsa_modulus;e=$rsa_exponent}|convertto-json -c)))
$jwk = @{kty=$rsa.signaturealgorithm;use='sig';kid=$kid;n=$rsa_modulus;e=$rsa_exponent}
return $jwk
}
# generate json web token signed with the provided rsa key
function New-Jwt([security.cryptography.rsa]$rsa, [string]$iss, [string]$sub, [string]$aud, [int]$ttl){
# header
$jwk = convertto-jwk $rsa
$jwt_header = @{typ='JWT';alg='RS256';kid=$jwk.kid}
$jwt_header_json = $jwt_header|convertto-json -c
$jwt_header_json_b64url = convertto-base64url $jwt_header_json
# payload
$jwt_payload = @{iss=$iss;aud=$aud;sub=$sub;iat=[datetimeoffset]::utcnow.tounixtimeseconds();exp=[datetimeoffset]::utcnow.addminutes($ttl).tounixtimeseconds()}
$jwt_payload_json = $jwt_payload|convertto-json -c
$jwt_payload_json_b64url = convertto-base64url $jwt_payload_json
# signature
$jwt_sig_bytes = $rsa.signdata([text.encoding]::utf8.getbytes("$jwt_header_json_b64url.$jwt_payload_json_b64url"),[security.cryptography.hashalgorithmname]::sha256,[security.cryptography.rsasignaturepadding]::pkcs1)
$jwt_sig_b64url = convertto-base64url $jwt_sig_bytes
# return composed header+payload+signature
return "$jwt_header_json_b64url.$jwt_payload_json_b64url.$jwt_sig_b64url"
}
# get entra id access token for specified scope using federated token assertion
function Get-AccessToken([string]$scope,[string]$tenantid,[string]$clientid,[string]$federatedtoken){
$body = (@{grant_type='client_credentials';client_id=$clientid;scope=$scope;client_assertion_type='urn:ietf:params:oauth:client-assertion-type:jwt-bearer';client_assertion=$federatedtoken}.getenumerator()|%{"$([web.httputility]::urlencode($_.key))=$([web.httputility]::urlencode($_.value))"}) -join '&'
$token = irm -me post -ur "https://login.microsoftonline.com/$tenantid/oauth2/v2.0/token" -cont 'application/x-www-form-urlencoded' -bo $body|select -exp access_token|convertto-securestring -a -f
return $token
}
Create an RSA key to sign tokens and map the public key to a JSON Web Key (JWK).
$rsa = [security.cryptography.rsa]::create(2048)
write-host -f yellow "Generated $($rsa.signaturealgorithm) key of size $($rsa.keysize)"
$jwk = convertto-jwk $rsa
write-host -f yellow "Generated JWK of type $($jwk.kty) with key id $($jwk.kid)"
Create an Azure Storage account with a publicly accessible blob container as a place to publicly host the OpenID provider (IDP) discovery document and JWKS.
$rg = az group create -l westeurope -n "rg-$([datetimeoffset]::utcnow.tostring('yyyyMMddTHHmmss'))"|convertfrom-json
write-host -f yellow "Created resource group $($rg.name)"
$st = az storage account create -g $rg.name -n "st$([guid]::newguid().guid.substring(0,8))" -l $rg.location --sku Standard_LRS --allow-blob-public-access|convertfrom-json
write-host -f yellow "Created storage account $($st.name)"
$co = az storage container-rm create --storage-account $st.name -n idp --public-access blob|convertfrom-json
write-host -f yellow "Created storage account publicly accessible container $($co.name)"
Define the IDP issuer URL based on the blob endpoint and container. The issuer must match the base prefix for where the IDP discovery document is hosted e.g. https://<storageaccountname>.blob.core.windows.net/idp
.
$issuer = "$($st.primaryEndpoints.blob)$($co.name)"
write-host -f yellow "Storage public endpoint and IDP authority is $issuer"
Upload a minimal OpenID discovery document identifying the issuer and where to find the JWKS (under the same path). The jwks_uri
must be an absolute URI. OpenID discovery documents are always hosted at the well known path .well-known/openid-configuration
.
@{issuer=$issuer;jwks_uri="$issuer/jwks"}|convertto-json -c|az storage blob upload --account-name $st.name -c $co.name -n '.well-known/openid-configuration' --content-type 'application/json' --data '@-' --only-show-errors --no-progress|convertfrom-json|out-null
write-host -f yellow "Published OIDC discovery document at $issuer/.well-known/openid-configuration"
Upload the JWKS containing a single JWK for our RSA signing key in the location specified by jwks_uri
. Entra ID will use this key to validate our JWT token signature.
@{keys=@($jwk)}|convertto-json -c|az storage blob upload --account-name $st.name -c $co.name -n 'jwks' --content-type 'application/json' --data '@-' --only-show-errors --no-progress|convertfrom-json|out-null
write-host -f yellow "Published JWKS at $issuer/jwks"
Define the default audience for workload identity federation api://AzureADTokenExchange
as well as a random subject. The subject in the JWT must match the subject defined in the federated credential (unless using flexible federated credentials).
$aud = 'api://AzureADTokenExchange'
$sub = [guid]::newguid().tostring()
Create a test user-assigned managed identity and then create a federated credential configuration to trust our storage account based IDP authority and the random subject.
$id = az identity create -l $rg.location -g $rg.name -n "id-userassignedidentity"|convertfrom-json
write-host -f yellow "Created user assigned identity $($id.name) with client id $($id.clientid) and principal id $($id.principalid)"
$fc = az identity federated-credential create -g $rg.name --identity-name $id.name -n $sub --audiences $aud --issuer $issuer --subject $sub|convertfrom-json
write-host -f yellow "Created federated credential for subject $($fc.subject) from issuer $($fc.issuer)"
Create test role assignment for the identity and allow it to read the resource group resources. This will allow us to validate authentication and authorization in a later step.
$ra = az role assignment create --role Reader --scope $rg.id --assignee-principal-type ServicePrincipal --assignee-object-id $id.principalid --description "Allow user assigned managed identity '$($id.name)' to read resource group '$($rg.name)'" --only-show-errors|convertfrom-json
write-host -f yellow "Created role assignment giving principal $($ra.principalid) role $($ra.roledefinitionname) on scope $($ra.scope)"
Generate a JWT to use as a federated token assertion. We define a token lifetime of 15 minutes. Entra ID supports external federated tokens with lifetimes up to 1 day and 1 minute (else you will get an error AADSTS70023: External OIDC Provider token must have a lifetime of less than or equal to 1.01:00:00.
).
$jwt = new-jwt -rsa $rsa -iss $issuer -sub $sub -aud $aud -ttl 15
write-host -f yellow "Created JWT token for identity federation. You can verify the JWT at:`nhttps://jwt.io/#debugger-io?token=$jwt&publicKey=$([uri]::escapedatastring($rsa.exportrsapublickeypem()))&"
You can verify the JWT contents and that the JWT is correctly signed using the link included in the host output. The page will show Signature Verified to indicate that the JWT is correctly signed by the provided RSA public key.
We can then use the Entra ID token endpoint to exchange the JWT for an Entra ID access token for our user-assigned managed identity. We can then call Microsoft Graph (scope https://graph.microsoft.com/.default
) as the user-assigned managed identity to introspect details about the service principal backing our identity.
write-host -f yellow "Get user assigned identity details from Microsoft Graph as the user assigned identity..."
irm -au bearer -to (get-accesstoken -scope 'https://graph.microsoft.com/.default' -tenantid $id.tenantid -clientid $id.clientid -federatedtoken $jwt) -ur "https://graph.microsoft.com/v1.0/servicePrincipals/$($id.principalid)"|select id,displayname,serviceprincipalnames,serviceprincipaltype,keycredentials
Or we can call ARM (scope https://management.azure.com/.default
) to get a list of resources in the test resource group (which we authorized with reader role earlier).
write-host -f yellow "Getting resource group contents as the user assigned identity..."
irm -au bearer -to (get-accesstoken -scope 'https://management.azure.com/.default' -tenantid $id.tenantid -clientid $id.clientid -federatedtoken $jwt) -ur "https://management.azure.com$($rg.id)/resources?api-version=2021-04-01"|select -exp value|select type, name
Or we can login to Azure CLI directly using the JWT token and Azure CLI will perform the necessary token exchange for us. You will be able to perform Azure CLI ARM operations according to the permissions of the user-assigned managed identity.
Note
This will replace your current Azure CLI login context. You will need to revert back to your original account to continue using Azure CLI under your own credentials.
write-host -f yellow "Logging into Azure CLI as the user assigned identity..."
az login --service-principal --tenant $id.tenantid --username $id.clientid --federated-token $jwt
az resource list -g $rg.name|convertfrom-json|select type,name
Warning
Anyone with access to the RSA private key (within the $rsa
variable in your PowerShell session) as well as knowledge of the federated credential issuer ($issuer
) and subject ($sub
) will be able to issue tokens trusted by your federated credential and allow authenticating as the associated user-assigned managed identity for as long as the identity, federated credential and storage account published discovery and JWKS documents exist.
Finally you might want to clean up:
- Logout of Azure CLI as the user-assigned identity principal
- Delete the created resource group including user-assigned identity and storage account