-
Notifications
You must be signed in to change notification settings - Fork 27
Support verifying cookies from Firebase #53
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
base: main
Are you sure you want to change the base?
Changes from all commits
9ceba2b
d791360
5eedf38
4a981ee
249e06f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
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 | ||
end) | ||
|
||
if Enum.any?(results, &(&1 == :error)) do | ||
:error | ||
else | ||
Nickforall marked this conversation as resolved.
Show resolved
Hide resolved
|
||
_ -> | ||
:error | ||
{:ok, Enum.reduce(results, %{}, fn {:ok, result}, acc -> Enum.into(result, acc) end)} | ||
end | ||
end | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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"}, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
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 |
There was a problem hiding this comment.
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