diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e1cbdda..c12605dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Added +- Azure auth provider #225 + ## [2.1.1] - 2023-03-02 diff --git a/lib/k8s/client/mint_http_provider.ex b/lib/k8s/client/mint_http_provider.ex index 4052a458..55ee5a56 100644 --- a/lib/k8s/client/mint_http_provider.ex +++ b/lib/k8s/client/mint_http_provider.ex @@ -194,7 +194,7 @@ defmodule K8s.Client.MintHTTPProvider do @spec get_content_type(keyword()) :: binary | nil defp get_content_type(headers) do case List.keyfind(headers, "content-type", 0) do - {_key, content_type} -> content_type + {_key, content_type} -> content_type |> String.split(";") |> List.first() _ -> nil end end diff --git a/lib/k8s/conn/auth/azure.ex b/lib/k8s/conn/auth/azure.ex new file mode 100644 index 00000000..64595328 --- /dev/null +++ b/lib/k8s/conn/auth/azure.ex @@ -0,0 +1,91 @@ +defmodule K8s.Conn.Auth.Azure do + @moduledoc """ + `auth-provider` for azure + """ + alias K8s.Conn.RequestOptions + + require Logger + @behaviour K8s.Conn.Auth + + defstruct [:token] + + @type t :: %__MODULE__{ + token: String.t() + } + + @impl true + @spec create(map, String.t()) :: {:ok, t} | :skip + def create( + %{ + "auth-provider" => %{ + "config" => %{ + "access-token" => token, + "tenant-id" => tenant, + "expires-on" => expires_on, + "refresh-token" => refresh_token, + "client-id" => client_id, + "apiserver-id" => apiserver_id + }, + "name" => "azure" + } + }, + _ + ) do + if DateTime.diff(DateTime.utc_now(), parse_expires(expires_on)) >= 0 do + Logger.info( + "Azure token expired, using refresh token get new access, this will stop working when refresh token expires" + ) + + {:ok, %__MODULE__{token: refresh_token(tenant, refresh_token, client_id, apiserver_id)}} + else + {:ok, %__MODULE__{token: token}} + end + end + + def create(_, _), do: :skip + + @spec parse_expires(String.t()) :: DateTime.t() + defp parse_expires(expires_on) do + case Integer.parse(expires_on) do + {expires_on, _} -> DateTime.from_unix!(expires_on) + :error -> DateTime.from_iso8601(expires_on) + end + end + + @spec refresh_token(String.t(), String.t(), String.t(), String.t()) :: String.t() + def refresh_token(tenant, refresh_token, client_id, _apiserver_id) do + payload = + URI.encode_query(%{ + "client_id" => client_id, + "grant_type" => "refresh_token", + "refresh_token" => refresh_token + }) + + {:ok, res} = + K8s.Client.MintHTTPProvider.request( + :post, + URI.new!("https://login.microsoftonline.com/#{tenant}/oauth2/v2.0/token"), + payload, + [ + { + "Content-Type", + "application/x-www-form-urlencoded" + } + ], + ssl: [] + ) + + Map.get(res, "access_token") + end + + defimpl RequestOptions, for: __MODULE__ do + @spec generate(K8s.Conn.Auth.Azure.t()) :: RequestOptions.generate_t() + def generate(%K8s.Conn.Auth.Azure{token: token}) do + {:ok, + %RequestOptions{ + headers: [{:Authorization, "Bearer #{token}"}], + ssl_options: [] + }} + end + end +end diff --git a/test/k8s/conn/auth/azure_test.exs b/test/k8s/conn/auth/azure_test.exs new file mode 100644 index 00000000..5883c7ef --- /dev/null +++ b/test/k8s/conn/auth/azure_test.exs @@ -0,0 +1,44 @@ +defmodule K8s.Conn.Auth.AzureTest do + @moduledoc false + use ExUnit.Case, async: true + + alias K8s.Conn + alias K8s.Conn.Auth.Azure + + describe "create/2" do + test "creates a Azure struct from data" do + non_expired_unix_ts = DateTime.utc_now() |> DateTime.add(10, :minute) |> DateTime.to_unix() + + auth = %{ + "auth-provider" => %{ + "config" => %{ + "access-token" => "xxx", + "apiserver-id" => "service_id", + "client-id" => "client_id", + "expires-on" => "#{non_expired_unix_ts}", + "refresh-token" => "yyy", + "tenant-id" => "tenant" + }, + "name" => "azure" + } + } + + assert {:ok, + %Azure{ + token: "xxx" + }} = Azure.create(auth, nil) + end + end + + test "creates http request signing options" do + provider = %Azure{ + token: "xxx" + } + + {:ok, %Conn.RequestOptions{headers: headers, ssl_options: ssl_options}} = + Conn.RequestOptions.generate(provider) + + assert headers == [{:Authorization, "Bearer xxx"}] + assert ssl_options == [] + end +end