From e6dd7cbf481578155b142822525f0f69b9b3d219 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Wed, 5 Nov 2025 21:35:06 +0000 Subject: [PATCH 01/33] Basic connected account connect/complete flow --- .../auth_server/my_account_client.py | 108 ++++++++++++++ .../auth_server/server_client.py | 101 +++++++++++++ .../auth_types/__init__.py | 37 +++++ .../tests/test_server_client.py | 135 +++++++++++++++++- 4 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 src/auth0_server_python/auth_server/my_account_client.py diff --git a/src/auth0_server_python/auth_server/my_account_client.py b/src/auth0_server_python/auth_server/my_account_client.py new file mode 100644 index 0000000..52e624d --- /dev/null +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -0,0 +1,108 @@ + +import httpx + +from auth0_server_python.auth_types import ( + ConnectAccountRequest, + ConnectAccountResponse, + ConnectParams, + CompleteConnectAccountRequest, + CompleteConnectAccountResponse, +) + +from auth0_server_python.error import ( + ApiError, +) + +class MyAccountClient: + def __init__(self, domain: str): + self._domain = domain + self._base_url = f"https://{domain}/me/v1/" + + async def connect_account( + self, + access_token: str, + request: ConnectAccountRequest + ) -> ConnectAccountResponse: + try: + async with httpx.AsyncClient() as client: + response = await client.post( + url=f"{self._base_url}connected-accounts/connect", + data=request.model_dump_json(exclude_none=True), + auth=BearerAuth(access_token) + ) + + if response.status_code != 201: + error_data = response.json() + raise ApiError( + error_data.get("error", "connect_account_error"), + error_data.get( + "error_description", "Connected Accounts connect request failed") + ) + + data = response.json() + + return ConnectAccountResponse( + auth_session=data["auth_session"], + connect_uri=data["connect_uri"], + connect_params=ConnectParams( + ticket=data["connect_params"]["ticket"] + ), + expires_in=data["expires_in"] + ) + + except Exception as e: + if isinstance(e, ApiError): + raise + raise ApiError( + "connect_account_error", + f"Connected Accounts connect request failed: {str(e) or 'Unknown error'}", + e + ) + + async def complete_connect_account( + self, + access_token: str, + request: CompleteConnectAccountRequest + ) -> CompleteConnectAccountResponse: + try: + async with httpx.AsyncClient() as client: + response = await client.post( + url=f"{self._base_url}connected-accounts/complete", + data=request.model_dump_json(exclude_none=True), + auth=BearerAuth(access_token) + ) + + if response.status_code != 201: + error_data = response.json() + raise ApiError( + error_data.get("error", "connect_account_error"), + error_data.get( + "error_description", "Connected Accounts complete request failed") + ) + + data = response.json() + + return CompleteConnectAccountResponse( + id=data["id"], + connection=data["connection"], + access_type=data["access_type"], + scopes=data["scopes"], + created_at=data["created_at"] + ) + + except Exception as e: + if isinstance(e, ApiError): + raise + raise ApiError( + "connect_account_error", + f"Connected Accounts complete request failed: {str(e) or 'Unknown error'}", + e + ) + +class BearerAuth(httpx.Auth): + def __init__(self, token: str): + self.token = token + + def auth_flow(self, request): + request.headers['Authorization'] = f"Bearer {self.token}" + yield request \ No newline at end of file diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 204f749..11206b8 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -9,6 +9,7 @@ from typing import Any, Generic, Optional, TypeVar from urllib.parse import parse_qs, urlparse +from auth0_server_python.auth_server.my_account_client import MyAccountClient import httpx import jwt from auth0_server_python.auth_types import ( @@ -19,6 +20,10 @@ TokenSet, TransactionData, UserClaims, + ConnectAccountOptions, + ConnectAccountRequest, + CompleteConnectAccountRequest, + CompleteConnectAccountResponse ) from auth0_server_python.error import ( AccessTokenError, @@ -100,6 +105,8 @@ def __init__( client_id=client_id, client_secret=client_secret, ) + + self._my_account_client = MyAccountClient(domain=domain) async def _fetch_oidc_metadata(self, domain: str) -> dict: metadata_url = f"https://{domain}/.well-known/openid-configuration" @@ -1260,3 +1267,97 @@ async def get_token_for_connection(self, options: dict[str, Any]) -> dict[str, A "There was an error while trying to retrieve an access token for a connection.", e ) + + async def start_connect_account( + self, + options: ConnectAccountOptions, + store_options: dict = None + ) -> str: + + # Get effective authorization params (merge defaults with provided ones) + auth_params = dict(self._default_authorization_params) + if options.authorization_params: + auth_params.update( + {k: v for k, v in options.authorization_params.items( + ) if k not in INTERNAL_AUTHORIZE_PARAMS} + ) + + # Ensure we have a redirect_uri + if "redirect_uri" not in auth_params and not self._redirect_uri: + raise MissingRequiredArgumentError("redirect_uri") + + # Use the default redirect_uri if none is specified + if "redirect_uri" not in auth_params and self._redirect_uri: + auth_params["redirect_uri"] = self._redirect_uri + + # Generate PKCE code verifier and challenge + code_verifier = PKCE.generate_code_verifier() + code_challenge = PKCE.generate_code_challenge(code_verifier) + + # State parameter to prevent CSRF + state = PKCE.generate_random_string(32) + + connect_request = ConnectAccountRequest( + connection=options.connection, + redirect_uri = options.redirect_uri or auth_params["redirect_uri"], + code_challenge=code_challenge, + code_challenge_method="S256", + state=state, + authorization_params=options.authorization_params + ) + access_token = await self._get_token() + connect_response = await self._my_account_client.connect_account( + access_token=access_token, + request=connect_request + ) + + # Build the transaction data to store + transaction_data = TransactionData( + code_verifier=code_verifier, + app_state=state, + auth_session = connect_response.auth_session + ) + + # Store the transaction data + await self._transaction_store.set( + f"{self._transaction_identifier}:{state}", + transaction_data, + options=store_options + ) + + return f"{connect_response.connect_uri}?ticket={connect_response.connect_params.ticket}" + + + async def complete_connect_account( + self, + connect_code: str, + state: str, + store_options: dict = None + ) -> CompleteConnectAccountResponse: + # Retrieve the transaction data using the state + transaction_identifier = f"{self._transaction_identifier}:{state}" + transaction_data = await self._transaction_store.get(transaction_identifier, options=store_options) + + if not transaction_data: + raise MissingTransactionError() + + # TODO //do I need to check error in redirect?? + # TODO //handle no redirect uri?? + access_token = await self._get_token() + request = CompleteConnectAccountRequest( + auth_session=transaction_data.auth_session, + connect_code=connect_code, + redirect_uri=self._redirect_uri, + code_verifier=transaction_data.code_verifier + ) + + return await self._my_account_client.complete_connect_account( + access_token=access_token, + request=request + ) + + async def _get_token( + self, + ) -> str: + # TODO need to implement MRRT, for now can just copy a valid token with the correct aud/scopes + return "" \ 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 ce93101..2c5d6cf 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -87,6 +87,7 @@ class TransactionData(BaseModel): audience: Optional[str] = None code_verifier: str app_state: Optional[Any] = None + auth_session: Optional[str] = None class Config: extra = "allow" # Allow additional fields not defined in the model @@ -210,3 +211,39 @@ class StartLinkUserOptions(BaseModel): connection_scope: Optional[str] = None authorization_params: Optional[dict[str, Any]] = None app_state: Optional[Any] = None + +class ConnectParams(BaseModel): + ticket: str + +class ConnectAccountOptions(BaseModel): + connection: str + redirect_uri: Optional[str] = None + authorization_params: Optional[dict[str, Any]] = None + +class ConnectAccountRequest(BaseModel): + connection: str + redirect_uri: Optional[str] = None + state: Optional[str] = None + code_challenge: Optional[str] = None + code_challenge_method: Optional[str] = 'S256' + authorization_params: Optional[dict[str, Any]] = None + +class ConnectAccountResponse(BaseModel): + auth_session: str + connect_uri: str + connect_params: ConnectParams + expires_in: int + +class CompleteConnectAccountRequest(BaseModel): + auth_session: str + connect_code: str + 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 \ No newline at end of file diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 9328a34..137bdb3 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -4,8 +4,17 @@ from urllib.parse import parse_qs, urlparse import pytest +from auth0_server_python.auth_server.my_account_client import MyAccountClient from auth0_server_python.auth_server.server_client import ServerClient -from auth0_server_python.auth_types import LogoutOptions, TransactionData +from unittest.mock import ANY +from auth0_server_python.auth_types import ( + LogoutOptions, + TransactionData, + ConnectAccountOptions, + ConnectAccountResponse, + CompleteConnectAccountRequest, + ConnectParams, +) from auth0_server_python.error import ( AccessTokenForConnectionError, ApiError, @@ -15,6 +24,7 @@ PollingApiError, StartLinkUserError, ) +from auth0_server_python.utils import PKCE @pytest.mark.asyncio @@ -81,7 +91,6 @@ async def test_start_interactive_login_builds_auth_url(mocker): mock_transaction_store.set.assert_awaited() mock_oauth.assert_called_once() - @pytest.mark.asyncio async def test_complete_interactive_login_no_transaction(): mock_transaction_store = AsyncMock() @@ -1252,3 +1261,125 @@ async def test_get_token_by_refresh_token_exchange_failed(mocker): args, kwargs = mock_post.call_args assert kwargs["data"]["refresh_token"] == "" +@pytest.mark.asyncio +async def test_start_connect_account_calls_connect_and_builds_url(mocker): + # Setup + mock_transaction_store = AsyncMock() + mock_state_store = AsyncMock() + + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=mock_state_store, + transaction_store=mock_transaction_store, + secret="some-secret", + redirect_uri="/test_redirect_uri" + ) + mock_my_account_client = AsyncMock(MyAccountClient) + mocker.patch.object(client, "_my_account_client", mock_my_account_client) + mock_my_account_client.connect_account.return_value = ConnectAccountResponse( + auth_session="", + connect_uri="http://auth0.local/connected_accounts/connect", + connect_params=ConnectParams( + ticket="ticket123", + ), + expires_in=300 + ) + + mocker.patch.object(PKCE, "generate_random_string", return_value="") + mocker.patch.object(PKCE, "generate_code_verifier", return_value="") + + # Act + url = await client.start_connect_account( + options=ConnectAccountOptions( + connection="" + ), + ) + + # Assert + assert url == "http://auth0.local/connected_accounts/connect?ticket=ticket123" + mock_transaction_store.set.assert_awaited_with( + "_a0_tx:", + TransactionData( + code_verifier="", + app_state="", + auth_session="", + ), + options=ANY + ) + +@pytest.mark.asyncio +async def test_complete_connect_account_calls_complete(mocker): + # Setup + mock_transaction_store = AsyncMock() + mock_state_store = AsyncMock() + + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=mock_state_store, + transaction_store=mock_transaction_store, + secret="some-secret", + redirect_uri="/test_redirect_uri" + ) + + mock_my_account_client = AsyncMock(MyAccountClient) + mocker.patch.object(client, "_my_account_client", mock_my_account_client) + + mock_transaction_store.get.return_value = TransactionData( + code_verifier="", + app_state="", + auth_session="", + ) + + # Act + await client.complete_connect_account( + connect_code="", + state="" + ) + + # Assert + mock_my_account_client.complete_connect_account.assert_awaited_with( + access_token=ANY, + request=CompleteConnectAccountRequest( + auth_session="", + connect_code="", + redirect_uri="/test_redirect_uri", + code_verifier="" + ) + ) + + +@pytest.mark.asyncio +async def test_complete_connect_account_no_transactions(mocker): + # Setup + mock_transaction_store = AsyncMock() + mock_state_store = AsyncMock() + + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=mock_state_store, + transaction_store=mock_transaction_store, + secret="some-secret", + redirect_uri="/test_redirect_uri" + ) + + mock_my_account_client = AsyncMock(MyAccountClient) + mocker.patch.object(client, "_my_account_client", mock_my_account_client) + + mock_transaction_store.get.return_value = None # no transaction + + # Act + with pytest.raises(MissingTransactionError) as exc: + await client.complete_connect_account( + connect_code="", + state="" + ) + + # Assert + assert "transaction" in str(exc.value) + mock_my_account_client.complete_connect_account.assert_not_awaited() From a27ca1cfbecc71d02112e8b6256b9f91bc9d571d Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 6 Nov 2025 09:53:58 +0000 Subject: [PATCH 02/33] Add MRRT behaviour --- .../auth_server/my_account_client.py | 11 ++-- .../auth_server/server_client.py | 51 ++++++++++++------- .../tests/test_server_client.py | 17 ++++++- 3 files changed, 57 insertions(+), 22 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 52e624d..db2354c 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -16,8 +16,11 @@ class MyAccountClient: def __init__(self, domain: str): self._domain = domain - self._base_url = f"https://{domain}/me/v1/" - + + @property + def audienceIdentifier(self): + return f"https://{self._domain}/me/" + async def connect_account( self, access_token: str, @@ -26,7 +29,7 @@ async def connect_account( try: async with httpx.AsyncClient() as client: response = await client.post( - url=f"{self._base_url}connected-accounts/connect", + url=f"{self.audienceIdentifier}v1/connected-accounts/connect", data=request.model_dump_json(exclude_none=True), auth=BearerAuth(access_token) ) @@ -67,7 +70,7 @@ async def complete_connect_account( try: async with httpx.AsyncClient() as client: response = await client.post( - url=f"{self._base_url}connected-accounts/complete", + url=f"{self.audienceIdentifier}v1/connected-accounts/complete", data=request.model_dump_json(exclude_none=True), auth=BearerAuth(access_token) ) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 11206b8..74cb9fb 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -569,7 +569,12 @@ async def get_session(self, store_options: Optional[dict[str, Any]] = None) -> O return session_data return None - async def get_access_token(self, store_options: Optional[dict[str, Any]] = None) -> str: + async def get_access_token( + self, + audience = None, + scope = None, + 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. @@ -588,8 +593,10 @@ async def get_access_token(self, store_options: Optional[dict[str, Any]] = None) # Get audience and scope from options or use defaults auth_params = self._default_authorization_params or {} - audience = auth_params.get("audience", "default") - scope = auth_params.get("scope") + if not audience: + audience = auth_params.get("audience", "default") + if not scope: + scope = auth_params.get("scope") if state_data and hasattr(state_data, "dict") and callable(state_data.dict): state_data_dict = state_data.dict() @@ -618,7 +625,9 @@ async def get_access_token(self, store_options: Optional[dict[str, Any]] = None) # Get new token with refresh token try: token_endpoint_response = await self.get_token_by_refresh_token({ - "refresh_token": state_data_dict["refresh_token"] + "refresh_token": state_data_dict["refresh_token"], + "audience": audience, + "scope": scope }) # Update state data with new token @@ -1149,9 +1158,16 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str, "refresh_token": refresh_token, "client_id": self._client_id, } - - # Add scope if present in the original authorization params - if "scope" in self._default_authorization_params: + + 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: token_params["scope"] = self._default_authorization_params["scope"] # Exchange the refresh token for an access token @@ -1305,7 +1321,11 @@ async def start_connect_account( state=state, authorization_params=options.authorization_params ) - access_token = await self._get_token() + access_token = await self.get_access_token( + audience=self._my_account_client.audienceIdentifier, + scope="create:me:connected_accounts", + store_options=store_options + ) connect_response = await self._my_account_client.connect_account( access_token=access_token, request=connect_request @@ -1327,7 +1347,6 @@ async def start_connect_account( return f"{connect_response.connect_uri}?ticket={connect_response.connect_params.ticket}" - async def complete_connect_account( self, connect_code: str, @@ -1343,7 +1362,11 @@ async def complete_connect_account( # TODO //do I need to check error in redirect?? # TODO //handle no redirect uri?? - access_token = await self._get_token() + access_token = await self.get_access_token( + audience=self._my_account_client.audienceIdentifier, + scope="create:me:connected_accounts", + store_options=store_options + ) request = CompleteConnectAccountRequest( auth_session=transaction_data.auth_session, connect_code=connect_code, @@ -1354,10 +1377,4 @@ async def complete_connect_account( return await self._my_account_client.complete_connect_account( access_token=access_token, request=request - ) - - async def _get_token( - self, - ) -> str: - # TODO need to implement MRRT, for now can just copy a valid token with the correct aud/scopes - return "" \ No newline at end of file + ) \ No newline at end of file diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 137bdb3..e4f5c3d 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -11,6 +11,7 @@ LogoutOptions, TransactionData, ConnectAccountOptions, + ConnectAccountRequest, ConnectAccountResponse, CompleteConnectAccountRequest, ConnectParams, @@ -1276,6 +1277,8 @@ async def test_start_connect_account_calls_connect_and_builds_url(mocker): secret="some-secret", redirect_uri="/test_redirect_uri" ) + + mocker.patch.object(client, "get_access_token", AsyncMock(return_value="")) mock_my_account_client = AsyncMock(MyAccountClient) mocker.patch.object(client, "_my_account_client", mock_my_account_client) mock_my_account_client.connect_account.return_value = ConnectAccountResponse( @@ -1289,6 +1292,7 @@ async def test_start_connect_account_calls_connect_and_builds_url(mocker): mocker.patch.object(PKCE, "generate_random_string", return_value="") mocker.patch.object(PKCE, "generate_code_verifier", return_value="") + mocker.patch.object(PKCE, "generate_code_challenge", return_value="") # Act url = await client.start_connect_account( @@ -1299,6 +1303,16 @@ async def test_start_connect_account_calls_connect_and_builds_url(mocker): # Assert assert url == "http://auth0.local/connected_accounts/connect?ticket=ticket123" + mock_my_account_client.connect_account.assert_awaited_with( + access_token="", + request=ConnectAccountRequest( + connection="", + redirect_uri="/test_redirect_uri", + code_challenge_method="S256", + code_challenge="", + state= "" + ) + ) mock_transaction_store.set.assert_awaited_with( "_a0_tx:", TransactionData( @@ -1325,6 +1339,7 @@ async def test_complete_connect_account_calls_complete(mocker): redirect_uri="/test_redirect_uri" ) + mocker.patch.object(client, "get_access_token", AsyncMock(return_value="")) mock_my_account_client = AsyncMock(MyAccountClient) mocker.patch.object(client, "_my_account_client", mock_my_account_client) @@ -1342,7 +1357,7 @@ async def test_complete_connect_account_calls_complete(mocker): # Assert mock_my_account_client.complete_connect_account.assert_awaited_with( - access_token=ANY, + access_token="", request=CompleteConnectAccountRequest( auth_session="", connect_code="", From c1d01672187961b6942acda54880fe39fc31ac4b Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 6 Nov 2025 10:30:38 +0000 Subject: [PATCH 03/33] Move BearerAuth to own file --- src/auth0_server_python/auth_schemes/__init__.py | 3 +++ src/auth0_server_python/auth_schemes/bearer_auth.py | 9 +++++++++ src/auth0_server_python/auth_server/__init__.py | 3 ++- .../auth_server/my_account_client.py | 12 ++---------- 4 files changed, 16 insertions(+), 11 deletions(-) create mode 100644 src/auth0_server_python/auth_schemes/__init__.py create mode 100644 src/auth0_server_python/auth_schemes/bearer_auth.py diff --git a/src/auth0_server_python/auth_schemes/__init__.py b/src/auth0_server_python/auth_schemes/__init__.py new file mode 100644 index 0000000..1c2c869 --- /dev/null +++ b/src/auth0_server_python/auth_schemes/__init__.py @@ -0,0 +1,3 @@ +from .bearer_auth import BearerAuth + +__all__ = ["BearerAuth"] diff --git a/src/auth0_server_python/auth_schemes/bearer_auth.py b/src/auth0_server_python/auth_schemes/bearer_auth.py new file mode 100644 index 0000000..b8882b0 --- /dev/null +++ b/src/auth0_server_python/auth_schemes/bearer_auth.py @@ -0,0 +1,9 @@ +import httpx + +class BearerAuth(httpx.Auth): + def __init__(self, token: str): + self.token = token + + def auth_flow(self, request): + request.headers['Authorization'] = f"Bearer {self.token}" + yield request \ No newline at end of file diff --git a/src/auth0_server_python/auth_server/__init__.py b/src/auth0_server_python/auth_server/__init__.py index b95c7c0..fe0a446 100644 --- a/src/auth0_server_python/auth_server/__init__.py +++ b/src/auth0_server_python/auth_server/__init__.py @@ -1,3 +1,4 @@ from .server_client import ServerClient +from .my_account_client import MyAccountClient -__all__ = ["ServerClient"] +__all__ = ["ServerClient", "MyAccountClient"] 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 db2354c..636e8ad 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -1,6 +1,6 @@ import httpx - +from auth0_server_python.auth_schemes.bearer_auth import BearerAuth from auth0_server_python.auth_types import ( ConnectAccountRequest, ConnectAccountResponse, @@ -100,12 +100,4 @@ async def complete_connect_account( "connect_account_error", f"Connected Accounts complete request failed: {str(e) or 'Unknown error'}", e - ) - -class BearerAuth(httpx.Auth): - def __init__(self, token: str): - self.token = token - - def auth_flow(self, request): - request.headers['Authorization'] = f"Bearer {self.token}" - yield request \ No newline at end of file + ) \ No newline at end of file From c62cdc3c49bef4fe6d1473e8cb030ef2c17895c2 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 6 Nov 2025 11:06:23 +0000 Subject: [PATCH 04/33] Handle redirect_uri properly --- .../auth_server/server_client.py | 23 ++--- .../auth_types/__init__.py | 1 + .../tests/test_server_client.py | 97 ++++++++++++++++++- 3 files changed, 104 insertions(+), 17 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 74cb9fb..a7b2289 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -1289,7 +1289,6 @@ async def start_connect_account( options: ConnectAccountOptions, store_options: dict = None ) -> str: - # Get effective authorization params (merge defaults with provided ones) auth_params = dict(self._default_authorization_params) if options.authorization_params: @@ -1298,24 +1297,22 @@ async def start_connect_account( ) if k not in INTERNAL_AUTHORIZE_PARAMS} ) + # Use the default redirect_uri if none is specified + redirect_uri = options.redirect_uri or self._redirect_uri # Ensure we have a redirect_uri - if "redirect_uri" not in auth_params and not self._redirect_uri: + if not redirect_uri: raise MissingRequiredArgumentError("redirect_uri") - # Use the default redirect_uri if none is specified - if "redirect_uri" not in auth_params and self._redirect_uri: - auth_params["redirect_uri"] = self._redirect_uri - # Generate PKCE code verifier and challenge code_verifier = PKCE.generate_code_verifier() code_challenge = PKCE.generate_code_challenge(code_verifier) # State parameter to prevent CSRF state = PKCE.generate_random_string(32) - + connect_request = ConnectAccountRequest( connection=options.connection, - redirect_uri = options.redirect_uri or auth_params["redirect_uri"], + redirect_uri = redirect_uri, code_challenge=code_challenge, code_challenge_method="S256", state=state, @@ -1335,7 +1332,8 @@ async def start_connect_account( transaction_data = TransactionData( code_verifier=code_verifier, app_state=state, - auth_session = connect_response.auth_session + auth_session=connect_response.auth_session, + redirect_uri=redirect_uri ) # Store the transaction data @@ -1359,18 +1357,17 @@ async def complete_connect_account( if not transaction_data: raise MissingTransactionError() - - # TODO //do I need to check error in redirect?? - # TODO //handle no redirect uri?? + access_token = await self.get_access_token( audience=self._my_account_client.audienceIdentifier, scope="create:me:connected_accounts", store_options=store_options ) + request = CompleteConnectAccountRequest( auth_session=transaction_data.auth_session, connect_code=connect_code, - redirect_uri=self._redirect_uri, + redirect_uri=transaction_data.redirect_uri, code_verifier=transaction_data.code_verifier ) diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 2c5d6cf..4edd63d 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -88,6 +88,7 @@ class TransactionData(BaseModel): code_verifier: str app_state: Optional[Any] = None auth_session: Optional[str] = None + redirect_uri: Optional[str] = None class Config: extra = "allow" # Allow additional fields not defined in the model diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index e4f5c3d..2643f71 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -1268,6 +1268,68 @@ async def test_start_connect_account_calls_connect_and_builds_url(mocker): mock_transaction_store = AsyncMock() mock_state_store = AsyncMock() + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=mock_state_store, + transaction_store=mock_transaction_store, + secret="some-secret" + ) + + mocker.patch.object(client, "get_access_token", AsyncMock(return_value="")) + mock_my_account_client = AsyncMock(MyAccountClient) + mocker.patch.object(client, "_my_account_client", mock_my_account_client) + mock_my_account_client.connect_account.return_value = ConnectAccountResponse( + auth_session="", + connect_uri="http://auth0.local/connected_accounts/connect", + connect_params=ConnectParams( + ticket="ticket123", + ), + expires_in=300 + ) + + mocker.patch.object(PKCE, "generate_random_string", return_value="") + mocker.patch.object(PKCE, "generate_code_verifier", return_value="") + mocker.patch.object(PKCE, "generate_code_challenge", return_value="") + + # Act + url = await client.start_connect_account( + options=ConnectAccountOptions( + connection="", + redirect_uri="/test_redirect_uri" + ) + ) + + # Assert + assert url == "http://auth0.local/connected_accounts/connect?ticket=ticket123" + mock_my_account_client.connect_account.assert_awaited_with( + access_token="", + request=ConnectAccountRequest( + connection="", + redirect_uri="/test_redirect_uri", + code_challenge_method="S256", + code_challenge="", + state= "" + ) + ) + mock_transaction_store.set.assert_awaited_with( + "_a0_tx:", + TransactionData( + code_verifier="", + app_state="", + auth_session="", + redirect_uri="/test_redirect_uri" + ), + options=ANY + ) + +@pytest.mark.asyncio +async def test_start_connect_account_default_redirect_uri(mocker): + # Setup + mock_transaction_store = AsyncMock() + mock_state_store = AsyncMock() + client = ServerClient( domain="auth0.local", client_id="", @@ -1275,7 +1337,7 @@ async def test_start_connect_account_calls_connect_and_builds_url(mocker): state_store=mock_state_store, transaction_store=mock_transaction_store, secret="some-secret", - redirect_uri="/test_redirect_uri" + redirect_uri="/default_redirect_uri" ) mocker.patch.object(client, "get_access_token", AsyncMock(return_value="")) @@ -1298,7 +1360,7 @@ async def test_start_connect_account_calls_connect_and_builds_url(mocker): url = await client.start_connect_account( options=ConnectAccountOptions( connection="" - ), + ) ) # Assert @@ -1307,7 +1369,7 @@ async def test_start_connect_account_calls_connect_and_builds_url(mocker): access_token="", request=ConnectAccountRequest( connection="", - redirect_uri="/test_redirect_uri", + redirect_uri="/default_redirect_uri", code_challenge_method="S256", code_challenge="", state= "" @@ -1319,10 +1381,37 @@ async def test_start_connect_account_calls_connect_and_builds_url(mocker): code_verifier="", app_state="", auth_session="", + redirect_uri="/default_redirect_uri" ), options=ANY ) +@pytest.mark.asyncio +async def test_start_connect_account_no_redirect_uri(mocker): + # Setup + mock_transaction_store = AsyncMock() + mock_state_store = AsyncMock() + + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=mock_state_store, + transaction_store=mock_transaction_store, + secret="some-secret" + ) + + # Act + with pytest.raises(MissingRequiredArgumentError) as exc: + await client.start_connect_account( + options=ConnectAccountOptions( + connection="" + ) + ) + + # Assert + assert "redirect_uri" in str(exc.value) + @pytest.mark.asyncio async def test_complete_connect_account_calls_complete(mocker): # Setup @@ -1347,6 +1436,7 @@ async def test_complete_connect_account_calls_complete(mocker): code_verifier="", app_state="", auth_session="", + redirect_uri="/test_redirect_uri" ) # Act @@ -1366,7 +1456,6 @@ async def test_complete_connect_account_calls_complete(mocker): ) ) - @pytest.mark.asyncio async def test_complete_connect_account_no_transactions(mocker): # Setup From e7c65d1e16eb82d9d2c5d770438cf9ec80e3d815 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 6 Nov 2025 11:56:53 +0000 Subject: [PATCH 05/33] Add some doc comments --- .../auth_server/server_client.py | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index a7b2289..7c54244 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -1289,6 +1289,19 @@ async def start_connect_account( options: ConnectAccountOptions, store_options: dict = None ) -> str: + """ + Initiates the connect account flow for linking a third-party account to the user's profile. + + This method generates PKCE parameters, creates a transaction and calls the My Account API + to create a connect account request, returning /connect url containing a ticket. + + Args: + options: Options for retrieving an access token for a connection. + store_options: Optional options used to pass to the Transaction and State Store. + + Returns: + The a connect URL containing a ticket to redirect the user to. + """ # Get effective authorization params (merge defaults with provided ones) auth_params = dict(self._default_authorization_params) if options.authorization_params: @@ -1307,7 +1320,6 @@ async def start_connect_account( code_verifier = PKCE.generate_code_verifier() code_challenge = PKCE.generate_code_challenge(code_verifier) - # State parameter to prevent CSRF state = PKCE.generate_random_string(32) connect_request = ConnectAccountRequest( @@ -1351,6 +1363,21 @@ async def complete_connect_account( state: str, store_options: dict = None ) -> CompleteConnectAccountResponse: + """ + Handles the redirect callback to complete the connect account flow for linking a third-party + account to the user's profile. + + This works similiar to the redirect from the login flow except it verifies the `connect_code` + with the My Account API rather than the `code` with the Authorization Server. + + Args: + connect_code: The connect code returned from the redirect. + state: The state parameter persisted from the initial connect account request. + store_options: Optional options used to pass to the Transaction and State Store. + + Returns: + A response from the connect account flow. + """ # Retrieve the transaction data using the state transaction_identifier = f"{self._transaction_identifier}:{state}" transaction_data = await self._transaction_store.get(transaction_identifier, options=store_options) From f1a52b61011f5fe01990c395aa66e0b8eee53cb4 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 6 Nov 2025 14:29:15 +0000 Subject: [PATCH 06/33] Add tests around MyAccountClient --- .../auth_server/my_account_client.py | 29 ++-- src/auth0_server_python/error/__init__.py | 20 +++ .../tests/test_my_account_client.py | 163 ++++++++++++++++++ .../tests/test_server_client.py | 6 +- 4 files changed, 203 insertions(+), 15 deletions(-) create mode 100644 src/auth0_server_python/tests/test_my_account_client.py 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 636e8ad..1d35e5d 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -11,6 +11,7 @@ from auth0_server_python.error import ( ApiError, + MyAccountApiError, ) class MyAccountClient: @@ -30,16 +31,18 @@ async def connect_account( async with httpx.AsyncClient() as client: response = await client.post( url=f"{self.audienceIdentifier}v1/connected-accounts/connect", - data=request.model_dump_json(exclude_none=True), + json=request.model_dump(exclude_none=True), auth=BearerAuth(access_token) ) if response.status_code != 201: error_data = response.json() - raise ApiError( - error_data.get("error", "connect_account_error"), - error_data.get( - "error_description", "Connected Accounts connect request failed") + raise MyAccountApiError( + title=error_data.get("title"), + type=error_data.get("type"), + detail=error_data.get("detail"), + status=error_data.get("status"), + validation_errors=error_data.get("validation_errors", None) ) data = response.json() @@ -54,7 +57,7 @@ async def connect_account( ) except Exception as e: - if isinstance(e, ApiError): + if isinstance(e, MyAccountApiError): raise raise ApiError( "connect_account_error", @@ -71,16 +74,18 @@ async def complete_connect_account( async with httpx.AsyncClient() as client: response = await client.post( url=f"{self.audienceIdentifier}v1/connected-accounts/complete", - data=request.model_dump_json(exclude_none=True), + json=request.model_dump(exclude_none=True), auth=BearerAuth(access_token) ) if response.status_code != 201: error_data = response.json() - raise ApiError( - error_data.get("error", "connect_account_error"), - error_data.get( - "error_description", "Connected Accounts complete request failed") + raise MyAccountApiError( + title=error_data.get("title"), + type=error_data.get("type"), + detail=error_data.get("detail"), + status=error_data.get("status"), + validation_errors=error_data.get("validation_errors") ) data = response.json() @@ -94,7 +99,7 @@ async def complete_connect_account( ) except Exception as e: - if isinstance(e, ApiError): + if isinstance(e, MyAccountApiError): raise raise ApiError( "connect_account_error", diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 58ce85f..67ea165 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -56,6 +56,26 @@ def __init__(self, code: str, message: str, interval: Optional[int], cause=None) super().__init__(code, message, cause) self.interval = interval +class MyAccountApiError(Auth0Error): + """ + Error raised when an API request to My Account API fails. + Contains details about the original error from Auth0. + """ + + def __init__( + self, + title: str, + type: str, + detail: str, + status: int, + validation_errors: Optional[list[dict[str, str]]] = None + ): + super().__init__(detail) + self.title = title + self.type = type + self.detail = detail + self.status = status + self.validation_errors = validation_errors class AccessTokenError(Auth0Error): """Error raised when there's an issue with access tokens.""" diff --git a/src/auth0_server_python/tests/test_my_account_client.py b/src/auth0_server_python/tests/test_my_account_client.py new file mode 100644 index 0000000..030a7e3 --- /dev/null +++ b/src/auth0_server_python/tests/test_my_account_client.py @@ -0,0 +1,163 @@ +import pytest +import json +from unittest.mock import AsyncMock, MagicMock +from unittest.mock import ANY +from auth0_server_python.auth_server.my_account_client import MyAccountClient +from auth0_server_python.auth_types import ( + ConnectAccountRequest, + ConnectAccountResponse, + CompleteConnectAccountRequest, + CompleteConnectAccountResponse, + ConnectParams +) +from auth0_server_python.error import ( + MyAccountApiError, + ApiError +) + +@pytest.mark.asyncio +async def test_connect_account_success(mocker): + # Arrange + client = MyAccountClient(domain="auth0.local") + response = AsyncMock() + response.status_code = 201 + response.json = MagicMock(return_value={ + "connect_uri": "https://auth0.local/connect", + "auth_session": "", + "connect_params": {"ticket": ""}, + "expires_in": 3600 + }) + + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=response) + request = ConnectAccountRequest( + connection="", + redirect_uri="", + state="", + code_challenge="", + code_challenge_method="S256" + ) + + # Act + result = await client.connect_account(access_token="", request=request) + + # Assert + mock_post.assert_awaited_with( + url="https://auth0.local/me/v1/connected-accounts/connect", + json={ + "connection": "", + "redirect_uri": "", + "state": "", + "code_challenge": "", + "code_challenge_method": "S256", + }, + auth=ANY + ) + assert result == ConnectAccountResponse( + connect_uri="https://auth0.local/connect", + auth_session="", + connect_params=ConnectParams(ticket=""), + expires_in=3600 + ) + +@pytest.mark.asyncio +async def test_connect_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_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=response) + request = ConnectAccountRequest( + connection="", + redirect_uri="", + state="", + code_challenge="", + code_challenge_method="S256" + ) + + # Act + + with pytest.raises(MyAccountApiError) as exc: + await client.connect_account(access_token="", request=request) + + # Assert + mock_post.assert_awaited_once() + assert "Invalid Token" in str(exc.value) + + +@pytest.mark.asyncio +async def test_complete_connect_account_success(mocker): + # Arrange + client = MyAccountClient(domain="auth0.local") + response = AsyncMock() + response.status_code = 201 + response.json = MagicMock(return_value={ + "id": "", + "connection": "", + "access_type": "", + "scopes": [""], + "created_at": "", + }) + + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=response) + request = CompleteConnectAccountRequest( + auth_session="", + connect_code="", + redirect_uri="", + ) + + # Act + result = await client.complete_connect_account(access_token="", request=request) + + # Assert + mock_post.assert_awaited_with( + url="https://auth0.local/me/v1/connected-accounts/complete", + json={ + "auth_session": "", + "connect_code": "", + "redirect_uri": "" + }, + auth=ANY + ) + assert result == CompleteConnectAccountResponse( + id="", + connection="", + access_type="", + scopes=[""], + created_at="", + ) + +@pytest.mark.asyncio +async def test_complete_connect_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_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=response) + request = CompleteConnectAccountRequest( + auth_session="", + connect_code="", + redirect_uri="", + ) + + # Act + + with pytest.raises(MyAccountApiError) as exc: + await client.complete_connect_account(access_token="", request=request) + + # Assert + mock_post.assert_awaited_once() + assert "Invalid Token" in str(exc.value) \ No newline at end of file diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 2643f71..35074c5 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -1404,10 +1404,10 @@ async def test_start_connect_account_no_redirect_uri(mocker): # Act with pytest.raises(MissingRequiredArgumentError) as exc: await client.start_connect_account( - options=ConnectAccountOptions( - connection="" + options=ConnectAccountOptions( + connection="" + ) ) - ) # Assert assert "redirect_uri" in str(exc.value) From 8808ed9341bc7c8b94185ff7ef8ce0c4eff262ee Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 6 Nov 2025 15:29:28 +0000 Subject: [PATCH 07/33] Make use of mrrt configurable --- .../auth_server/server_client.py | 24 ++++++++++++++++--- src/auth0_server_python/error/__init__.py | 1 + 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 7c54244..3f16216 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -26,6 +26,7 @@ CompleteConnectAccountResponse ) from auth0_server_python.error import ( + Auth0Error, AccessTokenError, AccessTokenErrorCode, AccessTokenForConnectionError, @@ -66,7 +67,8 @@ 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, + use_mrrt: bool = False, ): """ Initialize the Auth0 server client. @@ -82,6 +84,8 @@ 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 + use_mrrt: Whether to allow use of Multi-Resource Refresh Tokens """ if not secret: raise MissingRequiredArgumentError("secret") @@ -93,6 +97,7 @@ def __init__( self._redirect_uri = redirect_uri self._default_authorization_params = authorization_params or {} self._pushed_authorization_requests = pushed_authorization_requests # store the flag + self._use_mrrt = use_mrrt # Initialize stores self._transaction_store = transaction_store @@ -294,10 +299,10 @@ async def complete_interactive_login( claims = jwt.decode(id_token, options={ "verify_signature": False}) user_claims = UserClaims.parse_obj(claims) - + # Build a token set using the token response data token_set = TokenSet( - audience=token_response.get("audience", "default"), + audience=self._default_authorization_params.get("audience", "default"), access_token=token_response.get("access_token", ""), scope=token_response.get("scope", ""), expires_at=int(time.time()) + @@ -610,6 +615,13 @@ async def get_access_token( if ts.get("audience") == audience and (not scope or ts.get("scope") == scope): token_set = ts break + elif ts.get("audience") != audience and not self._use_mrrt: + # We have a token but for a different audience but for a different audience + # since MRRT is disabled, we cannot use the RT to get a new AT for this audience + raise AccessTokenError( + AccessTokenErrorCode.INCORRECT_AUDIENCE, + "The access token for the requested audience is not available and Multi-Resource Refresh Tokens are disabled." + ) # If token is valid, return it if token_set and token_set.get("expires_at", 0) > time.time(): @@ -1302,6 +1314,9 @@ async def start_connect_account( Returns: The a connect URL containing a ticket to redirect the user to. """ + if not self._use_mrrt: + raise Auth0Error("Multi-Resource Refresh Tokens (MRRT) is required to use Connected Accounts functionality.") + # Get effective authorization params (merge defaults with provided ones) auth_params = dict(self._default_authorization_params) if options.authorization_params: @@ -1378,6 +1393,9 @@ async def complete_connect_account( Returns: A response from the connect account flow. """ + if not self._use_mrrt: + raise Auth0Error("Multi-Resource Refresh Tokens (MRRT) is required to use Connected Accounts functionality.") + # Retrieve the transaction data using the state transaction_identifier = f"{self._transaction_identifier}:{state}" transaction_data = await self._transaction_store.get(transaction_identifier, options=store_options) diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 67ea165..776a59f 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -144,6 +144,7 @@ class AccessTokenErrorCode: FAILED_TO_REQUEST_TOKEN = "failed_to_request_token" REFRESH_TOKEN_ERROR = "refresh_token_error" AUTH_REQ_ID_ERROR = "auth_req_id_error" + INCORRECT_AUDIENCE = "incorrect_audience" class AccessTokenForConnectionErrorCode: From 3e27dc5cc5e76d9e5d3ee85e020f8ad5b70234ed Mon Sep 17 00:00:00 2001 From: sam-muncke Date: Thu, 6 Nov 2025 16:02:20 +0000 Subject: [PATCH 08/33] Fix for code scanning alert no. 3: Unused import Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/auth0_server_python/tests/test_my_account_client.py | 1 - 1 file changed, 1 deletion(-) 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 030a7e3..679331a 100644 --- a/src/auth0_server_python/tests/test_my_account_client.py +++ b/src/auth0_server_python/tests/test_my_account_client.py @@ -1,5 +1,4 @@ import pytest -import json from unittest.mock import AsyncMock, MagicMock from unittest.mock import ANY from auth0_server_python.auth_server.my_account_client import MyAccountClient From c836d2eadd550a9e8aa27e1ff9305209f2617f53 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 6 Nov 2025 16:07:04 +0000 Subject: [PATCH 09/33] Fix linting issues --- .../auth_schemes/bearer_auth.py | 3 +- .../auth_server/__init__.py | 2 +- .../auth_server/my_account_client.py | 12 +++--- .../auth_server/server_client.py | 28 ++++++------- .../auth_types/__init__.py | 2 +- src/auth0_server_python/error/__init__.py | 4 +- .../tests/test_my_account_client.py | 42 +++++++++---------- .../tests/test_server_client.py | 17 ++++---- 8 files changed, 54 insertions(+), 56 deletions(-) diff --git a/src/auth0_server_python/auth_schemes/bearer_auth.py b/src/auth0_server_python/auth_schemes/bearer_auth.py index b8882b0..8fd629e 100644 --- a/src/auth0_server_python/auth_schemes/bearer_auth.py +++ b/src/auth0_server_python/auth_schemes/bearer_auth.py @@ -1,9 +1,10 @@ import httpx + class BearerAuth(httpx.Auth): def __init__(self, token: str): self.token = token def auth_flow(self, request): request.headers['Authorization'] = f"Bearer {self.token}" - yield request \ No newline at end of file + yield request diff --git a/src/auth0_server_python/auth_server/__init__.py b/src/auth0_server_python/auth_server/__init__.py index fe0a446..72818be 100644 --- a/src/auth0_server_python/auth_server/__init__.py +++ b/src/auth0_server_python/auth_server/__init__.py @@ -1,4 +1,4 @@ -from .server_client import ServerClient from .my_account_client import MyAccountClient +from .server_client import ServerClient __all__ = ["ServerClient", "MyAccountClient"] 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 1d35e5d..2eba3cb 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -2,26 +2,26 @@ import httpx from auth0_server_python.auth_schemes.bearer_auth import BearerAuth from auth0_server_python.auth_types import ( + CompleteConnectAccountRequest, + CompleteConnectAccountResponse, ConnectAccountRequest, ConnectAccountResponse, ConnectParams, - CompleteConnectAccountRequest, - CompleteConnectAccountResponse, ) - from auth0_server_python.error import ( ApiError, MyAccountApiError, ) + class MyAccountClient: def __init__(self, domain: str): self._domain = domain - + @property def audienceIdentifier(self): return f"https://{self._domain}/me/" - + async def connect_account( self, access_token: str, @@ -105,4 +105,4 @@ async def complete_connect_account( "connect_account_error", f"Connected Accounts complete request failed: {str(e) or 'Unknown error'}", e - ) \ No newline at end of file + ) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 3f16216..1061df4 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -9,10 +9,14 @@ from typing import Any, Generic, Optional, TypeVar from urllib.parse import parse_qs, urlparse -from auth0_server_python.auth_server.my_account_client import MyAccountClient import httpx import jwt +from auth0_server_python.auth_server.my_account_client import MyAccountClient from auth0_server_python.auth_types import ( + CompleteConnectAccountRequest, + CompleteConnectAccountResponse, + ConnectAccountOptions, + ConnectAccountRequest, LogoutOptions, LogoutTokenClaims, StartInteractiveLoginOptions, @@ -20,18 +24,14 @@ TokenSet, TransactionData, UserClaims, - ConnectAccountOptions, - ConnectAccountRequest, - CompleteConnectAccountRequest, - CompleteConnectAccountResponse ) from auth0_server_python.error import ( - Auth0Error, AccessTokenError, AccessTokenErrorCode, AccessTokenForConnectionError, AccessTokenForConnectionErrorCode, ApiError, + Auth0Error, BackchannelLogoutError, MissingRequiredArgumentError, MissingTransactionError, @@ -110,7 +110,7 @@ def __init__( client_id=client_id, client_secret=client_secret, ) - + self._my_account_client = MyAccountClient(domain=domain) async def _fetch_oidc_metadata(self, domain: str) -> dict: @@ -299,7 +299,7 @@ async def complete_interactive_login( claims = jwt.decode(id_token, options={ "verify_signature": False}) user_claims = UserClaims.parse_obj(claims) - + # Build a token set using the token response data token_set = TokenSet( audience=self._default_authorization_params.get("audience", "default"), @@ -1170,7 +1170,7 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str, "refresh_token": refresh_token, "client_id": self._client_id, } - + audience = options.get("audience") if audience: token_params["audience"] = audience @@ -1316,7 +1316,7 @@ async def start_connect_account( """ if not self._use_mrrt: raise Auth0Error("Multi-Resource Refresh Tokens (MRRT) is required to use Connected Accounts functionality.") - + # Get effective authorization params (merge defaults with provided ones) auth_params = dict(self._default_authorization_params) if options.authorization_params: @@ -1326,7 +1326,7 @@ async def start_connect_account( ) # Use the default redirect_uri if none is specified - redirect_uri = options.redirect_uri or self._redirect_uri + redirect_uri = options.redirect_uri or self._redirect_uri # Ensure we have a redirect_uri if not redirect_uri: raise MissingRequiredArgumentError("redirect_uri") @@ -1379,7 +1379,7 @@ async def complete_connect_account( store_options: dict = None ) -> CompleteConnectAccountResponse: """ - Handles the redirect callback to complete the connect account flow for linking a third-party + Handles the redirect callback to complete the connect account flow for linking a third-party account to the user's profile. This works similiar to the redirect from the login flow except it verifies the `connect_code` @@ -1395,7 +1395,7 @@ async def complete_connect_account( """ if not self._use_mrrt: raise Auth0Error("Multi-Resource Refresh Tokens (MRRT) is required to use Connected Accounts functionality.") - + # Retrieve the transaction data using the state transaction_identifier = f"{self._transaction_identifier}:{state}" transaction_data = await self._transaction_store.get(transaction_identifier, options=store_options) @@ -1419,4 +1419,4 @@ async def complete_connect_account( return await self._my_account_client.complete_connect_account( access_token=access_token, request=request - ) \ 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 4edd63d..94a7de3 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -247,4 +247,4 @@ class CompleteConnectAccountResponse(BaseModel): access_type: str scopes: list[str] created_at: str - expires_at: Optional[str] = None \ No newline at end of file + expires_at: Optional[str] = None diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 776a59f..f97cafb 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -63,8 +63,8 @@ class MyAccountApiError(Auth0Error): """ def __init__( - self, - title: str, + self, + title: str, type: str, detail: str, status: int, 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 679331a..f4f18fb 100644 --- a/src/auth0_server_python/tests/test_my_account_client.py +++ b/src/auth0_server_python/tests/test_my_account_client.py @@ -1,18 +1,16 @@ +from unittest.mock import ANY, AsyncMock, MagicMock + import pytest -from unittest.mock import AsyncMock, MagicMock -from unittest.mock import ANY from auth0_server_python.auth_server.my_account_client import MyAccountClient from auth0_server_python.auth_types import ( - ConnectAccountRequest, - ConnectAccountResponse, CompleteConnectAccountRequest, CompleteConnectAccountResponse, - ConnectParams -) -from auth0_server_python.error import ( - MyAccountApiError, - ApiError + ConnectAccountRequest, + ConnectAccountResponse, + ConnectParams, ) +from auth0_server_python.error import MyAccountApiError + @pytest.mark.asyncio async def test_connect_account_success(mocker): @@ -26,7 +24,7 @@ async def test_connect_account_success(mocker): "connect_params": {"ticket": ""}, "expires_in": 3600 }) - + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=response) request = ConnectAccountRequest( connection="", @@ -35,10 +33,10 @@ async def test_connect_account_success(mocker): code_challenge="", code_challenge_method="S256" ) - + # Act result = await client.connect_account(access_token="", request=request) - + # Assert mock_post.assert_awaited_with( url="https://auth0.local/me/v1/connected-accounts/connect", @@ -70,7 +68,7 @@ async def test_connect_account_api_response_failure(mocker): "detail": "Invalid Token", "status": 401 }) - + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=response) request = ConnectAccountRequest( connection="", @@ -79,12 +77,12 @@ async def test_connect_account_api_response_failure(mocker): code_challenge="", code_challenge_method="S256" ) - + # Act with pytest.raises(MyAccountApiError) as exc: await client.connect_account(access_token="", request=request) - + # Assert mock_post.assert_awaited_once() assert "Invalid Token" in str(exc.value) @@ -103,17 +101,17 @@ async def test_complete_connect_account_success(mocker): "scopes": [""], "created_at": "", }) - + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=response) request = CompleteConnectAccountRequest( auth_session="", connect_code="", redirect_uri="", ) - + # Act result = await client.complete_connect_account(access_token="", request=request) - + # Assert mock_post.assert_awaited_with( url="https://auth0.local/me/v1/connected-accounts/complete", @@ -144,19 +142,19 @@ async def test_complete_connect_account_api_response_failure(mocker): "detail": "Invalid Token", "status": 401 }) - + mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=response) request = CompleteConnectAccountRequest( auth_session="", connect_code="", redirect_uri="", ) - + # Act with pytest.raises(MyAccountApiError) as exc: await client.complete_connect_account(access_token="", request=request) - + # Assert mock_post.assert_awaited_once() - assert "Invalid Token" in str(exc.value) \ No newline at end of file + 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 35074c5..fe61ad7 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -1,20 +1,19 @@ import json import time -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import ANY, AsyncMock, MagicMock from urllib.parse import parse_qs, urlparse import pytest from auth0_server_python.auth_server.my_account_client import MyAccountClient from auth0_server_python.auth_server.server_client import ServerClient -from unittest.mock import ANY from auth0_server_python.auth_types import ( - LogoutOptions, - TransactionData, + CompleteConnectAccountRequest, ConnectAccountOptions, ConnectAccountRequest, ConnectAccountResponse, - CompleteConnectAccountRequest, ConnectParams, + LogoutOptions, + TransactionData, ) from auth0_server_python.error import ( AccessTokenForConnectionError, @@ -1276,7 +1275,7 @@ async def test_start_connect_account_calls_connect_and_builds_url(mocker): transaction_store=mock_transaction_store, secret="some-secret" ) - + mocker.patch.object(client, "get_access_token", AsyncMock(return_value="")) mock_my_account_client = AsyncMock(MyAccountClient) mocker.patch.object(client, "_my_account_client", mock_my_account_client) @@ -1314,7 +1313,7 @@ async def test_start_connect_account_calls_connect_and_builds_url(mocker): ) ) mock_transaction_store.set.assert_awaited_with( - "_a0_tx:", + "_a0_tx:", TransactionData( code_verifier="", app_state="", @@ -1339,7 +1338,7 @@ async def test_start_connect_account_default_redirect_uri(mocker): secret="some-secret", redirect_uri="/default_redirect_uri" ) - + mocker.patch.object(client, "get_access_token", AsyncMock(return_value="")) mock_my_account_client = AsyncMock(MyAccountClient) mocker.patch.object(client, "_my_account_client", mock_my_account_client) @@ -1376,7 +1375,7 @@ async def test_start_connect_account_default_redirect_uri(mocker): ) ) mock_transaction_store.set.assert_awaited_with( - "_a0_tx:", + "_a0_tx:", TransactionData( code_verifier="", app_state="", From b8fcc89199827c0d63e596c52e8b2e850b3124fd Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 6 Nov 2025 16:17:07 +0000 Subject: [PATCH 10/33] Test to ensure mrrt is used for connected accounts --- .../tests/test_server_client.py | 71 +++++++++++++++++-- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index fe61ad7..f152629 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -18,6 +18,7 @@ from auth0_server_python.error import ( AccessTokenForConnectionError, ApiError, + Auth0Error, BackchannelLogoutError, MissingRequiredArgumentError, MissingTransactionError, @@ -1273,7 +1274,8 @@ async def test_start_connect_account_calls_connect_and_builds_url(mocker): client_secret="", state_store=mock_state_store, transaction_store=mock_transaction_store, - secret="some-secret" + secret="some-secret", + use_mrrt=True ) mocker.patch.object(client, "get_access_token", AsyncMock(return_value="")) @@ -1336,7 +1338,8 @@ async def test_start_connect_account_default_redirect_uri(mocker): state_store=mock_state_store, transaction_store=mock_transaction_store, secret="some-secret", - redirect_uri="/default_redirect_uri" + redirect_uri="/default_redirect_uri", + use_mrrt=True ) mocker.patch.object(client, "get_access_token", AsyncMock(return_value="")) @@ -1397,7 +1400,8 @@ async def test_start_connect_account_no_redirect_uri(mocker): client_secret="", state_store=mock_state_store, transaction_store=mock_transaction_store, - secret="some-secret" + secret="some-secret", + use_mrrt=True ) # Act @@ -1411,6 +1415,33 @@ async def test_start_connect_account_no_redirect_uri(mocker): # Assert assert "redirect_uri" in str(exc.value) +@pytest.mark.asyncio +async def test_start_connect_account_mrrt_disabled(mocker): + # Setup + mock_transaction_store = AsyncMock() + mock_state_store = AsyncMock() + + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=mock_state_store, + transaction_store=mock_transaction_store, + secret="some-secret", + use_mrrt=False + ) + + # Act + with pytest.raises(Auth0Error) as exc: + await client.start_connect_account( + options=ConnectAccountOptions( + connection="" + ) + ) + + # Assert + assert "MRRT" in str(exc.value) + @pytest.mark.asyncio async def test_complete_connect_account_calls_complete(mocker): # Setup @@ -1424,7 +1455,8 @@ async def test_complete_connect_account_calls_complete(mocker): state_store=mock_state_store, transaction_store=mock_transaction_store, secret="some-secret", - redirect_uri="/test_redirect_uri" + redirect_uri="/test_redirect_uri", + use_mrrt=True ) mocker.patch.object(client, "get_access_token", AsyncMock(return_value="")) @@ -1468,7 +1500,8 @@ async def test_complete_connect_account_no_transactions(mocker): state_store=mock_state_store, transaction_store=mock_transaction_store, secret="some-secret", - redirect_uri="/test_redirect_uri" + redirect_uri="/test_redirect_uri", + use_mrrt=True ) mock_my_account_client = AsyncMock(MyAccountClient) @@ -1486,3 +1519,31 @@ 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_complete_connect_account_mrrt_disabled(mocker): + # Setup + mock_transaction_store = AsyncMock() + mock_state_store = AsyncMock() + + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=mock_state_store, + transaction_store=mock_transaction_store, + secret="some-secret", + redirect_uri="/test_redirect_uri", + use_mrrt=False + ) + + # Act + with pytest.raises(Auth0Error) as exc: + await client.complete_connect_account( + connect_code="", + state="" + ) + + # Assert + assert "MRRT" in str(exc.value) From c8cf1cf4f02e499b0f95364b2964038fe544c0f1 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 6 Nov 2025 17:19:00 +0000 Subject: [PATCH 11/33] add example docs --- examples/ConnectedAccounts.md | 91 +++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 examples/ConnectedAccounts.md diff --git a/examples/ConnectedAccounts.md b/examples/ConnectedAccounts.md new file mode 100644 index 0000000..49496e3 --- /dev/null +++ b/examples/ConnectedAccounts.md @@ -0,0 +1,91 @@ +# 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. + +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 SPA 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. + +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 SDK must also be configured to use refresh tokens and MRRT (Multiple Resource Refresh Tokens) 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 +server_client = ServerClient( + domain="YOUR_AUTH0_DOMAIN", + client_id="YOUR_CLIENT_ID", + client_secret="YOUR_CLIENT_SECRET", + secret="YOUR_SECRET", + use_mrrt=True, + authorization_params={ + "redirect_uri"="YOUR_CALLBACK_URL", + } +) +``` + +## Login to the application + +Use the login methods to authenticate to the application and get a refresh and access token for the API. + +```python +// Login specifying any scopes for the Auth0 API + +authorization_url = await server_client.start_interactive_login( + { + "authorization_params": { + # must include offline_access to obtain a refresh token + "scope": "openid profile email offline_access", + "audience": "YOUR_API_IDENTIFIER", + } + }, + store_options={"request": request, "response": response} +) + +// redirect user + +// handle redirect +result = await server_client.complete_interactive_login( + callback_url, + store_options={"request": request, "response": response} +) +``` + +## Connect to a third party account + +Start the flow using the `connectAccountWithRedirect` method to redirect the user to the third party Identity Provider to connect their account. + +```python + +connect_url = await self.client.start_connect_account( + ConnectAccountOptions( + connection="CONNETION", # e.g. google-oauth2 + redirect_uri="YOUR_CALLBACK_URL" + authorization_params= { + # additional auth parameters to be sent to the third-party IdP e.g. + "prompt": "consent", + "access_type": "offline" + } + ), + store_options={"request": request, "response": response} +) +``` + +Using the url returned, redirect the user to the third party Identity Provider to complete any required authorization. Once authorized, the user will be redirected back to the provided `redirect_uri` with a `connect_code` and `state` parameter generated by this SDK. + +## Complete the account connection + +Call the `complete_connect_account` method using the returned `connect_code` and `state` to complete the connected account flow. + +```python +await self.client.complete_connect_account( + connect_code= "CONNECT_CODE", + state= "STATE", + store_options=store_options +) +``` + +You can now [call the API](https://github.com/auth0/auth0-spa-js/blob/main/EXAMPLES.md#calling-an-api) with your access token and the API can use [Access Token Exchange with Token Vault](https://auth0.com/docs/secure/tokens/token-vault/access-token-exchange-with-token-vault) to get tokens from the Token Vault to access third party APIs on behalf of the user. \ No newline at end of file From d6e210dd77f852558c9a732bfcd906be799c9846 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 6 Nov 2025 21:13:01 +0000 Subject: [PATCH 12/33] Allow passing of app state --- examples/ConnectedAccounts.md | 18 ++-- .../auth_server/server_client.py | 29 +++++-- .../auth_types/__init__.py | 2 + .../tests/test_server_client.py | 84 ++++++++++++++++--- 4 files changed, 111 insertions(+), 22 deletions(-) diff --git a/examples/ConnectedAccounts.md b/examples/ConnectedAccounts.md index 49496e3..0dd498b 100644 --- a/examples/ConnectedAccounts.md +++ b/examples/ConnectedAccounts.md @@ -58,12 +58,18 @@ result = await server_client.complete_interactive_login( Start the flow using the `connectAccountWithRedirect` method to redirect the user to the third party Identity Provider to connect their account. +The `authorization_params` is used to pass additional parameters required by the third party IdP +The `app_state` parameter allows you to pass custom state (for example, a return URL) that is later available when the connect process completes. + ```python connect_url = await self.client.start_connect_account( ConnectAccountOptions( connection="CONNETION", # e.g. google-oauth2 redirect_uri="YOUR_CALLBACK_URL" + app_state = { + "returnUrl":"SOME_URL" + } authorization_params= { # additional auth parameters to be sent to the third-party IdP e.g. "prompt": "consent", @@ -74,18 +80,20 @@ connect_url = await self.client.start_connect_account( ) ``` -Using the url returned, redirect the user to the third party Identity Provider to complete any required authorization. Once authorized, the user will be redirected back to the provided `redirect_uri` with a `connect_code` and `state` parameter generated by this SDK. +Using the url returned, redirect the user to the third party Identity Provider to complete any required authorization. Once authorized, the user will be redirected back to the provided `redirect_uri` with a `connect_code` and `state` parameter. ## Complete the account connection -Call the `complete_connect_account` method using the returned `connect_code` and `state` to complete the connected account flow. +Call the `complete_connect_account` method using the full callback url returned from the third party IdP to complete the connected account flow. This method extracts the connect_code from the URL, completes the connection, and returns the response data (including any `app_state` you passed originally). ```python -await self.client.complete_connect_account( - connect_code= "CONNECT_CODE", - state= "STATE", +complete_response await self.client.complete_connect_account( + url= callback_url, store_options=store_options ) ``` +>[!NOTE] +>The `callback_url` must include the necessary parameters (`state` and `connect_code`) that Auth0 sends upon successful authentication. + You can now [call the API](https://github.com/auth0/auth0-spa-js/blob/main/EXAMPLES.md#calling-an-api) with your access token and the API can use [Access Token Exchange with Token Vault](https://auth0.com/docs/secure/tokens/token-vault/access-token-exchange-with-token-vault) to get tokens from the Token Vault to access third party APIs on behalf of the user. \ No newline at end of file diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 1061df4..bf65cb1 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -1358,7 +1358,7 @@ async def start_connect_account( # Build the transaction data to store transaction_data = TransactionData( code_verifier=code_verifier, - app_state=state, + app_state=options.app_state, auth_session=connect_response.auth_session, redirect_uri=redirect_uri ) @@ -1374,8 +1374,7 @@ async def start_connect_account( async def complete_connect_account( self, - connect_code: str, - state: str, + url: str, store_options: dict = None ) -> CompleteConnectAccountResponse: """ @@ -1386,8 +1385,7 @@ async def complete_connect_account( with the My Account API rather than the `code` with the Authorization Server. Args: - connect_code: The connect code returned from the redirect. - state: The state parameter persisted from the initial connect account request. + url: The full callback URL including query parameters store_options: Optional options used to pass to the Transaction and State Store. Returns: @@ -1396,6 +1394,20 @@ async def complete_connect_account( if not self._use_mrrt: raise Auth0Error("Multi-Resource Refresh Tokens (MRRT) is required to use Connected Accounts functionality.") + # Parse the URL to get query parameters + parsed_url = urlparse(url) + query_params = parse_qs(parsed_url.query) + + # Get state parameter from the URL + state = query_params.get("state", [""])[0] + if not state: + raise MissingRequiredArgumentError("state") + + # Get the authorization code from the URL + connect_code = query_params.get("connect_code", [""])[0] + if not connect_code: + raise MissingRequiredArgumentError("connect_code") + # Retrieve the transaction data using the state transaction_identifier = f"{self._transaction_identifier}:{state}" transaction_data = await self._transaction_store.get(transaction_identifier, options=store_options) @@ -1416,7 +1428,12 @@ async def complete_connect_account( code_verifier=transaction_data.code_verifier ) - return await self._my_account_client.complete_connect_account( + response = await self._my_account_client.complete_connect_account( access_token=access_token, request=request ) + + if transaction_data.app_state is not None: + response.app_state = transaction_data.app_state + + return response diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 94a7de3..0e5fd3c 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -219,6 +219,7 @@ class ConnectParams(BaseModel): class ConnectAccountOptions(BaseModel): connection: str redirect_uri: Optional[str] = None + app_state: Optional[Any] = None authorization_params: Optional[dict[str, Any]] = None class ConnectAccountRequest(BaseModel): @@ -248,3 +249,4 @@ class CompleteConnectAccountResponse(BaseModel): scopes: list[str] created_at: str expires_at: Optional[str] = None + app_state: Optional[Any] = None diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index f152629..682a53b 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -1285,7 +1285,7 @@ async def test_start_connect_account_calls_connect_and_builds_url(mocker): auth_session="", connect_uri="http://auth0.local/connected_accounts/connect", connect_params=ConnectParams( - ticket="ticket123", + ticket="ticket123" ), expires_in=300 ) @@ -1298,6 +1298,7 @@ async def test_start_connect_account_calls_connect_and_builds_url(mocker): url = await client.start_connect_account( options=ConnectAccountOptions( connection="", + app_state="", redirect_uri="/test_redirect_uri" ) ) @@ -1318,7 +1319,7 @@ async def test_start_connect_account_calls_connect_and_builds_url(mocker): "_a0_tx:", TransactionData( code_verifier="", - app_state="", + app_state="", auth_session="", redirect_uri="/test_redirect_uri" ), @@ -1361,7 +1362,8 @@ async def test_start_connect_account_default_redirect_uri(mocker): # Act url = await client.start_connect_account( options=ConnectAccountOptions( - connection="" + connection="", + app_state="" ) ) @@ -1381,7 +1383,7 @@ async def test_start_connect_account_default_redirect_uri(mocker): "_a0_tx:", TransactionData( code_verifier="", - app_state="", + app_state="", auth_session="", redirect_uri="/default_redirect_uri" ), @@ -1472,8 +1474,7 @@ async def test_complete_connect_account_calls_complete(mocker): # Act await client.complete_connect_account( - connect_code="", - state="" + url="/test_redirect_uri?connect_code=&state=" ) # Assert @@ -1487,6 +1488,70 @@ async def test_complete_connect_account_calls_complete(mocker): ) ) +@pytest.mark.asyncio +async def test_complete_connect_account_no_connect_code(mocker): + # Setup + mock_transaction_store = AsyncMock() + mock_state_store = AsyncMock() + + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=mock_state_store, + transaction_store=mock_transaction_store, + secret="some-secret", + redirect_uri="/test_redirect_uri", + use_mrrt=True + ) + + mock_my_account_client = AsyncMock(MyAccountClient) + mocker.patch.object(client, "_my_account_client", mock_my_account_client) + + mock_transaction_store.get.return_value = None # no transaction + + # Act + with pytest.raises(MissingRequiredArgumentError) as exc: + await client.complete_connect_account( + url="/test_redirect_uri?state=" + ) + + # Assert + assert "connect_code" in str(exc.value) + mock_my_account_client.complete_connect_account.assert_not_awaited() + +@pytest.mark.asyncio +async def test_complete_connect_account_no_state(mocker): + # Setup + mock_transaction_store = AsyncMock() + mock_state_store = AsyncMock() + + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=mock_state_store, + transaction_store=mock_transaction_store, + secret="some-secret", + redirect_uri="/test_redirect_uri", + use_mrrt=True + ) + + mock_my_account_client = AsyncMock(MyAccountClient) + mocker.patch.object(client, "_my_account_client", mock_my_account_client) + + mock_transaction_store.get.return_value = None # no transaction + + # Act + with pytest.raises(MissingRequiredArgumentError) as exc: + await client.complete_connect_account( + url="/test_redirect_uri?connect_code=" + ) + + # Assert + assert "state" in str(exc.value) + mock_my_account_client.complete_connect_account.assert_not_awaited() + @pytest.mark.asyncio async def test_complete_connect_account_no_transactions(mocker): # Setup @@ -1512,15 +1577,13 @@ async def test_complete_connect_account_no_transactions(mocker): # Act with pytest.raises(MissingTransactionError) as exc: await client.complete_connect_account( - connect_code="", - state="" + url="/test_redirect_uri?connect_code=&state=" ) # Assert assert "transaction" in str(exc.value) mock_my_account_client.complete_connect_account.assert_not_awaited() - @pytest.mark.asyncio async def test_complete_connect_account_mrrt_disabled(mocker): # Setup @@ -1541,8 +1604,7 @@ async def test_complete_connect_account_mrrt_disabled(mocker): # Act with pytest.raises(Auth0Error) as exc: await client.complete_connect_account( - connect_code="", - state="" + url="/test_redirect_uri?connect_code=&state=" ) # Assert From 08fbc31f431918b53780d7c9f2a42b15e9d6d8f9 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 6 Nov 2025 21:17:18 +0000 Subject: [PATCH 13/33] Fix comment --- src/auth0_server_python/auth_server/server_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index bf65cb1..1082263 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -616,8 +616,8 @@ async def get_access_token( token_set = ts break elif ts.get("audience") != audience and not self._use_mrrt: - # We have a token but for a different audience but for a different audience - # since MRRT is disabled, we cannot use the RT to get a new AT for this audience + # We have a token but for a different audience but since MRRT is disabled, + # we cannot use the RT to get a new AT for this audience raise AccessTokenError( AccessTokenErrorCode.INCORRECT_AUDIENCE, "The access token for the requested audience is not available and Multi-Resource Refresh Tokens are disabled." From 2b0439d9b4ece3c25a3db175aab769a668bdba4d Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 6 Nov 2025 21:25:31 +0000 Subject: [PATCH 14/33] Cleanup transaction data at end of the flow --- src/auth0_server_python/auth_server/server_client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 1082263..fac2296 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -1436,4 +1436,7 @@ async def complete_connect_account( if transaction_data.app_state is not None: response.app_state = transaction_data.app_state + # Clean up transaction data after successful login + await self._transaction_store.delete(transaction_identifier, options=store_options) + return response From c09d803063fb11bc05732172844fe92dea821f66 Mon Sep 17 00:00:00 2001 From: sam-muncke Date: Fri, 7 Nov 2025 10:56:24 +0000 Subject: [PATCH 15/33] Fix case where MRRT is disabled but we may have multiple token sets with one matching the audience Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../auth_server/server_client.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index fac2296..70d9539 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -615,13 +615,17 @@ async def get_access_token( if ts.get("audience") == audience and (not scope or ts.get("scope") == scope): token_set = ts break - elif ts.get("audience") != audience and not self._use_mrrt: - # We have a token but for a different audience but since MRRT is disabled, - # we cannot use the RT to get a new AT for this audience - raise AccessTokenError( - AccessTokenErrorCode.INCORRECT_AUDIENCE, - "The access token for the requested audience is not available and Multi-Resource Refresh Tokens are disabled." - ) + if ts.get("audience") == audience and (not scope or ts.get("scope") == scope): + token_set = ts + break + + # After loop: if no matching token found and MRRT disabled, check if we need to error + if not token_set and not self._use_mrrt and state_data_dict.get("token_sets"): + # We have tokens but none match, and we can't use RT to get a new one + raise AccessTokenError( + AccessTokenErrorCode.INCORRECT_AUDIENCE, + "The access token for the requested audience is not available and Multi-Resource Refresh Tokens are disabled." + ) # If token is valid, return it if token_set and token_set.get("expires_at", 0) > time.time(): From 3fecdbb0b0f866c339f20b93201811f54c7b5b52 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Fri, 7 Nov 2025 11:02:25 +0000 Subject: [PATCH 16/33] Fix docs issues from code review --- examples/ConnectedAccounts.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/ConnectedAccounts.md b/examples/ConnectedAccounts.md index 0dd498b..a70f82b 100644 --- a/examples/ConnectedAccounts.md +++ b/examples/ConnectedAccounts.md @@ -22,7 +22,7 @@ server_client = ServerClient( secret="YOUR_SECRET", use_mrrt=True, authorization_params={ - "redirect_uri"="YOUR_CALLBACK_URL", + "redirect_uri"=:"YOUR_CALLBACK_URL", } ) ``` @@ -32,7 +32,7 @@ server_client = ServerClient( Use the login methods to authenticate to the application and get a refresh and access token for the API. ```python -// Login specifying any scopes for the Auth0 API +# Login specifying any scopes for the Auth0 API authorization_url = await server_client.start_interactive_login( { @@ -65,7 +65,7 @@ The `app_state` parameter allows you to pass custom state (for example, a return connect_url = await self.client.start_connect_account( ConnectAccountOptions( - connection="CONNETION", # e.g. google-oauth2 + connection="CONNECTION", # e.g. google-oauth2 redirect_uri="YOUR_CALLBACK_URL" app_state = { "returnUrl":"SOME_URL" @@ -87,7 +87,7 @@ Using the url returned, redirect the user to the third party Identity Provider t Call the `complete_connect_account` method using the full callback url returned from the third party IdP to complete the connected account flow. This method extracts the connect_code from the URL, completes the connection, and returns the response data (including any `app_state` you passed originally). ```python -complete_response await self.client.complete_connect_account( +complete_response = await self.client.complete_connect_account( url= callback_url, store_options=store_options ) From 4ea8e5ff2eaf06f83a43e2c8b0dd5ae7d1b9573c Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Fri, 7 Nov 2025 13:14:49 +0000 Subject: [PATCH 17/33] Code review fixes --- examples/ConnectedAccounts.md | 2 +- .../auth_server/my_account_client.py | 6 +++--- .../auth_server/server_client.py | 16 ++++++++-------- src/auth0_server_python/auth_types/__init__.py | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/examples/ConnectedAccounts.md b/examples/ConnectedAccounts.md index a70f82b..93dca13 100644 --- a/examples/ConnectedAccounts.md +++ b/examples/ConnectedAccounts.md @@ -22,7 +22,7 @@ server_client = ServerClient( secret="YOUR_SECRET", use_mrrt=True, authorization_params={ - "redirect_uri"=:"YOUR_CALLBACK_URL", + "redirect_uri":"YOUR_CALLBACK_URL", } ) ``` 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 2eba3cb..243326e 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -19,7 +19,7 @@ def __init__(self, domain: str): self._domain = domain @property - def audienceIdentifier(self): + def audience_identifier(self): return f"https://{self._domain}/me/" async def connect_account( @@ -30,7 +30,7 @@ async def connect_account( try: async with httpx.AsyncClient() as client: response = await client.post( - url=f"{self.audienceIdentifier}v1/connected-accounts/connect", + url=f"{self.audience_identifier}v1/connected-accounts/connect", json=request.model_dump(exclude_none=True), auth=BearerAuth(access_token) ) @@ -73,7 +73,7 @@ async def complete_connect_account( try: async with httpx.AsyncClient() as client: response = await client.post( - url=f"{self.audienceIdentifier}v1/connected-accounts/complete", + url=f"{self.audience_identifier}v1/connected-accounts/complete", json=request.model_dump(exclude_none=True), auth=BearerAuth(access_token) ) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 70d9539..530cd9a 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -576,8 +576,8 @@ async def get_session(self, store_options: Optional[dict[str, Any]] = None) -> O async def get_access_token( self, - audience = None, - scope = None, + audience: Optional[str] = None, + scope: Optional[str] = None, store_options: Optional[dict[str, Any]] = None ) -> str: """ @@ -618,7 +618,7 @@ async def get_access_token( if ts.get("audience") == audience and (not scope or ts.get("scope") == scope): token_set = ts break - + # After loop: if no matching token found and MRRT disabled, check if we need to error if not token_set and not self._use_mrrt and state_data_dict.get("token_sets"): # We have tokens but none match, and we can't use RT to get a new one @@ -1347,10 +1347,10 @@ async def start_connect_account( code_challenge=code_challenge, code_challenge_method="S256", state=state, - authorization_params=options.authorization_params + authorization_params=auth_params or None ) access_token = await self.get_access_token( - audience=self._my_account_client.audienceIdentifier, + audience=self._my_account_client.audience_identifier, scope="create:me:connected_accounts", store_options=store_options ) @@ -1385,7 +1385,7 @@ async def complete_connect_account( Handles the redirect callback to complete the connect account flow for linking a third-party account to the user's profile. - This works similiar to the redirect from the login flow except it verifies the `connect_code` + This works similar to the redirect from the login flow except it verifies the `connect_code` with the My Account API rather than the `code` with the Authorization Server. Args: @@ -1420,7 +1420,7 @@ async def complete_connect_account( raise MissingTransactionError() access_token = await self.get_access_token( - audience=self._my_account_client.audienceIdentifier, + audience=self._my_account_client.audience_identifier, scope="create:me:connected_accounts", store_options=store_options ) @@ -1440,7 +1440,7 @@ async def complete_connect_account( if transaction_data.app_state is not None: response.app_state = transaction_data.app_state - # Clean up transaction data after successful login + # Clean up transaction data after successful account connection await self._transaction_store.delete(transaction_identifier, options=store_options) return response diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 0e5fd3c..72577cc 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -248,5 +248,5 @@ class CompleteConnectAccountResponse(BaseModel): access_type: str scopes: list[str] created_at: str - expires_at: Optional[str] = None + expires_at: Optional[str] = None app_state: Optional[Any] = None From 2d070d9368608ef1a25017ccb731799812237636 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Fri, 7 Nov 2025 14:26:00 +0000 Subject: [PATCH 18/33] Code review fixes --- .../auth_server/my_account_client.py | 36 ++++++------------- .../auth_server/server_client.py | 2 +- src/auth0_server_python/error/__init__.py | 8 ++--- 3 files changed, 16 insertions(+), 30 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 243326e..2b2e6cc 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -6,7 +6,6 @@ CompleteConnectAccountResponse, ConnectAccountRequest, ConnectAccountResponse, - ConnectParams, ) from auth0_server_python.error import ( ApiError, @@ -38,23 +37,16 @@ async def connect_account( if response.status_code != 201: error_data = response.json() raise MyAccountApiError( - title=error_data.get("title"), - type=error_data.get("type"), - detail=error_data.get("detail"), - status=error_data.get("status"), + 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 ConnectAccountResponse( - auth_session=data["auth_session"], - connect_uri=data["connect_uri"], - connect_params=ConnectParams( - ticket=data["connect_params"]["ticket"] - ), - expires_in=data["expires_in"] - ) + return ConnectAccountResponse.model_validate(data) except Exception as e: if isinstance(e, MyAccountApiError): @@ -81,22 +73,16 @@ async def complete_connect_account( if response.status_code != 201: error_data = response.json() raise MyAccountApiError( - title=error_data.get("title"), - type=error_data.get("type"), - detail=error_data.get("detail"), - status=error_data.get("status"), - validation_errors=error_data.get("validation_errors") + 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 CompleteConnectAccountResponse( - id=data["id"], - connection=data["connection"], - access_type=data["access_type"], - scopes=data["scopes"], - created_at=data["created_at"] - ) + return CompleteConnectAccountResponse.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 530cd9a..c22234a 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -1339,7 +1339,7 @@ async def start_connect_account( code_verifier = PKCE.generate_code_verifier() code_challenge = PKCE.generate_code_challenge(code_verifier) - state = PKCE.generate_random_string(32) + state= PKCE.generate_random_string(32) connect_request = ConnectAccountRequest( connection=options.connection, diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index f97cafb..ef181ce 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -64,10 +64,10 @@ class MyAccountApiError(Auth0Error): def __init__( self, - title: str, - type: str, - detail: str, - status: int, + title: Optional[str], + type: Optional[str], + detail: Optional[str], + status: Optional[int], validation_errors: Optional[list[dict[str, str]]] = None ): super().__init__(detail) From e91eab15720cdedb4bc7bad9a758e0726339194f Mon Sep 17 00:00:00 2001 From: sam-muncke Date: Fri, 7 Nov 2025 14:30:25 +0000 Subject: [PATCH 19/33] Update examples/ConnectedAccounts.md Co-authored-by: Adam Mcgrath --- examples/ConnectedAccounts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/ConnectedAccounts.md b/examples/ConnectedAccounts.md index 93dca13..168d0ff 100644 --- a/examples/ConnectedAccounts.md +++ b/examples/ConnectedAccounts.md @@ -45,7 +45,7 @@ authorization_url = await server_client.start_interactive_login( store_options={"request": request, "response": response} ) -// redirect user +# redirect user // handle redirect result = await server_client.complete_interactive_login( From f658b8b22647460b61543510f6fdef087a2e97a8 Mon Sep 17 00:00:00 2001 From: sam-muncke Date: Fri, 7 Nov 2025 14:30:57 +0000 Subject: [PATCH 20/33] Update examples/ConnectedAccounts.md Co-authored-by: Adam Mcgrath --- examples/ConnectedAccounts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/ConnectedAccounts.md b/examples/ConnectedAccounts.md index 168d0ff..8841bd1 100644 --- a/examples/ConnectedAccounts.md +++ b/examples/ConnectedAccounts.md @@ -47,7 +47,7 @@ authorization_url = await server_client.start_interactive_login( # redirect user -// handle redirect +# handle redirect result = await server_client.complete_interactive_login( callback_url, store_options={"request": request, "response": response} From 2e275495f72cf9bff41af0d349599b00c78d154c Mon Sep 17 00:00:00 2001 From: sam-muncke Date: Fri, 7 Nov 2025 14:31:44 +0000 Subject: [PATCH 21/33] Update examples/ConnectedAccounts.md Co-authored-by: Adam Mcgrath --- examples/ConnectedAccounts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/ConnectedAccounts.md b/examples/ConnectedAccounts.md index 8841bd1..9e9f699 100644 --- a/examples/ConnectedAccounts.md +++ b/examples/ConnectedAccounts.md @@ -56,7 +56,7 @@ result = await server_client.complete_interactive_login( ## Connect to a third party account -Start the flow using the `connectAccountWithRedirect` method to redirect the user to the third party Identity Provider to connect their account. +Start the flow using the `start_connect_account` method to redirect the user to the third party Identity Provider to connect their account. The `authorization_params` is used to pass additional parameters required by the third party IdP The `app_state` parameter allows you to pass custom state (for example, a return URL) that is later available when the connect process completes. From 70529b9775148e760dc5bcca3cdf202576754c7b Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Fri, 7 Nov 2025 14:37:00 +0000 Subject: [PATCH 22/33] Code review fixes --- examples/ConnectedAccounts.md | 2 +- src/auth0_server_python/auth_server/server_client.py | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/examples/ConnectedAccounts.md b/examples/ConnectedAccounts.md index 9e9f699..514b7f3 100644 --- a/examples/ConnectedAccounts.md +++ b/examples/ConnectedAccounts.md @@ -96,4 +96,4 @@ complete_response = await self.client.complete_connect_account( >[!NOTE] >The `callback_url` must include the necessary parameters (`state` and `connect_code`) that Auth0 sends upon successful authentication. -You can now [call the API](https://github.com/auth0/auth0-spa-js/blob/main/EXAMPLES.md#calling-an-api) with your access token and the API can use [Access Token Exchange with Token Vault](https://auth0.com/docs/secure/tokens/token-vault/access-token-exchange-with-token-vault) to get tokens from the Token Vault to access third party APIs on behalf of the user. \ No newline at end of file +You can now call the API with your access token and the API can use [Access Token Exchange with Token Vault](https://auth0.com/docs/secure/tokens/token-vault/access-token-exchange-with-token-vault) to get tokens from the Token Vault to access third party APIs on behalf of the user. \ No newline at end of file diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index c22234a..69ef1cc 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -615,9 +615,6 @@ async def get_access_token( if ts.get("audience") == audience and (not scope or ts.get("scope") == scope): token_set = ts break - if ts.get("audience") == audience and (not scope or ts.get("scope") == scope): - token_set = ts - break # After loop: if no matching token found and MRRT disabled, check if we need to error if not token_set and not self._use_mrrt and state_data_dict.get("token_sets"): From 7111e65bd58aed82db52dc2639b68cad77829204 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Fri, 7 Nov 2025 14:45:43 +0000 Subject: [PATCH 23/33] Clean up transaction data regardless of success/failure --- .../auth_server/server_client.py | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 69ef1cc..e92f71c 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -1428,16 +1428,13 @@ async def complete_connect_account( redirect_uri=transaction_data.redirect_uri, code_verifier=transaction_data.code_verifier ) - - response = await self._my_account_client.complete_connect_account( - access_token=access_token, - request=request - ) - - if transaction_data.app_state is not None: - response.app_state = transaction_data.app_state - - # Clean up transaction data after successful account connection - await self._transaction_store.delete(transaction_identifier, options=store_options) - - return response + try: + response = await self._my_account_client.complete_connect_account( + access_token=access_token, request=request) + if transaction_data.app_state is not None: + response.app_state = transaction_data.app_state + finally: + # Clean up transaction data + await self._transaction_store.delete(transaction_identifier, options=store_options) + + return response \ No newline at end of file From 61e5d420e94c98b99ba872afe2fc224bc0548199 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Fri, 7 Nov 2025 15:02:07 +0000 Subject: [PATCH 24/33] Populate/pull audience to store in token set from transaction state on login --- src/auth0_server_python/auth_server/server_client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index e92f71c..3e12992 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -167,7 +167,8 @@ async def start_interactive_login( # Build the transaction data to store transaction_data = TransactionData( code_verifier=code_verifier, - app_state=options.app_state + app_state=options.app_state, + audience=auth_params.get("audience", None), ) # Store the transaction data @@ -302,7 +303,7 @@ async def complete_interactive_login( # Build a token set using the token response data token_set = TokenSet( - audience=self._default_authorization_params.get("audience", "default"), + audience=transaction_data.audience or "default", access_token=token_response.get("access_token", ""), scope=token_response.get("scope", ""), expires_at=int(time.time()) + From 5a6e387aaa420d24ee16e43d5c346bed350698c1 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Fri, 7 Nov 2025 15:06:13 +0000 Subject: [PATCH 25/33] Dont merge the default auth params with the ones provided for connected accounts --- src/auth0_server_python/auth_server/server_client.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 3e12992..035ffd4 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -1319,14 +1319,6 @@ async def start_connect_account( if not self._use_mrrt: raise Auth0Error("Multi-Resource Refresh Tokens (MRRT) is required to use Connected Accounts functionality.") - # Get effective authorization params (merge defaults with provided ones) - auth_params = dict(self._default_authorization_params) - if options.authorization_params: - auth_params.update( - {k: v for k, v in options.authorization_params.items( - ) if k not in INTERNAL_AUTHORIZE_PARAMS} - ) - # Use the default redirect_uri if none is specified redirect_uri = options.redirect_uri or self._redirect_uri # Ensure we have a redirect_uri @@ -1345,7 +1337,7 @@ async def start_connect_account( code_challenge=code_challenge, code_challenge_method="S256", state=state, - authorization_params=auth_params or None + authorization_params=options.authorization_params ) access_token = await self.get_access_token( audience=self._my_account_client.audience_identifier, @@ -1438,4 +1430,4 @@ async def complete_connect_account( # Clean up transaction data await self._transaction_store.delete(transaction_identifier, options=store_options) - return response \ No newline at end of file + return response From 19977f8c2f70c5e1fdf96cf0933e040b4eae0f33 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Fri, 7 Nov 2025 15:15:49 +0000 Subject: [PATCH 26/33] Parsed returned url safely --- src/auth0_server_python/auth_server/server_client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 035ffd4..3e83bbb 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -7,7 +7,7 @@ import json import time from typing import Any, Generic, Optional, TypeVar -from urllib.parse import parse_qs, urlparse +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse import httpx import jwt @@ -1364,7 +1364,9 @@ async def start_connect_account( options=store_options ) - return f"{connect_response.connect_uri}?ticket={connect_response.connect_params.ticket}" + parsedUrl = urlparse(connect_response.connect_uri) + query = urlencode({"ticket": connect_response.connect_params.ticket}) + return urlunparse((parsedUrl.scheme, parsedUrl.netloc, parsedUrl.path, parsedUrl.params, query, parsedUrl.fragment)) async def complete_connect_account( self, From b5154aa3568c16dc31e55a17bf4b90121a0380ee Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Mon, 10 Nov 2025 18:31:54 +0000 Subject: [PATCH 27/33] Remove use_mrrt flag and have mrrt used by default --- examples/ConnectedAccounts.md | 3 +- .../auth_server/server_client.py | 18 ----- .../tests/test_server_client.py | 75 ++----------------- 3 files changed, 8 insertions(+), 88 deletions(-) diff --git a/examples/ConnectedAccounts.md b/examples/ConnectedAccounts.md index 514b7f3..2d990df 100644 --- a/examples/ConnectedAccounts.md +++ b/examples/ConnectedAccounts.md @@ -12,7 +12,7 @@ This is particularly useful for applications that require access to different re 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 SDK must also be configured to use refresh tokens and MRRT (Multiple Resource Refresh Tokens) 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. +The Auth0 client Application must be configured to use refresh tokens and MRRT (Multiple Resource Refresh Tokens) 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 server_client = ServerClient( @@ -20,7 +20,6 @@ server_client = ServerClient( client_id="YOUR_CLIENT_ID", client_secret="YOUR_CLIENT_SECRET", secret="YOUR_SECRET", - use_mrrt=True, authorization_params={ "redirect_uri":"YOUR_CALLBACK_URL", } diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 3e83bbb..b4a6626 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -31,7 +31,6 @@ AccessTokenForConnectionError, AccessTokenForConnectionErrorCode, ApiError, - Auth0Error, BackchannelLogoutError, MissingRequiredArgumentError, MissingTransactionError, @@ -68,7 +67,6 @@ def __init__( state_identifier: str = "_a0_session", authorization_params: Optional[dict[str, Any]] = None, pushed_authorization_requests: bool = False, - use_mrrt: bool = False, ): """ Initialize the Auth0 server client. @@ -85,7 +83,6 @@ def __init__( state_identifier: Identifier for state data authorization_params: Default parameters for authorization requests pushed_authorization_requests: Whether to use PAR for authorization requests - use_mrrt: Whether to allow use of Multi-Resource Refresh Tokens """ if not secret: raise MissingRequiredArgumentError("secret") @@ -97,7 +94,6 @@ def __init__( self._redirect_uri = redirect_uri self._default_authorization_params = authorization_params or {} self._pushed_authorization_requests = pushed_authorization_requests # store the flag - self._use_mrrt = use_mrrt # Initialize stores self._transaction_store = transaction_store @@ -617,14 +613,6 @@ async def get_access_token( token_set = ts break - # After loop: if no matching token found and MRRT disabled, check if we need to error - if not token_set and not self._use_mrrt and state_data_dict.get("token_sets"): - # We have tokens but none match, and we can't use RT to get a new one - raise AccessTokenError( - AccessTokenErrorCode.INCORRECT_AUDIENCE, - "The access token for the requested audience is not available and Multi-Resource Refresh Tokens are disabled." - ) - # If token is valid, return it if token_set and token_set.get("expires_at", 0) > time.time(): return token_set["access_token"] @@ -1316,9 +1304,6 @@ async def start_connect_account( Returns: The a connect URL containing a ticket to redirect the user to. """ - if not self._use_mrrt: - raise Auth0Error("Multi-Resource Refresh Tokens (MRRT) is required to use Connected Accounts functionality.") - # Use the default redirect_uri if none is specified redirect_uri = options.redirect_uri or self._redirect_uri # Ensure we have a redirect_uri @@ -1387,9 +1372,6 @@ async def complete_connect_account( Returns: A response from the connect account flow. """ - if not self._use_mrrt: - raise Auth0Error("Multi-Resource Refresh Tokens (MRRT) is required to use Connected Accounts functionality.") - # Parse the URL to get query parameters parsed_url = urlparse(url) query_params = parse_qs(parsed_url.query) diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 682a53b..5122776 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -18,7 +18,6 @@ from auth0_server_python.error import ( AccessTokenForConnectionError, ApiError, - Auth0Error, BackchannelLogoutError, MissingRequiredArgumentError, MissingTransactionError, @@ -1274,8 +1273,7 @@ async def test_start_connect_account_calls_connect_and_builds_url(mocker): client_secret="", state_store=mock_state_store, transaction_store=mock_transaction_store, - secret="some-secret", - use_mrrt=True + secret="some-secret" ) mocker.patch.object(client, "get_access_token", AsyncMock(return_value="")) @@ -1339,8 +1337,7 @@ async def test_start_connect_account_default_redirect_uri(mocker): state_store=mock_state_store, transaction_store=mock_transaction_store, secret="some-secret", - redirect_uri="/default_redirect_uri", - use_mrrt=True + redirect_uri="/default_redirect_uri" ) mocker.patch.object(client, "get_access_token", AsyncMock(return_value="")) @@ -1402,8 +1399,7 @@ async def test_start_connect_account_no_redirect_uri(mocker): client_secret="", state_store=mock_state_store, transaction_store=mock_transaction_store, - secret="some-secret", - use_mrrt=True + secret="some-secret" ) # Act @@ -1417,33 +1413,6 @@ async def test_start_connect_account_no_redirect_uri(mocker): # Assert assert "redirect_uri" in str(exc.value) -@pytest.mark.asyncio -async def test_start_connect_account_mrrt_disabled(mocker): - # Setup - mock_transaction_store = AsyncMock() - mock_state_store = AsyncMock() - - client = ServerClient( - domain="auth0.local", - client_id="", - client_secret="", - state_store=mock_state_store, - transaction_store=mock_transaction_store, - secret="some-secret", - use_mrrt=False - ) - - # Act - with pytest.raises(Auth0Error) as exc: - await client.start_connect_account( - options=ConnectAccountOptions( - connection="" - ) - ) - - # Assert - assert "MRRT" in str(exc.value) - @pytest.mark.asyncio async def test_complete_connect_account_calls_complete(mocker): # Setup @@ -1457,8 +1426,7 @@ async def test_complete_connect_account_calls_complete(mocker): state_store=mock_state_store, transaction_store=mock_transaction_store, secret="some-secret", - redirect_uri="/test_redirect_uri", - use_mrrt=True + redirect_uri="/test_redirect_uri" ) mocker.patch.object(client, "get_access_token", AsyncMock(return_value="")) @@ -1501,8 +1469,7 @@ async def test_complete_connect_account_no_connect_code(mocker): state_store=mock_state_store, transaction_store=mock_transaction_store, secret="some-secret", - redirect_uri="/test_redirect_uri", - use_mrrt=True + redirect_uri="/test_redirect_uri" ) mock_my_account_client = AsyncMock(MyAccountClient) @@ -1533,8 +1500,7 @@ async def test_complete_connect_account_no_state(mocker): state_store=mock_state_store, transaction_store=mock_transaction_store, secret="some-secret", - redirect_uri="/test_redirect_uri", - use_mrrt=True + redirect_uri="/test_redirect_uri" ) mock_my_account_client = AsyncMock(MyAccountClient) @@ -1565,8 +1531,7 @@ async def test_complete_connect_account_no_transactions(mocker): state_store=mock_state_store, transaction_store=mock_transaction_store, secret="some-secret", - redirect_uri="/test_redirect_uri", - use_mrrt=True + redirect_uri="/test_redirect_uri" ) mock_my_account_client = AsyncMock(MyAccountClient) @@ -1583,29 +1548,3 @@ 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_complete_connect_account_mrrt_disabled(mocker): - # Setup - mock_transaction_store = AsyncMock() - mock_state_store = AsyncMock() - - client = ServerClient( - domain="auth0.local", - client_id="", - client_secret="", - state_store=mock_state_store, - transaction_store=mock_transaction_store, - secret="some-secret", - redirect_uri="/test_redirect_uri", - use_mrrt=False - ) - - # Act - with pytest.raises(Auth0Error) as exc: - await client.complete_connect_account( - url="/test_redirect_uri?connect_code=&state=" - ) - - # Assert - assert "MRRT" in str(exc.value) From 2a863ddfc9f60bdc5d7472eba9f32c26d24f8161 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 11 Nov 2025 12:49:36 +0000 Subject: [PATCH 28/33] Add support for scope parameter on start_connect_account --- .../auth_server/server_client.py | 2 + .../auth_types/__init__.py | 2 + .../tests/test_server_client.py | 41 +++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index b4a6626..3b6f6a9 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -1318,12 +1318,14 @@ async def start_connect_account( connect_request = ConnectAccountRequest( connection=options.connection, + scope=" ".join(options.scope) if options.scope else None, redirect_uri = redirect_uri, code_challenge=code_challenge, code_challenge_method="S256", state=state, authorization_params=options.authorization_params ) + access_token = await self.get_access_token( audience=self._my_account_client.audience_identifier, scope="create:me:connected_accounts", diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 72577cc..f17cc94 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -219,11 +219,13 @@ class ConnectParams(BaseModel): class ConnectAccountOptions(BaseModel): connection: str redirect_uri: Optional[str] = None + scope: 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 redirect_uri: Optional[str] = None state: Optional[str] = None code_challenge: Optional[str] = None diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 5122776..8945f91 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -1324,6 +1324,47 @@ async def test_start_connect_account_calls_connect_and_builds_url(mocker): options=ANY ) +@pytest.mark.asyncio +async def test_start_connect_account_with_scope(mocker): + # Setup + mock_transaction_store = AsyncMock() + mock_state_store = AsyncMock() + + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=mock_state_store, + transaction_store=mock_transaction_store, + secret="some-secret" + ) + + mocker.patch.object(client, "get_access_token", AsyncMock(return_value="")) + mock_my_account_client = AsyncMock(MyAccountClient) + mocker.patch.object(client, "_my_account_client", mock_my_account_client) + mock_my_account_client.connect_account.return_value = ConnectAccountResponse( + auth_session="", + connect_uri="http://auth0.local/connected_accounts/connect", + connect_params=ConnectParams( + ticket="ticket123" + ), + expires_in=300 + ) + + # Act + await client.start_connect_account( + options=ConnectAccountOptions( + connection="", + scope=["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" + @pytest.mark.asyncio async def test_start_connect_account_default_redirect_uri(mocker): # Setup From 4aa265173f07b6c1ddee0cd894b196fe25388090 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 11 Nov 2025 14:22:29 +0000 Subject: [PATCH 29/33] Fix docs issues --- examples/ConnectedAccounts.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/ConnectedAccounts.md b/examples/ConnectedAccounts.md index 2d990df..2008fdd 100644 --- a/examples/ConnectedAccounts.md +++ b/examples/ConnectedAccounts.md @@ -4,7 +4,7 @@ The Connect Accounts feature uses the Auth0 My Account API to allow users to lin 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 SPA 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 [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. This is particularly useful for applications that require access to different resources on behalf of a user, like AI Agents. @@ -12,7 +12,7 @@ This is particularly useful for applications that require access to different re 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) 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. +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 server_client = ServerClient( @@ -22,6 +22,7 @@ server_client = ServerClient( secret="YOUR_SECRET", authorization_params={ "redirect_uri":"YOUR_CALLBACK_URL", + "audience": "YOUR_API_IDENTIFIER" } ) ``` @@ -37,8 +38,7 @@ authorization_url = await server_client.start_interactive_login( { "authorization_params": { # must include offline_access to obtain a refresh token - "scope": "openid profile email offline_access", - "audience": "YOUR_API_IDENTIFIER", + "scope": "openid profile email offline_access" } }, store_options={"request": request, "response": response} @@ -53,11 +53,11 @@ result = await server_client.complete_interactive_login( ) ``` -## Connect to a third party account +## Connect to a third-party account -Start the flow using the `start_connect_account` method to redirect the user to the third party Identity Provider to connect their account. +Start the flow using the `start_connect_account` method to redirect the user to the third-party Identity Provider to connect their account. -The `authorization_params` is used to pass additional parameters required by the third party IdP +The `authorization_params` is used to pass additional parameters required by the third-party IdP The `app_state` parameter allows you to pass custom state (for example, a return URL) that is later available when the connect process completes. ```python @@ -79,11 +79,11 @@ connect_url = await self.client.start_connect_account( ) ``` -Using the url returned, redirect the user to the third party Identity Provider to complete any required authorization. Once authorized, the user will be redirected back to the provided `redirect_uri` with a `connect_code` and `state` parameter. +Using the url returned, redirect the user to the third-party Identity Provider to complete any required authorization. Once authorized, the user will be redirected back to the provided `redirect_uri` with a `connect_code` and `state` parameter. ## Complete the account connection -Call the `complete_connect_account` method using the full callback url returned from the third party IdP to complete the connected account flow. This method extracts the connect_code from the URL, completes the connection, and returns the response data (including any `app_state` you passed originally). +Call the `complete_connect_account` method using the full callback url returned from the third-party IdP to complete the connected account flow. This method extracts the connect_code from the URL, completes the connection, and returns the response data (including any `app_state` you passed originally). ```python complete_response = await self.client.complete_connect_account( @@ -95,4 +95,4 @@ complete_response = await self.client.complete_connect_account( >[!NOTE] >The `callback_url` must include the necessary parameters (`state` and `connect_code`) that Auth0 sends upon successful authentication. -You can now call the API with your access token and the API can use [Access Token Exchange with Token Vault](https://auth0.com/docs/secure/tokens/token-vault/access-token-exchange-with-token-vault) to get tokens from the Token Vault to access third party APIs on behalf of the user. \ No newline at end of file +You can now call the API with your access token and the API can use [Access Token Exchange with Token Vault](https://auth0.com/docs/secure/tokens/token-vault/access-token-exchange-with-token-vault) to get tokens from the Token Vault to access third-party APIs on behalf of the user. \ No newline at end of file From c7a869e31c7d06e95c6d349e790897e47726ec13 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 11 Nov 2025 14:26:32 +0000 Subject: [PATCH 30/33] Rename my_account_client audience_identifier to audience --- src/auth0_server_python/auth_server/my_account_client.py | 6 +++--- src/auth0_server_python/auth_server/server_client.py | 4 ++-- src/auth0_server_python/tests/test_server_client.py | 2 +- 3 files changed, 6 insertions(+), 6 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 2b2e6cc..a5a31d2 100644 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ b/src/auth0_server_python/auth_server/my_account_client.py @@ -18,7 +18,7 @@ def __init__(self, domain: str): self._domain = domain @property - def audience_identifier(self): + def audience(self): return f"https://{self._domain}/me/" async def connect_account( @@ -29,7 +29,7 @@ async def connect_account( try: async with httpx.AsyncClient() as client: response = await client.post( - url=f"{self.audience_identifier}v1/connected-accounts/connect", + url=f"{self.audience}v1/connected-accounts/connect", json=request.model_dump(exclude_none=True), auth=BearerAuth(access_token) ) @@ -65,7 +65,7 @@ async def complete_connect_account( try: async with httpx.AsyncClient() as client: response = await client.post( - url=f"{self.audience_identifier}v1/connected-accounts/complete", + url=f"{self.audience}v1/connected-accounts/complete", json=request.model_dump(exclude_none=True), auth=BearerAuth(access_token) ) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 3b6f6a9..4dea845 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -1327,7 +1327,7 @@ async def start_connect_account( ) access_token = await self.get_access_token( - audience=self._my_account_client.audience_identifier, + audience=self._my_account_client.audience, scope="create:me:connected_accounts", store_options=store_options ) @@ -1396,7 +1396,7 @@ async def complete_connect_account( raise MissingTransactionError() access_token = await self.get_access_token( - audience=self._my_account_client.audience_identifier, + audience=self._my_account_client.audience, scope="create:me:connected_accounts", store_options=store_options ) diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 8945f91..ed7c9a4 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -1364,7 +1364,7 @@ async def test_start_connect_account_with_scope(mocker): 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" - + @pytest.mark.asyncio async def test_start_connect_account_default_redirect_uri(mocker): # Setup From d295e938c2d0b4a5391b63b267beb94f672f540d Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 11 Nov 2025 20:26:25 +0000 Subject: [PATCH 31/33] Revert MRRT related changes --- .../auth_server/server_client.py | 36 +++++-------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 4dea845..558b2c2 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -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. @@ -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") @@ -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 @@ -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()) + @@ -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. @@ -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() @@ -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 @@ -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 From 39e0df72cfbaa6ffeae213a5b04bc52531f3e690 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Wed, 12 Nov 2025 14:19:36 +0000 Subject: [PATCH 32/33] Fix docs and scope/scopes when passing to connected accounts --- examples/ConnectedAccounts.md | 19 +++++++++++++++---- .../auth_server/server_client.py | 2 +- .../auth_types/__init__.py | 4 ++-- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/examples/ConnectedAccounts.md b/examples/ConnectedAccounts.md index 2008fdd..486bf26 100644 --- a/examples/ConnectedAccounts.md +++ b/examples/ConnectedAccounts.md @@ -1,6 +1,10 @@ # 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. @@ -66,13 +70,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} diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 558b2c2..1c252cb 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -1300,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", diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index f17cc94..677a7da 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -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 From 48f5b88520c28c4f41cf33b9925f221c827f8607 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Wed, 12 Nov 2025 14:20:37 +0000 Subject: [PATCH 33/33] Fix test --- src/auth0_server_python/tests/test_server_client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index ed7c9a4..3427ee3 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -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() @@ -1355,7 +1355,7 @@ async def test_start_connect_account_with_scope(mocker): await client.start_connect_account( options=ConnectAccountOptions( connection="", - scope=["scope1", "scope2", "scope3"], + scopes=["scope1", "scope2", "scope3"], redirect_uri="/test_redirect_uri" ) ) @@ -1363,7 +1363,7 @@ async def test_start_connect_account_with_scope(mocker): # 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):