lex-identity-entra 0.5.4 → 0.6.1

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: a50147c3bd7a673e611e5191168b9ade2fe468041ad2b320fccba89dc267a999
4
- data.tar.gz: a0889be0de3da89dedae7360058f0463ae8570a735b5f759750f74bf0e720974
3
+ metadata.gz: e7076abeb6fcf3e837854a909a38bbe46ab78ff6dd8372fb0df53d507484170e
4
+ data.tar.gz: 6d9347e6aedc921ee6c16fc8a2004d0eb986e15eb8faf150a3a26787363e0d04
5
5
  SHA512:
6
- metadata.gz: bf3ff78771dc93dea6cb326c1577d6421382575e714eb4ef25ae67ca07cbeceab37f5b616dd9a611eea85e3ff31d868964878f85409ced0e8688510d3231e8d8
7
- data.tar.gz: 74f7b159ecc89ba0e306273e3e6438b81d8cb1ad09c52d5e2f9c1fb1d6cf4de95e06de62317c8253c8a69b9bfd9cb365ca5785ffede017d0e365dfbfd9502e61
6
+ metadata.gz: 6245ce699161036398ccfb11d141587f0188ebabe38944862fe693fcf7f6210c935cf4a493119de46d1b0b42691aa69a5f6459c2f0e11dc2d39b81bde8dad706
7
+ data.tar.gz: 0b2a7fae7d074dc4fe580f7289c79105ca6761227b6bbbd7045844d4e75b0fb0be90a4c46f92af04fd2ff4a112fa86a3dd12440f1cb6b805d2174cd52b809999
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.1] - 2026-10-19
4
+ ### Fixed
5
+ - **Browser tab storm**: `Delegated::Actor::TokenRefresher` opened a new login tab on every 15-minute tick once the refresh token went bad (about 50 tabs overnight). It now respects `browser_auth.auto_authenticate`, and every interactive attempt goes through the new `Helpers::InteractiveAuthGuard`, which provides:
6
+ - single-flight per process (Mutex) and across processes (`flock` lock file)
7
+ - exponential backoff after an unanswered automatic attempt (30 min, doubling up to 6 h), then a pause after `max_auto_attempts` (3) until the user signs in manually. State persists across restarts.
8
+ - skipping automatic attempts while the macOS screen is locked or the user is idle (`ioreg`)
9
+ - a single warning telling the user to run `legionio auth teams`
10
+ - **Refresh loop**: `TokenManager.refresh_token` now negative-caches failures per refresh token. Permanent OAuth errors (`invalid_grant`, `interaction_required`, ...) are not retried until a new token is saved, and transient errors back off from 30 s to 15 min. Previously the broker's `LeaseRenewer` called `provide_token` about once a second and every call re-POSTed the dead refresh token and logged a warning.
11
+ - `AuthValidator` no longer opens a browser for an expired token when `auto_authenticate` is false. It logs the re-auth hint instead.
12
+ - `browser_auth.callback_timeout` is now honored. It was hard-coded to 120 s. The hook listener is removed on timeout.
13
+
14
+ ### Changed
15
+ - The OAuth callback page (local callback server and API hook route) now reads "LegionIO — Microsoft Teams / Entra sign-in complete. You can close this tab." There is a matching failure page that shows the provider error and the `legionio auth teams` hint. Provider errors such as `access_denied` now surface immediately instead of waiting for the timeout. The local server returns 404 for unrelated requests like `/favicon.ico` instead of consuming the callback.
16
+
17
+ ## [0.6.0] - 2026-09-25
18
+ ### Changed
19
+ - **Breaking**: Vault token persistence is now opt-in in both directions. New per-pattern settings `identity.entra.<pattern>.token.vault_read_enabled` and `vault_write_enabled`, both defaulting to `false`. With the defaults, Entra tokens are never read from or written to Vault. Set the flags explicitly to restore 0.5.x Vault behavior (each direction is independent).
20
+ - **Breaking**: Removed local disk token persistence (`~/.legionio/tokens/entra_<qualifier>.json`) and its `local_token_path` setting. Tokens are held in-memory by default and in Vault only when explicitly opted in — nothing is ever written to disk. In the default configuration tokens no longer survive a process restart: re-auth via `auto_authenticate` or `legion lex exec entra auth login`.
21
+ - Upgrades from 0.5.x: tokens that exist only in Vault are not read by default (expect a one-time re-auth, or set `vault_read_enabled: true`), and stale on-disk token files are no longer read or written.
22
+
3
23
  ## [0.5.4] - 2026-08-04
4
24
  ### Fixed
5
25
  - 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.
data/README.md CHANGED
@@ -7,7 +7,7 @@ and workload identity authentication patterns.
7
7
  ## Features
8
8
 
9
9
  - **Delegated auth** — OAuth2 PKCE browser flow with local callback server; device-code fallback
10
- - **Token persistence** — Vault-first (`kv/data/users/<identity>/entra/<qualifier>/auth`), disk fallback, in-memory fallback; disk file deleted once Vault write succeeds
10
+ - **Token persistence** — in-memory by default; Vault (`kv/data/users/<identity>/entra/<qualifier>/auth`) reading and writing are separate opt-ins via `vault_read_enabled` / `vault_write_enabled`. Tokens are never written to local disk
11
11
  - **Scope fingerprinting** — MD5 fingerprint of active scopes stored with token; any scope change forces re-authentication on next boot
12
12
  - **Identity integration** — `AuthValidator` upgrades `Legion::Identity::Process` via `Resolver.upgrade!` and registers with `Legion::Identity::Broker` for cross-extension token access
13
13
  - **Application credentials** — client credentials flow for service-to-service auth
@@ -33,9 +33,17 @@ identity:
33
33
  client_id: "your-client-id"
34
34
  delegated:
35
35
  browser_auth:
36
- auto_authenticate: false # set true to open browser automatically at boot
36
+ auto_authenticate: false # set true to open browser automatically (boot + token refresher)
37
37
  callback_timeout: 120
38
+ # Guard for automatic (actor-initiated) sign-in:
39
+ backoff_base_seconds: 1800 # wait after an unanswered tab; doubles each time
40
+ backoff_max_seconds: 21600
41
+ max_auto_attempts: 3 # then stop opening tabs until you sign in manually
42
+ skip_when_screen_locked: true # macOS: never open tabs while the screen is locked
43
+ skip_when_idle_seconds: 900 # macOS: skip if no keyboard/mouse input for this long (0 = off)
38
44
  token:
45
+ vault_read_enabled: false # set true to read the token from Vault
46
+ vault_write_enabled: false # set true to write the token to Vault
39
47
  refresh_buffer: 60
40
48
  refresh_interval: 900
41
49
  scopes:
@@ -61,6 +69,21 @@ legion lex exec entra auth status
61
69
  The `AuthValidator` actor fires 9 seconds after boot. If `auto_authenticate: true` is set,
62
70
  it opens a browser window automatically. Otherwise trigger login via the CLI.
63
71
 
