Skip to content

Commit

Permalink
Merge pull request #8 from amezcua/add-http-response-caching
Browse files Browse the repository at this point in the history
Added caching HTTP client that will cache Google Http responses until…
  • Loading branch information
amezcua authored Oct 24, 2016
2 parents adaca0b + 4fc0d2d commit 29fdcff
Show file tree
Hide file tree
Showing 9 changed files with 249 additions and 12 deletions.
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions lib/cache.ex
Original file line number Diff line number Diff line change
@@ -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
62 changes: 62 additions & 0 deletions lib/etscache.ex
Original file line number Diff line number Diff line change
@@ -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
5 changes: 3 additions & 2 deletions lib/googlecerts.ex
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand All @@ -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
Expand Down
52 changes: 52 additions & 0 deletions lib/httpcacheclient.ex
Original file line number Diff line number Diff line change
@@ -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
2 changes: 0 additions & 2 deletions lib/plug.ex
Original file line number Diff line number Diff line change
@@ -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 "
Expand Down
11 changes: 8 additions & 3 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"]
Expand All @@ -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
7 changes: 6 additions & 1 deletion mix.lock
Original file line number Diff line number Diff line change
@@ -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], []},
Expand All @@ -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]}]}}
111 changes: 111 additions & 0 deletions test/httpcacheclient_test.exs
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 29fdcff

Please sign in to comment.