Implement reverse-proxy forward-auth
Add a /forward-auth endpoint that validates the existing Porchlight session for reverse proxies and returns identity headers for authenticated active users. Unauthenticated or inactive sessions redirect to login, with optional post-login return targets reconstructed from proxy headers and constrained by an allowed host list. Also add forward_auth_allowed_redirect_hosts configuration, preserve validated forward-auth return targets through login/session reset, exempt the endpoint from CSRF for proxy subrequests, document configuration, and cover the flow with tests.
This commit is contained in:
parent
299f6cb7fc
commit
58da15c825
9 changed files with 446 additions and 1 deletions
|
|
@ -15,6 +15,7 @@ def test_default_settings() -> None:
|
|||
assert settings.sqlite_path == "data/oidc_op.db"
|
||||
assert settings.manage_client_id == "manage-app"
|
||||
assert settings.invite_ttl == 86400
|
||||
assert settings.forward_auth_allowed_redirect_hosts == []
|
||||
assert settings.theme == "default"
|
||||
|
||||
|
||||
|
|
@ -49,6 +50,7 @@ def test_settings_from_toml_file(tmp_path: Path) -> None:
|
|||
issuer = "https://toml.example.com"
|
||||
debug = true
|
||||
sqlite_path = "custom/path.db"
|
||||
forward_auth_allowed_redirect_hosts = ["app.example.com", "*.apps.example.com"]
|
||||
|
||||
[clients.my-app]
|
||||
client_secret = "secret123"
|
||||
|
|
@ -62,6 +64,7 @@ scope = ["openid", "profile"]
|
|||
assert settings.issuer == "https://toml.example.com"
|
||||
assert settings.debug is True
|
||||
assert settings.sqlite_path == "custom/path.db"
|
||||
assert settings.forward_auth_allowed_redirect_hosts == ["app.example.com", "*.apps.example.com"]
|
||||
assert "my-app" in settings.clients
|
||||
assert settings.clients["my-app"].client_secret == "secret123"
|
||||
assert settings.clients["my-app"].redirect_uris == ["https://app.example.com/callback"]
|
||||
|
|
|
|||
184
tests/test_forward_auth.py
Normal file
184
tests/test_forward_auth.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import re
|
||||
from datetime import UTC, datetime
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from httpx import AsyncClient
|
||||
|
||||
from porchlight.authn.password import PasswordService
|
||||
from porchlight.models import PasswordCredential, User
|
||||
|
||||
|
||||
async def _create_user(
|
||||
client: AsyncClient,
|
||||
*,
|
||||
username: str = "alice",
|
||||
password: str = "password123!Secure",
|
||||
groups: list[str] | None = None,
|
||||
email: str | None = None,
|
||||
) -> User:
|
||||
app = client._transport.app # type: ignore[union-attr]
|
||||
user = User(
|
||||
userid="forward-user-01",
|
||||
username=username,
|
||||
preferred_username="Alice",
|
||||
email=email,
|
||||
groups=groups or ["users"],
|
||||
created_at=datetime.now(UTC),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
await app.state.user_repo.create(user)
|
||||
|
||||
svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192))
|
||||
await app.state.credential_repo.create_password(
|
||||
PasswordCredential(user_id=user.userid, password_hash=svc.hash(password))
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def _login(client: AsyncClient, *, username: str = "alice", password: str = "password123!Secure") -> None:
|
||||
token = await _login_page_csrf(client)
|
||||
res = await client.post(
|
||||
"/login/password",
|
||||
data={"username": username, "password": password},
|
||||
headers={"HX-Request": "true", "X-CSRF-Token": token},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.headers.get("HX-Redirect")
|
||||
|
||||
|
||||
async def _login_page_csrf(client: AsyncClient, return_to: str | None = None) -> str:
|
||||
url = "/login"
|
||||
if return_to is not None:
|
||||
url = f"/login?return_to={return_to}"
|
||||
resp = await client.get(url)
|
||||
match = re.search(r'name="csrf-token" content="([^"]+)"', resp.text)
|
||||
assert match, "CSRF meta tag not found in page"
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _allow_forward_auth_hosts(client: AsyncClient, hosts: list[str]) -> None:
|
||||
app = client._transport.app # type: ignore[union-attr]
|
||||
app.state.settings.forward_auth_allowed_redirect_hosts = hosts
|
||||
|
||||
|
||||
async def test_forward_auth_redirects_unauthenticated_to_login(client: AsyncClient) -> None:
|
||||
res = await client.get("/forward-auth", follow_redirects=False)
|
||||
|
||||
assert res.status_code == 303
|
||||
assert res.headers["location"] == "http://localhost:8000/login"
|
||||
assert res.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
async def test_forward_auth_redirect_includes_allowed_return_to(client: AsyncClient) -> None:
|
||||
_allow_forward_auth_hosts(client, ["app.example.com"])
|
||||
|
||||
res = await client.get(
|
||||
"/forward-auth",
|
||||
headers={
|
||||
"X-Forwarded-Proto": "https",
|
||||
"X-Forwarded-Host": "app.example.com",
|
||||
"X-Forwarded-Uri": "/private?tab=1",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert res.status_code == 303
|
||||
location = urlsplit(res.headers["location"])
|
||||
assert location.scheme == "http"
|
||||
assert location.netloc == "localhost:8000"
|
||||
assert location.path == "/login"
|
||||
assert parse_qs(location.query) == {"return_to": ["https://app.example.com/private?tab=1"]}
|
||||
|
||||
|
||||
async def test_forward_auth_ignores_disallowed_return_host(client: AsyncClient) -> None:
|
||||
_allow_forward_auth_hosts(client, ["app.example.com"])
|
||||
|
||||
res = await client.get(
|
||||
"/forward-auth",
|
||||
headers={
|
||||
"X-Forwarded-Proto": "https",
|
||||
"X-Forwarded-Host": "evil.example.net",
|
||||
"X-Forwarded-Uri": "/private",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert res.status_code == 303
|
||||
assert res.headers["location"] == "http://localhost:8000/login"
|
||||
|
||||
|
||||
async def test_forward_auth_allows_non_safe_proxy_subrequests_without_csrf(client: AsyncClient) -> None:
|
||||
res = await client.post("/forward-auth", follow_redirects=False)
|
||||
|
||||
assert res.status_code == 303
|
||||
assert res.headers["location"] == "http://localhost:8000/login"
|
||||
|
||||
|
||||
async def test_login_consumes_allowed_forward_auth_return_to(client: AsyncClient) -> None:
|
||||
_allow_forward_auth_hosts(client, ["app.example.com"])
|
||||
await _create_user(client)
|
||||
|
||||
token = await _login_page_csrf(client, "https://app.example.com/private")
|
||||
res = await client.post(
|
||||
"/login/password",
|
||||
data={"username": "alice", "password": "password123!Secure"},
|
||||
headers={"HX-Request": "true", "X-CSRF-Token": token},
|
||||
)
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.headers["HX-Redirect"] == "https://app.example.com/private"
|
||||
|
||||
res2 = await client.post(
|
||||
"/login/password",
|
||||
data={"username": "alice", "password": "password123!Secure"},
|
||||
headers={"HX-Request": "true", "X-CSRF-Token": await _login_page_csrf(client)},
|
||||
)
|
||||
assert res2.headers["HX-Redirect"] == "/manage/credentials"
|
||||
|
||||
|
||||
async def test_login_ignores_disallowed_forward_auth_return_to(client: AsyncClient) -> None:
|
||||
_allow_forward_auth_hosts(client, ["app.example.com"])
|
||||
await _create_user(client)
|
||||
|
||||
await _login_page_csrf(client, "https://app.example.com/private")
|
||||
token = await _login_page_csrf(client, "https://evil.example.net/private")
|
||||
res = await client.post(
|
||||
"/login/password",
|
||||
data={"username": "alice", "password": "password123!Secure"},
|
||||
headers={"HX-Request": "true", "X-CSRF-Token": token},
|
||||
)
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.headers["HX-Redirect"] == "/manage/credentials"
|
||||
|
||||
|
||||
async def test_forward_auth_authenticated_user_returns_identity_headers(client: AsyncClient) -> None:
|
||||
await _create_user(client, groups=["users", "admin"], email="alice@example.com")
|
||||
await _login(client)
|
||||
|
||||
res = await client.get("/forward-auth", follow_redirects=False)
|
||||
|
||||
assert res.status_code == 204
|
||||
assert res.headers["remote-user"] == "alice"
|
||||
assert res.headers["remote-email"] == "alice@example.com"
|
||||
assert res.headers["remote-groups"] == "admin,users"
|
||||
assert res.headers["x-forwarded-user"] == "alice"
|
||||
assert res.headers["x-forwarded-userid"] == "forward-user-01"
|
||||
assert res.headers["x-forwarded-preferred-username"] == "Alice"
|
||||
assert res.headers["x-forwarded-email"] == "alice@example.com"
|
||||
assert res.headers["x-forwarded-groups"] == "admin,users"
|
||||
assert res.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
async def test_forward_auth_rejects_inactive_session_user(client: AsyncClient) -> None:
|
||||
user = await _create_user(client)
|
||||
await _login(client)
|
||||
|
||||
app = client._transport.app # type: ignore[union-attr]
|
||||
await app.state.user_repo.update(user.model_copy(update={"active": False}))
|
||||
|
||||
res = await client.get("/forward-auth", follow_redirects=False)
|
||||
|
||||
assert res.status_code == 303
|
||||
assert "remote-user" not in res.headers
|
||||
Loading…
Add table
Add a link
Reference in a new issue