Last active
August 29, 2024 14:53
-
-
Save JustinGrote/2b7ff3c08b38ba459f9079e456e49563 to your computer and use it in GitHub Desktop.
A Managed Identity Emulator for testing Managed Identities locally. Returns a token from your currently logged in Azure PowerShell context
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#requires -Module Az.Accounts | |
$verbosePreference = 'continue' | |
function ConvertFrom-JWTtoken { | |
<# | |
.NOTES | |
Lovingly borrowed from: https://www.michev.info/blog/post/2140/decode-jwt-access-and-id-tokens-via-powershell | |
#> | |
[cmdletbinding()] | |
param([Parameter(Mandatory = $true)][string]$token) | |
#Validate as per https://tools.ietf.org/html/rfc7519 | |
#Access and ID tokens are fine, Refresh tokens will not work | |
if (!$token.Contains('.') -or !$token.StartsWith('eyJ')) { Write-Error 'Invalid token' -ErrorAction Stop } | |
#Header | |
$tokenheader = $token.Split('.')[0].Replace('-', '+').Replace('_', '/') | |
#Fix padding as needed, keep adding "=" until string length modulus 4 reaches 0 | |
while ($tokenheader.Length % 4) { Write-Verbose 'Invalid length for a Base-64 char array or string, adding ='; $tokenheader += '=' } | |
Write-Verbose 'Base64 encoded (padded) header:' | |
Write-Verbose $tokenheader | |
#Convert from Base64 encoded string to PSObject all at once | |
Write-Verbose 'Decoded header:' | |
[System.Text.Encoding]::ASCII.GetString([system.convert]::FromBase64String($tokenheader)) | ConvertFrom-Json | Format-List | Out-Default | |
#Payload | |
$tokenPayload = $token.Split('.')[1].Replace('-', '+').Replace('_', '/') | |
#Fix padding as needed, keep adding "=" until string length modulus 4 reaches 0 | |
while ($tokenPayload.Length % 4) { Write-Verbose 'Invalid length for a Base-64 char array or string, adding ='; $tokenPayload += '=' } | |
Write-Verbose 'Base64 encoded (padded) payoad:' | |
Write-Verbose $tokenPayload | |
#Convert to Byte array | |
$tokenByteArray = [System.Convert]::FromBase64String($tokenPayload) | |
#Convert to string array | |
$tokenArray = [System.Text.Encoding]::ASCII.GetString($tokenByteArray) | |
Write-Verbose 'Decoded array in JSON format:' | |
Write-Verbose $tokenArray | |
#Convert from JSON to PSObject | |
$tokobj = $tokenArray | ConvertFrom-Json | |
Write-Verbose 'Decoded Payload:' | |
return $tokobj | |
} | |
function Start-Listener ([int]$Port = 42069, $ListenIP = 'localhost', $Scope = 'User') { | |
$path = '/oauth2/token' | |
$listenEndpoint = "http://${ListenIP}:${Port}" | |
$msiEndpoint = "${listenEndpoint}${path}" | |
$ENV:IDENTITY_ENDPOINT = $msiEndpoint | |
$ENV:IDENTITY_HEADER = (New-Guid) | |
$ENV:MSI_ENDPOINT = $ENV:IDENTITY_ENDPOINT | |
$ENV:MSI_SECRET = $ENV:IDENTITY_HEADER | |
Write-Host -f green "Setting up $Scope MSI environment variables" | |
[Environment]::SetEnvironmentVariable('IDENTITY_ENDPOINT', $ENV:IDENTITY_ENDPOINT, $Scope) | |
[Environment]::SetEnvironmentVariable('IDENTITY_HEADER', $ENV:IDENTITY_HEADER, $Scope) | |
[Environment]::SetEnvironmentVariable('MSI_ENDPOINT', $ENV:MSI_ENDPOINT, $Scope) | |
[Environment]::SetEnvironmentVariable('MSI_SECRET', $ENV:MSI_SECRET, $Scope) | |
Write-Host -f green "Completed $Scope MSI environment variables" | |
Write-Host -f green "Starting IMDS token endpoint on $listenEndpoint" | |
$listener = [Net.HttpListener]::new() | |
$listener.prefixes.add("$listenEndpoint/") #Thanks Ben | |
$listener.start() | |
Write-Host -f green "Started IMDS token endpoint on $listenEndpoint" | |
$cache = [runtime.caching.memorycache]::new('tokens') | |
try { | |
while ($listener.islistening) { | |
$task = $listener.getcontextasync() | |
$taskid = $task.id | |
$startdate = Get-Date | |
while (-not $task.wait(1000)) { | |
Write-Progress -Id $taskid -Activity "Waiting for connection $taskid (CTRL-C to stop)" -Status "$([int]((Get-Date)-$startdate).totalseconds) seconds" | |
} | |
Write-Progress -Id $taskid -Activity "Waiting for connection $taskid" -Completed | |
$context = $task.result | |
$request = $context.request | |
$response = $context.response | |
Write-Host -f cyan "$($taskid): received request from $($request.remoteendpoint) by $($request.useragent) for $($request.url.absolutepath)" | |
if ($request.headers['Metadata'] -ne $true) { | |
Write-Warning "Metadata Header was not set to true. The Az PowerShell sometimes does this and it's technically incorrect. Value:$($request.headers['Metadata'])" | |
} | |
if ($request.url.absolutepath -ne $path) { | |
Write-Warning "Unknown Request Path $($request.url.absolutepath)" | |
$response.statuscode = 401 | |
$response.close() | |
continue | |
} | |
$request_returnid = $request.headers['x-ms-return-client-request-id'] | |
$request_requestid = $request.headers['x-ms-client-request-id'] | |
if ($request_returnid -eq $true) { | |
$response.headers['x-ms-client-request-id'] = $request_requestid | |
} | |
$request_resource = $request.querystring['resource'] | |
if (-not $request_resource) { | |
Write-Warning "No resource specified in request" | |
$response.statuscode = 400 | |
$response.close() | |
continue | |
} | |
$imds_token = $cache.get($request_resource) | |
if (-not $imds_token) { | |
Write-Verbose "$($taskid): getting token for resource $request_resource using Azure PowerShell" | |
$imds_token = Get-AzAccessToken -AsSecureString -ResourceUrl $request_resource -WarningAction SilentlyContinue | |
$imds_token_expireson = [datetimeoffset]$imds_token.ExpiresOn | |
$imds_token_cache_expireson = $imds_token_expireson.addminutes(-5) | |
$cache.add($request_resource, $imds_token, $imds_token_cache_expireson) | Out-Null | |
} | |
Write-Host -f cyan "$($taskid): got token for resource $request_resource expiring on $($imds_token.expireson) from Azure PowerShell" | |
$access_token = $imds_token.Token | ConvertFrom-SecureString -AsPlainText | |
$access_token_payload = ConvertFrom-JWTtoken -token $access_token -Verbose:$false | |
$response_obj = @{ | |
token_type = $imds_token.Type | |
resource = $access_token_payload.aud | |
not_before = [string]$access_token_payload.nbf | |
expires_on = [string]($access_token_payload.exp) | |
expires_in = [string]($access_token_payload.exp - $access_token_payload.iat) | |
refresh_token = '' | |
access_token = $access_token | |
} | |
$response_json = $response_obj | ConvertTo-Json -Compress | |
$response_bytes = [text.encoding]::utf8.getbytes($response_json) | |
$response.statuscode = 200 | |
$response.contenttype = 'application/json' | |
$response.contentlength64 = $response_bytes.length | |
Write-Host -f cyan "$($taskid): returning token for resource $($response_obj.resource)`n" | |
$response.outputstream.write($response_bytes, 0, $response_bytes.length) | |
$response.close() | |
} | |
} finally { | |
Write-Host -f green "Removing User MSI environment variables" | |
[Environment]::SetEnvironmentVariable('IDENTITY_ENDPOINT', $null, 'User') | |
[Environment]::SetEnvironmentVariable('IDENTITY_HEADER', $null, 'User') | |
[Environment]::SetEnvironmentVariable('MSI_ENDPOINT', $null, 'User') | |
[Environment]::SetEnvironmentVariable('MSI_SECRET', $null, 'User') | |
Write-Host -f green "Clearing cache of $($cache.getcount()) items" | |
$cache.dispose() | |
Write-Host -f green "Stopping IMDS token endpoint on $($listener.prefixes)" | |
$listener.stop() | |
Write-Host -f green "Stopped IMDS token endpoint on $($listener.prefixes)" | |
} | |
} | |
Start-Listener |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Original idea courtesy @maskati https://gist.github.com/maskati/e0d74330dcf15848b043825cf6b2f8b7