teams_rb 2.0.1

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 (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/LICENSE +21 -0
  4. data/README.md +500 -0
  5. data/docs/README.md +40 -0
  6. data/docs/essentials/README.md +13 -0
  7. data/docs/essentials/api-client.md +62 -0
  8. data/docs/essentials/app-authentication.md +27 -0
  9. data/docs/essentials/app-basics.md +50 -0
  10. data/docs/essentials/graph.md +41 -0
  11. data/docs/essentials/on-activity.md +72 -0
  12. data/docs/essentials/on-event.md +29 -0
  13. data/docs/essentials/proactive-messaging.md +44 -0
  14. data/docs/essentials/sending-messages.md +76 -0
  15. data/docs/essentials/sovereign-cloud.md +26 -0
  16. data/docs/getting-started/README.md +7 -0
  17. data/docs/getting-started/code-basics.md +61 -0
  18. data/docs/getting-started/quickstart.md +54 -0
  19. data/docs/getting-started/running-in-teams.md +44 -0
  20. data/docs/in-depth-guides/README.md +14 -0
  21. data/docs/in-depth-guides/adaptive-cards.md +62 -0
  22. data/docs/in-depth-guides/dialogs.md +77 -0
  23. data/docs/in-depth-guides/feedback.md +29 -0
  24. data/docs/in-depth-guides/meeting-events.md +27 -0
  25. data/docs/in-depth-guides/message-extensions.md +77 -0
  26. data/docs/in-depth-guides/message-reactions.md +29 -0
  27. data/docs/in-depth-guides/observability.md +28 -0
  28. data/docs/in-depth-guides/streaming.md +52 -0
  29. data/docs/in-depth-guides/tabs.md +59 -0
  30. data/docs/in-depth-guides/user-authentication.md +60 -0
  31. data/lib/teams/activity.rb +160 -0
  32. data/lib/teams/activity_context.rb +278 -0
  33. data/lib/teams/api/account.rb +90 -0
  34. data/lib/teams/api/activity_value.rb +49 -0
  35. data/lib/teams/api/bot_sign_in_client.rb +54 -0
  36. data/lib/teams/api/channel_data.rb +86 -0
  37. data/lib/teams/api/channel_info.rb +19 -0
  38. data/lib/teams/api/citation_appearance.rb +83 -0
  39. data/lib/teams/api/client.rb +28 -0
  40. data/lib/teams/api/conversation_account.rb +40 -0
  41. data/lib/teams/api/conversation_client.rb +156 -0
  42. data/lib/teams/api/conversation_reference.rb +83 -0
  43. data/lib/teams/api/conversation_resource.rb +19 -0
  44. data/lib/teams/api/meeting_client.rb +57 -0
  45. data/lib/teams/api/meeting_info.rb +26 -0
  46. data/lib/teams/api/meeting_notification_response.rb +29 -0
  47. data/lib/teams/api/meeting_participant.rb +33 -0
  48. data/lib/teams/api/message_activity.rb +201 -0
  49. data/lib/teams/api/message_extension.rb +110 -0
  50. data/lib/teams/api/model.rb +49 -0
  51. data/lib/teams/api/notification_info.rb +28 -0
  52. data/lib/teams/api/paged_members_result.rb +16 -0
  53. data/lib/teams/api/quoted_reply_entity.rb +65 -0
  54. data/lib/teams/api/reaction_client.rb +34 -0
  55. data/lib/teams/api/sent_activity.rb +48 -0
  56. data/lib/teams/api/task_module.rb +83 -0
  57. data/lib/teams/api/team_client.rb +45 -0
  58. data/lib/teams/api/team_details.rb +36 -0
  59. data/lib/teams/api/team_info.rb +44 -0
  60. data/lib/teams/api/tenant_info.rb +11 -0
  61. data/lib/teams/api/token.rb +81 -0
  62. data/lib/teams/api/typing_activity.rb +19 -0
  63. data/lib/teams/api/user_client.rb +83 -0
  64. data/lib/teams/app.rb +602 -0
  65. data/lib/teams/auth/client_secret_credentials.rb +7 -0
  66. data/lib/teams/auth/jwt_validator.rb +148 -0
  67. data/lib/teams/auth/token.rb +39 -0
  68. data/lib/teams/auth/token_manager.rb +68 -0
  69. data/lib/teams/cards/generated.rb +1764 -0
  70. data/lib/teams/cards/generated_base.rb +105 -0
  71. data/lib/teams/cards.rb +81 -0
  72. data/lib/teams/cloud_environment.rb +41 -0
  73. data/lib/teams/common/hashes.rb +20 -0
  74. data/lib/teams/common/http_client.rb +111 -0
  75. data/lib/teams/common/retry.rb +31 -0
  76. data/lib/teams/errors.rb +50 -0
  77. data/lib/teams/function_context.rb +82 -0
  78. data/lib/teams/graph/client.rb +73 -0
  79. data/lib/teams/http_stream.rb +460 -0
  80. data/lib/teams/rack_app.rb +62 -0
  81. data/lib/teams/response.rb +9 -0
  82. data/lib/teams/router.rb +171 -0
  83. data/lib/teams/storage/memory_store.rb +27 -0
  84. data/lib/teams/version.rb +5 -0
  85. data/lib/teams.rb +70 -0
  86. metadata +217 -0
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+
7
+ module Teams
8
+ module Auth
9
+ class JwtValidator
10
+ def initialize(client_id:, tenant_id: nil, cloud: PUBLIC_CLOUD, http: nil)
11
+ @client_id = client_id
12
+ @tenant_id = tenant_id
13
+ @cloud = cloud
14
+ @http = http || Common::HttpClient.new
15
+ @jwks = {}
16
+ # The validator is shared across request threads; the mutex keeps
17
+ # concurrent cold-cache requests from fetching the JWKS repeatedly.
18
+ @jwks_mutex = Mutex.new
19
+ end
20
+
21
+ def validate!(authorization_header, service_url: nil)
22
+ raise AuthenticationError, "Authorization header is required" if authorization_header.to_s.empty?
23
+
24
+ scheme, token = authorization_header.split(" ", 2)
25
+ raise AuthenticationError, "Authorization must be Bearer" unless scheme&.casecmp("Bearer")&.zero? && token
26
+
27
+ header, payload, signing_input, signature = decode(token)
28
+ validate_claims!(payload)
29
+ validate_service_url!(payload, service_url) if service_url
30
+ verify_signature!(header, signing_input, signature)
31
+ payload
32
+ end
33
+
34
+ private
35
+
36
+ def decode(token)
37
+ header_segment, payload_segment, signature_segment = token.split(".")
38
+ raise AuthenticationError, "JWT must contain three segments" unless signature_segment
39
+
40
+ header = JSON.parse(Base64.urlsafe_decode64(pad(header_segment)))
41
+ payload = JSON.parse(Base64.urlsafe_decode64(pad(payload_segment)))
42
+ signature = Base64.urlsafe_decode64(pad(signature_segment))
43
+ [header, payload, "#{header_segment}.#{payload_segment}", signature]
44
+ rescue JSON::ParserError, ArgumentError
45
+ raise AuthenticationError, "JWT is malformed"
46
+ end
47
+
48
+ def validate_claims!(payload)
49
+ now = Time.now.to_i
50
+ raise AuthenticationError, "JWT expired" if payload["exp"] && now >= payload["exp"].to_i
51
+ raise AuthenticationError, "JWT not active yet" if payload["nbf"] && now < payload["nbf"].to_i
52
+ raise AuthenticationError, "JWT issuer is invalid" unless valid_issuer?(payload["iss"])
53
+ raise AuthenticationError, "JWT audience is invalid" if (Array(payload["aud"]) & valid_audiences).empty?
54
+ end
55
+
56
+ # Inbound tokens may be audienced as the bare app id, api://{appId},
57
+ # or api://botid-{appId}; all three SDKs accept all three forms.
58
+ def valid_audiences
59
+ [@client_id, "api://#{@client_id}", "api://botid-#{@client_id}"]
60
+ end
61
+
62
+ def validate_service_url!(payload, expected_service_url)
63
+ token_service_url = payload["serviceurl"]
64
+ raise AuthenticationError, "Token missing serviceurl claim" if token_service_url.to_s.empty?
65
+
66
+ normalized_token_url = normalize_url(token_service_url)
67
+ normalized_expected_url = normalize_url(expected_service_url)
68
+
69
+ return if normalized_token_url == normalized_expected_url
70
+
71
+ raise AuthenticationError, "Service URL mismatch. Token: #{normalized_token_url}, Expected: #{normalized_expected_url}"
72
+ end
73
+
74
+ def verify_signature!(header, signing_input, signature)
75
+ raise AuthenticationError, "only RS256 JWTs are supported" unless header["alg"] == "RS256"
76
+
77
+ key = jwks_for_issuer(signing_input).fetch("keys").find { |candidate| candidate["kid"] == header["kid"] }
78
+ raise AuthenticationError, "JWT signing key was not found" unless key
79
+
80
+ public_key = rsa_public_key(key)
81
+ unless public_key.verify(OpenSSL::Digest.new("SHA256"), signature, signing_input)
82
+ raise AuthenticationError, "JWT signature is invalid"
83
+ end
84
+ end
85
+
86
+ def jwks_for_issuer(signing_input)
87
+ _header_segment, payload_segment = signing_input.split(".", 2)
88
+ payload = JSON.parse(Base64.urlsafe_decode64(pad(payload_segment)))
89
+ uri = jwks_uri_for_issuer(payload["iss"])
90
+
91
+ @jwks_mutex.synchronize { @jwks[uri] ||= @http.get(uri) }
92
+ rescue JSON::ParserError, ArgumentError
93
+ raise AuthenticationError, "JWT is malformed"
94
+ end
95
+
96
+ def jwks_uri_for_issuer(issuer)
97
+ return bot_framework_jwks_uri if issuer == @cloud.token_issuer
98
+ return entra_jwks_uri if @tenant_id && tenant_issuer?(issuer)
99
+
100
+ bot_framework_jwks_uri
101
+ end
102
+
103
+ def bot_framework_jwks_uri
104
+ @jwks_mutex.synchronize do
105
+ @bot_framework_jwks_uri ||= begin
106
+ metadata = @http.get(@cloud.open_id_metadata_url)
107
+ metadata.fetch("jwks_uri")
108
+ end
109
+ end
110
+ end
111
+
112
+ def entra_jwks_uri
113
+ "#{@cloud.login_endpoint}/#{@tenant_id}/discovery/v2.0/keys"
114
+ end
115
+
116
+ def valid_issuer?(issuer)
117
+ return true if issuer == @cloud.token_issuer
118
+ return false unless @tenant_id
119
+
120
+ tenant_issuer?(issuer)
121
+ end
122
+
123
+ def tenant_issuer?(issuer)
124
+ issuer == "#{@cloud.login_endpoint}/#{@tenant_id}/v2.0" ||
125
+ issuer == "https://sts.windows.net/#{@tenant_id}/"
126
+ end
127
+
128
+ def normalize_url(value)
129
+ value.to_s.sub(%r{/+\z}, "").downcase
130
+ end
131
+
132
+ def rsa_public_key(jwk)
133
+ n = OpenSSL::BN.new(Base64.urlsafe_decode64(pad(jwk.fetch("n"))), 2)
134
+ e = OpenSSL::BN.new(Base64.urlsafe_decode64(pad(jwk.fetch("e"))), 2)
135
+
136
+ sequence = OpenSSL::ASN1::Sequence([
137
+ OpenSSL::ASN1::Integer(n),
138
+ OpenSSL::ASN1::Integer(e)
139
+ ])
140
+ OpenSSL::PKey::RSA.new(sequence.to_der)
141
+ end
142
+
143
+ def pad(segment)
144
+ segment + ("=" * ((4 - segment.length % 4) % 4))
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+
6
+ module Teams
7
+ module Auth
8
+ class Token
9
+ attr_reader :value, :payload
10
+
11
+ def initialize(value)
12
+ @value = value
13
+ @payload = decode_payload(value)
14
+ end
15
+
16
+ def expired?(skew: 60)
17
+ exp = payload["exp"]
18
+ return false unless exp
19
+
20
+ Time.now.to_i >= exp.to_i - skew
21
+ end
22
+
23
+ private
24
+
25
+ def decode_payload(value)
26
+ _header, payload, = value.to_s.split(".")
27
+ return {} unless payload
28
+
29
+ JSON.parse(Base64.urlsafe_decode64(pad(payload)))
30
+ rescue JSON::ParserError, ArgumentError
31
+ {}
32
+ end
33
+
34
+ def pad(segment)
35
+ segment + ("=" * ((4 - segment.length % 4) % 4))
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Teams
6
+ module Auth
7
+ class TokenManager
8
+ attr_reader :credentials, :cloud, :http
9
+
10
+ def initialize(credentials:, cloud: PUBLIC_CLOUD, http: nil)
11
+ @credentials = credentials
12
+ @cloud = cloud
13
+ @http = http || Common::HttpClient.new
14
+ @tokens = {}
15
+ # One app-wide manager is shared across request threads; the mutex
16
+ # prevents concurrent refreshes of the same token (the other SDKs
17
+ # get this from MSAL, which locks internally).
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ def self.from_env(
22
+ client_id: ENV["CLIENT_ID"],
23
+ client_secret: ENV["CLIENT_SECRET"],
24
+ tenant_id: ENV["TENANT_ID"],
25
+ cloud: PUBLIC_CLOUD,
26
+ http: nil
27
+ )
28
+ credentials = if client_id && client_secret
29
+ ClientSecretCredentials.new(client_id:, client_secret:, tenant_id:)
30
+ end
31
+
32
+ new(credentials:, cloud:, http:)
33
+ end
34
+
35
+ def client_id
36
+ credentials&.client_id
37
+ end
38
+
39
+ def bot_token
40
+ token_for(cloud.bot_scope, credentials&.tenant_id || cloud.login_tenant)
41
+ end
42
+
43
+ def token_for(scope, tenant_id)
44
+ @mutex.synchronize do
45
+ cached = @tokens[[scope, tenant_id]]
46
+ return cached.value if cached && !cached.expired?
47
+
48
+ raise ConfigurationError, "CLIENT_ID and CLIENT_SECRET are required" unless credentials
49
+
50
+ response = http.post(
51
+ "#{cloud.login_endpoint}/#{tenant_id}/oauth2/v2.0/token",
52
+ body: URI.encode_www_form(
53
+ client_id: credentials.client_id,
54
+ client_secret: credentials.client_secret,
55
+ scope:,
56
+ grant_type: "client_credentials"
57
+ ),
58
+ headers: { "Content-Type" => "application/x-www-form-urlencoded" }
59
+ )
60
+
61
+ access_token = response.fetch("access_token")
62
+ @tokens[[scope, tenant_id]] = Token.new(access_token)
63
+ access_token
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end