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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +47 -0
- data/LICENSE +21 -0
- data/README.md +500 -0
- data/docs/README.md +40 -0
- data/docs/essentials/README.md +13 -0
- data/docs/essentials/api-client.md +62 -0
- data/docs/essentials/app-authentication.md +27 -0
- data/docs/essentials/app-basics.md +50 -0
- data/docs/essentials/graph.md +41 -0
- data/docs/essentials/on-activity.md +72 -0
- data/docs/essentials/on-event.md +29 -0
- data/docs/essentials/proactive-messaging.md +44 -0
- data/docs/essentials/sending-messages.md +76 -0
- data/docs/essentials/sovereign-cloud.md +26 -0
- data/docs/getting-started/README.md +7 -0
- data/docs/getting-started/code-basics.md +61 -0
- data/docs/getting-started/quickstart.md +54 -0
- data/docs/getting-started/running-in-teams.md +44 -0
- data/docs/in-depth-guides/README.md +14 -0
- data/docs/in-depth-guides/adaptive-cards.md +62 -0
- data/docs/in-depth-guides/dialogs.md +77 -0
- data/docs/in-depth-guides/feedback.md +29 -0
- data/docs/in-depth-guides/meeting-events.md +27 -0
- data/docs/in-depth-guides/message-extensions.md +77 -0
- data/docs/in-depth-guides/message-reactions.md +29 -0
- data/docs/in-depth-guides/observability.md +28 -0
- data/docs/in-depth-guides/streaming.md +52 -0
- data/docs/in-depth-guides/tabs.md +59 -0
- data/docs/in-depth-guides/user-authentication.md +60 -0
- data/lib/teams/activity.rb +160 -0
- data/lib/teams/activity_context.rb +278 -0
- data/lib/teams/api/account.rb +90 -0
- data/lib/teams/api/activity_value.rb +49 -0
- data/lib/teams/api/bot_sign_in_client.rb +54 -0
- data/lib/teams/api/channel_data.rb +86 -0
- data/lib/teams/api/channel_info.rb +19 -0
- data/lib/teams/api/citation_appearance.rb +83 -0
- data/lib/teams/api/client.rb +28 -0
- data/lib/teams/api/conversation_account.rb +40 -0
- data/lib/teams/api/conversation_client.rb +156 -0
- data/lib/teams/api/conversation_reference.rb +83 -0
- data/lib/teams/api/conversation_resource.rb +19 -0
- data/lib/teams/api/meeting_client.rb +57 -0
- data/lib/teams/api/meeting_info.rb +26 -0
- data/lib/teams/api/meeting_notification_response.rb +29 -0
- data/lib/teams/api/meeting_participant.rb +33 -0
- data/lib/teams/api/message_activity.rb +201 -0
- data/lib/teams/api/message_extension.rb +110 -0
- data/lib/teams/api/model.rb +49 -0
- data/lib/teams/api/notification_info.rb +28 -0
- data/lib/teams/api/paged_members_result.rb +16 -0
- data/lib/teams/api/quoted_reply_entity.rb +65 -0
- data/lib/teams/api/reaction_client.rb +34 -0
- data/lib/teams/api/sent_activity.rb +48 -0
- data/lib/teams/api/task_module.rb +83 -0
- data/lib/teams/api/team_client.rb +45 -0
- data/lib/teams/api/team_details.rb +36 -0
- data/lib/teams/api/team_info.rb +44 -0
- data/lib/teams/api/tenant_info.rb +11 -0
- data/lib/teams/api/token.rb +81 -0
- data/lib/teams/api/typing_activity.rb +19 -0
- data/lib/teams/api/user_client.rb +83 -0
- data/lib/teams/app.rb +602 -0
- data/lib/teams/auth/client_secret_credentials.rb +7 -0
- data/lib/teams/auth/jwt_validator.rb +148 -0
- data/lib/teams/auth/token.rb +39 -0
- data/lib/teams/auth/token_manager.rb +68 -0
- data/lib/teams/cards/generated.rb +1764 -0
- data/lib/teams/cards/generated_base.rb +105 -0
- data/lib/teams/cards.rb +81 -0
- data/lib/teams/cloud_environment.rb +41 -0
- data/lib/teams/common/hashes.rb +20 -0
- data/lib/teams/common/http_client.rb +111 -0
- data/lib/teams/common/retry.rb +31 -0
- data/lib/teams/errors.rb +50 -0
- data/lib/teams/function_context.rb +82 -0
- data/lib/teams/graph/client.rb +73 -0
- data/lib/teams/http_stream.rb +460 -0
- data/lib/teams/rack_app.rb +62 -0
- data/lib/teams/response.rb +9 -0
- data/lib/teams/router.rb +171 -0
- data/lib/teams/storage/memory_store.rb +27 -0
- data/lib/teams/version.rb +5 -0
- data/lib/teams.rb +70 -0
- 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
|