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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user