From 4fc0d2dcb6c9413ee53f6c0a39a6e0bfdf6395b1 Mon Sep 17 00:00:00 2001 From: Alejandro Mezcua Date: Thu, 20 Oct 2016 18:41:59 +0200 Subject: [PATCH] Added caching HTTP client that will cache Google Http responses until they expire, honoring the "Expires" http header (within a 20 minutes margin) sent in the response. Updated version, readme and added some debug logging. Updated the Ets cache to be a GenServer --- README.md | 6 +- lib/cache.ex | 5 ++ lib/etscache.ex | 62 +++++++++++++++++++ lib/googlecerts.ex | 5 +- lib/httpcacheclient.ex | 52 ++++++++++++++++ lib/plug.ex | 2 - mix.exs | 11 +++- mix.lock | 7 ++- test/httpcacheclient_test.exs | 111 ++++++++++++++++++++++++++++++++++ 9 files changed, 249 insertions(+), 12 deletions(-) create mode 100644 lib/cache.ex create mode 100644 lib/etscache.ex create mode 100644 lib/httpcacheclient.ex create mode 100644 test/httpcacheclient_test.exs diff --git a/README.md b/README.md index d537b21..0ee566c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The package can be installed as (will try to make it available in Hex in a futur ```elixir def deps do - [{:jwt, "~> 0.1.0"}] + [{:jwt, git: "https://github.com/amezcua/jwt-google-tokens.git", branch: "master"}] end ``` @@ -62,9 +62,7 @@ The tokens expiration timestamp are also checked to verify that they have not ex ## Limitations -* At this point the library only can verify RSA SHA256 signed tokens. It uses the [public discovery document](https://developers.google.com/identity/protocols/OpenIDConnect#discovery) provided by Google to retrieve the public key used to verify the RSA signatures but if the signing method is changed by Google the library will fail to verify the tokens. - -* For every verification request the library does two HTTP calls to retrieve the discovery document and the keys document. Those documents should be cached but they are not being cached at this moment so be aware of it if you use it. +* At this point the library does not validate any extra claims besides the signature. ## License diff --git a/lib/cache.ex b/lib/cache.ex new file mode 100644 index 0000000..5b587fc --- /dev/null +++ b/lib/cache.ex @@ -0,0 +1,5 @@ +defmodule Jwt.Cache do + @callback invalidate() :: {:ok} | {:error, any} + @callback get(uri :: String.t) :: {:ok, struct} | {:error, any} + @callback set(uri :: String.t, data :: struct) :: {:ok, struct} | {:error, any} +end \ No newline at end of file diff --git a/lib/etscache.ex b/lib/etscache.ex new file mode 100644 index 0000000..a22db11 --- /dev/null +++ b/lib/etscache.ex @@ -0,0 +1,62 @@ +defmodule Jwt.Cache.Ets do + use GenServer + @behaviour Jwt.Cache + + require Logger + + @cache_name EtsCache + @cache_table_name :certstable + + def start(_type, _args) do + link = GenServer.start_link(__MODULE__, [], name: @cache_name) + Logger.debug("Jwt.Cache.Ets Started: #{inspect link}") + link + end + + def invalidate(), do: GenServer.call(@cache_name, :invalidate) + def get(uri), do: GenServer.call(@cache_name, {:get, uri}) + def set(uri, data), do: GenServer.call(@cache_name, {:set, uri, data}) + + def init(_), do: {:ok, _invalidate()} + def handle_call(:invalidate, _from, _), do: {:reply, _invalidate(), []} + def handle_call({:get, uri}, _from, _), do: {:reply, _get(uri), uri} + def handle_call({:set, uri, data}, _from, _), do: {:reply, _set(uri, data), uri} + + defp _invalidate() do + :ets.info(@cache_table_name) + |> create_cache + |> case do + @cache_table_name -> {:ok, @cache_table_name} + _ -> {:error, "Failed to invalidate the cache"} + end + end + + defp create_cache(:undefined) do + cache = :ets.new(@cache_table_name, [:named_table, :public]) + Logger.debug("Jwt.Cache.Ets.create_cache: #{inspect cache}") + cache + end + + defp create_cache(_) do + :ets.delete(@cache_table_name) + create_cache(:undefined) + end + + defp _get(uri) do + value = :ets.lookup(@cache_table_name, uri) + Logger.debug "#{inspect uri} -> #{inspect value}" + case value do + [] -> {:error, uri} + _ -> {:ok, elem(Enum.at(value, 0), 1)} + end + end + + defp _set(uri, data) do + Logger.debug "#{inspect uri} saving to cache." + :ets.insert(@cache_table_name, {uri, data}) + |> case do + true -> {:ok, data} + false -> {:error, uri} + end + end +end \ No newline at end of file diff --git a/lib/googlecerts.ex b/lib/googlecerts.ex index 7636ed7..086c146 100644 --- a/lib/googlecerts.ex +++ b/lib/googlecerts.ex @@ -1,5 +1,6 @@ defmodule Jwt.GoogleCerts.PublicKey do + @httpclient Jwt.HttpCacheClient @discovery_url "https://accounts.google.com/.well-known/openid-configuration" @jwt_uri_in_discovery "jwks_uri" @keys_in_certificates "keys" @@ -10,7 +11,7 @@ defmodule Jwt.GoogleCerts.PublicKey do def getfor(id), do: fetch id defp fetch(id) do - HTTPoison.get!(@discovery_url) + @httpclient.get!(@discovery_url) |> get_response_body |> extract_certificated_url |> case do @@ -35,7 +36,7 @@ defmodule Jwt.GoogleCerts.PublicKey do defp extract_certificated_url({:error, _body}), do: [] - defp request_certificates_uri({:ok, uri}), do: HTTPoison.get! uri + defp request_certificates_uri({:ok, uri}), do: @httpclient.get! uri defp request_certificates_uri({:error, _}), do: %{body: nil, headers: nil, status_code: 404} defp extract_public_key_for_id({:error, _}, _id), do: nil diff --git a/lib/httpcacheclient.ex b/lib/httpcacheclient.ex new file mode 100644 index 0000000..eab0acf --- /dev/null +++ b/lib/httpcacheclient.ex @@ -0,0 +1,52 @@ + +defmodule Jwt.HttpCacheClient do + require Logger + @expires_header "Expires" + + + def get!(uri, cache \\ Jwt.Cache.Ets, httpclient \\ HTTPoison) do + {_result, value} = get(uri, cache, httpclient) + value + end + + def get(uri, cache \\ Jwt.Cache.Ets, httpclient \\ HTTPoison) do + cache.get(uri) + |> case do + {:ok, cachedvalue} -> + case expired_entry?(cachedvalue) do + true -> request_and_cache(uri, cache, httpclient) + false -> {:ok, cachedvalue} + end + {:error, _} -> request_and_cache(uri, cache, httpclient) + end + end + + defp expired_entry?(cachedvalue) do + case Enum.find cachedvalue.headers, fn(header) -> elem(header, 0) == @expires_header end do + {@expires_header, expiresvalue} -> expired_date?(expiresvalue) + _ -> true + end + end + + defp request_and_cache(uri, cache, httpclient) do + Logger.debug "Requesting URL: #{inspect uri}..." + + httpclient.get(uri) + |> case do + {:ok, response} -> cache_if_expires_header_present(uri, response, cache) + {:error, _} -> {:error, uri} + end + end + + defp cache_if_expires_header_present(uri, response, cache) do + case Enum.any?(response.headers, fn header -> elem(header, 0) == @expires_header end) do + true -> cache.set(uri, response) + false -> {:ok, response} + end + end + + defp expired_date?(date) do + shifted_time = Timex.shift(Timex.parse!(date, "{RFC1123}"), minutes: -20) + Timex.before? shifted_time, Timex.now + end +end \ No newline at end of file diff --git a/lib/plug.ex b/lib/plug.ex index a95f9dc..1f23937 100644 --- a/lib/plug.ex +++ b/lib/plug.ex @@ -1,8 +1,6 @@ defmodule Jwt.Plug do import Plug.Conn - require Logger - @timeutils Application.get_env(:jwt, :timeutils, Jwt.TimeUtils) @authorization_header "authorization" @bearer "Bearer " diff --git a/mix.exs b/mix.exs index 25a730e..47800f6 100644 --- a/mix.exs +++ b/mix.exs @@ -3,7 +3,7 @@ defmodule Jwt.Mixfile do def project do [app: :jwt, - version: "0.2.2", + version: "0.5.0", elixir: "~> 1.3", elixirc_paths: elixirc_paths(Mix.env), build_embedded: Mix.env == :prod, @@ -12,7 +12,10 @@ defmodule Jwt.Mixfile do end def application do - [ applications: [:logger, :httpoison, :cowboy, :plug] ] + [ + applications: [:logger, :httpoison, :cowboy, :plug, :timex], + mod: {Jwt.Cache.Ets, []} + ] end defp elixirc_paths(:test), do: ["lib", "test/support"] @@ -25,7 +28,9 @@ defmodule Jwt.Mixfile do {:poison, "~> 2.0" }, {:ex_doc, github: "elixir-lang/ex_doc" }, {:cowboy, "~> 1.0.0"}, - {:plug, "~> 1.0"} + {:plug, "~> 1.0"}, + {:stash, "~> 1.0.0"}, + {:timex, "~> 3.0"} ] end end diff --git a/mix.lock b/mix.lock index efe0d4a..dabd6e0 100644 --- a/mix.lock +++ b/mix.lock @@ -1,8 +1,10 @@ %{"certifi": {:hex, :certifi, "0.4.0", "a7966efb868b179023618d29a407548f70c52466bf1849b9e8ebd0e34b7ea11f", [:rebar3], []}, + "combine": {:hex, :combine, "0.9.2", "cd3c8721f378ebe032487d8a4fa2ced3181a456a3c21b16464da8c46904bb552", [:mix], []}, "cowboy": {:hex, :cowboy, "1.0.4", "a324a8df9f2316c833a470d918aaf73ae894278b8aa6226ce7a9bf699388f878", [:rebar, :make], [{:cowlib, "~> 1.0.0", [hex: :cowlib, optional: false]}, {:ranch, "~> 1.0", [hex: :ranch, optional: false]}]}, "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], []}, "earmark": {:hex, :earmark, "1.0.1", "2c2cd903bfdc3de3f189bd9a8d4569a075b88a8981ded9a0d95672f6e2b63141", [:mix], []}, "ex_doc": {:git, "https://github.com/elixir-lang/ex_doc.git", "2680a2f37738b490b1ac32808962ddb2f653be06", []}, + "gettext": {:hex, :gettext, "0.11.0", "80c1dd42d270482418fa158ec5ba073d2980e3718bacad86f3d4ad71d5667679", [:mix], []}, "hackney": {:hex, :hackney, "1.6.1", "ddd22d42db2b50e6a155439c8811b8f6df61a4395de10509714ad2751c6da817", [:rebar3], [{:certifi, "0.4.0", [hex: :certifi, optional: false]}, {:idna, "1.2.0", [hex: :idna, optional: false]}, {:metrics, "1.0.1", [hex: :metrics, optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, optional: false]}, {:ssl_verify_fun, "1.1.0", [hex: :ssl_verify_fun, optional: false]}]}, "httpoison": {:hex, :httpoison, "0.9.1", "6c2b4eaf2588a6f3ef29663d28c992531ca3f0bc832a97e0359bc822978e1c5d", [:mix], [{:hackney, "~> 1.6.0", [hex: :hackney, optional: false]}]}, "idna": {:hex, :idna, "1.2.0", "ac62ee99da068f43c50dc69acf700e03a62a348360126260e87f2b54eced86b2", [:rebar3], []}, @@ -12,4 +14,7 @@ "plug": {:hex, :plug, "1.2.0", "496bef96634a49d7803ab2671482f0c5ce9ce0b7b9bc25bc0ae8e09859dd2004", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}, {:mime, "~> 1.0", [hex: :mime, optional: false]}]}, "poison": {:hex, :poison, "2.2.0", "4763b69a8a77bd77d26f477d196428b741261a761257ff1cf92753a0d4d24a63", [:mix], []}, "ranch": {:hex, :ranch, "1.2.1", "a6fb992c10f2187b46ffd17ce398ddf8a54f691b81768f9ef5f461ea7e28c762", [:make], []}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.0", "edee20847c42e379bf91261db474ffbe373f8acb56e9079acb6038d4e0bf414f", [:rebar, :make], []}} + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.0", "edee20847c42e379bf91261db474ffbe373f8acb56e9079acb6038d4e0bf414f", [:rebar, :make], []}, + "stash": {:hex, :stash, "1.0.0", "e2d9e61f67009a0d2c59796169316754a06314f11572fce2a967b583bd947976", [:mix], []}, + "timex": {:hex, :timex, "3.1.0", "71c1bc6eef88f2bf8628224d62eab12dda5c5904e9db764289db1e360c622900", [:mix], [{:combine, "~> 0.7", [hex: :combine, optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, optional: false]}]}, + "tzdata": {:hex, :tzdata, "0.5.9", "575be217b039057a47e133b72838cbe104fb5329b19906ea4e66857001c37edb", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, optional: false]}]}} diff --git a/test/httpcacheclient_test.exs b/test/httpcacheclient_test.exs new file mode 100644 index 0000000..5144bf8 --- /dev/null +++ b/test/httpcacheclient_test.exs @@ -0,0 +1,111 @@ +defmodule CachingHttpClientTest do + use ExUnit.Case, async: true + + require Logger + + @validtesturl "https://accounts.google.com/.well-known/openid-configuration" + @invalidtesturl "https://www.google.com" + @testcache Jwt.Cache.Ets + + setup do + @testcache.invalidate() + :ok + end + + test "Response not in cache is cached" do + Application.put_env(:jwt, :httpclient, HTTPoison) + + Jwt.HttpCacheClient.get(@validtesturl, @testcache) + {result, value} = @testcache.get(@validtesturl) + + assert result == :ok + assert value != nil + end + + test "Invalid url is not cached" do + invaliduri = "invalid" + + Jwt.HttpCacheClient.get(invaliduri, @testcache) + + assert {:error, invaliduri} == @testcache.get(invaliduri) + end + + test "Cached values are returned instead of hitting the network" do + cachedresponse = cacheableresponse(Timex.shift(Timex.now, hours: 1)) + + Logger.debug("Cached response is: #{inspect cachedresponse}") + + @testcache.set(@validtesturl, cachedresponse) + + {client_result, client_value} = Jwt.HttpCacheClient.get(@validtesturl, @testcache) + {cached_result, cached_value} = @testcache.get(@validtesturl) + + assert client_result == :ok + assert client_value == cachedresponse + assert cached_result == :ok + assert cached_value == cachedresponse + end + + test "Do not cache responses that do not include the Expires header" do + testurl = "http://fakeurl" + testresponse = noncacheableresponse() + Application.put_env(:jwt, :fake_response, {:ok, testresponse}) + + {client_result, client_value} = Jwt.HttpCacheClient.get(testurl, @testcache, CachingHttpClientTest.FakeHttpClient) + {cached_result, cached_value} = @testcache.get(testurl) + + assert client_result == :ok + assert client_value == testresponse + assert cached_result == :error + assert cached_value == testurl + end + + test "Cache response if expires header present" do + testurl = "http://fakeurl" + response = cacheableresponse() + Application.put_env(:jwt, :fake_response, {:ok, response}) + + Jwt.HttpCacheClient.get(testurl, @testcache, CachingHttpClientTest.FakeHttpClient) + {result, value} = @testcache.get(testurl) + + assert result == :ok + assert value == response + end + + test "Expired responses are discarded and downloaded and cached" do + testurl = "http://fakeurl" + expiredresponse = cacheableresponse(Timex.shift(Timex.now, hours: -1)) + currentresponse = cacheableresponse(Timex.now) + Application.put_env(:jwt, :fake_response, {:ok, currentresponse}) + @testcache.set(testurl, expiredresponse) + + {client_result, client_value} = Jwt.HttpCacheClient.get(testurl, @testcache, CachingHttpClientTest.FakeHttpClient) + {cache_result, cache_value} = @testcache.get(testurl) + + assert client_result == :ok + assert client_value == currentresponse + assert cache_result == :ok + assert cache_value == currentresponse + end + + defp noncacheableresponse() do + %{body: "{\n \"issuer\": \"https://accounts.google.com\",\n \"authorization_endpoint\": \"https://accounts.google.com/o/oauth2/v2/auth\",\n \"token_endpoint\": \"https://www.googleapis.com/oauth2/v4/token\",\n \"userinfo_endpoint\": \"https://www.googleapis.com/oauth2/v3/userinfo\",\n \"revocation_endpoint\": \"https://accounts.google.com/o/oauth2/revoke\",\n \"jwks_uri\": \"https://www.googleapis.com/oauth2/v3/certs\",\n \"response_types_supported\": [\n \"code\",\n \"token\",\n \"id_token\",\n \"code token\",\n \"code id_token\",\n \"token id_token\",\n \"code token id_token\",\n \"none\"\n ],\n \"subject_types_supported\": [\n \"public\"\n ],\n \"id_token_signing_alg_values_supported\": [\n \"RS256\"\n ],\n \"scopes_supported\": [\n \"openid\",\n \"email\",\n \"profile\"\n ],\n \"token_endpoint_auth_methods_supported\": [\n \"client_secret_post\",\n \"client_secret_basic\"\n ],\n \"claims_supported\": [\n \"aud\",\n \"email\",\n \"email_verified\",\n \"exp\",\n \"family_name\",\n \"given_name\",\n \"iat\",\n \"iss\",\n \"locale\",\n \"name\",\n \"picture\",\n \"sub\"\n ],\n \"code_challenge_methods_supported\": [\n \"plain\",\n \"S256\"\n ]\n}\n", + headers: [ + {"Vary", "Accept-Encoding"}, + {"Content-Type", "application/json"}], + status_code: 200} + end + + defp cacheableresponse(expires_date \\ Timex.now) do + %{body: "{\n \"issuer\": \"https://accounts.google.com\",\n \"authorization_endpoint\": \"https://accounts.google.com/o/oauth2/v2/auth\",\n \"token_endpoint\": \"https://www.googleapis.com/oauth2/v4/token\",\n \"userinfo_endpoint\": \"https://www.googleapis.com/oauth2/v3/userinfo\",\n \"revocation_endpoint\": \"https://accounts.google.com/o/oauth2/revoke\",\n \"jwks_uri\": \"https://www.googleapis.com/oauth2/v3/certs\",\n \"response_types_supported\": [\n \"code\",\n \"token\",\n \"id_token\",\n \"code token\",\n \"code id_token\",\n \"token id_token\",\n \"code token id_token\",\n \"none\"\n ],\n \"subject_types_supported\": [\n \"public\"\n ],\n \"id_token_signing_alg_values_supported\": [\n \"RS256\"\n ],\n \"scopes_supported\": [\n \"openid\",\n \"email\",\n \"profile\"\n ],\n \"token_endpoint_auth_methods_supported\": [\n \"client_secret_post\",\n \"client_secret_basic\"\n ],\n \"claims_supported\": [\n \"aud\",\n \"email\",\n \"email_verified\",\n \"exp\",\n \"family_name\",\n \"given_name\",\n \"iat\",\n \"iss\",\n \"locale\",\n \"name\",\n \"picture\",\n \"sub\"\n ],\n \"code_challenge_methods_supported\": [\n \"plain\",\n \"S256\"\n ]\n}\n", + headers: [ + {"Vary", "Accept-Encoding"}, + {"Content-Type", "application/json"}, + {"Expires", Timex.format!(expires_date, "{RFC1123}")}], + status_code: 200} + end + + defmodule FakeHttpClient do + def get(_uri), do: Application.get_env(:jwt, :fake_response) + end +end \ No newline at end of file