Skip to content

Commit 8fdce98

Browse files
authored
Merge pull request #229 from rragundez/oauth
Enable login via oauth
2 parents 53d7a83 + bf5178d commit 8fdce98

14 files changed

Lines changed: 567 additions & 47 deletions

File tree

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ nav:
7575
- Authentication:
7676
- Overview: user-guide/authentication/index.md
7777
- JWT Tokens: user-guide/authentication/jwt-tokens.md
78+
- OAuth: user-guide/authentication/oauth.md
7879
- User Management: user-guide/authentication/user-management.md
7980
- Permissions: user-guide/authentication/permissions.md
8081
- Admin Panel:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ dependencies = [
3333
"gunicorn>=23.0.0",
3434
"ruff>=0.11.13",
3535
"mypy>=1.16.0",
36+
"fastapi-sso>=0.18.0",
3637
]
3738

3839
[project.optional-dependencies]

scripts/local_with_uvicorn/.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,13 @@ ENVIRONMENT="local"
7272

7373
# ------------- first tier -------------
7474
TIER_NAME="free"
75+
76+
# ------------- auth settings -------------
77+
# ENABLE_PASSWORD_AUTH=true
78+
# GOOGLE_CLIENT_ID=
79+
# GOOGLE_CLIENT_SECRET=
80+
# MICROSOFT_CLIENT_ID=
81+
# MICROSOFT_CLIENT_SECRET=
82+
# MICROSOFT_TENANT=
83+
# GITHUB_CLIENT_ID=
84+
# GITHUB_CLIENT_SECRET=

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/login.py

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from datetime import timedelta
21
from typing import Annotated
32

