Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Overhaul FastAPI integration with better exception handling #330

Merged
merged 1 commit into from
Jul 12, 2024
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
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
Loading