lex-identity-entra 0.5.4 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +29 -7
- data/lib/legion/extensions/identity/entra/application.rb +5 -4
- data/lib/legion/extensions/identity/entra/delegated/actors/auth_validator.rb +12 -7
- data/lib/legion/extensions/identity/entra/delegated/actors/token_refresher.rb +21 -7
- data/lib/legion/extensions/identity/entra/delegated/runners/login.rb +30 -27
- data/lib/legion/extensions/identity/entra/delegated.rb +14 -7
- data/lib/legion/extensions/identity/entra/helpers/account_discovery.rb +4 -10
- 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 +97 -88
- data/lib/legion/extensions/identity/entra/managed_identity.rb +5 -4
- data/lib/legion/extensions/identity/entra/version.rb +1 -1
- data/lib/legion/extensions/identity/entra/workload_identity.rb +5 -4
- 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,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.1] - 2026-10-19
|
|
4
|
+
### Fixed
|
|
5
|
+
- **Browser tab storm**: `Delegated::Actor::TokenRefresher` opened a new login tab on every 15-minute tick once the refresh token went bad (about 50 tabs overnight). It now respects `browser_auth.auto_authenticate`, and every interactive attempt goes through the new `Helpers::InteractiveAuthGuard`, which provides:
|
|
6
|
+
- single-flight per process (Mutex) and across processes (`flock` lock file)
|
|
7
|
+
- exponential backoff after an unanswered automatic attempt (30 min, doubling up to 6 h), then a pause after `max_auto_attempts` (3) until the user signs in manually. State persists across restarts.
|
|
8
|
+
- skipping automatic attempts while the macOS screen is locked or the user is idle (`ioreg`)
|
|
9
|
+
- a single warning telling the user to run `legionio auth teams`
|
|
10
|
+
- **Refresh loop**: `TokenManager.refresh_token` now negative-caches failures per refresh token. Permanent OAuth errors (`invalid_grant`, `interaction_required`, ...) are not retried until a new token is saved, and transient errors back off from 30 s to 15 min. Previously the broker's `LeaseRenewer` called `provide_token` about once a second and every call re-POSTed the dead refresh token and logged a warning.
|
|
11
|
+
- `AuthValidator` no longer opens a browser for an expired token when `auto_authenticate` is false. It logs the re-auth hint instead.
|
|
12
|
+
- `browser_auth.callback_timeout` is now honored. It was hard-coded to 120 s. The hook listener is removed on timeout.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- The OAuth callback page (local callback server and API hook route) now reads "LegionIO — Microsoft Teams / Entra sign-in complete. You can close this tab." There is a matching failure page that shows the provider error and the `legionio auth teams` hint. Provider errors such as `access_denied` now surface immediately instead of waiting for the timeout. The local server returns 404 for unrelated requests like `/favicon.ico` instead of consuming the callback.
|
|
16
|
+
|
|
17
|
+
## [0.6.0] - 2026-09-25
|
|
18
|
+
### Changed
|
|
19
|
+
- **Breaking**: Vault token persistence is now opt-in in both directions. New per-pattern settings `identity.entra.<pattern>.token.vault_read_enabled` and `vault_write_enabled`, both defaulting to `false`. With the defaults, Entra tokens are never read from or written to Vault. Set the flags explicitly to restore 0.5.x Vault behavior (each direction is independent).
|
|
20
|
+
- **Breaking**: Removed local disk token persistence (`~/.legionio/tokens/entra_<qualifier>.json`) and its `local_token_path` setting. Tokens are held in-memory by default and in Vault only when explicitly opted in — nothing is ever written to disk. In the default configuration tokens no longer survive a process restart: re-auth via `auto_authenticate` or `legion lex exec entra auth login`.
|
|
21
|
+
- Upgrades from 0.5.x: tokens that exist only in Vault are not read by default (expect a one-time re-auth, or set `vault_read_enabled: true`), and stale on-disk token files are no longer read or written.
|
|
22
|
+
|
|
3
23
|
## [0.5.4] - 2026-08-04
|
|
4
24
|
### Fixed
|
|
5
25
|
- Stop writing delegated Entra tokens to bootstrap Vault paths. Token persistence now uses only the canonical `users/<identity>/entra/<qualifier>/auth` Vault path and falls back to local disk until a canonical identity is available.
|
data/README.md
CHANGED
|
@@ -7,7 +7,7 @@ and workload identity authentication patterns.
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
9
|
- **Delegated auth** — OAuth2 PKCE browser flow with local callback server; device-code fallback
|
|
10
|
-
- **Token persistence** —
|
|
10
|
+
- **Token persistence** — in-memory by default; Vault (`kv/data/users/<identity>/entra/<qualifier>/auth`) reading and writing are separate opt-ins via `vault_read_enabled` / `vault_write_enabled`. Tokens are never written to local disk
|
|
11
11
|
- **Scope fingerprinting** — MD5 fingerprint of active scopes stored with token; any scope change forces re-authentication on next boot
|
|
12
12
|
- **Identity integration** — `AuthValidator` upgrades `Legion::Identity::Process` via `Resolver.upgrade!` and registers with `Legion::Identity::Broker` for cross-extension token access
|
|
13
13
|
- **Application credentials** — client credentials flow for service-to-service auth
|
|
@@ -33,9 +33,17 @@ identity:
|
|
|
33
33
|
client_id: "your-client-id"
|
|
34
34
|
delegated:
|
|
35
35
|
browser_auth:
|
|
36
|
-
auto_authenticate: false # set true to open browser automatically
|
|
36
|
+
auto_authenticate: false # set true to open browser automatically (boot + token refresher)
|
|
37
37
|
callback_timeout: 120
|
|
38
|
+
# Guard for automatic (actor-initiated) sign-in:
|
|
39
|
+
backoff_base_seconds: 1800 # wait after an unanswered tab; doubles each time
|
|
40
|
+
backoff_max_seconds: 21600
|
|
41
|
+
max_auto_attempts: 3 # then stop opening tabs until you sign in manually
|
|
42
|
+
skip_when_screen_locked: true # macOS: never open tabs while the screen is locked
|
|
43
|
+
skip_when_idle_seconds: 900 # macOS: skip if no keyboard/mouse input for this long (0 = off)
|
|
38
44
|
token:
|
|
45
|
+
vault_read_enabled: false # set true to read the token from Vault
|
|
46
|
+
vault_write_enabled: false # set true to write the token to Vault
|
|
39
47
|
refresh_buffer: 60
|
|
40
48
|
refresh_interval: 900
|
|
41
49
|
scopes:
|
|
@@ -61,6 +69,21 @@ legion lex exec entra auth status
|
|
|
61
69
|
The `AuthValidator` actor fires 9 seconds after boot. If `auto_authenticate: true` is set,
|
|
62
70
|
it opens a browser window automatically. Otherwise trigger login via the CLI.
|
|
63
71
|
|
|
72
|
+
Interactive sign-in is protected by `Helpers::InteractiveAuthGuard`:
|
|
73
|
+
|
|
74
|
+
- **Single-flight**: only one browser/device-code attempt runs at a time, per process (mutex) and across
|
|
75
|
+
processes (`flock` on `~/.legionio/run/entra_browser_auth.lock`).
|
|
76
|
+
- **Backoff**: when an automatic attempt times out, the next one waits `backoff_base_seconds` (default 30 min),
|
|
77
|
+
doubling up to `backoff_max_seconds`. After `max_auto_attempts` unanswered tabs, automatic sign-in pauses
|
|
78
|
+
until you sign in manually (`legionio auth teams` or `legion lex exec entra auth login`). Backoff state is
|
|
79
|
+
kept in `~/.legionio/run/entra_browser_auth_state.json` (no secrets), so restarting the daemon does not
|
|
80
|
+
reopen a tab right away.
|
|
81
|
+
- **Presence**: on macOS, automatic attempts are skipped while the screen is locked or the user is idle.
|
|
82
|
+
- **No refresh storms**: a refresh token that Microsoft rejects (`invalid_grant` and similar) is not retried
|
|
83
|
+
until a new token is saved, and transient refresh errors back off from 30 s up to 15 min. One warning tells
|
|
84
|
+
you how to sign in again.
|
|
85
|
+
- `TokenRefresher` only opens a browser when `auto_authenticate: true`.
|
|
86
|
+
|
|
64
87
|
### Accessing tokens from another extension
|
|
65
88
|
|
|
66
89
|
```ruby
|
|
@@ -98,11 +121,10 @@ identity:
|
|
|
98
121
|
|
|
99
122
|
## Token storage
|
|
100
123
|
|
|
101
|
-
| Backend | Path |
|
|
102
|
-
|
|
103
|
-
| HashiCorp Vault | `kv/data/users/<identity>/entra/delegated/auth` |
|
|
104
|
-
|
|
|
105
|
-
| Memory | In-process store | 3 (runtime fallback) |
|
|
124
|
+
| Backend | Path | When used |
|
|
125
|
+
|---------|------|-----------|
|
|
126
|
+
| HashiCorp Vault | `kv/data/users/<identity>/entra/delegated/auth` | Only when opted in: `vault_read_enabled` / `vault_write_enabled` (both default `false`) |
|
|
127
|
+
| Memory | In-process store | Default persistence; tokens are never written to local disk |
|
|
106
128
|
|
|
107
129
|
## Identity provider contract
|
|
108
130
|
|
|
@@ -32,10 +32,11 @@ module Legion
|
|
|
32
32
|
category_overrides: {}
|
|
33
33
|
},
|
|
34
34
|
token: {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
vault_read_enabled: false,
|
|
36
|
+
vault_write_enabled: false,
|
|
37
|
+
vault_path: nil,
|
|
38
|
+
refresh_buffer: 60,
|
|
39
|
+
refresh_interval: 2700
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
end
|
|
@@ -38,11 +38,15 @@ module Legion
|
|
|
38
38
|
raw_data = stored_token_data
|
|
39
39
|
log.warn('Entra delegated token expired but refresh_token available — refresh may be stuck') if raw_data && raw_data[:refresh_token]
|
|
40
40
|
if Legion::Extensions::Identity::Entra::Helpers::TokenManager.scope_fingerprint_stale?(:delegated)
|
|
41
|
-
log.info('Entra delegated scope fingerprint changed, re-
|
|
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
|
|
|
@@ -139,8 +145,7 @@ module Legion
|
|
|
139
145
|
end
|
|
140
146
|
|
|
141
147
|
def previously_authenticated?
|
|
142
|
-
|
|
143
|
-
File.exist?(path)
|
|
148
|
+
!stored_token_data.nil?
|
|
144
149
|
end
|
|
145
150
|
|
|
146
151
|
def stored_token_data
|
|
@@ -45,19 +45,29 @@ module Legion
|
|
|
45
45
|
if refreshed && !Legion::Extensions::Identity::Entra::Helpers::TokenManager.expired?(refreshed)
|
|
46
46
|
Legion::Extensions::Identity::Entra::Client.reset!(pattern: :delegated)
|
|
47
47
|
log.info('Delegated token refreshed successfully')
|
|
48
|
-
|
|
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
|
-
Legion::Extensions::Identity::Entra::Helpers::TokenManager.from_local_data(:delegated) ||
|
|
61
71
|
Legion::Extensions::Identity::Entra::Helpers::TokenManager.from_memory(:delegated)
|
|
62
72
|
end
|
|
63
73
|
|
|
@@ -69,12 +79,16 @@ module Legion
|
|
|
69
79
|
browser = Legion::Extensions::Identity::Entra::Helpers::BrowserAuth.new(
|
|
70
80
|
tenant_id: auth_settings[:tenant_id],
|
|
71
81
|
client_id: auth_settings[:client_id],
|
|
72
|
-
scopes: scopes
|
|
82
|
+
scopes: scopes,
|
|
83
|
+
automatic: true
|
|
73
84
|
)
|
|
74
85
|
|
|
75
86
|
result = browser.authenticate
|
|
76
87
|
body = result&.dig(:result)
|
|
77
|
-
|
|
88
|
+
unless body&.dig(:access_token)
|
|
89
|
+
log.debug("[entra][token_refresher] action=reauth_incomplete error=#{result&.dig(:error)} reason=#{result&.dig(:reason)}")
|
|
90
|
+
return
|
|
91
|
+
end
|
|
78
92
|
|
|
79
93
|
Legion::Extensions::Identity::Entra::Helpers::TokenManager.save_token(
|
|
80
94
|
:delegated,
|
|
@@ -88,7 +102,7 @@ module Legion
|
|
|
88
102
|
Legion::Extensions::Identity::Entra::Client.reset!(pattern: :delegated)
|
|
89
103
|
log.info('Delegated auth restored via browser re-auth')
|
|
90
104
|
rescue StandardError => e
|
|
91
|
-
|
|
105
|
+
handle_exception(e, level: :error, operation: 'entra.delegated.token_refresher.attempt_browser_reauth')
|
|
92
106
|
end
|
|
93
107
|
end
|
|
94
108
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'faraday'
|
|
4
4
|
require 'legion/extensions/identity/entra/helpers/scopes'
|
|
5
|
+
require 'legion/extensions/identity/entra/helpers/callback_page'
|
|
5
6
|
|
|
6
7
|
module Legion
|
|
7
8
|
module Extensions
|
|
@@ -108,24 +109,25 @@ module Legion
|
|
|
108
109
|
{ error: 'request_failed', description: e.message }
|
|
109
110
|
end
|
|
110
111
|
|
|
111
|
-
def auth_callback(code: nil, state: nil, **)
|
|
112
|
+
def auth_callback(code: nil, state: nil, error: nil, error_description: nil, **)
|
|
113
|
+
if error
|
|
114
|
+
log.warn("Login.auth_callback: provider returned error=#{error}")
|
|
115
|
+
emit_oauth_callback(state: state, error: error, error_description: error_description)
|
|
116
|
+
return { result: { error: error },
|
|
117
|
+
response: callback_response(400, callback_failure_html(error, error_description)) }
|
|
118
|
+
end
|
|
119
|
+
|
|
112
120
|
unless code && state
|
|
113
121
|
log.warn('Login.auth_callback: missing code or state parameter')
|
|
114
|
-
return {
|
|
115
|
-
|
|
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) &&
|
|
@@ -35,15 +35,22 @@ module Legion
|
|
|
35
35
|
category_overrides: {}
|
|
36
36
|
},
|
|
37
37
|
token: {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
vault_read_enabled: false,
|
|
39
|
+
vault_write_enabled: false,
|
|
40
|
+
vault_path: nil,
|
|
41
|
+
refresh_buffer: 60,
|
|
42
|
+
refresh_interval: 900
|
|
42
43
|
},
|
|
43
44
|
browser_auth: {
|
|
44
|
-
auto_authenticate:
|
|
45
|
-
force_local_server:
|
|
46
|
-
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
|
|
47
54
|
}
|
|
48
55
|
}
|
|
49
56
|
end
|
|
@@ -16,19 +16,13 @@ module Legion
|
|
|
16
16
|
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
|
|
17
17
|
Legion::Extensions::Helpers.const_defined?(:Lex, false)
|
|
18
18
|
|
|
19
|
-
# Returns an array of qualifier symbols for which tokens exist
|
|
19
|
+
# Returns an array of qualifier symbols for which tokens exist in-process.
|
|
20
20
|
def discovered_qualifiers
|
|
21
|
-
(
|
|
21
|
+
(memory_qualifiers + broker_qualifiers).uniq
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
Dir.glob(File.join(Legion::Extensions::Identity::Entra::Helpers::TokenManager::TOKEN_DIR, 'entra_*.json')).filter_map do |path|
|
|
28
|
-
basename = File.basename(path, '.json')
|
|
29
|
-
match = basename.match(/\Aentra_(.+)\z/)
|
|
30
|
-
match[1].to_sym if match
|
|
31
|
-
end
|
|
24
|
+
def memory_qualifiers
|
|
25
|
+
Legion::Extensions::Identity::Entra::Helpers::TokenManager.memory_store.keys
|
|
32
26
|
end
|
|
33
27
|
|
|
34
28
|
def broker_qualifiers
|
|
@@ -6,6 +6,7 @@ require 'securerandom'
|
|
|
6
6
|
require 'faraday'
|
|
7
7
|
|
|
8
8
|
require 'legion/extensions/identity/entra/helpers/callback_server'
|
|
9
|
+
require 'legion/extensions/identity/entra/helpers/interactive_auth_guard'
|
|
9
10
|
require 'legion/extensions/identity/entra/delegated/runners/login'
|
|
10
11
|
|
|
11
12
|
module Legion
|
|
@@ -23,26 +24,40 @@ module Legion
|
|
|
23
24
|
|
|
24
25
|
attr_reader :tenant_id, :client_id, :scopes
|
|
25
26
|
|
|
26
|
-
|
|
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
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'concurrent'
|
|
4
4
|
require 'digest'
|
|
5
|
-
require 'fileutils'
|
|
6
5
|
require 'time'
|
|
6
|
+
require 'legion/extensions/identity/entra/helpers/interactive_auth_guard'
|
|
7
7
|
|
|
8
8
|
module Legion
|
|
9
9
|
module Extensions
|
|
@@ -16,14 +16,26 @@ module Legion
|
|
|
16
16
|
include Legion::Settings::Helper
|
|
17
17
|
include Legion::JSON::Helper
|
|
18
18
|
|
|
19
|
-
TOKEN_DIR = File.join(Dir.home, '.legionio', 'tokens')
|
|
20
19
|
REFRESH_BUFFER = 60
|
|
21
20
|
MEMORY_STORE = Concurrent::Hash.new
|
|
21
|
+
# Negative cache for failed refresh attempts, keyed by qualifier. Without it
|
|
22
|
+
# every caller (LeaseRenewer polls once a second once the lease expires)
|
|
23
|
+
# re-POSTs a dead refresh_token to Microsoft and logs a warning each time.
|
|
24
|
+
REFRESH_FAILURES = Concurrent::Hash.new
|
|
25
|
+
REFRESH_BACKOFF_BASE = 30
|
|
26
|
+
REFRESH_BACKOFF_MAX = 900
|
|
27
|
+
# OAuth errors meaning the refresh_token will never work again; stop
|
|
28
|
+
# retrying until a new token is saved (user signs in again).
|
|
29
|
+
PERMANENT_REFRESH_ERRORS = %w[invalid_grant interaction_required invalid_client unauthorized_client].freeze
|
|
22
30
|
|
|
23
31
|
def memory_store
|
|
24
32
|
MEMORY_STORE
|
|
25
33
|
end
|
|
26
34
|
|
|
35
|
+
def refresh_failures
|
|
36
|
+
REFRESH_FAILURES
|
|
37
|
+
end
|
|
38
|
+
|
|
27
39
|
def load_token(qualifier = :delegated)
|
|
28
40
|
log.debug("TokenManager.load_token: qualifier=#{qualifier}")
|
|
29
41
|
data = token_data(qualifier, refresh: true)
|
|
@@ -34,20 +46,7 @@ module Legion
|
|
|
34
46
|
|
|
35
47
|
def token_data(qualifier = :delegated, refresh: true)
|
|
36
48
|
log.debug("TokenManager.token_data: qualifier=#{qualifier} refresh=#{refresh}")
|
|
37
|
-
|
|
38
|
-
other_data = vault_data || from_local_data(qualifier) || from_memory(qualifier)
|
|
39
|
-
if other_data && !vault_data && vault_available? && canonical_name_available?
|
|
40
|
-
log.info("TokenManager.token_data: backfilling #{qualifier} token to vault")
|
|
41
|
-
backfill_saved = save_to_vault(qualifier, access_token: other_data[:access_token],
|
|
42
|
-
refresh_token: other_data[:refresh_token],
|
|
43
|
-
expires_at: other_data[:expires_at],
|
|
44
|
-
scopes: other_data[:scopes],
|
|
45
|
-
tenant_id: other_data[:tenant_id],
|
|
46
|
-
client_id: other_data[:client_id],
|
|
47
|
-
scope_fingerprint: other_data[:scope_fingerprint])
|
|
48
|
-
delete_local(qualifier) if backfill_saved
|
|
49
|
-
end
|
|
50
|
-
data = other_data
|
|
49
|
+
data = from_vault_data(qualifier) || from_memory(qualifier)
|
|
51
50
|
return nil unless data
|
|
52
51
|
|
|
53
52
|
if scope_fingerprint_stale?(qualifier, data)
|
|
@@ -68,20 +67,14 @@ module Legion
|
|
|
68
67
|
def save_token(qualifier, access_token:, refresh_token: nil, expires_at: nil,
|
|
69
68
|
expires_in: nil, scopes: nil, tenant_id: nil, client_id: nil)
|
|
70
69
|
log.debug("TokenManager.save_token: qualifier=#{qualifier} expires_in=#{expires_in}")
|
|
70
|
+
clear_refresh_failure(qualifier)
|
|
71
|
+
Helpers::InteractiveAuthGuard.reset!(reason: 'token_saved') if qualifier.to_sym == :delegated && access_token
|
|
71
72
|
expires_at ||= Time.now + expires_in.to_i if expires_in
|
|
72
73
|
fingerprint = current_scope_fingerprint(qualifier)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if vault_saved
|
|
78
|
-
delete_local(qualifier)
|
|
79
|
-
else
|
|
80
|
-
save_to_local(qualifier, access_token: access_token, refresh_token: refresh_token,
|
|
81
|
-
expires_at: expires_at, scopes: scopes,
|
|
82
|
-
tenant_id: tenant_id, client_id: client_id,
|
|
83
|
-
scope_fingerprint: fingerprint)
|
|
84
|
-
end
|
|
74
|
+
save_to_vault(qualifier, access_token: access_token, refresh_token: refresh_token,
|
|
75
|
+
expires_at: expires_at, scopes: scopes,
|
|
76
|
+
tenant_id: tenant_id, client_id: client_id,
|
|
77
|
+
scope_fingerprint: fingerprint)
|
|
85
78
|
save_to_memory(qualifier, access_token: access_token, refresh_token: refresh_token,
|
|
86
79
|
expires_at: expires_at, scopes: scopes,
|
|
87
80
|
tenant_id: tenant_id, client_id: client_id,
|
|
@@ -89,6 +82,7 @@ module Legion
|
|
|
89
82
|
end
|
|
90
83
|
|
|
91
84
|
def from_vault_data(qualifier)
|
|
85
|
+
return nil unless vault_read_enabled?(qualifier)
|
|
92
86
|
return nil unless vault_available? && canonical_name_available?
|
|
93
87
|
|
|
94
88
|
read_vault_token(qualifier, vault_path(qualifier))
|
|
@@ -106,20 +100,9 @@ module Legion
|
|
|
106
100
|
nil
|
|
107
101
|
end
|
|
108
102
|
|
|
109
|
-
def from_local_data(qualifier)
|
|
110
|
-
path = local_path(qualifier)
|
|
111
|
-
return nil unless File.exist?(path)
|
|
112
|
-
|
|
113
|
-
log.debug("TokenManager.from_local_data: reading #{path}")
|
|
114
|
-
normalize_token_data(json_load(File.read(path)))
|
|
115
|
-
rescue StandardError => e
|
|
116
|
-
handle_exception(e, level: :warn, operation: 'token_manager.from_local_data',
|
|
117
|
-
qualifier: qualifier, path: path)
|
|
118
|
-
nil
|
|
119
|
-
end
|
|
120
|
-
|
|
121
103
|
def save_to_vault(qualifier, access_token:, refresh_token:, expires_at:,
|
|
122
104
|
scopes: nil, tenant_id: nil, client_id: nil, scope_fingerprint: nil)
|
|
105
|
+
return unless vault_write_enabled?(qualifier)
|
|
123
106
|
return unless vault_available?
|
|
124
107
|
return unless canonical_name_available?
|
|
125
108
|
|
|
@@ -144,38 +127,6 @@ module Legion
|
|
|
144
127
|
nil
|
|
145
128
|
end
|
|
146
129
|
|
|
147
|
-
def save_to_local(qualifier, access_token:, refresh_token:, expires_at:,
|
|
148
|
-
scopes: nil, tenant_id: nil, client_id: nil, scope_fingerprint: nil)
|
|
149
|
-
path = local_path(qualifier)
|
|
150
|
-
log.debug("TokenManager.save_to_local: writing #{path}")
|
|
151
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
152
|
-
File.write(path, json_dump({
|
|
153
|
-
access_token: access_token,
|
|
154
|
-
refresh_token: refresh_token,
|
|
155
|
-
expires_at: expires_at&.utc&.iso8601,
|
|
156
|
-
scopes: scopes,
|
|
157
|
-
tenant_id: tenant_id,
|
|
158
|
-
client_id: client_id,
|
|
159
|
-
scope_fingerprint: scope_fingerprint
|
|
160
|
-
}))
|
|
161
|
-
File.chmod(0o600, path)
|
|
162
|
-
log.debug('TokenManager.save_to_local: success')
|
|
163
|
-
rescue StandardError => e
|
|
164
|
-
handle_exception(e, level: :warn, operation: 'token_manager.save_to_local',
|
|
165
|
-
qualifier: qualifier, path: path)
|
|
166
|
-
nil
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def delete_local(qualifier)
|
|
170
|
-
path = local_path(qualifier)
|
|
171
|
-
return unless File.exist?(path)
|
|
172
|
-
|
|
173
|
-
File.delete(path)
|
|
174
|
-
log.info("TokenManager.delete_local: removed #{path} (vault is authoritative)")
|
|
175
|
-
rescue StandardError => e
|
|
176
|
-
handle_exception(e, level: :warn, operation: 'token_manager.delete_local', qualifier: qualifier)
|
|
177
|
-
end
|
|
178
|
-
|
|
179
130
|
def from_memory(qualifier)
|
|
180
131
|
data = TokenManager.memory_store[qualifier.to_sym]
|
|
181
132
|
return nil unless data
|
|
@@ -204,7 +155,7 @@ module Legion
|
|
|
204
155
|
end
|
|
205
156
|
|
|
206
157
|
def scope_fingerprint_stale?(qualifier, data = nil)
|
|
207
|
-
data ||=
|
|
158
|
+
data ||= from_memory(qualifier)
|
|
208
159
|
return false unless data
|
|
209
160
|
|
|
210
161
|
stored = data[:scope_fingerprint]
|
|
@@ -227,19 +178,17 @@ module Legion
|
|
|
227
178
|
return nil
|
|
228
179
|
end
|
|
229
180
|
|
|
230
|
-
|
|
231
|
-
|
|
181
|
+
return nil if refresh_suppressed?(qualifier, refresh)
|
|
182
|
+
|
|
183
|
+
result = login_runner.refresh_delegated_token(
|
|
232
184
|
tenant_id: auth[:tenant_id],
|
|
233
185
|
client_id: auth[:client_id],
|
|
234
186
|
refresh_token: refresh,
|
|
235
187
|
scope: auth[:scopes] || Legion::Extensions::Identity::Entra::Helpers::Scopes.resolve(pattern: :delegated)
|
|
236
188
|
)
|
|
237
|
-
body = result[:result]
|
|
189
|
+
body = result[:result] || {}
|
|
238
190
|
access_token = fetch_key(body, :access_token)
|
|
239
|
-
unless access_token
|
|
240
|
-
log.warn("TokenManager.refresh_token: refresh failed for #{qualifier}, no access_token in response")
|
|
241
|
-
return nil
|
|
242
|
-
end
|
|
191
|
+
return record_refresh_response_failure(qualifier, refresh, result, body) unless access_token
|
|
243
192
|
|
|
244
193
|
log.info("TokenManager.refresh_token: successfully refreshed token for #{qualifier}")
|
|
245
194
|
save_token(qualifier,
|
|
@@ -253,9 +202,69 @@ module Legion
|
|
|
253
202
|
rescue StandardError => e
|
|
254
203
|
handle_exception(e, level: :error, operation: 'token_manager.refresh_token',
|
|
255
204
|
qualifier: qualifier)
|
|
205
|
+
record_refresh_failure(qualifier, refresh, e.class.name, e.message) if refresh
|
|
256
206
|
nil
|
|
257
207
|
end
|
|
258
208
|
|
|
209
|
+
def login_runner
|
|
210
|
+
Object.new.extend(Legion::Extensions::Identity::Entra::Delegated::Runners::Login)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def record_refresh_response_failure(qualifier, refresh, result, body)
|
|
214
|
+
error = fetch_key(body, :error) || result[:error] || 'no_access_token'
|
|
215
|
+
record_refresh_failure(qualifier, refresh, error, fetch_key(body, :error_description) || result[:description])
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def refresh_suppressed?(qualifier, refresh)
|
|
220
|
+
entry = refresh_failures[qualifier.to_sym]
|
|
221
|
+
return false unless entry
|
|
222
|
+
return false unless entry[:fingerprint] == refresh_fingerprint(refresh)
|
|
223
|
+
|
|
224
|
+
if entry[:permanent]
|
|
225
|
+
log.debug("[entra][token_manager] action=refresh_skipped qualifier=#{qualifier} reason=permanent error=#{entry[:error]}")
|
|
226
|
+
return true
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
remaining = entry[:retry_at] - Time.now
|
|
230
|
+
return false unless remaining.positive?
|
|
231
|
+
|
|
232
|
+
log.debug("[entra][token_manager] action=refresh_skipped qualifier=#{qualifier} reason=backoff " \
|
|
233
|
+
"retry_in=#{remaining.round}s failures=#{entry[:failures]}")
|
|
234
|
+
true
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def record_refresh_failure(qualifier, refresh, error, description = nil)
|
|
238
|
+
key = qualifier.to_sym
|
|
239
|
+
fingerprint = refresh_fingerprint(refresh)
|
|
240
|
+
previous = refresh_failures[key]
|
|
241
|
+
previous = nil unless previous && previous[:fingerprint] == fingerprint
|
|
242
|
+
failures = (previous&.dig(:failures) || 0) + 1
|
|
243
|
+
permanent = PERMANENT_REFRESH_ERRORS.include?(error.to_s)
|
|
244
|
+
delay = [REFRESH_BACKOFF_BASE * (2**(failures - 1)), REFRESH_BACKOFF_MAX].min
|
|
245
|
+
refresh_failures[key] = { fingerprint: fingerprint, failures: failures, error: error.to_s,
|
|
246
|
+
permanent: permanent, retry_at: Time.now + delay }
|
|
247
|
+
|
|
248
|
+
if permanent && !previous&.dig(:permanent)
|
|
249
|
+
log.warn("TokenManager.refresh_token: refresh token for #{qualifier} was rejected (#{error}: #{description}). " \
|
|
250
|
+
'Not retrying until you sign in again.')
|
|
251
|
+
Helpers::InteractiveAuthGuard.notify_reauth_required('refresh_token_rejected') if key == :delegated
|
|
252
|
+
elsif failures == 1
|
|
253
|
+
log.warn("TokenManager.refresh_token: refresh failed for #{qualifier} (#{error}), backing off #{delay}s")
|
|
254
|
+
else
|
|
255
|
+
log.debug("[entra][token_manager] action=refresh_failed qualifier=#{qualifier} error=#{error} " \
|
|
256
|
+
"failures=#{failures} retry_in=#{delay}s permanent=#{permanent}")
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def clear_refresh_failure(qualifier)
|
|
261
|
+
refresh_failures.delete(qualifier.to_sym)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def refresh_fingerprint(refresh)
|
|
265
|
+
Digest::SHA256.hexdigest(refresh.to_s)[0, 16]
|
|
266
|
+
end
|
|
267
|
+
|
|
259
268
|
def from_broker(qualifier)
|
|
260
269
|
return nil unless defined?(Legion::Identity::Broker)
|
|
261
270
|
|
|
@@ -280,6 +289,14 @@ module Legion
|
|
|
280
289
|
!load_token(qualifier).nil?
|
|
281
290
|
end
|
|
282
291
|
|
|
292
|
+
def vault_read_enabled?(qualifier)
|
|
293
|
+
Legion::Settings.dig(:identity, :entra, qualifier.to_sym, :token, :vault_read_enabled)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def vault_write_enabled?(qualifier)
|
|
297
|
+
Legion::Settings.dig(:identity, :entra, qualifier.to_sym, :token, :vault_write_enabled)
|
|
298
|
+
end
|
|
299
|
+
|
|
283
300
|
def vault_available?
|
|
284
301
|
defined?(Legion::Crypt) &&
|
|
285
302
|
Legion::Crypt.respond_to?(:vault_connected?) &&
|
|
@@ -296,14 +313,6 @@ module Legion
|
|
|
296
313
|
"users/#{Legion::Identity::Process.canonical_name}/entra/#{qualifier}/auth"
|
|
297
314
|
end
|
|
298
315
|
|
|
299
|
-
def local_path(qualifier)
|
|
300
|
-
auth = settings_auth
|
|
301
|
-
pattern_settings = auth[qualifier.to_sym]
|
|
302
|
-
return File.expand_path(pattern_settings[:local_token_path]) if pattern_settings.is_a?(Hash) && pattern_settings[:local_token_path]
|
|
303
|
-
|
|
304
|
-
File.join(TOKEN_DIR, "entra_#{qualifier}.json")
|
|
305
|
-
end
|
|
306
|
-
|
|
307
316
|
def settings_auth
|
|
308
317
|
identity_entra = Legion::Settings.dig(:identity, :entra) || {}
|
|
309
318
|
auth = identity_entra[:auth].is_a?(Hash) ? identity_entra[:auth].dup : identity_entra.dup
|
|
@@ -28,10 +28,11 @@ module Legion
|
|
|
28
28
|
category_overrides: {}
|
|
29
29
|
},
|
|
30
30
|
token: {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
vault_read_enabled: false,
|
|
32
|
+
vault_write_enabled: false,
|
|
33
|
+
vault_path: nil,
|
|
34
|
+
refresh_buffer: 60,
|
|
35
|
+
refresh_interval: 2700
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
end
|
|
@@ -29,10 +29,11 @@ module Legion
|
|
|
29
29
|
category_overrides: {}
|
|
30
30
|
},
|
|
31
31
|
token: {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
vault_read_enabled: false,
|
|
33
|
+
vault_write_enabled: false,
|
|
34
|
+
vault_path: nil,
|
|
35
|
+
refresh_buffer: 60,
|
|
36
|
+
refresh_interval: 2700
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
end
|
|
@@ -12,6 +12,8 @@ require_relative 'entra/version'
|
|
|
12
12
|
require_relative 'entra/helpers/scopes'
|
|
13
13
|
require_relative 'entra/helpers/scope_registry'
|
|
14
14
|
require_relative 'entra/helpers/scope_gate'
|
|
15
|
+
require_relative 'entra/helpers/interactive_auth_guard'
|
|
16
|
+
require_relative 'entra/helpers/callback_page'
|
|
15
17
|
require_relative 'entra/helpers/token_manager'
|
|
16
18
|
require_relative 'entra/helpers/graph_client'
|
|
17
19
|
require_relative 'entra/helpers/account_discovery'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lex-identity-entra
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
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
|