defmodule TTlockClient.OAuth do @moduledoc """ TTLock OAuth2 client for authentication and token management. This module handles: - Getting access tokens using Resource Owner Password Credentials - Refreshing expired access tokens - Password hashing (MD5) for TTLock API requirements ## Usage # Get access token {:ok, response} = TTlockClient.OAuth.get_access_token( client_id: "your_client_id", client_secret: "your_client_secret", username: "+8618966498228", password: "your_password" ) # Refresh token {:ok, response} = TTlockClient.OAuth.refresh_token( client_id: "your_client_id", client_secret: "your_client_secret", refresh_token: "existing_refresh_token" ) """ @base_url "https://euapi.ttlock.com" @token_endpoint "/oauth2/token" @type oauth_config :: [ client_id: String.t(), client_secret: String.t(), username: String.t(), password: String.t() ] @type refresh_config :: [ client_id: String.t(), client_secret: String.t(), refresh_token: String.t() ] @type token_response :: %{ access_token: String.t(), uid: integer(), refresh_token: String.t(), expires_in: integer() } @type refresh_response :: %{ access_token: String.t(), refresh_token: String.t(), expires_in: integer() } @doc """ Get an access token using TTLock app account credentials. ## Parameters - `client_id`: Application clientId from TTLock Create Application page - `client_secret`: Application clientSecret from TTLock Create Application page - `username`: TTLock app account username (or prefixed username from User Register API) - `password`: Plain text password (will be MD5 hashed automatically) ## Returns - `{:ok, token_response}` on success - `{:error, reason}` on failure ## Example {:ok, %{ "access_token" => "39caac89b0b51c980aa61ad4264b693b", "uid" => 2340, "refresh_token" => "1bd2a21a7df889630f444364813738d7", "expires_in" => 7776000 }} = TTlockClient.OAuth.get_access_token( client_id: "your_client_id", client_secret: "your_client_secret", username: "+8618966498228", password: "your_password" ) """ @spec get_access_token(oauth_config()) :: {:ok, token_response()} | {:error, term()} def get_access_token(opts \\ []) do client_id = get_config_value(opts, :client_id) client_secret = get_config_value(opts, :client_secret) username = get_config_value(opts, :username) password = get_config_value(opts, :password) hashed_password = hash_password(password) params = [ {"clientId", client_id}, {"clientSecret", client_secret}, {"username", username}, {"password", hashed_password} ] make_token_request(params) end @doc """ Refresh an expired access token using a refresh token. ## Parameters - `client_id`: Application clientId - `client_secret`: Application clientSecret - `refresh_token`: Refresh token obtained from previous get_access_token call ## Returns - `{:ok, refresh_response}` on success - `{:error, reason}` on failure ## Example {:ok, %{ "access_token" => "39caac89b0b51c980aa61ad4264b693b", "refresh_token" => "1bd2a21a7df889630f444364813738d7", "expires_in" => 7776000 }} = TTlockClient.OAuth.refresh_token( client_id: "your_client_id", client_secret: "your_client_secret", refresh_token: "existing_refresh_token" ) """ @spec refresh_token(refresh_config()) :: {:ok, refresh_response()} | {:error, term()} def refresh_token(opts \\ []) do client_id = get_config_value(opts, :client_id) client_secret = get_config_value(opts, :client_secret) refresh_token = Keyword.fetch!(opts, :refresh_token) params = [ {"clientId", client_id}, {"clientSecret", client_secret}, {"grant_type", "refresh_token"}, {"refresh_token", refresh_token} ] make_token_request(params) end @doc """ Hash a password using MD5 as required by TTLock API. Returns a 32-character lowercase MD5 hash. ## Example iex> TTlockClient.OAuth.hash_password("123456") "e10adc3949ba59abbe56e057f20f883e" """ @spec hash_password(String.t()) :: String.t() def hash_password(password) when is_binary(password) do :crypto.hash(:md5, password) |> Base.encode16(case: :lower) end # Private functions @doc """ Get a valid access token, refreshing if necessary. This function first checks for a stored access token. If none exists or if it's expired, it will attempt to refresh using a stored refresh token. If refresh fails or no refresh token exists, it will return an error indicating new authentication is needed. ## Parameters - `client_id`: Application clientId (optional if configured in environment) - `client_secret`: Application clientSecret (optional if configured in environment) ## Returns - `{:ok, access_token}` on success - `{:error, :authentication_required}` if new authentication is needed - `{:error, reason}` on other failures ## Example {:ok, access_token} = TTlockClient.OAuth.get_valid_access_token( client_id: "your_client_id", client_secret: "your_client_secret" ) """ @spec get_valid_access_token(Keyword.t()) :: {:ok, String.t()} | {:error, term()} def get_valid_access_token(opts \\ []) do client_id = get_config_value(opts, :client_id) client_secret = get_config_value(opts, :client_secret) case get_stored_token() do {:ok, token, expires_at} -> if token_valid?(expires_at) do {:ok, token} else refresh_stored_token(client_id, client_secret) end :error -> case get_stored_refresh_token() do {:ok, refresh_token} -> refresh_stored_token(client_id, client_secret, refresh_token) :error -> {:error, :authentication_required} end end end @doc """ Store access token and refresh token for future use. ## Parameters - `access_token`: Access token to store - `refresh_token`: Refresh token to store - `expires_in`: Token lifetime in seconds ## Returns - `:ok` on success """ @spec store_tokens(String.t(), String.t(), integer()) :: :ok def store_tokens(access_token, refresh_token, expires_in) do # 5 min buffer expires_at = System.system_time(:second) + expires_in - 300 Application.put_env(:ex_ttlock, :access_token, access_token) Application.put_env(:ex_ttlock, :refresh_token, refresh_token) Application.put_env(:ex_ttlock, :token_expires_at, expires_at) :ok end defp get_config_value(opts, key) do case Keyword.get(opts, key) do nil -> env_key = case key do :client_id -> :ttlock_client_id :client_secret -> :ttlock_client_secret :access_token -> :ttlock_access_token _ -> key end case Application.get_env(:ex_ttlock, env_key) do nil -> raise ArgumentError, "#{key} is required either as option or in config" value -> value end value -> value end end defp get_stored_token() do case Application.get_env(:ex_ttlock, :access_token) do nil -> case Application.get_env(:ex_ttlock, :ttlock_access_token) do nil -> :error token -> expires_at = Application.get_env(:ex_ttlock, :token_expires_at, 0) {:ok, token, expires_at} end token -> expires_at = Application.get_env(:ex_ttlock, :token_expires_at, 0) {:ok, token, expires_at} end end defp get_stored_refresh_token() do case Application.get_env(:ex_ttlock, :refresh_token) do nil -> :error token -> {:ok, token} end end defp token_valid?(expires_at) do System.system_time(:second) < expires_at end defp refresh_stored_token(client_id, client_secret, refresh_token \\ nil) do refresh_token = refresh_token || Application.get_env(:ex_ttlock, :refresh_token) case refresh_token do nil -> {:error, :authentication_required} token -> case refresh_token( client_id: client_id, client_secret: client_secret, refresh_token: token ) do {:ok, %{ "access_token" => access_token, "refresh_token" => new_refresh_token, "expires_in" => expires_in }} -> store_tokens(access_token, new_refresh_token, expires_in) {:ok, access_token} {:error, _} -> # Clear invalid tokens Application.delete_env(:ex_ttlock, :access_token) Application.delete_env(:ex_ttlock, :refresh_token) Application.delete_env(:ex_ttlock, :token_expires_at) {:error, :authentication_required} end end end defp make_token_request(params) do url = @base_url <> @token_endpoint body = URI.encode_query(params) headers = [{"Content-Type", "application/x-www-form-urlencoded"}] case HTTPoison.post(url, body, headers) do {:ok, %HTTPoison.Response{status_code: 200, body: response_body}} -> case Jason.decode(response_body) do {:ok, data} -> {:ok, data} {:error, _} = error -> error end {:ok, %HTTPoison.Response{status_code: status_code, body: response_body}} -> case Jason.decode(response_body) do {:ok, error_data} -> {:error, {status_code, error_data}} {:error, _} -> {:error, {status_code, response_body}} end {:error, %HTTPoison.Error{} = error} -> {:error, error} end end end