defmodule BoxOAuth2 do use Tesla @box_auth_url "https://api.box.com/oauth2/token" @moduledoc """ Box OAuth2 authentication layer. """ plug(Tesla.Middleware.JSON, engine_opts: [keys: :atoms]) 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 configured from the application environment. """ @spec new(String.t()) :: BoxOAuth2.t() def new(base_url \\ @box_auth_url) do %BoxOAuth2{ authentication_url: base_url, client_id: client_id(), enterprise_id: enterprise_id(), client_secret: client_secret(), private_key: private_key(private_key_path(), private_key_password()), public_key_id: public_key_id() } end @doc """ login does an initial login or refreshes the OAuth token if it expired. """ @spec login(BoxOAuth2.t()) :: BoxOAuth2.t() def login(auth) do if is_nil(auth.token) 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. """ @spec downscope_token(BoxOAuth2.t(), list(), String.t()) :: String.t() 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 }) {:ok, response} = Tesla.post(auth.client, auth.authentication_url, form) if response.status == 200 do token = response.body.access_token dt = DateTime.utc_now() |> DateTime.add(response.body.expires_in, :second) {:ok, %{token: token, expires_after: dt}} else {:error, response.status, response.body} end end @spec perform_login(BoxOAuth2.t()) :: BoxAuth2.t() defp perform_login(auth) do {:ok, assertion} = bearer_token(auth) 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 }) {:ok, response} = post(auth.authentication_url, form) if response.status == 200 do token = response.body.access_token dt = DateTime.utc_now() |> DateTime.add(response.body.expires_in, :second) middleware = [ {Tesla.Middleware.JSON, [engine_opts: [keys: :atoms]]}, {Tesla.Middleware.Headers, [{"Authorization", "Bearer " <> token}]} ] client = Tesla.client(middleware) {:ok, %BoxOAuth2{auth | token: token, expires_after: dt, client: client}} else {:error, response.status, response.body} end 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 key_string = File.read!(path) {:ok, key} = ExPublicKey.loads(key_string, passphrase) {:ok, private_key} = ExPublicKey.pem_encode(key) private_key end end