Skip to content

Commit 160ad7f

Browse files
authored
feature: FGI-1574 add connected account support (#67)
* Basic support for connected accounts/mrrt * Fix linting issues * Pass returnUrl in app_state * Fix lint issues * Add some authclient tests * Code review fixes * Remove use_mrrt config value * Mount connected account start flow on the auth/connect route and make mutually exclusive with legacy connect behaviour * Name scopes param consistantly * Correctly filter out scopes param rather than scope param * Alias the returnTo parameter * Update auth0-server-python package * Update README.md * Fix lint issues * Fix type issue on scopes array * Fix liniting * Fix type issues
1 parent e84e8d2 commit 160ad7f

File tree

8 files changed

+164
-17
lines changed

8 files changed

+164
-17
lines changed

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,14 +189,20 @@ config = Auth0Config(
189189
)
190190
```
191191

192-
Additionally, by setting `mount_connect_routes` to `True` (it's `False` by default) the SDK also can also mount 4 routes useful for account-linking:
192+
Additionally, by setting `mount_connected_account_routes` to `True` (it's `False` by default) the SDK also can also mount routes useful for using Token Vault with Connected Accounts:
193+
194+
1. `/auth/connect`: the route that the user will be redirected to to initiate account linking
195+
2. `/auth/callback`: will also handle the callback behaviour from the Connected Accounts flow
196+
197+
Alternatively, by setting `mount_connect_routes` to `True` (it's `False` by default) the SDK also can also mount 4 routes useful for account-linking:
193198

194199
1. `/auth/connect`: the route that the user will be redirected to to initiate account linking
195200
2. `/auth/connect/callback`: the callback route for account linking that must be added to your Auth0 application's Allowed Callback URLs
196201
3. `/auth/unconnect`: the route that the user will be redirected to to initiate account linking
197202
4. `/auth/unconnect/callback`: the callback route for account linking that must be added to your Auth0 application's Allowed Callback URLs
198203

199-
204+
These two behaviours cannot be used simultaneously. This form of account-linking is now considered legacy, use of Connected Accounts is preferred.
205+
200206
#### Protecting Routes
201207

202208
In order to protect a FastAPI route, you can use the SDK's `get_session()` method and pass it through `Depends`:

poetry.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ packages = [
1212

1313
[tool.poetry.dependencies]
1414
python = ">=3.9"
15-
auth0-server-python = ">=1.0.0b5"
15+
auth0-server-python = ">=1.0.0b6"
1616
fastapi = ">=0.115.11,<0.117.0"
1717
pydantic = "^2.12.3"
1818

src/auth0_fastapi/auth/auth_client.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11

22
# Imported from auth0-server-python
3+
from typing import Optional
4+
35
from auth0_server_python.auth_server.server_client import ServerClient
4-
from auth0_server_python.auth_types import LogoutOptions, StartInteractiveLoginOptions
6+
from auth0_server_python.auth_types import (
7+
CompleteConnectAccountResponse,
8+
ConnectAccountOptions,
9+
LogoutOptions,
10+
StartInteractiveLoginOptions,
11+
)
512
from fastapi import HTTPException, Request, Response, status
613

714
from auth0_fastapi.config import Auth0Config
@@ -82,6 +89,38 @@ async def complete_login(
8289
"""
8390
return await self.client.complete_interactive_login(callback_url, store_options=store_options)
8491

92+
async def start_connect_account(
93+
self,
94+
connection: str,
95+
scopes: Optional[list[str]] = None,
96+
app_state: dict = None,
97+
authorization_params: dict = None,
98+
store_options: dict = None,
99+
) -> str:
100+
"""
101+
Initiates the connected account process.
102+
Optionally, an app_state dictionary can be passed to persist additional state.
103+
Returns the connect URL to redirect the user.
104+
"""
105+
options = ConnectAccountOptions(
106+
connection=connection,
107+
scopes=scopes,
108+
app_state=app_state,
109+
authorization_params=authorization_params
110+
)
111+
return await self.client.start_connect_account(options=options, store_options=store_options)
112+
113+
async def complete_connect_account(
114+
self,
115+
url: str,
116+
store_options: dict = None,
117+
) -> CompleteConnectAccountResponse:
118+
"""
119+
Completes the connect account process using the callback URL.
120+
Returns the completed connect account response.
121+
"""
122+
return await self.client.complete_connect_account(url, store_options=store_options)
123+
85124
async def logout(
86125
self,
87126
return_to: str = None,

src/auth0_fastapi/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class Auth0Config(BaseModel):
1818
# Route-mounting flags with desired defaults
1919
mount_routes: bool = Field(True, description="Controls /auth/* routes: login, logout, callback, backchannel-logout")
2020
mount_connect_routes: bool = Field(False, description="Controls /auth/connect routes (account-linking)")
21+
mount_connected_account_routes: bool = Field(False, description="Controls /auth/connect-account routes (for connected accounts)")
2122
#Cookie Settings
2223
cookie_name: str = Field("_a0_session", description="Name of the cookie storing session data")
2324
session_expiration: int = Field(259200, description="Session expiration time in seconds (default: 3 days)")

src/auth0_fastapi/errors/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@
1212
from fastapi.responses import JSONResponse
1313

1414

15+
class ConfigurationError(Auth0Error):
16+
"""
17+
Error raised when an invalid configuration is used.
18+
"""
19+
code = "configuration_error"
20+
21+
def __init__(self, message=None):
22+
super().__init__(message or "An invalid configuration was provided.")
23+
self.name = "ConfigurationError"
24+
1525
def auth0_exception_handler(request: Request, exc: Auth0Error):
1626
"""
1727
Exception handler for Auth0 SDK errors.

src/auth0_fastapi/server/routes.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
from typing import Optional
1+
from typing import Annotated, Optional
22

33
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
44
from fastapi.responses import RedirectResponse
55

66
from ..auth.auth_client import AuthClient
77
from ..config import Auth0Config
8+
from ..errors import ConfigurationError
89
from ..util import create_route_url, to_safe_redirect
910

1011
router = APIRouter()
@@ -26,6 +27,13 @@ def register_auth_routes(router: APIRouter, config: Auth0Config):
2627
"""
2728
Conditionally register auth routes based on config.mount_routes and config.mount_connect_routes.
2829
"""
30+
if config.mount_connect_routes and config.mount_connected_account_routes:
31+
# Connect routes uses the legacy account linking flow for token vault
32+
# Connects Accounts is the preferred mechanism
33+
# Both mount the `/auth/connect` route to initiate the flow
34+
raise ConfigurationError(
35+
"'mount_connect_routes' and 'mount_connected_account_routes' cannot be used together.")
36+
2937
if config.mount_routes:
3038
@router.get("/auth/login")
3139
async def login(
@@ -58,25 +66,35 @@ async def callback(
5866
):
5967
"""
6068
Endpoint to handle the callback after Auth0 authentication.
61-
Processes the callback URL and completes the login flow.
69+
Processes the callback URL and completes the login or connected account flow.
6270
Redirects the user to a post-login URL based on appState or a default.
6371
"""
6472
full_callback_url = str(request.url)
73+
6574
try:
66-
session_data = await auth_client.complete_login(
67-
full_callback_url,
68-
store_options={"request": request, "response": response},
69-
)
75+
if "connect_code" in request.query_params and config.mount_connected_account_routes:
76+
connect_complete_response = await auth_client.complete_connect_account(
77+
full_callback_url, store_options={"request": request, "response": response})
78+
79+
app_state = connect_complete_response.app_state or {}
80+
else:
81+
session_data = await auth_client.complete_login(
82+
full_callback_url, store_options={"request": request, "response": response})
83+
84+
# Extract the returnTo URL from the appState if available.
85+
app_state = session_data.get("app_state", {})
7086
except Exception as e:
7187
raise HTTPException(status_code=400, detail=str(e))
7288

89+
7390
# Extract the returnTo URL from the appState if available.
74-
return_to = session_data.get("app_state", {}).get("returnTo")
91+
return_to = app_state.get("returnTo")
7592

7693
# Assuming config is stored on app.state
7794
default_redirect = auth_client.config.app_base_url
7895

79-
return RedirectResponse(url=return_to or default_redirect, headers=response.headers)
96+
safe_redirect = to_safe_redirect(return_to, default_redirect) if return_to else str(default_redirect)
97+
return RedirectResponse(url=safe_redirect, headers=response.headers)
8098

8199
@router.get("/auth/logout")
82100
async def logout(
@@ -123,7 +141,32 @@ async def backchannel_logout(
123141
raise HTTPException(status_code=400, detail=str(e))
124142
return Response(status_code=204)
125143

144+
if config.mount_connected_account_routes:
145+
@router.get("/auth/connect")
146+
async def connect_account(
147+
request: Request,
148+
response: Response,
149+
connection: str = Query(),
150+
scopes: Annotated[Optional[list[str]], Query()] = None,
151+
return_to: str = Query(default=None, alias="returnTo"),
152+
auth_client: AuthClient = Depends(get_auth_client),
153+
):
154+
"""
155+
Endpoint to initiate the connect account flow for linking a third-party account to the user's profile.
156+
Redirects the user to the Auth0 connect account URL.
157+
"""
158+
authorization_params = {
159+
k: v for k, v in request.query_params.items() if k not in ["connection", "returnTo", "scopes"]}
160+
161+
connect_account_url = await auth_client.start_connect_account(
162+
connection=connection,
163+
scopes=scopes,
164+
app_state={"returnTo": return_to} if return_to else None,
165+
authorization_params=authorization_params,
166+
store_options={"request": request, "response": response},
167+
)
126168

169+
return RedirectResponse(url=connect_account_url, headers=response.headers)
127170

128171
if config.mount_connect_routes:
129172

src/auth0_fastapi/test/test_auth_client.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from unittest.mock import AsyncMock, Mock, patch
33

44
import pytest
5+
from auth0_server_python.auth_types import CompleteConnectAccountResponse, ConnectAccountOptions
56
from fastapi import HTTPException, Request, Response
67

78
from auth0_fastapi.auth.auth_client import AuthClient
@@ -392,3 +393,50 @@ async def test_store_options_validation(self, auth_client):
392393
await auth_client.start_login(store_options=valid_options)
393394

394395
mock_start.assert_called()
396+
397+
398+
class TestConnectedAccountFlow:
399+
"""Test connected account functionality."""
400+
401+
@pytest.mark.asyncio
402+
async def test_start_connect_account(self, auth_client):
403+
"""Test initiating user account linking."""
404+
mock_connect_url = "https://test.auth0.com/connected-accounts/connect?ticket"
405+
406+
with patch.object(auth_client.client, 'start_connect_account', new_callable=AsyncMock) as mock_start_connect:
407+
mock_start_connect.return_value = mock_connect_url
408+
409+
result = await auth_client.start_connect_account(
410+
connection="google-oauth2",
411+
scopes=["openid", "profile", "email"],
412+
app_state={"returnTo": "/profile"},
413+
authorization_params={"prompt": "consent"},
414+
)
415+
416+
assert result == mock_connect_url
417+
mock_start_connect.assert_called_once_with(
418+
options=ConnectAccountOptions(
419+
connection="google-oauth2",
420+
app_state={"returnTo": "/profile"},
421+
scopes=["openid", "profile", "email"],
422+
authorization_params={"prompt": "consent"},
423+
), store_options=None)
424+
425+
@pytest.mark.asyncio
426+
async def test_complete_connect_account(self, auth_client):
427+
"""Test initiating user account linking."""
428+
mock_callback_url = "https://test.auth0.com/connected-accounts/connect?ticket"
429+
mock_result = CompleteConnectAccountResponse(
430+
id="id_12345",
431+
connection="google-oauth2",
432+
access_type="offline",
433+
scopes=["read:foo"],
434+
created_at="1970-01-01T00:00:00Z"
435+
)
436+
with patch.object(auth_client.client, 'complete_connect_account', new_callable=AsyncMock) as mock_complete:
437+
mock_complete.return_value = mock_result
438+
439+
result = await auth_client.complete_connect_account(mock_callback_url)
440+
441+
assert result == mock_result
442+
mock_complete.assert_called_once_with(mock_callback_url, store_options=None)

0 commit comments

Comments
 (0)