Skip to content

Commit

Permalink
Get principal from plug and improve ergonomics (#24)
Browse files Browse the repository at this point in the history
* fix: AccessToken -> ApplicationToken

Cloudflare Acccess specifically calls the type of token we are verifying
"application tokens" in their docs. This commit renames the
AccessTokenVerifier to ApplicationTokenVerifier to match the terminology
and any other references to "access token" to "application token".

https://developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/application-token/

* feat: plug stores user principal

Allowing users of the plug to access the principal of the user
after it has been verified.

* feat: create a principal struct

The representation of the current user from AccessTokenVerifier
felt a little awkward, so I've created a Principal struct to
represent the current user. This also allows us to add more
information to the struct in the future if we need to.
  • Loading branch information
EddieWhi authored Nov 10, 2023
1 parent 0613f59 commit 94bdc63
Show file tree
Hide file tree
Showing 9 changed files with 238 additions and 127 deletions.
14 changes: 7 additions & 7 deletions lib/cloudflare_access_ex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,16 @@ defmodule CloudflareAccessEx do
plug CloudflareAccessEx.Plug, cfa_app: :my_cfa_app
or using `CloudflareAccessEx.AccessTokenVerifier` directly if you need even more control:
or using `CloudflareAccessEx.ApplicationTokenVerifier` directly if you need even more control:
alias CloudflareAccessEx.AccessTokenVerifier
alias CloudflareAccessEx.{ApplicationTokenVerifier, Principal}
verifier = AccessTokenVerifier.create(:my_cfa_app)
{:ok, token} = conn |> AccessTokenVerifier.verify(verifier)
verifier = ApplicationTokenVerifier.create(:my_cfa_app)
{:ok, principal} = conn |> ApplicationTokenVerifier.verify(verifier)
case token do
:anonymous -> # do something
{:user, %{id: _, email: _}} -> # do something else
case principal do
%Principal{type: :anonymous} -> # do something
%Principal{type: :authenticated, user_id: _, email: _}} -> # do something else
end
"""
end
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
defmodule CloudflareAccessEx.AccessTokenVerifier do
defmodule CloudflareAccessEx.ApplicationTokenVerifier do
@moduledoc """
Verifies a Cloudflare Access token (JWT) and returns decoded information from the token.
Verifies a Cloudflare Access application token (JWT) and returns decoded information from the token.
"""

require Logger
alias CloudflareAccessEx.{Config, JwksStrategy}
alias CloudflareAccessEx.{Config, JwksStrategy, Principal}

@opaque t :: %__MODULE__{
domain: String.t(),
Expand All @@ -16,17 +16,10 @@ defmodule CloudflareAccessEx.AccessTokenVerifier do
@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()}
@type verify_result() :: {:ok, Principal.t()} | {:error, atom() | Keyword.t()}

@doc """
Creates an AccessTokenVerifier that can be used by `AccessTokenVerifier.verify/2`.
Creates an ApplicationTokenVerifier that can be used by `ApplicationTokenVerifier.verify/2`.
If the config is an atom, it will be used to lookup the config in the `:cloudflare_access_ex` `Application` environment.
Expand All @@ -43,8 +36,8 @@ defmodule CloudflareAccessEx.AccessTokenVerifier do
...> audience: "audience_string",
...> ])
...>
...> AccessTokenVerifier.create(:my_cfa_app)
%AccessTokenVerifier{
...> ApplicationTokenVerifier.create(:my_cfa_app)
%ApplicationTokenVerifier{
audience: "audience_string",
domain: "example.com",
issuer: "https://example.com",
Expand All @@ -59,20 +52,20 @@ defmodule CloudflareAccessEx.AccessTokenVerifier do
...> domain: "example.com"
...> )
...>
...> AccessTokenVerifier.create(:my_cfa_app)
%AccessTokenVerifier{
...> ApplicationTokenVerifier.create(:my_cfa_app)
%ApplicationTokenVerifier{
audience: "audience_string",
domain: "example.com",
issuer: "https://example.com",
jwks_strategy: CloudflareAccessEx.JwksStrategy
}
iex> AccessTokenVerifier.create(
iex> ApplicationTokenVerifier.create(
...> domain: "example.com",
...> audience: "audience_string",
...> jwks_strategy: MyCustomJwksStrategy
...> )
%AccessTokenVerifier{
%ApplicationTokenVerifier{
audience: "audience_string",
domain: "example.com",
issuer: "https://example.com",
Expand Down Expand Up @@ -110,25 +103,25 @@ defmodule CloudflareAccessEx.AccessTokenVerifier do
end

@doc """
Verifies the authenticity of the Cloudflare Access token in the given `Plug.Conn` or access_token against the given verifier.
Verifies the authenticity of the Cloudflare Access application token in the given `Plug.Conn` or application_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)
[application_token] -> verify(application_token, config)
[] -> {:error, :header_not_found}
_ -> {:error, :multiple_headers_found}
end
end

def verify(access_token, verifier) do
def verify(application_token, verifier) do
joken_result =
Joken.verify_and_validate(
token_config(),
access_token,
application_token,
nil,
verifier,
hooks(verifier)
Expand All @@ -140,17 +133,19 @@ defmodule CloudflareAccessEx.AccessTokenVerifier do
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}
Logger.debug("Cloudflare Access application token is anonymous: #{log_inspect(claims)}")
{:ok, Principal.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}
Logger.debug(
"Cloudflare Access application token is for an authenticated user #{log_inspect(claims)}"
)

{:ok, Principal.authenticated(sub, email)}

{:ok, claims} ->
Logger.warning(
"Cloudflare Access token did not have expected claims #{log_inspect(claims)}"
"Cloudflare Access application token did not have expected claims #{log_inspect(claims)}"
)

{:error, [message: "Invalid token", claims: claims]}
Expand Down
62 changes: 46 additions & 16 deletions lib/cloudflare_access_ex/plug.ex
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule CloudflareAccessEx.Plug do
@moduledoc """
This plug is responsible for verifying the Cloudflare Access JWT token
and ensuring that it is valid.
This plug is responsible for blocking requets that do not have a valid
Cloudflare Access application token.
## Examples
Expand All @@ -14,35 +14,65 @@ defmodule CloudflareAccessEx.Plug do
require Logger
import Plug.ErrorHandler

alias CloudflareAccessEx.{ApplicationTokenVerifier, Principal}

@behaviour Plug

@doc false
@impl Plug
def init(opts), do: opts

@doc """
This function is responsible for verifying the Cloudflare Access JWT token
It will reject the request if the token is invalid or if the token is anonymous
and anonymous access is not allowed.
Verifies the Cloudflare Access application token.
It will reject the request with 401 (Unauthorized) if the token is invalid
or if the token is anonymous and anonymous access is not allowed.
If the token is valid, the principal will be set in the conn's private map
and can be accessed via `CloudflareAccessEx.Plug.get_principal/1`.
"""
@impl Plug
def call(conn, opts) do
verifier = CloudflareAccessEx.AccessTokenVerifier.create(opts[:cfa_app])
verifier = ApplicationTokenVerifier.create(opts[:cfa_app])

case ApplicationTokenVerifier.verify(conn, verifier) do
{:ok, token} -> verified(conn, token, opts[:allow_anonymous] || false)
{:error, _} -> unauthorized(conn)
end
end

case CloudflareAccessEx.AccessTokenVerifier.verify(conn, verifier) do
{:ok, :anonymous} ->
if Keyword.get(opts, :allow_anonymous, false) do
conn
else
unauthorized(conn)
end
@spec get_principal(Plug.Conn.t()) :: Principal.t()
@doc """
Returns the principal. Will raise if executed on a request that has not passed through the plug
or if the plug has rejected the request.
"""
def get_principal(conn) do
conn.private[:cloudflare_access_ex_principal] ||
raise "get_principal/1 called on a request that has not passed successfully through CloudflareAccessEx.Plug"
end

{:ok, _} ->
conn
defp verified(conn, principal, allow_anonymous) do
case {principal, allow_anonymous} do
{%Principal{type: :anonymous}, true} ->
authorized(conn, principal)

_else ->
{%Principal{type: :anonymous}, false} ->
Logger.warn("Anonymous access has been disabled in this application")
unauthorized(conn)

_ ->
authorized(conn, principal)
end
end

defp authorized(conn, principal) do
conn
|> Plug.Conn.put_private(:cloudflare_access_ex_principal, principal)
end

defp unauthorized(conn) do
conn
|> Plug.Conn.put_private(:cloudflare_access_ex_principal, nil)
|> Plug.Conn.resp(401, "401 Unauthorized")
|> Plug.Conn.halt()
end
Expand Down
66 changes: 66 additions & 0 deletions lib/cloudflare_access_ex/principal.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
defmodule CloudflareAccessEx.Principal do
@moduledoc """
Defines the `Principal` struct for representing the principal identity of a user coming through Cloudflare Access.
A `Principal` can either represent an anonymous user or a user that has logged in through an identity provider (IdP).
The struct differentiates between these two states with the `:type` field, which can be `:anonymous` for users without
a user ID or email, or `:authenticated` for users who have been verified and have these attributes.
"""
@enforce_keys [:type]
defstruct [:type, user_id: nil, email: nil]

@type anonymous_principal :: %__MODULE__{
type: :anonymous,
user_id: nil,
email: nil
}

@type authenticated_principal :: %__MODULE__{
type: :authenticated,
user_id: String.t(),
email: String.t()
}

@type t :: anonymous_principal() | authenticated_principal()

@doc """
Creates a `Principal` struct for an anonymous user.
## Examples
iex> CloudflareAccessEx.Principal.anonymous()
%CloudflareAccessEx.Principal{type: :anonymous, user_id: nil, email: nil}
"""
@spec anonymous() :: anonymous_principal()
def anonymous do
%__MODULE__{
type: :anonymous
}
end

@doc """
Creates a `Principal` struct for an authenticated user with the provided `user_id` and `email`.
## Parameters
- `user_id`: The user ID from the IdP.
- `email`: The email address associated with the user.
## Examples
iex> CloudflareAccessEx.Principal.authenticated("user123", "user@example.com")
%CloudflareAccessEx.Principal{
type: :authenticated,
user_id: "user123",
email: "user@example.com"
}
"""
@spec authenticated(String.t(), String.t()) :: authenticated_principal()
def authenticated(user_id, email) do
%__MODULE__{
type: :authenticated,
user_id: user_id,
email: email
}
end
end
Loading

0 comments on commit 94bdc63

Please sign in to comment.