Skip to content

Commit a234cd5

Browse files
committed
Merge branch 'main' into FGI-1573_connected-account-support
1 parent 7a10284 commit a234cd5

3 files changed

Lines changed: 536 additions & 20 deletions

File tree

examples/RetrievingData.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,107 @@ access_token = await server_client.get_access_token(store_options=store_options)
7070

7171
Read more above in [Configuring the Store](./ConfigureStore.md).
7272

73+
## Multi-Resource Refresh Tokens (MRRT)
74+
75+
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.
76+
77+
Read more about [Multi-Resource Refresh Tokens in the Auth0 documentation](https://auth0.com/docs/secure/tokens/refresh-tokens/multi-resource-refresh-token).
78+
79+
80+
> [!WARNING]
81+
> 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.
82+
>
83+
> **Tokens requested for audiences outside your configured policies will be ignored by Auth0, which will return a token for the default audience instead!**
84+
85+
### Configuring Scopes Per Audience
86+
87+
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:
88+
89+
```python
90+
server_client = ServerClient(
91+
...
92+
authorization_params={
93+
"audience": "https://api.example.com", # Default audience
94+
"scope": {
95+
"https://api.example.com": "openid profile email offline_access read:products read:orders",
96+
"https://analytics.example.com": "openid profile email offline_access read:analytics write:analytics",
97+
"https://admin.example.com": "openid profile email offline_access read:admin write:admin delete:admin"
98+
}
99+
}
100+
)
101+
```
102+
103+
**How it works:**
104+
105+
- Each key in the `scope` object is an `audience` identifier
106+
- The corresponding value is the scope string for that audience
107+
- 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.
108+
109+
### Usage Example
110+
111+
To retrieve access tokens for different audiences, use the `get_access_token()` method with an `audience` (and optionally also the `scope`) parameter.
112+
113+
```python
114+
115+
server_client = ServerClient(
116+
...
117+
authorization_params={
118+
"audience": "https://api.example.com", # Default audience
119+
"scope": {
120+
"https://api.example.com": "openid email profile",
121+
"https://analytics.example.com": "read:analytics write:analytics"
122+
}
123+
}
124+
)
125+
126+
# Get token for default audience
127+
default_token = await server_client.get_access_token()
128+
# returns token for https://api.example.com with openid, email, and profile scopes
129+
130+
# Get token for different audience
131+
data_token = await server_client.get_access_token(audience="https://analytics.example.com")
132+
# returns token for https://analytics.example.com with read:analytics and write:analytics scopes
133+
134+
# Get token with additional scopes
135+
admin_token = await server_client.get_access_token(
136+
audience="https://api.example.com",
137+
scope="write:admin"
138+
)
139+
# returns token for https://api.example.com with openid, email, profile and write:admin scopes
140+
141+
```
142+
143+
### Token Management Best Practices
144+
145+
**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.
146+
147+
```python
148+
server_client = ServerClient(
149+
...
150+
authorization_params={
151+
"audience": "https://api.example.com", # Default audience
152+
# Configure broad default scopes for most common operations
153+
"scope": {
154+
"https://api.example.com": "openid profile email offline_access read:products read:orders read:users"
155+
}
156+
}
157+
)
158+
```
159+
160+
**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.
161+
162+
```python
163+
# Preferred: Use default scopes
164+
token = await server_client.get_access_token(audience="https://api.example.com")
165+
166+
167+
# Avoid unless necessary: Dynamic scopes increase session size
168+
token = await server_client.get_access_token(
169+
audience="https://api.example.com"
170+
scope="openid profile email read:products write:products admin:all"
171+
)
172+
```
173+
73174
## Retrieving an Access Token for a Connections
74175

75176
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:

src/auth0_server_python/auth_server/server_client.py

Lines changed: 90 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,15 @@
4545
# Generic type for store options
4646
TStoreOptions = TypeVar('TStoreOptions')
4747
INTERNAL_AUTHORIZE_PARAMS = ["client_id", "redirect_uri", "response_type",
48-
"code_challenge", "code_challenge_method", "state", "nonce"]
48+
"code_challenge", "code_challenge_method", "state", "nonce", "scope"]
4949

