Current section
Files
Jump to
Current section
Files
lib/graph_api/token_store.ex
defmodule GraphApi.TokenStore do
@moduledoc """
GenServer that caches OAuth tokens keyed by `{tenant_id, client_id}`.
Tokens are proactively refreshed before expiry. When a token is requested
and found expired, it is refreshed synchronously before returning.
This module is started automatically by the application supervisor.
"""
use GenServer
alias GraphApi.Auth
alias GraphApi.Config
@refresh_buffer_ms :timer.minutes(5)
# Client API
@doc """
Starts the token store.
"""
def start_link(opts \\ []) do
name = Keyword.get(opts, :name, __MODULE__)
GenServer.start_link(__MODULE__, %{}, name: name)
end
@doc """
Retrieves a valid access token for the given config.
If a cached token exists and is still valid, it is returned immediately.
If the token is expired or missing, a new token is acquired synchronously.
"""
@spec get_token(Config.t(), GenServer.server()) ::
{:ok, String.t()} | {:error, term()}
def get_token(%Config{} = config, server \\ __MODULE__) do
GenServer.call(server, {:get_token, config}, 30_000)
end
# Server callbacks
@impl true
def init(state) do
{:ok, state}
end
@impl true
def handle_call({:get_token, config}, _from, state) do
key = cache_key(config)
case Map.get(state, key) do
nil ->
reply_with_acquired(config, key, state)
token ->
if Auth.token_expired?(token) do
reply_with_acquired(config, key, state)
else
{:reply, {:ok, token.access_token}, state}
end
end
end
defp reply_with_acquired(config, key, state) do
case acquire_and_cache(config, key, state) do
{:ok, token, new_state} ->
{:reply, {:ok, token.access_token}, new_state}
{:error, error, _new_state} ->
{:reply, {:error, error}, state}
end
end
@impl true
def handle_info({:refresh, config_attrs, key}, state) do
config = struct!(Config, config_attrs)
case Auth.acquire_token(config) do
{:ok, token} ->
schedule_refresh(config, token)
{:noreply, Map.put(state, key, token)}
{:error, _reason} ->
# Retry in 30 seconds on failure
Process.send_after(self(), {:refresh, config_attrs, key}, :timer.seconds(30))
{:noreply, state}
end
end
# Internal
defp acquire_and_cache(config, key, state) do
case Auth.acquire_token(config) do
{:ok, token} ->
schedule_refresh(config, token)
{:ok, token, Map.put(state, key, token)}
{:error, error} ->
{:error, error, state}
end
end
defp schedule_refresh(config, token) do
refresh_in_ms = max((token.expires_in - 300) * 1000, @refresh_buffer_ms)
config_attrs = Map.from_struct(config)
key = cache_key(config)
Process.send_after(self(), {:refresh, config_attrs, key}, refresh_in_ms)
end
defp cache_key(%Config{tenant_id: tid, client_id: cid}), do: {tid, cid}
end