diff --git a/.doctor.exs b/.doctor.exs new file mode 100644 index 0000000..3611a41 --- /dev/null +++ b/.doctor.exs @@ -0,0 +1,3 @@ +%Doctor.Config{ + ignore_paths: [~r/test\/support/], +} diff --git a/README.md b/README.md index 017ef82..97933c9 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,19 @@ # CloudflareAccessEx -**TODO: Add description** +## TODO + +- [ ] Add a test `JwksStrategy` +- [ ] If the keys get rotated unexpectedly, the 'JwksStrategy` signers will be out of date until the next poll. +- [ ] As the `JwksStrategy` module will be called for every request, it is a potential bottleneck. + Should consider using an ets table or other shared memory mechanism. +- [ ] Create a `Plug` module. +- [ ] Write a better Readme +- [ ] Consider publishing to hex +- [ ] Consider contributing `JwksStrategy` (if good) back to [joken_jwks](https://github.com/joken-elixir/joken_jwks) ## Installation -If [available in Hex](https://hex.pm/docs/publish), the package can be installed -by adding `cloudflare_access_ex` to your list of dependencies in `mix.exs`: +For now, installations should be through git reference. Tags will be available for releases. ```elixir def deps do diff --git a/lib/cloudflare_access_ex.ex b/lib/cloudflare_access_ex.ex index d00788c..cd6e286 100644 --- a/lib/cloudflare_access_ex.ex +++ b/lib/cloudflare_access_ex.ex @@ -2,15 +2,13 @@ defmodule CloudflareAccessEx do @moduledoc """ This library aims to simplify the process of sitting an application behind Cloudflare Access. - By default, this library starts an Application (see `CloudflareAccessEx.Application`). - The application will read Application config to determine which Cloudflare Access domains to - retrieve JWKs from. These keys can then be used to verify the application tokens sent by Cloudflare Access - when your application is accessed. + By default, this library starts its own supervision tree. The root application will read Application + config to determine which Cloudflare Access domains to retrieve JWKs from. These keys can then be used + to verify the application tokens sent by Cloudflare Access when your application is accessed. The [Cloudflare docs](https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/application-token/) provide more information. - TODO: The library also provides a Plug (see `CloudflareAccessEx.Plug`) that can be used to to extract and verify tokens from requests. diff --git a/lib/cloudflare_access_ex/access_token_verifier.ex b/lib/cloudflare_access_ex/access_token_verifier.ex new file mode 100644 index 0000000..cacfa67 --- /dev/null +++ b/lib/cloudflare_access_ex/access_token_verifier.ex @@ -0,0 +1,200 @@ +defmodule CloudflareAccessEx.AccessTokenVerifier do + @moduledoc """ + Verifies a Cloudflare Access token (JWT) and returns decoded information from the token. + """ + + require Logger + alias CloudflareAccessEx.{Config, JwksStrategy} + + @opaque t :: %__MODULE__{ + domain: String.t(), + audience: String.t(), + issuer: String.t(), + jwks_strategy: atom() + } + + @enforce_keys [:domain, :audience, :issuer, :jwks_strategy] + defstruct [:domain, :audience, :issuer, :jwks_strategy] + + @type verified_token() :: + :anonymous + | {:user, + %{ + required(id: String.t()) => String.t(), + required(email: String.t()) => String.t() + }} + @type verify_result() :: {:ok, verified_token()} | {:error, atom() | Keyword.t()} + + @doc """ + Creates an AccessTokenVerifier that can be used by `AccessTokenVerifier.verify/2`. + + If the config is an atom, it will be used to lookup the config in the `:cloudflare_access_ex` `Application` environment. + + Alternatively, the config can be a keyword list with the following keys: + + * `:domain` - The domain to verify the token against. This can be a string or an atom that is used to lookup the domain in the `:cloudflare_access_ex` `Application` environment. + * `:audience` - The audience to verify the token against. + * `:jwks_strategy` - The module to use to fetch the public keys from Cloudflare's JWKS endpoint. Defaults to `CloudflareAccessEx.JwksStrategy`. + + ## Examples + + iex> Application.put_env(:cloudflare_access_ex, :my_cfa_app, [ + ...> domain: "example.com", + ...> audience: "audience_string", + ...> ]) + ...> + ...> AccessTokenVerifier.create(:my_cfa_app) + %AccessTokenVerifier{ + audience: "audience_string", + domain: "example.com", + issuer: "https://example.com", + jwks_strategy: CloudflareAccessEx.JwksStrategy + } + + iex> Application.put_env(:cloudflare_access_ex, :my_cfa_app, [ + ...> domain: :example, + ...> audience: "audience_string", + ...> ]) + ...> Application.put_env(:cloudflare_access_ex, :example, + ...> domain: "example.com" + ...> ) + ...> + ...> AccessTokenVerifier.create(:my_cfa_app) + %AccessTokenVerifier{ + audience: "audience_string", + domain: "example.com", + issuer: "https://example.com", + jwks_strategy: CloudflareAccessEx.JwksStrategy + } + + iex> AccessTokenVerifier.create( + ...> domain: "example.com", + ...> audience: "audience_string", + ...> jwks_strategy: MyCustomJwksStrategy + ...> ) + %AccessTokenVerifier{ + audience: "audience_string", + domain: "example.com", + issuer: "https://example.com", + jwks_strategy: MyCustomJwksStrategy + } + """ + @spec create(atom | keyword) :: __MODULE__.t() + def create(config_key) when is_atom(config_key) do + opts = + Application.get_env(:cloudflare_access_ex, config_key) || + throw("Could not find config for #{inspect(config_key)} in :cloudflare_access_ex") + + create(opts) + end + + def create(opts) when is_list(opts) do + audience = + Keyword.get(opts, :audience) || + throw(":audience is required in cloudflare_access_ex config") + + domain = + Keyword.get(opts, :domain) || throw(":domain is required in cloudflare_access_ex config") + + jwks_strategy = Keyword.get(opts, :jwks_strategy) || JwksStrategy + + domain = Config.resolve_domain(domain) + issuer = Config.get_issuer(domain) + + %__MODULE__{ + domain: domain, + issuer: issuer, + audience: audience, + jwks_strategy: jwks_strategy + } + end + + @doc """ + Verifies the authenticity of the Cloudflare Access token in the given `Plug.Conn` or access_token against the given verifier. + """ + @spec verify(Plug.Conn.t() | binary(), __MODULE__.t()) :: + verify_result() + def verify(conn = %Plug.Conn{}, config) do + header = Plug.Conn.get_req_header(conn, "cf-access-jwt-assertion") + + case header do + [cf_access_token] -> verify(cf_access_token, config) + [] -> {:error, :header_not_found} + _ -> {:error, :multiple_headers_found} + end + end + + def verify(access_token, verifier) do + joken_result = + Joken.verify_and_validate( + token_config(), + access_token, + nil, + verifier, + hooks(verifier) + ) + + joken_result |> to_verify_result() + end + + defp to_verify_result(joken_result) do + case joken_result do + {:ok, claims = %{"sub" => ""}} -> + Logger.debug("Cloudflare Access token is anonymous: #{log_inspect(claims)}") + {:ok, :anonymous} + + {:ok, claims = %{"sub" => sub, "email" => email}} when email != "" -> + user = {:user, %{id: sub, email: email}} + Logger.debug("Cloudflare Access token is for user #{log_inspect(claims)}") + {:ok, user} + + {:ok, claims} -> + Logger.warning( + "Cloudflare Access token did not have expected claims #{log_inspect(claims)}" + ) + + {:error, [message: "Invalid token", claims: claims]} + + error -> + Logger.warning("Cloudflare Access error #{inspect(error)}") + error + end + end + + defp token_config() do + Joken.Config.default_claims(skip: [:jti, :iss, :aud]) + # Default audience claim does not verify against an array of audiences + |> Joken.Config.add_claim("aud", nil, &verify_audience/3) + |> Joken.Config.add_claim("iss", nil, &verify_issuer/3) + end + + defp verify_audience(aud, _claims, %{audience: expected}) when is_list(aud), + do: expected in aud + + defp verify_audience(aud, _claims, %{audience: expected}) + when is_binary(expected) and expected != "", + do: expected == aud + + defp verify_audience(_, _, verifier), + do: throw("Expected audience not provided on verifier: #{inspect(verifier)}") + + defp verify_issuer(iss, _claims, %{issuer: expected}) + when is_binary(expected) and expected != "", + do: expected == iss + + defp verify_issuer(_, _, verifier), + do: throw("Expected issuer not provided on verifier: #{inspect(verifier)}") + + defp hooks(verifier) do + [ + { + JokenJwks, + strategy: verifier.jwks_strategy, domain: verifier.domain + } + ] + end + + defp log_inspect(claims) do + inspect(claims |> Map.delete("identity_nonce")) + end +end diff --git a/lib/cloudflare_access_ex/application.ex b/lib/cloudflare_access_ex/application.ex index e95c1e2..d1e17ec 100644 --- a/lib/cloudflare_access_ex/application.ex +++ b/lib/cloudflare_access_ex/application.ex @@ -1,16 +1,21 @@ defmodule CloudflareAccessEx.Application do - # See https://hexdocs.pm/elixir/Application.html - # for more information on OTP Applications @moduledoc false - use Application + alias CloudflareAccessEx.{Config, JwksStrategy} + @impl true - def start(_type, _args) do - children = [ - # Starts a worker by calling: CloudflareAccessEx.Worker.start_link(arg) - # {CloudflareAccessEx.Worker, arg} - ] + def start(_type, args) do + domains = + Keyword.get(args, :domains) || + Config.get_domain_strings() || + [] + + children = + domains + |> Enum.map(fn domain -> + {JwksStrategy, [domain: domain]} + end) # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options diff --git a/lib/cloudflare_access_ex/config.ex b/lib/cloudflare_access_ex/config.ex new file mode 100644 index 0000000..95054c9 --- /dev/null +++ b/lib/cloudflare_access_ex/config.ex @@ -0,0 +1,70 @@ +defmodule CloudflareAccessEx.Config do + @moduledoc """ + Utility functions for processing the configuration of the library. + """ + + @spec get_issuer(String.t()) :: String.t() + @doc """ + Get the issuer URL for a domain. By default, just the domain name is provided, but it + is also possible to provide a full URL. This function will ensure that the issuer URL + is a full URL. + """ + def get_issuer(domain) do + if String.match?(domain, ~r/^https?\:\/\//) do + domain + else + "https://#{domain}" + end + end + + @spec resolve_domain(atom() | String.t()) :: String.t() + @doc """ + Given a domain atom or string, return the domain string. + + If the domain is an atom, it will be looked up in the application config. + """ + def resolve_domain(domain) when is_atom(domain) do + resolved_domain = + Application.get_env(:cloudflare_access_ex, domain, []) + |> Keyword.get(:domain) || + throw( + "Attempting to get domain name for :cloudflare_access_ex, #{inspect(domain)} but no :domain key found in config" + ) + + if is_binary(resolved_domain) do + resolved_domain + else + throw( + "Domain configuration #{inspect(domain)} refers to #{inspect(resolved_domain)} which is not a string" + ) + end + end + + def resolve_domain(domain) when is_binary(domain), + do: domain + + def resolve_domain(domain), + do: throw("Invalid domain name: #{inspect(domain)}") + + @spec get_domain_strings :: list(String.t()) + @doc """ + Return all domain names in all keys under :cloudflare_access_ex. + """ + def get_domain_strings() do + Application.get_all_env(:cloudflare_access_ex) + |> get_domain_strings() + end + + defp get_domain_strings(config) do + config + |> Enum.map(fn + {_, configs} when is_list(configs) -> + Keyword.get(configs, :domain) + + _ -> + nil + end) + |> Enum.filter(&is_binary/1) + |> Enum.uniq() + end +end diff --git a/lib/cloudflare_access_ex/jwks_strategy.ex b/lib/cloudflare_access_ex/jwks_strategy.ex new file mode 100644 index 0000000..6ef0c48 --- /dev/null +++ b/lib/cloudflare_access_ex/jwks_strategy.ex @@ -0,0 +1,135 @@ +defmodule CloudflareAccessEx.JwksStrategy do + @moduledoc """ + This module is responsible for fetching and caching the public keys from Cloudflare's JWKS endpoint. + + The keys are fetched on startup and then every hour after that. + + The module implements JokenJwks's SignerMatchStrategy behaviour which is used by the JokenJwks hook to attempt to + retrieve the correct signer to verify the token given the kid (key id) in the token header. + """ + + require Logger + + use GenServer + + alias Joken.Signer + alias JokenJwks.SignerMatchStrategy + alias CloudflareAccessEx.Config + + # Default 1 hour poll time for certs + @poll_time_ms :timer.hours(1) + + @behaviour SignerMatchStrategy + + @impl SignerMatchStrategy + @spec match_signer_for_kid(String.t(), keyword) :: + {:error, atom()} | {:ok, Joken.Signer.t()} + @doc """ + Implementing `SignerMatchStrategy`, attempts to find the `Signer` for the given key ID (`kid`). + + Expects `opts` to contain the domain that owns the key. + """ + def match_signer_for_kid(kid, opts) do + domain = Keyword.get(opts, :domain) || throw(":domain is required") + + signers = get_signers(domain) + signer = signers[kid] + + case {signers, signer} do + {signers, nil} when signers == %{} -> {:error, :no_signers_fetched} + {_, nil} -> {:error, :kid_does_not_match} + {_, signer} -> {:ok, signer} + end + end + + @doc """ + Ensures that the `JwksStrategy` for the given domain has initialized OK and is ready to return signers. + """ + def ready?(domain) do + signers = get_signers(domain) + signers != %{} + end + + @spec get_signers(String.t()) :: %{String.t() => Joken.Signer.t()} + defp get_signers(domain) do + GenServer.call(name(domain), :get_signers) + end + + @type options :: [ + domain: String.t(), + poll_time_ms: non_neg_integer() + ] + + @spec start_link(any) :: :ignore | {:error, any} | {:ok, pid} + def start_link(opts) do + domain = Keyword.get(opts, :domain) || throw(":domain is required") + + GenServer.start_link(__MODULE__, opts, name: name(domain)) + end + + @impl true + def init(opts) do + domain = Keyword.get(opts, :domain) + + state = %{ + domain: domain, + url: get_jwks_url(domain), + poll_time_ms: Keyword.get(opts, :poll_time_ms, @poll_time_ms), + signers: %{} + } + + {:ok, state, {:continue, :update_signers}} + end + + defp get_jwks_url(domain) do + "#{Config.get_issuer(domain)}/cdn-cgi/access/certs" + end + + @impl true + def handle_call(:get_signers, _from, state) do + {:reply, state.signers, state} + end + + @impl true + def handle_continue(:update_signers, state) do + state = %{state | signers: fetch_signers(state.url)} + {:noreply, state} + end + + @impl true + def handle_info(:update_signers, state) do + {:noreply, state, {:continue, :update_signers}} + end + + defp name(domain) do + {:global, {__MODULE__, domain}} + end + + @spec fetch_signers(String.t()) :: %{String.t() => Joken.Signer.t()} + defp fetch_signers(url) do + {:ok, response} = + HTTPoison.get!(url) + |> Map.get(:body) + |> Jason.decode() + + signers = Map.get(response, "keys") |> create_signers + + Logger.info("Created #{Enum.count(signers)} signers from keys at #{url}") + + Process.send_after(self(), :update_signers, @poll_time_ms) + + signers + end + + defp create_signers(keys) do + Enum.map(keys, fn + %{"kid" => kid, "alg" => alg} = key -> + {kid, Signer.create(alg, key)} + + _ -> + {nil, nil} + end) + |> Enum.filter(fn {kid, _} -> !is_nil(kid) end) + |> Map.new() + end +end diff --git a/mix.exs b/mix.exs index 0c83491..4f3020b 100644 --- a/mix.exs +++ b/mix.exs @@ -6,6 +6,7 @@ defmodule CloudflareAccessEx.MixProject do app: :cloudflare_access_ex, version: "0.1.0", elixir: "~> 1.14", + elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), preferred_cli_env: [ @@ -19,6 +20,10 @@ defmodule CloudflareAccessEx.MixProject do ] end + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: elixirc_paths(:dev) ++ ["test/support"] + defp elixirc_paths(_), do: ["lib"] + # Run "mix help compile.app" to learn about applications. def application do [ @@ -32,9 +37,15 @@ defmodule CloudflareAccessEx.MixProject do [ {:credo, "~> 1.7", only: :test, runtime: false}, {:dialyxir, "~> 1.3", only: :test, runtime: false}, - {:doctor, "~> 0.21.0", only: :test, runtime: false}, + {:doctor, "~> 0.21.0", only: [:dev, :test], runtime: false}, {:ex_check, "~> 0.15.0", only: :test, runtime: false}, - {:ex_doc, "~> 0.27", only: :test, runtime: false} + {:ex_doc, "~> 0.27", only: [:dev, :test], runtime: false}, + {:mix_audit, "~> 2.0", only: :test, runtime: false}, + {:httpoison, "~> 1.7"}, + {:joken, "~> 2.6"}, + {:joken_jwks, "~> 1.6.0"}, + {:plug, "~> 1.14.2"}, + {:test_server, "~> 0.1.13", only: :test} ] end end diff --git a/mix.lock b/mix.lock index 81e8a93..98fdd85 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,6 @@ %{ "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, + "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "credo": {:hex, :credo, "1.7.0", "6119bee47272e85995598ee04f2ebbed3e947678dee048d10b5feca139435f75", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "6839fcf63d1f0d1c0f450abc8564a57c43d644077ab96f2934563e68b8a769d7"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "dialyxir": {:hex, :dialyxir, "1.4.1", "a22ed1e7bd3a3e3f197b68d806ef66acb61ee8f57b3ac85fc5d57354c5482a93", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "84b795d6d7796297cca5a3118444b80c7d94f7ce247d49886e7c291e1ae49801"}, @@ -9,9 +10,30 @@ "ex_check": {:hex, :ex_check, "0.15.0", "074b94c02de11c37bba1ca82ae5cc4926e6ccee862e57a485b6ba60fca2d8dc1", [:mix], [], "hexpm", "33848031a0c7e4209c3b4369ce154019788b5219956220c35ca5474299fb6a0e"}, "ex_doc": {:hex, :ex_doc, "0.30.6", "5f8b54854b240a2b55c9734c4b1d0dd7bdd41f71a095d42a70445c03cf05a281", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bd48f2ddacf4e482c727f9293d9498e0881597eae6ddc3d9562bd7923375109f"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "hackney": {:hex, :hackney, "1.18.2", "d7ff544ddae5e1cb49e9cf7fa4e356d7f41b283989a1c304bfc47a8cc1cf966f", [: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", "af94d5c9f97857db257090a4a10e5426ecb6f4918aa5cc666798566ae14b65fd"}, + "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, + "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.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "joken": {:hex, :joken, "2.6.0", "b9dd9b6d52e3e6fcb6c65e151ad38bf4bc286382b5b6f97079c47ade6b1bcc6a", [:mix], [{:jose, "~> 1.11.5", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5a95b05a71cd0b54abd35378aeb1d487a23a52c324fa7efdffc512b655b5aaa7"}, + "joken_jwks": {:hex, :joken_jwks, "1.6.0", "f24ba0f3071a790bfbd6ccefdec6109076d13cca691819432824464af7ae0372", [:mix], [{:hackney, "~> 1.17.4 or ~> 1.18.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.4", [hex: :joken, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:tesla, "~> 1.4", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "05de4b7851816c31f05ad1602e00f3e75a60a037b0e5b7fab4f880eb34ca32a0"}, + "jose": {:hex, :jose, "1.11.6", "613fda82552128aa6fb804682e3a616f4bc15565a048dabd05b1ebd5827ed965", [:mix, :rebar3], [], "hexpm", "6275cb75504f9c1e60eeacb771adfeee4905a9e182103aa59b53fed651ff9738"}, "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [: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", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "mix_audit": {:hex, :mix_audit, "2.1.1", "653aa6d8f291fc4b017aa82bdb79a4017903902ebba57960ef199cbbc8c008a1", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "541990c3ab3a7bb8c4aaa2ce2732a4ae160ad6237e5dcd5ad1564f4f85354db1"}, "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "plug": {:hex, :plug, "1.14.2", "cff7d4ec45b4ae176a227acd94a7ab536d9b37b942c8e8fa6dfc0fff98ff4d80", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "842fc50187e13cf4ac3b253d47d9474ed6c296a8732752835ce4a86acdf68d13"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, + "tesla": {:hex, :tesla, "1.7.0", "a62dda2f80d4f8a925eb7b8c5b78c461e0eb996672719fe1a63b26321a5f8b4e", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "2e64f01ebfdb026209b47bc651a0e65203fcff4ae79c11efb73c4852b00dc313"}, + "test_server": {:hex, :test_server, "0.1.13", "73457f382f2fab9a2378b088d56135eb0efe53e41507025970f7e7c624bb8134", [:mix], [{:bandit, ">= 0.7.6", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 2.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:x509, "~> 0.6", [hex: :x509, repo: "hexpm", optional: false]}], "hexpm", "807be2bf0188a2e541d93b0bfc02654a2446f23430420351bc2136460694d207"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "x509": {:hex, :x509, "0.8.8", "aaf5e58b19a36a8e2c5c5cff0ad30f64eef5d9225f0fd98fb07912ee23f7aba3", [:mix], [], "hexpm", "ccc3bff61406e5bb6a63f06d549f3dba3a1bbb456d84517efaaa210d8a33750f"}, + "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.9.0", "9a256da867b37b8d2c1ffd5d9de373a4fda77a32a45b452f1708508ba7bbcb53", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "0cb0e7d4c56f5e99a6253ed1a670ed0e39c13fc45a6da054033928607ac08dfc"}, } diff --git a/test/cloudflare_access/access_token_verifier_test.exs b/test/cloudflare_access/access_token_verifier_test.exs new file mode 100644 index 0000000..cc9f22d --- /dev/null +++ b/test/cloudflare_access/access_token_verifier_test.exs @@ -0,0 +1,156 @@ +defmodule CloudflareAccessEx.AccessTokenVerifierTest do + use ExUnit.Case, async: true + + alias CloudflareAccessEx.JwksStrategy + + # Importing the test subject + alias CloudflareAccessEx.AccessTokenVerifier + alias CloudflareAccessEx.Test.Simulator + + doctest(CloudflareAccessEx.AccessTokenVerifier) + + setup %{} do + :ok = Simulator.start_test_server() + + {:ok, _} = start_supervised({JwksStrategy, [domain: Simulator.domain()]}) + JwksStrategy.ready?(Simulator.domain()) + + verifier = + AccessTokenVerifier.create( + domain: Simulator.domain(), + audience: Simulator.audience() + ) + + {:ok, verifier: verifier} + end + + test "create/1 :atom reads config from application" do + :ok = + Application.put_env(:cloudflare_access_ex, :example, + domain: "example.com", + audience: "audience_string" + ) + + verifier = AccessTokenVerifier.create(:example) + + assert verifier.domain == "example.com" + assert verifier.audience == "audience_string" + assert verifier.issuer == "https://example.com" + end + + @tag start_simulator: true + test "extracts token from plug conn", %{verifier: verifier} do + token = Simulator.create_access_token() + + conn = %Plug.Conn{req_headers: [{"cf-access-jwt-assertion", token}]} + + assert AccessTokenVerifier.verify(conn, verifier) == + {:ok, Simulator.user()} + end + + test "errors if header missing from plug conn", %{verifier: verifier} do + conn = %Plug.Conn{req_headers: []} + + assert AccessTokenVerifier.verify(conn, verifier) == + {:error, :header_not_found} + end + + test "errors if multiple headers on plug conn", %{verifier: verifier} do + token = Simulator.create_access_token() + + conn = %Plug.Conn{ + req_headers: [ + {"cf-access-jwt-assertion", token}, + {"cf-access-jwt-assertion", token} + ] + } + + assert AccessTokenVerifier.verify(conn, verifier) == + {:error, :multiple_headers_found} + end + + @tag start_simulator: true + test "valid token", %{verifier: verifier} do + token = Simulator.create_access_token() + + assert AccessTokenVerifier.verify(token, verifier) == + {:ok, Simulator.user()} + end + + @tag start_simulator: true + test "anonymous token", %{verifier: verifier} do + token = Simulator.create_access_token(anonymous: true) + + assert AccessTokenVerifier.verify(token, verifier) == + {:ok, :anonymous} + end + + @tag start_simulator: true + test "valid token with aud array", %{verifier: verifier} do + audience = [Simulator.audience()] + token = Simulator.create_access_token(audience: audience) + + assert AccessTokenVerifier.verify(token, verifier) == + {:ok, Simulator.user()} + end + + @tag start_simulator: true + test "valid token with multiple audiences", %{verifier: verifier} do + audience = ["another_aud", verifier.audience] + token = Simulator.create_access_token(audience: audience) + + assert AccessTokenVerifier.verify(token, verifier) == + {:ok, Simulator.user()} + end + + @tag start_simulator: true + test "incorrect audience", %{verifier: verifier} do + audience = "wrong_audience" + token = Simulator.create_access_token(audience: audience) + + assert AccessTokenVerifier.verify(token, verifier) == + {:error, [message: "Invalid token", claim: "aud", claim_val: audience]} + end + + @tag start_simulator: true + test "audience not in array", %{verifier: verifier} do + audience = ["wrong_audience", "another_wrong_audience"] + token = Simulator.create_access_token(audience: audience) + + assert AccessTokenVerifier.verify(token, verifier) == + {:error, [message: "Invalid token", claim: "aud", claim_val: audience]} + end + + @tag start_simulator: true + test "incorrect issuer", %{verifier: verifier} do + issuer = "wrong_issuer" + token = Simulator.create_access_token(iss: issuer) + + assert AccessTokenVerifier.verify(token, verifier) == + {:error, [message: "Invalid token", claim: "iss", claim_val: issuer]} + end + + test "malformed token", %{verifier: verifier} do + token = Simulator.create_access_token() |> String.replace(".", "") + + assert AccessTokenVerifier.verify(token, verifier) == {:error, :token_malformed} + end + + @tag start_simulator: true + test "invalid signature", %{verifier: verifier} do + # change the last char in the signature at the end of the token + # (still valid base64, but invalid signature) + token = + Simulator.create_access_token() + |> String.graphemes() + |> Enum.reverse() + |> Kernel.then(fn + ["A" | rest] -> ["q" | rest] + [_ | rest] -> ["A" | rest] + end) + |> Enum.reverse() + |> Enum.join() + + assert {:error, :signature_error} = AccessTokenVerifier.verify(token, verifier) + end +end diff --git a/test/support/rsa_generator.ex b/test/support/rsa_generator.ex new file mode 100644 index 0000000..e975502 --- /dev/null +++ b/test/support/rsa_generator.ex @@ -0,0 +1,30 @@ +defmodule CloudflareAccessEx.Support.RSAGenerator do + @moduledoc """ + Copied from [this gist](https://gist.github.com/InoMurko/aae72fa8e1773556ee4e7b6eb6cf1801) + + Generates an RSA key pair by calling "openssl genrsa 2048" and parsing the output. + """ + @spec generate_rsa :: map() + def generate_rsa() do + port = Port.open({:spawn, "openssl genrsa 2048"}, [:binary]) + + priv_key_string = + receive do + {^port, {:data, data}} -> + data + end + + Port.close(port) + [pem_entry] = :public_key.pem_decode(priv_key_string) + pub_key = :public_key.pem_entry_decode(pem_entry) |> public_key + + pub_key_string = + :public_key.pem_encode([:public_key.pem_entry_encode(:RSAPublicKey, pub_key)]) + + %{private: priv_key_string, public: pub_key_string} + end + + defp public_key({:RSAPrivateKey, _, modulus, public_exponent, _, _, _, _, _, _, _}) do + {:RSAPublicKey, modulus, public_exponent} + end +end diff --git a/test/support/signers.ex b/test/support/signers.ex new file mode 100644 index 0000000..1e15df3 --- /dev/null +++ b/test/support/signers.ex @@ -0,0 +1,30 @@ +defmodule CloudflareAccessEx.Test.Signers do + @moduledoc "Utilities for generating signer and matching JWK" + + alias Joken.Signer + alias CloudflareAccessEx.Support.RSAGenerator + + @key_pair RSAGenerator.generate_rsa() + + # Same alg as cloudflare currently uses + @alg "RS256" + + def create_jwk(kid) do + {_, from_pem} = + @key_pair.public + |> JOSE.JWK.from_pem() + |> JOSE.JWK.to_map() + + from_pem + |> Map.put("kid", kid) + |> Map.put("alg", @alg) + |> Map.put("use", "sig") + end + + @doc """ + Create a signer that can encode tokens (i.e. it has the private key) + """ + def create_signer(kid) do + Signer.create(@alg, %{"pem" => @key_pair.private}, %{"kid" => kid}) + end +end diff --git a/test/support/simulator.ex b/test/support/simulator.ex new file mode 100644 index 0000000..0778e66 --- /dev/null +++ b/test/support/simulator.ex @@ -0,0 +1,73 @@ +defmodule CloudflareAccessEx.Test.Simulator do + @moduledoc """ + Simulates cloudflare certs endpoint using test server and creates + access tokens that verify against those keys. + """ + + alias CloudflareAccessEx.Test.Signers + + @default_user_id "62fc3fd0-5ac8-11ee-8c99-0242ac120002" + @default_user_email "test_user@example.com" + @default_audience "a8d3b7..." + @default_kid "2b34ecb..." + + def create_access_token(opts \\ []) do + anonymous = Keyword.get(opts, :anonymous, false) + sub = (anonymous && "") || Keyword.get(opts, :id, @default_user_id) + email = Keyword.get(opts, :email, @default_user_email) + iss = Keyword.get(opts, :iss, domain()) + kid = Keyword.get(opts, :kid, @default_kid) + + signer = Signers.create_signer(kid) + joken_config = Joken.Config.default_claims(skip: [:jti, :iss, :aud]) + + claims = %{ + "aud" => audience(opts), + "iss" => iss, + "sub" => sub + } + + claims = (anonymous && claims) || Map.put(claims, "email", email) + + Joken.generate_and_sign!( + joken_config, + claims, + signer, + [] + ) + end + + def domain() do + TestServer.url() + end + + def audience(opts \\ []) do + Keyword.get(opts, :audience, @default_audience) + end + + def user(opts \\ []) do + {:user, + %{ + id: Keyword.get(opts, :id, @default_user_id), + email: Keyword.get(opts, :email, @default_user_email) + }} + end + + def start_test_server() do + start_test_server([Signers.create_jwk(@default_kid)]) + end + + def start_test_server(jwks) do + TestServer.add("/cdn-cgi/access/certs", + to: fn conn -> + Plug.Conn.send_resp( + conn, + 200, + Jason.encode!(%{ + keys: jwks + }) + ) + end + ) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..04d34b2 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,2 @@ -ExUnit.start() +Application.ensure_all_started(:test_server) +ExUnit.start(capture_log: true)