├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── elixir.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── lib ├── ex_firebase_auth.ex ├── key_source.ex ├── key_store.ex ├── mock.ex ├── source │ ├── google_key_source.ex │ └── mock_key_source.ex └── token.ex ├── mix.exs ├── mix.lock └── test ├── key_store_test.exs ├── mock_test.exs ├── test_helper.exs └── token_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: ex_doc 11 | versions: 12 | - 0.24.0 13 | - 0.24.1 14 | - dependency-name: finch 15 | versions: 16 | - 0.6.0 17 | - 0.6.1 18 | - 0.6.2 19 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | # Based upon https://github.com/absinthe-graphql/absinthe/blob/master/.github/workflows/elixir.yml 2 | # Copyright (c) Bruce Williams, Ben Wilson 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | test: 14 | name: Elixir ${{matrix.elixir}} / OTP ${{matrix.otp}} 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | elixir: 20 | - "1.11" 21 | - "1.12" 22 | otp: 23 | - "23" 24 | - "24" 25 | 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v2 29 | 30 | - name: Set up Elixir 31 | uses: erlef/setup-elixir@v1 32 | with: 33 | elixir-version: ${{ matrix.elixir }} 34 | otp-version: ${{ matrix.otp }} 35 | 36 | - name: Restore deps cache 37 | uses: actions/cache@v2 38 | with: 39 | path: | 40 | deps 41 | _build 42 | key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }}-${{ github.sha }} 43 | restore-keys: | 44 | deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 45 | deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }} 46 | 47 | - name: Install package dependencies 48 | run: mix deps.get 49 | 50 | - name: Check Formatting 51 | run: mix format --check-formatted 52 | 53 | - name: Run unit tests 54 | run: | 55 | mix clean 56 | mix test 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | ex_firebase_auth-*.tar 24 | 25 | 26 | # Temporary files for e.g. tests 27 | /tmp 28 | 29 | # elixir language server 30 | .elixir_ls/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.5.1 4 | 5 | - Set default expiry on mocked token to 1 hour from utc now. 6 | 7 | ## 0.5.0 8 | 9 | - Verify expiration date of token 10 | - Do not raise on a wrongly formatted JWT 11 | - Update dependencies to support telemetry 1.0 12 | 13 | ## 0.4.2 14 | 15 | - OTP 24 support 16 | 17 | ## 0.4.1 18 | 19 | - Add config value to prevent KeyStore from crashing (@lucasavila00 #19) 20 | 21 | ## 0.4.0 22 | 23 | - Support Elixir 1.10 24 | - Added a pluggable KeySource for testing of library 25 | - Added more comprehensive errors for invalid tokens 26 | - Added tests 27 | 28 | ## 0.3.1 29 | 30 | - Fixed an issue where token store was never refreshed 31 | 32 | ## 0.2.1 33 | 34 | - Tweaked the refresh interval of fetching private keys from Google 35 | 36 | ## 0.2.0 37 | 38 | - Improve performance of fetching public keys by storing them in an ETS table 39 | - Added `ExFirebaseAuth.Mock` for writing integration tests with ID tokens 40 | 41 | ## 0.1.0 Initial Release 42 | 43 | - 🔥 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Nick Vernij 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ExFirebaseAuth 🔥 2 | 3 | ExFirebaseAuth is a library that handles ID tokens from Firebase, which is useful for using Firebase's auth solution because Firebase does not have an Elixir SDK for auth themselves. ExFirebaseAuth also comes with some testing utilities that mock and generate ID tokens for your integration tests. 4 | 5 | [More information on how ID tokens work in Firebase Auth](https://firebase.google.com/docs/auth/admin/verify-id-tokens) 6 | 7 | This library 8 | 9 | - Keeps track of google's public keys used for signing ID tokens 10 | - Verifies ID tokens 11 | - Veries whether the issuer matches your firebase project 12 | 13 | This library does **not** 14 | 15 | - Aim to implement Firebase user admin SDK endpoints 16 | 17 | ## Installation 18 | 19 | If [available in Hex](https://hex.pm/packages/ex_firebase_auth), the package can be installed 20 | by adding `ex_firebase_auth` to your list of dependencies in `mix.exs`: 21 | 22 | ```elixir 23 | def deps do 24 | [ 25 | {:ex_firebase_auth, "~> 0.5.1"} 26 | ] 27 | end 28 | ``` 29 | 30 | ## Usage 31 | 32 | 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. 33 | 34 | ```elixir 35 | config :ex_firebase_auth, :issuer, "https://securetoken.google.com/project-123abc" 36 | ``` 37 | or if you'd like to define a different issuer per app 38 | ```elixir 39 | config :your_app, :ex_firebase_auth, 40 | issuer: "https://securetoken.google.com/project-123abc" 41 | ``` 42 | 43 | Verifying a token 44 | 45 | ```elixir 46 | ExFirebaseAuth.Token.verify_token("Some token string") 47 | iex> {:ok, "userid", %{}} 48 | ``` 49 | or 50 | ```elixir 51 | ExFirebaseAuth.Token.verify_token("Some token string", :your_app) 52 | iex> {:ok, "userid", %{}} 53 | ``` 54 | 55 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 56 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 57 | be found at [https://hexdocs.pm/ex_firebase_auth](https://hexdocs.pm/ex_firebase_auth). 58 | -------------------------------------------------------------------------------- /lib/ex_firebase_auth.ex: -------------------------------------------------------------------------------- 1 | defmodule ExFirebaseAuth do 2 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | import Supervisor.Spec, warn: false 10 | 11 | # Define workers and child supervisors to be supervised 12 | children = [ 13 | {Finch, name: ExFirebaseAuthFinch}, 14 | {ExFirebaseAuth.KeyStore, name: ExFirebaseAuth.KeyStore} 15 | ] 16 | 17 | if ExFirebaseAuth.Mock.is_enabled?() do 18 | ExFirebaseAuth.Mock.generate_and_store_key_pair() 19 | end 20 | 21 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 22 | # for other strategies and supported options 23 | opts = [strategy: :one_for_one, name: ExFirebaseAuth.Supervisor] 24 | Supervisor.start_link(children, opts) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/key_source.ex: -------------------------------------------------------------------------------- 1 | defmodule ExFirebaseAuth.KeySource do 2 | @moduledoc false 3 | 4 | @callback fetch_certificates() :: :error | {:ok, list(JOSE.JWK.t())} 5 | 6 | def fetch_certificates do 7 | apply( 8 | Application.get_env(:ex_firebase_auth, :key_source, ExFirebaseAuth.KeySource.Google), 9 | :fetch_certificates, 10 | [] 11 | ) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/key_store.ex: -------------------------------------------------------------------------------- 1 | defmodule ExFirebaseAuth.KeyStore do 2 | @moduledoc """ 3 | The KeyStore handles fetching public keys from Google's servers to verify public keys with 4 | 5 | ## Warnings 6 | 7 | By default ExFirebaseAuth.KeyStore will stop when the initial fetch failed. This behavior can be 8 | changed by the following config key: 9 | 10 | ``` 11 | config :ex_firebase_auth, :key_store_fail_strategy, :silent 12 | ``` 13 | 14 | The following values are available 15 | 16 | - `:stop`: Stops server after initial fetch fails. 17 | - `:warn`: Logs a warning message with Logger. Continues retrying to fetch. 18 | - `:silent`: Silently retries fetching new public keys, without warning when failing. 19 | """ 20 | 21 | use GenServer, restart: :transient 22 | 23 | require Logger 24 | 25 | def start_link(_) do 26 | GenServer.start_link(__MODULE__, %{}, name: ExFirebaseAuth.KeyStore) 27 | end 28 | 29 | @spec key_store_fail_strategy :: :stop | :warn | :silent 30 | @doc ~S""" 31 | Returns the configured key_store_fail_strategy 32 | 33 | ## Examples 34 | 35 | iex> ExFirebaseAuth.Token.key_store_fail_strategy() 36 | :stop 37 | """ 38 | def key_store_fail_strategy, 39 | do: Application.get_env(:ex_firebase_auth, :key_store_fail_strategy, :stop) 40 | 41 | def init(_) do 42 | find_or_create_ets_table() 43 | 44 | case ExFirebaseAuth.KeySource.fetch_certificates() do 45 | :error -> 46 | case key_store_fail_strategy() do 47 | :stop -> 48 | {:stop, 49 | """ 50 | Initial certificate fetch failed 51 | 52 | If you want to run ExFirebaseAuth offline during tests or development, add the following key to your config 53 | 54 | ``` 55 | config :ex_firebase_auth, :key_store_fail_strategy, :silent 56 | ``` 57 | """} 58 | 59 | :warn -> 60 | unless key_store_fail_strategy() == :silent do 61 | Logger.warn("Fetching firebase auth certificates failed. Retrying again shortly.") 62 | end 63 | 64 | schedule_refresh(10) 65 | 66 | {:ok, %{}} 67 | 68 | :silent -> 69 | schedule_refresh(10) 70 | 71 | {:ok, %{}} 72 | end 73 | 74 | {:ok, data} -> 75 | store_data_to_ets(data) 76 | 77 | Logger.debug("Fetched initial firebase auth certificates.") 78 | 79 | schedule_refresh() 80 | 81 | {:ok, %{}} 82 | end 83 | end 84 | 85 | # When the refresh `info` is sent, we want to fetch the certificates 86 | def handle_info(:refresh, state) do 87 | case ExFirebaseAuth.KeySource.fetch_certificates() do 88 | # keep trying with a lower interval, until then keep the old state 89 | :error -> 90 | Logger.warn("Fetching firebase auth certificates failed, using old state and retrying...") 91 | schedule_refresh(10) 92 | 93 | # if everything went okay, refresh at the regular interval and store the returned keys in state 94 | {:ok, keys} -> 95 | store_data_to_ets(keys) 96 | 97 | Logger.debug("Fetched new firebase auth certificates.") 98 | schedule_refresh() 99 | end 100 | 101 | {:noreply, state} 102 | end 103 | 104 | @doc false 105 | def find_or_create_ets_table do 106 | case :ets.whereis(ExFirebaseAuth.KeyStore) do 107 | :undefined -> :ets.new(ExFirebaseAuth.KeyStore, [:set, :public, :named_table]) 108 | table -> table 109 | end 110 | end 111 | 112 | defp store_data_to_ets(data) do 113 | data 114 | |> Enum.each(fn {key, value} -> 115 | :ets.insert(ExFirebaseAuth.KeyStore, {key, value}) 116 | end) 117 | end 118 | 119 | defp schedule_refresh(after_s \\ 300) do 120 | Process.send_after(self(), :refresh, after_s * 1000) 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/mock.ex: -------------------------------------------------------------------------------- 1 | defmodule ExFirebaseAuth.Mock do 2 | @moduledoc """ 3 | This module will generate a public-private keypair and store it in ExFirebaseAuth's ETS tables. 4 | You can create ID tokens identical to Firebase's tokens for use in integrating testing your auth 5 | stack. 6 | 7 | When enabled, tokens generated from this mock will be accepted by ExFirebaseAuth.Token.verify_token/1 8 | 9 | ## Enabling the mock 10 | In order to prevent non-google tokens from being added to real-world environments, you need to 11 | enable the mock in your app's configuration. 12 | 13 | ```elixir 14 | config :ex_firebase_auth, :mock, 15 | enabled: true # defaults to false 16 | ``` 17 | """ 18 | 19 | @spec is_enabled? :: boolean() 20 | @doc """ 21 | Returns whether mocking is enabled, returns false by default 22 | """ 23 | def is_enabled?, do: Keyword.get(mock_config(), :enabled, false) 24 | 25 | @spec generate_and_store_key_pair :: any() 26 | @doc """ 27 | Generates and stores a new key pair in ETS tables. **Note: this already gets called on app init, 28 | you probably do not need this.** 29 | """ 30 | def generate_and_store_key_pair do 31 | unless is_enabled?() do 32 | raise "Cannot generate mocked token, because ExFirebaseAuth.Mock is not enabled in your config." 33 | end 34 | 35 | private_table = find_or_create_private_key_table() 36 | public_table = ExFirebaseAuth.KeyStore.find_or_create_ets_table() 37 | 38 | {kid, public_key, private_key} = generate_key() 39 | 40 | :ets.insert(private_table, {kid, private_key}) 41 | :ets.insert(public_table, {kid, public_key}) 42 | end 43 | 44 | @doc false 45 | def generate_key do 46 | private_key = JOSE.JWS.generate_key(%{"alg" => "RS256"}) 47 | public_key = JOSE.JWK.to_public(private_key) 48 | 49 | kid = JOSE.JWK.thumbprint(:md5, public_key) 50 | 51 | {kid, public_key, private_key} 52 | end 53 | 54 | @spec generate_token(String.t(), map, atom) :: String.t() 55 | @doc ~S""" 56 | Generates a firebase-like ID token with the mock's private key. Will raise when mock is not enabled. 57 | 58 | ## Examples 59 | 60 | iex> ExFirebaseAuth.Mock.generate_token("userid", %{"claim" => "value"}) 61 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlLCJpc3MiOiJqb2UifQ.shLcxOl_HBBsOTvPnskfIlxHUibPN7Y9T4LhPB-iBwM" 62 | 63 | iex> ExFirebaseAuth.Mock.generate_token("userid", %{"claim" => "value"}, :your_app) 64 | "aeHaajfIOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlLCJpc3MiOiJqb2UifQ.shLcxOl_HBBsOTvPnskfIlxHUibPN7Y9T4LhPB-iBwM" 65 | """ 66 | def generate_token(sub, claims \\ %{}, app \\ :ex_firebase_auth) do 67 | unless is_enabled?() do 68 | raise "Cannot generate mocked token, because ExFirebaseAuth.Mock is not enabled in your config." 69 | end 70 | 71 | {kid, jwk} = get_private_key() 72 | 73 | jws = %{ 74 | "alg" => "RS256", 75 | "kid" => kid 76 | } 77 | 78 | # Put exp claim, unless previously specified in claims 79 | exp = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.to_unix() 80 | claims = Map.put_new(claims, "exp", exp) 81 | 82 | jwt = 83 | Map.merge(claims, %{ 84 | "iss" => ExFirebaseAuth.Token.issuer(app), 85 | "sub" => sub 86 | }) 87 | 88 | {_, payload} = JOSE.JWT.sign(jwk, jws, jwt) |> JOSE.JWS.compact() 89 | 90 | payload 91 | end 92 | 93 | defp mock_config, do: Application.get_env(:ex_firebase_auth, :mock, []) 94 | 95 | defp find_or_create_private_key_table do 96 | case :ets.whereis(ExFirebaseAuth.Mock) do 97 | :undefined -> :ets.new(ExFirebaseAuth.Mock, [:set, :public, :named_table]) 98 | table -> table 99 | end 100 | end 101 | 102 | defp get_private_key do 103 | case :ets.lookup(ExFirebaseAuth.Mock, :ets.first(ExFirebaseAuth.Mock)) do 104 | [] -> raise "No private key set for ExFirebaseAuth.Mock, is mock enabled?" 105 | [{_kid, _key} = value] -> value 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/source/google_key_source.ex: -------------------------------------------------------------------------------- 1 | defmodule ExFirebaseAuth.KeySource.Google do 2 | @moduledoc false 3 | 4 | @endpoint_url "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com" 5 | 6 | @behaviour ExFirebaseAuth.KeySource 7 | 8 | def fetch_certificates do 9 | with {:ok, %Finch.Response{body: body}} <- 10 | Finch.build(:get, @endpoint_url) |> Finch.request(ExFirebaseAuthFinch), 11 | {:ok, json_data} <- Jason.decode(body) do 12 | {:ok, convert_to_jose_keys(json_data)} 13 | else 14 | _ -> 15 | :error 16 | end 17 | end 18 | 19 | defp convert_to_jose_keys(json_data) do 20 | json_data 21 | |> Enum.map(fn {key, value} -> 22 | case JOSE.JWK.from_pem(value) do 23 | [] -> {key, nil} 24 | jwk -> {key, jwk} 25 | end 26 | end) 27 | |> Enum.filter(fn {_, value} -> not is_nil(value) end) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/source/mock_key_source.ex: -------------------------------------------------------------------------------- 1 | defmodule ExFirebaseAuth.KeySource.Mock do 2 | @moduledoc false 3 | 4 | @behaviour ExFirebaseAuth.KeySource 5 | 6 | defp config do 7 | Application.get_env(:ex_firebase_auth, :key_source_mock, keys: []) 8 | end 9 | 10 | def fetch_certificates do 11 | {:ok, config()[:keys]} 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/token.ex: -------------------------------------------------------------------------------- 1 | defmodule ExFirebaseAuth.Token do 2 | defp get_public_key(keyid) do 3 | case :ets.lookup(ExFirebaseAuth.KeyStore, keyid) do 4 | [{_keyid, key}] -> 5 | key 6 | 7 | [] -> 8 | nil 9 | end 10 | end 11 | 12 | @default_app :ex_firebase_auth 13 | 14 | @spec issuer() :: String.t() 15 | @spec issuer(atom()) :: String.t() 16 | @doc ~S""" 17 | Returns the configured issuer 18 | 19 | ## Examples 20 | 21 | iex> ExFirebaseAuth.Token.issuer() 22 | "https://securetoken.google.com/project-123abc" 23 | 24 | iex> ExFirebaseAuth.Token.issuer(:my_other_project) 25 | "https://securetoken.google.com/other-project-123abc" 26 | """ 27 | def issuer(app \\ @default_app), do: do_get_issuer(app) 28 | defp do_get_issuer(@default_app), do: Application.fetch_env!(@default_app, :issuer) 29 | 30 | defp do_get_issuer(app), 31 | do: Application.fetch_env!(app, @default_app) |> Keyword.fetch!(:issuer) 32 | 33 | @spec verify_token(String.t(), atom()) :: 34 | {:error, String.t()} | {:ok, String.t(), JOSE.JWT.t()} 35 | @doc ~S""" 36 | Verifies a token agains google's public keys. Returns {:ok, user_id, claims} if successful. {:error, _} otherwise. 37 | 38 | ## Examples 39 | 40 | iex> ExFirebaseAuth.Token.verify_token("ey.some.token") 41 | {:ok, "user id", %{}} 42 | 43 | iex> ExFirebaseAuth.Token.verify_token("ey.some.token", :my_app) 44 | {:ok, "user id", %{}} 45 | 46 | iex> ExFirebaseAuth.Token.verify_token("ey.some.token") 47 | {:error, "Invalid JWT header, `kid` missing"} 48 | """ 49 | def verify_token(token_string, app \\ @default_app) do 50 | issuer = issuer(app) 51 | 52 | with {:jwtheader, %{fields: %{"kid" => kid}}} <- peek_token_kid(token_string), 53 | # read key from store 54 | {:key, %JOSE.JWK{} = key} <- {:key, get_public_key(kid)}, 55 | # check if verify returns true and issuer matches 56 | {:verify, {true, %{fields: %{"iss" => ^issuer, "sub" => sub, "exp" => exp}} = data, _}} <- 57 | {:verify, JOSE.JWT.verify(key, token_string)}, 58 | # Verify exp date 59 | {:verify, {:ok, _}} <- {:verify, verify_expiry(exp)} do 60 | {:ok, sub, data} 61 | else 62 | :invalidjwt -> 63 | {:error, "Invalid JWT"} 64 | 65 | {:jwtheader, _} -> 66 | {:error, "Invalid JWT header, `kid` missing"} 67 | 68 | {:key, _} -> 69 | {:error, "Public key retrieved from google was not found or could not be parsed"} 70 | 71 | {:verify, {false, _, _}} -> 72 | {:error, "Invalid signature"} 73 | 74 | {:verify, {true, _, _}} -> 75 | {:error, "Signed by invalid issuer"} 76 | 77 | {:verify, {:expired, _}} -> 78 | {:error, "Expired JWT"} 79 | 80 | {:verify, _} -> 81 | {:error, "None of public keys matched auth token's key ids"} 82 | end 83 | end 84 | 85 | defp peek_token_kid(token_string) do 86 | {:jwtheader, JOSE.JWT.peek_protected(token_string)} 87 | rescue 88 | _ -> :invalidjwt 89 | end 90 | 91 | defp verify_expiry(exp) do 92 | cond do 93 | exp > DateTime.utc_now() |> DateTime.to_unix() -> {:ok, exp} 94 | true -> {:expired, exp} 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ExFirebaseAuth.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :ex_firebase_auth, 7 | version: "0.5.1", 8 | elixir: "~> 1.10", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | package: package(), 12 | aliases: [test: "test"], 13 | docs: [ 14 | main: "readme", 15 | extras: ["README.md"] 16 | ] 17 | ] 18 | end 19 | 20 | # Run "mix help compile.app" to learn about applications. 21 | def application do 22 | [ 23 | mod: {ExFirebaseAuth, []}, 24 | extra_applications: [:logger], 25 | registered: [ExFirebaseAuth.KeyStore] 26 | ] 27 | end 28 | 29 | # Run "mix help deps" to learn about dependencies. 30 | defp deps do 31 | [ 32 | {:jose, "~> 1.11.10"}, 33 | {:finch, "~> 0.19.0"}, 34 | {:jason, "~> 1.4.0"}, 35 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} 36 | ] 37 | end 38 | 39 | defp package do 40 | [ 41 | description: "Handle ID Tokens from the Firebase Authentication service", 42 | links: %{ 43 | "GitHub" => "https://github.com/Nickforall/ExFirebaseAuth" 44 | }, 45 | licenses: ["MIT"] 46 | ] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "0.1.18", "deb5b9ab02400561b6f5708f3e7660fc35ca2d51bfc6a940d2f513f89c2975fc", [:mix], [], "hexpm", "61bbaf6452b782ef80b33cdb45701afbcf0a918a45ebe7e73f1130d661e66a06"}, 3 | "earmark_parser": {:hex, :earmark_parser, "1.4.30", "0b938aa5b9bafd455056440cdaa2a79197ca5e693830b4a982beada840513c5f", [:mix], [], "hexpm", "3b5385c2d36b0473d0b206927b841343d25adb14f95f0110062506b300cd5a1b"}, 4 | "ex_doc": {:hex, :ex_doc, "0.29.2", "dfa97532ba66910b2a3016a4bbd796f41a86fc71dd5227e96f4c8581fdf0fdf0", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "6b5d7139eda18a753e3250e27e4a929f8d2c880dd0d460cb9986305dea3e03af"}, 5 | "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, 6 | "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"}, 7 | "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, 8 | "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, 9 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 10 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 11 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 12 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 13 | "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, 14 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 15 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 16 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 17 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 18 | } 19 | -------------------------------------------------------------------------------- /test/key_store_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExFirebaseAuth.KeyStoreTest do 2 | use ExUnit.Case 3 | 4 | setup do 5 | {kid, public_key, _} = ExFirebaseAuth.Mock.generate_key() 6 | 7 | Application.get_env(:ex_firebase_auth, :key_source, ExFirebaseAuth.KeySource.Mock) 8 | 9 | Application.put_env(:ex_firebase_auth, :key_source_mock, 10 | keys: [ 11 | {kid, public_key} 12 | ] 13 | ) 14 | 15 | %{kid: kid, key: public_key} 16 | end 17 | 18 | test "Does add new key to ets on refresh", %{kid: kid, key: public_key} do 19 | assert :ets.lookup(ExFirebaseAuth.KeyStore, kid) == [] 20 | 21 | Process.send(ExFirebaseAuth.KeyStore, :refresh, []) 22 | 23 | # TODO: there's probably a better way to test this behavior 24 | Process.sleep(100) 25 | 26 | assert :ets.lookup(ExFirebaseAuth.KeyStore, kid) == [{kid, public_key}] 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/mock_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExFirebaseAuth.MockTest do 2 | use ExUnit.Case 3 | 4 | alias ExFirebaseAuth.Mock 5 | 6 | setup do 7 | on_exit(fn -> 8 | :ok = Application.delete_env(:ex_firebase_auth, :mock) 9 | end) 10 | end 11 | 12 | describe "Token.generate_and_store_key_pair/0" do 13 | test "Fails when mock is disabled" do 14 | assert_raise( 15 | RuntimeError, 16 | ~r/^Cannot generate mocked token, because ExFirebaseAuth.Mock is not enabled in your config./, 17 | fn -> 18 | Mock.generate_and_store_key_pair() 19 | end 20 | ) 21 | end 22 | 23 | test "Creates ETS table and stores key" do 24 | Application.put_env(:ex_firebase_auth, :mock, enabled: true) 25 | 26 | assert :ets.whereis(ExFirebaseAuth.Mock) == :undefined 27 | Mock.generate_and_store_key_pair() 28 | 29 | [{_, %JOSE.JWK{} = _}] = :ets.lookup(ExFirebaseAuth.Mock, :ets.first(ExFirebaseAuth.Mock)) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.put_env(:ex_firebase_auth, :key_source, ExFirebaseAuth.KeySource.Mock) 2 | 3 | ExUnit.start() 4 | -------------------------------------------------------------------------------- /test/token_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExFirebaseAuth.TokenTest do 2 | use ExUnit.Case 3 | 4 | alias ExFirebaseAuth.{ 5 | Token, 6 | Mock 7 | } 8 | 9 | defp generate_token(claims, jws) do 10 | [{_kid, jwk}] = :ets.lookup(ExFirebaseAuth.Mock, :ets.first(ExFirebaseAuth.Mock)) 11 | 12 | {_, payload} = JOSE.JWT.sign(jwk, jws, claims) |> JOSE.JWS.compact() 13 | 14 | payload 15 | end 16 | 17 | setup do 18 | Application.put_env(:ex_firebase_auth, :mock, enabled: true) 19 | Mock.generate_and_store_key_pair() 20 | 21 | on_exit(fn -> 22 | :ok = Application.delete_env(:ex_firebase_auth, :mock) 23 | :ok = Application.delete_env(:ex_firebase_auth, :issuer) 24 | end) 25 | end 26 | 27 | describe "Token.verify_token/1" do 28 | test "Does succeed on correct token" do 29 | issuer = Enum.random(?a..?z) 30 | Application.put_env(:ex_firebase_auth, :issuer, issuer) 31 | 32 | sub = Enum.random(?a..?z) 33 | time_in_future = DateTime.utc_now() |> DateTime.add(360, :second) |> DateTime.to_unix() 34 | claims = %{"exp" => time_in_future} 35 | valid_token = Mock.generate_token(sub, claims) 36 | assert {:ok, ^sub, jwt} = Token.verify_token(valid_token) 37 | 38 | %JOSE.JWT{ 39 | fields: %{ 40 | "iss" => iss_claim, 41 | "sub" => sub_claim 42 | } 43 | } = jwt 44 | 45 | assert sub_claim == sub 46 | assert iss_claim == issuer 47 | end 48 | 49 | test "Does succeed on correct token with specific app" do 50 | issuer = Enum.random(?a..?z) 51 | Application.put_env(:my_app, :ex_firebase_auth, issuer: issuer) 52 | 53 | sub = Enum.random(?a..?z) 54 | time_in_future = DateTime.utc_now() |> DateTime.add(360, :second) |> DateTime.to_unix() 55 | claims = %{"exp" => time_in_future} 56 | valid_token = Mock.generate_token(sub, claims, :my_app) 57 | assert {:ok, ^sub, jwt} = Token.verify_token(valid_token, :my_app) 58 | 59 | %JOSE.JWT{ 60 | fields: %{ 61 | "iss" => iss_claim, 62 | "sub" => sub_claim 63 | } 64 | } = jwt 65 | 66 | assert sub_claim == sub 67 | assert iss_claim == issuer 68 | end 69 | 70 | test "Does raise on referencing application with bad config" do 71 | assert_raise ArgumentError, fn -> Token.verify_token("any_toke", :my_app) end 72 | end 73 | 74 | test "Does raise on no issuer being set" do 75 | Application.put_env(:ex_firebase_auth, :issuer, "issuer") 76 | valid_token = Mock.generate_token("subsub") 77 | Application.delete_env(:ex_firebase_auth, :issuer) 78 | 79 | assert_raise( 80 | ArgumentError, 81 | ~r/^could not fetch application environment :issuer for application :ex_firebase_auth because configuration at :issuer was not set/, 82 | fn -> 83 | Token.verify_token(valid_token) 84 | end 85 | ) 86 | end 87 | 88 | test "Does fail on no `kid` being set in JWT header" do 89 | sub = Enum.random(?a..?z) 90 | Application.put_env(:ex_firebase_auth, :issuer, "issuer") 91 | 92 | token = 93 | generate_token( 94 | %{ 95 | "sub" => sub, 96 | "iss" => "issuer" 97 | }, 98 | %{ 99 | "alg" => "RS256" 100 | } 101 | ) 102 | 103 | assert {:error, "Invalid JWT header, `kid` missing"} = Token.verify_token(token) 104 | end 105 | end 106 | 107 | test "Does fail invalid kid being set" do 108 | sub = Enum.random(?a..?z) 109 | Application.put_env(:ex_firebase_auth, :issuer, "issuer") 110 | 111 | token = 112 | generate_token( 113 | %{ 114 | "sub" => sub, 115 | "iss" => "issuer" 116 | }, 117 | %{ 118 | "alg" => "RS256", 119 | "kid" => "bogusbogus" 120 | } 121 | ) 122 | 123 | assert {:error, "Public key retrieved from google was not found or could not be parsed"} = 124 | Token.verify_token(token) 125 | end 126 | 127 | test "Does fail on invalid signature with non-matching kid" do 128 | sub = Enum.random(?a..?z) 129 | Application.put_env(:ex_firebase_auth, :issuer, "issuer") 130 | 131 | {_invalid_kid, public_key, private_key} = Mock.generate_key() 132 | 133 | _invalid_kid = JOSE.JWK.thumbprint(:md5, public_key) 134 | [{valid_kid, _}] = :ets.lookup(ExFirebaseAuth.Mock, :ets.first(ExFirebaseAuth.Mock)) 135 | 136 | {_, token} = 137 | JOSE.JWT.sign( 138 | private_key, 139 | %{ 140 | "alg" => "RS256", 141 | "kid" => valid_kid 142 | }, 143 | %{ 144 | "sub" => sub, 145 | "iss" => "issuer" 146 | } 147 | ) 148 | |> JOSE.JWS.compact() 149 | 150 | assert {:error, "Invalid signature"} = Token.verify_token(token) 151 | end 152 | 153 | test "Does fail on invalid issuer" do 154 | sub = Enum.random(?a..?z) 155 | Application.put_env(:ex_firebase_auth, :issuer, "issuer") 156 | 157 | [{kid, _}] = :ets.lookup(ExFirebaseAuth.Mock, :ets.first(ExFirebaseAuth.Mock)) 158 | 159 | token = 160 | generate_token( 161 | %{ 162 | "sub" => sub, 163 | "iss" => "bogusissuer" 164 | }, 165 | %{ 166 | "alg" => "RS256", 167 | "kid" => kid 168 | } 169 | ) 170 | 171 | assert {:error, "Signed by invalid issuer"} = Token.verify_token(token) 172 | end 173 | 174 | test "Does fail on invalid JWT with raised exception handled" do 175 | Application.put_env(:ex_firebase_auth, :issuer, "issuer") 176 | 177 | invalid_token = "invalid.jwt.token" 178 | 179 | assert {:error, "Invalid JWT"} = Token.verify_token(invalid_token) 180 | end 181 | 182 | test "Does fail on expired JWT" do 183 | issuer = Enum.random(?a..?z) 184 | Application.put_env(:ex_firebase_auth, :issuer, issuer) 185 | 186 | sub = Enum.random(?a..?z) 187 | 188 | time_in_past = DateTime.utc_now() |> DateTime.add(-60, :second) |> DateTime.to_unix() 189 | claims = %{"exp" => time_in_past} 190 | 191 | valid_token = Mock.generate_token(sub, claims) 192 | 193 | assert {:error, "Expired JWT"} = Token.verify_token(valid_token) 194 | end 195 | end 196 | --------------------------------------------------------------------------------