lex-identity-entra 0.5.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +7 -6
- data/lib/legion/extensions/identity/entra/application.rb +5 -4
- data/lib/legion/extensions/identity/entra/delegated/actors/auth_validator.rb +1 -2
- data/lib/legion/extensions/identity/entra/delegated/actors/token_refresher.rb +0 -1
- data/lib/legion/extensions/identity/entra/delegated.rb +5 -4
- data/lib/legion/extensions/identity/entra/helpers/account_discovery.rb +4 -10
- data/lib/legion/extensions/identity/entra/helpers/token_manager.rb +30 -124
- 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
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ea7d2caf79ed2c6025cbad1ee6daae0a5f5250aada92cf26f42c71bf8e849680
|
|
4
|
+
data.tar.gz: 3f7afe033551273e7ebe6345c40e24f824529b0f4df79a1f3e7677002dcb006c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 55af5175673b2d9371c0e1d8b8ff03479299e7425b8f6f79b88b941e2b31bcb44069f026bcae90d898a87a7a6511b69a2c0618279ed73b1ac6631369d9733db2
|
|
7
|
+
data.tar.gz: fc672dfff7aa30a9325b7046752f5cdefe737f39b5e8bb69066657c0873d2f8ae600bf758a2ad42fcc382cb5a47467090e074931581486124e89eb52b66dd53b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.0] - 2026-09-25
|
|
4
|
+
### Changed
|
|
5
|
+
- **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).
|
|
6
|
+
- **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`.
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
## [0.5.4] - 2026-08-04
|
|
10
|
+
### Fixed
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
3
13
|
## [0.5.3] - 2026-07-15
|
|
4
14
|
### Fixed
|
|
5
15
|
- Fix token refresh dead-end: refresh when token exists regardless of authenticated? state
|
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
|
|
@@ -36,6 +36,8 @@ identity:
|
|
|
36
36
|
auto_authenticate: false # set true to open browser automatically at boot
|
|
37
37
|
callback_timeout: 120
|
|
38
38
|
token:
|
|
39
|
+
vault_read_enabled: false # set true to read the token from Vault
|
|
40
|
+
vault_write_enabled: false # set true to write the token to Vault
|
|
39
41
|
refresh_buffer: 60
|
|
40
42
|
refresh_interval: 900
|
|
41
43
|
scopes:
|
|
@@ -98,11 +100,10 @@ identity:
|
|
|
98
100
|
|
|
99
101
|
## Token storage
|
|
100
102
|
|
|
101
|
-
| Backend | Path |
|
|
102
|
-
|
|
103
|
-
| HashiCorp Vault | `kv/data/users/<identity>/entra/delegated/auth` |
|
|
104
|
-
|
|
|
105
|
-
| Memory | In-process store | 3 (runtime fallback) |
|
|
103
|
+
| Backend | Path | When used |
|
|
104
|
+
|---------|------|-----------|
|
|
105
|
+
| HashiCorp Vault | `kv/data/users/<identity>/entra/delegated/auth` | Only when opted in: `vault_read_enabled` / `vault_write_enabled` (both default `false`) |
|
|
106
|
+
| Memory | In-process store | Default persistence; tokens are never written to local disk |
|
|
106
107
|
|
|
107
108
|
## Identity provider contract
|
|
108
109
|
|
|
@@ -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
|
|
@@ -57,7 +57,6 @@ module Legion
|
|
|
57
57
|
|
|
58
58
|
def stored_raw_data
|
|
59
59
|
Legion::Extensions::Identity::Entra::Helpers::TokenManager.from_vault_data(:delegated) ||
|
|
60
|
-
Legion::Extensions::Identity::Entra::Helpers::TokenManager.from_local_data(:delegated) ||
|
|
61
60
|
Legion::Extensions::Identity::Entra::Helpers::TokenManager.from_memory(:delegated)
|
|
62
61
|
end
|
|
63
62
|
|
|
@@ -35,10 +35,11 @@ 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
45
|
auto_authenticate: false,
|
|
@@ -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
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require 'concurrent'
|
|
4
4
|
require 'digest'
|
|
5
|
-
require 'fileutils'
|
|
6
5
|
require 'time'
|
|
7
6
|
|
|
8
7
|
module Legion
|
|
@@ -16,7 +15,6 @@ module Legion
|
|
|
16
15
|
include Legion::Settings::Helper
|
|
17
16
|
include Legion::JSON::Helper
|
|
18
17
|
|
|
19
|
-
TOKEN_DIR = File.join(Dir.home, '.legionio', 'tokens')
|
|
20
18
|
REFRESH_BUFFER = 60
|
|
21
19
|
MEMORY_STORE = Concurrent::Hash.new
|
|
22
20
|
|
|
@@ -34,20 +32,7 @@ module Legion
|
|
|
34
32
|
|
|
35
33
|
def token_data(qualifier = :delegated, refresh: true)
|
|
36
34
|
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
|
|
35
|
+
data = from_vault_data(qualifier) || from_memory(qualifier)
|
|
51
36
|
return nil unless data
|
|
52
37
|
|
|
53
38
|
if scope_fingerprint_stale?(qualifier, data)
|
|
@@ -70,18 +55,10 @@ module Legion
|
|
|
70
55
|
log.debug("TokenManager.save_token: qualifier=#{qualifier} expires_in=#{expires_in}")
|
|
71
56
|
expires_at ||= Time.now + expires_in.to_i if expires_in
|
|
72
57
|
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
|
|
58
|
+
save_to_vault(qualifier, access_token: access_token, refresh_token: refresh_token,
|
|
59
|
+
expires_at: expires_at, scopes: scopes,
|
|
60
|
+
tenant_id: tenant_id, client_id: client_id,
|
|
61
|
+
scope_fingerprint: fingerprint)
|
|
85
62
|
save_to_memory(qualifier, access_token: access_token, refresh_token: refresh_token,
|
|
86
63
|
expires_at: expires_at, scopes: scopes,
|
|
87
64
|
tenant_id: tenant_id, client_id: client_id,
|
|
@@ -89,13 +66,10 @@ module Legion
|
|
|
89
66
|
end
|
|
90
67
|
|
|
91
68
|
def from_vault_data(qualifier)
|
|
92
|
-
return nil unless
|
|
69
|
+
return nil unless vault_read_enabled?(qualifier)
|
|
70
|
+
return nil unless vault_available? && canonical_name_available?
|
|
93
71
|
|
|
94
|
-
|
|
95
|
-
# bootstrap path so a fresh process can recover a Vault-only token
|
|
96
|
-
# before Legion::Identity::Process is resolved (issue #4).
|
|
97
|
-
read_vault_token(qualifier, vault_path(qualifier)) ||
|
|
98
|
-
read_vault_token(qualifier, bootstrap_vault_path(qualifier))
|
|
72
|
+
read_vault_token(qualifier, vault_path(qualifier))
|
|
99
73
|
end
|
|
100
74
|
|
|
101
75
|
def read_vault_token(qualifier, path)
|
|
@@ -110,41 +84,25 @@ module Legion
|
|
|
110
84
|
nil
|
|
111
85
|
end
|
|
112
86
|
|
|
113
|
-
def from_local_data(qualifier)
|
|
114
|
-
path = local_path(qualifier)
|
|
115
|
-
return nil unless File.exist?(path)
|
|
116
|
-
|
|
117
|
-
log.debug("TokenManager.from_local_data: reading #{path}")
|
|
118
|
-
normalize_token_data(json_load(File.read(path)))
|
|
119
|
-
rescue StandardError => e
|
|
120
|
-
handle_exception(e, level: :warn, operation: 'token_manager.from_local_data',
|
|
121
|
-
qualifier: qualifier, path: path)
|
|
122
|
-
nil
|
|
123
|
-
end
|
|
124
|
-
|
|
125
87
|
def save_to_vault(qualifier, access_token:, refresh_token:, expires_at:,
|
|
126
88
|
scopes: nil, tenant_id: nil, client_id: nil, scope_fingerprint: nil)
|
|
89
|
+
return unless vault_write_enabled?(qualifier)
|
|
127
90
|
return unless vault_available?
|
|
91
|
+
return unless canonical_name_available?
|
|
128
92
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
# before resolution). Before resolution, fall back to the bootstrap
|
|
132
|
-
# key so we never write to a placeholder (anonymous) canonical path.
|
|
133
|
-
paths = [vault_path(qualifier), bootstrap_vault_path(qualifier)].compact.uniq
|
|
134
|
-
return if paths.empty?
|
|
93
|
+
path = vault_path(qualifier)
|
|
94
|
+
return unless path
|
|
135
95
|
|
|
136
96
|
cluster = Legion::Crypt.respond_to?(:default_cluster_name) ? Legion::Crypt.default_cluster_name : 'vault'
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
scope_fingerprint: scope_fingerprint)
|
|
147
|
-
end
|
|
97
|
+
log.info("TokenManager.save_to_vault: writing to #{cluster} :: kv/data/#{path} qualifier=#{qualifier}")
|
|
98
|
+
vault_kv_client.write(path,
|
|
99
|
+
access_token: access_token,
|
|
100
|
+
refresh_token: refresh_token,
|
|
101
|
+
expires_at: expires_at&.utc&.iso8601,
|
|
102
|
+
scopes: scopes,
|
|
103
|
+
tenant_id: tenant_id,
|
|
104
|
+
client_id: client_id,
|
|
105
|
+
scope_fingerprint: scope_fingerprint)
|
|
148
106
|
log.info('TokenManager.save_to_vault: success')
|
|
149
107
|
true
|
|
150
108
|
rescue StandardError => e
|
|
@@ -153,38 +111,6 @@ module Legion
|
|
|
153
111
|
nil
|
|
154
112
|
end
|
|
155
113
|
|
|
156
|
-
def save_to_local(qualifier, access_token:, refresh_token:, expires_at:,
|
|
157
|
-
scopes: nil, tenant_id: nil, client_id: nil, scope_fingerprint: nil)
|
|
158
|
-
path = local_path(qualifier)
|
|
159
|
-
log.debug("TokenManager.save_to_local: writing #{path}")
|
|
160
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
161
|
-
File.write(path, json_dump({
|
|
162
|
-
access_token: access_token,
|
|
163
|
-
refresh_token: refresh_token,
|
|
164
|
-
expires_at: expires_at&.utc&.iso8601,
|
|
165
|
-
scopes: scopes,
|
|
166
|
-
tenant_id: tenant_id,
|
|
167
|
-
client_id: client_id,
|
|
168
|
-
scope_fingerprint: scope_fingerprint
|
|
169
|
-
}))
|
|
170
|
-
File.chmod(0o600, path)
|
|
171
|
-
log.debug('TokenManager.save_to_local: success')
|
|
172
|
-
rescue StandardError => e
|
|
173
|
-
handle_exception(e, level: :warn, operation: 'token_manager.save_to_local',
|
|
174
|
-
qualifier: qualifier, path: path)
|
|
175
|
-
nil
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
def delete_local(qualifier)
|
|
179
|
-
path = local_path(qualifier)
|
|
180
|
-
return unless File.exist?(path)
|
|
181
|
-
|
|
182
|
-
File.delete(path)
|
|
183
|
-
log.info("TokenManager.delete_local: removed #{path} (vault is authoritative)")
|
|
184
|
-
rescue StandardError => e
|
|
185
|
-
handle_exception(e, level: :warn, operation: 'token_manager.delete_local', qualifier: qualifier)
|
|
186
|
-
end
|
|
187
|
-
|
|
188
114
|
def from_memory(qualifier)
|
|
189
115
|
data = TokenManager.memory_store[qualifier.to_sym]
|
|
190
116
|
return nil unless data
|
|
@@ -213,7 +139,7 @@ module Legion
|
|
|
213
139
|
end
|
|
214
140
|
|
|
215
141
|
def scope_fingerprint_stale?(qualifier, data = nil)
|
|
216
|
-
data ||=
|
|
142
|
+
data ||= from_memory(qualifier)
|
|
217
143
|
return false unless data
|
|
218
144
|
|
|
219
145
|
stored = data[:scope_fingerprint]
|
|
@@ -289,6 +215,14 @@ module Legion
|
|
|
289
215
|
!load_token(qualifier).nil?
|
|
290
216
|
end
|
|
291
217
|
|
|
218
|
+
def vault_read_enabled?(qualifier)
|
|
219
|
+
Legion::Settings.dig(:identity, :entra, qualifier.to_sym, :token, :vault_read_enabled)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def vault_write_enabled?(qualifier)
|
|
223
|
+
Legion::Settings.dig(:identity, :entra, qualifier.to_sym, :token, :vault_write_enabled)
|
|
224
|
+
end
|
|
225
|
+
|
|
292
226
|
def vault_available?
|
|
293
227
|
defined?(Legion::Crypt) &&
|
|
294
228
|
Legion::Crypt.respond_to?(:vault_connected?) &&
|
|
@@ -305,34 +239,6 @@ module Legion
|
|
|
305
239
|
"users/#{Legion::Identity::Process.canonical_name}/entra/#{qualifier}/auth"
|
|
306
240
|
end
|
|
307
241
|
|
|
308
|
-
# Pre-resolution Vault key for delegated tokens, derived from stable
|
|
309
|
-
# inputs (tenant id + client id) that are available before canonical
|
|
310
|
-
# identity resolution. Lets a fresh process recover a Vault-only
|
|
311
|
-
# delegated token so it can resolve identity (issue #4). Scoped to
|
|
312
|
-
# delegated only — other patterns are unaffected.
|
|
313
|
-
def bootstrap_vault_path(qualifier)
|
|
314
|
-
return nil unless qualifier.to_sym == :delegated
|
|
315
|
-
|
|
316
|
-
auth = settings_auth
|
|
317
|
-
pattern_settings = auth[qualifier.to_sym]
|
|
318
|
-
return pattern_settings[:vault_path] if pattern_settings.is_a?(Hash) && pattern_settings[:vault_path]
|
|
319
|
-
|
|
320
|
-
tenant_id = auth[:tenant_id]
|
|
321
|
-
client_id = auth[:client_id]
|
|
322
|
-
return nil unless tenant_id && client_id
|
|
323
|
-
|
|
324
|
-
account = Digest::MD5.hexdigest("#{tenant_id}:#{client_id}")
|
|
325
|
-
"bootstrap/entra/#{qualifier}/#{account}/auth"
|
|
326
|
-
end
|
|
327
|
-
|
|
328
|
-
def local_path(qualifier)
|
|
329
|
-
auth = settings_auth
|
|
330
|
-
pattern_settings = auth[qualifier.to_sym]
|
|
331
|
-
return File.expand_path(pattern_settings[:local_token_path]) if pattern_settings.is_a?(Hash) && pattern_settings[:local_token_path]
|
|
332
|
-
|
|
333
|
-
File.join(TOKEN_DIR, "entra_#{qualifier}.json")
|
|
334
|
-
end
|
|
335
|
-
|
|
336
242
|
def settings_auth
|
|
337
243
|
identity_entra = Legion::Settings.dig(:identity, :entra) || {}
|
|
338
244
|
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
|