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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/Gemfile +6 -3
- data/LICENSE +21 -0
- data/README.md +230 -0
- data/lex-identity.gemspec +15 -12
- data/lib/legion/extensions/identity/helpers/browser.rb +44 -0
- data/lib/legion/extensions/identity/helpers/callback_server.rb +170 -0
- data/lib/legion/extensions/identity/helpers/oauth.rb +62 -0
- data/lib/legion/extensions/identity/helpers/pkce.rb +20 -0
- data/lib/legion/extensions/identity/helpers/provider.rb +52 -0
- data/lib/legion/extensions/identity/runners/base/oauth.rb +140 -0
- data/lib/legion/extensions/identity/version.rb +1 -1
- data/lib/legion/extensions/identity.rb +8 -14
- data/spec/support/provider_contract.rb +49 -0
- metadata +67 -33
- data/lib/legion/extensions/identity/actors/orphan_check.rb +0 -48
- data/lib/legion/extensions/identity/client.rb +0 -23
- data/lib/legion/extensions/identity/helpers/dimensions.rb +0 -71
- data/lib/legion/extensions/identity/helpers/fingerprint.rb +0 -166
- data/lib/legion/extensions/identity/helpers/vault_secrets.rb +0 -76
- data/lib/legion/extensions/identity/local_migrations/20260316000030_create_fingerprint.rb +0 -20
- data/lib/legion/extensions/identity/runners/entra.rb +0 -223
- data/lib/legion/extensions/identity/runners/identity.rb +0 -86
- data/spec/legion/extensions/identity/actors/orphan_check_spec.rb +0 -104
- data/spec/legion/extensions/identity/client_spec.rb +0 -32
- data/spec/legion/extensions/identity/helpers/dimensions_spec.rb +0 -51
- data/spec/legion/extensions/identity/helpers/fingerprint_spec.rb +0 -66
- data/spec/legion/extensions/identity/runners/entra_spec.rb +0 -405
- data/spec/legion/extensions/identity/runners/identity_spec.rb +0 -61
- data/spec/local_persistence_spec.rb +0 -329
- data/spec/spec_helper.rb +0 -33
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 18e33b5500f9d9b8ea10ec64025f01c80ecf3e31587c1d0933fe12f0a6b5a951
|
|
4
|
+
data.tar.gz: e3545bf086d36d22f7001ac6fbb523396b6380311d5c2a06aaabd7a22affdf90
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
8
|
-
gem '
|
|
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 = ['
|
|
9
|
-
spec.email = ['matthewdiverson@gmail.com']
|
|
8
|
+
spec.authors = ['LegionIO']
|
|
10
9
|
|
|
11
|
-
spec.summary = '
|
|
12
|
-
spec.description = '
|
|
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']
|
|
18
|
-
spec.metadata['source_code_uri']
|
|
19
|
-
spec.metadata['documentation_uri']
|
|
20
|
-
spec.metadata['changelog_uri']
|
|
21
|
-
spec.metadata['bug_tracker_uri']
|
|
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}
|
|
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
|
-
|
|
29
|
-
spec.
|
|
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
|