lex-microsoft_teams 0.5.4 → 0.5.6

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: 050cf6a6703c4b6ee4d636980ee7843a3bc34b27c533d0d1cab0325c9903ca02
4
- data.tar.gz: ffb0ca49267fc2f64b1a27c10f25e45099e2d2450839b3ddf8984efe21572d6b
3
+ metadata.gz: d414b08d6eaabf909ab4de6fe900cc879bdeda369ea386925c2c3258ed9d4a24
4
+ data.tar.gz: 8bdf0da6fb7f666eb9a4489180370e5f4e3124104e9c8736d85e9d0dbe1afbb5
5
5
  SHA512:
6
- metadata.gz: 9f93cdb1ff43c5ff3829f71de8aedca04086cf1a8efed385441a8d0920d39c7a429718e3de6035f734021921d3583ed2004648dc97bd47982cb75d6258baebc7
7
- data.tar.gz: 50327854fc1dad382f923be114d6b2c413666070f2e8cffb14c35cd467d73519654bdc7bb372fb370ed743e834d438ea4ce25663b7826c0f56a0f6bdd1b6953f
6
+ metadata.gz: 02b042dea9cc6292403cc504c6bdcfde101e63c24c7120300985a0825ea3dcf11aa988705f64ad9ff5bace61513b4b621f263740f7ce9e40e858b45123017acb
7
+ data.tar.gz: 9d09c3aa2518ccc19655d864c393f01ff2a578c7085c47ff4dfa130b1fb8dcec6a6cb465b2c3ae57787617201f06f9528006f60d6c0373860e2191c5b9a3216d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.6] - 2026-03-19
4
+
5
+ ### Added
6
+ - BrowserAuth API hook detection: uses hook URL when Legion::API is running instead of ephemeral CallbackServer
7
+ - `api_hook_available?` and `hook_redirect_uri` methods on BrowserAuth
8
+ - `authenticate_via_hook` path using `Legion::Events` for callback notification
9
+ - `authenticate_via_server` extracted from original `authenticate_browser` as fallback path
10
+
11
+ ### Changed
12
+ - `authenticate_browser` now delegates to hook path (API running) or server path (standalone)
13
+
14
+ ## [0.5.5] - 2026-03-19
15
+
16
+ ### Added
17
+ - `Hooks::Auth` hook class with `mount '/callback'` for OAuth redirect via expanded hooks system
18
+ - `Runners::Auth#auth_callback` method handling OAuth callback with HTML response and event emission
19
+ - OAuth callback now routes through `Ingress.run` for RBAC and audit support
20
+
21
+ ### Changed
22
+ - OAuth callback URL moves from hardcoded `/api/oauth/microsoft_teams/callback` to `/api/hooks/lex/microsoft_teams/auth/callback`
23
+
3
24
  ## [0.5.4] - 2026-03-19
4
25
 
5
26
  ### Added
data/CLAUDE.md CHANGED
@@ -10,14 +10,14 @@ Legion Extension that connects LegionIO to Microsoft Teams via Graph API and Bot
10
10
 
11
11
  **GitHub**: https://github.com/LegionIO/lex-microsoft_teams
12
12
  **License**: MIT
13
- **Version**: 0.5.2
13
+ **Version**: 0.5.5
14
14
 
15
15
  ## Architecture
16
16
 
