A notebook to use the local MSAL cache to talk to Azure
Mix . install ( [
{ :jason , "~> 1.4" } ,
{ :jsonrs , "~> 0.3.3" } ,
{ :req , "~> 0.4.8" } ,
{ :jose , "~> 1.11" } ,
{ :jose_utils , "~> 0.4.0" } ,
{ :explorer , "~> 0.7.2" } ,
{ :kino , "~> 0.12.0" } ,
{ :kino_explorer , "~> 0.1.13" }
] )
import Explorer.DataFrame
alias Explorer.Series
defmodule Entra.ClientInfo do
@ type t :: % __MODULE__ { user_object_id: String . t ( ) , user_tenant_tid: String . t ( ) }
defstruct [ :user_object_id , :user_tenant_tid ]
@ doc ~S"""
Handle id
## Examples
iex> "eyJ1aWQiOiJlNjcyM2Y3NS0wMzMyLTRkZDgtYjMzNi05NmJmY2M4MTAwMDYiLCJ1dGlkIjoiNzJmOTg4YmYtODZmMS00MWFmLTkxYWItMmQ3Y2QwMTFkYjQ3In0"
...> |> Entra.ClientInfo.from_base64()
...> |> Entra.ClientInfo.to_base64()
...> |> Entra.ClientInfo.from_base64()
...> |> Entra.ClientInfo.to_home_account_id()
...> |> Entra.ClientInfo.from_home_account_id()
%Entra.ClientInfo{
user_object_id: "e6723f75-0332-4dd8-b336-96bfcc810006",
user_tenant_tid: "72f988bf-86f1-41af-91ab-2d7cd011db47"
}
iex> Entra.ClientInfo.new(
...> "f2691ff1-6e10-4969-a550-d25f99ab7c8e",
...> "a78648ba-0157-4003-be64-98bd2b3ec54a")
%Entra.ClientInfo{
user_object_id: "f2691ff1-6e10-4969-a550-d25f99ab7c8e",
user_tenant_tid: "a78648ba-0157-4003-be64-98bd2b3ec54a"
}
"""
def new ( user_object_id , user_tenant_tid ) do
% __MODULE__ { user_object_id: user_object_id , user_tenant_tid: user_tenant_tid }
end
@ doc ~S"""
Parse a client_info from base64-encodede client_info claim.
## Examples
iex> "eyJ1aWQiOiJmMjY5MWZmMS02ZTEwLTQ5NjktYTU1MC1kMjVmOTlhYjdjOGUiLCJ1dGlkIjoiYTc4NjQ4YmEtMDE1Ny00MDAzLWJlNjQtOThiZDJiM2VjNTRhIn0"
...> |> Entra.ClientInfo.from_base64()
...>
%Entra.ClientInfo{
user_object_id: "f2691ff1-6e10-4969-a550-d25f99ab7c8e",
user_tenant_tid: "a78648ba-0157-4003-be64-98bd2b3ec54a"
}
"""
def from_base64 ( client_info_claim ) when is_binary ( client_info_claim ) do
% { "uid" => user_object_id , "utid" => user_tenant_tid } =
client_info_claim
|> Base . decode64! ( padding: false )
|> Jsonrs . decode! ( )
% __MODULE__ { user_object_id: user_object_id , user_tenant_tid: user_tenant_tid }
end
@ doc ~S"""
Convert to base64-encoded client_info claim value.
## Examples
iex> Entra.ClientInfo.new(
...> "f2691ff1-6e10-4969-a550-d25f99ab7c8e",
...> "a78648ba-0157-4003-be64-98bd2b3ec54a")
...> |> Entra.ClientInfo.to_base64()
"eyJ1aWQiOiJmMjY5MWZmMS02ZTEwLTQ5NjktYTU1MC1kMjVmOTlhYjdjOGUiLCJ1dGlkIjoiYTc4NjQ4YmEtMDE1Ny00MDAzLWJlNjQtOThiZDJiM2VjNTRhIn0"
"""
def to_base64 ( % __MODULE__ { user_object_id: user_object_id , user_tenant_tid: user_tenant_tid } ) do
% { "uid" => user_object_id , "utid" => user_tenant_tid }
|> Jsonrs . encode! ( )
|> Base . encode64 ( padding: false )
end
def from_home_account_id ( home_account_id ) when is_binary ( home_account_id ) do
[ user_object_id , user_tenant_tid ] = home_account_id |> String . split ( "." )
% __MODULE__ { user_object_id: user_object_id , user_tenant_tid: user_tenant_tid }
end
@ doc ~S"""
Convert to base64-encoded client_info claim value.
## Examples
iex> Entra.ClientInfo.new(
...> "f2691ff1-6e10-4969-a550-d25f99ab7c8e",
...> "a78648ba-0157-4003-be64-98bd2b3ec54a")
...> |> Entra.ClientInfo.to_home_account_id()
"f2691ff1-6e10-4969-a550-d25f99ab7c8e.a78648ba-0157-4003-be64-98bd2b3ec54a"
"""
def to_home_account_id ( % __MODULE__ { } = ci ) do
"#{ ci . user_object_id } .#{ ci . user_tenant_tid } "
end
def to_routing_header ( % __MODULE__ {
user_object_id: user_object_id ,
user_tenant_tid: user_tenant_tid
} ) do
{ "X-AnchorMailbox" , "oid:#{ user_object_id } @#{ user_tenant_tid } " }
end
end
user = System . get_env ( "USERNAME" )
# [email protected]
user_id = "e6723f75-0332-4dd8-b336-96bfcc810006"
# microsoft.microsoftonline.com
tenant_id = "72f988bf-86f1-41af-91ab-2d7cd011db47"
laptop_ip = "192.168.1.29"
fiddler_port = 8888
fiddler_cert = "C:/Users/#{ user } /Desktop/FiddlerRoot.pem"
client_info = Entra.ClientInfo . new ( user_id , tenant_id )
defmodule Req.Fiddler do
def fiddler_req ( proxy_ip , proxy_port , proxy_cert ) do
mint_connect_options = [
proxy: { :http , proxy_ip , proxy_port , [ ] } ,
# https://hexdocs.pm/mint/Mint.HTTP.html#connect/4-transport-options
transport_opts: [
# openssl x509 -inform der -in FiddlerRoot.cer -out FiddlerRoot.pem
cacertfile: proxy_cert
]
]
Req . new ( connect_options: mint_connect_options )
end
end
proxy_req = Req.Fiddler . fiddler_req ( laptop_ip , fiddler_port , fiddler_cert )
req = Req . new ( )
defmodule MsalTokenCacheParser do
defstruct [ :access_tokens , :accounts , :id_tokens , :refresh_tokens , :app_metadata ]
defp decode_access_token (
{ key ,
% {
"cached_at" => cached_at ,
"expires_on" => expires_on ,
"client_id" => client_id ,
"credential_type" => "AccessToken" ,
"environment" => environment ,
"extended_expires_on" => extended_expires_on ,
"home_account_id" => home_account_id ,
"realm" => realm ,
"secret" => access_token ,
"target" => target
} }
) do
% {
key: key |> parse_key ( ) ,
cached_at: cached_at |> epoch_string_to_datetime ( ) ,
client_id: client_id ,
environment: environment ,
expires_on: expires_on |> epoch_string_to_datetime ( ) ,
extended_expires_on: extended_expires_on |> epoch_string_to_datetime ( ) ,
home_account_id: home_account_id ,
realm: realm ,
access_token: access_token ,
target: target
}
end
defp encode_access_token ( % {
key: % { key: key } ,
cached_at: cached_at ,
client_id: client_id ,
environment: environment ,
expires_on: expires_on ,
extended_expires_on: extended_expires_on ,
home_account_id: home_account_id ,
realm: realm ,
access_token: access_token ,
target: target
} ) do
{ key ,
% {
"cached_at" => cached_at |> datetime_to_epoch_string ( ) ,
"expires_on" => expires_on |> datetime_to_epoch_string ( ) ,
"client_id" => client_id ,
"credential_type" => "AccessToken" ,
"environment" => environment ,
"extended_expires_on" => extended_expires_on |> datetime_to_epoch_string ( ) ,
"home_account_id" => home_account_id ,
"realm" => realm ,
"secret" => access_token ,
"target" => target
} }
end
defp decode_refresh_token (
{ key ,
% {
"client_id" => client_id ,
"credential_type" => "RefreshToken" ,
"environment" => environment ,
"family_id" => family_id ,
"home_account_id" => home_account_id ,
"last_modification_time" => last_modification_time ,
"secret" => refresh_token ,
"target" => target
} }
) do
% {
key: key |> parse_key ( ) ,
client_id: client_id ,
environment: environment ,
family_id: family_id ,
home_account_id: home_account_id ,
last_modification_time: last_modification_time |> epoch_string_to_datetime ( ) ,
refresh_token: refresh_token ,
target: target
}
end
defp encode_refresh_token ( % {
key: % { key: key } ,
client_id: client_id ,
environment: environment ,
family_id: family_id ,
home_account_id: home_account_id ,
last_modification_time: last_modification_time ,
refresh_token: refresh_token ,
target: target
} ) do
{ key ,
% {
"client_id" => client_id ,
"credential_type" => "RefreshToken" ,
"environment" => environment ,
"family_id" => family_id ,
"home_account_id" => home_account_id ,
"last_modification_time" => last_modification_time |> datetime_to_epoch_string ( ) ,
"secret" => refresh_token ,
"target" => target
} }
end
defp decode_account (
{ key ,
% {
"home_account_id" => home_account_id ,
"environment" => environment ,
"realm" => realm ,
"local_account_id" => local_account_id ,
"username" => username ,
"authority_type" => authority_type
} }
) do
% {
key: key ,
home_account_id: home_account_id ,
environment: environment ,
realm: realm ,
local_account_id: local_account_id ,
username: username ,
authority_type: authority_type
}
end
defp encode_account ( % {
key: key ,
home_account_id: home_account_id ,
environment: environment ,
realm: realm ,
local_account_id: local_account_id ,
username: username ,
authority_type: authority_type
} ) do
{ key ,
% {
"home_account_id" => home_account_id ,
"environment" => environment ,
"realm" => realm ,
"local_account_id" => local_account_id ,
"username" => username ,
"authority_type" => authority_type
} }
end
defp decode_id_token (
{ key ,
% {
"credential_type" => "IdToken" ,
"secret" => id_token ,
"home_account_id" => home_account_id ,
"environment" => environment ,
"realm" => realm ,
"client_id" => client_id
} }
) do
% {
key: key |> parse_key ( ) ,
id_token: id_token ,
home_account_id: home_account_id ,
environment: environment ,
realm: realm ,
client_id: client_id
}
end
defp encode_id_token ( % {
key: % { key: key } ,
id_token: id_token ,
home_account_id: home_account_id ,
environment: environment ,
realm: realm ,
client_id: client_id
} ) do
{ key ,
% {
"credential_type" => "IdToken" ,
"secret" => id_token ,
"home_account_id" => home_account_id ,
"environment" => environment ,
"realm" => realm ,
"client_id" => client_id
} }
end
defp decode_app_metadata (
{ key ,
% {
"client_id" => client_id ,
"environment" => environment ,
"family_id" => family_id
} }
) do
% {
key: key ,
client_id: client_id ,
environment: environment ,
family_id: family_id
}
end
defp encode_app_metadata ( % {
key: key ,
client_id: client_id ,
environment: environment ,
family_id: family_id
} ) do
{ key ,
% {
"client_id" => client_id ,
"environment" => environment ,
"family_id" => family_id
} }
end
def decode_file ( % {
"AccessToken" => access_tokens ,
"Account" => accounts ,
"IdToken" => id_tokens ,
"RefreshToken" => refresh_tokens ,
"AppMetadata" => app_metadata
} ) do
% __MODULE__ {
access_tokens: access_tokens |> Enum . map ( & decode_access_token / 1 ) ,
accounts: accounts |> Enum . map ( & decode_account / 1 ) ,
id_tokens: id_tokens |> Enum . map ( & decode_id_token / 1 ) ,
refresh_tokens: refresh_tokens |> Enum . map ( & decode_refresh_token / 1 ) ,
app_metadata: app_metadata |> Enum . map ( & decode_app_metadata / 1 )
}
end
def encode_file ( % __MODULE__ {
access_tokens: access_tokens ,
accounts: accounts ,
id_tokens: id_tokens ,
refresh_tokens: refresh_tokens ,
app_metadata: app_metadata
} ) do
% {
"AccessToken" => access_tokens |> Map . new ( & encode_access_token / 1 ) ,
"Account" => accounts |> Map . new ( & encode_account / 1 ) ,
"IdToken" => id_tokens |> Map . new ( & encode_id_token / 1 ) ,
"RefreshToken" => refresh_tokens |> Map . new ( & encode_refresh_token / 1 ) ,
"AppMetadata" => app_metadata |> Map . new ( & encode_app_metadata / 1 )
}
end
defp epoch_string_to_datetime ( epoch_string ) when is_binary ( epoch_string ) do
with { epoch_int , "" } <- Integer . parse ( epoch_string , 10 ) ,
{ :ok , timestamp } <- DateTime . from_unix ( epoch_int , :second ) do
timestamp
else
s -> { :error , s }
end
end
defp datetime_to_epoch_string ( timestamp ) do
timestamp
|> DateTime . to_unix ( :second )
|> Integer . to_string ( )
end
@ key_regex ~r/ ^(?<oid>[0-9A-Fa-f]{8}[-]?([0-9A-Fa-f]{4}[-]?){3}[0-9A-Fa-f]{12})\. (?<tid>[0-9A-Fa-f]{8}[-]?([0-9A-Fa-f]{4}[-]?){3}[0-9A-Fa-f]{12})-(?<domain>[^-]+)-(?<type>[^-]+)-(?<app_id>[0-9A-Fa-f]{8}[-]?([0-9A-Fa-f]{4}[-]?){3}[0-9A-Fa-f]{12})-(?<realm>organizations|[0-9A-Fa-f]{8}[-]?([0-9A-Fa-f]{4}[-]?){3}[0-9A-Fa-f]{12})?-(?<scope>.*)$/
defp parse_key ( key ) do
% {
"app_id" => app_id ,
"domain" => domain ,
"oid" => oid ,
"realm" => realm ,
"scope" => scope ,
"tid" => tid ,
"type" => type
} = Regex . named_captures ( @ key_regex , key )
% {
key: key ,
app_id: app_id ,
domain: domain ,
oid: oid ,
realm: realm ,
scope: scope ,
tid: tid ,
type: type
}
end
def load_from_file! ( filename ) do
File . read! ( filename )
|> Jsonrs . decode! ( )
|> decode_file ( )
end
def write_to_file! ( % __MODULE__ { } = msal_contents , filename ) do
json =
msal_contents
|> MsalTokenCacheParser . encode_file ( )
|> Jsonrs . encode! ( pretty: true )
File . write! ( filename , json )
end
def remove_expired_access_tokens ( msal_contents ) ,
do: remove_expired_access_tokens ( msal_contents , DateTime . utc_now ( ) )
def remove_expired_access_tokens ( % __MODULE__ { access_tokens: tokens } = msal_contents , now ) do
still_valid_tokens =
tokens
|> Enum . filter ( fn token -> DateTime . compare ( now , token . expires_on ) == :lt end )
% __MODULE__ { msal_contents | access_tokens: still_valid_tokens }
end
# def remove_expired_id_tokens(msal_contents),
# do: remove_expired_id_tokens(msal_contents, DateTime.utc_now())
#
# def remove_expired_id_tokens(%__MODULE__{id_tokens: tokens} = msal_contents, now) do
# still_valid_tokens =
# tokens
# |> Enum.filter(fn token -> DateTime.compare(now, token.expires_on) == :lt end)
#
# %__MODULE__{msal_contents | id_tokens: still_valid_tokens}
# end
def get_refresh_token ( % __MODULE__ { } = msal_contents , % Entra.ClientInfo { } = client_info ) do
home_account_id = Entra.ClientInfo . to_home_account_id ( client_info )
matching_refresh_tokens =
msal_contents . refresh_tokens
|> Enum . filter ( fn % { home_account_id: id } -> home_account_id == id end )
case matching_refresh_tokens do
[ refresh_token ] -> { :ok , refresh_token }
[ ] -> :no_found
end
end
def update_refresh_token (
% MsalTokenCacheParser { refresh_tokens: refresh_tokens } = msal_contents ,
% { key: new_key } = new_refresh_token
) do
case refresh_tokens |> Enum . find_index ( fn % { key: old_key } -> old_key == new_key end ) do
nil ->
msal_contents
index ->
% {
msal_contents
| refresh_tokens: refresh_tokens |> List . replace_at ( index , new_refresh_token )
}
end
end
end
msal_contents =
[ System . get_env ( "USERPROFILE" ) , ".azure" , "msal_token_cache.json" ]
|> Enum . join ( "\\ " )
|> MsalTokenCacheParser . load_from_file! ( )
|> MsalTokenCacheParser . remove_expired_access_tokens ( )
# |> MsalTokenCacheParser.prune_access_tokens()
IO . puts (
"Read #{ length ( msal_contents . access_tokens ) } access tokens, #{ length ( msal_contents . refresh_tokens ) } refresh tokens, #{ length ( msal_contents . id_tokens ) } ID tokens"
)
msal_contents . id_tokens
|> Enum . map ( fn x -> x . id_token end )
|> Enum . map ( fn x -> JOSE.JWT . peek ( x ) end )
|> Enum . map ( fn % JOSE.JWT {
fields: % {
"sub" => sub ,
"tid" => tid ,
"aud" => aud ,
"exp" => exp ,
"preferred_username" => preferred_username
}
} ->
% {
exp: DateTime . to_iso8601 ( DateTime . from_unix! ( exp , :second ) ) ,
sub: sub ,
tid: tid ,
aud: aud ,
uname: preferred_username
}
end )
|> Explorer.DataFrame . new ( )
|> Kino.Explorer . new ( )
# Want exactly a single user
{ :ok , old_refresh_token } = MsalTokenCacheParser . get_refresh_token ( msal_contents , client_info )
% Req.Response {
status: 200 ,
body: % {
"access_token" => new_access_token ,
"client_info" => client_info ,
"expires_in" => expires_in ,
"ext_expires_in" => ext_expires_in ,
"foci" => foci ,
"id_token" => id_token ,
"refresh_token" => new_refresh_token ,
"scope" => scope ,
"token_type" => token_type
}
} =
proxy_req
|> Req . post! (
url: "https://login.microsoftonline.com/common/oauth2/v2.0/token" ,
form: [
grant_type: :refresh_token ,
client_info: 1 ,
# refresh_token.target,
scope: "https://management.core.windows.net//.default offline_access openid profile" ,
client_id: old_refresh_token . client_id ,
refresh_token: old_refresh_token . refresh_token
]
)
msal_contents =
msal_contents
|> MsalTokenCacheParser . update_refresh_token ( % {
old_refresh_token
| refresh_token: new_refresh_token ,
last_modification_time: DateTime . utc_now ( )
} )
MsalTokenCacheParser . write_to_file! (
msal_contents ,
"C:/Users/#{ user } /.azure/msal_token_cache.json"
)
access_stuff = % {
access_token: new_access_token ,
id_token: id_token ,
refresh_token: new_refresh_token ,
client_info: client_info |> Entra.ClientInfo . from_base64 ( ) ,
expires_in: DateTime . utc_now ( ) |> DateTime . add ( expires_in , :second ) ,
ext_expires_in: DateTime . utc_now ( ) |> DateTime . add ( ext_expires_in , :second ) ,
scope: scope ,
token_type: token_type
}
auth_req =
proxy_req
|> Req.Request . put_header ( "Authorization" , "Bearer #{ new_access_token } " )
subscriptions =
auth_req
|> Req . get! ( url: "https://management.azure.com/subscriptions?api-version=2023-07-01" )
|> ( fn response -> response . body end ) . ( )
|> ( fn subs -> subs [ "value" ] end ) . ( )
customer_tenant_id = "5f9e748d-300b-48f1-85f5-3aa96d6260cb"
customer_subscriptions =
subscriptions
|> Enum . filter ( fn
% { "tenantId" => ^ customer_tenant_id } -> true
_ -> false
end )
|> Enum . map ( fn % { "displayName" => name , "subscriptionId" => subscription_id } ->
% { name: name , subscription_id: subscription_id }
end )
customer_subscriptions
query_response =
auth_req
|> Req . post! (
url:
"https://management.azure.com/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" ,
json: % {
subscriptions: Enum . map ( customer_subscriptions , fn % { subscription_id: id } -> id end ) ,
query: "Resources"
}
)
# |> (fn response -> response.body end).()
# |> (fn body -> body["data"] end).()
defmodule Converter do
def convert! ( "true" ) , do: true
def convert! ( "false" ) , do: false
def convert! ( num ) , do: String . to_integer ( num )
end
% Req.Response {
status: 200 ,
headers: % {
"x-content-type-options" => x_content_type_options ,
"x-ms-ratelimit-remaining-tenant-reads" => x_ms_ratelimit_remaining_tenant_reads ,
"x-ms-ratelimit-remaining-tenant-resource-requests" =>
x_ms_ratelimit_remaining_tenant_resource_requests ,
"x-ms-user-quota-remaining" => x_ms_user_quota_remaining ,
"x-ms-user-quota-resets-after" => x_ms_user_quota_resets_after ,
"x-ms-resource-graph-request-duration" => x_ms_resource_graph_request_duration ,
"x-ms-request-id" => x_ms_request_id ,
"x-ms-correlation-request-id" => x_ms_correlation_request_id ,
"x-ms-routing-request-id" => x_ms_routing_request_id
} ,
body: % {
"data" => data ,
"count" => count ,
"resultTruncated" => resultTruncated ,
"totalRecords" => totalRecords
}
} = query_response
% {
headers: % {
x_content_type_options: x_content_type_options |> hd ( ) ,
x_ms_ratelimit_remaining_tenant_reads:
x_ms_ratelimit_remaining_tenant_reads |> hd ( ) |> Converter . convert! ( ) ,
x_ms_ratelimit_remaining_tenant_resource_requests:
x_ms_ratelimit_remaining_tenant_resource_requests |> hd ( ) |> Converter . convert! ( ) ,
x_ms_user_quota_remaining: x_ms_user_quota_remaining |> hd ( ) |> Converter . convert! ( ) ,
x_ms_user_quota_resets_after: x_ms_user_quota_resets_after |> hd ( ) ,
x_ms_resource_graph_request_duration: x_ms_resource_graph_request_duration |> hd ( ) ,
x_ms_request_id: x_ms_request_id |> hd ( ) ,
x_ms_correlation_request_id: x_ms_correlation_request_id |> hd ( ) ,
x_ms_routing_request_id: x_ms_routing_request_id |> hd ( )
} ,
data: data ,
count: count ,
resultTruncated: resultTruncated |> Converter . convert! ( ) ,
totalRecords: totalRecords
}