Skip to content

Commit 2013620

Browse files
authored
Merge pull request #58 from auth0/FGI-1573_mrrt_support
feat: FGI-1573 add MRRT support
2 parents 83315b2 + bbabb25 commit 2013620

File tree

3 files changed

+541
-23
lines changed

3 files changed

+541
-23
lines changed

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: 91 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,15 @@
4040
# Generic type for store options
4141
TStoreOptions = TypeVar('TStoreOptions')
4242
INTERNAL_AUTHORIZE_PARAMS = ["client_id", "redirect_uri", "response_type",
43-
"code_challenge", "code_challenge_method", "state", "nonce"]
43+
"code_challenge", "code_challenge_method", "state", "nonce", "scope"]
4444

4545

4646
class ServerClient(Generic[TStoreOptions]):
4747
"""
4848
Main client for Auth0 server SDK. Handles authentication flows, session management,
4949
and token operations using Authlib for OIDC functionality.
5050
"""
51+
DEFAULT_AUDIENCE_STATE_KEY = "default"
5152

5253
def __init__(
5354
self,
@@ -77,6 +78,7 @@ def __init__(
7778
transaction_identifier: Identifier for transaction data
7879
state_identifier: Identifier for state data
7980
authorization_params: Default parameters for authorization requests
81+
pushed_authorization_requests: Whether to use PAR for authorization requests
8082
"""
8183
if not secret:
8284
raise MissingRequiredArgumentError("secret")
@@ -152,10 +154,17 @@ async def start_interactive_login(
152154
state = PKCE.generate_random_string(32)
153155
auth_params["state"] = state
154156

157+
#merge any requested scope with defaults
158+
requested_scope = options.authorization_params.get("scope", None) if options.authorization_params else None
159+
audience = auth_params.get("audience", None)
160+
merged_scope = self._merge_scope_with_defaults(requested_scope, audience)
161+
auth_params["scope"] = merged_scope
162+
155163
# Build the transaction data to store
156164
transaction_data = TransactionData(
157165
code_verifier=code_verifier,
158-
app_state=options.app_state
166+
app_state=options.app_state,
167+
audience=audience,
159168
)
160169

161170
# Store the transaction data
@@ -290,7 +299,7 @@ async def complete_interactive_login(
290299

291300
# Build a token set using the token response data
292301
token_set = TokenSet(
293-
audience=token_response.get("audience", "default"),
302+
audience=transaction_data.audience or self.DEFAULT_AUDIENCE_STATE_KEY,
294303
access_token=token_response.get("access_token", ""),
295304
scope=token_response.get("scope", ""),
296305
expires_at=int(time.time()) +
@@ -509,7 +518,7 @@ async def login_backchannel(
509518
existing_state_data = await self._state_store.get(self._state_identifier, store_options)
510519

511520
audience = self._default_authorization_params.get(
512-
"audience", "default")
521+
"audience", self.DEFAULT_AUDIENCE_STATE_KEY)
513522

514523
state_data = State.update_state_data(
515524
audience,
@@ -562,7 +571,12 @@ async def get_session(self, store_options: Optional[dict[str, Any]] = None) -> O
562571
return session_data
563572
return None
564573

565-
async def get_access_token(self, store_options: Optional[dict[str, Any]] = None) -> str:
574+
async def get_access_token(
575+
self,
576+
store_options: Optional[dict[str, Any]] = None,
577+
audience: Optional[str] = None,
578+
scope: Optional[str] = None,
579+
) -> str:
566580
"""
567581
Retrieves the access token from the store, or calls Auth0 when the access token
568582
is expired and a refresh token is available in the store.
@@ -579,10 +593,13 @@ async def get_access_token(self, store_options: Optional[dict[str, Any]] = None)
579593
"""
580594
state_data = await self._state_store.get(self._state_identifier, store_options)
581595

582-
# Get audience and scope from options or use defaults
583596
auth_params = self._default_authorization_params or {}
584-
audience = auth_params.get("audience", "default")
585-
scope = auth_params.get("scope")
597+
598+
# Get audience passed in on options or use defaults
599+
if not audience:
600+
audience = auth_params.get("audience", None)
601+
602+
merged_scope = self._merge_scope_with_defaults(scope, audience)
586603

587604
if state_data and hasattr(state_data, "dict") and callable(state_data.dict):
588605
state_data_dict = state_data.dict()
@@ -592,10 +609,7 @@ async def get_access_token(self, store_options: Optional[dict[str, Any]] = None)
592609
# Find matching token set
593610
token_set = None
594611
if state_data_dict and "token_sets" in state_data_dict:
595-
for ts in state_data_dict["token_sets"]:
596-
if ts.get("audience") == audience and (not scope or ts.get("scope") == scope):
597-
token_set = ts
598-
break
612+
token_set = self._find_matching_token_set(state_data_dict["token_sets"], audience, merged_scope)
599613

600614
# If token is valid, return it
601615
if token_set and token_set.get("expires_at", 0) > time.time():
@@ -610,9 +624,14 @@ async def get_access_token(self, store_options: Optional[dict[str, Any]] = None)
610624

611625
# Get new token with refresh token
612626
try:
613-
token_endpoint_response = await self.get_token_by_refresh_token({
614-
"refresh_token": state_data_dict["refresh_token"]
615-
})
627+
get_refresh_token_options = {"refresh_token": state_data_dict["refresh_token"]}
628+
if audience:
629+
get_refresh_token_options["audience"] = audience
630+
631+
if merged_scope:
632+
get_refresh_token_options["scope"] = merged_scope
633+
634+
token_endpoint_response = await self.get_token_by_refresh_token(get_refresh_token_options)
616635

617636
# Update state data with new token
618637
existing_state_data = await self._state_store.get(self._state_identifier, store_options)
@@ -631,6 +650,51 @@ async def get_access_token(self, store_options: Optional[dict[str, Any]] = None)
631650
f"Failed to get token with refresh token: {str(e)}"
632651
)
633652

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

1146-
# Add scope if present in the original authorization params
1147-
if "scope" in self._default_authorization_params:
1148-
token_params["scope"] = self._default_authorization_params["scope"]
1210+
audience = options.get("audience")
1211+
if audience:
1212+
token_params["audience"] = audience
1213+
1214+
# Merge scope if present in options with any in the original authorization params
1215+
merged_scope = self._merge_scope_with_defaults(
1216+
request_scope=options.get("scope"),
1217+
audience=audience
1218+
)
1219+
1220+
if merged_scope:
1221+
token_params["scope"] = merged_scope
11491222

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

0 commit comments

Comments
 (0)