lex-identity 0.2.0 → 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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/Gemfile +6 -3
  4. data/LICENSE +21 -0
  5. data/README.md +230 -0
  6. data/lex-identity.gemspec +15 -12
  7. data/lib/legion/extensions/identity/helpers/browser.rb +44 -0
  8. data/lib/legion/extensions/identity/helpers/callback_server.rb +170 -0
  9. data/lib/legion/extensions/identity/helpers/oauth.rb +62 -0
  10. data/lib/legion/extensions/identity/helpers/pkce.rb +20 -0
  11. data/lib/legion/extensions/identity/helpers/provider.rb +52 -0
  12. data/lib/legion/extensions/identity/runners/base/oauth.rb +140 -0
  13. data/lib/legion/extensions/identity/version.rb +1 -1
  14. data/lib/legion/extensions/identity.rb +8 -14
  15. data/spec/support/provider_contract.rb +49 -0
  16. metadata +67 -33
  17. data/lib/legion/extensions/identity/actors/orphan_check.rb +0 -48
  18. data/lib/legion/extensions/identity/client.rb +0 -23
  19. data/lib/legion/extensions/identity/helpers/dimensions.rb +0 -71
  20. data/lib/legion/extensions/identity/helpers/fingerprint.rb +0 -166
  21. data/lib/legion/extensions/identity/helpers/vault_secrets.rb +0 -76
  22. data/lib/legion/extensions/identity/local_migrations/20260316000030_create_fingerprint.rb +0 -20
  23. data/lib/legion/extensions/identity/runners/entra.rb +0 -223
  24. data/lib/legion/extensions/identity/runners/identity.rb +0 -86
  25. data/spec/legion/extensions/identity/actors/orphan_check_spec.rb +0 -104
  26. data/spec/legion/extensions/identity/client_spec.rb +0 -32
  27. data/spec/legion/extensions/identity/helpers/dimensions_spec.rb +0 -51
  28. data/spec/legion/extensions/identity/helpers/fingerprint_spec.rb +0 -66
  29. data/spec/legion/extensions/identity/runners/entra_spec.rb +0 -405
  30. data/spec/legion/extensions/identity/runners/identity_spec.rb +0 -61
  31. data/spec/local_persistence_spec.rb +0 -329
  32. data/spec/spec_helper.rb +0 -33
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3681b4b6aab75c906d15647f1f359d19760d42994803b8c89d733480e77eacff
4
- data.tar.gz: 4b49caa8730568c80e4e84a8d43f9053d83e6bef70a22730115a63492d7d5a06
3
+ metadata.gz: 18e33b5500f9d9b8ea10ec64025f01c80ecf3e31587c1d0933fe12f0a6b5a951
4
+ data.tar.gz: e3545bf086d36d22f7001ac6fbb523396b6380311d5c2a06aaabd7a22affdf90
5
5
  SHA512:
6
- metadata.gz: 527ef0472ad306da82d94ebea24a2b48c0375e164248b322aff1a721897d84f7518eac789852f17af88df14117ddfc9523dd65b42047a84d27af4fb907797d03
7
- data.tar.gz: d7e69edd6068f138112c1a685423fc34fd44b8c5d83482c4c4ec8a1179b628955f2043a2e0d43a2b0dc0035b71129f8f56d5d6119dfc69ca64317087a7905849
6
+ metadata.gz: a884c9d1092ab1560f008da1c5f45270d64f2702f31dc9316976a4c2e62cc4a8fe4c0cb127d4a13174170d192907cc7e9508401a77f00bc6676872cf3bbd13a0
7
+ data.tar.gz: cab921cd7605cc190b9cf5157d650c6816d4af46c0da7394b16b4b114fc556d82ed96b0135ee7eeac188df7f61dbb2ce0604fd7a868d295536ad673ae75e48f4
data/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ ## [0.3.0] - 2026-11-09
4
+
5
+ ### Added
6
+ - Explicit provider-neutral browser login combining PKCE, authorization, a state-correlated loopback callback, and token exchange.
7
+ - A single-use callback listener with bounded reads/writes, request limits, optional fixed-port binding, and cleanup on every exit.
8
+ - Callback security and lifecycle specs for invalid state, ambiguous parameters, provider denial, stalled clients, timeout, and listener disposal.
9
+
10
+ ### Changed
11
+ - Moved reusable OAuth runners to `runners/base/oauth.rb` so generic endpoint and browser operations are not automatically exposed through extension API routes. Consumers must explicitly require the new path.
12
+
13
+ ## [0.2.0] - 2026-11-07
14
+
15
+ ### Added
16
+ - Provider integration helpers for resolver registration, lease construction, and broker credential access using one provider name.
17
+ - Optional PKCE generation and browser helpers.
18
+ - Provider-neutral OAuth authorization, code exchange, refresh, and deadline-bounded device-code polling.
19
+ - Reusable provider conformance examples covering the core resolver's metadata and resolution contracts.
20
+ - Shared OAuth runner compatible with standard extension discovery; the base extension is not itself an identity provider or remotely invocable.
21
+
22
+ ## [0.1.0] - 2026-11-07
23
+
24
+ ### Added
25
+ - Initial gem packaging, namespace, and version.
26
+ - Standard actors, runners, helpers, and spec directories.
27
+ - Shared CI/release workflow, dependency updates, and RuboCop/RSpec configuration.
data/Gemfile CHANGED
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  source 'https://rubygems.org'
4
-
5
4
  gemspec
6
5
 
