Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Support for authentication Service principal #56

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions lib/azurex/authorization/auth.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
defmodule Azurex.Authorization.Auth do
alias Azurex.Blob.Config
alias Azurex.Authorization.SharedKey
alias Azurex.Authorization.ServicePrincipal

def authorize_request(request, content_type \\ "") do
case Config.auth_method() do
{:account_key, storage_account_key} ->
SharedKey.sign(
request,
storage_account_name: Config.storage_account_name(),
storage_account_key: storage_account_key,
content_type: content_type
)

{:service_principal, client_id, client_secret, tenant} ->
ServicePrincipal.add_bearer_token(
request,
client_id,
client_secret,
tenant
)
|> SharedKey.put_standard_headers(content_type, DateTime.utc_now())
end
end
end
71 changes: 71 additions & 0 deletions lib/azurex/authorization/service_principal.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
defmodule Azurex.Authorization.ServicePrincipal do
@cache_key "azurex_bearer_token"
@cache_expiry_margin_seconds 10
def add_bearer_token(%HTTPoison.Request{} = request, client_id, client_secret, tenant_id) do
bearer_token = fetch_bearer_token_cached(client_id, client_secret, tenant_id)
authorization = {"Authorization", "Bearer #{bearer_token}"}

headers = [authorization | request.headers]
struct(request, headers: headers)
end

defp fetch_bearer_token_cached(client_id, client_secret, tenant_id) do
cache_key = @cache_key

:ets.info(:bearer_token_cache) != :undefined ||
:ets.new(:bearer_token_cache, [:named_table])

case :ets.lookup(:bearer_token_cache, cache_key) do
[{^cache_key, token, expiry}] ->
if expiry > System.os_time(:second) do
token
else
IO.inspect("Token expired, refreshing")
refresh_bearer_token_cache(client_id, client_secret, tenant_id)
end

_ ->
refresh_bearer_token_cache(client_id, client_secret, tenant_id)
end
end

defp refresh_bearer_token_cache(client_id, client_secret, tenant_id) do
token = fetch_bearer_token(client_id, client_secret, tenant_id)
expiry = extract_expiry_time(token) - @cache_expiry_margin_seconds
:ets.insert(:bearer_token_cache, {@cache_key, token, expiry})
token
end

defp extract_expiry_time(token) do
token
|> String.split(".")
|> Enum.at(1)
|> Base.decode64!()
|> Jason.decode!()
|> Map.get("exp")
end

def fetch_bearer_token(client_id, client_secret, tenant_id) do
body =
"grant_type=client_credentials&client_id=#{client_id}&client_secret=#{client_secret}&scope=https://storage.azure.com/.default"

respone =
%HTTPoison.Request{
method: :post,
url: "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/token",
body: body,
headers: [
{"content-type", "application/x-www-form-urlencoded"}
]
}
|> HTTPoison.request()

case respone do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
body |> Jason.decode!() |> Map.get("access_token")

{:error, err} ->
{:error, err}
end
end
end
2 changes: 1 addition & 1 deletion lib/azurex/authorization/shared_key.ex
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ defmodule Azurex.Authorization.SharedKey do
put_signature(request, signature, storage_account_name, storage_account_key)
end

