lex-identity 0.3.0 → 0.4.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +4 -6
  3. data/lex-identity.gemspec +12 -15
  4. data/lib/legion/extensions/identity/actors/credential_refresh.rb +45 -0
  5. data/lib/legion/extensions/identity/actors/orphan_check.rb +48 -0
  6. data/lib/legion/extensions/identity/client.rb +23 -0
  7. data/lib/legion/extensions/identity/helpers/dimensions.rb +71 -0
  8. data/lib/legion/extensions/identity/helpers/fingerprint.rb +166 -0
  9. data/lib/legion/extensions/identity/helpers/graph_client.rb +25 -0
  10. data/lib/legion/extensions/identity/helpers/graph_token.rb +32 -0
  11. data/lib/legion/extensions/identity/helpers/token_cache.rb +55 -0
  12. data/lib/legion/extensions/identity/helpers/vault_secrets.rb +76 -0
  13. data/lib/legion/extensions/identity/local_migrations/20260316000030_create_fingerprint.rb +20 -0
  14. data/lib/legion/extensions/identity/runners/entra.rb +398 -0
  15. data/lib/legion/extensions/identity/runners/identity.rb +86 -0
  16. data/lib/legion/extensions/identity/version.rb +1 -1
  17. data/lib/legion/extensions/identity.rb +14 -8
  18. data/spec/legion/extensions/identity/actors/orphan_check_spec.rb +104 -0
  19. data/spec/legion/extensions/identity/client_spec.rb +32 -0
  20. data/spec/legion/extensions/identity/helpers/dimensions_spec.rb +51 -0
  21. data/spec/legion/extensions/identity/helpers/fingerprint_spec.rb +66 -0
  22. data/spec/legion/extensions/identity/helpers/graph_client_spec.rb +19 -0
  23. data/spec/legion/extensions/identity/helpers/graph_token_spec.rb +31 -0
  24. data/spec/legion/extensions/identity/helpers/token_cache_spec.rb +50 -0
  25. data/spec/legion/extensions/identity/runners/entra_spec.rb +655 -0
  26. data/spec/legion/extensions/identity/runners/identity_spec.rb +61 -0
  27. data/spec/local_persistence_spec.rb +329 -0
  28. data/spec/spec_helper.rb +33 -0
  29. metadata +40 -67
  30. data/CHANGELOG.md +0 -27
  31. data/LICENSE +0 -21
  32. data/README.md +0 -230
  33. data/lib/legion/extensions/identity/helpers/browser.rb +0 -44
  34. data/lib/legion/extensions/identity/helpers/callback_server.rb +0 -170
  35. data/lib/legion/extensions/identity/helpers/oauth.rb +0 -62
  36. data/lib/legion/extensions/identity/helpers/pkce.rb +0 -20
  37. data/lib/legion/extensions/identity/helpers/provider.rb +0 -52
  38. data/lib/legion/extensions/identity/runners/base/oauth.rb +0 -140
  39. data/spec/support/provider_contract.rb +0 -49
@@ -1,140 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'legion/extensions/identity/helpers/oauth'
4
- require 'legion/extensions/identity/helpers/browser'
5
- require 'legion/extensions/identity/helpers/callback_server'
6
- require 'legion/extensions/identity/helpers/pkce'
7
- require 'securerandom'
8
- require 'legion/logging'
9
-
10
- module Legion
11
- module Extensions
12
- module Identity
13
- module Runners
14
- module Oauth
15
- extend self
16
- extend Legion::Extensions::Identity::Helpers::Oauth
17
- extend Legion::Extensions::Identity::Helpers::Browser
18
- extend Legion::Extensions::Identity::Helpers::Pkce
19
-
20
- DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
21
-
22
- def browser_login(authorization_endpoint:, token_endpoint:, client_id:, scope:, callback_port: 0, callback_timeout: 120, **)
23
- unless callback_timeout.is_a?(Numeric) && callback_timeout.real? && callback_timeout.finite? && callback_timeout.positive?
24
- raise ArgumentError, 'callback_timeout must be positive and finite'
25
- end
26
-
27
- return { error: 'browser_unavailable', description: 'Interactive browser login requires a graphical session' } unless browser_gui_available?
28
-
29
- begin
30
- pkce = pkce_generate
31
- state = SecureRandom.hex(32)
32
- server = Legion::Extensions::Identity::Helpers::CallbackServer.new(port: callback_port)
33
- redirect_uri = server.redirect_uri
34
- url = oauth_authorize_url(authorization_endpoint: authorization_endpoint, client_id: client_id,
35
- redirect_uri: redirect_uri, scope: scope, state: state,
36
- code_challenge: pkce[:code_challenge], code_challenge_method: pkce[:code_challenge_method])
37
- if browser_open(url: url)
38
- callback = server.wait_for_callback(state: state, timeout: callback_timeout)
39
- if callback[:error]
40
- callback
41
- else
42
- exchange_code(token_endpoint: token_endpoint, client_id: client_id, code: callback[:code],
43
- redirect_uri: redirect_uri, code_verifier: pkce[:code_verifier], scope: scope)
44
- end
45
- else
46
- { error: 'browser_launch_failed', description: 'Unable to launch the browser' }
47
- end
48
- rescue StandardError => e
49
- oauth_runner_error(operation: 'identity.oauth.browser_login', exception: e)
50
- ensure
51
- server&.shutdown
52
- end
53
- end
54
-
55
- def request_device_code(device_authorization_endpoint:, client_id:, scope:, **)
56
- result = oauth_post(endpoint: device_authorization_endpoint,
57
- form: { client_id: client_id, scope: scope })
58
- oauth_runner_result(result: result, required_key: :device_code)
59
- rescue StandardError => e
60
- oauth_runner_error(operation: 'identity.oauth.request_device_code', exception: e)
61
- end
62
-
63
- def exchange_code(token_endpoint:, client_id:, code:, redirect_uri:, code_verifier:, scope: nil, **)
64
- form = {
65
- grant_type: 'authorization_code', client_id: client_id, code: code,
66
- redirect_uri: redirect_uri, code_verifier: code_verifier
67
- }
68
- form[:scope] = scope unless scope.nil?
69
- result = oauth_post(endpoint: token_endpoint, form: form)
70
- oauth_runner_result(result: result, required_key: :access_token)
71
- rescue StandardError => e
72
- oauth_runner_error(operation: 'identity.oauth.exchange_code', exception: e)
73
- end
74
-
75
- def refresh_token(token_endpoint:, client_id:, refresh_token:, scope: nil, **)
76
- form = { grant_type: 'refresh_token', client_id: client_id, refresh_token: refresh_token }
77
- form[:scope] = scope unless scope.nil?
78
- result = oauth_post(endpoint: token_endpoint, form: form)
79
- oauth_runner_result(result: result, required_key: :access_token)
80
- rescue StandardError => e
81
- oauth_runner_error(operation: 'identity.oauth.refresh_token', exception: e)
82
- end
83
-
84
- def poll_device_code(token_endpoint:, client_id:, device_code:, interval: 5, timeout: 300, **)
85
- raise ArgumentError, 'interval must be positive' unless interval.positive?
86
- raise ArgumentError, 'timeout must not be negative' if timeout.negative?
87
-
88
- deadline = oauth_monotonic_now + timeout
89
- current_interval = interval
90
- outcome = { error: 'timeout', description: "Device code flow timed out after #{timeout}s" }
91
- begin
92
- while (remaining = deadline - oauth_monotonic_now).positive?
93
- result = oauth_post(endpoint: token_endpoint,
94
- form: { grant_type: DEVICE_CODE_GRANT, client_id: client_id, device_code: device_code },
95
- open_timeout: [remaining, 5].min,
96
- request_timeout: [remaining, 15].min)
97
- if result[:error] == 'slow_down'
98
- current_interval += 5
99
- elsif result[:error] != 'authorization_pending'
100
- outcome = oauth_runner_result(result: result, required_key: :access_token)
101
- break
102
- end
103
- sleep_for = [current_interval, deadline - oauth_monotonic_now].min
104
- break if sleep_for <= 0
105
-
106
- sleep(sleep_for)
107
- end
108
-
109
- outcome
110
- rescue StandardError => e
111
- oauth_runner_error(operation: 'identity.oauth.poll_device_code', exception: e)
112
- end
113
- end
114
-
115
- private
116
-
117
- def oauth_runner_result(result:, required_key:)
118
- return { error: result[:error].to_s, description: (result[:error_description] || result[:description]).to_s } if result[:error]
119
- unless result[required_key].is_a?(String) && !result[required_key].empty?
120
- return { error: 'invalid_response', description: "OAuth response did not include #{required_key}" }
121
- end
122
-
123
- { result: result }
124
- end
125
-
126
- def oauth_runner_error(operation:, exception:)
127
- sanitized = StandardError.new("OAuth runner failed (#{exception.class})")
128
- sanitized.set_backtrace(exception.backtrace)
129
- handle_exception(sanitized, level: :error, operation: operation)
130
- { error: 'request_failed', description: exception.message }
131
- end
132
-
133
- def oauth_monotonic_now
134
- ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
135
- end
136
- end
137
- end
138
- end
139
- end
140
- end
@@ -1,49 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- RSpec.shared_examples 'identity provider contract' do
4
- let(:canonical_name) { 'example-user' }
5
-
6
- it 'exposes provider metadata with contract types' do
7
- provider = identity_provider
8
-
9
- expect(provider.provider_name).to be_a(Symbol)
10
- expect(%i[auth profile fallback]).to include(provider.provider_type)
11
- expect(provider.priority).to be_a(Numeric)
12
- expect(provider.trust_weight).to be_a(Numeric)
13
- expect(provider.capabilities).to be_an(Array)
14
- expect(provider.capabilities).to all(be_a(Symbol))
15
-
16
- trust_levels = if defined?(Legion::Identity::Trust) && Legion::Identity::Trust.respond_to?(:levels)
17
- Legion::Identity::Trust.levels
18
- else
19
- %i[verified authenticated configured cached unverified]
20
- end
21
- expect(trust_levels).to include(provider.trust_level)
22
- end
23
-
24
- it 'uses the core resolution call contract' do
25
- provider = identity_provider
26
- result = case provider.provider_type
27
- when :profile
28
- if provider.respond_to?(:resolve_all)
29
- provider.resolve_all(canonical_name: canonical_name)
30
- else
31
- provider.resolve(canonical_name: canonical_name)
32
- end
33
- when :auth, :fallback
34
- provider.resolve
35
- end
36
-
37
- expect(result).to be_nil.or be_a(Hash)
38
- next if result.nil?
39
-
40
- if %i[auth fallback].include?(provider.provider_type)
41
- expect(result).to include(:canonical_name, :kind, :source)
42
- expect(result[:canonical_name]).to match(/\A[a-z0-9][a-z0-9_-]*\z/)
43
- expect(result[:kind]).not_to be_nil
44
- expect(result[:source]).not_to be_nil
45
- else
46
- expect(result).not_to have_key(:canonical_name)
47
- end
48
- end
49
- end