diff --git a/lib/openid_connect.ex b/lib/openid_connect.ex index a532724..fb96fdb 100644 --- a/lib/openid_connect.ex +++ b/lib/openid_connect.ex @@ -152,12 +152,24 @@ defmodule OpenIDConnect do end end - @spec verify(provider, jwt, name) :: success(claims) | error(:verify) + @spec verify(provider, jwt, name) :: + success(claims) | error(:verify) @doc """ Verifies the validity of the JSON Web Token (JWT) - This verification will assert the token's encryption against the provider's - JSON Web Key (JWK) + This verification will: + + 1. assert the token's encryption against the provider's JSON Web Key (JWK) + 2. ensure that the token has not expired (`exp` claim, default leeway of 30s) + 3. ensure that the token is intended for this application (`aud` claim) + + The expiration delay can be customized using the `leeway` option in the + provider configuration, e.g. `leeway: 15` will allow a 15 second grace period + before the token is effectively considered expired. + + The token `aud` claim can be either a string or an array of strings and is + matched against the provider `client_id`. + """ def verify(provider, jwt, name \\ :openid_connect) do jwk = jwk(provider, name) @@ -165,8 +177,10 @@ defmodule OpenIDConnect do with {:ok, protected} <- peek_protected(jwt), {:ok, decoded_protected} <- Jason.decode(protected), {:ok, token_alg} <- Map.fetch(decoded_protected, "alg"), - {true, claims, _jwk} <- do_verify(jwk, token_alg, jwt) do - Jason.decode(claims) + {true, payload, _jwk} <- verify_signature(jwk, token_alg, jwt), + {:ok, unverified_claims} <- Jason.decode(payload), + {:ok, verified_claims} <- verify_claims(unverified_claims, config(provider, name)) do + {:ok, verified_claims} else {:error, %Jason.DecodeError{}} -> {:error, :verify, "token claims did not contain a JSON payload"} @@ -180,6 +194,9 @@ defmodule OpenIDConnect do {false, _claims, _jwk} -> {:error, :verify, "verification failed"} + {:error, invalid_claim, message} -> + {:error, :verify, "invalid #{invalid_claim} claim: #{message}"} + _ -> {:error, :verify, "verification error"} end @@ -242,11 +259,11 @@ defmodule OpenIDConnect do end end - defp do_verify(%JOSE.JWK{keys: {:jose_jwk_set, jwks}}, token_alg, jwt) do + defp verify_signature(%JOSE.JWK{keys: {:jose_jwk_set, jwks}}, token_alg, jwt) do Enum.find_value(jwks, {false, "{}", jwt}, fn jwk -> jwk |> JOSE.JWK.from() - |> do_verify(token_alg, jwt) + |> verify_signature(token_alg, jwt) |> case do {false, _claims, _jwt} -> false verified_claims -> verified_claims @@ -254,7 +271,7 @@ defmodule OpenIDConnect do end) end - defp do_verify(%JOSE.JWK{} = jwk, token_alg, jwt), + defp verify_signature(%JOSE.JWK{} = jwk, token_alg, jwt), do: JOSE.JWS.verify_strict(jwk, [token_alg], jwt) defp from_certs(certs) do @@ -266,6 +283,43 @@ defmodule OpenIDConnect do end end + defp verify_claims(claims, config) do + with :ok <- verify_exp_claim(claims, exp_leeway(config)), + :ok <- verify_aud_claim(claims, client_id(config)) do + {:ok, claims} + end + end + + defp verify_exp_claim(claims, leeway) do + case Map.fetch(claims, "exp") do + {:ok, exp} when is_integer(exp) -> + if epoch() < exp + leeway, + do: :ok, + else: {:error, "exp", "token has expired"} + + {:ok, _exp} -> + {:error, "exp", "is invalid"} + + :error -> + {:error, "exp", "missing"} + end + end + + defp verify_aud_claim(claims, expected_aud) do + case Map.fetch(claims, "aud") do + {:ok, aud} -> + if audience_matches?(aud, expected_aud), + do: :ok, + else: {:error, "aud", "token is intended for another application"} + + :error -> + {:error, "aud", "missing"} + end + end + + defp audience_matches?(aud, expected_aud) when is_list(aud), do: Enum.member?(aud, expected_aud) + defp audience_matches?(aud, expected_aud), do: aud === expected_aud + defp discovery_document(provider, name) do GenServer.call(name, {:discovery_document, provider}) end @@ -294,6 +348,10 @@ defmodule OpenIDConnect do Keyword.get(config, :redirect_uri) end + defp exp_leeway(config) do + Keyword.get(config, :leeway, 30) + end + defp response_type(provider, config, name) do response_type = config @@ -405,4 +463,6 @@ defmodule OpenIDConnect do defp http_client_options do Application.get_env(:openid_connect, :http_client_options, []) end + + defp epoch, do: DateTime.utc_now() |> DateTime.to_unix() end diff --git a/test/openid_connect_test.exs b/test/openid_connect_test.exs index b964560..982ab89 100644 --- a/test/openid_connect_test.exs +++ b/test/openid_connect_test.exs @@ -381,13 +381,13 @@ defmodule OpenIDConnectTest do {jwk, []} = Code.eval_file("test/fixtures/rsa/jwk1.exs") :ok = GenServer.call(pid, {:put, :jwk, JOSE.JWK.from(jwk)}) - claims = %{"email" => "brian@example.com"} + claims = %{ + "email" => "brian@example.com", + "exp" => epoch() + 60 * 60, + "aud" => "CLIENT_ID_1" + } - {_alg, token} = - jwk - |> JOSE.JWK.from() - |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) - |> JOSE.JWS.compact() + {_alg, token} = build_token(jwk, claims) result = OpenIDConnect.verify(:google, token) assert result == {:ok, claims} @@ -403,15 +403,17 @@ defmodule OpenIDConnectTest do {jwk, []} = Code.eval_file("test/fixtures/rsa/jwks.exs") :ok = GenServer.call(pid, {:put, :jwk, JOSE.JWK.from(jwk)}) - claims = %{"email" => "brian@example.com"} + claims = %{ + "email" => "brian@example.com", + "exp" => epoch() + 60 * 60, + "aud" => "CLIENT_ID_1" + } {_alg, token} = jwk |> Map.get("keys") |> List.last() - |> JOSE.JWK.from() - |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) - |> JOSE.JWS.compact() + |> build_token(claims) result = OpenIDConnect.verify(:google, token) assert result == {:ok, claims} @@ -480,7 +482,7 @@ defmodule OpenIDConnectTest do end end - test "fails when verification fails" do + test "fails when signature verification fails" do {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) try do @@ -490,11 +492,7 @@ defmodule OpenIDConnectTest do claims = %{"email" => "brian@example.com"} - {_alg, token} = - jwk2 - |> JOSE.JWK.from() - |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) - |> JOSE.JWS.compact() + {_alg, token} = build_token(jwk2, claims) result = OpenIDConnect.verify(:google, token) assert result == {:error, :verify, "verification failed"} @@ -512,11 +510,7 @@ defmodule OpenIDConnectTest do claims = %{"email" => "brian@example.com"} - {_alg, token} = - jwk - |> JOSE.JWK.from() - |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) - |> JOSE.JWS.compact() + {_alg, token} = build_token(jwk, claims) result = OpenIDConnect.verify(:google, token <> " :)") assert result == {:error, :verify, "verification error"} @@ -524,10 +518,126 @@ defmodule OpenIDConnectTest do GenServer.stop(pid) end end + + test "fails when the token is expired" do + {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) + + try do + {jwk, []} = Code.eval_file("test/fixtures/rsa/jwks.exs") + :ok = GenServer.call(pid, {:put, :jwk, JOSE.JWK.from(jwk)}) + + claims = %{ + "email" => "brian@example.com", + "exp" => epoch() - 60, + "aud" => "CLIENT_ID_1" + } + + {_alg, token} = + jwk + |> Map.get("keys") + |> List.last() + |> build_token(claims) + + result = OpenIDConnect.verify(:google, token) + assert result == {:error, :verify, "invalid exp claim: token has expired"} + after + GenServer.stop(pid) + end + end + + test "accepts expired token if within leeway" do + {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) + + try do + {jwk, []} = Code.eval_file("test/fixtures/rsa/jwks.exs") + :ok = GenServer.call(pid, {:put, :jwk, JOSE.JWK.from(jwk)}) + + claims = %{ + "email" => "brian@example.com", + "exp" => epoch() - 10, + "aud" => "CLIENT_ID_1" + } + + {_alg, token} = + jwk + |> Map.get("keys") + |> List.last() + |> build_token(claims) + + result = OpenIDConnect.verify(:google, token) + assert result == {:ok, claims} + after + GenServer.stop(pid) + end + end + + test "fails when the token is intended for a different application" do + {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) + + try do + {jwk, []} = Code.eval_file("test/fixtures/rsa/jwks.exs") + :ok = GenServer.call(pid, {:put, :jwk, JOSE.JWK.from(jwk)}) + + claims = %{ + "email" => "brian@example.com", + "exp" => epoch() + 60 * 60, + "aud" => "BOGUS" + } + + {_alg, token} = + jwk + |> Map.get("keys") + |> List.last() + |> build_token(claims) + + result = OpenIDConnect.verify(:google, token) + + assert result == + {:error, :verify, "invalid aud claim: token is intended for another application"} + after + GenServer.stop(pid) + end + end + + test "accepts token intended for multiple applications" do + {:ok, pid} = GenServer.start_link(MockWorker, [], name: :openid_connect) + + try do + {jwk, []} = Code.eval_file("test/fixtures/rsa/jwks.exs") + :ok = GenServer.call(pid, {:put, :jwk, JOSE.JWK.from(jwk)}) + + claims = %{ + "email" => "brian@example.com", + "exp" => epoch() + 60 * 60, + "aud" => ["some_client", "CLIENT_ID_1", "other_client"] + } + + {_alg, token} = + jwk + |> Map.get("keys") + |> List.last() + |> build_token(claims) + + result = OpenIDConnect.verify(:google, token) + + assert result == {:ok, claims} + after + GenServer.stop(pid) + end + end end defp set_jose_json_lib(_) do JOSE.json_module(JasonEncoder) [] end + + defp build_token(key, claims) do + key + |> JOSE.JWK.from() + |> JOSE.JWS.sign(Jason.encode!(claims), %{"alg" => "RS256"}) + |> JOSE.JWS.compact() + end + + defp epoch, do: DateTime.utc_now() |> DateTime.to_unix() end