teams_rb 2.0.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3d8a62ece3135e07d8e1bd653ffbce95e706c4c318b96f69c4065f4c55b96b5
4
- data.tar.gz: 47b7b2e70aebbe6509b68cf4af7df807e1e30de1b02739494ff781589181c554
3
+ metadata.gz: 1502c7094dd98f8e69bfc496dbc8887273cda01eefb1729ff20d480f2e490d24
4
+ data.tar.gz: 13bdd7a69d783a0e7421c0ea9a2d1b6e5c364dfd89b1ccdbb38c7afa832008a6
5
5
  SHA512:
6
- metadata.gz: cb518ff2d7c087f4b2e07ac866525b9ccdc4fc5583a012c920b563b7e04b428d8523826ae13a1c7b3d2500a5584caebd529ca9fe83de6752010c43ee6e5698ca
7
- data.tar.gz: a7875d84333fea72c4c3d1ccaadc2ecd59731f3314a50a6838278db81e41945844e6f2e9d5511b987135912cb1a5695ab9a90ee3793f393d8945e1d18408d52e
6
+ metadata.gz: cceee102720b9bee0243602582c815ba0deeb3b8b23f7580c324b72b6b1a4431083ec68926677887acd4d3780520d31be30460499d9d2868b1adeb9f60ef671e
7
+ data.tar.gz: 71df520f4b46f72ec60919a9c0cf2403d5a0e7b5e7118503130a3ca9e77cd8d933a5841040078ddde03c8188a06a6db4c8e369e1c7e334237c0e6db432c74433
data/CHANGELOG.md CHANGED
@@ -6,6 +6,29 @@ All notable changes to this project are documented in this file. The format foll
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.0.3] - 2026-11-05
10
+
11
+ ### Added
12
+
13
+ - `Api::AgenticIdentity` and agentic account fields (`agentic_user_id`, `agentic_app_id`, `agentic_app_blueprint_id`, `callback_uri`, plus an `agentic_identity` helper on `Api::Account`) for Agent 365 activities, matching the TypeScript, Python, and .NET SDKs
14
+ - Inbound activity validation now accepts Entra-issued agent tokens: validated against the token's own tenant (bounded per-tenant JWKS caching) with the same three audience forms; the Bot Framework service-token path and the remote-function validation path are unchanged. Entra inbound tokens carry no `serviceurl` claim, so that check applies only to service tokens
15
+ - `CloudEnvironment#agent_bot_scope` (`https://botapi.skype.com/.default`)
16
+ - `on_agent_lifecycle` plus per-variant routes (`on_agentic_user_identity_created`/`identity_updated`/`manager_updated`/`enabled`/`disabled`/`deleted`/`undeleted`/`workload_onboarding_updated`) for Agent 365 `agentLifecycle` events, discriminated on the activity's `valueType`, with value readers for the lifecycle payload fields
17
+ - Agentic operation (experimental): inbound activities addressed to an agentic user scope the whole turn — context, sends, and streaming — to that identity; `app.agentic_identity` builds identities for proactive work, `post`/`reply`/`update` accept `agentic_identity:`, and `Api::Client#for_agentic_identity` returns an identity-scoped client. Agentic tokens use the three-step Agent 365 exchange (blueprint assertion with `fmi_path`, agentic-app client assertion, user federated-identity grant)
18
+ - GitHub Actions CI runs the test suite and gem build for pull requests and pushes to main ([#11](https://github.com/bavmind/teams_rb/pull/11), thanks [@dblock](https://github.com/dblock))
19
+
20
+ ### Changed
21
+
22
+ - Use the `jwt` gem (new runtime dependency, `~> 3.3`) for inbound JWT decoding, JWK import, RS256 verification, and standard claim validation instead of handwritten token parsing and RSA key construction ([#12](https://github.com/bavmind/teams_rb/pull/12), thanks [@dblock](https://github.com/dblock))
23
+ - Match the TypeScript SDK's signing-key cache defaults: cache up to five keys per JWKS endpoint for ten minutes, fetch uncached keys immediately without rate limiting or negative caching, and reject key-fetch failures as authentication errors ([#12](https://github.com/bavmind/teams_rb/pull/12), thanks [@dblock](https://github.com/dblock))
24
+ - Allow five minutes of clock tolerance for inbound JWT expiry and not-before validation, matching the TypeScript, Python, and .NET SDKs ([#12](https://github.com/bavmind/teams_rb/pull/12), thanks [@dblock](https://github.com/dblock))
25
+
26
+ ### Fixed
27
+
28
+ - Recover from signing-key rotation without restarting the bot instead of caching the first JWKS document indefinitely ([#14](https://github.com/bavmind/teams_rb/issues/14), [#12](https://github.com/bavmind/teams_rb/pull/12), thanks [@dblock](https://github.com/dblock))
29
+ - Avoid blocking cached signing-key validation behind slow JWKS or discovery requests while preserving coordinated successful cache misses ([#12](https://github.com/bavmind/teams_rb/pull/12), thanks [@dblock](https://github.com/dblock))
30
+ - Duplicate `signin/tokenExchange` invokes are deduplicated on the exchange id: each Teams client (desktop, web, mobile) rendering the OAuth card attempts its own silent exchange, and previously each successful one fired `on_sign_in`. Now only the first runs the exchange; duplicates answer 200 without re-emitting, an in-flight duplicate waits for the first result, and failed exchanges are not marked so retries still work. Follows the same fix in the TypeScript SDK (.NET already deduplicated)
31
+
9
32
  ## [2.0.2] - 2026-08-06
10
33
 
11
34
  ### Added
@@ -64,7 +87,8 @@ the Python SDK made when it joined the family.
64
87
  - Thread-safe token management, storage, and JWKS caching for multi-threaded Rack servers
65
88
  - Documentation mirroring the official teams-sdk docs structure (getting started, essentials, in-depth guides)
66
89
 
67
- [Unreleased]: https://github.com/bavmind/teams_rb/compare/v2.0.2...HEAD
90
+ [Unreleased]: https://github.com/bavmind/teams_rb/compare/v2.0.3...HEAD
91
+ [2.0.3]: https://github.com/bavmind/teams_rb/compare/v2.0.2...v2.0.3
68
92
  [2.0.2]: https://github.com/bavmind/teams_rb/compare/v2.0.1...v2.0.2
69
93
  [2.0.1]: https://github.com/bavmind/teams_rb/compare/v2.0.0...v2.0.1
70
94
  [2.0.0]: https://github.com/bavmind/teams_rb/releases/tag/v2.0.0
data/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # teams_rb — Teams SDK for Ruby
2
2
 
3
3
  [![Gem Version](https://img.shields.io/gem/v/teams_rb)](https://rubygems.org/gems/teams_rb)
4
+ [![CI](https://github.com/bavmind/teams_rb/actions/workflows/ci.yml/badge.svg)](https://github.com/bavmind/teams_rb/actions/workflows/ci.yml)
4
5
  [![Documentation](https://img.shields.io/badge/docs-getting_started-blue)](docs/getting-started/README.md)
5
6
 
6
7
  A Ruby-native port of the Microsoft Teams SDKs for building Teams bots and apps with Rack or Rails. It includes message routing, proactive messaging, the Bot Framework API client, typed Adaptive Cards, dialogs, message extensions, streaming, meetings, OAuth, Microsoft Graph, tabs, and remote functions.
@@ -146,6 +147,8 @@ bundle install
146
147
  bundle exec rake test
147
148
  ```
148
149
 
150
+ GitHub Actions runs `bundle exec rake ci` on pushes and pull requests using Ruby 4.0. This runs the full test suite and builds the gem; use the same command locally to check both.
151
+
149
152
  The gem is self-contained. Regenerating the typed Adaptive Card classes requires a sibling checkout of Microsoft's Python SDK; see the [Adaptive Cards guide](docs/in-depth-guides/adaptive-cards.md#regenerating).
150
153
 
151
154
  ## Questions and issues
@@ -1,19 +1,29 @@
1
1
  # App authentication
2
2
 
3
- How the app proves itself to Teams, and how Teams proves itself to the app. All of it matches the TypeScript, Python, and .NET SDKs.
3
+ How the app proves itself to Teams, and how Teams proves itself to the app.
4
4
 
5
5
  ## Inbound validation
6
6
 
7
- Every `POST /api/messages` request must carry a Bot Framework JWT. The app validates:
7
+ Every `POST /api/messages` request must carry a Bot Framework JWT. Token decoding, JWK import, RS256 signature verification, and standard claims use the established [`jwt` gem](https://github.com/jwt/ruby-jwt); Teams-specific endpoint selection, signing-key caching, and service URL checks remain in this SDK.
8
+
9
+ The app validates:
8
10
 
9
11
  - **Signature** against Microsoft's published signing keys (RS256, JWKS fetched and cached)
10
12
  - **Issuer** — the Bot Framework issuer, or your tenant's Entra issuers (v1 and v2 forms) when `TENANT_ID` is configured
11
13
  - **Audience** — your app id, in any of the three accepted forms: the bare id, `api://{id}`, `api://botid-{id}`
12
- - **Expiry / not-before**
14
+ - **Expiry / not-before** with five minutes of clock tolerance, matching the JavaScript Teams SDK
13
15
  - **`serviceurl` claim** — must match the activity's service URL, preventing spoofed routing
14
16
 
15
17
  Rejected requests get a 401 with a warn log line. Requests are rejected wholesale when no credentials are configured (with a loud startup warning) unless `dangerously_allow_unauthenticated_requests: true` is set explicitly — which is for local development only. The option can also come from the `DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS` environment variable (`true`/`false`, `1`/`0`, `yes`/`no`, or `on`/`off`); the older `skip_auth:` name still works as a deprecated alias.
16
18
 
19
+ ### Signing-key caching and rotation
20
+
21
+ The signing-key cache follows the defaults used by the [JavaScript Teams SDK](https://github.com/microsoft/teams.js/blob/3420e8899e71ed7cc74711d62f07c25e73d45b42/packages/apps/src/middleware/auth/jwt-validator.ts) and its [`jwks-rsa` 4.1.0 dependency](https://github.com/auth0/node-jwks-rsa/blob/v4.1.0/src/wrappers/cache.js): each JWKS endpoint has a least-recently-used cache of at most five public keys, indexed by JWT `kid`. Only the requested key is cached, even when a downloaded key document contains several keys.
22
+
23
+ Keys expire ten minutes after retrieval. Cache hits update recency but do not extend expiry. An uncached or expired key triggers a fresh JWKS fetch immediately, including when another key was fetched moments earlier. Misses are coordinated per JWKS endpoint, and waiting requests recheck the cache to share successful lookups. Cache reads and updates take only a brief shared mutex; HTTP requests run outside it, and Bot Framework discovery has separate synchronization. A slow key fetch or discovery request therefore does not block verification with another unexpired cached key. There is no background refresh, negative caching, retry cooldown, or fetch rate limit, matching the JavaScript SDK's current configuration. Arbitrary unknown key IDs can therefore cause repeated outbound requests; account for this when protecting your bot endpoint.
24
+
25
+ Key-fetch, discovery, and malformed key-response failures reject authentication with HTTP 401 and a warn log line through the Rack adapter. Failed lookups are not cached and may be retried immediately. Other unexpired cached keys remain usable, but expired keys are never used as a fallback during an outage. Bot Framework discovery still resolves `jwks_uri` once per validator; tenant keys still use the configured tenant endpoint. Existing issuer, audience, service URL, and RS256 checks remain in place; this matches JavaScript's cache defaults, not every feature of its authentication stack.
26
+
17
27
  ## Outbound bot tokens
18
28
 
19
29
  Replies and proactive sends authenticate with an app-only token from the Entra client-credentials flow, acquired and cached automatically (single-flight refresh with expiry skew — the other SDKs get the equivalent from MSAL). Nothing to configure beyond the three credentials.
@@ -46,6 +46,19 @@ teams.on_team_renamed { |ctx| } # also: on_team_archived, on_team_una
46
46
 
47
47
  A generic `on_conversation_update` registered before a specific route sees the activity first; declare `|ctx, nxt|` and call `nxt.call` to continue to the specific route (see Middleware below).
48
48
 
49
+ ## Agent 365 lifecycle events
50
+
51
+ `on_agent_lifecycle` matches any `agentLifecycle` event (sent when this app runs as an Agent 365 agentic user); the variants also have named routes, matched on the activity's `valueType`:
52
+
53
+ ```ruby
54
+ teams.on_agent_lifecycle { |ctx| } # any agentLifecycle event
55
+ teams.on_agentic_user_identity_created { |ctx| } # also: identity_updated, manager_updated,
56
+ teams.on_agentic_user_enabled { |ctx| } # disabled, deleted, undeleted,
57
+ # workload_onboarding_updated
58
+ ```
59
+
60
+ The event value exposes `tenant_id`, `agentic_user_id`, `agentic_app_instance_id`, `agent_identity_blueprint_id`, `version`, and per-variant fields (`manager`, `deletion_reason`, `workload_name`, …).
61
+
49
62
  ## Meeting events
50
63
 
51
64
  ```ruby
@@ -10,5 +10,6 @@ Feature guides for building richer Teams apps:
10
10
  - [Streaming](streaming.md) — chunked responses, informative updates, events, typed errors
11
11
  - [User authentication](user-authentication.md) — OAuth sign-in, token exchange, Azure setup
12
12
  - [Tabs and remote functions](tabs.md) — calling the bot backend from a tab with SSO
13
+ - [Agent 365](agent-365.md) — running as an agentic user (experimental)
13
14
  - [Meeting events](meeting-events.md) — meeting start/end
14
15
  - [Observability](observability.md) — logging and middleware
@@ -0,0 +1,46 @@
1
+ # Agent 365 (experimental)
2
+
3
+ > Agent 365 support is experimental across the whole Teams SDK family. Expect
4
+ > changes.
5
+
6
+ Agent 365 gives an app a *stacked identity*: the app registration acts as an
7
+ **agentic app blueprint** (its client id *is* the blueprint id), a tenant can
8
+ instantiate **agentic apps** from it, and each app can carry **agentic users**
9
+ — user-shaped Entra identities that appear in Teams like people.
10
+
11
+ ## Inbound
12
+
13
+ Nothing to configure. When Teams delivers an activity to an agentic user, the
14
+ identity arrives on the activity's `recipient` account and the request carries
15
+ an Entra-issued token (accepted automatically alongside classic Bot Framework
16
+ tokens). The SDK scopes the whole turn to that identity — `ctx.post`, replies,
17
+ and streaming authenticate as the agent, not the app:
18
+
19
+ ```ruby
20
+ teams.on_message do |ctx|
21
+ identity = ctx.activity.recipient.agentic_identity
22
+ ctx.log.info "acting as agentic user #{identity.agentic_user_id}" if identity
23
+ ctx.reply "Hello from your agent"
24
+ end
25
+ ```
26
+
27
+ ## Lifecycle events
28
+
29
+ Teams reports agentic-user lifecycle changes as `agentLifecycle` events — see
30
+ [activity routing](../essentials/on-activity.md) for `on_agent_lifecycle` and
31
+ the per-variant routes.
32
+
33
+ ## Proactive
34
+
35
+ Build the identity explicitly (the blueprint defaults to the app's client id,
36
+ the tenant to the configured credentials tenant) and pass it to the proactive
37
+ senders:
38
+
39
+ ```ruby
40
+ identity = teams.agentic_identity(agentic_app_id: "...", agentic_user_id: "...")
41
+ teams.post(conversation_id, "Proactive hello from the agent", agentic_identity: identity)
42
+ ```
43
+
44
+ Omitting `agentic_user_id` acts as the agentic app itself. There is no
45
+ fallback between identity kinds: if an agentic token cannot be acquired the
46
+ call fails rather than silently sending as the wrong identity.
@@ -16,6 +16,11 @@ module Teams
16
16
  raw["name"]
17
17
  end
18
18
 
19
+ # Discriminates agentLifecycle event variants (PascalCase wire values).
20
+ def value_type
21
+ raw["valueType"] || raw["value_type"]
22
+ end
23
+
19
24
  def text
20
25
  raw["text"]
21
26
  end
@@ -7,12 +7,13 @@ module Teams
7
7
  class ActivityContext
8
8
  attr_reader :app, :activity, :conversation_reference, :extra, :stream
9
9
 
10
- def initialize(app:, activity:, conversation_reference:, extra: {}, stream: nil)
10
+ def initialize(app:, activity:, conversation_reference:, extra: {}, stream: nil, api: nil)
11
11
  @app = app
12
12
  @activity = activity
13
13
  @conversation_reference = conversation_reference
14
14
  @extra = extra
15
15
  @stream = stream
16
+ @api = api
16
17
  end
17
18
 
18
19
  def ref
@@ -31,15 +32,17 @@ module Teams
31
32
  app.storage
32
33
  end
33
34
 
35
+ # The turn's API client; scoped to the activity's agentic identity when
36
+ # the app runs as an Agent 365 agent.
34
37
  def api
35
- app.api
38
+ @api || app.api
36
39
  end
37
40
 
38
41
  # The TypeScript, Python, and .NET SDKs call this operation `send`.
39
42
  # Ruby already defines Object#send for dynamic dispatch, so the public
40
43
  # Ruby API uses `post` to avoid shadowing a core language method.
41
44
  def post(activity_or_text)
42
- app.send_activity(conversation_reference, apply_targeted_defaults(activity_or_text))
45
+ app.send_activity(conversation_reference, apply_targeted_defaults(activity_or_text), api:)
43
46
  end
44
47
 
45
48
  def reply(activity_or_text)
@@ -140,7 +143,7 @@ module Teams
140
143
  ]
141
144
  }
142
145
 
143
- app.send_activity(conversation_reference, payload)
146
+ app.send_activity(conversation_reference, payload, api:)
144
147
  nil
145
148
  end
146
149
 
@@ -55,6 +55,36 @@ module Teams
55
55
  read("tenantId", "tenant_id")
56
56
  end
57
57
 
58
+ def agentic_user_id
59
+ read("agenticUserId", "agentic_user_id")
60
+ end
61
+
62
+ def agentic_app_id
63
+ read("agenticAppId", "agentic_app_id")
64
+ end
65
+
66
+ def agentic_app_blueprint_id
67
+ read("agenticAppBlueprintId", "agentic_app_blueprint_id")
68
+ end
69
+
70
+ def callback_uri
71
+ read("callbackUri", "callback_uri")
72
+ end
73
+
74
+ # The Agent 365 identity scope this account carries (agentic accounts
75
+ # arrive with role "agenticUser" on the activity's recipient); nil
76
+ # unless the account belongs to an agentic app blueprint.
77
+ def agentic_identity
78
+ return nil unless agentic_app_blueprint_id
79
+
80
+ AgenticIdentity.new(
81
+ agentic_app_blueprint_id:,
82
+ agentic_app_id:,
83
+ agentic_user_id:,
84
+ tenant_id:
85
+ )
86
+ end
87
+
58
88
  def to_h
59
89
  body = raw.dup
60
90
  body["id"] = id if id
@@ -70,6 +100,10 @@ module Teams
70
100
  body["email"] = email if email
71
101
  body["userPrincipalName"] = user_principal_name if user_principal_name
72
102
  body["tenantId"] = tenant_id if tenant_id
103
+ body["agenticUserId"] = agentic_user_id if agentic_user_id
104
+ body["agenticAppId"] = agentic_app_id if agentic_app_id
105
+ body["agenticAppBlueprintId"] = agentic_app_blueprint_id if agentic_app_blueprint_id
106
+ body["callbackUri"] = callback_uri if callback_uri
73
107
  remove_aliases(body)
74
108
  end
75
109
 
@@ -83,6 +117,10 @@ module Teams
83
117
  body.delete("given_name")
84
118
  body.delete("user_principal_name")
85
119
  body.delete("tenant_id")
120
+ body.delete("agentic_user_id")
121
+ body.delete("agentic_app_id")
122
+ body.delete("agentic_app_blueprint_id")
123
+ body.delete("callback_uri")
86
124
  body
87
125
  end
88
126
  end
@@ -73,6 +73,69 @@ module Teams
73
73
  def top
74
74
  read("top")
75
75
  end
76
+
77
+ # Agent 365 agentLifecycle event value fields. The service sends the
78
+ # agenticAppInstanceId / agentIdentityBlueprintId wire keys (not the
79
+ # account-style names); all SDKs preserve them.
80
+ def tenant_id
81
+ read("tenantId", "tenant_id")
82
+ end
83
+
84
+ def agentic_user_id
85
+ read("agenticUserId", "agentic_user_id")
86
+ end
87
+
88
+ def agentic_app_instance_id
89
+ read("agenticAppInstanceId", "agentic_app_instance_id")
90
+ end
91
+
92
+ def agent_identity_blueprint_id
93
+ read("agentIdentityBlueprintId", "agent_identity_blueprint_id")
94
+ end
95
+
96
+ def version
97
+ read("version")
98
+ end
99
+
100
+ # Variant extras: manager (identity-created carries userId/email/
101
+ # displayName; manager-updated carries managerId), deletion reason,
102
+ # workload onboarding fields, and the created identity's expiry.
103
+ def manager
104
+ value = read("manager")
105
+ value.is_a?(Hash) ? ActivityValue.new(value) : value
106
+ end
107
+
108
+ def user_id
109
+ read("userId", "user_id")
110
+ end
111
+
112
+ def email
113
+ read("email")
114
+ end
115
+
116
+ def display_name
117
+ read("displayName", "display_name")
118
+ end
119
+
120
+ def manager_id
121
+ read("managerId", "manager_id")
122
+ end
123
+
124
+ def deletion_reason
125
+ read("deletionReason", "deletion_reason")
126
+ end
127
+
128
+ def workload_name
129
+ read("workloadName", "workload_name")
130
+ end
131
+
132
+ def workload_onboarding_state
133
+ read("workloadOnboardingState", "workload_onboarding_state")
134
+ end
135
+
136
+ def expiration_date_time
137
+ read("expirationDateTime", "expiration_date_time")
138
+ end
76
139
  end
77
140
  end
78
141
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teams
4
+ module Api
5
+ # The Agent 365 identity scope for SDK operations: an agentic app
6
+ # blueprint (the app registration itself), optionally narrowed to an
7
+ # agentic app instance and an agentic user attached to that instance.
8
+ class AgenticIdentity
9
+ attr_reader :agentic_app_blueprint_id, :agentic_app_id, :agentic_user_id, :tenant_id
10
+
11
+ def initialize(agentic_app_blueprint_id:, agentic_app_id: nil, agentic_user_id: nil, tenant_id: nil)
12
+ @agentic_app_blueprint_id = agentic_app_blueprint_id
13
+ @agentic_app_id = agentic_app_id
14
+ @agentic_user_id = agentic_user_id
15
+ @tenant_id = tenant_id
16
+ end
17
+
18
+ def to_h
19
+ body = { "agenticAppBlueprintId" => agentic_app_blueprint_id }
20
+ body["agenticAppId"] = agentic_app_id if agentic_app_id
21
+ body["agenticUserId"] = agentic_user_id if agentic_user_id
22
+ body["tenantId"] = tenant_id if tenant_id
23
+ body
24
+ end
25
+ end
26
+ end
27
+ end
@@ -12,17 +12,41 @@ module Teams
12
12
  # Sub-clients are constructed eagerly like the other SDKs' ApiClient
13
13
  # constructors, which also keeps the shared client thread-safe.
14
14
  # oauth_url is the Bot Framework token service (cloud-dependent), used
15
- # by the user token and bot sign-in clients.
16
- def initialize(service_url:, http:, logger: nil, oauth_url: DEFAULT_OAUTH_URL)
15
+ # by the user token and bot sign-in clients. token_provider is a
16
+ # callable(agentic_identity) returning a bearer token; it enables
17
+ # for_agentic_identity scoping (the App wires it to its TokenManager).
18
+ def initialize(service_url:, http:, logger: nil, oauth_url: DEFAULT_OAUTH_URL, token_provider: nil)
17
19
  @service_url = service_url.sub(%r{/+\z}, "")
18
20
  @http = http
19
21
  @logger = logger
22
+ @oauth_url = oauth_url
23
+ @token_provider = token_provider
20
24
  @conversations = ConversationClient.new(service_url: @service_url, http:, logger:)
21
25
  @teams = TeamClient.new(service_url: @service_url, http:, logger:)
22
26
  @meetings = MeetingClient.new(service_url: @service_url, http:, logger:)
23
27
  @users = UserClient.new(oauth_url:, http:, logger:)
24
28
  @bots = BotClient.new(oauth_url:, http:, logger:)
25
29
  end
30
+
31
+ # Returns a client scoped to an Agent 365 identity (and optionally a
32
+ # different service URL): requests authenticate with the identity's
33
+ # agentic token instead of the app token. The other SDKs call this
34
+ # clone(); Ruby avoids shadowing Object#clone, like post vs send.
35
+ # A nil identity scopes back to the app token.
36
+ def for_agentic_identity(agentic_identity, service_url: nil)
37
+ unless @token_provider
38
+ raise Error, "This API client was constructed without a token provider; agentic scoping is unavailable."
39
+ end
40
+
41
+ identity = agentic_identity
42
+ self.class.new(
43
+ service_url: service_url || @service_url,
44
+ http: Common::HttpClient.new(token: -> { @token_provider.call(identity) }),
45
+ logger: @logger,
46
+ oauth_url: @oauth_url,
47
+ token_provider: @token_provider
48
+ )
49
+ end
26
50
  end
27
51
  end
28
52
  end
data/lib/teams/app.rb CHANGED
@@ -6,6 +6,7 @@ module Teams
6
6
  class App
7
7
  DEFAULT_MESSAGING_ENDPOINT = "/api/messages"
8
8
  DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS_ENV_VAR = "DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS"
9
+ MAX_PROCESSED_EXCHANGE_IDS = 1000
9
10
  TRUE_ENV_VALUES = %w[1 true yes on].freeze
10
11
  FALSE_ENV_VALUES = %w[0 false no off].freeze
11
12
 
@@ -48,7 +49,8 @@ module Teams
48
49
  service_url:,
49
50
  http: Common::HttpClient.new(token: -> { @token_manager.bot_token }),
50
51
  logger:,
51
- oauth_url: cloud.token_service_url
52
+ oauth_url: cloud.token_service_url,
53
+ token_provider: method(:token_for_agentic_identity)
52
54
  )
53
55
  @jwt_validator = Auth::JwtValidator.new(
54
56
  client_id: @token_manager.client_id,
@@ -235,6 +237,21 @@ module Teams
235
237
  end
236
238
  end
237
239
 
240
+ # Agent 365 agentLifecycle events plus their per-variant routes
241
+ # (on_agentic_user_identity_created, on_agentic_user_enabled, ...),
242
+ # discriminated by the activity-level valueType.
243
+ def on_agent_lifecycle(&block)
244
+ @router.on_agent_lifecycle(&block)
245
+ self
246
+ end
247
+
248
+ Router::AGENT_LIFECYCLE_EVENTS.each_key do |method_name|
249
+ define_method(method_name) do |&block|
250
+ @router.public_send(method_name, &block)
251
+ self
252
+ end
253
+ end
254
+
238
255
  # Message extension handlers (on_message_ext_query, on_message_ext_submit,
239
256
  # on_message_ext_open, ...) route the composeExtension/* invokes with the
240
257
  # TypeScript/Python route names. Handler return values (typed responses
@@ -266,11 +283,17 @@ module Teams
266
283
  validate_inbound!(env, activity)
267
284
 
268
285
  conversation_reference = Api::ConversationReference.from_activity(activity)
286
+ # Agentic activities carry the identity on the recipient account; the
287
+ # whole turn (context and stream) is scoped to it so replies
288
+ # authenticate as the agent, like the other SDKs' per-turn clients.
289
+ agentic_identity = activity.recipient.agentic_identity
290
+ turn_api = agentic_identity ? api.for_agentic_identity(agentic_identity) : api
269
291
  context = ActivityContext.new(
270
292
  app: self,
271
293
  activity:,
272
294
  conversation_reference:,
273
- stream: HttpStream.new(app: self, conversation_reference:)
295
+ api: turn_api,
296
+ stream: HttpStream.new(app: self, conversation_reference:, api: turn_api)
274
297
  )
275
298
  result = run_handlers(context)
276
299
  context.stream.close
@@ -283,21 +306,34 @@ module Teams
283
306
  Response.new(status: 200)
284
307
  end
285
308
 
309
+ # Builds an Api::AgenticIdentity for proactive agentic operations: the
310
+ # blueprint defaults to this app's client id and the tenant to the
311
+ # configured credentials tenant, like the Python SDK.
312
+ def agentic_identity(agentic_app_id: nil, agentic_user_id: nil, tenant_id: nil, agentic_app_blueprint_id: nil)
313
+ Api::AgenticIdentity.new(
314
+ agentic_app_blueprint_id: agentic_app_blueprint_id || client_id,
315
+ agentic_app_id:,
316
+ agentic_user_id:,
317
+ tenant_id: tenant_id || @token_manager.credentials&.tenant_id
318
+ )
319
+ end
320
+
286
321
  # The TypeScript, Python, and .NET SDKs call this operation `send`.
287
322
  # Ruby already defines Object#send for dynamic dispatch, so the public
288
323
  # Ruby API uses `post` to avoid shadowing a core language method.
289
- def post(conversation_id, activity_or_text, service_url: nil)
324
+ def post(conversation_id, activity_or_text, service_url: nil, agentic_identity: nil)
290
325
  assert_string!(conversation_id, "conversation_id")
291
326
 
292
327
  send_activity(
293
328
  proactive_reference(conversation_id, service_url:),
294
- activity_or_text
329
+ activity_or_text,
330
+ api: agentic_identity ? api.for_agentic_identity(agentic_identity) : api
295
331
  )
296
332
  end
297
333
 
298
334
  # Proactive threaded replies use a ";messageid=" conversation ID like the
299
335
  # TypeScript and Python SDKs; the service decides whether threading applies.
300
- def reply(conversation_id, activity_id_or_activity, activity_or_text = nil, service_url: nil)
336
+ def reply(conversation_id, activity_id_or_activity, activity_or_text = nil, service_url: nil, agentic_identity: nil)
301
337
  assert_string!(conversation_id, "conversation_id")
302
338
 
303
339
  if activity_or_text
@@ -306,23 +342,24 @@ module Teams
306
342
  post(
307
343
  Teams.to_threaded_conversation_id(conversation_id, activity_id_or_activity),
308
344
  activity_or_text,
309
- service_url:
345
+ service_url:,
346
+ agentic_identity:
310
347
  )
311
348
  else
312
- post(conversation_id, activity_id_or_activity, service_url:)
349
+ post(conversation_id, activity_id_or_activity, service_url:, agentic_identity:)
313
350
  end
314
351
  end
315
352
 
316
353
  # Sugar over post: the SDKs update by sending an activity that already
317
354
  # carries an id, and post does exactly that. See AGENTS.md.
318
- def update(conversation_id, activity_id, activity_or_text, service_url: nil)
355
+ def update(conversation_id, activity_id, activity_or_text, service_url: nil, agentic_identity: nil)
319
356
  assert_string!(conversation_id, "conversation_id")
320
357
  assert_string!(activity_id, "activity_id")
321
358
 
322
- post(conversation_id, activity_with_id(activity_id, activity_or_text), service_url:)
359
+ post(conversation_id, activity_with_id(activity_id, activity_or_text), service_url:, agentic_identity:)
323
360
  end
324
361
 
325
- def send_activity(conversation_reference, activity_or_text)
362
+ def send_activity(conversation_reference, activity_or_text, api: self.api)
326
363
  activity = activity_for_reference(conversation_reference, activity_or_text)
327
364
  targeted = targeted_activity?(activity)
328
365
 
@@ -426,7 +463,18 @@ module Teams
426
463
  def register_default_oauth_handlers
427
464
  app = self
428
465
 
429
- @router.on_signin_token_exchange do |ctx, nxt|
466
+ # Each Teams client (desktop, web, mobile) that renders the OAuth card
467
+ # attempts its own silent exchange, so one sign-in can produce several
468
+ # signin/tokenExchange invokes sharing the same value id. Only the first
469
+ # runs the exchange and fires sign-in; a duplicate of a completed
470
+ # exchange answers 200 without re-emitting or chaining, and a duplicate
471
+ # arriving while the first is in flight waits and returns its result.
472
+ # Failed exchanges are not marked, so each attempt gets its own 412.
473
+ exchange_mutex = Mutex.new
474
+ processed_exchange_ids = []
475
+ exchange_locks = {}
476
+
477
+ perform_token_exchange = lambda do |ctx, nxt|
430
478
  value = ctx.activity.value
431
479
  begin
432
480
  if value.raw["connectionName"] != app.default_connection_name
@@ -467,6 +515,43 @@ module Teams
467
515
  end
468
516
  end
469
517
 
518
+ @router.on_signin_token_exchange do |ctx, nxt|
519
+ exchange_id = ctx.activity.value.raw["id"]
520
+ next perform_token_exchange.call(ctx, nxt) unless exchange_id
521
+
522
+ lock = nil
523
+ duplicate = exchange_mutex.synchronize do
524
+ if processed_exchange_ids.include?(exchange_id)
525
+ true
526
+ else
527
+ lock = (exchange_locks[exchange_id] ||= { mutex: Mutex.new, settled: false, result: nil })
528
+ false
529
+ end
530
+ end
531
+ next Response.new(status: 200) if duplicate
532
+
533
+ outcome = lock[:mutex].synchronize do
534
+ if lock[:settled]
535
+ lock[:result]
536
+ else
537
+ result = perform_token_exchange.call(ctx, nxt)
538
+ if result.nil?
539
+ exchange_mutex.synchronize do
540
+ processed_exchange_ids << exchange_id
541
+ processed_exchange_ids.shift while processed_exchange_ids.length > MAX_PROCESSED_EXCHANGE_IDS
542
+ end
543
+ lock[:result] = Response.new(status: 200)
544
+ else
545
+ lock[:result] = result
546
+ end
547
+ lock[:settled] = true
548
+ result
549
+ end
550
+ end
551
+ exchange_mutex.synchronize { exchange_locks.delete(exchange_id) }
552
+ outcome
553
+ end
554
+
470
555
  @router.on_signin_verify_state do |ctx, nxt|
471
556
  begin
472
557
  state = ctx.activity.value.raw["state"]
@@ -562,12 +647,35 @@ module Teams
562
647
  end
563
648
  end
564
649
 
650
+ # Token dispatch for scoped API clients: no identity uses the app
651
+ # token; an identity with an agentic user uses the agentic user token;
652
+ # otherwise the agentic app token. Kinds never fall back to each other -
653
+ # failing is safer than authenticating under the wrong identity.
654
+ def token_for_agentic_identity(identity)
655
+ return @token_manager.bot_token unless identity
656
+
657
+ if identity.agentic_user_id
658
+ @token_manager.agentic_user_token(
659
+ @cloud.agent_bot_scope,
660
+ agentic_app_id: identity.agentic_app_id,
661
+ agentic_user_id: identity.agentic_user_id,
662
+ tenant_id: identity.tenant_id
663
+ )
664
+ else
665
+ @token_manager.agentic_app_token(
666
+ @cloud.agent_bot_scope,
667
+ agentic_app_id: identity.agentic_app_id,
668
+ tenant_id: identity.tenant_id
669
+ )
670
+ end
671
+ end
672
+
565
673
  def validate_inbound!(env, activity)
566
674
  return if @dangerously_allow_unauthenticated_requests
567
675
 
568
676
  raise AuthenticationError, "CLIENT_ID is required for inbound validation" unless @jwt_validator
569
677
 
570
- @jwt_validator.validate!(env["HTTP_AUTHORIZATION"], service_url: activity.service_url)
678
+ @jwt_validator.validate_inbound_activity!(env["HTTP_AUTHORIZATION"], service_url: activity.service_url)
571
679
  end
572
680
 
573
681
  def run_handlers(context)
@@ -1,56 +1,117 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
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
- 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)
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
- private
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 decode(token)
37
- header_segment, payload_segment, signature_segment = token.split(".")
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
- 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"
97
+ issuer.start_with?("#{@cloud.login_endpoint}/") || issuer.start_with?(ENTRA_V1_ISSUER_PREFIX)
46
98
  end
47
99
 
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?
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 verify_signature!(header, signing_input, signature)
75
- raise AuthenticationError, "only RS256 JWTs are supported" unless header["alg"] == "RS256"
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
- 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
140
+ cached = cached_signing_key(uri, kid)
141
+ return cached if cached
79
142
 
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"
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 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"])
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
- @jwks_mutex.synchronize { @jwks[uri] ||= @http.get(uri) }
92
- rescue JSON::ParserError, ArgumentError
93
- raise AuthenticationError, "JWT is malformed"
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
- return bot_framework_jwks_uri if issuer == @cloud.token_issuer
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
- @jwks_mutex.synchronize do
217
+ @discovery_mutex.synchronize do
105
218
  @bot_framework_jwks_uri ||= begin
106
219
  metadata = @http.get(@cloud.open_id_metadata_url)
107
- metadata.fetch("jwks_uri")
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 entra_jwks_uri
113
- "#{@cloud.login_endpoint}/#{@tenant_id}/discovery/v2.0/keys"
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 valid_issuer?(issuer)
117
- return true if issuer == @cloud.token_issuer
118
- return false unless @tenant_id
119
-
120
- tenant_issuer?(issuer)
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
- def tenant_issuer?(issuer)
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 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))
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
@@ -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
@@ -363,14 +365,14 @@ module Teams
363
365
  # Stream chunks and the streamed final carry a streaminfo entity and are
364
366
  # always created; only the timed-out in-place final routes through update.
365
367
  if body["id"] && !streaminfo_entity?(body)
366
- app.api.conversations.update_activity(
368
+ @api.conversations.update_activity(
367
369
  conversation_reference.conversation_id,
368
370
  body["id"],
369
371
  body,
370
372
  service_url: conversation_reference.service_url
371
373
  )
372
374
  else
373
- app.api.conversations.create_activity(
375
+ @api.conversations.create_activity(
374
376
  conversation_reference.conversation_id,
375
377
  body,
376
378
  service_url: conversation_reference.service_url
data/lib/teams/router.rb CHANGED
@@ -96,6 +96,31 @@ module Teams
96
96
  register("meeting_end", event_selector("application/vnd.microsoft.meetingEnd"), &block)
97
97
  end
98
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
+
99
124
  # conversationUpdate activities and their channel/team lifecycle
100
125
  # sub-events, routed by channelData.eventType with the Python method
101
126
  # names (the eventType literals are shared by all three SDKs).
@@ -190,6 +215,14 @@ module Teams
190
215
  end
191
216
  end
192
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
+
193
226
  def dialog_selector(invoke_name, key, expected)
194
227
  lambda do |activity|
195
228
  next false unless activity.invoke? && activity.name == invoke_name
data/lib/teams/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Teams
4
- VERSION = "2.0.2"
4
+ VERSION = "2.0.3"
5
5
  end
data/lib/teams.rb CHANGED
@@ -8,6 +8,7 @@ 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"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: teams_rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.2
4
+ version: 2.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Devran Cosmo Uenal
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '2.14'
40
+ - !ruby/object:Gem::Dependency
41
+ name: jwt
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.3'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.3'
40
54
  - !ruby/object:Gem::Dependency
41
55
  name: rack
42
56
  requirement: !ruby/object:Gem::Requirement
@@ -124,6 +138,7 @@ files:
124
138
  - docs/getting-started/running-in-teams.md
125
139
  - docs/in-depth-guides/README.md
126
140
  - docs/in-depth-guides/adaptive-cards.md
141
+ - docs/in-depth-guides/agent-365.md
127
142
  - docs/in-depth-guides/dialogs.md
128
143
  - docs/in-depth-guides/feedback.md
129
144
  - docs/in-depth-guides/meeting-events.md
@@ -138,6 +153,7 @@ files:
138
153
  - lib/teams/activity_context.rb
139
154
  - lib/teams/api/account.rb
140
155
  - lib/teams/api/activity_value.rb
156
+ - lib/teams/api/agentic_identity.rb
141
157
  - lib/teams/api/app_info.rb
142
158
  - lib/teams/api/bot_sign_in_client.rb
143
159
  - lib/teams/api/channel_data.rb
@@ -213,7 +229,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
213
229
  - !ruby/object:Gem::Version
214
230
  version: '0'
215
231
  requirements: []
216
- rubygems_version: 4.0.10
232
+ rubygems_version: 4.0.16
217
233
  specification_version: 4
218
234
  summary: Unofficial Ruby SDK for building Microsoft Teams bots and apps
219
235
  test_files: []