Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 101 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,31 +168,123 @@ claims = {
token = Vonage::JWT.generate(claims)

client = Vonage::Client.new(token: token)
````
```

Documentation for the Vonage Ruby JWT generator gem can be found at
[https://www.rubydoc.info/github/nexmo/nexmo-jwt-ruby](https://www.rubydoc.info/github/nexmo/nexmo-jwt-ruby).
The documentation outlines all the possible parameters you can use to customize and build a token with.

## Webhook signatures

To check webhook signatures you'll also need to specify the `signature_secret` option. For example:
Certain Vonage APIs provide signed [webhooks](https://developer.vonage.com/en/getting-started/concepts/webhooks) as a means of verifying the origin of the webhooks. The exact signing mechanism varies depending on the API.

### Signature in Request Body

The [SMS API](https://developer.vonage.com/en/messaging/sms/overview) signs the webhook request using a hash digest. This is assigned to a `sig` parameter in the request body.

You can verify the webhook request using the `Vonage::SMS#verify_webhook_sig` method. As well as the **request params** from the received webhook, the method also needs access to the signature secret associated with the Vonage account (available from the [Vonage Dashboard](https://dashboard.nexmo.com/settings)), and the signature method used for signing (e.g. `sha512`), again this is based on thes setting in the Dashboard.

There are a few different ways of providing these values to the method:

1. Pass all values to the method invocation.

```ruby
client = Vonage::Client.new

client.sms.verify_webhook_sig(
webhook_params: params,
signature_secret: 'secret',
signature_method: 'sha512'
) # => returns true if the signature is valid, false otherwise
```

2. Set `signature_secret` and `signature_method` at `Client` instantiation.

```ruby
client = Vonage::Client.new(
signature_secret: 'secret',
signature_method: 'sha512'
)

client.sms.verify_webhook_sig(webhook_params: params) # => returns true if the signature is valid, false otherwise
```

3. Set `signature_secret` and `signature_method` on the `Config` object.

```ruby
client = Vonage::Client.new
client.config.signature_secret = 'secret'
client.config.signature_method = 'sha512'

if client.signature.check(request.GET)
# valid signature
else
# invalid signature
end
client.sms.verify_webhook_sig(webhook_params: params) # => returns true if the signature is valid, false otherwise
```

4. Set `signature_secret` and `signature_method` as environment variables named `VONAGE_SIGNATURE_SECRET` and `VONAGE_SIGNATURE_METHOD`

```ruby
client = Vonage::Client.new

client.sms.verify_webhook_sig(webhook_params: params) # => returns true if the signature is valid, false otherwise
```

**Note:** Webhook signing for the SMS API is not switched on by default. You'll need to contact support@vonage.com to enable message signing on your account.

### Signed JWT in Header

The [Voice API](https://developer.vonage.com/en/voice/voice-api/overview) and [Messages API](https://developer.vonage.com/en/messages/overview) both include an `Authorization` header in their webhook requests. The value of this header includes a JSON Web Token (JWT) signed using the Signature Secret associated with your Vonage account.

The `Vonage::Voice` and `Vonage::Messaging` classes both define a `verify_webhook_token` method which can be used to verify the JWT received in the webhook `Authorization` header.

To verify the JWT, you'll first need to extract it from the `Authorization` header. The header value will look something like the following:

```ruby
"Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE1OTUyN" # remainder of token omitted for brevity
```

Note: we are only interested in the token itself, which comes *after* the word `Bearer` and the space.

Once you have extrated the token, you can pass it to the `verify_webhook_token` method in order to verify it.

The method also needs access to the the method also needs access to the signature secret associated with the Vonage account (available from the [Vonage Dashboard](https://dashboard.nexmo.com/settings)). There are a few different ways of providing this value to the method:

1. Pass all values to the method invocation.

```ruby
client = Vonage::Client.new

client.voice.verify_webhook_token(
token: extracted_token,
signature_secret: 'secret'
) # => returns true if the token is valid, false otherwise
```

Alternatively you can set the `VONAGE_SIGNATURE_SECRET` environment variable.
2. Set `signature_secret` at `Client` instantiation.

```ruby
client = Vonage::Client.new(
signature_secret: 'secret'
)

Note: you'll need to contact support@nexmo.com to enable message signing on your account.
client.voice.verify_webhook_token(token: extracted_token) # => returns true if the token is valid, false otherwise
```

3. Set `signature_secret` on the `Config` object.

```ruby
client = Vonage::Client.new
client.config.signature_secret = 'secret'
client.config.signature_method = 'sha512'

client.voice.verify_webhook_token(token: extracted_token) # => returns true if the token is valid, false otherwise
```

4. Set `signature_secret` as an environment variable named `VONAGE_SIGNATURE_SECRET`

```ruby
client = Vonage::Client.new

client.voice.verify_webhook_token(token: extracted_token) # => returns true if the token is valid, false otherwise
```

## Pagination

Expand Down
17 changes: 17 additions & 0 deletions lib/vonage/jwt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,22 @@ def self.generate(payload, private_key = nil)
payload[:private_key] = private_key if private_key && !payload[:private_key]
@token = Vonage::JWTBuilder.new(payload).jwt.generate
end

# Validate a JSON Web Token from a Vonage Webhook.
#
# Certain Vonage APIs include a JWT signed with a user's account signature secret in
# the Authorization header of Webhook requests. This method can be used to verify that those requests originate
# from the Vonage API.
#
# @param [String, required] :token The JWT from the Webhook's Authorization header
# @param [String, required] :signature_secret The account signature secret
#
# @return [Boolean] true, if the JWT is verified, false otherwise
#
# @see https://developer.vonage.com/en/getting-started/concepts/webhooks#decoding-signed-webhooks
#
def self.verify_hs256_signature(token:, signature_secret:)
verify_signature(token, signature_secret, 'HS256')
end
end
end
11 changes: 11 additions & 0 deletions lib/vonage/messaging.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,16 @@ class Messaging < Namespace
def send(params)
request('/v1/messages', params: params, type: Post)
end

# Validate a JSON Web Token from a Messages API Webhook.
#
# @param [String, required] :token The JWT from the Webhook's Authorization header
# @param [String, optional] :signature_secret The account signature secret. Required, unless `signature_secret`
# is set in `Config`
#
# @return [Boolean] true, if the JWT is verified, false otherwise
def verify_webhook_token(token:, signature_secret: @config.signature_secret)
JWT.verify_hs256_signature(token: token, signature_secret: signature_secret)
end
end
end
10 changes: 5 additions & 5 deletions lib/vonage/signature.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,24 +27,24 @@ def initialize(config)
#
# @see https://developer.nexmo.com/concepts/guides/signing-messages
#
def check(params, signature_method: @config.signature_method)
def check(params, signature_secret: @config.signature_secret, signature_method: @config.signature_method)
params = params.dup

signature = params.delete('sig')

::JWT::Algos::Hmac::SecurityUtils.secure_compare(signature, digest(params, signature_method))
::JWT::Algos::Hmac::SecurityUtils.secure_compare(signature, digest(params, signature_secret, signature_method))
end

private

def digest(params, signature_method)
def digest(params, signature_secret, signature_method)
digest_string = params.sort.map { |k, v| "&#{k}=#{v.tr('&=', '_')}" }.join

case signature_method
when 'md5', 'sha1', 'sha256', 'sha512'
OpenSSL::HMAC.hexdigest(signature_method, @config.signature_secret, digest_string).upcase
OpenSSL::HMAC.hexdigest(signature_method, signature_secret, digest_string).upcase
when 'md5hash'
Digest::MD5.hexdigest("#{digest_string}#{@config.signature_secret}")
Digest::MD5.hexdigest("#{digest_string}#{signature_secret}")
else
raise ArgumentError, "Unknown signature algorithm: #{signature_method}. Expected: md5hash, md5, sha1, sha256, or sha512."
end
Expand Down
20 changes: 20 additions & 0 deletions lib/vonage/sms.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,31 @@ def send(params)
response
end

# Validate a Signature from an SMS API Webhook.
#
# @param [Hash, required] :webhook_params The parameters from the webhook request body
# @param [String, optional] :signature_secret The account signature secret. Required, unless `signature_secret`
# is set in `Config`
# @param [String, optional] :signature_method The account signature method. Required, unless `signature_method`
# is set in `Config`
#
# @return [Boolean] true, if the JWT is verified, false otherwise
def verify_webhook_sig(webhook_params:, signature_secret: @config.signature_secret, signature_method: @config.signature_method)
signature.check(webhook_params, signature_secret: signature_secret, signature_method: signature_method)
end

private

sig { params(text: String).returns(T::Boolean) }
def unicode?(text)
!Vonage::GSM7.encoded?(text)
end

# @return [Signature]
#
sig { returns(T.nilable(Vonage::Signature)) }
def signature
@signature ||= T.let(Signature.new(@config), T.nilable(Vonage::Signature))
end
end
end
11 changes: 11 additions & 0 deletions lib/vonage/voice.rb
Original file line number Diff line number Diff line change
Expand Up @@ -279,5 +279,16 @@ def talk
def dtmf
@dtmf ||= DTMF.new(@config)
end

# Validate a JSON Web Token from a Voice API Webhook.
#
# @param [String, required] :token The JWT from the Webhook's Authorization header
# @param [String, optional] :signature_secret The account signature secret. Required, unless `signature_secret`
# is set in `Config`
#
# @return [Boolean] true, if the JWT is verified, false otherwise
def verify_webhook_token(token:, signature_secret: @config.signature_secret)
JWT.verify_hs256_signature(token: token, signature_secret: signature_secret)
end
end
end
14 changes: 13 additions & 1 deletion test/vonage/jwt_test.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# typed: false
require_relative './test'

class Vonage::JWTTest < Minitest::Test
class Vonage::JWTTest < Vonage::Test
def private_key
@private_key ||= File.read('test/private_key.txt')
end
Expand Down Expand Up @@ -111,4 +111,16 @@ def test_no_exception_with_private_key_in_second_argument

assert token
end

def test_verify_hs256_signature_with_valid_secret
verification = Vonage::JWT.verify_hs256_signature(token: sample_webhook_token, signature_secret: sample_valid_signature_secret)

assert_equal(true, verification)
end

def test_verify_hs256_signature_with_invalid_secret
verification = Vonage::JWT.verify_hs256_signature(token: sample_webhook_token, signature_secret: sample_invalid_signature_secret)

assert_equal(false, verification)
end
end
25 changes: 24 additions & 1 deletion test/vonage/messaging_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,30 @@ def test_send_method
stub_request(:post, messaging_uri).with(request(body: params)).to_return(response)

message = Vonage::Messaging::Message.sms(message: "Hello world!")

assert_kind_of Vonage::Response, messaging.send(to: "447700900000", from: "447700900001", **message)
end

def test_verify_webhook_token_method_with_valid_secret_passed_in
verification = messaging.verify_webhook_token(token: sample_webhook_token, signature_secret: sample_valid_signature_secret)

assert_equal(true, verification)
end

def test_verify_webhook_token_method_with_valid_secret_in_config
config.signature_secret = sample_valid_signature_secret
verification = messaging.verify_webhook_token(token: sample_webhook_token)

assert_equal(true, verification)
end

def test_verify_webhook_token_method_with_invalid_secret
verification = messaging.verify_webhook_token(token: sample_webhook_token, signature_secret: sample_invalid_signature_secret)

assert_equal(false, verification)
end

def test_verify_webhook_token_method_with_no_token
assert_raises(ArgumentError) { messaging.verify_webhook_token }
end
end
23 changes: 23 additions & 0 deletions test/vonage/sms_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ def error_response
}
end

def signed_webhook_params
{
'message-timestamp' => '2013-11-21 15:27:30',
'messageId' => '020000001B0FE827',
'msisdn' => '14843472194',
'text' => 'Test again',
'timestamp' => '1385047698',
'to' => '13239877404',
'type' => 'text',
'sig' => 'd2e7b1dc968737c5998ad624e02f90b7'
}
end

def test_send_method
params = {from: 'Ruby', to: msisdn, text: 'Hello from Ruby!'}

Expand Down Expand Up @@ -60,4 +73,14 @@ def test_warn_when_sending_unicode_without_type

assert_includes io.string, 'WARN -- : Sending unicode text SMS without setting the type parameter'
end

def test_verify_webhook_sig_method
check = sms.verify_webhook_sig(
webhook_params: signed_webhook_params,
signature_secret: 'my_secret_key_for_testing',
signature_method: 'md5hash'
)

assert_equal(true, check)
end
end
12 changes: 12 additions & 0 deletions test/vonage/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ def private_key
)
end

def sample_webhook_token
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1ODc0OTQ5NjIsImp0aSI6ImM1YmE4ZjI0LTFhMTQtNGMxMC1iZmRmLTNmYmU4Y2U1MTFiNSIsImlzcyI6IlZvbmFnZSIsInBheWxvYWRfaGFzaCI6ImQ2YzBlNzRiNTg1N2RmMjBlM2I3ZTUxYjMwYzBjMmE0MGVjNzNhNzc4NzliNmYwNzRkZGM3YTIzMTdkZDAzMWIiLCJhcGlfa2V5IjoiYTFiMmMzZCIsImFwcGxpY2F0aW9uX2lkIjoiYWFhYWFhYWEtYmJiYi1jY2NjLWRkZGQtMDEyMzQ1Njc4OWFiIn0.JQRKi1d0SQitmjPINfTWMpt3XZkGsLbD7EjCdXoNSbk"
end

def sample_valid_signature_secret
"ZYtdTtGV3BCFN7tWmOWr1md66XsquMggr4W2cTtXtcPgfnI0Xw"
end

def sample_invalid_signature_secret
"ZYtdTtGV3BCFN7tWmOWr1md66XsquMggr4W2cTtXtcPgf55555"
end

def config
@config ||=
Vonage::Config.new.merge(
Expand Down
23 changes: 23 additions & 0 deletions test/vonage/voice_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,27 @@ def test_talk_method
def test_dtmf_method
assert_kind_of Vonage::Voice::DTMF, calls.dtmf
end

def test_verify_webhook_token_method_with_valid_secret_passed_in
verification = calls.verify_webhook_token(token: sample_webhook_token, signature_secret: sample_valid_signature_secret)

assert_equal(true, verification)
end

def test_verify_webhook_token_method_with_valid_secret_in_config
config.signature_secret = sample_valid_signature_secret
verification = calls.verify_webhook_token(token: sample_webhook_token)

assert_equal(true, verification)
end

def test_verify_webhook_token_method_with_invalid_secret
verification = calls.verify_webhook_token(token: sample_webhook_token, signature_secret: sample_invalid_signature_secret)

assert_equal(false, verification)
end

def test_verify_webhook_token_method_with_no_token
assert_raises(ArgumentError) { calls.verify_webhook_token }
end
end
Loading