Current section
Files
Jump to
Current section
Files
lib/idverse/client/token_manager.ex
defmodule Idverse.Client.TokenManager do
@moduledoc """
GenServer that manages OAuth2 access tokens for the ID Kit API.
This module ensures that:
- Tokens are shared across multiple processes
- Only one token refresh happens at a time (GenServer serialization)
- Concurrent requests are automatically queued by GenServer's message queue
## Usage
TokenManagers are typically started automatically by the Application supervisor
based on configuration, but can also be started manually:
{:ok, pid} = Idverse.Client.TokenManager.start_link(
name: Idverse.TokenManager.Prod,
environment: :prod,
base_url: "https://mycompany.api.au.idkit.com/v1",
oauth2_url: "https://auth.idkit.com",
api_key: "your_api_key"
)
To get a token:
{:ok, token} = Idverse.Client.TokenManager.get_token(Idverse.TokenManager.Prod)
"""
use GenServer
require Logger
alias Idverse.Client.Auth
@type state :: %{
environment: atom(),
base_url: String.t(),
oauth2_url: String.t(),
api_key: String.t(),
access_token: String.t() | nil,
token_expires_at: DateTime.t() | nil
}
## Public API
@doc """
Starts a TokenManager process.
## Options
* `:name` - The registered name for this process (required)
* `:environment` - The environment name (e.g., :prod, :test)
* `:base_url` - The API base URL (required)
* `:oauth2_url` - The OAuth2 token endpoint base URL (required)
* `:api_key` - API key for authentication (required)
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
name = Keyword.fetch!(opts, :name)
GenServer.start_link(__MODULE__, opts, name: name)
end
@doc """
Gets a valid access token, refreshing if necessary.
Concurrent calls will be queued by GenServer and processed sequentially.
The first call will refresh the token if needed, and subsequent calls
will receive the cached token.
## Examples
{:ok, token} = TokenManager.get_token(Idverse.TokenManager.Prod)
{:error, reason} = TokenManager.get_token(Idverse.TokenManager.Test)
"""
@spec get_token(atom() | pid()) :: {:ok, String.t()} | {:error, term()}
def get_token(name_or_pid) do
GenServer.call(name_or_pid, :get_token, 10_000)
end
@doc """
Clears the cached access token, forcing a fresh token fetch on the next call.
Useful in test environments where Ecto's SQL sandbox rolls back the
database transaction that created the token.
## Examples
:ok = TokenManager.clear_token(Idverse.TokenManager.Test)
"""
@spec clear_token(atom() | pid()) :: :ok
def clear_token(name_or_pid) do
GenServer.call(name_or_pid, :clear_token)
end
## GenServer Callbacks
@impl true
def init(opts) do
state = %{
environment: Keyword.get(opts, :environment),
base_url: Keyword.fetch!(opts, :base_url),
oauth2_url: Keyword.fetch!(opts, :oauth2_url),
api_key: Keyword.fetch!(opts, :api_key),
access_token: nil,
token_expires_at: nil
}
Logger.info(
"Idverse.Client.TokenManager started for environment: #{state.environment} (#{state.oauth2_url})"
)
{:ok, state}
end
@impl true
def handle_call(:get_token, _from, state) do
if token_valid?(state) do
# Token is still valid, return it immediately
{:reply, {:ok, state.access_token}, state}
else
# Token is invalid or expired, refresh it synchronously
Logger.debug("Idverse.Client.TokenManager refreshing token for #{state.environment}")
case perform_token_refresh(state) do
{:ok, access_token, token_expires_at} ->
new_state = %{
state
| access_token: access_token,
token_expires_at: token_expires_at
}
Logger.info(
"Idverse.Client.TokenManager successfully refreshed token for #{state.environment}"
)
{:reply, {:ok, access_token}, new_state}
{:error, reason} = error ->
Logger.error(
"Idverse.Client.TokenManager failed to refresh token for #{state.environment}: #{inspect(reason)}"
)
{:reply, error, state}
end
end
end
@impl true
def handle_call(:clear_token, _from, state) do
{:reply, :ok, %{state | access_token: nil, token_expires_at: nil}}
end
## Private Functions
defp perform_token_refresh(state) do
token_url = Auth.build_token_url(state.oauth2_url)
# Basic Auth expects base64 encoded "api_key:" format
encoded_credentials = Base.encode64("#{state.api_key}:")
headers = [
{"content-type", "application/x-www-form-urlencoded"},
{"authorization", "Basic #{encoded_credentials}"}
]
case Req.post(token_url, headers: headers, body: "") do
{:ok, %{status: 200, body: body}} ->
access_token = body["access_token"]
expires_in = body["expires_in"] || 3600
# Calculate expiration time with a 60 second buffer
expires_at =
DateTime.utc_now()
|> DateTime.add(expires_in - 60, :second)
{:ok, access_token, expires_at}
{:ok, %{status: status, body: body}} ->
{:error, {:auth_failed, status, body}}
{:error, reason} ->
{:error, {:request_failed, reason}}
end
end
defp token_valid?(%{access_token: nil}), do: false
defp token_valid?(%{token_expires_at: nil}), do: false
defp token_valid?(%{token_expires_at: expires_at}) do
DateTime.compare(DateTime.utc_now(), expires_at) == :lt
end
end