Skip to content

Feature (A): Bind-aware gateway auth posture — loopback permissive, external strict #1506

@MervinPraison

Description

@MervinPraison

Overview

Introduce a bind-aware authentication posture in WebSocketGateway and the Chainlit UI: permissive on loopback (127.0.0.1 / localhost / ::1) so non-developer onboarding keeps its one-command quickstart, strict on any external interface so a LAN/VPS deploy can't ship with admin/admin or a missing token.

This is the foundation for issues B (magic-link), C (Origin/CSRF), D (UI pairing banner), E (owner-DM pairing buttons). Ship this first; the others build on AuthMode and the bind_host signal.

Parent tracking: #1502.


Background

Current state (evidence):

File Lines Behaviour
src/praisonai/praisonai/gateway/server.py 205-220 Auto-generates GATEWAY_AUTH_TOKEN if missing, logs raw token to WARNING
src/praisonai/praisonai/gateway/server.py 278-305 Uniform auth regardless of bind interface
src/praisonai/praisonai/ui/chat.py 387-401 CHAINLIT_USERNAME/PASSWORD defaults to admin/admin — warn only, accepts request

Peer implementations of the same idea (proven in production):

  • ~/hermes-agent/gateway/platforms/api_server.py:476-477"If no API key is configured, all requests are allowed (only when API server is local)"
  • ~/openclaw/src/gateway/auth.ts:139-160isLocalDirectRequest() helper driving GatewayAuthSurface

Architecture

Single rule, enforced at two entry points:

bind_host in {"127.0.0.1", "localhost", "::1", "0:0:0:0:0:0:0:1"}
  → AuthMode = "local"       → token not required, admin/admin allowed, warn-only
bind_host otherwise
  → AuthMode = "token" (default) → token required at startup;
                                   admin/admin refused unless PRAISONAI_ALLOW_DEFAULT_CREDS=1

Protocol-first (zero heavy imports in core):

# praisonaiagents/gateway/protocols.py   (NEW, ~30 lines)
from typing import Protocol, Literal, Optional
AuthMode = Literal["local", "token", "password", "trusted-proxy"]

def resolve_auth_mode(bind_host: str, configured: Optional[AuthMode]) -> AuthMode: ...
def is_loopback(host: str) -> bool: ...

Adapters / enforcement live in the wrapper (src/praisonai/praisonai/gateway/auth.py, new, ~80 lines).


Files to Create / Modify

New files

File Purpose Est. LOC
src/praisonai-agents/praisonaiagents/gateway/protocols.py AuthMode literal + is_loopback() + resolve_auth_mode() pure helpers 30
src/praisonai/praisonai/gateway/auth.py assert_external_bind_safe(config) — raises GatewayStartupError with one-line fix message 80
src/praisonai/praisonai/ui/_auth.py register_password_auth(app, *, bind_host) — shared helper; enforces non-default creds on external bind 60
src/praisonai/tests/unit/gateway/test_bind_aware_auth.py Matrix: loopback-permissive vs external-strict 80
src/praisonai/tests/unit/ui/test_ui_bind_aware_creds.py UI refuses admin/admin on 0.0.0.0 unless escape hatch set 40

Modified files

File Change Approx LOC delta
src/praisonai/praisonai/gateway/server.py In start(), call assert_external_bind_safe(self.config) before binding; log fingerprint not raw token +15 / -4
src/praisonai/praisonai/ui/{chat,bot,agents,realtime,code}.py Replace duplicated password_auth_callback bootstrap with register_password_auth(app, bind_host=…) -25 × 5 = −125 net
src/praisonai-agents/praisonaiagents/gateway/config.py Add bind_host: str = "127.0.0.1" field to GatewayConfig +2

Net: +267 / −129 ≈ +138 LOC across 8 touched files. Well within "minimal and focused".


Technical Considerations

  • No new deps. All stdlib (ipaddress for safe loopback check).
  • Import-time budget: protocols.py contains only Literal + two pure functions → 0 ms added.
  • Backward compat: bind_host defaults to 127.0.0.1 (matches current default) → no behaviour change for local users.
  • Safe default: external bind without token → hard fail at startup with:
    GatewayStartupError: Cannot bind to 0.0.0.0 without an auth token.
      Fix:  praisonai onboard         (30 seconds, 3 prompts)
      Or:   export GATEWAY_AUTH_TOKEN=$(openssl rand -hex 16)
    
  • Escape hatch: PRAISONAI_ALLOW_DEFAULT_CREDS=1 — for lab/demo only; documented.
  • DRY win: 5× UI auth duplication collapses into one helper.

Acceptance Criteria

  • praisonaiagents.gateway.protocols.is_loopback("127.0.0.1") is True, same for localhost, ::1, 0:0:0:0:0:0:0:1; False for 0.0.0.0, 10.x, 192.168.x, public IPs.
  • resolve_auth_mode("127.0.0.1", None) == "local", resolve_auth_mode("0.0.0.0", None) == "token", explicit config wins.
  • Gateway with host="127.0.0.1" starts with no auth_token → permissive mode, warn-level log.
  • Gateway with host="0.0.0.0" and no token → raises GatewayStartupError with the fix message.
  • Gateway with host="0.0.0.0" and token → starts normally.
  • Chainlit UI on localhost with unset CHAINLIT_USERNAME/PASSWORDadmin/admin works, WARN log.
  • Chainlit UI on 0.0.0.0 with admin/admin → refuses to start unless PRAISONAI_ALLOW_DEFAULT_CREDS=1.
  • 5 UI files share one helper — no @cl.password_auth_callback duplication (grep shows exactly 1 definition).
  • Auto-generated GATEWAY_AUTH_TOKEN logged only as fingerprint gw_****XXXX (last 4 chars); raw token written to ~/.praisonai/.env (mode 0600) only.
  • All existing tests pass; new tests cover the matrix above.
  • Import-time budget: python -c "import time; t=time.time(); import praisonaiagents; print(f'{(time.time()-t)*1000:.0f}ms')" < 200 ms.

Real agentic test

# src/praisonai/tests/integration/gateway/test_bind_aware_e2e.py
import os, subprocess, time, requests
from praisonaiagents import Agent

# Loopback with no token — must work
proc = subprocess.Popen(["praisonai", "gateway", "start", "--host", "127.0.0.1"])
time.sleep(3)
assert requests.get("http://127.0.0.1:8765/info").status_code in (200, 401)
proc.terminate()

# Real agent still works (unrelated to gateway, sanity)
a = Agent(instructions="Reply in exactly 3 words.")
r = a.start("Say hi")
print(r)
assert len(r) > 0

Implementation Notes

Key files to read first

  1. src/praisonai/praisonai/gateway/server.py 191-325 — the init + _check_auth path you will wrap
  2. src/praisonai/praisonai/ui/chat.py 197-401 — the auth bootstrap to consolidate
  3. ~/hermes-agent/gateway/platforms/api_server.py 454-488 — reference behaviour

Testing commands

pytest src/praisonai/tests/unit/gateway/test_bind_aware_auth.py -v
pytest src/praisonai/tests/unit/ui/test_ui_bind_aware_creds.py -v
pytest src/praisonai/tests/integration/gateway/test_bind_aware_e2e.py -v

# Smoke: DRY win
grep -rn "@cl.password_auth_callback" src/praisonai/praisonai/ui/ | wc -l   # must be 1

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingclaudeAuto-trigger Claude analysis

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions