jswanner / jswanner/req_client_credentials

Support other OAuth Token Exchanges

Open
#3 4 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Elixir
Stars
7
Forks
1
PR merge metrics
No merged PRs in 30d

Description

I was looking for a Req plugin for the JWT bearer authorization grant from RFC 7523, specifically the token endpoint request using:

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
assertion=<jwt>

I didn’t find a dedicated Req plugin for that flow, but req_client_credentials has almost exactly the scaffolding I want: attach a request step, request an access token from the token endpoint, cache it, add Authorization: Bearer ..., and retry once after a 401.
Thanks for the library!

Rather than create req_jwt_bearer, I wonder if a generic plugin like req_oauth_token_exchange might support many single-request OAuth token exchanges (client credentials, JWT bearer, ...).

Example:

  • ReqOAuthTokenExchange plugin
    • handles Req integration, token endpoint request, access-token caching, bearer auth, and one-time 401 renewal
    • accepts a grant module
  • grant modules implement:
    • grant_type(config)
    • token_request_params(config)
    • optional cache_key(config)
  • ClientCredentialsGrant
    • returns grant_type=client_credentials
    • returns client_id, client_secret, optional scope, etc.
  • DocusignJWTBearerGrant
    • returns grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
    • returns assertion=<jwt>
    • has DocuSign-specific handling where scope is included in the JWT assertion rather than passed as a token endpoint parameter

Wdyt?

