Skip to content

Commit

Permalink
COOP-270: Porting code from original repo
Browse files Browse the repository at this point in the history
Ported code from uk-pcw and fixed `mix check` issues.
  • Loading branch information
EddieWhi committed Sep 29, 2023
1 parent ab1eacc commit 3cbb2c8
Show file tree
Hide file tree
Showing 14 changed files with 761 additions and 19 deletions.
3 changes: 3 additions & 0 deletions .doctor.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
%Doctor.Config{
ignore_paths: [~r/test\/support/],
}
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 3 additions & 5 deletions lib/cloudflare_access_ex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
200 changes: 200 additions & 0 deletions lib/cloudflare_access_ex/access_token_verifier.ex
Original file line number Diff line number Diff line change
@@ -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
21 changes: 13 additions & 8 deletions lib/cloudflare_access_ex/application.ex
Original file line number Diff line number Diff line change
@@ -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
Expand Down
70 changes: 70 additions & 0 deletions lib/cloudflare_access_ex/config.ex
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 3cbb2c8

Please sign in to comment.