Skip to content

Commit b3ee5a5

Browse files
authored
Merge branch 'main' into auth-5288-user-locale
2 parents de9ae5b + f454e0a commit b3ee5a5

15 files changed

+377
-1
lines changed

tests/test_session.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def session_constants(self):
5151
"roles": ["admin"],
5252
"permissions": ["read"],
5353
"entitlements": ["feature_1"],
54+
"feature_flags": ["flag1", "flag2"],
5455
"exp": int(current_datetime.timestamp()) + 3600,
5556
"iat": int(current_datetime.timestamp()),
5657
}
@@ -244,6 +245,7 @@ def test_authenticate_success(self, session_constants, mock_user_management):
244245
"roles": ["admin"],
245246
"permissions": ["read"],
246247
"entitlements": ["feature_1"],
248+
"feature_flags": ["flag1", "flag2"],
247249
}
248250

249251
with patch.object(Session, "unseal_data", return_value=mock_session), patch(
@@ -263,6 +265,7 @@ def test_authenticate_success(self, session_constants, mock_user_management):
263265
assert response.roles == ["admin"]
264266
assert response.permissions == ["read"]
265267
assert response.entitlements == ["feature_1"]
268+
assert response.feature_flags == ["flag1", "flag2"]
266269
assert response.user.id == session_constants["USER_ID"]
267270
assert response.impersonator is None
268271

@@ -312,6 +315,7 @@ def test_authenticate_success_with_roles(
312315
"roles": ["admin", "member"],
313316
"permissions": ["read", "write"],
314317
"entitlements": ["feature_1"],
318+
"feature_flags": ["flag1", "flag2"],
315319
}
316320

317321
with patch.object(Session, "unseal_data", return_value=mock_session), patch(
@@ -331,6 +335,7 @@ def test_authenticate_success_with_roles(
331335
assert response.roles == ["admin", "member"]
332336
assert response.permissions == ["read", "write"]
333337
assert response.entitlements == ["feature_1"]
338+
assert response.feature_flags == ["flag1", "flag2"]
334339
assert response.user.id == session_constants["USER_ID"]
335340
assert response.impersonator is None
336341

@@ -410,6 +415,7 @@ def test_refresh_success(self, session_constants, mock_user_management):
410415
"roles": ["admin"],
411416
"permissions": ["read"],
412417
"entitlements": ["feature_1"],
418+
"feature_flags": ["flag1", "flag2"],
413419
},
414420
):
415421
response = session.refresh()
@@ -511,6 +517,7 @@ async def test_refresh_success(self, session_constants, mock_user_management):
511517
"roles": ["admin"],
512518
"permissions": ["read"],
513519
"entitlements": ["feature_1"],
520+
"feature_flags": ["flag1", "flag2"],
514521
},
515522
):
516523
response = await session.refresh()

tests/test_sync_http_client.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
BadRequestException,
1111
BaseRequestException,
1212
ConflictException,
13+
EmailVerificationRequiredException,
1314
ServerException,
1415
)
1516
from workos.utils.http_client import SyncHTTPClient
@@ -263,6 +264,57 @@ def test_conflict_exception(self):
263264
assert str(ex) == "(message=No message, request_id=request-123)"
264265
assert ex.__class__ == ConflictException
265266

267+
def test_email_verification_required_exception(self):
268+
request_id = "request-123"
269+
email_verification_id = "email_verification_01J6K4PMSWQXVFGF5ZQJXC6VC8"
270+
271+
self.http_client._client.request = MagicMock(
272+
return_value=httpx.Response(
273+
status_code=403,
274+
json={
275+
"message": "Please verify your email to authenticate via password.",
276+
"code": "email_verification_required",
277+
"email_verification_id": email_verification_id,
278+
},
279+
headers={"X-Request-ID": request_id},
280+
),
281+
)
282+
283+
try:
284+
self.http_client.request("bad_place")
285+
except EmailVerificationRequiredException as ex:
286+
assert (
287+
ex.message == "Please verify your email to authenticate via password."
288+
)
289+
assert ex.code == "email_verification_required"
290+
assert ex.email_verification_id == email_verification_id
291+
assert ex.request_id == request_id
292+
assert ex.__class__ == EmailVerificationRequiredException
293+
assert isinstance(ex, AuthorizationException)
294+
295+
def test_regular_authorization_exception_still_raised(self):
296+
request_id = "request-123"
297+
298+
self.http_client._client.request = MagicMock(
299+
return_value=httpx.Response(
300+
status_code=403,
301+
json={
302+
"message": "You do not have permission to access this resource.",
303+
"code": "forbidden",
304+
},
305+
headers={"X-Request-ID": request_id},
306+
),
307+
)
308+
309+
try:
310+
self.http_client.request("bad_place")
311+
except AuthorizationException as ex:
312+
assert ex.message == "You do not have permission to access this resource."
313+
assert ex.code == "forbidden"
314+
assert ex.request_id == request_id
315+
assert ex.__class__ == AuthorizationException
316+
assert not isinstance(ex, EmailVerificationRequiredException)
317+
266318
def test_request_includes_base_headers(self, capture_and_mock_http_client_request):
267319
request_kwargs = capture_and_mock_http_client_request(self.http_client, {}, 200)
268320

