diff --git a/tests/test_sync_http_client.py b/tests/test_sync_http_client.py index 2a0f571b..d86c5b99 100644 --- a/tests/test_sync_http_client.py +++ b/tests/test_sync_http_client.py @@ -10,6 +10,7 @@ BadRequestException, BaseRequestException, ConflictException, + EmailVerificationRequiredException, ServerException, ) from workos.utils.http_client import SyncHTTPClient @@ -263,6 +264,57 @@ def test_conflict_exception(self): assert str(ex) == "(message=No message, request_id=request-123)" assert ex.__class__ == ConflictException + def test_email_verification_required_exception(self): + request_id = "request-123" + email_verification_id = "email_verification_01J6K4PMSWQXVFGF5ZQJXC6VC8" + + self.http_client._client.request = MagicMock( + return_value=httpx.Response( + status_code=403, + json={ + "message": "Please verify your email to authenticate via password.", + "code": "email_verification_required", + "email_verification_id": email_verification_id, + }, + headers={"X-Request-ID": request_id}, + ), + ) + + try: + self.http_client.request("bad_place") + except EmailVerificationRequiredException as ex: + assert ( + ex.message == "Please verify your email to authenticate via password." + ) + assert ex.code == "email_verification_required" + assert ex.email_verification_id == email_verification_id + assert ex.request_id == request_id + assert ex.__class__ == EmailVerificationRequiredException + assert isinstance(ex, AuthorizationException) + + def test_regular_authorization_exception_still_raised(self): + request_id = "request-123" + + self.http_client._client.request = MagicMock( + return_value=httpx.Response( + status_code=403, + json={ + "message": "You do not have permission to access this resource.", + "code": "forbidden", + }, + headers={"X-Request-ID": request_id}, + ), + ) + + try: + self.http_client.request("bad_place") + except AuthorizationException as ex: + assert ex.message == "You do not have permission to access this resource." + assert ex.code == "forbidden" + assert ex.request_id == request_id + assert ex.__class__ == AuthorizationException + assert not isinstance(ex, EmailVerificationRequiredException) + def test_request_includes_base_headers(self, capture_and_mock_http_client_request): request_kwargs = capture_and_mock_http_client_request(self.http_client, {}, 200) diff --git a/tests/test_user_management_revoke_session.py b/tests/test_user_management_revoke_session.py index 7efedc65..6f6247ca 100644 --- a/tests/test_user_management_revoke_session.py +++ b/tests/test_user_management_revoke_session.py @@ -6,24 +6,6 @@ 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) @@ -32,10 +14,7 @@ def setup(self, module_instance: Union[UserManagement, AsyncUserManagement]): 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 - ) + request_kwargs = capture_and_mock_http_client_request(self.http_client, {}, 200) response = syncify( self.user_management.revoke_session(session_id="session_abc") @@ -44,4 +23,4 @@ def test_revoke_session(self, capture_and_mock_http_client_request): 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" + assert response is None diff --git a/workos/__about__.py b/workos/__about__.py index 5040e10d..e02b0486 100644 --- a/workos/__about__.py +++ b/workos/__about__.py @@ -12,7 +12,7 @@ __package_url__ = "https://github.com/workos-inc/workos-python" -__version__ = "5.31.0" +__version__ = "5.31.1" __author__ = "WorkOS" diff --git a/workos/exceptions.py b/workos/exceptions.py index 9aee53b2..a79e1159 100644 --- a/workos/exceptions.py +++ b/workos/exceptions.py @@ -45,6 +45,24 @@ class AuthorizationException(BaseRequestException): pass +class EmailVerificationRequiredException(AuthorizationException): + """Raised when email verification is required before authentication. + + This exception includes an email_verification_id field that can be used + to retrieve the email verification object or resend the verification email. + """ + + def __init__( + self, + response: httpx.Response, + response_json: Optional[Mapping[str, Any]], + ) -> None: + super().__init__(response, response_json) + self.email_verification_id = self.extract_from_json( + "email_verification_id", None + ) + + class AuthenticationException(BaseRequestException): pass diff --git a/workos/user_management.py b/workos/user_management.py index 9e72bb14..d06b90c7 100644 --- a/workos/user_management.py +++ b/workos/user_management.py @@ -739,9 +739,7 @@ def list_sessions( order: Optional[PaginationOrder] = "desc", ) -> SyncOrAsync["SessionsListResource"]: ... - def revoke_session( - self, *, session_id: str - ) -> SyncOrAsync[UserManagementSession]: ... + def revoke_session(self, *, session_id: str) -> SyncOrAsync[None]: ... def get_magic_auth(self, magic_auth_id: str) -> SyncOrAsync[MagicAuth]: """Get the details of a Magic Auth object. @@ -1439,15 +1437,13 @@ def list_sessions( **ListPage[UserManagementSession](**response).model_dump(), ) - def revoke_session(self, *, session_id: str) -> UserManagementSession: + def revoke_session(self, *, session_id: str) -> None: json = {"session_id": session_id} - response = self._http_client.request( + self._http_client.request( SESSIONS_REVOKE_PATH, method=REQUEST_METHOD_POST, json=json ) - return UserManagementSession.model_validate(response) - def enroll_auth_factor( self, *, @@ -2143,15 +2139,13 @@ async def list_sessions( **ListPage[UserManagementSession](**response).model_dump(), ) - async def revoke_session(self, *, session_id: str) -> UserManagementSession: + async def revoke_session(self, *, session_id: str) -> None: json = {"session_id": session_id} - response = await self._http_client.request( + 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, *, diff --git a/workos/utils/_base_http_client.py b/workos/utils/_base_http_client.py index a9ab0c55..49dcbcf5 100644 --- a/workos/utils/_base_http_client.py +++ b/workos/utils/_base_http_client.py @@ -20,6 +20,7 @@ ServerException, AuthenticationException, AuthorizationException, + EmailVerificationRequiredException, NotFoundException, BadRequestException, ) @@ -99,6 +100,11 @@ def _maybe_raise_error_by_status_code( if status_code == 401: raise AuthenticationException(response, response_json) elif status_code == 403: + if ( + response_json is not None + and response_json.get("code") == "email_verification_required" + ): + raise EmailVerificationRequiredException(response, response_json) raise AuthorizationException(response, response_json) elif status_code == 404: raise NotFoundException(response, response_json)