legionio 1.4.44 → 1.4.45
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/.rubocop.yml +4 -0
- data/CHANGELOG.md +7 -0
- data/lib/legion/api/auth_human.rb +134 -0
- data/lib/legion/api/middleware/auth.rb +2 -1
- data/lib/legion/api.rb +2 -0
- data/lib/legion/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7915294130831737224c3d34264ee4687251f338b30ba1703ec5649cd045fff2
|
|
4
|
+
data.tar.gz: 2561c25ca2abfd8bd8cf6a154b29122f6157b8785ed376509b23b4a3b2fc38d2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2129142bd86c0a7b39686d4f4c025eda3139296d1ee3a2d44ae5c80e67aa4ef85d5cbcca8424188028136cc1438946c5c6e717cb511b90d224394d49aa88d37a
|
|
7
|
+
data.tar.gz: e77f6aca567e9b3f6ba08509b928237771e85ff4433dc767ef24cf3f0113e8dda97d98491efab37846c5265657c5bf8071a41ec1cdbc4e791d58c88dc67b7be1
|
data/.rubocop.yml
CHANGED
|
@@ -38,6 +38,7 @@ Metrics/BlockLength:
|
|
|
38
38
|
- 'lib/legion/cli/update_command.rb'
|
|
39
39
|
- 'lib/legion/api/auth.rb'
|
|
40
40
|
- 'lib/legion/api/auth_worker.rb'
|
|
41
|
+
- 'lib/legion/api/auth_human.rb'
|
|
41
42
|
|
|
42
43
|
Metrics/AbcSize:
|
|
43
44
|
Max: 60
|
|
@@ -48,9 +49,12 @@ Metrics/CyclomaticComplexity:
|
|
|
48
49
|
Max: 15
|
|
49
50
|
Exclude:
|
|
50
51
|
- 'lib/legion/cli/chat_command.rb'
|
|
52
|
+
- 'lib/legion/api/auth_human.rb'
|
|
51
53
|
|
|
52
54
|
Metrics/PerceivedComplexity:
|
|
53
55
|
Max: 17
|
|
56
|
+
Exclude:
|
|
57
|
+
- 'lib/legion/api/auth_human.rb'
|
|
54
58
|
|
|
55
59
|
Style/Documentation:
|
|
56
60
|
Enabled: false
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Legion Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.45] - 2026-03-17
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `GET /api/auth/authorize`: redirects to Entra authorization endpoint for browser-based OAuth2 login
|
|
7
|
+
- `GET /api/auth/callback`: exchanges authorization code for tokens, validates id_token via JWKS, maps claims, issues Legion human JWT
|
|
8
|
+
- Auth middleware SKIP_PATHS now includes `/api/auth/authorize` and `/api/auth/callback`
|
|
9
|
+
|
|
3
10
|
## [1.4.44] - 2026-03-17
|
|
4
11
|
|
|
5
12
|
### Added
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
class API < Sinatra::Base
|
|
8
|
+
module Routes
|
|
9
|
+
module AuthHuman
|
|
10
|
+
def self.registered(app)
|
|
11
|
+
register_authorize(app)
|
|
12
|
+
register_callback(app)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.resolve_entra_settings
|
|
16
|
+
return {} unless defined?(Legion::Settings)
|
|
17
|
+
|
|
18
|
+
rbac = Legion::Settings[:rbac]
|
|
19
|
+
entra = rbac.is_a?(Hash) ? rbac[:entra] : nil
|
|
20
|
+
return entra if entra.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
{}
|
|
23
|
+
rescue StandardError
|
|
24
|
+
{}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.exchange_code(entra, code)
|
|
28
|
+
uri = URI("https://login.microsoftonline.com/#{entra[:tenant_id]}/oauth2/v2.0/token")
|
|
29
|
+
response = Net::HTTP.post_form(uri, {
|
|
30
|
+
'client_id' => entra[:client_id],
|
|
31
|
+
'client_secret' => entra[:client_secret],
|
|
32
|
+
'code' => code,
|
|
33
|
+
'redirect_uri' => entra[:redirect_uri],
|
|
34
|
+
'grant_type' => 'authorization_code'
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
38
|
+
|
|
39
|
+
Legion::JSON.load(response.body)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.register_authorize(app)
|
|
45
|
+
app.get '/api/auth/authorize' do
|
|
46
|
+
entra = Routes::AuthHuman.resolve_entra_settings
|
|
47
|
+
halt 500, json_error('entra_not_configured', 'Entra OAuth settings are missing', status_code: 500) unless entra[:tenant_id] && entra[:client_id]
|
|
48
|
+
|
|
49
|
+
state = Legion::Crypt::JWT.issue(
|
|
50
|
+
{ nonce: SecureRandom.hex(16), purpose: 'oauth_state' },
|
|
51
|
+
ttl: 300
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
query = URI.encode_www_form({
|
|
55
|
+
'client_id' => entra[:client_id],
|
|
56
|
+
'redirect_uri' => entra[:redirect_uri],
|
|
57
|
+
'response_type' => 'code',
|
|
58
|
+
'scope' => 'openid profile',
|
|
59
|
+
'state' => state
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
redirect "https://login.microsoftonline.com/#{entra[:tenant_id]}/oauth2/v2.0/authorize?#{query}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.register_callback(app) # rubocop:disable Metrics/AbcSize
|
|
67
|
+
app.get '/api/auth/callback' do
|
|
68
|
+
entra = Routes::AuthHuman.resolve_entra_settings
|
|
69
|
+
halt 500, json_error('entra_not_configured', 'Entra OAuth settings are missing', status_code: 500) unless entra[:tenant_id] && entra[:client_id]
|
|
70
|
+
|
|
71
|
+
halt 400, json_error('oauth_error', params[:error_description] || params[:error], status_code: 400) if params[:error]
|
|
72
|
+
halt 400, json_error('missing_code', 'authorization code is required', status_code: 400) unless params[:code]
|
|
73
|
+
|
|
74
|
+
if params[:state]
|
|
75
|
+
begin
|
|
76
|
+
Legion::Crypt::JWT.verify(params[:state])
|
|
77
|
+
rescue Legion::Crypt::JWT::Error
|
|
78
|
+
halt 400, json_error('invalid_state', 'CSRF state token is invalid or expired', status_code: 400)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
token_response = Routes::AuthHuman.exchange_code(entra, params[:code])
|
|
83
|
+
halt 502, json_error('token_exchange_failed', 'Failed to exchange code for tokens', status_code: 502) unless token_response
|
|
84
|
+
|
|
85
|
+
id_token = token_response[:id_token] || token_response['id_token']
|
|
86
|
+
halt 502, json_error('no_id_token', 'Entra did not return an id_token', status_code: 502) unless id_token
|
|
87
|
+
|
|
88
|
+
jwks_url = "https://login.microsoftonline.com/#{entra[:tenant_id]}/discovery/v2.0/keys"
|
|
89
|
+
issuer = "https://login.microsoftonline.com/#{entra[:tenant_id]}/v2.0"
|
|
90
|
+
|
|
91
|
+
begin
|
|
92
|
+
claims = Legion::Crypt::JWT.verify_with_jwks(id_token, jwks_url: jwks_url, issuers: [issuer])
|
|
93
|
+
rescue Legion::Crypt::JWT::Error => e
|
|
94
|
+
halt 401, json_error('invalid_id_token', e.message, status_code: 401)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
unless defined?(Legion::Rbac::EntraClaimsMapper)
|
|
98
|
+
halt 501, json_error('claims_mapper_not_available', 'EntraClaimsMapper is not loaded', status_code: 501)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
mapped = Legion::Rbac::EntraClaimsMapper.map_claims(
|
|
102
|
+
claims,
|
|
103
|
+
role_map: entra[:role_map] || Legion::Rbac::EntraClaimsMapper::DEFAULT_ROLE_MAP,
|
|
104
|
+
group_map: entra[:group_map] || {},
|
|
105
|
+
default_role: entra[:default_role] || 'worker'
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
ttl = 28_800
|
|
109
|
+
token = Legion::API::Token.issue_human_token(
|
|
110
|
+
msid: mapped[:sub], name: mapped[:name], roles: mapped[:roles], ttl: ttl
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if request.env['HTTP_ACCEPT']&.include?('application/json')
|
|
114
|
+
json_response({
|
|
115
|
+
access_token: token,
|
|
116
|
+
token_type: 'Bearer',
|
|
117
|
+
expires_in: ttl,
|
|
118
|
+
roles: mapped[:roles],
|
|
119
|
+
name: mapped[:name]
|
|
120
|
+
})
|
|
121
|
+
else
|
|
122
|
+
redirect_url = entra[:success_redirect] || '/api/health'
|
|
123
|
+
redirect "#{redirect_url}#access_token=#{token}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
class << self
|
|
129
|
+
private :register_authorize, :register_callback
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -4,7 +4,8 @@ module Legion
|
|
|
4
4
|
class API < Sinatra::Base
|
|
5
5
|
module Middleware
|
|
6
6
|
class Auth
|
|
7
|
-
SKIP_PATHS = %w[/api/health /api/ready /api/openapi.json /metrics /api/auth/token /api/auth/worker-token
|
|
7
|
+
SKIP_PATHS = %w[/api/health /api/ready /api/openapi.json /metrics /api/auth/token /api/auth/worker-token
|
|
8
|
+
/api/auth/authorize /api/auth/callback].freeze
|
|
8
9
|
AUTH_HEADER = 'HTTP_AUTHORIZATION'
|
|
9
10
|
BEARER_PATTERN = /\ABearer\s+(.+)\z/i
|
|
10
11
|
API_KEY_HEADER = 'HTTP_X_API_KEY'
|
data/lib/legion/api.rb
CHANGED
|
@@ -28,6 +28,7 @@ require_relative 'api/openapi'
|
|
|
28
28
|
require_relative 'api/rbac'
|
|
29
29
|
require_relative 'api/auth'
|
|
30
30
|
require_relative 'api/auth_worker'
|
|
31
|
+
require_relative 'api/auth_human'
|
|
31
32
|
require_relative 'api/audit'
|
|
32
33
|
require_relative 'api/metrics'
|
|
33
34
|
|
|
@@ -100,6 +101,7 @@ module Legion
|
|
|
100
101
|
register Routes::Rbac
|
|
101
102
|
register Routes::Auth
|
|
102
103
|
register Routes::AuthWorker
|
|
104
|
+
register Routes::AuthHuman
|
|
103
105
|
register Routes::Audit
|
|
104
106
|
register Routes::Metrics
|
|
105
107
|
|
data/lib/legion/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: legionio
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.4.
|
|
4
|
+
version: 1.4.45
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -318,6 +318,7 @@ files:
|
|
|
318
318
|
- lib/legion/api.rb
|
|
319
319
|
- lib/legion/api/audit.rb
|
|
320
320
|
- lib/legion/api/auth.rb
|
|
321
|
+
- lib/legion/api/auth_human.rb
|
|
321
322
|
- lib/legion/api/auth_worker.rb
|
|
322
323
|
- lib/legion/api/chains.rb
|
|
323
324
|
- lib/legion/api/coldstart.rb
|