lex-microsoft_teams 0.5.3 → 0.5.4

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: 6c23c0559f6761c3c19f3aea4a372e7bc4c07e0c271795cca4d8ac73a984cac4
4
- data.tar.gz: affd2bdc5020080b320816044999b23403e463290cdabdbb625bc9bf831ffb8e
3
+ metadata.gz: 050cf6a6703c4b6ee4d636980ee7843a3bc34b27c533d0d1cab0325c9903ca02
4
+ data.tar.gz: ffb0ca49267fc2f64b1a27c10f25e45099e2d2450839b3ddf8984efe21572d6b
5
5
  SHA512:
6
- metadata.gz: a2de35d93f2e6377c8eb1f078df5093c676c575fe1a65bc70ce454bd0ba94261821d771df585209d780624bb037f9a21c12a3bf729a2cff306b73628487994ab
7
- data.tar.gz: 6e739d4ab5d13bd78230860dd62920e9921138c89062afa9f113158bf1a14764727981ec666b22bb62d2dd40ef911e9c85f4399f46a1398a27bf0d5f544dff77
6
+ metadata.gz: 9f93cdb1ff43c5ff3829f71de8aedca04086cf1a8efed385441a8d0920d39c7a429718e3de6035f734021921d3583ed2004648dc97bd47982cb75d6258baebc7
7
+ data.tar.gz: 50327854fc1dad382f923be114d6b2c413666070f2e8cffb14c35cd467d73519654bdc7bb372fb370ed743e834d438ea4ce25663b7826c0f56a0f6bdd1b6953f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.4] - 2026-03-19
4
+
5
+ ### Added
6
+ - `TokenCache#authenticated?` predicate for runtime delegated token state
7
+ - `TokenCache#previously_authenticated?` predicate for persistent auth history
8
+ - `AuthValidator` actor (Once): validates and restores delegated tokens on boot
9
+ - `TokenRefresher` actor (Every, 15min configurable): keeps delegated tokens fresh
10
+ - Automatic browser re-auth when previously authenticated user's token expires
11
+ - `refresh_interval` config key at `settings[:microsoft_teams][:auth][:delegated]`
12
+
3
13
  ## [0.5.3] - 2026-03-19
4
14
 
5
15
  ### Added
