From e6dd7cbf481578155b142822525f0f69b9b3d219 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Wed, 5 Nov 2025 21:35:06 +0000 Subject: [PATCH 01/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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 5cde102c0b3f74327dd6e967eb213fd1e330d0da Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 11 Nov 2025 14:38:47 +0000 Subject: [PATCH 31/49] Revert connected accounts behaviour to have a seperate mrrt PR --- examples/ConnectedAccounts.md | 98 ----- .../auth_schemes/__init__.py | 3 - .../auth_schemes/bearer_auth.py | 10 - .../auth_server/__init__.py | 1 - .../auth_server/my_account_client.py | 94 ----- .../auth_server/server_client.py | 141 +------- .../auth_types/__init__.py | 40 --- src/auth0_server_python/error/__init__.py | 21 -- .../tests/test_my_account_client.py | 160 --------- .../tests/test_server_client.py | 338 +----------------- 10 files changed, 2 insertions(+), 904 deletions(-) delete mode 100644 examples/ConnectedAccounts.md delete mode 100644 src/auth0_server_python/auth_schemes/__init__.py delete mode 100644 src/auth0_server_python/auth_schemes/bearer_auth.py delete mode 100644 src/auth0_server_python/auth_server/my_account_client.py delete mode 100644 src/auth0_server_python/tests/test_my_account_client.py diff --git a/examples/ConnectedAccounts.md b/examples/ConnectedAccounts.md deleted file mode 100644 index 2008fdd..0000000 --- a/examples/ConnectedAccounts.md +++ /dev/null @@ -1,98 +0,0 @@ -# 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 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 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( - domain="YOUR_AUTH0_DOMAIN", - client_id="YOUR_CLIENT_ID", - client_secret="YOUR_CLIENT_SECRET", - secret="YOUR_SECRET", - authorization_params={ - "redirect_uri":"YOUR_CALLBACK_URL", - "audience": "YOUR_API_IDENTIFIER" - } -) -``` - -## 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" - } - }, - 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 `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. - -```python - -connect_url = await self.client.start_connect_account( - ConnectAccountOptions( - connection="CONNECTION", # 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", - "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. - -## 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). - -```python -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 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_schemes/__init__.py b/src/auth0_server_python/auth_schemes/__init__.py deleted file mode 100644 index 1c2c869..0000000 --- a/src/auth0_server_python/auth_schemes/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 8fd629e..0000000 --- a/src/auth0_server_python/auth_schemes/bearer_auth.py +++ /dev/null @@ -1,10 +0,0 @@ -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 diff --git a/src/auth0_server_python/auth_server/__init__.py b/src/auth0_server_python/auth_server/__init__.py index 72818be..969937c 100644 --- a/src/auth0_server_python/auth_server/__init__.py +++ b/src/auth0_server_python/auth_server/__init__.py @@ -1,4 +1,3 @@ -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 deleted file mode 100644 index a5a31d2..0000000 --- a/src/auth0_server_python/auth_server/my_account_client.py +++ /dev/null @@ -1,94 +0,0 @@ - -import httpx -from auth0_server_python.auth_schemes.bearer_auth import BearerAuth -from auth0_server_python.auth_types import ( - CompleteConnectAccountRequest, - CompleteConnectAccountResponse, - ConnectAccountRequest, - ConnectAccountResponse, -) -from auth0_server_python.error import ( - ApiError, - MyAccountApiError, -) - - -class MyAccountClient: - def __init__(self, domain: str): - self._domain = domain - - @property - def audience(self): - return f"https://{self._domain}/me/" - - 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.audience}v1/connected-accounts/connect", - json=request.model_dump(exclude_none=True), - auth=BearerAuth(access_token) - ) - - if response.status_code != 201: - error_data = response.json() - raise MyAccountApiError( - title=error_data.get("title", None), - type=error_data.get("type", None), - detail=error_data.get("detail", None), - status=error_data.get("status", None), - validation_errors=error_data.get("validation_errors", None) - ) - - data = response.json() - - return ConnectAccountResponse.model_validate(data) - - except Exception as e: - if isinstance(e, MyAccountApiError): - 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.audience}v1/connected-accounts/complete", - json=request.model_dump(exclude_none=True), - auth=BearerAuth(access_token) - ) - - if response.status_code != 201: - error_data = response.json() - raise MyAccountApiError( - title=error_data.get("title", None), - type=error_data.get("type", None), - detail=error_data.get("detail", None), - status=error_data.get("status", None), - validation_errors=error_data.get("validation_errors", None) - ) - - data = response.json() - - return CompleteConnectAccountResponse.model_validate(data) - - except Exception as e: - if isinstance(e, MyAccountApiError): - raise - raise ApiError( - "connect_account_error", - f"Connected Accounts complete request failed: {str(e) or 'Unknown error'}", - e - ) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 4dea845..f1f2d06 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -7,16 +7,11 @@ import json import time from typing import Any, Generic, Optional, TypeVar -from urllib.parse import parse_qs, urlencode, urlparse, urlunparse +from urllib.parse import parse_qs, urlparse 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, @@ -107,8 +102,6 @@ def __init__( 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" async with httpx.AsyncClient() as client: @@ -1285,135 +1278,3 @@ 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: - """ - 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. - """ - # 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 not redirect_uri: - raise MissingRequiredArgumentError("redirect_uri") - - # Generate PKCE code verifier and challenge - code_verifier = PKCE.generate_code_verifier() - code_challenge = PKCE.generate_code_challenge(code_verifier) - - state= PKCE.generate_random_string(32) - - 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, - 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 - ) - - # Build the transaction data to store - transaction_data = TransactionData( - code_verifier=code_verifier, - app_state=options.app_state, - auth_session=connect_response.auth_session, - redirect_uri=redirect_uri - ) - - # Store the transaction data - await self._transaction_store.set( - f"{self._transaction_identifier}:{state}", - transaction_data, - options=store_options - ) - - 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, - url: 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 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: - url: The full callback URL including query parameters - store_options: Optional options used to pass to the Transaction and State Store. - - Returns: - A response from the connect account flow. - """ - # 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) - - if not transaction_data: - raise MissingTransactionError() - - access_token = await self.get_access_token( - audience=self._my_account_client.audience, - scope="create:me:connected_accounts", - store_options=store_options - ) - - request = CompleteConnectAccountRequest( - auth_session=transaction_data.auth_session, - connect_code=connect_code, - redirect_uri=transaction_data.redirect_uri, - code_verifier=transaction_data.code_verifier - ) - 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 diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index f17cc94..c2e11f2 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -212,43 +212,3 @@ 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 - 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 - 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 - app_state: Optional[Any] = None diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index ef181ce..2fb8deb 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -56,27 +56,6 @@ 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: Optional[str], - type: Optional[str], - detail: Optional[str], - status: Optional[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 deleted file mode 100644 index f4f18fb..0000000 --- a/src/auth0_server_python/tests/test_my_account_client.py +++ /dev/null @@ -1,160 +0,0 @@ -from unittest.mock import ANY, AsyncMock, MagicMock - -import pytest -from auth0_server_python.auth_server.my_account_client import MyAccountClient -from auth0_server_python.auth_types import ( - CompleteConnectAccountRequest, - CompleteConnectAccountResponse, - ConnectAccountRequest, - ConnectAccountResponse, - ConnectParams, -) -from auth0_server_python.error import MyAccountApiError - - -@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) diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index ed7c9a4..a019586 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -1,17 +1,11 @@ import json import time -from unittest.mock import ANY, AsyncMock, MagicMock +from unittest.mock import 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 auth0_server_python.auth_types import ( - CompleteConnectAccountRequest, - ConnectAccountOptions, - ConnectAccountRequest, - ConnectAccountResponse, - ConnectParams, LogoutOptions, TransactionData, ) @@ -24,7 +18,6 @@ PollingApiError, StartLinkUserError, ) -from auth0_server_python.utils import PKCE @pytest.mark.asyncio @@ -1260,332 +1253,3 @@ 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" - ) - - 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="", - app_state="", - 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_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 - 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="/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) - 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="", - app_state="" - ) - ) - - # 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="/default_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="/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 - 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" - ) - - 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_transaction_store.get.return_value = TransactionData( - code_verifier="", - app_state="", - auth_session="", - redirect_uri="/test_redirect_uri" - ) - - # Act - await client.complete_connect_account( - url="/test_redirect_uri?connect_code=&state=" - ) - - # Assert - mock_my_account_client.complete_connect_account.assert_awaited_with( - access_token="", - request=CompleteConnectAccountRequest( - auth_session="", - connect_code="", - redirect_uri="/test_redirect_uri", - code_verifier="" - ) - ) - -@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" - ) - - 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" - ) - - 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 - 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( - url="/test_redirect_uri?connect_code=&state=" - ) - - # Assert - assert "transaction" in str(exc.value) - mock_my_account_client.complete_connect_account.assert_not_awaited() From 3ed756e1df6e6e512a9deb92756f008815d5ac01 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 11 Nov 2025 14:40:20 +0000 Subject: [PATCH 32/49] Update __init__.py --- src/auth0_server_python/auth_server/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth0_server_python/auth_server/__init__.py b/src/auth0_server_python/auth_server/__init__.py index 969937c..b95c7c0 100644 --- a/src/auth0_server_python/auth_server/__init__.py +++ b/src/auth0_server_python/auth_server/__init__.py @@ -1,3 +1,3 @@ from .server_client import ServerClient -__all__ = ["ServerClient", "MyAccountClient"] +__all__ = ["ServerClient"] From 9d0ea7018d610757fd505bcff02e4e968d049d74 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 11 Nov 2025 14:41:54 +0000 Subject: [PATCH 33/49] Remove auth_session from transaction state --- src/auth0_server_python/auth_types/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index c2e11f2..153ca5b 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -87,7 +87,6 @@ class TransactionData(BaseModel): audience: Optional[str] = None code_verifier: str app_state: Optional[Any] = None - auth_session: Optional[str] = None redirect_uri: Optional[str] = None class Config: From 14b8b075172d1f056d675448167a9de57bac9a8c Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 11 Nov 2025 14:48:16 +0000 Subject: [PATCH 34/49] Move audience/scope on get_access_token params to avoid introduce breaking change for postitional arguments --- src/auth0_server_python/auth_server/server_client.py | 2 +- 1 file changed, 1 insertion(+), 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 f1f2d06..e034c07 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -566,9 +566,9 @@ async def get_session(self, store_options: Optional[dict[str, Any]] = None) -> O async def get_access_token( self, + store_options: Optional[dict[str, Any]] = None, audience: Optional[str] = None, scope: Optional[str] = None, - store_options: Optional[dict[str, Any]] = None ) -> str: """ Retrieves the access token from the store, or calls Auth0 when the access token From e2959396d93a7ee9ef02f4cab4983bc1b3165575 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 11 Nov 2025 20:09:35 +0000 Subject: [PATCH 35/49] Add scopes per audience and merging of default/request scopes --- .../auth_server/server_client.py | 68 ++++- .../tests/test_server_client.py | 267 +++++++++++++++++- 2 files changed, 318 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 e034c07..9113fa0 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -48,6 +48,7 @@ class ServerClient(Generic[TStoreOptions]): Main client for Auth0 server SDK. Handles authentication flows, session management, and token operations using Authlib for OIDC functionality. """ + DEFAULT_AUDIENCE_STATE_KEY = "default" def __init__( self, @@ -292,7 +293,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=transaction_data.audience or self.DEFAULT_AUDIENCE_STATE_KEY, access_token=token_response.get("access_token", ""), scope=token_response.get("scope", ""), expires_at=int(time.time()) + @@ -511,7 +512,7 @@ async def login_backchannel( existing_state_data = await self._state_store.get(self._state_identifier, store_options) audience = self._default_authorization_params.get( - "audience", "default") + "audience", self.DEFAULT_AUDIENCE_STATE_KEY) state_data = State.update_state_data( audience, @@ -586,12 +587,17 @@ async def get_access_token( """ state_data = await self._state_store.get(self._state_identifier, store_options) - # Get audience and scope from options or use defaults auth_params = self._default_authorization_params or {} + + # Get audience options or use defaults if not audience: - audience = auth_params.get("audience", "default") - if not scope: - scope = auth_params.get("scope") + audience = auth_params.get("audience", None) + + merged_scope = self._get_scope_to_request( + scope, + auth_params.get("scope", None), + audience or self.DEFAULT_AUDIENCE_STATE_KEY + ) if state_data and hasattr(state_data, "dict") and callable(state_data.dict): state_data_dict = state_data.dict() @@ -601,10 +607,8 @@ async def get_access_token( # Find matching token set token_set = None if state_data_dict and "token_sets" in state_data_dict: - for ts in state_data_dict["token_sets"]: - if ts.get("audience") == audience and (not scope or ts.get("scope") == scope): - token_set = ts - break + token_set = self._find_matching_token_set( + state_data_dict["token_sets"], audience or self.DEFAULT_AUDIENCE_STATE_KEY, merged_scope) # If token is valid, return it if token_set and token_set.get("expires_at", 0) > time.time(): @@ -619,11 +623,14 @@ 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 - }) + request_body = {"refresh_token": state_data_dict["refresh_token"]} + if audience: + request_body["audience"] = audience + + if merged_scope: + request_body["scope"] = merged_scope + + token_endpoint_response = await self.get_token_by_refresh_token(request_body) # Update state data with new token existing_state_data = await self._state_store.get(self._state_identifier, store_options) @@ -642,6 +649,37 @@ async def get_access_token( f"Failed to get token with refresh token: {str(e)}" ) + def _get_scope_to_request( + self, + request_scopes: Optional[str], + default_scopes: Optional[str] | Optional[dict[str, str]], + audience: Optional[str] + ) -> Optional[str]: + # For backwards compatibility, allow scope to be a single string + # or dictionary by audience for MRRT + if isinstance(default_scopes, dict) and audience in default_scopes: + default_scopes = default_scopes[audience] + + default_scopes_list = (default_scopes or "").split() + request_scopes_list = (request_scopes or "").split() + + merged_scopes = default_scopes_list + [x for x in request_scopes_list if x not in default_scopes_list] + return " ".join(merged_scopes) if merged_scopes else None + + + def _find_matching_token_set( + self, + token_sets: list[dict[str, Any]], + audience: Optional[str], + scope: Optional[str] + ) -> Optional[dict[str, Any]]: + for token_set in token_sets: + token_set_audience = token_set.get("audience") + matches_audience = token_set_audience == audience + matches_scope = not scope or token_set.get("scope", None) == scope + if matches_audience and matches_scope: + return token_set + async def get_access_token_for_connection( self, options: dict[str, Any], diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index a019586..51dbcee 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -384,8 +384,128 @@ async def test_get_access_token_refresh_expired(mocker): secret="some-secret" ) - # Patch method that does the refresh call - mocker.patch.object(client, "get_token_by_refresh_token", return_value={ + get_refresh_token_mock = mocker.patch.object(client, "get_token_by_refresh_token", return_value={ + "access_token": "new_token", + "expires_in": 3600 + }) + + token = await client.get_access_token() + assert token == "new_token" + mock_state_store.set.assert_awaited_once() + get_refresh_token_mock.assert_awaited_with({ + "refresh_token": "refresh_xyz" + }) + +@pytest.mark.asyncio +async def test_get_access_token_refresh_merging_default_scope(mocker): + mock_state_store = AsyncMock() + # expired token + mock_state_store.get.return_value = { + "refresh_token": "refresh_xyz", + "token_sets": [ + { + "audience": "default", + "access_token": "expired_token", + "expires_at": int(time.time()) - 500 + } + ] + } + + client = ServerClient( + domain="auth0.local", + client_id="client_id", + client_secret="client_secret", + transaction_store=AsyncMock(), + state_store=mock_state_store, + secret="some-secret", + authorization_params= { + "audience": "default", + "scope": "openid profile email" + } + ) + + get_refresh_token_mock = mocker.patch.object(client, "get_token_by_refresh_token", return_value={ + "access_token": "new_token", + "expires_in": 3600 + }) + + token = await client.get_access_token(scope="foo:bar") + assert token == "new_token" + mock_state_store.set.assert_awaited_once() + get_refresh_token_mock.assert_awaited_with({ + "refresh_token": "refresh_xyz", + "audience": "default", + "scope": "openid profile email foo:bar" + }) + +@pytest.mark.asyncio +async def test_get_access_token_refresh_with_auth_params_scope(mocker): + mock_state_store = AsyncMock() + # expired token + mock_state_store.get.return_value = { + "refresh_token": "refresh_xyz", + "token_sets": [ + { + "audience": "default", + "access_token": "expired_token", + "expires_at": int(time.time()) - 500 + } + ] + } + + client = ServerClient( + domain="auth0.local", + client_id="client_id", + client_secret="client_secret", + transaction_store=AsyncMock(), + state_store=mock_state_store, + secret="some-secret", + authorization_params= { + "scope": "openid profile email" + } + ) + + get_refresh_token_mock = mocker.patch.object(client, "get_token_by_refresh_token", return_value={ + "access_token": "new_token", + "expires_in": 3600 + }) + + token = await client.get_access_token() + assert token == "new_token" + mock_state_store.set.assert_awaited_once() + get_refresh_token_mock.assert_awaited_with({ + "refresh_token": "refresh_xyz", + "scope": "openid profile email" + }) + +@pytest.mark.asyncio +async def test_get_access_token_refresh_with_auth_params_audience(mocker): + mock_state_store = AsyncMock() + # expired token + mock_state_store.get.return_value = { + "refresh_token": "refresh_xyz", + "token_sets": [ + { + "audience": "my_audience", + "access_token": "expired_token", + "expires_at": int(time.time()) - 500 + } + ] + } + + client = ServerClient( + domain="auth0.local", + client_id="client_id", + client_secret="client_secret", + transaction_store=AsyncMock(), + state_store=mock_state_store, + secret="some-secret", + authorization_params= { + "audience": "my_audience" + } + ) + + get_refresh_token_mock = mocker.patch.object(client, "get_token_by_refresh_token", return_value={ "access_token": "new_token", "expires_in": 3600 }) @@ -393,6 +513,149 @@ async def test_get_access_token_refresh_expired(mocker): token = await client.get_access_token() assert token == "new_token" mock_state_store.set.assert_awaited_once() + get_refresh_token_mock.assert_awaited_with({ + "refresh_token": "refresh_xyz", + "audience": "my_audience" + }) + +@pytest.mark.asyncio +async def test_get_access_token_mrrt(mocker): + mock_state_store = AsyncMock() + # expired token + mock_state_store.get.return_value = { + "refresh_token": "refresh_xyz", + "token_sets": [ + { + "audience": "default", + "access_token": "valid_token_for_other_audience", + "expires_at": int(time.time()) + 500 + } + ] + } + + client = ServerClient( + domain="auth0.local", + client_id="client_id", + client_secret="client_secret", + transaction_store=AsyncMock(), + state_store=mock_state_store, + secret="some-secret" + ) + + # Patch method that does the refresh call + get_refresh_token_mock = mocker.patch.object(client, "get_token_by_refresh_token", return_value={ + "access_token": "new_token", + "expires_in": 3600 + }) + + token = await client.get_access_token( + audience="some_audience", + scope="foo:bar" + ) + + assert token == "new_token" + mock_state_store.set.assert_awaited_once() + args, kwargs = mock_state_store.set.call_args + stored_state = args[1] + assert "token_sets" in stored_state + assert len(stored_state["token_sets"]) == 2 + get_refresh_token_mock.assert_awaited_with({ + "refresh_token": "refresh_xyz", + "audience": "some_audience", + "scope": "foo:bar", + }) + +@pytest.mark.asyncio +async def test_get_access_token_mrrt_with_auth_params_scope(mocker): + mock_state_store = AsyncMock() + # expired token + mock_state_store.get.return_value = { + "refresh_token": "refresh_xyz", + "token_sets": [ + { + "audience": "default", + "access_token": "valid_token_for_other_audience", + "expires_at": int(time.time()) + 500 + } + ] + } + + client = ServerClient( + domain="auth0.local", + client_id="client_id", + client_secret="client_secret", + transaction_store=AsyncMock(), + state_store=mock_state_store, + secret="some-secret", + authorization_params= { + "audience": "default", + "scope": { + "default": "openid profile email foo:bar", + "some_audience": "foo:bar" + } + } + ) + + # Patch method that does the refresh call + get_refresh_token_mock = mocker.patch.object(client, "get_token_by_refresh_token", return_value={ + "access_token": "new_token", + "expires_in": 3600 + }) + + token = await client.get_access_token( + audience="some_audience" + ) + + assert token == "new_token" + mock_state_store.set.assert_awaited_once() + args, kwargs = mock_state_store.set.call_args + stored_state = args[1] + assert "token_sets" in stored_state + assert len(stored_state["token_sets"]) == 2 + get_refresh_token_mock.assert_awaited_with({ + "refresh_token": "refresh_xyz", + "audience": "some_audience", + "scope": "foo:bar", + }) + +@pytest.mark.asyncio +async def test_get_access_token_from_store_with_multilpe_audiences(mocker): + mock_state_store = AsyncMock() + mock_state_store.get.return_value = { + "refresh_token": None, + "token_sets": [ + { + "audience": "default", + "access_token": "token_from_store", + "expires_at": int(time.time()) + 500 + }, + { + "audience": "some_audience", + "access_token": "other_token_from_store", + "scope": "foo:bar", + "expires_at": int(time.time()) + 500 + } + ] + } + + client = ServerClient( + domain="auth0.local", + client_id="client_id", + client_secret="client_secret", + transaction_store=AsyncMock(), + state_store=mock_state_store, + secret="some-secret" + ) + + get_refresh_token_mock = mocker.patch.object(client, "get_token_by_refresh_token") + + token = await client.get_access_token( + audience="some_audience", + scope="foo:bar" + ) + + assert token == "other_token_from_store" + get_refresh_token_mock.assert_not_awaited() @pytest.mark.asyncio async def test_get_access_token_for_connection_cached(): From e8152da56acbbdde6c3be91ffe4a6aae17624d4e Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 11 Nov 2025 20:55:42 +0000 Subject: [PATCH 36/49] Apply scope merging to RT exchange --- .../auth_server/server_client.py | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 9113fa0..65fa81d 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -593,11 +593,7 @@ async def get_access_token( if not audience: audience = auth_params.get("audience", None) - merged_scope = self._get_scope_to_request( - scope, - auth_params.get("scope", None), - audience or self.DEFAULT_AUDIENCE_STATE_KEY - ) + merged_scope = self._merge_scope_with_defaults(scope, audience) if state_data and hasattr(state_data, "dict") and callable(state_data.dict): state_data_dict = state_data.dict() @@ -623,14 +619,14 @@ async def get_access_token( # Get new token with refresh token try: - request_body = {"refresh_token": state_data_dict["refresh_token"]} + get_refresh_token_options = {"refresh_token": state_data_dict["refresh_token"]} if audience: - request_body["audience"] = audience + get_refresh_token_options["audience"] = audience if merged_scope: - request_body["scope"] = merged_scope + get_refresh_token_options["scope"] = merged_scope - token_endpoint_response = await self.get_token_by_refresh_token(request_body) + token_endpoint_response = await self.get_token_by_refresh_token(get_refresh_token_options) # Update state data with new token existing_state_data = await self._state_store.get(self._state_identifier, store_options) @@ -649,19 +645,24 @@ async def get_access_token( f"Failed to get token with refresh token: {str(e)}" ) - def _get_scope_to_request( + def _merge_scope_with_defaults( self, - request_scopes: Optional[str], - default_scopes: Optional[str] | Optional[dict[str, str]], + request_scope: Optional[str], audience: Optional[str] ) -> Optional[str]: - # For backwards compatibility, allow scope to be a single string - # or dictionary by audience for MRRT - if isinstance(default_scopes, dict) and audience in default_scopes: - default_scopes = default_scopes[audience] + audience = audience or self.DEFAULT_AUDIENCE_STATE_KEY + default_scopes = "" + if self._default_authorization_params and "scope" in self._default_authorization_params: + auth_param_scope = self._default_authorization_params.get("scope") + # For backwards compatibility, allow scope to be a single string + # or dictionary by audience for MRRT + if isinstance(auth_param_scope, dict) and audience in auth_param_scope: + default_scopes = auth_param_scope[audience] + else: + default_scopes = auth_param_scope default_scopes_list = (default_scopes or "").split() - request_scopes_list = (request_scopes or "").split() + request_scopes_list = (request_scope or "").split() merged_scopes = default_scopes_list + [x for x in request_scopes_list if x not in default_scopes_list] return " ".join(merged_scopes) if merged_scopes else None @@ -1196,12 +1197,14 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str, 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"] + # Merge scope if present in options with any in the original authorization params + merged_scope = self._merge_scope_with_defaults( + request_scope=options.get("scope"), + audience=audience + ) + + if merged_scope: + token_params["scope"] = self.merged_scope # Exchange the refresh token for an access token async with httpx.AsyncClient() as client: From 618a11d38dd060099af19b5e4801038a32910654 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 11 Nov 2025 21:01:38 +0000 Subject: [PATCH 37/49] Review fixes --- 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 65fa81d..fc1b439 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -62,7 +62,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. @@ -589,7 +589,7 @@ async def get_access_token( auth_params = self._default_authorization_params or {} - # Get audience options or use defaults + # Get audience passed in on options or use defaults if not audience: audience = auth_params.get("audience", None) @@ -680,6 +680,8 @@ def _find_matching_token_set( matches_scope = not scope or token_set.get("scope", None) == scope if matches_audience and matches_scope: return token_set + + return None async def get_access_token_for_connection( self, From de81f374c98a1ac52d6258c4a8fc52336231c82f Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 11 Nov 2025 21:06:08 +0000 Subject: [PATCH 38/49] Small refactor --- 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 fc1b439..d3deb58 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -603,8 +603,7 @@ async def get_access_token( # Find matching token set token_set = None if state_data_dict and "token_sets" in state_data_dict: - token_set = self._find_matching_token_set( - state_data_dict["token_sets"], audience or self.DEFAULT_AUDIENCE_STATE_KEY, merged_scope) + token_set = self._find_matching_token_set(state_data_dict["token_sets"], audience, merged_scope) # If token is valid, return it if token_set and token_set.get("expires_at", 0) > time.time(): @@ -674,6 +673,7 @@ def _find_matching_token_set( audience: Optional[str], scope: Optional[str] ) -> Optional[dict[str, Any]]: + audience = audience or self.DEFAULT_AUDIENCE_STATE_KEY for token_set in token_sets: token_set_audience = token_set.get("audience") matches_audience = token_set_audience == audience From 314dc9ae2334306e656b84a733d144157fb0d8cc Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 11 Nov 2025 21:07:37 +0000 Subject: [PATCH 39/49] Remove unused error code --- src/auth0_server_python/error/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 2fb8deb..14e3d7d 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -123,7 +123,6 @@ 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 1c429aefc0cd84d33fa97fd6f03b677ca76a032e Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 11 Nov 2025 21:09:10 +0000 Subject: [PATCH 40/49] Remove unused transaction property --- src/auth0_server_python/auth_types/__init__.py | 1 - src/auth0_server_python/error/__init__.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 153ca5b..ce93101 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -87,7 +87,6 @@ class TransactionData(BaseModel): audience: Optional[str] = None code_verifier: str app_state: Optional[Any] = 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/error/__init__.py b/src/auth0_server_python/error/__init__.py index 14e3d7d..58ce85f 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -56,6 +56,7 @@ def __init__(self, code: str, message: str, interval: Optional[int], cause=None) super().__init__(code, message, cause) self.interval = interval + class AccessTokenError(Auth0Error): """Error raised when there's an issue with access tokens.""" From 8adbbb118b25eed889599a7c963ae5997c2f233a Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 11 Nov 2025 21:45:34 +0000 Subject: [PATCH 41/49] Add some MRRT docs --- examples/RetrievingData.md | 101 +++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/examples/RetrievingData.md b/examples/RetrievingData.md index 29290f0..39856b6 100644 --- a/examples/RetrievingData.md +++ b/examples/RetrievingData.md @@ -70,6 +70,107 @@ access_token = await server_client.get_access_token(store_options=store_options) Read more above in [Configuring the Store](./ConfigureStore.md). +## Multi-Resource Refresh Tokens (MRRT) + +Multi-Resource Refresh Tokens allow using a single refresh token to obtain access tokens for multiple audiences, simplifying token management in applications that interact with multiple backend services. + +Read more about [Multi-Resource Refresh Tokens in the Auth0 documentation](https://auth0.com/docs/secure/tokens/refresh-tokens/multi-resource-refresh-token). + + +> [!WARNING] +> When using Multi-Resource Refresh Token Configuration (MRRT), **Refresh Token Policies** on your Application need to be configured with the audiences you want to support. See the [Auth0 MRRT documentation](https://auth0.com/docs/secure/tokens/refresh-tokens/multi-resource-refresh-token) for setup instructions. +> +> **Tokens requested for audiences outside your configured policies will be ignored by Auth0, which will return a token for the default audience instead!** + +### Configuring Scopes Per Audience + +When working with multiple APIs, you can define different default scopes for each audience by passing an object instead of a string. This is particularly useful when different APIs require different default scopes: + +```python +server_client = ServerClient( + ... + authorization_params={ + audience: "https://api.example.com", # Default audience + scope: { + "https://api.example.com": "openid profile email offline_access read:products read:orders", + "https://analytics.example.com": "openid profile email offline_access read:analytics write:analytics", + "https://admin.example.com": "openid profile email offline_access read:admin write:admin delete:admin" + } + } +) +``` + +**How it works:** + +- Each key in the `scope` object is an `audience` identifier +- The corresponding value is the scope string for that audience +- When calling `get_access_token(audience=audience)`, the SDK automatically uses the configured scopes for that audience. When scopes are also passed in the method call, they are be merged with the default scopes for that audience. + +### Usage Example + +To retrieve access tokens for different audiences, use the `get_access_token()` method with an `audience` (and optionally also the `scope`) parameter. + +```python + +server_client = ServerClient( + ... + authorization_params={ + "audience": "https://api.example.com", # Default audience + "scope": { + "https://api.example.com": "openid email profile", + "https://analytics.example.com": "read:analytics write:analytics" + } + } +) + +# Get token for default audience +default_token = await server_client.get_access_token() +# returns token for https://api.example.com with openid, email, and profile scopes + + # Get token for different audience +data_token = await server_client.get_access_token(audience="https://data-api.example.com") +# returns token for https://analytics.example.com with read:analytics and write:analytics scopes + +# Get token with additional scopes +admin_token = await server_client.get_access_token( + audience="https://api.example.com", + scope="write:admin" +) +# returns token for https://api.example.com with openid, email, profile and write:admin scopes + +``` + +### Token Management Best Practices + +**Configure Broad Default Scopes**: Define comprehensive scopes in your `ServerClient` constructor for common use cases. This minimizes the need to request additional scopes dynamically, reducing the amount of tokens that need to be stored. + +```python +server_client = ServerClient( + ... + authorization_params={ + "audience": "https://api.example.com", # Default audience + # Configure broad default scopes for most common operations + "scope": { + "https://api.example.com": "openid profile email offline_access read:products read:orders read:users" + } + } +) +``` + +**Minimize Dynamic Scope Requests**: Avoid passing `scope` when calling `get_access_token()` unless absolutely necessary. Each `audience` + `scope` combination results in a token to store in the session, increasing session size. + +```python +# Preferred: Use default scopes +token = await server_client.get_access_token(audience="https://api.example.com") + + +# Avoid unless necessary: Dynamic scopes increase session size +token = await server_client.get_access_token( + audience="https://api.example.com" + scope="openid profile email read:products write:products admin:all" +) +``` + ## Retrieving an Access Token for a Connections The SDK's `get_access_token_for_connection()` can be used to retrieve an Access Token for a connection (e.g. `google-oauth2`) for the current logged-in user: From 6481f949216f34722c36f8fea3d97a7cd95b7bd2 Mon Sep 17 00:00:00 2001 From: sam-muncke Date: Tue, 11 Nov 2025 21:54:14 +0000 Subject: [PATCH 42/49] Fix reference Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/auth0_server_python/auth_server/server_client.py | 2 +- 1 file changed, 1 insertion(+), 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 d3deb58..56792dc 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -1206,7 +1206,7 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str, ) if merged_scope: - token_params["scope"] = self.merged_scope + token_params["scope"] = merged_scope # Exchange the refresh token for an access token async with httpx.AsyncClient() as client: From 458ba772fe3920d897abdc8853f02d189cfa6386 Mon Sep 17 00:00:00 2001 From: sam-muncke Date: Tue, 11 Nov 2025 21:56:46 +0000 Subject: [PATCH 43/49] Update examples/RetrievingData.md Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- examples/RetrievingData.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/RetrievingData.md b/examples/RetrievingData.md index 39856b6..9a66c11 100644 --- a/examples/RetrievingData.md +++ b/examples/RetrievingData.md @@ -90,7 +90,7 @@ When working with multiple APIs, you can define different default scopes for eac server_client = ServerClient( ... authorization_params={ - audience: "https://api.example.com", # Default audience + "audience": "https://api.example.com", # Default audience scope: { "https://api.example.com": "openid profile email offline_access read:products read:orders", "https://analytics.example.com": "openid profile email offline_access read:analytics write:analytics", From 8ce824b55e88332fb0a58f934a3ca0f7b7228561 Mon Sep 17 00:00:00 2001 From: sam-muncke Date: Tue, 11 Nov 2025 21:56:57 +0000 Subject: [PATCH 44/49] Update examples/RetrievingData.md Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- examples/RetrievingData.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/RetrievingData.md b/examples/RetrievingData.md index 9a66c11..274c5bb 100644 --- a/examples/RetrievingData.md +++ b/examples/RetrievingData.md @@ -91,7 +91,7 @@ server_client = ServerClient( ... authorization_params={ "audience": "https://api.example.com", # Default audience - scope: { + "scope": { "https://api.example.com": "openid profile email offline_access read:products read:orders", "https://analytics.example.com": "openid profile email offline_access read:analytics write:analytics", "https://admin.example.com": "openid profile email offline_access read:admin write:admin delete:admin" From 9677456fbfe19102406c16eb747af2ad34148e63 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 11 Nov 2025 22:05:34 +0000 Subject: [PATCH 45/49] Review fixes --- src/auth0_server_python/auth_server/server_client.py | 12 +++++++----- src/auth0_server_python/tests/test_server_client.py | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 56792dc..89b3578 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -657,10 +657,10 @@ def _merge_scope_with_defaults( # or dictionary by audience for MRRT if isinstance(auth_param_scope, dict) and audience in auth_param_scope: default_scopes = auth_param_scope[audience] - else: + elif isinstance(auth_param_scope, str): default_scopes = auth_param_scope - default_scopes_list = (default_scopes or "").split() + default_scopes_list = default_scopes.split() request_scopes_list = (request_scope or "").split() merged_scopes = default_scopes_list + [x for x in request_scopes_list if x not in default_scopes_list] @@ -674,14 +674,16 @@ def _find_matching_token_set( scope: Optional[str] ) -> Optional[dict[str, Any]]: audience = audience or self.DEFAULT_AUDIENCE_STATE_KEY + match = None for token_set in token_sets: token_set_audience = token_set.get("audience") matches_audience = token_set_audience == audience matches_scope = not scope or token_set.get("scope", None) == scope if matches_audience and matches_scope: - return token_set - - return None + match = token_set + break + + return match async def get_access_token_for_connection( self, diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 51dbcee..9cb4022 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -619,7 +619,7 @@ async def test_get_access_token_mrrt_with_auth_params_scope(mocker): }) @pytest.mark.asyncio -async def test_get_access_token_from_store_with_multilpe_audiences(mocker): +async def test_get_access_token_from_store_with_multiple_audiences(mocker): mock_state_store = AsyncMock() mock_state_store.get.return_value = { "refresh_token": None, From 49ae6b4300220d857fb9498243222049fecd9341 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Tue, 11 Nov 2025 22:23:52 +0000 Subject: [PATCH 46/49] Code review fixes --- examples/RetrievingData.md | 2 +- src/auth0_server_python/auth_server/server_client.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/RetrievingData.md b/examples/RetrievingData.md index 274c5bb..88fb610 100644 --- a/examples/RetrievingData.md +++ b/examples/RetrievingData.md @@ -128,7 +128,7 @@ default_token = await server_client.get_access_token() # returns token for https://api.example.com with openid, email, and profile scopes # Get token for different audience -data_token = await server_client.get_access_token(audience="https://data-api.example.com") +data_token = await server_client.get_access_token(audience="https://analytics.example.com") # returns token for https://analytics.example.com with read:analytics and write:analytics scopes # Get token with additional scopes diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 89b3578..29a6d63 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -678,7 +678,8 @@ def _find_matching_token_set( for token_set in token_sets: token_set_audience = token_set.get("audience") matches_audience = token_set_audience == audience - matches_scope = not scope or token_set.get("scope", None) == scope + token_set_scopes = set(token_set.get("scope", "").split()) + matches_scope = not scope or token_set_scopes == set(scope.split()) if matches_audience and matches_scope: match = token_set break From 34db3e834d6c59a8ea73293a15cfecec67e03988 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Wed, 12 Nov 2025 11:27:37 +0000 Subject: [PATCH 47/49] Allow reuse of stored tokens if tokenset scopes are a superset of requested scopes --- .../auth_server/server_client.py | 5 +-- .../tests/test_server_client.py | 39 +++++++++++++++++++ 2 files changed, 41 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 29a6d63..f12822b 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -677,10 +677,9 @@ def _find_matching_token_set( match = None for token_set in token_sets: token_set_audience = token_set.get("audience") - matches_audience = token_set_audience == audience token_set_scopes = set(token_set.get("scope", "").split()) - matches_scope = not scope or token_set_scopes == set(scope.split()) - if matches_audience and matches_scope: + requested_scopes = set(scope.split()) if scope else set() + if token_set_audience == audience and token_set_scopes.issuperset(requested_scopes): match = token_set break diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 9cb4022..e31a351 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -657,6 +657,45 @@ async def test_get_access_token_from_store_with_multiple_audiences(mocker): assert token == "other_token_from_store" get_refresh_token_mock.assert_not_awaited() +@pytest.mark.asyncio +async def test_get_access_token_from_store_with_a_superset_of_requested_scopes(mocker): + mock_state_store = AsyncMock() + mock_state_store.get.return_value = { + "refresh_token": None, + "token_sets": [ + { + "audience": "default", + "access_token": "token_from_store", + "expires_at": int(time.time()) + 500 + }, + { + "audience": "some_audience", + "access_token": "other_token_from_store", + "scope": "read:foo write:foo read:bar write:bar", + "expires_at": int(time.time()) + 500 + } + ] + } + + client = ServerClient( + domain="auth0.local", + client_id="client_id", + client_secret="client_secret", + transaction_store=AsyncMock(), + state_store=mock_state_store, + secret="some-secret" + ) + + get_refresh_token_mock = mocker.patch.object(client, "get_token_by_refresh_token") + + token = await client.get_access_token( + audience="some_audience", + scope="read:foo read:bar" + ) + + assert token == "other_token_from_store" + get_refresh_token_mock.assert_not_awaited() + @pytest.mark.asyncio async def test_get_access_token_for_connection_cached(): mock_state_store = AsyncMock() From 6a4ce2674f8a16e7613d0244eaf5a7894cf322e6 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Wed, 12 Nov 2025 12:38:58 +0000 Subject: [PATCH 48/49] Improve scope matching logic to return the stored token with the minimum number of scopes rather than first match --- .../auth_server/server_client.py | 16 +++++--- .../tests/test_server_client.py | 41 +++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index f12822b..192d86a 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -663,7 +663,7 @@ def _merge_scope_with_defaults( default_scopes_list = default_scopes.split() request_scopes_list = (request_scope or "").split() - merged_scopes = default_scopes_list + [x for x in request_scopes_list if x not in default_scopes_list] + merged_scopes = list(dict.fromkeys(default_scopes_list + request_scopes_list)) return " ".join(merged_scopes) if merged_scopes else None @@ -674,16 +674,20 @@ def _find_matching_token_set( scope: Optional[str] ) -> Optional[dict[str, Any]]: audience = audience or self.DEFAULT_AUDIENCE_STATE_KEY - match = None + requested_scopes = set(scope.split()) if scope else set() + matches: list[tuple[int, dict]] = [] for token_set in token_sets: token_set_audience = token_set.get("audience") token_set_scopes = set(token_set.get("scope", "").split()) - requested_scopes = set(scope.split()) if scope else set() + if token_set_audience == audience and token_set_scopes == requested_scopes: + # short-circuit if exact match + return token_set if token_set_audience == audience and token_set_scopes.issuperset(requested_scopes): - match = token_set - break + # consider stored tokens with more scopes than requested by number of scopes + matches.append((len(token_set_scopes), token_set)) - return match + # Return the token set with the smallest superset of scopes that matches the requested audience and scopes + return min(matches, key=lambda t: t[0])[1] if matches else None async def get_access_token_for_connection( self, diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index e31a351..c990513 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -696,6 +696,47 @@ async def test_get_access_token_from_store_with_a_superset_of_requested_scopes(m assert token == "other_token_from_store" get_refresh_token_mock.assert_not_awaited() + +@pytest.mark.asyncio +async def test_get_access_token_from_store_returns_minimum_matching_scopes(mocker): + mock_state_store = AsyncMock() + mock_state_store.get.return_value = { + "refresh_token": None, + "token_sets": [ + { + "audience": "some_audience", + "access_token": "maximum_scope_token", + "scope": "read:foo write:foo read:bar write:bar admin:all", + "expires_at": int(time.time()) + 500 + }, + { + "audience": "some_audience", + "access_token": "minimum_scope_token", + "scope": "read:foo write:foo read:bar write:bar", + "expires_at": int(time.time()) + 500 + } + ] + } + + client = ServerClient( + domain="auth0.local", + client_id="client_id", + client_secret="client_secret", + transaction_store=AsyncMock(), + state_store=mock_state_store, + secret="some-secret" + ) + + get_refresh_token_mock = mocker.patch.object(client, "get_token_by_refresh_token") + + token = await client.get_access_token( + audience="some_audience", + scope="read:foo read:bar" + ) + + assert token == "minimum_scope_token" + get_refresh_token_mock.assert_not_awaited() + @pytest.mark.asyncio async def test_get_access_token_for_connection_cached(): mock_state_store = AsyncMock() From bbabb250005d606e11f1b23a9065e15ed6de6fb3 Mon Sep 17 00:00:00 2001 From: Sam Muncke Date: Thu, 13 Nov 2025 19:05:38 +0000 Subject: [PATCH 49/49] Merge requested scopes and defaults scopes correctly on login --- src/auth0_server_python/auth_server/server_client.py | 10 ++++++++-- 1 file changed, 8 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 192d86a..e66680f 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -40,7 +40,7 @@ # Generic type for store options TStoreOptions = TypeVar('TStoreOptions') INTERNAL_AUTHORIZE_PARAMS = ["client_id", "redirect_uri", "response_type", - "code_challenge", "code_challenge_method", "state", "nonce"] + "code_challenge", "code_challenge_method", "state", "nonce", "scope"] class ServerClient(Generic[TStoreOptions]): @@ -154,11 +154,17 @@ async def start_interactive_login( state = PKCE.generate_random_string(32) auth_params["state"] = state + #merge any requested scope with defaults + requested_scope = options.authorization_params.get("scope", None) if options.authorization_params else None + audience = auth_params.get("audience", None) + merged_scope = self._merge_scope_with_defaults(requested_scope, audience) + auth_params["scope"] = merged_scope + # Build the transaction data to store transaction_data = TransactionData( code_verifier=code_verifier, app_state=options.app_state, - audience=auth_params.get("audience", None), + audience=audience, ) # Store the transaction data