From 1001e0f7f977088a15979f6fa56799c7cb90c876 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Fri, 21 Nov 2025 15:18:31 +0700 Subject: [PATCH 01/13] Add auth settings --- src/app/core/config.py | 10 ++++++++++ src/app/core/setup.py | 3 +++ 2 files changed, 13 insertions(+) diff --git a/src/app/core/config.py b/src/app/core/config.py index c031243..6f3023f 100644 --- a/src/app/core/config.py +++ b/src/app/core/config.py @@ -141,6 +141,15 @@ class CORSSettings(BaseSettings): CORS_HEADERS: list[str] = ["*"] +class AuthSettings(BaseSettings): + ENABLE_LOCAL_AUTH: bool = True + GOOGLE_CLIENT_ID: str | None = None + GOOGLE_CLIENT_SECRET: str | None = None + MICROSOFT_CLIENT_ID: str | None = None + MICROSOFT_CLIENT_SECRET: str | None = None + MICROSOFT_TENANT: str | None = None + + class Settings( AppSettings, PostgresSettings, @@ -155,6 +164,7 @@ class Settings( CRUDAdminSettings, EnvironmentSettings, CORSSettings, + AuthSettings, ): model_config = SettingsConfigDict( env_file=os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..", ".env"), diff --git a/src/app/core/setup.py b/src/app/core/setup.py index b2cdcbf..766eae6 100644 --- a/src/app/core/setup.py +++ b/src/app/core/setup.py @@ -18,6 +18,7 @@ from ..models import * # noqa: F403 from .config import ( AppSettings, + AuthSettings, ClientSideCacheSettings, CORSSettings, DatabaseSettings, @@ -86,6 +87,7 @@ def lifespan_factory( | RedisQueueSettings | RedisRateLimiterSettings | EnvironmentSettings + | AuthSettings ), create_tables_on_start: bool = True, ) -> Callable[[FastAPI], _AsyncGeneratorContextManager[Any]]: @@ -142,6 +144,7 @@ def create_application( | RedisQueueSettings | RedisRateLimiterSettings | EnvironmentSettings + | AuthSettings ), create_tables_on_start: bool = True, lifespan: Callable[[FastAPI], _AsyncGeneratorContextManager[Any]] | None = None, From e5946f9b111f433e4c2e95137b3d3f6d3d4bc606 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Fri, 21 Nov 2025 16:07:46 +0700 Subject: [PATCH 02/13] Make password auth optional based on environment variable --- src/app/api/v1/login.py | 42 +++++++++++++++++++---------------------- src/app/core/config.py | 2 +- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/app/api/v1/login.py b/src/app/api/v1/login.py index e784731..5303463 100644 --- a/src/app/api/v1/login.py +++ b/src/app/api/v1/login.py @@ -1,4 +1,3 @@ -from datetime import timedelta from typing import Annotated from fastapi import APIRouter, Depends, Request, Response @@ -10,7 +9,6 @@ from ...core.exceptions.http_exceptions import UnauthorizedException from ...core.schemas import Token from ...core.security import ( - ACCESS_TOKEN_EXPIRE_MINUTES, TokenType, authenticate_user, create_access_token, @@ -21,27 +19,25 @@ router = APIRouter(tags=["login"]) -@router.post("/login", response_model=Token) -async def login_for_access_token( - response: Response, - form_data: Annotated[OAuth2PasswordRequestForm, Depends()], - db: Annotated[AsyncSession, Depends(async_get_db)], -) -> dict[str, str]: - user = await authenticate_user(username_or_email=form_data.username, password=form_data.password, db=db) - if not user: - raise UnauthorizedException("Wrong username, email or password.") - - access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - access_token = await create_access_token(data={"sub": user["username"]}, expires_delta=access_token_expires) - - refresh_token = await create_refresh_token(data={"sub": user["username"]}) - max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 - - response.set_cookie( - key="refresh_token", value=refresh_token, httponly=True, secure=True, samesite="lax", max_age=max_age - ) - - return {"access_token": access_token, "token_type": "bearer"} +if settings.ENABLE_PASSWORD_AUTH: + + @router.post("/login", response_model=Token) + async def login_with_password( + response: Response, + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + db: Annotated[AsyncSession, Depends(async_get_db)], + ) -> dict[str, str]: + user = await authenticate_user(username_or_email=form_data.username, password=form_data.password, db=db) + if not user: + raise UnauthorizedException("Wrong username, email or password.") + + access_token = await create_access_token(data={"sub": user["username"]}) + refresh_token = await create_refresh_token(data={"sub": user["username"]}) + max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 + response.set_cookie( + key="refresh_token", value=refresh_token, httponly=True, secure=True, samesite="lax", max_age=max_age + ) + return {"access_token": access_token, "token_type": "bearer"} @router.post("/refresh") diff --git a/src/app/core/config.py b/src/app/core/config.py index 6f3023f..2bca7c7 100644 --- a/src/app/core/config.py +++ b/src/app/core/config.py @@ -142,7 +142,7 @@ class CORSSettings(BaseSettings): class AuthSettings(BaseSettings): - ENABLE_LOCAL_AUTH: bool = True + ENABLE_PASSWORD_AUTH: bool = True GOOGLE_CLIENT_ID: str | None = None GOOGLE_CLIENT_SECRET: str | None = None MICROSOFT_CLIENT_ID: str | None = None From 6a043eee49b6e0878dfc80d031d8ff3de8c793e5 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Fri, 21 Nov 2025 16:10:47 +0700 Subject: [PATCH 03/13] Add environment variables to the .env file example --- scripts/local_with_uvicorn/.env.example | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/local_with_uvicorn/.env.example b/scripts/local_with_uvicorn/.env.example index 9f3e5f4..b913cfa 100644 --- a/scripts/local_with_uvicorn/.env.example +++ b/scripts/local_with_uvicorn/.env.example @@ -72,3 +72,11 @@ ENVIRONMENT="local" # ------------- first tier ------------- TIER_NAME="free" + +# ------------- auth settings ------------- +# ENABLE_PASSWORD_AUTH=true +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= +# MICROSOFT_CLIENT_ID= +# MICROSOFT_CLIENT_SECRET= +# MICROSOFT_TENANT= From 3b3421366fd39152d1c5f16779e0af871e5573ed Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Fri, 21 Nov 2025 21:08:36 +0700 Subject: [PATCH 04/13] Add oauth for Google and Microsoft --- src/app/api/v1/__init__.py | 6 +- src/app/api/v1/oauth.py | 126 +++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 src/app/api/v1/oauth.py diff --git a/src/app/api/v1/__init__.py b/src/app/api/v1/__init__.py index 7575848..823fa14 100644 --- a/src/app/api/v1/__init__.py +++ b/src/app/api/v1/__init__.py @@ -3,6 +3,7 @@ from .health import router as health_router from .login import router as login_router from .logout import router as logout_router +from .oauth import router as oauth_router from .posts import router as posts_router from .rate_limits import router as rate_limits_router from .tasks import router as tasks_router @@ -13,8 +14,9 @@ router.include_router(health_router) router.include_router(login_router) router.include_router(logout_router) -router.include_router(users_router) +router.include_router(oauth_router) router.include_router(posts_router) +router.include_router(rate_limits_router) router.include_router(tasks_router) router.include_router(tiers_router) -router.include_router(rate_limits_router) +router.include_router(users_router) diff --git a/src/app/api/v1/oauth.py b/src/app/api/v1/oauth.py new file mode 100644 index 0000000..36d6e20 --- /dev/null +++ b/src/app/api/v1/oauth.py @@ -0,0 +1,126 @@ +import secrets +from abc import ABC +from typing import Any + +from fastapi import APIRouter, Depends, Request, Response +from fastapi_sso.sso.base import OpenID, SSOBase +from fastapi_sso.sso.google import GoogleSSO +from fastapi_sso.sso.microsoft import MicrosoftSSO +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.config import settings +from ...core.db.database import async_get_db +from ...core.exceptions.http_exceptions import UnauthorizedException +from ...core.security import ( + create_access_token, + create_refresh_token, +) +from ...crud.crud_users import crud_users +from ...schemas.user import UserCreate, UserRead +from .users import write_user + +router = APIRouter(tags=["login", "oauth"]) + + +class BaseOAuthProvider(ABC): + provider_config: dict[str, Any] + sso_provider: type[SSOBase] + + def __init__(self, router: Any): + self.router = router + self.provider_name: str = self.sso_provider.provider + if self.is_enabled: + self.sso = self.sso_provider(redirect_uri=self.redirect_uri, **self.provider_config) + tag = f"{self.sso_provider.provider.title()} OAuth" + self.router.add_api_route( + f"/login/{self.provider_name}", + self._login_handler, + methods=["GET"], + tags=[tag], + summary=f"Login with {self.provider_name.title()} OAuth", + ) + self.router.add_api_route( + f"/callback/{self.provider_name}", + self._callback_handler, + methods=["GET"], + tags=[tag], + summary=f"Callback for {self.provider_name.title()} OAuth", + ) + + @property + def redirect_uri(self) -> str: + return f"{settings.APP_BACKEND_HOST}/api/v1/callback/{self.provider_name}" + + @property + def is_enabled(self) -> bool: + return all(self.provider_config.values()) + + async def _create_and_set_token(self, response: Response, user: dict[str, Any]) -> str: + access_token = await create_access_token(data={"sub": user["username"]}) + refresh_token = await create_refresh_token(data={"sub": user["username"]}) + max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 + response.set_cookie( + key="refresh_token", value=refresh_token, httponly=True, secure=True, samesite="lax", max_age=max_age + ) + return access_token + + async def _login_handler(self): + async with self.sso: + return await self.sso.get_login_redirect() + + async def _callback_handler(self, request: Request, response: Response, db: AsyncSession = Depends(async_get_db)): + async with self.sso: + oauth_user: OpenID | None = await self.sso.verify_and_process(request) + if not oauth_user or not oauth_user.email: + raise UnauthorizedException(f"Invalid response from {self.provider_name.title()} OAuth.") + + db_user = await crud_users.get(db=db, email=oauth_user.email, is_deleted=False, schema_to_select=UserRead) + if not db_user: + user_create = await self._get_user_details(oauth_user) + db_user = await write_user(request=request, user=user_create, db=db) + + access_token = await self._create_and_set_token(response, db_user) + return {"access_token": access_token, "token_type": "bearer"} + + async def _get_user_details(self, oauth_user: OpenID) -> UserCreate: + """Get user details from the OAuth provider response. + + The exact details exposed by the OpenID class can be found here: + https://github.com/tomasvotava/fastapi-sso/blob/master/fastapi_sso/sso/base.py#L64 + """ + if not oauth_user.email: + raise UnauthorizedException(f"Invalid response from {self.provider_name.title()} OAuth.") + username = oauth_user.email.split("@")[0] + name = oauth_user.display_name or username + + # Create a random password for OAuth users. + # It can still be changed if the user requests login with password. + random_password = secrets.token_urlsafe(32) + return UserCreate( + email=oauth_user.email, + name=name, + password=random_password, + username=username, + ) + + +class GoogleOAuthProvider(BaseOAuthProvider): + sso_provider = GoogleSSO + provider_config = { + "client_id": settings.GOOGLE_CLIENT_ID, + "client_secret": settings.GOOGLE_CLIENT_SECRET, + } + + +# TODO: There is a bug in fastapi-sso, it does not return the email address +class MicrosoftOAuthProvider(BaseOAuthProvider): + sso_provider = MicrosoftSSO + provider_config = { + "client_id": settings.MICROSOFT_CLIENT_ID, + "client_secret": settings.MICROSOFT_CLIENT_SECRET, + "tenant": settings.MICROSOFT_TENANT, + } + + +GoogleOAuthProvider(router) +MicrosoftOAuthProvider(router) From 7c98abfc3bdd38de162a1d7fd179e0ddfa6cdbdf Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Fri, 21 Nov 2025 23:40:46 +0700 Subject: [PATCH 05/13] Add microsoft and github oauth --- scripts/local_with_uvicorn/.env.example | 2 ++ src/app/api/v1/oauth.py | 11 ++++++++++- src/app/core/config.py | 2 ++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/scripts/local_with_uvicorn/.env.example b/scripts/local_with_uvicorn/.env.example index b913cfa..b3be8b3 100644 --- a/scripts/local_with_uvicorn/.env.example +++ b/scripts/local_with_uvicorn/.env.example @@ -80,3 +80,5 @@ TIER_NAME="free" # MICROSOFT_CLIENT_ID= # MICROSOFT_CLIENT_SECRET= # MICROSOFT_TENANT= +# GITHUB_CLIENT_ID= +# GITHUB_CLIENT_SECRET= diff --git a/src/app/api/v1/oauth.py b/src/app/api/v1/oauth.py index 36d6e20..cbf58c4 100644 --- a/src/app/api/v1/oauth.py +++ b/src/app/api/v1/oauth.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, Request, Response from fastapi_sso.sso.base import OpenID, SSOBase +from fastapi_sso.sso.github import GithubSSO from fastapi_sso.sso.google import GoogleSSO from fastapi_sso.sso.microsoft import MicrosoftSSO from sqlalchemy.ext.asyncio import AsyncSession @@ -112,7 +113,6 @@ class GoogleOAuthProvider(BaseOAuthProvider): } -# TODO: There is a bug in fastapi-sso, it does not return the email address class MicrosoftOAuthProvider(BaseOAuthProvider): sso_provider = MicrosoftSSO provider_config = { @@ -122,5 +122,14 @@ class MicrosoftOAuthProvider(BaseOAuthProvider): } +class GithubSSOProvider(BaseOAuthProvider): + sso_provider = GithubSSO + provider_config = { + "client_id": settings.GITHUB_CLIENT_ID, + "client_secret": settings.GITHUB_CLIENT_SECRET, + } + + GoogleOAuthProvider(router) MicrosoftOAuthProvider(router) +GithubSSOProvider(router) diff --git a/src/app/core/config.py b/src/app/core/config.py index 2bca7c7..5693c52 100644 --- a/src/app/core/config.py +++ b/src/app/core/config.py @@ -148,6 +148,8 @@ class AuthSettings(BaseSettings): MICROSOFT_CLIENT_ID: str | None = None MICROSOFT_CLIENT_SECRET: str | None = None MICROSOFT_TENANT: str | None = None + GITHUB_CLIENT_ID: str | None = None + GITHUB_CLIENT_SECRET: str | None = None class Settings( From db727a320bfe112f57a7980c80b26839cbfdbe51 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Sun, 23 Nov 2025 10:46:44 +0700 Subject: [PATCH 06/13] Add warning message about mixing password auth with oauth --- src/app/api/v1/oauth.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app/api/v1/oauth.py b/src/app/api/v1/oauth.py index cbf58c4..0c9ed87 100644 --- a/src/app/api/v1/oauth.py +++ b/src/app/api/v1/oauth.py @@ -1,3 +1,4 @@ +import logging import secrets from abc import ABC from typing import Any @@ -21,6 +22,7 @@ from .users import write_user router = APIRouter(tags=["login", "oauth"]) +logger = logging.getLogger(__name__) class BaseOAuthProvider(ABC): @@ -54,7 +56,14 @@ def redirect_uri(self) -> str: @property def is_enabled(self) -> bool: - return all(self.provider_config.values()) + is_enabled = all(self.provider_config.values()) + if settings.ENABLE_PASSWORD_AUTH and is_enabled: + logger.warning( + f"Both password authentication and {self.provider_name} OAuth are enabled. " + "For enterprise or B2B deployments, it is recommended to disable password authentication " + "by setting ENABLE_PASSWORD_AUTH=false and relying solely on OAuth." + ) + return is_enabled async def _create_and_set_token(self, response: Response, user: dict[str, Any]) -> str: access_token = await create_access_token(data={"sub": user["username"]}) From 325e89a7822647f0883092642adfa73a7f8a9963 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Mon, 24 Nov 2025 18:23:47 +0800 Subject: [PATCH 07/13] Allow Oauth user to have a null password and deny password auth when user password i null/None --- src/app/api/v1/oauth.py | 18 +++++++----------- src/app/api/v1/users.py | 21 +++++++++++++++------ src/app/core/security.py | 2 +- src/app/models/user.py | 2 +- src/app/schemas/user.py | 4 +++- 5 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/app/api/v1/oauth.py b/src/app/api/v1/oauth.py index 0c9ed87..c711820 100644 --- a/src/app/api/v1/oauth.py +++ b/src/app/api/v1/oauth.py @@ -1,5 +1,4 @@ import logging -import secrets from abc import ABC from typing import Any @@ -18,8 +17,8 @@ create_refresh_token, ) from ...crud.crud_users import crud_users -from ...schemas.user import UserCreate, UserRead -from .users import write_user +from ...schemas.user import UserCreateInternal, UserRead +from .users import write_user_internal router = APIRouter(tags=["login", "oauth"]) logger = logging.getLogger(__name__) @@ -86,13 +85,13 @@ async def _callback_handler(self, request: Request, response: Response, db: Asyn db_user = await crud_users.get(db=db, email=oauth_user.email, is_deleted=False, schema_to_select=UserRead) if not db_user: - user_create = await self._get_user_details(oauth_user) - db_user = await write_user(request=request, user=user_create, db=db) + user = await self._get_user_details(oauth_user) + db_user = await write_user_internal(user=user, db=db) access_token = await self._create_and_set_token(response, db_user) return {"access_token": access_token, "token_type": "bearer"} - async def _get_user_details(self, oauth_user: OpenID) -> UserCreate: + async def _get_user_details(self, oauth_user: OpenID) -> UserCreateInternal: """Get user details from the OAuth provider response. The exact details exposed by the OpenID class can be found here: @@ -103,14 +102,11 @@ async def _get_user_details(self, oauth_user: OpenID) -> UserCreate: username = oauth_user.email.split("@")[0] name = oauth_user.display_name or username - # Create a random password for OAuth users. - # It can still be changed if the user requests login with password. - random_password = secrets.token_urlsafe(32) - return UserCreate( + return UserCreateInternal( email=oauth_user.email, name=name, - password=random_password, username=username, + hashed_password=None, # No password since OAuth is used ) diff --git a/src/app/api/v1/users.py b/src/app/api/v1/users.py index 60264cc..300c018 100644 --- a/src/app/api/v1/users.py +++ b/src/app/api/v1/users.py @@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from ...api.dependencies import get_current_superuser, get_current_user +from ...core.config import settings from ...core.db.database import async_get_db from ...core.exceptions.http_exceptions import DuplicateValueException, ForbiddenException, NotFoundException from ...core.security import blacklist_token, get_password_hash, oauth2_scheme @@ -17,10 +18,17 @@ router = APIRouter(tags=["users"]) -@router.post("/user", response_model=UserRead, status_code=201) -async def write_user( - request: Request, user: UserCreate, db: Annotated[AsyncSession, Depends(async_get_db)] -) -> dict[str, Any]: +if settings.ENABLE_PASSWORD_AUTH: + + @router.post("/user", response_model=UserRead, status_code=201) + async def write_user( + request: Request, user: UserCreate, db: Annotated[AsyncSession, Depends(async_get_db)] + ) -> dict[str, Any]: + created_user = await write_user_internal(user=user, db=db) + return created_user + + +async def write_user_internal(user: UserCreate | UserCreateInternal, db: AsyncSession) -> dict[str, Any]: email_row = await crud_users.exists(db=db, email=user.email) if email_row: raise DuplicateValueException("Email is already registered") @@ -30,8 +38,9 @@ async def write_user( raise DuplicateValueException("Username not available") user_internal_dict = user.model_dump() - user_internal_dict["hashed_password"] = get_password_hash(password=user_internal_dict["password"]) - del user_internal_dict["password"] + if isinstance(user, UserCreate): + user_internal_dict["hashed_password"] = get_password_hash(password=user_internal_dict["password"]) + del user_internal_dict["password"] user_internal = UserCreateInternal(**user_internal_dict) created_user = await crud_users.create(db=db, object=user_internal, schema_to_select=UserRead) diff --git a/src/app/core/security.py b/src/app/core/security.py index d589078..d77f13f 100644 --- a/src/app/core/security.py +++ b/src/app/core/security.py @@ -45,7 +45,7 @@ async def authenticate_user(username_or_email: str, password: str, db: AsyncSess if not db_user: return False - if not await verify_password(password, db_user["hashed_password"]): + if db_user["hashed_password"] is None or not await verify_password(password, db_user["hashed_password"]): return False return db_user diff --git a/src/app/models/user.py b/src/app/models/user.py index 07cca2d..d8f1170 100644 --- a/src/app/models/user.py +++ b/src/app/models/user.py @@ -17,7 +17,7 @@ class User(Base): name: Mapped[str] = mapped_column(String(30)) username: Mapped[str] = mapped_column(String(20), unique=True, index=True) email: Mapped[str] = mapped_column(String(50), unique=True, index=True) - hashed_password: Mapped[str] = mapped_column(String) + hashed_password: Mapped[str | None] = mapped_column(String, nullable=True) profile_image_url: Mapped[str] = mapped_column(String, default="https://profileimageurl.com") uuid: Mapped[uuid_pkg.UUID] = mapped_column(UUID(as_uuid=True), default_factory=uuid7, unique=True) diff --git a/src/app/schemas/user.py b/src/app/schemas/user.py index c33a94e..6303168 100644 --- a/src/app/schemas/user.py +++ b/src/app/schemas/user.py @@ -36,7 +36,9 @@ class UserCreate(UserBase): class UserCreateInternal(UserBase): - hashed_password: str + model_config = ConfigDict(extra="forbid") + + hashed_password: str | None class UserUpdate(BaseModel): From 300ec9f80f13df3e3745a2d359c1927ca7fb2278 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Tue, 25 Nov 2025 15:54:12 +0800 Subject: [PATCH 08/13] Solve conflicts and sync uv.lock file --- pyproject.toml | 1 + uv.lock | 39 +++++++++++++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f5b2692..24ac3e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "gunicorn>=23.0.0", "ruff>=0.11.13", "mypy>=1.16.0", + "fastapi-sso>=0.18.0", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 5dda7a2..1dd702a 100644 --- a/uv.lock +++ b/uv.lock @@ -387,6 +387,7 @@ dependencies = [ { name = "bcrypt" }, { name = "crudadmin" }, { name = "fastapi" }, + { name = "fastapi-sso" }, { name = "fastcrud" }, { name = "greenlet" }, { name = "gunicorn" }, @@ -435,6 +436,7 @@ requires-dist = [ { name = "crudadmin", specifier = ">=0.4.2" }, { name = "faker", marker = "extra == 'dev'", specifier = ">=26.0.0" }, { name = "fastapi", specifier = ">=0.109.1" }, + { name = "fastapi-sso", specifier = ">=0.18.0" }, { name = "fastcrud", specifier = ">=0.19.2" }, { name = "greenlet", specifier = ">=2.0.2" }, { name = "gunicorn", specifier = ">=23.0.0" }, @@ -470,6 +472,22 @@ dev = [ { name = "watchfiles", specifier = ">=1.1.1" }, ] +[[package]] +name = "fastapi-sso" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "oauthlib" }, + { name = "pydantic", extra = ["email"] }, + { name = "pyjwt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/57/cc971c018af5d09eb5f8d1cd12abdd99ab4c59ea5c0b0b1b96349ffe117d/fastapi_sso-0.18.0.tar.gz", hash = "sha256:d8df5a686af7a6a7be248817544b405cf77f7e9ffcd5d0d7d2a196fd071964bc", size = 16811, upload-time = "2025-03-20T17:09:09.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/03/70ca13994f5569d343a9f99dba2930c8ae3471171f161b8887d44b6c526f/fastapi_sso-0.18.0-py3-none-any.whl", hash = "sha256:727754ad770b70690f1471f7b0a9e17c6dfd8ebd6e477616d3bde1eaf62e53dc", size = 26103, upload-time = "2025-03-20T17:09:08.656Z" }, +] + [[package]] name = "fastcrud" version = "0.19.2" @@ -816,6 +834,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -1039,11 +1066,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.9.0" +version = "2.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/68/ce067f09fca4abeca8771fe667d89cc347d1e99da3e093112ac329c6020e/pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c", size = 78825, upload-time = "2024-08-01T15:01:08.445Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/84/0fdf9b18ba31d69877bd39c9cd6052b47f3761e9910c15de788e519f079f/PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850", size = 22344, upload-time = "2024-08-01T15:01:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, ] [[package]] @@ -1175,15 +1202,15 @@ wheels = [ [[package]] name = "redis" -version = "5.3.0" +version = "5.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, { name = "pyjwt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/dd/2b37032f4119dff2a2f9bbcaade03221b100ba26051bb96e275de3e5db7a/redis-5.3.0.tar.gz", hash = "sha256:8d69d2dde11a12dc85d0dbf5c45577a5af048e2456f7077d87ad35c1c81c310e", size = 4626288, upload-time = "2025-04-30T14:54:40.634Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/cf/128b1b6d7086200c9f387bd4be9b2572a30b90745ef078bd8b235042dc9f/redis-5.3.1.tar.gz", hash = "sha256:ca49577a531ea64039b5a36db3d6cd1a0c7a60c34124d46924a45b956e8cf14c", size = 4626200, upload-time = "2025-07-25T08:06:27.778Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/b0/aa601efe12180ba492b02e270554877e68467e66bda5d73e51eaa8ecc78a/redis-5.3.0-py3-none-any.whl", hash = "sha256:f1deeca1ea2ef25c1e4e46b07f4ea1275140526b1feea4c6459c0ec27a10ef83", size = 272836, upload-time = "2025-04-30T14:54:30.744Z" }, + { url = "https://files.pythonhosted.org/packages/7f/26/5c5fa0e83c3621db835cfc1f1d789b37e7fa99ed54423b5f519beb931aa7/redis-5.3.1-py3-none-any.whl", hash = "sha256:dc1909bd24669cc31b5f67a039700b16ec30571096c5f1f0d9d2324bff31af97", size = 272833, upload-time = "2025-07-25T08:06:26.317Z" }, ] [package.optional-dependencies] From 36794747e816bba6ce5c8cfc2ba836d53cb4791b Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Tue, 25 Nov 2025 16:20:09 +0800 Subject: [PATCH 09/13] Add comments for clarity --- src/app/api/v1/users.py | 9 ++++----- src/app/core/security.py | 1 + 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/api/v1/users.py b/src/app/api/v1/users.py index 300c018..49acb96 100644 --- a/src/app/api/v1/users.py +++ b/src/app/api/v1/users.py @@ -18,7 +18,7 @@ router = APIRouter(tags=["users"]) -if settings.ENABLE_PASSWORD_AUTH: +if settings.ENABLE_PASSWORD_AUTH: # If password auth is not enable there should be no way to create users via the API @router.post("/user", response_model=UserRead, status_code=201) async def write_user( @@ -37,14 +37,13 @@ async def write_user_internal(user: UserCreate | UserCreateInternal, db: AsyncSe if username_row: raise DuplicateValueException("Username not available") - user_internal_dict = user.model_dump() if isinstance(user, UserCreate): + user_internal_dict = user.model_dump() user_internal_dict["hashed_password"] = get_password_hash(password=user_internal_dict["password"]) del user_internal_dict["password"] + user = UserCreateInternal(**user_internal_dict) - user_internal = UserCreateInternal(**user_internal_dict) - created_user = await crud_users.create(db=db, object=user_internal, schema_to_select=UserRead) - + created_user = await crud_users.create(db=db, object=user, schema_to_select=UserRead) if created_user is None: raise NotFoundException("Failed to create user") diff --git a/src/app/core/security.py b/src/app/core/security.py index d77f13f..d6f18ca 100644 --- a/src/app/core/security.py +++ b/src/app/core/security.py @@ -45,6 +45,7 @@ async def authenticate_user(username_or_email: str, password: str, db: AsyncSess if not db_user: return False + # If the user has no password set (e.g. OAuth2 only accounts), reject authentication if db_user["hashed_password"] is None or not await verify_password(password, db_user["hashed_password"]): return False From 00f1a37fc7224ae955ebebab266b186144a64833 Mon Sep 17 00:00:00 2001 From: Rodrigo Agundez Date: Tue, 23 Dec 2025 00:31:51 -0800 Subject: [PATCH 10/13] Remove inline comments, and rename githubsso to githuboauth and add type to redirect --- src/app/api/v1/oauth.py | 10 +++++----- src/app/api/v1/users.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/api/v1/oauth.py b/src/app/api/v1/oauth.py index c711820..76079a1 100644 --- a/src/app/api/v1/oauth.py +++ b/src/app/api/v1/oauth.py @@ -2,7 +2,7 @@ from abc import ABC from typing import Any -from fastapi import APIRouter, Depends, Request, Response +from fastapi import APIRouter, Depends, RedirectResponse, Request, Response from fastapi_sso.sso.base import OpenID, SSOBase from fastapi_sso.sso.github import GithubSSO from fastapi_sso.sso.google import GoogleSSO @@ -73,7 +73,7 @@ async def _create_and_set_token(self, response: Response, user: dict[str, Any]) ) return access_token - async def _login_handler(self): + async def _login_handler(self) -> RedirectResponse: async with self.sso: return await self.sso.get_login_redirect() @@ -106,7 +106,7 @@ async def _get_user_details(self, oauth_user: OpenID) -> UserCreateInternal: email=oauth_user.email, name=name, username=username, - hashed_password=None, # No password since OAuth is used + hashed_password=None, ) @@ -127,7 +127,7 @@ class MicrosoftOAuthProvider(BaseOAuthProvider): } -class GithubSSOProvider(BaseOAuthProvider): +class GithubOAuthProvider(BaseOAuthProvider): sso_provider = GithubSSO provider_config = { "client_id": settings.GITHUB_CLIENT_ID, @@ -137,4 +137,4 @@ class GithubSSOProvider(BaseOAuthProvider): GoogleOAuthProvider(router) MicrosoftOAuthProvider(router) -GithubSSOProvider(router) +GithubOAuthProvider(router) diff --git a/src/app/api/v1/users.py b/src/app/api/v1/users.py index 49acb96..2822094 100644 --- a/src/app/api/v1/users.py +++ b/src/app/api/v1/users.py @@ -18,7 +18,7 @@ router = APIRouter(tags=["users"]) -if settings.ENABLE_PASSWORD_AUTH: # If password auth is not enable there should be no way to create users via the API +if settings.ENABLE_PASSWORD_AUTH: @router.post("/user", response_model=UserRead, status_code=201) async def write_user( From 716f797678b2da434584da69ea4a8b58d5683b06 Mon Sep 17 00:00:00 2001 From: LucasQR Date: Tue, 13 Jan 2026 20:00:46 -0300 Subject: [PATCH 11/13] creating users with no password and with other characters on their email fix --- src/app/api/v1/oauth.py | 4 +++- src/app/api/v1/users.py | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/api/v1/oauth.py b/src/app/api/v1/oauth.py index 76079a1..2e75bfd 100644 --- a/src/app/api/v1/oauth.py +++ b/src/app/api/v1/oauth.py @@ -2,7 +2,8 @@ from abc import ABC from typing import Any -from fastapi import APIRouter, Depends, RedirectResponse, Request, Response +from fastapi import APIRouter, Depends, Request, Response +from fastapi.responses import RedirectResponse from fastapi_sso.sso.base import OpenID, SSOBase from fastapi_sso.sso.github import GithubSSO from fastapi_sso.sso.google import GoogleSSO @@ -100,6 +101,7 @@ async def _get_user_details(self, oauth_user: OpenID) -> UserCreateInternal: if not oauth_user.email: raise UnauthorizedException(f"Invalid response from {self.provider_name.title()} OAuth.") username = oauth_user.email.split("@")[0] + username = "".join(c for c in username.lower() if c.isalnum()) name = oauth_user.display_name or username return UserCreateInternal( diff --git a/src/app/api/v1/users.py b/src/app/api/v1/users.py index 2822094..2140963 100644 --- a/src/app/api/v1/users.py +++ b/src/app/api/v1/users.py @@ -42,6 +42,10 @@ async def write_user_internal(user: UserCreate | UserCreateInternal, db: AsyncSe user_internal_dict["hashed_password"] = get_password_hash(password=user_internal_dict["password"]) del user_internal_dict["password"] user = UserCreateInternal(**user_internal_dict) + elif isinstance(user, UserCreateInternal) and user.hashed_password is None: + # NULL passwords are only allowed for OAuth users (UserCreateInternal from OAuth flow) + # This is validated here to prevent any other code path from creating passwordless users + pass created_user = await crud_users.create(db=db, object=user, schema_to_select=UserRead) if created_user is None: From ba5684f6be3adccda642e63c49c5be9562a6b614 Mon Sep 17 00:00:00 2001 From: LucasQR Date: Tue, 13 Jan 2026 20:06:12 -0300 Subject: [PATCH 12/13] took out dev comments --- src/app/api/v1/oauth.py | 5 ----- src/app/api/v1/users.py | 4 ---- 2 files changed, 9 deletions(-) diff --git a/src/app/api/v1/oauth.py b/src/app/api/v1/oauth.py index 2e75bfd..5ca63d0 100644 --- a/src/app/api/v1/oauth.py +++ b/src/app/api/v1/oauth.py @@ -93,11 +93,6 @@ async def _callback_handler(self, request: Request, response: Response, db: Asyn return {"access_token": access_token, "token_type": "bearer"} async def _get_user_details(self, oauth_user: OpenID) -> UserCreateInternal: - """Get user details from the OAuth provider response. - - The exact details exposed by the OpenID class can be found here: - https://github.com/tomasvotava/fastapi-sso/blob/master/fastapi_sso/sso/base.py#L64 - """ if not oauth_user.email: raise UnauthorizedException(f"Invalid response from {self.provider_name.title()} OAuth.") username = oauth_user.email.split("@")[0] diff --git a/src/app/api/v1/users.py b/src/app/api/v1/users.py index 2140963..2822094 100644 --- a/src/app/api/v1/users.py +++ b/src/app/api/v1/users.py @@ -42,10 +42,6 @@ async def write_user_internal(user: UserCreate | UserCreateInternal, db: AsyncSe user_internal_dict["hashed_password"] = get_password_hash(password=user_internal_dict["password"]) del user_internal_dict["password"] user = UserCreateInternal(**user_internal_dict) - elif isinstance(user, UserCreateInternal) and user.hashed_password is None: - # NULL passwords are only allowed for OAuth users (UserCreateInternal from OAuth flow) - # This is validated here to prevent any other code path from creating passwordless users - pass created_user = await crud_users.create(db=db, object=user, schema_to_select=UserRead) if created_user is None: From bf5178d87274f2bc39a42460d12051ea363c8133 Mon Sep 17 00:00:00 2001 From: LucasQR Date: Wed, 14 Jan 2026 17:52:49 -0300 Subject: [PATCH 13/13] adding tests and docs --- mkdocs.yml | 1 + src/app/api/v1/oauth.py | 3 +- src/app/schemas/user.py | 6 +- tests/test_oauth.py | 321 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 326 insertions(+), 5 deletions(-) create mode 100644 tests/test_oauth.py diff --git a/mkdocs.yml b/mkdocs.yml index 3abdf42..05de38b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -75,6 +75,7 @@ nav: - Authentication: - Overview: user-guide/authentication/index.md - JWT Tokens: user-guide/authentication/jwt-tokens.md + - OAuth: user-guide/authentication/oauth.md - User Management: user-guide/authentication/user-management.md - Permissions: user-guide/authentication/permissions.md - Admin Panel: diff --git a/src/app/api/v1/oauth.py b/src/app/api/v1/oauth.py index 5ca63d0..0335799 100644 --- a/src/app/api/v1/oauth.py +++ b/src/app/api/v1/oauth.py @@ -95,8 +95,7 @@ async def _callback_handler(self, request: Request, response: Response, db: Asyn async def _get_user_details(self, oauth_user: OpenID) -> UserCreateInternal: if not oauth_user.email: raise UnauthorizedException(f"Invalid response from {self.provider_name.title()} OAuth.") - username = oauth_user.email.split("@")[0] - username = "".join(c for c in username.lower() if c.isalnum()) + username = oauth_user.email.split("@")[0].lower() name = oauth_user.display_name or username return UserCreateInternal( diff --git a/src/app/schemas/user.py b/src/app/schemas/user.py index 6303168..f88aa15 100644 --- a/src/app/schemas/user.py +++ b/src/app/schemas/user.py @@ -8,7 +8,7 @@ class UserBase(BaseModel): name: Annotated[str, Field(min_length=2, max_length=30, examples=["User Userson"])] - username: Annotated[str, Field(min_length=2, max_length=20, pattern=r"^[a-z0-9]+$", examples=["userson"])] + username: Annotated[str, Field(min_length=2, max_length=20, pattern=r"^[a-z0-9._+-]+$", examples=["userson"])] email: Annotated[EmailStr, Field(examples=["user.userson@example.com"])] @@ -23,7 +23,7 @@ class UserRead(BaseModel): id: int name: Annotated[str, Field(min_length=2, max_length=30, examples=["User Userson"])] - username: Annotated[str, Field(min_length=2, max_length=20, pattern=r"^[a-z0-9]+$", examples=["userson"])] + username: Annotated[str, Field(min_length=2, max_length=20, pattern=r"^[a-z0-9._+-]+$", examples=["userson"])] email: Annotated[EmailStr, Field(examples=["user.userson@example.com"])] profile_image_url: str tier_id: int | None @@ -46,7 +46,7 @@ class UserUpdate(BaseModel): name: Annotated[str | None, Field(min_length=2, max_length=30, examples=["User Userberg"], default=None)] username: Annotated[ - str | None, Field(min_length=2, max_length=20, pattern=r"^[a-z0-9]+$", examples=["userberg"], default=None) + str | None, Field(min_length=2, max_length=20, pattern=r"^[a-z0-9._+-]+$", examples=["userberg"], default=None) ] email: Annotated[EmailStr | None, Field(examples=["user.userberg@example.com"], default=None)] profile_image_url: Annotated[ diff --git a/tests/test_oauth.py b/tests/test_oauth.py new file mode 100644 index 0000000..1355222 --- /dev/null +++ b/tests/test_oauth.py @@ -0,0 +1,321 @@ +"""Unit tests for OAuth functionality.""" + +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from src.app.api.v1.oauth import BaseOAuthProvider, GithubOAuthProvider, GoogleOAuthProvider, MicrosoftOAuthProvider +from src.app.core.exceptions.http_exceptions import UnauthorizedException +from src.app.schemas.user import UserCreateInternal + + +class MockOpenID: + """Mock OpenID response from OAuth provider.""" + + def __init__(self, email: str, display_name: str | None = None, id: str | None = None): + self.email = email + self.display_name = display_name + self.id = id + + +class TestOAuthProviderEnabled: + """Test OAuth provider enabled/disabled logic.""" + + def test_github_provider_enabled_with_credentials(self): + """Test GitHub provider is enabled when credentials are set.""" + with patch("src.app.api.v1.oauth.settings") as mock_settings: + mock_settings.GITHUB_CLIENT_ID = "test_client_id" + mock_settings.GITHUB_CLIENT_SECRET = "test_client_secret" + mock_settings.ENABLE_PASSWORD_AUTH = False + + provider = GithubOAuthProvider.__new__(GithubOAuthProvider) + provider.provider_config = { + "client_id": mock_settings.GITHUB_CLIENT_ID, + "client_secret": mock_settings.GITHUB_CLIENT_SECRET, + } + + assert provider.is_enabled is True + + def test_github_provider_disabled_without_credentials(self): + """Test GitHub provider is disabled when credentials are missing.""" + provider = GithubOAuthProvider.__new__(GithubOAuthProvider) + provider.provider_config = { + "client_id": "", + "client_secret": "", + } + + assert provider.is_enabled is False + + +class TestUsernameExtraction: + """Test username extraction from email addresses.""" + + @pytest.mark.asyncio + async def test_extract_username_with_periods(self, mock_db): + """Test username keeps periods from email.""" + oauth_user = MockOpenID(email="test.user.name@example.com", display_name="Test User") + + provider = GithubOAuthProvider.__new__(GithubOAuthProvider) + provider.provider_name = "github" + + user_internal = await provider._get_user_details(oauth_user) + + assert user_internal.username == "test.user.name" + assert user_internal.email == "test.user.name@example.com" + assert user_internal.hashed_password is None + + @pytest.mark.asyncio + async def test_extract_username_with_special_chars(self, mock_db): + """Test username keeps valid email special characters.""" + oauth_user = MockOpenID( + email="User.Name+Tag-12@example.com", + display_name="Test User" + ) + + provider = GithubOAuthProvider.__new__(GithubOAuthProvider) + provider.provider_name = "github" + + user_internal = await provider._get_user_details(oauth_user) + + assert user_internal.username == "user.name+tag-12" + assert user_internal.hashed_password is None + assert user_internal.username.islower() + assert "." in user_internal.username + assert "+" in user_internal.username + assert "-" in user_internal.username + + @pytest.mark.asyncio + async def test_extract_username_lowercase_conversion(self, mock_db): + """Test username is converted to lowercase.""" + oauth_user = MockOpenID(email="TestUser@example.com", display_name="Test User") + + provider = GithubOAuthProvider.__new__(GithubOAuthProvider) + provider.provider_name = "github" + + user_internal = await provider._get_user_details(oauth_user) + + assert user_internal.username == "testuser" + assert user_internal.username.islower() + + @pytest.mark.asyncio + async def test_extract_common_email_patterns(self, mock_db): + """Test username extraction with common real-world email patterns.""" + test_cases = [ + ("john.doe@gmail.com", "john.doe"), + ("jane_smith@outlook.com", "jane_smith"), + ("user+tag@example.com", "user+tag"), + ("first-last@company.com", "first-last"), + ("test.user_name-123@domain.com", "test.user_name-123"), + ] + + provider = GithubOAuthProvider.__new__(GithubOAuthProvider) + provider.provider_name = "github" + + for email, expected_username in test_cases: + oauth_user = MockOpenID(email=email, display_name="Test User") + user_internal = await provider._get_user_details(oauth_user) + assert user_internal.username == expected_username, f"Failed for {email}" + assert user_internal.username.islower() + + @pytest.mark.asyncio + async def test_extract_username_mixed_case(self, mock_db): + """Test mixed case email is converted to lowercase username.""" + oauth_user = MockOpenID( + email="User.Name+Tag@example.com", + display_name="Test User" + ) + + provider = GithubOAuthProvider.__new__(GithubOAuthProvider) + provider.provider_name = "github" + + user_internal = await provider._get_user_details(oauth_user) + + assert user_internal.username == "user.name+tag" + assert user_internal.username.islower() + assert "." in user_internal.username + assert "+" in user_internal.username + + +class TestOAuthCallback: + """Test OAuth callback handler.""" + + @pytest.mark.asyncio + async def test_callback_creates_new_user(self, mock_db): + """Test OAuth callback creates new user when email doesn't exist.""" + oauth_user = MockOpenID(email="newuser@example.com", display_name="New User") + + mock_request = Mock() + mock_response = Mock() + + provider = GithubOAuthProvider.__new__(GithubOAuthProvider) + provider.provider_name = "github" + provider.sso_provider = Mock() + + with patch("src.app.api.v1.oauth.crud_users") as mock_crud: + mock_crud.get = AsyncMock(return_value=None) + + with patch("src.app.api.v1.oauth.write_user_internal") as mock_write: + mock_write.return_value = { + "id": 1, + "username": "newuser", + "email": "newuser@example.com", + "name": "New User", + } + + with patch("src.app.api.v1.oauth.create_access_token") as mock_access: + with patch("src.app.api.v1.oauth.create_refresh_token") as mock_refresh: + mock_access.return_value = "access_token" + mock_refresh.return_value = "refresh_token" + + mock_sso = Mock() + mock_sso.__aenter__ = AsyncMock(return_value=mock_sso) + mock_sso.__aexit__ = AsyncMock(return_value=None) + mock_sso.verify_and_process = AsyncMock(return_value=oauth_user) + + provider.sso = mock_sso + + result = await provider._callback_handler(mock_request, mock_response, mock_db) + + assert result["access_token"] == "access_token" + assert result["token_type"] == "bearer" + mock_write.assert_called_once() + + call_args = mock_write.call_args + user_arg = call_args.kwargs["user"] + assert isinstance(user_arg, UserCreateInternal) + assert user_arg.hashed_password is None + + @pytest.mark.asyncio + async def test_callback_existing_user_login(self, mock_db): + """Test OAuth callback logs in existing user.""" + oauth_user = MockOpenID(email="existing@example.com", display_name="Existing User") + + mock_request = Mock() + mock_response = Mock() + + provider = GithubOAuthProvider.__new__(GithubOAuthProvider) + provider.provider_name = "github" + + existing_user = { + "id": 1, + "username": "existing", + "email": "existing@example.com", + "name": "Existing User", + } + + with patch("src.app.api.v1.oauth.crud_users") as mock_crud: + mock_crud.get = AsyncMock(return_value=existing_user) + + with patch("src.app.api.v1.oauth.write_user_internal") as mock_write: + with patch("src.app.api.v1.oauth.create_access_token") as mock_access: + with patch("src.app.api.v1.oauth.create_refresh_token") as mock_refresh: + mock_access.return_value = "access_token" + mock_refresh.return_value = "refresh_token" + + mock_sso = Mock() + mock_sso.__aenter__ = AsyncMock(return_value=mock_sso) + mock_sso.__aexit__ = AsyncMock(return_value=None) + mock_sso.verify_and_process = AsyncMock(return_value=oauth_user) + + provider.sso = mock_sso + + result = await provider._callback_handler(mock_request, mock_response, mock_db) + + assert result["access_token"] == "access_token" + mock_write.assert_not_called() + + @pytest.mark.asyncio + async def test_callback_no_email_raises_error(self, mock_db): + """Test OAuth callback raises error when email is missing.""" + oauth_user = MockOpenID(email=None, display_name="User") + + mock_request = Mock() + mock_response = Mock() + + provider = GithubOAuthProvider.__new__(GithubOAuthProvider) + provider.provider_name = "github" + + mock_sso = Mock() + mock_sso.__aenter__ = AsyncMock(return_value=mock_sso) + mock_sso.__aexit__ = AsyncMock(return_value=None) + mock_sso.verify_and_process = AsyncMock(return_value=oauth_user) + + provider.sso = mock_sso + + with pytest.raises(UnauthorizedException, match="Invalid response from Github OAuth"): + await provider._callback_handler(mock_request, mock_response, mock_db) + + +class TestOAuthSecurity: + """Test OAuth security features.""" + + @pytest.mark.asyncio + async def test_oauth_user_has_null_password(self, mock_db): + """Test OAuth users are created with NULL password.""" + oauth_user = MockOpenID(email="oauth@example.com", display_name="OAuth User") + + provider = GithubOAuthProvider.__new__(GithubOAuthProvider) + provider.provider_name = "github" + + user_internal = await provider._get_user_details(oauth_user) + + assert user_internal.hashed_password is None + + @pytest.mark.asyncio + async def test_oauth_user_cannot_password_login(self, mock_db): + """Test OAuth users cannot login with password authentication.""" + from src.app.core.security import authenticate_user + + oauth_user = { + "username": "oauthuser", + "email": "oauth@example.com", + "hashed_password": None, + } + + with patch("src.app.core.security.crud_users") as mock_crud: + mock_crud.get = AsyncMock(return_value=oauth_user) + + result = await authenticate_user( + username_or_email="oauthuser", + password="any_password", + db=mock_db + ) + + assert result is False + + +class TestMultipleProviders: + """Test all OAuth providers work consistently.""" + + @pytest.mark.asyncio + async def test_github_provider_extracts_username(self, mock_db): + """Test GitHub provider extracts usernames correctly.""" + oauth_user = MockOpenID(email="test.user@example.com") + + provider = GithubOAuthProvider.__new__(GithubOAuthProvider) + provider.provider_name = "github" + + user = await provider._get_user_details(oauth_user) + assert user.username == "test.user" + + @pytest.mark.asyncio + async def test_google_provider_extracts_username(self, mock_db): + """Test Google provider extracts usernames correctly.""" + oauth_user = MockOpenID(email="test.user@example.com") + + provider = GoogleOAuthProvider.__new__(GoogleOAuthProvider) + provider.provider_name = "google" + + user = await provider._get_user_details(oauth_user) + assert user.username == "test.user" + + @pytest.mark.asyncio + async def test_microsoft_provider_extracts_username(self, mock_db): + """Test Microsoft provider extracts usernames correctly.""" + oauth_user = MockOpenID(email="test.user@example.com") + + provider = MicrosoftOAuthProvider.__new__(MicrosoftOAuthProvider) + provider.provider_name = "microsoft" + + user = await provider._get_user_details(oauth_user) + assert user.username == "test.user"