TASK-033: Remove session token from JSON response body

Fixes a critical security vulnerability where the session token was
being returned in the JSON response body of POST /api/auth/login.
This exposed the token to JavaScript, allowing malicious scripts to
steal it and bypass the HttpOnly cookie protection.

Changes:
- Backend: Remove 'token' field from LoginResponse model (auth.py)
- Backend: Update login() endpoint to return only 'expires_at'
- Frontend: Update LoginResponse type to exclude 'token' field
- Backend: Update test helper _login() to extract token from cookie
- Backend: Update test cases to verify token is NOT in response body
- Documentation: Add section 'Authentication Endpoints' in Backend-Development.md
- Documentation: Update Web-Development.md to explain HttpOnly cookie benefits

Security benefit: Session tokens are now only accessible via HttpOnly
cookies, protected from JavaScript access, XSS attacks, and malicious
third-party scripts. The frontend continues to use only the cookie for
authentication.

All auth tests pass (23 tests). Type checking and linting pass with
zero errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-26 19:38:33 +02:00
parent e2560f5db0
commit 93021500c3
7 changed files with 93 additions and 58 deletions

View File

@@ -21,13 +21,17 @@ class LoginRequest(BaseModel):
class LoginResponse(BaseModel):
"""Successful login response.
The session token is also set as an ``HttpOnly`` cookie by the router.
This model documents the JSON body for API-first consumers.
The session token is set as an ``HttpOnly`` ``SameSite=Lax`` cookie by the
router, protecting it from JavaScript access. The JSON body contains only
the expiry timestamp, allowing the frontend to know when to prompt for
re-authentication.
For programmatic API clients that require a token in the response body,
use ``POST /api/auth/token`` instead, which does not set a cookie.
"""
model_config = ConfigDict(strict=True)
token: str = Field(..., description="Session token for use in subsequent requests.")
expires_at: str = Field(..., description="ISO 8601 UTC expiry timestamp.")

View File

@@ -3,8 +3,14 @@
``POST /api/auth/login`` — verify master password and issue a session.
``POST /api/auth/logout`` — revoke the current session.
The session token is returned both in the JSON body (for API-first
consumers) and as an ``HttpOnly`` cookie (for the browser SPA).
The session token is set as an ``HttpOnly`` ``SameSite=Lax`` cookie for
browser-based SPAs. The cookie is automatically included in all requests
and is inaccessible to JavaScript, protecting it from XSS attacks and
malicious scripts.
For programmatic API clients (non-browser), use ``POST /api/auth/token``
which returns a token in the response body for use in the ``Authorization``
header. This endpoint does not set a cookie.
Login attempts are rate-limited to 5 per minute per IP address to prevent
brute-force attacks. Requests exceeding the limit return ``429 Too Many Requests``
@@ -117,7 +123,7 @@ async def login(
max_age=settings.session_duration_minutes * 60,
)
log.info("login_success", client_ip=client_ip)
return LoginResponse(token=signed_token, expires_at=expires_at)
return LoginResponse(expires_at=expires_at)
@router.get(

View File

@@ -30,10 +30,16 @@ async def _do_setup(client: AsyncClient) -> None:
async def _login(client: AsyncClient, password: str = "Mysecretpass1!") -> str:
"""Helper: perform login and return the session token."""
"""Helper: perform login and return the session token from the cookie.
Note: The token is returned in the HttpOnly cookie, not in the JSON body.
For testing Bearer token auth, we extract it from the cookie.
"""
resp = await client.post("/api/auth/login", json={"password": password})
assert resp.status_code == 200
return str(resp.json()["token"])
token = resp.cookies.get(SESSION_COOKIE_NAME)
assert token is not None
return str(token)
# ---------------------------------------------------------------------------
@@ -47,16 +53,15 @@ class TestLogin:
async def test_login_succeeds_with_correct_password(
self, client: AsyncClient
) -> None:
"""Login returns 200 and a session token for the correct password."""
"""Login returns 200 and sets a session cookie for the correct password."""
await _do_setup(client)
response = await client.post(
"/api/auth/login", json={"password": "Mysecretpass1!"}
)
assert response.status_code == 200
body = response.json()
assert "token" in body
assert len(body["token"]) > 0
assert "." in body["token"]
# Token is not returned in the JSON body; it's set as an HttpOnly cookie
assert "token" not in body
assert "expires_at" in body
async def test_login_sets_cookie(self, client: AsyncClient) -> None: