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
data/README.md
DELETED
|
@@ -1,230 +0,0 @@
|
|
|
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.
|
|
@@ -1,44 +0,0 @@
|
|
|
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
|
|
@@ -1,170 +0,0 @@
|
|
|
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
|
|
@@ -1,62 +0,0 @@
|
|
|
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
|
|
@@ -1,20 +0,0 @@
|
|
|
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
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Legion
|
|
4
|
-
module Extensions
|
|
5
|
-
module Identity
|
|
6
|
-
module Helpers
|
|
7
|
-
module Provider
|
|
8
|
-
def identity_register(**)
|
|
9
|
-
if defined?(Legion::Identity::Resolver) && Legion::Identity::Resolver.respond_to?(:register)
|
|
10
|
-
Legion::Identity::Resolver.register(self)
|
|
11
|
-
:registered
|
|
12
|
-
elsif defined?(Legion::Identity) &&
|
|
13
|
-
Legion::Identity.respond_to?(:pending_registrations) &&
|
|
14
|
-
Legion::Identity.pending_registrations.respond_to?(:<<)
|
|
15
|
-
Legion::Identity.pending_registrations << self
|
|
16
|
-
:pending
|
|
17
|
-
else
|
|
18
|
-
:unavailable
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def identity_build_lease(credential:, **attributes)
|
|
23
|
-
attributes = attributes.merge(provider: provider_name, credential: credential)
|
|
24
|
-
return attributes unless defined?(Legion::Identity::Lease)
|
|
25
|
-
|
|
26
|
-
Legion::Identity::Lease.new(**attributes)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def identity_register_credential(lease:, qualifier: :default, default: false, **)
|
|
30
|
-
unless defined?(Legion::Identity::Broker) && Legion::Identity::Broker.respond_to?(:register_provider)
|
|
31
|
-
raise 'Credential registration requires Legion::Identity::Broker'
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
Legion::Identity::Broker.register_provider(provider_name, provider: self, lease: lease, qualifier: qualifier, default: default)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def identity_credentials_available(**)
|
|
38
|
-
return [] unless defined?(Legion::Identity::Broker)
|
|
39
|
-
|
|
40
|
-
Legion::Identity::Broker.credentials_available(provider_name)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def identity_token_for(qualifier: nil, **)
|
|
44
|
-
return nil unless defined?(Legion::Identity::Broker)
|
|
45
|
-
|
|
46
|
-
Legion::Identity::Broker.token_for(provider_name, qualifier: qualifier, **)
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|