├── mise.toml ├── config └── config.exs ├── .formatter.exs ├── test ├── test_helper.exs ├── support │ ├── test_token.ex │ ├── init_opts_token.ex │ └── utils.ex ├── joken_jwks_test.exs ├── integration_test.exs └── default_strategy_template_test.exs ├── .github ├── dependabot.yaml └── workflows │ └── ci.yaml ├── lib ├── joken_jwks │ ├── signer_match_strategy.ex │ ├── ets_cache.ex │ ├── http_fetcher.ex │ └── default_strategy_template.ex └── joken_jwks.ex ├── .gitignore ├── mix.exs ├── CHANGELOG.md ├── README.md ├── mix.lock └── LICENSE /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | elixir = "1.18.1-otp-27" 3 | erlang = "27.2" 4 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :tesla, JokenJwks.HttpFetcher, adapter: Tesla.Adapter.Hackney 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.configure(exclude: [external: true]) 2 | ExUnit.start() 3 | Mox.defmock(TeslaAdapterMock, for: Tesla.Adapter) 4 | Application.put_env(:tesla, JokenJwks.HttpFetcher, adapter: TeslaAdapterMock) 5 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: jose 10 | versions: 11 | - 1.11.1 12 | -------------------------------------------------------------------------------- /test/support/test_token.ex: -------------------------------------------------------------------------------- 1 | defmodule TestToken do 2 | @moduledoc false 3 | use Joken.Config 4 | 5 | defmodule Strategy do 6 | @moduledoc false 7 | use JokenJwks.DefaultStrategyTemplate 8 | end 9 | 10 | add_hook(JokenJwks, strategy: Strategy) 11 | 12 | def token_config, do: %{} 13 | end 14 | -------------------------------------------------------------------------------- /test/support/init_opts_token.ex: -------------------------------------------------------------------------------- 1 | defmodule InitOptsToken do 2 | @moduledoc false 3 | use Joken.Config 4 | 5 | defmodule Strategy do 6 | @moduledoc false 7 | use JokenJwks.DefaultStrategyTemplate 8 | 9 | @doc false 10 | def init_opts(_other_opts) do 11 | # override options 12 | [jwks_url: "http://jwks", first_fetch_sync: true] 13 | end 14 | end 15 | 16 | add_hook(JokenJwks, strategy: Strategy) 17 | 18 | def token_config, do: %{} 19 | end 20 | -------------------------------------------------------------------------------- /lib/joken_jwks/signer_match_strategy.ex: -------------------------------------------------------------------------------- 1 | defmodule JokenJwks.SignerMatchStrategy do 2 | @moduledoc """ 3 | A strategy behaviour for using with `JokenJwks`. 4 | 5 | JokenJwks will call this for every token with a kid. It is the strategy's responsibility to handle 6 | caching and matching of the kid with its signers cache. 7 | 8 | See `JokenJwks.DefaultStrategyTemplate` for an implementation. 9 | """ 10 | 11 | @callback match_signer_for_kid(kid :: binary(), hook_options :: any()) :: 12 | {:ok, Joken.Signer.t()} | {:error, reason :: atom()} 13 | end 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | joken_jwks-*.tar 24 | 25 | # Temporary files for e.g. tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /test/joken_jwks_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JokenJwksTest do 2 | use ExUnit.Case, async: true 3 | 4 | test "fails if token has no kid" do 5 | header = %{"alg" => "RS256"} |> Jason.encode!() |> Base.url_encode64(padding: false) 6 | payload = %{} |> Jason.encode!() |> Base.url_encode64(padding: false) 7 | token = "#{header}.#{payload}.signature" 8 | opts = [strategy: JokenJwksTest] 9 | 10 | assert {:halt, {:error, :no_kid_in_token_header}} == 11 | JokenJwks.before_verify(opts, {token, nil}) 12 | end 13 | 14 | test "fails if token malformed" do 15 | opts = [strategy: JokenJwksTest] 16 | 17 | assert {:halt, {:error, :token_malformed}} == JokenJwks.before_verify(opts, {"asd.asd", nil}) 18 | end 19 | 20 | test "raises if no strategy provided" do 21 | assert_raise(RuntimeError, fn -> JokenJwks.before_verify([], {"asd.asd", nil}) end) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/integration_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JokenJwks.IntegrationTest do 2 | use ExUnit.Case, async: true 3 | 4 | @moduletag :external 5 | 6 | @google_certs_url "https://www.googleapis.com/oauth2/v3/certs" 7 | @microsoft_certs_url "https://login.microsoftonline.com/common/discovery/v2.0/keys" 8 | 9 | alias JokenJwks.DefaultStrategyTemplate.EtsCache 10 | 11 | defmodule Strategy do 12 | use JokenJwks.DefaultStrategyTemplate 13 | end 14 | 15 | @tag :capture_log 16 | test "can parse Google's JWKS" do 17 | Strategy.start_link( 18 | jwks_url: @google_certs_url, 19 | http_adapter: Tesla.Adapter.Hackney, 20 | first_fetch_sync: true 21 | ) 22 | 23 | :timer.sleep(1_000) 24 | 25 | assert signers = EtsCache.get_signers(Strategy) 26 | assert Enum.count(signers) >= 1 27 | end 28 | 29 | @tag :capture_log 30 | test "can parse Microsoft's JWKS" do 31 | Strategy.start_link( 32 | jwks_url: @microsoft_certs_url, 33 | http_adapter: Tesla.Adapter.Hackney, 34 | first_fetch_sync: true, 35 | explicit_alg: "RS256" 36 | ) 37 | 38 | :timer.sleep(1_000) 39 | 40 | assert signers = EtsCache.get_signers(Strategy) 41 | assert Enum.count(signers) >= 1 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/joken_jwks/ets_cache.ex: -------------------------------------------------------------------------------- 1 | defmodule JokenJwks.DefaultStrategyTemplate.EtsCache do 2 | @moduledoc "Simple ETS counter based state machine" 3 | 4 | @doc "Starts ETS cache - will only create if table doesn't exist already" 5 | def new(module) do 6 | case :ets.whereis(name(module)) do 7 | :undefined -> 8 | :ets.new(name(module), [ 9 | :set, 10 | :public, 11 | :named_table, 12 | read_concurrency: true, 13 | write_concurrency: true 14 | ]) 15 | 16 | :ets.insert(name(module), {:counter, 0}) 17 | 18 | _ -> 19 | true 20 | end 21 | end 22 | 23 | @doc "Returns 0 - no need to fetch signers or 1 - need to fetch" 24 | def check_state(module) do 25 | :ets.lookup_element(name(module), :counter, 2) 26 | end 27 | 28 | @doc "Sets the cache status" 29 | def set_status(module, :refresh) do 30 | :ets.update_counter(name(module), :counter, {2, 1, 1, 1}, {:counter, 0}) 31 | end 32 | 33 | def set_status(module, :ok) do 34 | :ets.update_counter(name(module), :counter, {2, -1, 1, 0}, {:counter, 0}) 35 | end 36 | 37 | @doc "Loads fetched signers" 38 | def get_signers(module) do 39 | :ets.lookup(name(module), :signers) 40 | end 41 | 42 | @doc "Puts fetched signers" 43 | def put_signers(module, signers) do 44 | :ets.insert(name(module), {:signers, signers}) 45 | end 46 | 47 | defp name(name), do: :"#{name}.EtsCache" 48 | end 49 | -------------------------------------------------------------------------------- /test/support/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule JokenJwks.TestUtils do 2 | @moduledoc "Utilities for faking a HTTP server and signer helpers" 3 | 4 | alias Joken.Signer 5 | 6 | @rsa_private """ 7 | -----BEGIN RSA PRIVATE KEY----- 8 | MIICWwIBAAKBgQDdlatRjRjogo3WojgGHFHYLugdUWAY9iR3fy4arWNA1KoS8kVw 9 | 33cJibXr8bvwUAUparCwlvdbH6dvEOfou0/gCFQsHUfQrSDv+MuSUMAe8jzKE4qW 10 | +jK+xQU9a03GUnKHkkle+Q0pX/g6jXZ7r1/xAK5Do2kQ+X5xK9cipRgEKwIDAQAB 11 | AoGAD+onAtVye4ic7VR7V50DF9bOnwRwNXrARcDhq9LWNRrRGElESYYTQ6EbatXS 12 | 3MCyjjX2eMhu/aF5YhXBwkppwxg+EOmXeh+MzL7Zh284OuPbkglAaGhV9bb6/5Cp 13 | uGb1esyPbYW+Ty2PC0GSZfIXkXs76jXAu9TOBvD0ybc2YlkCQQDywg2R/7t3Q2OE 14 | 2+yo382CLJdrlSLVROWKwb4tb2PjhY4XAwV8d1vy0RenxTB+K5Mu57uVSTHtrMK0 15 | GAtFr833AkEA6avx20OHo61Yela/4k5kQDtjEf1N0LfI+BcWZtxsS3jDM3i1Hp0K 16 | Su5rsCPb8acJo5RO26gGVrfAsDcIXKC+bQJAZZ2XIpsitLyPpuiMOvBbzPavd4gY 17 | 6Z8KWrfYzJoI/Q9FuBo6rKwl4BFoToD7WIUS+hpkagwWiz+6zLoX1dbOZwJACmH5 18 | fSSjAkLRi54PKJ8TFUeOP15h9sQzydI8zJU+upvDEKZsZc/UhT/SySDOxQ4G/523 19 | Y0sz/OZtSWcol/UMgQJALesy++GdvoIDLfJX5GBQpuFgFenRiRDabxrE9MNUZ2aP 20 | FaFp+DyAe+b4nDwuJaW2LURbr8AEZga7oQj0uYxcYw== 21 | -----END RSA PRIVATE KEY----- 22 | """ 23 | 24 | def build_key(kid) do 25 | %{ 26 | "kid" => kid, 27 | "kty" => "RSA", 28 | "alg" => "RS512", 29 | "e" => "AQAB", 30 | "n" => 31 | "3ZWrUY0Y6IKN1qI4BhxR2C7oHVFgGPYkd38uGq1jQNSqEvJFcN93CYm16_G78FAFKWqwsJb3Wx-nbxDn6LtP4AhULB1H0K0g7_jLklDAHvI8yhOKlvoyvsUFPWtNxlJyh5JJXvkNKV_4Oo12e69f8QCuQ6NpEPl-cSvXIqUYBCs" 32 | } 33 | end 34 | 35 | def create_signer_with_kid(kid, alg \\ "RS512") do 36 | Signer.create(alg, %{"pem" => @rsa_private}, %{"kid" => kid}) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule JokenJwks.MixProject do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/joken-elixir/joken_jwks" 5 | @version "1.7.0-rc.1" 6 | 7 | def project do 8 | [ 9 | app: :joken_jwks, 10 | version: @version, 11 | name: "Joken JWKS", 12 | elixir: "~> 1.13", 13 | elixirc_paths: elixirc_paths(Mix.env()), 14 | start_permanent: Mix.env() == :prod, 15 | consolidate_protocols: Mix.env() != :test, 16 | package: package(), 17 | deps: deps(), 18 | docs: docs(), 19 | test_coverage: [tool: ExCoveralls], 20 | preferred_cli_env: [ 21 | coveralls: :test, 22 | "coveralls.detail": :test, 23 | "coveralls.post": :test, 24 | "coveralls.html": :test 25 | ] 26 | ] 27 | end 28 | 29 | def application do 30 | [ 31 | extra_applications: [:logger] 32 | ] 33 | end 34 | 35 | defp elixirc_paths(:test), do: ["lib", "test/support"] 36 | defp elixirc_paths(_), do: ["lib"] 37 | 38 | defp deps do 39 | [ 40 | {:joken, "~> 2.6"}, 41 | {:jason, "~> 1.4"}, 42 | {:tesla, "~> 1.4"}, 43 | {:hackney, "~> 1.18", optional: true}, 44 | {:telemetry, "~> 0.4.2 or ~> 1.0"}, 45 | 46 | # docs 47 | {:ex_doc, ">= 0.0.0", only: [:dev, :test], runtime: false}, 48 | 49 | # linters & coverage 50 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 51 | {:excoveralls, "~> 0.14", only: :test}, 52 | 53 | # tests 54 | {:mox, "~> 1.0", only: :test} 55 | ] 56 | end 57 | 58 | defp package do 59 | [ 60 | description: "JWKS (JSON Web Keys Set) support for Joken2", 61 | files: ["lib", "mix.exs", "README.md", "LICENSE", "CHANGELOG.md"], 62 | maintainers: ["Bryan Joseph", "Victor Nascimento"], 63 | licenses: ["Apache-2.0"], 64 | links: %{ 65 | "Changelog" => "https://hexdocs.pm/joken_jwks/changelog.html", 66 | "GitHub" => @source_url 67 | } 68 | ] 69 | end 70 | 71 | defp docs do 72 | [ 73 | extras: [ 74 | "CHANGELOG.md", 75 | LICENSE: [title: "License"], 76 | "README.md": [title: "Overview"] 77 | ], 78 | source_url: @source_url, 79 | source_ref: "v#{@version}", 80 | main: "readme", 81 | formatters: ["html"] 82 | ] 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/joken_jwks/http_fetcher.ex: -------------------------------------------------------------------------------- 1 | defmodule JokenJwks.HttpFetcher do 2 | @moduledoc """ 3 | Makes a GET request to an OpenID Connect certificates endpoint. 4 | 5 | This must be a standard JWKS URI as per the specification here: 6 | https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata 7 | 8 | This uses the `Tesla` library to make it easy to test or change the adapter 9 | if wanted. 10 | 11 | Options include: 12 | 13 | - `:http_adapter` (default: `Tesla.Adapter.Hackney`) 14 | - `:http_middlewares`: a list of extra `Tesla.Middleware` configurations 15 | 16 | See our tests for an example of mocking the HTTP fetching. 17 | """ 18 | alias Tesla.Middleware, as: M 19 | 20 | @doc """ 21 | Fetches the JWKS signers from the given url. 22 | 23 | This retries up to 10 times with a fixed delay of 500 ms until the server 24 | delivers an answer. We only perform a GET request that is idempotent. 25 | 26 | We use `:hackney` as it validates certificates automatically. 27 | """ 28 | @spec fetch_signers(binary, keyword()) :: {:ok, list} | {:error, atom} | no_return() 29 | def fetch_signers(url, opts) do 30 | metadata = %{url: url, opts: opts} 31 | 32 | :telemetry.span([:joken_jwks, :http_fetcher], metadata, fn -> 33 | {do_fetch(url, opts), %{}} 34 | end) 35 | end 36 | 37 | defp do_fetch(url, opts) do 38 | with {:ok, resp} <- Tesla.get(new(opts), url), 39 | {:status, 200} <- {:status, resp.status}, 40 | {:keys, keys} when not is_nil(keys) <- {:keys, resp.body["keys"]} do 41 | {:ok, keys} 42 | else 43 | {:status, status} when is_integer(status) and status >= 400 and status < 500 -> 44 | {:error, :jwks_client_http_error} 45 | 46 | {:status, status} when is_integer(status) and status >= 500 -> 47 | {:error, :jwks_server_http_error} 48 | 49 | {:status, _status} -> 50 | {:error, :status_not_200} 51 | 52 | {:error, :econnrefused} -> 53 | {:error, :could_not_reach_jwks_url} 54 | 55 | {:keys, nil} -> 56 | {:error, :no_keys_on_response} 57 | end 58 | end 59 | 60 | @default_adapter Tesla.Adapter.Hackney 61 | 62 | defp new(opts) do 63 | adapter = 64 | Application.get_env(:tesla, __MODULE__)[:adapter] || 65 | Application.get_env(:tesla, :adapter, @default_adapter) 66 | 67 | adapter = opts[:http_adapter] || adapter 68 | 69 | middleware = 70 | [ 71 | {M.JSON, decode_content_types: ["application/jwk-set+json"]}, 72 | {M.Retry, 73 | delay: opts[:http_delay_per_retry] || 500, 74 | max_retries: opts[:http_max_retries_per_fetch] || 10} 75 | ] ++ (opts[:http_middlewares] || []) 76 | 77 | Tesla.client(middleware, adapter) 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/joken_jwks.ex: -------------------------------------------------------------------------------- 1 | defmodule JokenJwks do 2 | @moduledoc """ 3 | `Joken.Hooks` implementation for fetching `Joken.Signer`s from public JWKS URLs. 4 | 5 | This hook is intended to be used when you are _verifying_ a token is signed with 6 | a well known public key. It only overrides the `before_verify/2` callback providing a 7 | `Joken.Signer` for the given token. It is important to notice this is not meant for 8 | use when **GENERATING** a token. So, using this hook with `Joken.encode_and_sign` 9 | function **WILL NOT WORK!!!** 10 | 11 | To use it, pass this hook to Joken either with the `add_hook/2` macro or directly 12 | to each `Joken` function. Example: 13 | 14 | defmodule MyToken do 15 | use Joken.Config 16 | 17 | add_hook(JokenJwks, strategy: MyFetchingStrategy) 18 | 19 | # rest of your token config 20 | end 21 | 22 | Or: 23 | 24 | Joken.verify_and_validate(config, token, nil, context, [{JokenJwks, strategy: MyStrategy}]) 25 | 26 | ## Fetching strategy 27 | 28 | Very rarely, your authentication server might rotate or block its keys. Key rotation is the 29 | process of issuing a new key that in time will replace the older key. This is security hygiene 30 | and should/might be a regular process. 31 | 32 | Sometimes it is important to block keys because they got leaked or for any other reason. 33 | 34 | Other times you simply don't control the authentication server and can't ensure the keys won't 35 | change. This is the most common scenario for this hook. 36 | 37 | In these cases (and some others) it is important to have a cache invalidation strategy: all your 38 | cached keys should be refreshed. Since the best strategy might differ for each use case, there 39 | is a behaviour that can be customized as the "fetching strategy", that is: when to fetch and re-fetch 40 | keys. `JokenJwks` has a default strategy that tries to be smart and cover most use cases by default. 41 | It combines a time based state machine to avoid overflowing the system with re-fetching keys. If that 42 | is not a good option for your use case, it can still be configured. Please, see 43 | `JokenJwks.SignerMatchStrategy` or `JokenJwks.DefaultStrategyTemplate` docs for more information. 44 | """ 45 | 46 | require Logger 47 | 48 | use Joken.Hooks 49 | 50 | @impl true 51 | def before_verify(hook_options, {token, _signer}) do 52 | with strategy <- hook_options[:strategy] || raise("No strategy provided"), 53 | {:ok, kid} <- get_token_kid(token), 54 | {:ok, signer} <- strategy.match_signer_for_kid(kid, hook_options) do 55 | {:cont, {token, signer}} 56 | else 57 | err -> {:halt, err} 58 | end 59 | end 60 | 61 | defp get_token_kid(token) do 62 | with {:ok, headers} <- Joken.peek_header(token), 63 | {:kid, kid} when not is_nil(kid) <- {:kid, headers["kid"]} do 64 | {:ok, kid} 65 | else 66 | {:kid, nil} -> {:error, :no_kid_in_token_header} 67 | err -> err 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [1.7.0] - 2025-01-19 11 | 12 | - All the changes in the 1.7 RCs! 13 | - CI housekeeping (thanks @kianmeng) 14 | 15 | ## [1.7.0-rc.1] 16 | 17 | - fix: `:first_fetch_sync` default is `false` 18 | 19 | ## [1.7.0-rc.0] 20 | 21 | ### BREAKING CHANGES 22 | 23 | - No more custom logging (use Logger facilities) 24 | 25 | If you need to disable specific logging from the library, you can use default `Logger` facilities like `Logger.put_application_level/2` (which accepts `:none`) and so on. There is no need to use custom logging here anymore. 26 | 27 | - No more custom telemetry (use Teslas built-in) 28 | 29 | Tesla has its own middleware for telemetry events. We should just use that :) 30 | 31 | - Different process strategy (thanks @lovebes) 32 | 33 | 34 | ## [1.6.0] - 2021-10-26 35 | 36 | This release brings a more resilient parsing of JWK sets. If the provider has encryption keys along with signing keys, we skip them. 37 | 38 | Also, since JWS specification has some algorithms that are not natively available to all installations (Edwards curves for example) we also skip those. This aims to avoid a server not loading other keys if one is not supported. 39 | 40 | ### Changed 41 | 42 | - Update Hackney spec to include 1.18 versions (thanks to @J3RN) 43 | - Add patch level to 1.18 spec (thanks to @J3RN) 44 | - More resilient parsing of JWKs (#28) 45 | 46 | ## [1.5.0] - 2020-12-23 47 | 48 | ### Changed 49 | 50 | - Use hackney v1.17.4 51 | - Documentation re-organization (#22 thanks to @kianmeng) 52 | - Conditional telemetry version (#23 thanks to @J3RN) 53 | 54 | ## [1.4.0] - 2020-09-27 55 | 56 | ### Changed 57 | 58 | - (Emil Bostijancic) upgrades hackney dependency to 1.16.0 (#17) 59 | - Updated deps 60 | 61 | ### Fixed 62 | 63 | - (@seancribbs) Address unmatched return warnings from Dialyzer (#16) 64 | 65 | ## [1.3.1] - 2020-03-02 66 | 67 | ### Fixed 68 | 69 | - (@duzzifelipe) #13 fix: change fetch signers spec 70 | 71 | ## [1.3.0] - 2020-03-02 72 | 73 | ### Added 74 | 75 | - (@duzzifelipe) #12 feat: telemetry middleware for tesla 76 | 77 | ## [1.2.0] - 2019-11-08 78 | 79 | ### Added 80 | 81 | - (René Mygind Andersen) #7 Adds support for parsing JWKS returned with content-type 'applicat… 82 | 83 | ### Changed 84 | 85 | - (@ltj) Use hackney v1.15.2 86 | 87 | ## [1.1.0] - 2019-03-05 88 | 89 | ### Added 90 | 91 | - Options for explicitly telling which algorithm will use for the parsed signers; 92 | - HTTP options for retry and adapter; 93 | - Integration tests for Google and Microsoft JWKS endpoints. 94 | 95 | ### Fixed 96 | 97 | - Fixed docs about how to use DefaultStrategyTemplate and Fixed spelling (#4 thanks to @bforchhammer) 98 | 99 | ## [1.0.0] - 2019-01-02 100 | 101 | ### Added 102 | 103 | - First version of the library with a default time window strategy for refetching signers. 104 | 105 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | check_duplicate_runs: 11 | name: Check for duplicate runs 12 | continue-on-error: true 13 | runs-on: ubuntu-latest 14 | outputs: 15 | should_skip: ${{ steps.skip_check.outputs.should_skip }} 16 | steps: 17 | - id: skip_check 18 | uses: fkirc/skip-duplicate-actions@master 19 | with: 20 | concurrent_skipping: always 21 | cancel_others: true 22 | skip_after_successful_duplicate: true 23 | paths_ignore: '["**/README.md", "**/CHANGELOG.md", "**/LICENSE.txt"]' 24 | do_not_skip: '["pull_request"]' 25 | 26 | tests: 27 | name: Run tests 28 | 29 | needs: check_duplicate_runs 30 | if: ${{ needs.check_duplicate_runs.outputs.should_skip != 'true' }} 31 | 32 | env: 33 | FORCE_COLOR: 1 34 | 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | include: 39 | - elixir: "1.14" 40 | otp: "24" 41 | - elixir: "1.15" 42 | otp: "25" 43 | - elixir: "1.16" 44 | otp: "26" 45 | - elixir: "1.17" 46 | otp: "27" 47 | - elixir: "1.18" 48 | otp: "27" 49 | lint: true 50 | 51 | runs-on: ubuntu-latest 52 | 53 | steps: 54 | - name: Checkout 55 | uses: actions/checkout@v4 56 | 57 | - name: Set up Elixir 58 | uses: erlef/setup-beam@v1 59 | with: 60 | elixir-version: ${{ matrix.elixir }} 61 | otp-version: ${{ matrix.otp }} 62 | 63 | - name: Restore deps and _build cache 64 | uses: actions/cache@v4 65 | with: 66 | path: | 67 | deps 68 | _build 69 | key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }}-git-${{ github.sha }} 70 | restore-keys: | 71 | deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 72 | deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}- 73 | 74 | - name: Create dializer plts path 75 | run: mkdir -p priv/plts 76 | 77 | - name: Restore plts cache 78 | uses: actions/cache@v4 79 | with: 80 | path: priv/plts 81 | key: plts-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }}-${{ github.sha }} 82 | restore-keys: | 83 | plts-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 84 | plts-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}- 85 | 86 | - name: Install dependencies 87 | run: mix deps.get --only test 88 | 89 | - name: Check source code format 90 | run: mix format --check-formatted 91 | if: ${{ matrix.lint }} 92 | 93 | - name: Perform source code static analysis 94 | run: mix credo 95 | if: ${{ matrix.lint }} 96 | env: 97 | MIX_ENV: test 98 | 99 | - name: Remove compiled application files 100 | run: mix clean 101 | 102 | - name: Compile dependencies 103 | run: mix compile 104 | env: 105 | MIX_ENV: test 106 | 107 | - name: Compile & lint dependencies 108 | run: mix compile --warnings-as-errors 109 | env: 110 | MIX_ENV: test 111 | 112 | - name: Run tests 113 | run: mix coveralls --warnings-as-errors 114 | if: ${{ matrix.lint }} 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Joken JWKS 2 | 3 | [![CI](https://github.com/joken-elixir/joken/actions/workflows/ci.yml/badge.svg)](https://github.com/joken-elixir/joken_jwks/actions/workflows/ci.yml) 4 | [![Module Version](https://img.shields.io/hexpm/v/joken_jwks.svg)](https://hex.pm/packages/joken_jwks) 5 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/joken_jwks/) 6 | [![Total Download](https://img.shields.io/hexpm/dt/joken_jwks.svg)](https://hex.pm/packages/joken_jwks) 7 | [![License](https://img.shields.io/hexpm/l/joken_jwks.svg)](https://github.com/joken-elixir/joken_jwks/blob/master/LICENSE) 8 | [![Last Updated](https://img.shields.io/github/last-commit/joken-elixir/joken_jwks.svg)](https://github.com/joken-elixir/joken_jwks/commits/master) 9 | 10 | A `Joken.Hooks` implementation that builds a signer out of a JWKS url for verification. 11 | 12 | ## Usage 13 | 14 | Please see our [documentation](https://hexdocs.pm/joken_jwks/) for usage. 15 | 16 | ## Installation 17 | 18 | ```elixir 19 | def deps do 20 | [ 21 | {:joken_jwks, "~> 1.6.0"} 22 | ] 23 | end 24 | ``` 25 | 26 | # JWKS (JSON Web Key Set) 27 | 28 | When using JWTs for digital signing we need a key to sign and verify the contents of the token. Many times the part that is doing signature verification is the same that does the signing and so, it possesses both the private and public key. Other times, this is not the case. 29 | 30 | When using an authentication server that is different than the "business" server, the later has no control about the keys being used to authenticate a request. On these cases, the authentication server publishes its own public key so that anyone can validate tokens that it has generated before with the matching private key. 31 | 32 | This scenario is common on delegated authentication as mentioned. One specification that uses this approach is OAuth2 with OpenID Connect. The auth server has a public configuration endpoint where there is plenty information about how to connect with the server. One such information is the JWKS (JSON Web Key Set): a list of public keys the server uses to generate signed tokens. 33 | 34 | Example of well known JWKS URIs: 35 | 36 | - Google: https://www.googleapis.com/oauth2/v3/certs 37 | - Microsoft: https://login.microsoftonline.com/common/discovery/keys 38 | 39 | ## Joken JWKS hook 40 | 41 | If your server is using Joken 2 to validate tokens, then you can use this hook to retrieve the list of signers from a JWKS URI. This is how it happens: 42 | 43 | 1. Build you token configuration (either with `use Joken.Config` or directly) 44 | 2. Initialize your fetching strategy 45 | 3. Pass this hook to your verify call: 46 | - If you are using `Joken.Config` then it's just a matter of `add_hook(JokenJwks, strategy: <>)` 47 | - If you are calling `Joken.verify_*` directly, you can pass the hook as a last parameter 48 | 49 | **Remember** that there can't be a default signer otherwise it will have precedence over this! 50 | 51 | ## Architecture 52 | 53 | This hook will call the behaviour `JokenJwks.SignerMatchstrategy` for every token. It is the implementation job to decide how to choose a signer for the token. 54 | 55 | The only occasion where this will not call the behaviour is if the given token is not properly formed nor it contains a "kid" claim on its header. JWKS mandates this claim so that we know which key to use for verifying. 56 | 57 | A very naive approach of implementing the callback would be to fetch signers upon startup and then re-fetching every time a token kid does not match with the loaded cache. 58 | 59 | This could potentially open an attack vector for massively hitting the authentication server. Of course, the auth server JWKS url is public and an attacker could just hit it directly, but it is wise to have some defense mechanism in place when developing your strategy. 60 | 61 | ## Default Strategy Template 62 | 63 | `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 re-fetches the cache. So, it will fetch JWKS once every minute tops. 64 | 65 | ## Interpretation of the JWKS RFC 66 | 67 | 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: 68 | 69 | - Every key must have a "kid" (even if there is only one key) 70 | - We skip keys that are used for encryption (which have a field "use" with value "enc") 71 | - We skip keys that have JWE algs or unsupported JWS algorithms 72 | - If no "alg" claim is provided, then the user must pass the option "explicit_alg" 73 | 74 | That's it for now :) 75 | 76 | ## Copyright and License 77 | 78 | Copyright (c) 2018 Victor Nascimento 79 | 80 | Licensed under the Apache License, Version 2.0 (the "License"); 81 | you may not use this file except in compliance with the License. 82 | You may obtain a copy of the License at [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0) 83 | 84 | Unless required by applicable law or agreed to in writing, software 85 | distributed under the License is distributed on an "AS IS" BASIS, 86 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 87 | See the License for the specific language governing permissions and 88 | limitations under the License. 89 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, 4 | "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.42", "f23d856f41919f17cd06a493923a722d87a2d684f143a1e663c04a2b93100682", [:mix], [], "hexpm", "6915b6ca369b5f7346636a2f41c6a6d78b5af419d61a611079189233358b8b8b"}, 6 | "ex_doc": {:hex, :ex_doc, "0.36.1", "4197d034f93e0b89ec79fac56e226107824adcce8d2dd0a26f5ed3a95efc36b1", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d7d26a7cf965dacadcd48f9fa7b5953d7d0cfa3b44fa7a65514427da44eafd89"}, 7 | "excoveralls": {:hex, :excoveralls, "0.18.3", "bca47a24d69a3179951f51f1db6d3ed63bca9017f476fe520eb78602d45f7756", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "746f404fcd09d5029f1b211739afb8fb8575d775b21f6a3908e7ce3e640724c6"}, 8 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 9 | "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, 10 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 11 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 12 | "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, 13 | "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, 14 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 15 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 16 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 17 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 18 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 19 | "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, 20 | "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, 21 | "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, 22 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 23 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 24 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 25 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 26 | "tesla": {:hex, :tesla, "1.13.2", "85afa342eb2ac0fee830cf649dbd19179b6b359bec4710d02a3d5d587f016910", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "960609848f1ef654c3cdfad68453cd84a5febecb6ed9fed9416e36cd9cd724f9"}, 27 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 28 | } 29 | -------------------------------------------------------------------------------- /lib/joken_jwks/default_strategy_template.ex: -------------------------------------------------------------------------------- 1 | defmodule JokenJwks.DefaultStrategyTemplate do 2 | @moduledoc """ 3 | A `JokenJwks.SignerMatchStrategy` template that has a window of time for refreshing its 4 | cache. This is a template and not a concrete implementation. You should `use` this module 5 | in order to use the default strategy. 6 | 7 | This implementation is a task that should be supervised. It loops on a time window checking 8 | whether it should re-fetch keys or not. 9 | 10 | Every time a bad kid is received it writes to an ets table a counter to 1. When the task 11 | loops, it polls for the counter value. If it is more than zero it starts re-fetching the 12 | cache. Upon successful fetching, it zeros the counter once again. This way we avoid 13 | overloading the JWKS server. 14 | 15 | It will try to fetch signers when supervision starts it. This can be a sync or async operation 16 | depending on the value of `first_fetch_sync`. It defaults to `false`. 17 | 18 | ## Resiliency 19 | 20 | This strategy tries to be smart about keys it can USE to verify signatures. For example, if the 21 | provider has encryption keys, it will skip those (any key with field "use" with value "enc"). 22 | 23 | Also, if the running BEAM instance has no support for a given signature algorithm (possibly not implemented 24 | on the given OpenSSL + BEAM + JOSE combination) this implementation will also skip those. 25 | 26 | Be sure to check your logs as if there are NO signers available it will log a warning telling you 27 | that. 28 | 29 | For debugging purpouses, calling the function `fetch_signers/2` directly might be helpful. 30 | 31 | ## Usage 32 | 33 | This strategy must be under your apps' supervision tree. It must be explicitly used under a 34 | module so that you can have more than one JWKS source. 35 | 36 | When using this strategy, there is an `init_opts/1` callback that can be overridden. This is called 37 | upon supervision start. It should return a keyword list with all the options. This follows the 38 | standard practice of allowing a callback for using runtime configuration. It can override all 39 | other options as this has higher preference. 40 | 41 | ## Configuration 42 | 43 | Other than the `init_opts/1` callback you can pass options through `Config` and when starting 44 | the supervisor. The order of preference in least significant order is: 45 | 46 | - Per environment `Config` 47 | - Supervisor child options 48 | - `init_opts/1` callback 49 | 50 | The only mandatory option is `jwks_url` (`binary()`) that is, usually, a 51 | runtime parameter like a system environment variable. It is recommended to 52 | use the `init_opts/1` callback. 53 | 54 | Other options are: 55 | 56 | - `time_interval` (`integer()` - default 60_000 (1 minute)): time interval 57 | for polling if it is needed to re-fetch the keys 58 | 59 | - `should_start` (`boolean()` - default `true`): whether to start the 60 | supervised polling task. For tests, this should be false 61 | 62 | - `first_fetch_sync` (`boolean()` - default `false`): whether to fetch the 63 | first time synchronously or async 64 | 65 | - `explicit_alg` (`String.t()`): the JWS algorithm for use with the key. 66 | Overrides the one in the JWK 67 | 68 | - `http_max_retries_per_fetch` (`pos_integer()` - default `10`): passed to 69 | `Tesla.Middleware.Retry` 70 | 71 | - `http_delay_per_retry` (`pos_integer()` - default `500`): passed to 72 | `Tesla.Middleware.Retry` 73 | 74 | ### Examples 75 | 76 | defmodule JokenExample.MyStrategy do 77 | use JokenJwks.DefaultStrategyTemplate 78 | 79 | def init_opts(opts) do 80 | url = # fetch url ... 81 | Keyword.merge(opts, jwks_url: url) 82 | end 83 | end 84 | 85 | defmodule JokenExample.Application do 86 | @doc false 87 | def start(_type, _args) do 88 | import Supervisor.Spec, warn: false 89 | 90 | children = [ 91 | {MyStrategy, time_interval: 2_000} 92 | ] 93 | 94 | opts = [strategy: :one_for_one] 95 | Supervisor.start_link(children, opts) 96 | end 97 | end 98 | 99 | Then on your token configuration module: 100 | 101 | defmodule MyToken do 102 | use Joken.Config 103 | 104 | add_hook(JokenJwks, strategy: MyStrategy) 105 | # rest of your token config 106 | end 107 | 108 | 109 | ## Telemetry events 110 | 111 | This library produces events for helping understand its behaviour. Event prefix is 112 | `[:joken_jwks, :default_strategy]`. It always add the module as a metadata. 113 | 114 | This can be useful to implement logging. 115 | 116 | Events: 117 | 118 | - `[:joken_jwks, :default_strategy, :refetch]`: starts refetching 119 | - `[:joken_jwks, :default_strategy, :signers]`: signers sucessfully fetched 120 | - `[:joken_jwks, :http_fetcher, :start | :stop | :exception]`: http lifecycle 121 | """ 122 | 123 | require Logger 124 | 125 | alias TestToken.Strategy.EtsCache 126 | alias JokenJwks.DefaultStrategyTemplate.EtsCache 127 | alias JokenJwks.DefaultStrategyTemplate 128 | alias Joken.Signer 129 | alias JokenJwks.{HttpFetcher, SignerMatchStrategy} 130 | 131 | @telemetry_prefix [:joken_jwks, :default_strategy] 132 | 133 | defmacro __using__(_opts) do 134 | quote do 135 | use GenServer, restart: :transient 136 | 137 | alias JokenJwks.DefaultStrategyTemplate 138 | alias JokenJwks.SignerMatchStrategy 139 | 140 | @behaviour SignerMatchStrategy 141 | 142 | @doc "Callback for initializing options upon strategy startup" 143 | @spec init_opts(opts :: Keyword.t()) :: Keyword.t() 144 | def init_opts(opts), do: opts 145 | 146 | @impl SignerMatchStrategy 147 | def match_signer_for_kid(kid, opts), 148 | do: DefaultStrategyTemplate.match_signer_for_kid(__MODULE__, kid, opts) 149 | 150 | defoverridable init_opts: 1 151 | 152 | @doc false 153 | def start_link(opts), do: DefaultStrategyTemplate.start_link(__MODULE__, opts) 154 | 155 | # Server (callbacks) 156 | @impl GenServer 157 | def init(opts), do: DefaultStrategyTemplate.init(__MODULE__, opts) 158 | 159 | @doc false 160 | @impl GenServer 161 | def handle_info(:check_fetch, state) do 162 | DefaultStrategyTemplate.check_fetch(__MODULE__, state[:jwks_url], state) 163 | DefaultStrategyTemplate.schedule_check_fetch(__MODULE__, state[:time_interval]) 164 | 165 | {:noreply, state} 166 | end 167 | end 168 | end 169 | 170 | @doc false 171 | def start_link(module, opts) do 172 | opts = 173 | Application.get_env(:joken_jwks, module, []) 174 | |> Keyword.merge(opts) 175 | |> module.init_opts() 176 | 177 | opts[:jwks_url] || raise "No url set for fetching JWKS!" 178 | 179 | GenServer.start_link(module, opts, name: module) 180 | end 181 | 182 | @doc false 183 | def init(module, opts) do 184 | [_, _, {:jws, {:alg, algs}}] = JOSE.JWA.supports() 185 | 186 | opts = 187 | opts 188 | |> Keyword.put_new(:time_interval, 60 * 1_000) 189 | |> Keyword.put(:jws_supported_algs, algs) 190 | |> Keyword.put(:mod, module) 191 | 192 | # init callback runs in the server process already 193 | EtsCache.new(module) 194 | 195 | if Keyword.get(opts, :first_fetch_sync) do 196 | fetch_signers(module, opts[:jwks_url], opts) 197 | end 198 | 199 | if Keyword.get(opts, :should_start, true) do 200 | EtsCache.set_status(module, :refresh) 201 | schedule_check_fetch(module, opts[:time_interval]) 202 | {:ok, opts} 203 | else 204 | :ignore 205 | end 206 | end 207 | 208 | @doc false 209 | def check_fetch(module, url, opts) do 210 | case EtsCache.check_state(module) do 211 | # no need to re-fetch 212 | 0 -> 213 | :ok 214 | 215 | # start re-fetching 216 | _counter -> 217 | :telemetry.execute(@telemetry_prefix ++ [:refetch], %{count: 1}, %{module: module}) 218 | fetch_signers(module, url, opts) 219 | end 220 | end 221 | 222 | @doc false 223 | def match_signer_for_kid(module, kid, _hook_options) do 224 | with {:cache, [{:signers, signers}]} <- {:cache, EtsCache.get_signers(module)}, 225 | {:signer, signer} when not is_nil(signer) <- {:signer, signers[kid]} do 226 | {:ok, signer} 227 | else 228 | {:signer, nil} -> 229 | EtsCache.set_status(module, :refresh) 230 | {:error, :kid_does_not_match} 231 | 232 | {:cache, []} -> 233 | {:error, :no_signers_fetched} 234 | 235 | err -> 236 | err 237 | end 238 | end 239 | 240 | @doc "Fetch signers with `JokenJwks.HttpFetcher`" 241 | def fetch_signers(module, url, opts) do 242 | with {:ok, keys} <- HttpFetcher.fetch_signers(url, opts), 243 | {:ok, signers} <- validate_and_parse_keys(keys, opts) do 244 | :telemetry.execute( 245 | @telemetry_prefix ++ [:signers], 246 | %{count: 1}, 247 | %{module: module, signers: signers} 248 | ) 249 | 250 | if signers == %{} do 251 | Logger.warning("NO VALID SIGNERS FOUND!") 252 | end 253 | 254 | true = EtsCache.put_signers(module, signers) 255 | EtsCache.set_status(module, :ok) 256 | 257 | {:ok, opts} 258 | else 259 | {:error, _reason} = err -> 260 | Logger.error("Failed to fetch signers. Reason: #{inspect(err)}") 261 | EtsCache.set_status(module, :refresh) 262 | err 263 | 264 | err -> 265 | Logger.error("Unexpected error while fetching signers. Reason: #{inspect(err)}") 266 | EtsCache.set_status(module, :refresh) 267 | err 268 | end 269 | end 270 | 271 | defp validate_and_parse_keys(keys, opts) when is_list(keys) do 272 | Enum.reduce_while(keys, {:ok, %{}}, fn key, {:ok, acc} -> 273 | case parse_signer(key, opts) do 274 | {:ok, signer} -> {:cont, {:ok, Map.put(acc, key["kid"], signer)}} 275 | # We don't support "enc" keys but should not break otherwise 276 | {:error, :not_signing_key} -> {:cont, {:ok, acc}} 277 | # We skip unknown JWS algorithms or JWEs 278 | {:error, :not_signing_alg} -> {:cont, {:ok, acc}} 279 | e -> {:halt, e} 280 | end 281 | end) 282 | end 283 | 284 | defp parse_signer(key, opts) do 285 | with {:use, true} <- {:use, key["use"] != "enc"}, 286 | {:kid, kid} when is_binary(kid) <- {:kid, key["kid"]}, 287 | {:ok, alg} <- get_algorithm(key["alg"], opts[:explicit_alg]), 288 | {:jws_alg?, true} <- {:jws_alg?, alg in opts[:jws_supported_algs]}, 289 | {:ok, _signer} = res <- {:ok, Signer.create(alg, key)} do 290 | res 291 | else 292 | {:use, false} -> {:error, :not_signing_key} 293 | {:kid, _} -> {:error, :kid_not_binary} 294 | {:jws_alg?, false} -> {:error, :not_signing_alg} 295 | err -> err 296 | end 297 | rescue 298 | e -> 299 | Logger.error(""" 300 | Error while parsing a key entry fetched from the network. 301 | 302 | This should be investigated by a human. 303 | 304 | Key: #{inspect(key)} 305 | 306 | Error: #{inspect(e)} 307 | """) 308 | 309 | {:error, :invalid_key_params} 310 | end 311 | 312 | # According to JWKS spec (https://tools.ietf.org/html/rfc7517#section-4.4) the "alg"" claim 313 | # is not mandatory. This is why we allow this to be passed as a hook option. 314 | # 315 | # We give preference to the one provided as option 316 | defp get_algorithm(nil, nil), do: {:error, :no_algorithm_supplied} 317 | defp get_algorithm(_, alg) when is_binary(alg), do: {:ok, alg} 318 | defp get_algorithm(alg, _) when is_binary(alg), do: {:ok, alg} 319 | defp get_algorithm(_, _), do: {:error, :bad_algorithm} 320 | 321 | @doc false 322 | def schedule_check_fetch(module, interval), 323 | do: Process.send_after(module, :check_fetch, interval) 324 | end 325 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /test/default_strategy_template_test.exs: -------------------------------------------------------------------------------- 1 | :ok = Application.ensure_started(:telemetry) 2 | 3 | defmodule JokenJwks.DefaultStrategyTest do 4 | use ExUnit.Case, async: false 5 | 6 | import ExUnit.CaptureLog 7 | import Mox 8 | import Tesla.Mock, only: [json: 1, json: 2] 9 | 10 | alias JokenJwks.TestUtils 11 | alias JokenJwks.DefaultStrategyTemplate.EtsCache 12 | 13 | @telemetry_events [ 14 | [:joken_jwks, :default_strategy, :refetch], 15 | [:joken_jwks, :default_strategy, :signers], 16 | [:joken_jwks, :http_fetcher, :start], 17 | [:joken_jwks, :http_fetcher, :stop], 18 | [:joken_jwks, :http_fetcher, :exception], 19 | [:tesla, :request, :start] 20 | ] 21 | 22 | setup :set_mox_global 23 | setup :verify_on_exit! 24 | 25 | setup do 26 | self = self() 27 | on_exit(fn -> :telemetry.detach("telemetry-test") end) 28 | 29 | capture_log(fn -> 30 | :telemetry.attach_many( 31 | "telemetry-test", 32 | @telemetry_events, 33 | &reply_telemetry(self, &1, &2, &3, &4), 34 | nil 35 | ) 36 | end) 37 | 38 | :ok 39 | end 40 | 41 | test "can fetch keys" do 42 | setup_jwks() 43 | 44 | token = TestToken.generate_and_sign!(%{}, TestUtils.create_signer_with_kid("id2")) 45 | assert {:ok, %{}} == TestToken.verify_and_validate(token) 46 | 47 | assert_receive {:telemetry_event, [:joken_jwks, :http_fetcher, :start], _, _} 48 | assert_receive {:telemetry_event, [:joken_jwks, :http_fetcher, :stop], _, _} 49 | 50 | assert_receive {:telemetry_event, [:joken_jwks, :default_strategy, :signers], %{count: 1}, 51 | %{signers: %{"id1" => _, "id2" => _}}} 52 | end 53 | 54 | test "fails if kid does not match" do 55 | setup_jwks() 56 | 57 | token = TestToken.generate_and_sign!(%{}, TestUtils.create_signer_with_kid("id3")) 58 | assert {:error, :kid_does_not_match} == TestToken.verify_and_validate(token) 59 | 60 | assert_receive {:telemetry_event, [:joken_jwks, :http_fetcher, :start], _, _} 61 | assert_receive {:telemetry_event, [:joken_jwks, :http_fetcher, :stop], _, _} 62 | 63 | assert_receive {:telemetry_event, [:joken_jwks, :default_strategy, :signers], %{count: 1}, 64 | %{signers: %{"id1" => _, "id2" => _}}} 65 | end 66 | 67 | test "fails if it can't fetch" do 68 | expect_call(fn %{url: "http://jwks/500"} -> {:ok, %Tesla.Env{status: 500}} end) 69 | 70 | assert capture_log(fn -> 71 | start_supervised!( 72 | {TestToken.Strategy, jwks_url: "http://jwks/500", first_fetch_sync: true} 73 | ) 74 | 75 | token = TestToken.generate_and_sign!(%{}, TestUtils.create_signer_with_kid("id1")) 76 | assert {:error, :no_signers_fetched} == TestToken.verify_and_validate(token) 77 | end) =~ "[error] Failed to fetch signers. Reason: {:error, :jwks_server_http_error}" 78 | 79 | assert_receive {:telemetry_event, [:joken_jwks, :http_fetcher, :start], _, _} 80 | assert_receive {:telemetry_event, [:joken_jwks, :http_fetcher, :stop], _, _} 81 | end 82 | 83 | test "fails if http raises" do 84 | expect_call(fn %{url: "http://jwks"} -> {:error, :econnrefused} end) 85 | 86 | assert capture_log(fn -> 87 | start_supervised!( 88 | {TestToken.Strategy, 89 | jwks_url: "http://jwks", http_max_retries_per_fetch: 0, first_fetch_sync: true} 90 | ) 91 | 92 | token = TestToken.generate_and_sign!(%{}, TestUtils.create_signer_with_kid("id1")) 93 | assert {:error, :no_signers_fetched} == TestToken.verify_and_validate(token) 94 | end) =~ "[error] Failed to fetch signers. Reason: {:error, :could_not_reach_jwks_url}" 95 | 96 | assert_receive {:telemetry_event, [:joken_jwks, :http_fetcher, :start], _, _} 97 | end 98 | 99 | test "fails if no option was provided" do 100 | assert_raise(RuntimeError, ~r/No url set for fetching JWKS!/, fn -> 101 | start_supervised!({TestToken.Strategy, []}) 102 | end) 103 | end 104 | 105 | test "can configure window of time for searching for new signers" do 106 | setup_jwks(500) 107 | 108 | expect_call(fn %{url: "http://jwks"} -> 109 | {:ok, 110 | json(%{ 111 | "keys" => [ 112 | TestUtils.build_key("id1"), 113 | TestUtils.build_key("id2"), 114 | TestUtils.build_key("id3") 115 | ] 116 | })} 117 | end) 118 | 119 | token = TestToken.generate_and_sign!(%{}, TestUtils.create_signer_with_kid("id3")) 120 | assert {:error, :kid_does_not_match} == TestToken.verify_and_validate(token) 121 | 122 | :timer.sleep(800) 123 | assert {:ok, %{}} == TestToken.verify_and_validate(token) 124 | end 125 | 126 | test "fetches only one per window of time invariably" do 127 | setup_jwks(100) 128 | 129 | expect_call(fn %{url: "http://jwks"} -> 130 | {:ok, 131 | json(%{ 132 | "keys" => [ 133 | TestUtils.build_key("id1"), 134 | TestUtils.build_key("id2"), 135 | TestUtils.build_key("id3") 136 | ] 137 | })} 138 | end) 139 | 140 | token = TestToken.generate_and_sign!(%{}, TestUtils.create_signer_with_kid("id3")) 141 | assert {:error, :kid_does_not_match} == TestToken.verify_and_validate(token) 142 | 143 | # Let's wait for next poll... 144 | # By default we are only populating id1 and id2 145 | # On poll it will add id3 146 | :timer.sleep(200) 147 | assert {:ok, %{}} == TestToken.verify_and_validate(token) 148 | end 149 | 150 | test "allows not fetching sync the first time" do 151 | token = TestToken.generate_and_sign!(%{}, TestUtils.create_signer_with_kid("id1")) 152 | 153 | expect_call(fn %{url: "http://jwks"} -> 154 | {:ok, json(%{"keys" => [TestUtils.build_key("id1")]})} 155 | end) 156 | 157 | capture_log(fn -> 158 | start_supervised!( 159 | {TestToken.Strategy, jwks_url: "http://jwks", first_fetch_sync: false, time_interval: 100} 160 | ) 161 | end) 162 | 163 | assert {:error, :no_signers_fetched} == TestToken.verify_and_validate(token) 164 | 165 | # Let's wait for next poll... 166 | :timer.sleep(120) 167 | assert {:ok, %{}} == TestToken.verify_and_validate(token) 168 | end 169 | 170 | test "can skip start polling and fetching" do 171 | # expect 0 invocations 172 | expect_call(0, fn _, _opts -> :ok end) 173 | 174 | capture_log(fn -> 175 | start_supervised!( 176 | {TestToken.Strategy, 177 | jwks_url: "http://jwks", should_start: false, first_fetch_sync: false} 178 | ) 179 | end) 180 | 181 | assert :ets.whereis(TestToken.Strategy.EtsCache) == :undefined 182 | end 183 | 184 | test "can set extra tesla middlewares" do 185 | expect_call(fn %{url: "http://jwks/500"} -> {:ok, json(%{}, status: 500)} end) 186 | 187 | assert capture_log(fn -> 188 | start_supervised!( 189 | {TestToken.Strategy, 190 | jwks_url: "http://jwks/500", 191 | http_middlewares: [Tesla.Middleware.Telemetry], 192 | first_fetch_sync: true} 193 | ) 194 | end) =~ "[error] Failed to fetch signers. Reason: {:error, :jwks_server_http_error}" 195 | 196 | assert_receive {:telemetry_event, [:tesla, :request, :start], %{system_time: _}, 197 | %{env: %Tesla.Env{}}} 198 | end 199 | 200 | test "can set options on callback init_opts/1" do 201 | expect_call(fn %{url: "http://jwks"} -> 202 | {:ok, json(%{"keys" => [TestUtils.build_key("id1"), TestUtils.build_key("id2")]})} 203 | end) 204 | 205 | # sets jwks URL dynamically on boot 206 | start_supervised!(InitOptsToken.Strategy) 207 | assert EtsCache.get_signers(InitOptsToken.Strategy)[:signers] |> Map.keys() == ["id1", "id2"] 208 | end 209 | 210 | test "can override alg" do 211 | expect_call(fn %{url: "http://jwks"} -> 212 | assert key = "id1" |> TestUtils.build_key() |> Map.put("alg", "RS256") 213 | assert key["alg"] == "RS256" 214 | {:ok, json(%{"keys" => [key]})} 215 | end) 216 | 217 | start_supervised!( 218 | {TestToken.Strategy, jwks_url: "http://jwks", explicit_alg: "RS384", first_fetch_sync: true} 219 | ) 220 | 221 | token = TestToken.generate_and_sign!(%{}, TestUtils.create_signer_with_kid("id1", "RS384")) 222 | assert {:ok, %{}} == TestToken.verify_and_validate(token) 223 | end 224 | 225 | test "can parse key without alg with option explicit_alg" do 226 | expect_call(fn %{url: "http://jwks"} -> 227 | assert key = "id1" |> TestUtils.build_key() |> Map.delete("alg") 228 | refute key["alg"] 229 | {:ok, json(%{"keys" => [key]})} 230 | end) 231 | 232 | start_supervised!( 233 | {TestToken.Strategy, jwks_url: "http://jwks", explicit_alg: "RS384", first_fetch_sync: true} 234 | ) 235 | 236 | token = TestToken.generate_and_sign!(%{}, TestUtils.create_signer_with_kid("id1", "RS384")) 237 | assert {:ok, %{}} == TestToken.verify_and_validate(token) 238 | end 239 | 240 | test "even if first fetch sync fails will try to poll" do 241 | expect_call(2, fn %{url: "http://jwks"} -> {:error, :econnrefused} end) 242 | 243 | assert capture_log(fn -> 244 | start_supervised!( 245 | # disable retries 246 | {TestToken.Strategy, 247 | jwks_url: "http://jwks", 248 | time_interval: 70, 249 | http_max_retries_per_fetch: 0, 250 | first_fetch_sync: true} 251 | ) 252 | 253 | # We expect 2 calls in the timespan of 100 milliseconds: 254 | # 1. Try first fetch synchronously 255 | # 2. Because it fails, it will try again after time_interval 256 | :timer.sleep(80) 257 | end) =~ "[error] Failed to fetch signers. Reason: {:error, :could_not_reach_jwks_url}" 258 | 259 | assert_receive {:telemetry_event, [:joken_jwks, :default_strategy, :refetch], %{count: 1}, 260 | %{module: TestToken.Strategy}} 261 | end 262 | 263 | test "ignores keys with `use` as `enc`" do 264 | expect_call(fn %{url: "http://jwks"} -> 265 | {:ok, 266 | json(%{ 267 | "keys" => [ 268 | TestUtils.build_key("id1"), 269 | Map.merge(TestUtils.build_key("id2"), %{"use" => "enc", "alg" => "RSA-OAEP-256"}) 270 | ] 271 | })} 272 | end) 273 | 274 | start_supervised!({TestToken.Strategy, jwks_url: "http://jwks", first_fetch_sync: true}) 275 | 276 | # use is ignored 277 | assert length(EtsCache.get_signers(TestToken.Strategy)) == 1 278 | 279 | token = TestToken.generate_and_sign!(%{}, TestUtils.create_signer_with_kid("id1")) 280 | assert {:ok, %{}} == TestToken.verify_and_validate(token) 281 | end 282 | 283 | test "ignores keys algorithms that are not JWS" do 284 | expect_call(fn %{url: "http://jwks"} -> 285 | {:ok, 286 | json(%{ 287 | "keys" => [ 288 | Map.merge(TestUtils.build_key("id1"), %{"use" => "sig", "alg" => "RSA-OAEP-256"}) 289 | ] 290 | })} 291 | end) 292 | 293 | assert capture_log(fn -> 294 | start_supervised!( 295 | {TestToken.Strategy, jwks_url: "http://jwks", first_fetch_sync: true} 296 | ) 297 | end) =~ 298 | "NO VALID SIGNERS FOUND!" 299 | 300 | # use is ignored 301 | assert Enum.empty?(EtsCache.get_signers(TestToken.Strategy)[:signers]) 302 | end 303 | 304 | test "ets table creation attempt should not error out even if table already exists" do 305 | setup_jwks() 306 | EtsCache.new(TestToken.Strategy) 307 | 308 | token = TestToken.generate_and_sign!(%{}, TestUtils.create_signer_with_kid("id2")) 309 | assert {:ok, %{}} == TestToken.verify_and_validate(token) 310 | end 311 | 312 | def setup_jwks(time_interval \\ 1_000) do 313 | expect_call(fn %{url: "http://jwks"} -> 314 | {:ok, json(%{"keys" => [TestUtils.build_key("id1"), TestUtils.build_key("id2")]})} 315 | end) 316 | 317 | start_supervised!( 318 | {TestToken.Strategy, 319 | jwks_url: "http://jwks", time_interval: time_interval, first_fetch_sync: true} 320 | ) 321 | end 322 | 323 | defp expect_call(num_of_invocations \\ 1, function), 324 | do: expect(TeslaAdapterMock, :call, num_of_invocations, fn env, _opts -> function.(env) end) 325 | 326 | defp reply_telemetry(pid, name, measurements, metadata, _config) do 327 | send(pid, {:telemetry_event, name, measurements, metadata}) 328 | end 329 | end 330 | --------------------------------------------------------------------------------