7
- gem 'rspec', '~> 3.13'
8
- gem 'rubocop', '~> 1.75', require: false
6
+ group :development, :test do
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75'
9
+ gem 'rubocop-legion', '~> 0.1'
10
+ gem 'simplecov', '~> 0.22'
11
+ end
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LegionIO
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # lex-identity
2
+
3
+ Shared base extension for LegionIO identity providers. Ruby 3.4+.
4
+
5
+ Providers opt into small helper mixins and common OAuth runners. No provider
6
+ inheritance hierarchy, authentication policy, storage backend, or name-normalization
7
+ rule is imposed. Existing identity extensions are not migrated by this gem.
8
+
9
+ ## Layout and runtime ownership
10
+
11
+ - `lib/legion/extensions/identity/helpers/` — provider integration, PKCE, browser, OAuth, loopback callback listener
12
+ - `lib/legion/extensions/identity/runners/base/oauth.rb` — reusable OAuth operations, outside automatic runner discovery
13
+ - `lib/legion/extensions/identity/actors/` — reserved for shared actors; no timers are started
14
+ - `spec/support/provider_contract.rb` — reusable conformance examples, included in the gem
15
+
16
+ The base extension reports `identity_provider?`, `remote_invocable?`,
17
+ `transport_required?`, and `crypt_required?` as false. It does not register itself
18
+ as an identity provider. Shared runner code lives under `runners/base/` and is
19
+ explicitly required; only direct `runners/*.rb` files are discovered by LegionIO.
20
+ This prevents generic endpoint arguments and browser control from becoming API
21
+ routes. `remote_invocable?` alone does not disable route registration.
22
+
23
+ `Legion::Identity::Resolver`, `Broker`, `Lease`, and `LeaseRenewer` remain owned by
24
+ LegionIO core. This gem does not duplicate their registries or renewal scheduler,
25
+ and can be loaded without booting LegionIO.
26
+
27
+ ## Provider integration
28
+
29
+ ```ruby
30
+ require 'legion/extensions/identity'
31
+
32
+ # MyProvider is your existing identity provider module or object.
33
+ MyProvider.extend(Legion::Extensions::Identity::Helpers::Provider)
34
+ MyProvider.identity_register
35
+
36
+ lease = MyProvider.identity_build_lease(
37
+ credential: access_token,
38
+ expires_at: expires_at,
39
+ renewable: true,
40
+ metadata: { scopes: granted_scopes }
41
+ )
42
+ MyProvider.identity_register_credential(lease: lease, qualifier: :work, default: true)
43
+ MyProvider.identity_credentials_available
44
+ MyProvider.identity_token_for(qualifier: :work, purpose: :api_request)
45
+ ```
46
+
47
+ All registration, lease provenance, credential discovery, and token lookup use
48
+ `MyProvider.provider_name`. There is no second provider-name argument to keep in sync.
49
+
50
+ | Method | Behavior |
51
+ |---|---|
52
+ | `identity_register` | Registers with the loaded Resolver, or appends to core's existing pending registrations; returns `:registered`, `:pending`, or `:unavailable` when core is absent. Standalone loading does not raise or create a second registry. |
53
+ | `identity_build_lease(credential:, **attributes)` | Builds the core Lease when loaded, otherwise a plain attribute Hash for standalone use. The provider name cannot be overridden through attributes. |
54
+ | `identity_register_credential(lease:, qualifier: :default, default: false)` | Registers with core Broker; raises if Broker is unavailable. Pass a real core Lease when using Broker. |
55
+ | `identity_credentials_available` | Returns this provider's broker qualifiers, or `[]` before Broker loads. |
56
+ | `identity_token_for(qualifier: nil, **options)` | Uses Broker lookup, including context/purpose options; returns `nil` before Broker loads. |
57
+
58
+ Providers retain their metadata: `provider_name`, `provider_type`, `priority`,
59
+ `trust_weight`, `trust_level`, and `capabilities`. They also retain subject
60
+ verification, profile mapping, normalization, and credential acquisition.
61
+
62
+ ## Optional browser and PKCE helpers
63
+
64
+ ```ruby
65
+ require 'legion/extensions/identity/helpers/pkce'
66
+ require 'legion/extensions/identity/helpers/browser'
67
+
68
+ helper = Object.new
69
+ helper.extend(Legion::Extensions::Identity::Helpers::Pkce)
70
+ helper.extend(Legion::Extensions::Identity::Helpers::Browser)
71
+
72
+ proof = helper.pkce_generate
73
+ # { code_verifier:, code_challenge:, code_challenge_method: 'S256' }
74
+ helper.browser_open(url: authorization_url) if helper.browser_gui_available?
75
+ ```
76
+
77
+ These low-level browser/PKCE helpers do not start login sessions. The optional
78
+ `browser_login` runner below supplies the common callback lifecycle.
79
+
80
+ ## Optional OAuth operations
81
+
82
+ ```ruby
83
+ require 'legion/extensions/identity/runners/base/oauth'
84
+
85
+ oauth = Legion::Extensions::Identity::Runners::Oauth
86
+ url = oauth.oauth_authorize_url(
87
+ authorization_endpoint: authorization_endpoint,
88
+ client_id: client_id,
89
+ redirect_uri: redirect_uri,
90
+ scope: 'openid profile',
91
+ state: state,
92
+ code_challenge: proof[:code_challenge]
93
+ )
94
+
95
+ result = oauth.exchange_code(
96
+ token_endpoint: token_endpoint,
97
+ client_id: client_id,
98
+ code: code,
99
+ redirect_uri: redirect_uri,
100
+ code_verifier: proof[:code_verifier]
101
+ )
102
+ ```
103
+
104
+ The authorization URL helper returns a String, preserving unrelated endpoint query
105
+ parameters while replacing conflicting OAuth parameters. Scope values are
106
+ space-separated strings.
107
+
108
+ The public runners return `{ result: provider_response }` on success or
109
+ `{ error:, description: }` on failure:
110
+
111
+ - `browser_login(authorization_endpoint:, token_endpoint:, client_id:, scope:, callback_port: 0, callback_timeout: 120)`
112
+
113
+ - `request_device_code(device_authorization_endpoint:, client_id:, scope:)`
114
+ - `exchange_code(token_endpoint:, client_id:, code:, redirect_uri:, code_verifier:, scope: nil)`
115
+ - `refresh_token(token_endpoint:, client_id:, refresh_token:, scope: nil)`
116
+ - `poll_device_code(token_endpoint:, client_id:, device_code:, interval: 5, timeout: 300)`
117
+
118
+ Device responses require a nonempty device code; token responses require a
119
+ nonempty access token. Provider errors are preserved rather than wrapped as success.
120
+ Polling uses monotonic time, increases the wait by five seconds on `slow_down`,
121
+ and caps waits and request timeouts by the remaining budget. A zero timeout does
122
+ not send a request; nonpositive intervals and negative timeouts raise `ArgumentError`.
123
+
124
+ For provider-specific runners, extend the helpers you use and the common runner:
125
+
126
+ ```ruby
127
+ MyRunner.extend(Legion::Extensions::Identity::Helpers::Oauth)
128
+ MyRunner.extend(Legion::Extensions::Identity::Helpers::Pkce) # browser_login
129
+ MyRunner.extend(Legion::Extensions::Identity::Helpers::Browser) # browser_login
130
+ MyRunner.extend(Legion::Extensions::Identity::Runners::Oauth)
131
+ ```
132
+
133
+ `Helpers::Oauth#oauth_post(endpoint:, form:, open_timeout: 5, request_timeout: 15)`
134
+ provides form encoding and symbol-keyed JSON parsing for additional provider grants.
135
+ It propagates transport exceptions; the public runners log and return an explicit
136
+ failure. No token persistence, refresh-token rotation policy, or scheduled refresh
137
+ is implemented here.
138
+
139
+ Concrete provider runners can expose provider-specific operations using configured
140
+ endpoints. Do not expose these generic endpoint arguments through a public runner.
141
+ The old `legion/extensions/identity/runners/oauth` require path has been removed;
142
+ use the explicit `runners/base/oauth` path.
143
+
144
+ ## Explicit browser login
145
+
146
+ ```ruby
147
+ require 'legion/extensions/identity/runners/base/oauth'
148
+
149
+ result = Legion::Extensions::Identity::Runners::Oauth.browser_login(
150
+ authorization_endpoint: authorization_endpoint,
151
+ token_endpoint: token_endpoint,
152
+ client_id: client_id,
153
+ scope: 'openid profile',
154
+ callback_port: 0,
155
+ callback_timeout: 120
156
+ )
157
+ ```
158
+
159
+ Each call creates its own state, PKCE verifier, and listener. The runner opens the
160
+ browser, waits for a correlated authorization response, exchanges the code using
161
+ the exact redirect URI and verifier, then returns the existing token-result Hash.
162
+ Providers still own token persistence and authenticated-subject verification.
163
+
164
+ The redirect URI is `http://127.0.0.1:<port>/callback`. Port `0` requests an available
165
+ ephemeral port. If your provider requires an exact registered redirect URI, supply
166
+ its registered fixed port; the listener never binds a non-loopback interface.
167
+
168
+ The listener accepts only a complete GET callback with matching state and exactly
169
+ one nonempty code or error. Missing/mismatched state, duplicate parameters,
170
+ ambiguous code/error responses, and malformed requests are rejected without
171
+ consuming the pending login. Unrelated paths do not terminate the wait. State is
172
+ checked before accepting provider errors as well as authorization codes.
173
+
174
+ Callback waiting uses a monotonic deadline, with an 8 KiB header limit and a
175
+ two-second per-client I/O budget bounded by the remaining wait. The listener and
176
+ accepted sockets close on success, provider denial, timeout, and exceptions.
177
+ Browser-launch failure also closes the listener. The static browser response
178
+ contains no callback parameters and only confirms receipt—not token-exchange
179
+ success.
180
+
181
+ `callback_timeout` must be positive and finite. It bounds callback waiting, not
182
+ browser process startup or the subsequent token endpoint's separate HTTP timeouts.
183
+ A missing graphical session returns `browser_unavailable`; launcher failure returns
184
+ `browser_launch_failed`; an unanswered callback returns `timeout`.
185
+
186
+ This is explicit user-initiated login only. There is no automatic sign-in, persistent
187
+ session store, background actor, API-hook registration, or implicit device-code
188
+ fallback. Headless callers can use `request_device_code` and `poll_device_code`.
189
+
190
+ ## Shared provider conformance
191
+
192
+ ```ruby
193
+ require File.join(
194
+ Gem.loaded_specs.fetch('lex-identity').full_gem_path,
195
+ 'spec/support/provider_contract'
196
+ )
197
+
198
+ RSpec.describe MyProvider do
199
+ let(:identity_provider) { described_class }
200
+ let(:canonical_name) { 'example-user' }
201
+
202
+ # Configure your provider's upstream responses in the surrounding spec.
203
+ it_behaves_like 'identity provider contract'
204
+ end
205
+ ```
206
+
207
+ These examples exercise the current core resolver contract:
208
+
209
+ - Auth and fallback providers receive `resolve` without arguments.
210
+ - Profile providers receive `resolve_all(canonical_name:)` when present, otherwise
211
+ `resolve(canonical_name:)`. The selected method must return a profile Hash or
212
+ `nil`, not an account Array.
213
+ - Auth/fallback results require a valid canonical name, kind, and source.
214
+ - Profile providers cannot choose the canonical name.
215
+ - Metadata types and trust levels are checked without forcing common values.
216
+
217
+ Account enumeration is separate from the profile result consumed by core. The
218
+ shared examples do not add or change a provider's `resolve_all` implementation.
219
+
220
+ ## Development
221
+
222
+ ```bash
223
+ bundle install
224
+ bundle exec rspec --format json --out tmp/rspec_results.json --format progress --out tmp/rspec_progress.txt
225
+ bundle exec rubocop -A
226
+ ```
227
+
228
+ ## License
229
+
230
+ MIT.
data/lex-identity.gemspec CHANGED
@@ -5,26 +5,29 @@ require_relative 'lib/legion/extensions/identity/version'
5
5
  Gem::Specification.new do |spec|
