From a2a7d5ca6cdbd323c8a4c05df8915665f5427af9 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Fri, 14 Nov 2025 10:10:43 +0000 Subject: [PATCH 01/16] Add support for getting and deleting connected accounts --- .../auth_server/my_account_client.py | 124 ++++++++++++++++++ .../auth_server/server_client.py | 42 ++++++ .../auth_types/__init__.py | 21 +++ 3 files changed, 187 insertions(+) diff --git a/src/auth0_server_python/auth_server/my_account_client.py b/src/auth0_server_python/auth_server/my_account_client.py index a5a31d2..d1786a1 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -1,4 +1,5 @@ +from typing import Optional import httpx from auth0_server_python.auth_schemes.bearer_auth import BearerAuth from auth0_server_python.auth_types import ( @@ -6,6 +7,8 @@ CompleteConnectAccountResponse, ConnectAccountRequest, ConnectAccountResponse, + ListConnectedAccountResponse, + ListConnectedAccountConnectionsResponse ) from auth0_server_python.error import ( ApiError, @@ -92,3 +95,124 @@ async def complete_connect_account( f"Connected Accounts complete request failed: {str(e) or 'Unknown error'}", e ) + + async def list_connected_accounts( + self, + access_token: str, + connection: Optional[str] = None, + from_token: Optional[str] = None, + take: Optional[int] = None + ) -> ListConnectedAccountResponse: + try: + async with httpx.AsyncClient() as client: + params = {} + if connection: + params["connection"] = connection + if from_token: + params["from"] = from_token + if take: + params["take"] = take + + response = await client.get( + url=f"{self.audience}v1/connected-accounts/accounts", + params=params, + auth=BearerAuth(access_token) + ) + + if response.status_code != 200: + error_data = response.json() + raise MyAccountApiError( + title=error_data.get("title", None), + type=error_data.get("type", None), + detail=error_data.get("detail", None), + status=error_data.get("status", None), + validation_errors=error_data.get("validation_errors", None) + ) + + data = response.json() + + return ListConnectedAccountResponse.model_validate(data) + + except Exception as e: + if isinstance(e, MyAccountApiError): + raise + raise ApiError( + "connect_account_error", + f"Connected Accounts complete request failed: {str(e) or 'Unknown error'}", + e + ) + + + async def delete_connected_account( + self, + access_token: str, + connected_account_id: str + ) -> CompleteConnectAccountResponse: + try: + async with httpx.AsyncClient() as client: + response = await client.delete( + url=f"{self.audience}v1/connected-accounts/accounts/{connected_account_id}", + auth=BearerAuth(access_token) + ) + + if response.status_code != 204: + error_data = response.json() + raise MyAccountApiError( + title=error_data.get("title", None), + type=error_data.get("type", None), + detail=error_data.get("detail", None), + status=error_data.get("status", None), + validation_errors=error_data.get("validation_errors", None) + ) + + except Exception as e: + if isinstance(e, MyAccountApiError): + raise + raise ApiError( + "connect_account_error", + f"Connected Accounts complete request failed: {str(e) or 'Unknown error'}", + e + ) + + async def list_connected_account_connections( + self, + access_token: str, + from_token: Optional[str] = None, + take: Optional[int] = None + ) -> CompleteConnectAccountResponse: + try: + async with httpx.AsyncClient() as client: + params = {} + if from_token: + params["from"] = from_token + if take: + params["take"] = take + + response = await client.get( + url=f"{self.audience}v1/connected-accounts/connections", + params=params, + auth=BearerAuth(access_token) + ) + + if response.status_code != 200: + error_data = response.json() + raise MyAccountApiError( + title=error_data.get("title", None), + type=error_data.get("type", None), + detail=error_data.get("detail", None), + status=error_data.get("status", None), + validation_errors=error_data.get("validation_errors", None) + ) + + data = response.json() + + return ListConnectedAccountConnectionsResponse.model_validate(data) + + except Exception as e: + if isinstance(e, MyAccountApiError): + raise + raise ApiError( + "connect_account_error", + f"Connected Accounts complete request failed: {str(e) or 'Unknown error'}", + e + ) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index c968120..17b5e94 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -1471,3 +1471,45 @@ async def complete_connect_account( await self._transaction_store.delete(transaction_identifier, options=store_options) return response + + async def list_connected_accounts( + self, + connection: Optional[str] = None, + from_token: Optional[str] = None, + take: Optional[int] = None, + store_options: dict = None + ) -> CompleteConnectAccountResponse: + access_token = await self.get_access_token( + audience=self._my_account_client.audience, + scope="read:me:connected_accounts", + store_options=store_options + ) + return await self._my_account_client.list_connected_accounts( + access_token=access_token, connection=connection, from_token=from_token, take=take) + + async def delete_connected_account( + self, + connected_account_id: str, + store_options: dict = None + ) -> CompleteConnectAccountResponse: + access_token = await self.get_access_token( + audience=self._my_account_client.audience, + scope="delete:me:connected_accounts", + store_options=store_options + ) + return await self._my_account_client.delete_connected_account( + access_token=access_token, connected_account_id=connected_account_id) + + async def list_connected_account_connections( + self, + from_token: Optional[str] = None, + take: Optional[int] = None, + store_options: dict = None + ) -> CompleteConnectAccountResponse: + access_token = await self.get_access_token( + audience=self._my_account_client.audience, + scope="read:me:connected_accounts", + store_options=store_options + ) + return await self._my_account_client.list_connected_account_connections( + access_token=access_token, from_token=from_token, take=take) \ No newline at end of file diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 677a7da..7c2253d 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -252,3 +252,24 @@ class CompleteConnectAccountResponse(BaseModel): created_at: str expires_at: Optional[str] = None app_state: Optional[Any] = None + +class ConnectedAccount(BaseModel): + id: str + connection: str + access_type: str + scopes: list[str] + created_at: str + expires_at: Optional[str] = None + +class ListConnectedAccountResponse(BaseModel): + accounts: list[ConnectedAccount] + next: Optional[str] = None + +class ConnectedAccountConnection(BaseModel): + name: str + strategy: str + scopes: Optional[list[str]] = None + +class ListConnectedAccountConnectionsResponse(BaseModel): + connections: list[ConnectedAccountConnection] + next: Optional[str] = None \ No newline at end of file From ea85e80d856923ec85d7017c6fad80a21e719353 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 18 Nov 2025 17:02:34 +0000 Subject: [PATCH 02/16] Linting fixes --- .../auth_server/my_account_client.py | 3 ++- src/auth0_server_python/auth_server/server_client.py | 12 ++++++------ src/auth0_server_python/auth_types/__init__.py | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/auth0_server_python/auth_server/my_account_client.py b/src/auth0_server_python/auth_server/my_account_client.py index d1786a1..fbc6f2c 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -1,5 +1,6 @@ from typing import Optional + import httpx from auth0_server_python.auth_schemes.bearer_auth import BearerAuth from auth0_server_python.auth_types import ( @@ -7,8 +8,8 @@ CompleteConnectAccountResponse, ConnectAccountRequest, ConnectAccountResponse, + ListConnectedAccountConnectionsResponse, ListConnectedAccountResponse, - ListConnectedAccountConnectionsResponse ) from auth0_server_python.error import ( ApiError, diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 17b5e94..de6e3e6 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -1471,14 +1471,14 @@ async def complete_connect_account( await self._transaction_store.delete(transaction_identifier, options=store_options) return response - + async def list_connected_accounts( self, connection: Optional[str] = None, from_token: Optional[str] = None, take: Optional[int] = None, store_options: dict = None - ) -> CompleteConnectAccountResponse: + ) -> CompleteConnectAccountResponse: access_token = await self.get_access_token( audience=self._my_account_client.audience, scope="read:me:connected_accounts", @@ -1486,12 +1486,12 @@ async def list_connected_accounts( ) return await self._my_account_client.list_connected_accounts( access_token=access_token, connection=connection, from_token=from_token, take=take) - + async def delete_connected_account( self, connected_account_id: str, store_options: dict = None - ) -> CompleteConnectAccountResponse: + ) -> CompleteConnectAccountResponse: access_token = await self.get_access_token( audience=self._my_account_client.audience, scope="delete:me:connected_accounts", @@ -1505,11 +1505,11 @@ async def list_connected_account_connections( from_token: Optional[str] = None, take: Optional[int] = None, store_options: dict = None - ) -> CompleteConnectAccountResponse: + ) -> CompleteConnectAccountResponse: access_token = await self.get_access_token( audience=self._my_account_client.audience, scope="read:me:connected_accounts", store_options=store_options ) return await self._my_account_client.list_connected_account_connections( - access_token=access_token, from_token=from_token, take=take) \ No newline at end of file + access_token=access_token, from_token=from_token, take=take) diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 7c2253d..45ba69d 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -272,4 +272,4 @@ class ConnectedAccountConnection(BaseModel): class ListConnectedAccountConnectionsResponse(BaseModel): connections: list[ConnectedAccountConnection] - next: Optional[str] = None \ No newline at end of file + next: Optional[str] = None From 100ce13be96de09ba24990e1443aa8c2875af42a Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 18 Nov 2025 17:03:04 +0000 Subject: [PATCH 03/16] Add some tests for new my account api methods --- .../tests/test_my_account_client.py | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) diff --git a/src/auth0_server_python/tests/test_my_account_client.py b/src/auth0_server_python/tests/test_my_account_client.py index f4f18fb..3797115 100644 --- a/src/auth0_server_python/tests/test_my_account_client.py +++ b/src/auth0_server_python/tests/test_my_account_client.py @@ -7,7 +7,11 @@ CompleteConnectAccountResponse, ConnectAccountRequest, ConnectAccountResponse, + ConnectedAccount, + ConnectedAccountConnection, ConnectParams, + ListConnectedAccountConnectionsResponse, + ListConnectedAccountResponse, ) from auth0_server_python.error import MyAccountApiError @@ -158,3 +162,227 @@ async def test_complete_connect_account_api_response_failure(mocker): # Assert mock_post.assert_awaited_once() assert "Invalid Token" in str(exc.value) + +@pytest.mark.asyncio +async def test_list_connected_accounts_success(mocker): + # Arrange + client = MyAccountClient(domain="auth0.local") + response = AsyncMock() + response.status_code = 200 + response.json = MagicMock(return_value={ + "accounts": [{ + "id": "", + "connection": "", + "access_type": "offline", + "scopes": ["openid", "profile", "email", "offline_access"], + "created_at": "", + "expires_at": "" + }, + { + "id": "", + "connection": "", + "access_type": "offline", + "scopes": ["user:email", "foo", "bar"], + "created_at": "", + "expires_at": "" + }], + "next": "" + }) + + mock_get = mocker.patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=response) + + # Act + result = await client.list_connected_accounts( + access_token="", + connection="", + from_token="", + take=2 + ) + + # Assert + mock_get.assert_awaited_with( + url="https://auth0.local/me/v1/connected-accounts/accounts", + params={ + "connection": "", + "from": "", + "take": 2 + }, + auth=ANY + ) + assert result == ListConnectedAccountResponse( + accounts=[ ConnectedAccount( + id="", + connection="", + access_type="offline", + scopes=["openid", "profile", "email", "offline_access"], + created_at="", + expires_at="" + ), ConnectedAccount( + id="", + connection="", + access_type="offline", + scopes=["user:email", "foo", "bar"], + created_at="", + expires_at="" + ) ], + next="" + ) + +@pytest.mark.asyncio +async def test_list_connected_accounts_api_response_failure(mocker): + # Arrange + client = MyAccountClient(domain="auth0.local") + response = AsyncMock() + response.status_code = 401 + response.json = MagicMock(return_value={ + "title": "Invalid Token", + "type": "https://auth0.com/api-errors/A0E-401-0003", + "detail": "Invalid Token", + "status": 401 + }) + + mock_get = mocker.patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=response) + + # Act + with pytest.raises(MyAccountApiError) as exc: + await client.list_connected_accounts( + access_token="", + connection="", + from_token="", + take=2 + ) + + # Assert + mock_get.assert_awaited_once() + assert "Invalid Token" in str(exc.value) + +@pytest.mark.asyncio +async def test_delete_connected_account_success(mocker): + # Arrange + client = MyAccountClient(domain="auth0.local") + response = AsyncMock() + response.status_code = 204 + + mock_get = mocker.patch("httpx.AsyncClient.delete", new_callable=AsyncMock, return_value=response) + + # Act + await client.delete_connected_account( + access_token="", + connected_account_id="" + ) + + # Assert + mock_get.assert_awaited_with( + url="https://auth0.local/me/v1/connected-accounts/accounts/", + auth=ANY + ) + +@pytest.mark.asyncio +async def test_delete_connected_account_api_response_failure(mocker): + # Arrange + client = MyAccountClient(domain="auth0.local") + response = AsyncMock() + response.status_code = 401 + response.json = MagicMock(return_value={ + "title": "Invalid Token", + "type": "https://auth0.com/api-errors/A0E-401-0003", + "detail": "Invalid Token", + "status": 401 + }) + + mock_delete = mocker.patch("httpx.AsyncClient.delete", new_callable=AsyncMock, return_value=response) + + # Act + with pytest.raises(MyAccountApiError) as exc: + await client.delete_connected_account( + access_token="", + connected_account_id="" + ) + + # Assert + mock_delete.assert_awaited_once() + assert "Invalid Token" in str(exc.value) + +@pytest.mark.asyncio +async def test_list_connected_account_connections_success(mocker): + # Arrange + client = MyAccountClient(domain="auth0.local") + response = AsyncMock() + response.status_code = 200 + response.json = MagicMock(return_value={ + "connections": [{ + "name": "github", + "strategy": "github", + "scopes": [ + "user:email" + ] + }, + { + "name": "google-oauth2", + "strategy": "google-oauth2", + "scopes": [ + "email", + "profile" + ] + }], + "next": "" + }) + + mock_get = mocker.patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=response) + + # Act + result = await client.list_connected_account_connections( + access_token="", + from_token="", + take=2 + ) + + # Assert + mock_get.assert_awaited_with( + url="https://auth0.local/me/v1/connected-accounts/connections", + params={ + "from": "", + "take": 2 + }, + auth=ANY + ) + assert result == ListConnectedAccountConnectionsResponse( + connections=[ ConnectedAccountConnection( + name="github", + strategy="github", + scopes=["user:email"] + ), ConnectedAccountConnection( + name="google-oauth2", + strategy="google-oauth2", + scopes=["email", "profile"] + ) ], + next="" + ) + +@pytest.mark.asyncio +async def test_list_connected_account_connections_api_response_failure(mocker): + # Arrange + client = MyAccountClient(domain="auth0.local") + response = AsyncMock() + response.status_code = 401 + response.json = MagicMock(return_value={ + "title": "Invalid Token", + "type": "https://auth0.com/api-errors/A0E-401-0003", + "detail": "Invalid Token", + "status": 401 + }) + + mock_get = mocker.patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=response) + + # Act + with pytest.raises(MyAccountApiError) as exc: + await client.list_connected_account_connections( + access_token="", + from_token="", + take=2 + ) + + # Assert + mock_get.assert_awaited_once() + assert "Invalid Token" in str(exc.value) + From a8db5d589f377d542894bfb7b07774faf17a77dd Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 18 Nov 2025 17:39:17 +0000 Subject: [PATCH 04/16] Add tests for server_client behaviour --- .../auth_server/my_account_client.py | 2 +- .../auth_server/server_client.py | 10 +- .../tests/test_server_client.py | 137 ++++++++++++++++++ 3 files changed, 144 insertions(+), 5 deletions(-) diff --git a/src/auth0_server_python/auth_server/my_account_client.py b/src/auth0_server_python/auth_server/my_account_client.py index fbc6f2c..ce5832c 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -180,7 +180,7 @@ async def list_connected_account_connections( access_token: str, from_token: Optional[str] = None, take: Optional[int] = None - ) -> CompleteConnectAccountResponse: + ) -> ListConnectedAccountConnectionsResponse: try: async with httpx.AsyncClient() as client: params = {} diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index de6e3e6..6ef1e3a 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -17,6 +17,8 @@ CompleteConnectAccountResponse, ConnectAccountOptions, ConnectAccountRequest, + ListConnectedAccountConnectionsResponse, + ListConnectedAccountResponse, LogoutOptions, LogoutTokenClaims, StartInteractiveLoginOptions, @@ -1478,7 +1480,7 @@ async def list_connected_accounts( from_token: Optional[str] = None, take: Optional[int] = None, store_options: dict = None - ) -> CompleteConnectAccountResponse: + ) -> ListConnectedAccountResponse: access_token = await self.get_access_token( audience=self._my_account_client.audience, scope="read:me:connected_accounts", @@ -1491,13 +1493,13 @@ async def delete_connected_account( self, connected_account_id: str, store_options: dict = None - ) -> CompleteConnectAccountResponse: + ) -> None: access_token = await self.get_access_token( audience=self._my_account_client.audience, scope="delete:me:connected_accounts", store_options=store_options ) - return await self._my_account_client.delete_connected_account( + await self._my_account_client.delete_connected_account( access_token=access_token, connected_account_id=connected_account_id) async def list_connected_account_connections( @@ -1505,7 +1507,7 @@ async def list_connected_account_connections( from_token: Optional[str] = None, take: Optional[int] = None, store_options: dict = None - ) -> CompleteConnectAccountResponse: + ) -> ListConnectedAccountConnectionsResponse: access_token = await self.get_access_token( audience=self._my_account_client.audience, scope="read:me:connected_accounts", diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 9f4f2cd..87a7671 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -11,7 +11,11 @@ ConnectAccountOptions, ConnectAccountRequest, ConnectAccountResponse, + ConnectedAccount, + ConnectedAccountConnection, ConnectParams, + ListConnectedAccountConnectionsResponse, + ListConnectedAccountResponse, LogoutOptions, TransactionData, ) @@ -1932,3 +1936,136 @@ async def test_complete_connect_account_no_transactions(mocker): # Assert assert "transaction" in str(exc.value) mock_my_account_client.complete_connect_account.assert_not_awaited() + +@pytest.mark.asyncio +async def test_list_connected_accounts_gets_access_token_and_calls_my_account(mocker): + # Setup + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + secret="some-secret" + ) + mock_get_access_token = AsyncMock(return_value="") + mocker.patch.object(client, "get_access_token", mock_get_access_token) + mock_my_account_client = AsyncMock(MyAccountClient) + mocker.patch.object(client, "_my_account_client", mock_my_account_client) + mocker.patch.object(mock_my_account_client, "audience", "https://auth0.local/me/") + expected_response= ListConnectedAccountResponse( + accounts=[ ConnectedAccount( + id="", + connection="", + access_type="offline", + scopes=["openid", "profile", "email", "offline_access"], + created_at="", + expires_at="" + ), ConnectedAccount( + id="", + connection="", + access_type="offline", + scopes=["user:email", "foo", "bar"], + created_at="", + expires_at="" + ) ], + next="" + ) + + mock_my_account_client.list_connected_accounts.return_value = expected_response + + # Act + response = await client.list_connected_accounts( + connection="", + from_token="", + take=2 + ) + + # Assert + assert response == expected_response + mock_get_access_token.assert_awaited_with( + audience="https://auth0.local/me/", + scope="read:me:connected_accounts", + store_options=ANY + ) + mock_my_account_client.list_connected_accounts.assert_awaited_with( + access_token="", + connection="", + from_token="", + take=2 + ) + +@pytest.mark.asyncio +async def test_delete_connected_account_gets_access_token_and_calls_my_account(mocker): + # Setup + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + secret="some-secret" + ) + mock_get_access_token = AsyncMock(return_value="") + mocker.patch.object(client, "get_access_token", mock_get_access_token) + mock_my_account_client = AsyncMock(MyAccountClient) + mocker.patch.object(client, "_my_account_client", mock_my_account_client) + mocker.patch.object(mock_my_account_client, "audience", "https://auth0.local/me/") + + # Act + await client.delete_connected_account(connected_account_id="") + + # Assert + mock_get_access_token.assert_awaited_with( + audience="https://auth0.local/me/", + scope="delete:me:connected_accounts", + store_options=ANY + ) + mock_my_account_client.delete_connected_account.assert_awaited_with( + access_token="", + connected_account_id="" + ) + +@pytest.mark.asyncio +async def test_list_connected_account_connections_gets_access_token_and_calls_my_account(mocker): + # Setup + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + secret="some-secret" + ) + mock_get_access_token = AsyncMock(return_value="") + mocker.patch.object(client, "get_access_token", mock_get_access_token) + mock_my_account_client = AsyncMock(MyAccountClient) + mocker.patch.object(client, "_my_account_client", mock_my_account_client) + mocker.patch.object(mock_my_account_client, "audience", "https://auth0.local/me/") + expected_response= ListConnectedAccountConnectionsResponse( + connections=[ ConnectedAccountConnection( + name="github", + strategy="github", + scopes=["user:email"] + ), ConnectedAccountConnection( + name="google-oauth2", + strategy="google-oauth2", + scopes=["email", "profile"] + ) ], + next="" + ) + + mock_my_account_client.list_connected_account_connections.return_value = expected_response + + # Act + response = await client.list_connected_account_connections( + from_token="", + take=2 + ) + + # Assert + assert response == expected_response + mock_get_access_token.assert_awaited_with( + audience="https://auth0.local/me/", + scope="read:me:connected_accounts", + store_options=ANY + ) + mock_my_account_client.list_connected_account_connections.assert_awaited_with( + access_token="", + from_token="", + take=2 + ) From c76446826eb4b414c7405f99073242c55042ca54 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 18 Nov 2025 18:45:15 +0000 Subject: [PATCH 05/16] Add some docs about managing connected accounts --- examples/ConnectedAccounts.md | 75 ++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/examples/ConnectedAccounts.md b/examples/ConnectedAccounts.md index 638e27c..e099ff7 100644 --- a/examples/ConnectedAccounts.md +++ b/examples/ConnectedAccounts.md @@ -111,6 +111,79 @@ You can now call the API with your access token and the API can use [Access Toke ```python access_token_for_google = await server_client.get_access_token_for_connection( { "connection": "google-oauth2" }, - store_options=store_options + store_options={"request": request, "response": response} +) +``` + +## Managing Connected Accounts + +`ServerClient` exposes three methods for managing a user's connected accounts + +### List Available Connections + +This method provides a list of connections that have been enabled for use with Connected Accounts for Token Vault that the user may use to connect accounts. + +This method requires the My Account `read:me:connected_accounts` scope to be enabled for your application and configured for MRRT. + +This method supports paging via optional the use of `take` parameter. Without this parameters, a default page size of 10 is used. Subsequent pages can be retrieved by also passing the `from_token` parameter with the token returned in the `next` property of the response + +```python +available_connections = await client.list_connected_account_connections( + take= 5, # optional + from_token= "NEXT_VALUE_FROM_PREVIOUS_RESPONSE", # optional + store_options= {"request": request, "response": response} +) +``` + +### List Connected Accounts + +This method provides a list of accounts that you have already connected. + +This method requires the My Account `read:me:connected_accounts` scope to be enabled for your application and configured for MRRT. + +An optional `connection` parameter can be used to filter the connected accounts for a specific connection, otherwise all connected accounts will be returns + +This method supports paging via optional the use of `take` parameter. Without this parameters, a default page size of 10 is used. Subsequent pages can be retrieved by also passing the `from_token` parameter with the token returned in the `next` property of the response + +```python +connected_accounts = await client.list_connected_accounts( + connection= "google-oauth2" # optional + take= 5, # optional + from_token= "NEXT_VALUE_FROM_PREVIOUS_RESPONSE", # optional + store_options= {"request": request, "response": response} +) +``` + +### Delete Connected Account + +This method removes a connected account for the user. + +This method requires the My Account `delete:me:connected_accounts` scope to be enabled for your application and configured for MRRT. + +This method takes a `connected_account_id` parameter which can be obtained from `list_connected_accounts`. + +```python +connected_accounts = await client.delete_connected_account( + connected_account_id= "CONNECTED_ACCOUNT_ID" + store_options= {"request": request, "response": response} +) +``` + +## A note about scopes + +If multiple pieces of Connected Account functionality are intended to be used, it is recommended that you set the default `scope` for the My Account audience when creating you `ServerClient`. This will avoid multiple token requests as without it a new token will be requested for each scope used. This can be done by configuring the `scope` dictionary in the `authorization_params` when configuring the SDK. Each value in the dictionary corresponds to an `audience` and sets the `default` requested scopes for that audience. + +```python +server_client = ServerClient( + domain="YOUR_AUTH0_DOMAIN", + client_id="YOUR_CLIENT_ID", + client_secret="YOUR_CLIENT_SECRET", + secret="YOUR_SECRET", + authorization_params={ + "scope" { + "https://YOUR_AUTH0_DOMAIN/me/": "create:me:connected_accounts read:me:connected_accounts delete:me:connected_accounts", # scopes required for the My Account audience + # default scopes for custom API audiences can also be defined + } + } ) ``` \ No newline at end of file From 4f2e80581fcd7bafdde3a63e884bd51acec5a0c1 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 18 Nov 2025 18:58:55 +0000 Subject: [PATCH 06/16] Add doc comments --- .../auth_server/server_client.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 6ef1e3a..7b50cd4 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -1481,6 +1481,22 @@ async def list_connected_accounts( take: Optional[int] = None, store_options: dict = None ) -> ListConnectedAccountResponse: + """ + Retrieves a list of connected accounts for the authenticated user. + + Args: + connection (Optional[str], optional): Filter results to a specific connection. Defaults to None. + from_token (Optional[str], optional): A pagination token indicating where to start retrieving results, obtained from a prior request. Defaults to None. + take (Optional[int], optional): The maximum number of connections to retrieve. Defaults to None. + store_options: Optional options used to pass to the Transaction and State Store. + + Returns: + ListConnectedAccountResponse: The response object containing the list of connected accounts. + + Raises: + Auth0Error: If there is an error retrieving the access token. + MyAccountApiError: If the My Account API returns an error response. + """ access_token = await self.get_access_token( audience=self._my_account_client.audience, scope="read:me:connected_accounts", @@ -1494,6 +1510,17 @@ async def delete_connected_account( connected_account_id: str, store_options: dict = None ) -> None: + """ + Deletes a connected account. + + Args: + connected_account_id (str): The ID of the connected account to delete. + store_options: Optional options used to pass to the Transaction and State Store. + + Raises: + Auth0Error: If there is an error retrieving the access token. + MyAccountApiError: If the My Account API returns an error response. + """ access_token = await self.get_access_token( audience=self._my_account_client.audience, scope="delete:me:connected_accounts", @@ -1508,6 +1535,21 @@ async def list_connected_account_connections( take: Optional[int] = None, store_options: dict = None ) -> ListConnectedAccountConnectionsResponse: + """ + Retrieves a list of available connections the can be used connected accounts for the authenticated user. + + Args: + from_token (Optional[str], optional): A pagination token indicating where to start retrieving results, obtained from a prior request. Defaults to None. + take (Optional[int], optional): The maximum number of connections to retrieve. Defaults to None. + store_options: Optional options used to pass to the Transaction and State Store. + + Returns: + ListConnectedAccountConnectionsResponse: The response object containing the list of connected account connections. + + Raises: + Auth0Error: If there is an error retrieving the access token. + MyAccountApiError: If the My Account API returns an error response. + """ access_token = await self.get_access_token( audience=self._my_account_client.audience, scope="read:me:connected_accounts", From 9717a263354d29be21aef4feb9b478acc345b6e7 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Mon, 8 Dec 2025 17:21:25 +0000 Subject: [PATCH 07/16] Claude review fixes --- src/auth0_server_python/auth_server/my_account_client.py | 8 ++++---- src/auth0_server_python/auth_server/server_client.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/auth0_server_python/auth_server/my_account_client.py b/src/auth0_server_python/auth_server/my_account_client.py index ce5832c..803db44 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -139,7 +139,7 @@ async def list_connected_accounts( raise raise ApiError( "connect_account_error", - f"Connected Accounts complete request failed: {str(e) or 'Unknown error'}", + f"Connected Accounts list request failed: {str(e) or 'Unknown error'}", e ) @@ -148,7 +148,7 @@ async def delete_connected_account( self, access_token: str, connected_account_id: str - ) -> CompleteConnectAccountResponse: + ) -> None: try: async with httpx.AsyncClient() as client: response = await client.delete( @@ -171,7 +171,7 @@ async def delete_connected_account( raise raise ApiError( "connect_account_error", - f"Connected Accounts complete request failed: {str(e) or 'Unknown error'}", + f"Connected Accounts delete request failed: {str(e) or 'Unknown error'}", e ) @@ -214,6 +214,6 @@ async def list_connected_account_connections( raise raise ApiError( "connect_account_error", - f"Connected Accounts complete request failed: {str(e) or 'Unknown error'}", + f"Connected Accounts list connections request failed: {str(e) or 'Unknown error'}", e ) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 7b50cd4..b9a826f 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -1536,7 +1536,7 @@ async def list_connected_account_connections( store_options: dict = None ) -> ListConnectedAccountConnectionsResponse: """ - Retrieves a list of available connections the can be used connected accounts for the authenticated user. + Retrieves a list of available connections that can be used connected accounts for the authenticated user. Args: from_token (Optional[str], optional): A pagination token indicating where to start retrieving results, obtained from a prior request. Defaults to None. From 29570003003e34e1e1c3bf03b16b24e8e47d610f Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Mon, 8 Dec 2025 17:36:47 +0000 Subject: [PATCH 08/16] Claude doc fixes --- examples/ConnectedAccounts.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/ConnectedAccounts.md b/examples/ConnectedAccounts.md index e099ff7..41f5b6f 100644 --- a/examples/ConnectedAccounts.md +++ b/examples/ConnectedAccounts.md @@ -147,7 +147,7 @@ This method supports paging via optional the use of `take` parameter. Without th ```python connected_accounts = await client.list_connected_accounts( - connection= "google-oauth2" # optional + connection= "google-oauth2", # optional take= 5, # optional from_token= "NEXT_VALUE_FROM_PREVIOUS_RESPONSE", # optional store_options= {"request": request, "response": response} @@ -164,7 +164,7 @@ This method takes a `connected_account_id` parameter which can be obtained from ```python connected_accounts = await client.delete_connected_account( - connected_account_id= "CONNECTED_ACCOUNT_ID" + connected_account_id= "CONNECTED_ACCOUNT_ID", store_options= {"request": request, "response": response} ) ``` From 8fb55b130cff8ce84d1792385bb9bff091be63ef Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 9 Dec 2025 10:38:04 +0000 Subject: [PATCH 09/16] Rename from_token to from_param --- examples/ConnectedAccounts.md | 8 ++++---- .../auth_server/my_account_client.py | 12 ++++++------ src/auth0_server_python/auth_server/server_client.py | 12 ++++++------ .../tests/test_my_account_client.py | 12 ++++++------ src/auth0_server_python/tests/test_server_client.py | 8 ++++---- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/examples/ConnectedAccounts.md b/examples/ConnectedAccounts.md index 41f5b6f..7722bd1 100644 --- a/examples/ConnectedAccounts.md +++ b/examples/ConnectedAccounts.md @@ -125,12 +125,12 @@ This method provides a list of connections that have been enabled for use with C This method requires the My Account `read:me:connected_accounts` scope to be enabled for your application and configured for MRRT. -This method supports paging via optional the use of `take` parameter. Without this parameters, a default page size of 10 is used. Subsequent pages can be retrieved by also passing the `from_token` parameter with the token returned in the `next` property of the response +This method supports paging via optional the use of `take` parameter. Without this parameters, a default page size of 10 is used. Subsequent pages can be retrieved by also passing the `from_param` parameter with the token returned in the `next` property of the response ```python available_connections = await client.list_connected_account_connections( take= 5, # optional - from_token= "NEXT_VALUE_FROM_PREVIOUS_RESPONSE", # optional + from_param= "NEXT_VALUE_FROM_PREVIOUS_RESPONSE", # optional store_options= {"request": request, "response": response} ) ``` @@ -143,13 +143,13 @@ This method requires the My Account `read:me:connected_accounts` scope to be ena An optional `connection` parameter can be used to filter the connected accounts for a specific connection, otherwise all connected accounts will be returns -This method supports paging via optional the use of `take` parameter. Without this parameters, a default page size of 10 is used. Subsequent pages can be retrieved by also passing the `from_token` parameter with the token returned in the `next` property of the response +This method supports paging via optional the use of `take` parameter. Without this parameters, a default page size of 10 is used. Subsequent pages can be retrieved by also passing the `from_param` parameter with the token returned in the `next` property of the response ```python connected_accounts = await client.list_connected_accounts( connection= "google-oauth2", # optional take= 5, # optional - from_token= "NEXT_VALUE_FROM_PREVIOUS_RESPONSE", # optional + from_param= "NEXT_VALUE_FROM_PREVIOUS_RESPONSE", # optional store_options= {"request": request, "response": response} ) ``` diff --git a/src/auth0_server_python/auth_server/my_account_client.py b/src/auth0_server_python/auth_server/my_account_client.py index 803db44..0e43759 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -101,7 +101,7 @@ async def list_connected_accounts( self, access_token: str, connection: Optional[str] = None, - from_token: Optional[str] = None, + from_param: Optional[str] = None, take: Optional[int] = None ) -> ListConnectedAccountResponse: try: @@ -109,8 +109,8 @@ async def list_connected_accounts( params = {} if connection: params["connection"] = connection - if from_token: - params["from"] = from_token + if from_param: + params["from"] = from_param if take: params["take"] = take @@ -178,14 +178,14 @@ async def delete_connected_account( async def list_connected_account_connections( self, access_token: str, - from_token: Optional[str] = None, + from_param: Optional[str] = None, take: Optional[int] = None ) -> ListConnectedAccountConnectionsResponse: try: async with httpx.AsyncClient() as client: params = {} - if from_token: - params["from"] = from_token + if from_param: + params["from"] = from_param if take: params["take"] = take diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index b9a826f..ce1091a 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -1477,7 +1477,7 @@ async def complete_connect_account( async def list_connected_accounts( self, connection: Optional[str] = None, - from_token: Optional[str] = None, + from_param: Optional[str] = None, take: Optional[int] = None, store_options: dict = None ) -> ListConnectedAccountResponse: @@ -1486,7 +1486,7 @@ async def list_connected_accounts( Args: connection (Optional[str], optional): Filter results to a specific connection. Defaults to None. - from_token (Optional[str], optional): A pagination token indicating where to start retrieving results, obtained from a prior request. Defaults to None. + from_param (Optional[str], optional): A pagination token indicating where to start retrieving results, obtained from a prior request. Defaults to None. take (Optional[int], optional): The maximum number of connections to retrieve. Defaults to None. store_options: Optional options used to pass to the Transaction and State Store. @@ -1503,7 +1503,7 @@ async def list_connected_accounts( store_options=store_options ) return await self._my_account_client.list_connected_accounts( - access_token=access_token, connection=connection, from_token=from_token, take=take) + access_token=access_token, connection=connection, from_param=from_param, take=take) async def delete_connected_account( self, @@ -1531,7 +1531,7 @@ async def delete_connected_account( async def list_connected_account_connections( self, - from_token: Optional[str] = None, + from_param: Optional[str] = None, take: Optional[int] = None, store_options: dict = None ) -> ListConnectedAccountConnectionsResponse: @@ -1539,7 +1539,7 @@ async def list_connected_account_connections( Retrieves a list of available connections that can be used connected accounts for the authenticated user. Args: - from_token (Optional[str], optional): A pagination token indicating where to start retrieving results, obtained from a prior request. Defaults to None. + from_param (Optional[str], optional): A pagination token indicating where to start retrieving results, obtained from a prior request. Defaults to None. take (Optional[int], optional): The maximum number of connections to retrieve. Defaults to None. store_options: Optional options used to pass to the Transaction and State Store. @@ -1556,4 +1556,4 @@ async def list_connected_account_connections( store_options=store_options ) return await self._my_account_client.list_connected_account_connections( - access_token=access_token, from_token=from_token, take=take) + access_token=access_token, from_param=from_param, take=take) diff --git a/src/auth0_server_python/tests/test_my_account_client.py b/src/auth0_server_python/tests/test_my_account_client.py index 3797115..5959adc 100644 --- a/src/auth0_server_python/tests/test_my_account_client.py +++ b/src/auth0_server_python/tests/test_my_account_client.py @@ -195,7 +195,7 @@ async def test_list_connected_accounts_success(mocker): result = await client.list_connected_accounts( access_token="", connection="", - from_token="", + from_param="", take=2 ) @@ -204,7 +204,7 @@ async def test_list_connected_accounts_success(mocker): url="https://auth0.local/me/v1/connected-accounts/accounts", params={ "connection": "", - "from": "", + "from": "", "take": 2 }, auth=ANY @@ -248,7 +248,7 @@ async def test_list_connected_accounts_api_response_failure(mocker): await client.list_connected_accounts( access_token="", connection="", - from_token="", + from_param="", take=2 ) @@ -333,7 +333,7 @@ async def test_list_connected_account_connections_success(mocker): # Act result = await client.list_connected_account_connections( access_token="", - from_token="", + from_param="", take=2 ) @@ -341,7 +341,7 @@ async def test_list_connected_account_connections_success(mocker): mock_get.assert_awaited_with( url="https://auth0.local/me/v1/connected-accounts/connections", params={ - "from": "", + "from": "", "take": 2 }, auth=ANY @@ -378,7 +378,7 @@ async def test_list_connected_account_connections_api_response_failure(mocker): with pytest.raises(MyAccountApiError) as exc: await client.list_connected_account_connections( access_token="", - from_token="", + from_param="", take=2 ) diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 87a7671..629e33c 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -1975,7 +1975,7 @@ async def test_list_connected_accounts_gets_access_token_and_calls_my_account(mo # Act response = await client.list_connected_accounts( connection="", - from_token="", + from_param="", take=2 ) @@ -1989,7 +1989,7 @@ async def test_list_connected_accounts_gets_access_token_and_calls_my_account(mo mock_my_account_client.list_connected_accounts.assert_awaited_with( access_token="", connection="", - from_token="", + from_param="", take=2 ) @@ -2053,7 +2053,7 @@ async def test_list_connected_account_connections_gets_access_token_and_calls_my # Act response = await client.list_connected_account_connections( - from_token="", + from_param="", take=2 ) @@ -2066,6 +2066,6 @@ async def test_list_connected_account_connections_gets_access_token_and_calls_my ) mock_my_account_client.list_connected_account_connections.assert_awaited_with( access_token="", - from_token="", + from_param="", take=2 ) From 6f468019477e28e9a0bd893ac8d90f180e4cabca Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 11 Dec 2025 12:41:46 +0000 Subject: [PATCH 10/16] Add argument validation to new server_client methods --- .../auth_server/server_client.py | 10 +++ src/auth0_server_python/error/__init__.py | 13 ++++ .../tests/test_server_client.py | 70 +++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index ce1091a..e35013a 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -34,6 +34,7 @@ AccessTokenForConnectionErrorCode, ApiError, BackchannelLogoutError, + InvalidArgumentError, MissingRequiredArgumentError, MissingTransactionError, PollingApiError, @@ -1497,6 +1498,9 @@ async def list_connected_accounts( Auth0Error: If there is an error retrieving the access token. MyAccountApiError: If the My Account API returns an error response. """ + if take is not None and (not isinstance(take, int) or take < 1): + raise InvalidArgumentError("take", "The 'take' parameter must be a positive integer.") + access_token = await self.get_access_token( audience=self._my_account_client.audience, scope="read:me:connected_accounts", @@ -1521,6 +1525,9 @@ async def delete_connected_account( Auth0Error: If there is an error retrieving the access token. MyAccountApiError: If the My Account API returns an error response. """ + if not connected_account_id: + raise MissingRequiredArgumentError("connected_account_id") + access_token = await self.get_access_token( audience=self._my_account_client.audience, scope="delete:me:connected_accounts", @@ -1550,6 +1557,9 @@ async def list_connected_account_connections( Auth0Error: If there is an error retrieving the access token. MyAccountApiError: If the My Account API returns an error response. """ + if take is not None and (not isinstance(take, int) or take < 1): + raise InvalidArgumentError("take", "The 'take' parameter must be a positive integer.") + access_token = await self.get_access_token( audience=self._my_account_client.audience, scope="read:me:connected_accounts", diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index ef181ce..144db32 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -101,6 +101,19 @@ def __init__(self, argument: str): self.argument = argument +class InvalidArgumentError(Auth0Error): + """ + Error raised when a given argument is an invalid value. + """ + code = "invalid_argument" + + def __init__(self, argument: str, message: str): + message = message + super().__init__(message) + self.name = "InvalidArgumentError" + self.argument = argument + + class BackchannelLogoutError(Auth0Error): """ Error raised during backchannel logout processing. diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 629e33c..acee128 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -23,6 +23,7 @@ AccessTokenForConnectionError, ApiError, BackchannelLogoutError, + InvalidArgumentError, MissingRequiredArgumentError, MissingTransactionError, PollingApiError, @@ -1937,6 +1938,31 @@ async def test_complete_connect_account_no_transactions(mocker): assert "transaction" in str(exc.value) mock_my_account_client.complete_connect_account.assert_not_awaited() +@pytest.mark.asyncio +@pytest.mark.parametrize("take", ["not_an_integer", 21.3, -5, 0]) +async def test_list_connected_accounts__with_invalid_take_param(mocker, take): + # Setup + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + secret="some-secret" + ) + mock_my_account_client = AsyncMock(MyAccountClient) + mocker.patch.object(client, "_my_account_client", mock_my_account_client) + + # Act + with pytest.raises(InvalidArgumentError) as exc: + await client.list_connected_accounts( + connection="", + from_param="", + take=take + ) + + # Assert + assert "The 'take' parameter must be a positive integer." in str(exc.value) + mock_my_account_client.list_connected_accounts.assert_not_awaited() + @pytest.mark.asyncio async def test_list_connected_accounts_gets_access_token_and_calls_my_account(mocker): # Setup @@ -2022,6 +2048,26 @@ async def test_delete_connected_account_gets_access_token_and_calls_my_account(m connected_account_id="" ) +@pytest.mark.asyncio +async def test_delete_connected_account_with_empty_connected_account_id(mocker): + # Setup + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + secret="some-secret" + ) + mock_my_account_client = AsyncMock(MyAccountClient) + mocker.patch.object(client, "_my_account_client", mock_my_account_client) + + # Act + with pytest.raises(MissingRequiredArgumentError) as exc: + await client.delete_connected_account(connected_account_id=None) + + # Assert + assert "connected_account_id" in str(exc.value) + mock_my_account_client.delete_connected_account.assert_not_awaited() + @pytest.mark.asyncio async def test_list_connected_account_connections_gets_access_token_and_calls_my_account(mocker): # Setup @@ -2069,3 +2115,27 @@ async def test_list_connected_account_connections_gets_access_token_and_calls_my from_param="", take=2 ) + +@pytest.mark.asyncio +@pytest.mark.parametrize("take", ["not_an_integer", 21.3, -5, 0]) +async def test_list_connected_account_connections_with_invalid_take_param(mocker, take): + # Setup + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + secret="some-secret" + ) + mock_my_account_client = AsyncMock(MyAccountClient) + mocker.patch.object(client, "_my_account_client", mock_my_account_client) + + # Act + with pytest.raises(InvalidArgumentError) as exc: + await client.list_connected_account_connections( + from_param="", + take=take + ) + + # Assert + assert "The 'take' parameter must be a positive integer." in str(exc.value) + mock_my_account_client.list_connected_account_connections.assert_not_awaited() From e672073c22208703fb5583a47f99cc0fe0d3b4fe Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 11 Dec 2025 13:27:07 +0000 Subject: [PATCH 11/16] Add parameter validation to new my_account_api functions --- .../auth_server/my_account_client.py | 21 ++++ .../auth_server/server_client.py | 6 +- src/auth0_server_python/error/__init__.py | 1 - .../tests/test_my_account_client.py | 117 +++++++++++++++++- 4 files changed, 140 insertions(+), 5 deletions(-) diff --git a/src/auth0_server_python/auth_server/my_account_client.py b/src/auth0_server_python/auth_server/my_account_client.py index 0e43759..535b59e 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -13,6 +13,8 @@ ) from auth0_server_python.error import ( ApiError, + InvalidArgumentError, + MissingRequiredArgumentError, MyAccountApiError, ) @@ -104,6 +106,12 @@ async def list_connected_accounts( from_param: Optional[str] = None, take: Optional[int] = None ) -> ListConnectedAccountResponse: + if access_token is None: + raise MissingRequiredArgumentError("access_token") + + if take is not None and (not isinstance(take, int) or take < 1): + raise InvalidArgumentError("take", "The 'take' parameter must be a positive integer.") + try: async with httpx.AsyncClient() as client: params = {} @@ -149,6 +157,13 @@ async def delete_connected_account( access_token: str, connected_account_id: str ) -> None: + + if access_token is None: + raise MissingRequiredArgumentError("access_token") + + if connected_account_id is None: + raise MissingRequiredArgumentError("connected_account_id") + try: async with httpx.AsyncClient() as client: response = await client.delete( @@ -181,6 +196,12 @@ async def list_connected_account_connections( from_param: Optional[str] = None, take: Optional[int] = None ) -> ListConnectedAccountConnectionsResponse: + if access_token is None: + raise MissingRequiredArgumentError("access_token") + + if take is not None and (not isinstance(take, int) or take < 1): + raise InvalidArgumentError("take", "The 'take' parameter must be a positive integer.") + try: async with httpx.AsyncClient() as client: params = {} diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index e35013a..ec0ecd9 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -1500,7 +1500,7 @@ async def list_connected_accounts( """ if take is not None and (not isinstance(take, int) or take < 1): raise InvalidArgumentError("take", "The 'take' parameter must be a positive integer.") - + access_token = await self.get_access_token( audience=self._my_account_client.audience, scope="read:me:connected_accounts", @@ -1527,7 +1527,7 @@ async def delete_connected_account( """ if not connected_account_id: raise MissingRequiredArgumentError("connected_account_id") - + access_token = await self.get_access_token( audience=self._my_account_client.audience, scope="delete:me:connected_accounts", @@ -1559,7 +1559,7 @@ async def list_connected_account_connections( """ if take is not None and (not isinstance(take, int) or take < 1): raise InvalidArgumentError("take", "The 'take' parameter must be a positive integer.") - + access_token = await self.get_access_token( audience=self._my_account_client.audience, scope="read:me:connected_accounts", diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 144db32..73fb41c 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -108,7 +108,6 @@ class InvalidArgumentError(Auth0Error): code = "invalid_argument" def __init__(self, argument: str, message: str): - message = message super().__init__(message) self.name = "InvalidArgumentError" self.argument = argument diff --git a/src/auth0_server_python/tests/test_my_account_client.py b/src/auth0_server_python/tests/test_my_account_client.py index 5959adc..d97be4b 100644 --- a/src/auth0_server_python/tests/test_my_account_client.py +++ b/src/auth0_server_python/tests/test_my_account_client.py @@ -13,7 +13,11 @@ ListConnectedAccountConnectionsResponse, ListConnectedAccountResponse, ) -from auth0_server_python.error import MyAccountApiError +from auth0_server_python.error import ( + InvalidArgumentError, + MissingRequiredArgumentError, + MyAccountApiError, +) @pytest.mark.asyncio @@ -228,6 +232,45 @@ async def test_list_connected_accounts_success(mocker): next="" ) +@pytest.mark.asyncio +async def test_list_connected_accounts_missing_access_token(mocker): + # Arrange + client = MyAccountClient(domain="auth0.local") + mock_get = mocker.patch("httpx.AsyncClient.get", new_callable=AsyncMock) + + # Act + with pytest.raises(MissingRequiredArgumentError) as exc: + await client.list_connected_accounts( + access_token=None, + connection="", + from_param="", + take=2 + ) + + # Assert + mock_get.assert_not_awaited() + assert "access_token" in str(exc.value) + +@pytest.mark.asyncio +@pytest.mark.parametrize("take", ["not_an_integer", 21.3, -5, 0]) +async def test_list_connected_accounts_invalid_take_param(mocker, take): + # Arrange + client = MyAccountClient(domain="auth0.local") + mock_get = mocker.patch("httpx.AsyncClient.get", new_callable=AsyncMock) + + # Act + with pytest.raises(InvalidArgumentError) as exc: + await client.list_connected_accounts( + access_token="", + connection="", + from_param="", + take=take + ) + + # Assert + mock_get.assert_not_awaited() + assert "The 'take' parameter must be a positive integer." in str(exc.value) + @pytest.mark.asyncio async def test_list_connected_accounts_api_response_failure(mocker): # Arrange @@ -277,6 +320,40 @@ async def test_delete_connected_account_success(mocker): auth=ANY ) +@pytest.mark.asyncio +async def test_delete_connected_account_missing_access_token(mocker): + # Arrange + client = MyAccountClient(domain="auth0.local") + mock_delete = mocker.patch("httpx.AsyncClient.delete", new_callable=AsyncMock) + + # Act + with pytest.raises(MissingRequiredArgumentError) as exc: + await client.delete_connected_account( + access_token=None, + connected_account_id="" + ) + + # Assert + mock_delete.assert_not_awaited() + assert "access_token" in str(exc.value) + +@pytest.mark.asyncio +async def test_delete_connected_account_missing_connected_account_id(mocker): + # Arrange + client = MyAccountClient(domain="auth0.local") + mock_delete = mocker.patch("httpx.AsyncClient.delete", new_callable=AsyncMock) + + # Act + with pytest.raises(MissingRequiredArgumentError) as exc: + await client.delete_connected_account( + access_token="", + connected_account_id=None + ) + + # Assert + mock_delete.assert_not_awaited() + assert "connected_account_id" in str(exc.value) + @pytest.mark.asyncio async def test_delete_connected_account_api_response_failure(mocker): # Arrange @@ -359,6 +436,44 @@ async def test_list_connected_account_connections_success(mocker): next="" ) +@pytest.mark.asyncio +async def test_list_connected_account_connections_missing_access_token(mocker): + # Arrange + client = MyAccountClient(domain="auth0.local") + mock_get = mocker.patch("httpx.AsyncClient.get", new_callable=AsyncMock) + + # Act + with pytest.raises(MissingRequiredArgumentError) as exc: + await client.list_connected_account_connections( + access_token=None, + from_param="", + take=2 + ) + + # Assert + mock_get.assert_not_awaited() + assert "access_token" in str(exc.value) + +@pytest.mark.asyncio +@pytest.mark.parametrize("take", ["not_an_integer", 21.3, -5, 0]) +async def test_list_connected_account_connections_invalid_take_param(mocker, take): + # Arrange + client = MyAccountClient(domain="auth0.local") + mock_get = mocker.patch("httpx.AsyncClient.get", new_callable=AsyncMock) + + # Act + with pytest.raises(InvalidArgumentError) as exc: + await client.list_connected_account_connections( + access_token="", + from_param="", + take=take + ) + + # Assert + mock_get.assert_not_awaited() + assert "The 'take' parameter must be a positive integer." in str(exc.value) + + @pytest.mark.asyncio async def test_list_connected_account_connections_api_response_failure(mocker): # Arrange From 4f778366b0529ea7b7641f3a2f640b5969ea89fc Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 11 Dec 2025 13:35:30 +0000 Subject: [PATCH 12/16] Rename ListConnectedAccountResponse -> ListConnectedAccountsResponse --- src/auth0_server_python/auth_server/my_account_client.py | 6 +++--- src/auth0_server_python/auth_server/server_client.py | 6 +++--- src/auth0_server_python/auth_types/__init__.py | 2 +- src/auth0_server_python/tests/test_my_account_client.py | 4 ++-- src/auth0_server_python/tests/test_server_client.py | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/auth0_server_python/auth_server/my_account_client.py b/src/auth0_server_python/auth_server/my_account_client.py index 535b59e..35d6c12 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -9,7 +9,7 @@ ConnectAccountRequest, ConnectAccountResponse, ListConnectedAccountConnectionsResponse, - ListConnectedAccountResponse, + ListConnectedAccountsResponse, ) from auth0_server_python.error import ( ApiError, @@ -105,7 +105,7 @@ async def list_connected_accounts( connection: Optional[str] = None, from_param: Optional[str] = None, take: Optional[int] = None - ) -> ListConnectedAccountResponse: + ) -> ListConnectedAccountsResponse: if access_token is None: raise MissingRequiredArgumentError("access_token") @@ -140,7 +140,7 @@ async def list_connected_accounts( data = response.json() - return ListConnectedAccountResponse.model_validate(data) + return ListConnectedAccountsResponse.model_validate(data) except Exception as e: if isinstance(e, MyAccountApiError): diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index ec0ecd9..a73622e 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -18,7 +18,7 @@ ConnectAccountOptions, ConnectAccountRequest, ListConnectedAccountConnectionsResponse, - ListConnectedAccountResponse, + ListConnectedAccountsResponse, LogoutOptions, LogoutTokenClaims, StartInteractiveLoginOptions, @@ -1481,7 +1481,7 @@ async def list_connected_accounts( from_param: Optional[str] = None, take: Optional[int] = None, store_options: dict = None - ) -> ListConnectedAccountResponse: + ) -> ListConnectedAccountsResponse: """ Retrieves a list of connected accounts for the authenticated user. @@ -1492,7 +1492,7 @@ async def list_connected_accounts( store_options: Optional options used to pass to the Transaction and State Store. Returns: - ListConnectedAccountResponse: The response object containing the list of connected accounts. + ListConnectedAccountsResponse: The response object containing the list of connected accounts. Raises: Auth0Error: If there is an error retrieving the access token. diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 45ba69d..6e5ee4f 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -261,7 +261,7 @@ class ConnectedAccount(BaseModel): created_at: str expires_at: Optional[str] = None -class ListConnectedAccountResponse(BaseModel): +class ListConnectedAccountsResponse(BaseModel): accounts: list[ConnectedAccount] next: Optional[str] = None diff --git a/src/auth0_server_python/tests/test_my_account_client.py b/src/auth0_server_python/tests/test_my_account_client.py index d97be4b..212bd9f 100644 --- a/src/auth0_server_python/tests/test_my_account_client.py +++ b/src/auth0_server_python/tests/test_my_account_client.py @@ -11,7 +11,7 @@ ConnectedAccountConnection, ConnectParams, ListConnectedAccountConnectionsResponse, - ListConnectedAccountResponse, + ListConnectedAccountsResponse, ) from auth0_server_python.error import ( InvalidArgumentError, @@ -213,7 +213,7 @@ async def test_list_connected_accounts_success(mocker): }, auth=ANY ) - assert result == ListConnectedAccountResponse( + assert result == ListConnectedAccountsResponse( accounts=[ ConnectedAccount( id="", connection="", diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index acee128..260d9ba 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -15,7 +15,7 @@ ConnectedAccountConnection, ConnectParams, ListConnectedAccountConnectionsResponse, - ListConnectedAccountResponse, + ListConnectedAccountsResponse, LogoutOptions, TransactionData, ) @@ -1977,7 +1977,7 @@ async def test_list_connected_accounts_gets_access_token_and_calls_my_account(mo mock_my_account_client = AsyncMock(MyAccountClient) mocker.patch.object(client, "_my_account_client", mock_my_account_client) mocker.patch.object(mock_my_account_client, "audience", "https://auth0.local/me/") - expected_response= ListConnectedAccountResponse( + expected_response= ListConnectedAccountsResponse( accounts=[ ConnectedAccount( id="", connection="", From 167475f1eea85a645713fd5419b1440526dfe382 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 22 Jan 2026 13:10:59 +0000 Subject: [PATCH 13/16] Restructure auth_types --- .../auth_types/__init__.py | 54 +++++++++++-------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 6e5ee4f..7f32e8d 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -213,8 +213,32 @@ class StartLinkUserOptions(BaseModel): authorization_params: Optional[dict[str, Any]] = None app_state: Optional[Any] = None -class ConnectParams(BaseModel): - ticket: str +# BASE & SHARED +class ConnectedAccountBase(BaseModel): + id: str + connection: str + access_type: str + scopes: list[str] + created_at: str + expires_at: Optional[str] = None + +# ENTITIES (What exists) +class ConnectedAccount(ConnectedAccountBase): + id: str + connection: str + access_type: str + scopes: list[str] + created_at: str + expires_at: Optional[str] = None + + +class ConnectedAccountConnection(BaseModel): + name: str + strategy: str + scopes: Optional[list[str]] = None + + +# Connect Operations (How to connect) class ConnectAccountOptions(BaseModel): connection: str @@ -232,6 +256,9 @@ class ConnectAccountRequest(BaseModel): code_challenge_method: Optional[str] = 'S256' authorization_params: Optional[dict[str, Any]] = None +class ConnectParams(BaseModel): + ticket: str + class ConnectAccountResponse(BaseModel): auth_session: str connect_uri: str @@ -244,32 +271,15 @@ class CompleteConnectAccountRequest(BaseModel): redirect_uri: str code_verifier: Optional[str] = None -class CompleteConnectAccountResponse(BaseModel): - id: str - connection: str - access_type: str - scopes: list[str] - created_at: str - expires_at: Optional[str] = None +class CompleteConnectAccountResponse(ConnectedAccountBase): app_state: Optional[Any] = None -class ConnectedAccount(BaseModel): - id: str - connection: str - access_type: str - scopes: list[str] - created_at: str - expires_at: Optional[str] = None - +# Manage operations class ListConnectedAccountsResponse(BaseModel): accounts: list[ConnectedAccount] next: Optional[str] = None -class ConnectedAccountConnection(BaseModel): - name: str - strategy: str - scopes: Optional[list[str]] = None - class ListConnectedAccountConnectionsResponse(BaseModel): connections: list[ConnectedAccountConnection] next: Optional[str] = None + From f5245dc371d96c2e338215f3c2d9f29829ac14d4 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 22 Jan 2026 13:21:19 +0000 Subject: [PATCH 14/16] Add doc comments to my account client --- .../auth_server/my_account_client.py | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src/auth0_server_python/auth_server/my_account_client.py b/src/auth0_server_python/auth_server/my_account_client.py index 35d6c12..d3aa946 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -20,11 +20,28 @@ class MyAccountClient: + """ + Client for interacting with the Auth0 MyAccount API. + Handles connected accounts operations including connecting, completing, listing, and deleting accounts. + """ + def __init__(self, domain: str): + """ + Initialize the MyAccount API client. + + Args: + domain: Auth0 domain (e.g., 'your-tenant.auth0.com') + """ self._domain = domain @property def audience(self): + """ + Get the MyAccount API audience URL. + + Returns: + The audience URL for the MyAccount API + """ return f"https://{self._domain}/me/" async def connect_account( @@ -32,6 +49,20 @@ async def connect_account( access_token: str, request: ConnectAccountRequest ) -> ConnectAccountResponse: + """ + Initiate the connected account flow. + + Args: + access_token: User's access token for authentication + request: Request containing connection details and configuration + + Returns: + Response containing the connect URI and authentication session details + + Raises: + MyAccountApiError: If the API returns an error response + ApiError: If the request fails due to network or other issues + """ try: async with httpx.AsyncClient() as client: response = await client.post( @@ -68,6 +99,20 @@ async def complete_connect_account( access_token: str, request: CompleteConnectAccountRequest ) -> CompleteConnectAccountResponse: + """ + Complete the connected account flow after user authorization. + + Args: + access_token: User's access token for authentication + request: Request containing the auth session, connect code, and redirect URI + + Returns: + Response containing the connected account details including ID, connection, and scopes + + Raises: + MyAccountApiError: If the API returns an error response + ApiError: If the request fails due to network or other issues + """ try: async with httpx.AsyncClient() as client: response = await client.post( @@ -106,6 +151,24 @@ async def list_connected_accounts( from_param: Optional[str] = None, take: Optional[int] = None ) -> ListConnectedAccountsResponse: + """ + List connected accounts for the authenticated user. + + Args: + access_token: User's access token for authentication + connection: Optional filter to list accounts for a specific connection + from_param: Optional pagination cursor for fetching next page of results + take: Optional number of results to return (must be a positive integer) + + Returns: + Response containing the list of connected accounts and pagination details + + Raises: + MissingRequiredArgumentError: If access_token is not provided + InvalidArgumentError: If take parameter is not a positive integer + MyAccountApiError: If the API returns an error response + ApiError: If the request fails due to network or other issues + """ if access_token is None: raise MissingRequiredArgumentError("access_token") @@ -157,6 +220,21 @@ async def delete_connected_account( access_token: str, connected_account_id: str ) -> None: + """ + Delete a connected account for the authenticated user. + + Args: + access_token: User's access token for authentication + connected_account_id: ID of the connected account to delete + + Returns: + None + + Raises: + MissingRequiredArgumentError: If access_token or connected_account_id is not provided + MyAccountApiError: If the API returns an error response + ApiError: If the request fails due to network or other issues + """ if access_token is None: raise MissingRequiredArgumentError("access_token") @@ -196,6 +274,23 @@ async def list_connected_account_connections( from_param: Optional[str] = None, take: Optional[int] = None ) -> ListConnectedAccountConnectionsResponse: + """ + List available connections that support connected accounts. + + Args: + access_token: User's access token for authentication + from_param: Optional pagination cursor for fetching next page of results + take: Optional number of results to return (must be a positive integer) + + Returns: + Response containing the list of available connections and pagination details + + Raises: + MissingRequiredArgumentError: If access_token is not provided + InvalidArgumentError: If take parameter is not a positive integer + MyAccountApiError: If the API returns an error response + ApiError: If the request fails due to network or other issues + """ if access_token is None: raise MissingRequiredArgumentError("access_token") From 5273f385af41ed092cb1033873684e0fda629d7d Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 22 Jan 2026 13:22:18 +0000 Subject: [PATCH 15/16] fix example domain in comment --- src/auth0_server_python/auth_server/my_account_client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/auth0_server_python/auth_server/my_account_client.py b/src/auth0_server_python/auth_server/my_account_client.py index d3aa946..1810fd0 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -22,7 +22,6 @@ class MyAccountClient: """ Client for interacting with the Auth0 MyAccount API. - Handles connected accounts operations including connecting, completing, listing, and deleting accounts. """ def __init__(self, domain: str): @@ -30,7 +29,7 @@ def __init__(self, domain: str): Initialize the MyAccount API client. Args: - domain: Auth0 domain (e.g., 'your-tenant.auth0.com') + domain: Auth0 domain (e.g., '..auth0.com') """ self._domain = domain From 55888392db3f7ca5ece46e5fe7d502e5cd1af1b2 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Wed, 4 Feb 2026 13:31:49 +0000 Subject: [PATCH 16/16] Add error handling section to the docs --- examples/ConnectedAccounts.md | 50 +++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/examples/ConnectedAccounts.md b/examples/ConnectedAccounts.md index 7722bd1..88f28f6 100644 --- a/examples/ConnectedAccounts.md +++ b/examples/ConnectedAccounts.md @@ -169,6 +169,56 @@ connected_accounts = await client.delete_connected_account( ) ``` +## Error Handling + +All SDK errors inherit from `Auth0Error`. For most cases, catch `Auth0Error` to handle all errors uniformly. Only catch specific error types when you need to take different actions based on the error. + +### Basic Error Handling (Recommended) + +```python +from auth0_server_python.error import Auth0Error + +try: + connect_url = await client.start_connect_account( + ConnectAccountOptions(connection="google-oauth2", redirect_uri="https://example.com/callback"), + store_options={"request": request, "response": response} + ) +except Auth0Error as e: + print(f"Error: {str(e)}") + return {"error": "Failed to connect account"} +``` + +### Advanced Error Handling (When Specific Actions Required) + +Catch specific types only when you need to handle different error conditions differently: + +```python +from auth0_server_python.error import Auth0Error, MyAccountApiError + +try: + connect_url = await client.start_connect_account( + ConnectAccountOptions(connection="google-oauth2", redirect_uri="https://example.com/callback"), + store_options={"request": request, "response": response} + ) +except MyAccountApiError as e: + if e.status == 401: + return redirect_to_login() # Token expired + elif e.status == 403: + return {"error": "Missing required permission"} # Missing scope + elif e.status == 400 and e.validation_errors: + return {"error": "Validation failed", "details": e.validation_errors} + raise # Re-raise other cases +except Auth0Error as e: + return {"error": str(e)} +``` + +### Common Error Types + +- **`Auth0Error`** (base): Catch this for general error handling +- **`MyAccountApiError`**: My Account API errors with `status`, `detail`, and optional `validation_errors` +- **`InvalidArgumentError`**: Invalid parameter value +- **`MissingRequiredArgumentError`**: Required parameter not provided + ## A note about scopes If multiple pieces of Connected Account functionality are intended to be used, it is recommended that you set the default `scope` for the My Account audience when creating you `ServerClient`. This will avoid multiple token requests as without it a new token will be requested for each scope used. This can be done by configuring the `scope` dictionary in the `authorization_params` when configuring the SDK. Each value in the dictionary corresponds to an `audience` and sets the `default` requested scopes for that audience.