Second commit in the session-expiration-policy series. Lands the error-detail taxonomy from §4.10 of the plan; no UI-visible change yet because the frontend interceptor (commit 7) doesn't read the new detail strings, but the wire is now ready for it. Today every /auth/refresh failure returns 401 "Invalid refresh token" regardless of cause, so the frontend has no way to distinguish "your session ended for security" from "we don't recognize this token at all." This commit introduces: - decode_refresh_token_strict(): wraps jose.jwt.decode and raises a new IdleTokenExpired exception (from ExpiredSignatureError) so callers can branch on idle expiry. All other jose failures still propagate as JWTError. The legacy decode_token() is preserved for access-token, password-reset, and email-verification paths that don't need the distinction. - get_refresh_token_payload(): now maps IdleTokenExpired -> "session_expired_idle", JWTError and wrong-type tokens -> "invalid_refresh_token". - test_session_policy.py: new test file (will accumulate cases across the series). Three tests for the taxonomy: idle-expired returns session_expired_idle; wrong type returns invalid_refresh_token; bad signature returns invalid_refresh_token. 20/20 across test_session_policy + test_auth + test_oauth_callbacks. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
104 lines
3.1 KiB
Python
104 lines
3.1 KiB
Python
"""Tests for the session-expiration-policy series.
|
|
|
|
See docs/plans/2026-05-13-session-expiration-policy.md.
|
|
Test numbers below correspond to the cases listed in §6 of the plan.
|
|
|
|
This file grows across commits — commit 2 lands the error-detail
|
|
taxonomy tests (#11 + a wrong-type case + a bad-signature case).
|
|
"""
|
|
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from jose import jwt
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
def _encode_refresh_token(
|
|
*,
|
|
sub: str,
|
|
exp: datetime,
|
|
token_type: str = "refresh",
|
|
secret: str | None = None,
|
|
) -> str:
|
|
"""Build a refresh JWT with arbitrary `exp` for testing.
|
|
|
|
Bypasses create_refresh_token so tests can produce already-expired
|
|
tokens, wrong-type tokens, or wrong-signature tokens.
|
|
"""
|
|
return jwt.encode(
|
|
{
|
|
"sub": sub,
|
|
"type": token_type,
|
|
"jti": str(uuid.uuid4()),
|
|
"exp": exp,
|
|
},
|
|
secret or settings.SECRET_KEY,
|
|
algorithm=settings.ALGORITHM,
|
|
)
|
|
|
|
|
|
class TestRefreshTokenErrorTaxonomy:
|
|
"""§6 test #11 — refresh-token error-detail taxonomy.
|
|
|
|
`/auth/refresh` distinguishes idle expiry from generic invalid-token
|
|
failures via `detail`, so the frontend can choose between the "session
|
|
ended for security" banner and a plain logout redirect.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_idle_expired_refresh_returns_session_expired_idle(
|
|
self, client: AsyncClient, test_user: dict
|
|
):
|
|
token = _encode_refresh_token(
|
|
sub=test_user["user_data"]["id"],
|
|
exp=datetime.now(timezone.utc) - timedelta(seconds=1),
|
|
)
|
|
|
|
response = await client.post(
|
|
"/api/v1/auth/refresh",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
assert response.json()["detail"] == "session_expired_idle"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wrong_type_token_returns_invalid_refresh_token(
|
|
self, client: AsyncClient, test_user: dict
|
|
):
|
|
token = _encode_refresh_token(
|
|
sub=test_user["user_data"]["id"],
|
|
exp=datetime.now(timezone.utc) + timedelta(minutes=5),
|
|
token_type="access",
|
|
)
|
|
|
|
response = await client.post(
|
|
"/api/v1/auth/refresh",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
assert response.json()["detail"] == "invalid_refresh_token"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bad_signature_returns_invalid_refresh_token(
|
|
self, client: AsyncClient, test_user: dict
|
|
):
|
|
token = _encode_refresh_token(
|
|
sub=test_user["user_data"]["id"],
|
|
exp=datetime.now(timezone.utc) + timedelta(minutes=5),
|
|
secret="not-the-real-secret-key",
|
|
)
|
|
|
|
response = await client.post(
|
|
"/api/v1/auth/refresh",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
assert response.json()["detail"] == "invalid_refresh_token"
|