Current section
Files
Jump to
Current section
Files
lib/token_verifier.ex
defmodule AshAuthentication.Firebase.TokenVerifier do
@moduledoc """
Verifies Firebase ID tokens using Google's public keys.
Implements all security checks as per Firebase Auth documentation.
## Clock skew
Time-based claims (`exp`, `iat`, `auth_time`) are evaluated with a small
leeway to tolerate clock drift between Firebase / the client and the
server. The default leeway is 60 seconds; override with:
config :ash_authentication_firebase, clock_skew_leeway_seconds: 30
Valid values are integers in `0..300`. Anything outside that range — or
a non-integer — is logged as a warning and the default is used.
"""
require Logger
alias AshAuthentication.Firebase.Errors.InvalidToken
@issuer_prefix "https://securetoken.google.com/"
@default_clock_skew_leeway 60
@max_clock_skew_leeway 300
@clock_skew_cache_key {__MODULE__, :clock_skew_leeway}
@max_sub_length 128
@type claims :: %{optional(String.t()) => term()}
@doc """
Verifies a Firebase ID token against the provided project ID.
On success returns `{:ok, sub, claims}` where `sub` is the Firebase user id.
On failure returns `{:error, AshAuthentication.Firebase.Errors.InvalidToken.t()}`
whose `:reason` field describes the specific failure (see
`t:AshAuthentication.Firebase.Errors.InvalidToken.reason/0`).
"""
@spec verify(term(), term()) ::
{:ok, sub :: String.t(), claims()} | {:error, InvalidToken.t()}
def verify(token, project_id)
when is_binary(token) and is_binary(project_id) and token != "" and project_id != "" do
issuer = @issuer_prefix <> project_id
now = System.os_time(:second)
leeway = clock_skew_leeway()
with {:jwt_header, %JOSE.JWS{alg: {_, :RS256}, fields: %{"kid" => kid}}}
when is_binary(kid) and kid != "" <-
peek_token_kid(token),
{:ok, %JOSE.JWK{} = key} <- get_public_key_or_refresh(kid),
{:verify, {true, %{fields: fields}, _}} <- verify_jwt(key, token),
{:validate_iss, true} <- {:validate_iss, fields["iss"] == issuer},
{:validate_aud, true} <- {:validate_aud, fields["aud"] == project_id},
{:validate_sub, true} <- {:validate_sub, valid_sub?(fields["sub"])},
{:validate_exp, true} <-
{:validate_exp, is_integer(fields["exp"]) and fields["exp"] > now - leeway},
{:validate_iat, true} <-
{:validate_iat, is_integer(fields["iat"]) and fields["iat"] <= now + leeway},
{:validate_auth, true} <-
{:validate_auth,
is_integer(fields["auth_time"]) and fields["auth_time"] <= now + leeway} do
{:ok, fields["sub"], fields}
else
{:jwt_header, _} -> error(:invalid_header)
{:verify, {false, _, _}} -> error(:invalid_signature)
{:verify, _} -> error(:malformed_payload)
{:validate_iss, _} -> error(:invalid_issuer)
{:validate_aud, _} -> error(:invalid_audience)
{:validate_sub, _} -> error(:invalid_sub)
{:validate_exp, _} -> error(:expired)
{:validate_iat, _} -> error(:invalid_iat)
{:validate_auth, _} -> error(:invalid_auth_time)
{:error, _} -> error(:key_not_found)
end
end
def verify(_token, project_id) when not is_binary(project_id) or project_id == "" do
error(:invalid_project_id)
end
def verify(_token, _project_id), do: error(:invalid_token)
defp error(reason), do: {:error, InvalidToken.exception(reason: reason)}
defp valid_sub?(sub) when is_binary(sub),
do: sub != "" and byte_size(sub) <= @max_sub_length
defp valid_sub?(_), do: false
defp get_public_key_or_refresh(kid) do
case lookup_key(kid) do
{:ok, jwk} ->
{:ok, jwk}
{:error, _} ->
_ = key_store().refresh_now()
case lookup_key(kid) do
{:ok, jwk} -> {:ok, jwk}
{:error, _} -> {:error, :key_not_found}
end
end
end
defp lookup_key(kid) do
case key_store().get_keys() do
{:ok, keys} ->
case Map.fetch(keys, kid) do
{:ok, jwk} -> {:ok, jwk}
:error -> {:error, :key_not_found}
end
error ->
error
end
end
defp peek_token_kid(token_string) do
{:jwt_header, JOSE.JWT.peek_protected(token_string)}
rescue
_ -> {:jwt_header, :invalid}
end
defp verify_jwt(key, token) do
{:verify, JOSE.JWT.verify_strict(key, ["RS256"], token)}
rescue
_ -> {:verify, :malformed}
end
defp key_store do
Application.get_env(
:ash_authentication_firebase,
:key_store,
AshAuthentication.Firebase.TokenVerifier.KeyStore
)
end
defp clock_skew_leeway do
case :persistent_term.get(@clock_skew_cache_key, :__absent__) do
:__absent__ ->
value = resolve_clock_skew_leeway()
:persistent_term.put(@clock_skew_cache_key, value)
value
cached ->
cached
end
end
defp resolve_clock_skew_leeway do
case Application.get_env(
:ash_authentication_firebase,
:clock_skew_leeway_seconds,
@default_clock_skew_leeway
) do
n when is_integer(n) and n >= 0 and n <= @max_clock_skew_leeway ->
n
other ->
Logger.warning(
"Invalid :clock_skew_leeway_seconds #{inspect(other)} " <>
"(must be an integer in 0..#{@max_clock_skew_leeway}); " <>
"falling back to #{@default_clock_skew_leeway}s"
)
@default_clock_skew_leeway
end
end
@doc false
# For tests that change :clock_skew_leeway_seconds at runtime.
def __reset_clock_skew_cache__ do
:persistent_term.erase(@clock_skew_cache_key)
:ok
end
end