WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit 68a3a6e

Browse files
feat: add webhook verification (#46)
* feat: add webhook verification * update gemfile * wip * wip * wip * wip
1 parent 8651931 commit 68a3a6e

File tree

7 files changed

+254
-40
lines changed

7 files changed

+254
-40
lines changed

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
authsignal-ruby (5.1.1)
4+
authsignal-ruby (5.2.0)
55
faraday (>= 2.0.1)
66
faraday-retry (~> 2.2)
77

README.md

Lines changed: 34 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
# Authsignal Ruby SDK
44

5-
The Authsignal Ruby library for server-side applications.
5+
[![Gem Version](https://img.shields.io/gem/v/authsignal-ruby.svg)](https://rubygems.org/gems/authsignal-ruby)
6+
[![License](https://img.shields.io/github/license/authsignal/authsignal-ruby.svg)](https://github.com/authsignal/authsignal-ruby/blob/main/LICENSE.txt)
7+
8+
The official Authsignal Ruby library for server-side applications. Use this SDK to easily integrate Authsignal's multi-factor authentication (MFA) and passwordless features into your Ruby backend.
69

710
## Installation
811

@@ -12,48 +15,42 @@ Add this line to your application's Gemfile:
1215
gem "authsignal-ruby"
1316
```
1417

15-
## Documentation
16-
17-
Refer to our [SDK documentation](https://docs.authsignal.com/sdks/server/overview) for information on how to use this SDK.
18-
19-
Or check out our [Ruby on Rails Quickstart Guide](https://docs.authsignal.com/integrations/ruby-on-rails).
18+
And then execute:
19+
```bash
20+
bundle install
21+
```
2022

21-
### Response & Error handling
23+
Or install it yourself as:
24+
```bash
25+
gem install authsignal-ruby
26+
```
2227

23-
The Authsignal SDK offers two response formats. By default, its methods return the payload in hash format.
28+
## Getting started
2429

25-
Example:
30+
Initialize the Authsignal client with your secret key from the [Authsignal Portal](https://portal.authsignal.com/) and the API URL for your region.
2631

2732
```ruby
28-
Authsignal.enroll_verified_authenticator(
29-
user_id: 'AS_001',
30-
attributes: {
31-
verification_method: 'INVALID',
32-
33-
}
34-
)
35-
36-
# returns:
37-
{
38-
"error": "invalid_request",
39-
"errorCode": "invalid_request",
40-
"errorDescription": "body.verificationMethod must be equal to one of the allowed values - allowedValues: AUTHENTICATOR_APP,EMAIL_MAGIC_LINK,EMAIL_OTP,SMS"
41-
}
33+
require 'authsignal'
34+
35+
# Initialize the client
36+
Authsignal.setup do |config|
37+
config.api_secret_key = ENV['AUTHSIGNAL_SECRET_KEY']
38+
config.api_url = ENV['AUTHSIGNAL_API_URL'] # Use region-specific URL
39+
end
4240
```
4341

44-
All methods have a bang (!) counterpart that raises an Authsignal::ApiError if the request fails.
42+
### API URLs by region
4543

46-
Example:
44+
| Region | API URL |
45+
| ----------- | -------------------------------- |
46+
| US (Oregon) | https://api.authsignal.com/v1 |
47+
| AU (Sydney) | https://au.api.authsignal.com/v1 |
48+
| EU (Dublin) | https://eu.api.authsignal.com/v1 |
4749

48-
```ruby
49-
Authsignal.enroll_verified_authenticator!(
50-
user_id: 'AS_001',
51-
attributes: {
52-
verification_method: 'INVALID',
53-
54-
}
55-
)
56-
57-
# raise:
58-
<Authsignal::ApiError: AuthsignalError: 400 - body.verificationMethod must be equal to one of the allowed values - allowedValues: AUTHENTICATOR_APP,EMAIL_MAGIC_LINK,EMAIL_OTP,SMS.
59-
```
50+
## License
51+
52+
This SDK is licensed under the [MIT License](LICENSE.txt).
53+
54+
## Documentation
55+
56+
For more information and advanced usage examples, refer to the official [Authsignal server-Side SDK documentation](https://docs.authsignal.com/sdks/server/overview).

lib/authsignal.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
require "authsignal/client"
55
require "authsignal/configuration"
66
require "authsignal/api_error"
7+
require "authsignal/invalid_signature_error"
8+
require "authsignal/webhook"
79
require "authsignal/middleware/json_response"
810
require "authsignal/middleware/json_request"
911

1012
module Authsignal
11-
NON_API_METHODS = [:setup, :configuration, :default_configuration]
13+
NON_API_METHODS = [:setup, :configuration, :default_configuration, :webhook]
1214

1315
class << self
1416
attr_writer :configuration
@@ -25,6 +27,10 @@ def default_configuration
2527
configuration.defaults
2628
end
2729

30+
def webhook
31+
@webhook ||= Webhook.new(configuration.api_secret_key)
32+
end
33+
2834
def get_user(user_id:)
2935
response = Client.new.get_user(user_id: user_id)
3036

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module Authsignal
2+
class InvalidSignatureError < StandardError
3+
def initialize(message)
4+
super(message)
5+
end
6+
end
7+
end

lib/authsignal/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
22

33
module Authsignal
4-
VERSION = "5.1.1"
4+
VERSION = "5.2.0"
55
end

lib/authsignal/webhook.rb

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
require 'openssl'
2+
require 'json'
3+
require 'base64'
4+
5+
module Authsignal
6+
DEFAULT_TOLERANCE = 5
7+
8+
class Webhook
9+
VERSION = "v2"
10+
11+
attr_reader :api_secret_key
12+
13+
def initialize(api_secret_key)
14+
@api_secret_key = api_secret_key
15+
end
16+
17+
def construct_event(payload, signature, tolerance = DEFAULT_TOLERANCE)
18+
parsed_signature = parse_signature(signature)
19+
20+
seconds_since_epoch = Time.now.to_i
21+
22+
if tolerance > 0 && parsed_signature[:timestamp] < seconds_since_epoch - (tolerance * 60)
23+
raise InvalidSignatureError, "Timestamp is outside the tolerance zone."
24+
end
25+
26+
hmac_content = "#{parsed_signature[:timestamp]}.#{payload}"
27+
28+
computed_signature = OpenSSL::HMAC.digest(
29+
OpenSSL::Digest.new('sha256'),
30+
@api_secret_key,
31+
hmac_content
32+
)
33+
computed_signature_base64 = Base64.strict_encode64(computed_signature).delete('=')
34+
35+
match = false
36+
37+
parsed_signature[:signatures].each do |sig|
38+
if sig == computed_signature_base64
39+
match = true
40+
break
41+
end
42+
end
43+
44+
unless match
45+
raise InvalidSignatureError, "Signature mismatch."
46+
end
47+
48+
JSON.parse(payload, symbolize_names: true)
49+
end
50+
51+
def parse_signature(value)
52+
result = {
53+
timestamp: -1,
54+
signatures: []
55+
}
56+
57+
return handle_invalid_signature unless value
58+
59+
value.split(',').each do |item|
60+
kv = item.split('=')
61+
next unless kv.length == 2
62+
63+
if kv[0] == 't'
64+
result[:timestamp] = kv[1].to_i
65+
elsif kv[0] == VERSION
66+
result[:signatures] << kv[1]
67+
end
68+
end
69+
70+
if result[:timestamp] == -1 || result[:signatures].empty?
71+
handle_invalid_signature
72+
end
73+
74+
result
75+
end
76+
77+
private
78+
79+
def handle_invalid_signature
80+
raise InvalidSignatureError, "Signature format is invalid."
81+
end
82+
end
83+
end

spec/authsignal/webhook_spec.rb

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
require 'json'
2+
require 'openssl'
3+
require 'base64'
4+
5+
RSpec.describe Authsignal::Webhook do
6+
let(:api_url) { ENV['AUTHSIGNAL_API_URL'] }
7+
let(:api_secret_key) { ENV['AUTHSIGNAL_API_SECRET_KEY'] }
8+
9+
before do
10+
raise "AUTHSIGNAL_API_URL is undefined in env" unless api_url
11+
raise "AUTHSIGNAL_API_SECRET_KEY is undefined in env" unless api_secret_key
12+
13+
Authsignal.setup do |config|
14+
config.api_secret_key = api_secret_key
15+
config.api_url = api_url
16+
end
17+
end
18+
19+
describe "webhook verification" do
20+
it "raises error for invalid signature format" do
21+
payload = JSON.generate({})
22+
signature = "123"
23+
24+
expect {
25+
Authsignal.webhook.construct_event(payload, signature)
26+
}.to raise_error(Authsignal::InvalidSignatureError, "Signature format is invalid.")
27+
end
28+
29+
it "raises error for timestamp outside tolerance zone" do
30+
payload = JSON.generate({})
31+
signature = "t=1630000000,v2=invalid_signature"
32+
33+
expect {
34+
Authsignal.webhook.construct_event(payload, signature)
35+
}.to raise_error(Authsignal::InvalidSignatureError, "Timestamp is outside the tolerance zone.")
36+
end
37+
38+
it "raises error for invalid computed signature" do
39+
payload = JSON.generate({})
40+
timestamp = Time.now.to_i
41+
signature = "t=#{timestamp},v2=invalid_signature"
42+
43+
expect {
44+
Authsignal.webhook.construct_event(payload, signature)
45+
}.to raise_error(Authsignal::InvalidSignatureError, "Signature mismatch.")
46+
end
47+
48+
it "validates a valid signature" do
49+
payload = JSON.generate({
50+
version: 1,
51+
id: "bc1598bc-e5d6-4c69-9afb-1a6fe3469d6e",
52+
source: "https://authsignal.com",
53+
time: "2025-02-20T01:51:56.070Z",
54+
tenantId: "7752d28e-e627-4b1b-bb81-b45d68d617bc",
55+
type: "email.created",
56+
data: {
57+
58+
code: "157743",
59+
userId: "b9f74d36-fcfc-4efc-87f1-3664ab5a7fb0",
60+
actionCode: "accountRecovery",
61+
idempotencyKey: "ba8c1a7c-775d-4dff-9abe-be798b7b8bb9",
62+
verificationMethod: "EMAIL_OTP"
63+
}
64+
})
65+
66+
tolerance = -1
67+
timestamp = Time.now.to_i
68+
69+
hmac_content = "#{timestamp}.#{payload}"
70+
computed_signature = OpenSSL::HMAC.digest(
71+
OpenSSL::Digest.new('sha256'),
72+
api_secret_key,
73+
hmac_content
74+
)
75+
signature_b64 = Base64.strict_encode64(computed_signature).delete('=')
76+
signature = "t=#{timestamp},v2=#{signature_b64}"
77+
78+
event = Authsignal.webhook.construct_event(payload, signature, tolerance)
79+
80+
expect(event).not_to be_nil
81+
expect(event[:version]).to eq(1)
82+
expect(event[:data][:actionCode]).to eq("accountRecovery")
83+
end
84+
85+
it "validates a signature when 2 API keys are active" do
86+
payload = JSON.generate({
87+
version: 1,
88+
id: "af7be03c-ea8f-4739-b18e-8b48fcbe4e38",
89+
source: "https://authsignal.com",
90+
time: "2025-02-20T01:47:17.248Z",
91+
tenantId: "7752d28e-e627-4b1b-bb81-b45d68d617bc",
92+
type: "email.created",
93+
data: {
94+
95+
code: "718190",
96+
userId: "b9f74d36-fcfc-4efc-87f1-3664ab5a7fb0",
97+
actionCode: "accountRecovery",
98+
idempotencyKey: "68d68190-fac9-4e91-b277-c63d31d3c6b1",
99+
verificationMethod: "EMAIL_OTP"
100+
}
101+
})
102+
103+
tolerance = -1
104+
timestamp = Time.now.to_i
105+
106+
hmac_content = "#{timestamp}.#{payload}"
107+
computed_signature = OpenSSL::HMAC.digest(
108+
OpenSSL::Digest.new('sha256'),
109+
api_secret_key,
110+
hmac_content
111+
)
112+
signature_b64 = Base64.strict_encode64(computed_signature).delete('=')
113+
114+
signature = "t=#{timestamp},v2=#{signature_b64},v2=oldKeySignature123"
115+
116+
event = Authsignal.webhook.construct_event(payload, signature, tolerance)
117+
118+
expect(event).not_to be_nil
119+
end
120+
end
121+
end

0 commit comments

Comments
 (0)