17
17
  ```
18
18
  Legion::Extensions::MicrosoftTeams
19
19
  ├── Runners/
20
- │ ├── Auth # OAuth2 client credentials (Graph + Bot Framework)
20
+ │ ├── Auth # OAuth2 client credentials (Graph + Bot Framework) + auth_callback for hook
21
21
  │ ├── Teams # List/get teams, members
22
22
  │ ├── Chats # 1:1 and group chat CRUD
23
23
  │ ├── Messages # Chat message send/read/reply
@@ -36,7 +36,9 @@ Legion::Extensions::MicrosoftTeams
36
36
  │ ├── CacheSync # Every 5min: incremental ingest of new messages
37
37
  │ ├── DirectChatPoller # Every 5s: polls bot DM chats via Graph API
38
38
  │ ├── ObservedChatPoller # Every 30s: polls subscribed human conversations (compliance-gated)
39
- └── MessageProcessor # Subscription: consumes AMQP queue, routes by mode
39
+ ├── MessageProcessor # Subscription: consumes AMQP queue, routes by mode
40
+ │ ├── AuthValidator # Once: validates/restores delegated tokens on boot (2s delay)
41
+ │ └── TokenRefresher # Every 15min (configurable): keeps delegated tokens fresh
40
42
  ├── Transport/
41
43
  │ ├── Exchanges/Messages # teams.messages topic exchange
42
44
  │ ├── Queues/MessagesProcess # teams.messages.process durable queue
@@ -50,10 +52,12 @@ Legion::Extensions::MicrosoftTeams
50
52
  │ ├── HighWaterMark # Per-chat message dedup via legion-cache (with in-memory fallback)
51
53
  │ ├── PromptResolver # Layered system prompt resolution (settings -> mode -> per-conversation)
52
54
  │ ├── SessionManager # Multi-turn LLM session lifecycle with lex-memory persistence
53
- │ ├── TokenCache # In-memory OAuth token cache with pre-expiry refresh (app + delegated slots)
55
+ │ ├── TokenCache # In-memory OAuth token cache with pre-expiry refresh (app + delegated slots, authenticated?/previously_authenticated? predicates)
54
56
  │ ├── SubscriptionRegistry # Conversation observation subscriptions (in-memory + lex-memory)
55
57
  │ ├── BrowserAuth # Delegated OAuth orchestrator (PKCE, headless detection, browser launch)
56
58
  │ └── CallbackServer # Ephemeral TCP server for OAuth redirect callback
59
+ ├── Hooks/
60
+ │ └── Auth # OAuth callback hook (mount '/callback') → /api/hooks/lex/microsoft_teams/auth/callback
57
61
  └── Client # Standalone client (includes all runners)
58
62
  ```
59
63
 
@@ -64,9 +68,21 @@ Opt-in browser-based OAuth for delegated Microsoft Graph permissions. Two flows:
64
68
  - **Authorization Code + PKCE** (primary): Opens browser for Entra ID login, captures callback on ephemeral local port, exchanges code with PKCE verification
65
69
  - **Device Code** (fallback): Auto-selected in headless/SSH environments (no `DISPLAY`/`WAYLAND_DISPLAY`)
66
70
 
67
- Tokens stored in Vault (`legionio/microsoft_teams/delegated_token`) with configurable pre-expiry silent refresh. CLI command: `legion auth teams`. Sinatra route: `GET /api/oauth/microsoft_teams/callback` for daemon re-auth.
71
+ Tokens stored in Vault (`legionio/microsoft_teams/delegated_token`) with configurable pre-expiry silent refresh. CLI command: `legion auth teams`. Hook route: `GET|POST /api/hooks/lex/microsoft_teams/auth/callback` for daemon re-auth (routed through Ingress for RBAC/audit).
68
72
 
69
- Key files: `Helpers::BrowserAuth` (orchestrator), `Helpers::CallbackServer` (ephemeral TCP), `Runners::Auth` (authorize_url, exchange_code, refresh_delegated_token), `Helpers::TokenCache` (delegated slot).
73
+ Key files: `Helpers::BrowserAuth` (orchestrator), `Helpers::CallbackServer` (ephemeral TCP), `Runners::Auth` (authorize_url, exchange_code, refresh_delegated_token, auth_callback), `Helpers::TokenCache` (delegated slot), `Hooks::Auth` (hook class with mount path).
74
+
75
+ ## Token Lifecycle (v0.5.4)
76
+
77
+ Automatic delegated token management: validate on boot, refresh on a timer, re-authenticate via browser when a previously authenticated user's token expires.
78
+
79
+ - **AuthValidator** (Once actor, 2s delay): Loads token from Vault/local file on boot, attempts refresh. If refresh fails and user previously authenticated (`previously_authenticated?` — local file exists), fires BrowserAuth. Silent for users who never opted in.
80
+ - **TokenRefresher** (Every actor, 15min default): Guards with `authenticated?` (live token in memory). Refreshes and persists on each tick. On failure, same re-auth logic as AuthValidator.
81
+ - **TokenCache predicates**: `authenticated?` = live token in `@delegated_cache`. `previously_authenticated?` = local token file exists on disk. This distinction controls auto re-auth (returning users only) vs silence (never-authenticated users).
82
+
83
+ Configuration: `settings[:microsoft_teams][:auth][:delegated][:refresh_interval]` (default 900 seconds).
84
+
85
+ Design doc: `docs/plans/2026-03-19-teams-token-lifecycle-design.md`
70
86
 
