defmodule AuthToken do
@moduledoc """
Simplified encrypted authentication tokens using JWE.
Configuration needed:
config :authtoken,
token_key: PUT_KEY_HERE
Generate a token for your user after successful authentication like this:
## Examples
token_content = %{userid: user.id}
token = AuthToken.generate_token(token_content)
"""
@doc """
Generate a random key for AES128
## Examples
iex> AuthToken.generate_key()
{:ok, <<153, 67, 252, 211, 199, 186, 212, 114, 109, 99, 222, 205, 31, 26, 100, 253>>}
"""
@spec generate_key() :: {:ok, binary}
def generate_key do
{:ok, :crypto.strong_rand_bytes(16)}
end
@doc """
Generates an encrypted auth token.
Contains an encoded version of the provided map, plus a timestamp for timeout and refresh.
"""
@spec generate_token(map) :: {:ok, String.t}
def generate_token(user_data) do
base_data = %{
"ct" => DateTime.to_unix(DateTime.utc_now()),
"rt" => DateTime.to_unix(DateTime.utc_now())}
token_content = user_data |> Enum.into(base_data)
jwt = JOSE.JWT.encrypt(get_jwk(), get_jwe(), token_content) |> JOSE.JWE.compact |> elem(1)
# Remove JWT header
{:ok, Regex.run(~r/.+?\.(.+)/, jwt) |> List.last}
end
@doc """
Checks a token and refreshes if necessary.
## Examples
case AuthToken.refresh_token(token) do
{:error, :timedout} ->
# Redirect to login
{:error, :stillfresh} ->
# Do nothing
{:ok, token} ->
# Check credentials and send back new token
end
"""
@spec refresh_token(map) :: {:ok, String.t} | {:error, :stillfresh} | {:error, :timedout}
def refresh_token(token) when is_map(token) do
cond do
is_timedout?(token) -> {:error, :timedout}
!needs_refresh?(token) -> {:error, :stillfresh}
needs_refresh?(token) ->
token = %{"rt" => DateTime.to_unix(DateTime.utc_now())} |> Enum.into(token)
generate_token(token)
end
end
@spec refresh_token(String.t) :: {:ok, String.t} | {:error, :stillfresh} | {:error, :timedout}
def refresh_token(token) when is_binary(token) do
{:ok, token} = decrypt_token(token)
token
|> refresh_token
end
@doc """
Check if token is timedout and not valid anymore
"""
@spec is_timedout?(map) :: boolean
def is_timedout?(token) do
{:ok, ct} = DateTime.from_unix(token["ct"])
DateTime.diff(DateTime.utc_now(), ct) > get_config(:timeout)
end
@doc """
Check if token is stale and needs to be refreshed
"""
@spec needs_refresh?(map) :: boolean
def needs_refresh?(token) do
{:ok, rt} = DateTime.from_unix(token["rt"])
DateTime.diff(DateTime.utc_now(), rt) > get_config(:refresh)
end
@doc """
Decrypt an authentication token
Format "bearer: tokengoeshere" and "bearer tokengoeshere" will be accepted and parsed out.
"""
@spec decrypt_token(Plug.Conn.t) :: {:ok, String.t} | {:error}
def decrypt_token(%Plug.Conn{} = conn) do
token_header = Plug.Conn.get_req_header(conn, "authorization") |> List.first
crypto_token = if token_header, do: Regex.run(~r/(bearer\:? )?(.+)/, token_header) |> List.last
decrypt_token(crypto_token)
end
@spec decrypt_token(String.t) :: {:ok, String.t} | {:error}
def decrypt_token(headless_token) when is_binary(headless_token) do
header = get_jwe() |> OJSON.encode! |> Base.url_encode64(padding: false)
auth_token = header <> "." <> headless_token
try do
%{fields: token} = JOSE.JWT.decrypt(get_jwk(), auth_token) |> elem(1)
{:ok, token}
rescue
_ -> {:error}
end
end
@spec decrypt_token(nil) :: {:error}
def decrypt_token(_) do
{:error}
end
@spec get_jwe() :: %{alg: String.t, enc: String.t, typ: String.t}
defp get_jwe do
%{ "alg" => "dir", "enc" => "A128GCM", "typ" => "JWT" }
end
@spec get_jwk() :: %{}
defp get_jwk do
get_config(:token_key)
|> JOSE.JWK.from_oct()
end
@spec get_config(atom) :: %{}
def get_config(atom) do
content = Application.get_env(:authtoken, atom)
unless content, do: raise "No AuthToken configuration set for " <> atom
content
end
end