defmodule Assent.Strategy.OAuth do
@moduledoc """
OAuth 1.0a strategy.
`authorize_url/1` returns a map with a `:session_params` and `:url` key. The
`:session_params` key carries a `:oauth_token_secret` value for the request.
## Configuration
- `:consumer_key` - The OAuth consumer key, required
- `:site` - The domain of the OAuth server, required
- `:signature_method` - The signature method, optional, defaults to
`:hmac_sha1`. The value may be one of the following:
- `:hmac_sha1` - Generates signature with HMAC-SHA1
- `:rsa_sha1` - Generates signature with RSA-SHA1
- `:plaintext` - Doesn't generate signature
- `:consumer_secret` - The OAuth consumer secret, required if
`:signature_method` is either `:hmac_sha1` or `:plaintext`
- `:private_key_path` - The path for the private key, required if
`:signature_method` is `:rsa_sha1` and `:private_key` hasn't been set
- `:private_key` - The private key content that can be defined instead of
`:private_key_path`, required if `:signature_method` is `:rsa_sha1` and
`:private_key_path` hasn't been set
## Usage
config = [
consumer_key: "REPLACE_WITH_CONSUMER_KEY",
consumer_secret: "REPLACE_WITH_CONSUMER_SECRET",
site: "https://auth.example.com",
authorization_params: [scope: "user:read user:write"],
user_url: "https://example.com/api/user"
]
{:ok, {url: url, session_params: session_params}} =
config
|> Assent.Config.put(:redirect_uri, "http://localhost:4000/auth/callback")
|> OAuth.authorize_url()
{:ok, %{user: user, token: token}} =
config
|> Assent.Config.put(:session_params, session_params)
|> OAuth.callback(params)
"""
@behaviour Assent.Strategy
alias Assent.Strategy, as: Helpers
alias Assent.{Config, HTTPAdapter.HTTPResponse, JWTAdapter, MissingParamError, RequestError}
@doc """
Generate authorization URL for request phase.
## Configuration
- `:redirect_uri` - The URI that the server redirects the user to after
authentication, required
- `:request_token_url` - The path or URL to fetch the token from, optional,
defaults to `/oauth/request_token`
- `:authorize_url` - The path or URL for the OAuth server to redirect users
to, defaults to `/oauth/authenticate`
- `:authorization_params` - The authorization parameters, defaults to `[]`
"""
@impl true
@spec authorize_url(Config.t()) :: {:ok, %{url: binary(), session_params: %{oauth_token_secret: binary()}}} | {:error, term()}
def authorize_url(config) do
case Config.fetch(config, :redirect_uri) do
{:ok, redirect_uri} -> authorize_url(config, redirect_uri)
{:error, error} -> {:error, error}
end
end
defp authorize_url(config, redirect_uri) do
config
|> get_request_token([{"oauth_callback", redirect_uri}])
|> build_authorize_url(config)
|> case do
{:ok, url, oauth_token_secret} -> {:ok, %{url: url, session_params: %{oauth_token_secret: oauth_token_secret}}}
{:error, error} -> {:error, error}
end
end
defp get_request_token(config, oauth_params) do
with {:ok, site} <- Config.fetch(config, :site) do
request_token_url = Config.get(config, :request_token_url, "/request_token")
url = process_url(site, request_token_url)
config
|> do_request(:post, site, url, [], oauth_params)
|> Helpers.decode_response(config)
|> process_token_response()
end
end
defp process_url(site, url) do
case String.downcase(url) do
<<"http://"::utf8, _::binary>> -> url
<<"https://"::utf8, _::binary>> -> url
_ -> site <> url
end
end
defp do_request(config, method, site, url, params, oauth_params, headers \\ [], token_secret \\ nil) do
params =
params
|> Enum.to_list()
|> Enum.map(fn {key, value} -> {to_string(key), value} end)
signature_method = Config.get(config, :signature_method, :hmac_sha1)
with {:ok, oauth_params} <- gen_oauth_params(config, signature_method, oauth_params),
{:ok, signed_header} <- signed_header(config, signature_method, method, url, oauth_params, params, token_secret) do
req_headers = request_headers(method, [signed_header] ++ headers)
req_body = request_body(method, params)
query_params = url_params(method, params)
url = Helpers.to_url(site, url, query_params)
Helpers.request(method, url, req_body, req_headers, config)
end
end
defp gen_oauth_params(config, signature_method, oauth_params) do
with {:ok, consumer_key} <- Config.fetch(config, :consumer_key) do
params =
[
{"oauth_consumer_key", consumer_key},
{"oauth_nonce", gen_nonce()},
{"oauth_signature_method", to_signature_method_string(signature_method)},
{"oauth_timestamp", timestamp()},
{"oauth_version", "1.0"}
| oauth_params
]
{:ok, params}
end
end
defp signed_header(config, signature_method, method, url, oauth_params, params, token_secret) do
uri = URI.parse(url)
query_params = Map.to_list(URI.decode_query(uri.query || ""))
request_params = params ++ query_params ++ oauth_params
with {:ok, signature} <- gen_signature(config, method, uri, request_params, signature_method, token_secret) do
oauth_header_value =
Enum.map_join([{"oauth_signature", signature} | oauth_params], ", ", fn {key, value} ->
percent_encode(key) <> "=\"" <> percent_encode(value) <> "\""
end)
{:ok, {"Authorization", "OAuth " <> oauth_header_value}}
end
end
defp gen_nonce do
16
|> :crypto.strong_rand_bytes()
|> Base.encode64(padding: false)
end
defp to_signature_method_string(:hmac_sha1), do: "HMAC-SHA1"
defp to_signature_method_string(:rsa_sha1), do: "RSA-SHA1"
defp to_signature_method_string(:plaintext), do: "PLAINTEXT"
defp timestamp, do: to_string(:os.system_time(:second))
defp gen_signature(config, method, uri, request_params, :hmac_sha1, token_secret) do
with {:ok, shared_secret} <- encoded_shared_secret(config, token_secret) do
text = signature_base_string(method, uri, request_params)
signature =
:hmac
|> :crypto.mac(:sha, shared_secret, text)
|> Base.encode64()
{:ok, signature}
end
end
defp gen_signature(config, method, uri, request_params, :rsa_sha1, _token_secret) do
with {:ok, pem} <- JWTAdapter.load_private_key(config),
{:ok, private_key} <- decode_pem(pem) do
signature =
method
|> signature_base_string(uri, request_params)
|> :public_key.sign(:sha, private_key)
|> Base.encode64()
{:ok, signature}
end
end
defp gen_signature(config, _method, _url, _request_params, :plaintext, token_secret),
do: encoded_shared_secret(config, token_secret)
defp encoded_shared_secret(config, token_secret) do
with {:ok, consumer_secret} <- Config.fetch(config, :consumer_secret) do
shared_secret = Enum.map_join([consumer_secret, token_secret || ""], "&", &percent_encode/1)
{:ok, shared_secret}
end
end
defp percent_encode(value) do
value
|> to_string()
|> URI.encode(&URI.char_unreserved?/1)
end
defp signature_base_string(method, uri, request_params) do
method =
method
|> to_string()
|> String.upcase()
base_string_uri =
%{uri | query: nil, host: uri.host}
|> URI.to_string()
|> String.downcase()
normalized_request_params =
request_params
|> Enum.map(fn {key, value} ->
percent_encode(key) <> "=" <> percent_encode(value)
end)
|> Enum.sort()
|> Enum.join("&")
Enum.map_join([method, base_string_uri, normalized_request_params], "&", &percent_encode/1)
end
defp decode_pem(pem) do
case :public_key.pem_decode(pem) do
[entry] -> {:ok, :public_key.pem_entry_decode(entry)}
_any -> {:error, "Private key should only have one entry"}
end
end
defp request_headers(:post, headers), do: [{"content-type", "application/x-www-form-urlencoded"}] ++ headers
defp request_headers(_method, headers), do: headers
defp request_body(:post, req_params), do: URI.encode_query(req_params)
defp request_body(_method, _req_params), do: nil
defp url_params(:post, _params), do: []
defp url_params(_method, params), do: params
defp process_token_response({:ok, %HTTPResponse{status: 200, body: body} = response}) when is_binary(body), do: process_token_response({:ok, %{response | body: URI.decode_query(body)}})
defp process_token_response({:ok, %HTTPResponse{status: 200, body: %{"oauth_token" => _, "oauth_token_secret" => _} = token}}), do: {:ok, token}
defp process_token_response(any), do: process_response(any)
defp process_response({:ok, %HTTPResponse{} = response}), do: {:error, RequestError.unexpected(response)}
defp process_response({:error, %HTTPResponse{} = response}), do: {:error, RequestError.invalid(response)}
defp process_response({:error, error}), do: {:error, error}
defp build_authorize_url({:ok, token}, config) do
with {:ok, site} <- Config.fetch(config, :site),
{:ok, oauth_token} <- fetch_from_token(token, "oauth_token"),
{:ok, oauth_token_secret} <- fetch_from_token(token, "oauth_token_secret") do
authorization_url = Config.get(config, :authorize_url, "/authorize")
params = authorization_params(config, oauth_token: oauth_token)
url = Helpers.to_url(site, authorization_url, params)
{:ok, url, oauth_token_secret}
end
end
defp build_authorize_url({:error, error}, _config), do: {:error, error}
defp fetch_from_token(token, key) do
case Map.fetch(token, key) do
{:ok, value} -> {:ok, value}
:error -> {:error, "No `#{key}` in token map"}
end
end
defp authorization_params(config, params) do
config
|> Config.get(:authorization_params, [])
|> Config.merge(params)
|> List.keysort(0)
end
@doc """
Callback phase for generating access token and fetch user data.
## Configuration
- `:access_token_url` - The path or URL to fetch the access token from,
optional, defaults to `/oauth/access_token`
- `:user_url` - The path or URL to fetch user data, required
- `:session_params` - The session parameters that was returned from
`authorize_url/1`, optional
"""
@impl true
@spec callback(Config.t(), map(), atom()) :: {:ok, %{user: map(), token: map()}} | {:error, term()}
def callback(config, params, strategy \\ __MODULE__) do
with {:ok, oauth_token} <- fetch_oauth_token(params),
{:ok, oauth_verifier} <- fetch_oauth_verifier(params),
{:ok, token} <- get_access_token(config, oauth_token, oauth_verifier),
{:ok, user} <- strategy.fetch_user(config, token) do
{:ok, %{user: user, token: token}}
end
end
defp fetch_oauth_token(%{"oauth_token" => code}), do: {:ok, code}
defp fetch_oauth_token(params), do: {:error, MissingParamError.new("oauth_token", params)}
defp fetch_oauth_verifier(%{"oauth_verifier" => code}), do: {:ok, code}
defp fetch_oauth_verifier(params), do: {:error, MissingParamError.new("oauth_verifier", params)}
defp get_access_token(config, oauth_token, oauth_verifier) do
with {:ok, site} <- Config.fetch(config, :site) do
access_token_url = Config.get(config, :access_token_url, "/access_token")
url = process_url(site, access_token_url)
oauth_token_secret = Kernel.get_in(config, [:session_params, :oauth_token_secret])
config
|> do_request(:post, site, url, [], [{"oauth_token", oauth_token}, {"oauth_verifier", oauth_verifier}], [], oauth_token_secret)
|> Helpers.decode_response(config)
|> process_token_response()
end
end
@doc """
Performs a signed HTTP request to the API using the oauth token.
"""
@spec request(Config.t(), map(), atom(), binary(), map() | Keyword.t(), [{binary(), binary()}]) :: {:ok, map()} | {:error, term()}
def request(config, token, method, url, params \\ [], headers \\ []) do
with {:ok, site} <- Config.fetch(config, :site),
{:ok, oauth_token} <- fetch_from_token(token, "oauth_token"),
{:ok, oauth_token_secret} <- fetch_from_token(token, "oauth_token_secret") do
url = process_url(site, url)
config
|> do_request(method, site, url, params, [{"oauth_token", oauth_token}], headers, oauth_token_secret)
|> Helpers.decode_response(config)
end
end
@doc false
@spec fetch_user(Config.t(), map()) :: {:ok, map()} | {:error, term()}
def fetch_user(config, token) do
with {:ok, url} <- Config.fetch(config, :user_url) do
config
|> request(token, :get, url)
|> process_user_response()
end
end
defp process_user_response({:ok, %HTTPResponse{status: 200, body: user}}), do: {:ok, user}
defp process_user_response({:error, %HTTPResponse{status: 401}}), do: {:error, %RequestError{message: "Unauthorized token"}}
defp process_user_response(any), do: process_response(any)
end