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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 16599e1c095434bfb65c5a5aa763cb821324e192f8e367cad1aff64fadec7e10
4
- data.tar.gz: 2747877e468f15212ed3bbd19eaa287323d159a676a01e80e6b1ca11e0abed9b
3
+ metadata.gz: 7915294130831737224c3d34264ee4687251f338b30ba1703ec5649cd045fff2
4
+ data.tar.gz: 2561c25ca2abfd8bd8cf6a154b29122f6157b8785ed376509b23b4a3b2fc38d2
5
5
  SHA512:
6
- metadata.gz: e522c7f831673baa38cf887b1493e78e7133d930f3e857b82fefddbeeffbcc88877e4d741330a222e744170bd0e5876ec08deca5c8a00d8dc6cc520fbd0f54dc
7
- data.tar.gz: 802b45c5a8d13d79f0c52ac872080a6fde0e5b64bc7ee0f46a21fbdb5be56c23ddad2f6b242b535b9a862e13b446735cb57c44ac50a9a0b021a728c620defe20
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].freeze
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.4.44'
4
+ VERSION = '1.4.45'
5
5
  end
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.44
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