I receive an incoming transfer event webhook from the Manager One API. For signature verification, they employ an HMAC-SHA256 algorithm. They provided me with a shared secret key, which is a combination of numbers and letters that I have placed in my ENV["MANAGER_ONE_SECRET_SIGNATURE"]. For this example, let's assume the value is "shared_secret_key." I have been working on matching the key obtained from the request header information with this shared secret key to successfully verify the webhook.
Here's what I have accomplished thus far:
class Api::ManagerOneController < Api::ApplicationController
def create
if expected_signature == provided_signature_base64
request_body = JSON.parse(request.raw_post)["incoming_bank_operation"]
ManagerOne::Webhooks::IncomingTransferJob.perform_later(request_body)
render status: :ok, json: { message: "Signature verified successfully" }
else
render status: :unauthorized, json: { error: "Unauthorized" }
end
end
private
def expected_signature
Base64.strict_encode64(
OpenSSL::HMAC.digest(
OpenSSL::Digest.new("sha256"),
ENV["MANAGER_ONE_SECRET_SIGNATURE"],
signature_input
)
)
end
def signature_input
content_length = "\"content-length\": #{request.headers['Content-Length']}\n"
user_agent = "\"user-agent\": #{request.headers['User-agent']}\n"
content_type= "\"content-type\": #{request.headers['Content-Type']}\n"
host = "\"host\": #{request.headers['Host']}\n"
request_target = "\"(request-target)\": #{"post #{request.path}"}\n"
date = "\"date\": #{request.headers['Date']}\n"
digest = "\"digest\": #{request.headers['Digest']}\n"
signature_params = "\"@signature-params\": (\"content-length\" \"user-agent\" \"content-type\" \"host\" \"(request-target)\" \"date\" \"digest\"); keyid=\"#{ENV["MANAGER_ONE_SECRET_SIGNATURE"]}\"; algorithm=\"hmac-sha256\";"
"#{content_length}#{user_agent}#{content_type}#{host}#{request_target}#{date}#{digest}#{signature_params}"
end
def provided_signature_base64
request.headers['Signature'].match(/signature="([^"]+)"/)[1]
end
end
The headers change with each request; the data I provided is just an example:
expected_signature = "1zmbBUVFEsqlj8G0h8SPdikbu7et4boR/r/HHoLkr2o="
I generated the signature_input by following the instructions provided in this documentation:
signature_input = "\"content-length\": 1107\n\"user-agent\": manager.one\n\"content-type\": application/json\n\"host\": localhost:3000\n\"(request-target)\": post /api/manager_one/webhooks\n\"date\": Mon, 30 Oct 2023 13:02:55 GMT\n\"digest\": SHA-256=sTz9SzsDH3WGcbYSZb+G8Xk1javIUxE0egQaKD1Wyws=\n\"@signature-params\": (\"content-length\" \"user-agent\" \"content-type\" \"host\" \"(request-target)\" \"date\" \"digest\"); keyid=\"shared_secret_key\"; algorithm=\"hmac-sha256\";"
The key I'm supposed to match with the expected signature is the one I obtained from the Manager One API documentation example, although it changes with each request:
provided_signature_base64 = "pkLgUJmM5FixErazNavlwWizmDZdTDBcMZb9xY3ZUv8="
Am I missing something? I can't figure out what I'm doing wrong.
I've figured it out; my signature input had several issues:
In the end, I resolved these issues and arrived at the correct solution:
class Api::ManagerOneController < Api::ApplicationController
def create
success = expected_signature == provided_signature_base64
request_body = JSON.parse(request.raw_post)["incoming_bank_operation"]
ManagerOne::Webhooks::IncomingTransferJob.perform_later(request_body, success)
render status: :ok, json: { success: true, message: success ? "Authorized" : "Unauthorized" }
end
private
def expected_signature
hmac = OpenSSL::HMAC.digest("SHA-256", ENV["MANAGER_ONE_SECRET_SIGNATURE"], signature_input)
Base64.encode64(hmac).chomp
end
def signature_input
content_length = "content-length: #{request.headers['Content-Length']}"
user_agent = "user-agent: #{request.headers['User-agent']}"
content_type= "content-type: #{request.headers['Content-Type']}"
# When using Ngrok in development environment, host must be ngrok url & not localhost:3000
host = "host: #{request.headers['Host']}"
request_target = "(request-target): #{"post #{request.path}"}"
date = "date: #{request.headers['Date']}"
digest = "digest: #{request.headers['Digest']}"
"#{content_length}\n#{user_agent}\n#{content_type}\n#{host}\n#{request_target}\n#{date}\n#{digest}"
end
def provided_signature_base64
request.headers['Signature'].match(/signature="([^"]+)"/)[1]
end
end