72
+ Interactive sign-in is protected by `Helpers::InteractiveAuthGuard`:
73
+
74
+ - **Single-flight**: only one browser/device-code attempt runs at a time, per process (mutex) and across
75
+ processes (`flock` on `~/.legionio/run/entra_browser_auth.lock`).
76
+ - **Backoff**: when an automatic attempt times out, the next one waits `backoff_base_seconds` (default 30 min),
77
+ doubling up to `backoff_max_seconds`. After `max_auto_attempts` unanswered tabs, automatic sign-in pauses
78
+ until you sign in manually (`legionio auth teams` or `legion lex exec entra auth login`). Backoff state is
79
+ kept in `~/.legionio/run/entra_browser_auth_state.json` (no secrets), so restarting the daemon does not
80
+ reopen a tab right away.
81
+ - **Presence**: on macOS, automatic attempts are skipped while the screen is locked or the user is idle.
82
+ - **No refresh storms**: a refresh token that Microsoft rejects (`invalid_grant` and similar) is not retried
83
+ until a new token is saved, and transient refresh errors back off from 30 s up to 15 min. One warning tells
84
+ you how to sign in again.
85
+ - `TokenRefresher` only opens a browser when `auto_authenticate: true`.
86
+
64
87
  ### Accessing tokens from another extension
65
88
 
66
89
  ```ruby
@@ -98,11 +121,10 @@ identity:
98
121
 
99
122
  ## Token storage
100
123
 
101
- | Backend | Path | Priority |
102
- |---------|------|----------|
103
- | HashiCorp Vault | `kv/data/users/<identity>/entra/delegated/auth` | 1 (preferred) |
104
- | Local disk | `~/.legionio/tokens/entra_delegated.json` | 2 (fallback, deleted when Vault succeeds) |
105
- | Memory | In-process store | 3 (runtime fallback) |
124
+ | Backend | Path | When used |
125
+ |---------|------|-----------|
126
+ | HashiCorp Vault | `kv/data/users/<identity>/entra/delegated/auth` | Only when opted in: `vault_read_enabled` / `vault_write_enabled` (both default `false`) |
127
+ | Memory | In-process store | Default persistence; tokens are never written to local disk |
106
128
 
107
129
  ## Identity provider contract
108
130
 
@@ -32,10 +32,11 @@ module Legion
32
32
  category_overrides: {}
33
33
  },
34
34
  token: {
35
- vault_path: nil,
36
- local_token_path: nil,
37
- refresh_buffer: 60,
38
- refresh_interval: 2700
35
+ vault_read_enabled: false,
36
+ vault_write_enabled: false,
37
+ vault_path: nil,
38
+ refresh_buffer: 60,
39
+ refresh_interval: 2700
39
40
  }
40
41
  }
41
42
  end
@@ -38,11 +38,15 @@ module Legion
38
38
  raw_data = stored_token_data
39
39
  log.warn('Entra delegated token expired but refresh_token available — refresh may be stuck') if raw_data && raw_data[:refresh_token]
40
40
  if Legion::Extensions::Identity::Entra::Helpers::TokenManager.scope_fingerprint_stale?(:delegated)
41
- log.info('Entra delegated scope fingerprint changed, re-authenticating to acquire updated scopes')
41
+ log.info('Entra delegated scope fingerprint changed, re-authentication needed to acquire updated scopes')
42
42
  else
43
- log.info('Entra delegated token file found but expired, attempting re-auth')
43
+ log.info('Entra delegated token found but expired, re-authentication needed')
44
+ end
45
+ if auto_authenticate?
46
+ attempt_browser_reauth
47
+ else
48
+ Legion::Extensions::Identity::Entra::Helpers::InteractiveAuthGuard.notify_reauth_required('token_expired')
44
49
  end
45
- attempt_browser_reauth
46
50
  elsif auto_authenticate?
47
51
  log.info('auto_authenticate enabled, opening browser for initial auth')
48
52
  attempt_browser_reauth
@@ -67,12 +71,14 @@ module Legion
67
71
  browser = Legion::Extensions::Identity::Entra::Helpers::BrowserAuth.new(
68
72
  tenant_id: auth_settings[:tenant_id],
69
73
  client_id: auth_settings[:client_id],
70
- scopes: scopes
74
+ scopes: scopes,
75
+ automatic: true
71
76
  )
72
77
 
73
78
  result = browser.authenticate
74
79
  if result[:error]
75
- log.error("Browser auth error: #{result[:error]} - #{result[:description]}")
80
+ log.debug("[entra][auth_validator] action=reauth_incomplete error=#{result[:error]} reason=#{result[:reason]}")
81
+ log.error("Browser auth error: #{result[:error]} - #{result[:description]}") unless result[:error] == 'auth_skipped'
76
82
  return false
77
83
  end
78
84
 
@@ -139,8 +145,7 @@ module Legion
139
145
  end
140
146
 
141
147
  def previously_authenticated?
142
- path = Legion::Extensions::Identity::Entra::Helpers::TokenManager.local_path(:delegated)
143
- File.exist?(path)
148
+ !stored_token_data.nil?
144
149
  end
145
150
 
146
151
  def stored_token_data
@@ -45,19 +45,29 @@ module Legion
45
45
  if refreshed && !Legion::Extensions::Identity::Entra::Helpers::TokenManager.expired?(refreshed)
46
46
  Legion::Extensions::Identity::Entra::Client.reset!(pattern: :delegated)
47
47
  log.info('Delegated token refreshed successfully')
48
- else
49
- log.warn('Delegated token refresh failed, attempting browser re-auth')
48
+ elsif auto_authenticate?
49
+ log.info('Delegated token refresh failed, attempting automatic browser re-auth')
50
50
  attempt_browser_reauth
51
+ else
52
+ log.debug('[entra][token_refresher] action=reauth_skipped reason=auto_authenticate_disabled')
53
+ auth_guard.notify_reauth_required('token_refresh_failed')
51
54
  end
52
55
  rescue StandardError => e
53
- log.error("Delegated TokenRefresher: #{e.message}")
56
+ handle_exception(e, level: :error, operation: 'entra.delegated.token_refresher.manual')
54
57
  end
55
58
 
56
59
  private
57
60
 
61
+ def auto_authenticate?
62
+ Legion::Settings.dig(:identity, :entra, :delegated, :browser_auth, :auto_authenticate) == true
63
+ end
64
+
65
+ def auth_guard
66
+ Legion::Extensions::Identity::Entra::Helpers::InteractiveAuthGuard
67
+ end
68
+
58
69
  def stored_raw_data
59
70
  Legion::Extensions::Identity::Entra::Helpers::TokenManager.from_vault_data(:delegated) ||
60
- Legion::Extensions::Identity::Entra::Helpers::TokenManager.from_local_data(:delegated) ||
61
71
  Legion::Extensions::Identity::Entra::Helpers::TokenManager.from_memory(:delegated)
62
72
  end
63
73
 
@@ -69,12 +79,16 @@ module Legion
69
79
  browser = Legion::Extensions::Identity::Entra::Helpers::BrowserAuth.new(
70
80
  tenant_id: auth_settings[:tenant_id],
71
81
  client_id: auth_settings[:client_id],
72
- scopes: scopes
82
+ scopes: scopes,
83
+ automatic: true
73
84
  )
74
85
 
75
86
  result = browser.authenticate
76
87
  body = result&.dig(:result)
77
- return unless body&.dig(:access_token)
88
+ unless body&.dig(:access_token)
89
+ log.debug("[entra][token_refresher] action=reauth_incomplete error=#{result&.dig(:error)} reason=#{result&.dig(:reason)}")
90
+ return
91
+ end
78
92
 
79
93
  Legion::Extensions::Identity::Entra::Helpers::TokenManager.save_token(
80
94
  :delegated,
@@ -88,7 +102,7 @@ module Legion
88
102
  Legion::Extensions::Identity::Entra::Client.reset!(pattern: :delegated)
89
103
  log.info('Delegated auth restored via browser re-auth')
90
104
  rescue StandardError => e
91
- log.error("Delegated browser re-auth failed: #{e.message}")
105
+ handle_exception(e, level: :error, operation: 'entra.delegated.token_refresher.attempt_browser_reauth')
92
106
  end
93
107
  end
94
108
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'faraday'
4
4
  require 'legion/extensions/identity/entra/helpers/scopes'
5
+ require 'legion/extensions/identity/entra/helpers/callback_page'
5
6
 
6
7
  module Legion
7
8
  module Extensions
@@ -108,24 +109,25 @@ module Legion
108
109
  { error: 'request_failed', description: e.message }
109
110
  end
110
111
 
111
- def auth_callback(code: nil, state: nil, **)
112
+ def auth_callback(code: nil, state: nil, error: nil, error_description: nil, **)
113
+ if error
114
+ log.warn("Login.auth_callback: provider returned error=#{error}")
115
+ emit_oauth_callback(state: state, error: error, error_description: error_description)
116
+ return { result: { error: error },
117
+ response: callback_response(400, callback_failure_html(error, error_description)) }
118
+ end
119
+
112
120
  unless code && state
113
121
  log.warn('Login.auth_callback: missing code or state parameter')
114
- return {
115
- result: { error: 'missing_params' },
116
- response: { status: 400, content_type: 'text/html',
117
- body: '<html><body><h2>Missing code or state parameter</h2></body></html>' }
118
- }
122
+ return { result: { error: 'missing_params' },
123
+ response: callback_response(400, callback_failure_html('missing_params',
124
+ 'The sign-in response was missing the code or state parameter.')) }
119
125
  end
120
126
 
121
127
  log.info('Login.auth_callback: OAuth callback received')
122
- Legion::Events.emit('identity.entra.oauth.callback', code: code, state: state) if defined?(Legion::Events)
123
-
124
- {
125
- result: { authenticated: true, code: code, state: state },
126
- response: { status: 200, content_type: 'text/html',
127
- body: callback_success_html }
128
- }
128
+ emit_oauth_callback(code: code, state: state)
129
+ { result: { authenticated: true, code: code, state: state },
130
+ response: callback_response(200, callback_success_html) }
129
131
  end
130
132
  alias handle auth_callback
131
133
 
@@ -156,21 +158,22 @@ module Legion
156
158
  body
157
159
  end
158
160
 
161
+ def emit_oauth_callback(**payload)
162
+ return unless defined?(Legion::Events)
163
+
164
+ Legion::Events.emit('identity.entra.oauth.callback', **payload.compact)
165
+ end
166
+
167
+ def callback_response(status, body)
168
+ { status: status, content_type: 'text/html; charset=utf-8', body: body }
169
+ end
170
+
171
+ def callback_failure_html(error, description)
172
+ Legion::Extensions::Identity::Entra::Helpers::CallbackPage.failure_html(error, description)
173
+ end
174
+
159
175
  def callback_success_html
160
- <<~HTML
161
- <html><body style="font-family:sans-serif;text-align:center;padding:40px;">
162
- <h2>Authentication complete</h2>
163
- <p>Closing in <span id="t">10</span> seconds&hellip;</p>
164
- <script>
165
- var s = 10;
166
- var i = setInterval(function() {
167
- s--;
168
- document.getElementById('t').textContent = s;
169
- if (s <= 0) { clearInterval(i); window.close(); }
170
- }, 1000);
171
- </script>
172
- </body></html>
173
- HTML
176
+ Legion::Extensions::Identity::Entra::Helpers::CallbackPage.success_html
174
177
  end
175
178
 
176
179
  include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
@@ -35,15 +35,22 @@ module Legion
35
35
  category_overrides: {}
36
36
  },
37
37
  token: {
38
- vault_path: nil,
39
- local_token_path: nil,
40
- refresh_buffer: 60,
41
- refresh_interval: 900
38
+ vault_read_enabled: false,
39
+ vault_write_enabled: false,
40
+ vault_path: nil,
41
+ refresh_buffer: 60,
42
+ refresh_interval: 900
42
43
  },
43
44
  browser_auth: {
44
- auto_authenticate: false,
45
- force_local_server: false,
46
- callback_timeout: 120
45
+ auto_authenticate: false,
46
+ force_local_server: false,
47
+ callback_timeout: 120,
48
+ # Automatic (actor-initiated) sign-in guard; see Helpers::InteractiveAuthGuard
49
+ backoff_base_seconds: 1800,
50
+ backoff_max_seconds: 21_600,
51
+ max_auto_attempts: 3,
52
+ skip_when_screen_locked: true,
53
+ skip_when_idle_seconds: 900
47
54
  }
48
55
  }
49
56
  end
@@ -16,19 +16,13 @@ module Legion
16
16
  include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
17
17
  Legion::Extensions::Helpers.const_defined?(:Lex, false)
18
18
 
19
- # Returns an array of qualifier symbols for which tokens exist locally.
19
+ # Returns an array of qualifier symbols for which tokens exist in-process.
20
20
  def discovered_qualifiers
21
- (local_qualifiers + broker_qualifiers).uniq
21
+ (memory_qualifiers + broker_qualifiers).uniq
22
22
  end
23
23
 
24
- def local_qualifiers
25
- return [] unless File.directory?(Legion::Extensions::Identity::Entra::Helpers::TokenManager::TOKEN_DIR)
26
-
27
- Dir.glob(File.join(Legion::Extensions::Identity::Entra::Helpers::TokenManager::TOKEN_DIR, 'entra_*.json')).filter_map do |path|
28
- basename = File.basename(path, '.json')
29
- match = basename.match(/\Aentra_(.+)\z/)
30
- match[1].to_sym if match
31
- end
24
+ def memory_qualifiers
25
+ Legion::Extensions::Identity::Entra::Helpers::TokenManager.memory_store.keys
32
26
  end
33
27
 
34
28
  def broker_qualifiers
@@ -6,6 +6,7 @@ require 'securerandom'
6
6
  require 'faraday'
7
7
 
8
8
  require 'legion/extensions/identity/entra/helpers/callback_server'
9
+ require 'legion/extensions/identity/entra/helpers/interactive_auth_guard'
9
10
  require 'legion/extensions/identity/entra/delegated/runners/login'
10
11
 
11
12
  module Legion
@@ -23,26 +24,40 @@ module Legion
23
24
 
24
25
  attr_reader :tenant_id, :client_id, :scopes
25
26
 
26
- def initialize(tenant_id:, client_id:, scopes: self.class.default_scopes, auth: nil, force_local_server: false, **)
27
+ DEFAULT_CALLBACK_TIMEOUT = 120
28
+
29
+ # automatic: true marks a background (actor-initiated) attempt. Those are
30
+ # subject to InteractiveAuthGuard backoff/suspension and screen-lock/idle
31
+ # checks; user-initiated attempts (CLI) only get single-flight protection.
32
+ def initialize(tenant_id:, client_id:, scopes: self.class.default_scopes, auth: nil, force_local_server: false,
33
+ automatic: false, **)
27
34
  @tenant_id = tenant_id
28
35
  @client_id = client_id
29
36
  @scopes = scopes
30
37
  @auth = auth || Object.new.extend(Legion::Extensions::Identity::Entra::Delegated::Runners::Login)
31
38
  @force_local_server = force_local_server
32
- log.debug("BrowserAuth initialized: tenant=#{tenant_id} client=#{client_id} force_local=#{force_local_server}")
39
+ @automatic = automatic
40
+ log.debug("BrowserAuth initialized: tenant=#{tenant_id} client=#{client_id} force_local=#{force_local_server} automatic=#{automatic}")
33
41
  log.info("BrowserAuth scopes: #{@scopes}")
34
42
  end
35
43
 
36
44
  def authenticate
37
- if gui_available?
38
- log.info('BrowserAuth: GUI available, using browser auth')
39
- authenticate_browser
40
- else
41
- log.info('BrowserAuth: no GUI detected, using device code flow')
42
- authenticate_device_code
45
+ InteractiveAuthGuard.run(automatic: @automatic) do
46
+ if gui_available?
47
+ log.info('BrowserAuth: GUI available, using browser auth')
48
+ authenticate_browser
49
+ else
50
+ log.info('BrowserAuth: no GUI detected, using device code flow')
51
+ authenticate_device_code
52
+ end
43
53
  end
44
54
  end
45
55
 
56
+ def callback_timeout
57
+ value = Legion::Settings.dig(:identity, :entra, :delegated, :browser_auth, :callback_timeout).to_i
58
+ value.positive? ? value : DEFAULT_CALLBACK_TIMEOUT
59
+ end
60
+
46
61
  def api_hook_available?
47
62
  return false if @force_local_server
48
63
  return false unless defined?(Legion::API) && defined?(Legion::Events)
@@ -143,8 +158,9 @@ module Legion
143
158
  return authenticate_device_code
144
159
  end
145
160
 
146
- log.debug('BrowserAuth: waiting for callback (timeout=120s)')
147
- mutex.synchronize { cv.wait(mutex, 120) unless result_holder[:result] }
161
+ log.debug("BrowserAuth: waiting for callback (timeout=#{callback_timeout}s)")
162
+ mutex.synchronize { cv.wait(mutex, callback_timeout) unless result_holder[:result] }
163
+ Legion::Events.off('identity.entra.oauth.callback', listener) unless result_holder[:result]
148
164
  exchange_callback(result_holder[:result], state: state, verifier: verifier, callback_uri: callback_uri)
149
165
  end
150
166
 
@@ -163,8 +179,8 @@ module Legion
163
179
  return authenticate_device_code
164
180
  end
165
181
 
166
- log.debug('BrowserAuth: waiting for callback on local server (timeout=120s)')
167
- result = server.wait_for_callback(timeout: 120)
182
+ log.debug("BrowserAuth: waiting for callback on local server (timeout=#{callback_timeout}s)")
183
+ result = server.wait_for_callback(timeout: callback_timeout)
168
184
  exchange_callback(result, state: state, verifier: verifier, callback_uri: callback_uri)
169
185
  ensure
170
186
  server&.shutdown
@@ -172,6 +188,11 @@ module Legion
172
188
  end
173
189
 
174
190
  def exchange_callback(result, state:, verifier:, callback_uri:)
191
+ if result && result[:error] && !result[:code]
192
+ log.error("BrowserAuth: OAuth callback returned error=#{result[:error]}")
193
+ return { error: result[:error].to_s, description: result[:error_description] }
194
+ end
195
+
175
196
  unless result && result[:code]
176
197
  log.error('BrowserAuth: OAuth callback timed out or missing code')
177
198
  return { error: 'timeout', description: 'No callback received within timeout' }
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Identity
8
+ module Entra
9
+ module Helpers
10
+ # HTML returned to the browser by the OAuth redirect (local callback
11
+ # server and the API hook route). Keeps the wording in one place so the
12
+ # user always knows which app the tab belongs to.
13
+ module CallbackPage
14
+ module_function
15
+
16
+ TITLE = 'LegionIO &mdash; Microsoft Teams / Entra sign-in'
17
+
18
+ def success_html
19
+ render(heading: 'LegionIO &mdash; Microsoft Teams / Entra sign-in complete',
20
+ message: 'You can close this tab.', color: '#107c10', icon: '&#10004;')
21
+ end
22
+
23
+ def failure_html(reason = nil, description = nil)
24
+ detail = [reason, description].compact.map { |v| CGI.escapeHTML(v.to_s) }.reject(&:empty?).join(': ')
25
+ detail_html = detail.empty? ? '' : "<p class=\"detail\">#{detail}</p>"
26
+ render(heading: 'LegionIO &mdash; Microsoft Teams / Entra sign-in failed',
27
+ message: 'You can close this tab. To try again, run <code>legionio auth teams</code>.',
28
+ color: '#a4262c', icon: '&#10008;', extra: detail_html)
29
+ end
30
+
31
+ def render(heading:, message:, color:, icon:, extra: '')
32
+ <<~HTML
33
+ <!DOCTYPE html>
34
+ <html lang="en"><head><meta charset="utf-8"><title>#{TITLE}</title>
35
+ <style>
36
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:center;padding:48px;color:#323130}
37
+ .icon{font-size:48px;color:#{color}} h1{font-size:22px;color:#{color}} .detail{color:#605e5c;font-size:13px}
38
+ code{background:#f3f2f1;padding:2px 6px;border-radius:3px}
39
+ </style></head>
40
+ <body><div class="icon">#{icon}</div><h1>#{heading}</h1><p>#{message}</p>#{extra}</body></html>
41
+ HTML
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'socket'
4
4
  require 'uri'
5
+ require 'legion/extensions/identity/entra/helpers/callback_page'
5
6
 
6
7
  module Legion
7
8
  module Extensions
@@ -12,11 +13,6 @@ module Legion
12
13
  include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
13
14
  Legion::Extensions::Helpers.const_defined?(:Lex, false)
14
15
 
15
- RESPONSE_HTML = <<~HTML
16
- <html><body style="font-family:sans-serif;text-align:center;padding:40px;">
17
- <h2>Authentication complete</h2><p>You can close this window.</p></body></html>
18
- HTML
19
-
20
16
  attr_reader :port
21
17
 
22
18
  def initialize
@@ -60,9 +56,9 @@ module Legion
60
56
  client = @server.accept
61
57
  request_line = client.gets
62
58
  drain_headers(client)
63
- capture_callback(request_line) if request_line&.include?('/callback?')
64
-
65
- client.print "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n#{RESPONSE_HTML}"
59
+ status, body = handle_request(request_line)
60
+ client.print "HTTP/1.1 #{status}\r\nContent-Type: text/html; charset=utf-8\r\n" \
61
+ "Content-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
66
62
  client.close
67
63
  end
68
64
  rescue IOError # rubocop:disable Legion/RescueLogging/NoCapture
@@ -79,17 +75,34 @@ module Legion
79
75
  line = client.gets until line.nil? || line.strip.empty?
80
76
  end
81
77
 
82
- def capture_callback(request_line)
83
- query = request_line.split[1].split('?', 2).last
78
+ # Returns [status_line, html]. Only /callback?... completes the flow;
79
+ # anything else (favicon, probes) gets a 404 and keeps listening.
80
+ def handle_request(request_line)
81
+ target = request_line.to_s.split[1].to_s
82
+ return ['404 Not Found', 'Not Found'] unless target.start_with?('/callback?')
83
+
84
+ params = capture_callback(target)
85
+ if params['code']
86
+ ['200 OK', CallbackPage.success_html]
87
+ else
88
+ ['400 Bad Request', CallbackPage.failure_html(params['error'] || 'missing_code', params['error_description'])]
89
+ end
90
+ end
91
+
92
+ def capture_callback(target)
93
+ query = target.split('?', 2).last
84
94
  params = URI.decode_www_form(query).to_h
85
95
 
86
96
  @mutex.synchronize do
87
97
  @result = {
88
- code: params['code'],
89
- state: params['state']
90
- }
98
+ code: params['code'],
99
+ state: params['state'],
100
+ error: params['error'],
101
+ error_description: params['error_description']
102
+ }.compact
91
103
  @cv.broadcast
92
104
  end
105
+ params
93
106
  end
94
107
 
95
108
  def log_debug(message)
@@ -0,0 +1,331 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'open3'
5
+ require 'rbconfig'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module Identity
10
+ module Entra
11
+ module Helpers
12
+ # Guards interactive (browser / device-code) sign-in so background actors
13
+ # can never stack up login tabs:
14
+ #
15
+ # * single-flight: one attempt at a time per process (Mutex) and across
16
+ # processes (flock on a lock file)
17
+ # * exponential backoff after an unanswered automatic attempt, and a
18
+ # hard stop after +max_auto_attempts+ until the user signs in manually
19
+ # (any successful TokenManager.save_token(:delegated) resets the guard)
20
+ # * optional skip while the macOS screen is locked or the user is idle
21
+ # * one clear warning telling the user how to re-authenticate
22
+ #
23
+ # Backoff state is persisted (no secrets) so a daemon restart does not
24
+ # immediately reopen a tab.
25
+ module InteractiveAuthGuard
26
+ extend self
27
+ include Legion::Logging::Helper
28
+ include Legion::JSON::Helper
29
+
30
+ REAUTH_HINT = 'Run `legionio auth teams` to sign in to Microsoft Teams / Entra again.'
31
+ DEFAULTS = {
32
+ backoff_base_seconds: 1800,
33
+ backoff_max_seconds: 21_600,
34
+ max_auto_attempts: 3,
35
+ skip_when_screen_locked: true,
36
+ skip_when_idle_seconds: 900
37
+ }.freeze
38
+ RUN_DIR = File.join(Dir.home, '.legionio', 'run')
39
+ SUCCESS_KEYS = %i[access_token].freeze
40
+
41
+ MUTEX = Mutex.new
42
+ STATE_MUTEX = Mutex.new
43
+
44
+ # Runs the block as an interactive sign-in attempt.
45
+ #
46
+ # automatic: true -> background actor; subject to backoff, suspension, screen lock/idle
47
+ # automatic: false -> user asked for it (CLI); only single-flight applies
48
+ #
49
+ # Returns the block's result, or { error:, description: } when skipped.
50
+ def run(automatic: false, &)
51
+ if automatic
52
+ skip = automatic_skip_reason
53
+ return skipped(skip) if skip
54
+ end
55
+
56
+ return busy('in_process') unless MUTEX.try_lock
57
+
58
+ begin
59
+ with_process_lock { record_outcome(yield, automatic: automatic) }
60
+ ensure
61
+ MUTEX.unlock
62
+ end
63
+ end
64
+
65
+ def in_flight?
66
+ MUTEX.locked?
67
+ end
68
+
69
+ # Clears backoff/suspension. Called after any successful delegated sign-in.
70
+ def reset!(reason: 'success')
71
+ STATE_MUTEX.synchronize do
72
+ state = current_state
73
+ had_state = state[:failures].to_i.positive? || state[:suspended] || state[:notified]
74
+ next unless had_state
75
+
76
+ @state = blank_state
77
+ persist_state
78
+ log.info("[entra][auth_guard] action=reset reason=#{reason}")
79
+ end
80
+ end
81
+
82
+ def state
83
+ STATE_MUTEX.synchronize { current_state.dup }
84
+ end
85
+
86
+ # Logs a single warning (once per process until the next successful
87
+ # sign-in) telling the user how to re-authenticate.
88
+ def notify_reauth_required(reason)
89
+ STATE_MUTEX.synchronize do
90
+ state = current_state
91
+ if state[:notified]
92
+ log.debug("[entra][auth_guard] action=notify_suppressed reason=#{reason}")
93
+ return false
94
+ end
95
+
96
+ state[:notified] = reason.to_s
97
+ persist_state
98
+ end
99
+ log.warn("Microsoft Teams / Entra sign-in required (#{reason}). #{REAUTH_HINT}")
100
+ true
101
+ end
102
+
103
+ def screen_locked?
104
+ return false unless darwin?
105
+
106
+ out, status = Open3.capture2('/usr/sbin/ioreg', '-n', 'Root', '-d1')
107
+ status.success? && out.include?('"CGSSessionScreenIsLocked"=Yes')
108
+ rescue StandardError => e
109
+ handle_exception(e, level: :debug, operation: 'entra.auth_guard.screen_locked?')
110
+ false
111
+ end
112
+
113
+ # Seconds since the last keyboard/mouse input (macOS only), or nil.
114
+ def idle_seconds
115
+ return nil unless darwin?
116
+
117
+ out, status = Open3.capture2('/usr/sbin/ioreg', '-c', 'IOHIDSystem', '-d', '4')
118
+ return nil unless status.success?
119
+
120
+ match = out.match(/"HIDIdleTime"\s*=\s*(\d+)/)
121
+ match ? match[1].to_i / 1_000_000_000 : nil
122
+ rescue StandardError => e
123
+ handle_exception(e, level: :debug, operation: 'entra.auth_guard.idle_seconds')
124
+ nil
125
+ end
126
+
127
+ def lock_path
128
+ setting(:lock_path) || File.join(RUN_DIR, 'entra_browser_auth.lock')
129
+ end
130
+
131
+ def state_path
132
+ setting(:state_path) || File.join(RUN_DIR, 'entra_browser_auth_state.json')
133
+ end
134
+
135
+ # Test helper: forget in-memory state so it is re-read from disk.
136
+ def reload!
137
+ STATE_MUTEX.synchronize do
138
+ @state = nil
139
+ @state_mtime = nil
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ def automatic_skip_reason
146
+ state = self.state
147
+ if state[:suspended]
148
+ log.debug("[entra][auth_guard] action=skip reason=suspended failures=#{state[:failures]}")
149
+ return 'suspended'
150
+ end
151
+
152
+ retry_at = state[:next_attempt_at]
153
+ if retry_at && Time.now.to_i < retry_at
154
+ log.debug("[entra][auth_guard] action=skip reason=backoff retry_in=#{retry_at - Time.now.to_i}s")
155
+ return 'backoff'
156
+ end
157
+
158
+ if config[:skip_when_screen_locked] && screen_locked?
159
+ log.debug('[entra][auth_guard] action=skip reason=screen_locked')
160
+ return 'screen_locked'
161
+ end
162
+
163
+ idle_limit = config[:skip_when_idle_seconds].to_i
164
+ if idle_limit.positive?
165
+ idle = idle_seconds
166
+ if idle && idle >= idle_limit
167
+ log.debug("[entra][auth_guard] action=skip reason=user_idle idle=#{idle}s limit=#{idle_limit}s")
168
+ return 'user_idle'
169
+ end
170
+ end
171
+
172
+ nil
173
+ end
174
+
175
+ def with_process_lock
176
+ path = lock_path
177
+ FileUtils.mkdir_p(File.dirname(path))
178
+ File.open(path, File::RDWR | File::CREAT, 0o600) do |file|
179
+ unless file.flock(File::LOCK_EX | File::LOCK_NB)
180
+ holder = file.read.to_s.strip
181
+ return busy('other_process', holder: holder)
182
+ end
183
+
184
+ begin
185
+ file.truncate(0)
186
+ file.write(::Process.pid.to_s)
187
+ file.flush
188
+ log.debug("[entra][auth_guard] action=lock_acquired path=#{path} pid=#{::Process.pid}")
189
+ yield
190
+ ensure
191
+ file.truncate(0)
192
+ file.flock(File::LOCK_UN)
193
+ end
194
+ end
195
+ end
196
+
197
+ def record_outcome(result, automatic:)
198
+ if success?(result)
199
+ reset!
200
+ elsif automatic
201
+ record_failure(result)
202
+ end
203
+ result
204
+ end
205
+
206
+ def success?(result)
207
+ return false unless result.is_a?(Hash)
208
+
209
+ body = result[:result]
210
+ body.is_a?(Hash) && SUCCESS_KEYS.all? { |k| body[k] || body[k.to_s] }
211
+ end
212
+
213
+ def record_failure(result)
214
+ error = result.is_a?(Hash) ? (result[:error] || 'no_token') : 'no_token'
215
+ cfg = config
216
+ warning = nil
217
+ STATE_MUTEX.synchronize do
218
+ state = current_state
219
+ state[:failures] = state[:failures].to_i + 1
220
+ state[:last_error] = error.to_s
221
+ state[:last_attempt_at] = Time.now.to_i
222
+ if state[:failures] >= cfg[:max_auto_attempts].to_i
223
+ state[:suspended] = true
224
+ state[:next_attempt_at] = nil
225
+ warning = "Automatic Microsoft Teams / Entra browser sign-in paused after #{state[:failures]} " \
226
+ "unanswered attempts (last error: #{error}). No more login tabs will be opened. #{REAUTH_HINT}"
227
+ else
228
+ delay = backoff_delay(state[:failures], cfg)
229
+ state[:next_attempt_at] = Time.now.to_i + delay
230
+ warning = "Microsoft Teams / Entra browser sign-in was not completed (#{error}). " \
231
+ "Will retry automatically in #{format_duration(delay)} " \
232
+ "(attempt #{state[:failures]}/#{cfg[:max_auto_attempts]}). #{REAUTH_HINT}"
233
+ end
234
+ state[:notified] = 'auto_auth_failed'
235
+ persist_state
236
+ end
237
+ log.warn(warning)
238
+ end
239
+
240
+ def backoff_delay(failures, cfg)
241
+ base = cfg[:backoff_base_seconds].to_i
242
+ max = cfg[:backoff_max_seconds].to_i
243
+ [base * (2**(failures - 1)), max].min
244
+ end
245
+
246
+ def format_duration(seconds)
247
+ return "#{seconds}s" if seconds < 120
248
+ return "#{seconds / 60} minutes" if seconds < 7200
249
+
250
+ "#{(seconds / 3600.0).round(1)} hours"
251
+ end
252
+
253
+ def skipped(reason)
254
+ notify_reauth_required('automatic sign-in deferred') if %w[screen_locked user_idle].include?(reason)
255
+ { error: 'auth_skipped', reason: reason,
256
+ description: "interactive sign-in skipped (#{reason}). #{REAUTH_HINT}" }
257
+ end
258
+
259
+ def busy(scope, holder: nil)
260
+ log.info("[entra][auth_guard] action=skip reason=in_progress scope=#{scope} holder=#{holder}")
261
+ { error: 'auth_in_progress', reason: scope,
262
+ description: "another Microsoft sign-in is already in progress#{" (pid #{holder})" unless holder.to_s.empty?}" }
263
+ end
264
+
265
+ # Re-reads the state file when another process (e.g. `legionio entra login`)
266
+ # changed it, so a sign-in elsewhere clears this daemon's backoff.
267
+ def current_state
268
+ mtime = state_file_mtime
269
+ if @state.nil? || (mtime && mtime != @state_mtime)
270
+ notified = @state&.dig(:notified)
271
+ @state = load_state
272
+ @state[:notified] ||= notified if @state[:failures].to_i.positive? || @state[:suspended]
273
+ @state_mtime = mtime
274
+ end
275
+ @state
276
+ end
277
+
278
+ def state_file_mtime
279
+ path = state_path
280
+ File.exist?(path) ? File.mtime(path) : nil
281
+ end
282
+
283
+ def blank_state
284
+ { failures: 0, suspended: false, next_attempt_at: nil, last_error: nil,
285
+ last_attempt_at: nil, notified: nil }
286
+ end
287
+
288
+ def load_state
289
+ path = state_path
290
+ return blank_state unless File.exist?(path)
291
+
292
+ raw = json_load(File.read(path))
293
+ return blank_state unless raw.is_a?(Hash)
294
+
295
+ # :notified is per-process so each daemon start logs the hint once.
296
+ blank_state.merge(raw.transform_keys(&:to_sym).slice(*blank_state.keys).except(:notified))
297
+ rescue StandardError => e
298
+ handle_exception(e, level: :warn, operation: 'entra.auth_guard.load_state', path: path)
299
+ blank_state
300
+ end
301
+
302
+ def persist_state
303
+ path = state_path
304
+ FileUtils.mkdir_p(File.dirname(path))
305
+ File.write(path, json_dump(@state.except(:notified)))
306
+ File.chmod(0o600, path)
307
+ @state_mtime = state_file_mtime
308
+ rescue StandardError => e
309
+ handle_exception(e, level: :warn, operation: 'entra.auth_guard.persist_state', path: path)
310
+ end
311
+
312
+ def config
313
+ DEFAULTS.to_h { |key, default| [key, setting(key).nil? ? default : setting(key)] }
314
+ end
315
+
316
+ def setting(key)
317
+ Legion::Settings.dig(:identity, :entra, :delegated, :browser_auth, key)
318
+ rescue StandardError => e
319
+ handle_exception(e, level: :debug, operation: 'entra.auth_guard.setting', key: key)
320
+ nil
321
+ end
322
+
323
+ def darwin?
324
+ RbConfig::CONFIG['host_os'].include?('darwin')
325
+ end
326
+ end
327
+ end
328
+ end
329
+ end
330
+ end
331
+ end
@@ -2,8 +2,8 @@
2
2
 
3
3
  require 'concurrent'
4
4
  require 'digest'
5
- require 'fileutils'
6
5
  require 'time'
6
+ require 'legion/extensions/identity/entra/helpers/interactive_auth_guard'
7
7
 
8
8
  module Legion
9
9
  module Extensions
@@ -16,14 +16,26 @@ module Legion
16
16
  include Legion::Settings::Helper
17
17
  include Legion::JSON::Helper
18
18
 
19
- TOKEN_DIR = File.join(Dir.home, '.legionio', 'tokens')
20
19
  REFRESH_BUFFER = 60
21
20
  MEMORY_STORE = Concurrent::Hash.new
21
+ # Negative cache for failed refresh attempts, keyed by qualifier. Without it
22
+ # every caller (LeaseRenewer polls once a second once the lease expires)
23
+ # re-POSTs a dead refresh_token to Microsoft and logs a warning each time.
24
+ REFRESH_FAILURES = Concurrent::Hash.new
25
+ REFRESH_BACKOFF_BASE = 30
26
+ REFRESH_BACKOFF_MAX = 900
27
+ # OAuth errors meaning the refresh_token will never work again; stop
28
+ # retrying until a new token is saved (user signs in again).
29
+ PERMANENT_REFRESH_ERRORS = %w[invalid_grant interaction_required invalid_client unauthorized_client].freeze
22
30
 
23
31
  def memory_store
24
32
  MEMORY_STORE
25
33
  end
26
34
 
35
+ def refresh_failures
36
+ REFRESH_FAILURES
37
+ end
38
+
27
39
  def load_token(qualifier = :delegated)
28
40
  log.debug("TokenManager.load_token: qualifier=#{qualifier}")
29
41
  data = token_data(qualifier, refresh: true)
@@ -34,20 +46,7 @@ module Legion
34
46
 
35
47
  def token_data(qualifier = :delegated, refresh: true)
36
48
  log.debug("TokenManager.token_data: qualifier=#{qualifier} refresh=#{refresh}")
37
- vault_data = from_vault_data(qualifier)
38
- other_data = vault_data || from_local_data(qualifier) || from_memory(qualifier)
39
- if other_data && !vault_data && vault_available? && canonical_name_available?
40
- log.info("TokenManager.token_data: backfilling #{qualifier} token to vault")
41
- backfill_saved = save_to_vault(qualifier, access_token: other_data[:access_token],
42
- refresh_token: other_data[:refresh_token],
43
- expires_at: other_data[:expires_at],
44
- scopes: other_data[:scopes],
45
- tenant_id: other_data[:tenant_id],
46
- client_id: other_data[:client_id],
47
- scope_fingerprint: other_data[:scope_fingerprint])
48
- delete_local(qualifier) if backfill_saved
49
- end
50
- data = other_data
49
+ data = from_vault_data(qualifier) || from_memory(qualifier)
51
50
  return nil unless data
52
51
 
53
52
  if scope_fingerprint_stale?(qualifier, data)
@@ -68,20 +67,14 @@ module Legion
68
67
  def save_token(qualifier, access_token:, refresh_token: nil, expires_at: nil,
69
68
  expires_in: nil, scopes: nil, tenant_id: nil, client_id: nil)
70
69
  log.debug("TokenManager.save_token: qualifier=#{qualifier} expires_in=#{expires_in}")
70
+ clear_refresh_failure(qualifier)
71
+ Helpers::InteractiveAuthGuard.reset!(reason: 'token_saved') if qualifier.to_sym == :delegated && access_token
71
72
  expires_at ||= Time.now + expires_in.to_i if expires_in
72
73
  fingerprint = current_scope_fingerprint(qualifier)
73
- vault_saved = save_to_vault(qualifier, access_token: access_token, refresh_token: refresh_token,
74
- expires_at: expires_at, scopes: scopes,
75
- tenant_id: tenant_id, client_id: client_id,
76
- scope_fingerprint: fingerprint)
77
- if vault_saved
78
- delete_local(qualifier)
79
- else
80
- save_to_local(qualifier, access_token: access_token, refresh_token: refresh_token,
81
- expires_at: expires_at, scopes: scopes,
82
- tenant_id: tenant_id, client_id: client_id,
83
- scope_fingerprint: fingerprint)
84
- end
74
+ save_to_vault(qualifier, access_token: access_token, refresh_token: refresh_token,
75
+ expires_at: expires_at, scopes: scopes,
76
+ tenant_id: tenant_id, client_id: client_id,
77
+ scope_fingerprint: fingerprint)
85
78
  save_to_memory(qualifier, access_token: access_token, refresh_token: refresh_token,
86
79
  expires_at: expires_at, scopes: scopes,
87
80
  tenant_id: tenant_id, client_id: client_id,
@@ -89,6 +82,7 @@ module Legion
89
82
  end
90
83
 
91
84
  def from_vault_data(qualifier)
85
+ return nil unless vault_read_enabled?(qualifier)
92
86
  return nil unless vault_available? && canonical_name_available?
93
87
 
94
88
  read_vault_token(qualifier, vault_path(qualifier))
@@ -106,20 +100,9 @@ module Legion
106
100
  nil
107
101
  end
108
102
 
109
- def from_local_data(qualifier)
110
- path = local_path(qualifier)
111
- return nil unless File.exist?(path)
112
-
113
- log.debug("TokenManager.from_local_data: reading #{path}")
114
- normalize_token_data(json_load(File.read(path)))
115
- rescue StandardError => e
116
- handle_exception(e, level: :warn, operation: 'token_manager.from_local_data',
117
- qualifier: qualifier, path: path)
118
- nil
119
- end
120
-
121
103
  def save_to_vault(qualifier, access_token:, refresh_token:, expires_at:,
122
104
  scopes: nil, tenant_id: nil, client_id: nil, scope_fingerprint: nil)
105
+ return unless vault_write_enabled?(qualifier)
123
106
  return unless vault_available?
124
107
  return unless canonical_name_available?
125
108
 
@@ -144,38 +127,6 @@ module Legion
144
127
  nil
145
128
  end
146
129
 
147
- def save_to_local(qualifier, access_token:, refresh_token:, expires_at:,
148
- scopes: nil, tenant_id: nil, client_id: nil, scope_fingerprint: nil)
149
- path = local_path(qualifier)
150
- log.debug("TokenManager.save_to_local: writing #{path}")
151
- FileUtils.mkdir_p(File.dirname(path))
152
- File.write(path, json_dump({
153
- access_token: access_token,
154
- refresh_token: refresh_token,
155
- expires_at: expires_at&.utc&.iso8601,
156
- scopes: scopes,
157
- tenant_id: tenant_id,
158
- client_id: client_id,
159
- scope_fingerprint: scope_fingerprint
160
- }))
161
- File.chmod(0o600, path)
162
- log.debug('TokenManager.save_to_local: success')
163
- rescue StandardError => e
164
- handle_exception(e, level: :warn, operation: 'token_manager.save_to_local',
165
- qualifier: qualifier, path: path)
166
- nil
167
- end
168
-
169
- def delete_local(qualifier)
170
- path = local_path(qualifier)
171
- return unless File.exist?(path)
172
-
173
- File.delete(path)
174
- log.info("TokenManager.delete_local: removed #{path} (vault is authoritative)")
175
- rescue StandardError => e
176
- handle_exception(e, level: :warn, operation: 'token_manager.delete_local', qualifier: qualifier)
177
- end
178
-
179
130
  def from_memory(qualifier)
180
131
  data = TokenManager.memory_store[qualifier.to_sym]
181
132
  return nil unless data
@@ -204,7 +155,7 @@ module Legion
204
155
  end
205
156
 
206
157
  def scope_fingerprint_stale?(qualifier, data = nil)
207
- data ||= from_local_data(qualifier) || from_memory(qualifier)
158
+ data ||= from_memory(qualifier)
208
159
  return false unless data
209
160
 
210
161
  stored = data[:scope_fingerprint]
@@ -227,19 +178,17 @@ module Legion
227
178
  return nil
228
179
  end
229
180
 
230
- runner = Object.new.extend(Legion::Extensions::Identity::Entra::Delegated::Runners::Login)
231
- result = runner.refresh_delegated_token(
181
+ return nil if refresh_suppressed?(qualifier, refresh)
182
+
183
+ result = login_runner.refresh_delegated_token(
232
184
  tenant_id: auth[:tenant_id],
233
185
  client_id: auth[:client_id],
234
186
  refresh_token: refresh,
235
187
  scope: auth[:scopes] || Legion::Extensions::Identity::Entra::Helpers::Scopes.resolve(pattern: :delegated)
236
188
  )
237
- body = result[:result]
189
+ body = result[:result] || {}
238
190
  access_token = fetch_key(body, :access_token)
239
- unless access_token
240
- log.warn("TokenManager.refresh_token: refresh failed for #{qualifier}, no access_token in response")
241
- return nil
242
- end
191
+ return record_refresh_response_failure(qualifier, refresh, result, body) unless access_token
243
192
 
244
193
  log.info("TokenManager.refresh_token: successfully refreshed token for #{qualifier}")
245
194
  save_token(qualifier,
@@ -253,9 +202,69 @@ module Legion
253
202
  rescue StandardError => e
254
203
  handle_exception(e, level: :error, operation: 'token_manager.refresh_token',
255
204
  qualifier: qualifier)
205
+ record_refresh_failure(qualifier, refresh, e.class.name, e.message) if refresh
256
206
  nil
257
207
  end
258
208
 
209
+ def login_runner
210
+ Object.new.extend(Legion::Extensions::Identity::Entra::Delegated::Runners::Login)
211
+ end
212
+
213
+ def record_refresh_response_failure(qualifier, refresh, result, body)
214
+ error = fetch_key(body, :error) || result[:error] || 'no_access_token'
215
+ record_refresh_failure(qualifier, refresh, error, fetch_key(body, :error_description) || result[:description])
216
+ nil
217
+ end
218
+
219
+ def refresh_suppressed?(qualifier, refresh)
220
+ entry = refresh_failures[qualifier.to_sym]
221
+ return false unless entry
222
+ return false unless entry[:fingerprint] == refresh_fingerprint(refresh)
223
+
224
+ if entry[:permanent]
225
+ log.debug("[entra][token_manager] action=refresh_skipped qualifier=#{qualifier} reason=permanent error=#{entry[:error]}")
226
+ return true
227
+ end
228
+
229
+ remaining = entry[:retry_at] - Time.now
230
+ return false unless remaining.positive?
231
+
232
+ log.debug("[entra][token_manager] action=refresh_skipped qualifier=#{qualifier} reason=backoff " \
233
+ "retry_in=#{remaining.round}s failures=#{entry[:failures]}")
234
+ true
235
+ end
236
+
237
+ def record_refresh_failure(qualifier, refresh, error, description = nil)
238
+ key = qualifier.to_sym
239
+ fingerprint = refresh_fingerprint(refresh)
240
+ previous = refresh_failures[key]
241
+ previous = nil unless previous && previous[:fingerprint] == fingerprint
242
+ failures = (previous&.dig(:failures) || 0) + 1
243
+ permanent = PERMANENT_REFRESH_ERRORS.include?(error.to_s)
244
+ delay = [REFRESH_BACKOFF_BASE * (2**(failures - 1)), REFRESH_BACKOFF_MAX].min
245
+ refresh_failures[key] = { fingerprint: fingerprint, failures: failures, error: error.to_s,
246
+ permanent: permanent, retry_at: Time.now + delay }
247
+
248
+ if permanent && !previous&.dig(:permanent)
249
+ log.warn("TokenManager.refresh_token: refresh token for #{qualifier} was rejected (#{error}: #{description}). " \
250
+ 'Not retrying until you sign in again.')
251
+ Helpers::InteractiveAuthGuard.notify_reauth_required('refresh_token_rejected') if key == :delegated
252
+ elsif failures == 1
253
+ log.warn("TokenManager.refresh_token: refresh failed for #{qualifier} (#{error}), backing off #{delay}s")
254
+ else
255
+ log.debug("[entra][token_manager] action=refresh_failed qualifier=#{qualifier} error=#{error} " \
256
+ "failures=#{failures} retry_in=#{delay}s permanent=#{permanent}")
257
+ end
258
+ end
259
+
260
+ def clear_refresh_failure(qualifier)
261
+ refresh_failures.delete(qualifier.to_sym)
262
+ end
263
+
264
+ def refresh_fingerprint(refresh)
265
+ Digest::SHA256.hexdigest(refresh.to_s)[0, 16]
266
+ end
267
+
259
268
  def from_broker(qualifier)
260
269
  return nil unless defined?(Legion::Identity::Broker)
261
270
 
@@ -280,6 +289,14 @@ module Legion
280
289
  !load_token(qualifier).nil?
281
290
  end
282
291
 
292
+ def vault_read_enabled?(qualifier)
293
+ Legion::Settings.dig(:identity, :entra, qualifier.to_sym, :token, :vault_read_enabled)
294
+ end
295
+
296
+ def vault_write_enabled?(qualifier)
297
+ Legion::Settings.dig(:identity, :entra, qualifier.to_sym, :token, :vault_write_enabled)
298
+ end
299
+
283
300
  def vault_available?
284
301
  defined?(Legion::Crypt) &&
285
302
  Legion::Crypt.respond_to?(:vault_connected?) &&
@@ -296,14 +313,6 @@ module Legion
296
313
  "users/#{Legion::Identity::Process.canonical_name}/entra/#{qualifier}/auth"
297
314
  end
298
315
 
299
- def local_path(qualifier)
300
- auth = settings_auth
301
- pattern_settings = auth[qualifier.to_sym]
302
- return File.expand_path(pattern_settings[:local_token_path]) if pattern_settings.is_a?(Hash) && pattern_settings[:local_token_path]
303
-
304
- File.join(TOKEN_DIR, "entra_#{qualifier}.json")
305
- end
306
-
307
316
  def settings_auth
308
317
  identity_entra = Legion::Settings.dig(:identity, :entra) || {}
309
318
  auth = identity_entra[:auth].is_a?(Hash) ? identity_entra[:auth].dup : identity_entra.dup
@@ -28,10 +28,11 @@ module Legion
28
28
  category_overrides: {}
29
29
  },
30
30
  token: {
31
- vault_path: nil,
32
- local_token_path: nil,
33
- refresh_buffer: 60,
34
- refresh_interval: 2700
31
+ vault_read_enabled: false,
32
+ vault_write_enabled: false,
33
+ vault_path: nil,
34
+ refresh_buffer: 60,
35
+ refresh_interval: 2700
35
36
  }
36
37
  }
37
38
  end
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Identity
6
6
  module Entra
7
- VERSION = '0.5.4'
7
+ VERSION = '0.6.1'
8
8
  end
9
9
  end
10
10
  end
@@ -29,10 +29,11 @@ module Legion
29
29
  category_overrides: {}
30
30
  },
31
31
  token: {
32
- vault_path: nil,
33
- local_token_path: nil,
34
- refresh_buffer: 60,
35
- refresh_interval: 2700
32
+ vault_read_enabled: false,
33
+ vault_write_enabled: false,
34
+ vault_path: nil,
35
+ refresh_buffer: 60,
36
+ refresh_interval: 2700
36
37
  }
37
38
  }
38
39
  end
@@ -12,6 +12,8 @@ require_relative 'entra/version'
12
12
  require_relative 'entra/helpers/scopes'
13
13
  require_relative 'entra/helpers/scope_registry'
14
14
  require_relative 'entra/helpers/scope_gate'
15
+ require_relative 'entra/helpers/interactive_auth_guard'
16
+ require_relative 'entra/helpers/callback_page'
15
17
  require_relative 'entra/helpers/token_manager'
16
18
  require_relative 'entra/helpers/graph_client'
17
19
  require_relative 'entra/helpers/account_discovery'
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.5.4
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -129,8 +129,10 @@ files:
129
129
  - lib/legion/extensions/identity/entra/delegated/scopes.rb
130
130
  - lib/legion/extensions/identity/entra/helpers/account_discovery.rb
131
131
  - lib/legion/extensions/identity/entra/helpers/browser_auth.rb
132
+ - lib/legion/extensions/identity/entra/helpers/callback_page.rb
132
133
  - lib/legion/extensions/identity/entra/helpers/callback_server.rb
133
134
  - lib/legion/extensions/identity/entra/helpers/graph_client.rb
135
+ - lib/legion/extensions/identity/entra/helpers/interactive_auth_guard.rb
134
136
  - lib/legion/extensions/identity/entra/helpers/scope_gate.rb
135
137
  - lib/legion/extensions/identity/entra/helpers/scope_registry.rb
136
138
  - lib/legion/extensions/identity/entra/helpers/scopes.rb