5050

5151
class ServerClient(Generic[TStoreOptions]):
5252
"""
5353
Main client for Auth0 server SDK. Handles authentication flows, session management,
5454
and token operations using Authlib for OIDC functionality.
5555
"""
56+
DEFAULT_AUDIENCE_STATE_KEY = "default"
5657

5758
def __init__(
5859
self,
@@ -159,10 +160,17 @@ async def start_interactive_login(
159160
state = PKCE.generate_random_string(32)
160161
auth_params["state"] = state
161162

163+
#merge any requested scope with defaults
164+
requested_scope = options.authorization_params.get("scope", None) if options.authorization_params else None
165+
audience = auth_params.get("audience", None)
166+
merged_scope = self._merge_scope_with_defaults(requested_scope, audience)
167+
auth_params["scope"] = merged_scope
168+
162169
# Build the transaction data to store
163170
transaction_data = TransactionData(
164171
code_verifier=code_verifier,
165-
app_state=options.app_state
172+
app_state=options.app_state,
173+
audience=audience,
166174
)
167175

168176
# Store the transaction data
@@ -297,7 +305,7 @@ async def complete_interactive_login(
297305

298306
# Build a token set using the token response data
299307
token_set = TokenSet(
300-
audience=token_response.get("audience", "default"),
308+
audience=transaction_data.audience or self.DEFAULT_AUDIENCE_STATE_KEY,
301309
access_token=token_response.get("access_token", ""),
302310
scope=token_response.get("scope", ""),
303311
expires_at=int(time.time()) +
@@ -516,7 +524,7 @@ async def login_backchannel(
516524
existing_state_data = await self._state_store.get(self._state_identifier, store_options)
517525

518526
audience = self._default_authorization_params.get(
519-
"audience", "default")
527+
"audience", self.DEFAULT_AUDIENCE_STATE_KEY)
520528

521529
state_data = State.update_state_data(
522530
audience,
@@ -569,7 +577,12 @@ async def get_session(self, store_options: Optional[dict[str, Any]] = None) -> O
569577
return session_data
570578
return None
571579

572-
async def get_access_token(self, store_options: Optional[dict[str, Any]] = None) -> str:
580+
async def get_access_token(
581+
self,
582+
store_options: Optional[dict[str, Any]] = None,
583+
audience: Optional[str] = None,
584+
scope: Optional[str] = None,
585+
) -> str:
573586
"""
574587
Retrieves the access token from the store, or calls Auth0 when the access token
575588
is expired and a refresh token is available in the store.
@@ -586,10 +599,13 @@ async def get_access_token(self, store_options: Optional[dict[str, Any]] = None)
586599
"""
587600
state_data = await self._state_store.get(self._state_identifier, store_options)
588601

589-
# Get audience and scope from options or use defaults
590602
auth_params = self._default_authorization_params or {}
591-
audience = auth_params.get("audience", "default")
592-
scope = auth_params.get("scope")
603+
604+
# Get audience passed in on options or use defaults
605+
if not audience:
606+
audience = auth_params.get("audience", None)
607+
608+
merged_scope = self._merge_scope_with_defaults(scope, audience)
593609

594610
if state_data and hasattr(state_data, "dict") and callable(state_data.dict):
595611
state_data_dict = state_data.dict()
@@ -599,10 +615,7 @@ async def get_access_token(self, store_options: Optional[dict[str, Any]] = None)
599615
# Find matching token set
600616
token_set = None
601617
if state_data_dict and "token_sets" in state_data_dict:
602-
for ts in state_data_dict["token_sets"]:
603-
if ts.get("audience") == audience and (not scope or ts.get("scope") == scope):
604-
token_set = ts
605-
break
618+
token_set = self._find_matching_token_set(state_data_dict["token_sets"], audience, merged_scope)
606619

607620
# If token is valid, return it
608621
if token_set and token_set.get("expires_at", 0) > time.time():
@@ -617,9 +630,14 @@ async def get_access_token(self, store_options: Optional[dict[str, Any]] = None)
617630

618631
# Get new token with refresh token
619632
try:
620-
token_endpoint_response = await self.get_token_by_refresh_token({
621-
"refresh_token": state_data_dict["refresh_token"]
622-
})
633+
get_refresh_token_options = {"refresh_token": state_data_dict["refresh_token"]}
634+
if audience:
635+
get_refresh_token_options["audience"] = audience
636+
637+
if merged_scope:
638+
get_refresh_token_options["scope"] = merged_scope
639+
640+
token_endpoint_response = await self.get_token_by_refresh_token(get_refresh_token_options)
623641

624642
# Update state data with new token
625643
existing_state_data = await self._state_store.get(self._state_identifier, store_options)
@@ -638,6 +656,51 @@ async def get_access_token(self, store_options: Optional[dict[str, Any]] = None)
638656
f"Failed to get token with refresh token: {str(e)}"
639657
)
640658

659+
def _merge_scope_with_defaults(
660+
self,
661+
request_scope: Optional[str],
662+
audience: Optional[str]
663+
) -> Optional[str]:
664+
audience = audience or self.DEFAULT_AUDIENCE_STATE_KEY
665+
default_scopes = ""
666+
if self._default_authorization_params and "scope" in self._default_authorization_params:
667+
auth_param_scope = self._default_authorization_params.get("scope")
668+
# For backwards compatibility, allow scope to be a single string
669+
# or dictionary by audience for MRRT
670+
if isinstance(auth_param_scope, dict) and audience in auth_param_scope:
671+
default_scopes = auth_param_scope[audience]
672+
elif isinstance(auth_param_scope, str):
673+
default_scopes = auth_param_scope
674+
675+
default_scopes_list = default_scopes.split()
676+
request_scopes_list = (request_scope or "").split()
677+
678+
merged_scopes = list(dict.fromkeys(default_scopes_list + request_scopes_list))
679+
return " ".join(merged_scopes) if merged_scopes else None
680+
681+
682+
def _find_matching_token_set(
683+
self,
684+
token_sets: list[dict[str, Any]],
685+
audience: Optional[str],
686+
scope: Optional[str]
687+
) -> Optional[dict[str, Any]]:
688+
audience = audience or self.DEFAULT_AUDIENCE_STATE_KEY
689+
requested_scopes = set(scope.split()) if scope else set()
690+
matches: list[tuple[int, dict]] = []
691+
for token_set in token_sets:
692+
token_set_audience = token_set.get("audience")
693+
token_set_scopes = set(token_set.get("scope", "").split())
694+
if token_set_audience == audience and token_set_scopes == requested_scopes:
695+
# short-circuit if exact match
696+
return token_set
697+
if token_set_audience == audience and token_set_scopes.issuperset(requested_scopes):
698+
# consider stored tokens with more scopes than requested by number of scopes
699+
matches.append((len(token_set_scopes), token_set))
700+
701+
# Return the token set with the smallest superset of scopes that matches the requested audience and scopes
702+
return min(matches, key=lambda t: t[0])[1] if matches else None
703+
641704
async def get_access_token_for_connection(
642705
self,
643706
options: dict[str, Any],
@@ -1150,9 +1213,18 @@ async def get_token_by_refresh_token(self, options: dict[str, Any]) -> dict[str,
11501213
"client_id": self._client_id,
11511214
}
11521215

1153-
# Add scope if present in the original authorization params
1154-
if "scope" in self._default_authorization_params:
1155-
token_params["scope"] = self._default_authorization_params["scope"]
1216+
audience = options.get("audience")
1217+
if audience:
1218+
token_params["audience"] = audience
1219+
1220+
# Merge scope if present in options with any in the original authorization params
1221+
merged_scope = self._merge_scope_with_defaults(
1222+
request_scope=options.get("scope"),
1223+
audience=audience
1224+
)
1225+
1226+
if merged_scope:
1227+
token_params["scope"] = merged_scope
11561228

11571229
# Exchange the refresh token for an access token
11581230
async with httpx.AsyncClient() as client:

0 commit comments

Comments
 (0)