diff --git a/README.md b/README.md index 63143a8..d537b21 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ name = claims["name"] If the token is invalid, the plug with directly return a 401 response to the client. +The tokens expiration timestamp are also checked to verify that they have not expired. Expired tokens (within a 5 minute time difference) are rejected. + ## 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. diff --git a/config/test.exs b/config/test.exs index 6ad42ba..7c48e70 100644 --- a/config/test.exs +++ b/config/test.exs @@ -2,4 +2,5 @@ use Mix.Config config :logger, level: :debug -config :jwt, :googlecerts, Jwt.GoogleCerts.PublicKey.Mock \ No newline at end of file +config :jwt, :googlecerts, Jwt.GoogleCerts.PublicKey.Mock +config :jwt, :timeutils, Jwt.TimeUtils.Mock \ No newline at end of file diff --git a/lib/googlecerts.ex b/lib/googlecerts.ex index bd7b054..7636ed7 100644 --- a/lib/googlecerts.ex +++ b/lib/googlecerts.ex @@ -23,7 +23,7 @@ defmodule Jwt.GoogleCerts.PublicKey do end defp get_response_body(%{body: body, headers: _headers, status_code: 200}), do: {:ok, body} - defp get_response_body(%{body: body, headers: _headers, status_code: status_code}), do: {:error, body} + defp get_response_body(%{body: body, headers: _headers, status_code: _status_code}), do: {:error, body} defp extract_certificated_url({:ok, body}) do {:ok, parsed} = Poison.Parser.parse body diff --git a/lib/jwt.ex b/lib/jwt.ex index a123d0a..8dc60ad 100644 --- a/lib/jwt.ex +++ b/lib/jwt.ex @@ -6,7 +6,8 @@ defmodule Jwt do @alg "alg" @doc """ - Verifies a Google generated JWT token against the current public Google certificates and returns the claims if the token is verified successfully. + Verifies a Google generated JWT token against the current public Google certificates and returns the claims + if the token's signature is verified successfully. ## Example iex > {:ok, {claims}} = Jwt.verify token @@ -17,7 +18,7 @@ defmodule Jwt do _verify(Enum.map(token_parts, fn(part) -> Base.url_decode64(part, padding: false) end), token_parts) end - defp _verify([{:ok, header}, {:ok, claims}, {:ok, signature}], [header_b64, claims_b64, _signature_b64]) do + defp _verify([{:ok, header}, {:ok, _claims}, {:ok, signature}], [header_b64, claims_b64, _signature_b64]) do Poison.Parser.parse!(header)[@key_id] |> @google_certs_api.getfor |> verify_signature(header_b64, claims_b64, signature) diff --git a/lib/plug.ex b/lib/plug.ex index 57518d9..a95f9dc 100644 --- a/lib/plug.ex +++ b/lib/plug.ex @@ -1,16 +1,27 @@ defmodule Jwt.Plug do import Plug.Conn + require Logger + + @timeutils Application.get_env(:jwt, :timeutils, Jwt.TimeUtils) @authorization_header "authorization" @bearer "Bearer " @invalid_header_error {:error, "Invalid authorization header value."} + @expired_token_error {:error, "Expired token."} + @five_minutes 5 * 60 + @default_options %{:ignore_token_expiration => false, :time_window => @five_minutes} - def init([]), do: false + def init(opts) do + case Enum.count(opts) do + 2 -> opts + _ -> [@default_options.ignore_token_expiration, @default_options.time_window] + end + end - def call(conn, _opts) do + def call(conn, opts) do List.first(get_req_header(conn, @authorization_header)) |> extract_token - |> verify + |> verify_token(opts) |> continue_if_verified(conn) end @@ -22,8 +33,27 @@ defmodule Jwt.Plug do end defp extract_token(_), do: @invalid_header_error - defp verify({:ok, token}), do: Jwt.verify(token) - defp verify({:error, _}), do: @invalid_header_error + defp verify_token({:ok, token}, opts) do + verify_signature(token) + |> verify_expiration(opts) + end + defp verify_token({:error, _}, _opts), do: @invalid_header_error + + defp verify_signature(token), do: Jwt.verify(token) + + defp verify_expiration({:ok, claims}, opts) do + [ignore_token_expiration, time_window] = opts + + expiration_date = claims["exp"] - time_window + now = @timeutils.get_system_time() + + cond do + ignore_token_expiration -> {:ok, claims} + now > expiration_date -> @expired_token_error + now < expiration_date -> {:ok, claims} + end + end + defp verify_expiration({:error, _}, _opts), do: @invalid_header_error defp continue_if_verified({:ok, claims}, conn) do assign(conn, :jwtclaims, claims) diff --git a/lib/timeutils.ex b/lib/timeutils.ex new file mode 100644 index 0000000..3353a83 --- /dev/null +++ b/lib/timeutils.ex @@ -0,0 +1,3 @@ +defmodule Jwt.TimeUtils do + def get_system_time(), do: :os.system_time(:seconds) +end \ No newline at end of file diff --git a/mix.exs b/mix.exs index d573f09..25a730e 100644 --- a/mix.exs +++ b/mix.exs @@ -3,7 +3,7 @@ defmodule Jwt.Mixfile do def project do [app: :jwt, - version: "0.2.1", + version: "0.2.2", elixir: "~> 1.3", elixirc_paths: elixirc_paths(Mix.env), build_embedded: Mix.env == :prod, @@ -16,7 +16,7 @@ defmodule Jwt.Mixfile do end defp elixirc_paths(:test), do: ["lib", "test/support"] - defp elixirc_paths(:dev), do: ["lib", "test/support"] + defp elixirc_paths(:dev), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] defp deps do diff --git a/test/certs_test.exs b/test/certs_test.exs index b982cc1..d1a9f23 100644 --- a/test/certs_test.exs +++ b/test/certs_test.exs @@ -1,8 +1,6 @@ defmodule CertsTest do use ExUnit.Case, async: true - require Logger - @mod "zkSRsA8npcga4dKSt91-OtSXA481Y94jt5tn64h2MUtUnQ_1JP-4xcDBYVG52m1Cdc7Fq2_cpUOvm27jAxIc4oYxLk1YtyJX9ce5p2rkbKyC71nSq5om3rBE4n3hYUa0nPCcXNC0uC_G0UTVY_OsiYS6hSNVSnHqySn50yid8EBWY8sHHCsqEtlk4uwXXalgnpZ5BXI22yQWQASnZdeIiRKhxSWdkDrbLUq1FmyfNn9vabhIADZsdjCL3iCfJVW8YTdntObZRVsuh_ezm9K7-l3U400EvZA7RN_Dt5QGC6gSjo4syP5TkGXD6iC6rUx67FLzgww_Lo0O4kYEFzDLzw" @exp "AQAB" @header "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEwZWZiZjlmOWEzZThlYzVlN2RmYTc5NjFkNzFlMmU0YmZkYTI0MzUifQ" diff --git a/test/jwt_plug_test.exs b/test/jwt_plug_test.exs index 9ce56ae..abf07af 100644 --- a/test/jwt_plug_test.exs +++ b/test/jwt_plug_test.exs @@ -1,14 +1,21 @@ defmodule JwtPlugTest do use ExUnit.Case, async: true use Plug.Test - require Logger - @opts Jwt.Plug.init([]) + @opts [] + + @test_header "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEwZWZiZjlmOWEzZThlYzVlN2RmYTc5NjFkNzFlMmU0YmZkYTI0MzUifQ" + @test_claims "eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhdWQiOiIzNDczODQ1NjIxMTMtcmRtNnNsZG0xbWIzOGs0dW1yY28zcDhsN3I1aGcwazUuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTAzNjE0MDAyNDQ4NzEyMjU0MTQiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXpwIjoiMzQ3Mzg0NTYyMTEzLXIyOHBqZDB1Yzlwb2Y1Y20xcDBubmwyNXM5N2o4dXFwLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiaGQiOiJieXRlYWJ5dGUubmV0IiwiZW1haWwiOiJhbGVqYW5kcm8ubWV6Y3VhQGJ5dGVhYnl0ZS5uZXQiLCJpYXQiOjE0NzMyMjU4NjQsImV4cCI6MTQ3MzIyOTQ2NCwibmFtZSI6IkFsZWphbmRybyBNZXpjdWEiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tLy1mSUpUN0cydVozRS9BQUFBQUFBQUFBSS9BQUFBQUFBQUFRWS9KdkNWbUZIWG5yOC9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiQWxlamFuZHJvIiwiZmFtaWx5X25hbWUiOiJNZXpjdWEiLCJsb2NhbGUiOiJlbiJ9" + @test_signature "Kc90u_gtZyhq6glw6UoYQSInZx9r16uqrRO7g50x17JWH7VkyAZrh3sfjdBYpGtDNJjDRBKSxuinpDjpyfiCp3-XAqqOUWqziyYvkV4-CdQvNhcnUQFXjjx_CzNiiEi5PRPCHhX4ajidet1NH4Me02S17gwOZiaZfed1BMWQuQ_7Hf2RsX5FID1xqOpcaaouMFcrqQFmdBIbcstHamWxs9D83c4JpOsioNOMb6-LBinzOg7qdxr1D4NvHD6VSXBTbyXiOBjK2elLU1iCz_Hz_BH-R1IYCdTRr5PczRWdSCgoTdZ7ds1nTTglfuXlGNbaEhhzsFxX8OCR4uNK6vbWXQ" + @test_token_exp_value 1473229464 # Expiration timestamp for the token avobe + @ten_minutes 10 * 60 + @four_minutes 4 * 60 test "Missing authorization header returns 401" do + Application.put_env(:jwt, :current_time_for_test, :os.system_time(:seconds)) conn = conn(:get, "/protected") - conn = Jwt.Plug.call(conn, @opts) + conn = Jwt.Plug.call(conn, Jwt.Plug.init(@opts)) assert conn.state == :sent assert conn.status == 401 @@ -16,42 +23,96 @@ defmodule JwtPlugTest do end test "Empty authorization header returns 401" do - conn = conn(:get, "/protected") - conn = put_req_header conn, "authorization", "" + Application.put_env(:jwt, :current_time_for_test, :os.system_time(:seconds)) + conn = conn(:get, "/protected") + conn = put_req_header conn, "authorization", "" - conn = Jwt.Plug.call(conn, @opts) + conn = Jwt.Plug.call(conn, Jwt.Plug.init(@opts)) - assert conn.state == :sent - assert conn.status == 401 - assert conn.resp_body == "" + assert conn.state == :sent + assert conn.status == 401 + assert conn.resp_body == "" end test "Invalid token in authorization header returns 401" do - conn = conn(:get, "/protected") - conn = put_req_header conn, "authorization", "token" + Application.put_env(:jwt, :current_time_for_test, :os.system_time(:seconds)) + conn = conn(:get, "/protected") + conn = put_req_header conn, "authorization", "token" - conn = Jwt.Plug.call(conn, @opts) + conn = Jwt.Plug.call(conn, Jwt.Plug.init(@opts)) - assert conn.state == :sent - assert conn.status == 401 - assert conn.resp_body == "" + assert conn.state == :sent + assert conn.status == 401 + assert conn.resp_body == "" end test "Valid token is allowed" do - header = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEwZWZiZjlmOWEzZThlYzVlN2RmYTc5NjFkNzFlMmU0YmZkYTI0MzUifQ" - claims = "eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhdWQiOiIzNDczODQ1NjIxMTMtcmRtNnNsZG0xbWIzOGs0dW1yY28zcDhsN3I1aGcwazUuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTAzNjE0MDAyNDQ4NzEyMjU0MTQiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXpwIjoiMzQ3Mzg0NTYyMTEzLXIyOHBqZDB1Yzlwb2Y1Y20xcDBubmwyNXM5N2o4dXFwLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiaGQiOiJieXRlYWJ5dGUubmV0IiwiZW1haWwiOiJhbGVqYW5kcm8ubWV6Y3VhQGJ5dGVhYnl0ZS5uZXQiLCJpYXQiOjE0NzMyMjU4NjQsImV4cCI6MTQ3MzIyOTQ2NCwibmFtZSI6IkFsZWphbmRybyBNZXpjdWEiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tLy1mSUpUN0cydVozRS9BQUFBQUFBQUFBSS9BQUFBQUFBQUFRWS9KdkNWbUZIWG5yOC9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiQWxlamFuZHJvIiwiZmFtaWx5X25hbWUiOiJNZXpjdWEiLCJsb2NhbGUiOiJlbiJ9" - signature = "Kc90u_gtZyhq6glw6UoYQSInZx9r16uqrRO7g50x17JWH7VkyAZrh3sfjdBYpGtDNJjDRBKSxuinpDjpyfiCp3-XAqqOUWqziyYvkV4-CdQvNhcnUQFXjjx_CzNiiEi5PRPCHhX4ajidet1NH4Me02S17gwOZiaZfed1BMWQuQ_7Hf2RsX5FID1xqOpcaaouMFcrqQFmdBIbcstHamWxs9D83c4JpOsioNOMb6-LBinzOg7qdxr1D4NvHD6VSXBTbyXiOBjK2elLU1iCz_Hz_BH-R1IYCdTRr5PczRWdSCgoTdZ7ds1nTTglfuXlGNbaEhhzsFxX8OCR4uNK6vbWXQ" - valid_token = header <> "." <> claims <> "." <> signature - + Application.put_env(:jwt, :current_time_for_test, :os.system_time(:seconds)) + valid_token = @test_header <> "." <> @test_claims <> "." <> @test_signature auth_header= "Bearer " <> valid_token + conn = conn(:get, "/protected") conn = put_req_header conn, "authorization", auth_header + conn = Jwt.Plug.call(conn, Jwt.Plug.init([true, 5 * 60])) + + claims = conn.assigns[:jwtclaims] + assert claims != nil + assert claims["name"] == "Alejandro Mezcua" + end + + test "Expired token is rejected by default" do + Application.put_env(:jwt, :current_time_for_test, :os.system_time(:seconds)) + expired_token = @test_header <> "." <> @test_claims <> "." <> @test_signature + auth_header= "Bearer " <> expired_token + + conn = conn(:get, "/protected") + conn = put_req_header conn, "authorization", auth_header + conn = Jwt.Plug.call(conn, Jwt.Plug.init(@opts)) + + assert conn.state == :sent + assert conn.status == 401 + assert conn.resp_body == "" + end + + test "Token expiration allowed below but outside time window" do + Application.put_env(:jwt, :current_time_for_test, @test_token_exp_value - @ten_minutes) + expired_in_window_token = @test_header <> "." <> @test_claims <> "." <> @test_signature + auth_header= "Bearer " <> expired_in_window_token - conn = Jwt.Plug.call(conn, @opts) + conn = conn(:get, "/protected") + conn = put_req_header conn, "authorization", auth_header + conn = Jwt.Plug.call(conn, Jwt.Plug.init(@opts)) claims = conn.assigns[:jwtclaims] - Logger.debug fn -> "Claims: " <> inspect(claims) end assert claims != nil assert claims["name"] == "Alejandro Mezcua" end + + test "Token expiration not allowed below but within time window" do + Application.put_env(:jwt, :current_time_for_test, @test_token_exp_value - @four_minutes) + expired_in_window_token = @test_header <> "." <> @test_claims <> "." <> @test_signature + auth_header= "Bearer " <> expired_in_window_token + + conn = conn(:get, "/protected") + conn = put_req_header conn, "authorization", auth_header + conn = Jwt.Plug.call(conn, Jwt.Plug.init(@opts)) + + assert conn.state == :sent + assert conn.status == 401 + assert conn.resp_body == "" + end + + test "Token expiration not allowed avobe expiration time" do + Application.put_env(:jwt, :current_time_for_test, @test_token_exp_value + 1) + expired_in_window_token = @test_header <> "." <> @test_claims <> "." <> @test_signature + auth_header= "Bearer " <> expired_in_window_token + + conn = conn(:get, "/protected") + conn = put_req_header conn, "authorization", auth_header + conn = Jwt.Plug.call(conn, Jwt.Plug.init(@opts)) + + assert conn.state == :sent + assert conn.status == 401 + assert conn.resp_body == "" + end end \ No newline at end of file diff --git a/test/support/timeutilsmock.ex b/test/support/timeutilsmock.ex new file mode 100644 index 0000000..602affa --- /dev/null +++ b/test/support/timeutilsmock.ex @@ -0,0 +1,5 @@ +defmodule Jwt.TimeUtils.Mock do + + def get_system_time(), do: Application.get_env(:jwt, :current_time_for_test) + +end \ No newline at end of file