lib/assent/strategies/oauth2.ex

defmodule Assent.Strategy.OAuth2 do
  @moduledoc """
  OAuth 2.0 strategy.

  This strategy only supports the Authorization Code flow per
  [RFC 6749](https://tools.ietf.org/html/rfc6749#section-1.3.1).

  `authorize_url/1` returns a map with a `:url` and `:session_params` key. The
  `:session_params` should be stored and passed back into `callback/3` as part
  of config when the user returns. The `:session_params` carries a `:state`
  value for the request [to prevent
  CSRF](https://tools.ietf.org/html/rfc6749#section-4.1.1).

  This library also supports JWT tokens for client authentication as per
  [RFC 7523](https://tools.ietf.org/html/rfc7523).

  ## Configuration

    - `:client_id` - The OAuth2 client id, required
    - `:site` - The domain of the OAuth2 server, required
    - `:auth_method` - The authentication strategy used, optional. If not set,
      no authentication will be used during the access token request. The value
      may be one of the following:

      - `:client_secret_basic` - Authenticate with basic authorization header
      - `:client_secret_post` - Authenticate with post params
      - `:client_secret_jwt` - Authenticate with JWT using `:client_secret` as
        secret
      - `:private_key_jwt` - Authenticate with JWT using `:private_key_path` or
        `:private_key` as secret
    - `:client_secret` - The OAuth2 client secret, required if `:auth_method`
      is `:client_secret_basic`, `:client_secret_post`, or `:client_secret_jwt`
    - `:private_key_id` - The private key ID, required if `:auth_method` is
      `:private_key_jwt`
    - `:private_key_path` - The path for the private key, required if
      `:auth_method` is `:private_key_jwt` and `:private_key` hasn't been set
    - `:private_key` - The private key content that can be defined instead of
      `:private_key_path`, required if `:auth_method` is `:private_key_jwt` and
      `:private_key_path` hasn't been set
    - `:jwt_algorithm` - The algorithm to use for JWT signing, optional,
      defaults to `HS256` for `:client_secret_jwt` and `RS256` for
      `:private_key_jwt`

  ## Usage

      config =  [
        client_id: "REPLACE_WITH_CLIENT_ID",
        client_secret: "REPLACE_WITH_CLIENT_SECRET",
        auth_method: :client_secret_post,
        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")
        |> Assent.Strategy.OAuth2.authorize_url()

      {:ok, %{user: user, token: token}} =
        config
        |> Assent.Config.put(:session_params, session_params)
        |> Assent.Strategy.OAuth2.callback(params)
  """
  @behaviour Assent.Strategy

  alias Assent.Strategy, as: Helpers
  alias Assent.{CallbackCSRFError, CallbackError, 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
    - `:authorize_url` - The path or URL for the OAuth2 server to redirect
      users to, defaults to `/oauth/authorize`
    - `:authorization_params` - The authorization parameters, defaults to `[]`
  """
  @impl true
  @spec authorize_url(Config.t()) :: {:ok, %{session_params: %{state: binary()}, url: binary()}} | {:error, term()}
  def authorize_url(config) do
    with {:ok, redirect_uri} <- Config.fetch(config, :redirect_uri),
         {:ok, site}         <- Config.fetch(config, :site),
         {:ok, client_id}    <- Config.fetch(config, :client_id) do
      params        = authorization_params(config, client_id, redirect_uri)
      authorize_url = Config.get(config, :authorize_url, "/oauth/authorize")
      url           = Helpers.to_url(site, authorize_url, params)

      {:ok, %{url: url, session_params: %{state: params[:state]}}}
    end
  end

  defp authorization_params(config, client_id, redirect_uri) do
    params = Config.get(config, :authorization_params, [])

    [
      response_type: "code",
      client_id: client_id,
      state: gen_state(),
      redirect_uri: redirect_uri]
    |> Keyword.merge(params)
    |> List.keysort(0)
  end

  defp gen_state do
    24
    |> :crypto.strong_rand_bytes()
    |> :erlang.bitstring_to_list()
    |> Enum.map_join(fn x -> :erlang.integer_to_binary(x, 16) end)
    |> String.downcase()
  end

  @doc """
  Callback phase for generating access token with authorization code and fetch
  user data. Returns a map with access token in `:token` and user data in
  `:user`.

  ## Configuration

    - `:token_url` - The path or URL to fetch the token from, optional,
      defaults to `/oauth/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, session_params} <- Config.fetch(config, :session_params),
         :ok                   <- check_error_params(params),
         {:ok, code}           <- fetch_code_param(params),
         {:ok, redirect_uri}   <- Config.fetch(config, :redirect_uri),
         :ok                   <- maybe_check_state(session_params, params),
         {:ok, token}          <- grant_access_token(config, "authorization_code", code: code, redirect_uri: redirect_uri) do

      fetch_user_with_strategy(config, token, strategy)
    end
  end

  defp check_error_params(%{"error" => _} = params) do
    message   = params["error_description"] || params["error_reason"] || params["error"]
    error     = params["error"]
    error_uri = params["error_uri"]

    {:error, %CallbackError{message: message, error: error, error_uri: error_uri}}
  end
  defp check_error_params(_params), do: :ok

  defp fetch_code_param(%{"code" => code}), do: {:ok, code}
  defp fetch_code_param(params), do: {:error, MissingParamError.new("code", params)}

  defp maybe_check_state(%{state: stored_state}, %{"state" => provided_state}) do
    case Assent.constant_time_compare(stored_state, provided_state) do
      true -> :ok
      false -> {:error, CallbackCSRFError.new("state")}
    end
  end
  defp maybe_check_state(%{state: _state}, params) do
    {:error, MissingParamError.new("state", params)}
  end
  defp maybe_check_state(_session_params, _params), do: :ok

  defp authentication_params(nil, config) do
    with {:ok, client_id} <- Config.fetch(config, :client_id) do

      headers = []
      body    = [client_id: client_id]

      {:ok, headers, body}
    end
  end
  defp authentication_params(:client_secret_basic, config) do
    with {:ok, client_id}     <- Config.fetch(config, :client_id),
         {:ok, client_secret} <- Config.fetch(config, :client_secret) do

      auth    = Base.encode64("#{client_id}:#{client_secret}")
      headers = [{"authorization", "Basic #{auth}"}]
      body    = []

      {:ok, headers, body}
    end
  end
  defp authentication_params(:client_secret_post, config) do
    with {:ok, client_id}     <- Config.fetch(config, :client_id),
         {:ok, client_secret} <- Config.fetch(config, :client_secret) do

      headers = []
      body    = [client_id: client_id, client_secret: client_secret]

      {:ok, headers, body}
    end
  end
  defp authentication_params(:client_secret_jwt, config) do
    alg = Config.get(config, :jwt_algorithm, "HS256")

    with {:ok, client_secret} <- Config.fetch(config, :client_secret) do
      jwt_authentication_params(alg, client_secret, config)
    end
  end
  defp authentication_params(:private_key_jwt, config) do
    alg = Config.get(config, :jwt_algorithm, "RS256")

    with {:ok, pem}             <- JWTAdapter.load_private_key(config),
         {:ok, _private_key_id} <- Config.fetch(config, :private_key_id) do
      jwt_authentication_params(alg, pem, config)
    end
  end
  defp authentication_params(method, _config) do
    {:error, "Invalid `:auth_method` #{method}"}
  end

  defp jwt_authentication_params(alg, secret, config) do
    with {:ok, claims}    <- jwt_claims(config),
         {:ok, token}     <- Helpers.sign_jwt(claims, alg, secret, config) do

      headers = []
      body    = [client_assertion: token, client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"]

      {:ok, headers, body}
    end
  end

  defp jwt_claims(config) do
    timestamp = :os.system_time(:second)

    with {:ok, site}      <- Config.fetch(config, :site),
         {:ok, client_id} <- Config.fetch(config, :client_id) do

      {:ok, %{
        "iss" => client_id,
        "sub" => client_id,
        "aud" => site,
        "iat" => timestamp,
        "exp" => timestamp + 60
      }}
    end
  end

  @doc """
  Grants an access token.
  """
  @spec grant_access_token(Config.t(), binary(), Keyword.t()) :: {:ok, map()} | {:error, term()}
  def grant_access_token(config, grant_type, params)  do
    auth_method  = Config.get(config, :auth_method, nil)
    token_url    = Config.get(config, :token_url, "/oauth/token")

    with {:ok, site}                    <- Config.fetch(config, :site),
         {:ok, auth_headers, auth_body} <- authentication_params(auth_method, config) do
      headers = [{"content-type", "application/x-www-form-urlencoded"}] ++ auth_headers
      params  = Keyword.merge(params, Keyword.put(auth_body, :grant_type, grant_type))
      url     = Helpers.to_url(site, token_url)
      body    = URI.encode_query(params)

      :post
      |> Helpers.request(url, body, headers, config)
      |> Helpers.decode_response(config)
      |> process_access_token_response()
    end
  end

  defp process_access_token_response({:ok, %HTTPResponse{status: 200, body: %{"access_token" => _} = token}}), do: {:ok, token}
  defp process_access_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 fetch_user_with_strategy(config, token, strategy) do
    config
    |> strategy.fetch_user(token)
    |> case do
      {:ok, user}     -> {:ok, %{token: token, user: user}}
      {:error, error} -> {:error, error}
    end
  end

  @doc """
  Refreshes the access token.
  """
  @spec refresh_access_token(Config.t(), map(), Keyword.t()) :: {:ok, map()} | {:error, term()}
  def refresh_access_token(config, token, params \\ []) do
    with {:ok, refresh_token} <- fetch_from_token(token, "refresh_token") do
      grant_access_token(config, "refresh_token", Keyword.put(params, :refresh_token, refresh_token))
    end
  end

  @doc """
  Performs a HTTP request to the API using the access 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, auth_headers} <- authorization_headers(config, token) do

      req_headers = request_headers(method, auth_headers ++ headers)
      req_body    = request_body(method, params)
      params      = url_params(method, params)
      url         = Helpers.to_url(site, url, params)

      method
      |> Helpers.request(url, req_body, req_headers, config)
      |> Helpers.decode_response(config)
    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, params), do: URI.encode_query(params)
  defp request_body(_method, _params), do: nil

  defp url_params(:post, _params), do: []
  defp url_params(_method, params), do: params

  @doc """
  Fetch user data with the access token.

  Uses `request/6` to fetch the user data.
  """
  @spec fetch_user(Config.t(), map(), map() | Keyword.t(), [{binary(), binary()}]) :: {:ok, map()} | {:error, term()}
  def fetch_user(config, token, params \\ [], headers \\ []) do
    with {:ok, user_url} <- Config.fetch(config, :user_url) do
      config
      |> request(token, :get, user_url, params, headers)
      |> process_user_response()
    end
  end

  defp authorization_headers(config, token) do
    type =
      token
      |> Map.get("token_type", "Bearer")
      |> String.downcase()

    authorization_headers(config, token, type)
  end
  defp authorization_headers(_config, token, "bearer") do
    with {:ok, access_token} <- fetch_from_token(token, "access_token") do
      {:ok, [{"authorization", "Bearer #{access_token}"}]}
    end
  end
  defp authorization_headers(_config, _token, type) do
    {:error, "Authorization with token type `#{type}` not supported"}
  end

  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 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