lex-microsoft_teams 0.5.3 → 0.5.5
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 +20 -0
- data/CLAUDE.md +20 -4
- data/docs/plans/2026-03-19-teams-token-lifecycle-design.md +120 -0
- data/docs/plans/2026-03-19-teams-token-lifecycle-implementation.md +679 -0
- data/lib/legion/extensions/microsoft_teams/actors/auth_validator.rb +105 -0
- data/lib/legion/extensions/microsoft_teams/actors/token_refresher.rb +103 -0
- data/lib/legion/extensions/microsoft_teams/helpers/token_cache.rb +8 -0
- data/lib/legion/extensions/microsoft_teams/hooks/auth.rb +21 -0
- data/lib/legion/extensions/microsoft_teams/runners/auth.rb +29 -0
- data/lib/legion/extensions/microsoft_teams/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5dd2ecc48c7eda33072b23d32d9a08947b43d51aaeba49b6b4eb46fb410e8065
|
|
4
|
+
data.tar.gz: ec8b561ede2e96afe790aad56ecc02c3491117ea26e3bbd2e78a480d214a58d2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 786b4d6f2b4e3cce9fee5d61d96cb2c89f31080628d19c5e146976e9eaf3ecb6e4e8eaf76854493cfa37e6dba89721c0e6dca42e8dd657bbaac67ad84b195cf6
|
|
7
|
+
data.tar.gz: 82fc1cf2c2f31b81e7f640b5edc00be53cc976733bb7c33f3d3a68648991ad0a6979cfb2eb43f57584c48aeb7dd90542ac787e056fc44938216b7b7b9bbed41a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.5] - 2026-03-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `Hooks::Auth` hook class with `mount '/callback'` for OAuth redirect via expanded hooks system
|
|
7
|
+
- `Runners::Auth#auth_callback` method handling OAuth callback with HTML response and event emission
|
|
8
|
+
- OAuth callback now routes through `Ingress.run` for RBAC and audit support
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- OAuth callback URL moves from hardcoded `/api/oauth/microsoft_teams/callback` to `/api/hooks/lex/microsoft_teams/auth/callback`
|
|
12
|
+
|
|
13
|
+
## [0.5.4] - 2026-03-19
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- `TokenCache#authenticated?` predicate for runtime delegated token state
|
|
17
|
+
- `TokenCache#previously_authenticated?` predicate for persistent auth history
|
|
18
|
+
- `AuthValidator` actor (Once): validates and restores delegated tokens on boot
|
|
19
|
+
- `TokenRefresher` actor (Every, 15min configurable): keeps delegated tokens fresh
|
|
20
|
+
- Automatic browser re-auth when previously authenticated user's token expires
|
|
21
|
+
- `refresh_interval` config key at `settings[:microsoft_teams][:auth][:delegated]`
|
|
22
|
+
|
|
3
23
|
## [0.5.3] - 2026-03-19
|
|
4
24
|
|
|
5
25
|
### Added
|
data/CLAUDE.md
CHANGED
|
@@ -10,7 +10,7 @@ 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.
|
|
13
|
+
**Version**: 0.5.4
|
|
14
14
|
|
|
15
15
|
## Architecture
|
|
16
16
|
|
|
@@ -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
|
-
│
|
|
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,7 +52,7 @@ 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
|
|
@@ -68,6 +70,18 @@ Tokens stored in Vault (`legionio/microsoft_teams/delegated_token`) with configu
|
|
|
68
70
|
|
|
69
71
|
Key files: `Helpers::BrowserAuth` (orchestrator), `Helpers::CallbackServer` (ephemeral TCP), `Runners::Auth` (authorize_url, exchange_code, refresh_delegated_token), `Helpers::TokenCache` (delegated slot).
|
|
70
72
|
|
|
73
|
+
## Token Lifecycle (v0.5.4)
|
|
74
|
+
|
|
75
|
+
Automatic delegated token management: validate on boot, refresh on a timer, re-authenticate via browser when a previously authenticated user's token expires.
|
|
76
|
+
|
|
77
|
+
- **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.
|
|
78
|
+
- **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.
|
|
79
|
+
- **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).
|
|
80
|
+
|
|
81
|
+
Configuration: `settings[:microsoft_teams][:auth][:delegated][:refresh_interval]` (default 900 seconds).
|
|
82
|
+
|
|
83
|
+
Design doc: `docs/plans/2026-03-19-teams-token-lifecycle-design.md`
|
|
84
|
+
|
|
71
85
|
## AI Bot (v0.2.0)
|
|
72
86
|
|
|
73
87
|
Two operating modes, both using polling (Graph API) with AMQP-based message routing:
|
|
@@ -104,6 +118,8 @@ microsoft_teams:
|
|
|
104
118
|
tenant_id: "..."
|
|
105
119
|
client_id: "..."
|
|
106
120
|
client_secret: "vault://secret/teams/client_secret"
|
|
121
|
+
delegated:
|
|
122
|
+
refresh_interval: 900 # seconds (TokenRefresher interval)
|
|
107
123
|
bot:
|
|
108
124
|
bot_id: "28:your-bot-id"
|
|
109
125
|
direct_poll_interval: 5 # seconds
|
|
@@ -197,7 +213,7 @@ Optional framework dependencies (guarded with `defined?`, not in gemspec):
|
|
|
197
213
|
|
|
198
214
|
```bash
|
|
199
215
|
bundle install
|
|
200
|
-
bundle exec rspec #
|
|
216
|
+
bundle exec rspec # 209 specs (as of v0.5.4)
|
|
201
217
|
bundle exec rubocop # Clean
|
|
202
218
|
```
|
|
203
219
|
|
|
@@ -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
|
|
|
@@ -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
|
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
|
+
version: 0.5.5
|
|
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
|
|
@@ -87,6 +91,7 @@ files:
|
|
|
87
91
|
- lib/legion/extensions/microsoft_teams/helpers/session_manager.rb
|
|
88
92
|
- lib/legion/extensions/microsoft_teams/helpers/subscription_registry.rb
|
|
89
93
|
- lib/legion/extensions/microsoft_teams/helpers/token_cache.rb
|
|
94
|
+
- lib/legion/extensions/microsoft_teams/hooks/auth.rb
|
|
90
95
|
- lib/legion/extensions/microsoft_teams/local_cache/extractor.rb
|
|
91
96
|
- lib/legion/extensions/microsoft_teams/local_cache/record_parser.rb
|
|
92
97
|
- lib/legion/extensions/microsoft_teams/local_cache/sstable_reader.rb
|