Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate OpenID claims #36

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 68 additions & 8 deletions lib/openid_connect.ex
Original file line number Diff line number Diff line change
Expand Up @@ -152,21 +152,35 @@ 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)

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"}
Expand All @@ -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
Expand Down Expand Up @@ -242,19 +259,19 @@ 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
end
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
Expand All @@ -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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the client_id always going be the same as the expected aud? I was under the impression these could be different. Apologies in advance if this comment betrays my ignorance :).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From https://openid.net/specs/openid-connect-core-1_0.html#IDToken (emphasis mine)

aud
REQUIRED. Audience(s) that this ID Token is intended for. It MUST contain the OAuth 2.0 client_id of the Relying Party as an audience value. It MAY also contain identifiers for other audiences. In the general case, the aud value is an array of case sensitive strings. In the common special case when there is one audience, the aud value MAY be a single case sensitive string.

The audience_matches? private helper function handles the special case when the aud claim may contain multiple audiences.

{:ok, claims}
end
end

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps being able to opt out of these verifications would be useful to some. Certainly on board with secure by default.


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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
152 changes: 131 additions & 21 deletions test/openid_connect_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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" => "[email protected]"}
claims = %{
"email" => "[email protected]",
"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}
Expand All @@ -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" => "[email protected]"}
claims = %{
"email" => "[email protected]",
"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}
Expand Down Expand Up @@ -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
Expand All @@ -490,11 +492,7 @@ defmodule OpenIDConnectTest do

claims = %{"email" => "[email protected]"}

{_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"}
Expand All @@ -512,22 +510,134 @@ defmodule OpenIDConnectTest do

claims = %{"email" => "[email protected]"}

{_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"}
after
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" => "[email protected]",
"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" => "[email protected]",
"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" => "[email protected]",
"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" => "[email protected]",
"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