fix(security): reset session on login to prevent fixation

Both password and WebAuthn login wrote the authenticated identity onto the
existing pre-auth session, so a fixed/planted session could be elevated to an
authenticated one. Add _establish_authenticated_session() which clears the
session (preserving only a pending OIDC authorization request) before setting
the identity, used by both login paths.

Tests that reused a pre-login CSRF token now re-fetch it after login, matching
real client behavior.

Refs: porchlight-vxr

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan Lundberg 2026-06-04 14:23:08 +02:00
parent 7c4dbf2cd9
commit 407db57279
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
5 changed files with 65 additions and 6 deletions

View file

@ -23,6 +23,21 @@ def _login_redirect_target(request: Request) -> str:
return "/manage/credentials"
def _establish_authenticated_session(request: Request, user: User) -> None:
"""Reset the session before recording the authenticated identity.
Clearing first defeats session fixation: any values an attacker planted in
the pre-auth session are dropped and the session cookie is reissued. A
pending OIDC authorization request is the only pre-auth state worth keeping.
"""
pending_oidc = request.session.get("oidc_auth_request")
request.session.clear()
if pending_oidc is not None:
request.session["oidc_auth_request"] = pending_oidc
request.session["userid"] = user.userid
request.session["username"] = user.username
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request) -> HTMLResponse:
templates = request.app.state.templates
@ -56,8 +71,7 @@ async def login_password(
if not user.active:
return HTMLResponse(error_html)
request.session["userid"] = user.userid
request.session["username"] = user.username
_establish_authenticated_session(request, user)
response = Response()
response.headers["HX-Redirect"] = _login_redirect_target(request)
@ -167,7 +181,6 @@ async def login_webauthn_complete(request: Request) -> Response:
if user is None or not user.active:
return JSONResponse({"error": "Authentication failed"}, status_code=400)
request.session["userid"] = user.userid
request.session["username"] = user.username
_establish_authenticated_session(request, user)
return JSONResponse({"redirect": _login_redirect_target(request)})