From 01d9bd48b26bb13ff4dbd099a7b8862abeaef0c9 Mon Sep 17 00:00:00 2001 From: Adam Wolfman Date: Fri, 10 Oct 2025 16:28:58 -0600 Subject: [PATCH 1/5] Add list sessions and revoke session methods and tests --- tests/test_user_management_list_sessions.py | 72 ++++++++++++ tests/test_user_management_revoke_session.py | 47 ++++++++ workos/__init__.py | 3 +- workos/types/list_resource.py | 2 + workos/types/user_management/__init__.py | 1 + workos/types/user_management/list_filters.py | 4 + workos/types/user_management/session.py | 17 +++ workos/user_management.py | 113 +++++++++++++++++++ 8 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 tests/test_user_management_list_sessions.py create mode 100644 tests/test_user_management_revoke_session.py diff --git a/tests/test_user_management_list_sessions.py b/tests/test_user_management_list_sessions.py new file mode 100644 index 00000000..9ce102bb --- /dev/null +++ b/tests/test_user_management_list_sessions.py @@ -0,0 +1,72 @@ +from typing import Union + +import pytest + +from tests.utils.list_resource import list_response_of +from tests.utils.syncify import syncify +from tests.types.test_auto_pagination_function import TestAutoPaginationFunction +from workos.user_management import AsyncUserManagement, UserManagement + + +def _mock_session(id: str): + now = "2025-07-23T14:00:00.000Z" + return { + "object": "session", + "id": id, + "user_id": "user_123", + "organization_id": "org_123", + "status": "active", + "auth_method": "password", + "ip_address": "192.168.1.1", + "user_agent": "Mozilla/5.0", + "expires_at": "2025-07-23T15:00:00.000Z", + "ended_at": None, + "created_at": now, + "updated_at": now, + } + + +@pytest.mark.sync_and_async(UserManagement, AsyncUserManagement) +class TestUserManagementListSessions: + @pytest.fixture(autouse=True) + def setup(self, module_instance: Union[UserManagement, AsyncUserManagement]): + self.http_client = module_instance._http_client + self.user_management = module_instance + + def test_list_sessions_query_and_parsing( + self, capture_and_mock_http_client_request + ): + sessions = [_mock_session("session_1"), _mock_session("session_2")] + response = list_response_of(data=sessions) + request_kwargs = capture_and_mock_http_client_request( + self.http_client, response, 200 + ) + + result = syncify( + self.user_management.list_sessions( + user_id="user_123", limit=10, before="before_id", order="desc" + ) + ) + + assert request_kwargs["url"].endswith("user_management/users/user_123/sessions") + assert request_kwargs["method"] == "get" + assert request_kwargs["params"]["limit"] == 10 + assert request_kwargs["params"]["before"] == "before_id" + assert request_kwargs["params"]["order"] == "desc" + assert "after" not in request_kwargs["params"] + assert len(result.data) == 2 + assert result.data[0].id == "session_1" + assert result.list_metadata.before is None + assert result.list_metadata.after is None + + def test_list_sessions_auto_pagination( + self, test_auto_pagination: TestAutoPaginationFunction + ): + data = [_mock_session(str(i)) for i in range(40)] + test_auto_pagination( + http_client=self.http_client, + list_function=self.user_management.list_sessions, + list_function_params={"user_id": "user_123"}, + expected_all_page_data=data, + url_path_keys=["user_id"], + ) diff --git a/tests/test_user_management_revoke_session.py b/tests/test_user_management_revoke_session.py new file mode 100644 index 00000000..7efedc65 --- /dev/null +++ b/tests/test_user_management_revoke_session.py @@ -0,0 +1,47 @@ +from typing import Union + +import pytest + +from tests.utils.syncify import syncify +from workos.user_management import AsyncUserManagement, UserManagement + + +def _mock_session(id: str): + now = "2025-07-23T14:00:00.000Z" + return { + "object": "session", + "id": id, + "user_id": "user_123", + "organization_id": "org_123", + "status": "revoked", + "auth_method": "password", + "ip_address": "192.168.1.1", + "user_agent": "Mozilla/5.0", + "expires_at": "2025-07-23T15:00:00.000Z", + "ended_at": now, + "created_at": now, + "updated_at": now, + } + + +@pytest.mark.sync_and_async(UserManagement, AsyncUserManagement) +class TestUserManagementRevokeSession: + @pytest.fixture(autouse=True) + def setup(self, module_instance: Union[UserManagement, AsyncUserManagement]): + self.http_client = module_instance._http_client + self.user_management = module_instance + + def test_revoke_session(self, capture_and_mock_http_client_request): + mock = _mock_session("session_abc") + request_kwargs = capture_and_mock_http_client_request( + self.http_client, mock, 200 + ) + + response = syncify( + self.user_management.revoke_session(session_id="session_abc") + ) + + assert request_kwargs["url"].endswith("user_management/sessions/revoke") + assert request_kwargs["method"] == "post" + assert request_kwargs["json"] == {"session_id": "session_abc"} + assert response.id == "session_abc" diff --git a/workos/__init__.py b/workos/__init__.py index fe4fd7ec..30503486 100644 --- a/workos/__init__.py +++ b/workos/__init__.py @@ -1,4 +1,5 @@ from workos.client import SyncClient as WorkOSClient from workos.async_client import AsyncClient as AsyncWorkOSClient +from workos.types.user_management.session import Session as Session -__all__ = ["WorkOSClient", "AsyncWorkOSClient"] +__all__ = ["WorkOSClient", "AsyncWorkOSClient", "Session"] diff --git a/workos/types/list_resource.py b/workos/types/list_resource.py index ce5bdeb8..e2ece480 100644 --- a/workos/types/list_resource.py +++ b/workos/types/list_resource.py @@ -34,6 +34,7 @@ from workos.types.organizations import Organization from workos.types.sso import ConnectionWithDomains from workos.types.user_management import Invitation, OrganizationMembership, User +from workos.types.user_management.session import Session as UserManagementSession from workos.types.vault import ObjectDigest from workos.types.workos_model import WorkOSModel from workos.utils.request_helper import DEFAULT_LIST_RESPONSE_LIMIT @@ -54,6 +55,7 @@ AuthorizationResource, AuthorizationResourceType, User, + UserManagementSession, ObjectDigest, Warrant, WarrantQueryResult, diff --git a/workos/types/user_management/__init__.py b/workos/types/user_management/__init__.py index cd3c9d8a..29a1890e 100644 --- a/workos/types/user_management/__init__.py +++ b/workos/types/user_management/__init__.py @@ -9,3 +9,4 @@ from .password_reset import * from .user_management_provider_type import * from .user import * +from .session import * diff --git a/workos/types/user_management/list_filters.py b/workos/types/user_management/list_filters.py index 785699a2..a3be45ce 100644 --- a/workos/types/user_management/list_filters.py +++ b/workos/types/user_management/list_filters.py @@ -23,3 +23,7 @@ class OrganizationMembershipsListFilters(ListArgs, total=False): class AuthenticationFactorsListFilters(ListArgs, total=False): user_id: str + + +class SessionsListFilters(ListArgs, total=False): + user_id: str diff --git a/workos/types/user_management/session.py b/workos/types/user_management/session.py index 06869a97..913040c7 100644 --- a/workos/types/user_management/session.py +++ b/workos/types/user_management/session.py @@ -46,3 +46,20 @@ class RefreshWithSessionCookieErrorResponse(WorkOSModel): class SessionConfig(TypedDict, total=False): seal_session: bool cookie_password: str + + +class Session(WorkOSModel): + """Representation of a WorkOS User Management Session.""" + + object: Literal["session"] + id: str + user_id: str + organization_id: Optional[str] = None + status: str + auth_method: Optional[str] = None + ip_address: Optional[str] = None + user_agent: Optional[str] = None + expires_at: Optional[str] = None + ended_at: Optional[str] = None + created_at: str + updated_at: str diff --git a/workos/user_management.py b/workos/user_management.py index 4a093b78..abeabff8 100644 --- a/workos/user_management.py +++ b/workos/user_management.py @@ -48,6 +48,7 @@ from workos.types.user_management.password_hash_type import PasswordHashType from workos.types.user_management.screen_hint import ScreenHintType from workos.types.user_management.session import SessionConfig +from workos.types.user_management.session import Session as UserManagementSession from workos.types.user_management.user_management_provider_type import ( UserManagementProviderType, ) @@ -86,6 +87,8 @@ MAGIC_AUTH_PATH = "user_management/magic_auth" USER_SEND_MAGIC_AUTH_PATH = "user_management/magic_auth/send" USER_AUTH_FACTORS_PATH = "user_management/users/{0}/auth_factors" +USER_SESSIONS_PATH = "user_management/users/{0}/sessions" +SESSIONS_REVOKE_PATH = "user_management/sessions/revoke" EMAIL_VERIFICATION_DETAIL_PATH = "user_management/email_verification/{0}" INVITATION_PATH = "user_management/invitations" INVITATION_DETAIL_PATH = "user_management/invitations/{0}" @@ -109,6 +112,12 @@ Invitation, InvitationsListFilters, ListMetadata ] +from workos.types.user_management.list_filters import SessionsListFilters + +SessionsListResource = WorkOSListResource[ + UserManagementSession, SessionsListFilters, ListMetadata +] + class UserManagementModule(Protocol): """Offers methods for using the WorkOS User Management API.""" @@ -720,6 +729,20 @@ def verify_email(self, *, user_id: str, code: str) -> SyncOrAsync[User]: """ ... + def list_sessions( + self, + *, + user_id: str, + limit: Optional[int] = None, + before: Optional[str] = None, + after: Optional[str] = None, + order: Optional[PaginationOrder] = None, + ) -> SyncOrAsync["SessionsListResource"]: ... + + def revoke_session( + self, *, session_id: str + ) -> SyncOrAsync[UserManagementSession]: ... + def get_magic_auth(self, magic_auth_id: str) -> SyncOrAsync[MagicAuth]: """Get the details of a Magic Auth object. @@ -1377,6 +1400,51 @@ def create_magic_auth( return MagicAuth.model_validate(response) + def list_sessions( + self, + *, + user_id: str, + limit: Optional[int] = None, + before: Optional[str] = None, + after: Optional[str] = None, + order: Optional[PaginationOrder] = None, + ) -> "SessionsListResource": + params: ListArgs = { + "limit": limit or DEFAULT_LIST_RESPONSE_LIMIT, + "before": before, + "after": after, + "order": order, + } + + response = self._http_client.request( + USER_SESSIONS_PATH.format(user_id), + method=REQUEST_METHOD_GET, + params=params, + ) + + list_args: SessionsListFilters = { + "limit": limit or DEFAULT_LIST_RESPONSE_LIMIT, + "before": before, + "after": after, + "order": order, + "user_id": user_id, + } + + return SessionsListResource( + list_method=self.list_sessions, + list_args=list_args, + **ListPage[UserManagementSession](**response).model_dump(), + ) + + def revoke_session(self, *, session_id: str) -> UserManagementSession: + json = {"session_id": session_id} + + response = self._http_client.request( + SESSIONS_REVOKE_PATH, method=REQUEST_METHOD_POST, json=json + ) + + return UserManagementSession.model_validate(response) + def enroll_auth_factor( self, *, @@ -2033,6 +2101,51 @@ async def create_magic_auth( return MagicAuth.model_validate(response) + async def list_sessions( + self, + *, + user_id: str, + limit: Optional[int] = None, + before: Optional[str] = None, + after: Optional[str] = None, + order: Optional[PaginationOrder] = None, + ) -> "SessionsListResource": + params: ListArgs = { + "limit": limit or DEFAULT_LIST_RESPONSE_LIMIT, + "before": before, + "after": after, + "order": order, + } + + response = await self._http_client.request( + USER_SESSIONS_PATH.format(user_id), + method=REQUEST_METHOD_GET, + params=params, + ) + + list_args: SessionsListFilters = { + "limit": limit or DEFAULT_LIST_RESPONSE_LIMIT, + "before": before, + "after": after, + "order": order, + "user_id": user_id, + } + + return SessionsListResource( + list_method=self.list_sessions, + list_args=list_args, + **ListPage[UserManagementSession](**response).model_dump(), + ) + + async def revoke_session(self, *, session_id: str) -> UserManagementSession: + json = {"session_id": session_id} + + response = await self._http_client.request( + SESSIONS_REVOKE_PATH, method=REQUEST_METHOD_POST, json=json + ) + + return UserManagementSession.model_validate(response) + async def enroll_auth_factor( self, *, From ffdf1697461eb35ea738646b62c26a954a102357 Mon Sep 17 00:00:00 2001 From: Adam Wolfman Date: Tue, 14 Oct 2025 10:29:09 -0600 Subject: [PATCH 2/5] Refactored types and updated where session is exposed based on review feedback --- workos/__init__.py | 3 +-- workos/types/user_management/session.py | 18 ++++++++++++++++-- workos/user_management.py | 12 ++++++------ 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/workos/__init__.py b/workos/__init__.py index 30503486..fe4fd7ec 100644 --- a/workos/__init__.py +++ b/workos/__init__.py @@ -1,5 +1,4 @@ from workos.client import SyncClient as WorkOSClient from workos.async_client import AsyncClient as AsyncWorkOSClient -from workos.types.user_management.session import Session as Session -__all__ = ["WorkOSClient", "AsyncWorkOSClient", "Session"] +__all__ = ["WorkOSClient", "AsyncWorkOSClient"] diff --git a/workos/types/user_management/session.py b/workos/types/user_management/session.py index 913040c7..546e6b2f 100644 --- a/workos/types/user_management/session.py +++ b/workos/types/user_management/session.py @@ -48,6 +48,19 @@ class SessionConfig(TypedDict, total=False): cookie_password: str +AuthMethodType = Literal[ + "external_auth", + "impersonation", + "magic_code", + "migrated_session", + "oauth", + "passkey", + "password", + "sso", + "unknown", +] + + class Session(WorkOSModel): """Representation of a WorkOS User Management Session.""" @@ -56,10 +69,11 @@ class Session(WorkOSModel): user_id: str organization_id: Optional[str] = None status: str - auth_method: Optional[str] = None + auth_method: AuthMethodType + impersonator: Optional[Impersonator] = None ip_address: Optional[str] = None user_agent: Optional[str] = None - expires_at: Optional[str] = None + expires_at: str ended_at: Optional[str] = None created_at: str updated_at: str diff --git a/workos/user_management.py b/workos/user_management.py index abeabff8..430da219 100644 --- a/workos/user_management.py +++ b/workos/user_management.py @@ -1404,13 +1404,13 @@ def list_sessions( self, *, user_id: str, - limit: Optional[int] = None, + limit: Optional[int] = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, order: Optional[PaginationOrder] = None, ) -> "SessionsListResource": params: ListArgs = { - "limit": limit or DEFAULT_LIST_RESPONSE_LIMIT, + "limit": limit, "before": before, "after": after, "order": order, @@ -1423,7 +1423,7 @@ def list_sessions( ) list_args: SessionsListFilters = { - "limit": limit or DEFAULT_LIST_RESPONSE_LIMIT, + "limit": limit, "before": before, "after": after, "order": order, @@ -2105,13 +2105,13 @@ async def list_sessions( self, *, user_id: str, - limit: Optional[int] = None, + limit: Optional[int] = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, order: Optional[PaginationOrder] = None, ) -> "SessionsListResource": params: ListArgs = { - "limit": limit or DEFAULT_LIST_RESPONSE_LIMIT, + "limit": limit, "before": before, "after": after, "order": order, @@ -2124,7 +2124,7 @@ async def list_sessions( ) list_args: SessionsListFilters = { - "limit": limit or DEFAULT_LIST_RESPONSE_LIMIT, + "limit": limit, "before": before, "after": after, "order": order, From 723c258ef19fce1e0f988a765bce9b640aa5c8b9 Mon Sep 17 00:00:00 2001 From: Adam Wolfman Date: Tue, 14 Oct 2025 10:39:59 -0600 Subject: [PATCH 3/5] Update tests --- tests/test_user_management_list_sessions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_user_management_list_sessions.py b/tests/test_user_management_list_sessions.py index 9ce102bb..c9d4065c 100644 --- a/tests/test_user_management_list_sessions.py +++ b/tests/test_user_management_list_sessions.py @@ -17,6 +17,7 @@ def _mock_session(id: str): "organization_id": "org_123", "status": "active", "auth_method": "password", + "impersonator": None, "ip_address": "192.168.1.1", "user_agent": "Mozilla/5.0", "expires_at": "2025-07-23T15:00:00.000Z", From 2ee912fcbcdbe1a36bc1dc393faa0743e17edd91 Mon Sep 17 00:00:00 2001 From: Adam Wolfman Date: Tue, 14 Oct 2025 11:30:05 -0600 Subject: [PATCH 4/5] Update implementatino of DEFAULT_LIST_RESPONSE_LIMIT to align with methods like list_users, list_invitations, and list_auth_factors --- workos/user_management.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/workos/user_management.py b/workos/user_management.py index 430da219..898705fa 100644 --- a/workos/user_management.py +++ b/workos/user_management.py @@ -1404,10 +1404,10 @@ def list_sessions( self, *, user_id: str, - limit: Optional[int] = DEFAULT_LIST_RESPONSE_LIMIT, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, - order: Optional[PaginationOrder] = None, + order: PaginationOrder = "desc", ) -> "SessionsListResource": params: ListArgs = { "limit": limit, @@ -2105,10 +2105,10 @@ async def list_sessions( self, *, user_id: str, - limit: Optional[int] = DEFAULT_LIST_RESPONSE_LIMIT, + limit: int = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, - order: Optional[PaginationOrder] = None, + order: PaginationOrder = "desc", ) -> "SessionsListResource": params: ListArgs = { "limit": limit, From 16fa8063392a1ae4fe9514331ee5dbff6e3711c2 Mon Sep 17 00:00:00 2001 From: Adam Wolfman Date: Tue, 14 Oct 2025 11:58:24 -0600 Subject: [PATCH 5/5] Update list session arguments to align with type checks --- workos/user_management.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/workos/user_management.py b/workos/user_management.py index 898705fa..9e72bb14 100644 --- a/workos/user_management.py +++ b/workos/user_management.py @@ -736,7 +736,7 @@ def list_sessions( limit: Optional[int] = None, before: Optional[str] = None, after: Optional[str] = None, - order: Optional[PaginationOrder] = None, + order: Optional[PaginationOrder] = "desc", ) -> SyncOrAsync["SessionsListResource"]: ... def revoke_session( @@ -1404,13 +1404,15 @@ def list_sessions( self, *, user_id: str, - limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + limit: Optional[int] = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, - order: PaginationOrder = "desc", + order: Optional[PaginationOrder] = "desc", ) -> "SessionsListResource": + limit_value: int = limit if limit is not None else DEFAULT_LIST_RESPONSE_LIMIT + params: ListArgs = { - "limit": limit, + "limit": limit_value, "before": before, "after": after, "order": order, @@ -1423,12 +1425,13 @@ def list_sessions( ) list_args: SessionsListFilters = { - "limit": limit, + "limit": limit_value, "before": before, "after": after, - "order": order, "user_id": user_id, } + if order is not None: + list_args["order"] = order return SessionsListResource( list_method=self.list_sessions, @@ -2105,13 +2108,15 @@ async def list_sessions( self, *, user_id: str, - limit: int = DEFAULT_LIST_RESPONSE_LIMIT, + limit: Optional[int] = DEFAULT_LIST_RESPONSE_LIMIT, before: Optional[str] = None, after: Optional[str] = None, - order: PaginationOrder = "desc", + order: Optional[PaginationOrder] = "desc", ) -> "SessionsListResource": + limit_value: int = limit if limit is not None else DEFAULT_LIST_RESPONSE_LIMIT + params: ListArgs = { - "limit": limit, + "limit": limit_value, "before": before, "after": after, "order": order, @@ -2124,12 +2129,13 @@ async def list_sessions( ) list_args: SessionsListFilters = { - "limit": limit, + "limit": limit_value, "before": before, "after": after, - "order": order, "user_id": user_id, } + if order is not None: + list_args["order"] = order return SessionsListResource( list_method=self.list_sessions,