Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
10 changes: 5 additions & 5 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
41 changes: 40 additions & 1 deletion src/auth0_fastapi/auth/auth_client.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/auth0_fastapi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
10 changes: 10 additions & 0 deletions src/auth0_fastapi/errors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
59 changes: 51 additions & 8 deletions src/auth0_fastapi/server/routes.py
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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(
Expand Down Expand Up @@ -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")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not the fault of this PR, but this SDK should have something like this https://github.com/auth0/nextjs-auth0/blob/main/EXAMPLES.md#oncallback-hook - otherwise the api response gets lost

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeh I struggled with what to do after completion. I thought this was the best option for expediency to get something functional for the GA date as there's not actually a lot thats useful in the response. This SDK is pretty bare feature wise though compared to the JS SDK. I can add something like this in in a follow up PR


# 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(
Expand Down Expand Up @@ -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:

Expand Down
48 changes: 48 additions & 0 deletions src/auth0_fastapi/test/test_auth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)