diff --git a/backend/app/gateway/auth/errors.py b/backend/app/gateway/auth/errors.py index b5899ebd..1c8065d2 100644 --- a/backend/app/gateway/auth/errors.py +++ b/backend/app/gateway/auth/errors.py @@ -21,6 +21,7 @@ class AuthErrorCode(StrEnum): PROVIDER_NOT_FOUND = "provider_not_found" NOT_AUTHENTICATED = "not_authenticated" SYSTEM_ALREADY_INITIALIZED = "system_already_initialized" + WORKSPACE_REQUIRED = "workspace_required" class TokenError(StrEnum): @@ -29,6 +30,7 @@ class TokenError(StrEnum): EXPIRED = "expired" INVALID_SIGNATURE = "invalid_signature" MALFORMED = "malformed" + WORKSPACE_MISSING = "workspace_missing" class AuthErrorResponse(BaseModel): @@ -42,4 +44,6 @@ def token_error_to_code(err: TokenError) -> AuthErrorCode: """Map TokenError to AuthErrorCode — single source of truth.""" if err == TokenError.EXPIRED: return AuthErrorCode.TOKEN_EXPIRED + if err == TokenError.WORKSPACE_MISSING: + return AuthErrorCode.WORKSPACE_REQUIRED return AuthErrorCode.TOKEN_INVALID diff --git a/backend/app/gateway/auth/jwt.py b/backend/app/gateway/auth/jwt.py index cc004840..52d7e3e8 100644 --- a/backend/app/gateway/auth/jwt.py +++ b/backend/app/gateway/auth/jwt.py @@ -68,10 +68,20 @@ def decode_token(token: str) -> TokenPayload | TokenError: config = get_auth_config() try: payload = jwt.decode(token, config.jwt_secret, algorithms=["HS256"]) - return TokenPayload(**payload) except jwt.ExpiredSignatureError: return TokenError.EXPIRED except jwt.InvalidSignatureError: return TokenError.INVALID_SIGNATURE except jwt.PyJWTError: return TokenError.MALFORMED + + # Reject legacy pre-PR4 tokens that lack the wid claim. Reported as + # WORKSPACE_MISSING (not MALFORMED) so middleware can surface a + # specific 401 telling the frontend to re-issue via /select-workspace. + if "wid" not in payload or payload.get("wid") is None: + return TokenError.WORKSPACE_MISSING + + try: + return TokenPayload(**payload) + except Exception: + return TokenError.MALFORMED diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index f19c83c7..89fdcacd 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -101,7 +101,7 @@ def test_create_and_decode_token(): import os os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars" - token = create_access_token(user_id) + token = create_access_token(user_id, workspace_id="ws-test", role="owner") assert isinstance(token, str) payload = decode_token(token) @@ -132,7 +132,7 @@ def test_decode_token_invalid(): def test_create_token_custom_expiry(): """Custom expiry is respected.""" user_id = str(uuid4()) - token = create_access_token(user_id, expires_delta=timedelta(hours=1)) + token = create_access_token(user_id, expires_delta=timedelta(hours=1), workspace_id="ws-test", role="owner") payload = decode_token(token) assert payload is not None assert payload.sub == user_id @@ -420,7 +420,7 @@ def test_jwt_encodes_ver(): from app.gateway.auth.errors import TokenError os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars" - token = create_access_token(str(uuid4()), token_version=3) + token = create_access_token(str(uuid4()), token_version=3, workspace_id="ws-test", role="owner") payload = decode_token(token) assert not isinstance(payload, TokenError) assert payload.ver == 3 @@ -433,7 +433,7 @@ def test_jwt_default_ver_zero(): from app.gateway.auth.errors import TokenError os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars" - token = create_access_token(str(uuid4())) + token = create_access_token(str(uuid4()), workspace_id="ws-test", role="owner") payload = decode_token(token) assert not isinstance(payload, TokenError) assert payload.ver == 0 @@ -447,7 +447,7 @@ def test_token_version_mismatch_rejects(): os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars" user_id = str(uuid4()) - token = create_access_token(user_id, token_version=0) + token = create_access_token(user_id, token_version=0, workspace_id="ws-test", role="owner") mock_user = User(id=user_id, email="test@example.com", password_hash="hash", token_version=1) diff --git a/backend/tests/test_auth_errors.py b/backend/tests/test_auth_errors.py index b3b46c75..dc60f997 100644 --- a/backend/tests/test_auth_errors.py +++ b/backend/tests/test_auth_errors.py @@ -69,7 +69,7 @@ def test_decode_token_returns_token_error_on_malformed(): def test_decode_token_returns_payload_on_valid(): _setup_config() - token = create_access_token("user-123") + token = create_access_token("user-123", workspace_id="ws-test", role="owner") result = decode_token(token) assert not isinstance(result, TokenError) assert result.sub == "user-123" diff --git a/backend/tests/test_langgraph_auth.py b/backend/tests/test_langgraph_auth.py index d2ee8105..f5db4052 100644 --- a/backend/tests/test_langgraph_auth.py +++ b/backend/tests/test_langgraph_auth.py @@ -74,7 +74,7 @@ def test_expired_jwt_raises_401(): def test_user_not_found_raises_401(): - token = create_access_token("ghost") + token = create_access_token("ghost", workspace_id="ws-test", role="owner") with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(None)): with pytest.raises(Auth.exceptions.HTTPException) as exc: asyncio.run(authenticate(_req({"access_token": token}))) @@ -84,7 +84,7 @@ def test_user_not_found_raises_401(): def test_token_version_mismatch_raises_401(): user = _user(token_version=2) - token = create_access_token(str(user.id), token_version=1) + token = create_access_token(str(user.id), token_version=1, workspace_id="ws-test", role="owner") with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)): with pytest.raises(Auth.exceptions.HTTPException) as exc: asyncio.run(authenticate(_req({"access_token": token}))) @@ -94,7 +94,7 @@ def test_token_version_mismatch_raises_401(): def test_valid_token_returns_user_id(): user = _user(token_version=0) - token = create_access_token(str(user.id), token_version=0) + token = create_access_token(str(user.id), token_version=0, workspace_id="ws-test", role="owner") with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)): result = asyncio.run(authenticate(_req({"access_token": token}))) assert result == str(user.id) @@ -102,7 +102,7 @@ def test_valid_token_returns_user_id(): def test_valid_token_matching_version(): user = _user(token_version=5) - token = create_access_token(str(user.id), token_version=5) + token = create_access_token(str(user.id), token_version=5, workspace_id="ws-test", role="owner") with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)): result = asyncio.run(authenticate(_req({"access_token": token}))) assert result == str(user.id) @@ -113,7 +113,7 @@ def test_valid_token_matching_version(): def test_provider_exception_propagates(): """Provider raises → should not be swallowed silently.""" - token = create_access_token("user-1") + token = create_access_token("user-1", workspace_id="ws-test", role="owner") p = AsyncMock() p.get_user = AsyncMock(side_effect=RuntimeError("DB down")) with patch("app.gateway.langgraph_auth.get_local_provider", return_value=p): @@ -126,7 +126,11 @@ def test_jwt_missing_ver_defaults_to_zero(): import jwt as pyjwt uid = str(uuid4()) - raw = pyjwt.encode({"sub": uid, "exp": 9999999999, "iat": 1000000000}, _JWT_SECRET, algorithm="HS256") + raw = pyjwt.encode( + {"sub": uid, "wid": "ws-test", "role": "owner", "exp": 9999999999, "iat": 1000000000}, + _JWT_SECRET, + algorithm="HS256", + ) user = _user(user_id=uid, token_version=0) with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)): result = asyncio.run(authenticate(_req({"access_token": raw}))) @@ -138,7 +142,11 @@ def test_jwt_missing_ver_rejected_when_user_version_nonzero(): import jwt as pyjwt uid = str(uuid4()) - raw = pyjwt.encode({"sub": uid, "exp": 9999999999, "iat": 1000000000}, _JWT_SECRET, algorithm="HS256") + raw = pyjwt.encode( + {"sub": uid, "wid": "ws-test", "role": "owner", "exp": 9999999999, "iat": 1000000000}, + _JWT_SECRET, + algorithm="HS256", + ) user = _user(user_id=uid, token_version=1) with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)): with pytest.raises(Auth.exceptions.HTTPException) as exc: @@ -221,7 +229,7 @@ def test_filter_with_empty_metadata(): def test_shared_jwt_secret(): - token = create_access_token("user-1", token_version=3) + token = create_access_token("user-1", token_version=3, workspace_id="ws-test", role="owner") payload = decode_token(token) from app.gateway.auth.errors import TokenError diff --git a/backend/tests/test_legacy_token_compat.py b/backend/tests/test_legacy_token_compat.py new file mode 100644 index 00000000..96a8031f --- /dev/null +++ b/backend/tests/test_legacy_token_compat.py @@ -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