lex-identity-entra 0.4.1 → 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: e7177778379b280a2ad07bec74ee53d23cde1a1836d0cd383147c7b698eee92b
4
- data.tar.gz: f94406e11d549ee543ccbc4c5355e8f2f0f6c3dd33627536b05c4a98664d7e39
3
+ metadata.gz: a50147c3bd7a673e611e5191168b9ade2fe468041ad2b320fccba89dc267a999
4
+ data.tar.gz: a0889be0de3da89dedae7360058f0463ae8570a735b5f759750f74bf0e720974
5
5
  SHA512:
6
- metadata.gz: 1ed5170ea80a10cdb6794d35b9ea89b0ad8a74abf8bbadd75be3615679e218d85a8473b83abc8c9aa9c03b79a565befbda5afe52633ca9f6aa0f0c053993a56a
7
- data.tar.gz: ae42e1bc9f07607d20f810ce84f7ba0ce78ec8f10768105d85b92e4e214a91da1b5d58d2b89be1cc9c69fe1263a8ffe0736fd9ed7c8ca5a6a0a78361947bba47
6
+ metadata.gz: bf3ff78771dc93dea6cb326c1577d6421382575e714eb4ef25ae67ca07cbeceab37f5b616dd9a611eea85e3ff31d868964878f85409ced0e8688510d3231e8d8
7
+ data.tar.gz: 74f7b159ecc89ba0e306273e3e6438b81d8cb1ad09c52d5e2f9c1fb1d6cf4de95e06de62317c8253c8a69b9bfd9cb365ca5785ffede017d0e365dfbfd9502e61
data/CHANGELOG.md CHANGED
@@ -1,6 +1,28 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [0.5.4] - 2026-08-04
4
+ ### Fixed
5
+ - Stop writing delegated Entra tokens to bootstrap Vault paths. Token persistence now uses only the canonical `users/<identity>/entra/<qualifier>/auth` Vault path and falls back to local disk until a canonical identity is available.
6
+
7
+ ## [0.5.3] - 2026-07-15
8
+ ### Fixed
9
+ - Fix token refresh dead-end: refresh when token exists regardless of authenticated? state
10
+
11
+ ## [0.5.2] - 2026-07-02
12
+
13
+ ### Fixed
14
+ - 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.
15
+
16
+ ### Fixed
17
+ - 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.
18
+
19
+ ## [0.5.0] - 2026-07-02
20
+
21
+ ### Added
22
+ - 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.
23
+
24
+ ### Note
25
+ - 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.
4
26
 
5
27
  ## [0.4.1] - 2026-05-18
6
28
 
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
- unless Legion::Extensions::Identity::Entra::Helpers::TokenManager.authenticated?(:delegated)
31
- log.debug('No active delegated token, skipping refresh')
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
- data = Legion::Extensions::Identity::Entra::Helpers::TokenManager.token_data(:delegated, refresh: false)
36
- if data && !Legion::Extensions::Identity::Entra::Helpers::TokenManager.expired?(data)
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 nearing expiry, refreshing')
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
- loop do
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
- loop do
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
- loop do
80
- line = client.gets
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
@@ -87,13 +91,18 @@ module Legion
87
91
  def from_vault_data(qualifier)
88
92
  return nil unless vault_available? && canonical_name_available?
89
93
 
90
- path = vault_path(qualifier)
94
+ read_vault_token(qualifier, vault_path(qualifier))
95
+ end
96
+
97
+ def read_vault_token(qualifier, path)
98
+ return nil unless path
99
+
91
100
  log.debug("TokenManager.from_vault_data: reading kv/#{path}")
92
101
  result = vault_kv_client.read(path)
93
102
  normalize_token_data(result&.data)
94
103
  rescue StandardError => e
95
104
  handle_exception(e, level: :warn, operation: 'token_manager.from_vault_data',
96
- qualifier: qualifier)
105
+ qualifier: qualifier, path: path)
97
106
  nil
98
107
  end
99
108
 
@@ -115,6 +124,8 @@ module Legion
115
124
  return unless canonical_name_available?
116
125
 
117
126
  path = vault_path(qualifier)
127
+ return unless path
128
+
118
129
  cluster = Legion::Crypt.respond_to?(:default_cluster_name) ? Legion::Crypt.default_cluster_name : 'vault'
119
130
  log.info("TokenManager.save_to_vault: writing to #{cluster} :: kv/data/#{path} qualifier=#{qualifier}")
120
131
  vault_kv_client.write(path,
@@ -249,7 +260,7 @@ module Legion
249
260
  return nil unless defined?(Legion::Identity::Broker)
250
261
 
251
262
  log.debug("TokenManager.from_broker: requesting token for #{qualifier}")
252
- token = Legion::Identity::Broker.token_for(:entra, qualifier: qualifier)
263
+ token = Legion::Identity::Broker.token_for(:entra_delegated, qualifier: qualifier)
253
264
  log.debug("TokenManager.from_broker: #{token ? 'received' : 'not available'}")
254
265
  token
255
266
  rescue StandardError => e
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Identity
6
6
  module Entra
7
- VERSION = '0.4.1'
7
+ VERSION = '0.5.4'
8
8
  end
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-identity-entra
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity