Skip to content

Commit 32cd93e

Browse files
authored
fix(backend): Pangolin-Standard-Header (Remote-User + X-Pangolin-Token) als SSO-Pfad (#96)
Backend akzeptiert Pangolin-Standard-Header (Remote-User + X-Pangolin-Token Trust-Validation) analog zu Hangar. Legacy X-Pangolin-User bleibt für Frontend-internal-Calls (PR #95) erhalten. PRINTER_HUB_SSO_TRUST_TOKEN muss in Backend-Env gesetzt werden, sonst ist der Pfad sicherheitshalber deaktiviert (leerer Default). 6 neue Tests, 876 Tests grün insgesamt.
1 parent 0d2de27 commit 32cd93e

4 files changed

Lines changed: 296 additions & 14 deletions

File tree

backend/.env.example

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,27 @@ PRINTER_HUB_SSE_IDLE_TIMEOUT_S=300
9191
PRINTER_HUB_SSE_MAX_SUBSCRIBERS=100
9292
PRINTER_HUB_SSE_PROBE_INTERVAL_S=30
9393

94+
# ---------------------------------------------------------------------------
95+
# Pangolin-SSO Standard-Header-Authentifizierung
96+
#
97+
# Pangolin injiziert nach erfolgreicher SSO-Authentifizierung folgende Headers:
98+
# Remote-User — authentifizierter Benutzername / E-Mail
99+
# X-Pangolin-Token — statischer Trust-Token (in Pangolin-Resource konfiguriert)
100+
#
101+
# Das Backend vertraut den Remote-* Headers NUR wenn PRINTER_HUB_SSO_TRUST_TOKEN
102+
# gesetzt ist UND der Header-Wert exakt übereinstimmt.
103+
# Leer lassen = SSO via Remote-User deaktiviert (Standard: deaktiviert).
104+
#
105+
# Konfiguration in Pangolin Resource (Header-Injection):
106+
# Header-Injection: x-pangolin-token = <gleicher Wert wie SSO_TRUST_TOKEN>
107+
#
108+
# Trust-Token generieren:
109+
# openssl rand -hex 32
110+
# ---------------------------------------------------------------------------
111+
PRINTER_HUB_SSO_USER_HEADER=Remote-User
112+
PRINTER_HUB_SSO_TRUST_HEADER=X-Pangolin-Token
113+
PRINTER_HUB_SSO_TRUST_TOKEN=
114+
94115
# ---------------------------------------------------------------------------
95116
# Server
96117
# ---------------------------------------------------------------------------

backend/app/auth/dependencies.py

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,26 @@
11
"""FastAPI authentication dependency — Phase 7c require_scope().
22
3-
Three authentication paths (in priority order):
3+
Four authentication paths (in priority order):
44
55
1. API-Key header ``X-Label-Hub-Key: lh_...``
66
- Validated via bcrypt verify + LRU cache
77
- Full 3-level scope model (read/print/admin)
88
- Scope hierarchy: admin ⊇ print ⊇ read
99
10-
2. Pangolin-SSO browser session (``X-Pangolin-User`` header set by Pangolin)
10+
2. Pangolin-SSO browser session — Pangolin-Standard-Headers
11+
- ``Remote-User`` (oder konfigurierbar via ``sso_user_header``) enthält
12+
den eingeloggten Benutzernamen / die E-Mail-Adresse
13+
- ``X-Pangolin-Token`` (oder konfigurierbar via ``sso_trust_header``) ist
14+
ein statischer Trust-Token, den Pangolin beim Forwarding injiziert.
15+
Pangolin-Resource → Header-Injection → Backend.
16+
- Wird NUR akzeptiert wenn ``settings.sso_trust_token`` gesetzt ist UND
17+
der Header-Wert exakt übereinstimmt. Leer = Pfad deaktiviert.
18+
- Only grants ``read`` scope
19+
20+
2b. Backwards-Compatibility: ``X-Pangolin-User`` header (Legacy-Pfad)
21+
- Wird weiterhin akzeptiert, kein Trust-Token erforderlich
22+
- Nur für interne Frontend-Aufrufe via PR #95 Forwarding
1123
- Only grants ``read`` scope
12-
- Used by the frontend after SSO login
1324
1425
3. Pangolin-bypass claude-automation (``Authorization: Basic ...`` with
1526
the claude-automation credential)
@@ -83,15 +94,37 @@ def _scope_satisfies(key_scope: str, required_scope: str) -> bool:
8394
return required_scope in _SCOPE_HIERARCHY[key_scope]
8495

8596

86-
def _has_pangolin_sso_session(request: Request) -> bool:
87-
"""Return True when the Pangolin reverse proxy has set the SSO user header.
97+
def _has_pangolin_sso_session(request: Request, settings: Settings) -> bool:
98+
"""Return True when the Pangolin reverse proxy has set a trusted SSO user header.
99+
100+
Zwei unterstützte Pfade (in Prioritätsreihenfolge):
101+
102+
1. **Pangolin-Standard-Headers** (bevorzugt, analog Hangar):
103+
- ``Remote-User`` (oder konfigurierbar via ``settings.sso_user_header``)
104+
enthält den authentifizierten Benutzernamen.
105+
- ``X-Pangolin-Token`` (oder konfigurierbar via ``settings.sso_trust_header``)
106+
ist ein statischer Trust-Token, den Pangolin beim Forwarding injiziert.
107+
- Wird NUR akzeptiert wenn ``settings.sso_trust_token`` nicht leer ist
108+
UND der Wert im Request-Header exakt mit dem konfigurierten Token
109+
übereinstimmt. Leeres Token = dieser Pfad ist deaktiviert.
88110
89-
Pangolin sets ``X-Pangolin-User`` after the user has authenticated via SSO.
90-
This header is trusted only when it originates from the Pangolin proxy —
91-
in HomeLab deployments, direct internet access to the backend is blocked
92-
at the network level (Tailscale), so the header cannot be spoofed by
93-
external callers.
111+
2. **Legacy ``X-Pangolin-User``** (Rückwärtskompatibilität):
112+
- Wird weiterhin ohne Trust-Token akzeptiert.
113+
- Für interne Frontend-Aufrufe via PR #95 Auth-Header-Forwarding.
114+
- Kein Trust-Token erforderlich, da der Pfad ausschließlich über
115+
das interne Tailscale-Netz erreichbar ist.
94116
"""
117+
# Pfad 1: Pangolin-Standard mit konfiguriertem Trust-Token
118+
trust_token = settings.sso_trust_token
119+
if trust_token:
120+
user_header = settings.sso_user_header
121+
trust_header = settings.sso_trust_header
122+
user_value = request.headers.get(user_header, "")
123+
token_value = request.headers.get(trust_header, "")
124+
if user_value and token_value == trust_token:
125+
return True
126+
127+
# Pfad 2: Legacy X-Pangolin-User (Rückwärtskompatibilität)
95128
return bool(request.headers.get("X-Pangolin-User"))
96129

97130

@@ -259,9 +292,10 @@ def require_scope(
259292
required: One of "read", "print", "admin".
260293
settings: Override settings (for testing). Defaults to get_settings().
261294
262-
The dependency resolves through three paths (in priority order):
295+
The dependency resolves through four paths (in priority order):
263296
1. X-Label-Hub-Key API key header
264-
2. Pangolin-SSO (X-Pangolin-User) — read scope only
297+
2. Pangolin-SSO (Remote-User + X-Pangolin-Token Trust-Token) — read scope only
298+
2b. Pangolin-SSO Legacy (X-Pangolin-User) — read scope only (Rückwärtskompatibilität)
265299
3. Pangolin-bypass (claude-automation Basic Auth) — read scope only
266300
267301
Returns a callable that FastAPI injects as ``Depends(require_scope("read"))``.
@@ -279,8 +313,8 @@ async def _check(
279313
if key_header:
280314
return await _validate_api_key(session, key_header, required, client_ip)
281315

282-
# Path 2: Pangolin-SSO (browser session)
283-
if _has_pangolin_sso_session(request) and required == "read":
316+
# Path 2: Pangolin-SSO (browser session) — Standard-Headers + Legacy
317+
if _has_pangolin_sso_session(request, effective_settings) and required == "read":
284318
return AuthContext(
285319
source="pangolin-sso",
286320
scope="read",

backend/app/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,17 @@ class Settings(BaseSettings):
106106
# Set to False during transition to avoid surprising existing automation.
107107
pangolin_bypass_scope_downgrade: bool = False
108108

109+
# Pangolin-SSO Standard-Header-Konfiguration (analog Hangar).
110+
# Pangolin setzt nach erfolgreicher SSO-Authentifizierung folgende Headers:
111+
# Remote-User — Benutzername / E-Mail-Adresse
112+
# X-Pangolin-Token — statischer Trust-Token, der in der Pangolin-Resource
113+
# konfiguriert wird (Resource → Header-Injection)
114+
# Das Backend vertraut den Remote-* Headers NUR wenn der Trust-Token
115+
# übereinstimmt. Ein leeres sso_trust_token deaktiviert diesen Pfad.
116+
sso_user_header: str = "Remote-User"
117+
sso_trust_header: str = "X-Pangolin-Token"
118+
sso_trust_token: str = "" # leer = SSO via Remote-User deaktiviert
119+
109120
# Phase 2: Job-Retention für CleanupTask
110121
job_retention_days: int = Field(
111122
default=30,

backend/tests/unit/auth/test_dependencies.py

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,3 +468,219 @@ async def _session():
468468

469469
assert resp.status_code == 403
470470
await eng.dispose()
471+
472+
473+
# --------------------------------------------------------------------------
474+
# Path 2b: Pangolin-Standard-Headers (Remote-User + X-Pangolin-Token)
475+
# --------------------------------------------------------------------------
476+
477+
TRUST_TOKEN = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899" # nosec B105 noqa: S105 # ggignore
478+
479+
480+
def _make_sso_app_with_trust(required_scope: str = "read"):
481+
"""Build a minimal app with Pangolin-standard SSO headers configured."""
482+
from app.auth.dependencies import require_scope
483+
from app.config import Settings
484+
from app.db.session import get_session
485+
from fastapi import Depends, FastAPI
486+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
487+
from sqlmodel import SQLModel
488+
489+
eng = create_async_engine("sqlite+aiosqlite:///:memory:")
490+
settings = Settings(
491+
_env_file=None,
492+
sso_trust_token=TRUST_TOKEN,
493+
)
494+
test_app = FastAPI()
495+
496+
@test_app.get("/test")
497+
async def ep(ctx=Depends(require_scope(required_scope, settings=settings))):
498+
return {"source": ctx.source, "scope": ctx.scope}
499+
500+
async def override_session():
501+
factory = async_sessionmaker(eng, expire_on_commit=False)
502+
async with eng.begin() as conn:
503+
await conn.run_sync(SQLModel.metadata.create_all)
504+
async with factory() as s:
505+
yield s
506+
507+
test_app.dependency_overrides[get_session] = override_session
508+
return test_app, eng
509+
510+
511+
@pytest.mark.asyncio
512+
async def test_sso_session_with_remote_user_and_trust_token():
513+
"""Remote-User + korrekter X-Pangolin-Token → 200 (Pangolin-Standard)."""
514+
test_app, eng = _make_sso_app_with_trust("read")
515+
516+
async with AsyncClient(transport=ASGITransport(app=test_app), base_url="http://t") as client:
517+
resp = await client.get(
518+
"/test",
519+
headers={
520+
"Remote-User": "sso-user@example.com",
521+
"X-Pangolin-Token": TRUST_TOKEN,
522+
},
523+
)
524+
525+
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
526+
assert resp.json()["source"] == "pangolin-sso"
527+
await eng.dispose()
528+
529+
530+
@pytest.mark.asyncio
531+
async def test_sso_session_remote_user_without_trust_token():
532+
"""Remote-User ohne X-Pangolin-Token → 401 (nicht vertrauenswürdig)."""
533+
test_app, eng = _make_sso_app_with_trust("read")
534+
535+
async with AsyncClient(transport=ASGITransport(app=test_app), base_url="http://t") as client:
536+
resp = await client.get(
537+
"/test",
538+
headers={"Remote-User": "evil-user@attacker.com"},
539+
)
540+
541+
assert resp.status_code == 401, f"Expected 401, got {resp.status_code}: {resp.text}"
542+
await eng.dispose()
543+
544+
545+
@pytest.mark.asyncio
546+
async def test_sso_session_trust_token_wrong_value():
547+
"""Remote-User + falscher X-Pangolin-Token → 401 (Token-Mismatch)."""
548+
test_app, eng = _make_sso_app_with_trust("read")
549+
550+
async with AsyncClient(transport=ASGITransport(app=test_app), base_url="http://t") as client:
551+
resp = await client.get(
552+
"/test",
553+
headers={
554+
"Remote-User": "sso-user@example.com",
555+
"X-Pangolin-Token": "wrong-token-value",
556+
},
557+
)
558+
559+
assert resp.status_code == 401, f"Expected 401, got {resp.status_code}: {resp.text}"
560+
await eng.dispose()
561+
562+
563+
@pytest.mark.asyncio
564+
async def test_sso_no_trust_token_configured_rejects_remote_user():
565+
"""Wenn sso_trust_token leer → Remote-User wird NICHT akzeptiert (Sicherheitsstandard)."""
566+
from app.auth.dependencies import require_scope
567+
from app.config import Settings
568+
from app.db.session import get_session
569+
from fastapi import Depends, FastAPI
570+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
571+
from sqlmodel import SQLModel
572+
573+
eng = create_async_engine("sqlite+aiosqlite:///:memory:")
574+
settings = Settings(_env_file=None, sso_trust_token="") # leer = SSO disabled
575+
test_app = FastAPI()
576+
577+
@test_app.get("/test")
578+
async def ep(ctx=Depends(require_scope("read", settings=settings))):
579+
return {"source": ctx.source}
580+
581+
async def override_session():
582+
factory = async_sessionmaker(eng, expire_on_commit=False)
583+
async with eng.begin() as conn:
584+
await conn.run_sync(SQLModel.metadata.create_all)
585+
async with factory() as s:
586+
yield s
587+
588+
test_app.dependency_overrides[get_session] = override_session
589+
590+
async with AsyncClient(transport=ASGITransport(app=test_app), base_url="http://t") as client:
591+
resp = await client.get(
592+
"/test",
593+
headers={
594+
"Remote-User": "sso-user@example.com",
595+
"X-Pangolin-Token": TRUST_TOKEN,
596+
},
597+
)
598+
599+
assert resp.status_code == 401, f"Expected 401, got {resp.status_code}: {resp.text}"
600+
await eng.dispose()
601+
602+
603+
@pytest.mark.asyncio
604+
async def test_sso_backwards_compat_x_pangolin_user():
605+
"""X-Pangolin-User Header akzeptiert (Rückwärtskompatibilität, kein Trust-Token nötig)."""
606+
from app.auth.dependencies import require_scope
607+
from app.config import Settings
608+
from app.db.session import get_session
609+
from fastapi import Depends, FastAPI
610+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
611+
from sqlmodel import SQLModel
612+
613+
eng = create_async_engine("sqlite+aiosqlite:///:memory:")
614+
settings = Settings(_env_file=None) # sso_trust_token leer — X-Pangolin-User trotzdem erlaubt
615+
test_app = FastAPI()
616+
617+
@test_app.get("/test")
618+
async def ep(ctx=Depends(require_scope("read", settings=settings))):
619+
return {"source": ctx.source}
620+
621+
async def override_session():
622+
factory = async_sessionmaker(eng, expire_on_commit=False)
623+
async with eng.begin() as conn:
624+
await conn.run_sync(SQLModel.metadata.create_all)
625+
async with factory() as s:
626+
yield s
627+
628+
test_app.dependency_overrides[get_session] = override_session
629+
630+
async with AsyncClient(transport=ASGITransport(app=test_app), base_url="http://t") as client:
631+
resp = await client.get(
632+
"/test",
633+
headers={"X-Pangolin-User": "legacy-user@example.com"},
634+
)
635+
636+
assert resp.status_code == 200, (
637+
f"Expected 200 for legacy X-Pangolin-User, got {resp.status_code}: {resp.text}"
638+
)
639+
assert resp.json()["source"] == "pangolin-sso"
640+
await eng.dispose()
641+
642+
643+
@pytest.mark.asyncio
644+
async def test_sso_configurable_header_names():
645+
"""Benutzerdefinierte SSO-Header-Namen werden korrekt ausgewertet."""
646+
from app.auth.dependencies import require_scope
647+
from app.config import Settings
648+
from app.db.session import get_session
649+
from fastapi import Depends, FastAPI
650+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
651+
from sqlmodel import SQLModel
652+
653+
eng = create_async_engine("sqlite+aiosqlite:///:memory:")
654+
settings = Settings(
655+
_env_file=None,
656+
sso_user_header="X-Custom-User",
657+
sso_trust_header="X-Custom-Trust",
658+
sso_trust_token=TRUST_TOKEN,
659+
)
660+
test_app = FastAPI()
661+
662+
@test_app.get("/test")
663+
async def ep(ctx=Depends(require_scope("read", settings=settings))):
664+
return {"source": ctx.source}
665+
666+
async def override_session():
667+
factory = async_sessionmaker(eng, expire_on_commit=False)
668+
async with eng.begin() as conn:
669+
await conn.run_sync(SQLModel.metadata.create_all)
670+
async with factory() as s:
671+
yield s
672+
673+
test_app.dependency_overrides[get_session] = override_session
674+
675+
async with AsyncClient(transport=ASGITransport(app=test_app), base_url="http://t") as client:
676+
resp = await client.get(
677+
"/test",
678+
headers={
679+
"X-Custom-User": "custom-user@example.com",
680+
"X-Custom-Trust": TRUST_TOKEN,
681+
},
682+
)
683+
684+
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
685+
assert resp.json()["source"] == "pangolin-sso"
686+
await eng.dispose()

0 commit comments

Comments
 (0)