chat_sdk-gchat 1.2.0 → 1.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a9d22b76276f761a3436f508397dbef8b15fc177d03f390a1898e4f981bc9c1
4
- data.tar.gz: 0f92f166374ea5b14dcc81e3ccd8742de0a3b244cf3e51bb71bc4c8b50ef276b
3
+ metadata.gz: 78386a734193c0fd0557415787e8071933bb90f23c3a7c9b8a3a5981df5df15e
4
+ data.tar.gz: 5548dc38e75827c76603a337dc15313da54d45a22b746fd0d6650c5b9f2772a9
5
5
  SHA512:
6
- metadata.gz: f4aa6693d9ce80cec67766859a5aa95f28bf8db6dad55505cd404602db828231d47f08c57cf19881e9f008c7c8ca888ff73b857f4d779aae7c4135d9cfae7100
7
- data.tar.gz: 869d867f863ac6201a63167e0dfa72f2f6e3e6b889a96454ecd54f7a733ca255006ebc3316314e1aa4f4efdbba18c8db1b21204cdc0a8d66d993296792a1e4e1
6
+ metadata.gz: 97c182dc7e84015d6b9f2cad180b463f4607d3e2b8b6c16fef48488365840f1e4566e58838e8616e29d72964bcb164de648ede6c61df1c8e16af5ea38a7e3dd7
7
+ data.tar.gz: 86115605552b3ed47f4c5a7f85f64456b35a1a4a0de4a6e6a9529bf3a66bdd6f605ad233535efdc587229954cdca22253cf0563228cf27265ed6073847d586d4
@@ -11,16 +11,48 @@ module ChatSDK
11
11
 
12
12
  attr_reader :client
13
13
 
14
- def initialize(project_number:, credentials: nil)
15
- @project_number = project_number.to_s
14
+ def initialize(
15
+ project_number: nil,
16
+ endpoint_url: nil,
17
+ pubsub_audience: nil,
18
+ pubsub_service_account_email: nil,
19
+ workspace_add_on_service_account_email: nil,
20
+ disable_signature_verification: ENV["GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION"] == "true",
21
+ credentials: nil
22
+ )
23
+ @project_number = normalize_config_value(project_number || ENV["GOOGLE_CHAT_PROJECT_NUMBER"])
24
+ @endpoint_url = normalize_config_value(endpoint_url)
25
+ @pubsub_audience = normalize_config_value(pubsub_audience || ENV["GOOGLE_CHAT_PUBSUB_AUDIENCE"])
26
+ @pubsub_service_account_email = normalize_config_value(
27
+ pubsub_service_account_email || ENV["GOOGLE_CHAT_PUBSUB_SERVICE_ACCOUNT_EMAIL"]
28
+ )
29
+ @workspace_add_on_service_account_email = normalize_config_value(
30
+ workspace_add_on_service_account_email || ENV["GOOGLE_CHAT_WORKSPACE_ADDON_SERVICE_ACCOUNT_EMAIL"]
31
+ )
32
+ @disable_signature_verification = if disable_signature_verification.nil?
33
+ ENV["GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION"] == "true"
34
+ else
35
+ disable_signature_verification
36
+ end
16
37
 
17
- raise ChatSDK::ConfigurationError, "Google Chat project_number required" if @project_number.empty?
38
+ unless @project_number || @endpoint_url || @pubsub_audience || @disable_signature_verification
39
+ raise ChatSDK::ConfigurationError,
40
+ "Google Chat webhook verification requires project_number, endpoint_url, or pubsub_audience; " \
41
+ "set disable_signature_verification to true to accept unverified requests"
42
+ end
18
43
 
19
44
  @credentials = build_credentials(credentials)
20
45
  @client = Google::Apps::Chat::V1::ChatService::Client.new do |config|
21
46
  config.credentials = @credentials if @credentials
22
47
  end
23
- @verifier = TokenVerifier.new(@project_number)
48
+ @verifier = TokenVerifier.new(
49
+ @project_number,
50
+ endpoint_url: @endpoint_url,
51
+ pubsub_audience: @pubsub_audience,
52
+ pubsub_service_account_email: @pubsub_service_account_email,
53
+ workspace_add_on_service_account_email: @workspace_add_on_service_account_email,
54
+ disable_signature_verification: @disable_signature_verification
55
+ )
24
56
  @renderer = CardV2Renderer.new
