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:
@@ -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.")
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user