req_oauth_token_exchange.ex
defmodule ReqOAuthTokenExchange do
  @moduledoc """
  `Req` plugin for [OAuth 2.0] bearer-token authentication for single-request
  access token exchanges.

  This plugin is intended for OAuth flows where the client can obtain an
  access token by making one request to the token endpoint, such as client
  credentials, JWT bearer authorization grants from the [OAuth 2.0 Assertion
  Framework], or refresh-token exchanges. It accepts token request
  configuration under the `:oauth` key. The configured grant module supplies
  the OAuth `grant_type` and any grant-specific token endpoint request params.
  The access token will be cached and reused for subsequent requests. If the
  response to the authenticated request is a 401, the plugin will request a new
  access token and retry once.

  This is not a full OAuth flow implementation and does not handle flows that
  require redirects, browser/user-agent interaction, device-code polling,
  callback handling, or authorization-code acquisition.

  [OAuth 2.0]: https://www.rfc-editor.org/rfc/rfc6749
  [OAuth 2.0 Assertion Framework]: https://www.rfc-editor.org/rfc/rfc7521
  """

  @callback grant_type(Keyword.t()) :: String.t()
  @callback token_request_params(Keyword.t()) :: Keyword.t()
  @callback cache_key(Keyword.t()) :: term()

  @optional_callbacks cache_key: 1

  defguardp validated_request?(request)
            when is_tuple(request.private.req_oauth_data)

  @doc """
  Runs the plugin.

  ## Usage

      Req.new(base_url: "https://api.example.com", finch: Ev2Finch)
      |> ReqOAuthTokenExchange.attach()
      |> Req.get!(
        url: "/resource",
        oauth: [
          grant: MyApp.OauthGrant,
          url: "https://auth.example.com/oauth/token",
          grant_config: []
        ]
      )
      #=> %Req.Response{}
  """
  def attach(%Req.Request{} = req, opts \\ []) do
    req
    |> Req.Request.register_options([:oauth])
    |> Req.Request.merge_options(opts)
    |> Req.Request.append_request_steps(oauth: &auth/1)
    |> Req.Request.prepend_response_steps(oauth: &refresh_on_unauthorized/1)
  end

  def bust_cache(%Req.Request{} = request) do
    with {:ok, cache_key} <- request_cache_key(request) do
      :persistent_term.erase(cache_key)
    end
  end

  @doc false
  def write_cache(%Req.Request{} = request, token) do
    with {:ok, cache_key} <- request_cache_key(request) do
      :persistent_term.put(cache_key, token)
    end
  end

  defp auth(request) do
    options = Req.Request.get_option(request, :oauth, [])

    with {:ok, request} <- validate(request, options),
         {:ok, token} <- fetch_token(request) do
      request
      |> Req.Request.put_header("authorization", "Bearer " <> token)
      |> Req.Request.put_private(:req_oauth_refreshed?, false)
    else
      {_request, _response_or_exception} = result -> result
      _other -> request
    end
  end

  defp refresh_on_unauthorized({request, response}) when validated_request?(request) and response.status == 401 do
    if Req.Request.get_private(request, :req_oauth_refreshed?) do
      {request, response}
    else
      bust_cache(request)

      case request_token(request) do
        {:ok, token} ->
          %{request | halted: false}
          |> Req.Request.put_header("authorization", "Bearer " <> token)
          |> Req.Request.put_private(:req_oauth_refreshed?, true)
          |> Req.Request.run_request()

        _error ->
          {request, response}
      end
    end
  end

  defp refresh_on_unauthorized({request, response}), do: {request, response}

  defp validate(request, options) do
    with :ok <- validate_url(options),
         {:ok, {options, params, cache_key}} <- validate_params(options) do
      {:ok, Req.Request.put_private(request, :req_oauth_data, {options, params, cache_key})}
    else
      _ -> :error
    end
  end

  defp validate_url(options) do
    if is_binary(options[:url]) and options[:url] != "" do
      :ok
    else
      :error
    end
  end

  defp validate_params(options) do
    grant = options[:grant]
    grant_config = Keyword.get(options, :grant_config, [])

    if is_atom(grant) and Code.ensure_loaded?(grant) and
         function_exported?(grant, :grant_type, 1) and function_exported?(grant, :token_request_params, 1) and
         is_list(grant_config) do
      params = [{:grant_type, grant.grant_type(grant_config)} | grant.token_request_params(grant_config)]
      {:ok, {Keyword.take(options, [:url]), params, cache_key(options)}}
    else
      :error
    end
  end

  defp fetch_token(request) do
    case fetch_cache(request) do
      {:ok, token} -> {:ok, token}
      :error -> request_token(request)
    end
  end

  defp fetch_cache(request) do
    with {:ok, cache_key} <- request_cache_key(request),
         token when is_binary(token) <- :persistent_term.get(cache_key, :error) do
      {:ok, token}
    else
      _other -> :error
    end
  end

  defp request_token(request) do
    {options, params, _cache_key} = Req.Request.get_private(request, :req_oauth_data)
    options = put_in(options[:form], params)

    auth_req =
      Req.Request.new()
      |> Req.Request.append_request_steps(Keyword.delete(request.request_steps, :oauth))
      |> Req.Request.append_response_steps(Keyword.delete(request.response_steps, :oauth))
      |> Req.Request.append_error_steps(request.error_steps)
      |> Req.Request.register_options(Enum.to_list(request.registered_options))
      |> Req.Request.merge_options(
        request.options
        |> Map.drop([:body, :form, :json, :oauth])
        |> Map.to_list()
      )

    # Safe because auth_req inherits the parent request options, including finch: Ev2Finch.
    # credo:disable-for-next-line Ev2.Credo.RequireReqFinch
    case Req.post(auth_req, options) do
      {:ok, %{body: %{"access_token" => token}}} ->
        write_cache(request, token)
        {:ok, token}

      {:ok, %Req.Response{} = response} ->
        {request, response}

      _error ->
        :error
    end
  end

  defp request_cache_key(request) do
    case Req.Request.get_private(request, :req_oauth_data) do
      {_options, _params, cache_key} ->
        {:ok, cache_key}

      nil ->
        options = Req.Request.get_option(request, :oauth, [])

        with :ok <- validate_url(options),
             {:ok, {_options, _params, cache_key}} <- validate_params(options) do
          {:ok, cache_key}
        else
          _other -> :error
        end
    end
  end

  defp cache_key(options) do
    grant = options[:grant]
    grant_config = Keyword.get(options, :grant_config, [])

    grant_cache_key =
      if Code.ensure_loaded?(grant) and function_exported?(grant, :cache_key, 1) do
        grant.cache_key(grant_config)
      else
        grant_config
      end

    {__MODULE__, options[:url], grant, grant_cache_key}
  end
