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:
Johan Lundberg 2026-07-04 23:29:26 +02:00
parent 299f6cb7fc
commit 58da15c825
9 changed files with 446 additions and 1 deletions

View file

@ -96,6 +96,7 @@ variables always take priority over file values.
| `OIDC_OP_INVITE_TTL` | `86400` | Magic link expiry in seconds |
| `OIDC_OP_MANAGE_CLIENT_ID` | `manage-app` | Client ID for the management UI |
| `OIDC_OP_SESSION_HTTPS_ONLY` | `true` | Restrict session cookie to HTTPS (set `false` for local dev) |
| `OIDC_OP_FORWARD_AUTH_ALLOWED_REDIRECT_HOSTS` | `[]` | JSON list of hosts allowed as post-login return targets for `/forward-auth` |
| `OIDC_OP_CONFIG_FILE` | `porchlight.toml` | Path to TOML config file |
Database migrations run automatically on startup.
@ -123,6 +124,26 @@ startup. Only `client_secret` and `redirect_uris` are required; the other
fields have sensible defaults (`response_types = ["code"]`,
`scope = ["openid"]`, `token_endpoint_auth_method = "client_secret_basic"`).
### Forward-auth for reverse proxies
Porchlight exposes `/forward-auth` for proxies such as Traefik, Caddy, and
nginx `auth_request`. Authenticated active users receive `204 No Content` with
identity headers (`Remote-User`, `Remote-Email`, `Remote-Groups`, and
`X-Forwarded-*` equivalents). Anonymous users receive a `303` redirect to the
Porchlight login page.
To redirect users back to a protected app after login, allow that app's host:
```toml
forward_auth_allowed_redirect_hosts = ["app.example.com", "*.apps.example.com"]
```
The endpoint uses `X-Forwarded-Proto`, `X-Forwarded-Host`, and
`X-Forwarded-Uri` (or `X-Original-Url`) to reconstruct the original request URL.
Only exact hosts, `host:port` entries, or explicit `*.example.com` wildcard
suffixes listed in `forward_auth_allowed_redirect_hosts` are accepted as return
targets.
To use a config file at a different path:
```bash

View file

@ -11,6 +11,7 @@ issuer = "https://auth.example.com"
# sqlite_path = "data/oidc_op.db"
# signing_key_path = "data/keys"
# invite_ttl = 86400
# forward_auth_allowed_redirect_hosts = ["app.example.com", "*.apps.example.com"]
# Register OIDC Relying Party clients below.
# Each [clients.<client-id>] section defines one client.

View file

@ -11,6 +11,7 @@ issuer = "https://auth.example.com"
# sqlite_path = "data/oidc_op.db"
# signing_key_path = "data/keys"
# invite_ttl = 86400
# forward_auth_allowed_redirect_hosts = ["app.example.com", "*.apps.example.com"]
# Register OIDC Relying Party clients below.
# Each [clients.<client-id>] section defines one client.

View file

@ -21,6 +21,7 @@ from porchlight.authn.routes import router as authn_router
from porchlight.authn.webauthn import WebAuthnService
from porchlight.config import Settings, StorageBackend
from porchlight.csrf import CSRFMiddleware, generate_csrf_token
from porchlight.forward_auth import router as forward_auth_router
from porchlight.invite.service import MagicLinkService
from porchlight.manage.routes import router as manage_router
from porchlight.oidc.endpoints import router as oidc_router
@ -143,7 +144,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.state.session_secret = session_secret
app.add_middleware(
CSRFMiddleware, # ty: ignore[invalid-argument-type]
exempt_paths={"/token", "/userinfo"},
exempt_paths={"/token", "/userinfo", "/forward-auth"},
check_origin=settings.issuer,
)
app.add_middleware(
@ -181,6 +182,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
# Routers
app.include_router(admin_router)
app.include_router(authn_router)
app.include_router(forward_auth_router)
app.include_router(manage_router)
app.include_router(oidc_router)

View file

@ -6,6 +6,11 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fido2.webauthn import AttestedCredentialData, AuthenticationResponse
from porchlight.authn.acr import ACR_PASSWORD, ACR_WEBAUTHN, SESSION_ACR_KEY
from porchlight.forward_auth import (
FORWARD_AUTH_RETURN_TO_SESSION_KEY,
capture_forward_auth_return_to,
pop_forward_auth_return_to,
)
from porchlight.models import User
from porchlight.rate_limit import limiter
from porchlight.userid import generate_unique_userid
@ -21,6 +26,9 @@ def _login_redirect_target(request: Request) -> str:
"""
if "oidc_auth_request" in request.session:
return "/authorization/complete"
forward_auth_return_to = pop_forward_auth_return_to(request)
if forward_auth_return_to is not None:
return forward_auth_return_to
return "/manage/credentials"
@ -48,9 +56,12 @@ def _establish_authenticated_session(request: Request, user: User, acr: str) ->
surface a truthful Authentication Context Class Reference (see acr.py).
"""
pending_oidc = request.session.get("oidc_auth_request")
pending_forward_auth = request.session.get(FORWARD_AUTH_RETURN_TO_SESSION_KEY)
request.session.clear()
if pending_oidc is not None:
request.session["oidc_auth_request"] = pending_oidc
if pending_forward_auth is not None:
request.session[FORWARD_AUTH_RETURN_TO_SESSION_KEY] = pending_forward_auth
request.session["userid"] = user.userid
request.session["username"] = user.username
request.session[SESSION_ACR_KEY] = acr
@ -58,6 +69,7 @@ def _establish_authenticated_session(request: Request, user: User, acr: str) ->
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request) -> HTMLResponse:
capture_forward_auth_return_to(request)
templates = request.app.state.templates
return templates.TemplateResponse(request, "login.html")

View file

@ -69,6 +69,12 @@ class Settings(BaseSettings):
# many hops from the right. Keep 0 unless deployed behind a known proxy.
trusted_proxy_count: int = 0
# Reverse-proxy forward-auth. Hosts listed here are eligible post-login
# redirect targets when /forward-auth receives X-Forwarded-* request
# metadata. Supports exact hosts, host:port, and explicit "*.example.com"
# wildcard suffixes.
forward_auth_allowed_redirect_hosts: list[str] = []
# Signing keys
signing_key_path: str = "data/keys"

View file

@ -0,0 +1,215 @@
from dataclasses import dataclass
from urllib.parse import quote, urlsplit, urlunsplit
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from starlette.responses import Response
from porchlight.config import Settings
from porchlight.dependencies import get_session_user
from porchlight.models import User
FORWARD_AUTH_RETURN_TO_SESSION_KEY = "forward_auth_return_to"
router = APIRouter(tags=["forward-auth"])
_FORWARD_AUTH_METHODS = ("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
_ORIGINAL_URL_HEADERS = ("x-original-url", "x-forwarded-url")
_ORIGINAL_URI_HEADERS = ("x-forwarded-uri", "x-original-uri")
@dataclass(frozen=True)
class _HostPattern:
host: str
port: int | None = None
scheme: str | None = None
@router.api_route("/forward-auth", methods=list(_FORWARD_AUTH_METHODS))
async def forward_auth(request: Request) -> Response:
"""Reverse-proxy forward-auth endpoint.
Authenticated, active users receive a 204 with identity headers. Anonymous
users are redirected to the login page, optionally with a validated
post-login return URL reconstructed from proxy headers.
"""
session_user = get_session_user(request)
if session_user is None:
return _login_redirect_response(request)
userid, _username = session_user
user = await request.app.state.user_repo.get_by_userid(userid)
if user is None or not user.active:
request.session.clear()
return _login_redirect_response(request)
response = Response(status_code=204, headers=_identity_headers(user))
response.headers["Cache-Control"] = "no-store"
return response
def capture_forward_auth_return_to(request: Request) -> None:
"""Store a validated forward-auth return URL from ``/login`` query params."""
return_to = request.query_params.get("return_to")
if return_to is None:
return
settings: Settings = request.app.state.settings
if is_forward_auth_return_to_allowed(settings, return_to):
request.session[FORWARD_AUTH_RETURN_TO_SESSION_KEY] = return_to
else:
request.session.pop(FORWARD_AUTH_RETURN_TO_SESSION_KEY, None)
def pop_forward_auth_return_to(request: Request) -> str | None:
"""Consume the stored forward-auth return URL if it is still allowed."""
return_to = request.session.pop(FORWARD_AUTH_RETURN_TO_SESSION_KEY, None)
if not isinstance(return_to, str):
return None
settings: Settings = request.app.state.settings
if not is_forward_auth_return_to_allowed(settings, return_to):
return None
return return_to
def is_forward_auth_return_to_allowed(settings: Settings, return_to: str) -> bool:
"""Return true when ``return_to`` is an HTTP(S) URL on an allowed host."""
try:
parsed = urlsplit(return_to)
except ValueError:
return False
if parsed.scheme not in ("http", "https") or parsed.hostname is None:
return False
if parsed.username is not None or parsed.password is not None:
return False
try:
return_port = parsed.port
except ValueError:
return False
for pattern in settings.forward_auth_allowed_redirect_hosts:
if _host_pattern_matches(pattern, parsed.hostname, return_port, parsed.scheme):
return True
return False
def _login_redirect_response(request: Request) -> RedirectResponse:
target = _forwarded_return_to(request)
response = RedirectResponse(_login_url(request, target), status_code=303)
response.headers["Cache-Control"] = "no-store"
return response
def _login_url(request: Request, return_to: str | None) -> str:
settings: Settings = request.app.state.settings
url = f"{settings.issuer.rstrip('/')}/login"
if return_to is None:
return url
return f"{url}?return_to={quote(return_to, safe='')}"
def _forwarded_return_to(request: Request) -> str | None:
settings: Settings = request.app.state.settings
absolute = _first_header_value(request, _ORIGINAL_URL_HEADERS)
if absolute is not None and is_forward_auth_return_to_allowed(settings, absolute):
return absolute
proto = _first_header_value(request, ("x-forwarded-proto",))
host = _first_header_value(request, ("x-forwarded-host",))
uri = _first_header_value(request, _ORIGINAL_URI_HEADERS)
if proto is None or host is None or uri is None:
return None
proto = proto.lower()
if proto not in ("http", "https") or not uri.startswith("/"):
return None
candidate = urlunsplit((proto, host, uri, "", ""))
if not is_forward_auth_return_to_allowed(settings, candidate):
return None
return candidate
def _first_header_value(request: Request, names: tuple[str, ...]) -> str | None:
for name in names:
raw = request.headers.get(name)
if raw is None:
continue
first = raw.split(",", 1)[0].strip()
if first and "\r" not in first and "\n" not in first:
return first
return None
def _identity_headers(user: User) -> dict[str, str]:
display_name = user.preferred_username or user.username
headers = {
"Remote-User": user.username,
"X-Forwarded-User": user.username,
"X-Forwarded-Userid": user.userid,
"X-Forwarded-Preferred-Username": display_name,
}
if user.email:
headers["Remote-Email"] = user.email
headers["X-Forwarded-Email"] = user.email
if user.groups:
groups = ",".join(sorted(user.groups))
headers["Remote-Groups"] = groups
headers["X-Forwarded-Groups"] = groups
return {name: value for name, value in headers.items() if _is_safe_header_value(value)}
def _is_safe_header_value(value: str) -> bool:
if "\r" in value or "\n" in value:
return False
try:
value.encode("latin-1")
except UnicodeEncodeError:
return False
return True
def _host_pattern_matches(pattern: str, hostname: str, port: int | None, scheme: str) -> bool:
parsed = _parse_host_pattern(pattern)
if parsed is None:
return False
if parsed.scheme is not None and parsed.scheme != scheme:
return False
if parsed.port is not None and parsed.port != port:
return False
hostname = hostname.lower().rstrip(".")
if parsed.host.startswith("*."):
suffix = parsed.host[1:]
return hostname.endswith(suffix) and hostname != suffix.removeprefix(".")
return hostname == parsed.host
def _parse_host_pattern(pattern: str) -> _HostPattern | None:
pattern = pattern.strip().lower().rstrip(".")
if not pattern:
return None
if "://" in pattern:
try:
parsed_pattern = urlsplit(pattern)
pattern_host = parsed_pattern.hostname
pattern_port = parsed_pattern.port
except ValueError:
return None
if parsed_pattern.scheme not in ("http", "https") or pattern_host is None:
return None
return _HostPattern(host=pattern_host.rstrip("."), port=pattern_port, scheme=parsed_pattern.scheme)
pattern_port = None
if pattern.count(":") == 1:
possible_host, possible_port = pattern.rsplit(":", 1)
if possible_port.isdigit():
pattern = possible_host.rstrip(".")
pattern_port = int(possible_port)
return _HostPattern(host=pattern, port=pattern_port)

View file

@ -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
View 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