Skip to content

Commit 3b34213

Browse files
committed
Add oauth for Google and Microsoft
1 parent 6a043ee commit 3b34213

File tree

2 files changed

+130
-2
lines changed

2 files changed

+130
-2
lines changed

src/app/api/v1/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .health import router as health_router
44
from .login import router as login_router
55
from .logout import router as logout_router
6+
from .oauth import router as oauth_router
67
from .posts import router as posts_router
78
from .rate_limits import router as rate_limits_router
89
from .tasks import router as tasks_router
@@ -13,8 +14,9 @@
1314
router.include_router(health_router)
1415
router.include_router(login_router)
1516
router.include_router(logout_router)
16-
router.include_router(users_router)
17+
router.include_router(oauth_router)
1718
router.include_router(posts_router)
19+
router.include_router(rate_limits_router)
1820
router.include_router(tasks_router)
1921
router.include_router(tiers_router)
20-
router.include_router(rate_limits_router)
22+
router.include_router(users_router)

src/app/api/v1/oauth.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import secrets
2+
from abc import ABC
3+
from typing import Any
4+
5+
from fastapi import APIRouter, Depends, Request, Response
6+
from fastapi_sso.sso.base import OpenID, SSOBase
7+
from fastapi_sso.sso.google import GoogleSSO
8+
from fastapi_sso.sso.microsoft import MicrosoftSSO
9+
from sqlalchemy.ext.asyncio import AsyncSession
10+
11+
from ...core.config import settings
12+
from ...core.db.database import async_get_db
13+
from ...core.exceptions.http_exceptions import UnauthorizedException
14+
from ...core.security import (
15+
create_access_token,
16+
create_refresh_token,
17+
)
18+
from ...crud.crud_users import crud_users
19+
from ...schemas.user import UserCreate, UserRead
20+
from .users import write_user
21+
22+
router = APIRouter(tags=["login", "oauth"])
23+
24+
25+
class BaseOAuthProvider(ABC):
26+
provider_config: dict[str, Any]
27+
sso_provider: type[SSOBase]
28+
29+
def __init__(self, router: Any):
30+
self.router = router
31+
self.provider_name: str = self.sso_provider.provider
32+
if self.is_enabled:
33+
self.sso = self.sso_provider(redirect_uri=self.redirect_uri, **self.provider_config)
34+
tag = f"{self.sso_provider.provider.title()} OAuth"
35+
self.router.add_api_route(
36+
f"/login/{self.provider_name}",
37+
self._login_handler,
38+
methods=["GET"],
39+
tags=[tag],
40+
summary=f"Login with {self.provider_name.title()} OAuth",
41+
)
42+
self.router.add_api_route(
43+
f"/callback/{self.provider_name}",
44+
self._callback_handler,
45+
methods=["GET"],
46+
tags=[tag],
47+
summary=f"Callback for {self.provider_name.title()} OAuth",
48+
)
49+
50+
@property
51+
def redirect_uri(self) -> str:
52+
return f"{settings.APP_BACKEND_HOST}/api/v1/callback/{self.provider_name}"
53+
54+
@property
55+
def is_enabled(self) -> bool:
56+
return all(self.provider_config.values())
57+
58+
async def _create_and_set_token(self, response: Response, user: dict[str, Any]) -> str:
59+
access_token = await create_access_token(data={"sub": user["username"]})
60+
refresh_token = await create_refresh_token(data={"sub": user["username"]})
61+
max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
62+
response.set_cookie(
63+
key="refresh_token", value=refresh_token, httponly=True, secure=True, samesite="lax", max_age=max_age
64+
)
65+
return access_token
66+
67+
async def _login_handler(self):
68+
async with self.sso:
69+
return await self.sso.get_login_redirect()
70+
71+
async def _callback_handler(self, request: Request, response: Response, db: AsyncSession = Depends(async_get_db)):
72+
async with self.sso:
73+
oauth_user: OpenID | None = await self.sso.verify_and_process(request)
74+
if not oauth_user or not oauth_user.email:
75+
raise UnauthorizedException(f"Invalid response from {self.provider_name.title()} OAuth.")
76+
77+
db_user = await crud_users.get(db=db, email=oauth_user.email, is_deleted=False, schema_to_select=UserRead)
78+
if not db_user:
79+
user_create = await self._get_user_details(oauth_user)
80+
db_user = await write_user(request=request, user=user_create, db=db)
81+
82+
access_token = await self._create_and_set_token(response, db_user)
83+
return {"access_token": access_token, "token_type": "bearer"}
84+
85+
async def _get_user_details(self, oauth_user: OpenID) -> UserCreate:
86+
"""Get user details from the OAuth provider response.
87+
88+
The exact details exposed by the OpenID class can be found here:
89+
https://github.com/tomasvotava/fastapi-sso/blob/master/fastapi_sso/sso/base.py#L64
90+
"""
91+
if not oauth_user.email:
92+
raise UnauthorizedException(f"Invalid response from {self.provider_name.title()} OAuth.")
93+
username = oauth_user.email.split("@")[0]
94+
name = oauth_user.display_name or username
95+
96+
# Create a random password for OAuth users.
97+
# It can still be changed if the user requests login with password.
98+
random_password = secrets.token_urlsafe(32)
99+
return UserCreate(
100+
email=oauth_user.email,
101+
name=name,
102+
password=random_password,
103+
username=username,
104+
)
105+
106+
107+
class GoogleOAuthProvider(BaseOAuthProvider):
108+
sso_provider = GoogleSSO
109+
provider_config = {
110+
"client_id": settings.GOOGLE_CLIENT_ID,
111+
"client_secret": settings.GOOGLE_CLIENT_SECRET,
112+
}
113+
114+
115+
# TODO: There is a bug in fastapi-sso, it does not return the email address
116+
class MicrosoftOAuthProvider(BaseOAuthProvider):
117+
sso_provider = MicrosoftSSO
118+
provider_config = {
119+
"client_id": settings.MICROSOFT_CLIENT_ID,
120+
"client_secret": settings.MICROSOFT_CLIENT_SECRET,
121+
"tenant": settings.MICROSOFT_TENANT,
122+
}
123+
124+
125+
GoogleOAuthProvider(router)
126+
MicrosoftOAuthProvider(router)

0 commit comments

Comments
 (0)