@@ -0,0 +1,120 @@
1
+ # Teams Token Lifecycle Design
2
+
3
+ ## Summary
4
+
5
+ Add automatic delegated token management to lex-microsoft_teams: validate on boot, refresh on a timer, and re-authenticate via browser when a previously authenticated user's token expires.
6
+
7
+ ## Problem
8
+
9
+ Currently, delegated tokens require manual `legion auth teams` invocation. If a token expires between restarts (or the refresh token dies), nothing recovers it. Pollers silently fail because `cached_delegated_token` returns nil.
10
+
11
+ ## Approach
12
+
13
+ Two new actors + TokenCache enhancements. Follows the existing actor conventions (CacheBulkIngest is Once, DirectChatPoller is Every).
14
+
15
+ ## Components
16
+
17
+ ### TokenCache Enhancements
18
+
19
+ Two new public methods on `Helpers::TokenCache`:
20
+
21
+ - `authenticated?` — returns true when `@delegated_cache` is non-nil (live token in memory)
22
+ - `previously_authenticated?` — returns true when the local token file exists on disk (user opted in before)
23
+
24
+ The distinction: `previously_authenticated?` means "user said yes before" (file exists). `authenticated?` means "we have a live token right now." This controls whether auto re-auth fires (only for returning users) vs staying silent (never-authenticated users).
25
+
26
+ ### AuthValidator Actor (Once)
27
+
28
+ `Actor::AuthValidator < Legion::Extensions::Actors::Once`
29
+
30
+ Runs once on boot with a 2-second delay. Sequence:
31
+
32
+ 1. Create TokenCache instance
33
+ 2. Call `token_cache.load_from_vault` (tries Vault, falls back to local file)
34
+ 3. If loaded: try `cached_delegated_token` (triggers internal refresh if expired)
35
+ - Refresh succeeds: log info "Teams delegated auth restored"
36
+ - Refresh fails + `previously_authenticated?`: log warning, fire BrowserAuth
37
+ - Refresh fails + not previously authenticated: silent (user never opted in)
38
+ 4. If nothing loaded: check `previously_authenticated?`
39
+ - True: log warning, fire BrowserAuth (file corrupt or unloadable)
40
+ - False: log debug "No Teams delegated auth configured" — silent
41
+
42
+ Does NOT touch the app token (client_credentials). That is handled lazily by `cached_graph_token` in the pollers.
43
+
44
+ ### TokenRefresher Actor (Every)
45
+
46
+ `Actor::TokenRefresher < Legion::Extensions::Actors::Every`
47
+
48
+ Runs every 15 minutes (configurable). Each tick:
49
+
50
+ 1. Guard: `return unless token_cache.authenticated?`
51
+ 2. Call `cached_delegated_token` (internally refreshes if within 60s of expiry)
52
+ 3. If token returned: `save_to_vault` (persists to local file + optional Vault). Done.
53
+ 4. If nil (refresh failed):
54
+ - `previously_authenticated?` true: log warning, fire BrowserAuth
55
+ - Otherwise: do nothing (delegated_cache already nil)
56
+
57
+ `run_now?` = false (AuthValidator handles the initial check).
58
+
59
+ ### BrowserAuth Trigger
60
+
61
+ Both actors use the same private `attempt_browser_reauth` method:
62
+
63
+ 1. Read tenant_id, client_id, scopes from settings
64
+ 2. Log warning: "Delegated token expired, opening browser for re-authentication..."
65
+ 3. Create `BrowserAuth.new(...)` and call `authenticate`
66
+ 4. On success: `store_delegated_token` + `save_to_vault`
67
+ 5. On failure: log error, return false
68
+
69
+ BrowserAuth already detects headless environments (no DISPLAY/WAYLAND) and falls back to device code flow. No special handling needed.
70
+
71
+ Both actors define this method privately. No shared module — it is ~20 lines, used in two places, and a premature abstraction would add complexity for no gain.
72
+
73
+ ### Shared TokenCache Instance
74
+
75
+ AuthValidator and TokenRefresher each create their own TokenCache instance. This is fine because the local file is the source of truth. AuthValidator loads on boot, TokenRefresher refreshes and saves back to the file on each tick.
76
+
77
+ ## Configuration
78
+
79
+ Settings path: `Legion::Settings[:microsoft_teams][:auth][:delegated]`
80
+
81
+ New key:
82
+
83
+ | Key | Type | Default | Purpose |
84
+ |-----|------|---------|---------|
85
+ | `refresh_interval` | Integer (seconds) | 900 | TokenRefresher polling interval |
86
+
87
+ Existing keys unchanged: `refresh_buffer`, `scopes`, `vault_path`, `local_token_path`.
88
+
89
+ ## Testing
90
+
91
+ ### TokenCache Specs
92
+ - `authenticated?` returns false with no cache, true after store
93
+ - `previously_authenticated?` returns false with no file, true after save_to_local
94
+
95
+ ### AuthValidator Specs
96
+ - Loads token and refreshes successfully (log info)
97
+ - Loads token, refresh fails, previously authed -> triggers browser reauth
98
+ - Loads token, refresh fails, never authed -> silent
99
+ - No token file exists -> silent
100
+
101
+ ### TokenRefresher Specs
102
+ - Skips when not authenticated
103
+ - Refreshes successfully and saves
104
+ - Refresh fails, previously authed -> triggers browser reauth
105
+
106
+ ### Actor Patterns
107
+ - Stub base classes with `$LOADED_FEATURES` injection + `described_class.allocate`
108
+ - Stub TokenCache and BrowserAuth (no real network calls)
109
+
110
+ ## Files Changed
111
+
112
+ | File | Change |
113
+ |------|--------|
114
+ | `helpers/token_cache.rb` | Add `authenticated?`, `previously_authenticated?` |
115
+ | `actors/auth_validator.rb` | New file |
116
+ | `actors/token_refresher.rb` | New file |
117
+ | `spec/.../helpers/token_cache_spec.rb` | Add 4 specs |
118
+ | `spec/.../actors/auth_validator_spec.rb` | New file |
119
+ | `spec/.../actors/token_refresher_spec.rb` | New file |
120
+ | `microsoft_teams.rb` | Require new actor files |
@@ -0,0 +1,679 @@
1
+ # Teams Token Lifecycle Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Add automatic delegated token validation on boot, periodic refresh, and browser re-auth for previously authenticated users.
6
+
7
+ **Architecture:** Two new actors (AuthValidator/Once, TokenRefresher/Every) plus two new methods on TokenCache. AuthValidator loads tokens on startup and recovers expired sessions. TokenRefresher keeps tokens fresh on a configurable 15-minute interval. Both trigger BrowserAuth for re-auth when a previously authenticated user's token cannot be refreshed.
8
+
9
+ **Tech Stack:** Ruby, RSpec, lex-microsoft_teams actor/helper conventions
10
+
11
+ ---
12
+
13
+ ### Task 1: Add `authenticated?` and `previously_authenticated?` to TokenCache
14
+
15
+ **Files:**
16
+ - Modify: `lib/legion/extensions/microsoft_teams/helpers/token_cache.rb:38-63`
17
+ - Test: `spec/legion/extensions/microsoft_teams/helpers/token_cache_spec.rb`
18
+
19
+ **Step 1: Write the failing tests**
20
+
21
+ Add to the end of `token_cache_spec.rb`, before the final `end`:
22
+
23
+ ```ruby
24
+ describe '#authenticated?' do
25
+ it 'returns false when no delegated token is cached' do
26
+ expect(cache.authenticated?).to be false
27
+ end
28
+
29
+ it 'returns true when a delegated token is stored' do
30
+ cache.store_delegated_token(
31
+ access_token: 'tok', refresh_token: 'ref',
32
+ expires_in: 3600, scopes: 'scope1'
33
+ )
34
+ expect(cache.authenticated?).to be true
35
+ end
36
+
37
+ it 'returns false after clearing delegated token' do
38
+ cache.store_delegated_token(
39
+ access_token: 'tok', refresh_token: 'ref',
40
+ expires_in: 3600, scopes: 'scope1'
41
+ )
42
+ cache.clear_delegated_token!
43
+ expect(cache.authenticated?).to be false
44
+ end
45
+ end
46
+
47
+ describe '#previously_authenticated?' do
48
+ it 'returns false when no local file exists' do
49
+ expect(cache.previously_authenticated?).to be false
50
+ end
51
+
52
+ it 'returns true after save_to_local' do
53
+ cache.store_delegated_token(
54
+ access_token: 'tok', refresh_token: 'ref',
55
+ expires_in: 3600, scopes: 'scope1'
56
+ )
57
+ cache.save_to_local
58
+ expect(cache.previously_authenticated?).to be true
59
+ end
60
+ end
61
+ ```
62
+
63
+ **Step 2: Run tests to verify they fail**
64
+
65
+ Run: `cd extensions/lex-microsoft_teams && bundle exec rspec spec/legion/extensions/microsoft_teams/helpers/token_cache_spec.rb -v`
66
+ Expected: FAIL — `NoMethodError: undefined method 'authenticated?'`
67
+
68
+ **Step 3: Write minimal implementation**
69
+
70
+ Add these two methods to `token_cache.rb` after `clear_delegated_token!` (around line 63), before the `load_from_vault` method:
71
+
72
+ ```ruby
73
+ def authenticated?
74
+ @mutex.synchronize { !@delegated_cache.nil? }
75
+ end
76
+
77
+ def previously_authenticated?
78
+ File.exist?(local_token_path)
79
+ end
80
+ ```
81
+
82
+ **Step 4: Run tests to verify they pass**
83
+
84
+ Run: `cd extensions/lex-microsoft_teams && bundle exec rspec spec/legion/extensions/microsoft_teams/helpers/token_cache_spec.rb -v`
85
+ Expected: All pass (including existing specs)
86
+
87
+ **Step 5: Run rubocop**
88
+
89
+ Run: `cd extensions/lex-microsoft_teams && bundle exec rubocop lib/legion/extensions/microsoft_teams/helpers/token_cache.rb`
90
+ Expected: No offenses
91
+
92
+ **Step 6: Commit**
93
+
94
+ ```bash
95
+ cd extensions/lex-microsoft_teams
96
+ git add lib/legion/extensions/microsoft_teams/helpers/token_cache.rb spec/legion/extensions/microsoft_teams/helpers/token_cache_spec.rb
97
+ git commit -m "add authenticated? and previously_authenticated? to TokenCache"
98
+ ```
99
+
100
+ ---
101
+
102
+ ### Task 2: Create AuthValidator actor
103
+
104
+ **Files:**
105
+ - Create: `lib/legion/extensions/microsoft_teams/actors/auth_validator.rb`
106
+ - Test: `spec/legion/extensions/microsoft_teams/actors/auth_validator_spec.rb`
107
+
108
+ **Step 1: Write the spec file**
109
+
110
+ Create `spec/legion/extensions/microsoft_teams/actors/auth_validator_spec.rb`:
111
+
112
+ ```ruby
113
+ # frozen_string_literal: true
114
+
115
+ require 'spec_helper'
116
+
117
+ unless defined?(Legion::Extensions::Actors::Once)
118
+ module Legion
119
+ module Extensions
120
+ module Actors
121
+ class Once; end # rubocop:disable Lint/EmptyClass
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ $LOADED_FEATURES << 'legion/extensions/actors/once' unless $LOADED_FEATURES.include?('legion/extensions/actors/once')
128
+
129
+ require 'legion/extensions/microsoft_teams/actors/auth_validator'
130
+
131
+ RSpec.describe Legion::Extensions::MicrosoftTeams::Actor::AuthValidator do
132
+ subject(:actor) { described_class.allocate }
133
+
134
+ let(:token_cache) { instance_double(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache) }
135
+ let(:browser_auth) { instance_double(Legion::Extensions::MicrosoftTeams::Helpers::BrowserAuth) }
136
+
137
+ before do
138
+ allow(actor).to receive(:token_cache).and_return(token_cache)
139
+ end
140
+
141
+ it 'has a 2 second delay' do
142
+ expect(actor.delay).to eq(2.0)
143
+ end
144
+
145
+ it 'does not generate tasks' do
146
+ expect(actor.generate_task?).to be false
147
+ end
148
+
149
+ it 'does not check subtasks' do
150
+ expect(actor.check_subtask?).to be false
151
+ end
152
+
153
+ describe '#manual' do
154
+ before do
155
+ allow(token_cache).to receive(:previously_authenticated?).and_return(false)
156
+ end
157
+
158
+ context 'when token loads and refreshes successfully' do
159
+ before do
160
+ allow(token_cache).to receive(:load_from_vault).and_return(true)
161
+ allow(token_cache).to receive(:cached_delegated_token).and_return('valid-token')
162
+ end
163
+
164
+ it 'logs success and does not trigger browser auth' do
165
+ expect(actor).not_to receive(:attempt_browser_reauth)
166
+ actor.manual
167
+ end
168
+ end
169
+
170
+ context 'when token loads but refresh fails and previously authenticated' do
171
+ before do
172
+ allow(token_cache).to receive(:load_from_vault).and_return(true)
173
+ allow(token_cache).to receive(:cached_delegated_token).and_return(nil)
174
+ allow(token_cache).to receive(:previously_authenticated?).and_return(true)
175
+ allow(actor).to receive(:attempt_browser_reauth).and_return(true)
176
+ end
177
+
178
+ it 'triggers browser re-auth' do
179
+ actor.manual
180
+ expect(actor).to have_received(:attempt_browser_reauth)
181
+ end
182
+ end
183
+
184
+ context 'when token loads but refresh fails and never authenticated' do
185
+ before do
186
+ allow(token_cache).to receive(:load_from_vault).and_return(true)
187
+ allow(token_cache).to receive(:cached_delegated_token).and_return(nil)
188
+ allow(token_cache).to receive(:previously_authenticated?).and_return(false)
189
+ end
190
+
191
+ it 'does not trigger browser re-auth' do
192
+ expect(actor).not_to receive(:attempt_browser_reauth)
193
+ actor.manual
194
+ end
195
+ end
196
+
197
+ context 'when no token file exists' do
198
+ before do
199
+ allow(token_cache).to receive(:load_from_vault).and_return(false)
200
+ allow(token_cache).to receive(:previously_authenticated?).and_return(false)
201
+ end
202
+
203
+ it 'does nothing silently' do
204
+ expect(actor).not_to receive(:attempt_browser_reauth)
205
+ actor.manual
206
+ end
207
+ end
208
+
209
+ context 'when no token loads but previously authenticated' do
210
+ before do
211
+ allow(token_cache).to receive(:load_from_vault).and_return(false)
212
+ allow(token_cache).to receive(:previously_authenticated?).and_return(true)
213
+ allow(actor).to receive(:attempt_browser_reauth).and_return(true)
214
+ end
215
+
216
+ it 'triggers browser re-auth' do
217
+ actor.manual
218
+ expect(actor).to have_received(:attempt_browser_reauth)
219
+ end
220
+ end
221
+ end
222
+ end
223
+ ```
224
+
225
+ **Step 2: Run test to verify it fails**
226
+
227
+ Run: `cd extensions/lex-microsoft_teams && bundle exec rspec spec/legion/extensions/microsoft_teams/actors/auth_validator_spec.rb -v`
228
+ Expected: FAIL — `LoadError: cannot load such file -- legion/extensions/microsoft_teams/actors/auth_validator`
229
+
230
+ **Step 3: Write the actor**
231
+
232
+ Create `lib/legion/extensions/microsoft_teams/actors/auth_validator.rb`:
233
+
234
+ ```ruby
235
+ # frozen_string_literal: true
236
+
237
+ module Legion
238
+ module Extensions
239
+ module MicrosoftTeams
240
+ module Actor
241
+ class AuthValidator < Legion::Extensions::Actors::Once
242
+ def use_runner? = false
243
+ def check_subtask? = false
244
+ def generate_task? = false
245
+
246
+ def delay
247
+ 2.0
248
+ end
249
+
250
+ def enabled?
251
+ defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
252
+ rescue StandardError
253
+ false
254
+ end
255
+
256
+ def token_cache
257
+ @token_cache ||= Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.new
258
+ end
259
+
260
+ def manual
261
+ loaded = token_cache.load_from_vault
262
+
263
+ if loaded
264
+ token = token_cache.cached_delegated_token
265
+ if token
266
+ log_info('Teams delegated auth restored')
267
+ elsif token_cache.previously_authenticated?
268
+ attempt_browser_reauth(token_cache)
269
+ end
270
+ elsif token_cache.previously_authenticated?
271
+ log_warn('Token file found but could not load, attempting re-authentication')
272
+ attempt_browser_reauth(token_cache)
273
+ else
274
+ log_debug('No Teams delegated auth configured, skipping')
275
+ end
276
+ rescue StandardError => e
277
+ log_error("AuthValidator: #{e.message}")
278
+ end
279
+
280
+ private
281
+
282
+ def attempt_browser_reauth(tc)
283
+ settings = teams_auth_settings
284
+ return false unless settings[:tenant_id] && settings[:client_id]
285
+
286
+ log_warn('Delegated token expired, opening browser for re-authentication...')
287
+
288
+ scopes = settings.dig(:delegated, :scopes) ||
289
+ Legion::Extensions::MicrosoftTeams::Helpers::BrowserAuth::DEFAULT_SCOPES
290
+ browser_auth = Legion::Extensions::MicrosoftTeams::Helpers::BrowserAuth.new(
291
+ tenant_id: settings[:tenant_id],
292
+ client_id: settings[:client_id],
293
+ scopes: scopes
294
+ )
295
+
296
+ result = browser_auth.authenticate
297
+ return false if result[:error]
298
+
299
+ body = result[:result]
300
+ tc.store_delegated_token(
301
+ access_token: body['access_token'],
302
+ refresh_token: body['refresh_token'],
303
+ expires_in: body['expires_in'],
304
+ scopes: scopes
305
+ )
306
+ tc.save_to_vault
307
+ log_info('Teams delegated auth restored via browser')
308
+ true
309
+ rescue StandardError => e
310
+ log_error("Browser re-auth failed: #{e.message}")
311
+ false
312
+ end
313
+
314
+ def teams_auth_settings
315
+ return {} unless defined?(Legion::Settings)
316
+
317
+ Legion::Settings.dig(:microsoft_teams, :auth) || {}
318
+ end
319
+
320
+ def log_info(msg)
321
+ Legion::Logging.info(msg) if defined?(Legion::Logging)
322
+ end
323
+
324
+ def log_warn(msg)
325
+ Legion::Logging.warn(msg) if defined?(Legion::Logging)
326
+ end
327
+
328
+ def log_debug(msg)
329
+ Legion::Logging.debug(msg) if defined?(Legion::Logging)
330
+ end
331
+
332
+ def log_error(msg)
333
+ Legion::Logging.error(msg) if defined?(Legion::Logging)
334
+ end
335
+ end
336
+ end
337
+ end
338
+ end
339
+ end
340
+ ```
341
+
342
+ **Step 4: Run tests to verify they pass**
343
+
344
+ Run: `cd extensions/lex-microsoft_teams && bundle exec rspec spec/legion/extensions/microsoft_teams/actors/auth_validator_spec.rb -v`
345
+ Expected: All pass
346
+
347
+ **Step 5: Run rubocop**
348
+
349
+ Run: `cd extensions/lex-microsoft_teams && bundle exec rubocop lib/legion/extensions/microsoft_teams/actors/auth_validator.rb`
350
+ Expected: No offenses
351
+
352
+ **Step 6: Commit**
353
+
354
+ ```bash
355
+ cd extensions/lex-microsoft_teams
356
+ git add lib/legion/extensions/microsoft_teams/actors/auth_validator.rb spec/legion/extensions/microsoft_teams/actors/auth_validator_spec.rb
357
+ git commit -m "add AuthValidator actor for boot-time token validation"
358
+ ```
359
+
360
+ ---
361
+
362
+ ### Task 3: Create TokenRefresher actor
363
+
364
+ **Files:**
365
+ - Create: `lib/legion/extensions/microsoft_teams/actors/token_refresher.rb`
366
+ - Test: `spec/legion/extensions/microsoft_teams/actors/token_refresher_spec.rb`
367
+
368
+ **Step 1: Write the spec file**
369
+
370
+ Create `spec/legion/extensions/microsoft_teams/actors/token_refresher_spec.rb`:
371
+
372
+ ```ruby
373
+ # frozen_string_literal: true
374
+
375
+ require 'spec_helper'
376
+
377
+ unless defined?(Legion::Extensions::Actors::Every)
378
+ module Legion
379
+ module Extensions
380
+ module Actors
381
+ class Every; end # rubocop:disable Lint/EmptyClass
382
+ end
383
+ end
384
+ end
385
+ end
386
+
387
+ $LOADED_FEATURES << 'legion/extensions/actors/every' unless $LOADED_FEATURES.include?('legion/extensions/actors/every')
388
+
389
+ require 'legion/extensions/microsoft_teams/actors/token_refresher'
390
+
391
+ RSpec.describe Legion::Extensions::MicrosoftTeams::Actor::TokenRefresher do
392
+ subject(:actor) { described_class.allocate }
393
+
394
+ let(:token_cache) { instance_double(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache) }
395
+
396
+ before do
397
+ allow(actor).to receive(:token_cache).and_return(token_cache)
398
+ end
399
+
400
+ it 'has a default 900 second interval' do
401
+ expect(actor.time).to eq(900)
402
+ end
403
+
404
+ it 'does not run immediately on start' do
405
+ expect(actor.run_now?).to be false
406
+ end
407
+
408
+ it 'does not generate tasks' do
409
+ expect(actor.generate_task?).to be false
410
+ end
411
+
412
+ it 'does not check subtasks' do
413
+ expect(actor.check_subtask?).to be false
414
+ end
415
+
416
+ describe '#manual' do
417
+ context 'when not authenticated' do
418
+ before do
419
+ allow(token_cache).to receive(:authenticated?).and_return(false)
420
+ end
421
+
422
+ it 'skips refresh entirely' do
423
+ expect(token_cache).not_to receive(:cached_delegated_token)
424
+ actor.manual
425
+ end
426
+ end
427
+
428
+ context 'when authenticated and refresh succeeds' do
429
+ before do
430
+ allow(token_cache).to receive(:authenticated?).and_return(true)
431
+ allow(token_cache).to receive(:cached_delegated_token).and_return('refreshed-token')
432
+ allow(token_cache).to receive(:save_to_vault)
433
+ end
434
+
435
+ it 'saves the refreshed token' do
436
+ actor.manual
437
+ expect(token_cache).to have_received(:save_to_vault)
438
+ end
439
+ end
440
+
441
+ context 'when authenticated but refresh fails and previously authenticated' do
442
+ before do
443
+ allow(token_cache).to receive(:authenticated?).and_return(true)
444
+ allow(token_cache).to receive(:cached_delegated_token).and_return(nil)
445
+ allow(token_cache).to receive(:previously_authenticated?).and_return(true)
446
+ allow(actor).to receive(:attempt_browser_reauth).and_return(true)
447
+ end
448
+
449
+ it 'triggers browser re-auth' do
450
+ actor.manual
451
+ expect(actor).to have_received(:attempt_browser_reauth)
452
+ end
453
+ end
454
+
455
+ context 'when authenticated but refresh fails and never previously authenticated' do
456
+ before do
457
+ allow(token_cache).to receive(:authenticated?).and_return(true)
458
+ allow(token_cache).to receive(:cached_delegated_token).and_return(nil)
459
+ allow(token_cache).to receive(:previously_authenticated?).and_return(false)
460
+ end
461
+
462
+ it 'does not trigger browser re-auth' do
463
+ expect(actor).not_to receive(:attempt_browser_reauth)
464
+ actor.manual
465
+ end
466
+ end
467
+ end
468
+ end
469
+ ```
470
+
471
+ **Step 2: Run test to verify it fails**
472
+
473
+ Run: `cd extensions/lex-microsoft_teams && bundle exec rspec spec/legion/extensions/microsoft_teams/actors/token_refresher_spec.rb -v`
474
+ Expected: FAIL — `LoadError: cannot load such file`
475
+
476
+ **Step 3: Write the actor**
477
+
478
+ Create `lib/legion/extensions/microsoft_teams/actors/token_refresher.rb`:
479
+
480
+ ```ruby
481
+ # frozen_string_literal: true
482
+
483
+ module Legion
484
+ module Extensions
485
+ module MicrosoftTeams
486
+ module Actor
487
+ class TokenRefresher < Legion::Extensions::Actors::Every
488
+ DEFAULT_REFRESH_INTERVAL = 900 # 15 minutes
489
+
490
+ def runner_class = Legion::Extensions::MicrosoftTeams::Helpers::TokenCache
491
+ def runner_function = 'cached_delegated_token'
492
+ def run_now? = false
493
+ def use_runner? = false
494
+ def check_subtask? = false
495
+ def generate_task? = false
496
+
497
+ def time
498
+ settings = teams_auth_settings
499
+ delegated = settings[:delegated]
500
+ return DEFAULT_REFRESH_INTERVAL unless delegated.is_a?(Hash)
501
+
502
+ delegated[:refresh_interval] || DEFAULT_REFRESH_INTERVAL
503
+ end
504
+
505
+ def enabled?
506
+ defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
507
+ rescue StandardError
508
+ false
509
+ end
510
+
511
+ def token_cache
512
+ @token_cache ||= Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.new
513
+ end
514
+
515
+ def manual
516
+ return unless token_cache.authenticated?
517
+
518
+ token = token_cache.cached_delegated_token
519
+ if token
520
+ token_cache.save_to_vault
521
+ elsif token_cache.previously_authenticated?
522
+ attempt_browser_reauth(token_cache)
523
+ end
524
+ rescue StandardError => e
525
+ log_error("TokenRefresher: #{e.message}")
526
+ end
527
+
528
+ private
529
+
530
+ def attempt_browser_reauth(tc)
531
+ settings = teams_auth_settings
532
+ return false unless settings[:tenant_id] && settings[:client_id]
533
+
534
+ log_warn('Delegated token expired, opening browser for re-authentication...')
535
+
536
+ scopes = settings.dig(:delegated, :scopes) ||
537
+ Legion::Extensions::MicrosoftTeams::Helpers::BrowserAuth::DEFAULT_SCOPES
538
+ browser_auth = Legion::Extensions::MicrosoftTeams::Helpers::BrowserAuth.new(
539
+ tenant_id: settings[:tenant_id],
540
+ client_id: settings[:client_id],
541
+ scopes: scopes
542
+ )
543
+
544
+ result = browser_auth.authenticate
545
+ return false if result[:error]
546
+
547
+ body = result[:result]
548
+ tc.store_delegated_token(
549
+ access_token: body['access_token'],
550
+ refresh_token: body['refresh_token'],
551
+ expires_in: body['expires_in'],
552
+ scopes: scopes
553
+ )
554
+ tc.save_to_vault
555
+ log_info('Teams delegated auth restored via browser')
556
+ true
557
+ rescue StandardError => e
558
+ log_error("Browser re-auth failed: #{e.message}")
559
+ false
560
+ end
561
+
562
+ def teams_auth_settings
563
+ return {} unless defined?(Legion::Settings)
564
+
565
+ Legion::Settings.dig(:microsoft_teams, :auth) || {}
566
+ end
567
+
568
+ def log_info(msg)
569
+ Legion::Logging.info(msg) if defined?(Legion::Logging)
570
+ end
571
+
572
+ def log_warn(msg)
573
+ Legion::Logging.warn(msg) if defined?(Legion::Logging)
574
+ end
575
+
576
+ def log_error(msg)
577
+ Legion::Logging.error(msg) if defined?(Legion::Logging)
578
+ end
579
+ end
580
+ end
581
+ end
582
+ end
583
+ end
584
+ ```
585
+
586
+ **Step 4: Run tests to verify they pass**
587
+
588
+ Run: `cd extensions/lex-microsoft_teams && bundle exec rspec spec/legion/extensions/microsoft_teams/actors/token_refresher_spec.rb -v`
589
+ Expected: All pass
590
+
591
+ **Step 5: Run rubocop**
592
+
593
+ Run: `cd extensions/lex-microsoft_teams && bundle exec rubocop lib/legion/extensions/microsoft_teams/actors/token_refresher.rb`
594
+ Expected: No offenses
595
+
596
+ **Step 6: Commit**
597
+
598
+ ```bash
599
+ cd extensions/lex-microsoft_teams
600
+ git add lib/legion/extensions/microsoft_teams/actors/token_refresher.rb spec/legion/extensions/microsoft_teams/actors/token_refresher_spec.rb
601
+ git commit -m "add TokenRefresher actor for periodic delegated token refresh"
602
+ ```
603
+
604
+ ---
605
+
606
+ ### Task 4: Wire actors into entry point and run full suite
607
+
608
+ **Files:**
609
+ - Modify: `lib/legion/extensions/microsoft_teams.rb`
610
+
611
+ **Step 1: Add requires to the entry point**
612
+
613
+ In `lib/legion/extensions/microsoft_teams.rb`, the actor files are auto-discovered by the framework (loaded via `Legion::Extensions::Core`). However, to ensure they are available, verify the actors directory is loaded. No explicit require needed — actors are discovered by convention. But if other actors in this extension are explicitly required elsewhere, check that pattern.
614
+
615
+ Actually, reviewing the codebase: actors are NOT explicitly required in the entry point. They are auto-discovered by the framework via the `Actor` module namespace. The existing actors (CacheBulkIngest, CacheSync, DirectChatPoller, ObservedChatPoller, MessageProcessor) are all loaded this way. No change to `microsoft_teams.rb` is needed.
616
+
617
+ **Step 2: Run full spec suite**
618
+
619
+ Run: `cd extensions/lex-microsoft_teams && bundle exec rspec -v`
620
+ Expected: All specs pass (should be ~200+ now)
621
+
622
+ **Step 3: Run rubocop on entire repo**
623
+
624
+ Run: `cd extensions/lex-microsoft_teams && bundle exec rubocop`
625
+ Expected: No offenses
626
+
627
+ **Step 4: Verify spec count increased**
628
+
629
+ Expected: 188 (previous) + 5 (TokenCache) + 9 (AuthValidator) + 8 (TokenRefresher) = ~210 specs
630
+
631
+ **Step 5: Commit integration verification**
632
+
633
+ No files changed in this step — this is verification only.
634
+
635
+ ---
636
+
637
+ ### Task 5: Bump version and update changelog
638
+
639
+ **Files:**
640
+ - Modify: `lib/legion/extensions/microsoft_teams/version.rb`
641
+ - Modify: `CHANGELOG.md`
642
+
643
+ **Step 1: Bump version**
644
+
645
+ Change version from `0.5.3` to `0.5.4` in `version.rb`.
646
+
647
+ **Step 2: Update changelog**
648
+
649
+ Add entry at top of CHANGELOG.md:
650
+
651
+ ```markdown
652
+ ## [0.5.4] - 2026-03-19
653
+
654
+ ### Added
655
+ - `TokenCache#authenticated?` predicate for runtime delegated token state
656
+ - `TokenCache#previously_authenticated?` predicate for persistent auth history
657
+ - `AuthValidator` actor (Once): validates and restores delegated tokens on boot
658
+ - `TokenRefresher` actor (Every, 15min configurable): keeps delegated tokens fresh
659
+ - Automatic browser re-auth when previously authenticated user's token expires
660
+ - `refresh_interval` config key at `settings[:microsoft_teams][:auth][:delegated]`
661
+ ```
662
+
663
+ **Step 3: Commit**
664
+
665
+ ```bash
666
+ cd extensions/lex-microsoft_teams
667
+ git add lib/legion/extensions/microsoft_teams/version.rb CHANGELOG.md
668
+ git commit -m "bump version to 0.5.4, update changelog"
669
+ ```
670
+
671
+ ---
672
+
673
+ ### Task 6: Push
674
+
675
+ **Step 1: Push to remote**
676
+
677
+ ```bash
678
+ cd extensions/lex-microsoft_teams && git push
679
+ ```
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MicrosoftTeams
6
+ module Actor
7
+ class AuthValidator < Legion::Extensions::Actors::Once
8
+ def use_runner? = false
9
+ def check_subtask? = false
10
+ def generate_task? = false
11
+
12
+ def delay
13
+ 2.0
14
+ end
15
+
16
+ def enabled?
17
+ defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
18
+ rescue StandardError
19
+ false
20
+ end
21
+
22
+ def token_cache
23
+ @token_cache ||= Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.new
24
+ end
25
+
26
+ def manual
27
+ loaded = token_cache.load_from_vault
28
+
29
+ if loaded
30
+ token = token_cache.cached_delegated_token
31
+ if token
32
+ log_info('Teams delegated auth restored')
33
+ elsif token_cache.previously_authenticated?
34
+ attempt_browser_reauth(token_cache)
35
+ end
36
+ elsif token_cache.previously_authenticated?
37
+ log_warn('Token file found but could not load, attempting re-authentication')
38
+ attempt_browser_reauth(token_cache)
39
+ else
40
+ log_debug('No Teams delegated auth configured, skipping')
41
+ end
42
+ rescue StandardError => e
43
+ log_error("AuthValidator: #{e.message}")
44
+ end
45
+
46
+ private
47
+
48
+ def attempt_browser_reauth(cache)
49
+ settings = teams_auth_settings
50
+ return false unless settings[:tenant_id] && settings[:client_id]
51
+
52
+ log_warn('Delegated token expired, opening browser for re-authentication...')
53
+
54
+ scopes = settings.dig(:delegated, :scopes) ||
55
+ Legion::Extensions::MicrosoftTeams::Helpers::BrowserAuth::DEFAULT_SCOPES
56
+ browser_auth = Legion::Extensions::MicrosoftTeams::Helpers::BrowserAuth.new(
57
+ tenant_id: settings[:tenant_id],
58
+ client_id: settings[:client_id],
59
+ scopes: scopes
60
+ )
61
+
62
+ result = browser_auth.authenticate
63
+ return false if result[:error]
64
+
65
+ body = result[:result]
66
+ cache.store_delegated_token(
67
+ access_token: body['access_token'],
68
+ refresh_token: body['refresh_token'],
69
+ expires_in: body['expires_in'],
70
+ scopes: scopes
71
+ )
72
+ cache.save_to_vault
73
+ log_info('Teams delegated auth restored via browser')
74
+ true
75
+ rescue StandardError => e
76
+ log_error("Browser re-auth failed: #{e.message}")
77
+ false
78
+ end
79
+
80
+ def teams_auth_settings
81
+ return {} unless defined?(Legion::Settings)
82
+
83
+ Legion::Settings.dig(:microsoft_teams, :auth) || {}
84
+ end
85
+
86
+ def log_info(msg)
87
+ Legion::Logging.info(msg) if defined?(Legion::Logging)
88
+ end
89
+
90
+ def log_warn(msg)
91
+ Legion::Logging.warn(msg) if defined?(Legion::Logging)
92
+ end
93
+
94
+ def log_debug(msg)
95
+ Legion::Logging.debug(msg) if defined?(Legion::Logging)
96
+ end
97
+
98
+ def log_error(msg)
99
+ Legion::Logging.error(msg) if defined?(Legion::Logging)
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MicrosoftTeams
6
+ module Actor
7
+ class TokenRefresher < Legion::Extensions::Actors::Every
8
+ DEFAULT_REFRESH_INTERVAL = 900
9
+
10
+ def runner_class = Legion::Extensions::MicrosoftTeams::Helpers::TokenCache
11
+ def runner_function = 'cached_delegated_token'
12
+ def run_now? = false
13
+ def use_runner? = false
14
+ def check_subtask? = false
15
+ def generate_task? = false
16
+
17
+ def time
18
+ settings = teams_auth_settings
19
+ delegated = settings[:delegated]
20
+ return DEFAULT_REFRESH_INTERVAL unless delegated.is_a?(Hash)
21
+
22
+ delegated[:refresh_interval] || DEFAULT_REFRESH_INTERVAL
23
+ end
24
+
25
+ def enabled?
26
+ defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
27
+ rescue StandardError
28
+ false
29
+ end
30
+
31
+ def token_cache
32
+ @token_cache ||= Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.new
33
+ end
34
+
35
+ def manual
36
+ return unless token_cache.authenticated?
37
+
38
+ token = token_cache.cached_delegated_token
39
+ if token
40
+ token_cache.save_to_vault
41
+ elsif token_cache.previously_authenticated?
42
+ attempt_browser_reauth(token_cache)
43
+ end
44
+ rescue StandardError => e
45
+ log_error("TokenRefresher: #{e.message}")
46
+ end
47
+
48
+ private
49
+
50
+ def attempt_browser_reauth(cache)
51
+ settings = teams_auth_settings
52
+ return false unless settings[:tenant_id] && settings[:client_id]
53
+
54
+ log_warn('Delegated token expired, opening browser for re-authentication...')
55
+
56
+ scopes = settings.dig(:delegated, :scopes) ||
57
+ Legion::Extensions::MicrosoftTeams::Helpers::BrowserAuth::DEFAULT_SCOPES
58
+ browser_auth = Legion::Extensions::MicrosoftTeams::Helpers::BrowserAuth.new(
59
+ tenant_id: settings[:tenant_id],
60
+ client_id: settings[:client_id],
61
+ scopes: scopes
62
+ )
63
+
64
+ result = browser_auth.authenticate
65
+ return false if result[:error]
66
+
67
+ body = result[:result]
68
+ cache.store_delegated_token(
69
+ access_token: body['access_token'],
70
+ refresh_token: body['refresh_token'],
71
+ expires_in: body['expires_in'],
72
+ scopes: scopes
73
+ )
74
+ cache.save_to_vault
75
+ log_info('Teams delegated auth restored via browser')
76
+ true
77
+ rescue StandardError => e
78
+ log_error("Browser re-auth failed: #{e.message}")
79
+ false
80
+ end
81
+
82
+ def teams_auth_settings
83
+ return {} unless defined?(Legion::Settings)
84
+
85
+ Legion::Settings.dig(:microsoft_teams, :auth) || {}
86
+ end
87
+
88
+ def log_info(msg)
89
+ Legion::Logging.info(msg) if defined?(Legion::Logging)
90
+ end
91
+
92
+ def log_warn(msg)
93
+ Legion::Logging.warn(msg) if defined?(Legion::Logging)
94
+ end
95
+
96
+ def log_error(msg)
97
+ Legion::Logging.error(msg) if defined?(Legion::Logging)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -62,6 +62,14 @@ module Legion
62
62
  @mutex.synchronize { @delegated_cache = nil }
63
63
  end
64
64
 
65
+ def authenticated?
66
+ @mutex.synchronize { !@delegated_cache.nil? }
67
+ end
68
+
69
+ def previously_authenticated?
70
+ File.exist?(local_token_path)
71
+ end
72
+
65
73
  def load_from_vault
66
74
  return load_from_local unless defined?(Legion::Crypt)
67
75
 
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module MicrosoftTeams
6
- VERSION = '0.5.3'
6
+ VERSION = '0.5.4'
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.3
4
+ version: 0.5.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -71,13 +71,17 @@ files:
71
71
  - docs/plans/2026-03-15-meetings-transcripts-design.md
72
72
  - docs/plans/2026-03-16-delegated-oauth-browser-flow-design.md
73
73
  - docs/plans/2026-03-16-delegated-oauth-browser-flow-plan.md
74
+ - docs/plans/2026-03-19-teams-token-lifecycle-design.md
75
+ - docs/plans/2026-03-19-teams-token-lifecycle-implementation.md
74
76
  - lex-microsoft_teams.gemspec
75
77
  - lib/legion/extensions/microsoft_teams.rb
78
+ - lib/legion/extensions/microsoft_teams/actors/auth_validator.rb
76
79
  - lib/legion/extensions/microsoft_teams/actors/cache_bulk_ingest.rb
77
80
  - lib/legion/extensions/microsoft_teams/actors/cache_sync.rb
78
81
  - lib/legion/extensions/microsoft_teams/actors/direct_chat_poller.rb
79
82
  - lib/legion/extensions/microsoft_teams/actors/message_processor.rb
80
83
  - lib/legion/extensions/microsoft_teams/actors/observed_chat_poller.rb
84
+ - lib/legion/extensions/microsoft_teams/actors/token_refresher.rb
81
85
  - lib/legion/extensions/microsoft_teams/client.rb
82
86
  - lib/legion/extensions/microsoft_teams/helpers/browser_auth.rb
83
87
  - lib/legion/extensions/microsoft_teams/helpers/callback_server.rb