Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 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
d295e93
Revert MRRT related changes
sam-muncke Nov 11, 2025
39e0df7
Fix docs and scope/scopes when passing to connected accounts
sam-muncke Nov 12, 2025
48f5b88
Fix test
sam-muncke Nov 12, 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
109 changes: 109 additions & 0 deletions examples/ConnectedAccounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# 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. In order to use this feature, [My Account API](https://auth0.com/docs/manage-users/my-account-api) must be activated on your Auth0 tenant.

>[!NOTE]
>DPoP sender token constraining is not yet supported in this SDK. My Account API can be configured to support it (default behaviour) but must not be configured to require it.


When using Connected Accounts, Auth0 acquires tokens from upstream Identity Providers (like Google) and stores them in a secure [Token Vault](https://auth0.com/docs/secure/tokens/token-vault). These tokens can then be used to access third-party APIs (like Google Calendar) on behalf of the user.

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"
}
scopes= [
# scopes to passed to the third-party IdP
"openid",
"email",
"profile"
"offline_access"
]
authorization_params= {
# additional auth parameters to be sent to the third-party IdP e.g.
"login_hint": "user123",
"resource": "some_resource"
}
),
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.
3 changes: 3 additions & 0 deletions src/auth0_server_python/auth_schemes/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .bearer_auth import BearerAuth

__all__ = ["BearerAuth"]
10 changes: 10 additions & 0 deletions src/auth0_server_python/auth_schemes/bearer_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +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
3 changes: 2 additions & 1 deletion src/auth0_server_python/auth_server/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .my_account_client import MyAccountClient
from .server_client import ServerClient

__all__ = ["ServerClient"]
__all__ = ["ServerClient", "MyAccountClient"]
94 changes: 94 additions & 0 deletions src/auth0_server_python/auth_server/my_account_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@

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
)
141 changes: 140 additions & 1 deletion src/auth0_server_python/auth_server/server_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@
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
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,
Expand Down Expand Up @@ -101,6 +106,8 @@ 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:
Expand Down Expand Up @@ -1260,3 +1267,135 @@ 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,
scopes=options.scopes,
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
Loading