defp put_standard_headers(request, content_type, date) do
def put_standard_headers(request, content_type, date) do
headers =
if content_type,
do: [{"content-type", content_type} | request.headers],
Expand Down
29 changes: 6 additions & 23 deletions lib/azurex/blob.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ defmodule Azurex.Blob do
In the functions below set container as nil to use the one configured in `Azurex.Blob.Config`.
"""

alias Azurex.Authorization.Auth
alias Azurex.Blob.{Block, Config}
alias Azurex.Authorization.SharedKey

@typep optional_string :: String.t() | nil

Expand All @@ -15,10 +15,7 @@ defmodule Azurex.Blob do
url: Config.api_url() <> "/",
params: [comp: "list"]
}
|> SharedKey.sign(
storage_account_name: Config.storage_account_name(),
storage_account_key: Config.storage_account_key()
)
|> Auth.authorize_request()
|> HTTPoison.request()
|> case do
{:ok, %{body: xml, status_code: 200}} -> {:ok, xml}
Expand Down Expand Up @@ -106,11 +103,7 @@ defmodule Azurex.Blob do
# is not applicable for the put request, so we set it to infinity
options: [recv_timeout: :infinity]
}
|> SharedKey.sign(
storage_account_name: Config.storage_account_name(),
storage_account_key: Config.storage_account_key(),
content_type: content_type
)
|> Auth.authorize_request(content_type)
|> HTTPoison.request()
|> case do
{:ok, %{status_code: 201}} -> :ok
Expand Down Expand Up @@ -185,11 +178,7 @@ defmodule Azurex.Blob do
url: get_url(container, destination_name),
headers: headers
}
|> SharedKey.sign(
storage_account_name: Config.storage_account_name(),
storage_account_key: Config.storage_account_key(),
content_type: content_type
)
|> Auth.authorize_request(content_type)
|> HTTPoison.request()
|> case do
{:ok, %HTTPoison.Response{status_code: 202} = resp} -> {:ok, resp}
Expand Down Expand Up @@ -219,10 +208,7 @@ defmodule Azurex.Blob do
headers: headers,
options: options
}
|> SharedKey.sign(
storage_account_name: Config.storage_account_name(),
storage_account_key: Config.storage_account_key()
)
|> Auth.authorize_request()
end

@doc """
Expand All @@ -248,10 +234,7 @@ defmodule Azurex.Blob do
restype: "container"
] ++ params
}
|> SharedKey.sign(
storage_account_name: Config.storage_account_name(),
storage_account_key: Config.storage_account_key()
)
|> Auth.authorize_request()
|> HTTPoison.request()
|> case do
{:ok, %{body: xml, status_code: 200}} -> {:ok, xml}
Expand Down
15 changes: 3 additions & 12 deletions lib/azurex/blob/block.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ defmodule Azurex.Blob.Block do
- [commit a list of blocks as part of a blob](https://learn.microsoft.com/en-us/rest/api/storageservices/put-block-list)
"""

alias Azurex.Authorization.SharedKey
alias Azurex.Authorization.Auth
alias Azurex.Blob
alias Azurex.Blob.Config

@doc """
Creates a block to be committed to a blob.
Expand All @@ -34,11 +33,7 @@ defmodule Azurex.Blob.Block do
{"content-length", byte_size(chunk)}
]
}
|> SharedKey.sign(
storage_account_name: Config.storage_account_name(),
storage_account_key: Config.storage_account_key(),
content_type: content_type
)
|> Auth.authorize_request(content_type)
|> HTTPoison.request()
|> case do
{:ok, %HTTPoison.Response{status_code: 201}} -> {:ok, block_id}
Expand Down Expand Up @@ -82,11 +77,7 @@ defmodule Azurex.Blob.Block do
{"x-ms-blob-content-type", blob_content_type}
]
}
|> SharedKey.sign(
storage_account_name: Config.storage_account_name(),
storage_account_key: Config.storage_account_key(),
content_type: content_type
)
|> Auth.authorize_request(content_type)
|> HTTPoison.request()
|> case do
{:ok, %HTTPoison.Response{status_code: 201}} -> :ok
Expand Down
68 changes: 52 additions & 16 deletions lib/azurex/blob/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,6 @@ defmodule Azurex.Blob.Config do
Azurex Blob Config
"""

@missing_envs_error_msg """
Azurex.Blob.Config: `storage_account_name` and `storage_account_key`
or `storage_account_connection_string` required.
"""

defp conf, do: Application.get_env(:azurex, __MODULE__, [])

@doc """
Expand Down Expand Up @@ -41,21 +36,62 @@ defmodule Azurex.Blob.Config do
case Keyword.get(conf(), :storage_account_name) do
nil -> get_connection_string_value("AccountName")
storage_account_name -> storage_account_name
end || raise @missing_envs_error_msg
end || raise "Azurex.Blob.Config: Missing storage account name"
end

@doc """
Azure storage account access key. Base64 encoded, as provided by azure UI.
Required if `storage_account_connection_string` not set.
"""
@spec storage_account_key :: binary
def storage_account_key do
defp try_account_key_env(nil) do
case Keyword.get(conf(), :storage_account_key) do
nil -> get_connection_string_value("AccountKey")
key -> key
nil -> nil
key -> {:account_key, Base.decode64!(key)}
end
|> Kernel.||(raise @missing_envs_error_msg)
|> Base.decode64!()
end

defp try_account_key_env(value), do: value