43
from fastapi import APIRouter, Depends, Request, Response
@@ -10,7 +9,6 @@
109
from ...core.exceptions.http_exceptions import UnauthorizedException
1110
from ...core.schemas import Token
1211
from ...core.security import (
13-
ACCESS_TOKEN_EXPIRE_MINUTES,
1412
TokenType,
1513
authenticate_user,
1614
create_access_token,
@@ -21,27 +19,25 @@
2119
router = APIRouter(tags=["login"])
2220

2321

24-
@router.post("/login", response_model=Token)
25-
async def login_for_access_token(
26-
response: Response,
27-
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
28-
db: Annotated[AsyncSession, Depends(async_get_db)],
29-
) -> dict[str, str]:
30-
user = await authenticate_user(username_or_email=form_data.username, password=form_data.password, db=db)
31-
if not user:
32-
raise UnauthorizedException("Wrong username, email or password.")
33-
34-
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
35-
access_token = await create_access_token(data={"sub": user["username"]}, expires_delta=access_token_expires)
36-
37-
refresh_token = await create_refresh_token(data={"sub": user["username"]})
38-
max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
39-
40-
response.set_cookie(
41-
key="refresh_token", value=refresh_token, httponly=True, secure=True, samesite="lax", max_age=max_age
42-
)
43-
44-
return {"access_token": access_token, "token_type": "bearer"}
22+
if settings.ENABLE_PASSWORD_AUTH:
23+
24+
@router.post("/login", response_model=Token)
25+
async def login_with_password(
26+
response: Response,
27+
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
28+
db: Annotated[AsyncSession, Depends(async_get_db)],
29+
) -> dict[str, str]:
30+
user = await authenticate_user(username_or_email=form_data.username, password=form_data.password, db=db)
31+
if not user:
32+
raise UnauthorizedException("Wrong username, email or password.")
33+
34+
access_token = await create_access_token(data={"sub": user["username"]})
35+
refresh_token = await create_refresh_token(data={"sub": user["username"]})
36+
max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
37+
response.set_cookie(
38+
key="refresh_token", value=refresh_token, httponly=True, secure=True, samesite="lax", max_age=max_age
39+
)
40+
return {"access_token": access_token, "token_type": "bearer"}
4541

4642

4743
@router.post("/refresh")

src/app/api/v1/oauth.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import logging
2+
from abc import ABC
3+
from typing import Any
4+
5+
from fastapi import APIRouter, Depends, Request, Response
6+
from fastapi.responses import RedirectResponse
7+
from fastapi_sso.sso.base import OpenID, SSOBase
8+
from fastapi_sso.sso.github import GithubSSO
9+
from fastapi_sso.sso.google import GoogleSSO
10+
from fastapi_sso.sso.microsoft import MicrosoftSSO
11+
from sqlalchemy.ext.asyncio import AsyncSession
12+
13+
from ...core.config import settings
14+
from ...core.db.database import async_get_db
15+
from ...core.exceptions.http_exceptions import UnauthorizedException
16+
from ...core.security import (
17+
create_access_token,
18+
create_refresh_token,
19+
)
20+
from ...crud.crud_users import crud_users
21+
from ...schemas.user import UserCreateInternal, UserRead
22+
from .users import write_user_internal
23+
24+
router = APIRouter(tags=["login", "oauth"])
25+
logger = logging.getLogger(__name__)
26+
27+
28+
class BaseOAuthProvider(ABC):
29+
provider_config: dict[str, Any]
30+
sso_provider: type[SSOBase]
31+
32+
def __init__(self, router: Any):
33+
self.router = router
34+
self.provider_name: str = self.sso_provider.provider
35+
if self.is_enabled:
36+
self.sso = self.sso_provider(redirect_uri=self.redirect_uri, **self.provider_config)
37+
tag = f"{self.sso_provider.provider.title()} OAuth"
38+
self.router.add_api_route(
39+
f"/login/{self.provider_name}",
40+
self._login_handler,
41+
methods=["GET"],
42+
tags=[tag],
43+
summary=f"Login with {self.provider_name.title()} OAuth",
44+
)
45+
self.router.add_api_route(
46+
f"/callback/{self.provider_name}",
47+
self._callback_handler,
48+
methods=["GET"],
49+
tags=[tag],
50+
summary=f"Callback for {self.provider_name.title()} OAuth",
51+
)
52+
53+
@property
54+
def redirect_uri(self) -> str:
55+
return f"{settings.APP_BACKEND_HOST}/api/v1/callback/{self.provider_name}"
56+
57+
@property
58+
def is_enabled(self) -> bool:
59+
is_enabled = all(self.provider_config.values())
60+
if settings.ENABLE_PASSWORD_AUTH and is_enabled:
61+
logger.warning(
62+
f"Both password authentication and {self.provider_name} OAuth are enabled. "
63+
"For enterprise or B2B deployments, it is recommended to disable password authentication "
64+
"by setting ENABLE_PASSWORD_AUTH=false and relying solely on OAuth."
65+
)
66+
return is_enabled
67+
68+
async def _create_and_set_token(self, response: Response, user: dict[str, Any]) -> str:
69+
access_token = await create_access_token(data={"sub": user["username"]})
70+
refresh_token = await create_refresh_token(data={"sub": user["username"]})
71+
max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
72+
response.set_cookie(
73+
key="refresh_token", value=refresh_token, httponly=True, secure=True, samesite="lax", max_age=max_age
74+
)
75+
return access_token
76+
77+
async def _login_handler(self) -> RedirectResponse:
78+
async with self.sso:
79+
return await self.sso.get_login_redirect()
80+
81+
async def _callback_handler(self, request: Request, response: Response, db: AsyncSession = Depends(async_get_db)):
82+
async with self.sso:
83+
oauth_user: OpenID | None = await self.sso.verify_and_process(request)
84+
if not oauth_user or not oauth_user.email:
85+
raise UnauthorizedException(f"Invalid response from {self.provider_name.title()} OAuth.")
86+
87+
db_user = await crud_users.get(db=db, email=oauth_user.email, is_deleted=False, schema_to_select=UserRead)
88+
if not db_user:
89+
user = await self._get_user_details(oauth_user)
90+
db_user = await write_user_internal(user=user, db=db)
91+
92+
access_token = await self._create_and_set_token(response, db_user)
93+
return {"access_token": access_token, "token_type": "bearer"}
94+
95+
async def _get_user_details(self, oauth_user: OpenID) -> UserCreateInternal:
96+
if not oauth_user.email:
97+
raise UnauthorizedException(f"Invalid response from {self.provider_name.title()} OAuth.")
98+
username = oauth_user.email.split("@")[0].lower()
99+
name = oauth_user.display_name or username
100+
101+
return UserCreateInternal(
102+
email=oauth_user.email,
103+
name=name,
104+
username=username,
105+
hashed_password=None,
106+
)
107+
108+
109+
class GoogleOAuthProvider(BaseOAuthProvider):
110+
sso_provider = GoogleSSO
111+
provider_config = {
112+
"client_id": settings.GOOGLE_CLIENT_ID,
113+
"client_secret": settings.GOOGLE_CLIENT_SECRET,
114+
}
115+
116+
117+
class MicrosoftOAuthProvider(BaseOAuthProvider):
118+
sso_provider = MicrosoftSSO
119+
provider_config = {
120+
"client_id": settings.MICROSOFT_CLIENT_ID,
121+
"client_secret": settings.MICROSOFT_CLIENT_SECRET,
122+
"tenant": settings.MICROSOFT_TENANT,
123+
}
124+
125+
126+
class GithubOAuthProvider(BaseOAuthProvider):
127+
sso_provider = GithubSSO
128+
provider_config = {
129+
"client_id": settings.GITHUB_CLIENT_ID,
130+
"client_secret": settings.GITHUB_CLIENT_SECRET,
131+
}
132+
133+
134+
GoogleOAuthProvider(router)
135+
MicrosoftOAuthProvider(router)
136+
GithubOAuthProvider(router)

src/app/api/v1/users.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from sqlalchemy.ext.asyncio import AsyncSession
66

77
from ...api.dependencies import get_current_superuser, get_current_user
8+
from ...core.config import settings
89
from ...core.db.database import async_get_db
910
from ...core.exceptions.http_exceptions import DuplicateValueException, ForbiddenException, NotFoundException
1011
from ...core.security import blacklist_token, get_password_hash, oauth2_scheme
@@ -17,10 +18,17 @@
1718
router = APIRouter(tags=["users"])
1819

1920

20-
@router.post("/user", response_model=UserRead, status_code=201)
21-
async def write_user(
22-
request: Request, user: UserCreate, db: Annotated[AsyncSession, Depends(async_get_db)]
23-
) -> dict[str, Any]:
21+
if settings.ENABLE_PASSWORD_AUTH:
22+
23+
@router.post("/user", response_model=UserRead, status_code=201)
24+
async def write_user(
25+
request: Request, user: UserCreate, db: Annotated[AsyncSession, Depends(async_get_db)]
26+
) -> dict[str, Any]:
27+
created_user = await write_user_internal(user=user, db=db)
28+
return created_user
29+
30+
31+
async def write_user_internal(user: UserCreate | UserCreateInternal, db: AsyncSession) -> dict[str, Any]:
2432
email_row = await crud_users.exists(db=db, email=user.email)
2533
if email_row:
2634
raise DuplicateValueException("Email is already registered")
@@ -29,13 +37,13 @@ async def write_user(
2937
if username_row:
3038
raise DuplicateValueException("Username not available")
3139

32-
user_internal_dict = user.model_dump()
33-
user_internal_dict["hashed_password"] = get_password_hash(password=user_internal_dict["password"])
34-
del user_internal_dict["password"]
35-
36-
user_internal = UserCreateInternal(**user_internal_dict)
37-
created_user = await crud_users.create(db=db, object=user_internal, schema_to_select=UserRead)
40+
if isinstance(user, UserCreate):
41+
user_internal_dict = user.model_dump()
42+
user_internal_dict["hashed_password"] = get_password_hash(password=user_internal_dict["password"])
43+
del user_internal_dict["password"]
44+
user = UserCreateInternal(**user_internal_dict)
3845

46+
created_user = await crud_users.create(db=db, object=user, schema_to_select=UserRead)
3947
if created_user is None:
4048
raise NotFoundException("Failed to create user")
4149

src/app/core/config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,17 @@ class CORSSettings(BaseSettings):
141141
CORS_HEADERS: list[str] = ["*"]
142142

143143

144+
class AuthSettings(BaseSettings):
145+
ENABLE_PASSWORD_AUTH: bool = True
146+
GOOGLE_CLIENT_ID: str | None = None
147+
GOOGLE_CLIENT_SECRET: str | None = None
148+
MICROSOFT_CLIENT_ID: str | None = None
149+
MICROSOFT_CLIENT_SECRET: str | None = None
150+
MICROSOFT_TENANT: str | None = None
151+
GITHUB_CLIENT_ID: str | None = None
152+
GITHUB_CLIENT_SECRET: str | None = None
153+
154+
144155
class Settings(
145156
AppSettings,
146157
PostgresSettings,
@@ -155,6 +166,7 @@ class Settings(
155166
CRUDAdminSettings,
156167
EnvironmentSettings,
157168
CORSSettings,
169+
AuthSettings,
158170
):
159171
model_config = SettingsConfigDict(
160172
env_file=os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..", ".env"),

src/app/core/security.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ async def authenticate_user(username_or_email: str, password: str, db: AsyncSess
4545
if not db_user:
4646
return False
4747

48-
if not await verify_password(password, db_user["hashed_password"]):
48+
# If the user has no password set (e.g. OAuth2 only accounts), reject authentication
49+
if db_user["hashed_password"] is None or not await verify_password(password, db_user["hashed_password"]):
4950
return False
5051

5152
return db_user

src/app/core/setup.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from ..models import * # noqa: F403
1919
from .config import (
2020
AppSettings,
21+
AuthSettings,
2122
ClientSideCacheSettings,
2223
CORSSettings,
2324
DatabaseSettings,
@@ -86,6 +87,7 @@ def lifespan_factory(
8687
| RedisQueueSettings
8788
| RedisRateLimiterSettings
8889
| EnvironmentSettings
90+
| AuthSettings
8991
),
9092
create_tables_on_start: bool = True,
9193
) -> Callable[[FastAPI], _AsyncGeneratorContextManager[Any]]:
@@ -142,6 +144,7 @@ def create_application(
142144
| RedisQueueSettings
143145
| RedisRateLimiterSettings
144146
| EnvironmentSettings
147+
| AuthSettings
145148
),
146149
create_tables_on_start: bool = True,
147150
lifespan: Callable[[FastAPI], _AsyncGeneratorContextManager[Any]] | None = None,

0 commit comments

Comments
 (0)