lex-identity-entra 0.6.0 → 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 +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +22 -1
- data/lib/legion/extensions/identity/entra/delegated/actors/auth_validator.rb +11 -5
- data/lib/legion/extensions/identity/entra/delegated/actors/token_refresher.rb +21 -6
- data/lib/legion/extensions/identity/entra/delegated/runners/login.rb +30 -27
- data/lib/legion/extensions/identity/entra/delegated.rb +9 -3
- data/lib/legion/extensions/identity/entra/helpers/browser_auth.rb +33 -12
- data/lib/legion/extensions/identity/entra/helpers/callback_page.rb +48 -0
- data/lib/legion/extensions/identity/entra/helpers/callback_server.rb +26 -13
- data/lib/legion/extensions/identity/entra/helpers/interactive_auth_guard.rb +331 -0
- data/lib/legion/extensions/identity/entra/helpers/token_manager.rb +81 -7
- data/lib/legion/extensions/identity/entra/version.rb +1 -1
- data/lib/legion/extensions/identity/entra.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7076abeb6fcf3e837854a909a38bbe46ab78ff6dd8372fb0df53d507484170e
|
|
4
|
+
data.tar.gz: 6d9347e6aedc921ee6c16fc8a2004d0eb986e15eb8faf150a3a26787363e0d04
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6245ce699161036398ccfb11d141587f0188ebabe38944862fe693fcf7f6210c935cf4a493119de46d1b0b42691aa69a5f6459c2f0e11dc2d39b81bde8dad706
|
|
7
|
+
data.tar.gz: 0b2a7fae7d074dc4fe580f7289c79105ca6761227b6bbbd7045844d4e75b0fb0be90a4c46f92af04fd2ff4a112fa86a3dd12440f1cb6b805d2174cd52b809999
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
3
17
|
## [0.6.0] - 2026-09-25
|
|
4
18
|
### Changed
|
|
5
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).
|
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
|
|
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
|
|
@@ -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-
|
|
41
|
+
log.info('Entra delegated scope fingerprint changed, re-authentication needed to acquire updated scopes')
|
|
42
42
|
else
|
|
43
|
-
log.info('Entra delegated token
|
|
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.
|
|
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
|
-
|
|
49
|
-
log.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
+
handle_exception(e, level: :error, operation: 'entra.delegated.token_refresher.attempt_browser_reauth')
|
|
91
106
|
end
|
|
92
107
|
end
|
|
93
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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…</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:
|
|
46
|
-
force_local_server:
|
|
47
|
-
callback_timeout:
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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(
|
|
147
|
-
mutex.synchronize { cv.wait(mutex,
|
|
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(
|
|
167
|
-
result = server.wait_for_callback(timeout:
|
|
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 — Microsoft Teams / Entra sign-in'
|
|
17
|
+
|
|
18
|
+
def success_html
|
|
19
|
+
render(heading: 'LegionIO — Microsoft Teams / Entra sign-in complete',
|
|
20
|
+
message: 'You can close this tab.', color: '#107c10', icon: '✔')
|
|
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 — 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: '✘', 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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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:
|
|
89
|
-
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
|
|
@@ -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
|
-
|
|
166
|
-
|
|
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
|
|
|
@@ -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.
|
|
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
|