View Source Charon.TokenPlugs (Charon v3.2.0)
The plugs in this module (and its submodules) can be used to verify tokens. The token's presence, signature, expiration and any claims can be checked. Additionally, the token's session can be loaded and, in case of a refresh token, it can be verified that it matches the session.
In case of validation errors, the plugs add an "auth error" to the conn,
but don't raise or halt the connection immediately.
This property can be used to support endpoints
that work with- or without authentication, for example,
or if you want to support multiple kinds of tokens.
The plug verify_no_auth_error/2
can be used to actually do something if there is an error.
All the plugs short-circuit, meaning that they immediately
return the connection if there are errors.
Using the plugs in these module, you can construct your own verification pipeline using either Plug.Builder or standard Phoenix router pipelines. Here are two examples for access- and refresh tokens, respectively, that should be a good baseline for your own pipelines.
Access tokens
defmodule MyApp.AccessTokenPipeline do
use Plug.Builder
@config Charon.Config.from_enum(Application.compile_env!(:my_app, :charon))
plug :get_token_from_auth_header
plug :get_token_from_cookie, @config.access_cookie_name
plug :verify_token_signature, @config
plug :verify_token_nbf_claim
plug :verify_token_exp_claim
plug :verify_token_claim_equals, {"type", "access"}
plug :verify_no_auth_error, &MyApp.TokenErrorHandler.on_error/2
plug Charon.TokenPlugs.PutAssigns
end
Refresh tokens
defmodule MyApp.RefreshTokenPipeline do
use Plug.Builder
@config Charon.Config.from_enum(Application.compile_env!(:my_app, :charon))
plug :get_token_from_auth_header
plug :get_token_from_cookie, @config.refresh_cookie_name
plug :verify_token_signature, @config
plug :verify_token_nbf_claim
plug :verify_token_exp_claim
plug :verify_token_claim_equals, {"type", "refresh"}
plug :load_session, @config
plug :verify_token_fresh, 10
plug :verify_no_auth_error, &MyApp.TokenErrorHandler.on_error/2
plug Charon.TokenPlugs.PutAssigns
end
Summary
Functions
Get a bearer token from the authorization
header.
Get the token or token signature from a cookie, if
Appends the specified cookie's content to the bearer token, if the cookie is present and the token ends with a "." character.
Must be used after get_token_from_auth_header/2
.
Fetch the session to which the bearer token belongs.
Raises on session store error.
Must be used after verify_token_signature/2
.
Make sure that no previous plug of this module added an auth error.
In case of an error, on_error
is called (it should probably halt the connection).
Generically verify the bearer token payload.
The validation function func
must return the conn or an error message.
Must be used after load_session/2
.
Generically verify that the bearer token payload contains claim
and that its value matches func
. The function must return the conn or an error message.
Must be used after verify_token_signature/2
.
Verify that the bearer token payload contains claim
and that its value is expected
.
Must be used after verify_token_signature/2
.
Verify that the bearer token payload contains claim
and that its value is in expected
.
Must be used after verify_token_signature/2
.
Verify that the bearer token payload contains a non-expired exp
(expires at) claim.
Must be used after verify_token_signature/2
.
Verify that the token (either access or refresh) is fresh.
Verify that the bearer token payload contains a valid nbf
(not before) claim.
Must be used after verify_token_signature/2
.
Verify that the bearer token payload contains claim
, which is assumed to be an :ordset
,
and that ordset
(which is also assumed to be either an ordset or a single element)
is a subset of that ordset.
Generically verify the bearer token payload.
The validation function func
must return the conn or an error message.
Must be used after verify_token_signature/2
.
Verify that the bearer token found by get_token_from_auth_header/2
is signed correctly.
Functions
@spec get_token_from_auth_header(Plug.Conn.t(), any()) :: Plug.Conn.t()
Get a bearer token from the authorization
header.
Doctests
iex> conn = conn() |> put_req_header("authorization", "Bearer aaa")
iex> conn |> get_token_from_auth_header([]) |> Utils.get_auth_error()
nil
iex> conn |> get_token_from_auth_header([]) |> Utils.get_token_transport()
:bearer
iex> conn |> get_token_from_auth_header([]) |> Utils.get_bearer_token()
"aaa"
# missing auth header
iex> conn = conn()
iex> conn |> get_token_from_auth_header([]) |> Utils.get_auth_error()
nil
# auth header format must be correct
iex> conn = conn() |> put_req_header("authorization", "boom")
iex> conn |> get_token_from_auth_header([]) |> Utils.get_bearer_token()
nil
iex> conn = conn() |> put_req_header("authorization", "Bearer ")
iex> conn |> get_token_from_auth_header([]) |> Utils.get_auth_error()
nil
@spec get_token_from_cookie(Plug.Conn.t(), String.t()) :: Plug.Conn.t()
Get the token or token signature from a cookie, if:
- no bearer token was previously found by
get_token_from_auth_header/2
- OR the bearer token ends with ".", in which case the cookie contents are appended to it
Doctests
# cookie is appended to a bearer token that ends with "."
iex> conn = conn() |> set_token("token.") |> put_req_cookie("c", "sig") |> fetch_cookies()
iex> conn = conn |> get_token_from_cookie("c")
iex> conn |> Utils.get_token_transport()
:cookie
iex> conn |> Utils.get_bearer_token()
"token.sig"
# cookie is ignored if a bearer token is present that does not end with "."
iex> conn = conn() |> set_token("token") |> put_req_cookie("c", "sig") |> fetch_cookies()
iex> conn = conn |> get_token_from_cookie("c")
iex> conn |> Utils.get_token_transport()
nil
iex> conn |> Utils.get_bearer_token()
"token"
# cookie contents are used as token if no bearer token was found previously
iex> conn = conn() |> put_req_cookie("c", "cookie token") |> fetch_cookies()
iex> conn = conn |> get_token_from_cookie("c")
iex> conn |> Utils.get_token_transport()
:cookie_only
iex> conn |> Utils.get_bearer_token()
"cookie token"
@spec get_token_sig_from_cookie(Plug.Conn.t(), String.t()) :: Plug.Conn.t()
Appends the specified cookie's content to the bearer token, if the cookie is present and the token ends with a "." character.
Must be used after get_token_from_auth_header/2
.
Doctests
iex> conn = conn() |> set_token("token.") |> put_req_cookie("c", "sig") |> fetch_cookies()
iex> conn = conn |> get_token_from_cookie("c")
iex> conn |> Utils.get_token_transport()
:cookie
iex> conn |> Utils.get_bearer_token()
"token.sig"
# cookie is ignored if bearer token does not end with .
iex> conn = conn() |> set_token("token") |> put_req_cookie("c", "sig") |> fetch_cookies()
iex> conn = conn |> get_token_from_cookie("c")
iex> conn |> Utils.get_token_transport()
nil
iex> conn |> Utils.get_bearer_token()
"token"
@spec load_session(Plug.Conn.t(), Charon.Config.t()) :: Plug.Conn.t()
Fetch the session to which the bearer token belongs.
Raises on session store error.
Must be used after verify_token_signature/2
.
Doctests
iex> SessionStore.upsert(test_session(refresh_expires_at: 999999999999999), @config)
iex> conn = conn() |> set_token_payload(%{"sid" => "a", "sub" => 1, "styp" => "full"})
iex> %Session{} = conn |> load_session(@config) |> Internal.get_private(@session)
# token payload must contain "sub", "sid" and "styp" claims
iex> conn = conn() |> set_token_payload(1)
iex> conn |> load_session(@config) |> Utils.get_auth_error()
"bearer token claim sub, sid or styp not found"
# session must be found
iex> conn = conn() |> set_token_payload(%{"sid" => "a", "sub" => 1, "styp" => "full"})
iex> conn |> load_session(@config) |> Utils.get_auth_error()
"session not found"
iex> conn() |> load_session(@config)
** (RuntimeError) must be used after verify_token_signature/2
@spec verify_no_auth_error( Plug.Conn.t(), (Plug.Conn.t(), String.t() -> Plug.Conn.t()) ) :: Plug.Conn.t()
Make sure that no previous plug of this module added an auth error.
In case of an error, on_error
is called (it should probably halt the connection).
Doctests
iex> conn = conn()
iex> ^conn = verify_no_auth_error(conn, fn _conn, _error -> "BOOM" end)
# on error, send an error response
iex> conn = conn() |> set_auth_error("oops!")
iex> conn = verify_no_auth_error(conn, & &1 |> send_resp(401, &2) |> halt())
iex> conn.halted
true
iex> conn.resp_body
"oops!"
@spec verify_session_payload( Plug.Conn.t(), (Plug.Conn.t(), any() -> Plug.Conn.t() | binary()) ) :: Plug.Conn.t()
Generically verify the bearer token payload.
The validation function func
must return the conn or an error message.
Must be used after load_session/2
.
Doctests
iex> conn = conn() |> set_session(%{the: "session"})
iex> ^conn = conn |> verify_session_payload(fn conn, %{the: "session"} -> conn end)
# invalid
iex> conn = conn() |> set_session(%{the: "session"})
iex> conn |> verify_session_payload(fn _conn, s -> s[:missing] || "invalid" end) |> Utils.get_auth_error()
"invalid"
iex> conn() |> verify_session_payload(fn conn, _ -> conn end)
** (RuntimeError) must be used after load_session/2
@spec verify_token_claim( Plug.Conn.t(), {String.t(), (Plug.Conn.t(), any() -> Plug.Conn.t() | binary())} ) :: Plug.Conn.t()
Generically verify that the bearer token payload contains claim
and that its value matches func
. The function must return the conn or an error message.
Must be used after verify_token_signature/2
.
Doctests
def verify_read_scope(conn, value) do
if "read" in String.split(value, ",") do
conn
else
"no read scope"
end
end
iex> conn = conn() |> set_token_payload(%{"scope" => "read,write"})
iex> ^conn = conn |> verify_token_claim({"scope", &verify_read_scope/2})
# invalid
iex> conn = conn() |> set_token_payload(%{"scope" => "write"})
iex> conn |> verify_token_claim({"scope", &verify_read_scope/2}) |> Utils.get_auth_error()
"no read scope"
# claim must be present
iex> conn = conn() |> set_token_payload(%{})
iex> conn |> verify_token_claim({"scope", &verify_read_scope/2}) |> Utils.get_auth_error()
"bearer token claim scope not found"
@spec verify_token_claim_equals( Plug.Conn.t(), {String.t(), String.t()} ) :: Plug.Conn.t()
Verify that the bearer token payload contains claim
and that its value is expected
.
Must be used after verify_token_signature/2
.
Doctests
iex> conn = conn() |> set_token_payload(%{"type" => "access"})
iex> ^conn = conn |> verify_token_claim_equals({"type", "access"})
# invalid
iex> conn = conn() |> set_token_payload(%{"type" => "refresh"})
iex> conn |> verify_token_claim_equals({"type", "access"}) |> Utils.get_auth_error()
"bearer token claim type invalid"
# claim must be present
iex> conn = conn() |> set_token_payload(%{})
iex> conn |> verify_token_claim_equals({"type", "access"}) |> Utils.get_auth_error()
"bearer token claim type not found"
@spec verify_token_claim_in( Plug.Conn.t(), {String.t(), [any()]} ) :: Plug.Conn.t()
Verify that the bearer token payload contains claim
and that its value is in expected
.
Must be used after verify_token_signature/2
.
Doctests
iex> conn = conn() |> set_token_payload(%{"type" => "access"})
iex> ^conn = conn |> verify_token_claim_in({"type", ~w(access)})
# invalid
iex> conn = conn() |> set_token_payload(%{"type" => "refresh"})
iex> conn |> verify_token_claim_in({"type", ~w(access)}) |> Utils.get_auth_error()
"bearer token claim type invalid"
# claim must be present
iex> conn = conn() |> set_token_payload(%{})
iex> conn |> verify_token_claim_in({"type", ~w(access)}) |> Utils.get_auth_error()
"bearer token claim type not found"
@spec verify_token_exp_claim(Plug.Conn.t(), Plug.opts()) :: Plug.Conn.t()
Verify that the bearer token payload contains a non-expired exp
(expires at) claim.
Must be used after verify_token_signature/2
.
Note that a token created by Charon.SessionPlugs.upsert_session/3
is guaranteed
to have an exp claim that does not outlive its underlying session.
Doctests
iex> conn = conn() |> set_token_payload(%{"exp" => Internal.now()})
iex> ^conn = conn |> verify_token_exp_claim([])
# some clock drift is allowed
iex> conn = conn() |> set_token_payload(%{"exp" => Internal.now() - 3})
iex> ^conn = conn |> verify_token_exp_claim([])
# expired
iex> conn = conn() |> set_token_payload(%{"exp" => Internal.now() - 6})
iex> conn |> verify_token_exp_claim([]) |> Utils.get_auth_error()
"bearer token expired"
# claim must be present
iex> conn = conn() |> set_token_payload(%{})
iex> conn |> verify_token_exp_claim([]) |> Utils.get_auth_error()
"bearer token claim exp not found"
@spec verify_token_fresh(Plug.Conn.t(), pos_integer()) :: Plug.Conn.t()
Verify that the token (either access or refresh) is fresh.
A token is fresh if it belongs to the current or previous "refresh token generation". A generation is a set of tokens that is created within a "cycle TTL" amount of seconds from when the generation is first created. A new generation is created after the cycle TTL expires.
So a token must be "fresh", but because of refresh race conditions caused by network issues or misbehaving clients, enforcing only a single fresh token causes too many problems in practice.
Must be used after load_session/2
. Verify the token type with verify_token_claim_equals/2
.
Freshness example
New cycle is created 5 seconds after the generation's first token is created, and token ttl is 24h (so irrelevant in this example).
When | New gen | Fresh tokens | Created token | Current gen (timestamp) | Previous gen | Comment |
---|---|---|---|---|---|---|
0 | - | A | A (0) | - | Login | |
10 | y | A | B | B (10) | A | Refresh after cycle TTL of A, [A] becomes prev gen |
11 | A, B | C | B, C (10) | A | Refresh race within cycle TTL of B | |
12 | A, B, C | D | B, C, D (10) | A | Refresh race within cycle TTL of B | |
20 | y | B, C, D | E | E (20) | B, C, D | Refresh after cycle TTL of B, so [A] is now stale, and [B,C,D] becomes prev gen |
30 | y | E | F | F (30) | E | Refresh after cycle TTL of E, so [B,C,D] is now stale, [E] becomes prev gen |
Doctests
# some clock drift is allowed
iex> now = Internal.now()
iex> conn = conn() |> set_session(%{tokens_fresh_from: now, prev_tokens_fresh_from: now - 10}) |> set_token_payload(%{"iat" => now - 11})
iex> nil = conn |> verify_token_fresh(5) |> Utils.get_auth_error()
# if current gen is still within the cycle TTL, tokens from both it and previous gen are "fresh"
iex> now = Internal.now()
iex> conn = conn() |> set_session(%{tokens_fresh_from: now - 3, prev_tokens_fresh_from: now - 10})
iex> nil = conn |> set_token_payload(%{"iat" => now}) |> verify_token_fresh(5) |> Utils.get_auth_error()
# tokens are invalid from: iat < now - 10 (*previous* gen age) - 5 (max clock drift)
# younger-than-previous-gen-and-clock-drift token is valid
iex> nil = conn |> set_token_payload(%{"iat" => now - 15}) |> verify_token_fresh(5) |> Utils.get_auth_error()
# older-than-previous-gen-and-clock-drift token is invalid
iex> conn |> set_token_payload(%{"iat" => now - 16}) |> verify_token_fresh(5) |> Utils.get_auth_error()
"token stale"
# no generation cycle will take place
iex> conn |> set_token_payload(%{"iat" => now}) |> verify_token_fresh(5) |> Internal.get_private(@cycle_token_generation)
false
# if current gen is too old, a generation cycle happens, and previous gen tokens are no longer valid
# tokens are invalid from: iat < now - 10 (*current* gen age) - 5 (max clock drift)
iex> now = Internal.now()
iex> conn = conn() |> set_session(%{tokens_fresh_from: now - 10, prev_tokens_fresh_from: now - 20})
iex> nil = conn |> set_token_payload(%{"iat" => now}) |> verify_token_fresh(5) |> Utils.get_auth_error()
# younger-than-current-gen-and-clock-drift token is valid
iex> nil = conn |> set_token_payload(%{"iat" => now - 15}) |> verify_token_fresh(5) |> Utils.get_auth_error()
# older-than-current-gen-and-clock-drift token is invalid
iex> conn |> set_token_payload(%{"iat" => now - 16}) |> verify_token_fresh(5) |> Utils.get_auth_error()
"token stale"
# a generation cycle will occur
iex> conn |> set_token_payload(%{"iat" => now}) |> verify_token_fresh(5) |> Internal.get_private(@cycle_token_generation)
true
# claim must be present
iex> conn = conn() |> set_session(%{tokens_fresh_from: 0, prev_tokens_fresh_from: 0}) |> set_token_payload(%{})
iex> conn |> verify_token_fresh(5) |> Utils.get_auth_error()
"bearer token claim iat not found"
@spec verify_token_nbf_claim(Plug.Conn.t(), Plug.opts()) :: Plug.Conn.t()
Verify that the bearer token payload contains a valid nbf
(not before) claim.
Must be used after verify_token_signature/2
.
Doctests
iex> conn = conn() |> set_token_payload(%{"nbf" => Internal.now()})
iex> ^conn = conn |> verify_token_nbf_claim([])
# some clock drift is allowed
iex> conn = conn() |> set_token_payload(%{"nbf" => Internal.now() + 3})
iex> ^conn = conn |> verify_token_nbf_claim([])
# not yet valid
iex> conn = conn() |> set_token_payload(%{"nbf" => Internal.now() + 6})
iex> conn |> verify_token_nbf_claim([]) |> Utils.get_auth_error()
"bearer token not yet valid"
# claim must be present
iex> conn = conn() |> set_token_payload(%{})
iex> conn |> verify_token_nbf_claim([]) |> Utils.get_auth_error()
"bearer token claim nbf not found"
@spec verify_token_ordset_claim_contains( Plug.Conn.t(), {binary(), any()} ) :: Plug.Conn.t()
Verify that the bearer token payload contains claim
, which is assumed to be an :ordset
,
and that ordset
(which is also assumed to be either an ordset or a single element)
is a subset of that ordset.
Doctests
iex> conn = conn() |> set_token_payload(%{"scope" => ~w(a b c)})
iex> ^conn = conn |> verify_token_ordset_claim_contains({"scope", "a"})
iex> ^conn = conn |> verify_token_ordset_claim_contains({"scope", ~w(a b)})
# invalid
iex> conn = conn() |> set_token_payload(%{"scope" => ~w(a b c)})
iex> conn |> verify_token_ordset_claim_contains({"scope", "d"}) |> get_auth_error()
"bearer token claim scope does not contain [d]"
iex> conn |> verify_token_ordset_claim_contains({"scope", ~w(d e)}) |> get_auth_error()
"bearer token claim scope does not contain [d, e]"
# claim must be present
iex> conn = conn() |> set_token_payload(%{})
iex> conn |> verify_token_ordset_claim_contains({"scope", ~w(a b c)}) |> get_auth_error()
"bearer token claim scope not found"
# WATCH OUT!
# things will go horribly wrong if either the claim or the comparison value is not an ordset
iex> conn = conn() |> set_token_payload(%{"scope" => ~w(c b a)})
iex> conn |> verify_token_ordset_claim_contains({"scope", "a"}) |> get_auth_error()
"bearer token claim scope does not contain [a]"
iex> conn = conn() |> set_token_payload(%{"scope" => ~w(a b c)})
iex> conn |> verify_token_ordset_claim_contains({"scope", ~w(b a)}) |> get_auth_error()
"bearer token claim scope does not contain [a]"
@spec verify_token_payload( Plug.Conn.t(), (Plug.Conn.t(), any() -> Plug.Conn.t() | binary()) ) :: Plug.Conn.t()
Generically verify the bearer token payload.
The validation function func
must return the conn or an error message.
Must be used after verify_token_signature/2
.
Doctests
iex> conn = conn() |> set_token_payload(%{})
iex> ^conn = conn |> verify_token_payload(fn conn, _pl -> conn end)
# invalid
iex> conn = conn() |> set_token_payload(%{"scope" => "write"})
iex> conn |> verify_token_payload(fn _conn, _pl -> "no read scope" end) |> Utils.get_auth_error()
"no read scope"
iex> conn() |> verify_token_payload(fn conn, _pl -> conn end)
** (RuntimeError) must be used after verify_token_signature/2
@spec verify_token_signature(Plug.Conn.t(), Charon.Config.t()) :: Plug.Conn.t()
Verify that the bearer token found by get_token_from_auth_header/2
is signed correctly.
Doctests
iex> token = sign(%{"msg" => "hurray!"})
iex> conn = conn() |> set_token(token) |> verify_token_signature(@config)
iex> %{"msg" => "hurray!"} = Internal.get_private(conn, @bearer_token_payload)
# signature must match
iex> token = sign(%{"msg" => "hurray!"})
iex> conn = conn() |> set_token(token <> "boom") |> verify_token_signature(@config)
iex> Internal.get_private(conn, @bearer_token_payload)
nil
iex> Utils.get_auth_error(conn)
"bearer token signature invalid"
iex> conn() |> verify_token_signature(@config) |> Utils.get_auth_error()
"bearer token not found"