From 81db5cdcadb20bc885178360cb0442638c1ae75c Mon Sep 17 00:00:00 2001 From: Matt Franczak Date: Tue, 12 Dec 2023 16:04:36 -0600 Subject: [PATCH] chore: copy code for release from private repo --- .formatter.exs | 4 + .gitignore | 26 ++ README.md | 122 +++++++ lib/o_auth2_token_manager/token_agent.ex | 158 +++++++++ .../token_refresh_strategy.ex | 35 ++ mix.exs | 41 +++ .../token_agent_test.exs | 309 ++++++++++++++++++ .../token_refresh_strategy_test.exs | 108 ++++++ test/test_helper.exs | 1 + 9 files changed, 804 insertions(+) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 README.md create mode 100644 lib/o_auth2_token_manager/token_agent.ex create mode 100644 lib/o_auth2_token_manager/token_refresh_strategy.ex create mode 100644 mix.exs create mode 100644 test/o_auth2_token_manager/token_agent_test.exs create mode 100644 test/o_auth2_token_manager/token_refresh_strategy_test.exs create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a487fd2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +oauth2_token_manager-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c30297 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# OAuth2TokenManager + +This package works with the `oauth2` package to manage the automatic renewal of +tokens before they expire + +## Installation + +If [available in Hex](https://hex.pm/docs/publish), the package can be installed +by adding `oauth2_token_manager` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:oauth2_token_manager, "~> 0.1.0"} + ] +end +``` + +Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) +and published on [HexDocs](https://hexdocs.pm). Once published, the docs can +be found at . + +## Usage + +Create an `OAuth2.Client` instance to use to get the initial token and pass it to +`OAuth2TokenMananger.TokenAgent.start_link/1` along with the inline +`OAuth2TokenManager.TokenRefreshStrategy` and the name for the `Agent`. + +The client can be configured as described at +https://github.com/ueberauth/oauth2#configure-a-http-client. + +```Elixir +client = Client.new([ + strategy: OAuth2.Strategy.ClientCredentials, + client_id: "example_client_id", + client_secret: "example_client_secret", + site: "https://example.com/" + ]) + +{:ok, agent} = OAuth2TokenManager.TokenAgent.start_link( + name: MyModule.TokenAgent, + initial_client: client, + inline_refresh_strategy: %OAuth2TokenManager.TokenRefreshStrategy{seconds_before_expires: 30} +) +``` + +The current version of the client can be retrieved using +`OAuth2TokenManager.TokenAgent.get_client/1` with the agent's PID or name. +This client will automatically use the access token as a Bearer token in +the Authorization header when calling its request methods. The access token +itself can be retrieved from the client struct if it is desirable to use separately +configured clients and `OAuth2TokenManager.TokenAgent.get_access_token/1` is +provided for convenience when doing so. + +```Elixir +current_client = OAuth2TokenManager.TokenAgent.get_client(agent) +current_client = OAuth2TokenManager.TokenAgent.get_client(MyModule.TokenAgent) +access_token = OAuth2TokenManager.TokenAgent.get_access_token(MyModule.TokenAgent) +``` + +The token can be refreshed by calling `OAuth2TokenManager.TokenAgent.refresh_tokens/1`. +If a refresh token is available, it will be exchanged for a new set of tokens. +If no refresh token is available or the attempt to use the refresh results in an error, +the original client will be used again to attempt to obtain a new set of tokens. + +```Elixir +:ok = OAuth2TokenManager.TokenAgent.refresh(MyModule.TokenAgent) +``` + +This can be used to handle complex token refresh logic, but it will generally be +preferable to configure [a strategy](#inline-token-refresh-strategies). + +### Inline Token Refresh Strategies + +Inline token refresh strategies are used to determine when to obtain a new token +before returning the current client state or token value. The supported conditions +are show in the below example. + +```Elixir +%TokenRefreshStrategy{ + seconds_before_expires: 30, # refresh the token if it will expire in the next N seconds + every_seconds: 300 # refresh the token if it has been at least N seconds since the last refresh +} +``` + +If the Authorization Server does not provide an expiration time for the token, the +expiration time conditions will not trigger a refresh, so `:every_seconds` should +be used. + +The inline refreshes only occur as part of request for data from the agent; this +saves unnecessary renewal requests in low-volume systems but tokens may be allowed +to expire if unused. If refresh tokens need to be kept active in a system where the +time between requests exceeds the token duration and the initial client cannot be +reused, using `OAuth2TokenManager.TokenAgent.refresh` may be necessary. + +The inline refreshes occur during message processing in the agent, which is not +concurrent per agent. This guarantees that redundant refresh calls will not be +made, which is particularly important when using single-use refresh tokens, but +also adds the latency of the refresh to the processing of the message that +triggers it. + +The same strategy can have properties configured for multiple conditions and the +token will be refreshed if any of them are met. + +If no value is specified when starting the agent, the behavior defaults to +`%TokenRefreshStrategy{seconds_before_expires: 30, every_seconds: 300}`. + +### Configuring a Singleton + +Providing a name for the agent allows it to be referred to in code without passing +around the PID, which is useful for reusing the same token whenever providing +credentials for the same principal, cutting down on the number of calls that need +to be made to the Authorization Server. A named instance can be added to a +supervision tree's children using a tuple like the below example. This will +launch the agent under the supervision tree and restart it if it crashes. + +```Elixir +children = [ + {TokenAgent, name: MyModule.TokenAgent, initial_client: client} +] + +``` diff --git a/lib/o_auth2_token_manager/token_agent.ex b/lib/o_auth2_token_manager/token_agent.ex new file mode 100644 index 0000000..d0dd7ec --- /dev/null +++ b/lib/o_auth2_token_manager/token_agent.ex @@ -0,0 +1,158 @@ +defmodule OAuth2TokenManager.TokenAgent do + @moduledoc """ + Defines the Agent used to manage the token and the struct it uses to store its state + """ + + use Agent + use TypedStruct + + alias __MODULE__ + alias OAuth2TokenManager.TokenRefreshStrategy + alias OAuth2.{AccessToken, Client, Error, Response} + + require Logger + + @typedoc """ + Struct for tracking the state of the agent + """ + typedstruct do + field(:name, String.t(), enforce: true) + field(:initial_client, Client.t(), enforce: true) + field(:client_with_token, Client.t(), enforce: true) + field(:inline_refresh_strategy, TokenRefreshStrategy.t()) + field(:last_refreshed, Calendar.datetime(), enforce: true) + end + + @type option :: + {:name, term()} + | {:initial_client, Client.t()} + | {:inline_refresh_strategy, TokenRefreshStrategy.t()} + + @spec start_link([option()]) :: Agent.on_start() | {:error, Response.t()} | {:error, Error.t()} + def start_link(opts) do + case Keyword.fetch(opts, :initial_client) do + :error -> + {:error, ":initial_client required"} + + {:ok, initial_client} -> + inline_refresh_strategy = + Keyword.get(opts, :inline_refresh_strategy, %TokenRefreshStrategy{ + seconds_before_expires: 30, + every_seconds: 300 + }) + + name = Keyword.get(opts, :name) + + case Client.get_token(initial_client) do + {:ok, client_with_token} -> + Agent.start_link( + fn -> + %TokenAgent{ + name: name, + initial_client: initial_client, + client_with_token: client_with_token, + inline_refresh_strategy: inline_refresh_strategy, + last_refreshed: DateTime.utc_now() + } + end, + name: name + ) + + error -> + error + end + end + end + + @doc """ + Returns the current client instance; if :inline_updates is configured, the client will be refreshed first if the strategy indicates + it needs to be + """ + @spec get_current_client(Agent.agent()) :: Client.t() + def get_current_client(token_agent) do + Agent.get_and_update(token_agent, fn state -> + new_state = + if state.inline_refresh_strategy && + TokenRefreshStrategy.refresh_now?( + state.inline_refresh_strategy, + state.last_refreshed, + DateTime.from_unix!(state.client_with_token.token.expires_at) + ) do + get_state_with_new_tokens(state) + else + state + end + + {new_state.client_with_token, new_state} + end) + end + + @doc """ + Returns the current access token; if :inline_updates is configured, the token will be refreshed first if the strategy indicates + it needs to be + """ + @spec get_access_token(Agent.agent()) :: String.t() + def get_access_token(token_agent) do + get_current_client(token_agent).token.access_token + end + + @doc """ + Triggers a refresh of the agent's tokens + """ + @spec refresh(Agent.agent()) :: :ok + def refresh(token_agent) do + Agent.update(token_agent, fn state -> + get_state_with_new_tokens(state) + end) + end + + defp get_state_with_new_tokens( + %TokenAgent{client_with_token: %Client{token: nil}, initial_client: client} = state + ) do + Logger.info("Refreshing tokens for TokenAgent #{state.name}") + + %TokenAgent{ + state + | client_with_token: Client.get_token!(client), + last_refreshed: DateTime.utc_now() + } + end + + defp get_state_with_new_tokens( + %TokenAgent{ + client_with_token: %Client{token: %AccessToken{refresh_token: nil}}, + initial_client: client + } = state + ) do + Logger.info("Refreshing tokens for TokenAgent #{state.name}") + + %TokenAgent{ + state + | client_with_token: Client.get_token!(client), + last_refreshed: DateTime.utc_now() + } + end + + defp get_state_with_new_tokens( + %TokenAgent{client_with_token: client_with_token, initial_client: initial_client} = + state + ) do + Logger.info("Refreshing tokens for TokenAgent #{state.name}") + + case Client.refresh_token(client_with_token) do + {:ok, client} -> + %TokenAgent{state | client_with_token: client, last_refreshed: DateTime.utc_now()} + + {:error, error} -> + Logger.warning( + "Unable to use refresh token for TokenAgent #{state.name}: #{inspect(error)}; attempting to obtain new tokens using the initial client" + ) + + %TokenAgent{ + state + | client_with_token: Client.get_token!(initial_client), + last_refreshed: DateTime.utc_now() + } + end + end +end diff --git a/lib/o_auth2_token_manager/token_refresh_strategy.ex b/lib/o_auth2_token_manager/token_refresh_strategy.ex new file mode 100644 index 0000000..434cf58 --- /dev/null +++ b/lib/o_auth2_token_manager/token_refresh_strategy.ex @@ -0,0 +1,35 @@ +defmodule OAuth2TokenManager.TokenRefreshStrategy do + @moduledoc """ + Module defining a struct for representing strategies for refreshing tokens and + the functions for applying them + """ + + @typedoc """ + Struct for the refresh timing strategy for OAuth2 tokens; multiple mechanisms + + ## Fields + * `:every_seconds` - refresh the token if at least the specified number of seconds has elapsed since the last refresh + * `:seconds_before_expires` - refresh the token if it will expire within the specified number of seconds + """ + + use TypedStruct + + alias __MODULE__ + + typedstruct do + field(:every_seconds, integer()) + field(:seconds_before_expires, integer()) + end + + @doc """ + Returns true if at least one of the conditions is met and the token should be refreshed + """ + @spec refresh_now?(TokenRefreshStrategy.t(), Calendar.datetime(), Calendar.datetime()) :: + boolean() + def refresh_now?(strategy, lastRefreshed, expiresAt) do + (strategy.every_seconds && + strategy.every_seconds <= DateTime.diff(DateTime.utc_now(), lastRefreshed)) || + (strategy.seconds_before_expires && expiresAt && + strategy.seconds_before_expires >= DateTime.diff(expiresAt, DateTime.utc_now())) + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..3571a82 --- /dev/null +++ b/mix.exs @@ -0,0 +1,41 @@ +defmodule OAuth2TokenManager.MixProject do + use Mix.Project + + def project do + [ + aliases: aliases(), + app: :oauth2_token_manager, + version: "0.1.0", + build_path: "../../_build", + config_path: "../../config/config.exs", + deps_path: "../../deps", + lockfile: "../../mix.lock", + elixir: "~> 1.15", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:oauth2, "== 2.1.0"}, + {:typed_struct, "== 0.3.0"}, + {:jason, "== 1.4.1"}, + {:mock, "== 0.3.8", only: :test} + ] + end + + defp aliases do + [ + setup: [] + ] + end +end diff --git a/test/o_auth2_token_manager/token_agent_test.exs b/test/o_auth2_token_manager/token_agent_test.exs new file mode 100644 index 0000000..2dca9fa --- /dev/null +++ b/test/o_auth2_token_manager/token_agent_test.exs @@ -0,0 +1,309 @@ +defmodule OAuth2TokenManager.TokenAgentTest do + @moduledoc """ + Tests for TokenAgent, which is used to track token state + """ + use ExUnit.Case, async: false + + import Mock + + alias OAuth2.{AccessToken, Client} + alias OAuth2TokenManager.{TokenAgent, TokenRefreshStrategy} + + defp test_client do + Client.new( + strategy: OAuth2.Strategy.ClientCredentials, + client_id: "test_client_id", + client_secret: "test_client_secret_abc123", + site: "http://localhost/" + ) + end + + describe "start_link/1" do + test "returns an agent storing the token if successful" do + with_mock Client, [:passthrough], + get_token: fn client -> + {:ok, + %Client{ + client + | token: %AccessToken{ + access_token: "test_access_token", + expires_at: (DateTime.utc_now() |> DateTime.to_unix()) + 6000 + } + }} + end do + {:ok, agent} = TokenAgent.start_link(initial_client: test_client()) + + assert TokenAgent.get_access_token(agent) == "test_access_token" + + assert TokenAgent.get_current_client(agent).token.access_token == "test_access_token" + end + end + + test "returns an error if the initial token retrieval did not succeed" do + sample_error = %OAuth2.Error{reason: :econnrefused} + + with_mock Client, [:passthrough], get_token: fn _client -> {:error, sample_error} end do + {:error, error} = TokenAgent.start_link(initial_client: test_client()) + + assert error == sample_error + end + end + + test "returns an error if no client is provided" do + {:error, ":initial_client required"} = TokenAgent.start_link(name: MyModule.TokenAgent) + end + + test "can be provided with an name to refer to the agent" do + with_mock Client, [:passthrough], + get_token: fn client -> + {:ok, + %Client{ + client + | token: %AccessToken{ + access_token: "test_access_token", + expires_at: (DateTime.utc_now() |> DateTime.to_unix()) + 6000 + } + }} + end do + {:ok, _} = + TokenAgent.start_link( + initial_client: test_client(), + inline_refresh_strategy: %TokenRefreshStrategy{}, + name: MyModule.TokenAgent + ) + + assert TokenAgent.get_access_token(MyModule.TokenAgent) == "test_access_token" + end + end + end + + describe "refresh_tokens/1" do + test "uses the refresh token if available" do + with_mock Client, [:passthrough], + get_token: fn client -> + {:ok, + %Client{ + client + | token: %AccessToken{ + access_token: "test_access_token", + expires_at: (DateTime.utc_now() |> DateTime.to_unix()) + 600, + refresh_token: "test_refresh_token" + } + }} + end, + refresh_token: fn client -> + {:ok, + %Client{ + client + | token: %AccessToken{ + access_token: "new_access_token", + expires_at: (DateTime.utc_now() |> DateTime.to_unix()) + 600, + refresh_token: "new_refresh_token" + } + }} + end do + {:ok, agent} = TokenAgent.start_link(initial_client: test_client()) + TokenAgent.refresh(agent) + + assert TokenAgent.get_access_token(agent) == "new_access_token" + end + end + + test "uses the initial client to get a new token if the refresh token cannot be used" do + with_mock Client, [:passthrough], + get_token: fn client -> + {:ok, + %Client{ + client + | token: %AccessToken{ + access_token: "test_access_token", + expires_at: (DateTime.utc_now() |> DateTime.to_unix()) + 600, + refresh_token: "test_refresh_token" + } + }} + end, + refresh_token: fn _client -> {:error, %OAuth2.Error{reason: :econnrefused}} end, + get_token!: fn client -> + %Client{ + client + | token: %AccessToken{ + access_token: "new_access_token", + expires_at: (DateTime.utc_now() |> DateTime.to_unix()) + 600, + refresh_token: "new_refresh_token" + } + } + end do + {:ok, agent} = TokenAgent.start_link(initial_client: test_client()) + TokenAgent.refresh(agent) + + assert TokenAgent.get_access_token(agent) == "new_access_token" + + assert_called(Client.refresh_token(:_)) + end + end + + test "uses the initial client to get a new token if no refresh token is available" do + with_mock Client, [:passthrough], + get_token: fn client -> + {:ok, + %Client{ + client + | token: %AccessToken{ + access_token: "test_access_token", + expires_at: (DateTime.utc_now() |> DateTime.to_unix()) + 600 + } + }} + end, + refresh_token: fn _client -> {:error, %OAuth2.Error{reason: :econnrefused}} end, + get_token!: fn client -> + %Client{ + client + | token: %AccessToken{ + access_token: "new_access_token", + expires_at: (DateTime.utc_now() |> DateTime.to_unix()) + 600 + } + } + end do + {:ok, agent} = TokenAgent.start_link(initial_client: test_client()) + TokenAgent.refresh(agent) + + assert TokenAgent.get_access_token(agent) == "new_access_token" + + assert_not_called(Client.refresh_token(:_)) + end + end + + test "uses the initial client to get a new token if no token is available" do + with_mock Client, [:passthrough], + get_token: fn client -> + {:ok, client} + end, + refresh_token: fn _client -> {:error, %OAuth2.Error{reason: :econnrefused}} end, + get_token!: fn client -> + %Client{ + client + | token: %AccessToken{ + access_token: "new_access_token", + expires_at: (DateTime.utc_now() |> DateTime.to_unix()) + 600 + } + } + end do + {:ok, agent} = TokenAgent.start_link(initial_client: test_client()) + TokenAgent.refresh(agent) + + assert TokenAgent.get_access_token(agent) == "new_access_token" + + assert_not_called(Client.refresh_token(:_)) + end + end + end + + describe "inline refresh" do + test "triggers during get_access_token if the inline_refresh strategy requires it" do + with_mock Client, [:passthrough], + get_token: fn client -> + {:ok, + %Client{ + client + | token: %AccessToken{ + access_token: "test_access_token", + expires_at: (DateTime.utc_now() |> DateTime.to_unix()) + 10, + refresh_token: "test_refresh_token" + } + }} + end, + refresh_token: fn client -> + {:ok, + %Client{ + client + | token: %AccessToken{ + access_token: "new_access_token", + expires_at: (DateTime.utc_now() |> DateTime.to_unix()) + 6000, + refresh_token: "new_refresh_token" + } + }} + end do + {:ok, agent} = + TokenAgent.start_link( + initial_client: test_client(), + inline_refresh_strategy: %TokenRefreshStrategy{ + seconds_before_expires: 30 + } + ) + + assert TokenAgent.get_access_token(agent) == "new_access_token" + end + end + + test "does not trigger during get_access_token if the inline_refresh strategy does not require it" do + with_mock Client, [:passthrough], + get_token: fn client -> + {:ok, + %Client{ + client + | token: %AccessToken{ + access_token: "test_access_token", + expires_at: (DateTime.utc_now() |> DateTime.to_unix()) + 100, + refresh_token: "test_refresh_token" + } + }} + end, + refresh_token: fn client -> + {:ok, + %Client{ + client + | token: %AccessToken{ + access_token: "new_access_token", + expires_at: (DateTime.utc_now() |> DateTime.to_unix()) + 6000, + refresh_token: "new_refresh_token" + } + }} + end do + {:ok, agent} = + TokenAgent.start_link( + initial_client: test_client(), + inline_refresh_strategy: %TokenRefreshStrategy{ + seconds_before_expires: 30 + } + ) + + assert TokenAgent.get_access_token(agent) == "test_access_token" + + assert_not_called(Client.refresh_token(:_)) + end + end + + test "does not trigger during get_access_token if no inline_refresh strategy is set" do + with_mock Client, [:passthrough], + get_token: fn client -> + {:ok, + %Client{ + client + | token: %AccessToken{ + access_token: "test_access_token", + expires_at: (DateTime.utc_now() |> DateTime.to_unix()) + 10, + refresh_token: "test_refresh_token" + } + }} + end, + refresh_token: fn client -> + {:ok, + %Client{ + client + | token: %AccessToken{ + access_token: "new_access_token", + expires_at: (DateTime.utc_now() |> DateTime.to_unix()) + 6000, + refresh_token: "new_refresh_token" + } + }} + end do + {:ok, agent} = + TokenAgent.start_link(initial_client: test_client(), inline_refresh_strategy: nil) + + assert TokenAgent.get_access_token(agent) == "test_access_token" + + assert_not_called(Client.refresh_token(:_)) + end + end + end +end diff --git a/test/o_auth2_token_manager/token_refresh_strategy_test.exs b/test/o_auth2_token_manager/token_refresh_strategy_test.exs new file mode 100644 index 0000000..699f14a --- /dev/null +++ b/test/o_auth2_token_manager/token_refresh_strategy_test.exs @@ -0,0 +1,108 @@ +defmodule OAuth2TokenManager.TokenRefreshStrategyTest do + @moduledoc """ + Tests the refresh strategies configured with TokenRefreshStrategy + """ + + use ExUnit.Case + + alias OAuth2TokenManager.TokenRefreshStrategy + + defp seconds_from_now(seconds) do + DateTime.utc_now() |> DateTime.add(seconds, :second, Calendar.UTCOnlyTimeZoneDatabase) + end + + describe "every_seconds" do + test "refresh_now? is true if it has been more than the configured value of seconds since the last refresh" do + strategy = %TokenRefreshStrategy{ + every_seconds: 30 + } + + assert !TokenRefreshStrategy.refresh_now?( + strategy, + DateTime.utc_now(), + seconds_from_now(300) + ) + + assert TokenRefreshStrategy.refresh_now?( + strategy, + seconds_from_now(-30), + seconds_from_now(300) + ) + end + end + + describe "seconds_before_expires" do + test "refresh_now? is true if if the token expires at or before the specified number of seconds" do + strategy = %TokenRefreshStrategy{ + seconds_before_expires: 30 + } + + assert !TokenRefreshStrategy.refresh_now?( + strategy, + seconds_from_now(-30), + seconds_from_now(60) + ) + + assert TokenRefreshStrategy.refresh_now?( + strategy, + seconds_from_now(-30), + seconds_from_now(30) + ) + end + + test "refresh_now is false if expires_at is nil" do + strategy = %TokenRefreshStrategy{ + seconds_before_expires: 30 + } + + assert !TokenRefreshStrategy.refresh_now?( + strategy, + seconds_from_now(-30), + nil + ) + end + end + + describe "refresh_now?" do + test "returns true if any of the conditions in the strategy are met" do + strategy = %TokenRefreshStrategy{ + seconds_before_expires: 30, + every_seconds: 60 + } + + assert !TokenRefreshStrategy.refresh_now?( + strategy, + seconds_from_now(-30), + seconds_from_now(60) + ) + + assert TokenRefreshStrategy.refresh_now?( + strategy, + seconds_from_now(-60), + seconds_from_now(30) + ) + + assert TokenRefreshStrategy.refresh_now?( + strategy, + seconds_from_now(-30), + seconds_from_now(30) + ) + + assert TokenRefreshStrategy.refresh_now?( + strategy, + seconds_from_now(-60), + seconds_from_now(60) + ) + end + + test "returns false if none of the conditions in the strategy are specified" do + strategy = %TokenRefreshStrategy{} + + assert !TokenRefreshStrategy.refresh_now?( + strategy, + seconds_from_now(-1), + seconds_from_now(1) + ) + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()