Skip to content
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
28 changes: 28 additions & 0 deletions lib/cookie.ex
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions lib/mock.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 19 additions & 7 deletions lib/source/google_key_source.ex
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
defmodule ExFirebaseAuth.KeySource.Google do
@moduledoc false

@endpoint_url "https://www.googleapis.com/robot/v1/metadata/x509/[email protected]"
@endpoint_urls [
"https://www.googleapis.com/robot/v1/metadata/x509/[email protected]",
"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
Comment on lines +15 to +21
Copy link
Owner

Choose a reason for hiding this comment

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

Let's extract this to a seperate function for readability

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

Expand Down
6 changes: 4 additions & 2 deletions lib/token.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)},
Expand Down
6 changes: 3 additions & 3 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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"},
Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure if this can be a problem, but it might be better to remove these changes so it will be easier for the maintainer to merge the dependabot patches that are already waiting.

Copy link
Owner

Choose a reason for hiding this comment

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

Yeah i'll verify and merge dependabot's PRs, this change should be removed after rebasing :)

{:jason, "~> 1.3"},
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false}
]
end
Expand Down
170 changes: 170 additions & 0 deletions test/cookie_test.exs
Original file line number Diff line number Diff line change
@@ -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