Current section
Files
Jump to
Current section
Files
lib/ttlock_client/auth_manager.ex
defmodule TTlockClient.AuthManager do
@moduledoc """
GenServer that manages TTLock authentication state and token lifecycle.
Provides centralized token management with automatic refresh capabilities.
All authentication logic is contained within this module, eliminating the
need for module-specific authentication handling.
"""
use GenServer
require Logger
import TTlockClient.Types
alias TTlockClient.OAuthClient
# Client API
@doc """
Starts the AuthManager GenServer.
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Configures the client credentials for OAuth authentication.
## Parameters
* `client_id` - Application client ID from TTLock developer portal
* `client_secret` - Application client secret from TTLock developer portal
* `base_url` - Optional base URL (defaults to EU API endpoint)
## Example
TTlockClient.AuthManager.configure("your_client_id", "your_client_secret")
"""
@spec configure(String.t(), String.t(), String.t()) :: :ok | {:error, term()}
def configure(client_id, client_secret, base_url \\ "https://euapi.ttlock.com") do
config = new_client_config(client_id, client_secret, base_url)
GenServer.call(__MODULE__, {:configure, config})
end
@doc """
Authenticates with TTLock using username and password.
## Parameters
* `username` - TTLock app username (not developer account)
* `password` - Plain text password
## Returns
* `:ok` - Authentication successful
* `{:error, reason}` - Authentication failed
## Example
TTlockClient.AuthManager.authenticate("your_username", "your_password")
"""
@spec authenticate(String.t(), String.t()) :: :ok | {:error, term()}
def authenticate(username, password) do
GenServer.call(__MODULE__, {:authenticate, username, password}, 30_000)
end
@doc """
Gets a valid access token, refreshing if necessary.
## Returns
* `{:ok, access_token}` - Valid token available
* `{:error, :not_authenticated}` - No authentication data available
* `{:error, reason}` - Token refresh failed
## Example
case TTlockClient.AuthManager.get_valid_token() do
{:ok, token} ->
# Use token for API requests
{:error, :not_authenticated} ->
# Need to authenticate first
{:error, reason} ->
# Handle error
end
"""
@spec get_valid_token() :: {:ok, String.t()} | {:error, term()}
def get_valid_token do
GenServer.call(__MODULE__, :get_valid_token)
end
@doc """
Gets the current user ID if authenticated.
"""
@spec get_user_id() :: {:ok, integer()} | {:error, :not_authenticated}
def get_user_id do
GenServer.call(__MODULE__, :get_user_id)
end
@doc """
Manually triggers a token refresh.
"""
@spec refresh_token() :: :ok | {:error, term()}
def refresh_token do
GenServer.call(__MODULE__, :refresh_token)
end
@doc """
Gets the current authentication status.
"""
@spec status() :: :configured | :authenticated | :not_configured
def status do
GenServer.call(__MODULE__, :status)
end
@doc """
Gets the current client configuration.
"""
@spec get_config() :: {:ok, TTlockClient.Types.client_config()} | {:error, :not_configured}
def get_config do
GenServer.call(__MODULE__, :get_config)
end
@doc """
Clears all authentication data.
"""
@spec reset() :: :ok
def reset do
GenServer.call(__MODULE__, :reset)
end
# GenServer Callbacks
defmodule State do
@moduledoc false
defstruct [
:config,
:token_info,
:refresh_timer
]
end
@impl true
def init(_opts) do
Logger.info("Starting TTlockClient AuthManager")
{:ok, %State{}}
end
@impl true
def handle_call({:configure, config}, _from, state) do
Logger.info("Configuring TTlockClient client")
new_state = %{state | config: config}
{:reply, :ok, new_state}
end
@impl true
def handle_call({:authenticate, _username, _password}, _from, %State{config: nil} = state) do
{:reply, {:error, :not_configured}, state}
end
@impl true
def handle_call({:authenticate, username, password}, _from, %State{config: config} = state) do
case OAuthClient.get_access_token(config, username, password) do
{:ok, oauth_response} ->
token_info = new_token_info(oauth_response)
timer = schedule_refresh(token_info)
new_state = %{state | token_info: token_info, refresh_timer: timer}
Logger.info("Authentication successful for user: #{username}")
{:reply, :ok, new_state}
{:error, reason} = error ->
Logger.error("Authentication failed for user #{username}: #{inspect(reason)}")
{:reply, error, state}
end
end
@impl true
def handle_call(:get_valid_token, _from, %State{token_info: nil} = state) do
{:reply, {:error, :not_authenticated}, state}
end
@impl true
def handle_call(:get_valid_token, _from, %State{token_info: token_info} = state) do
if token_expired?(token_info) do
# Token is expired, try to refresh
case perform_refresh(state) do
{:ok, new_state} ->
token_info(access_token: access_token) = new_state.token_info
{:reply, {:ok, access_token}, new_state}
{:error, _reason} = error ->
{:reply, error, state}
end
else
token_info(access_token: access_token) = token_info
{:reply, {:ok, access_token}, state}
end
end
@impl true
def handle_call(:get_user_id, _from, %State{token_info: nil} = state) do
{:reply, {:error, :not_authenticated}, state}
end
@impl true
def handle_call(:get_user_id, _from, %State{token_info: token_info} = state) do
token_info(uid: uid) = token_info
{:reply, {:ok, uid}, state}
end
@impl true
def handle_call(:refresh_token, _from, state) do
case perform_refresh(state) do
{:ok, new_state} ->
{:reply, :ok, new_state}
{:error, _reason} = error ->
{:reply, error, state}
end
end
@impl true
def handle_call(:status, _from, %State{config: nil} = state) do
{:reply, :not_configured, state}
end
@impl true
def handle_call(:status, _from, %State{token_info: nil} = state) do
{:reply, :configured, state}
end
@impl true
def handle_call(:status, _from, state) do
{:reply, :authenticated, state}
end
@impl true
def handle_call(:reset, _from, %State{refresh_timer: timer} = _state) do
if timer, do: Process.cancel_timer(timer)
Logger.info("Resetting TTlockClient authentication state")
{:reply, :ok, %State{}}
end
@impl true
def handle_call(:get_config, _from, %State{config: config} = state) do
case config do
nil -> {:reply, {:error, :not_configured}, state}
config -> {:reply, {:ok, config}, state}
end
end
@impl true
def handle_info(:refresh_token, state) do
case perform_refresh(state) do
{:ok, new_state} ->
Logger.info("Automatic token refresh successful")
{:noreply, new_state}
{:error, reason} ->
Logger.error("Automatic token refresh failed: #{inspect(reason)}")
# Clear token info on refresh failure
new_state = %{state | token_info: nil, refresh_timer: nil}
{:noreply, new_state}
end
end
@impl true
def handle_info(_msg, state) do
{:noreply, state}
end
# Private functions
@spec perform_refresh(State.t()) :: {:ok, State.t()} | {:error, term()}
defp perform_refresh(%State{config: nil}) do
{:error, :not_configured}
end
defp perform_refresh(%State{token_info: nil}) do
{:error, :not_authenticated}
end
defp perform_refresh(%State{config: config, token_info: token_info, refresh_timer: timer} = state) do
token_info(refresh_token: refresh_token) = token_info
case OAuthClient.refresh_access_token(config, refresh_token) do
{:ok, oauth_response} ->
if timer, do: Process.cancel_timer(timer)
new_token_info = new_token_info(oauth_response)
new_timer = schedule_refresh(new_token_info)
new_state = %{state | token_info: new_token_info, refresh_timer: new_timer}
{:ok, new_state}
{:error, reason} = error ->
Logger.error("Token refresh failed: #{inspect(reason)}")
error
end
end
@spec schedule_refresh(TTlockClient.Types.token_info()) :: reference()
defp schedule_refresh(token_info(expires_at: expires_at)) do
# Schedule refresh 5 minutes before expiry
refresh_time = DateTime.add(expires_at, -300, :second)
delay_ms = max(DateTime.diff(refresh_time, DateTime.utc_now(), :millisecond), 60_000)
Logger.debug("Scheduling token refresh in #{delay_ms}ms")
Process.send_after(self(), :refresh_token, delay_ms)
end
end