tests/test_user_management.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,37 @@ def test_authenticate_impersonator_with_code(
743743
"grant_type": "authorization_code",
744744
}
745745

746+
def test_authenticate_with_code_with_invitation_token(
747+
self,
748+
capture_and_mock_http_client_request,
749+
mock_auth_response,
750+
base_authentication_params,
751+
):
752+
params = {
753+
"code": "test_code",
754+
"code_verifier": "test_code_verifier",
755+
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
756+
"ip_address": "192.0.0.1",
757+
"invitation_token": "invitation_token_12345",
758+
}
759+
request_kwargs = capture_and_mock_http_client_request(
760+
self.http_client, mock_auth_response, 200
761+
)
762+
763+
response = syncify(self.user_management.authenticate_with_code(**params))
764+
765+
assert request_kwargs["url"].endswith("user_management/authenticate")
766+
assert request_kwargs["method"] == "post"
767+
assert response.user.id == "user_01H7ZGXFP5C6BBQY6Z7277ZCT0"
768+
assert response.organization_id == "org_12345"
769+
assert response.access_token == "access_token_12345"
770+
assert response.refresh_token == "refresh_token_12345"
771+
assert request_kwargs["json"] == {
772+
**params,
773+
**base_authentication_params,
774+
"grant_type": "authorization_code",
775+
}
776+
746777
def test_authenticate_with_magic_auth(
747778
self,
748779
capture_and_mock_http_client_request,
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from typing import Union
2+
3+
import pytest
4+
5+
from tests.utils.list_resource import list_response_of
6+
from tests.utils.syncify import syncify
7+
from tests.types.test_auto_pagination_function import TestAutoPaginationFunction
8+
from workos.user_management import AsyncUserManagement, UserManagement
9+
10+
11+
def _mock_session(id: str):
12+
now = "2025-07-23T14:00:00.000Z"
13+
return {
14+
"object": "session",
15+
"id": id,
16+
"user_id": "user_123",
17+
"organization_id": "org_123",
18+
"status": "active",
19+
"auth_method": "password",
20+
"impersonator": None,
21+
"ip_address": "192.168.1.1",
22+
"user_agent": "Mozilla/5.0",
23+
"expires_at": "2025-07-23T15:00:00.000Z",
24+
"ended_at": None,
25+
"created_at": now,
26+
"updated_at": now,
27+
}
28+
29+
30+
@pytest.mark.sync_and_async(UserManagement, AsyncUserManagement)
31+
class TestUserManagementListSessions:
32+
@pytest.fixture(autouse=True)
33+
def setup(self, module_instance: Union[UserManagement, AsyncUserManagement]):
34+
self.http_client = module_instance._http_client
35+
self.user_management = module_instance
36+
37+
def test_list_sessions_query_and_parsing(
38+
self, capture_and_mock_http_client_request
39+
):
40+
sessions = [_mock_session("session_1"), _mock_session("session_2")]
41+
response = list_response_of(data=sessions)
42+
request_kwargs = capture_and_mock_http_client_request(
43+
self.http_client, response, 200
44+
)
45+
46+
result = syncify(
47+
self.user_management.list_sessions(
48+
user_id="user_123", limit=10, before="before_id", order="desc"
49+
)
50+
)
51+
52+
assert request_kwargs["url"].endswith("user_management/users/user_123/sessions")
53+
assert request_kwargs["method"] == "get"
54+
assert request_kwargs["params"]["limit"] == 10
55+
assert request_kwargs["params"]["before"] == "before_id"
56+
assert request_kwargs["params"]["order"] == "desc"
57+
assert "after" not in request_kwargs["params"]
58+
assert len(result.data) == 2
59+
assert result.data[0].id == "session_1"
60+
assert result.list_metadata.before is None
61+
assert result.list_metadata.after is None
62+
63+
def test_list_sessions_auto_pagination(
64+
self, test_auto_pagination: TestAutoPaginationFunction
65+
):
66+
data = [_mock_session(str(i)) for i in range(40)]
67+
test_auto_pagination(
68+
http_client=self.http_client,
69+
list_function=self.user_management.list_sessions,
70+
list_function_params={"user_id": "user_123"},
71+
expected_all_page_data=data,
72+
url_path_keys=["user_id"],
73+
)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from typing import Union
2+
3+
import pytest
4+
5+
from tests.utils.syncify import syncify
6+
from workos.user_management import AsyncUserManagement, UserManagement
7+
8+
9+
@pytest.mark.sync_and_async(UserManagement, AsyncUserManagement)
10+
class TestUserManagementRevokeSession:
11+
@pytest.fixture(autouse=True)
12+
def setup(self, module_instance: Union[UserManagement, AsyncUserManagement]):
13+
self.http_client = module_instance._http_client
14+
self.user_management = module_instance
15+
16+
def test_revoke_session(self, capture_and_mock_http_client_request):
17+
request_kwargs = capture_and_mock_http_client_request(self.http_client, {}, 200)
18+
19+
response = syncify(
20+
self.user_management.revoke_session(session_id="session_abc")
21+
)
22+
23+
assert request_kwargs["url"].endswith("user_management/sessions/revoke")
24+
assert request_kwargs["method"] == "post"
25+
assert request_kwargs["json"] == {"session_id": "session_abc"}
26+
assert response is None

workos/__about__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
__package_url__ = "https://github.com/workos-inc/workos-python"
1414

15-
__version__ = "5.29.0"
15+
__version__ = "5.31.2"
1616

1717
__author__ = "WorkOS"
1818

workos/exceptions.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,24 @@ class AuthorizationException(BaseRequestException):
4545
pass
4646

4747

48+
class EmailVerificationRequiredException(AuthorizationException):
49+
"""Raised when email verification is required before authentication.
50+
51+
This exception includes an email_verification_id field that can be used
52+
to retrieve the email verification object or resend the verification email.
53+
"""
54+
55+
def __init__(
56+
self,
57+
response: httpx.Response,
58+
response_json: Optional[Mapping[str, Any]],
59+
) -> None:
60+
super().__init__(response, response_json)
61+
self.email_verification_id = self.extract_from_json(
62+
"email_verification_id", None
63+
)
64+
65+
4866
class AuthenticationException(BaseRequestException):
4967
pass
5068

workos/session.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from functools import lru_cache
55
import json
66
from typing import Any, Dict, Optional, Union, cast
7+
78
import jwt
89
from jwt import PyJWKClient
910
from cryptography.fernet import Fernet
@@ -107,6 +108,7 @@ def authenticate(
107108
entitlements=decoded.get("entitlements", None),
108109
user=session["user"],
109110
impersonator=session.get("impersonator", None),
111+
feature_flags=decoded.get("feature_flags", None),
110112
)
111113

112114
def refresh(
@@ -235,6 +237,7 @@ def refresh(
235237
entitlements=decoded.get("entitlements", None),
236238
user=auth_response.user,
237239
impersonator=auth_response.impersonator,
240+
feature_flags=decoded.get("feature_flags", None),
238241
)
239242
except Exception as e:
240243
return RefreshWithSessionCookieErrorResponse(
@@ -326,6 +329,7 @@ async def refresh(
326329
entitlements=decoded.get("entitlements", None),
327330
user=auth_response.user,
328331
impersonator=auth_response.impersonator,
332+
feature_flags=decoded.get("feature_flags", None),
329333
)
330334
except Exception as e:
331335
return RefreshWithSessionCookieErrorResponse(

workos/types/list_resource.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from workos.types.organizations import Organization
3535
from workos.types.sso import ConnectionWithDomains
3636
from workos.types.user_management import Invitation, OrganizationMembership, User
37+
from workos.types.user_management.session import Session as UserManagementSession
3738
from workos.types.vault import ObjectDigest
3839
from workos.types.workos_model import WorkOSModel
3940
from workos.utils.request_helper import DEFAULT_LIST_RESPONSE_LIMIT
@@ -54,6 +55,7 @@
5455
AuthorizationResource,
5556
AuthorizationResourceType,
5657
User,
58+
UserManagementSession,
5759
ObjectDigest,
5860
Warrant,
5961
WarrantQueryResult,

workos/types/user_management/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99
from .password_reset import *
1010
from .user_management_provider_type import *
1111
from .user import *
12+
from .session import *

0 commit comments

Comments
 (0)