71
87
  ## AI Bot (v0.2.0)
72
88
 
@@ -104,6 +120,8 @@ microsoft_teams:
104
120
  tenant_id: "..."
105
121
  client_id: "..."
106
122
  client_secret: "vault://secret/teams/client_secret"
123
+ delegated:
124
+ refresh_interval: 900 # seconds (TokenRefresher interval)
107
125
  bot:
108
126
  bot_id: "28:your-bot-id"
109
127
  direct_poll_interval: 5 # seconds
@@ -197,7 +215,7 @@ Optional framework dependencies (guarded with `defined?`, not in gemspec):
197
215
 
198
216
  ```bash
199
217
  bundle install
200
- bundle exec rspec # 185 specs (as of v0.5.2)
218
+ bundle exec rspec # 219 specs (as of v0.5.5)
201
219
  bundle exec rubocop # Clean
202
220
  ```
203
221
 
@@ -32,6 +32,19 @@ module Legion
32
32
  end
33
33
  end
34
34
 
35
+ def api_hook_available?
36
+ !!(defined?(Legion::API) && defined?(Legion::Events))
37
+ end
38
+
39
+ def hook_redirect_uri
40
+ port = if defined?(Legion::Settings)
41
+ Legion::Settings.dig(:api, :port) || 4567
42
+ else
43
+ 4567
44
+ end
45
+ "http://127.0.0.1:#{port}/api/hooks/lex/microsoft_teams/auth/callback"
46
+ end
47
+
35
48
  def generate_pkce
36
49
  verifier = SecureRandom.urlsafe_base64(32)
37
50
  challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
@@ -66,17 +79,62 @@ module Legion
66
79
  verifier, challenge = generate_pkce
67
80
  state = SecureRandom.hex(32)
68
81
 
82
+ if api_hook_available?
83
+ authenticate_via_hook(verifier: verifier, challenge: challenge, state: state)
84
+ else
85
+ authenticate_via_server(verifier: verifier, challenge: challenge, state: state)
86
+ end
87
+ end
88
+
89
+ def authenticate_via_hook(verifier:, challenge:, state:)
90
+ callback_uri = hook_redirect_uri
91
+ result_holder = { result: nil }
92
+ mutex = Mutex.new
93
+ cv = ConditionVariable.new
94
+
95
+ listener = Legion::Events.once('microsoft_teams.oauth.callback') do |event|
96
+ mutex.synchronize do
97
+ result_holder[:result] = event
98
+ cv.broadcast
99
+ end
100
+ end
101
+
102
+ url = @auth.authorize_url(
103
+ tenant_id: tenant_id, client_id: client_id,
104
+ redirect_uri: callback_uri, scope: scopes,
105
+ state: state, code_challenge: challenge
106
+ )
107
+
108
+ log_info('Opening browser for authentication (using API hook)...')
109
+ unless open_browser(url)
110
+ Legion::Events.off('microsoft_teams.oauth.callback', listener)
111
+ log_info('Could not open browser. Falling back to device code flow.')
112
+ return authenticate_device_code
113
+ end
114
+
115
+ mutex.synchronize { cv.wait(mutex, 120) unless result_holder[:result] }
116
+ result = result_holder[:result]
117
+
118
+ return { error: 'timeout', description: 'No callback received within timeout' } unless result && result[:code]
119
+
120
+ return { error: 'state_mismatch', description: 'CSRF state parameter mismatch' } unless result[:state] == state
121
+
122
+ @auth.exchange_code(
123
+ tenant_id: tenant_id, client_id: client_id,
124
+ code: result[:code], redirect_uri: callback_uri,
125
+ code_verifier: verifier, scope: scopes
126
+ )
127
+ end
128
+
129
+ def authenticate_via_server(verifier:, challenge:, state:)
69
130
  server = CallbackServer.new