end
docusign_jwt_bearer_grant.ex
defmodule ReqOAuthTokenExchange.DocusignJWTBearerGrant do
  @moduledoc """
  DocuSign JWT bearer grant for `ReqOAuthTokenExchange`.

  Implements DocuSign's JWT grant token request as described in
  [DocuSign's JWT access token guide](https://developers.docusign.com/platform/auth/jwt-get-token/).

  DocuSign expects the requested scope in the signed JWT assertion. This is
  different from the generic OAuth assertion profile, where `scope` is an
  OAuth token endpoint request parameter.
  """
  @behaviour ReqOAuthTokenExchange

  alias ReqOAuthTokenExchange

  @grant_type "urn:ietf:params:oauth:grant-type:jwt-bearer"

  @config_schema [
    auth_host: [type: :string, required: true],
    integration_key: [type: :string, required: true],
    user_id: [type: :string, required: true],
    private_key: [type: :string, required: true],
    scope: [type: :string, required: true],
    expires_in_seconds: [type: :pos_integer, required: true]
  ]

  @impl ReqOAuthTokenExchange
  def grant_type(_config), do: @grant_type

  @impl ReqOAuthTokenExchange
  def token_request_params(config), do: [assertion: jwt(config)]

  @impl ReqOAuthTokenExchange
  def cache_key(config) do
    config = config(config)

    {
      config[:auth_host],
      config[:integration_key],
      config[:user_id],
      config[:scope],
      config[:expires_in_seconds],
      :crypto.hash(:sha256, config[:private_key])
    }
  end

  defp jwt(config) do
    config = config(config)
    now = Joken.current_time()

    claims = %{
      "iss" => config[:integration_key],
      "sub" => config[:user_id],
      "aud" => config[:auth_host],
      "iat" => now,
      "exp" => now + config[:expires_in_seconds],
      "scope" => config[:scope]
    }

    signer = Joken.Signer.create("RS256", %{"pem" => config[:private_key]})
    {:ok, jwt} = Joken.Signer.sign(claims, signer)
    jwt
  end

  defp config(config), do: NimbleOptions.validate!(config, @config_schema)
end
client_credentials_grant.ex
defmodule ReqOAuthTokenExchange.ClientCredentialsGrant do
  @moduledoc """
  OAuth 2.0 client credentials grant for `ReqOAuthTokenExchange`.

  Implements the token request params for the client credentials grant. `scope`
  is optional per OAuth 2.0. `audience` and `extra_params` are included for
  providers that require additional token endpoint params.
  """
  @behaviour ReqOAuthTokenExchange

  alias ReqOAuthTokenExchange

  @grant_type "client_credentials"

  @config_schema [
    client_id: [type: :string, required: true],
    client_secret: [type: :string, required: true],
    scope: [type: :string],
    audience: [type: :string],
    extra_params: [type: :keyword_list, default: []]
  ]

  @impl ReqOAuthTokenExchange
  def grant_type(_config), do: @grant_type

  @impl ReqOAuthTokenExchange
  def token_request_params(config) do
    config = config(config)

    config[:extra_params]
    |> Keyword.put_new(:audience, config[:audience])
    |> Keyword.put_new(:scope, config[:scope])
    |> Keyword.put(:client_secret, config[:client_secret])
    |> Keyword.put(:client_id, config[:client_id])
    |> Enum.reject(fn {_key, value} -> is_nil(value) end)
  end

  defp config(config), do: NimbleOptions.validate!(config, @config_schema)
end

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the proposed req_oauth_token_exchange.ex and compare its Req integration with the existing client-credentials plugin. Review docusign_jwt_bearer_grant.ex and client_credentials_grant.ex to understand the grant callbacks and configuration. Done means agreeing on the generic exchange design and supporting token caching, bearer authorization, and one-time 401 renewal without requiring separate plugins for each grant.

Written by the indexing model from the issue text.

Assessment

Tech stack
elixir
Domain
api, authentication, backend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.