Skip to content
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
1 change: 1 addition & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Used by "mix format"
[
locals_without_parens: [add_hook: 1, add_hook: 2],
inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
10 changes: 6 additions & 4 deletions lib/joken/hooks.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ defmodule Joken.Hooks do
## Before hooks

A before hook receives as the first parameter its options and then a tuple with the input of
the function. For example, the `generate_claims` function receives the token configuration plus a
the function. For example, the `generate_claims` function receives the token configuration plus a
map of extra claims. Therefore, a `before_generate` hook receives:
- the hook options or `[]` if none are given;
- a tuple with two elements where the first is the token configuration and the second is the extra
Expand All @@ -32,7 +32,7 @@ defmodule Joken.Hooks do
end
end

You could also halt execution completely on a before hook. Just use the `:halt` return with an error
You could also halt execution completely on a before hook. Just use the `:halt` return with an error
tuple:

defmodule StopTheWorldHook do
Expand All @@ -52,7 +52,7 @@ defmodule Joken.Hooks do
- the result tuple which might be `{:error, reason}` or a tuple with `:ok` and its parameters;
- the input to the function call.

Let's see an example with `after_verify`. The verify function takes as argument the token and a signer. So,
Let's see an example with `after_verify`. The verify function takes as argument the token and a signer. So,
an `after_verify` might look like this:

defmodule CheckVerifyError do
Expand All @@ -72,7 +72,7 @@ defmodule Joken.Hooks do
end
end

On this example we have conditional logic for different results.
On this example we have conditional logic for different results.

## `Joken.Config`

Expand Down Expand Up @@ -100,6 +100,8 @@ defmodule Joken.Hooks do

add_hook(JokenJwks, jwks_url: "http://someserver.com/.well-known/certs")
end

For an implementation reference, please see the source code of `Joken.Hooks.RequiredClaims`
"""
alias Joken.Signer

Expand Down
48 changes: 48 additions & 0 deletions lib/joken/hooks/required_claims.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
defmodule Joken.Hooks.RequiredClaims do
@moduledoc """
Hook to demand claims presence.

Adding this hook to your token configuration will allow to ensure some claims are present. It
adds an `after_validate/3` implementation that checks claims presence. Example:

defmodule MyToken do
use Joken.Config

add_hook Joken.Hooks.RequiredClaims, ensure: [:claim1, :claim2]
end

On missing claims it returns: `{:error, [message: "Invalid token", missing_claims: claims]}`.
"""
use Joken.Hooks

@impl Joken.Hooks
def after_validate([], _, _) do
raise "Missing required claims options"
end

def after_validate(opts, _, _) when not is_list(opts) do
raise "Options must be a list of claim keys"
end

def after_validate(required_claims, {:ok, claims} = result, input) do
required_claims = required_claims |> Enum.map(&map_keys/1) |> MapSet.new()
claims = claims |> Map.keys() |> MapSet.new()

required_claims
|> MapSet.subset?(claims)
|> case do
true ->
{:cont, result, input}

_ ->
diff = required_claims |> MapSet.difference(claims) |> MapSet.to_list()
{:halt, {:error, [message: "Invalid token", missing_claims: diff]}}
end
end

def after_validate(_, result, input), do: {:cont, result, input}

# will raise if not binary or atom
defp map_keys(key) when is_binary(key), do: key
defp map_keys(key) when is_atom(key), do: Atom.to_string(key)
end
11 changes: 5 additions & 6 deletions lib/joken/signer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ defmodule Joken.Signer do
@moduledoc """
Interface between Joken and JOSE for signing and verifying tokens.

In the future we plan to keep this interface but make it pluggable for other crypto
implementations like using only standard `:crypto` and `:public_key` modules. So,
In the future we plan to keep this interface but make it pluggable for other crypto
implementations like using only standard `:crypto` and `:public_key` modules. So,
**avoid** depending on the inner structure of this module.
"""
alias JOSE.{JWK, JWS, JWT}
Expand Down Expand Up @@ -42,7 +42,7 @@ defmodule Joken.Signer do

@doc """
Creates a new Joken.Signer struct. Can accept either a binary for HS*** algorithms
or a map with arguments for the other kinds of keys. Also, accepts an optional map
or a map with arguments for the other kinds of keys. Also, accepts an optional map
that will be passed as extra header arguments for generated JWT tokens.

## Example:
Expand Down Expand Up @@ -137,9 +137,8 @@ defmodule Joken.Signer do
@spec verify(Joken.bearer_token(), __MODULE__.t()) ::
{:ok, Joken.claims()} | {:error, Joken.error_reason()}
def verify(token, %__MODULE__{alg: alg, jwk: jwk}) when is_binary(token) do
with {true, %JWT{fields: claims}, _} <- JWT.verify_strict(jwk, [alg], token) do
{:ok, claims}
else
case JWT.verify_strict(jwk, [alg], token) do
{true, %JWT{fields: claims}, _} -> {:ok, claims}
_ -> {:error, :signature_error}
end
end
Expand Down
10 changes: 5 additions & 5 deletions mix.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule Joken.Mixfile do
use Mix.Project

@version "2.1.0"
@version "2.2.0"

def project do
[
Expand Down Expand Up @@ -45,18 +45,18 @@ defmodule Joken.Mixfile do
{:benchee, "~> 1.0", only: :dev},

# Docs
{:ex_doc, "~> 0.19", only: :dev, runtime: false},
{:ex_doc, "~> 0.21", only: :dev, runtime: false},

# Dialyzer
{:dialyxir, "~> 1.0.0-rc4", only: :dev, runtime: false},
{:dialyxir, "~> 1.0.0-rc7", only: :dev, runtime: false},

# Credo
{:credo, "~> 1.0", only: [:dev, :test], runtime: false},
{:credo, "~> 1.1", only: [:dev, :test], runtime: false},

# Test
{:junit_formatter, "~> 3.0", only: :test},
{:stream_data, "~> 0.4", only: :test},
{:excoveralls, "~> 0.10", only: :test}
{:excoveralls, "~> 0.11", only: :test}
]
end

Expand Down
10 changes: 5 additions & 5 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
"benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"},
"certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
"credo": {:hex, :credo, "1.0.5", "fdea745579f8845315fe6a3b43e2f9f8866839cfbc8562bb72778e9fdaa94214", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
"credo": {:hex, :credo, "1.1.4", "c2f3b73c895d81d859cec7fcee7ffdb972c595fd8e85ab6f8c2adbf01cf7c29c", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
"deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm"},
"dialyxir": {:hex, :dialyxir, "1.0.0-rc.7", "6287f8f2cb45df8584317a4be1075b8c9b8a69de8eeb82b4d9e6c761cf2664cd", [:mix], [{:erlex, ">= 0.2.5", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm"},
"earmark": {:hex, :earmark, "1.3.6", "ce1d0675e10a5bb46b007549362bd3f5f08908843957687d8484fe7f37466b19", [:mix], [], "hexpm"},
"earmark": {:hex, :earmark, "1.4.1", "07bb382826ee8d08d575a1981f971ed41bd5d7e86b917fd012a93c51b5d28727", [:mix], [], "hexpm"},
"erlex": {:hex, :erlex, "0.2.5", "e51132f2f472e13d606d808f0574508eeea2030d487fc002b46ad97e738b0510", [:mix], [], "hexpm"},
"ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"},
"excoveralls": {:hex, :excoveralls, "0.11.2", "0c6f2c8db7683b0caa9d490fb8125709c54580b4255ffa7ad35f3264b075a643", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
"hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
"hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
"idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
"jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
"jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"},
Expand All @@ -19,9 +19,9 @@
"makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"},
"nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [], [], "hexpm"},
"nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"},
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"},
"stream_data": {:hex, :stream_data, "0.4.3", "62aafd870caff0849a5057a7ec270fad0eb86889f4d433b937d996de99e3db25", [:mix], [], "hexpm"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"},
}
120 changes: 120 additions & 0 deletions test/hooks/required_claims_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
defmodule Joken.Hooks.RequiredClaimsTest do
use ExUnit.Case, async: true

test "fails if required claim is missing - list of binaries" do
defmodule MissingRequiredClaimAsListOfBinaries do
use Joken.Config

add_hook Joken.Hooks.RequiredClaims, ["claim1", "claim2"]

def token_config, do: %{}
end

alias MissingRequiredClaimAsListOfBinaries, as: Config

assert {:error, [message: "Invalid token", missing_claims: ["claim2"]]} ==
Config.generate_and_sign!(%{claim1: 1, claim3: 3})
|> Config.verify_and_validate()
end

test "succeeds if required claim is present - list of binaries" do
defmodule RequiredClaimPresentAsListOfBinaries do
use Joken.Config

add_hook Joken.Hooks.RequiredClaims, ["claim1", "claim2"]

def token_config, do: %{}
end

alias RequiredClaimPresentAsListOfBinaries, as: Config

assert {:ok, %{"claim1" => 1, "claim2" => 2, "claim3" => 3}} ==
Config.generate_and_sign!(%{claim1: 1, claim2: 2, claim3: 3})
|> Config.verify_and_validate()
end

test "fails if required claim is missing - list of atoms" do
defmodule MissingRequiredClaimAsListOfAtoms do
use Joken.Config

add_hook Joken.Hooks.RequiredClaims, [:claim1, :claim2]

def token_config, do: %{}
end

alias MissingRequiredClaimAsListOfAtoms, as: Config

assert {:error, [message: "Invalid token", missing_claims: ["claim2"]]} ==
Config.generate_and_sign!(%{claim1: 1, claim3: 3})
|> Config.verify_and_validate()
end

test "succeeds if required claim is present - list of atoms" do
defmodule RequiredClaimPresentAsListOfAtoms do
use Joken.Config

add_hook Joken.Hooks.RequiredClaims, [:claim1, :claim2]

def token_config, do: %{}
end

alias RequiredClaimPresentAsListOfAtoms, as: Config

assert {:ok, %{"claim1" => 1, "claim2" => 2, "claim3" => 3}} ==
Config.generate_and_sign!(%{claim1: 1, claim2: 2, claim3: 3})
|> Config.verify_and_validate()
end

test "raises if missing options" do
defmodule MissingRequiredClaimsOptions do
use Joken.Config

add_hook Joken.Hooks.RequiredClaims

def token_config, do: %{}
end

alias MissingRequiredClaimsOptions, as: Config

assert_raise RuntimeError, "Missing required claims options", fn ->
Config.generate_and_sign!(%{claim1: 1, claim2: 2, claim3: 3})
|> Config.verify_and_validate()
end
end

test "raises if options are not a list" do
defmodule MissingRequiredClaimsOptionsNotAList do
use Joken.Config

add_hook Joken.Hooks.RequiredClaims, :my_option

def token_config, do: %{}
end

alias MissingRequiredClaimsOptionsNotAList, as: Config

assert_raise RuntimeError, "Options must be a list of claim keys", fn ->
Config.generate_and_sign!(%{claim1: 1, claim2: 2, claim3: 3})
|> Config.verify_and_validate()
end
end

test "raises if any of the keys is not an atom or string" do
defmodule BadRequiredClaimsKeyOption do
use Joken.Config

add_hook Joken.Hooks.RequiredClaims, [:good_option, 1]

def token_config, do: %{}
end

alias BadRequiredClaimsKeyOption, as: Config

assert_raise FunctionClauseError,
"no function clause matching in Joken.Hooks.RequiredClaims.map_keys/1",
fn ->
Config.generate_and_sign!(%{claim1: 1, claim2: 2, claim3: 3})
|> Config.verify_and_validate()
end
end
end