Current section
Files
Jump to
Current section
Files
lib/box_oauth2.ex
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