Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions examples/ConnectedAccounts.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
# Connect Accounts for using Token Vault

The Connect Accounts feature uses the Auth0 My Account API to allow users to link multiple third party accounts to a single Auth0 user profile.
The Connect Accounts feature uses the Auth0 My Account API to allow users to link multiple third party accounts to a single Auth0 user profile. In order to use this feature, [My Account API](https://auth0.com/docs/manage-users/my-account-api) must be activated on your Auth0 tenant.

>[!NOTE]
>DPoP sender token constraining is not yet supported in this SDK. My Account API can be configured to support it (default behaviour) but must not be configured to require it.


When using Connected Accounts, Auth0 acquires tokens from upstream Identity Providers (like Google) and stores them in a secure [Token Vault](https://auth0.com/docs/secure/tokens/token-vault). These tokens can then be used to access third-party APIs (like Google Calendar) on behalf of the user.

The tokens in the Token Vault are then accessible to [Resource Servers](https://auth0.com/docs/get-started/apis) (APIs) configured in Auth0. The application can then issue requests to the API, which can retrieve the tokens from the Token Vault and use them to access the third-party APIs.
The tokens in the Token Vault are then accessible to [Applications](https://auth0.com/docs/get-started/applications) configured in Auth0. The application can issue requests to Auth0 to retrieve the tokens from the Token Vault and use them to access the third-party APIs.

This is particularly useful for applications that require access to different resources on behalf of a user, like AI Agents.

## Configure the SDK

The SDK must be configured with an audience (an API Identifier) - this will be the resource server that uses the tokens from the Token Vault.

The Auth0 client Application must be configured to use refresh tokens and [MRRT (Multiple Resource Refresh Tokens)](https://auth0.com/docs/secure/tokens/refresh-tokens/multi-resource-refresh-token) since we will use the refresh token grant to get Access Tokens for the My Account API in addition to the API we are calling.

```python
Expand Down Expand Up @@ -66,13 +68,20 @@ connect_url = await self.client.start_connect_account(
ConnectAccountOptions(
connection="CONNECTION", # e.g. google-oauth2
redirect_uri="YOUR_CALLBACK_URL"
app_state = {
app_state= {
"returnUrl":"SOME_URL"
}
scopes= [
# scopes to passed to the third-party IdP
"openid",
"email",
"profile"
"offline_access"
]
authorization_params= {
# additional auth parameters to be sent to the third-party IdP e.g.
"prompt": "consent",
"access_type": "offline"
"login_hint": "user123",
"resource": "some_resource"
}
),
store_options={"request": request, "response": response}
Expand Down
38 changes: 10 additions & 28 deletions src/auth0_server_python/auth_server/server_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def __init__(
transaction_identifier: str = "_a0_tx",
state_identifier: str = "_a0_session",
authorization_params: Optional[dict[str, Any]] = None,
pushed_authorization_requests: bool = False,
pushed_authorization_requests: bool = False
):
"""
Initialize the Auth0 server client.
Expand All @@ -82,7 +82,6 @@ def __init__(
transaction_identifier: Identifier for transaction data
state_identifier: Identifier for state data
authorization_params: Default parameters for authorization requests
pushed_authorization_requests: Whether to use PAR for authorization requests
"""
if not secret:
raise MissingRequiredArgumentError("secret")
Expand Down Expand Up @@ -163,8 +162,7 @@ async def start_interactive_login(
# Build the transaction data to store
transaction_data = TransactionData(
code_verifier=code_verifier,
app_state=options.app_state,
audience=auth_params.get("audience", None),
app_state=options.app_state
)

# Store the transaction data
Expand Down Expand Up @@ -299,7 +297,7 @@ async def complete_interactive_login(

# Build a token set using the token response data
token_set = TokenSet(
audience=transaction_data.audience or "default",
audience=token_response.get("audience", "default"),
access_token=token_response.get("access_token", ""),
scope=token_response.get("scope", ""),
expires_at=int(time.time()) +
Expand Down Expand Up @@ -571,12 +569,7 @@ async def get_session(self, store_options: Optional[dict[str, Any]] = None) -> O
return session_data
return None

async def get_access_token(
self,
audience: Optional[str] = None,
scope: Optional[str] = None,
store_options: Optional[dict[str, Any]] = None
) -> str:
async def get_access_token(self, store_options: Optional[dict[str, Any]] = None) -> str:
"""
Retrieves the access token from the store, or calls Auth0 when the access token
is expired and a refresh token is available in the store.
Expand All @@ -595,10 +588,8 @@ async def get_access_token(

# Get audience and scope from options or use defaults
auth_params = self._default_authorization_params or {}
if not audience:
audience = auth_params.get("audience", "default")
if not scope:
scope = auth_params.get("scope")
audience = auth_params.get("audience", "default")
scope = auth_params.get("scope")

if state_data and hasattr(state_data, "dict") and callable(state_data.dict):
state_data_dict = state_data.dict()
Expand Down Expand Up @@ -627,9 +618,7 @@ async def get_access_token(
# Get new token with refresh token
try:
token_endpoint_response = await self.get_token_by_refresh_token({
"refresh_token": state_data_dict["refresh_token"],
"audience": audience,
"scope": scope
"refresh_token": state_data_dict["refresh_token"]
})

# Update state data with new token
Expand Down Expand Up @@ -1161,15 +1150,8 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str,
"client_id": self._client_id,
}

audience = options.get("audience")
if audience:
token_params["audience"] = audience

# Add scope if present in options or the original authorization params
scope = options.get("scope")
if scope:
token_params["scope"] = scope
elif "scope" in self._default_authorization_params:
# Add scope if present in the original authorization params
if "scope" in self._default_authorization_params:
token_params["scope"] = self._default_authorization_params["scope"]

# Exchange the refresh token for an access token
Expand Down Expand Up @@ -1318,7 +1300,7 @@ async def start_connect_account(

connect_request = ConnectAccountRequest(
connection=options.connection,
scope=" ".join(options.scope) if options.scope else None,
scopes=options.scopes,
redirect_uri = redirect_uri,
code_challenge=code_challenge,
code_challenge_method="S256",
Expand Down
4 changes: 2 additions & 2 deletions src/auth0_server_python/auth_types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,13 +219,13 @@ class ConnectParams(BaseModel):
class ConnectAccountOptions(BaseModel):
connection: str
redirect_uri: Optional[str] = None
scope: Optional[list[str]] = None
scopes: Optional[list[str]] = None
app_state: Optional[Any] = None
authorization_params: Optional[dict[str, Any]] = None

class ConnectAccountRequest(BaseModel):
connection: str
scope: Optional[str] = None
scopes: Optional[list[str]] = None
redirect_uri: Optional[str] = None
state: Optional[str] = None
code_challenge: Optional[str] = None
Expand Down
6 changes: 3 additions & 3 deletions src/auth0_server_python/tests/test_server_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1325,7 +1325,7 @@ async def test_start_connect_account_calls_connect_and_builds_url(mocker):
)

@pytest.mark.asyncio
async def test_start_connect_account_with_scope(mocker):
async def test_start_connect_account_with_scopes(mocker):
# Setup
mock_transaction_store = AsyncMock()
mock_state_store = AsyncMock()
Expand Down Expand Up @@ -1355,15 +1355,15 @@ async def test_start_connect_account_with_scope(mocker):
await client.start_connect_account(
options=ConnectAccountOptions(
connection="<connection>",
scope=["scope1", "scope2", "scope3"],
scopes=["scope1", "scope2", "scope3"],
redirect_uri="/test_redirect_uri"
)
)

# Assert
mock_my_account_client.connect_account.assert_awaited()
request = mock_my_account_client.connect_account.mock_calls[0].kwargs["request"]
assert request.scope == "scope1 scope2 scope3"
assert request.scopes == ["scope1", "scope2", "scope3"]

@pytest.mark.asyncio
async def test_start_connect_account_default_redirect_uri(mocker):
Expand Down
Loading