Skip to content
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
# Legion Changelog

## [1.9.48] - 2026-07-02

### Security
- API: fixed an authentication bypass in the auth middleware's `skip_path?` (CWE-284). It used bare prefix matching (`start_with?`), so any path sharing a prefix with an unauthenticated route bypassed auth — e.g. `/api/healthily_fake`, `/api/health/admin/export`, `/api/auth/tokens_dump` all matched `/api/health` / `/api/auth/token`. Now uses segment-bounded matching (exact path or `/`-delimited sub-path) via precompiled `SKIP_PATTERNS`, so only the intended paths and their legitimate sub-paths skip auth (fixes #209)

## [1.9.47] - 2026-07-02

### Fixed
- API: `GET /api/health` now reflects subsystem health instead of always returning `200 ok`. It reports `degraded` with HTTP `503` when an **enabled and previously-healthy** subsystem (transport, cache, data) has broken — e.g. transport `session_open: false` after a connection recovery failure — and includes a per-component `components` breakdown (`enabled`/`healthy`/`detail`). A subsystem only degrades health if it was marked ready at boot (via `Legion::Readiness`); disabled or still-booting subsystems never fail the check. This makes the endpoint usable for load balancers and monitoring (fixes #194)

## [1.9.46] - 2026-07-02

### Fixed
- API: `GET /api/extensions/tools` no longer 500s with `undefined method 'filter_tool_entries'`. The route block runs in the Sinatra instance context but `filter_tool_entries`/`serialize_tool_entry` are class methods on `Routes::Extensions`; they are now called on the explicit receiver, matching the pattern used by the other catalog routes. Added request specs for `/api/extensions/tools` (fixes #227)

## [1.9.45] - 2026-07-02

### Fixed
- API: Teams delegated OAuth callback now forwards `tenant_id`/`client_id` to `Entra::Helpers::TokenManager.save_token(:delegated, …)` so browser-login tokens can be refreshed (previously omitted, forcing refresh to fall back to settings and silently fail for delegated-only logins). The unshipped `TokenCache` `require` that 500'd the callback was already removed in 1.9.43 (fixes #212)

## [1.9.44] - 2026-07-01

### Fixed
- CLI: `connect status` now reads the Entra `TokenManager` (`token_data(:delegated)` + `expired?`) for the `microsoft` provider instead of the legacy `Legion::Auth::TokenManager` secret store, which the delegated/Teams login never writes to — previously always reported `microsoft: not connected` after a successful login (fixes #213)
- CLI: `connect microsoft` now forwards `--tenant_id`/`--client_id`/`--scope` (as `--scopes`) explicitly to the Teams auth flow instead of dropping flag values via `ARGV.select`

## [1.9.43] - 2026-06-25

### Fixed
Expand Down
7 changes: 6 additions & 1 deletion lib/legion/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require_relative 'events'
require_relative 'readiness'
require_relative 'api/default_settings'
require_relative 'api/health'

require_relative 'api/middleware/auth'
require_relative 'api/middleware/body_limit'
Expand Down Expand Up @@ -107,7 +108,11 @@ class API < Sinatra::Base
# Health and readiness
get '/api/health' do
uptime_seconds = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - START_TIME).to_i
json_response({ status: 'ok', version: Legion::VERSION, uptime_seconds: uptime_seconds, uptime: uptime_seconds })
assessment = Legion::API::Health.assess
json_response({ status: assessment[:status], version: Legion::VERSION,
uptime_seconds: uptime_seconds, uptime: uptime_seconds,
components: assessment[:components] },
status_code: assessment[:status] == 'ok' ? 200 : 503)
end

get '/api/ready' do
Expand Down
11 changes: 7 additions & 4 deletions lib/legion/api/auth_teams.rb
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,9 @@ def self.register_callback(app)
token_body = Legion::JSON.load(token_response.body)

if token_body[:access_token]
# Store token via TokenCache if available
store_teams_token(token_body, entry[:scopes])
# Persist via the Entra TokenManager (the store the read path consults)
store_teams_token(token_body, entry[:scopes],
tenant_id: entry[:tenant_id], client_id: entry[:client_id])
AuthTeams.mutex.synchronize { entry[:result] = { authenticated: true } }
content_type :html
'<html><body><h2>Authentication successful!</h2><p>You can close this tab.</p></body></html>'
Expand All @@ -128,14 +129,16 @@ def self.register_callback(app)
end

module TeamsTokenHelper
def store_teams_token(token_body, scopes)
def store_teams_token(token_body, scopes, tenant_id: nil, client_id: nil)
require 'legion/extensions/identity/entra/helpers/token_manager'
Legion::Extensions::Identity::Entra::Helpers::TokenManager.save_token(
:delegated,
access_token: token_body[:access_token],
refresh_token: token_body[:refresh_token],
expires_in: token_body[:expires_in] || 3600,
scopes: scopes
scopes: scopes,
tenant_id: tenant_id,
client_id: client_id
)
Legion::Logging.info 'Teams delegated token stored' if defined?(Legion::Logging)
rescue StandardError => e
Expand Down
4 changes: 2 additions & 2 deletions lib/legion/api/extensions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ def self.register_loaded_summary_route(app)

def self.register_tools_route(app)
app.get '/api/extensions/tools' do
entries = filter_tool_entries(Array(Legion::Settings::Extensions.tools), params)
tools = entries.map { |e| serialize_tool_entry(e) }
entries = Routes::Extensions.filter_tool_entries(Array(Legion::Settings::Extensions.tools), params)
tools = entries.map { |e| Routes::Extensions.serialize_tool_entry(e) }
json_response({ total: tools.size, tools: tools })
end
end
Expand Down
73 changes: 73 additions & 0 deletions lib/legion/api/health.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# frozen_string_literal: true

module Legion
class API < Sinatra::Base
# Subsystem health assessment for GET /api/health.
#
# A component degrades health ONLY if it is both:
# 1. enabled — Legion::Readiness.status[c] == true (Service marks a
# component ready only when its config flag is on AND setup succeeded).
# :skipped (disabled) and false/nil (never booted / still booting) are
# NOT enabled-and-previously-healthy, so they never fail health.
# 2. currently unhealthy — its live liveness check now returns false.
#
# This prevents both false negatives (200 while transport can't process
# messages) and false positives (503 for a subsystem that was never
# configured or hasn't finished booting).
module Health
COMPONENTS = %i[transport cache data].freeze

class << self
# Returns { status:, components: { name => { enabled:, healthy:, detail? } } }
def assess
components = COMPONENTS.to_h { |name| [name, component_health(name)] }
degraded = components.any? { |_name, c| c[:enabled] && c[:healthy] == false }
{ status: degraded ? 'degraded' : 'ok', components: components }
end

def component_health(name)
enabled = Legion::Readiness.status[name] == true
return { enabled: false, healthy: nil } unless enabled

healthy, detail = send("#{name}_liveness")
info = { enabled: true, healthy: healthy }
info[:detail] = detail if detail && !healthy
info
end

# --- liveness checks: [healthy_boolean, detail_string_or_nil] ---

def transport_liveness
conn = Legion::Transport::Connection
return [true, nil] if conn.respond_to?(:lite_mode?) && conn.lite_mode?

session = conn.session
open = session.respond_to?(:open?) && session.open?
[open, open ? nil : 'session_open: false']
rescue StandardError => e
[false, e.message]
end

def cache_liveness
return [true, nil] unless defined?(Legion::Cache)

connected = Legion::Cache.connected?
[connected, connected ? nil : 'cache not connected']
rescue StandardError => e
[false, e.message]
end

def data_liveness
connected = begin
Legion::Settings[:data][:connected] == true
rescue StandardError
false
end
[connected, connected ? nil : 'data not connected']
rescue StandardError => e
[false, e.message]
end
end
end
end
end
5 changes: 4 additions & 1 deletion lib/legion/api/middleware/auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ module Middleware
class Auth
SKIP_PATHS = %w[/api/health /api/ready /api/openapi.json /metrics /api/auth/token /api/auth/worker-token
/api/auth/authorize /api/auth/callback /api/auth/negotiate].freeze
# Match a skip path exactly or as a bounded sub-path (segment boundary),
# NOT as a bare prefix — `/api/healthily_fake` must not match `/api/health`.
SKIP_PATTERNS = SKIP_PATHS.map { |p| %r{\A#{Regexp.escape(p)}(?:/|\z)} }.freeze
AUTH_HEADER = 'HTTP_AUTHORIZATION'
BEARER_PATTERN = /\ABearer\s+(.+)\z/i
NEGOTIATE_PATTERN = /\ANegotiate\s+(.+)\z/i
Expand Down Expand Up @@ -81,7 +84,7 @@ def try_negotiate(env)
end

def skip_path?(path)
SKIP_PATHS.any? { |p| path.start_with?(p) }
SKIP_PATTERNS.any? { |re| re.match?(path) }
end

def extract_negotiate_token(env)
Expand Down
10 changes: 7 additions & 3 deletions lib/legion/api/openapi.rb
Original file line number Diff line number Diff line change
Expand Up @@ -432,19 +432,23 @@ def self.health_paths
get: {
tags: ['Health'],
summary: 'Health check',
description: 'Returns ok status and version. Skips auth middleware.',
description: 'Returns ok/degraded status, version, and per-subsystem component health. ' \
'Returns 503 when an enabled, previously-healthy subsystem (transport, cache, data) ' \
'has degraded. Skips auth middleware.',
operationId: 'getHealth',
security: [],
responses: {
'200' => ok_response('Healthy', wrap_data('TaskObject').merge(
properties: {
data: {
type: 'object',
properties: { status: { type: 'string', example: 'ok' }, version: { type: 'string' } }
properties: { status: { type: 'string', example: 'ok' }, version: { type: 'string' },
components: { type: 'object' } }
},
meta: { '$ref' => '#/components/schemas/Meta' }
}
))
)),
'503' => { description: 'Degraded — an enabled subsystem has broken' }
}
}
},
Expand Down
43 changes: 34 additions & 9 deletions lib/legion/cli/connect_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class ConnectCommand < Thor
namespace :connect

PROVIDERS = %w[microsoft github google].freeze
STATE_COLORS = { 'connected' => :green, 'revoked' => :red, 'not connected' => :yellow }.freeze

desc 'microsoft', 'Connect a Microsoft account (OAuth2 delegated auth)'
method_option :tenant_id, type: :string, desc: 'Azure tenant ID'
Expand All @@ -17,7 +18,11 @@ class ConnectCommand < Thor
method_option :no_browser, type: :boolean, default: false, desc: 'Print URL instead of launching browser'
def microsoft
say 'Delegating to Teams OAuth2 browser auth...', :blue
Legion::CLI::Auth.start(['teams'] + ARGV.select { |a| a.start_with?('--') })
forwarded = ['teams']
forwarded += ['--tenant_id', options[:tenant_id]] if options[:tenant_id]
forwarded += ['--client_id', options[:client_id]] if options[:client_id]
forwarded += ['--scopes', options[:scope]] if options[:scope]
Legion::CLI::Auth.start(forwarded)
end

desc 'github', 'Connect a GitHub account (OAuth2 device flow)'
Expand All @@ -31,14 +36,8 @@ def status
require 'legion/auth/token_manager'

PROVIDERS.each do |provider|
manager = Legion::Auth::TokenManager.new(provider: provider.to_sym)
if manager.token_valid?
say " #{provider}: connected", :green
elsif manager.revoked?
say " #{provider}: revoked", :red
else
say " #{provider}: not connected", :yellow
end
state = provider_state(provider)
say " #{provider}: #{state}", STATE_COLORS.fetch(state, :yellow)
end
end

Expand All @@ -51,6 +50,32 @@ def disconnect(provider)

say "Disconnected #{provider} account.", :green
end

no_commands do
# Microsoft delegated login writes tokens via the Entra TokenManager
# (vault/local/memory), not the legacy Legion::Auth secret store — so
# status for :microsoft must consult the Entra store to avoid always
# reporting 'not connected' after a successful Teams/delegated login.
def provider_state(provider)
return microsoft_state if provider == 'microsoft'

manager = Legion::Auth::TokenManager.new(provider: provider.to_sym)
return 'connected' if manager.token_valid?
return 'revoked' if manager.revoked?

'not connected'
end

def microsoft_state
return 'not connected' unless defined?(Legion::Extensions::Identity::Entra::Helpers::TokenManager)

tm = Legion::Extensions::Identity::Entra::Helpers::TokenManager
data = tm.token_data(:delegated, refresh: false)
data && !tm.expired?(data) ? 'connected' : 'not connected'
rescue StandardError
'not connected'
end
end
end
end
end
2 changes: 1 addition & 1 deletion lib/legion/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Legion
VERSION = '1.9.43'
VERSION = '1.9.48'
end
18 changes: 17 additions & 1 deletion spec/api/health_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,30 @@ def app
before(:all) { ApiSpecSetup.configure_settings }

describe 'GET /api/health' do
it 'returns ok status' do
after { Legion::Readiness.reset }

it 'returns ok status when no enabled subsystem is degraded' do
Legion::Readiness.reset
get '/api/health'
expect(last_response.status).to eq(200)
body = Legion::JSON.load(last_response.body)
expect(body[:data][:status]).to eq('ok')
expect(body[:data][:version]).to eq(Legion::VERSION)
expect(body[:data][:uptime_seconds]).to be_an(Integer)
expect(body[:data][:uptime]).to eq(body[:data][:uptime_seconds])
expect(body[:data][:components]).to have_key(:transport)
end

it 'returns 503 degraded when an enabled subsystem has broken' do
Legion::Readiness.reset
Legion::Readiness.mark_ready(:transport)
allow(Legion::API::Health).to receive(:transport_liveness).and_return([false, 'session_open: false'])

get '/api/health'
expect(last_response.status).to eq(503)
body = Legion::JSON.load(last_response.body)
expect(body[:data][:status]).to eq('degraded')
expect(body[:data][:components][:transport]).to include(enabled: true, healthy: false)
end
end

Expand Down
52 changes: 52 additions & 0 deletions spec/legion/api/auth_teams_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

require 'spec_helper'
require 'legion/api/auth_teams'

RSpec.describe Legion::API::Routes::AuthTeams::TeamsTokenHelper do
subject(:helper) { Object.new.extend(described_class) }

let(:token_manager) do
class_double('Legion::Extensions::Identity::Entra::Helpers::TokenManager', save_token: true)
end

let(:token_body) do
{ access_token: 'at', refresh_token: 'rt', expires_in: 3600 }
end

before do
stub_const('Legion::Extensions::Identity::Entra::Helpers::TokenManager', token_manager)
allow(helper).to receive(:require).and_return(true)
end

describe '#store_teams_token' do
it 'persists the delegated token via the Entra TokenManager' do
helper.store_teams_token(token_body, 'OnlineMeetings.Read',
tenant_id: 'tid', client_id: 'cid')

expect(token_manager).to have_received(:save_token).with(
:delegated,
access_token: 'at',
refresh_token: 'rt',
expires_in: 3600,
scopes: 'OnlineMeetings.Read',
tenant_id: 'tid',
client_id: 'cid'
)
end

it 'forwards tenant_id and client_id so the stored token can be refreshed' do
helper.store_teams_token(token_body, 'scope', tenant_id: 'tid', client_id: 'cid')

expect(token_manager).to have_received(:save_token)
.with(:delegated, hash_including(tenant_id: 'tid', client_id: 'cid'))
end

it 'does not raise when the token store fails (logs a warning instead)' do
allow(token_manager).to receive(:save_token).and_raise(StandardError, 'vault down')

expect { helper.store_teams_token(token_body, 'scope', tenant_id: 't', client_id: 'c') }
.not_to raise_error
end
end
end
Loading
Loading