diff --git a/backend/app/gateway/routers/auth.py b/backend/app/gateway/routers/auth.py index ff4a8e72..dd8a7ff5 100644 --- a/backend/app/gateway/routers/auth.py +++ b/backend/app/gateway/routers/auth.py @@ -340,7 +340,15 @@ async def login_local( ) _record_login_success(client_ip) - token = create_access_token(str(user.id), token_version=user.token_version) + # Ensure the user has a workspace (covers pre-PR4 users still in DB + # whose default_workspace_id was never backfilled by the lifespan hook). + workspace_id = await _ensure_default_workspace(user) + token = create_access_token( + str(user.id), + token_version=user.token_version, + workspace_id=workspace_id, + role="owner", + ) _set_session_cookie(response, token, request) return LoginResponse( @@ -423,8 +431,17 @@ async def change_password(request: Request, response: Response, body: ChangePass await provider.update_user(user) - # Re-issue cookie with new token_version - token = create_access_token(str(user.id), token_version=user.token_version) + # Re-issue cookie with new token_version. wid + role must be carried + # forward so the re-signed JWT still passes the AuthMiddleware + # workspace gate; _ensure_default_workspace fills in for the (rare) + # case where the user predates PR4 and has not been backfilled. + workspace_id = await _ensure_default_workspace(user) + token = create_access_token( + str(user.id), + token_version=user.token_version, + workspace_id=workspace_id, + role="owner", + ) _set_session_cookie(response, token, request) return MessageResponse(message="Password changed successfully") diff --git a/backend/tests/test_change_password_keeps_wid.py b/backend/tests/test_change_password_keeps_wid.py new file mode 100644 index 00000000..9dcaacd4 --- /dev/null +++ b/backend/tests/test_change_password_keeps_wid.py @@ -0,0 +1,108 @@ +"""``change_password`` and ``login_local`` re-issue JWTs carrying wid + role. + +Stage 0 PR4 T4.12. The contract: + +- After ``POST /auth/change-password`` the new session cookie's JWT must + still encode the user's workspace under ``wid`` (and ``role='owner'``), + with ``ver`` bumped. Dropping ``wid`` here would lock the user out of + every protected endpoint immediately after a password change. +- Same for ``POST /auth/login/local`` — it issues a fresh JWT and must + also encode ``wid``. +""" + +from __future__ import annotations + +import asyncio +import os + +import jwt +import pytest +from fastapi.testclient import TestClient + +os.environ.setdefault("AUTH_JWT_SECRET", "test-secret-key-change-password-wid-32x") + +from app.gateway.auth.config import AuthConfig, set_auth_config + +_TEST_SECRET = "test-secret-key-change-password-wid-32x" + + +@pytest.fixture(autouse=True) +def _setup_auth(tmp_path): + from app.gateway import deps + from app.gateway.routers.auth import _SETUP_STATUS_COOLDOWN + from deerflow.persistence.engine import close_engine, init_engine + + set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET)) + url = f"sqlite+aiosqlite:///{tmp_path}/change_pwd.db" + asyncio.run(init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))) + deps._cached_local_provider = None + deps._cached_repo = None + _SETUP_STATUS_COOLDOWN.clear() + try: + yield + finally: + deps._cached_local_provider = None + deps._cached_repo = None + _SETUP_STATUS_COOLDOWN.clear() + asyncio.run(close_engine()) + + +@pytest.fixture() +def client(_setup_auth): + from app.gateway.app import create_app + + set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET)) + yield TestClient(create_app()) + + +def _decode(token: str) -> dict: + return jwt.decode(token, _TEST_SECRET, algorithms=["HS256"]) + + +def _bootstrap_user(client) -> tuple[str, dict]: + """Register a user and return (user_id, initial claims).""" + client.post("/api/v1/auth/initialize", json={"email": "admin@example.com", "password": "Str0ng!Pass99"}) + resp = client.post("/api/v1/auth/register", json={"email": "alice@example.com", "password": "Tr0ub4dor3a-strong!"}) + assert resp.status_code == 201, resp.text + return resp.json()["id"], _decode(resp.cookies["access_token"]) + + +def test_change_password_keeps_wid_and_bumps_ver(client): + """change_password re-signs the JWT with wid + role; ver moves forward.""" + user_id, initial_claims = _bootstrap_user(client) + + # /register set the csrf cookie; the matching header is required on + # the change-password POST (Double Submit Cookie pattern). + csrf = client.cookies.get("csrf_token") + assert csrf, "register must have set csrf_token cookie" + + resp = client.post( + "/api/v1/auth/change-password", + json={"current_password": "Tr0ub4dor3a-strong!", "new_password": "Tr0ub4dor3a-strong2!"}, + headers={"X-CSRF-Token": csrf}, + ) + assert resp.status_code == 200, resp.text + + new_claims = _decode(resp.cookies["access_token"]) + assert new_claims["sub"] == user_id + assert new_claims["wid"] == initial_claims["wid"], "wid must survive a password change" + assert new_claims["role"] == "owner" + assert new_claims["ver"] == initial_claims["ver"] + 1, "token_version must advance" + + +def test_login_issues_wid_carrying_jwt(client): + """/auth/login/local issues a JWT that the workspace middleware will accept.""" + user_id, _ = _bootstrap_user(client) + + # Clear the session cookie set by /register so the login response is observed in isolation. + client.cookies.clear() + resp = client.post( + "/api/v1/auth/login/local", + data={"username": "alice@example.com", "password": "Tr0ub4dor3a-strong!"}, + ) + assert resp.status_code == 200, resp.text + + claims = _decode(resp.cookies["access_token"]) + assert claims["sub"] == user_id + assert claims.get("wid"), "login must include wid" + assert claims["role"] == "owner"