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

Full cached JWT validation #175

Open
wants to merge 2 commits 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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,19 @@

For an example implementation see the [Überauth Example](https://github.com/ueberauth/ueberauth_example) application.

## JWT validation

By default JWTs presented by Auth0 are not verified.

In order to validate the signature of the JWT, the `cachex_cache_id` option must be set to a valid [Cachex](https://github.com/whitfin/cachex) cache ID.

The easiest way is to just add a Cachex cache to your application's supervision children list:

```Supervisor.child_spec({Cachex, name: :ueberauth_auth0}, id: :ueberauth_auth0)```

This must be done in the primary application.

If the `cachex_cache_id` option is not set, validation will not be done.
## Copyright and License

Copyright (c) 2015 Son Tran-Nguyen \
Expand Down
75 changes: 44 additions & 31 deletions lib/ueberauth/strategy/auth0.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ defmodule Ueberauth.Strategy.Auth0 do
]
Default is `"openid profile email"`.

To set the [`cachex_cache_id`](https://auth0.com/docs/glossary#cachex_cache_id)
config :ueberauth, Ueberauth,
providers: [
auth0: { Ueberauth.Strategy.Auth0, [cachex_cache_id: :example_cachex_id] }
]
Not used by default (set to `""`).

To set the [`audience`](https://auth0.com/docs/glossary#audience)
config :ueberauth, Ueberauth,
providers: [
Expand Down Expand Up @@ -84,6 +91,7 @@ defmodule Ueberauth.Strategy.Auth0 do
alias OAuth2.{Client, Error, Response}
alias Plug.Conn
alias Ueberauth.Auth.{Credentials, Extra, Info}
alias Ueberauth.Strategy.Auth0.Token

@doc """
Handles the redirect to Auth0.
Expand Down Expand Up @@ -128,24 +136,42 @@ defmodule Ueberauth.Strategy.Auth0 do
{code, state} = parse_params(conn)
module = option(conn, :oauth2_module)
redirect_uri = callback_url(conn)
otp_app = option(conn, :otp_app)

client =
apply(module, :get_token!, [
[code: code, redirect_uri: redirect_uri],
[otp_app: option(conn, :otp_app)]
[otp_app: otp_app]
])

token = client.token

if token.access_token == nil do
set_errors!(conn, [
error(
token.other_params["error"],
token.other_params["error_description"]
)
])
with {:token_validation, {:ok, _}} <- {:token_validation, Token.maybe_validation(otp_app, client)},
{:nil_token_check, {:ok, nil}} <- {:nil_token_check, nil_token_check(token)},
{:auth0, {:ok, %Response{status_code: status_code, body: user}}} when status_code in 200..399 <- {:auth0, Client.get(client, "/userinfo")}
do
conn
|> put_private(:auth0_user, user)
|> put_private(:auth0_token, token)
|> put_private(:auth0_state, state)
else
fetch_user(conn, client, state)
{:token_validation, err} ->
error = "token_validation"

set_errors!(conn, [error(error, err)])
{:nil_token_check, _err} ->
error = token.other_params["error"]
error_description = token.other_params["error_description"]

set_errors!(conn, [error(error, error_description)])

{:auth0, {:ok, %Response{status_code: 401, body: _body}}} ->
set_errors!(conn, [error("token", "unauthorized")])
{:auth0, {:error, %Response{body: body}}} ->
set_errors!(conn, [error("OAuth2", body)])
{:auth0, {:error, %Error{reason: reason}}} ->
set_errors!(conn, [error("OAuth2", reason)])

end
end

Expand All @@ -154,6 +180,15 @@ defmodule Ueberauth.Strategy.Auth0 do
set_errors!(conn, [error("missing_code", "No code received")])
end

defp nil_token_check(token) do
case token.access_token do
nil ->
{:error, :no_token}
_ ->
{:ok, nil}
end
end

@doc """
Cleans up the private area of the connection used for passing the raw Auth0 response around during the callback.
"""
Expand All @@ -163,28 +198,6 @@ defmodule Ueberauth.Strategy.Auth0 do
|> put_private(:auth0_token, nil)
end

defp fetch_user(conn, %{token: token} = client, state) do
conn =
conn
|> put_private(:auth0_token, token)
|> put_private(:auth0_state, state)

case Client.get(client, "/userinfo") do
{:ok, %Response{status_code: 401, body: _body}} ->
set_errors!(conn, [error("token", "unauthorized")])

{:ok, %Response{status_code: status_code, body: user}}
when status_code in 200..399 ->
put_private(conn, :auth0_user, user)

{:error, %Response{body: body}} ->
set_errors!(conn, [error("OAuth2", body)])

{:error, %Error{reason: reason}} ->
set_errors!(conn, [error("OAuth2", reason)])
end
end

@doc """
Fetches the uid field from the Auth0 response.
"""
Expand Down
140 changes: 140 additions & 0 deletions lib/ueberauth/strategy/auth0/token.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
defmodule Ueberauth.Strategy.Auth0.Token do

def maybe_validation(otp_app, client) do
configs = Application.get_env(otp_app || :ueberauth, Ueberauth.Strategy.Auth0.OAuth)
jwks_cache_id = Keyword.get(configs, :cachex_cache_id, nil)

case jwks_cache_id do
nil ->
# no validation. automatic success.
{:ok, nil}
jwks_cache_id ->
# full validation
validation(jwks_cache_id, client)
end
end

defp validation(jwks_cache_id, client) do
# full JWT validation
# see the following jose issue on validation
# https://github.com/potatosalad/erlang-jose/issues/28

base_url = client.site

# we want the raw JWT rather than the parsed result

jwt = Map.get(client.token.other_params, "id_token")

with {:jwks, {:ok, jwks}} <- {:jwks, jwks(jwks_cache_id, base_url)},
{:keys_by_alg_kid, {:ok, keys}} <- {:keys_by_alg_kid, keys_by_alg_kid(jwks)},
{:alg_and_kid, {:ok, %{"alg" => alg, "kid" => kid}}} <- {:alg_and_kid, alg_and_kid(jwt)},
{:jwt_verify, {true, payload, jws}} <- {:jwt_verify, jwt_verify(keys, jwt, alg, kid)}
do
{:ok, {payload, jws}}
else
{:jwks, err} -> {:error, {:jwks, err}}
{:keys_by_alg_kid, err} -> {:error, {:keys_by_alg_kid, err}}
{:alg_and_kid, err} -> {:error, {:alg_and_kid, err}}
{:jwt_verify, err} -> {:error, {:jwt_verify, err}}
err -> {:error, {:unknown, err}}
end
end

defp jwt_verify(keys, token, alg, kid) do

# perform the validation with the relevant key and algorithm
# the payload has the exact same data as before, but now it is trustworthy.

key = Map.get(keys, {alg, kid})

JOSE.JWS.verify_strict(key, [alg], token)

end

defp alg_and_kid(token) do
# we want the the algorithm and key ID of the signed JWT.
# these are safe to get since they describe _how_ the JWT is signed

token
|> JOSE.JWS.peek_protected()
|> Jason.decode()
end

defp keys_by_alg_kid(jwks) do
keys_by_alg_kid =
jwks
|> Map.fetch!("keys")
|> Enum.map(&JOSE.JWK.from/1)
|> Enum.map(fn k = %JOSE.JWK{fields: %{"alg" => alg, "kid" => kid}} -> {{alg, kid}, k} end)
|> Enum.into(%{})
{:ok, keys_by_alg_kid}
end

defp jwks(cache_id, site_url) do

# there should only be one JWKS data set per domain
# so the host is an ideal cache key

cache_key =
site_url
|> URI.parse()
|> Map.get(:host)

cache_ttl = 86400 # 24 hours

case Cachex.get(cache_id, cache_key) do
{:ok, nil} ->
# cache exists, no JWKS data

# forcing a store-fetch-decode to make sure it crashes and burns instantly
# rather than on the second request
with {:jwks_fetch, {:ok, jwks_data}} <- {:jwks_fetch, jwks_fetch(site_url)},
{:jwks_encode, {:ok, jwks_json_data}} <- {:jwks_encode, Jason.encode(jwks_data)},
{:jwks_cache_store, {:ok, true}} <- {:jwks_cache_store, Cachex.put(cache_id, cache_key, jwks_json_data, ttl: cache_ttl)},
{:jwks_cache_fetch, {:ok, jwks_encoded}} <- {:jwks_cache_fetch, Cachex.get(cache_id, cache_key)},
{:jwks_cache_decode, {:ok, jwks_data}} <- {:jwks_cache_decode, Jason.decode(jwks_encoded)}
do
{:ok, jwks_data}
else
{:jwks_fetch, err} -> {:error, {:jwks_fetch, err}}
{:jwks_encode, err} -> {:error, {:jwks_encode, err}}
{:jwks_cache_store, err} -> {:error, {:jwks_cache_store, err}}
{:jwks_cache_fetch, err} -> {:error, {:jwks_cache_fetch, err}}
{:jwks_cache_decode, err} -> {:error, {:jwks_cache_decode, err}}
end

{:ok, result} ->
# cache and key exist
Jason.decode(result)

{:error, err} ->
# something broke
{:error, err}
end
end
defp jwks_fetch(site_url) do
# construct the JWKS URL as per auth0 documentation, then fetch the JWKS key blob.
# https://auth0.com/docs/tokens/json-web-tokens/json-web-key-sets/locate-json-web-key-sets

auth0_openid_url =
site_url
|> URI.parse()
|> URI.merge("/.well-known/openid-configuration")
|> to_string()

with {:openid_fetch, {:ok, %Mojito.Response{body: openid_body, status_code: 200}}} <- {:openid_fetch, Mojito.get(auth0_openid_url)},
{:openid_decode, {:ok, %{"jwks_uri" => auth0_jwks_url}}} <- {:openid_decode, Jason.decode(openid_body)},
{:jwks_fetch, {:ok, %Mojito.Response{body: jwks_body, status_code: 200}}} <- {:jwks_fetch, Mojito.get(auth0_jwks_url)},
{:jwks_decode, {:ok, jwks}} <- {:jwks_decode, Jason.decode(jwks_body)}
do
{:ok, jwks}
else
{:openid_fetch, err} -> {:error, {:openid_fetch, err}}
{:openid_decode, err} -> {:error, {:openid_decode, err}}
{:jwks_fetch, err} -> {:error, {:jwks_fetch, err}}
{:jwks_decode, err} -> {:error, {:jwks_decode, err}}
err -> {:error, {:unknown, err}}
end
end

end
10 changes: 8 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ defmodule UeberauthAuth0.Mixfile do
use Mix.Project

@source_url "https://github.com/achedeuzot/ueberauth_auth0"
@version "1.0.0"
@version "1.0.1"

def project do
[
Expand Down Expand Up @@ -38,7 +38,7 @@ defmodule UeberauthAuth0.Mixfile do

def application do
[
extra_applications: [:logger]
extra_applications: [:jose, :cachex, :logger]
]
end

Expand All @@ -47,6 +47,12 @@ defmodule UeberauthAuth0.Mixfile do
{:ueberauth, "~> 0.7"},
{:oauth2, "~> 2.0"},

# JWT validation
{:cachex, "~> 3.4"},
{:jose, "~> 1.11"},
{:mojito, "~> 0.7.7"},
{:jason, "~> 1.1"},

# Docs:
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false},

Expand Down
Loading