6
6
  spec.name = 'lex-identity'
7
7
  spec.version = Legion::Extensions::Identity::VERSION
8
- spec.authors = ['Esity']
9
- spec.email = ['matthewdiverson@gmail.com']
8
+ spec.authors = ['LegionIO']
10
9
 
11
- spec.summary = 'LEX Identity'
12
- spec.description = 'Human partner identity modeling and behavioral entropy for brain-modeled agentic AI'
10
+ spec.summary = 'Shared LegionIO identity extension base'
11
+ spec.description = 'Base extension gem for shared behavior across LegionIO identity provider extensions.'
13
12
  spec.homepage = 'https://github.com/LegionIO/lex-identity'
14
13
  spec.license = 'MIT'
15
14
  spec.required_ruby_version = '>= 3.4'
16
15
 
17
- spec.metadata['homepage_uri'] = spec.homepage
18
- spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-identity'
19
- spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-identity'
20
- spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-identity'
21
- spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-identity/issues'
16
+ spec.metadata['homepage_uri'] = spec.homepage
17
+ spec.metadata['source_code_uri'] = spec.homepage
18
+ spec.metadata['documentation_uri'] = "#{spec.homepage}/blob/main/README.md"
19
+ spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+ spec.metadata['bug_tracker_uri'] = "#{spec.homepage}/issues"
22
21
  spec.metadata['rubygems_mfa_required'] = 'true'
