diff --git a/README.md b/README.md index 7469111..0d84420 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![PyPI version](https://badge.fury.io/py/httpx-oauth.svg)](https://badge.fury.io/py/httpx-oauth) -[![All Contributors](https://img.shields.io/badge/all_contributors-14-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-14-orange.svg?style=flat-square)](#contributors)

diff --git a/docs/fastapi.md b/docs/fastapi.md index e04abf3..8c6994f 100644 --- a/docs/fastapi.md +++ b/docs/fastapi.md @@ -6,14 +6,6 @@ Utilities are provided to ease the integration of an OAuth2 process in [FastAPI] Dependency callable to handle the authorization callback. It reads the query parameters and returns the access token and the state. -!!! abstract "Parameters" - * `client: OAuth2`: The OAuth2 client. - * `route_name: Optional[str]`: Name of the callback route, as defined in the `name` parameter of the route decorator. - * `redirect_url: Optional[str]`: Full URL to the callback route. - -!!! tip - You should either set `route_name`, which will automatically reverse the URL, or `redirect_url`, which is an arbitrary URL you set. - ```py from fastapi import FastAPI, Depends from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallback @@ -28,3 +20,28 @@ async def oauth_callback(access_token_state=Depends(oauth2_authorize_callback)): token, state = access_token_state # Do something useful ``` + +[Reference](./reference/httpx_oauth.integrations.fastapi.md){ .md-button } +{ .buttons } + +### Custom exception handler + +If an error occurs inside the callback logic (the user denied access, the authorization code is invalid...), the dependency will raise [OAuth2AuthorizeCallbackError][httpx_oauth.integrations.fastapi.OAuth2AuthorizeCallbackError]. + +It inherits from FastAPI's [HTTPException][fastapi.HTTPException], so it's automatically handled by the default FastAPI exception handler. You can customize this behavior by implementing your own exception handler for `OAuth2AuthorizeCallbackError`. + +```py +from fastapi import FastAPI +from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallbackError + +app = FastAPI() + +@app.exception_handler(OAuth2AuthorizeCallbackError) +async def oauth2_authorize_callback_error_handler(request: Request, exc: OAuth2AuthorizeCallbackError): + detail = exc.detail + status_code = exc.status_code + return JSONResponse( + status_code=status_code, + content={"message": "The OAuth2 callback failed", "detail": detail}, + ) +``` diff --git a/docs/reference/httpx_oauth.integrations.fastapi.md b/docs/reference/httpx_oauth.integrations.fastapi.md new file mode 100644 index 0000000..4fcb2af --- /dev/null +++ b/docs/reference/httpx_oauth.integrations.fastapi.md @@ -0,0 +1,6 @@ +# Reference - Integrations - FastAPI + +::: httpx_oauth.integrations.fastapi + options: + show_root_heading: false + show_source: false diff --git a/httpx_oauth/clients/facebook.py b/httpx_oauth/clients/facebook.py index 8c4e16d..99a6942 100644 --- a/httpx_oauth/clients/facebook.py +++ b/httpx_oauth/clients/facebook.py @@ -65,7 +65,7 @@ async def get_long_lived_access_token(self, token: str) -> OAuth2Token: Raises: GetLongLivedAccessTokenError: An error occurred while requesting - the long-lived access token. + the long-lived access token. Examples: ```py diff --git a/httpx_oauth/clients/github.py b/httpx_oauth/clients/github.py index a5030ca..8e4a733 100644 --- a/httpx_oauth/clients/github.py +++ b/httpx_oauth/clients/github.py @@ -58,7 +58,7 @@ def __init__( token_endpoint_auth_method="client_secret_post", ) - async def refresh_token(self, refresh_token: str): + async def refresh_token(self, refresh_token: str) -> OAuth2Token: """ Requests a new access token using a refresh token. diff --git a/httpx_oauth/clients/google.py b/httpx_oauth/clients/google.py index 79e1dbf..ea6810a 100644 --- a/httpx_oauth/clients/google.py +++ b/httpx_oauth/clients/google.py @@ -43,7 +43,7 @@ def __init__( client_id: str, client_secret: str, scopes: Optional[List[str]] = BASE_SCOPES, - name="google", + name: str = "google", ): """ Args: diff --git a/httpx_oauth/integrations/fastapi.py b/httpx_oauth/integrations/fastapi.py index 1cf55eb..98287d0 100644 --- a/httpx_oauth/integrations/fastapi.py +++ b/httpx_oauth/integrations/fastapi.py @@ -1,13 +1,54 @@ -from typing import Optional, Tuple +from typing import Any, Dict, Optional, Tuple, Union +import httpx from fastapi import HTTPException from starlette import status from starlette.requests import Request -from httpx_oauth.oauth2 import BaseOAuth2, OAuth2Token +from httpx_oauth.oauth2 import BaseOAuth2, GetAccessTokenError, OAuth2Error, OAuth2Token + + +class OAuth2AuthorizeCallbackError(HTTPException, OAuth2Error): + """ + Error raised when an error occurs during the OAuth2 authorization callback. + + It inherits from [HTTPException][fastapi.HTTPException], so you can either keep + the default FastAPI error handling or implement a + [dedicated exception handler](https://fastapi.tiangolo.com/tutorial/handling-errors/#install-custom-exception-handlers). + """ + + def __init__( + self, + status_code: int, + detail: Any = None, + headers: Union[Dict[str, str], None] = None, + response: Union[httpx.Response, None] = None, + ) -> None: + self.response = response + super().__init__(status_code, detail, headers) class OAuth2AuthorizeCallback: + """ + Dependency callable to handle the authorization callback. It reads the query parameters and returns the access token and the state. + + Examples: + ```py + from fastapi import FastAPI, Depends + from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallback + from httpx_oauth.oauth2 import OAuth2 + + client = OAuth2("CLIENT_ID", "CLIENT_SECRET", "AUTHORIZE_ENDPOINT", "ACCESS_TOKEN_ENDPOINT") + oauth2_authorize_callback = OAuth2AuthorizeCallback(client, "oauth-callback") + app = FastAPI() + + @app.get("/oauth-callback", name="oauth-callback") + async def oauth_callback(access_token_state=Depends(oauth2_authorize_callback)): + token, state = access_token_state + # Do something useful + ``` + """ + client: BaseOAuth2 route_name: Optional[str] redirect_url: Optional[str] @@ -18,6 +59,12 @@ def __init__( route_name: Optional[str] = None, redirect_url: Optional[str] = None, ): + """ + Args: + client: An [OAuth2][httpx_oauth.oauth2.BaseOAuth2] client. + route_name: Name of the callback route, as defined in the `name` parameter of the route decorator. + redirect_url: Full URL to the callback route. + """ assert (route_name is not None and redirect_url is None) or ( route_name is None and redirect_url is not None ), "You should either set route_name or redirect_url" @@ -34,7 +81,7 @@ async def __call__( error: Optional[str] = None, ) -> Tuple[OAuth2Token, Optional[str]]: if code is None or error is not None: - raise HTTPException( + raise OAuth2AuthorizeCallbackError( status_code=status.HTTP_400_BAD_REQUEST, detail=error if error is not None else None, ) @@ -44,8 +91,15 @@ async def __call__( elif self.redirect_url: redirect_url = self.redirect_url - access_token = await self.client.get_access_token( - code, redirect_url, code_verifier - ) + try: + access_token = await self.client.get_access_token( + code, redirect_url, code_verifier + ) + except GetAccessTokenError as e: + raise OAuth2AuthorizeCallbackError( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=e.message, + response=e.response, + ) from e return access_token, state diff --git a/httpx_oauth/oauth2.py b/httpx_oauth/oauth2.py index 211ccdd..b4d9e7b 100644 --- a/httpx_oauth/oauth2.py +++ b/httpx_oauth/oauth2.py @@ -172,14 +172,14 @@ def __init__( authorize_endpoint: The authorization endpoint URL. access_token_endpoint: The access token endpoint URL. refresh_token_endpoint: The refresh token endpoint URL. - If not supported, set it to `None`. + If not supported, set it to `None`. revoke_token_endpoint: The revoke token endpoint URL. - If not supported, set it to `None`. + If not supported, set it to `None`. name: A unique name for the OAuth2 client. base_scopes: The base scopes to be used in the authorization URL. token_endpoint_auth_method: The authentication method to be used in the token endpoint. revocation_endpoint_auth_method: The authentication method to be used in the revocation endpoint. - If the revocation endpoint is not supported, set it to `None`. + If the revocation endpoint is not supported, set it to `None`. Raises: NotSupportedAuthMethodError: @@ -227,13 +227,13 @@ async def get_authorization_url( Args: redirect_uri: The URL where the user will be redirected after authorization. state: An opaque value used by the client to maintain state - between the request and the callback. + between the request and the callback. scope: The scopes to be requested. If not provided, `base_scopes` will be used. code_challenge: Optional - [PKCE]((https://datatracker.ietf.org/doc/html/rfc7636)) code challenge. + [PKCE](https://datatracker.ietf.org/doc/html/rfc7636)) code challenge. code_challenge_method: Optional - [PKCE]((https://datatracker.ietf.org/doc/html/rfc7636)) code challenge + [PKCE](https://datatracker.ietf.org/doc/html/rfc7636)) code challenge method. extras_params: Optional extra parameters specific to the service. @@ -283,7 +283,7 @@ async def get_access_token( code: The authorization code. redirect_uri: The URL where the user was redirected after authorization. code_verifier: Optional code verifier used - in the [PKCE]((https://datatracker.ietf.org/doc/html/rfc7636)) flow. + in the [PKCE](https://datatracker.ietf.org/doc/html/rfc7636)) flow. Returns: An access token response dictionary. diff --git a/mkdocs.yml b/mkdocs.yml index 49d8e5a..1449df7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -60,6 +60,7 @@ plugins: python: import: - https://docs.python.org/3.8/objects.inv + - https://fastapi.tiangolo.com/objects.inv options: docstring_style: google extensions: @@ -77,4 +78,5 @@ nav: - Reference: - httpx_oauth.clients: reference/httpx_oauth.clients.md - httpx_oauth.oauth2: reference/httpx_oauth.oauth2.md + - httpx_oauth.integrations.fastapi: reference/httpx_oauth.integrations.fastapi.md - httpx_oauth.exceptions: reference/httpx_oauth.exceptions.md diff --git a/tests/test_integrations_fastapi.py b/tests/test_integrations_fastapi.py index 464f226..55a598c 100644 --- a/tests/test_integrations_fastapi.py +++ b/tests/test_integrations_fastapi.py @@ -1,10 +1,11 @@ import pytest from fastapi import Depends, FastAPI +from pytest_mock import MockerFixture from starlette import status from starlette.testclient import TestClient from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallback -from httpx_oauth.oauth2 import OAuth2 +from httpx_oauth.oauth2 import GetAccessTokenError, OAuth2 CLIENT_ID = "CLIENT_ID" CLIENT_SECRET = "CLIENT_SECRET" @@ -62,6 +63,21 @@ def test_oauth2_authorize_error(self, route, expected_redirect_url): assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json() == {"detail": "access_denied"} + def test_oauth2_authorize_get_access_token_error( + self, mocker: MockerFixture, route, expected_redirect_url + ): + get_access_token_mock = mocker.patch.object( + client, "get_access_token", side_effect=GetAccessTokenError("ERROR") + ) + + response = test_client.get(route, params={"code": "CODE"}) + + get_access_token_mock.assert_called_once_with( + "CODE", expected_redirect_url, None + ) + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert response.json() == {"detail": "ERROR"} + def test_oauth2_authorize_without_state( self, patch_async_method, route, expected_redirect_url ):