chat_sdk-gchat 1.3.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: '08f7bf3bc0128b23b9e9bb9d68431f7ce573179c6e40e6f61d942cf1c4770dea'
4
- data.tar.gz: e13b88766dfe5be010298320aa8406d818068dfd7709298e3c2da958a6c2993a
3
+ metadata.gz: 78386a734193c0fd0557415787e8071933bb90f23c3a7c9b8a3a5981df5df15e
4
+ data.tar.gz: 5548dc38e75827c76603a337dc15313da54d45a22b746fd0d6650c5b9f2772a9
5
5
  SHA512:
6
- metadata.gz: 9498945602865b18d40f6389afe9673a46460b0fc25fe56318d7b621901d2aa586fa189eb2159adde02358eb95c6ed67e04c1e713450bf981d71c7a2e56268ba
7
- data.tar.gz: 87693f0e0e19e74e753ee79df79f20708a35e63182df5bd370cd970556e811e93ce273a10a61243f65fd843b4dcb0b73f8000077809487e7eec4d25e6e7b58fe
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
@@ -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.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Quentin Rousseau
@@ -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