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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询会话
+
+
+
+
+
+
+
+
+ 用户 {{ selectedUserId }} 的会话
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 刷新
+ 共 {{ allSessions.length }} 条会话
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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