diff --git a/README.md b/README.md index 29be6db..372a7b9 100644 --- a/README.md +++ b/README.md @@ -189,14 +189,20 @@ config = Auth0Config( ) ``` -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: +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: + +1. `/auth/connect`: the route that the user will be redirected to to initiate account linking +2. `/auth/callback`: will also handle the callback behaviour from the Connected Accounts flow + +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: 1. `/auth/connect`: the route that the user will be redirected to to initiate account linking 2. `/auth/connect/callback`: the callback route for account linking that must be added to your Auth0 application's Allowed Callback URLs 3. `/auth/unconnect`: the route that the user will be redirected to to initiate account linking 4. `/auth/unconnect/callback`: the callback route for account linking that must be added to your Auth0 application's Allowed Callback URLs - + These two behaviours cannot be used simultaneously. This form of account-linking is now considered legacy, use of Connected Accounts is preferred. + #### Protecting Routes In order to protect a FastAPI route, you can use the SDK's `get_session()` method and pass it through `Depends`: diff --git a/poetry.lock b/poetry.lock index b2a6ccb..f1d3b84 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -35,14 +35,14 @@ trio = ["trio (>=0.31.0)"] [[package]] name = "auth0-server-python" -version = "1.0.0b5" +version = "1.0.0b6" description = "Auth0 server-side Python SDK" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "auth0_server_python-1.0.0b5-py3-none-any.whl", hash = "sha256:23d8049870781b760b632dab522e55436e51e5817dc1c857265dd1574a0328a8"}, - {file = "auth0_server_python-1.0.0b5.tar.gz", hash = "sha256:1c20a30c9627f28209727ffc0e4013562c75b3455ae7db2400b0fc66a657cc8e"}, + {file = "auth0_server_python-1.0.0b6-py3-none-any.whl", hash = "sha256:2e59838919550d4ad643bc2332be1965ade61a8e0b95273ccaf2fc269cbd1d7f"}, + {file = "auth0_server_python-1.0.0b6.tar.gz", hash = "sha256:028dae820ecec556c91bf9015eb7ab7ce7712ee2e954560fda783a9675a3016f"}, ] [package.dependencies] @@ -973,4 +973,4 @@ standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3) [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "2e8ecdc2e20aa176a24302c51adf9e3f105af7886e076952d9e9c96326882fc9" +content-hash = "b4d8fbf1f699042e986c014a3b64078897cfb5238977bfccb85a4debe1056e8e" diff --git a/pyproject.toml b/pyproject.toml index 904a557..b0fc4d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ packages = [ [tool.poetry.dependencies] python = ">=3.9" -auth0-server-python = ">=1.0.0b5" +auth0-server-python = ">=1.0.0b6" fastapi = ">=0.115.11,<0.117.0" pydantic = "^2.12.3" diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index 33c2eea..be42a8b 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -1,7 +1,14 @@ # Imported from auth0-server-python +from typing import Optional + from auth0_server_python.auth_server.server_client import ServerClient -from auth0_server_python.auth_types import LogoutOptions, StartInteractiveLoginOptions +from auth0_server_python.auth_types import ( + CompleteConnectAccountResponse, + ConnectAccountOptions, + LogoutOptions, + StartInteractiveLoginOptions, +) from fastapi import HTTPException, Request, Response, status from auth0_fastapi.config import Auth0Config @@ -82,6 +89,38 @@ async def complete_login( """ return await self.client.complete_interactive_login(callback_url, store_options=store_options) + async def start_connect_account( + self, + connection: str, + scopes: Optional[list[str]] = None, + app_state: dict = None, + authorization_params: dict = None, + store_options: dict = None, + ) -> str: + """ + Initiates the connected account process. + Optionally, an app_state dictionary can be passed to persist additional state. + Returns the connect URL to redirect the user. + """ + options = ConnectAccountOptions( + connection=connection, + scopes=scopes, + app_state=app_state, + authorization_params=authorization_params + ) + return await self.client.start_connect_account(options=options, store_options=store_options) + + async def complete_connect_account( + self, + url: str, + store_options: dict = None, + ) -> CompleteConnectAccountResponse: + """ + Completes the connect account process using the callback URL. + Returns the completed connect account response. + """ + return await self.client.complete_connect_account(url, store_options=store_options) + async def logout( self, return_to: str = None, diff --git a/src/auth0_fastapi/config.py b/src/auth0_fastapi/config.py index 9678b00..f68eb37 100644 --- a/src/auth0_fastapi/config.py +++ b/src/auth0_fastapi/config.py @@ -18,6 +18,7 @@ class Auth0Config(BaseModel): # Route-mounting flags with desired defaults mount_routes: bool = Field(True, description="Controls /auth/* routes: login, logout, callback, backchannel-logout") mount_connect_routes: bool = Field(False, description="Controls /auth/connect routes (account-linking)") + mount_connected_account_routes: bool = Field(False, description="Controls /auth/connect-account routes (for connected accounts)") #Cookie Settings cookie_name: str = Field("_a0_session", description="Name of the cookie storing session data") session_expiration: int = Field(259200, description="Session expiration time in seconds (default: 3 days)") diff --git a/src/auth0_fastapi/errors/__init__.py b/src/auth0_fastapi/errors/__init__.py index 9924a74..ddb6355 100644 --- a/src/auth0_fastapi/errors/__init__.py +++ b/src/auth0_fastapi/errors/__init__.py @@ -12,6 +12,16 @@ from fastapi.responses import JSONResponse +class ConfigurationError(Auth0Error): + """ + Error raised when an invalid configuration is used. + """ + code = "configuration_error" + + def __init__(self, message=None): + super().__init__(message or "An invalid configuration was provided.") + self.name = "ConfigurationError" + def auth0_exception_handler(request: Request, exc: Auth0Error): """ Exception handler for Auth0 SDK errors. diff --git a/src/auth0_fastapi/server/routes.py b/src/auth0_fastapi/server/routes.py index 4d01d2d..3167ee4 100644 --- a/src/auth0_fastapi/server/routes.py +++ b/src/auth0_fastapi/server/routes.py @@ -1,10 +1,11 @@ -from typing import Optional +from typing import Annotated, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi.responses import RedirectResponse from ..auth.auth_client import AuthClient from ..config import Auth0Config +from ..errors import ConfigurationError from ..util import create_route_url, to_safe_redirect router = APIRouter() @@ -26,6 +27,13 @@ def register_auth_routes(router: APIRouter, config: Auth0Config): """ Conditionally register auth routes based on config.mount_routes and config.mount_connect_routes. """ + if config.mount_connect_routes and config.mount_connected_account_routes: + # Connect routes uses the legacy account linking flow for token vault + # Connects Accounts is the preferred mechanism + # Both mount the `/auth/connect` route to initiate the flow + raise ConfigurationError( + "'mount_connect_routes' and 'mount_connected_account_routes' cannot be used together.") + if config.mount_routes: @router.get("/auth/login") async def login( @@ -58,25 +66,35 @@ async def callback( ): """ Endpoint to handle the callback after Auth0 authentication. - Processes the callback URL and completes the login flow. + Processes the callback URL and completes the login or connected account flow. Redirects the user to a post-login URL based on appState or a default. """ full_callback_url = str(request.url) + try: - session_data = await auth_client.complete_login( - full_callback_url, - store_options={"request": request, "response": response}, - ) + if "connect_code" in request.query_params and config.mount_connected_account_routes: + connect_complete_response = await auth_client.complete_connect_account( + full_callback_url, store_options={"request": request, "response": response}) + + app_state = connect_complete_response.app_state or {} + else: + session_data = await auth_client.complete_login( + full_callback_url, store_options={"request": request, "response": response}) + + # Extract the returnTo URL from the appState if available. + app_state = session_data.get("app_state", {}) except Exception as e: raise HTTPException(status_code=400, detail=str(e)) + # Extract the returnTo URL from the appState if available. - return_to = session_data.get("app_state", {}).get("returnTo") + return_to = app_state.get("returnTo") # Assuming config is stored on app.state default_redirect = auth_client.config.app_base_url - return RedirectResponse(url=return_to or default_redirect, headers=response.headers) + safe_redirect = to_safe_redirect(return_to, default_redirect) if return_to else str(default_redirect) + return RedirectResponse(url=safe_redirect, headers=response.headers) @router.get("/auth/logout") async def logout( @@ -123,7 +141,32 @@ async def backchannel_logout( raise HTTPException(status_code=400, detail=str(e)) return Response(status_code=204) + if config.mount_connected_account_routes: + @router.get("/auth/connect") + async def connect_account( + request: Request, + response: Response, + connection: str = Query(), + scopes: Annotated[Optional[list[str]], Query()] = None, + return_to: str = Query(default=None, alias="returnTo"), + auth_client: AuthClient = Depends(get_auth_client), + ): + """ + Endpoint to initiate the connect account flow for linking a third-party account to the user's profile. + Redirects the user to the Auth0 connect account URL. + """ + authorization_params = { + k: v for k, v in request.query_params.items() if k not in ["connection", "returnTo", "scopes"]} + + connect_account_url = await auth_client.start_connect_account( + connection=connection, + scopes=scopes, + app_state={"returnTo": return_to} if return_to else None, + authorization_params=authorization_params, + store_options={"request": request, "response": response}, + ) + return RedirectResponse(url=connect_account_url, headers=response.headers) if config.mount_connect_routes: diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index 4cb3289..e7b4d5b 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest +from auth0_server_python.auth_types import CompleteConnectAccountResponse, ConnectAccountOptions from fastapi import HTTPException, Request, Response from auth0_fastapi.auth.auth_client import AuthClient @@ -392,3 +393,50 @@ async def test_store_options_validation(self, auth_client): await auth_client.start_login(store_options=valid_options) mock_start.assert_called() + + +class TestConnectedAccountFlow: + """Test connected account functionality.""" + + @pytest.mark.asyncio + async def test_start_connect_account(self, auth_client): + """Test initiating user account linking.""" + mock_connect_url = "https://test.auth0.com/connected-accounts/connect?ticket" + + with patch.object(auth_client.client, 'start_connect_account', new_callable=AsyncMock) as mock_start_connect: + mock_start_connect.return_value = mock_connect_url + + result = await auth_client.start_connect_account( + connection="google-oauth2", + scopes=["openid", "profile", "email"], + app_state={"returnTo": "/profile"}, + authorization_params={"prompt": "consent"}, + ) + + assert result == mock_connect_url + mock_start_connect.assert_called_once_with( + options=ConnectAccountOptions( + connection="google-oauth2", + app_state={"returnTo": "/profile"}, + scopes=["openid", "profile", "email"], + authorization_params={"prompt": "consent"}, + ), store_options=None) + + @pytest.mark.asyncio + async def test_complete_connect_account(self, auth_client): + """Test initiating user account linking.""" + mock_callback_url = "https://test.auth0.com/connected-accounts/connect?ticket" + mock_result = CompleteConnectAccountResponse( + id="id_12345", + connection="google-oauth2", + access_type="offline", + scopes=["read:foo"], + created_at="1970-01-01T00:00:00Z" + ) + with patch.object(auth_client.client, 'complete_connect_account', new_callable=AsyncMock) as mock_complete: + mock_complete.return_value = mock_result + + result = await auth_client.complete_connect_account(mock_callback_url) + + assert result == mock_result + mock_complete.assert_called_once_with(mock_callback_url, store_options=None)