defmodule BoxOAuth2 do @box_auth_url "https://api.box.com/oauth2/token" @moduledoc """ OAuth2 authentication layer for unofficial Box SDK. ## Configuration You need to set the required credentials in your application configuration. Typically this is done in config/config.exs or a similar file. For example: config :box, client_id: "abc12", enterprise_id: "123", public_key_id: "abc123", client_secret: "secret", private_key_path: "config/key.pem", private_key_password: System.get_env("PRIVATE_KEY_PASSWORD") ## Usage Use `new` to fetch the application configuration and then `login`. {:ok, auth} = BoxOAuth2.new() {:ok, auth} = BoxOAuth2.login(auth) """ defstruct [ :authentication_url, :client_id, :enterprise_id, :client_secret, :private_key, :public_key_id, :token, :expires_after, :client ] @type t :: %BoxOAuth2{ authentication_url: String.t(), client_id: String.t(), enterprise_id: String.t(), client_secret: String.t(), private_key: String.t(), public_key_id: String.t(), token: String.t(), expires_after: DateTime.t(), client: Tesla.Client.t() } @doc """ New BoxAuth2 structure configured from the application environment. """ @spec new(String.t()) :: tuple() def new(base_url \\ @box_auth_url) do with {:ok, pkey} <- private_key(private_key_path(), private_key_password()) do {:ok, %BoxOAuth2{ authentication_url: base_url, client_id: client_id(), enterprise_id: enterprise_id(), client_secret: client_secret(), private_key: pkey, public_key_id: public_key_id() }} end end @doc """ Login does an initial login or refreshes the OAuth token if it expired. If the existing access token is still valid, login won't request a new token. """ @spec login(BoxOAuth2.t()) :: tuple() def login(auth) do if is_nil(auth.token) or is_nil(auth.expires_after) do perform_login(auth) else case DateTime.compare(DateTime.utc_now(), auth.expires_after) do :lt -> {:ok, auth} _ -> perform_login(auth) end end end @token_exchange "urn:ietf:params:oauth:grant-type:token-exchange" @access_token_type "urn:ietf:params:oauth:token-type:access_token" @doc """ Downscope token creates a token with limited access to a particular file or folder. scopes = ['base_upload'] resource = "https://api.box.com/2.0/folders/75453696283" {:ok, response} = BoxOAuth2.downscope_token(auth, scopes, resource) """ @spec downscope_token(BoxOAuth2.t(), list(), String.t()) :: tuple() def downscope_token(auth, scopes, resource) do form = URI.encode_query(%{ scope: Enum.join(scopes, " "), resource: resource, grant_type: @token_exchange, subject_token: auth.token, subject_token_type: @access_token_type }) with {:ok, response} <- Tesla.post(auth.client, auth.authentication_url, form) do case response do %{status: 200, body: %{access_token: token, expires_in: expires_in}} -> dt = DateTime.utc_now() |> DateTime.add(expires_in, :second) {:ok, %{token: token, expires_after: dt}} %{status: status, body: %{message: message}} -> {:error, status, message} %{status: status} -> {:error, status, to_string(status)} end end end @spec perform_login(BoxOAuth2.t()) :: BoxAuth2.t() defp perform_login(auth) do with {:ok, assertion} <- bearer_token(auth), {:ok, response} <- post_login(auth, assertion) do case response do %{status: 200, body: %{access_token: token, expires_in: expires_in}} -> dt = DateTime.utc_now() |> DateTime.add(expires_in, :second) {:ok, %BoxOAuth2{auth | token: token, expires_after: dt, client: make_client(token)}} %{body: %{error: error, error_description: error_description}} -> {:error, error, error_description} %{status: status, body: %{message: message}} -> {:error, status, message} %{status: status} -> {:error, status, to_string(status)} end end end defp post_login(auth, assertion) do form = URI.encode_query(%{ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", # Our JWT assertion assertion: assertion, # The OAuth 2 client ID and secret client_id: auth.client_id, client_secret: auth.client_secret }) Tesla.post(make_client(), auth.authentication_url, form) end defp make_client do middleware = [{Tesla.Middleware.JSON, [engine_opts: [keys: :atoms]]}] Tesla.client(middleware) end defp make_client(token) do middleware = [ {Tesla.Middleware.JSON, [engine_opts: [keys: :atoms]]}, {Tesla.Middleware.Headers, [{"Authorization", "Bearer " <> token}]} ] Tesla.client(middleware) end defp client_id, do: Application.get_env(:box, :client_id) defp enterprise_id, do: Application.get_env(:box, :enterprise_id) defp public_key_id, do: Application.get_env(:box, :public_key_id) defp client_secret, do: Application.get_env(:box, :client_secret) defp private_key_path, do: Application.get_env(:box, :private_key_path) defp private_key_password, do: Application.get_env(:box, :private_key_password) defp bearer_token(auth) do signer = Joken.Signer.create("RS512", %{"pem" => auth.private_key}, %{kid: auth.public_key_id}) Joken.Signer.sign(claims(auth), signer) end defp claims(auth) do # jti is an identifier that helps protect against replay attacks: jti = :crypto.strong_rand_bytes(64) |> Base.encode16(case: :lower) # give the assertion a lifetime of 45 seconds before it expires: exp = DateTime.to_unix(DateTime.utc_now(), :second) + 45 %{ iss: auth.client_id, sub: auth.enterprise_id, box_sub_type: "enterprise", aud: auth.authentication_url, jti: jti, exp: exp } end defp private_key(path, passphrase) do with {:ok, key_string} <- File.read(path), {:ok, key} <- ExPublicKey.loads(key_string, passphrase), {:ok, private_key} <- ExPublicKey.pem_encode(key) do {:ok, private_key} end end end