lib/acme/client/http_client.rb
# frozen_string_literal: true module Acme::Client::HTTPClient # Creates and returns a new HTTP client, with default settings. # # @param url [URI:HTTPS] # @param options [Hash] # @return [Faraday::Connection] def self.new_connection(url:, options: {}) Faraday.new(url, options) do |configuration| configuration.use Acme::Client::HTTPClient::ErrorMiddleware yield(configuration) if block_given? configuration.headers[:user_agent] = Acme::Client::USER_AGENT configuration.adapter Faraday.default_adapter end end # Creates and returns a new HTTP client designed for the Acme-protocol, with default settings. # # @param url [URI:HTTPS] # @param client [Acme::Client] # @param mode [Symbol] # @param options [Hash] # @param bad_nonce_retry [Integer] # @return [Faraday::Connection] def self.new_acme_connection(url:, client:, mode:, options: {}, bad_nonce_retry: 0) new_connection(url: url, options: options) do |configuration| if bad_nonce_retry > 0 configuration.request(:retry, max: bad_nonce_retry, methods: Faraday::Connection::METHODS, exceptions: [Acme::Client::Error::BadNonce]) end configuration.use Acme::Client::HTTPClient::AcmeMiddleware, client: client, mode: mode yield(configuration) if block_given? end end # ErrorMiddleware ensures the HTTP Client would not raise exceptions outside the Acme namespace. # # Exceptions are rescued and re-packaged as Acme exceptions. class ErrorMiddleware < Faraday::Middleware # Implements the Rack-alike Faraday::Middleware interface. def call(env) @app.call(env) rescue Faraday::TimeoutError, Faraday::ConnectionFailed raise Acme::Client::Error::Timeout end end # AcmeMiddleware implements the Acme-protocol requirements for JWK requests. class AcmeMiddleware < Faraday::Middleware attr_reader :env, :response, :client CONTENT_TYPE = 'application/jose+json' def initialize(app, options) super(app) @client = options.fetch(:client) @mode = options.fetch(:mode) end def call(env) @env = env @env[:request_headers]['Content-Type'] = CONTENT_TYPE if @env.method != :get @env.body = client.jwk.jws(header: jws_header, payload: env.body) end @app.call(env).on_complete { |response_env| on_complete(response_env) } end def on_complete(env) @env = env raise_on_not_found! store_nonce env.body = decode_body env.response_headers['Link'] = decode_link_headers return if env.success? raise_on_error! end private def jws_header headers = { nonce: pop_nonce, url: env.url.to_s } headers[:kid] = client.kid if @mode == :kid headers end def raise_on_not_found! raise Acme::Client::Error::NotFound, env.url.to_s if env.status == 404 end def raise_on_error! raise error_class, error_message end def error_message if env.body.is_a? Hash env.body['detail'] else "Error message: #{env.body}" end end def error_class Acme::Client::Error::ACME_ERRORS.fetch(error_name, Acme::Client::Error) end def error_name return unless env.body.is_a?(Hash) return unless env.body.key?('type') env.body['type'] end def decode_body content_type = env.response_headers['Content-Type'].to_s if content_type.start_with?('application/json', 'application/problem+json') JSON.load(env.body) else env.body end end def decode_link_headers return unless env.response_headers.key?('Link') link_header = env.response_headers['Link'] Acme::Client::Util.decode_link_headers(link_header) end def store_nonce nonce = env.response_headers['replay-nonce'] nonces << nonce if nonce end def pop_nonce if nonces.empty? get_nonce end nonces.pop end def get_nonce client.get_nonce end def nonces client.nonces end end end