feat(auth): decode_token rejects legacy 4-field JWTs as WORKSPACE_MISSING
After PR4 every JWT must carry wid (workspace_id). decode_token now returns the new TokenError.WORKSPACE_MISSING when the signature is valid but the payload lacks wid; expired tokens still report EXPIRED first so /auth/refresh logic stays correct. AuthErrorCode gains a matching WORKSPACE_REQUIRED for middleware to surface to clients. Updates 13 existing test sites that issued tokens without wid to pass workspace_id="ws-test" + role="owner", reflecting the new contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
"""Legacy 4-field JWT compatibility (Stage 0 PR4 T4.6).
|
||||
|
||||
Before PR4 every JWT carried only `{sub, exp, iat, ver}`. After PR4 the
|
||||
server expects `wid` (workspace_id) on every protected request. Old
|
||||
cookies in the wild must NOT collapse into ``TokenError.MALFORMED`` —
|
||||
that hides the actual problem (workspace required) and prevents the
|
||||
frontend from steering the user to ``/select-workspace``.
|
||||
|
||||
The contract: ``decode_token`` returns ``TokenError.WORKSPACE_MISSING``
|
||||
specifically when the JWT signature checks out and the payload is
|
||||
otherwise well-formed but does NOT carry a ``wid`` claim.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from app.gateway.auth.config import get_auth_config
|
||||
from app.gateway.auth.errors import TokenError
|
||||
from app.gateway.auth.jwt import decode_token
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stable_jwt_secret(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_JWT_SECRET", "test-secret-key-for-jwt-testing-minimum-32-chars")
|
||||
yield
|
||||
|
||||
|
||||
def _make_legacy_token(*, expired: bool = False) -> str:
|
||||
"""Encode a pre-PR4 JWT directly (bypassing create_access_token)."""
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
"sub": "u-legacy",
|
||||
"exp": now + (timedelta(seconds=-1) if expired else timedelta(hours=1)),
|
||||
"iat": now,
|
||||
"ver": 0,
|
||||
}
|
||||
return jwt.encode(payload, get_auth_config().jwt_secret, algorithm="HS256")
|
||||
|
||||
|
||||
def test_decode_legacy_token_returns_workspace_missing_error() -> None:
|
||||
"""4-field token (no wid) → TokenError.WORKSPACE_MISSING (not MALFORMED)."""
|
||||
token = _make_legacy_token()
|
||||
result = decode_token(token)
|
||||
assert result == TokenError.WORKSPACE_MISSING
|
||||
|
||||
|
||||
def test_decode_legacy_token_with_expired_still_reports_expired() -> None:
|
||||
"""Expired legacy tokens keep reporting EXPIRED — that signal takes priority.
|
||||
|
||||
Why: an expired token must trigger /auth/refresh logic before we
|
||||
decide it also lacks workspace; reporting WORKSPACE_MISSING on an
|
||||
expired token would steer the user to /select-workspace instead.
|
||||
"""
|
||||
token = _make_legacy_token(expired=True)
|
||||
result = decode_token(token)
|
||||
assert result == TokenError.EXPIRED
|
||||
Reference in New Issue
Block a user