70
131
  server.start
71
132
  callback_uri = server.redirect_uri
72
133
 
73
134
  url = @auth.authorize_url(
74
- tenant_id: tenant_id,
75
- client_id: client_id,
76
- redirect_uri: callback_uri,
77
- scope: scopes,
78
- state: state,
79
- code_challenge: challenge
135
+ tenant_id: tenant_id, client_id: client_id,
136
+ redirect_uri: callback_uri, scope: scopes,
137
+ state: state, code_challenge: challenge
80
138
  )
81
139
 
82
140
  log_info('Opening browser for authentication...')
@@ -92,12 +150,9 @@ module Legion
92
150
  return { error: 'state_mismatch', description: 'CSRF state parameter mismatch' } unless result[:state] == state
93
151
 
94
152
  @auth.exchange_code(
95
- tenant_id: tenant_id,
96
- client_id: client_id,
97
- code: result[:code],
98
- redirect_uri: callback_uri,
99
- code_verifier: verifier,
100
- scope: scopes
153
+ tenant_id: tenant_id, client_id: client_id,
154
+ code: result[:code], redirect_uri: callback_uri,
155
+ code_verifier: verifier, scope: scopes
101
156
  )
102
157
  ensure
103
158
  server&.shutdown
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MicrosoftTeams
6
+ module Hooks
7
+ class Auth < Legion::Extensions::Hooks::Base
8
+ mount '/callback'
9
+
10
+ def route(_headers, _payload)
11
+ :auth_callback
12
+ end
13
+
14
+ def runner_class
15
+ 'Legion::Extensions::MicrosoftTeams::Runners::Auth'
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -107,6 +107,35 @@ module Legion
107
107
  { result: response.body }
108
108
  end
109
109
 
110
+ def auth_callback(code: nil, state: nil, **)
111
+ unless code && state
112
+ return {
113
+ result: { error: 'missing_params' },
114
+ response: { status: 400, content_type: 'text/html',
115
+ body: '<html><body><h2>Missing code or state parameter</h2></body></html>' }
116
+ }
117
+ end
118
+
119
+ Legion::Events.emit('microsoft_teams.oauth.callback', code: code, state: state) if defined?(Legion::Events)
120
+
121
+ {
122
+ result: { authenticated: true, code: code, state: state },
123
+ response: { status: 200, content_type: 'text/html',
124
+ body: callback_success_html }
125
+ }
126
+ end
127
+
128
+ private
129
+
130
+ def callback_success_html
131
+ <<~HTML
132
+ <html><body style="font-family:sans-serif;text-align:center;padding:40px;">
133
+ <h2>Authentication complete</h2>
134
+ <p>You can close this window.</p>
135
+ </body></html>
136
+ HTML
137
+ end
138
+
110
139
  include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
111
140
  Legion::Extensions::Helpers.const_defined?(:Lex)
112
141
  end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module MicrosoftTeams
6
- VERSION = '0.5.4'
6
+ VERSION = '0.5.6'
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-microsoft_teams
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.4
4
+ version: 0.5.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -91,6 +91,7 @@ files:
91
91
  - lib/legion/extensions/microsoft_teams/helpers/session_manager.rb
92
92
  - lib/legion/extensions/microsoft_teams/helpers/subscription_registry.rb
93
93
  - lib/legion/extensions/microsoft_teams/helpers/token_cache.rb
94
+ - lib/legion/extensions/microsoft_teams/hooks/auth.rb
94
95
  - lib/legion/extensions/microsoft_teams/local_cache/extractor.rb
95
96
  - lib/legion/extensions/microsoft_teams/local_cache/record_parser.rb
96
97
  - lib/legion/extensions/microsoft_teams/local_cache/sstable_reader.rb