Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
e6dd7cb
Basic connected account connect/complete flow
sam-muncke Nov 5, 2025
a27ca1c
Add MRRT behaviour
sam-muncke Nov 6, 2025
c1d0167
Move BearerAuth to own file
sam-muncke Nov 6, 2025
c62cdc3
Handle redirect_uri properly
sam-muncke Nov 6, 2025
e7c65d1
Add some doc comments
sam-muncke Nov 6, 2025
f1a52b6
Add tests around MyAccountClient
sam-muncke Nov 6, 2025
8808ed9
Make use of mrrt configurable
sam-muncke Nov 6, 2025
3e27dc5
Fix for code scanning alert no. 3: Unused import
sam-muncke Nov 6, 2025
161e6a7
Merge branch 'FGI-1573_connected-account-support' of github.com:auth0…
sam-muncke Nov 6, 2025
c836d2e
Fix linting issues
sam-muncke Nov 6, 2025
b8fcc89
Test to ensure mrrt is used for connected accounts
sam-muncke Nov 6, 2025
c8cf1cf
add example docs
sam-muncke Nov 6, 2025
d6e210d
Allow passing of app state
sam-muncke Nov 6, 2025
08fbc31
Fix comment
sam-muncke Nov 6, 2025
2b0439d
Cleanup transaction data at end of the flow
sam-muncke Nov 6, 2025
c09d803
Fix case where MRRT is disabled but we may have multiple token sets w…
sam-muncke Nov 7, 2025
3fecdbb
Fix docs issues from code review
sam-muncke Nov 7, 2025
bbbc824
Merge branch 'FGI-1573_connected-account-support' of github.com:auth0…
sam-muncke Nov 7, 2025
4ea8e5f
Code review fixes
sam-muncke Nov 7, 2025
2d070d9
Code review fixes
sam-muncke Nov 7, 2025
e91eab1
Update examples/ConnectedAccounts.md
sam-muncke Nov 7, 2025
f658b8b
Update examples/ConnectedAccounts.md
sam-muncke Nov 7, 2025
2e27549
Update examples/ConnectedAccounts.md
sam-muncke Nov 7, 2025
70529b9
Code review fixes
sam-muncke Nov 7, 2025
7111e65
Clean up transaction data regardless of success/failure
sam-muncke Nov 7, 2025
61e5d42
Populate/pull audience to store in token set from transaction state o…
sam-muncke Nov 7, 2025
5a6e387
Dont merge the default auth params with the ones provided for connect…
sam-muncke Nov 7, 2025
19977f8
Parsed returned url safely
sam-muncke Nov 7, 2025
b5154aa
Remove use_mrrt flag and have mrrt used by default
sam-muncke Nov 10, 2025
2a863dd
Add support for scope parameter on start_connect_account
sam-muncke Nov 11, 2025
4aa2651
Fix docs issues
sam-muncke Nov 11, 2025
c7a869e
Rename my_account_client audience_identifier to audience
sam-muncke Nov 11, 2025
5cde102
Revert connected accounts behaviour to have a seperate mrrt PR
sam-muncke Nov 11, 2025
3ed756e
Update __init__.py
sam-muncke Nov 11, 2025
9d0ea70
Remove auth_session from transaction state
sam-muncke Nov 11, 2025
14b8b07
Move audience/scope on get_access_token params to avoid introduce bre…
sam-muncke Nov 11, 2025
e295939
Add scopes per audience and merging of default/request scopes
sam-muncke Nov 11, 2025
e8152da
Apply scope merging to RT exchange
sam-muncke Nov 11, 2025
618a11d
Review fixes
sam-muncke Nov 11, 2025
de81f37
Small refactor
sam-muncke Nov 11, 2025
314dc9a
Remove unused error code
sam-muncke Nov 11, 2025
1c429ae
Remove unused transaction property
sam-muncke Nov 11, 2025
8adbbb1
Add some MRRT docs
sam-muncke Nov 11, 2025
6481f94
Fix reference
sam-muncke Nov 11, 2025
458ba77
Update examples/RetrievingData.md
sam-muncke Nov 11, 2025
8ce824b
Update examples/RetrievingData.md
sam-muncke Nov 11, 2025
9677456
Review fixes
sam-muncke Nov 11, 2025
8c110df
Merge branch 'FGI-1573_mrrt_support' of github.com:auth0/auth0-server…
sam-muncke Nov 11, 2025
49ae6b4
Code review fixes
sam-muncke Nov 11, 2025
34db3e8
Allow reuse of stored tokens if tokenset scopes are a superset of req…
sam-muncke Nov 12, 2025
6a4ce26
Improve scope matching logic to return the stored token with the mini…
sam-muncke Nov 12, 2025
bbabb25
Merge requested scopes and defaults scopes correctly on login
sam-muncke Nov 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions examples/RetrievingData.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://analytics.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:
Expand Down
109 changes: 91 additions & 18 deletions src/auth0_server_python/auth_server/server_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,15 @@
# 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]):
"""
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,
Expand Down Expand Up @@ -77,6 +78,7 @@ def __init__(
transaction_identifier: Identifier for transaction data
state_identifier: Identifier for state data
authorization_params: Default parameters for authorization requests
pushed_authorization_requests: Whether to use PAR for authorization requests
"""
if not secret:
raise MissingRequiredArgumentError("secret")
Expand Down Expand Up @@ -152,10 +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
app_state=options.app_state,
audience=audience,
)

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

