diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b2dfbd..e2bce16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Added `ExFirebaseAuth.Cookie` to support for verifying session cookies. + ## 0.5.1 - Set default expiry on mocked token to 1 hour from utc now. diff --git a/README.md b/README.md index d1cb7bf..9d5ee23 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,10 @@ end Add the Firebase auth issuer name for your project to your `config.exs`. This is required to make sure only your project's firebase tokens are accepted. ```elixir -config :ex_firebase_auth, :issuer, "https://securetoken.google.com/project-123abc" +config :ex_firebase_auth, + issuer: "https://securetoken.google.com/project-123abc", + # See https://cloud.google.com/identity-platform/docs/reference/rest/v1/projects/createSessionCookie + cookie_issuer: "https://session.firebase.google.com/project-123abc" ``` Verifying a token @@ -42,6 +45,13 @@ ExFirebaseAuth.Token.verify_token("Some token string") iex> {:ok, "userid", %{}} ``` +Verifying a cookie + +```elixir +ExFirebaseAuth.Cookie.verify_cookie("Some token string") +iex> {:ok, "userid", %{}} +``` + Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) and published on [HexDocs](https://hexdocs.pm). Once published, the docs can be found at [https://hexdocs.pm/ex_firebase_auth](https://hexdocs.pm/ex_firebase_auth). diff --git a/lib/cookie.ex b/lib/cookie.ex new file mode 100644 index 0000000..2548f75 --- /dev/null +++ b/lib/cookie.ex @@ -0,0 +1,28 @@ +defmodule ExFirebaseAuth.Cookie do + @doc ~S""" + Returns the configured issuer + + ## Examples + + iex> ExFirebaseAuth.Token.issuer() + "https://session.firebase.google.com/project-123abc" + """ + def issuer, do: Application.fetch_env!(:ex_firebase_auth, :cookie_issuer) + + @spec verify_cookie(String.t()) :: + {:error, String.t()} | {:ok, String.t(), JOSE.JWT.t()} + @doc ~S""" + Verifies a cookie token agains Google's public keys. Returns {:ok, user_id, claims} if successful. {:error, _} otherwise. + + ## Examples + + iex> ExFirebaseAuth.Cookie.verify_cookie("ey.some.token") + {:ok, "user id", %{}} + + iex> ExFirebaseAuth.Cookie.verify_cookie("ey.some.token") + {:error, "Invalid JWT header, `kid` missing"} + """ + def verify_cookie(cookie_string) do + ExFirebaseAuth.Token.verify_token(cookie_string, issuer()) + end +end diff --git a/lib/mock.ex b/lib/mock.ex index ceb8f8f..03ad886 100644 --- a/lib/mock.ex +++ b/lib/mock.ex @@ -87,6 +87,42 @@ defmodule ExFirebaseAuth.Mock do payload end + @spec generate_cookie(String.t(), map) :: String.t() + @doc ~S""" + Generates a firebase-like session cookie token with the mock's private key. Will raise when mock is not enabled. + + ## Examples + + iex> ExFirebaseAuth.Mock.generate_cookie("userid", %{"claim" => "value"}) + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlLCJpc3MiOiJqb2UifQ.shLcxOl_HBBsOTvPnskfIlxHUibPN7Y9T4LhPB-iBwM" + """ + def generate_cookie(sub, claims \\ %{}) do + unless is_enabled?() do + raise "Cannot generate mocked token, because ExFirebaseAuth.Mock is not enabled in your config." + end + + {kid, jwk} = get_private_key() + + jws = %{ + "alg" => "RS256", + "kid" => kid + } + + # Put exp claim, unless previously specified in claims + exp = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.to_unix() + claims = Map.put_new(claims, "exp", exp) + + jwt = + Map.merge(claims, %{ + "iss" => ExFirebaseAuth.Cookie.issuer(), + "sub" => sub + }) + + {_, payload} = JOSE.JWT.sign(jwk, jws, jwt) |> JOSE.JWS.compact() + + payload + end + defp mock_config, do: Application.get_env(:ex_firebase_auth, :mock, []) defp find_or_create_private_key_table do diff --git a/lib/source/google_key_source.ex b/lib/source/google_key_source.ex index c1552c0..791a0ad 100644 --- a/lib/source/google_key_source.ex +++ b/lib/source/google_key_source.ex @@ -1,18 +1,30 @@ defmodule ExFirebaseAuth.KeySource.Google do @moduledoc false - @endpoint_url "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com" + @endpoint_urls [ + "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com", + "https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys" + ] @behaviour ExFirebaseAuth.KeySource def fetch_certificates do - with {:ok, %Finch.Response{body: body}} <- - Finch.build(:get, @endpoint_url) |> Finch.request(ExFirebaseAuthFinch), - {:ok, json_data} <- Jason.decode(body) do - {:ok, convert_to_jose_keys(json_data)} + results = + @endpoint_urls + |> Enum.map(fn endpoint_url -> + with {:ok, %Finch.Response{body: body}} <- + Finch.build(:get, endpoint_url) |> Finch.request(ExFirebaseAuthFinch), + {:ok, json_data} <- Jason.decode(body) do + {:ok, convert_to_jose_keys(json_data)} + else + _ -> :error + end + end) + + if Enum.any?(results, &(&1 == :error)) do + :error else - _ -> - :error + {:ok, Enum.reduce(results, %{}, fn {:ok, result}, acc -> Enum.into(result, acc) end)} end end diff --git a/lib/token.ex b/lib/token.ex index 76e9576..7944e90 100644 --- a/lib/token.ex +++ b/lib/token.ex @@ -23,7 +23,7 @@ defmodule ExFirebaseAuth.Token do @spec verify_token(String.t()) :: {:error, String.t()} | {:ok, String.t(), JOSE.JWT.t()} @doc ~S""" - Verifies a token agains google's public keys. Returns {:ok, user_id, claims} if successful. {:error, _} otherwise. + Verifies a token against Google's public keys. Returns {:ok, user_id, claims} if successful. {:error, _} otherwise. ## Examples @@ -34,8 +34,10 @@ defmodule ExFirebaseAuth.Token do {:error, "Invalid JWT header, `kid` missing"} """ def verify_token(token_string) do - issuer = issuer() + verify_token(token_string, issuer()) + end + def verify_token(token_string, issuer) do with {:jwtheader, %{fields: %{"kid" => kid}}} <- peek_token_kid(token_string), # read key from store {:key, %JOSE.JWK{} = key} <- {:key, get_public_key(kid)}, diff --git a/mix.exs b/mix.exs index 2f875c0..ce83a68 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule ExFirebaseAuth.MixProject do def project do [ app: :ex_firebase_auth, - version: "0.5.1", + version: "0.6.0", elixir: "~> 1.10", start_permanent: Mix.env() == :prod, deps: deps(), @@ -30,8 +30,8 @@ defmodule ExFirebaseAuth.MixProject do defp deps do [ {:jose, "~> 1.10"}, - {:finch, "~> 0.10.0"}, - {:jason, "~> 1.3.0"}, + {:finch, "~> 0.10"}, + {:jason, "~> 1.3"}, {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} ] end diff --git a/test/cookie_test.exs b/test/cookie_test.exs new file mode 100644 index 0000000..e5a7cf9 --- /dev/null +++ b/test/cookie_test.exs @@ -0,0 +1,170 @@ +defmodule ExFirebaseAuth.CookieTest do + use ExUnit.Case + + alias ExFirebaseAuth.{ + Cookie, + Mock + } + + defp generate_cookie(claims, jws) do + [{_kid, jwk}] = :ets.lookup(ExFirebaseAuth.Mock, :ets.first(ExFirebaseAuth.Mock)) + + {_, payload} = JOSE.JWT.sign(jwk, jws, claims) |> JOSE.JWS.compact() + + payload + end + + setup do + Application.put_env(:ex_firebase_auth, :mock, enabled: true) + Mock.generate_and_store_key_pair() + + on_exit(fn -> + :ok = Application.delete_env(:ex_firebase_auth, :mock) + :ok = Application.delete_env(:ex_firebase_auth, :cookie_issuer) + end) + end + + describe "Cookie.verify_cookie/1" do + test "Does succeed on correct token" do + issuer = Enum.random(?a..?z) + Application.put_env(:ex_firebase_auth, :cookie_issuer, issuer) + + sub = Enum.random(?a..?z) + time_in_future = DateTime.utc_now() |> DateTime.add(360, :second) |> DateTime.to_unix() + claims = %{"exp" => time_in_future} + valid_token = Mock.generate_cookie(sub, claims) + assert {:ok, ^sub, jwt} = Cookie.verify_cookie(valid_token) + + %JOSE.JWT{ + fields: %{ + "iss" => iss_claim, + "sub" => sub_claim + } + } = jwt + + assert sub_claim == sub + assert iss_claim == issuer + end + + test "Does raise on no issuer being set" do + Application.put_env(:ex_firebase_auth, :cookie_issuer, "issuer") + valid_token = Mock.generate_cookie("subsub") + Application.delete_env(:ex_firebase_auth, :cookie_issuer) + + assert_raise( + ArgumentError, + ~r/^could not fetch application environment :cookie_issuer for application :ex_firebase_auth because configuration at :cookie_issuer was not set/, + fn -> + Cookie.verify_cookie(valid_token) + end + ) + end + + test "Does fail on no `kid` being set in JWT header" do + sub = Enum.random(?a..?z) + Application.put_env(:ex_firebase_auth, :cookie_issuer, "issuer") + + token = + generate_cookie( + %{ + "sub" => sub, + "iss" => "issuer" + }, + %{ + "alg" => "RS256" + } + ) + + assert {:error, "Invalid JWT header, `kid` missing"} = Cookie.verify_cookie(token) + end + end + + test "Does fail invalid kid being set" do + sub = Enum.random(?a..?z) + Application.put_env(:ex_firebase_auth, :cookie_issuer, "issuer") + + token = + generate_cookie( + %{ + "sub" => sub, + "iss" => "issuer" + }, + %{ + "alg" => "RS256", + "kid" => "bogusbogus" + } + ) + + assert {:error, "Public key retrieved from google was not found or could not be parsed"} = + Cookie.verify_cookie(token) + end + + test "Does fail on invalid signature with non-matching kid" do + sub = Enum.random(?a..?z) + Application.put_env(:ex_firebase_auth, :cookie_issuer, "issuer") + + {_invalid_kid, public_key, private_key} = Mock.generate_key() + + _invalid_kid = JOSE.JWK.thumbprint(:md5, public_key) + [{valid_kid, _}] = :ets.lookup(ExFirebaseAuth.Mock, :ets.first(ExFirebaseAuth.Mock)) + + {_, token} = + JOSE.JWT.sign( + private_key, + %{ + "alg" => "RS256", + "kid" => valid_kid + }, + %{ + "sub" => sub, + "iss" => "issuer" + } + ) + |> JOSE.JWS.compact() + + assert {:error, "Invalid signature"} = Cookie.verify_cookie(token) + end + + test "Does fail on invalid issuer" do + sub = Enum.random(?a..?z) + Application.put_env(:ex_firebase_auth, :cookie_issuer, "issuer") + + [{kid, _}] = :ets.lookup(ExFirebaseAuth.Mock, :ets.first(ExFirebaseAuth.Mock)) + + token = + generate_cookie( + %{ + "sub" => sub, + "iss" => "bogusissuer" + }, + %{ + "alg" => "RS256", + "kid" => kid + } + ) + + assert {:error, "Signed by invalid issuer"} = Cookie.verify_cookie(token) + end + + test "Does fail on invalid JWT with raised exception handled" do + Application.put_env(:ex_firebase_auth, :cookie_issuer, "issuer") + + invalid_token = "invalid.jwt.token" + + assert {:error, "Invalid JWT"} = Cookie.verify_cookie(invalid_token) + end + + test "Does fail on expired JWT" do + issuer = Enum.random(?a..?z) + Application.put_env(:ex_firebase_auth, :cookie_issuer, issuer) + + sub = Enum.random(?a..?z) + + time_in_past = DateTime.utc_now() |> DateTime.add(-60, :second) |> DateTime.to_unix() + claims = %{"exp" => time_in_past} + + valid_token = Mock.generate_cookie(sub, claims) + + assert {:error, "Expired JWT"} = Cookie.verify_cookie(valid_token) + end +end