Skip to content

Instantly share code, notes, and snippets.

@chgeuer
Created December 18, 2023 21:30
Show Gist options
  • Save chgeuer/b59a8de8a5cbc1f448f81709f8b77c10 to your computer and use it in GitHub Desktop.
Save chgeuer/b59a8de8a5cbc1f448f81709f8b77c10 to your computer and use it in GitHub Desktop.

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"}
])

Variables

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")
# chgeuer@microsoft.com
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)

Hook up to Fiddler

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
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment