lex-identity-entra 0.6.0 → 0.7.0

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: ea7d2caf79ed2c6025cbad1ee6daae0a5f5250aada92cf26f42c71bf8e849680
4
- data.tar.gz: 3f7afe033551273e7ebe6345c40e24f824529b0f4df79a1f3e7677002dcb006c
3
+ metadata.gz: d2664847cdf119f5e114612d95e09b67f81d6964f466960a56cd246f01cbfa4d
4
+ data.tar.gz: 62c37fb1dabee26a49dfa806f995b6b5b8d144a8dd2e1559bb304947886ebfa4
5
5
  SHA512:
6
- metadata.gz: 55af5175673b2d9371c0e1d8b8ff03479299e7425b8f6f79b88b941e2b31bcb44069f026bcae90d898a87a7a6511b69a2c0618279ed73b1ac6631369d9733db2
7
- data.tar.gz: fc672dfff7aa30a9325b7046752f5cdefe737f39b5e8bb69066657c0873d2f8ae600bf758a2ad42fcc382cb5a47467090e074931581486124e89eb52b66dd53b
6
+ metadata.gz: 33c9ee548f2fd6a709a3b8a48411f74fcd47f69cc41a566993f9cb6077461041cb0b3a6dcc7e22054d238dae66c9b7968aec0272a999bd979a67f2221187a27c
7
+ data.tar.gz: b05f2770649c3321adfebf21c7bc352e75fce1872768fe8425fa65a08385bba5b07b96a027d1d21632e129f2157d11fa1a3b3ec7db3fe726be08edc093c6a430
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0] - 2026-10-31
4
+ ### Security
5
+ - Qualify delegated identity keys by verified Graph token tenant and object ID (`tenant:oid`), including multi-account discovery. A successful `/me` response must match the accepted token's object ID; opaque or mismatched token subjects cannot resolve an authenticated shared identity.
6
+ - Preserve human canonical names without treating them as cross-tenant account-linking evidence. Existing unscoped object-ID links require explicit tenant verification before migration; they are not automatically merged by name.
7
+
8
+ ## [0.6.1] - 2026-10-19
9
+ ### Fixed
10
+ - **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:
11
+ - single-flight per process (Mutex) and across processes (`flock` lock file)
12
+ - 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.
13
+ - skipping automatic attempts while the macOS screen is locked or the user is idle (`ioreg`)
14
+ - a single warning telling the user to run `legionio auth teams`
15
+ - **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.
16
+ - `AuthValidator` no longer opens a browser for an expired token when `auto_authenticate` is false. It logs the re-auth hint instead.
17
+ - `browser_auth.callback_timeout` is now honored. It was hard-coded to 120 s. The hook listener is removed on timeout.
18
+
19
+ ### Changed
20
+ - 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.
21
+
3
22
  ## [0.6.0] - 2026-09-25
4
23
  ### Changed
5
24
  - **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).
data/README.md CHANGED
@@ -33,8 +33,14 @@ 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:
39
45
  vault_read_enabled: false # set true to read the token from Vault
40
46
  vault_write_enabled: false # set true to write the token to Vault
@@ -63,6 +69,21 @@ legion lex exec entra auth status
63
69
  The `AuthValidator` actor fires 9 seconds after boot. If `auto_authenticate: true` is set,
64
70
  it opens a browser window automatically. Otherwise trigger login via the CLI.
65
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
+
66
87
  ### Accessing tokens from another extension
67
88
 
68
89
  ```ruby
@@ -112,11 +133,24 @@ identity:
112
133
  canonical_name: "jdoe", # normalized from onPremisesSamAccountName or mailNickname
113
134
  kind: :human,
114
135
  source: :entra_delegated,
115
- provider_identity: "object-id-guid",
116
- profile: { ... } # full Graph /me response
136
+ provider_identity: "tenant-id-guid:object-id-guid",
137
+ tenant_id: "tenant-id-guid",
138
+ object_id: "object-id-guid",
139
+ profile: { ... } # normalized Graph /me profile and verified subject context
117
140
  }
118
141
  ```
119
142
 
143
+ The tenant/object key is derived only after Graph accepts the exact bearer token and
144
+ the token's `oid` matches `/me.id`. Opaque tokens still allow profile retrieval but
145
+ cannot establish a shared authenticated identity without verifiable tenant context.
146
+ Canonical names and display names are not account-linking evidence. LegionIO 1.9.53+
147
+ reuses a discovered Teams principal through this immutable key without granting
148
+ access to the discovering reader's stored Teams content.
149
+
150
+ Existing object-ID-only identity rows are not automatically adopted: their tenant
151
+ must be established explicitly before linking them to the new scoped key. No tokens
152
+ or source profiles are copied into the shared identity registry by Teams discovery.
153
+
120
154
  ## Development
121
155
 
122
156
  ```bash
@@ -28,6 +28,7 @@ Gem::Specification.new do |spec|
28
28
  spec.require_paths = ['lib']
29
29
 
30
30
  # Core framework dependencies
31
+ spec.add_dependency 'base64', '>= 0.2'
31
32
  spec.add_dependency 'concurrent-ruby', '>= 1.2'
32
33
  spec.add_dependency 'faraday', '>= 2.0'
33
34
  spec.add_dependency 'legion-crypt', '>= 1.5.13'
@@ -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
 
@@ -45,16 +45,27 @@ 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
71
  Legion::Extensions::Identity::Entra::Helpers::TokenManager.from_memory(:delegated)
@@ -68,12 +79,16 @@ module Legion
68
79
  browser = Legion::Extensions::Identity::Entra::Helpers::BrowserAuth.new(
69
80
  tenant_id: auth_settings[:tenant_id],
70
81
  client_id: auth_settings[:client_id],
71
- scopes: scopes
82
+ scopes: scopes,
83
+ automatic: true
72
84
  )
73
85
 
74
86
  result = browser.authenticate
75
87
  body = result&.dig(:result)
76
- 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
77
92
 
78
93
  Legion::Extensions::Identity::Entra::Helpers::TokenManager.save_token(
79
94
  :delegated,
@@ -87,7 +102,7 @@ module Legion
87
102
  Legion::Extensions::Identity::Entra::Client.reset!(pattern: :delegated)
88
103
  log.info('Delegated auth restored via browser re-auth')
89
104
  rescue StandardError => e
90
- log.error("Delegated browser re-auth failed: #{e.message}")
105
+ handle_exception(e, level: :error, operation: 'entra.delegated.token_refresher.attempt_browser_reauth')
91
106
  end
92
107
  end
93
108
  end
@@ -31,6 +31,11 @@ module Legion
31
31
  return nil
32
32
  end
33
33
 
34
+ unless profile[:provider_identity_key]
35
+ log.warn('Delegated::Identity.resolve: no verified tenant/object subject in profile')
36
+ return nil
37
+ end
38
+
34
39
  canonical = profile[:on_premises_sam_account_name] || profile[:mail_nickname]
35
40
  if canonical.nil? || canonical.empty?
36
41
  log.warn('Delegated::Identity.resolve: no canonical name in profile')
@@ -42,7 +47,9 @@ module Legion
42
47
  canonical_name: normalize(canonical),
43
48
  kind: :human,
44
49
  source: :entra_delegated,
45
- provider_identity: profile[:id],
50
+ provider_identity: profile[:provider_identity_key],
51
+ tenant_id: profile[:tenant_id],
52
+ object_id: profile[:object_id],
46
53
  profile: profile,
47
54
  employee_id: profile[:employee_id]
48
55
  }
@@ -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) &&
@@ -42,9 +42,15 @@ module Legion
42
42
  refresh_interval: 900
43
43
  },
44
44
  browser_auth: {
45
- auto_authenticate: false,
46
- force_local_server: false,
47
- 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
48
54
  }
49
55
  }
50
56
  end
@@ -43,7 +43,7 @@ module Legion
43
43
  next unless token
44
44
 
45
45
  profile = Legion::Extensions::Identity::Entra::Helpers::GraphClient.fetch_me(token)
46
- next unless profile
46
+ next unless profile && profile[:provider_identity_key]
47
47
 
48
48
  canonical = profile[:on_premises_sam_account_name] || profile[:mail_nickname]
49
49
  next if canonical.nil? || canonical.empty?
@@ -54,7 +54,9 @@ module Legion
54
54
  source: :entra,
55
55
  qualifier: qualifier,
56
56
  account_type: account_type_for(qualifier, canonical),
57
- provider_identity: profile[:id],
57
+ provider_identity: profile[:provider_identity_key],
58
+ tenant_id: profile[:tenant_id],
59
+ object_id: profile[:object_id],
58
60
  profile: profile,
59
61
  employee_id: profile[:employee_id]
60
62
  }
@@ -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)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'faraday'
4
+ require 'base64'
4
5
 
5
6
  module Legion
6
7
  module Extensions
@@ -26,7 +27,8 @@ module Legion
26
27
  end
27
28
 
28
29
  log.debug('GraphClient.fetch_me: profile fetched successfully')
29
- parse_profile(json_load(response.body))
30
+ profile = parse_profile(json_load(response.body))
31
+ profile.merge(authenticated_subject(access_token: access_token, object_id: profile[:id]))
30
32
  rescue StandardError => e
31
33
  handle_exception(e, level: :warn, operation: 'graph_client.fetch_me')
32
34
  nil
@@ -55,6 +57,29 @@ module Legion
55
57
  f.options.timeout = 10
56
58
  end
57
59
  end
60
+
61
+ private
62
+
63
+ # Graph has accepted this exact bearer token. Only then may its tenant/object
64
+ # claims qualify the /me subject; configured tenants and names are not evidence.
65
+ def authenticated_subject(access_token:, object_id:)
66
+ parts = access_token.to_s.split('.')
67
+ return {} unless parts.length == 3
68
+
69
+ claims = json_load(Base64.urlsafe_decode64(parts[1]))
70
+ tenant = claims[:tid].to_s.downcase
71
+ oid = claims[:oid].to_s.downcase
72
+ guid = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
73
+ unless guid.match?(tenant) && guid.match?(oid) && oid == object_id.to_s.downcase
74
+ log.warn('Graph /me subject does not have matching tenant/object token claims')
75
+ return {}
76
+ end
77
+
78
+ { tenant_id: tenant, object_id: oid, provider_identity_key: "#{tenant}:#{oid}" }
79
+ rescue StandardError => e
80
+ log.warn("Graph /me subject context unavailable: #{e.class}")
81
+ {}
82
+ end
58
83
  end
59
84
  end
60
85
  end
@@ -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: :warn, 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: :warn, 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: :warn, 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
@@ -3,6 +3,7 @@
3
3
  require 'concurrent'
4
4
  require 'digest'
5
5
  require 'time'
6
+ require 'legion/extensions/identity/entra/helpers/interactive_auth_guard'
6
7
 
7
8
  module Legion
8
9
  module Extensions
@@ -17,11 +18,24 @@ module Legion
17
18
 
18
19
  REFRESH_BUFFER = 60
19
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
20
30
 
21
31
  def memory_store
22
32
  MEMORY_STORE
23
33
  end
24
34
 
35
+ def refresh_failures
36
+ REFRESH_FAILURES
37
+ end
38
+
25
39
  def load_token(qualifier = :delegated)
26
40
  log.debug("TokenManager.load_token: qualifier=#{qualifier}")
27
41
  data = token_data(qualifier, refresh: true)
@@ -53,6 +67,8 @@ module Legion
53
67
  def save_token(qualifier, access_token:, refresh_token: nil, expires_at: nil,
54
68
  expires_in: nil, scopes: nil, tenant_id: nil, client_id: nil)
55
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
56
72
  expires_at ||= Time.now + expires_in.to_i if expires_in
57
73
  fingerprint = current_scope_fingerprint(qualifier)
58
74
  save_to_vault(qualifier, access_token: access_token, refresh_token: refresh_token,
@@ -162,19 +178,17 @@ module Legion
162
178
  return nil
163
179
  end
164
180
 
165
- runner = Object.new.extend(Legion::Extensions::Identity::Entra::Delegated::Runners::Login)
166
- result = runner.refresh_delegated_token(
181
+ return nil if refresh_suppressed?(qualifier, refresh)
182
+
183
+ result = login_runner.refresh_delegated_token(
167
184
  tenant_id: auth[:tenant_id],
168
185
  client_id: auth[:client_id],
169
186
  refresh_token: refresh,
170
187
  scope: auth[:scopes] || Legion::Extensions::Identity::Entra::Helpers::Scopes.resolve(pattern: :delegated)
171
188
  )
172
- body = result[:result]
189
+ body = result[:result] || {}
173
190
  access_token = fetch_key(body, :access_token)
174
- unless access_token
175
- log.warn("TokenManager.refresh_token: refresh failed for #{qualifier}, no access_token in response")
176
- return nil
177
- end
191
+ return record_refresh_response_failure(qualifier, refresh, result, body) unless access_token
178
192
 
179
193
  log.info("TokenManager.refresh_token: successfully refreshed token for #{qualifier}")
180
194
  save_token(qualifier,
@@ -188,9 +202,69 @@ module Legion
188
202
  rescue StandardError => e
189
203
  handle_exception(e, level: :error, operation: 'token_manager.refresh_token',
190
204
  qualifier: qualifier)
205
+ record_refresh_failure(qualifier, refresh, e.class.name, e.message) if refresh
191
206
  nil
192
207
  end
193
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
+
194
268
  def from_broker(qualifier)
195
269
  return nil unless defined?(Legion::Identity::Broker)
196
270
 
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Identity
6
6
  module Entra
7
- VERSION = '0.6.0'
7
+ VERSION = '0.7.0'
8
8
  end
9
9
  end
10
10
  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.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -9,6 +9,20 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: concurrent-ruby
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -129,8 +143,10 @@ files:
129
143
  - lib/legion/extensions/identity/entra/delegated/scopes.rb
130
144
  - lib/legion/extensions/identity/entra/helpers/account_discovery.rb
131
145
  - lib/legion/extensions/identity/entra/helpers/browser_auth.rb
146
+ - lib/legion/extensions/identity/entra/helpers/callback_page.rb
132
147
  - lib/legion/extensions/identity/entra/helpers/callback_server.rb
133
148
  - lib/legion/extensions/identity/entra/helpers/graph_client.rb
149
+ - lib/legion/extensions/identity/entra/helpers/interactive_auth_guard.rb
134
150
  - lib/legion/extensions/identity/entra/helpers/scope_gate.rb
135
151
  - lib/legion/extensions/identity/entra/helpers/scope_registry.rb
136
152
  - lib/legion/extensions/identity/entra/helpers/scopes.rb