defp try_account_key_conn_string(nil) do
case get_connection_string_value("AccountKey") do
nil -> nil
key -> {:account_key, Base.decode64!(key)}
end
end

defp try_account_key_conn_string(value), do: value

defp try_service_principal(nil) do
{missing_values, values} =
[:storage_client_id, :storage_client_secret, :storage_tenant_id]
|> Enum.map(&Keyword.get(conf(), &1))
|> Enum.split_with(&is_nil/1)

case values do
[client_id, client_secret, tenant] ->
{:service_principal, client_id, client_secret, tenant}

[] ->
nil

_ ->
raise "Azurex.Blob.Config: Missing values for service principal #{Enum.join(missing_values, ", ")}"
end
end

defp try_service_principal(value), do: value

@doc """
Investigate which authentication method is set.
"""
@spec auth_method() ::
{:service_principal, binary(), binary(), binary()} | {:account_key, binary()}
def auth_method do
nil
|> try_account_key_env
|> try_account_key_conn_string
|> try_service_principal ||
raise """
Azurex.Blob.Config: Missing credentials settings.
Either set storage account key with: `storage_account_key` or `storage_account_connection_string`
Or set service principal with: `storage_client_id`, `storage_client_secret` and `storage_tenant_id`
"""
end

@doc """
Expand Down
13 changes: 3 additions & 10 deletions lib/azurex/blob/container.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,16 @@ defmodule Azurex.Blob.Container do
@moduledoc """
Implementation of Azure Blob Storage
"""
alias Azurex.Authorization.Auth
alias Azurex.Blob.Config
alias Azurex.Authorization.SharedKey

def head_container(container) do
%HTTPoison.Request{
url: Config.api_url() <> "/" <> container,
params: [restype: "container"],
method: :head
}
|> SharedKey.sign(
storage_account_name: Config.storage_account_name(),
storage_account_key: Config.storage_account_key()
)
|> Auth.authorize_request()
|> HTTPoison.request()
|> case do
{:ok, %{status_code: 200, headers: headers}} -> {:ok, headers}
Expand All @@ -30,11 +27,7 @@ defmodule Azurex.Blob.Container do
params: [restype: "container"],
method: :put
}
|> SharedKey.sign(
storage_account_name: Config.storage_account_name(),
storage_account_key: Config.storage_account_key(),
content_type: "application/octet-stream"
)
|> Auth.authorize_request("application/octet-stream")
|> HTTPoison.request()
|> case do
{:ok, %{status_code: 201}} -> {:ok, container}
Expand Down
9 changes: 8 additions & 1 deletion lib/azurex/blob/shared_access_signature.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defmodule Azurex.Blob.SharedAccessSignature do
Based on:
https://learn.microsoft.com/en-us/rest/api/storageservices/create-service-sas
"""
alias Azurex.Blob.Config

@doc """
Generates a SAS url on a resource in a given container.
Expand Down Expand Up @@ -32,14 +33,20 @@ defmodule Azurex.Blob.SharedAccessSignature do
expiry = Keyword.get(opts, :expiry, {:second, 3600})
resource = Path.join(container, resource)

account_key =
case Config.auth_method() do
{:account_key, key} -> key
_ -> raise "Only account key authentication is supported for SAS"
end

token =
build_token(
resource_type,
resource,
{from, expiry},
permissions,
Azurex.Blob.Config.storage_account_name(),
Azurex.Blob.Config.storage_account_key()
account_key
)

"#{Path.join(base_url, resource)}?#{token}"
Expand Down
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ defmodule Azurex.MixProject do
[
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false},
{:httpoison, "~> 1.8 or ~> 2.2"}
{:httpoison, "~> 1.8 or ~> 2.2"},
{:jason, "~> 1.4.4"}
]
end

Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"},
"httpoison": {:hex, :httpoison, "2.2.1", "87b7ed6d95db0389f7df02779644171d7319d319178f6680438167d7b69b1f3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "51364e6d2f429d80e14fe4b5f8e39719cacd03eb3f9a9286e61e216feac2d2df"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"},
"makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"},
"makeup_erlang": {:hex, :makeup_erlang, "0.1.5", "e0ff5a7c708dda34311f7522a8758e23bfcd7d8d8068dc312b5eb41c6fd76eba", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "94d2e986428585a21516d7d7149781480013c56e30c6a233534bedf38867a59a"},
Expand Down
Loading