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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6731d955e0a738ca12ad20875132f4b3e008e56313e20a8be750ecd5fc96942
4
- data.tar.gz: 785a786e42520016f0e3220e646aefed11da78375b2e8870f872a7a0c71bfd07
3
+ metadata.gz: 85cdc120eb3f0b5d4d17c8e5a946cc6597ad05c4281f6b058a3782f9a868c139
4
+ data.tar.gz: 8f74ba8695ef29922d7a058453b7ca1726c458bd5630db3ac9ed0e921185afb8
5
5
  SHA512:
6
- metadata.gz: bf3abe1ea0891638459bc7b5ed151d61a5346af07f2599bcede3af0a3f606f04853fd8b0320e3eb5584045243c1b047d3c84f43dbe218d7e95cf31d2a392892f
7
- data.tar.gz: 66e861f841b907ca63e1dc452458930afdba359c0a99b042b11e523164d3c919159fca0117c8469094903932ba83ae5dcd0e5a329c43df72488c92054a4100a4
6
+ metadata.gz: cedb8ba0c30d800b9ec234d10fe40a67cacfcf21e320786cdb735ee6025cf23b59d90dc115a05d60ee32cf61791c46cf3328e5b00c4387602715419799659361
7
+ data.tar.gz: cb74ced2592eb80c0d3bf06576798d44c9531af4fcac3c4215926a0e018c6c2f6d892987149792692a9b53e1c700572f2c9c7a1456e6d2e5075e933ea23799a2
data/CHANGELOG.md CHANGED
@@ -1,6 +1,29 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
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
- 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
@@ -85,15 +89,24 @@ module Legion
85
89
  end
86
90
 
87
91
  def from_vault_data(qualifier)
88
- return nil unless vault_available? && canonical_name_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
- path = vault_path(qualifier)
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
- log.info("TokenManager.save_to_vault: writing to #{cluster} :: kv/data/#{path} qualifier=#{qualifier}")
120
- vault_kv_client.write(path,
121
- access_token: access_token,
122
- refresh_token: refresh_token,
123
- expires_at: expires_at&.utc&.iso8601,
124
- scopes: scopes,
125
- tenant_id: tenant_id,
126
- client_id: client_id,
127
- scope_fingerprint: scope_fingerprint)
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(:entra, qualifier: qualifier)
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?(:canonical_name)
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'
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Identity
6
6
  module Entra
7
- VERSION = '0.4.0'
7
+ VERSION = '0.5.3'
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.0
4
+ version: 0.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity