Skip to content
Open
75 changes: 74 additions & 1 deletion examples/ConnectedAccounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
)
```

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add an Error handling Example Section here as I can see the whole document only talks about the happy path.

## 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
}
}
)
```
146 changes: 146 additions & 0 deletions src/auth0_server_python/auth_server/my_account_client.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@

from typing import Optional

import httpx
from auth0_server_python.auth_schemes.bearer_auth import BearerAuth
from auth0_server_python.auth_types import (
CompleteConnectAccountRequest,
CompleteConnectAccountResponse,
ConnectAccountRequest,
ConnectAccountResponse,
ListConnectedAccountConnectionsResponse,
ListConnectedAccountsResponse,
)
from auth0_server_python.error import (
ApiError,
InvalidArgumentError,
MissingRequiredArgumentError,
MyAccountApiError,
)

Expand Down Expand Up @@ -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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't see any docstrings for any of the functions in these files. Is there a specific reason not adding them?


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
)
96 changes: 96 additions & 0 deletions src/auth0_server_python/auth_server/server_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
CompleteConnectAccountResponse,
ConnectAccountOptions,
ConnectAccountRequest,
ListConnectedAccountConnectionsResponse,
ListConnectedAccountsResponse,
LogoutOptions,
LogoutTokenClaims,
StartInteractiveLoginOptions,
Expand All @@ -32,6 +34,7 @@
AccessTokenForConnectionErrorCode,
ApiError,
BackchannelLogoutError,
InvalidArgumentError,
MissingRequiredArgumentError,
MissingTransactionError,
PollingApiError,
Expand Down Expand Up @@ -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)
21 changes: 21 additions & 0 deletions src/auth0_server_python/auth_types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,24 @@ class CompleteConnectAccountResponse(BaseModel):
created_at: str
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would request you to restructure the current class structure related to ConnectedAccounts in this file to follow a functional flow. Something like this :

# BASE & SHARED
ConnectedAccountBase(BaseModel)

# ENTITIES (What exists)

ConnectedAccount(ConnectedAccountBase)
ConnectedAccountConnection(BaseModel)

# Connect Operations (How to connect)

ConnectAccountOptions(BaseModel)
ConnectAccountRequest(BaseModel)
ConnectParams(BaseModel)
ConnectAccountResponse(BaseModel)
CompleteConnectAccountRequest(BaseModel)
CompleteConnectAccountResponse(ConnectedAccountBase)  #Extends base

# Manage operations

ListConnectedAccountsResponse(BaseModel)
ListConnectedAccountConnectionsResponse(BaseModel)

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
Loading
Loading