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.
- checksums.yaml +4 -4
- data/Gemfile +4 -6
- data/lex-identity.gemspec +12 -15
- data/lib/legion/extensions/identity/actors/credential_refresh.rb +45 -0
- data/lib/legion/extensions/identity/actors/orphan_check.rb +48 -0
- data/lib/legion/extensions/identity/client.rb +23 -0
- data/lib/legion/extensions/identity/helpers/dimensions.rb +71 -0
- data/lib/legion/extensions/identity/helpers/fingerprint.rb +166 -0
- data/lib/legion/extensions/identity/helpers/graph_client.rb +25 -0
- data/lib/legion/extensions/identity/helpers/graph_token.rb +32 -0
- data/lib/legion/extensions/identity/helpers/token_cache.rb +55 -0
- data/lib/legion/extensions/identity/helpers/vault_secrets.rb +76 -0
- data/lib/legion/extensions/identity/local_migrations/20260316000030_create_fingerprint.rb +20 -0
- data/lib/legion/extensions/identity/runners/entra.rb +398 -0
- data/lib/legion/extensions/identity/runners/identity.rb +86 -0
- data/lib/legion/extensions/identity/version.rb +1 -1
- data/lib/legion/extensions/identity.rb +14 -8
- data/spec/legion/extensions/identity/actors/orphan_check_spec.rb +104 -0
- data/spec/legion/extensions/identity/client_spec.rb +32 -0
- data/spec/legion/extensions/identity/helpers/dimensions_spec.rb +51 -0
- data/spec/legion/extensions/identity/helpers/fingerprint_spec.rb +66 -0
- data/spec/legion/extensions/identity/helpers/graph_client_spec.rb +19 -0
- data/spec/legion/extensions/identity/helpers/graph_token_spec.rb +31 -0
- data/spec/legion/extensions/identity/helpers/token_cache_spec.rb +50 -0
- data/spec/legion/extensions/identity/runners/entra_spec.rb +655 -0
- data/spec/legion/extensions/identity/runners/identity_spec.rb +61 -0
- data/spec/local_persistence_spec.rb +329 -0
- data/spec/spec_helper.rb +33 -0
- metadata +40 -67
- data/CHANGELOG.md +0 -27
- data/LICENSE +0 -21
- data/README.md +0 -230
- data/lib/legion/extensions/identity/helpers/browser.rb +0 -44
- data/lib/legion/extensions/identity/helpers/callback_server.rb +0 -170
- data/lib/legion/extensions/identity/helpers/oauth.rb +0 -62
- data/lib/legion/extensions/identity/helpers/pkce.rb +0 -20
- data/lib/legion/extensions/identity/helpers/provider.rb +0 -52
- data/lib/legion/extensions/identity/runners/base/oauth.rb +0 -140
- 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
|