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 +4 -4
- data/CHANGELOG.md +25 -1
- data/README.md +3 -0
- data/docs/essentials/app-authentication.md +13 -3
- data/docs/essentials/on-activity.md +13 -0
- data/docs/in-depth-guides/README.md +1 -0
- data/docs/in-depth-guides/agent-365.md +46 -0
- data/lib/teams/activity.rb +5 -0
- data/lib/teams/activity_context.rb +7 -4
- data/lib/teams/api/account.rb +38 -0
- data/lib/teams/api/activity_value.rb +63 -0
- data/lib/teams/api/agentic_identity.rb +27 -0
- data/lib/teams/api/client.rb +26 -2
- data/lib/teams/app.rb +120 -12
- 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 +5 -3
- data/lib/teams/router.rb +33 -0
- data/lib/teams/version.rb +1 -1
- data/lib/teams.rb +1 -0
- metadata +18 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1502c7094dd98f8e69bfc496dbc8887273cda01eefb1729ff20d480f2e490d24
|
|
4
|
+
data.tar.gz: 13bdd7a69d783a0e7421c0ea9a2d1b6e5c364dfd89b1ccdbb38c7afa832008a6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
[](https://rubygems.org/gems/teams_rb)
|
|
4
|
+
[](https://github.com/bavmind/teams_rb/actions/workflows/ci.yml)
|
|
4
5
|
[](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.
|
|
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.
|
|
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.
|
data/lib/teams/activity.rb
CHANGED
|
@@ -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
|
|
data/lib/teams/api/account.rb
CHANGED
|
@@ -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
|
data/lib/teams/api/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 "
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
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.
|
|
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.
|
|
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: []
|