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

COOP-270: Porting code from original repo #4

Merged
merged 1 commit into from
Sep 29, 2023
Merged
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
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