teams_rb 2.0.1 → 2.0.3
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 +4 -4
- data/CHANGELOG.md +48 -1
- data/README.md +94 -434
- data/docs/essentials/app-authentication.md +14 -4
- data/docs/essentials/app-basics.md +2 -2
- data/docs/essentials/on-activity.md +27 -0
- data/docs/essentials/sending-messages.md +1 -0
- data/docs/getting-started/quickstart.md +6 -5
- data/docs/getting-started/running-in-teams.md +37 -14
- data/docs/in-depth-guides/README.md +1 -0
- data/docs/in-depth-guides/adaptive-cards.md +17 -0
- data/docs/in-depth-guides/agent-365.md +46 -0
- data/docs/in-depth-guides/user-authentication.md +1 -1
- data/lib/teams/activity.rb +5 -0
- data/lib/teams/activity_context.rb +17 -31
- data/lib/teams/api/account.rb +38 -0
- data/lib/teams/api/activity_value.rb +92 -0
- data/lib/teams/api/agentic_identity.rb +27 -0
- data/lib/teams/api/app_info.rb +15 -0
- data/lib/teams/api/channel_data.rb +5 -0
- data/lib/teams/api/client.rb +26 -2
- data/lib/teams/api/conversation_client.rb +3 -1
- data/lib/teams/api/message_activity.rb +1 -0
- data/lib/teams/api/search.rb +41 -0
- data/lib/teams/app.rb +184 -21
- data/lib/teams/auth/jwt_validator.rb +173 -68
- data/lib/teams/auth/token_manager.rb +132 -0
- data/lib/teams/cloud_environment.rb +5 -1
- data/lib/teams/http_stream.rb +8 -3
- data/lib/teams/router.rb +71 -0
- data/lib/teams/version.rb +1 -1
- data/lib/teams.rb +3 -0
- metadata +20 -2
|
@@ -1,56 +1,117 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
4
|
-
require "json"
|
|
5
|
-
require "openssl"
|
|
3
|
+
require "jwt"
|
|
6
4
|
|
|
7
5
|
module Teams
|
|
8
6
|
module Auth
|
|
9
7
|
class JwtValidator
|
|
8
|
+
ENTRA_V1_ISSUER_PREFIX = "https://sts.windows.net/"
|
|
9
|
+
JWKS_CACHE_TTL = 10 * 60
|
|
10
|
+
JWKS_CACHE_MAX_ENTRIES = 5
|
|
11
|
+
# Endpoints are bounded so random tenant ids in unverified inbound
|
|
12
|
+
# tokens cannot grow the JWKS caches forever, like the
|
|
13
|
+
# TypeScript/Python validators.
|
|
14
|
+
MAX_JWKS_ENDPOINTS = 100
|
|
15
|
+
CLOCK_TOLERANCE = 5 * 60
|
|
16
|
+
|
|
10
17
|
def initialize(client_id:, tenant_id: nil, cloud: PUBLIC_CLOUD, http: nil)
|
|
11
18
|
@client_id = client_id
|
|
12
19
|
@tenant_id = tenant_id
|
|
13
20
|
@cloud = cloud
|
|
14
21
|
@http = http || Common::HttpClient.new
|
|
15
22
|
@jwks = {}
|
|
16
|
-
# The validator is shared across request threads; the mutex keeps
|
|
17
|
-
# concurrent cold-cache requests from fetching the JWKS repeatedly.
|
|
18
23
|
@jwks_mutex = Mutex.new
|
|
24
|
+
# Coordinate misses per endpoint without blocking cached-key reads.
|
|
25
|
+
@jwks_fetch_mutexes = {}
|
|
26
|
+
@discovery_mutex = Mutex.new
|
|
19
27
|
end
|
|
20
28
|
|
|
21
29
|
def validate!(authorization_header, service_url: nil)
|
|
30
|
+
token = bearer_token!(authorization_header)
|
|
31
|
+
payload = decode!(token, issuers: valid_issuers) do |header, claims|
|
|
32
|
+
signing_key(jwks_uri_for_issuer(claims["iss"]), header["kid"])
|
|
33
|
+
end
|
|
34
|
+
validate_service_url!(payload, service_url) if service_url
|
|
35
|
+
payload
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Validates an inbound activity token. Classic bot activities carry
|
|
39
|
+
# Bot Framework service tokens and validate exactly like validate!;
|
|
40
|
+
# Agent 365 activities carry Entra tokens audienced to the agentic app
|
|
41
|
+
# blueprint, validated against the token's own tenant. The Entra path
|
|
42
|
+
# skips the serviceurl claim: agentic inbound tokens do not carry it
|
|
43
|
+
# (upstream revisits once the platform defines a signed equivalent).
|
|
44
|
+
def validate_inbound_activity!(authorization_header, service_url: nil)
|
|
45
|
+
token = bearer_token!(authorization_header)
|
|
46
|
+
claims = unverified_claims(token)
|
|
47
|
+
return validate_entra_inbound!(token, claims) if entra_issuer?(claims["iss"])
|
|
48
|
+
|
|
49
|
+
payload = decode!(token, issuers: valid_issuers) do |header, decoded|
|
|
50
|
+
signing_key(jwks_uri_for_issuer(decoded["iss"]), header["kid"])
|
|
51
|
+
end
|
|
52
|
+
validate_service_url!(payload, service_url) if service_url
|
|
53
|
+
payload
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def bearer_token!(authorization_header)
|
|
22
59
|
raise AuthenticationError, "Authorization header is required" if authorization_header.to_s.empty?
|
|
23
60
|
|
|
24
61
|
scheme, token = authorization_header.split(" ", 2)
|
|
25
62
|
raise AuthenticationError, "Authorization must be Bearer" unless scheme&.casecmp("Bearer")&.zero? && token
|
|
26
63
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
64
|
+
token
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def decode!(token, issuers:)
|
|
68
|
+
payload, = JWT.decode(
|
|
69
|
+
token, nil, true,
|
|
70
|
+
algorithms: ["RS256"], leeway: CLOCK_TOLERANCE,
|
|
71
|
+
verify_expiration: true, verify_not_before: true,
|
|
72
|
+
verify_iss: true, iss: issuers,
|
|
73
|
+
verify_aud: true, aud: valid_audiences
|
|
74
|
+
) do |header, claims|
|
|
75
|
+
raise AuthenticationError, "JWT payload must be an object" unless claims.is_a?(Hash)
|
|
76
|
+
unless header["kid"].nil? || header["kid"].is_a?(String)
|
|
77
|
+
raise AuthenticationError, "JWT signing key ID is invalid"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
yield(header, claims)
|
|
81
|
+
end
|
|
31
82
|
payload
|
|
83
|
+
rescue JWT::DecodeError => error
|
|
84
|
+
raise AuthenticationError, "JWT validation failed: #{error.message}"
|
|
32
85
|
end
|
|
33
86
|
|
|
34
|
-
|
|
87
|
+
def unverified_claims(token)
|
|
88
|
+
claims, = JWT.decode(token, nil, false)
|
|
89
|
+
claims.is_a?(Hash) ? claims : {}
|
|
90
|
+
rescue JWT::DecodeError => error
|
|
91
|
+
raise AuthenticationError, "JWT validation failed: #{error.message}"
|
|
92
|
+
end
|
|
35
93
|
|
|
36
|
-
def
|
|
37
|
-
|
|
38
|
-
raise AuthenticationError, "JWT must contain three segments" unless signature_segment
|
|
94
|
+
def entra_issuer?(issuer)
|
|
95
|
+
return false unless issuer.is_a?(String)
|
|
39
96
|
|
|
40
|
-
|
|
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"
|
|
97
|
+
issuer.start_with?("#{@cloud.login_endpoint}/") || issuer.start_with?(ENTRA_V1_ISSUER_PREFIX)
|
|
46
98
|
end
|
|
47
99
|
|
|
48
|
-
def
|
|
49
|
-
|
|
50
|
-
raise AuthenticationError, "
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
100
|
+
def validate_entra_inbound!(token, claims)
|
|
101
|
+
tenant_id = claims["tid"]
|
|
102
|
+
raise AuthenticationError, "Entra inbound token is missing tid" if tenant_id.to_s.empty?
|
|
103
|
+
|
|
104
|
+
issuers = ["#{@cloud.login_endpoint}/#{tenant_id}/v2.0", "#{ENTRA_V1_ISSUER_PREFIX}#{tenant_id}/"]
|
|
105
|
+
# Cheap claim checks run first so junk inbound tokens cannot trigger
|
|
106
|
+
# JWKS fetches for arbitrary tenants; decode! re-verifies both after
|
|
107
|
+
# the signature.
|
|
108
|
+
raise AuthenticationError, "JWT issuer is invalid" unless issuers.include?(claims["iss"])
|
|
109
|
+
raise AuthenticationError, "JWT audience is invalid" if (Array(claims["aud"]) & valid_audiences).empty?
|
|
110
|
+
|
|
111
|
+
uri = entra_tenant_jwks_uri(tenant_id)
|
|
112
|
+
decode!(token, issuers: issuers) do |header, _decoded|
|
|
113
|
+
signing_key(uri, header["kid"])
|
|
114
|
+
end
|
|
54
115
|
end
|
|
55
116
|
|
|
56
117
|
# Inbound tokens may be audienced as the bare app id, api://{appId},
|
|
@@ -71,77 +132,121 @@ module Teams
|
|
|
71
132
|
raise AuthenticationError, "Service URL mismatch. Token: #{normalized_token_url}, Expected: #{normalized_expected_url}"
|
|
72
133
|
end
|
|
73
134
|
|
|
74
|
-
def
|
|
75
|
-
|
|
135
|
+
def signing_key(uri, kid)
|
|
136
|
+
unless kid.nil? || kid.is_a?(String)
|
|
137
|
+
raise AuthenticationError, "JWT signing key ID is invalid"
|
|
138
|
+
end
|
|
76
139
|
|
|
77
|
-
|
|
78
|
-
|
|
140
|
+
cached = cached_signing_key(uri, kid)
|
|
141
|
+
return cached if cached
|
|
79
142
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
143
|
+
fetch_mutex = @jwks_mutex.synchronize do
|
|
144
|
+
mutex = @jwks_fetch_mutexes[uri]
|
|
145
|
+
unless mutex
|
|
146
|
+
@jwks_fetch_mutexes.shift while @jwks_fetch_mutexes.length >= MAX_JWKS_ENDPOINTS
|
|
147
|
+
mutex = @jwks_fetch_mutexes[uri] = Mutex.new
|
|
148
|
+
end
|
|
149
|
+
mutex
|
|
150
|
+
end
|
|
151
|
+
fetch_mutex.synchronize do
|
|
152
|
+
cached = cached_signing_key(uri, kid)
|
|
153
|
+
return cached if cached
|
|
154
|
+
|
|
155
|
+
key = fetch_signing_key(uri, kid)
|
|
156
|
+
@jwks_mutex.synchronize do
|
|
157
|
+
cache = endpoint_cache(uri)
|
|
158
|
+
cache.shift if cache.size >= JWKS_CACHE_MAX_ENTRIES
|
|
159
|
+
cache[kid] = { key:, expires_at: monotonic_time + JWKS_CACHE_TTL }
|
|
160
|
+
end
|
|
161
|
+
key
|
|
83
162
|
end
|
|
84
163
|
end
|
|
85
164
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
165
|
+
def cached_signing_key(uri, kid)
|
|
166
|
+
@jwks_mutex.synchronize do
|
|
167
|
+
cache = endpoint_cache(uri)
|
|
168
|
+
cached = cache.delete(kid)
|
|
169
|
+
if cached && monotonic_time < cached[:expires_at]
|
|
170
|
+
cache[kid] = cached
|
|
171
|
+
cached[:key]
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Must be called while holding @jwks_mutex.
|
|
177
|
+
def endpoint_cache(uri)
|
|
178
|
+
cache = @jwks[uri]
|
|
179
|
+
unless cache
|
|
180
|
+
@jwks.shift while @jwks.length >= MAX_JWKS_ENDPOINTS
|
|
181
|
+
cache = @jwks[uri] = {}
|
|
182
|
+
end
|
|
183
|
+
cache
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def fetch_signing_key(uri, kid)
|
|
187
|
+
document = @http.get(uri)
|
|
188
|
+
unless document.is_a?(Hash) && document["keys"].is_a?(Array)
|
|
189
|
+
raise AuthenticationError, "JWT signing keys response is invalid"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
keys = document["keys"].select do |key|
|
|
193
|
+
key.is_a?(Hash) && key["kty"] == "RSA" && [nil, "sig"].include?(key["use"])
|
|
194
|
+
end
|
|
195
|
+
if kid.nil? && keys.size != 1
|
|
196
|
+
raise AuthenticationError, "JWT signing key was not found"
|
|
197
|
+
end
|
|
198
|
+
key = kid.nil? ? keys.first : keys.find { |candidate| candidate["kid"] == kid }
|
|
199
|
+
raise AuthenticationError, "JWT signing key was not found" unless key
|
|
90
200
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
201
|
+
unless [key["n"], key["e"]].all? { |value| value.is_a?(String) && !value.empty? }
|
|
202
|
+
raise AuthenticationError, "JWT signing key is invalid"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
JWT::JWK.import(key).verify_key
|
|
206
|
+
rescue HttpError, Faraday::Error, JWT::DecodeError, ArgumentError, OpenSSL::PKey::PKeyError, OpenSSL::ASN1::ASN1Error => error
|
|
207
|
+
raise AuthenticationError, "Unable to retrieve JWT signing key: #{error.message}"
|
|
94
208
|
end
|
|
95
209
|
|
|
96
210
|
def jwks_uri_for_issuer(issuer)
|
|
97
|
-
|
|
98
|
-
return entra_jwks_uri if @tenant_id && tenant_issuer?(issuer)
|
|
211
|
+
raise AuthenticationError, "JWT issuer is invalid" unless valid_issuers.include?(issuer)
|
|
99
212
|
|
|
100
|
-
bot_framework_jwks_uri
|
|
213
|
+
issuer == @cloud.token_issuer ? bot_framework_jwks_uri : entra_tenant_jwks_uri(@tenant_id)
|
|
101
214
|
end
|
|
102
215
|
|
|
103
216
|
def bot_framework_jwks_uri
|
|
104
|
-
@
|
|
217
|
+
@discovery_mutex.synchronize do
|
|
105
218
|
@bot_framework_jwks_uri ||= begin
|
|
106
219
|
metadata = @http.get(@cloud.open_id_metadata_url)
|
|
107
|
-
metadata.
|
|
220
|
+
unless metadata.is_a?(Hash) && metadata["jwks_uri"].is_a?(String) && !metadata["jwks_uri"].empty?
|
|
221
|
+
raise AuthenticationError, "JWT signing keys metadata is invalid"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
metadata["jwks_uri"]
|
|
108
225
|
end
|
|
109
226
|
end
|
|
227
|
+
rescue HttpError, Faraday::Error => error
|
|
228
|
+
raise AuthenticationError, "Unable to retrieve JWT signing keys metadata: #{error.message}"
|
|
110
229
|
end
|
|
111
230
|
|
|
112
|
-
def
|
|
113
|
-
"#{@cloud.login_endpoint}/#{
|
|
231
|
+
def entra_tenant_jwks_uri(tenant_id)
|
|
232
|
+
"#{@cloud.login_endpoint}/#{tenant_id}/discovery/v2.0/keys"
|
|
114
233
|
end
|
|
115
234
|
|
|
116
|
-
def
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
end
|
|
235
|
+
def valid_issuers
|
|
236
|
+
issuers = [@cloud.token_issuer]
|
|
237
|
+
if @tenant_id
|
|
238
|
+
issuers.concat(["#{@cloud.login_endpoint}/#{@tenant_id}/v2.0", "#{ENTRA_V1_ISSUER_PREFIX}#{@tenant_id}/"])
|
|
239
|
+
end
|
|
122
240
|
|
|
123
|
-
|
|
124
|
-
issuer == "#{@cloud.login_endpoint}/#{@tenant_id}/v2.0" ||
|
|
125
|
-
issuer == "https://sts.windows.net/#{@tenant_id}/"
|
|
241
|
+
issuers
|
|
126
242
|
end
|
|
127
243
|
|
|
128
244
|
def normalize_url(value)
|
|
129
245
|
value.to_s.sub(%r{/+\z}, "").downcase
|
|
130
246
|
end
|
|
131
247
|
|
|
132
|
-
def
|
|
133
|
-
|
|
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))
|
|
248
|
+
def monotonic_time
|
|
249
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
145
250
|
end
|
|
146
251
|
end
|
|
147
252
|
end
|
|
@@ -5,6 +5,10 @@ require "uri"
|
|
|
5
5
|
module Teams
|
|
6
6
|
module Auth
|
|
7
7
|
class TokenManager
|
|
8
|
+
TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default"
|
|
9
|
+
CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
|
10
|
+
JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
|
11
|
+
|
|
8
12
|
attr_reader :credentials, :cloud, :http
|
|
9
13
|
|
|
10
14
|
def initialize(credentials:, cloud: PUBLIC_CLOUD, http: nil)
|
|
@@ -63,6 +67,134 @@ module Teams
|
|
|
63
67
|
access_token
|
|
64
68
|
end
|
|
65
69
|
end
|
|
70
|
+
|
|
71
|
+
# Agent 365 tokens use a three-step exchange (the other SDKs delegate
|
|
72
|
+
# it to MSAL; Ruby implements the grants directly):
|
|
73
|
+
# 1. client-credentials with fmi_path=<agenticAppId> for the token
|
|
74
|
+
# exchange scope - the blueprint assertion.
|
|
75
|
+
# 2. client-credentials as client_id=<agenticAppId>, authenticated
|
|
76
|
+
# by the step-1 assertion - the agentic app token.
|
|
77
|
+
# 3. the user federated-identity grant (requested_token_use
|
|
78
|
+
# on_behalf_of, user_object_id=<agenticUserId>), asserted by a
|
|
79
|
+
# step-2 exchange token - the agentic user token.
|
|
80
|
+
# Steps 1 and 3 reproduce MSAL-internal wire forms from its documented
|
|
81
|
+
# parameters.
|
|
82
|
+
# There is deliberately no app-token fallback: failing is safer than
|
|
83
|
+
# authenticating under the wrong identity, like the other SDKs.
|
|
84
|
+
def agentic_app_token(scope, agentic_app_id:, tenant_id: nil)
|
|
85
|
+
tenant = resolve_agentic_tenant!(tenant_id)
|
|
86
|
+
raise ArgumentError, "agentic_app_id is required for agentic tokens" if agentic_app_id.to_s.empty?
|
|
87
|
+
|
|
88
|
+
@mutex.synchronize do
|
|
89
|
+
cached_token([:agentic_app, scope, agentic_app_id, tenant]) do
|
|
90
|
+
acquire_agentic_app_token(scope, agentic_app_id, tenant)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def agentic_user_token(scope, agentic_app_id:, agentic_user_id:, tenant_id: nil)
|
|
96
|
+
tenant = resolve_agentic_tenant!(tenant_id)
|
|
97
|
+
raise ArgumentError, "agentic_app_id is required for agentic tokens" if agentic_app_id.to_s.empty?
|
|
98
|
+
raise ArgumentError, "agentic_user_id is required for agentic user tokens" if agentic_user_id.to_s.empty?
|
|
99
|
+
|
|
100
|
+
@mutex.synchronize do
|
|
101
|
+
cached_token([:agentic_user, scope, agentic_app_id, agentic_user_id, tenant]) do
|
|
102
|
+
exchange_token = acquire_agentic_app_token(TOKEN_EXCHANGE_SCOPE, agentic_app_id, tenant)
|
|
103
|
+
response = http.post(
|
|
104
|
+
token_url(tenant),
|
|
105
|
+
body: URI.encode_www_form(
|
|
106
|
+
grant_type: JWT_BEARER_GRANT_TYPE,
|
|
107
|
+
client_id: agentic_app_id,
|
|
108
|
+
client_assertion_type: CLIENT_ASSERTION_TYPE,
|
|
109
|
+
client_assertion: blueprint_assertion(agentic_app_id, tenant),
|
|
110
|
+
assertion: exchange_token,
|
|
111
|
+
requested_token_use: "on_behalf_of",
|
|
112
|
+
user_object_id: agentic_user_id,
|
|
113
|
+
scope:
|
|
114
|
+
),
|
|
115
|
+
headers: FORM_HEADERS
|
|
116
|
+
)
|
|
117
|
+
fetch_access_token!(response, "Agent token exchange step 3 failed")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
FORM_HEADERS = { "Content-Type" => "application/x-www-form-urlencoded" }.freeze
|
|
125
|
+
|
|
126
|
+
def token_url(tenant_id)
|
|
127
|
+
"#{cloud.login_endpoint}/#{tenant_id}/oauth2/v2.0/token"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Agentic tenant resolution is strict: the identity's tenant or the
|
|
131
|
+
# configured credentials tenant, never the shared login tenant.
|
|
132
|
+
def resolve_agentic_tenant!(tenant_id)
|
|
133
|
+
raise ConfigurationError, "CLIENT_ID and CLIENT_SECRET are required" unless credentials
|
|
134
|
+
|
|
135
|
+
tenant = tenant_id || credentials.tenant_id
|
|
136
|
+
raise ArgumentError, "tenant_id is required for agentic tokens" if tenant.to_s.empty?
|
|
137
|
+
|
|
138
|
+
tenant
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def cached_token(key)
|
|
142
|
+
cached = @tokens[key]
|
|
143
|
+
return cached.value if cached && !cached.expired?
|
|
144
|
+
|
|
145
|
+
token = yield
|
|
146
|
+
@tokens[key] = Token.new(token)
|
|
147
|
+
token
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Step 1: the blueprint assertion - the app's own client-credentials
|
|
151
|
+
# grant carrying MSAL's federated-managed-identity path parameter.
|
|
152
|
+
# Cached like any token so multi-step flows reuse it while valid.
|
|
153
|
+
def blueprint_assertion(agentic_app_id, tenant_id)
|
|
154
|
+
cached_token([:blueprint_assertion, agentic_app_id, tenant_id]) do
|
|
155
|
+
request_blueprint_assertion(agentic_app_id, tenant_id)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def request_blueprint_assertion(agentic_app_id, tenant_id)
|
|
160
|
+
response = http.post(
|
|
161
|
+
token_url(tenant_id),
|
|
162
|
+
body: URI.encode_www_form(
|
|
163
|
+
grant_type: "client_credentials",
|
|
164
|
+
client_id: credentials.client_id,
|
|
165
|
+
client_secret: credentials.client_secret,
|
|
166
|
+
scope: TOKEN_EXCHANGE_SCOPE,
|
|
167
|
+
fmi_path: agentic_app_id
|
|
168
|
+
),
|
|
169
|
+
headers: FORM_HEADERS
|
|
170
|
+
)
|
|
171
|
+
fetch_access_token!(response, "Agent token exchange step 1 failed")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Step 2: client-credentials as the agentic app, authenticated by the
|
|
175
|
+
# step-1 assertion instead of a secret.
|
|
176
|
+
def acquire_agentic_app_token(scope, agentic_app_id, tenant_id)
|
|
177
|
+
response = http.post(
|
|
178
|
+
token_url(tenant_id),
|
|
179
|
+
body: URI.encode_www_form(
|
|
180
|
+
grant_type: "client_credentials",
|
|
181
|
+
client_id: agentic_app_id,
|
|
182
|
+
client_assertion_type: CLIENT_ASSERTION_TYPE,
|
|
183
|
+
client_assertion: blueprint_assertion(agentic_app_id, tenant_id),
|
|
184
|
+
scope:
|
|
185
|
+
),
|
|
186
|
+
headers: FORM_HEADERS
|
|
187
|
+
)
|
|
188
|
+
fetch_access_token!(response, "Agent token exchange step 2 failed")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def fetch_access_token!(response, error_prefix)
|
|
192
|
+
token = response["access_token"] if response.is_a?(Hash)
|
|
193
|
+
return token unless token.to_s.empty?
|
|
194
|
+
|
|
195
|
+
detail = response.is_a?(Hash) ? response["error_description"] || response["error"] : nil
|
|
196
|
+
raise Error, "#{error_prefix}: #{detail || "could not acquire token"}"
|
|
197
|
+
end
|
|
66
198
|
end
|
|
67
199
|
end
|
|
68
200
|
end
|
|
@@ -9,6 +9,7 @@ module Teams
|
|
|
9
9
|
:open_id_metadata_url,
|
|
10
10
|
:token_issuer,
|
|
11
11
|
:graph_scope,
|
|
12
|
+
:agent_bot_scope,
|
|
12
13
|
keyword_init: true
|
|
13
14
|
)
|
|
14
15
|
|
|
@@ -19,7 +20,10 @@ module Teams
|
|
|
19
20
|
token_service_url: "https://token.botframework.com",
|
|
20
21
|
open_id_metadata_url: "https://login.botframework.com/v1/.well-known/openidconfiguration",
|
|
21
22
|
token_issuer: "https://api.botframework.com",
|
|
22
|
-
graph_scope: "https://graph.microsoft.com/.default"
|
|
23
|
+
graph_scope: "https://graph.microsoft.com/.default",
|
|
24
|
+
# Agent ID Bot API scope; sovereign-cloud values are unconfirmed
|
|
25
|
+
# upstream too.
|
|
26
|
+
agent_bot_scope: "https://botapi.skype.com/.default"
|
|
23
27
|
)
|
|
24
28
|
|
|
25
29
|
module CloudEnvironments
|
data/lib/teams/http_stream.rb
CHANGED
|
@@ -14,9 +14,11 @@ module Teams
|
|
|
14
14
|
|
|
15
15
|
attr_reader :app, :conversation_reference
|
|
16
16
|
|
|
17
|
-
def initialize(app:, conversation_reference:)
|
|
17
|
+
def initialize(app:, conversation_reference:, api: nil)
|
|
18
18
|
@app = app
|
|
19
19
|
@conversation_reference = conversation_reference
|
|
20
|
+
# Scoped to the turn's agentic identity when the app runs as an agent.
|
|
21
|
+
@api = api || app.api
|
|
20
22
|
@mutex = Mutex.new
|
|
21
23
|
@flusher = nil
|
|
22
24
|
@flushing = false
|
|
@@ -356,18 +358,21 @@ module Teams
|
|
|
356
358
|
body = activity.dup
|
|
357
359
|
body["from"] = conversation_reference.bot.to_h if conversation_reference.bot
|
|
358
360
|
body["conversation"] = conversation_reference.conversation.to_h
|
|
361
|
+
# Every streamed activity replies to the inbound message, restoring
|
|
362
|
+
# Bot Framework v1 threading behavior.
|
|
363
|
+
body["replyToId"] = conversation_reference.activity_id if conversation_reference.activity_id
|
|
359
364
|
|
|
360
365
|
# Stream chunks and the streamed final carry a streaminfo entity and are
|
|
361
366
|
# always created; only the timed-out in-place final routes through update.
|
|
362
367
|
if body["id"] && !streaminfo_entity?(body)
|
|
363
|
-
|
|
368
|
+
@api.conversations.update_activity(
|
|
364
369
|
conversation_reference.conversation_id,
|
|
365
370
|
body["id"],
|
|
366
371
|
body,
|
|
367
372
|
service_url: conversation_reference.service_url
|
|
368
373
|
)
|
|
369
374
|
else
|
|
370
|
-
|
|
375
|
+
@api.conversations.create_activity(
|
|
371
376
|
conversation_reference.conversation_id,
|
|
372
377
|
body,
|
|
373
378
|
service_url: conversation_reference.service_url
|
data/lib/teams/router.rb
CHANGED
|
@@ -72,6 +72,12 @@ module Teams
|
|
|
72
72
|
register("message.submit", invoke_selector("message/submitAction"), &block)
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
# application/search invokes - Adaptive Card dynamic typeahead
|
|
76
|
+
# Input.ChoiceSet queries (choices.data / Data.Query).
|
|
77
|
+
def on_card_search(&block)
|
|
78
|
+
register("card.search", invoke_selector("application/search"), &block)
|
|
79
|
+
end
|
|
80
|
+
|
|
75
81
|
# message/submitAction invokes whose actionName is "feedback" - the
|
|
76
82
|
# submissions from add_feedback's thumbs up/down UI.
|
|
77
83
|
def on_message_submit_feedback(&block)
|
|
@@ -90,6 +96,57 @@ module Teams
|
|
|
90
96
|
register("meeting_end", event_selector("application/vnd.microsoft.meetingEnd"), &block)
|
|
91
97
|
end
|
|
92
98
|
|
|
99
|
+
# Agent 365 agentLifecycle events (type "event", name "agentLifecycle"),
|
|
100
|
+
# discriminated by the activity-level valueType, with the Python method
|
|
101
|
+
# names. The PascalCase valueType literals are service-owned wire values
|
|
102
|
+
# shared by all three SDKs.
|
|
103
|
+
def on_agent_lifecycle(&block)
|
|
104
|
+
register("agent_lifecycle", agent_lifecycle_selector(nil), &block)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
AGENT_LIFECYCLE_EVENTS = {
|
|
108
|
+
"on_agentic_user_identity_created" => "AgenticUserIdentityCreated",
|
|
109
|
+
"on_agentic_user_identity_updated" => "AgenticUserIdentityUpdated",
|
|
110
|
+
"on_agentic_user_manager_updated" => "AgenticUserManagerUpdated",
|
|
111
|
+
"on_agentic_user_enabled" => "AgenticUserEnabled",
|
|
112
|
+
"on_agentic_user_disabled" => "AgenticUserDisabled",
|
|
113
|
+
"on_agentic_user_deleted" => "AgenticUserDeleted",
|
|
114
|
+
"on_agentic_user_undeleted" => "AgenticUserUndeleted",
|
|
115
|
+
"on_agentic_user_workload_onboarding_updated" => "AgenticUserWorkloadOnboardingUpdated"
|
|
116
|
+
}.freeze
|
|
117
|
+
|
|
118
|
+
AGENT_LIFECYCLE_EVENTS.each do |method_name, value_type|
|
|
119
|
+
define_method(method_name) do |&block|
|
|
120
|
+
register(method_name.delete_prefix("on_"), agent_lifecycle_selector(value_type), &block)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# conversationUpdate activities and their channel/team lifecycle
|
|
125
|
+
# sub-events, routed by channelData.eventType with the Python method
|
|
126
|
+
# names (the eventType literals are shared by all three SDKs).
|
|
127
|
+
def on_conversation_update(&block)
|
|
128
|
+
register("conversation_update", ->(activity) { activity.type == "conversationUpdate" }, &block)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
CONVERSATION_UPDATE_EVENTS = {
|
|
132
|
+
"on_channel_created" => "channelCreated",
|
|
133
|
+
"on_channel_deleted" => "channelDeleted",
|
|
134
|
+
"on_channel_renamed" => "channelRenamed",
|
|
135
|
+
"on_channel_restored" => "channelRestored",
|
|
136
|
+
"on_team_archived" => "teamArchived",
|
|
137
|
+
"on_team_deleted" => "teamDeleted",
|
|
138
|
+
"on_team_hard_deleted" => "teamHardDeleted",
|
|
139
|
+
"on_team_renamed" => "teamRenamed",
|
|
140
|
+
"on_team_restored" => "teamRestored",
|
|
141
|
+
"on_team_unarchived" => "teamUnarchived"
|
|
142
|
+
}.freeze
|
|
143
|
+
|
|
144
|
+
CONVERSATION_UPDATE_EVENTS.each do |method_name, event_type|
|
|
145
|
+
define_method(method_name) do |&block|
|
|
146
|
+
register(event_type, conversation_update_selector(event_type), &block)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
93
150
|
# Message extension (compose extension) invoke routes, using the same
|
|
94
151
|
# route names as the TypeScript and Python SDKs.
|
|
95
152
|
MESSAGE_EXTENSION_ROUTES = {
|
|
@@ -152,6 +209,20 @@ module Teams
|
|
|
152
209
|
->(activity) { activity.type == "event" && activity.name == event_name }
|
|
153
210
|
end
|
|
154
211
|
|
|
212
|
+
def conversation_update_selector(event_type)
|
|
213
|
+
lambda do |activity|
|
|
214
|
+
activity.type == "conversationUpdate" && activity.channel_data.event_type == event_type
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def agent_lifecycle_selector(value_type)
|
|
219
|
+
lambda do |activity|
|
|
220
|
+
next false unless activity.type == "event" && activity.name == "agentLifecycle"
|
|
221
|
+
|
|
222
|
+
value_type.nil? || activity.value_type == value_type
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
155
226
|
def dialog_selector(invoke_name, key, expected)
|
|
156
227
|
lambda do |activity|
|
|
157
228
|
next false unless activity.invoke? && activity.name == invoke_name
|
data/lib/teams/version.rb
CHANGED
data/lib/teams.rb
CHANGED
|
@@ -8,10 +8,12 @@ require_relative "teams/cloud_environment"
|
|
|
8
8
|
require_relative "teams/cards"
|
|
9
9
|
require_relative "teams/cards/generated"
|
|
10
10
|
require_relative "teams/api/model"
|
|
11
|
+
require_relative "teams/api/agentic_identity"
|
|
11
12
|
require_relative "teams/api/account"
|
|
12
13
|
require_relative "teams/api/conversation_account"
|
|
13
14
|
require_relative "teams/api/conversation_resource"
|
|
14
15
|
require_relative "teams/api/paged_members_result"
|
|
16
|
+
require_relative "teams/api/app_info"
|
|
15
17
|
require_relative "teams/api/tenant_info"
|
|
16
18
|
require_relative "teams/api/team_info"
|
|
17
19
|
require_relative "teams/api/team_details"
|
|
@@ -27,6 +29,7 @@ require_relative "teams/api/sent_activity"
|
|
|
27
29
|
require_relative "teams/api/citation_appearance"
|
|
28
30
|
require_relative "teams/api/task_module"
|
|
29
31
|
require_relative "teams/api/message_extension"
|
|
32
|
+
require_relative "teams/api/search"
|
|
30
33
|
require_relative "teams/activity"
|
|
31
34
|
require_relative "teams/activity_context"
|
|
32
35
|
require_relative "teams/function_context"
|