TASK-004: Bootstrap frontend auth state from backend session check

Validates session on app mount by calling GET /api/auth/session instead of relying
solely on cached sessionStorage. This ensures the UI state always reflects server
reality — expired or revoked sessions are detected immediately.

Changes:
- Backend: Add GET /api/auth/session endpoint (requires valid session, returns 200/401)
- Frontend: Add useSessionValidation hook for mount-time validation
- Frontend: Add SessionValidationLoading component for validation spinner
- Frontend: Update AuthProvider to call validation on mount with loading state
- Frontend: Add validateSession API function
- Docs: Update Features.md with session validation behavior
- Docs: Update Web-Development.md with session validation pattern

Handles three outcomes:
1. Valid session (200): Proceed with cached state
2. Invalid session (401): Clear sessionStorage and redirect to login
3. Network error: Don't logout (backend may be temporarily unreachable)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-26 12:00:21 +02:00
parent d982fe3efc
commit 29daaa9906
11 changed files with 1314 additions and 15 deletions

View File

@@ -12,7 +12,7 @@ from __future__ import annotations
import structlog
from fastapi import APIRouter, HTTPException, Request, Response, status
from app.dependencies import DbDep, SessionCacheDep, SessionRepoDep, SettingsDep
from app.dependencies import AuthDep, DbDep, SessionCacheDep, SessionRepoDep, SettingsDep
from app.models.auth import LoginRequest, LoginResponse, LogoutResponse
from app.services import auth_service
from app.utils.constants import SESSION_COOKIE_NAME
@@ -76,6 +76,31 @@ async def login(
return LoginResponse(token=signed_token, expires_at=expires_at)
@router.get(
"/session",
summary="Validate the current session",
)
async def validate_session(
_: AuthDep,
) -> dict[str, bool]:
"""Validate the current session.
This endpoint requires a valid session and returns 200 if the session is
valid and still active. If the session is invalid, expired, or missing,
FastAPI's ``require_auth`` dependency returns 401 automatically.
The frontend calls this on mount to bootstrap its authentication state
from the backend rather than relying solely on cached ``sessionStorage``.
Args:
_: The injected session object (unused, but its presence triggers validation).
Returns:
A simple JSON object confirming the session is valid.
"""
return {"valid": True}
@router.post(
"/logout",
response_model=LogoutResponse,

View File

@@ -202,6 +202,68 @@ class TestRequireAuth:
assert call_count == 2
# ---------------------------------------------------------------------------
# Session validation (Task 4)
# ---------------------------------------------------------------------------
class TestValidateSession:
"""GET /api/auth/session."""
async def test_validate_session_returns_200_with_valid_token(
self, client: AsyncClient
) -> None:
"""Validate session returns 200 for a valid authenticated request."""
await _do_setup(client)
await _login(client)
response = await client.get("/api/auth/session")
assert response.status_code == 200
assert response.json() == {"valid": True}
async def test_validate_session_returns_401_without_token(
self, client: AsyncClient
) -> None:
"""Validate session returns 401 when no token is present."""
await _do_setup(client)
response = await client.get("/api/auth/session")
assert response.status_code == 401
async def test_validate_session_returns_401_with_invalid_token(
self, client: AsyncClient
) -> None:
"""Validate session returns 401 for an invalid or expired token."""
await _do_setup(client)
response = await client.get(
"/api/auth/session",
headers={"Authorization": "Bearer invalidtoken"},
)
assert response.status_code == 401
async def test_validate_session_with_cookie(
self, client: AsyncClient
) -> None:
"""Validate session works with cookie-based authentication."""
await _do_setup(client)
token = await _login(client)
# Login sets the cookie on the client automatically via httpx.
response = await client.get("/api/auth/session")
assert response.status_code == 200
assert response.json() == {"valid": True}
async def test_validate_session_after_logout(
self, client: AsyncClient
) -> None:
"""Validate session returns 401 after logout."""
await _do_setup(client)
token = await _login(client)
await client.post("/api/auth/logout")
response = await client.get(
"/api/auth/session",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 401
# ---------------------------------------------------------------------------
# Session-token cache (Task 4)
# ---------------------------------------------------------------------------