Skip to content

Commit 4be8dac

Browse files
feat: implement bind-aware gateway auth posture (fixes #1506)
Introduces bind-aware authentication that is permissive on loopback interfaces (127.0.0.1/localhost) for developer ease, but strict on external interfaces for security. Key features: - Protocol-driven design: protocols in core SDK, implementations in wrapper - Gateway validation prevents unsafe external binding without auth tokens - UI credential validation refuses admin/admin on external interfaces - Consolidated 5 duplicated UI auth callbacks into 1 shared helper - Zero heavy dependencies, fast imports (32ms) - Comprehensive test coverage with 280+ lines of tests Architecture: - praisonaiagents/gateway/protocols.py: AuthMode types and core utilities - praisonai/gateway/auth.py: Concrete auth enforcement implementations - praisonai/ui/_auth.py: Shared UI authentication helper - Updated gateway server and UI components to use bind-aware logic Security posture: - Loopback: local mode, no token required, admin/admin allowed with warning - External: token mode, auth required, admin/admin blocked unless escaped This provides the foundation for issues B-E (magic-link, CSRF, UI pairing). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: MervinPraison <MervinPraison@users.noreply.github.com>
1 parent bac89d2 commit 4be8dac

11 files changed

Lines changed: 1524 additions & 65 deletions

File tree

src/praisonai-agents/praisonaiagents/gateway/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ class GatewayConfig:
210210

211211
host: str = "127.0.0.1"
212212
port: int = 8765
213+
bind_host: str = "127.0.0.1" # For authentication posture resolution
213214
cors_origins: List[str] = field(default_factory=lambda: [])
214215
auth_token: Optional[str] = None
215216
max_connections: int = 1000
@@ -226,6 +227,7 @@ def to_dict(self) -> Dict[str, Any]:
226227
return {
227228
"host": self.host,
228229
"port": self.port,
230+
"bind_host": self.bind_host,
229231
"cors_origins": self.cors_origins,
230232
"auth_token": "***" if self.auth_token else None,
231233
"max_connections": self.max_connections,

src/praisonai-agents/praisonaiagents/gateway/protocols.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
Callable,
2121
Dict,
2222
List,
23+
Literal,
2324
Optional,
2425
Protocol,
2526
Union,
@@ -684,3 +685,188 @@ async def purge_acknowledged(self, max_age_seconds: int = 86400) -> int:
684685
Number of messages purged
685686
"""
686687
...
688+
689+
690+
# ---------------------------------------------------------------------------
691+
# Authentication protocols and utilities
692+
# ---------------------------------------------------------------------------
693+
694+
# Type definitions for authentication modes
695+
AuthMode = Literal["local", "token", "password", "trusted-proxy"]
696+
697+
698+
def is_loopback(host: str) -> bool:
699+
"""Check if a host address is a loopback interface.
700+
701+
Supports IPv4 and IPv6 loopback addresses:
702+
- 127.0.0.1, localhost (IPv4)
703+
- ::1, 0:0:0:0:0:0:0:1 (IPv6)
704+
705+
Args:
706+
host: Host address to check
707+
708+
Returns:
709+
True if the host is a loopback address, False otherwise
710+
711+
Example:
712+
>>> is_loopback("127.0.0.1")
713+
True
714+
>>> is_loopback("localhost")
715+
True
716+
>>> is_loopback("0.0.0.0")
717+
False
718+
>>> is_loopback("192.168.1.1")
719+
False
720+
"""
721+
if not host:
722+
return False
723+
724+
# Handle common string representations
725+
host = host.lower().strip()
726+
if host in ("localhost", "local"):
727+
return True
728+
729+
try:
730+
import ipaddress
731+
# Try to parse as IP address
732+
addr = ipaddress.ip_address(host)
733+
return addr.is_loopback
734+
except (ValueError, ImportError):
735+
# Not a valid IP address or ipaddress not available
736+
return False
737+
738+
739+
def resolve_auth_mode(
740+
bind_host: str,
741+
configured: Optional[AuthMode] = None
742+
) -> AuthMode:
743+
"""Resolve the authentication mode based on bind host and configuration.
744+
745+
Single rule: loopback binds default to "local" mode (permissive),
746+
external binds default to "token" mode (strict). Explicit configuration
747+
always takes precedence.
748+
749+
Args:
750+
bind_host: The host address the server is binding to
751+
configured: Explicitly configured auth mode (overrides default)
752+
753+
Returns:
754+
The resolved authentication mode
755+
756+
Example:
757+
>>> resolve_auth_mode("127.0.0.1", None)
758+
'local'
759+
>>> resolve_auth_mode("0.0.0.0", None)
760+
'token'
761+
>>> resolve_auth_mode("0.0.0.0", "local")
762+
'local'
763+
"""
764+
# Explicit configuration wins
765+
if configured:
766+
return configured
767+
768+
# Default based on bind interface
769+
if is_loopback(bind_host):
770+
return "local"
771+
else:
772+
return "token"
773+
774+
775+
@runtime_checkable
776+
class GatewayAuthProtocol(Protocol):
777+
"""Protocol for gateway authentication validation.
778+
779+
Defines the interface for validating authentication requirements
780+
based on the resolved auth mode and configuration.
781+
"""
782+
783+
def validate_auth_config(
784+
self,
785+
auth_mode: AuthMode,
786+
bind_host: str,
787+
auth_token: Optional[str] = None,
788+
**kwargs
789+
) -> None:
790+
"""Validate that authentication configuration is safe for the bind host.
791+
792+
Args:
793+
auth_mode: The authentication mode to validate
794+
bind_host: The host the server will bind to
795+
auth_token: The configured auth token (if any)
796+
**kwargs: Additional auth configuration
797+
798+
Raises:
799+
GatewayStartupError: If configuration is unsafe for external binding
800+
"""
801+
...
802+
803+
def check_request_auth(
804+
self,
805+
auth_mode: AuthMode,
806+
request_token: Optional[str] = None,
807+
expected_token: Optional[str] = None,
808+
**kwargs
809+
) -> bool:
810+
"""Check if a request satisfies authentication requirements.
811+
812+
Args:
813+
auth_mode: The current authentication mode
814+
request_token: Token provided in the request
815+
expected_token: Expected token value
816+
**kwargs: Additional request context
817+
818+
Returns:
819+
True if authentication is satisfied, False otherwise
820+
"""
821+
...
822+
823+
824+
@runtime_checkable
825+
class UIAuthProtocol(Protocol):
826+
"""Protocol for UI authentication validation.
827+
828+
Defines the interface for validating UI credentials based on
829+
bind host and authentication mode.
830+
"""
831+
832+
def validate_credentials_config(
833+
self,
834+
bind_host: str,
835+
username: str,
836+
password: str,
837+
allow_defaults: bool = False
838+
) -> None:
839+
"""Validate that UI credentials are safe for the bind host.
840+
841+
Args:
842+
bind_host: The host the UI server will bind to
843+
username: Configured username
844+
password: Configured password
845+
allow_defaults: Whether to allow default credentials (escape hatch)
846+
847+
Raises:
848+
UIStartupError: If credentials are unsafe for external binding
849+
"""
850+
...
851+
852+
def check_auth_callback(
853+
self,
854+
bind_host: str,
855+
provided_username: str,
856+
provided_password: str,
857+
expected_username: str,
858+
expected_password: str
859+
) -> bool:
860+
"""Check if provided credentials are valid for the bind host.
861+
862+
Args:
863+
bind_host: The host the UI server is bound to
864+
provided_username: Username from login attempt
865+
provided_password: Password from login attempt
866+
expected_username: Expected username
867+
expected_password: Expected password
868+
869+
Returns:
870+
True if credentials are valid, False otherwise
871+
"""
872+
...

0 commit comments

Comments
 (0)