chore: copy code for release from private repo

This commit is contained in:
Matt Franczak 2023-12-12 16:04:36 -06:00
commit 81db5cdcad
9 changed files with 804 additions and 0 deletions

4
.formatter.exs Normal file
View File

@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

26
.gitignore vendored Normal file
View 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
View 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}
]
```

View 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

View 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
View 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

View 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

View 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
View File

@ -0,0 +1 @@
ExUnit.start()