lex-identity-entra 0.4.0 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -1
- data/README.md +7 -0
- data/lib/legion/extensions/identity/entra/delegated/actors/auth_validator.rb +6 -0
- data/lib/legion/extensions/identity/entra/delegated/actors/token_refresher.rb +13 -5
- data/lib/legion/extensions/identity/entra/delegated/runners/login.rb +4 -5
- data/lib/legion/extensions/identity/entra/delegated/scopes.rb +16 -0
- data/lib/legion/extensions/identity/entra/helpers/callback_server.rb +3 -6
- data/lib/legion/extensions/identity/entra/helpers/token_manager.rb +57 -16
- data/lib/legion/extensions/identity/entra/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 85cdc120eb3f0b5d4d17c8e5a946cc6597ad05c4281f6b058a3782f9a868c139
|
|
4
|
+
data.tar.gz: 8f74ba8695ef29922d7a058453b7ca1726c458bd5630db3ac9ed0e921185afb8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cedb8ba0c30d800b9ec234d10fe40a67cacfcf21e320786cdb735ee6025cf23b59d90dc115a05d60ee32cf61791c46cf3328e5b00c4387602715419799659361
|
|
7
|
+
data.tar.gz: cb74ced2592eb80c0d3bf06576798d44c9531af4fcac3c4215926a0e018c6c2f6d892987149792692a9b53e1c700572f2c9c7a1456e6d2e5075e933ea23799a2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [
|
|
3
|
+
## [0.5.3] - 2026-07-15
|
|
4
|
+
### Fixed
|
|
5
|
+
- Fix token refresh dead-end: refresh when token exists regardless of authenticated? state
|
|
6
|
+
|
|
7
|
+
## [0.5.2] - 2026-07-02
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- Load delegated Vault token before canonical identity resolution (issue #4). `TokenManager` previously gated all Vault reads/writes on canonical identity availability, but `Delegated::Identity.resolve` needs the token to *produce* that identity — a bootstrap dependency cycle that left Vault-only tokens unrecoverable on a fresh process. Added a canonical-free bootstrap Vault key (derived from tenant id + client id) for delegated tokens: reads prefer the canonical key then fall back to bootstrap; writes go to the bootstrap key before resolution and to both the canonical and bootstrap keys after. Preserves the no-placeholder-path guarantee from 0.4.1; scoped to the delegated pattern, so application/workload/managed-identity token paths are unchanged.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Align delegated Broker provider lookup with registration (issue #5). `TokenManager.from_broker` now requests the `:entra_delegated` provider name that `AuthValidator#register_broker` registers; previously it asked Broker for `:entra`, and because Broker provider names are exact keys the delegated token fallback missed the registered provider. Application, workload, and managed-identity token paths are unchanged.
|
|
14
|
+
|
|
15
|
+
## [0.5.0] - 2026-07-02
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Opt-in `:mail` delegated scope category (`Mail.Read` + `Mail.Send` only) for the lex-outlook build (ADR-0005). Least privilege: excludes `Mail.ReadWrite`, `MailboxSettings.Read`, and `Mail.Read.Shared`. Not a member of the default-enabled `:microsoft_graph` category, so existing delegated/Teams installs are unaffected.
|
|
19
|
+
|
|
20
|
+
### Note
|
|
21
|
+
- Enabling `:mail` in `identity.entra.delegated.scopes.enabled_categories` changes the delegated `scope_fingerprint` and forces a **one-time re-consent** for that qualifier. This is expected behavior (least-privilege scope opt-in), not an error.
|
|
22
|
+
|
|
23
|
+
## [0.4.1] - 2026-05-18
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- Require resolved identity before vault operations; prevents 403 errors from writing to placeholder paths
|
|
4
27
|
|
|
5
28
|
## [0.4.0] - 2026-05-18
|
|
6
29
|
|
data/README.md
CHANGED
|
@@ -83,12 +83,19 @@ identity:
|
|
|
83
83
|
| Category | Description |
|
|
84
84
|
|----------|-------------|
|
|
85
85
|
| `microsoft_graph` | Core Graph API: User.Read, Files, Devices, OpenID scopes |
|
|
86
|
+
| `mail` | Mailbox access for lex-outlook: `Mail.Read` + `Mail.Send` only (opt-in) |
|
|
86
87
|
| `teams` | Teams, Chat, Channel, OnlineMeetings, Presence, Activity |
|
|
87
88
|
| `one_note` | OneNote notebook read/write |
|
|
88
89
|
| `sharepoint` | SharePoint/OneDrive files and sites |
|
|
89
90
|
| `azure_communication_services` | Teams calls and chat management |
|
|
90
91
|
| `yammer` | Viva Engage / Yammer communities and conversations |
|
|
91
92
|
|
|
93
|
+
> **Note on `:mail`** — the `mail` category is opt-in and is **not** part of the
|
|
94
|
+
> default-enabled `microsoft_graph` set, so existing delegated installs see no scope
|
|
95
|
+
> change. Adding `mail` to `identity.entra.delegated.scopes.enabled_categories` changes
|
|
96
|
+
> the delegated `scope_fingerprint` and forces a **one-time re-consent** for that
|
|
97
|
+
> qualifier on next boot. This is expected least-privilege behavior, not an error.
|
|
98
|
+
|
|
92
99
|
## Token storage
|
|
93
100
|
|
|
94
101
|
| Backend | Path | Priority |
|
|
@@ -35,6 +35,8 @@ module Legion
|
|
|
35
35
|
attempt_browser_reauth
|
|
36
36
|
end
|
|
37
37
|
elsif previously_authenticated?
|
|
38
|
+
raw_data = stored_token_data
|
|
39
|
+
log.warn('Entra delegated token expired but refresh_token available — refresh may be stuck') if raw_data && raw_data[:refresh_token]
|
|
38
40
|
if Legion::Extensions::Identity::Entra::Helpers::TokenManager.scope_fingerprint_stale?(:delegated)
|
|
39
41
|
log.info('Entra delegated scope fingerprint changed, re-authenticating to acquire updated scopes')
|
|
40
42
|
else
|
|
@@ -140,6 +142,10 @@ module Legion
|
|
|
140
142
|
path = Legion::Extensions::Identity::Entra::Helpers::TokenManager.local_path(:delegated)
|
|
141
143
|
File.exist?(path)
|
|
142
144
|
end
|
|
145
|
+
|
|
146
|
+
def stored_token_data
|
|
147
|
+
Legion::Extensions::Identity::Entra::Helpers::TokenManager.token_data(:delegated, refresh: false)
|
|
148
|
+
end
|
|
143
149
|
end
|
|
144
150
|
end
|
|
145
151
|
end
|
|
@@ -27,18 +27,20 @@ module Legion
|
|
|
27
27
|
|
|
28
28
|
def manual
|
|
29
29
|
log.debug('Delegated TokenRefresher tick')
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
data = stored_raw_data
|
|
31
|
+
|
|
32
|
+
unless data && data[:refresh_token]
|
|
33
|
+
log.debug('No stored delegated token with refresh_token, skipping refresh')
|
|
32
34
|
return
|
|
33
35
|
end
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
unless Legion::Extensions::Identity::Entra::Helpers::TokenManager.expired?(data) ||
|
|
38
|
+
Legion::Extensions::Identity::Entra::Helpers::TokenManager.scope_fingerprint_stale?(:delegated, data)
|
|
37
39
|
log.debug('Delegated token still valid')
|
|
38
40
|
return
|
|
39
41
|
end
|
|
40
42
|
|
|
41
|
-
log.info('Delegated token
|
|
43
|
+
log.info('Delegated token expired or stale, refreshing')
|
|
42
44
|
refreshed = Legion::Extensions::Identity::Entra::Helpers::TokenManager.token_data(:delegated, refresh: true)
|
|
43
45
|
if refreshed && !Legion::Extensions::Identity::Entra::Helpers::TokenManager.expired?(refreshed)
|
|
44
46
|
Legion::Extensions::Identity::Entra::Client.reset!(pattern: :delegated)
|
|
@@ -53,6 +55,12 @@ module Legion
|
|
|
53
55
|
|
|
54
56
|
private
|
|
55
57
|
|
|
58
|
+
def stored_raw_data
|
|
59
|
+
Legion::Extensions::Identity::Entra::Helpers::TokenManager.from_vault_data(:delegated) ||
|
|
60
|
+
Legion::Extensions::Identity::Entra::Helpers::TokenManager.from_local_data(:delegated) ||
|
|
61
|
+
Legion::Extensions::Identity::Entra::Helpers::TokenManager.from_memory(:delegated)
|
|
62
|
+
end
|
|
63
|
+
|
|
56
64
|
def attempt_browser_reauth
|
|
57
65
|
auth_settings = Legion::Extensions::Identity::Entra::Helpers::TokenManager.settings_auth
|
|
58
66
|
return unless auth_settings[:tenant_id] && auth_settings[:client_id]
|
|
@@ -32,7 +32,7 @@ module Legion
|
|
|
32
32
|
deadline = Time.now + timeout
|
|
33
33
|
current_interval = interval
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
while Time.now <= deadline
|
|
36
36
|
body = oauth_post(tenant_id, 'oauth2/v2.0/token',
|
|
37
37
|
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
38
38
|
client_id: client_id,
|
|
@@ -44,10 +44,6 @@ module Legion
|
|
|
44
44
|
|
|
45
45
|
case body[:error]
|
|
46
46
|
when 'authorization_pending'
|
|
47
|
-
if Time.now > deadline
|
|
48
|
-
log.warn("Login.poll_device_code: timed out after #{timeout}s")
|
|
49
|
-
return { error: 'timeout', description: "Device code flow timed out after #{timeout}s" }
|
|
50
|
-
end
|
|
51
47
|
sleep(current_interval)
|
|
52
48
|
when 'slow_down'
|
|
53
49
|
current_interval += 5
|
|
@@ -57,6 +53,9 @@ module Legion
|
|
|
57
53
|
return { error: body[:error], description: body[:error_description] }
|
|
58
54
|
end
|
|
59
55
|
end
|
|
56
|
+
|
|
57
|
+
log.warn("Login.poll_device_code: timed out after #{timeout}s")
|
|
58
|
+
{ error: 'timeout', description: "Device code flow timed out after #{timeout}s" }
|
|
60
59
|
rescue StandardError => e
|
|
61
60
|
handle_exception(e, level: :error, operation: 'delegated.login.poll_device_code')
|
|
62
61
|
{ error: 'request_failed', description: e.message }
|
|
@@ -24,6 +24,22 @@ module Legion
|
|
|
24
24
|
CallTranscripts.Read.All
|
|
25
25
|
Channel.ReadBasic.All
|
|
26
26
|
],
|
|
27
|
+
# Opt-in mailbox access for the signed-in user (lex-outlook, ADR-0005).
|
|
28
|
+
# Least privilege: Mail.Read + Mail.Send ONLY. Deliberately excludes
|
|
29
|
+
# Mail.ReadWrite (create/update/delete/move), MailboxSettings.Read
|
|
30
|
+
# (auto-reply/timezone/working-hours), and Mail.Read.Shared — no v1
|
|
31
|
+
# lex-outlook runner needs them, so they are deferred to a later
|
|
32
|
+
# fingerprint-changing edit when a runner actually requires them.
|
|
33
|
+
#
|
|
34
|
+
# NOT a member of :microsoft_graph, so existing delegated/Teams
|
|
35
|
+
# installs see no scope change and no forced re-consent. Enabling
|
|
36
|
+
# :mail in identity.entra.delegated.scopes.enabled_categories changes
|
|
37
|
+
# the delegated scope_fingerprint and forces a one-time re-consent for
|
|
38
|
+
# that qualifier — expected behavior, not an error (see README).
|
|
39
|
+
mail: %w[
|
|
40
|
+
Mail.Read
|
|
41
|
+
Mail.Send
|
|
42
|
+
],
|
|
27
43
|
teams: %w[
|
|
28
44
|
Chat.Create
|
|
29
45
|
Chat.Read
|
|
@@ -56,7 +56,7 @@ module Legion
|
|
|
56
56
|
private
|
|
57
57
|
|
|
58
58
|
def listen
|
|
59
|
-
|
|
59
|
+
until @result
|
|
60
60
|
client = @server.accept
|
|
61
61
|
request_line = client.gets
|
|
62
62
|
drain_headers(client)
|
|
@@ -64,7 +64,6 @@ module Legion
|
|
|
64
64
|
|
|
65
65
|
client.print "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n#{RESPONSE_HTML}"
|
|
66
66
|
client.close
|
|
67
|
-
break if @result
|
|
68
67
|
end
|
|
69
68
|
rescue IOError # rubocop:disable Legion/RescueLogging/NoCapture
|
|
70
69
|
nil
|
|
@@ -76,10 +75,8 @@ module Legion
|
|
|
76
75
|
end
|
|
77
76
|
|
|
78
77
|
def drain_headers(client)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
break if line.nil? || line.strip.empty?
|
|
82
|
-
end
|
|
78
|
+
line = client.gets
|
|
79
|
+
line = client.gets until line.nil? || line.strip.empty?
|
|
83
80
|
end
|
|
84
81
|
|
|
85
82
|
def capture_callback(request_line)
|
|
@@ -51,6 +51,10 @@ module Legion
|
|
|
51
51
|
return nil unless data
|
|
52
52
|
|
|
53
53
|
if scope_fingerprint_stale?(qualifier, data)
|
|
54
|
+
if refresh && data[:refresh_token]
|
|
55
|
+
log.info("TokenManager.token_data: scope fingerprint mismatch for #{qualifier}, attempting refresh with current scopes")
|
|
56
|
+
return refresh_token(qualifier, data)
|
|
57
|
+
end
|
|
54
58
|
log.info("TokenManager.token_data: scope fingerprint mismatch for #{qualifier}, forcing re-auth")
|
|
55
59
|
return nil
|
|
56
60
|
end
|
|
@@ -85,15 +89,24 @@ module Legion
|
|
|
85
89
|
end
|
|
86
90
|
|
|
87
91
|
def from_vault_data(qualifier)
|
|
88
|
-
return nil unless vault_available?
|
|
92
|
+
return nil unless vault_available?
|
|
93
|
+
|
|
94
|
+
# Prefer the canonical (post-resolution) path, then fall back to the
|
|
95
|
+
# bootstrap path so a fresh process can recover a Vault-only token
|
|
96
|
+
# before Legion::Identity::Process is resolved (issue #4).
|
|
97
|
+
read_vault_token(qualifier, vault_path(qualifier)) ||
|
|
98
|
+
read_vault_token(qualifier, bootstrap_vault_path(qualifier))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def read_vault_token(qualifier, path)
|
|
102
|
+
return nil unless path
|
|
89
103
|
|
|
90
|
-
path = vault_path(qualifier)
|
|
91
104
|
log.debug("TokenManager.from_vault_data: reading kv/#{path}")
|
|
92
105
|
result = vault_kv_client.read(path)
|
|
93
106
|
normalize_token_data(result&.data)
|
|
94
107
|
rescue StandardError => e
|
|
95
108
|
handle_exception(e, level: :warn, operation: 'token_manager.from_vault_data',
|
|
96
|
-
qualifier: qualifier)
|
|
109
|
+
qualifier: qualifier, path: path)
|
|
97
110
|
nil
|
|
98
111
|
end
|
|
99
112
|
|
|
@@ -112,19 +125,26 @@ module Legion
|
|
|
112
125
|
def save_to_vault(qualifier, access_token:, refresh_token:, expires_at:,
|
|
113
126
|
scopes: nil, tenant_id: nil, client_id: nil, scope_fingerprint: nil)
|
|
114
127
|
return unless vault_available?
|
|
115
|
-
return unless canonical_name_available?
|
|
116
128
|
|
|
117
|
-
|
|
129
|
+
# When canonical identity is resolved, write the canonical key (and
|
|
130
|
+
# alias to the bootstrap key so a later fresh process can recover it
|
|
131
|
+
# before resolution). Before resolution, fall back to the bootstrap
|
|
132
|
+
# key so we never write to a placeholder (anonymous) canonical path.
|
|
133
|
+
paths = [vault_path(qualifier), bootstrap_vault_path(qualifier)].compact.uniq
|
|
134
|
+
return if paths.empty?
|
|
135
|
+
|
|
118
136
|
cluster = Legion::Crypt.respond_to?(:default_cluster_name) ? Legion::Crypt.default_cluster_name : 'vault'
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
137
|
+
paths.each do |path|
|
|
138
|
+
log.info("TokenManager.save_to_vault: writing to #{cluster} :: kv/data/#{path} qualifier=#{qualifier}")
|
|
139
|
+
vault_kv_client.write(path,
|
|
140
|
+
access_token: access_token,
|
|
141
|
+
refresh_token: refresh_token,
|
|
142
|
+
expires_at: expires_at&.utc&.iso8601,
|
|
143
|
+
scopes: scopes,
|
|
144
|
+
tenant_id: tenant_id,
|
|
145
|
+
client_id: client_id,
|
|
146
|
+
scope_fingerprint: scope_fingerprint)
|
|
147
|
+
end
|
|
128
148
|
log.info('TokenManager.save_to_vault: success')
|
|
129
149
|
true
|
|
130
150
|
rescue StandardError => e
|
|
@@ -249,7 +269,7 @@ module Legion
|
|
|
249
269
|
return nil unless defined?(Legion::Identity::Broker)
|
|
250
270
|
|
|
251
271
|
log.debug("TokenManager.from_broker: requesting token for #{qualifier}")
|
|
252
|
-
token = Legion::Identity::Broker.token_for(:
|
|
272
|
+
token = Legion::Identity::Broker.token_for(:entra_delegated, qualifier: qualifier)
|
|
253
273
|
log.debug("TokenManager.from_broker: #{token ? 'received' : 'not available'}")
|
|
254
274
|
token
|
|
255
275
|
rescue StandardError => e
|
|
@@ -285,6 +305,26 @@ module Legion
|
|
|
285
305
|
"users/#{Legion::Identity::Process.canonical_name}/entra/#{qualifier}/auth"
|
|
286
306
|
end
|
|
287
307
|
|
|
308
|
+
# Pre-resolution Vault key for delegated tokens, derived from stable
|
|
309
|
+
# inputs (tenant id + client id) that are available before canonical
|
|
310
|
+
# identity resolution. Lets a fresh process recover a Vault-only
|
|
311
|
+
# delegated token so it can resolve identity (issue #4). Scoped to
|
|
312
|
+
# delegated only — other patterns are unaffected.
|
|
313
|
+
def bootstrap_vault_path(qualifier)
|
|
314
|
+
return nil unless qualifier.to_sym == :delegated
|
|
315
|
+
|
|
316
|
+
auth = settings_auth
|
|
317
|
+
pattern_settings = auth[qualifier.to_sym]
|
|
318
|
+
return pattern_settings[:vault_path] if pattern_settings.is_a?(Hash) && pattern_settings[:vault_path]
|
|
319
|
+
|
|
320
|
+
tenant_id = auth[:tenant_id]
|
|
321
|
+
client_id = auth[:client_id]
|
|
322
|
+
return nil unless tenant_id && client_id
|
|
323
|
+
|
|
324
|
+
account = Digest::MD5.hexdigest("#{tenant_id}:#{client_id}")
|
|
325
|
+
"bootstrap/entra/#{qualifier}/#{account}/auth"
|
|
326
|
+
end
|
|
327
|
+
|
|
288
328
|
def local_path(qualifier)
|
|
289
329
|
auth = settings_auth
|
|
290
330
|
pattern_settings = auth[qualifier.to_sym]
|
|
@@ -354,7 +394,8 @@ module Legion
|
|
|
354
394
|
|
|
355
395
|
def canonical_name_available?
|
|
356
396
|
return false unless defined?(Legion::Identity::Process)
|
|
357
|
-
return false unless Legion::Identity::Process.respond_to?(:
|
|
397
|
+
return false unless Legion::Identity::Process.respond_to?(:resolved?) &&
|
|
398
|
+
Legion::Identity::Process.resolved?
|
|
358
399
|
|
|
359
400
|
name = Legion::Identity::Process.canonical_name
|
|
360
401
|
!name.nil? && !name.empty? && name != 'anonymous'
|