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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +34 -0
  3. data/.gitignore +11 -0
  4. data/.rubocop.yml +20 -0
  5. data/CHANGELOG.md +39 -0
  6. data/Gemfile +11 -0
  7. data/README.md +120 -0
  8. data/lex-identity-entra.gemspec +37 -0
  9. data/lib/legion/extensions/identity/entra/application/actors/token_refresher.rb +71 -0
  10. data/lib/legion/extensions/identity/entra/application/client.rb +45 -0
  11. data/lib/legion/extensions/identity/entra/application/runners/credential.rb +81 -0
  12. data/lib/legion/extensions/identity/entra/application/scope_registry.rb +15 -0
  13. data/lib/legion/extensions/identity/entra/application/scopes.rb +47 -0
  14. data/lib/legion/extensions/identity/entra/application.rb +46 -0
  15. data/lib/legion/extensions/identity/entra/client.rb +181 -0
  16. data/lib/legion/extensions/identity/entra/delegated/actors/auth_validator.rb +149 -0
  17. data/lib/legion/extensions/identity/entra/delegated/actors/token_refresher.rb +91 -0
  18. data/lib/legion/extensions/identity/entra/delegated/cli/auth.rb +82 -0
  19. data/lib/legion/extensions/identity/entra/delegated/client.rb +46 -0
  20. data/lib/legion/extensions/identity/entra/delegated/hooks/auth.rb +23 -0
  21. data/lib/legion/extensions/identity/entra/delegated/identity.rb +116 -0
  22. data/lib/legion/extensions/identity/entra/delegated/runners/login.rb +185 -0
  23. data/lib/legion/extensions/identity/entra/delegated/runners/on_behalf_of.rb +64 -0
  24. data/lib/legion/extensions/identity/entra/delegated/scope_registry.rb +15 -0
  25. data/lib/legion/extensions/identity/entra/delegated/scopes.rb +100 -0
  26. data/lib/legion/extensions/identity/entra/delegated.rb +54 -0
  27. data/lib/legion/extensions/identity/entra/helpers/account_discovery.rb +85 -0
  28. data/lib/legion/extensions/identity/entra/helpers/browser_auth.rb +212 -0
  29. data/lib/legion/extensions/identity/entra/helpers/callback_server.rb +106 -0
  30. data/lib/legion/extensions/identity/entra/helpers/graph_client.rb +63 -0
  31. data/lib/legion/extensions/identity/entra/helpers/scope_gate.rb +96 -0
  32. data/lib/legion/extensions/identity/entra/helpers/scope_registry.rb +72 -0
  33. data/lib/legion/extensions/identity/entra/helpers/scopes.rb +61 -0
  34. data/lib/legion/extensions/identity/entra/helpers/token_manager.rb +362 -0
  35. data/lib/legion/extensions/identity/entra/managed_identity/actors/token_refresher.rb +64 -0
  36. data/lib/legion/extensions/identity/entra/managed_identity/client.rb +34 -0
  37. data/lib/legion/extensions/identity/entra/managed_identity/runners/token.rb +61 -0
  38. data/lib/legion/extensions/identity/entra/managed_identity/scope_registry.rb +15 -0
  39. data/lib/legion/extensions/identity/entra/managed_identity/scopes.rb +25 -0
  40. data/lib/legion/extensions/identity/entra/managed_identity.rb +42 -0
  41. data/lib/legion/extensions/identity/entra/version.rb +11 -0
  42. data/lib/legion/extensions/identity/entra/workload_identity/actors/token_refresher.rb +64 -0
  43. data/lib/legion/extensions/identity/entra/workload_identity/client.rb +34 -0
  44. data/lib/legion/extensions/identity/entra/workload_identity/runners/token.rb +99 -0
  45. data/lib/legion/extensions/identity/entra/workload_identity/scope_registry.rb +15 -0
  46. data/lib/legion/extensions/identity/entra/workload_identity/scopes.rb +25 -0
  47. data/lib/legion/extensions/identity/entra/workload_identity.rb +43 -0
  48. data/lib/legion/extensions/identity/entra.rb +51 -0
  49. 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