lex-identity-entra 0.3.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 +7 -0
- data/.github/workflows/ci.yml +34 -0
- data/.gitignore +11 -0
- data/.rubocop.yml +20 -0
- data/CHANGELOG.md +39 -0
- data/Gemfile +11 -0
- data/README.md +120 -0
- data/lex-identity-entra.gemspec +37 -0
- data/lib/legion/extensions/identity/entra/application/actors/token_refresher.rb +71 -0
- data/lib/legion/extensions/identity/entra/application/client.rb +45 -0
- data/lib/legion/extensions/identity/entra/application/runners/credential.rb +81 -0
- data/lib/legion/extensions/identity/entra/application/scope_registry.rb +15 -0
- data/lib/legion/extensions/identity/entra/application/scopes.rb +47 -0
- data/lib/legion/extensions/identity/entra/application.rb +46 -0
- data/lib/legion/extensions/identity/entra/client.rb +181 -0
- data/lib/legion/extensions/identity/entra/delegated/actors/auth_validator.rb +149 -0
- data/lib/legion/extensions/identity/entra/delegated/actors/token_refresher.rb +91 -0
- data/lib/legion/extensions/identity/entra/delegated/cli/auth.rb +82 -0
- data/lib/legion/extensions/identity/entra/delegated/client.rb +46 -0
- data/lib/legion/extensions/identity/entra/delegated/hooks/auth.rb +23 -0
- data/lib/legion/extensions/identity/entra/delegated/identity.rb +116 -0
- data/lib/legion/extensions/identity/entra/delegated/runners/login.rb +185 -0
- data/lib/legion/extensions/identity/entra/delegated/runners/on_behalf_of.rb +64 -0
- data/lib/legion/extensions/identity/entra/delegated/scope_registry.rb +15 -0
- data/lib/legion/extensions/identity/entra/delegated/scopes.rb +100 -0
- data/lib/legion/extensions/identity/entra/delegated.rb +54 -0
- data/lib/legion/extensions/identity/entra/helpers/account_discovery.rb +85 -0
- data/lib/legion/extensions/identity/entra/helpers/browser_auth.rb +212 -0
- data/lib/legion/extensions/identity/entra/helpers/callback_server.rb +106 -0
- data/lib/legion/extensions/identity/entra/helpers/graph_client.rb +63 -0
- data/lib/legion/extensions/identity/entra/helpers/scope_gate.rb +96 -0
- data/lib/legion/extensions/identity/entra/helpers/scope_registry.rb +72 -0
- data/lib/legion/extensions/identity/entra/helpers/scopes.rb +61 -0
- data/lib/legion/extensions/identity/entra/helpers/token_manager.rb +362 -0
- data/lib/legion/extensions/identity/entra/managed_identity/actors/token_refresher.rb +64 -0
- data/lib/legion/extensions/identity/entra/managed_identity/client.rb +34 -0
- data/lib/legion/extensions/identity/entra/managed_identity/runners/token.rb +61 -0
- data/lib/legion/extensions/identity/entra/managed_identity/scope_registry.rb +15 -0
- data/lib/legion/extensions/identity/entra/managed_identity/scopes.rb +25 -0
- data/lib/legion/extensions/identity/entra/managed_identity.rb +42 -0
- data/lib/legion/extensions/identity/entra/version.rb +11 -0
- data/lib/legion/extensions/identity/entra/workload_identity/actors/token_refresher.rb +64 -0
- data/lib/legion/extensions/identity/entra/workload_identity/client.rb +34 -0
- data/lib/legion/extensions/identity/entra/workload_identity/runners/token.rb +99 -0
- data/lib/legion/extensions/identity/entra/workload_identity/scope_registry.rb +15 -0
- data/lib/legion/extensions/identity/entra/workload_identity/scopes.rb +25 -0
- data/lib/legion/extensions/identity/entra/workload_identity.rb +43 -0
- data/lib/legion/extensions/identity/entra.rb +51 -0
- metadata +178 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent-ruby'
|
|
4
|
+
require 'faraday'
|
|
5
|
+
require 'legion/extensions/identity/entra/helpers/scopes'
|
|
6
|
+
require 'legion/extensions/identity/entra/helpers/scope_registry'
|
|
7
|
+
require 'legion/extensions/identity/entra/helpers/token_manager'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module Identity
|
|
12
|
+
module Entra
|
|
13
|
+
class Client
|
|
14
|
+
GRAPH_BASE = 'https://graph.microsoft.com/v1.0'
|
|
15
|
+
|
|
16
|
+
@instances = Concurrent::Map.new
|
|
17
|
+
@registries = Concurrent::Map.new
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def instance(pattern: :delegated)
|
|
21
|
+
@instances.compute_if_absent(pattern.to_sym) { client_class_for(pattern).new }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def graph(pattern: :delegated)
|
|
25
|
+
instance(pattern: pattern).connection
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def registry(pattern: :delegated)
|
|
29
|
+
@registries.compute_if_absent(pattern.to_sym) do
|
|
30
|
+
Legion::Extensions::Identity::Entra::Helpers::ScopeRegistry.new(pattern: pattern)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def permitted?(scope, pattern: :delegated)
|
|
35
|
+
registry(pattern: pattern).permitted?(scope)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def permitted_all?(*scopes, pattern: :delegated)
|
|
39
|
+
registry(pattern: pattern).permitted_all?(*scopes)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def permitted_any?(*scopes, pattern: :delegated)
|
|
43
|
+
registry(pattern: pattern).permitted_any?(*scopes)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def denied_scopes(pattern: :delegated)
|
|
47
|
+
registry(pattern: pattern).denied
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def reset!(pattern: nil)
|
|
51
|
+
if pattern
|
|
52
|
+
@instances.delete(pattern.to_sym)
|
|
53
|
+
@registries[pattern.to_sym]&.reset!
|
|
54
|
+
else
|
|
55
|
+
@instances.clear
|
|
56
|
+
@registries.each_value(&:reset!)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def authenticated?(pattern: :delegated)
|
|
61
|
+
Legion::Extensions::Identity::Entra::Helpers::TokenManager.authenticated?(pattern)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def client_class_for(pattern)
|
|
67
|
+
case pattern.to_sym
|
|
68
|
+
when :delegated then Delegated::Client
|
|
69
|
+
when :application then Application::Client
|
|
70
|
+
when :managed_identity then ManagedIdentity::Client
|
|
71
|
+
when :workload_identity then WorkloadIdentity::Client
|
|
72
|
+
else self
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def initialize
|
|
78
|
+
@connection = Concurrent::AtomicReference.new(nil)
|
|
79
|
+
@access_token = Concurrent::AtomicReference.new(nil)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def pattern
|
|
83
|
+
raise NotImplementedError, "#{self.class} must define #pattern"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def connection
|
|
87
|
+
@connection.set(nil) if token_expired?
|
|
88
|
+
conn = @connection.get
|
|
89
|
+
return conn if conn
|
|
90
|
+
|
|
91
|
+
@connection.compare_and_set(nil, build_connection)
|
|
92
|
+
@connection.get
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def get(path, params: {})
|
|
96
|
+
connection.get(path, params)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def post(path, body: {})
|
|
100
|
+
connection.post(path) do |req|
|
|
101
|
+
req.headers['Content-Type'] = 'application/json'
|
|
102
|
+
req.body = json_dump(body)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def patch(path, body: {})
|
|
107
|
+
connection.patch(path) do |req|
|
|
108
|
+
req.headers['Content-Type'] = 'application/json'
|
|
109
|
+
req.body = json_dump(body)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def delete(path)
|
|
114
|
+
connection.delete(path)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def access_token
|
|
118
|
+
@access_token.set(nil) if token_expired?
|
|
119
|
+
token = @access_token.get
|
|
120
|
+
return token if token
|
|
121
|
+
|
|
122
|
+
resolved = resolve_token
|
|
123
|
+
@access_token.compare_and_set(nil, resolved)
|
|
124
|
+
@access_token.get
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def resolve_token
|
|
130
|
+
token = Legion::Extensions::Identity::Entra::Helpers::TokenManager.load_token(pattern)
|
|
131
|
+
if token
|
|
132
|
+
sync_registry_from_cache
|
|
133
|
+
return token
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
authenticate
|
|
137
|
+
Legion::Extensions::Identity::Entra::Helpers::TokenManager.load_token(pattern)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def sync_registry_from_cache
|
|
141
|
+
data = Legion::Extensions::Identity::Entra::Helpers::TokenManager.token_data(pattern, refresh: false)
|
|
142
|
+
return unless data&.dig(:scopes)
|
|
143
|
+
|
|
144
|
+
reg = Legion::Extensions::Identity::Entra::Client.registry(pattern: pattern)
|
|
145
|
+
reg.record_requested(Legion::Extensions::Identity::Entra::Helpers::Scopes.resolve(pattern: pattern))
|
|
146
|
+
reg.record_granted(data[:scopes])
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def authenticate
|
|
150
|
+
raise NotImplementedError, "#{self.class} must implement #authenticate"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def build_connection
|
|
154
|
+
token = access_token
|
|
155
|
+
Faraday.new(url: GRAPH_BASE) do |f|
|
|
156
|
+
f.headers['Authorization'] = "Bearer #{token}"
|
|
157
|
+
f.headers['Accept'] = 'application/json'
|
|
158
|
+
f.options.open_timeout = 5
|
|
159
|
+
f.options.timeout = 30
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def token_expired?
|
|
164
|
+
data = Legion::Extensions::Identity::Entra::Helpers::TokenManager.token_data(pattern, refresh: false)
|
|
165
|
+
return true unless data
|
|
166
|
+
|
|
167
|
+
Legion::Extensions::Identity::Entra::Helpers::TokenManager.expired?(data)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def registry
|
|
171
|
+
Legion::Extensions::Identity::Entra::Client.registry(pattern: pattern)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def auth_settings
|
|
175
|
+
Legion::Extensions::Identity::Entra::Helpers::TokenManager.settings_auth
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Identity
|
|
6
|
+
module Entra
|
|
7
|
+
module Delegated
|
|
8
|
+
module Actor
|
|
9
|
+
class AuthValidator < Legion::Extensions::Actors::Once
|
|
10
|
+
def runner_class = self.class
|
|
11
|
+
def runner_function = 'manual'
|
|
12
|
+
def use_runner? = false
|
|
13
|
+
def check_subtask? = false
|
|
14
|
+
def generate_task? = false
|
|
15
|
+
|
|
16
|
+
def delay
|
|
17
|
+
9.0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def enabled? # rubocop:disable Legion/Extension/ActorEnabledSideEffects
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def manual
|
|
25
|
+
log.info('Entra Delegated AuthValidator starting')
|
|
26
|
+
client = Legion::Extensions::Identity::Entra::Client.instance(pattern: :delegated)
|
|
27
|
+
|
|
28
|
+
if Legion::Extensions::Identity::Entra::Helpers::TokenManager.authenticated?(:delegated)
|
|
29
|
+
token = client.access_token
|
|
30
|
+
if token
|
|
31
|
+
log.info('Entra delegated auth valid')
|
|
32
|
+
upgrade_identity
|
|
33
|
+
elsif auto_authenticate?
|
|
34
|
+
log.info('Entra delegated token expired, attempting browser re-auth')
|
|
35
|
+
attempt_browser_reauth
|
|
36
|
+
end
|
|
37
|
+
elsif previously_authenticated?
|
|
38
|
+
if Legion::Extensions::Identity::Entra::Helpers::TokenManager.scope_fingerprint_stale?(:delegated)
|
|
39
|
+
log.info('Entra delegated scope fingerprint changed, re-authenticating to acquire updated scopes')
|
|
40
|
+
else
|
|
41
|
+
log.info('Entra delegated token file found but expired, attempting re-auth')
|
|
42
|
+
end
|
|
43
|
+
attempt_browser_reauth
|
|
44
|
+
elsif auto_authenticate?
|
|
45
|
+
log.info('auto_authenticate enabled, opening browser for initial auth')
|
|
46
|
+
attempt_browser_reauth
|
|
47
|
+
else
|
|
48
|
+
log.debug('No Entra delegated auth configured, skipping')
|
|
49
|
+
end
|
|
50
|
+
log.info('Entra Delegated AuthValidator complete')
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
log.error("AuthValidator: #{e.message}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def attempt_browser_reauth
|
|
58
|
+
auth_settings = Legion::Extensions::Identity::Entra::Helpers::TokenManager.settings_auth
|
|
59
|
+
unless auth_settings[:tenant_id] && auth_settings[:client_id]
|
|
60
|
+
log.warn('Cannot re-auth: missing tenant_id or client_id')
|
|
61
|
+
return false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
scopes = Legion::Extensions::Identity::Entra::Helpers::Scopes.resolve(pattern: :delegated)
|
|
65
|
+
browser = Legion::Extensions::Identity::Entra::Helpers::BrowserAuth.new(
|
|
66
|
+
tenant_id: auth_settings[:tenant_id],
|
|
67
|
+
client_id: auth_settings[:client_id],
|
|
68
|
+
scopes: scopes
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
result = browser.authenticate
|
|
72
|
+
if result[:error]
|
|
73
|
+
log.error("Browser auth error: #{result[:error]} - #{result[:description]}")
|
|
74
|
+
return false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
body = result[:result]
|
|
78
|
+
Legion::Extensions::Identity::Entra::Helpers::TokenManager.save_token(
|
|
79
|
+
:delegated,
|
|
80
|
+
access_token: body[:access_token],
|
|
81
|
+
refresh_token: body[:refresh_token],
|
|
82
|
+
expires_in: body[:expires_in],
|
|
83
|
+
scopes: body[:scope] || scopes,
|
|
84
|
+
tenant_id: auth_settings[:tenant_id],
|
|
85
|
+
client_id: auth_settings[:client_id]
|
|
86
|
+
)
|
|
87
|
+
Legion::Extensions::Identity::Entra::Client.reset!(pattern: :delegated)
|
|
88
|
+
upgrade_identity
|
|
89
|
+
log.info('Entra delegated auth restored via browser')
|
|
90
|
+
true
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
log.error("Browser re-auth failed: #{e.message}")
|
|
93
|
+
false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def upgrade_identity
|
|
97
|
+
return unless defined?(Legion::Identity::Resolver)
|
|
98
|
+
|
|
99
|
+
identity_module = Legion::Extensions::Identity::Entra::Delegated::Identity
|
|
100
|
+
result = identity_module.resolve
|
|
101
|
+
unless result
|
|
102
|
+
log.warn('AuthValidator.upgrade_identity: resolve returned nil, skipping upgrade')
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
Legion::Identity::Resolver.upgrade!(identity_module, result)
|
|
107
|
+
log.info("AuthValidator.upgrade_identity: identity upgraded canonical=#{result[:canonical_name]}")
|
|
108
|
+
register_broker
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
log.error("AuthValidator.upgrade_identity failed: #{e.message}")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def register_broker
|
|
114
|
+
return unless defined?(Legion::Identity::Broker)
|
|
115
|
+
|
|
116
|
+
identity_module = Legion::Extensions::Identity::Entra::Delegated::Identity
|
|
117
|
+
lease = identity_module.provide_token(qualifier: :delegated)
|
|
118
|
+
unless lease
|
|
119
|
+
log.warn('AuthValidator.register_broker: provide_token returned nil, skipping broker registration')
|
|
120
|
+
return
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
Legion::Identity::Broker.register_provider(
|
|
124
|
+
:entra_delegated,
|
|
125
|
+
provider: identity_module,
|
|
126
|
+
lease: lease,
|
|
127
|
+
qualifier: :delegated,
|
|
128
|
+
default: true
|
|
129
|
+
)
|
|
130
|
+
log.info('AuthValidator.register_broker: entra_delegated registered with broker qualifier=delegated')
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
log.error("AuthValidator.register_broker failed: #{e.message}")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def auto_authenticate?
|
|
136
|
+
Legion::Settings.dig(:identity, :entra, :delegated, :browser_auth, :auto_authenticate) == true
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def previously_authenticated?
|
|
140
|
+
path = Legion::Extensions::Identity::Entra::Helpers::TokenManager.local_path(:delegated)
|
|
141
|
+
File.exist?(path)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Identity
|
|
6
|
+
module Entra
|
|
7
|
+
module Delegated
|
|
8
|
+
module Actor
|
|
9
|
+
class TokenRefresher < Legion::Extensions::Actors::Every
|
|
10
|
+
DEFAULT_REFRESH_INTERVAL = 900
|
|
11
|
+
|
|
12
|
+
def runner_class = self.class
|
|
13
|
+
def runner_function = 'manual'
|
|
14
|
+
def use_runner? = false
|
|
15
|
+
def check_subtask? = false
|
|
16
|
+
def generate_task? = false
|
|
17
|
+
def run_now? = false
|
|
18
|
+
|
|
19
|
+
def time
|
|
20
|
+
Legion::Settings.dig(:identity, :entra, :delegated, :token, :refresh_interval) ||
|
|
21
|
+
DEFAULT_REFRESH_INTERVAL
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def enabled? # rubocop:disable Legion/Extension/ActorEnabledSideEffects
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def manual
|
|
29
|
+
log.debug('Delegated TokenRefresher tick')
|
|
30
|
+
unless Legion::Extensions::Identity::Entra::Helpers::TokenManager.authenticated?(:delegated)
|
|
31
|
+
log.debug('No active delegated token, skipping refresh')
|
|
32
|
+
return
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
data = Legion::Extensions::Identity::Entra::Helpers::TokenManager.token_data(:delegated, refresh: false)
|
|
36
|
+
if data && !Legion::Extensions::Identity::Entra::Helpers::TokenManager.expired?(data)
|
|
37
|
+
log.debug('Delegated token still valid')
|
|
38
|
+
return
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
log.info('Delegated token nearing expiry, refreshing')
|
|
42
|
+
refreshed = Legion::Extensions::Identity::Entra::Helpers::TokenManager.token_data(:delegated, refresh: true)
|
|
43
|
+
if refreshed && !Legion::Extensions::Identity::Entra::Helpers::TokenManager.expired?(refreshed)
|
|
44
|
+
Legion::Extensions::Identity::Entra::Client.reset!(pattern: :delegated)
|
|
45
|
+
log.info('Delegated token refreshed successfully')
|
|
46
|
+
else
|
|
47
|
+
log.warn('Delegated token refresh failed, attempting browser re-auth')
|
|
48
|
+
attempt_browser_reauth
|
|
49
|
+
end
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
log.error("Delegated TokenRefresher: #{e.message}")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def attempt_browser_reauth
|
|
57
|
+
auth_settings = Legion::Extensions::Identity::Entra::Helpers::TokenManager.settings_auth
|
|
58
|
+
return unless auth_settings[:tenant_id] && auth_settings[:client_id]
|
|
59
|
+
|
|
60
|
+
scopes = Legion::Extensions::Identity::Entra::Helpers::Scopes.resolve(pattern: :delegated)
|
|
61
|
+
browser = Legion::Extensions::Identity::Entra::Helpers::BrowserAuth.new(
|
|
62
|
+
tenant_id: auth_settings[:tenant_id],
|
|
63
|
+
client_id: auth_settings[:client_id],
|
|
64
|
+
scopes: scopes
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
result = browser.authenticate
|
|
68
|
+
body = result&.dig(:result)
|
|
69
|
+
return unless body&.dig(:access_token)
|
|
70
|
+
|
|
71
|
+
Legion::Extensions::Identity::Entra::Helpers::TokenManager.save_token(
|
|
72
|
+
:delegated,
|
|
73
|
+
access_token: body[:access_token],
|
|
74
|
+
refresh_token: body[:refresh_token],
|
|
75
|
+
expires_in: body[:expires_in],
|
|
76
|
+
scopes: body[:scope] || scopes,
|
|
77
|
+
tenant_id: auth_settings[:tenant_id],
|
|
78
|
+
client_id: auth_settings[:client_id]
|
|
79
|
+
)
|
|
80
|
+
Legion::Extensions::Identity::Entra::Client.reset!(pattern: :delegated)
|
|
81
|
+
log.info('Delegated auth restored via browser re-auth')
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
log.error("Delegated browser re-auth failed: #{e.message}")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Identity
|
|
6
|
+
module Entra
|
|
7
|
+
module Delegated
|
|
8
|
+
module CLI
|
|
9
|
+
class Auth
|
|
10
|
+
def self.cli_alias = 'entra'
|
|
11
|
+
|
|
12
|
+
def self.descriptions
|
|
13
|
+
{
|
|
14
|
+
login: 'Authenticate with Microsoft Entra via delegated OAuth',
|
|
15
|
+
status: 'Show current Entra authentication state'
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def login(tenant_id: nil, client_id: nil, scopes: nil, **)
|
|
20
|
+
settings = tenant_id && client_id ? {} : resolve_settings
|
|
21
|
+
tid = tenant_id || settings[:tenant_id] || ENV.fetch('AZURE_TENANT_ID', nil)
|
|
22
|
+
cid = client_id || settings[:client_id] || ENV.fetch('AZURE_CLIENT_ID', nil)
|
|
23
|
+
requested_scopes = scopes || settings.dig(:delegated, :scopes) || Helpers::BrowserAuth.default_scopes
|
|
24
|
+
|
|
25
|
+
unless tid && cid
|
|
26
|
+
puts 'Error: tenant_id and client_id required (set identity.entra.auth, env vars, or pass as args)'
|
|
27
|
+
return { error: 'missing_config' }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
browser_auth = Helpers::BrowserAuth.new(tenant_id: tid, client_id: cid,
|
|
31
|
+
scopes: requested_scopes, force_local_server: true)
|
|
32
|
+
result = browser_auth.authenticate
|
|
33
|
+
body = result&.dig(:result)
|
|
34
|
+
|
|
35
|
+
if body&.dig(:access_token)
|
|
36
|
+
store_token(body, tenant_id: tid, client_id: cid, scopes: requested_scopes)
|
|
37
|
+
puts 'Entra authenticated successfully.'
|
|
38
|
+
else
|
|
39
|
+
puts 'Entra authentication failed or was cancelled.'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
result
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
puts "Error: #{e.message}"
|
|
45
|
+
{ error: 'login_failed', description: e.message }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def status
|
|
49
|
+
data = Helpers::TokenManager.token_data(:delegated, refresh: false)
|
|
50
|
+
if data && !Helpers::TokenManager.expired?(data)
|
|
51
|
+
puts 'Entra: authenticated (delegated token present)'
|
|
52
|
+
{ result: { authenticated: true, expires_at: data[:expires_at]&.utc&.iso8601 } }
|
|
53
|
+
else
|
|
54
|
+
puts 'Entra: not authenticated'
|
|
55
|
+
{ result: { authenticated: false } }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def resolve_settings
|
|
62
|
+
Helpers::TokenManager.settings_auth
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def store_token(body, tenant_id:, client_id:, scopes:)
|
|
66
|
+
Helpers::TokenManager.save_token(
|
|
67
|
+
:delegated,
|
|
68
|
+
access_token: body[:access_token],
|
|
69
|
+
refresh_token: body[:refresh_token],
|
|
70
|
+
expires_in: body[:expires_in],
|
|
71
|
+
scopes: body[:scope] || scopes,
|
|
72
|
+
tenant_id: tenant_id,
|
|
73
|
+
client_id: client_id
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Identity
|
|
6
|
+
module Entra
|
|
7
|
+
module Delegated
|
|
8
|
+
class Client < Legion::Extensions::Identity::Entra::Client
|
|
9
|
+
def pattern = :delegated
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def authenticate
|
|
14
|
+
settings = auth_settings
|
|
15
|
+
return unless settings[:tenant_id] && settings[:client_id]
|
|
16
|
+
|
|
17
|
+
requested = Legion::Extensions::Identity::Entra::Helpers::Scopes.resolve(pattern: :delegated)
|
|
18
|
+
registry.record_requested(requested)
|
|
19
|
+
|
|
20
|
+
browser = Legion::Extensions::Identity::Entra::Helpers::BrowserAuth.new(
|
|
21
|
+
tenant_id: settings[:tenant_id],
|
|
22
|
+
client_id: settings[:client_id],
|
|
23
|
+
scopes: requested
|
|
24
|
+
)
|
|
25
|
+
result = browser.authenticate
|
|
26
|
+
body = result&.dig(:result)
|
|
27
|
+
return unless body&.dig(:access_token)
|
|
28
|
+
|
|
29
|
+
granted = body[:scope] || requested
|
|
30
|
+
registry.record_granted(granted)
|
|
31
|
+
Legion::Extensions::Identity::Entra::Helpers::TokenManager.save_token(
|
|
32
|
+
:delegated,
|
|
33
|
+
access_token: body[:access_token],
|
|
34
|
+
refresh_token: body[:refresh_token],
|
|
35
|
+
expires_in: body[:expires_in],
|
|
36
|
+
scopes: granted,
|
|
37
|
+
tenant_id: settings[:tenant_id],
|
|
38
|
+
client_id: settings[:client_id]
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
if defined?(Legion::Extensions::Hooks::Base)
|
|
4
|
+
module Legion
|
|
5
|
+
module Extensions
|
|
6
|
+
module Identity
|
|
7
|
+
module Entra
|
|
8
|
+
module Delegated
|
|
9
|
+
module Hooks
|
|
10
|
+
class Auth < Legion::Extensions::Hooks::Base # rubocop:disable Legion/Extension/HookMissingRunnerClass
|
|
11
|
+
mount '/callback'
|
|
12
|
+
|
|
13
|
+
def self.runner_class
|
|
14
|
+
'Legion::Extensions::Identity::Entra::Delegated::Runners::Login'
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Identity
|
|
6
|
+
module Entra
|
|
7
|
+
module Delegated
|
|
8
|
+
module Identity
|
|
9
|
+
extend self
|
|
10
|
+
include Legion::Logging::Helper
|
|
11
|
+
include Legion::Settings::Helper
|
|
12
|
+
|
|
13
|
+
def provider_name = :entra_delegated
|
|
14
|
+
def provider_type = :auth
|
|
15
|
+
def priority = 100
|
|
16
|
+
def trust_weight = 40
|
|
17
|
+
def trust_level = :verified
|
|
18
|
+
def capabilities = %i[authenticate profile interactive outbound_auth]
|
|
19
|
+
|
|
20
|
+
def resolve
|
|
21
|
+
log.debug('Delegated::Identity.resolve: starting identity resolution')
|
|
22
|
+
token = find_cached_token
|
|
23
|
+
unless token
|
|
24
|
+
log.debug('Delegated::Identity.resolve: no cached token, cannot resolve')
|
|
25
|
+
return nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
profile = Legion::Extensions::Identity::Entra::Helpers::GraphClient.fetch_me(token)
|
|
29
|
+
unless profile
|
|
30
|
+
log.warn('Delegated::Identity.resolve: Graph /me returned nil')
|
|
31
|
+
return nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
canonical = profile[:on_premises_sam_account_name] || profile[:mail_nickname]
|
|
35
|
+
if canonical.nil? || canonical.empty?
|
|
36
|
+
log.warn('Delegated::Identity.resolve: no canonical name in profile')
|
|
37
|
+
return nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
log.info("Delegated::Identity.resolve: resolved identity canonical=#{normalize(canonical)}")
|
|
41
|
+
{
|
|
42
|
+
canonical_name: normalize(canonical),
|
|
43
|
+
kind: :human,
|
|
44
|
+
source: :entra_delegated,
|
|
45
|
+
provider_identity: profile[:id],
|
|
46
|
+
profile: profile,
|
|
47
|
+
employee_id: profile[:employee_id]
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def resolve_all
|
|
52
|
+
accounts = Legion::Extensions::Identity::Entra::Helpers::AccountDiscovery.resolve_all_accounts
|
|
53
|
+
return accounts unless accounts.empty?
|
|
54
|
+
|
|
55
|
+
result = resolve
|
|
56
|
+
result ? [result] : []
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def refresh
|
|
60
|
+
log.debug('Delegated::Identity.refresh: attempting token refresh')
|
|
61
|
+
data = Legion::Extensions::Identity::Entra::Helpers::TokenManager.token_data(:delegated, refresh: true)
|
|
62
|
+
if data && !Legion::Extensions::Identity::Entra::Helpers::TokenManager.expired?(data)
|
|
63
|
+
Legion::Extensions::Identity::Entra::Client.reset!(pattern: :delegated)
|
|
64
|
+
log.info('Delegated::Identity.refresh: token refreshed successfully')
|
|
65
|
+
true
|
|
66
|
+
else
|
|
67
|
+
log.warn('Delegated::Identity.refresh: token refresh returned expired or nil data')
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
handle_exception(e, level: :warn, operation: 'delegated.identity.refresh')
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def normalize(val)
|
|
76
|
+
str = val.to_s.downcase.strip
|
|
77
|
+
str = str.split('@', 2).first if str.include?('@')
|
|
78
|
+
str.gsub(/[^a-z0-9_-]/, '')
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def provide_token(qualifier: :delegated)
|
|
82
|
+
token = find_cached_token(qualifier)
|
|
83
|
+
return nil unless token
|
|
84
|
+
|
|
85
|
+
data = Legion::Extensions::Identity::Entra::Helpers::TokenManager.token_data(qualifier, refresh: false)
|
|
86
|
+
build_lease(
|
|
87
|
+
provider: :entra_delegated,
|
|
88
|
+
credential: token,
|
|
89
|
+
expires_at: data&.dig(:expires_at) || (Time.now + 3600),
|
|
90
|
+
renewable: !data&.dig(:refresh_token).nil?,
|
|
91
|
+
metadata: { qualifier: qualifier, scopes: data&.dig(:scopes) }.compact
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def find_cached_token(qualifier = :delegated)
|
|
98
|
+
log.debug("Delegated::Identity.find_cached_token: qualifier=#{qualifier}")
|
|
99
|
+
Legion::Extensions::Identity::Entra::Helpers::TokenManager.load_token(qualifier)
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
handle_exception(e, level: :warn, operation: 'delegated.identity.find_cached_token',
|
|
102
|
+
qualifier: qualifier)
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def build_lease(**attrs)
|
|
107
|
+
return Legion::Identity::Lease.new(**attrs) if defined?(Legion::Identity::Lease)
|
|
108
|
+
|
|
109
|
+
attrs
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|