23
22
 
24
23
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
- Dir.glob('{lib,spec}/**/*') + %w[lex-identity.gemspec Gemfile]
24
+ Dir.glob('{lib,spec/support}/**/*.rb') + %w[lex-identity.gemspec Gemfile LICENSE README.md CHANGELOG.md]
26
25
  end
27
26
  spec.require_paths = ['lib']
28
- spec.add_development_dependency 'sequel', '>= 5.70'
29
- spec.add_development_dependency 'sqlite3', '>= 2.0'
27
+
28
+ spec.add_dependency 'faraday', '~> 2.0'
29
+ spec.add_dependency 'legion-cache', '>= 1.3.0'
30
+ spec.add_dependency 'legion-json', '>= 1.2.1'
31
+ spec.add_dependency 'legion-logging', '>= 1.5.3'
32
+ spec.add_dependency 'legion-settings', '>= 1.3.14'
30
33
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rbconfig'
4
+ require 'legion/logging/helper'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Identity
9
+ module Helpers
10
+ module Browser
11
+ include Legion::Logging::Helper
12
+
13
+ def browser_gui_available?(**)
14
+ return true if /darwin|mswin|mingw/.match?(browser_host_os)
15
+
16
+ !ENV['DISPLAY'].nil? || !ENV['WAYLAND_DISPLAY'].nil?
17
+ end
18
+
19
+ def browser_open(url:, **)
20
+ command = case browser_host_os
21
+ when /darwin/ then 'open'
22
+ when /linux/ then 'xdg-open'
23
+ when /mswin|mingw/ then 'start'
24
+ end
25
+ unless command
26
+ log.warn('No browser command is available for this operating system')
27
+ return false
28
+ end
29
+
30
+ opened = system(command, url) == true
31
+ log.warn('Browser launch failed') unless opened
32
+ opened
33
+ end
34
+
35
+ private
36
+
37
+ def browser_host_os
38
+ RbConfig::CONFIG['host_os']
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'uri'
5
+ require 'legion/logging/helper'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module Identity
10
+ module Helpers
11
+ class CallbackServer
12
+ include Legion::Logging::Helper
13
+
14
+ HEADER_LIMIT = 8192
15
+ CLIENT_TIMEOUT = 2
16
+ RESPONSE_BODY = "Callback received. Return to the application.\n"
17
+
18
+ attr_reader :redirect_uri
19
+
20
+ def initialize(port: 0, **)
21
+ raise ArgumentError, 'port must be an integer between 0 and 65535' unless port.is_a?(Integer) && (0..65_535).cover?(port)
22
+
23
+ @listener = TCPServer.new('127.0.0.1', port)
24
+ @redirect_uri = "http://127.0.0.1:#{@listener.addr[1]}/callback"
25
+ end
26
+
27
+ def wait_for_callback(state:, timeout: 120, **)
28
+ raise ArgumentError, 'timeout must be finite and nonnegative' unless timeout.is_a?(Numeric) && timeout.real? && timeout.finite? && timeout >= 0
29
+ raise ArgumentError, 'state must be a nonempty string' unless state.is_a?(String) && !state.empty?
30
+ raise IOError, 'callback server is closed' if @listener.closed?
31
+
32
+ deadline = callback_now + timeout
33
+ while callback_wait(io: @listener, deadline: deadline)
34
+ @client = @listener.accept_nonblock(exception: false)
35
+ next if @client == :wait_readable
36
+
37
+ result = callback_receive(client: @client, state: state, deadline: [deadline, callback_now + CLIENT_TIMEOUT].min)
38
+ return result if result
39
+ end
40
+ { error: 'timeout', description: 'Timed out waiting for OAuth callback' }
41
+ rescue IOError, SystemCallError => e
42
+ handle_exception(e, level: :warn, operation: 'identity.callback.wait')
43
+ { error: 'callback_failed', description: 'OAuth callback listener failed' }
44
+ ensure
45
+ shutdown
46
+ end
47
+
48
+ def shutdown(**)
49
+ callback_close(socket: @client) if @client.is_a?(IO)
50
+ callback_close(socket: @listener)
51
+ nil
52
+ end
53
+
54
+ private
55
+
56
+ def callback_now
57
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
58
+ end
59
+
60
+ def callback_wait(io:, deadline:, writable: false)
61
+ remaining = deadline - callback_now
62
+ return false unless remaining.positive?
63
+
64
+ readers, writers = writable ? [nil, [io]] : [[io], nil]
65
+ !IO.select(readers, writers, nil, remaining).nil?
66
+ end
67
+
68
+ def callback_receive(client:, state:, deadline:)
69
+ header = callback_read_header(client: client, deadline: deadline)
70
+ status, result = header ? callback_parse(header: header, state: state) : [400, nil]
71
+ callback_respond(client: client, status: status, deadline: deadline)
72
+ result
73
+ rescue IOError, SystemCallError => e
74
+ handle_exception(e, level: :warn, operation: 'identity.callback.receive')
75
+ nil
76
+ ensure
77
+ callback_close(socket: client)
78
+ end
79
+
80
+ def callback_read_header(client:, deadline:)
81
+ header = +''.b
82
+ while header.bytesize < HEADER_LIMIT && callback_wait(io: client, deadline: deadline)
83
+ chunk = client.read_nonblock(HEADER_LIMIT - header.bytesize, exception: false)
84
+ next if chunk == :wait_readable
85
+ return if chunk.nil?
86
+
87
+ header << chunk
88
+ return header if header.include?("\r\n\r\n")
89
+ end
90
+ nil
91
+ end
92
+
93
+ def callback_parse(header:, state:)
94
+ return [400, nil] unless header.end_with?("\r\n\r\n")
95
+
96
+ request, *headers = header.delete_suffix("\r\n\r\n").split("\r\n")
97
+ match = %r{\AGET (/[^\s]*) HTTP/1\.[01]\z}.match(request.to_s)
98
+ return [400, nil] unless match && callback_headers_valid?(headers: headers)
99
+
100
+ target = match[1]
101
+ return [400, nil] if target.start_with?('//') || /[^\x21-\x7e]|%(?![0-9a-fA-F]{2})/.match?(target)
102
+
103
+ uri = URI.parse(target)
104
+ return [400, nil] if uri.host || uri.fragment
105
+ return [404, nil] unless uri.path == '/callback'
106
+
107
+ callback_result(query: uri.query.to_s, state: state)
108
+ rescue URI::InvalidURIError, ArgumentError => e
109
+ sanitized = ArgumentError.new('Rejected malformed OAuth callback request')
110
+ sanitized.set_backtrace(e.backtrace)
111
+ handle_exception(sanitized, level: :warn, operation: 'identity.callback.parse')
112
+ [400, nil]
113
+ end
114
+
115
+ def callback_headers_valid?(headers:)
116
+ headers.all? do |header|
117
+ match = /\A([!#$%&'*+.^_`|~0-9A-Za-z-]+):[\t\x20-\x7e]*\z/.match(header)
118
+ next false unless match
119
+
120
+ name = match[1].downcase
121
+ next false if name == 'transfer-encoding'
122
+ next false if name == 'content-length' && header.split(':', 2).last.strip != '0'
123
+
124
+ true
125
+ end
126
+ end
127
+
128
+ def callback_result(query:, state:)
129
+ pairs = URI.decode_www_form(query, Encoding::UTF_8)
130
+ params = pairs.to_h
131
+ return [400, nil] if pairs.length != params.length || params['state'] != state
132
+ return [400, nil] if params.key?('code') == params.key?('error')
133
+
134
+ if params.key?('code')
135
+ return [400, nil] if params['code'].empty? || params.key?('error_description') || params.key?('error_uri')
136
+
137
+ [200, { code: params['code'] }]
138
+ else
139
+ return [400, nil] if params['error'].empty?
140
+
141
+ [200, { error: params['error'], description: params.fetch('error_description', '') }]
142
+ end
143
+ end
144
+
145
+ def callback_respond(client:, status:, deadline:)
146
+ reason = { 200 => 'OK', 400 => 'Bad Request', 404 => 'Not Found' }.fetch(status)
147
+ body = status == 200 ? RESPONSE_BODY : "Callback request not accepted.\n"
148
+ response = "HTTP/1.1 #{status} #{reason}\r\nContent-Type: text/plain; charset=utf-8\r\n" \
149
+ "Cache-Control: no-store\r\nConnection: close\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}"
150
+ offset = 0
151
+ while offset < response.bytesize && callback_wait(io: client, deadline: deadline, writable: true)
152
+ written = client.write_nonblock(response.byteslice(offset..), exception: false)
153
+ next if written == :wait_writable
154
+
155
+ offset += written
156
+ end
157
+ rescue IOError, SystemCallError => e
158
+ handle_exception(e, level: :warn, operation: 'identity.callback.respond')
159
+ end
160
+
161
+ def callback_close(socket:)
162
+ socket.close unless socket.closed?
163
+ rescue IOError, SystemCallError => e
164
+ handle_exception(e, level: :warn, operation: 'identity.callback.close')
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'faraday'
5
+ require 'legion/json'
6
+ require 'legion/json/helper'
7
+ require 'legion/logging'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module Identity
12
+ module Helpers
13
+ module Oauth
14
+ include Legion::Logging::Helper
15
+ include Legion::JSON::Helper
16
+
17
+ def oauth_authorize_url(authorization_endpoint:, client_id:, redirect_uri:, scope:, state:, code_challenge:,
18
+ code_challenge_method: 'S256', **)
19
+ uri = URI.parse(authorization_endpoint)
20
+ params = {
21
+ 'client_id' => client_id, 'response_type' => 'code', 'redirect_uri' => redirect_uri,
22
+ 'scope' => scope, 'state' => state, 'code_challenge' => code_challenge,
23
+ 'code_challenge_method' => code_challenge_method
24
+ }
25
+ query = URI.decode_www_form(uri.query.to_s).reject { |key, _value| params.key?(key) }
26
+ uri.query = URI.encode_www_form(query + params.to_a)
27
+ uri.to_s
28
+ end
29
+
30
+ def oauth_post(endpoint:, form:, open_timeout: 5, request_timeout: 15, **)
31
+ connection = Faraday.new do |http|
32
+ http.headers['Accept'] = 'application/json'
33
+ http.headers['Content-Type'] = 'application/x-www-form-urlencoded'
34
+ http.options.open_timeout = open_timeout
35
+ http.options.timeout = request_timeout
36
+ end
37
+ response = connection.post(endpoint, URI.encode_www_form(form))
38
+ oauth_parse_response(response: response)
39
+ end
40
+
41
+ private
42
+
43
+ def oauth_parse_response(response:)
44
+ body = response.body.to_s.empty? ? {} : json_load(response.body)
45
+ return body if body.is_a?(Hash) && body[:error]
46
+ return { error: "http_#{response.status}", error_description: response.reason_phrase.to_s } unless response.success?
47
+ return body if body.is_a?(Hash)
48
+
49
+ { error: 'invalid_response', error_description: 'OAuth response must be a JSON object' }
50
+ rescue Legion::JSON::ParseError => e
51
+ sanitized = StandardError.new("OAuth response parsing failed (#{e.class})")
52
+ sanitized.set_backtrace(e.backtrace)
53
+ handle_exception(sanitized, level: :error, operation: 'identity.oauth_parse_response')
54
+ return { error: "http_#{response.status}", error_description: response.reason_phrase.to_s } unless response.success?
55
+
56
+ { error: 'invalid_response', error_description: 'OAuth response is not valid JSON' }
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'securerandom'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Identity
9
+ module Helpers
10
+ module Pkce
11
+ def pkce_generate(**)
12
+ verifier = SecureRandom.hex(32)
13
+ challenge = [Digest::SHA256.digest(verifier)].pack('m0').tr('+/', '-_').delete('=')
14
+ { code_verifier: verifier, code_challenge: challenge, code_challenge_method: 'S256' }
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end