Skip to content

Commit

Permalink
Merge pull request #5 from joken-elixir/feat/override_algorithm
Browse files Browse the repository at this point in the history
feat: override "alg" claim
  • Loading branch information
victorolinasc authored Mar 6, 2019
2 parents 8a0ccf8 + f0d5385 commit a1c6020
Show file tree
Hide file tree
Showing 12 changed files with 239 additions and 76 deletions.
20 changes: 18 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,29 @@ language: elixir
elixir:
- 1.6.6
- 1.7.4
- 1.8.1

otp_release:
- 19.3
- 20.3
- 21.0
- 21.2

matrix:
exclude:
- otp_release: 19.3
elixir: 1.8.1

script:
- mix credo
- mix credo --strict
- mix format --check-formatted
- MIX_ENV=test mix coveralls.travis
- mix test --only external

cache:
directories:
- ~/.mix
- ~/.hex
- deps
- _build


12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.1.0] - 2019-03-05

### Added

- Options for explicitly telling which algorithm will use for the parsed signers;
- HTTP options for retry and adapter;
- Integration tests for Google and Microsoft JWKS endpoints.

### Fixed

- Fixed docs about how to use DefaultStrategyTemplate and Fixed spelling (#4 thanks to @bforchhammer)

## [1.0.0] - 2019-01-02

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Please see our guides and documentation for usage.
```elixir
def deps do
[
{:joken_jwks, "~> 1.0.0"}
{:joken_jwks, "~> 1.1.0"}
]
end
```
Expand Down
11 changes: 11 additions & 0 deletions guides/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,14 @@ This could potentially open an attack vector for massively hitting the authentic

`JokenJwks` comes with a smart enough implementation that uses a time window approach for re-fetching signers. By default, it polls the cache state every minute to see if a bad kid was attempted. If so, it refetches the cache. So, it will fetch JWKS once every minute tops.

## Interpretation of the JWKS RFC

Since the JWKS specification is just that, a specification, many servers might disagree on how to implement this. For example, Google specifies the "alg" claim on every key instance. Microsoft does not. Therefore we assume some interpretations:

- Every key must have a "kid" (even if there is only one key);
- We don't currently check for the "use" claim and so we might hit an encryption key (which will be parsed as well);
- If no "alg" claim is provided, then the user must pass the option "explicit_alg".

That's it for now :)


82 changes: 52 additions & 30 deletions lib/joken_jwks/default_strategy_template.ex
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,14 @@ defmodule JokenJwks.DefaultStrategyTemplate do
events in the strategy like HTTP errors and so on. It is advised not to turn off logging in production;
- `should_start` (`boolean()` - default true): whether to start the supervised polling task. For tests, this
should be false;
- `first_fetch_sync` (`boolean()` - default false): whether to fetch the first time synchronously or async.
- `first_fetch_sync` (`boolean()` - default false): whether to fetch the first time synchronously or async;
- `explicit_alg` (`String.t()`): the JWS algorithm for use with the key. Overrides the one in the JWK;
- `http_max_retries_per_fetch` (`pos_integer()` - default 10): passed to `Tesla.Middleware.Retry`;
- `http_delay_per_retry` (`pos_integer()` - default 500): passed to `Tesla.Middleware.Retry`.
### Example usage:
defmodule MyStrategy do
defmodule JokenExample.MyStrategy do
use JokenJwks.DefaultMatchStrategy
def init_opts(opts) do
Expand Down Expand Up @@ -158,26 +161,32 @@ defmodule JokenJwks.DefaultStrategyTemplate do
url = opts[:jwks_url] || raise "No url set for fetching JWKS!"
EtsCache.new()

do_init(start?, first_fetch_sync, time_interval, log_level, url)
opts =
opts
|> Keyword.put(:time_interval, time_interval)
|> Keyword.put(:log_level, log_level)
|> Keyword.put(:jwks_url, url)

do_init(start?, first_fetch_sync, opts)
end

defp do_init(start?, first_fetch_sync, time_interval, log_level, url) do
defp do_init(should_start, first_fetch_sync, opts) do
cond do
start? and first_fetch_sync ->
fetch_signers(url, log_level)
Task.start_link(__MODULE__, :poll, [time_interval, log_level, url])
should_start and first_fetch_sync ->
fetch_signers(opts[:jwks_url], opts)
Task.start_link(__MODULE__, :poll, [opts])

start? ->
start_fetch_signers(url, log_level)
Task.start_link(__MODULE__, :poll, [time_interval, log_level, url])
should_start ->
start_fetch_signers(opts[:jwks_url], opts)
Task.start_link(__MODULE__, :poll, [opts])

true ->
{:ok, spawn_link(fn -> "Normal shutdown" end)}
end
end

@impl SignerMatchStrategy
def match_signer_for_kid(kid, _opts) do
def match_signer_for_kid(kid, opts) do
with {:cache, [{:signers, signers}]} <- {:cache, EtsCache.get_signers()},
{:signer, signer} when not is_nil(signer) <- {:signer, signers[kid]} do
{:ok, signer}
Expand All @@ -195,37 +204,41 @@ defmodule JokenJwks.DefaultStrategyTemplate do
end

@doc false
def poll(time_interval, log_level, url) when is_integer(time_interval) do
def poll(opts) do
interval = opts[:time_interval]

receive do
after
time_interval ->
check_fetch(url, log_level)
poll(time_interval, log_level, url)
interval ->
check_fetch(opts)
poll(opts)
end
end

defp check_fetch(url, log_level) do
defp check_fetch(opts) do
case EtsCache.check_state() do
# no need to re-fetch
0 ->
JokenJwks.log(:debug, log_level, "Re-fetching cache is not needed.")
JokenJwks.log(:debug, opts[:log_level], "Re-fetching cache is not needed.")
:ok

# start re-fetching
_counter ->
JokenJwks.log(:debug, log_level, "Re-fetching cache is needed and will start.")
start_fetch_signers(url, log_level)
JokenJwks.log(:debug, opts[:log_level], "Re-fetching cache is needed and will start.")
start_fetch_signers(opts[:jwks_url], opts)
end
end

defp start_fetch_signers(url, log_level) do
Task.start(fn -> fetch_signers(url, log_level) end)
defp start_fetch_signers(url, opts) do
Task.start(fn -> fetch_signers(url, opts) end)
end

@doc "Fetch signers with `JokenJwks.HttpFetcher`"
def fetch_signers(url, log_level) do
with {:ok, keys} <- HttpFetcher.fetch_signers(url, log_level),
{:ok, signers} <- validate_and_parse_keys(keys, log_level) do
def fetch_signers(url, opts) do
log_level = opts[:log_level]

with {:ok, keys} <- HttpFetcher.fetch_signers(url, opts),
{:ok, signers} <- validate_and_parse_keys(keys, opts) do
JokenJwks.log(:debug, log_level, "Fetched signers. #{inspect(signers)}")
EtsCache.put_signers(signers)
EtsCache.set_status(:ok)
Expand All @@ -245,28 +258,28 @@ defmodule JokenJwks.DefaultStrategyTemplate do
end
end

defp validate_and_parse_keys(keys, log_level) when is_list(keys) do
defp validate_and_parse_keys(keys, opts) when is_list(keys) do
Enum.reduce_while(keys, {:ok, %{}}, fn key, {:ok, acc} ->
with {:ok, signer} <- parse_signer(key, log_level) do
with {:ok, signer} <- parse_signer(key, opts) do
{:cont, {:ok, Map.put(acc, key["kid"], signer)}}
else
e -> {:halt, e}
end
end)
end

defp parse_signer(key, log_level) do
defp parse_signer(key, opts) do
with {:kid, kid} when is_binary(kid) <- {:kid, key["kid"]},
{:alg, alg} when is_binary(alg) <- {:alg, key["alg"]},
{:ok, alg} <- get_algorithm(key["alg"], opts[:explicit_alg]),
{:ok, _signer} = res <- {:ok, Signer.create(alg, key)} do
res
else
{:kid, _} -> {:error, :kid_not_binary}
{:alg, _} -> {:error, :algorithm_not_binary}
err -> err
end
rescue
e ->
JokenJwks.log(:error, log_level, """
JokenJwks.log(:error, opts[:log_level], """
Error while parsing a key entry fetched from the network.
This should be investigated by a human.
Expand All @@ -278,6 +291,15 @@ defmodule JokenJwks.DefaultStrategyTemplate do

{:error, :invalid_key_params}
end

# According to JWKS spec (https://tools.ietf.org/html/rfc7517#section-4.4) the "alg"" claim
# is not mandatory. This is why we allow this to be passed as a hook option.
#
# We give preference to the one provided as option
defp get_algorithm(nil, nil), do: {:error, :no_algorithm_supplied}
defp get_algorithm(_, alg) when is_binary(alg), do: {:ok, alg}
defp get_algorithm(alg, _) when is_binary(alg), do: {:ok, alg}
defp get_algorithm(_, _), do: {:error, :bad_algorithm}
end
end
end
22 changes: 16 additions & 6 deletions lib/joken_jwks/http_fetcher.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ defmodule JokenJwks.HttpFetcher do
"""
alias Tesla.Middleware, as: M

@middleware [{M.Retry, delay: 500, max_retries: 10}, M.JSON, M.Logger]

@doc """
Fetches the JWKS signers from the given url.
Expand All @@ -23,8 +21,10 @@ defmodule JokenJwks.HttpFetcher do
We use `:hackney` as it validates certificates automatically.
"""
@spec fetch_signers(binary, boolean) :: {:ok, list} | {:error, atom} | no_return()
def fetch_signers(url, log_level) do
with {:ok, resp} <- Tesla.get(new(), url),
def fetch_signers(url, opts) do
log_level = opts[:log_level]

with {:ok, resp} <- Tesla.get(new(opts), url),
{:status, 200} <- {:status, resp.status},
{:keys, keys} when not is_nil(keys) <- {:keys, resp.body["keys"]} do
JokenJwks.log(:debug, log_level, "JWKS fetching: fetched keys -> #{inspect(keys)}")
Expand Down Expand Up @@ -56,11 +56,21 @@ defmodule JokenJwks.HttpFetcher do

@default_adapter Tesla.Adapter.Hackney

defp new do
defp new(opts) do
adapter =
Application.get_env(:tesla, __MODULE__)[:adapter] ||
Application.get_env(:tesla, :adapter, @default_adapter)

Tesla.client(@middleware, adapter)
adapter = opts[:http_adapter] || adapter

middleware = [
M.JSON,
M.Logger,
{M.Retry,
delay: opts[:http_delay_per_retry] || 500,
max_retries: opts[:http_max_retries_per_fetch] || 10}
]

Tesla.client(middleware, adapter)
end
end
6 changes: 3 additions & 3 deletions mix.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule JokenJwks.MixProject do
use Mix.Project

@version "1.0.0"
@version "1.1.0"

def project do
[
Expand Down Expand Up @@ -39,7 +39,7 @@ defmodule JokenJwks.MixProject do

defp deps do
[
{:joken, "~> 2.0.0"},
{:joken, "~> 2.0"},
{:jason, "~> 1.1"},
{:tesla, "~> 1.2"},
{:hackney, "~> 1.14"},
Expand All @@ -52,7 +52,7 @@ defmodule JokenJwks.MixProject do
{:excoveralls, "~> 0.10", only: :test},

# tests
{:mox, "~> 0.4", only: :test}
{:mox, "~> 0.5", only: :test}
]
end

Expand Down
24 changes: 12 additions & 12 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,26 @@
"base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"},
"cachex": {:hex, :cachex, "3.1.1", "588bcf48d20eddad7bff5172f5453090a071eba3191a03f51f779f88e3ac1900", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm"},
"certifi": {:hex, :certifi, "2.4.2", "75424ff0f3baaccfd34b1214184b6ef616d89e420b258bb0a5ea7d7bc628f7f0", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
"credo": {:hex, :credo, "1.0.0", "aaa40fdd0543a0cf8080e8c5949d8c25f0a24e4fc8c1d83d06c388f5e5e0ea42", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
"earmark": {:hex, :earmark, "1.3.0", "17f0c38eaafb4800f746b457313af4b2442a8c2405b49c645768680f900be603", [: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.2", "88bc918f215168bf6ce7070610a6173c45c82f32baa08bdfc80bf58df2d103b6", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
"earmark": {:hex, :earmark, "1.3.1", "73812f447f7a42358d3ba79283cfa3075a7580a3a2ed457616d6517ac3738cb9", [:mix], [], "hexpm"},
"eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm"},
"ex_doc": {:hex, :ex_doc, "0.19.1", "519bb9c19526ca51d326c060cb1778d4a9056b190086a8c6c115828eaccea6cf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.7", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"},
"excoveralls": {:hex, :excoveralls, "0.10.3", "b090a3fbcb3cfa136f0427d038c92a9051f840953ec11b40ee74d9d4eac04d1e", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
"ex_doc": {:hex, :ex_doc, "0.19.3", "3c7b0f02851f5fc13b040e8e925051452e41248f685e40250d7e40b07b9f8c10", [:mix], [{:earmark, "~> 1.2", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"},
"excoveralls": {:hex, :excoveralls, "0.10.6", "e2b9718c9d8e3ef90bc22278c3f76c850a9f9116faf4ebe9678063310742edc2", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
"fastglobal": {:hex, :fastglobal, "1.0.0", "f3133a0cda8e9408aac7281ec579c4b4a8386ce0e99ca55f746b9f58192f455b", [:mix], [], "hexpm"},
"hackney": {:hex, :hackney, "1.14.3", "b5f6f5dcc4f1fba340762738759209e21914516df6be440d85772542d4a5e412", [:rebar3], [{:certifi, "2.4.2", [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.0.2", [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.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"},
"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"},
"joken": {:hex, :joken, "2.0.0", "ff10fca10ec539d7a73874da303f4a7a975fea53fcd59b1b89dda2a71ecb4c6b", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm"},
"joken": {:hex, :joken, "2.0.1", "ec9ab31bf660f343380da033b3316855197c8d4c6ef597fa3fcb451b326beb14", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm"},
"jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"},
"jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm"},
"makeup": {:hex, :makeup, "0.5.5", "9e08dfc45280c5684d771ad58159f718a7b5788596099bdfb0284597d368a882", [:mix], [{:nimble_parsec, "~> 0.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"},
"makeup_elixir": {:hex, :makeup_elixir, "0.10.0", "0f09c2ddf352887a956d84f8f7e702111122ca32fbbc84c2f0569b8b65cbf7fa", [:mix], [{:makeup, "~> 0.5.5", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"},
"makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"},
"makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
"mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"},
"mox": {:hex, :mox, "0.4.0", "7f120840f7d626184a3d65de36189ca6f37d432e5d63acd80045198e4c5f7e6e", [:mix], [], "hexpm"},
"nimble_parsec": {:hex, :nimble_parsec, "0.4.0", "ee261bb53214943679422be70f1658fff573c5d0b0a1ecd0f18738944f818efe", [:mix], [], "hexpm"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"},
"mox": {:hex, :mox, "0.5.0", "c519b48407017a85f03407a9a4c4ceb7cc6dec5fe886b2241869fb2f08476f9e", [:mix], [], "hexpm"},
"nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [: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"},
"tesla": {:hex, :tesla, "1.2.1", "864783cc27f71dd8c8969163704752476cec0f3a51eb3b06393b3971dc9733ff", [:mix], [{:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"},
Expand Down
Loading

0 comments on commit a1c6020

Please sign in to comment.