Skip to content

Commit

Permalink
Merge pull request #7 from amezcua/develop
Browse files Browse the repository at this point in the history
Develop
  • Loading branch information
amezcua authored Sep 27, 2016
2 parents baa521f + adaca0b commit ca718df
Show file tree
Hide file tree
Showing 10 changed files with 136 additions and 35 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ use Mix.Config

config :logger, level: :debug

config :jwt, :googlecerts, Jwt.GoogleCerts.PublicKey.Mock
config :jwt, :googlecerts, Jwt.GoogleCerts.PublicKey.Mock
config :jwt, :timeutils, Jwt.TimeUtils.Mock
2 changes: 1 addition & 1 deletion lib/googlecerts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions lib/jwt.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
40 changes: 35 additions & 5 deletions lib/plug.ex
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions lib/timeutils.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
defmodule Jwt.TimeUtils do
def get_system_time(), do: :os.system_time(:seconds)
end
4 changes: 2 additions & 2 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.1",
version: "0.2.2",
elixir: "~> 1.3",
elixirc_paths: elixirc_paths(Mix.env),
build_embedded: Mix.env == :prod,
Expand All @@ -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
Expand Down
2 changes: 0 additions & 2 deletions test/certs_test.exs
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
105 changes: 83 additions & 22 deletions test/jwt_plug_test.exs
Original file line number Diff line number Diff line change
@@ -1,57 +1,118 @@
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
assert conn.resp_body == ""
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
5 changes: 5 additions & 0 deletions test/support/timeutilsmock.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
defmodule Jwt.TimeUtils.Mock do

def get_system_time(), do: Application.get_env(:jwt, :current_time_for_test)

end

0 comments on commit ca718df

Please sign in to comment.