25
57
  end
26
58
 
@@ -30,36 +62,28 @@ module ChatSDK
30
62
 
31
63
  # Inbound
32
64
  def verify_request!(rack_request)
33
- auth_header = rack_request.env["HTTP_AUTHORIZATION"] || ""
34
- token = auth_header.sub(/\ABearer\s+/i, "")
35
- @verifier.verify!(token)
36
- true
65
+ @verifier.verify_request!(rack_request)
37
66
  end
38
67
 
39
68
  def parse_events(rack_request)
40
69
  payload = read_json_body(rack_request)
70
+ return parse_pubsub_payload(payload) if PubSubPush.pubsub_push?(payload)
71
+
41
72
  EventParser.parse(payload)
42
73
  rescue JSON::ParserError
43
74
  []
44
75
  end
45
76
 
46
- # Parses a Google Cloud Pub/Sub push message containing a Google Chat event.
47
- #
48
- # When a Pub/Sub subscription is configured externally (via Google Cloud
49
- # Console or Terraform) for Google Workspace Events, push messages arrive
50
- # as HTTP POSTs with the Chat event base64-encoded inside
51
- # `message.data`. This method decodes the envelope and delegates to
52
- # {EventParser}.
77
+ # Parses a Pub/Sub push after its request has been verified with #verify_request!.
78
+ # The standard webhook endpoint verifies and parses Pub/Sub pushes automatically.
53
79
  #
54
80
  # @param rack_request [Rack::Request] the incoming Pub/Sub push request
55
81
  # @return [Array<ChatSDK::Events::Base>] parsed events (empty on bad payload)
56
82
  def parse_pubsub_event(rack_request)
57
83
  envelope = read_json_body(rack_request)
58
- data = envelope.dig("message", "data")
59
- return [] unless data
84
+ return [] unless PubSubPush.pubsub_push?(envelope)
60
85
 
61
- decoded = JSON.parse(Base64.decode64(data))
62
- EventParser.parse(decoded)
86
+ parse_pubsub_payload(envelope)
63
87
  rescue JSON::ParserError, ArgumentError
64
88
  []
65
89
  end
@@ -182,6 +206,23 @@ module ChatSDK
182
206
 
183
207
  private
184
208
 
209
+ def parse_pubsub_payload(envelope)
210
+ data = envelope.dig("message", "data")
211
+ return [] unless data
212
+
213
+ decoded = JSON.parse(Base64.decode64(data))
214
+ EventParser.parse(decoded)
215
+ rescue JSON::ParserError, ArgumentError
216
+ []
217
+ end
218
+
219
+ def normalize_config_value(value)
220
+ return nil if value.nil?
221
+
222
+ normalized = value.to_s
223
+ normalized.empty? ? nil : normalized
224
+ end
225
+
185
226
  def build_credentials(credentials)
186
227
  case credentials
187
228
  when nil
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cgi"
4
+
3
5
  module ChatSDK
4
6
  module GChat
5
7
  class CardV2Renderer
@@ -24,10 +26,16 @@ module ChatSDK
24
26
  end
25
27
 
26
28
  def build_header(node)
27
- return nil unless node.attributes[:title]
29
+ image_url = node.attributes[:image_url]
30
+ has_image_url = image_url && !image_url.empty?
31
+ return nil unless node.attributes[:title] || has_image_url
28
32
 
29
- header = {title: node.attributes[:title]}
33
+ header = {title: node.attributes[:title] || ""}
30
34
  header[:subtitle] = node.attributes[:subtitle] if node.attributes[:subtitle]
35
+ if has_image_url
36
+ header[:imageUrl] = image_url
37
+ header[:imageType] = "SQUARE"
38
+ end
31
39
  header
32
40
  end
33
41
 
@@ -60,11 +68,13 @@ module ChatSDK
60
68
  def render_widget(node)
61
69
  case node.type
62
70
  when :text then render_text(node)
71
+ when :link then render_link(node)
63
72
  when :fields then render_fields(node)
