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
21
README.md
21
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
215
src/porchlight/forward_auth.py
Normal file
215
src/porchlight/forward_auth.py
Normal 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)
|
||||
|
|
@ -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