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:
@@ -21,6 +21,7 @@ class AuthErrorCode(StrEnum):
|
|||||||
PROVIDER_NOT_FOUND = "provider_not_found"
|
PROVIDER_NOT_FOUND = "provider_not_found"
|
||||||
NOT_AUTHENTICATED = "not_authenticated"
|
NOT_AUTHENTICATED = "not_authenticated"
|
||||||
SYSTEM_ALREADY_INITIALIZED = "system_already_initialized"
|
SYSTEM_ALREADY_INITIALIZED = "system_already_initialized"
|
||||||
|
WORKSPACE_REQUIRED = "workspace_required"
|
||||||
|
|
||||||
|
|
||||||
class TokenError(StrEnum):
|
class TokenError(StrEnum):
|
||||||
@@ -29,6 +30,7 @@ class TokenError(StrEnum):
|
|||||||
EXPIRED = "expired"
|
EXPIRED = "expired"
|
||||||
INVALID_SIGNATURE = "invalid_signature"
|
INVALID_SIGNATURE = "invalid_signature"
|
||||||
MALFORMED = "malformed"
|
MALFORMED = "malformed"
|
||||||
|
WORKSPACE_MISSING = "workspace_missing"
|
||||||
|
|
||||||
|
|
||||||
class AuthErrorResponse(BaseModel):
|
class AuthErrorResponse(BaseModel):
|
||||||
@@ -42,4 +44,6 @@ def token_error_to_code(err: TokenError) -> AuthErrorCode:
|
|||||||
"""Map TokenError to AuthErrorCode — single source of truth."""
|
"""Map TokenError to AuthErrorCode — single source of truth."""
|
||||||
if err == TokenError.EXPIRED:
|
if err == TokenError.EXPIRED:
|
||||||
return AuthErrorCode.TOKEN_EXPIRED
|
return AuthErrorCode.TOKEN_EXPIRED
|
||||||
|
if err == TokenError.WORKSPACE_MISSING:
|
||||||
|
return AuthErrorCode.WORKSPACE_REQUIRED
|
||||||
return AuthErrorCode.TOKEN_INVALID
|
return AuthErrorCode.TOKEN_INVALID
|
||||||
|
|||||||
@@ -68,10 +68,20 @@ def decode_token(token: str) -> TokenPayload | TokenError:
|
|||||||
config = get_auth_config()
|
config = get_auth_config()
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, config.jwt_secret, algorithms=["HS256"])
|
payload = jwt.decode(token, config.jwt_secret, algorithms=["HS256"])
|
||||||
return TokenPayload(**payload)
|
|
||||||
except jwt.ExpiredSignatureError:
|
except jwt.ExpiredSignatureError:
|
||||||
return TokenError.EXPIRED
|
return TokenError.EXPIRED
|
||||||
except jwt.InvalidSignatureError:
|
except jwt.InvalidSignatureError:
|
||||||
return TokenError.INVALID_SIGNATURE
|
return TokenError.INVALID_SIGNATURE
|
||||||
except jwt.PyJWTError:
|
except jwt.PyJWTError:
|
||||||
return TokenError.MALFORMED
|
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
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ def test_create_and_decode_token():
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars"
|
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)
|
assert isinstance(token, str)
|
||||||
|
|
||||||
payload = decode_token(token)
|
payload = decode_token(token)
|
||||||
@@ -132,7 +132,7 @@ def test_decode_token_invalid():
|
|||||||
def test_create_token_custom_expiry():
|
def test_create_token_custom_expiry():
|
||||||
"""Custom expiry is respected."""
|
"""Custom expiry is respected."""
|
||||||
user_id = str(uuid4())
|
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)
|
payload = decode_token(token)
|
||||||
assert payload is not None
|
assert payload is not None
|
||||||
assert payload.sub == user_id
|
assert payload.sub == user_id
|
||||||
@@ -420,7 +420,7 @@ def test_jwt_encodes_ver():
|
|||||||
from app.gateway.auth.errors import TokenError
|
from app.gateway.auth.errors import TokenError
|
||||||
|
|
||||||
os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars"
|
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)
|
payload = decode_token(token)
|
||||||
assert not isinstance(payload, TokenError)
|
assert not isinstance(payload, TokenError)
|
||||||
assert payload.ver == 3
|
assert payload.ver == 3
|
||||||
@@ -433,7 +433,7 @@ def test_jwt_default_ver_zero():
|
|||||||
from app.gateway.auth.errors import TokenError
|
from app.gateway.auth.errors import TokenError
|
||||||
|
|
||||||
os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars"
|
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)
|
payload = decode_token(token)
|
||||||
assert not isinstance(payload, TokenError)
|
assert not isinstance(payload, TokenError)
|
||||||
assert payload.ver == 0
|
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"
|
os.environ["AUTH_JWT_SECRET"] = "test-secret-key-for-jwt-testing-minimum-32-chars"
|
||||||
|
|
||||||
user_id = str(uuid4())
|
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)
|
mock_user = User(id=user_id, email="test@example.com", password_hash="hash", token_version=1)
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ def test_decode_token_returns_token_error_on_malformed():
|
|||||||
|
|
||||||
def test_decode_token_returns_payload_on_valid():
|
def test_decode_token_returns_payload_on_valid():
|
||||||
_setup_config()
|
_setup_config()
|
||||||
token = create_access_token("user-123")
|
token = create_access_token("user-123", workspace_id="ws-test", role="owner")
|
||||||
result = decode_token(token)
|
result = decode_token(token)
|
||||||
assert not isinstance(result, TokenError)
|
assert not isinstance(result, TokenError)
|
||||||
assert result.sub == "user-123"
|
assert result.sub == "user-123"
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ def test_expired_jwt_raises_401():
|
|||||||
|
|
||||||
|
|
||||||
def test_user_not_found_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 patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(None)):
|
||||||
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
||||||
asyncio.run(authenticate(_req({"access_token": token})))
|
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():
|
def test_token_version_mismatch_raises_401():
|
||||||
user = _user(token_version=2)
|
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 patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)):
|
||||||
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
||||||
asyncio.run(authenticate(_req({"access_token": token})))
|
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():
|
def test_valid_token_returns_user_id():
|
||||||
user = _user(token_version=0)
|
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)):
|
with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)):
|
||||||
result = asyncio.run(authenticate(_req({"access_token": token})))
|
result = asyncio.run(authenticate(_req({"access_token": token})))
|
||||||
assert result == str(user.id)
|
assert result == str(user.id)
|
||||||
@@ -102,7 +102,7 @@ def test_valid_token_returns_user_id():
|
|||||||
|
|
||||||
def test_valid_token_matching_version():
|
def test_valid_token_matching_version():
|
||||||
user = _user(token_version=5)
|
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)):
|
with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)):
|
||||||
result = asyncio.run(authenticate(_req({"access_token": token})))
|
result = asyncio.run(authenticate(_req({"access_token": token})))
|
||||||
assert result == str(user.id)
|
assert result == str(user.id)
|
||||||
@@ -113,7 +113,7 @@ def test_valid_token_matching_version():
|
|||||||
|
|
||||||
def test_provider_exception_propagates():
|
def test_provider_exception_propagates():
|
||||||
"""Provider raises → should not be swallowed silently."""
|
"""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 = AsyncMock()
|
||||||
p.get_user = AsyncMock(side_effect=RuntimeError("DB down"))
|
p.get_user = AsyncMock(side_effect=RuntimeError("DB down"))
|
||||||
with patch("app.gateway.langgraph_auth.get_local_provider", return_value=p):
|
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
|
import jwt as pyjwt
|
||||||
|
|
||||||
uid = str(uuid4())
|
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)
|
user = _user(user_id=uid, token_version=0)
|
||||||
with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)):
|
with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)):
|
||||||
result = asyncio.run(authenticate(_req({"access_token": raw})))
|
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
|
import jwt as pyjwt
|
||||||
|
|
||||||
uid = str(uuid4())
|
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)
|
user = _user(user_id=uid, token_version=1)
|
||||||
with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)):
|
with patch("app.gateway.langgraph_auth.get_local_provider", return_value=_mock_provider(user)):
|
||||||
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
with pytest.raises(Auth.exceptions.HTTPException) as exc:
|
||||||
@@ -221,7 +229,7 @@ def test_filter_with_empty_metadata():
|
|||||||
|
|
||||||
|
|
||||||
def test_shared_jwt_secret():
|
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)
|
payload = decode_token(token)
|
||||||
from app.gateway.auth.errors import TokenError
|
from app.gateway.auth.errors import TokenError
|
||||||
|
|
||||||
|
|||||||
@@ -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