-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
COOP-270: Porting code from original repo
Ported code from uk-pcw and fixed `mix check` issues.
- Loading branch information
Showing
14 changed files
with
761 additions
and
19 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
%Doctor.Config{ | ||
ignore_paths: [~r/test\/support/], | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.