attio-ruby 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +164 -0
  4. data/.simplecov +17 -0
  5. data/.yardopts +9 -0
  6. data/CHANGELOG.md +27 -0
  7. data/CONTRIBUTING.md +333 -0
  8. data/INTEGRATION_TEST_STATUS.md +149 -0
  9. data/LICENSE +21 -0
  10. data/README.md +638 -0
  11. data/Rakefile +8 -0
  12. data/attio-ruby.gemspec +61 -0
  13. data/docs/CODECOV_SETUP.md +34 -0
  14. data/examples/basic_usage.rb +149 -0
  15. data/examples/oauth_flow.rb +843 -0
  16. data/examples/oauth_flow_README.md +84 -0
  17. data/examples/typed_records_example.rb +167 -0
  18. data/examples/webhook_server.rb +463 -0
  19. data/lib/attio/api_resource.rb +539 -0
  20. data/lib/attio/builders/name_builder.rb +181 -0
  21. data/lib/attio/client.rb +160 -0
  22. data/lib/attio/errors.rb +126 -0
  23. data/lib/attio/internal/record.rb +359 -0
  24. data/lib/attio/oauth/client.rb +219 -0
  25. data/lib/attio/oauth/scope_validator.rb +162 -0
  26. data/lib/attio/oauth/token.rb +158 -0
  27. data/lib/attio/resources/attribute.rb +332 -0
  28. data/lib/attio/resources/comment.rb +114 -0
  29. data/lib/attio/resources/company.rb +224 -0
  30. data/lib/attio/resources/entry.rb +208 -0
  31. data/lib/attio/resources/list.rb +196 -0
  32. data/lib/attio/resources/meta.rb +113 -0
  33. data/lib/attio/resources/note.rb +213 -0
  34. data/lib/attio/resources/object.rb +66 -0
  35. data/lib/attio/resources/person.rb +294 -0
  36. data/lib/attio/resources/task.rb +147 -0
  37. data/lib/attio/resources/thread.rb +99 -0
  38. data/lib/attio/resources/typed_record.rb +98 -0
  39. data/lib/attio/resources/webhook.rb +224 -0
  40. data/lib/attio/resources/workspace_member.rb +136 -0
  41. data/lib/attio/util/configuration.rb +166 -0
  42. data/lib/attio/util/id_extractor.rb +115 -0
  43. data/lib/attio/util/webhook_signature.rb +175 -0
  44. data/lib/attio/version.rb +6 -0
  45. data/lib/attio/webhook/event.rb +114 -0
  46. data/lib/attio/webhook/signature_verifier.rb +73 -0
  47. data/lib/attio.rb +123 -0
  48. metadata +402 -0
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attio
4
+ # Utility classes for the Attio gem
5
+ module Util
6
+ # Configuration management for the Attio gem
7
+ class Configuration
8
+ # Raised when configuration validation fails
9
+ class ConfigurationError < ::Attio::InvalidRequestError; end
10
+
11
+ # Settings that must be configured
12
+ REQUIRED_SETTINGS = %i[api_key].freeze
13
+ # Optional settings with defaults
14
+ OPTIONAL_SETTINGS = %i[
15
+ api_base
16
+ api_version
17
+ timeout
18
+ open_timeout
19
+ max_retries
20
+ logger
21
+ debug
22
+ ca_bundle_path
23
+ verify_ssl_certs
24
+ use_faraday
25
+ ].freeze
26
+
27
+ # All available configuration settings
28
+ ALL_SETTINGS = (REQUIRED_SETTINGS + OPTIONAL_SETTINGS).freeze
29
+
30
+ # Default values for optional settings
31
+ DEFAULT_SETTINGS = {
32
+ api_base: "https://api.attio.com",
33
+ api_version: "v2",
34
+ timeout: 30,
35
+ open_timeout: 10,
36
+ max_retries: 3,
37
+ logger: nil,
38
+ debug: false,
39
+ ca_bundle_path: nil,
40
+ verify_ssl_certs: true,
41
+ use_faraday: true
42
+ }.freeze
43
+
44
+ attr_reader(*ALL_SETTINGS)
45
+
46
+ def initialize
47
+ @mutex = Mutex.new
48
+ @configured = false
49
+ reset_without_lock!
50
+ end
51
+
52
+ # Reset configuration to defaults
53
+ # @return [void]
54
+ def reset!
55
+ @mutex.synchronize do
56
+ reset_without_lock!
57
+ @configured = false
58
+ end
59
+ end
60
+
61
+ def configure
62
+ raise ConfigurationError, "Configuration has already been finalized" if frozen?
63
+
64
+ @mutex.synchronize do
65
+ yield(self) if block_given?
66
+ validate!
67
+ @configured = true
68
+ end
69
+ end
70
+
71
+ # Call this to make configuration immutable
72
+ def finalize!
73
+ @mutex.synchronize do
74
+ validate!
75
+ freeze unless frozen?
76
+ end
77
+ end
78
+
79
+ def validate!
80
+ REQUIRED_SETTINGS.each do |setting|
81
+ value = instance_variable_get("@#{setting}")
82
+ if value.nil? || (value.respond_to?(:empty?) && value.empty?)
83
+ raise ConfigurationError, "#{setting} must be configured"
84
+ end
85
+ end
86
+
87
+ raise ConfigurationError, "timeout must be positive" if @timeout && @timeout <= 0
88
+ raise ConfigurationError, "open_timeout must be positive" if @open_timeout && @open_timeout <= 0
89
+ raise ConfigurationError, "max_retries must be non-negative" if @max_retries&.negative?
90
+
91
+ true
92
+ end
93
+
94
+ # Convert configuration to hash
95
+ # @return [Hash] Configuration settings as a hash
96
+ def to_h
97
+ ALL_SETTINGS.each_with_object({}) do |setting, hash|
98
+ hash[setting] = instance_variable_get("@#{setting}")
99
+ end
100
+ end
101
+
102
+ def apply_env_vars!
103
+ raise ConfigurationError, "Cannot modify frozen configuration" if frozen?
104
+
105
+ @mutex.synchronize do
106
+ @api_key = ENV.fetch("ATTIO_API_KEY", @api_key)
107
+ @api_base = ENV.fetch("ATTIO_API_BASE", @api_base)
108
+ @api_version = ENV.fetch("ATTIO_API_VERSION", @api_version)
109
+ @timeout = ENV.fetch("ATTIO_TIMEOUT", @timeout).to_i if ENV.key?("ATTIO_TIMEOUT")
110
+ @open_timeout = ENV.fetch("ATTIO_OPEN_TIMEOUT", @open_timeout).to_i if ENV.key?("ATTIO_OPEN_TIMEOUT")
111
+ @max_retries = ENV.fetch("ATTIO_MAX_RETRIES", @max_retries).to_i if ENV.key?("ATTIO_MAX_RETRIES")
112
+ @debug = ENV.fetch("ATTIO_DEBUG", @debug).to_s.downcase == "true" if ENV.key?("ATTIO_DEBUG")
113
+ @ca_bundle_path = ENV.fetch("ATTIO_CA_BUNDLE_PATH", @ca_bundle_path) if ENV.key?("ATTIO_CA_BUNDLE_PATH")
114
+ @verify_ssl_certs = ENV.fetch("ATTIO_VERIFY_SSL_CERTS", @verify_ssl_certs).to_s.downcase != "false" if ENV.key?("ATTIO_VERIFY_SSL_CERTS")
115
+ @use_faraday = ENV.fetch("ATTIO_USE_FARADAY", @use_faraday).to_s.downcase != "false" if ENV.key?("ATTIO_USE_FARADAY")
116
+
117
+ if ENV.key?("ATTIO_LOGGER")
118
+ logger_class = ENV["ATTIO_LOGGER"]
119
+ @logger = (logger_class == "STDOUT") ? Logger.new($stdout) : nil
120
+ end
121
+ end
122
+ end
123
+
124
+ # Create a new configuration with merged options
125
+ # @param options [Hash] Options to merge
126
+ # @return [Configuration] New configuration instance
127
+ def merge(options)
128
+ dup.tap do |config|
129
+ options.each do |key, value|
130
+ if ALL_SETTINGS.include?(key.to_sym)
131
+ config.instance_variable_set("@#{key}", value)
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ # Create a duplicate configuration
138
+ # @return [Configuration] Duplicate configuration instance
139
+ def dup
140
+ self.class.new.tap do |config|
141
+ ALL_SETTINGS.each do |setting|
142
+ config.instance_variable_set("@#{setting}", instance_variable_get("@#{setting}"))
143
+ end
144
+ end
145
+ end
146
+
147
+ # Setters - only work before configuration is frozen
148
+ ALL_SETTINGS.each do |setting|
149
+ define_method("#{setting}=") do |value|
150
+ raise ConfigurationError, "Cannot modify frozen configuration" if frozen?
151
+ # Don't synchronize here - it's already synchronized in configure
152
+ instance_variable_set("@#{setting}", value)
153
+ end
154
+ end
155
+
156
+ private
157
+
158
+ def reset_without_lock!
159
+ DEFAULT_SETTINGS.each do |key, value|
160
+ instance_variable_set("@#{key}", value)
161
+ end
162
+ @api_key = nil
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attio
4
+ module Util
5
+ # Centralized ID extraction utility for handling various ID formats
6
+ # across different Attio resources
7
+ class IdExtractor
8
+ class << self
9
+ # Extract an ID from various formats
10
+ # @param id [String, Hash, nil] The ID in various formats
11
+ # @param key [Symbol, String] The key to extract from a hash ID
12
+ # @return [String, nil] The extracted ID
13
+ def extract(id, key = nil)
14
+ return nil if id.nil?
15
+
16
+ case id
17
+ when String
18
+ id
19
+ when Hash
20
+ extract_from_hash(id, key)
21
+ else
22
+ id.to_s if id.respond_to?(:to_s)
23
+ end
24
+ end
25
+
26
+ # Extract a specific ID type from a potentially nested structure
27
+ # @param id [String, Hash, nil] The ID structure
28
+ # @param resource_type [Symbol] The resource type (:record, :webhook, :attribute, etc.)
29
+ # @return [String, nil] The extracted ID
30
+ def extract_for_resource(id, resource_type)
31
+ return nil if id.nil?
32
+
33
+ key = resource_key_map[resource_type]
34
+ return id if id.is_a?(String) && key.nil?
35
+
36
+ extract(id, key)
37
+ end
38
+
39
+ # Normalize an ID structure to a consistent format
40
+ # @param id [String, Hash, nil] The ID to normalize
41
+ # @param resource_type [Symbol] The resource type
42
+ # @return [Hash, String, nil] The normalized ID
43
+ def normalize(id, resource_type)
44
+ return nil if id.nil?
45
+
46
+ extracted = extract_for_resource(id, resource_type)
47
+ return nil if extracted.nil?
48
+
49
+ # For resources that need hash format
50
+ if hash_format_resources.include?(resource_type)
51
+ key = resource_key_map[resource_type]
52
+ key ? {key => extracted} : extracted
53
+ else
54
+ extracted
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def extract_from_hash(hash, key = nil)
61
+ return nil unless hash.is_a?(Hash)
62
+
63
+ if key
64
+ # Try both symbol and string keys
65
+ hash[key] || hash[key.to_s] || hash[key.to_sym]
66
+ else
67
+ # Try common ID keys in order of preference
68
+ common_keys.each do |k|
69
+ value = hash[k] || hash[k.to_s]
70
+ return value if value
71
+ end
72
+ nil
73
+ end
74
+ end
75
+
76
+ def resource_key_map
77
+ @resource_key_map ||= {
78
+ record: :record_id,
79
+ workspace_member: :workspace_member_id,
80
+ webhook: :webhook_id,
81
+ attribute: :attribute_id,
82
+ object: :object_id,
83
+ list: :list_id,
84
+ note: :note_id,
85
+ comment: :comment_id,
86
+ task: :task_id,
87
+ entry: :entry_id,
88
+ thread: :thread_id
89
+ }.freeze
90
+ end
91
+
92
+ def hash_format_resources
93
+ @hash_format_resources ||= %i[record].freeze
94
+ end
95
+
96
+ def common_keys
97
+ @common_keys ||= %i[
98
+ id
99
+ record_id
100
+ workspace_member_id
101
+ webhook_id
102
+ attribute_id
103
+ object_id
104
+ list_id
105
+ note_id
106
+ comment_id
107
+ task_id
108
+ entry_id
109
+ thread_id
110
+ ].freeze
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "time"
6
+
7
+ module Attio
8
+ module Util
9
+ # Verifies webhook signatures from Attio to ensure authenticity
10
+ class WebhookSignature
11
+ # HTTP header containing the webhook signature
12
+ SIGNATURE_HEADER = "x-attio-signature"
13
+ # HTTP header containing the request timestamp
14
+ TIMESTAMP_HEADER = "x-attio-timestamp"
15
+ TOLERANCE_SECONDS = 300 # 5 minutes
16
+
17
+ class << self
18
+ # Verify webhook signature (raises exception on failure)
19
+ def verify!(payload:, signature:, timestamp:, secret:, tolerance: TOLERANCE_SECONDS)
20
+ validate_inputs!(payload, signature, timestamp, secret)
21
+
22
+ # Check timestamp to prevent replay attacks
23
+ verify_timestamp!(timestamp, tolerance)
24
+
25
+ # Calculate expected signature
26
+ expected_signature = calculate_signature(payload, timestamp, secret)
27
+
28
+ # Constant-time comparison to prevent timing attacks
29
+ raise SignatureVerificationError, "Invalid signature" unless secure_compare(signature, expected_signature)
30
+ rescue => e
31
+ raise SignatureVerificationError, "Webhook signature verification failed: #{e.message}"
32
+ end
33
+
34
+ # Verify webhook signature (returns boolean)
35
+ def verify(payload:, signature:, timestamp:, secret:, tolerance: TOLERANCE_SECONDS)
36
+ verify!(payload: payload, signature: signature, timestamp: timestamp, secret: secret, tolerance: tolerance)
37
+ true
38
+ rescue SignatureVerificationError
39
+ false
40
+ end
41
+
42
+ # Calculate signature for a payload
43
+ def calculate_signature(payload, timestamp, secret)
44
+ # Ensure payload is a string
45
+ payload_string = payload.is_a?(String) ? payload : JSON.generate(payload)
46
+
47
+ # Create the signed payload
48
+ signed_payload = "#{timestamp}.#{payload_string}"
49
+
50
+ # Calculate HMAC
51
+ hmac = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
52
+
53
+ # Return in the format Attio uses
54
+ "v1=#{hmac}"
55
+ end
56
+
57
+ # Extract signature from headers
58
+ def extract_from_headers(headers)
59
+ signature = headers[SIGNATURE_HEADER] || headers[SIGNATURE_HEADER.upcase] || headers[SIGNATURE_HEADER.tr("-", "_").upcase]
60
+ timestamp = headers[TIMESTAMP_HEADER] || headers[TIMESTAMP_HEADER.upcase] || headers[TIMESTAMP_HEADER.tr("-", "_").upcase]
61
+
62
+ raise SignatureVerificationError, "Missing signature header: #{SIGNATURE_HEADER}" unless signature
63
+ raise SignatureVerificationError, "Missing timestamp header: #{TIMESTAMP_HEADER}" unless timestamp
64
+
65
+ {
66
+ signature: signature,
67
+ timestamp: timestamp
68
+ }
69
+ end
70
+
71
+ private
72
+
73
+ def validate_inputs!(payload, signature, timestamp, secret)
74
+ raise ArgumentError, "Payload cannot be nil" if payload.nil?
75
+ raise ArgumentError, "Signature cannot be nil or empty" if signature.nil? || signature.empty?
76
+ raise ArgumentError, "Timestamp cannot be nil or empty" if timestamp.nil? || timestamp.to_s.empty?
77
+ raise ArgumentError, "Secret cannot be nil or empty" if secret.nil? || secret.empty?
78
+ end
79
+
80
+ def verify_timestamp!(timestamp, tolerance)
81
+ timestamp_int = timestamp.to_i
82
+ current_time = Time.now.to_i
83
+
84
+ if timestamp_int < (current_time - tolerance)
85
+ raise SignatureVerificationError, "Timestamp too old"
86
+ end
87
+
88
+ if timestamp_int > (current_time + tolerance)
89
+ raise SignatureVerificationError, "Timestamp too far in the future"
90
+ end
91
+ end
92
+
93
+ def secure_compare(a, b)
94
+ return false unless a.bytesize == b.bytesize
95
+
96
+ # Use constant-time comparison
97
+ res = 0
98
+ a.bytes.zip(b.bytes) { |x, y| res |= x ^ y }
99
+ res == 0
100
+ end
101
+ end
102
+
103
+ # Helper class for webhook handlers
104
+ class Handler
105
+ attr_reader :secret
106
+
107
+ def initialize(secret)
108
+ @secret = secret
109
+ validate_secret!
110
+ end
111
+
112
+ # Verify a request
113
+ def verify_request(request)
114
+ headers = extract_headers(request)
115
+ body = extract_body(request)
116
+
117
+ signature_data = WebhookSignature.extract_from_headers(headers)
118
+
119
+ WebhookSignature.verify!(
120
+ payload: body,
121
+ signature: signature_data[:signature],
122
+ timestamp: signature_data[:timestamp],
123
+ secret: secret
124
+ )
125
+ end
126
+
127
+ # Parse and verify a request
128
+ def parse_and_verify(request)
129
+ verify_request(request)
130
+
131
+ body = extract_body(request)
132
+ JSON.parse(body, symbolize_names: true)
133
+ rescue JSON::ParserError => e
134
+ raise SignatureVerificationError, "Invalid JSON payload: #{e.message}"
135
+ end
136
+
137
+ private
138
+
139
+ def validate_secret!
140
+ raise ArgumentError, "Webhook secret is required" if secret.nil? || secret.empty?
141
+ end
142
+
143
+ def extract_headers(request)
144
+ case request
145
+ when Hash
146
+ request[:headers] || request["headers"] || {}
147
+ when defined?(Rack::Request) && Rack::Request
148
+ request.env.select { |k, _| k.start_with?("HTTP_") }.transform_keys { |k| k.sub(/^HTTP_/, "").downcase }
149
+ when defined?(ActionDispatch::Request) && ActionDispatch::Request
150
+ request.headers.to_h
151
+ else
152
+ raise ArgumentError, "Unsupported request type: #{request.class}"
153
+ end
154
+ end
155
+
156
+ def extract_body(request)
157
+ case request
158
+ when Hash
159
+ request[:body] || request["body"] || ""
160
+ when defined?(Rack::Request) && Rack::Request
161
+ request.body.rewind
162
+ request.body.read
163
+ when defined?(ActionDispatch::Request) && ActionDispatch::Request
164
+ request.raw_post
165
+ else
166
+ raise ArgumentError, "Unsupported request type: #{request.class}"
167
+ end
168
+ end
169
+ end
170
+
171
+ # Raised when webhook signature verification fails
172
+ class SignatureVerificationError < StandardError; end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attio
4
+ # Current version of the Attio Ruby gem
5
+ VERSION = "0.1.0"
6
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attio
4
+ module WebhookUtils
5
+ # Represents a webhook event payload
6
+ class Event
7
+ attr_reader :id, :type, :occurred_at, :data, :raw_data
8
+
9
+ def initialize(payload)
10
+ @raw_data = payload.is_a?(String) ? JSON.parse(payload) : payload
11
+ @id = @raw_data["id"] || @raw_data[:id]
12
+ @type = @raw_data["type"] || @raw_data[:type]
13
+ @occurred_at = parse_timestamp(@raw_data["occurred_at"] || @raw_data[:occurred_at])
14
+ @data = @raw_data["data"] || @raw_data[:data] || {}
15
+ end
16
+
17
+ # Get the object type from the event data
18
+ def object_type
19
+ @data["object"] || @data[:object]
20
+ end
21
+
22
+ # Get the record from the event data
23
+ def record
24
+ @data["record"] || @data[:record]
25
+ end
26
+
27
+ # Get the record ID
28
+ def record_id
29
+ record_data = record
30
+ return nil unless record_data
31
+
32
+ record_data["id"] || record_data[:id]
33
+ end
34
+
35
+ # Get the record data
36
+ def record_data
37
+ record || {}
38
+ end
39
+
40
+ # Get changes from updated events
41
+ def changes
42
+ @data["changes"] || @data[:changes]
43
+ end
44
+
45
+ # Add present? method to match Rails expectations
46
+ def present?
47
+ true # Events are always present if they exist
48
+ end
49
+
50
+ # Check if this is a record event
51
+ def record_event?
52
+ type&.start_with?("record.")
53
+ end
54
+
55
+ # Check if this is a created event
56
+ def created_event?
57
+ type&.end_with?(".created")
58
+ end
59
+
60
+ # Check if this is an updated event
61
+ def updated_event?
62
+ type&.end_with?(".updated")
63
+ end
64
+
65
+ # Check if this is a deleted event
66
+ def deleted_event?
67
+ type&.end_with?(".deleted")
68
+ end
69
+
70
+ # Check if this is a list entry event
71
+ def list_entry_event?
72
+ type&.start_with?("list_entry.")
73
+ end
74
+
75
+ # Check if this is a note event
76
+ def note_event?
77
+ type&.start_with?("note.")
78
+ end
79
+
80
+ # Check if this is a task event
81
+ def task_event?
82
+ type&.start_with?("task.")
83
+ end
84
+
85
+ # Convert to hash
86
+ def to_h
87
+ {
88
+ id: id,
89
+ type: type,
90
+ occurred_at: occurred_at&.iso8601,
91
+ object_type: object_type,
92
+ record_id: record_id,
93
+ record_data: record_data,
94
+ changes: changes,
95
+ data: data
96
+ }.compact
97
+ end
98
+
99
+ # Convert event back to JSON
100
+ def to_json(*args)
101
+ @raw_data.to_json(*args)
102
+ end
103
+
104
+ private
105
+
106
+ def parse_timestamp(timestamp_str)
107
+ return nil unless timestamp_str
108
+ Time.parse(timestamp_str)
109
+ rescue ArgumentError
110
+ nil
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Attio
6
+ module WebhookUtils
7
+ # Verifies webhook signatures to ensure payloads are from Attio
8
+ class SignatureVerifier
9
+ TOLERANCE = 300 # 5 minutes in seconds
10
+
11
+ def initialize(secret)
12
+ @secret = secret
13
+ end
14
+
15
+ # Verify the webhook signature
16
+ # @param payload [String] The raw request body
17
+ # @param signature_header [String] The signature header from the request
18
+ # @param tolerance [Integer] Maximum age of timestamp in seconds
19
+ # @return [Boolean] True if signature is valid
20
+ def verify(payload, signature_header, tolerance: TOLERANCE)
21
+ timestamp, signature = parse_signature_header(signature_header)
22
+ return false unless timestamp && signature
23
+
24
+ # Check timestamp tolerance
25
+ current_time = Time.now.to_i
26
+ if (current_time - timestamp.to_i).abs > tolerance
27
+ return false
28
+ end
29
+
30
+ # Generate expected signature
31
+ signed_payload = "#{timestamp}.#{payload}"
32
+ expected_signature = OpenSSL::HMAC.hexdigest("SHA256", @secret, signed_payload)
33
+
34
+ # Compare signatures securely
35
+ secure_compare(signature, expected_signature)
36
+ end
37
+
38
+ private
39
+
40
+ # Parse the signature header format: "t=timestamp v1=signature"
41
+ def parse_signature_header(header)
42
+ return [nil, nil] unless header
43
+
44
+ timestamp = nil
45
+ signature = nil
46
+
47
+ header.split(/[,\s]+/).each do |element|
48
+ key, value = element.split("=", 2)
49
+ case key
50
+ when "t"
51
+ timestamp = value
52
+ when "v1"
53
+ signature = value
54
+ end
55
+ end
56
+
57
+ [timestamp, signature]
58
+ end
59
+
60
+ # Secure string comparison to prevent timing attacks
61
+ def secure_compare(a, b)
62
+ return false unless a.bytesize == b.bytesize
63
+
64
+ l = a.unpack("C*")
65
+ r = b.unpack("C*")
66
+ result = 0
67
+
68
+ l.zip(r) { |x, y| result |= x ^ y }
69
+ result == 0
70
+ end
71
+ end
72
+ end
73
+ end