diff --git a/Gemfile b/Gemfile
index ce1d73b..6b6f03f 100644
--- a/Gemfile
+++ b/Gemfile
@@ -5,4 +5,8 @@ gem "minitest"
gem "simplecov"
gem "simplecov-cobertura"
+# Ruby 3.1 resolves MultiJson 1.19, which cannot parse JSON 3 (ruby-sdk#89).
+# Keep its test bundle compatible without restricting consumers on newer Rubies.
+gem "json", "< 3.0" if RUBY_VERSION.start_with?("3.1.")
+
gemspec
diff --git a/README.md b/README.md
index 85d8118..e60d286 100644
--- a/README.md
+++ b/README.md
@@ -266,6 +266,25 @@ transloadit.assembly(
Read up more on the Notifications [on Transloadit's documentation page](https://transloadit.com/docs/#notifications)
+Verify a notification before trusting its status:
+
+```ruby
+# The original POST form values, without parsing or reserializing the JSON first.
+status = transloadit.verify_notification!(request.POST)
+assembly_id = status.fetch('assembly_id')
+```
+
+The client must have an Auth Secret; without one the helper raises
+`Transloadit::Exception::MissingSecret`, a configuration error rather than an
+authentication failure. This helper accepts string or symbol field
+keys and both the legacy SHA-1 signature and its `sha1:`-prefixed form. It compares
+the signature in constant time and raises `Transloadit::Exception::InvalidSignature`
+for missing, malformed, or mismatched authentication. A successfully authenticated
+body that contains invalid JSON raises `MultiJson::ParseError`.
+
+Authenticate and process notifications idempotently using your Assembly IDs;
+signature verification alone does not prevent a valid notification from being replayed.
+
### 7. Other Assembly methods
Transloadit also provides methods to retrieve/replay Assemblies and their Notifications.
diff --git a/lib/transloadit.rb b/lib/transloadit.rb
index a9f6f1f..88e5998 100644
--- a/lib/transloadit.rb
+++ b/lib/transloadit.rb
@@ -135,6 +135,37 @@ def to_json
MultiJson.dump(to_hash)
end
+ #
+ # Verifies an Assembly notification before parsing its status. Pass the received
+ # form fields unchanged: reserializing the JSON changes the signed bytes.
+ #
+ # @param [Hash] fields the transloadit and signature notification form fields
+ # @return [Hash] the verified Assembly status
+ # @raise [Transloadit::Exception::MissingSecret] if the client has no Auth Secret
+ # @raise [Transloadit::Exception::InvalidSignature] if authentication fails
+ # @raise [MultiJson::ParseError] if the authenticated body contains invalid JSON
+ #
+ def verify_notification!(fields)
+ raise Transloadit::Exception::MissingSecret unless secret.is_a?(String) && !secret.empty?
+ raise Transloadit::Exception::InvalidSignature unless fields.is_a?(Hash)
+
+ payload = fields["transloadit"] || fields[:transloadit]
+ signature = fields["signature"] || fields[:signature]
+ unless payload.is_a?(String) && signature.is_a?(String)
+ raise Transloadit::Exception::InvalidSignature
+ end
+
+ match = /\A(?:sha1:)?([0-9a-f]{40})\z/.match(signature)
+ raise Transloadit::Exception::InvalidSignature unless match
+
+ expected = OpenSSL::HMAC.hexdigest("SHA1", secret, payload)
+ unless OpenSSL.fixed_length_secure_compare(expected, match[1])
+ raise Transloadit::Exception::InvalidSignature
+ end
+
+ MultiJson.load(payload)
+ end
+
# @param workspace [String] Workspace slug
# @param template [String] Template slug or template ID
# @param input [String] Input value that is provided as `${fields.input}` in the template
diff --git a/lib/transloadit/exception.rb b/lib/transloadit/exception.rb
index 4071199..c34d303 100644
--- a/lib/transloadit/exception.rb
+++ b/lib/transloadit/exception.rb
@@ -8,6 +8,21 @@ module Transloadit::Exception
class RequestFailed < StandardError
end
+ # Exception raised when a notification is verified without a trusted Auth Secret.
+ # This is a configuration error, unlike a notification that fails authentication.
+ class MissingSecret < StandardError
+ def initialize
+ super("An Auth Secret is required to verify notifications")
+ end
+ end
+
+ # Exception raised when an Assembly notification cannot be authenticated.
+ class InvalidSignature < StandardError
+ def initialize
+ super("Invalid notification signature")
+ end
+ end
+
#
# Exception raised when Rate limit error response is returned from the API.
# See {Rate Limiting}[https://transloadit.com/docs/api-docs/#rate-limiting]
diff --git a/test/unit/test_transloadit.rb b/test/unit/test_transloadit.rb
index 4a07589..4eadf12 100644
--- a/test/unit/test_transloadit.rb
+++ b/test/unit/test_transloadit.rb
@@ -8,6 +8,93 @@
@max_size = 100
end
+ describe "notification verification" do
+ before do
+ @client = Transloadit.new(key: "workspace-key", secret: "notification-secret")
+ @payload = "{\n \"ok\": \"ASSEMBLY_COMPLETED\", \"assembly_id\": \"assembly-id\"\n}"
+ @signature = OpenSSL::HMAC.hexdigest("SHA1", @client.secret, @payload)
+ end
+
+ it "must verify prefixed signatures over the exact received JSON" do
+ status = @client.verify_notification!(
+ "transloadit" => @payload, "signature" => "sha1:#{@signature}"
+ )
+
+ _(status).must_equal "ok" => "ASSEMBLY_COMPLETED", "assembly_id" => "assembly-id"
+ end
+
+ it "must accept legacy signatures and symbol form-field keys" do
+ status = @client.verify_notification!(transloadit: @payload, signature: @signature)
+
+ _(status.fetch("assembly_id")).must_equal "assembly-id"
+ end
+
+ it "must reject a notification signed with a different secret" do
+ signature = OpenSSL::HMAC.hexdigest("SHA1", "other-secret", @payload)
+
+ assert_raises Transloadit::Exception::InvalidSignature do
+ @client.verify_notification!(transloadit: @payload, signature: signature)
+ end
+ end
+
+ it "must reject changed payload bytes even when the parsed status is identical" do
+ compact = MultiJson.dump(MultiJson.load(@payload))
+
+ assert_raises Transloadit::Exception::InvalidSignature do
+ @client.verify_notification!(transloadit: compact, signature: @signature)
+ end
+ end
+
+ it "must reject malformed signatures before comparing or parsing" do
+ assert_raises Transloadit::Exception::InvalidSignature do
+ @client.verify_notification!(transloadit: "invalid JSON", signature: "sha1:short")
+ end
+ assert_raises Transloadit::Exception::InvalidSignature do
+ @client.verify_notification!(transloadit: @payload, signature: "sha256:#{@signature}")
+ end
+ assert_raises Transloadit::Exception::InvalidSignature do
+ @client.verify_notification!(transloadit: @payload, signature: "g" * 40)
+ end
+ end
+
+ it "must reject missing or incorrectly typed fields" do
+ assert_raises(Transloadit::Exception::InvalidSignature) { @client.verify_notification!({}) }
+ assert_raises(Transloadit::Exception::InvalidSignature) { @client.verify_notification!(nil) }
+ assert_raises Transloadit::Exception::InvalidSignature do
+ @client.verify_notification!(transloadit: {}, signature: @signature)
+ end
+ assert_raises Transloadit::Exception::InvalidSignature do
+ @client.verify_notification!(transloadit: @payload, signature: nil)
+ end
+ end
+
+ it "must fail as a configuration error without an Auth Secret" do
+ client = Transloadit.new(key: "workspace-key")
+ error = assert_raises Transloadit::Exception::MissingSecret do
+ client.verify_notification!(transloadit: @payload, signature: @signature)
+ end
+
+ _(error.message).must_equal "An Auth Secret is required to verify notifications"
+ end
+
+ it "must report authentication failures without exposing notification contents" do
+ error = assert_raises Transloadit::Exception::InvalidSignature do
+ @client.verify_notification!(transloadit: @payload, signature: "0" * 40)
+ end
+
+ _(error.message).must_equal "Invalid notification signature"
+ end
+
+ it "must only parse malformed JSON after successfully verifying its signature" do
+ payload = "invalid JSON"
+ signature = OpenSSL::HMAC.hexdigest("SHA1", @client.secret, payload)
+
+ assert_raises MultiJson::ParseError do
+ @client.verify_notification!(transloadit: payload, signature: signature)
+ end
+ end
+ end
+
it "must allow initialization" do
t = Transloadit.new(key: @key, secret: @secret)
_(t).must_be_kind_of Transloadit