Skip to content

Commit

Permalink
Overhaul FastAPI integration with better exception handling
Browse files Browse the repository at this point in the history
  • Loading branch information
frankie567 committed Jul 12, 2024
1 parent 86a239e commit b81d463
Show file tree
Hide file tree
Showing 10 changed files with 121 additions and 26 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
[![PyPI version](https://badge.fury.io/py/httpx-oauth.svg)](https://badge.fury.io/py/httpx-oauth)

<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![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)
<!-- ALL-CONTRIBUTORS-BADGE:END -->

<p align="center">
Expand Down
33 changes: 25 additions & 8 deletions docs/fastapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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},
)
```
6 changes: 6 additions & 0 deletions docs/reference/httpx_oauth.integrations.fastapi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Reference - Integrations - FastAPI

::: httpx_oauth.integrations.fastapi
options:
show_root_heading: false
show_source: false
2 changes: 1 addition & 1 deletion httpx_oauth/clients/facebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion httpx_oauth/clients/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion httpx_oauth/clients/google.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def __init__(
client_id: str,
client_secret: str,
scopes: Optional[List[str]] = BASE_SCOPES,
name="google",
name: str = "google",
):
"""
Args:
Expand Down
66 changes: 60 additions & 6 deletions httpx_oauth/integrations/fastapi.py
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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"
Expand All @@ -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,
)
Expand All @@ -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
14 changes: 7 additions & 7 deletions httpx_oauth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
18 changes: 17 additions & 1 deletion tests/test_integrations_fastapi.py
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
):
Expand Down

0 comments on commit b81d463

Please sign in to comment.