lib/github/auth.ex

defprotocol GitHub.Auth do
  @moduledoc """
  Protocol for extracting API authentication tokens from application structs

  Credentials can be passed to operations using the `auth` option as strings (for tokens) or
  2-tuples (for client ID / secret or username / password). Sometimes, it's more convenient to pass
  a struct — like as a user struct — and extract the auth token from that.

  By implementing this protocol, the client can extract an auth token from the given struct without
  additional work by the caller.

  ## Example

      defimpl GitHub.Auth, for: MyApp.User do
        def to_auth(%MyApp.User{github_token: token}), do: token
      end

  ## Provided Implementations

  This library provides several implementations for the protocol based on library structs.

  ### GitHub.App

  For app structs with `id` and `pem` fields containing the GitHub App ID and private key
  (respectively), the default implementation will generate a JWT compatible with certain API
  endpoints. Generally, the PEM field can only be filled in manually. To assist with this, the
  helper function `GitHub.app/1` will construct a valid app struct using configured values.

  Creating a JWT requires the optional dependency `JOSE`.

  JWTs are made to last for several minutes, so it is prudent to cache values between requests.
  See `GitHub.Auth.Cache` for a built-in caching mechanism.
  """

  @typedoc "Auth token accepted by the client"
  @type auth ::
          nil
          | (token :: String.t())
          | {username_or_client_id :: String.t(), password_or_client_secret :: String.t()}

  @doc """
  Extract an auth token from the given struct

  The returned data should be in the form of a string (for a Bearer token) or a 2-tuple (for a Basic
  Auth user/password pair).
  """
  @spec to_auth(t) :: auth
  def to_auth(value)
end

if Code.ensure_loaded?(JOSE) do
  defimpl GitHub.Auth, for: GitHub.App do
    @token_clock_drift_allowance_sec 60
    @token_duration_sec 10 * 60

    def to_auth(%{id: app_id, pem: private_key}) do
      case GitHub.Auth.Cache.get({:app, app_id}) do
        {:ok, jwt} ->
          jwt

        _else ->
          time = DateTime.utc_now() |> DateTime.to_unix(:second)
          expiration = time - @token_clock_drift_allowance_sec + @token_duration_sec

          claims = %{
            "iat" => time - @token_clock_drift_allowance_sec,
            "exp" => expiration,
            "iss" => app_id
          }

          jwk = JOSE.JWK.from_pem(private_key)
          jws = JOSE.JWS.from_map(%{"alg" => "RS256", "typ" => "JWT"})

          {_, jwt} =
            JOSE.JWT.sign(jwk, jws, claims)
            |> JOSE.JWS.compact()

          GitHub.Auth.Cache.put({:app, app_id}, expiration, jwt)
          jwt
      end
    end
  end
else
  defimpl GitHub.Auth, for: GitHub.App do
    def to_auth(app) do
      raise GitHub.Error.new(
              source: app,
              message: """
              JOSE Not Installed

              Optional dependency JOSE was not installed at the
              time `GitHub.Auth` was compiled. Please ensure it
              is installed and run:

              `mix deps.compile --force oapi_github`
              """
            )
    end
  end
end