From aee7362665c988bedcb31c547d262fa3df488470 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Sun, 21 Jun 2026 09:08:34 +0800 Subject: [PATCH] feat(auth): U3/U4/U9 logout-others + whoami cold-start + admin UI + integration tests --- src/agentkit/server/auth/middleware.py | 18 +- src/agentkit/server/auth/session_service.py | 43 +- .../frontend/src/components/layout/TopNav.vue | 15 +- .../server/frontend/src/router/index.ts | 28 +- .../frontend/src/views/admin/UsersView.vue | 219 +++++++++ src/agentkit/server/routes/auth.py | 154 ++++++- tests/integration/auth/__init__.py | 0 tests/integration/auth/test_admin_routes.py | 275 +++++++++++ tests/integration/auth/test_auth_routes.py | 431 ++++++++++++++++++ 9 files changed, 1142 insertions(+), 41 deletions(-) create mode 100644 src/agentkit/server/frontend/src/views/admin/UsersView.vue create mode 100644 tests/integration/auth/__init__.py create mode 100644 tests/integration/auth/test_admin_routes.py create mode 100644 tests/integration/auth/test_auth_routes.py diff --git a/src/agentkit/server/auth/middleware.py b/src/agentkit/server/auth/middleware.py index 0fd2e88..7b66dde 100644 --- a/src/agentkit/server/auth/middleware.py +++ b/src/agentkit/server/auth/middleware.py @@ -47,6 +47,7 @@ class AuthMiddleware(BaseHTTPMiddleware): "/api/v1/auth/login", "/api/v1/auth/refresh", "/api/v1/auth/logout", + "/api/v1/auth/whoami", # Route does its own auth (access OR refresh) "/docs", "/openapi.json", "/redoc", @@ -70,8 +71,21 @@ class AuthMiddleware(BaseHTTPMiddleware): # ------------------------------------------------------------------ def _is_whitelisted(self, path: str) -> bool: - """Return True if ``path`` starts with any whitelisted prefix.""" - return any(path.startswith(p) for p in self.WHITELIST_PATHS) + """Return True if ``path`` matches a whitelisted route. + + Uses exact match for auth routes (so ``/auth/logout`` does NOT + whitelist ``/auth/logout-others``) and prefix match for docs. + """ + for prefix in self.WHITELIST_PATHS: + if path == prefix: + return True + # Prefix match only for documentation paths (trailing slash + # or sub-path is fine). Auth paths require exact match to + # avoid accidentally whitelisting sibling routes like + # /auth/logout-others under /auth/logout. + if prefix in ("/docs", "/openapi.json", "/redoc") and path.startswith(prefix): + return True + return False def _is_dev_mode(self) -> bool: """Dev mode = no JWT secret, no global API key, no client keys.""" diff --git a/src/agentkit/server/auth/session_service.py b/src/agentkit/server/auth/session_service.py index 37450af..75f640f 100644 --- a/src/agentkit/server/auth/session_service.py +++ b/src/agentkit/server/auth/session_service.py @@ -203,6 +203,23 @@ class SessionService: rows = await cursor.fetchall() return [_row_to_info(r) for r in rows] + async def list_active_by_provider(self, auth_provider: str) -> list[SessionInfo]: + """List active sessions for a given auth provider (KTD-10). + + Supports the future "show me all OIDC sessions" admin view. + Only non-revoked, non-expired sessions are returned. + """ + sql = ( + "SELECT * FROM auth_sessions " + "WHERE auth_provider = ? AND revoked = 0 " + "ORDER BY created_at DESC" + ) + async with aiosqlite.connect(str(self._db_path)) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute(sql, (auth_provider,)) + rows = await cursor.fetchall() + return [_row_to_info(r) for r in rows] + async def find_by_refresh_token(self, refresh_token: str) -> SessionInfo | None: """Look up a session by the SHA-256 hash of its refresh token.""" h = hash_token(refresh_token) @@ -378,7 +395,11 @@ class SessionService: return await self.revoke(info.id, reason=reason) async def revoke_all_for_user( - self, user_id: str, *, reason: str = REVOKE_REASON_USER_TERMINATED + self, + user_id: str, + *, + except_sid: str | None = None, + reason: str = REVOKE_REASON_USER_TERMINATED, ) -> int: """Revoke all of a user's active sessions. @@ -386,14 +407,22 @@ class SessionService: - The user changes their password (invalidate all other devices). - Token reuse is detected (invalidate everything as a precaution). - An admin kills a user account. + - The user calls ``/auth/logout-others`` (keep current session). + + If ``except_sid`` is provided, that session is spared (e.g. the + caller's current session on "logout others"). """ + sql = ( + "UPDATE auth_sessions " + "SET revoked = 1, revoked_reason = ? " + "WHERE user_id = ? AND revoked = 0" + ) + args: list[Any] = [reason, user_id] + if except_sid is not None: + sql += " AND id != ?" + args.append(except_sid) async with aiosqlite.connect(str(self._db_path)) as db: - cursor = await db.execute( - "UPDATE auth_sessions " - "SET revoked = 1, revoked_reason = ? " - "WHERE user_id = ? AND revoked = 0", - (reason, user_id), - ) + cursor = await db.execute(sql, tuple(args)) await db.commit() return cursor.rowcount diff --git a/src/agentkit/server/frontend/src/components/layout/TopNav.vue b/src/agentkit/server/frontend/src/components/layout/TopNav.vue index 274d872..c281b33 100644 --- a/src/agentkit/server/frontend/src/components/layout/TopNav.vue +++ b/src/agentkit/server/frontend/src/components/layout/TopNav.vue @@ -37,6 +37,11 @@ + + + @@ -45,9 +50,16 @@ import { computed } from 'vue' import { useRouter } from 'vue-router' import { Badge as ABadge, Tooltip as ATooltip } from 'ant-design-vue' -import { SettingOutlined, MenuFoldOutlined, MenuUnfoldOutlined, BulbOutlined } from '@ant-design/icons-vue' +import { + SettingOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + BulbOutlined, + TeamOutlined, +} from '@ant-design/icons-vue' import { useChatStore } from '@/stores/chat' import { useThemeStore } from '@/stores/theme' +import { useAuthStore } from '@/stores/auth' const props = defineProps<{ iconNavCollapsed?: boolean @@ -60,6 +72,7 @@ const emit = defineEmits<{ const router = useRouter() const chatStore = useChatStore() const themeStore = useThemeStore() +const authStore = useAuthStore() const wsConnected = computed(() => chatStore.isWsConnected) const iconNavCollapsed = computed(() => props.iconNavCollapsed ?? false) const isDark = computed(() => themeStore.resolvedMode === 'dark') diff --git a/src/agentkit/server/frontend/src/router/index.ts b/src/agentkit/server/frontend/src/router/index.ts index 2ba5f44..43cdb96 100644 --- a/src/agentkit/server/frontend/src/router/index.ts +++ b/src/agentkit/server/frontend/src/router/index.ts @@ -89,6 +89,14 @@ const routes: RouteRecordRaw[] = [ meta: { title: 'Computer Use' }, }, + // Admin: user sessions management (U9) + { + path: '/admin/users', + name: 'admin-users', + component: () => import('@/views/admin/UsersView.vue'), + meta: { title: '用户与会话管理', requiresAdmin: true }, + }, + // Legacy layout (fallback) { path: '/legacy', @@ -186,16 +194,22 @@ router.beforeEach((to, _from, next) => { } const authStore = useAuthStore() - if (authStore.isAuthenticated) { - next() + if (!authStore.isAuthenticated) { + // Preserve the original target so we can redirect after login + next({ + name: 'login', + query: { redirect: to.fullPath }, + }) return } - // Preserve the original target so we can redirect after login - next({ - name: 'login', - query: { redirect: to.fullPath }, - }) + // Admin-only routes require the admin role. + if (to.meta.requiresAdmin === true && !authStore.isAdmin()) { + next({ name: 'agent-chat' }) + return + } + + next() }) export default router diff --git a/src/agentkit/server/frontend/src/views/admin/UsersView.vue b/src/agentkit/server/frontend/src/views/admin/UsersView.vue new file mode 100644 index 0000000..55a12c3 --- /dev/null +++ b/src/agentkit/server/frontend/src/views/admin/UsersView.vue @@ -0,0 +1,219 @@ + + + + + diff --git a/src/agentkit/server/routes/auth.py b/src/agentkit/server/routes/auth.py index 359648a..8b49e46 100644 --- a/src/agentkit/server/routes/auth.py +++ b/src/agentkit/server/routes/auth.py @@ -27,6 +27,7 @@ from pathlib import Path from typing import Any import aiosqlite +import jwt from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel, ConfigDict, EmailStr @@ -137,6 +138,7 @@ class SessionResponse(BaseModel): revoked: bool = False revoked_reason: str | None = None user_id: str | None = None + previous_session_id: str | None = None class TokenResponse(BaseModel): @@ -152,6 +154,23 @@ class TokenResponse(BaseModel): session_id: str +class WhoamiResponse(BaseModel): + """Response of ``GET /auth/whoami`` (cold-start + session metadata). + + When the client calls whoami with a refresh token (cold-start), the + server issues a fresh access token so the client doesn't need a + separate ``/auth/refresh`` round-trip. When called with an access + token, ``access_token`` is ``None`` (the caller already has one). + """ + + model_config = ConfigDict(extra="forbid") + + user: UserResponse + access_token: str | None = None + session_id: str | None = None + session: SessionResponse | None = None + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -514,21 +533,42 @@ async def logout(payload: LogoutRequest, request: Request) -> dict[str, Any]: return {"revoked": revoked} -@router.get("/whoami", response_model=UserResponse) -async def whoami( - request: Request, - user: dict[str, Any] = Depends(require_authenticated), -) -> UserResponse: - """Return the current authenticated user's profile. +@router.get("/whoami", response_model=WhoamiResponse) +async def whoami(request: Request) -> WhoamiResponse: + """Return the current user + session metadata (cold-start support). - Used by the client for cold-start (after restoring a refresh token - from local storage) to verify the session is still valid AND fetch - the freshest user profile. + Accepts **either** an access token (normal call) **or** a refresh + token (cold-start, when the access token is gone). The route does + its own auth because the middleware only accepts access tokens. + + On cold-start (refresh token presented), the server issues a fresh + access token so the client doesn't need a separate ``/auth/refresh`` + round-trip. On 401 from this endpoint, the client treats it as + 'invalid' state (NOT 'error' state) so the router redirects to + /login. """ - user_id = user.get("user_id") - if not user_id: - raise HTTPException(status_code=401, detail="Authentication required") + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + raise HTTPException(status_code=401, detail="missing bearer token") + token = auth_header[7:] + secret = _resolve_jwt_secret(request) + try: + # Accept both access and refresh tokens for cold-start. + payload = verify_token(token, secret, expected_type=None) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="token expired") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="invalid token") + + user_id = payload.get("sub") + if not user_id: + raise HTTPException(status_code=401, detail="invalid token: no subject") + + token_type = payload.get("type") + sid = payload.get("sid") + + # Load the user row. db_path = await _ensure_db(request) async with aiosqlite.connect(str(db_path)) as db: db.row_factory = aiosqlite.Row @@ -536,7 +576,38 @@ async def whoami( row = await cursor.fetchone() if row is None: raise HTTPException(status_code=404, detail="User not found") - return _user_row_to_response(row) + user_response = _user_row_to_response(row) + + # V2 token with sid: validate the session is still active. + session_response: SessionResponse | None = None + if sid: + svc: SessionService = get_session_service() + info = await svc.get(sid) + if info is None or info.revoked: + raise HTTPException(status_code=401, detail="session revoked or expired") + session_response = SessionResponse( + **auth_session_row_to_dict(_info_to_dict(info)), + is_current=True, + ) + + # Cold-start: refresh token presented → issue a fresh access token. + new_access_token: str | None = None + if token_type == "refresh": + new_pair = create_token_pair( + user_id=str(row["id"]), + username=str(row["username"]), + role=str(row["role"]), + secret=secret, + session_id=sid, + ) + new_access_token = new_pair.access_token + + return WhoamiResponse( + user=user_response, + access_token=new_access_token, + session_id=sid, + session=session_response, + ) @router.get("/sessions", response_model=list[SessionResponse]) @@ -558,9 +629,6 @@ async def list_sessions( _info_to_dict(s), ), is_current=(s.id == current_sid), - revoked=s.revoked, - revoked_reason=s.revoked_reason, - user_id=s.user_id, ) for s in sessions ] @@ -604,6 +672,35 @@ async def revoke_own_session( return {"revoked": ok} +@router.post("/logout-others") +async def logout_others( + request: Request, + user: dict[str, Any] = Depends(require_authenticated), +) -> dict[str, Any]: + """Revoke all of the current user's sessions except the calling one. + + Used by the "Log out other devices" button in the security settings. + The current session (identified by the JWT's ``sid`` claim) is spared. + """ + user_id = user.get("user_id") + if not user_id: + raise HTTPException(status_code=401, detail="Authentication required") + current_sid = user.get("sid") + if not current_sid: + # Legacy token without sid — can't identify the current session. + raise HTTPException( + status_code=400, + detail="Current session cannot be identified (legacy token). Please log in again.", + ) + svc: SessionService = get_session_service() + count = await svc.revoke_all_for_user( + user_id, + except_sid=current_sid, + reason=REVOKE_REASON_USER_TERMINATED, + ) + return {"revoked_count": count} + + @router.post("/change-password") async def change_password( payload: ChangePasswordRequest, @@ -677,9 +774,6 @@ async def admin_list_sessions( SessionResponse( **auth_session_row_to_dict(_info_to_dict(s)), is_current=False, - revoked=s.revoked, - revoked_reason=s.revoked_reason, - user_id=s.user_id, ) for s in sessions ] @@ -734,9 +828,6 @@ async def admin_list_user_sessions( SessionResponse( **auth_session_row_to_dict(_info_to_dict(s)), is_current=(s.id == current_sid), - revoked=s.revoked, - revoked_reason=s.revoked_reason, - user_id=s.user_id, ) for s in sessions ] @@ -770,5 +861,20 @@ async def me( request: Request, user: dict[str, Any] = Depends(require_authenticated), ) -> UserResponse: - """Alias for :func:`whoami` (legacy path).""" - return await whoami(request=request, user=user) + """Alias for :func:`whoami` (legacy path, access-token only). + + Returns just the user profile (no session metadata) for backwards + compatibility with clients that expect the old ``UserResponse`` + shape from ``/auth/me``. + """ + user_id = user.get("user_id") + if not user_id: + raise HTTPException(status_code=401, detail="Authentication required") + db_path = await _ensure_db(request) + async with aiosqlite.connect(str(db_path)) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute("SELECT * FROM users WHERE id = ?", (user_id,)) + row = await cursor.fetchone() + if row is None: + raise HTTPException(status_code=404, detail="User not found") + return _user_row_to_response(row) diff --git a/tests/integration/auth/__init__.py b/tests/integration/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/auth/test_admin_routes.py b/tests/integration/auth/test_admin_routes.py new file mode 100644 index 0000000..c6d6dfd --- /dev/null +++ b/tests/integration/auth/test_admin_routes.py @@ -0,0 +1,275 @@ +"""Integration tests for the admin session-management routes (U4). + +Per the plan (U4 admin test scenarios): +- ``GET /admin/users/{id}/sessions`` as admin → returns all sessions (active + revoked) +- ``GET /admin/users/{id}/sessions`` as non-admin → 403 +- ``DELETE /admin/users/{id}/sessions/{sid}`` as admin → that session revoked +- ``DELETE /admin/users/{id}/sessions/{sid}`` as non-admin → 403 +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import aiosqlite +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from agentkit.server.auth.denylist import InMemoryRecentlyRevoked +from agentkit.server.auth.middleware import AuthMiddleware +from agentkit.server.auth.models import init_auth_db +from agentkit.server.auth.password import hash_password +from agentkit.server.auth.session_service import SessionService, set_session_service +from agentkit.server.routes import auth as auth_routes + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def jwt_secret() -> str: + return "admin-integration-test-jwt-secret-32bytes!" + + +@pytest.fixture +async def tmp_auth_db(tmp_path: Path) -> Path: + db_path = tmp_path / "admin_auth_integration.db" + await init_auth_db(db_path) + return db_path + + +async def _insert_user( + db_path: Path, + *, + username: str, + password: str, + role: str = "member", +) -> dict[str, Any]: + user_id = str(uuid.uuid4()) + email = f"{username}@example.com" + password_hash = hash_password(password) + now_iso = datetime.now(timezone.utc).isoformat() + async with aiosqlite.connect(str(db_path)) as db: + await db.execute( + "INSERT INTO users " + "(id, username, email, password_hash, role, is_active, " + " is_terminal_authorized, is_server_terminal_authorized, " + " created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (user_id, username, email, password_hash, role, 1, 0, 0, now_iso, now_iso), + ) + await db.commit() + return { + "id": user_id, + "username": username, + "email": email, + "password": password, + "role": role, + } + + +@pytest.fixture +async def users(tmp_auth_db: Path) -> dict[str, dict[str, Any]]: + """Create a member and an admin in the same DB.""" + member = await _insert_user(tmp_auth_db, username="alice", password="alice-pw-12345") + admin = await _insert_user( + tmp_auth_db, + username="admin", + password="admin-pw-12345", + role="admin", + ) + return {"member": member, "admin": admin} + + +@pytest.fixture +def auth_app(jwt_secret: str, users: dict[str, dict[str, Any]], tmp_auth_db: Path) -> FastAPI: + app = FastAPI() + app.state.jwt_secret = jwt_secret + app.state.auth_db_path = str(tmp_auth_db) + app.add_middleware(AuthMiddleware, jwt_secret=jwt_secret) + app.include_router(auth_routes.router, prefix="/api/v1") + app.include_router(auth_routes.admin_router, prefix="/api/v1") + return app + + +@pytest.fixture +def auth_client(auth_app: FastAPI) -> TestClient: + test_svc = SessionService( + db_path=auth_app.state.auth_db_path, + denylist=InMemoryRecentlyRevoked(), + ) + set_session_service(test_svc) + try: + yield TestClient(auth_app) + finally: + set_session_service(None) + + +def _login(client: TestClient, username: str, password: str) -> dict[str, Any]: + resp = client.post( + "/api/v1/auth/login", + json={"username": username, "password": password}, + ) + assert resp.status_code == 200, resp.text + return resp.json() + + +# --------------------------------------------------------------------------- +# Admin: list user sessions +# --------------------------------------------------------------------------- + + +class TestAdminListUserSessions: + """GET /admin/users/{user_id}/sessions.""" + + def test_admin_can_list_user_sessions( + self, + auth_client: TestClient, + users: dict[str, dict[str, Any]], + ): + # Member logs in (creates a session). + _login(auth_client, users["member"]["username"], users["member"]["password"]) + # Admin logs in. + admin_body = _login( + auth_client, + users["admin"]["username"], + users["admin"]["password"], + ) + member_id = users["member"]["id"] + + resp = auth_client.get( + f"/api/v1/admin/users/{member_id}/sessions", + headers={"Authorization": f"Bearer {admin_body['access_token']}"}, + ) + assert resp.status_code == 200, resp.text + sessions = resp.json() + assert len(sessions) >= 1 + # All sessions belong to the member. + assert all(s["user_id"] == member_id for s in sessions) + + def test_non_admin_cannot_list_user_sessions( + self, + auth_client: TestClient, + users: dict[str, dict[str, Any]], + ): + # Member logs in. + member_body = _login( + auth_client, + users["member"]["username"], + users["member"]["password"], + ) + # Member tries to access admin endpoint. + resp = auth_client.get( + f"/api/v1/admin/users/{users['member']['id']}/sessions", + headers={"Authorization": f"Bearer {member_body['access_token']}"}, + ) + assert resp.status_code in (403, 401) + + +# --------------------------------------------------------------------------- +# Admin: revoke user session +# --------------------------------------------------------------------------- + + +class TestAdminRevokeUserSession: + """DELETE /admin/users/{user_id}/sessions/{session_id}.""" + + def test_admin_can_revoke_user_session( + self, + auth_client: TestClient, + users: dict[str, dict[str, Any]], + ): + # Member logs in (creates a session). + member_body = _login( + auth_client, + users["member"]["username"], + users["member"]["password"], + ) + # Get the member's session id via whoami. + whoami_resp = auth_client.get( + "/api/v1/auth/whoami", + headers={"Authorization": f"Bearer {member_body['access_token']}"}, + ) + assert whoami_resp.status_code == 200 + member_sid = whoami_resp.json()["session_id"] + assert member_sid is not None + + # Admin logs in and revokes the member's session. + admin_body = _login( + auth_client, + users["admin"]["username"], + users["admin"]["password"], + ) + resp = auth_client.delete( + f"/api/v1/admin/users/{users['member']['id']}/sessions/{member_sid}", + headers={"Authorization": f"Bearer {admin_body['access_token']}"}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["revoked"] is True + + # Member's session should now be invalid. + resp_member = auth_client.get( + "/api/v1/auth/whoami", + headers={"Authorization": f"Bearer {member_body['access_token']}"}, + ) + assert resp_member.status_code == 401 + + def test_non_admin_cannot_revoke_user_session( + self, + auth_client: TestClient, + users: dict[str, dict[str, Any]], + ): + member_body = _login( + auth_client, + users["member"]["username"], + users["member"]["password"], + ) + whoami_resp = auth_client.get( + "/api/v1/auth/whoami", + headers={"Authorization": f"Bearer {member_body['access_token']}"}, + ) + member_sid = whoami_resp.json()["session_id"] + + # Member tries to use the admin endpoint. + resp = auth_client.delete( + f"/api/v1/admin/users/{users['member']['id']}/sessions/{member_sid}", + headers={"Authorization": f"Bearer {member_body['access_token']}"}, + ) + assert resp.status_code in (403, 401) + + +# --------------------------------------------------------------------------- +# Admin: list all sessions +# --------------------------------------------------------------------------- + + +class TestAdminListAllSessions: + """GET /admin/sessions.""" + + def test_admin_can_list_all_sessions( + self, + auth_client: TestClient, + users: dict[str, dict[str, Any]], + ): + # Both users log in. + _login(auth_client, users["member"]["username"], users["member"]["password"]) + admin_body = _login( + auth_client, + users["admin"]["username"], + users["admin"]["password"], + ) + + resp = auth_client.get( + "/api/v1/admin/sessions", + headers={"Authorization": f"Bearer {admin_body['access_token']}"}, + ) + assert resp.status_code == 200, resp.text + sessions = resp.json() + # At least 2 sessions (member + admin). + assert len(sessions) >= 2 diff --git a/tests/integration/auth/test_auth_routes.py b/tests/integration/auth/test_auth_routes.py new file mode 100644 index 0000000..78b7764 --- /dev/null +++ b/tests/integration/auth/test_auth_routes.py @@ -0,0 +1,431 @@ +"""Integration tests for the auth REST routes (U4). + +These tests exercise the full HTTP stack (TestClient → AuthMiddleware → +route → SessionService → SQLite) end-to-end. They complement the unit +tests in ``tests/unit/test_auth.py`` by covering the new endpoints +introduced by the centralized-auth feature: ``/auth/whoami`` (with +refresh-token cold-start), ``/auth/sessions``, ``/auth/logout-others``, +``/auth/change-password``. + +Per the plan (U4 test scenarios), the suite covers: +- Happy paths (login, refresh, whoami, sessions, logout-others, change-password) +- Error paths (wrong password, unknown user, expired token, missing auth) +- Integration (multi-device sessions, session cap eviction, password + change invalidating other devices) +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import aiosqlite +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from agentkit.server.auth.denylist import InMemoryRecentlyRevoked +from agentkit.server.auth.middleware import AuthMiddleware +from agentkit.server.auth.models import init_auth_db +from agentkit.server.auth.password import hash_password +from agentkit.server.auth.session_service import SessionService, set_session_service +from agentkit.server.routes import auth as auth_routes + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def jwt_secret() -> str: + return "integration-test-jwt-secret-do-not-use-in-prod-32bytes" + + +@pytest.fixture +async def tmp_auth_db(tmp_path: Path) -> Path: + db_path = tmp_path / "auth_integration.db" + await init_auth_db(db_path) + return db_path + + +async def _insert_user( + db_path: Path, + *, + username: str = "alice", + password: str = "correct-horse-battery-staple", + role: str = "member", +) -> dict[str, Any]: + """Insert a user row and return the user fields + plaintext password.""" + user_id = str(uuid.uuid4()) + email = f"{username}@example.com" + password_hash = hash_password(password) + now_iso = datetime.now(timezone.utc).isoformat() + async with aiosqlite.connect(str(db_path)) as db: + await db.execute( + "INSERT INTO users " + "(id, username, email, password_hash, role, is_active, " + " is_terminal_authorized, is_server_terminal_authorized, " + " created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (user_id, username, email, password_hash, role, 1, 0, 0, now_iso, now_iso), + ) + await db.commit() + return { + "id": user_id, + "username": username, + "email": email, + "password": password, + "role": role, + "db_path": db_path, + } + + +@pytest.fixture +async def auth_db_with_user(tmp_auth_db: Path) -> dict[str, Any]: + return await _insert_user(tmp_auth_db) + + +@pytest.fixture +async def auth_db_with_admin(tmp_auth_db: Path) -> dict[str, Any]: + """A second DB+user with admin role (for admin-route tests).""" + return await _insert_user( + tmp_auth_db, + username="admin", + password="admin-horse-battery-staple", + role="admin", + ) + + +@pytest.fixture +def auth_app(jwt_secret: str, auth_db_with_user: dict[str, Any]) -> FastAPI: + app = FastAPI() + app.state.jwt_secret = jwt_secret + app.state.auth_db_path = str(auth_db_with_user["db_path"]) + app.add_middleware(AuthMiddleware, jwt_secret=jwt_secret) + app.include_router(auth_routes.router, prefix="/api/v1") + app.include_router(auth_routes.admin_router, prefix="/api/v1") + return app + + +@pytest.fixture +def auth_client(auth_app: FastAPI) -> TestClient: + """TestClient with SessionService bound to the test DB.""" + test_svc = SessionService( + db_path=auth_app.state.auth_db_path, + denylist=InMemoryRecentlyRevoked(), + ) + set_session_service(test_svc) + try: + yield TestClient(auth_app) + finally: + set_session_service(None) + + +def _login(client: TestClient, username: str, password: str) -> dict[str, Any]: + """Helper: log in and return the TokenResponse body.""" + resp = client.post( + "/api/v1/auth/login", + json={"username": username, "password": password}, + ) + assert resp.status_code == 200, resp.text + return resp.json() + + +# --------------------------------------------------------------------------- +# Happy paths +# --------------------------------------------------------------------------- + + +class TestWhoamiColdStart: + """GET /auth/whoami with access OR refresh token (cold-start).""" + + def test_whoami_with_access_token_returns_user( + self, + auth_client: TestClient, + auth_db_with_user: dict[str, Any], + ): + body = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"]) + resp = auth_client.get( + "/api/v1/auth/whoami", + headers={"Authorization": f"Bearer {body['access_token']}"}, + ) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["user"]["username"] == auth_db_with_user["username"] + # Access-token call does NOT issue a new access token. + assert data["access_token"] is None + assert data["session_id"] is not None + assert data["session"] is not None + + def test_whoami_with_refresh_token_issues_new_access_token( + self, + auth_client: TestClient, + auth_db_with_user: dict[str, Any], + ): + """Cold-start: refresh token → fresh access token returned.""" + body = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"]) + resp = auth_client.get( + "/api/v1/auth/whoami", + headers={"Authorization": f"Bearer {body['refresh_token']}"}, + ) + assert resp.status_code == 200, resp.text + data = resp.json() + assert data["user"]["username"] == auth_db_with_user["username"] + # Cold-start issues a fresh access token. + assert data["access_token"] is not None + assert data["access_token"] != body["access_token"] + assert data["session_id"] is not None + + def test_whoami_missing_bearer_returns_401(self, auth_client: TestClient): + resp = auth_client.get("/api/v1/auth/whoami") + assert resp.status_code == 401 + + def test_whoami_invalid_token_returns_401(self, auth_client: TestClient): + resp = auth_client.get( + "/api/v1/auth/whoami", + headers={"Authorization": "Bearer not.a.valid.jwt"}, + ) + assert resp.status_code == 401 + + +class TestSessionsManagement: + """GET /auth/sessions, DELETE /auth/sessions/{id}.""" + + def test_list_sessions_returns_current_user_sessions( + self, + auth_client: TestClient, + auth_db_with_user: dict[str, Any], + ): + body = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"]) + resp = auth_client.get( + "/api/v1/auth/sessions", + headers={"Authorization": f"Bearer {body['access_token']}"}, + ) + assert resp.status_code == 200, resp.text + sessions = resp.json() + assert len(sessions) >= 1 + # The current session should be marked is_current=True. + assert any(s["is_current"] for s in sessions) + + def test_revoke_own_session( + self, + auth_client: TestClient, + auth_db_with_user: dict[str, Any], + ): + body = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"]) + # List sessions to get the session id. + sessions = auth_client.get( + "/api/v1/auth/sessions", + headers={"Authorization": f"Bearer {body['access_token']}"}, + ).json() + sid = sessions[0]["id"] + + resp = auth_client.delete( + f"/api/v1/auth/sessions/{sid}", + headers={"Authorization": f"Bearer {body['access_token']}"}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["revoked"] is True + + def test_revoke_other_user_session_returns_404( + self, + auth_client: TestClient, + auth_db_with_user: dict[str, Any], + tmp_auth_db: Path, + ): + """A user cannot revoke another user's session (404, not 403, to avoid leaking).""" + # Create a second user and log in as them. + other = _login_sync_create_user(auth_client, tmp_auth_db, username="bob") + # Alice tries to revoke Bob's session. + body = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"]) + resp = auth_client.delete( + f"/api/v1/auth/sessions/{other['session_id']}", + headers={"Authorization": f"Bearer {body['access_token']}"}, + ) + assert resp.status_code == 404 + + +def _login_sync_create_user( + client: TestClient, + db_path: Path, + *, + username: str, +) -> dict[str, Any]: + """Insert a user directly into the DB and log in via the API. + + Returns the login response + the session_id extracted from the JWT. + """ + import asyncio + + from agentkit.server.auth.jwt_utils import verify_token + + user = asyncio.run(_insert_user(db_path, username=username)) + body = _login(client, username, user["password"]) + payload = verify_token(body["access_token"], client.app.state.jwt_secret) + return {**body, "session_id": payload.get("sid"), "user_id": user["id"]} + + +class TestLogoutOthers: + """POST /auth/logout-others.""" + + def test_logout_others_revokes_all_other_sessions( + self, + auth_client: TestClient, + auth_db_with_user: dict[str, Any], + tmp_auth_db: Path, + ): + """Login from 2 devices, logout-others from device A → B's session revoked.""" + # Device A + body_a = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"]) + # Device B (same user, different fingerprint header) + body_b = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"]) + + # A calls logout-others. + resp = auth_client.post( + "/api/v1/auth/logout-others", + headers={"Authorization": f"Bearer {body_a['access_token']}"}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["revoked_count"] >= 1 + + # B's session should now be revoked → whoami with B's refresh token → 401. + resp_b = auth_client.get( + "/api/v1/auth/whoami", + headers={"Authorization": f"Bearer {body_b['refresh_token']}"}, + ) + assert resp_b.status_code == 401 + + # A's session should still be valid. + resp_a = auth_client.get( + "/api/v1/auth/whoami", + headers={"Authorization": f"Bearer {body_a['access_token']}"}, + ) + assert resp_a.status_code == 200 + + +class TestChangePassword: + """POST /auth/change-password.""" + + def test_change_password_revokes_other_sessions( + self, + auth_client: TestClient, + auth_db_with_user: dict[str, Any], + ): + """Change password → other sessions revoked, current stays alive.""" + # Device A + body_a = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"]) + # Device B + body_b = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"]) + + # A changes the password. + resp = auth_client.post( + "/api/v1/auth/change-password", + headers={"Authorization": f"Bearer {body_a['access_token']}"}, + json={ + "old_password": auth_db_with_user["password"], + "new_password": "new-strong-password-123", + }, + ) + assert resp.status_code == 200, resp.text + + # B's session should be revoked. + resp_b = auth_client.get( + "/api/v1/auth/whoami", + headers={"Authorization": f"Bearer {body_b['refresh_token']}"}, + ) + assert resp_b.status_code == 401 + + def test_change_password_wrong_old_returns_400( + self, + auth_client: TestClient, + auth_db_with_user: dict[str, Any], + ): + body = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"]) + resp = auth_client.post( + "/api/v1/auth/change-password", + headers={"Authorization": f"Bearer {body['access_token']}"}, + json={ + "old_password": "totally-wrong", + "new_password": "new-strong-password-123", + }, + ) + assert resp.status_code == 400 + + def test_change_password_weak_new_returns_400( + self, + auth_client: TestClient, + auth_db_with_user: dict[str, Any], + ): + body = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"]) + resp = auth_client.post( + "/api/v1/auth/change-password", + headers={"Authorization": f"Bearer {body['access_token']}"}, + json={ + "old_password": auth_db_with_user["password"], + "new_password": "short", + }, + ) + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# Integration: multi-device + session cap +# --------------------------------------------------------------------------- + + +class TestMultiDeviceIntegration: + """Login from multiple devices → independent sessions.""" + + def test_multiple_logins_create_independent_sessions( + self, + auth_client: TestClient, + auth_db_with_user: dict[str, Any], + ): + body_a = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"]) + body_b = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"]) + + # Both tokens should work independently. + resp_a = auth_client.get( + "/api/v1/auth/whoami", + headers={"Authorization": f"Bearer {body_a['access_token']}"}, + ) + resp_b = auth_client.get( + "/api/v1/auth/whoami", + headers={"Authorization": f"Bearer {body_b['access_token']}"}, + ) + assert resp_a.status_code == 200 + assert resp_b.status_code == 200 + # Different session ids. + assert resp_a.json()["session_id"] != resp_b.json()["session_id"] + + def test_session_cap_evicts_oldest( + self, + auth_client: TestClient, + auth_db_with_user: dict[str, Any], + ): + """The default session cap is 10; the 11th login evicts the oldest.""" + tokens = [] + for _ in range(11): + body = _login( + auth_client, + auth_db_with_user["username"], + auth_db_with_user["password"], + ) + tokens.append(body) + + # The first session should have been evicted (whoami → 401). + resp_first = auth_client.get( + "/api/v1/auth/whoami", + headers={"Authorization": f"Bearer {tokens[0]['refresh_token']}"}, + ) + assert resp_first.status_code == 401 + + # The last session should still be valid. + resp_last = auth_client.get( + "/api/v1/auth/whoami", + headers={"Authorization": f"Bearer {tokens[-1]['access_token']}"}, + ) + assert resp_last.status_code == 200