# Build a token set using the token response data
token_set = TokenSet(
audience=token_response.get("audience", "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()) +
Expand Down Expand Up @@ -509,7 +518,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,
Expand Down Expand Up @@ -562,7 +571,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,
store_options: Optional[dict[str, Any]] = None,
audience: Optional[str] = None,
scope: Optional[str] = None,
) -> str:
"""
Retrieves the access token from the store, or calls Auth0 when the access token
is expired and a refresh token is available in the store.
Expand All @@ -579,10 +593,13 @@ async def get_access_token(self, store_options: Optional[dict[str, Any]] = None)
"""
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 {}
audience = auth_params.get("audience", "default")
scope = auth_params.get("scope")

# Get audience passed in on options or use defaults
if not audience:
audience = auth_params.get("audience", None)

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()
Expand All @@ -592,10 +609,7 @@ async def get_access_token(self, store_options: Optional[dict[str, Any]] = None)
# 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, merged_scope)

# If token is valid, return it
if token_set and token_set.get("expires_at", 0) > time.time():
Expand All @@ -610,9 +624,14 @@ 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"]
})
get_refresh_token_options = {"refresh_token": state_data_dict["refresh_token"]}
if audience:
get_refresh_token_options["audience"] = audience

if merged_scope:
get_refresh_token_options["scope"] = merged_scope

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)
Expand All @@ -631,6 +650,51 @@ async def get_access_token(self, store_options: Optional[dict[str, Any]] = None)
f"Failed to get token with refresh token: {str(e)}"
)

def _merge_scope_with_defaults(
self,
request_scope: Optional[str],
audience: Optional[str]
) -> Optional[str]:
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]
elif isinstance(auth_param_scope, str):
default_scopes = auth_param_scope

default_scopes_list = default_scopes.split()
request_scopes_list = (request_scope or "").split()

merged_scopes = list(dict.fromkeys(default_scopes_list + request_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]]:
audience = audience or self.DEFAULT_AUDIENCE_STATE_KEY
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())
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):
# consider stored tokens with more scopes than requested by number of scopes
matches.append((len(token_set_scopes), token_set))

# 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,
options: dict[str, Any],
Expand Down Expand Up @@ -1143,9 +1207,18 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str,
"client_id": self._client_id,
}

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

# 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"] = merged_scope

# Exchange the refresh token for an access token
async with httpx.AsyncClient() as client:
Expand Down
Loading