diff --git a/examples/ConnectedAccounts.md b/examples/ConnectedAccounts.md index 638e27c..7722bd1 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_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_param= "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_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_param= "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 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..35d6c12 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,6 @@ +from typing import Optional + import httpx from auth0_server_python.auth_schemes.bearer_auth import BearerAuth from auth0_server_python.auth_types import ( @@ -6,9 +8,13 @@ CompleteConnectAccountResponse, ConnectAccountRequest, ConnectAccountResponse, + ListConnectedAccountConnectionsResponse, + ListConnectedAccountsResponse, ) from auth0_server_python.error import ( ApiError, + InvalidArgumentError, + MissingRequiredArgumentError, MyAccountApiError, ) @@ -92,3 +98,143 @@ 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_param: Optional[str] = None, + take: Optional[int] = None + ) -> ListConnectedAccountsResponse: + 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 = {} + if connection: + params["connection"] = connection + if from_param: + params["from"] = from_param + 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 ListConnectedAccountsResponse.model_validate(data) + + except Exception as e: + if isinstance(e, MyAccountApiError): + raise + raise ApiError( + "connect_account_error", + f"Connected Accounts list request failed: {str(e) or 'Unknown error'}", + e + ) + + + async def delete_connected_account( + self, + 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( + 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 delete request failed: {str(e) or 'Unknown error'}", + e + ) + + async def list_connected_account_connections( + self, + access_token: str, + 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 = {} + if from_param: + params["from"] = from_param + 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 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 c968120..a73622e 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, + ListConnectedAccountsResponse, LogoutOptions, LogoutTokenClaims, StartInteractiveLoginOptions, @@ -32,6 +34,7 @@ AccessTokenForConnectionErrorCode, ApiError, BackchannelLogoutError, + InvalidArgumentError, MissingRequiredArgumentError, MissingTransactionError, PollingApiError, @@ -1471,3 +1474,96 @@ 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_param: Optional[str] = None, + take: Optional[int] = None, + store_options: dict = None + ) -> ListConnectedAccountsResponse: + """ + 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_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. + + Returns: + ListConnectedAccountsResponse: 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. + """ + 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", + store_options=store_options + ) + return await self._my_account_client.list_connected_accounts( + access_token=access_token, connection=connection, from_param=from_param, take=take) + + async def delete_connected_account( + self, + 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. + """ + 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", + store_options=store_options + ) + 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_param: Optional[str] = None, + take: Optional[int] = None, + store_options: dict = None + ) -> ListConnectedAccountConnectionsResponse: + """ + Retrieves a list of available connections that can be used connected accounts for the authenticated user. + + Args: + 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. + + 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. + """ + 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", + store_options=store_options + ) + return await self._my_account_client.list_connected_account_connections( + access_token=access_token, from_param=from_param, take=take) diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 677a7da..6e5ee4f 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 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 diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index ef181ce..73fb41c 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -101,6 +101,18 @@ 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): + 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_my_account_client.py b/src/auth0_server_python/tests/test_my_account_client.py index f4f18fb..212bd9f 100644 --- a/src/auth0_server_python/tests/test_my_account_client.py +++ b/src/auth0_server_python/tests/test_my_account_client.py @@ -7,9 +7,17 @@ CompleteConnectAccountResponse, ConnectAccountRequest, ConnectAccountResponse, + ConnectedAccount, + ConnectedAccountConnection, ConnectParams, + ListConnectedAccountConnectionsResponse, + ListConnectedAccountsResponse, +) +from auth0_server_python.error import ( + InvalidArgumentError, + MissingRequiredArgumentError, + MyAccountApiError, ) -from auth0_server_python.error import MyAccountApiError @pytest.mark.asyncio @@ -158,3 +166,338 @@ 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_param="", + 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 == ListConnectedAccountsResponse( + 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_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 + 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_param="", + 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_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 + 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_param="", + 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_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 + 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_param="", + take=2 + ) + + # Assert + mock_get.assert_awaited_once() + assert "Invalid Token" in str(exc.value) + diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 9f4f2cd..260d9ba 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, + ListConnectedAccountsResponse, LogoutOptions, TransactionData, ) @@ -19,6 +23,7 @@ AccessTokenForConnectionError, ApiError, BackchannelLogoutError, + InvalidArgumentError, MissingRequiredArgumentError, MissingTransactionError, PollingApiError, @@ -1932,3 +1937,205 @@ 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 +@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 + 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= ListConnectedAccountsResponse( + 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_param="", + 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_param="", + 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_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 + 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_param="", + 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_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()