64
73
  when :image then render_image(node)
65
74
  when :actions then render_actions(node)
66
75
  when :divider then {divider: {}}
67
76
  when :select then render_select(node)
77
+ when :radio_select then render_radio_select(node)
68
78
  end
69
79
  end
70
80
 
@@ -78,7 +88,15 @@ module ChatSDK
78
88
  end
79
89
 
80
90
  def render_text(node)
81
- {textParagraph: {text: node.attributes[:content]}}
91
+ text = node.attributes[:content]
92
+ text = "*#{text}*" if node.attributes[:style] == :bold
93
+ {textParagraph: {text: text}}
94
+ end
95
+
96
+ def render_link(node)
97
+ url = CGI.escapeHTML(node.attributes[:url].to_s)
98
+ label = CGI.escapeHTML(node.attributes[:label].to_s)
99
+ {textParagraph: {text: %(<a href="#{url}">#{label}</a>)}}
82
100
  end
83
101
 
84
102
  def render_fields(node)
@@ -106,10 +124,15 @@ module ChatSDK
106
124
  widgets = []
107
125
  buttons = []
108
126
  node.children.each do |child|
109
- if child.type == :select
127
+ if %i[select radio_select].include?(child.type)
110
128
  widgets << render_button_list(buttons) unless buttons.empty?
111
129
  buttons = []
112
- widgets << render_select(child)
130
+ widget = if child.type == :radio_select
131
+ render_radio_select(child)
132
+ else
133
+ render_select(child)
134
+ end
135
+ widgets << widget
113
136
  else
114
137
  btn = render_action_element(child)
115
138
  buttons << btn if btn
@@ -152,36 +175,56 @@ module ChatSDK
152
175
  elsif node.attributes[:style] == :danger
153
176
  btn[:color] = {red: 0.87, green: 0.17, blue: 0.17, alpha: 1.0}
154
177
  end
178
+ btn[:disabled] = true if node.attributes[:disabled]
155
179
 
156
180
  btn
157
181
  end
158
182
 
159
183
  def render_link_button(node)
160
- {
184
+ button = {
161
185
  text: node.attributes[:text],
162
186
  onClick: {
163
187
  openLink: {url: node.attributes[:url]}
164
188
  }
165
189
  }
190
+ if node.attributes[:style] == :primary
191
+ button[:color] = {red: 0.0, green: 0.53, blue: 0.87, alpha: 1.0}
192
+ elsif node.attributes[:style] == :danger
193
+ button[:color] = {red: 0.87, green: 0.17, blue: 0.17, alpha: 1.0}
194
+ end
195
+ button
166
196
  end
167
197
 
168
198
  def render_select(node)
199
+ render_selection_input(node, type: "DROPDOWN")
200
+ end
201
+
202
+ def render_radio_select(node)
203
+ render_selection_input(node, type: "RADIO_BUTTON")
204
+ end
205
+
206
+ def render_selection_input(node, type:)
169
207
  {
170
208
  selectionInput: {
171
209
  name: node.attributes[:id],
172
- label: node.attributes[:placeholder] || "Select",
173
- type: "DROPDOWN",
174
- items: node.children.map { |opt| render_select_option(opt) }
210
+ label: node.attributes[:label] || node.attributes[:placeholder] || "Select",
211
+ type: type,
212
+ items: node.children.map { |option| render_select_option(option, node.attributes[:initial_option]) }
175
213
  }
176
214
  }
177
215
  end
178
216
 
179
- def render_select_option(node)
180
- {
217
+ def render_select_option(node, initial_option)
218
+ option = {
181
219
  text: node.attributes[:text],
182
- value: node.attributes[:value],
183
- selected: false
220
+ value: node.attributes[:value]
184
221
  }
222
+ if initial_option.nil?
223
+ option[:selected] = false
224
+ elsif node.attributes[:value] == initial_option
225
+ option[:selected] = true
226
+ end
227
+ option
185
228
  end
186
229
  end
187
230
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module GChat
5
+ module PubSubPush
6
+ def self.pubsub_push?(payload)
7
+ return false unless payload.is_a?(Hash)
8
+
9
+ message = payload["message"]
10
+ message.is_a?(Hash) && message.key?("data") && payload.key?("subscription")
11
+ end
12
+ end
13
+ end
14
+ end
@@ -1,31 +1,171 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "openssl"
5
+ require "socket"
6
+ require "timeout"
7
+
3
8
  module ChatSDK
4
9
  module GChat
5
10
  class TokenVerifier
6
11
  EXPECTED_ISSUER = "chat@system.gserviceaccount.com"
12
+ CHAT_ISSUER_CERTS_URL = "https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com"
13
+ WORKSPACE_ADD_ON_EMAIL_PATTERN = /\Aservice-\d+@gcp-sa-gsuiteaddons\.iam\.gserviceaccount\.com\z/
7
14
 
8
- def initialize(project_number)
9
- @project_number = project_number.to_s
15
+ def initialize(
16
+ project_number = nil,
17
+ endpoint_url: nil,
18
+ pubsub_audience: nil,
19
+ pubsub_service_account_email: nil,
20
+ workspace_add_on_service_account_email: nil,
21
+ disable_signature_verification: false
22
+ )
23
+ @project_number = project_number.to_s unless project_number.nil?
24
+ @endpoint_url = endpoint_url
25
+ @pubsub_audience = pubsub_audience
26
+ @pubsub_service_account_email = pubsub_service_account_email
27
+ @workspace_add_on_service_account_email = workspace_add_on_service_account_email
28
+ @disable_signature_verification = disable_signature_verification
29
+ @warned_unverified_direct = false
30
+ @warned_unverified_pubsub = false
10
31
  end
11
32
 
12
33
  def verify!(token)
34
+ verify_direct!(token)
35
+ end
36
+
37
+ def verify_request!(rack_request)
38
+ rack_request.body.rewind
39
+ body = rack_request.body.read
40
+ rack_request.body.rewind
41
+
42
+ payload = parse_request_body(body)
43
+ if PubSubPush.pubsub_push?(payload)
44
+ verify_pubsub!(bearer_token(rack_request))
45
+ else
46
+ verify_direct!(bearer_token(rack_request))
47
+ end
48
+ true
49
+ ensure
50
+ rack_request.body.rewind
51
+ end
52
+
53
+ private
54
+
55
+ def verify_direct!(token)
56
+ unless @endpoint_url || @project_number
57
+ return allow_unverified!(:direct) if @disable_signature_verification
58
+
59
+ raise ChatSDK::SignatureVerificationError, "Direct Google Chat webhook verification is not configured"
60
+ end
61
+
13
62
  raise ChatSDK::SignatureVerificationError, "Missing bearer token" if token.nil? || token.empty?
14
63
 
15
- payload = Google::Auth::IDTokens.verify_oidc(
16
- token,
17
- aud: @project_number
18
- )
64
+ failures = []
65
+ if @endpoint_url
66
+ begin
67
+ payload = Google::Auth::IDTokens.verify_oidc(token, aud: @endpoint_url)
68
+ return payload if endpoint_payload_trusted?(payload)
69
+
70
+ failures << "Untrusted endpoint URL token identity"
71
+ rescue Google::Auth::IDTokens::VerificationError,
72
+ Google::Auth::IDTokens::KeySourceError,
73
+ SocketError,
74
+ Timeout::Error,
75
+ IOError,
76
+ SystemCallError,
77
+ OpenSSL::OpenSSLError => e
78
+ failures << e.message
79
+ end
80
+ end
81
+
82
+ if @project_number
83
+ begin
84
+ return project_number_verifier.verify(token)
85
+ rescue Google::Auth::IDTokens::VerificationError,
86
+ Google::Auth::IDTokens::KeySourceError,
87
+ SocketError,
88
+ Timeout::Error,
89
+ IOError,
90
+ SystemCallError,
91
+ OpenSSL::OpenSSLError => e
92
+ failures << e.message
93
+ end
94
+ end
95
+
96
+ message = failures.last ? ": #{failures.last}" : ""
97
+ raise ChatSDK::SignatureVerificationError, "Google Chat webhook token verification failed#{message}"
98
+ end
19
99
 
20
- issuer = payload["iss"] || payload["email"]
21
- unless issuer == EXPECTED_ISSUER
22
- raise ChatSDK::SignatureVerificationError,
23
- "Unexpected issuer: #{issuer} (expected #{EXPECTED_ISSUER})"
100
+ def verify_pubsub!(token)
101
+ unless @pubsub_audience
102
+ return allow_unverified!(:pubsub) if @disable_signature_verification
103
+
104
+ raise ChatSDK::SignatureVerificationError, "Google Chat Pub/Sub verification is not configured"
105
+ end
106
+
107
+ raise ChatSDK::SignatureVerificationError, "Missing bearer token" if token.nil? || token.empty?
108
+
109
+ payload = Google::Auth::IDTokens.verify_oidc(token, aud: @pubsub_audience)
110
+ unless pubsub_payload_trusted?(payload)
111
+ raise ChatSDK::SignatureVerificationError, "Untrusted Google Chat Pub/Sub service account"
24
112
  end
25
113
 
26
114
  payload
27
- rescue Google::Auth::IDTokens::VerificationError => e
28
- raise ChatSDK::SignatureVerificationError, "Google ID token verification failed: #{e.message}"
115
+ rescue Google::Auth::IDTokens::VerificationError,
116
+ Google::Auth::IDTokens::KeySourceError,
117
+ SocketError,
118
+ Timeout::Error,
119
+ IOError,
120
+ SystemCallError,
121
+ OpenSSL::OpenSSLError => e
122
+ raise ChatSDK::SignatureVerificationError, "Google Pub/Sub ID token verification failed: #{e.message}"
123
+ end
124
+
125
+ def endpoint_payload_trusted?(payload)
126
+ return false unless payload["email_verified"] == true
127
+
128
+ email = payload["email"]
129
+ return true if email == EXPECTED_ISSUER
130
+
131
+ email.is_a?(String) &&
132
+ WORKSPACE_ADD_ON_EMAIL_PATTERN.match?(email) &&
133
+ email == @workspace_add_on_service_account_email
134
+ end
135
+
136
+ def pubsub_payload_trusted?(payload)
137
+ payload["email_verified"] == true &&
138
+ @pubsub_service_account_email &&
139
+ payload["email"] == @pubsub_service_account_email
140
+ end
141
+
142
+ def project_number_verifier
143
+ @project_number_verifier ||= Google::Auth::IDTokens::Verifier.new(
144
+ key_source: Google::Auth::IDTokens::X509CertHttpKeySource.new(CHAT_ISSUER_CERTS_URL),
145
+ aud: @project_number,
146
+ iss: EXPECTED_ISSUER
147
+ )
148
+ end
149
+
150
+ def allow_unverified!(transport)
151
+ warning_ivar = :"@warned_unverified_#{transport}"
152
+ return true if instance_variable_get(warning_ivar)
153
+
154
+ ChatSDK::Log.warn("Google Chat #{transport} signature verification is disabled.")
155
+ instance_variable_set(warning_ivar, true)
156
+ true
157
+ end
158
+
159
+ def parse_request_body(body)
160
+ JSON.parse(body)
161
+ rescue JSON::ParserError
162
+ nil
163
+ end
164
+
165
+ def bearer_token(rack_request)
166
+ authorization = rack_request.env["HTTP_AUTHORIZATION"].to_s
167
+ match = /\ABearer\s+(.+)\z/i.match(authorization)
168
+ match && match[1]
29
169
  end
30
170
  end
31
171
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chat_sdk-gchat
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Quentin Rousseau
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '1.2'
18
+ version: '1.3'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '1.2'
25
+ version: '1.3'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: google-apps-chat-v1
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -63,6 +63,7 @@ files:
63
63
  - lib/chat_sdk/gchat/card_v2_renderer.rb
64
64
  - lib/chat_sdk/gchat/event_parser.rb
65
65
  - lib/chat_sdk/gchat/format_converter.rb
66
+ - lib/chat_sdk/gchat/pub_sub_push.rb
66
67
  - lib/chat_sdk/gchat/resource_name.rb
67
68
  - lib/chat_sdk/gchat/token_verifier.rb
68
69
  homepage: https://github.com/rootlyhq/chat-sdk