chore: copy code for release from private repo
This commit is contained in:
commit
81db5cdcad
4
.formatter.exs
Normal file
4
.formatter.exs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Used by "mix format"
|
||||||
|
[
|
||||||
|
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||||
|
]
|
||||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@ -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/
|
||||||
122
README.md
Normal file
122
README.md
Normal file
@ -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 <https://hexdocs.pm/oauth2_token_manager>.
|
||||||
|
|
||||||
|
## 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}
|
||||||
|
]
|
||||||
|
|
||||||
|
```
|
||||||
158
lib/o_auth2_token_manager/token_agent.ex
Normal file
158
lib/o_auth2_token_manager/token_agent.ex
Normal file
@ -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
|
||||||
35
lib/o_auth2_token_manager/token_refresh_strategy.ex
Normal file
35
lib/o_auth2_token_manager/token_refresh_strategy.ex
Normal file
@ -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
|
||||||
41
mix.exs
Normal file
41
mix.exs
Normal file
@ -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
|
||||||
309
test/o_auth2_token_manager/token_agent_test.exs
Normal file
309
test/o_auth2_token_manager/token_agent_test.exs
Normal file
@ -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
|
||||||
108
test/o_auth2_token_manager/token_refresh_strategy_test.exs
Normal file
108
test/o_auth2_token_manager/token_refresh_strategy_test.exs
Normal file
@ -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
|
||||||
1
test/test_helper.exs
Normal file
1
test/test_helper.exs
Normal file
@ -0,0 +1 @@
|
|||||||
|
ExUnit.start()
|
||||||
Loading…
Reference in New Issue
Block a user