├── .github ├── CODEOWNERS └── workflows │ ├── release.yml │ └── ci.yml ├── .formatter.exs ├── .gitignore ├── coveralls.json ├── test ├── test_helper.exs ├── support │ ├── repo.ex │ ├── file_helpers.ex │ └── case_template.ex ├── guardian │ ├── db_fail_test.exs │ ├── sweeper_test.exs │ ├── adapter │ │ └── ets_test.exs │ └── db_test.exs └── mix │ └── tasks │ └── guardian.db.gen.migration_test.exs ├── Makefile ├── .editorconfig ├── config └── config.exs ├── priv └── templates │ └── migration.exs.eex ├── LICENSE ├── lib ├── guardian │ ├── db │ │ ├── adapter.ex │ │ ├── sweeper.ex │ │ ├── ecto_adapter.ex │ │ ├── token.ex │ │ └── ets_adapter.ex │ └── db.ex └── mix │ └── tasks │ └── guardian_db.gen.migration.ex ├── CHANGELOG.md ├── mix.exs ├── README.md └── mix.lock /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @ueberauth/developers 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["formatter.exs", "mix.exs", "{config,lib,test,priv}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | doc/* 6 | .DS_Store 7 | /priv/temp 8 | /docs 9 | /cover 10 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "coverage_options": { 3 | "treat_no_relevant_lines_as_covered": true 4 | }, 5 | "skip_files": [ 6 | "test/support" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | {:ok, _pid} = Guardian.DB.TestSupport.Repo.start_link() 2 | ExUnit.start() 3 | Mox.defmock(Guardian.DB.MockAdapter, for: Guardian.DB.Adapter) 4 | -------------------------------------------------------------------------------- /test/support/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Guardian.DB.TestSupport.Repo do 2 | @moduledoc false 3 | 4 | use Ecto.Repo, 5 | otp_app: :guardian_db, 6 | adapter: Ecto.Adapters.Postgres 7 | 8 | def log(_cmd), do: nil 9 | end 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | deps: 2 | mix local.hex --force 3 | mix local.rebar --force 4 | mix deps.get 5 | 6 | linter: 7 | mix format --check-formatted 8 | mix credo 9 | 10 | testing: 11 | mix coveralls.json 12 | 13 | docs: 14 | mix inch.report 15 | 16 | ci: deps linter testing 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /test/guardian/db_fail_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Guardian.DBFailTest do 2 | use Guardian.DB.TestSupport.CaseTemplate 3 | 4 | test "after_encode_and_sign_in is fails" do 5 | token = get_token() 6 | assert token == nil 7 | 8 | {:error, :token_storage_failure} = 9 | Guardian.DB.after_encode_and_sign(%{}, "token", %{}, "The JWT") 10 | 11 | token = get_token() 12 | assert token == nil 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :guardian, Guardian.DB, 4 | issuer: "GuardianDB", 5 | secret_key: "HcdlxxmyDRvfrwdpjUPh2M8mWP+KtpOQK1g6fT5SHrnflSY8KiWeORqN6IZSJYTA", 6 | adapter: Guardian.DB.EctoAdapter, 7 | repo: Guardian.DB.TestSupport.Repo 8 | 9 | config :guardian_db, ecto_repos: [Guardian.DB.TestSupport.Repo] 10 | 11 | config :guardian_db, Guardian.DB.TestSupport.Repo, 12 | database: "guardian_db_test", 13 | pool: Ecto.Adapters.SQL.Sandbox, 14 | priv: "priv/temp/guardian_db_test", 15 | hostname: "localhost", 16 | username: "postgres", 17 | password: "postgres" 18 | -------------------------------------------------------------------------------- /priv/templates/migration.exs.eex: -------------------------------------------------------------------------------- 1 | defmodule <%= module_prefix %>.Repo.Migrations.CreateGuardianDBTokensTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(<%= inspect(schema_name) %>, primary_key: false<%= if not is_nil(db_prefix), do: ", prefix: \"#{db_prefix}\"" %>) do 6 | add(:jti, :string, primary_key: true) 7 | add(:aud, :string, primary_key: true) 8 | add(:typ, :string) 9 | add(:iss, :string) 10 | add(:sub, :string) 11 | add(:exp, :bigint) 12 | add(:jwt, :text) 13 | add(:claims, :map) 14 | timestamps() 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/support/file_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Guardian.DB.TestSupport.FileHelpers do 2 | @moduledoc false 3 | 4 | def tmp_path do 5 | Path.expand("../../priv/temp", __DIR__) 6 | end 7 | 8 | def tmp_path(path) do 9 | Path.expand("../../#{path}", __DIR__) 10 | end 11 | 12 | def create_dir(path) do 13 | run_if_abs_path(&File.mkdir_p!/1, path) 14 | end 15 | 16 | def destroy_dir(path) do 17 | run_if_abs_path(&File.rm_rf!/1, path) 18 | end 19 | 20 | defp run_if_abs_path(fun, path) do 21 | if path == Path.absname(path) do 22 | fun.(path) 23 | else 24 | raise "Expected an absolute path" 25 | end 26 | end 27 | 28 | def destroy_tmp_dir(path) do 29 | path |> tmp_path() |> destroy_dir() 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Hexpm Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | name: Publish 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Elixir 14 | uses: erlef/setup-beam@v1 15 | with: 16 | elixir-version: "1.14" 17 | otp-version: "24.3" 18 | - name: Restore dependencies cache 19 | uses: actions/cache@v2 20 | with: 21 | path: deps 22 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 23 | restore-keys: ${{ runner.os }}-mix- 24 | - name: Install dependencies 25 | run: | 26 | mix local.rebar --force 27 | mix local.hex --force 28 | mix deps.get 29 | - name: Run Hex Publish 30 | run: mix hex.publish --yes 31 | env: 32 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 33 | -------------------------------------------------------------------------------- /test/support/case_template.ex: -------------------------------------------------------------------------------- 1 | defmodule Guardian.DB.TestSupport.CaseTemplate do 2 | @moduledoc false 3 | 4 | use ExUnit.CaseTemplate 5 | alias Guardian.DB.TestSupport.Repo 6 | alias Guardian.DB.Token 7 | import Guardian.DB.TestSupport.FileHelpers 8 | 9 | using _opts do 10 | quote do 11 | import Guardian.DB.TestSupport.CaseTemplate 12 | alias Guardian.DB.TestSupport.Repo 13 | end 14 | end 15 | 16 | setup_all do 17 | on_exit(fn -> destroy_tmp_dir("priv/temp/guardian_db_test") end) 18 | :ok 19 | end 20 | 21 | setup do 22 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo) 23 | Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()}) 24 | :ok 25 | end 26 | 27 | def get_token(token_id \\ "token-uuid") do 28 | schema_name = 29 | :guardian 30 | |> Application.fetch_env!(Guardian.DB) 31 | |> Keyword.get(:schema_name, "guardian_tokens") 32 | 33 | Repo.get({schema_name, Token}, token_id) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Daniel Neighman 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/guardian/db/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Guardian.DB.Adapter do 2 | @moduledoc """ 3 | The Guardian DB Adapter. 4 | 5 | This behaviour allows to use any storage system 6 | for Guardian Tokens. 7 | """ 8 | 9 | @typep schema :: Ecto.Schema.t() 10 | @typep changeset :: Ecto.Changeset.t() 11 | @typep schema_or_changeset :: schema() | changeset() 12 | @typep claims :: map() 13 | @typep exp :: pos_integer() 14 | @typep sub :: binary() 15 | @typep opts :: keyword() 16 | 17 | @doc """ 18 | Retrieves JWT token 19 | Used in `Guardian.DB.Token.find_by_claims/1` 20 | """ 21 | @callback one(claims(), opts()) :: schema() | nil 22 | 23 | @doc """ 24 | Persists JWT token 25 | Used in `Guardian.DB.Token.create/2` 26 | """ 27 | @callback insert(schema_or_changeset(), opts()) :: {:ok, schema()} | {:error, changeset()} 28 | 29 | @doc """ 30 | Deletes JWT token 31 | Used in `Guardian.DB.Token.destroy_token/3` 32 | """ 33 | @callback delete(schema_or_changeset(), opts()) :: {:ok, schema()} | {:error, changeset()} 34 | 35 | @doc """ 36 | Purges all JWT tokens for a given subject. 37 | 38 | Returns a tuple containing the number of entries and any returned result as second element. 39 | """ 40 | @callback delete_by_sub(sub(), opts()) :: {integer(), nil | [term()]} 41 | 42 | @doc """ 43 | Purges all expired JWT tokens. 44 | 45 | Returns a tuple containing the number of entries and any returned result as second element. 46 | """ 47 | @callback purge_expired_tokens(exp(), opts()) :: {integer(), nil | [term()]} 48 | end 49 | -------------------------------------------------------------------------------- /test/mix/tasks/guardian.db.gen.migration_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Guardian.Db.Gen.MigrationTest do 2 | use ExUnit.Case, async: true 3 | import Mix.Tasks.Guardian.Db.Gen.Migration, only: [run: 1] 4 | import Guardian.DB.TestSupport.FileHelpers 5 | 6 | require TemporaryEnv 7 | 8 | @tmp_path Path.join(tmp_path(), inspect(Guardian.Db.Gen.Migration)) 9 | @migrations_path Path.join(@tmp_path, "migrations") 10 | 11 | defmodule My.Repo do 12 | def __adapter__ do 13 | true 14 | end 15 | 16 | def config do 17 | [priv: Path.join("priv/temp", inspect(Guardian.Db.Gen.Migration)), otp_app: :guardian_db] 18 | end 19 | end 20 | 21 | setup do 22 | create_dir(@migrations_path) 23 | on_exit(fn -> destroy_tmp_dir("priv/temp/Guardian.Db.Gen.Migration") end) 24 | :ok 25 | end 26 | 27 | test "generates a new migration" do 28 | run(["-r", to_string(My.Repo)]) 29 | assert [name] = File.ls!(@migrations_path) 30 | assert String.match?(name, ~r/^\d{14}_guardiandb\.exs$/) 31 | end 32 | 33 | test "generates a new migration with custom name" do 34 | custom_schema_name = "my_custom_guardian_tokens" 35 | value = [schema_name: custom_schema_name] 36 | 37 | TemporaryEnv.put :guardian, Guardian.DB, value do 38 | run(["-r", to_string(My.Repo)]) 39 | assert [name] = File.ls!(@migrations_path) 40 | 41 | path = Path.join(@migrations_path, name) 42 | 43 | assert String.match?(name, ~r/^\d{14}_guardiandb\.exs$/) 44 | assert File.read!(path) =~ ":#{custom_schema_name}" 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, synchronize] 6 | push: 7 | branches: 8 | - 'master' 9 | jobs: 10 | Test: 11 | runs-on: ubuntu-latest 12 | services: 13 | postgres: 14 | image: postgres 15 | env: 16 | POSTGRES_USER: postgres 17 | POSTGRES_PASSWORD: postgres 18 | POSTGRES_DB: guardian_db_test 19 | options: >- 20 | --health-cmd pg_isready 21 | --health-interval 10s 22 | --health-timeout 5s 23 | --health-retries 5 24 | ports: 25 | - 5432:5432 26 | steps: 27 | - name: Checkout Code 28 | uses: actions/checkout@v1 29 | - name: Set up Elixir 30 | uses: erlef/setup-beam@v1 31 | with: 32 | elixir-version: "1.14" 33 | otp-version: "24.3" 34 | - name: Install Dependencies 35 | run: | 36 | mix local.rebar --force 37 | mix local.hex --force 38 | mix deps.get 39 | - name: Run Tests 40 | run: mix test 41 | 42 | Linting: 43 | runs-on: ubuntu-latest 44 | steps: 45 | - name: Checkout Code 46 | uses: actions/checkout@v1 47 | - name: Set up Elixir 48 | uses: erlef/setup-beam@v1 49 | with: 50 | elixir-version: "1.14" 51 | otp-version: "24.3" 52 | - name: Install Dependencies 53 | run: | 54 | mix local.rebar --force 55 | mix local.hex --force 56 | mix deps.get 57 | - name: Run Formatter 58 | run: mix format --check-formatted 59 | - name: Run Credo 60 | run: mix credo 61 | -------------------------------------------------------------------------------- /lib/mix/tasks/guardian_db.gen.migration.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Guardian.Db.Gen.Migration do 2 | @shortdoc "Generates Guardian.DB's migration" 3 | 4 | @moduledoc """ 5 | Generates the required GuardianDb's database migration. 6 | 7 | It allows custom schema name, using the config 8 | entry `schema_name`. 9 | """ 10 | use Mix.Task 11 | 12 | import Mix.Ecto 13 | import Mix.Generator 14 | 15 | @doc false 16 | def run(args) do 17 | no_umbrella!("ecto.gen.migration") 18 | 19 | repos = parse_repo(args) 20 | 21 | Enum.each(repos, fn repo -> 22 | ensure_repo(repo, args) 23 | path = Ecto.Migrator.migrations_path(repo) 24 | 25 | source_path = 26 | :guardian_db 27 | |> Application.app_dir() 28 | |> Path.join("priv/templates/migration.exs.eex") 29 | 30 | config = Application.fetch_env!(:guardian, Guardian.DB) 31 | 32 | schema_name = 33 | config 34 | |> Keyword.get(:schema_name, "guardian_tokens") 35 | |> String.to_atom() 36 | 37 | prefix = Keyword.get(config, :prefix, nil) 38 | 39 | generated_file = 40 | EEx.eval_file(source_path, 41 | module_prefix: app_module(), 42 | schema_name: schema_name, 43 | db_prefix: prefix 44 | ) 45 | 46 | target_file = Path.join(path, "#{timestamp()}_guardiandb.exs") 47 | create_directory(path) 48 | create_file(target_file, generated_file) 49 | end) 50 | end 51 | 52 | defp app_module do 53 | Mix.Project.config() 54 | |> Keyword.fetch!(:app) 55 | |> to_string() 56 | |> Macro.camelize() 57 | end 58 | 59 | defp timestamp do 60 | {{y, m, d}, {hh, mm, ss}} = :calendar.universal_time() 61 | "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}" 62 | end 63 | 64 | defp pad(i) when i < 10, do: <> 65 | defp pad(i), do: to_string(i) 66 | end 67 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v3.0.0 4 | 5 | * Introduced `Guardian.DB.Adapter` behaviour to allow for custom database adapters to be used with Guardian DB. 6 | - Add `config :guardian, Guardian.DB, adapter: Guardian.DB.EctoAdapter` to fall back to the default Ecto adapter. 7 | - Added `Guardian.DB.ETSAdapter`. 8 | - Added `Guardian.DB.EctoAdapter`. 9 | * Allow migrations mix task with custom table name. 10 | * Make `jti` and `aud` required fields, since they are primary keys. 11 | 12 | ### Breaking changes 13 | 14 | * `Guardian.DB.Token.SweeperServer` becomes `Guardian.DB.Sweeper` 15 | * `sweep_interval` option is no longer supported. Specify interval directly instead. 16 | * Sweep intervals are now specified in milliseconds instead of minutes. 17 | 18 | ## v2.0.2 19 | 20 | * Fix deps range to include Guardian 2 21 | 22 | ## v0.8.0 23 | 24 | * Sweeper configuration now works in minutes 25 | * Update dependencies 26 | * Fix 1.4 warnings 27 | * Raise error when configuration is missing 28 | 29 | ## v0.7.0 30 | 31 | * Add a schema prefix 32 | * Add an expired token sweeper process 33 | 34 | ## v0.6.0 35 | 36 | ### Breaking 37 | 38 | * Add support for ecto 2 rc 39 | 40 | ## v0.5.0 41 | 42 | * Updated to support Guardian 0.10 43 | * Make Postgrex optional 44 | * Support database schema configuration 45 | * Improve scope token lookup 46 | 47 | ## v0.4.0 48 | 49 | * Update deps to use higher level of postgrex 50 | * Add the typ field 51 | 52 | When migrating form 0.3.0 to 0.4.0 you'll need to run a migration to add the typ 53 | field. 54 | 55 | ```elixir 56 | alter table(:guardian_tokens) do 57 | add :typ, :string 58 | end 59 | ``` 60 | 61 | ## v0.3.0 62 | 63 | Update the schema to use a map for claims. 64 | 65 | To update you'll need to change your schema. 66 | 67 | ``` 68 | mix ecto.gen.migration update_guardian_db_tokens 69 | 70 | alter table(:guardian_tokens) do 71 | remove :claims 72 | add :claims, :map 73 | end 74 | ``` 75 | 76 | ## V0.1.0 77 | 78 | Initial release 79 | -------------------------------------------------------------------------------- /lib/guardian/db/sweeper.ex: -------------------------------------------------------------------------------- 1 | defmodule Guardian.DB.Sweeper do 2 | @moduledoc """ 3 | A GenServer that periodically checks for, and expires, tokens from storage. 4 | 5 | To leverage the automated Sweeper functionality update your project's Application 6 | file to include the following child in your supervision tree: 7 | 8 | * `interval` - The interval between db sweeps to remove old tokens, in 9 | milliseconds. Defaults to 1 hour. 10 | 11 | ## Example 12 | 13 | ```elixir 14 | worker(Guardian.DB.Sweeper, [interval: 60 * 60 * 1000]) 15 | ``` 16 | """ 17 | use GenServer 18 | 19 | alias Guardian.DB.Token 20 | 21 | @sixty_minutes 60 * 60 * 1000 22 | 23 | def start_link(opts) do 24 | interval = Keyword.get(opts, :interval, @sixty_minutes) 25 | GenServer.start_link(__MODULE__, [interval: interval], name: __MODULE__) 26 | end 27 | 28 | @impl true 29 | def init(state) do 30 | {:ok, schedule(state)} 31 | end 32 | 33 | @impl true 34 | def handle_cast(:reset_timer, state) do 35 | {:noreply, schedule(state)} 36 | end 37 | 38 | @impl true 39 | def handle_cast(:sweep, state) do 40 | Token.purge_expired_tokens() 41 | {:noreply, schedule(state)} 42 | end 43 | 44 | @impl true 45 | def handle_info(:sweep, state) do 46 | Token.purge_expired_tokens() 47 | {:noreply, schedule(state)} 48 | end 49 | 50 | def handle_info(_, state), do: {:noreply, state} 51 | 52 | @doc """ 53 | Manually trigger a database purge of expired tokens. Also resets the current 54 | scheduled work. 55 | """ 56 | def purge do 57 | GenServer.cast(__MODULE__, :sweep) 58 | end 59 | 60 | @doc """ 61 | Reset the purge timer. 62 | """ 63 | def reset_timer do 64 | GenServer.cast(__MODULE__, :reset_timer) 65 | end 66 | 67 | defp schedule(opts) do 68 | if timer = Keyword.get(opts, :timer), do: Process.cancel_timer(timer) 69 | 70 | interval = Keyword.get(opts, :interval) 71 | timer = Process.send_after(self(), :sweep, interval) 72 | 73 | [interval: interval, timer: timer] 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/guardian/db/ecto_adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Guardian.DB.EctoAdapter do 2 | @moduledoc """ 3 | Implement the Guardian.DB.Adapter for Ecto.Repo 4 | """ 5 | 6 | import Ecto.Query 7 | 8 | alias Guardian.DB.Token 9 | 10 | @behaviour Guardian.DB.Adapter 11 | 12 | @default_schema_name "guardian_tokens" 13 | 14 | @impl true 15 | def one(claims, opts) do 16 | prefix = Keyword.get(opts, :prefix, nil) 17 | repo = Keyword.get(opts, :repo) 18 | 19 | jti = Map.get(claims, "jti") 20 | aud = Map.get(claims, "aud") 21 | 22 | opts 23 | |> query_schema() 24 | |> where([token], token.jti == ^jti and token.aud == ^aud) 25 | |> repo.one(prefix: prefix) 26 | end 27 | 28 | @impl true 29 | def insert(changeset, opts) do 30 | prefix = Keyword.get(opts, :prefix, nil) 31 | repo = Keyword.get(opts, :repo) 32 | 33 | data = 34 | changeset 35 | |> Map.get(:data) 36 | |> Ecto.put_meta(source: schema_name(opts)) 37 | |> Ecto.put_meta(prefix: prefix) 38 | 39 | changeset = %{changeset | data: data} 40 | 41 | repo.insert(changeset, prefix: prefix) 42 | end 43 | 44 | @impl true 45 | def delete(record, opts) do 46 | prefix = Keyword.get(opts, :prefix, nil) 47 | repo = Keyword.get(opts, :repo) 48 | 49 | repo.delete(record, prefix: prefix, stale_error_field: :stale_token) 50 | end 51 | 52 | @impl true 53 | def delete_by_sub(sub, opts) do 54 | prefix = Keyword.get(opts, :prefix, nil) 55 | repo = Keyword.get(opts, :repo) 56 | 57 | opts 58 | |> query_schema() 59 | |> where([token], token.sub == ^sub) 60 | |> repo.delete_all(prefix: prefix) 61 | end 62 | 63 | @impl true 64 | def purge_expired_tokens(timestamp, opts) do 65 | prefix = Keyword.get(opts, :prefix, nil) 66 | repo = Keyword.get(opts, :repo) 67 | 68 | opts 69 | |> query_schema() 70 | |> where([token], token.exp < ^timestamp) 71 | |> repo.delete_all(prefix: prefix) 72 | end 73 | 74 | defp query_schema(opts) do 75 | {schema_name(opts), Token} 76 | end 77 | 78 | defp schema_name(opts) do 79 | Keyword.get(opts, :schema_name, @default_schema_name) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/guardian/sweeper_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Guardian.DB.SweeperTest do 2 | use ExUnit.Case 3 | 4 | import Mox 5 | 6 | alias Guardian.DB.{Sweeper, Token} 7 | 8 | setup_all context do 9 | config = Application.get_env(:guardian, Guardian.DB) 10 | 11 | Application.put_env( 12 | :guardian, 13 | Guardian.DB, 14 | Keyword.put(config, :adapter, Guardian.DB.MockAdapter) 15 | ) 16 | 17 | on_exit(fn -> 18 | Application.put_env(:guardian, Guardian.DB, config) 19 | end) 20 | 21 | {:ok, context} 22 | end 23 | 24 | describe "handle_cast/2" do 25 | setup :verify_on_exit! 26 | 27 | test "resets the timer" do 28 | ref = Process.send_after(__MODULE__, :sweep, 1_000_000_000) 29 | 30 | assert {:noreply, [interval: 1_000_000_000, timer: timer]} = 31 | Sweeper.handle_cast(:reset_timer, timer: ref, interval: 1_000_000_000) 32 | 33 | assert ref != timer 34 | assert is_reference(timer) 35 | 36 | Process.cancel_timer(timer) 37 | end 38 | 39 | test "triggers a sweep and resets the timer" do 40 | expect(Guardian.DB.MockAdapter, :purge_expired_tokens, fn _, _ -> 41 | {0, []} 42 | end) 43 | 44 | ref = Process.send_after(__MODULE__, :sweep, 1_000_000_000) 45 | 46 | assert {:noreply, [interval: 1_000_000_000, timer: timer]} = 47 | Sweeper.handle_cast(:sweep, timer: ref, interval: 1_000_000_000) 48 | 49 | assert ref != timer 50 | assert is_reference(timer) 51 | 52 | Process.cancel_timer(timer) 53 | end 54 | end 55 | 56 | describe "handle_info/2" do 57 | setup :verify_on_exit! 58 | 59 | test "triggers a sweep and resets the timer" do 60 | expect(Guardian.DB.MockAdapter, :purge_expired_tokens, fn _, _ -> 61 | {0, []} 62 | end) 63 | 64 | ref = Process.send_after(__MODULE__, :sweep, 1_000_000_000) 65 | 66 | assert {:noreply, [interval: 1_000_000_000, timer: timer]} = 67 | Sweeper.handle_info(:sweep, timer: ref, interval: 1_000_000_000) 68 | 69 | assert ref != timer 70 | assert is_reference(timer) 71 | 72 | Process.cancel_timer(timer) 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/guardian/db/token.ex: -------------------------------------------------------------------------------- 1 | defmodule Guardian.DB.Token do 2 | @moduledoc """ 3 | A very simple model for storing tokens generated by `Guardian`. 4 | """ 5 | 6 | use Ecto.Schema 7 | import Ecto.Changeset 8 | 9 | alias Guardian.DB.Token 10 | 11 | @primary_key {:jti, :string, autogenerate: false} 12 | @required_fields ~w(jti aud)a 13 | @allowed_fields ~w(jti typ aud iss sub exp jwt claims)a 14 | 15 | schema "virtual: token" do 16 | field(:typ, :string) 17 | field(:aud, :string) 18 | field(:iss, :string) 19 | field(:sub, :string) 20 | field(:exp, :integer) 21 | field(:jwt, :string) 22 | field(:claims, :map) 23 | 24 | timestamps() 25 | end 26 | 27 | @doc """ 28 | Find one token by matching jti and aud. 29 | """ 30 | def find_by_claims(claims) do 31 | adapter().one(claims, config()) 32 | end 33 | 34 | @doc """ 35 | Create a new token based on the JWT and decoded claims. 36 | """ 37 | def create(claims, jwt) do 38 | adapter().insert(changeset(claims, jwt), config()) 39 | end 40 | 41 | @doc false 42 | def changeset(claims, jwt) do 43 | prepared_claims = 44 | claims 45 | |> Map.put("jwt", jwt) 46 | |> Map.put("claims", claims) 47 | 48 | %Token{} 49 | |> cast(prepared_claims, @allowed_fields) 50 | |> validate_required(@required_fields) 51 | end 52 | 53 | @doc """ 54 | Purge any tokens that are expired. This should be done periodically to keep 55 | your DB table clean of clutter. 56 | """ 57 | def purge_expired_tokens do 58 | timestamp = Guardian.timestamp() 59 | 60 | adapter().purge_expired_tokens(timestamp, config()) 61 | end 62 | 63 | @doc false 64 | def destroy_by_sub(sub) do 65 | adapter().delete_by_sub(sub, config()) 66 | end 67 | 68 | @doc false 69 | defp config do 70 | Application.fetch_env!(:guardian, Guardian.DB) 71 | end 72 | 73 | @doc false 74 | def destroy_token(nil, claims, jwt), do: {:ok, {claims, jwt}} 75 | 76 | def destroy_token(model, claims, jwt) do 77 | case adapter().delete(model, config()) do 78 | {:error, _} -> {:error, :could_not_revoke_token} 79 | nil -> {:error, :could_not_revoke_token} 80 | _ -> {:ok, {claims, jwt}} 81 | end 82 | end 83 | 84 | defp adapter do 85 | Keyword.get(config(), :adapter, Guardian.DB.EctoAdapter) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/guardian/db/ets_adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Guardian.DB.ETSAdapter do 2 | @moduledoc """ 3 | Implement the Guardian.DB.Adapter for ETS 4 | """ 5 | 6 | @behaviour Guardian.DB.Adapter 7 | 8 | @impl true 9 | def one(claims, opts) do 10 | jti = Map.get(claims, "jti") 11 | aud = Map.get(claims, "aud") 12 | 13 | match = 14 | opts 15 | |> Keyword.fetch!(:table) 16 | |> :ets.match({jti, aud, :_, :_, :"$1"}) 17 | 18 | case match do 19 | [[token]] -> token 20 | _ -> nil 21 | end 22 | end 23 | 24 | @impl true 25 | def insert(%{valid?: true} = changeset, opts) do 26 | table = Keyword.fetch!(opts, :table) 27 | token = Map.merge(changeset.data, changeset.changes) 28 | 29 | unless :ets.insert(table, {token.jti, token.aud, token.sub, token.exp, token}) do 30 | raise """ 31 | An error occurred trying to insert a new record into the ETS table #{table}. 32 | 33 | Please ensure you've created the table before attempting to insert records. 34 | """ 35 | end 36 | 37 | {:ok, token} 38 | end 39 | 40 | def insert(changeset, _opts) do 41 | {:error, changeset} 42 | end 43 | 44 | @impl true 45 | def delete(%{jti: jti} = token, opts) do 46 | table = Keyword.fetch!(opts, :table) 47 | 48 | unless :ets.delete(table, jti) do 49 | raise """ 50 | An error occurred trying to delete a record from the ETS table #{table}. 51 | 52 | Please ensure you've created the table before attempting to delete records. 53 | """ 54 | end 55 | 56 | {:ok, token} 57 | end 58 | 59 | @impl true 60 | def delete_by_sub(sub, opts) do 61 | table = Keyword.fetch!(opts, :table) 62 | 63 | table 64 | |> :ets.match({:"$1", :_, sub, :_, :"$2"}) 65 | |> delete_many(table) 66 | end 67 | 68 | @impl true 69 | def purge_expired_tokens(exp, opts) do 70 | table = Keyword.fetch!(opts, :table) 71 | matcher = [{{:"$1", :"$2", :"$3", :"$4", :"$5"}, [{:<, :"$4", exp}], [[:"$1", :"$5"]]}] 72 | 73 | table 74 | |> :ets.select(matcher) 75 | |> delete_many(table) 76 | end 77 | 78 | defp delete_many(tokens, table) do 79 | deleted_tokens = 80 | Enum.reduce(tokens, [], fn [jti, token], acc -> 81 | if :ets.delete(table, jti) do 82 | [token | acc] 83 | else 84 | acc 85 | end 86 | end) 87 | 88 | {length(deleted_tokens), deleted_tokens} 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Guardian.DB.Mixfile do 2 | use Mix.Project 3 | 4 | @version "3.0.0" 5 | @source_url "https://github.com/ueberauth/guardian_db" 6 | 7 | def project do 8 | [ 9 | name: "Guardian.DB", 10 | app: :guardian_db, 11 | version: @version, 12 | description: "DB tracking for token validity", 13 | elixir: "~> 1.4 or ~> 1.5", 14 | elixirc_paths: elixirc_paths(Mix.env()), 15 | package: package(), 16 | docs: docs(), 17 | build_embedded: Mix.env() == :prod, 18 | start_permanent: Mix.env() == :prod, 19 | aliases: aliases(), 20 | deps: deps(), 21 | test_coverage: [tool: ExCoveralls], 22 | preferred_cli_env: [ 23 | coveralls: :test, 24 | "coveralls.html": :test, 25 | "coveralls.json": :test 26 | ] 27 | ] 28 | end 29 | 30 | def application do 31 | [extra_applications: [:logger]] 32 | end 33 | 34 | defp elixirc_paths(:test), do: ["lib", "test/support"] 35 | defp elixirc_paths(_), do: ["lib"] 36 | 37 | defp deps do 38 | [ 39 | {:guardian, "~> 1.0 or ~> 2.0"}, 40 | {:ecto, "~> 3.0"}, 41 | {:ecto_sql, "~> 3.1"}, 42 | {:postgrex, "~> 0.13", optional: true}, 43 | 44 | # Tools 45 | {:dialyxir, ">= 0.0.0", only: :dev, runtime: false}, 46 | {:credo, ">= 0.0.0", only: [:dev, :test], runtime: false}, 47 | {:excoveralls, ">= 0.0.0", only: :test, runtime: false}, 48 | {:temporary_env, ">= 0.0.0", only: :test, runtime: false}, 49 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 50 | {:inch_ex, ">= 0.0.0", only: :dev, runtime: false}, 51 | {:mox, ">= 0.0.0", only: :test} 52 | ] 53 | end 54 | 55 | defp package do 56 | [ 57 | maintainers: ["Daniel Neighman", "Sean Callan", "Sonny Scroggin", "Yordis Prieto"], 58 | licenses: ["MIT"], 59 | links: %{GitHub: @source_url}, 60 | files: [ 61 | "lib", 62 | "CHANGELOG.md", 63 | "LICENSE", 64 | "mix.exs", 65 | "README.md", 66 | "priv/templates" 67 | ] 68 | ] 69 | end 70 | 71 | defp docs do 72 | [ 73 | main: "readme", 74 | homepage_url: @source_url, 75 | source_ref: "v#{@version}", 76 | source_url: @source_url, 77 | extras: ["README.md"] 78 | ] 79 | end 80 | 81 | defp aliases do 82 | [ 83 | test: [ 84 | "ecto.drop --quiet", 85 | "ecto.create --quiet", 86 | "guardian.db.gen.migration", 87 | "ecto.migrate", 88 | "test" 89 | ] 90 | ] 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /test/guardian/adapter/ets_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Guardian.DB.ETSAdapterTest do 2 | use ExUnit.Case 3 | 4 | alias Guardian.DB.ETSAdapter, as: Adapter 5 | alias Guardian.DB.Token 6 | 7 | setup_all do 8 | {:ok, table: :ets.new(:guardian_db_test, [:set, :public])} 9 | end 10 | 11 | describe "insert/2" do 12 | test "creates a new row and returns the token", %{table: table} do 13 | claims = %{ 14 | "jti" => "token-jti-insert-test", 15 | "typ" => "token-typ", 16 | "aud" => "token-aud", 17 | "sub" => "token-aub", 18 | "iss" => "token-iss", 19 | "exp" => Guardian.timestamp() + 1_000_000_000 20 | } 21 | 22 | assert {:ok, %{jti: "token-jti-insert-test", jwt: "test-jwt"}} = 23 | claims 24 | |> Token.changeset("test-jwt") 25 | |> Adapter.insert(table: table) 26 | end 27 | end 28 | 29 | describe "one/2" do 30 | test "returns the token by claims", %{table: table} do 31 | token = %Token{ 32 | aud: "token-aud", 33 | exp: Guardian.timestamp() + 1_000_000_000, 34 | jti: "token-jti-one-test", 35 | sub: "token-sub" 36 | } 37 | 38 | :ets.insert(table, {token.jti, token.aud, token.sub, token.exp, token}) 39 | 40 | assert %Token{} = 41 | Adapter.one(%{"aud" => "token-aud", "jti" => "token-jti-one-test"}, table: table) 42 | end 43 | end 44 | 45 | describe "delete/2" do 46 | test "deletes and returns the token", %{table: table} do 47 | token = %Token{ 48 | aud: "token-aud", 49 | exp: Guardian.timestamp() + 1_000_000_000, 50 | jti: "token-jti-delete-test", 51 | sub: "token-sub" 52 | } 53 | 54 | :ets.insert(table, {token.jti, token.aud, token.sub, token.exp, token}) 55 | 56 | assert {:ok, %Token{}} = Adapter.delete(token, table: table) 57 | 58 | assert [] = :ets.match(table, {token.jti, :_, :_, :"$1"}) 59 | end 60 | end 61 | 62 | describe "delete_by_sub/2" do 63 | test "deletes many tokens by the subject", %{table: table} do 64 | one = %Token{ 65 | aud: "token-aud", 66 | exp: Guardian.timestamp() + 1_000_000_000, 67 | jti: "token-jti-delete-by-sub-test1", 68 | sub: "subject" 69 | } 70 | 71 | two = %Token{ 72 | aud: "token-aud", 73 | exp: Guardian.timestamp() + 1_000_000_000, 74 | jti: "token-jti-delete-by-sub-test2", 75 | sub: "subject" 76 | } 77 | 78 | :ets.insert(table, {one.jti, one.aud, one.sub, one.exp, one}) 79 | :ets.insert(table, {two.jti, two.aud, two.sub, two.exp, two}) 80 | 81 | assert {2, [%Token{}, %Token{}]} = Adapter.delete_by_sub("subject", table: table) 82 | 83 | assert [] = :ets.match(table, {:_, :_, "subject", :"$1"}) 84 | end 85 | end 86 | 87 | describe "purge_expired_tokens/2" do 88 | test "deletes all tokens older than expiration", %{table: table} do 89 | one = %Token{ 90 | aud: "token-aud", 91 | exp: 1, 92 | jti: "token-jti-purge-test1", 93 | sub: "token-sub" 94 | } 95 | 96 | two = %Token{ 97 | aud: "token-aud", 98 | exp: Guardian.timestamp() + 1_000_000_000, 99 | jti: "token-jti-purge-test2", 100 | sub: "token-sub" 101 | } 102 | 103 | :ets.insert(table, {one.jti, one.aud, one.sub, one.exp, one}) 104 | :ets.insert(table, {two.jti, two.aud, two.sub, two.exp, two}) 105 | 106 | assert {1, [%Token{jti: "token-jti-purge-test1"}]} = 107 | Adapter.purge_expired_tokens(Guardian.timestamp(), table: table) 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /test/guardian/db_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Guardian.DBTest do 2 | use Guardian.DB.TestSupport.CaseTemplate 3 | alias Guardian.DB.Token 4 | 5 | setup do 6 | {:ok, 7 | %{ 8 | claims: %{ 9 | "jti" => "token-uuid", 10 | "typ" => "token", 11 | "aud" => "token", 12 | "sub" => "the_subject", 13 | "iss" => "the_issuer", 14 | "exp" => Guardian.timestamp() + 1_000_000_000 15 | } 16 | }} 17 | end 18 | 19 | test "after_encode_and_sign_in is successful", context do 20 | token = get_token() 21 | assert token == nil 22 | 23 | Guardian.DB.after_encode_and_sign(%{}, "token", context.claims, "The JWT") 24 | 25 | token = get_token() 26 | 27 | assert token != nil 28 | assert token.jti == "token-uuid" 29 | assert token.aud == "token" 30 | assert token.sub == "the_subject" 31 | assert token.iss == "the_issuer" 32 | assert token.exp == context.claims["exp"] 33 | assert token.claims == context.claims 34 | end 35 | 36 | test "on_verify with a record in the db", context do 37 | Token.create(context.claims, "The JWT") 38 | token = get_token() 39 | assert token != nil 40 | 41 | assert {:ok, {context.claims, "The JWT"}} == Guardian.DB.on_verify(context.claims, "The JWT") 42 | end 43 | 44 | test "on_verify without a record in the db", context do 45 | token = get_token() 46 | assert token == nil 47 | assert {:error, :token_not_found} == Guardian.DB.on_verify(context.claims, "The JWT") 48 | end 49 | 50 | test "on_refresh without a record in the db", context do 51 | token = get_token() 52 | assert token == nil 53 | 54 | Guardian.DB.after_encode_and_sign(%{}, "token", context.claims, "The JWT 1") 55 | old_stuff = {get_token(), context.claims} 56 | 57 | new_claims = %{ 58 | "jti" => "token-uuid1", 59 | "typ" => "token", 60 | "aud" => "token", 61 | "sub" => "the_subject", 62 | "iss" => "the_issuer", 63 | "exp" => Guardian.timestamp() + 2_000_000_000 64 | } 65 | 66 | Guardian.DB.after_encode_and_sign(%{}, "token", new_claims, "The JWT 2") 67 | new_stuff = {get_token("token-uuid1"), new_claims} 68 | 69 | assert Guardian.DB.on_refresh(old_stuff, new_stuff) == {:ok, old_stuff, new_stuff} 70 | end 71 | 72 | test "on_revoke without a record in the db", context do 73 | token = get_token() 74 | assert token == nil 75 | assert Guardian.DB.on_revoke(context.claims, "The JWT") == {:ok, {context.claims, "The JWT"}} 76 | end 77 | 78 | test "on_revoke with a record in the db", context do 79 | Token.create(context.claims, "The JWT") 80 | 81 | token = get_token() 82 | assert token != nil 83 | 84 | assert Guardian.DB.on_revoke(context.claims, "The JWT") == {:ok, {context.claims, "The JWT"}} 85 | 86 | token = get_token() 87 | assert token == nil 88 | end 89 | 90 | test "purge stale tokens" do 91 | Token.create( 92 | %{"jti" => "token1", "aud" => "token", "exp" => Guardian.timestamp() + 5000}, 93 | "Token 1" 94 | ) 95 | 96 | Token.create( 97 | %{"jti" => "token2", "aud" => "token", "exp" => Guardian.timestamp() - 5000}, 98 | "Token 2" 99 | ) 100 | 101 | Token.purge_expired_tokens() 102 | 103 | token1 = get_token("token1") 104 | token2 = get_token("token2") 105 | assert token1 != nil 106 | assert token2 == nil 107 | end 108 | 109 | test "revoke_all deletes all tokens of a sub" do 110 | sub = "the_subject" 111 | 112 | Token.create( 113 | %{"jti" => "token1", "aud" => "token", "exp" => Guardian.timestamp(), "sub" => sub}, 114 | "Token 1" 115 | ) 116 | 117 | Token.create( 118 | %{"jti" => "token2", "aud" => "token", "exp" => Guardian.timestamp(), "sub" => sub}, 119 | "Token 2" 120 | ) 121 | 122 | Token.create( 123 | %{"jti" => "token3", "aud" => "token", "exp" => Guardian.timestamp(), "sub" => sub}, 124 | "Token 3" 125 | ) 126 | 127 | assert Guardian.DB.revoke_all(sub) == {:ok, 3} 128 | assert Repo.all({"guardian_tokens", Token}) == [] 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/guardian/db.ex: -------------------------------------------------------------------------------- 1 | defmodule Guardian.DB do 2 | @moduledoc """ 3 | `Guardian.DB` is a simple module that hooks into `Guardian` to prevent 4 | playback of tokens. 5 | 6 | In `Guardian`, tokens aren't tracked so the main mechanism that exists to 7 | make a token inactive is to set the expiry and wait until it arrives. 8 | 9 | `Guardian.DB` takes an active role and stores each token in the database 10 | verifying it's presence (based on it's jti) when `Guardian` verifies the 11 | token. 12 | If the token is not present in the DB, the `Guardian` token cannot be 13 | verified. 14 | 15 | Provides a simple database storage and check for `Guardian` tokens. 16 | 17 | - When generating a token, the token is stored in a database. 18 | - When tokens are verified (channel, session or header) the database is 19 | checked for an entry that matches. If none is found, verification results in 20 | an error. 21 | - When logout, or revoking the token, the corresponding entry is removed 22 | 23 | # Setup 24 | 25 | ### Config 26 | 27 | Add your configuration to your environment files. You need to specify 28 | 29 | * `repo` 30 | 31 | You may also configure 32 | 33 | * `prefix` - The schema prefix to use. 34 | * `schema_name` - The name of the schema to use. Default "guardian_tokens". 35 | 36 | ### Sweeper 37 | 38 | In order to sweep your expired tokens from the db, you'll need to add 39 | `Guardian.DB.Sweeper` to your supervision tree. 40 | In your supervisor add it as a worker 41 | 42 | * `interval` - The interval between db sweeps to remove old tokens, in 43 | milliseconds. Defaults to 1 hour. 44 | 45 | ```elixir 46 | worker(Guardian.DB.Sweeper, [interval: 60 * 60 * 1000]) 47 | ``` 48 | 49 | # Migration 50 | 51 | `Guardian.DB` requires a table in your database. Create a migration like the 52 | following: 53 | 54 | ```elixir 55 | create table(:guardian_tokens, primary_key: false) do 56 | add(:jti, :string, primary_key: true) 57 | add(:typ, :string) 58 | add(:aud, :string) 59 | add(:iss, :string) 60 | add(:sub, :string) 61 | add(:exp, :bigint) 62 | add(:jwt, :text) 63 | add(:claims, :map) 64 | timestamps() 65 | end 66 | ``` 67 | 68 | `Guardian.DB` allow to use a custom schema name when creating the migration. 69 | You can configure the schema name from config like the following: 70 | 71 | ```elixir 72 | config :guardian, Guardian.DB, 73 | schema_name: "my_custom_schema 74 | ``` 75 | 76 | And when you run `mix guardian.db.gen.migration` it'll generate the following 77 | migration: 78 | 79 | ```elixir 80 | create table(:my_custom_schema, primary_key: false) do 81 | add(:jti, :string, primary_key: true) 82 | add(:typ, :string) 83 | add(:aud, :string) 84 | add(:iss, :string) 85 | add(:sub, :string) 86 | add(:exp, :bigint) 87 | add(:jwt, :text) 88 | add(:claims, :map) 89 | timestamps() 90 | end 91 | ``` 92 | 93 | `Guardian.DB` works by hooking into the lifecycle of your token module. 94 | 95 | You'll need to add it to 96 | 97 | * `after_encode_and_sign` 98 | * `on_verify` 99 | * `on_revoke` 100 | 101 | For example: 102 | 103 | ```elixir 104 | defmodule MyApp.AuthTokens do 105 | use Guardian, otp_app: :my_app 106 | 107 | # snip... 108 | 109 | def after_encode_and_sign(resource, claims, token, _options) do 110 | with {:ok, _} <- Guardian.DB.after_encode_and_sign(resource, claims["typ"], claims, token) do 111 | {:ok, token} 112 | end 113 | end 114 | 115 | def on_verify(claims, token, _options) do 116 | with {:ok, _} <- Guardian.DB.on_verify(claims, token) do 117 | {:ok, claims} 118 | end 119 | end 120 | 121 | def on_revoke(claims, token, _options) do 122 | with {:ok, _} <- Guardian.DB.on_revoke(claims, token) do 123 | {:ok, claims} 124 | end 125 | end 126 | end 127 | ``` 128 | """ 129 | 130 | alias Guardian.DB.Token 131 | 132 | @doc """ 133 | After the JWT is generated, stores the various fields of it in the DB for 134 | tracking. If the token type does not match the configured types to be stored, 135 | the claims are passed through. 136 | """ 137 | def after_encode_and_sign(resource, type, claims, jwt) do 138 | case store_token(type, claims, jwt) do 139 | {:error, _} -> {:error, :token_storage_failure} 140 | _ -> {:ok, {resource, type, claims, jwt}} 141 | end 142 | end 143 | 144 | defp store_token(type, claims, jwt) do 145 | if storable_type?(type) do 146 | Token.create(claims, jwt) 147 | else 148 | :ignore 149 | end 150 | end 151 | 152 | @doc """ 153 | When a token is verified, check to make sure that it is present in the DB. 154 | If the token is found, the verification continues, if not an error is 155 | returned. 156 | If the type of the token does not match the configured token storage types, 157 | the claims are passed through. 158 | """ 159 | def on_verify(claims, jwt) do 160 | case find_token(claims) do 161 | nil -> {:error, :token_not_found} 162 | _ -> {:ok, {claims, jwt}} 163 | end 164 | end 165 | 166 | defp find_token(%{"typ" => type} = claims) do 167 | if storable_type?(type) do 168 | Token.find_by_claims(claims) 169 | else 170 | :ignore 171 | end 172 | end 173 | 174 | @doc """ 175 | When a token is refreshed, we invalidate the old token and add the new token 176 | in the DB. 177 | """ 178 | def on_refresh({old_token, old_claims}, {new_token, new_claims}) do 179 | on_revoke(old_claims, old_token) 180 | after_encode_and_sign(%{}, new_claims["typ"], new_claims, new_token) 181 | 182 | {:ok, {old_token, old_claims}, {new_token, new_claims}} 183 | end 184 | 185 | @doc """ 186 | When logging out, or revoking a token, removes from the database so the 187 | token may no longer be used. 188 | """ 189 | def on_revoke(claims, jwt) do 190 | claims 191 | |> Token.find_by_claims() 192 | |> Token.destroy_token(claims, jwt) 193 | end 194 | 195 | @doc """ 196 | Revoke all tokens of a given subject. Returns the amount of tokens revoked. 197 | 198 | ## Usage 199 | 200 | Add to your `Guardian` module. 201 | 202 | ```elixir 203 | defmodule MyApp.AuthTokens do 204 | use Guardian, otp_app: :my_app 205 | 206 | # snip... 207 | 208 | def revoke_all(resource, claims) do 209 | with {:ok, sub} <- subject_for_token(resource, claims) do 210 | Guardian.DB.revoke_all(sub) 211 | end 212 | end 213 | end 214 | ``` 215 | 216 | Then you revoke all tokens of a resource. 217 | 218 | ```elixir 219 | MyApp.AuthTokens.revoke_all(resource, %{}) 220 | ``` 221 | 222 | """ 223 | def revoke_all(sub) do 224 | {amount_deleted, _} = Token.destroy_by_sub(sub) 225 | 226 | {:ok, amount_deleted} 227 | end 228 | 229 | defp token_types do 230 | :guardian 231 | |> Application.fetch_env!(Guardian.DB) 232 | |> Keyword.get(:token_types, []) 233 | end 234 | 235 | defp storable_type?(type), do: storable_type?(type, token_types()) 236 | 237 | # Store all types by default 238 | defp storable_type?(_, []), do: true 239 | defp storable_type?(type, types), do: type in types 240 | end 241 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Guardian.DB 2 | 3 | [![Hex.pm](https://img.shields.io/hexpm/v/guardian_db.svg)](https://hex.pm/packages/guardian_db) 4 | ![Build Status](https://github.com/ueberauth/guardian_db/workflows/Continuous%20Integration/badge.svg) 5 | [![Codecov](https://codecov.io/gh/ueberauth/guardian_db/branch/master/graph/badge.svg)](https://codecov.io/gh/ueberauth/guardian_db) 6 | [![Inline docs](https://inch-ci.org/github/ueberauth/guardian_db.svg)](https://inch-ci.org/github/ueberauth/guardian_db) 7 | 8 | `Guardian.DB` is an extension to `Guardian` that tracks tokens in your 9 | application's database to prevent playback. 10 | 11 | ## Installation 12 | 13 | `Guardian.DB` assumes that you are using the Guardian framework for 14 | authentication. 15 | 16 | To install `Guardian.DB`, first add it to your `mix.exs` file: 17 | 18 | ```elixir 19 | defp deps do 20 | [ 21 | {:guardian_db, "~> 3.0"} 22 | ] 23 | end 24 | ``` 25 | 26 | Then run `mix deps.get` on your terminal. 27 | 28 | Configure your application as seen in the *Configuration* section below prior to attempting to generate the migration or you will get an `application was not loaded/started` error. 29 | 30 | Following configuration add the Guardian migration: 31 | 32 | run `mix guardian.db.gen.migration` to generate a migration. 33 | 34 | **Do not run the migration yet,** we need to complete our setup first. 35 | 36 | ## Configuration 37 | 38 | ```elixir 39 | config :guardian, Guardian.DB, 40 | adapter: Guardian.DB.EctoAdapter, # default 41 | repo: MyApp.Repo, # Add your repository module 42 | schema_name: "guardian_tokens", # default 43 | token_types: ["refresh_token"], # store all token types if not set 44 | ``` 45 | 46 | To use [ETS](https://hexdocs.pm/elixir/1.16/erlang-term-storage.html) instead 47 | of Ecto for storing tokens, you can set `adapter` to `Guardian.DB.ETSAdapter`. 48 | 49 | To sweep expired tokens from your db you should add 50 | `Guardian.DB.Sweeper` to your supervision tree. 51 | 52 | ```elixir 53 | children = [ 54 | {Guardian.DB.Sweeper, [interval: 60 * 60 * 1000]} # 1 hour 55 | ] 56 | ``` 57 | 58 | `Guardian.DB` works by hooking into the lifecycle of your `Guardian` module. 59 | 60 | You'll need to add it to: 61 | 62 | * `after_encode_and_sign` 63 | * `on_verify` 64 | * `on_refresh` 65 | * `on_revoke` 66 | 67 | For example: 68 | 69 | ```elixir 70 | defmodule MyApp.AuthTokens do 71 | use Guardian, otp_app: :my_app 72 | 73 | # snip... 74 | 75 | def after_encode_and_sign(resource, claims, token, _options) do 76 | with {:ok, _} <- Guardian.DB.after_encode_and_sign(resource, claims["typ"], claims, token) do 77 | {:ok, token} 78 | end 79 | end 80 | 81 | def on_verify(claims, token, _options) do 82 | with {:ok, _} <- Guardian.DB.on_verify(claims, token) do 83 | {:ok, claims} 84 | end 85 | end 86 | 87 | def on_refresh({old_token, old_claims}, {new_token, new_claims}, _options) do 88 | with {:ok, _, _} <- Guardian.DB.on_refresh({old_token, old_claims}, {new_token, new_claims}) do 89 | {:ok, {old_token, old_claims}, {new_token, new_claims}} 90 | end 91 | end 92 | 93 | def on_revoke(claims, token, _options) do 94 | with {:ok, _} <- Guardian.DB.on_revoke(claims, token) do 95 | {:ok, claims} 96 | end 97 | end 98 | end 99 | ``` 100 | 101 | Now run the migration and you'll be good to go. 102 | 103 | ### Custom schema name 104 | 105 | `Guardian.DB` allows custom schema name in migrations, based on following 106 | configuration: 107 | 108 | ```elixir 109 | config :guardian, Guardian.DB, 110 | schema_name: "my_custom_schema" 111 | ``` 112 | 113 | And when you run the migration, it'll generate the following migration: 114 | 115 | ```elixir 116 | create table(:my_custom_schema, primary_key: false) do 117 | add(:jti, :string, primary_key: true) 118 | add(:aud, :string, primary_key: true) 119 | add(:typ, :string) 120 | add(:iss, :string) 121 | add(:sub, :string) 122 | add(:exp, :bigint) 123 | add(:jwt, :text) 124 | add(:claims, :map) 125 | timestamps() 126 | end 127 | ``` 128 | 129 | Then, run the migration and you'll be good to go. 130 | 131 | ### Considerations 132 | 133 | `Guardian` is already a very robust JWT solution. However, if your 134 | application needs the ability to immediately revoke and invalidate tokens that 135 | have already been generated, you need something like `Guardian.DB` to build upon 136 | `Guardian`. 137 | 138 | In `Guardian`, you as a systems administrator have no way of revoking 139 | tokens that have already been generated, you can call `Guardian.revoke`, but in 140 | `Guardian` **that function does not actually do anything** - it just provides 141 | hooks for other libraries, such as this one, to define more specific behavior. 142 | Discarding the token after something like a log out action is left up to the 143 | client application. If the client application does not discard the token, or 144 | does not log out, or the token gets stolen by a malicious script (because the 145 | client application stores it in localStorage, for instance), the only thing you 146 | can do is wait until the token expires. Depending on the scenario, this may not 147 | be acceptable. 148 | 149 | With `Guardian.DB`, records of all generated tokens are kept in your 150 | application's database. During each request, the `Guardian.Plug.VerifyHeader` 151 | and `Guardian.Plug.VerifySession` plugs check the database to make sure the 152 | token is there. If it is not, the server returns a 401 Unauthorized response to 153 | the client. Furthermore, `Guardian.revoke!` behavior becomes enhanced, as it 154 | actually removes the token from the database. This means that if the user logs 155 | out, or you revoke their token (e.g. after noticing suspicious activity on the 156 | account), they will need to re-authenticate. 157 | 158 | ### Disadvantages 159 | 160 | In `Guardian`, token verification is very light-weight. The only thing 161 | `Guardian` does is decode incoming tokens and make sure they are valid. This can 162 | make it much easier to horizontally scale your application, since there is no 163 | need to centrally store sessions and make them available to load balancers or 164 | other servers. 165 | 166 | With `Guardian.DB`, every request requires a trip to the database, as `Guardian` 167 | now needs to ensure that a record of the token exists. In large scale 168 | applications this can be fairly costly, and can arguably eliminate the main 169 | advantage of using a JWT authentication solution, which is statelessness. 170 | Furthermore, session authentication already works this way, and in most cases 171 | there isn't a good enough reason to reinvent that wheel using JWTs. 172 | 173 | In other words, once you have reached a point where you think you need 174 | `Guardian.DB`, it may be time to take a step back and reconsider your whole 175 | approach to authentication! 176 | 177 | ### Create your own Repo 178 | 179 | We created `Guardian.DB.Adapter` behaviour to allow creating other repositories for persisting JWT tokens. 180 | You need to implement the `Guardian.DB.Adapter` behavior working with your preferred storage. 181 | 182 | ### Adapters 183 | 184 | 1. Redis adapter - [`guardian_redis`](https://github.com/alexfilatov/guardian_redis) 185 | 186 | Feel free to create your adapters using `Guardian.DB.Adapter` behavior and you are welcome to add them here. 187 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm", "fab09b20e3f5db886725544cbcf875b8e73ec93363954eb8a1a9ed834aa8c1f9"}, 3 | "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, 4 | "certifi": {:hex, :certifi, "2.4.2", "75424ff0f3baaccfd34b1214184b6ef616d89e420b258bb0a5ea7d7bc628f7f0", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "01d479edba0569a7b7a2c8bf923feeb6dc6a358edc2965ef69aea9ba288bb243"}, 5 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"}, 6 | "credo": {:hex, :credo, "1.7.0", "6119bee47272e85995598ee04f2ebbed3e947678dee048d10b5feca139435f75", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "6839fcf63d1f0d1c0f450abc8564a57c43d644077ab96f2934563e68b8a769d7"}, 7 | "db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"}, 8 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 9 | "dialyxir": {:hex, :dialyxir, "1.4.1", "a22ed1e7bd3a3e3f197b68d806ef66acb61ee8f57b3ac85fc5d57354c5482a93", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "84b795d6d7796297cca5a3118444b80c7d94f7ce247d49886e7c291e1ae49801"}, 10 | "earmark": {:hex, :earmark, "1.3.1", "73812f447f7a42358d3ba79283cfa3075a7580a3a2ed457616d6517ac3738cb9", [:mix], [], "hexpm", "000aaeff08919e95e7aea13e4af7b2b9734577b3e6a7c50ee31ee88cab6ec4fb"}, 11 | "earmark_parser": {:hex, :earmark_parser, "1.4.34", "b0fbb4fd333ee7e9babc07e9573796850759cd12796fcf2fec59cf0031cbaad9", [:mix], [], "hexpm", "cc0d7a6f2367e4504867b4ec38ceee24e89ee6bca9c7b94a6d940f54aba2e8d5"}, 12 | "ecto": {:hex, :ecto, "3.10.3", "eb2ae2eecd210b4eb8bece1217b297ad4ff824b4384c0e3fdd28aaf96edd6135", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "44bec74e2364d491d70f7e42cd0d690922659d329f6465e89feb8a34e8cd3433"}, 13 | "ecto_sql": {:hex, :ecto_sql, "3.10.2", "6b98b46534b5c2f8b8b5f03f126e75e2a73c64f3c071149d32987a5378b0fdbd", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "68c018debca57cb9235e3889affdaec7a10616a4e3a80c99fa1d01fdafaa9007"}, 14 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 15 | "ex_doc": {:hex, :ex_doc, "0.30.6", "5f8b54854b240a2b55c9734c4b1d0dd7bdd41f71a095d42a70445c03cf05a281", [:mix], [{:earmark_parser, "~> 1.4.31", [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", "bd48f2ddacf4e482c727f9293d9498e0881597eae6ddc3d9562bd7923375109f"}, 16 | "excoveralls": {:hex, :excoveralls, "0.17.1", "83fa7906ef23aa7fc8ad7ee469c357a63b1b3d55dd701ff5b9ce1f72442b2874", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "95bc6fda953e84c60f14da4a198880336205464e75383ec0f570180567985ae0"}, 17 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 18 | "guardian": {:hex, :guardian, "2.3.1", "2b2d78dc399a7df182d739ddc0e566d88723299bfac20be36255e2d052fd215d", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bbe241f9ca1b09fad916ad42d6049d2600bbc688aba5b3c4a6c82592a54274c3"}, 19 | "hackney": {:hex, :hackney, "1.14.3", "b5f6f5dcc4f1fba340762738759209e21914516df6be440d85772542d4a5e412", [:rebar3], [{:certifi, "2.4.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "ed15491f324aa0e95647dca8ef4340418dac479d1204d57e455d52dcfba3f705"}, 20 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, 21 | "inch_ex": {:hex, :inch_ex, "2.0.0", "24268a9284a1751f2ceda569cd978e1fa394c977c45c331bb52a405de544f4de", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "96d0ec5ecac8cf63142d02f16b7ab7152cf0f0f1a185a80161b758383c9399a8"}, 22 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 23 | "jose": {:hex, :jose, "1.11.6", "613fda82552128aa6fb804682e3a616f4bc15565a048dabd05b1ebd5827ed965", [:mix, :rebar3], [], "hexpm", "6275cb75504f9c1e60eeacb771adfeee4905a9e182103aa59b53fed651ff9738"}, 24 | "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"}, 25 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 26 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, 27 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 28 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm", "7a4c8e1115a2732a67d7624e28cf6c9f30c66711a9e92928e745c255887ba465"}, 29 | "mox": {:hex, :mox, "1.0.2", "dc2057289ac478b35760ba74165b4b3f402f68803dd5aecd3bfd19c183815d64", [:mix], [], "hexpm", "f9864921b3aaf763c8741b5b8e6f908f44566f1e427b2630e89e9a73b981fef2"}, 30 | "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, 31 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 32 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, 33 | "postgrex": {:hex, :postgrex, "0.17.3", "c92cda8de2033a7585dae8c61b1d420a1a1322421df84da9a82a6764580c503d", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "946cf46935a4fdca7a81448be76ba3503cff082df42c6ec1ff16a4bdfbfb098d"}, 34 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm", "603561dc0fd62f4f2ea9b890f4e20e1a0d388746d6e20557cafb1b16950de88c"}, 35 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 36 | "temporary_env": {:hex, :temporary_env, "2.0.1", "d4b5e031837e5619485e1f23af7cba7e897b8fd546eaaa8b10c812d642ec4546", [:mix], [], "hexpm", "f9420044742b5f0479a7f8243e86b048b6a2d4878bce026a3615065b11199c27"}, 37 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, 38 | } 39 | --------------------------------------------------------------------------------