Skip to content

Commit 2196290

Browse files
committed
feat: add social login functionality and external account management
- Introduced social login support for Google and Apple in the authentication flow. - Created new schemas for social login and external account management. - Implemented integration service for connecting external accounts and handling OAuth2 flows. - Added new models and enums for external account providers. - Updated user model to include provider information. - Enhanced error handling and validation for social login requests. - Added unit tests for new functionalities and updated existing tests for coverage.
1 parent 9ff5375 commit 2196290

24 files changed

Lines changed: 1453 additions & 7 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ node_modules/
77

88
# macOS
99
.DS_Store
10+
11+
.env
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""create a new table
2+
3+
Revision ID: e98732087769
4+
Revises: c09c4a1bfec5
5+
Create Date: 2025-12-22 16:42:32.197493
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
import sqlmodel.sql.sqltypes
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = 'e98732087769'
15+
down_revision = 'c09c4a1bfec5'
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
# ### commands auto generated by Alembic - please adjust! ###
22+
# Create enum types first (Postgres requires the type to exist before using it)
23+
auth_provider = sa.Enum('google', 'apple', name='authprovider')
24+
auth_provider.create(op.get_bind(), checkfirst=True)
25+
26+
op.create_index(op.f('ix_otp_token_status'), 'otp', ['token_status'], unique=False)
27+
op.add_column('user', sa.Column('provider', auth_provider, nullable=True))
28+
op.add_column('user', sa.Column('provider_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
29+
op.create_index(op.f('ix_user_provider'), 'user', ['provider'], unique=False)
30+
op.create_index(op.f('ix_user_provider_id'), 'user', ['provider_id'], unique=False)
31+
# ### end Alembic commands ###
32+
33+
34+
def downgrade():
35+
# ### commands auto generated by Alembic - please adjust! ###
36+
op.drop_index(op.f('ix_user_provider_id'), table_name='user')
37+
op.drop_index(op.f('ix_user_provider'), table_name='user')
38+
op.drop_column('user', 'provider_id')
39+
op.drop_column('user', 'provider')
40+
op.drop_index(op.f('ix_otp_token_status'), table_name='otp')
41+
# Drop enum types if they exist
42+
auth_provider = sa.Enum('google', 'apple', name='authprovider')
43+
auth_provider.drop(op.get_bind(), checkfirst=True)
44+
# ### end Alembic commands ###
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""add external-account-table
2+
3+
Revision ID: eec7222e7348
4+
Revises: e98732087769
5+
Create Date: 2025-12-22 16:51:35.314511
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
import sqlmodel.sql.sqltypes
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = 'eec7222e7348'
15+
down_revision = 'e98732087769'
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
# ### commands auto generated by Alembic - please adjust! ###
22+
pass
23+
# ### end Alembic commands ###
24+
25+
26+
def downgrade():
27+
# ### commands auto generated by Alembic - please adjust! ###
28+
pass
29+
# ### end Alembic commands ###

backend/app/api/controllers/auth_controller.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
LoginSchema,
1010
ResendEmailSchema,
1111
ResetPasswordSchema,
12+
SocialLoginSchema,
1213
VerifySchema,
1314
)
1415
from app.services.auth_service import AuthService
@@ -161,3 +162,16 @@ async def reset_password(self, request: ResetPasswordSchema) -> JSONResponse:
161162
)
162163
except Exception as exc:
163164
return self._error(exc)
165+
166+
async def social_login(self, request: SocialLoginSchema) -> JSONResponse:
167+
try:
168+
result = await self.service.social_login(
169+
provider=request.provider, access_token=request.access_token
170+
)
171+
return self._success(
172+
data=result,
173+
message=MSG.AUTH["SUCCESS"]["USER_LOGGED_IN"],
174+
status_code=status.HTTP_200_OK,
175+
)
176+
except Exception as exc:
177+
return self._error(exc)
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import uuid
2+
from typing import Any
3+
4+
from fastapi.responses import JSONResponse
5+
from starlette import status
6+
7+
from app.core.exceptions import AppException
8+
from app.schemas.external_account import ExternalAccountCreate
9+
from app.schemas.response import ResponseSchema
10+
from app.services.integrations_service import IntegrationService
11+
12+
13+
class IntegrationsController:
14+
def __init__(self) -> None:
15+
self.service = IntegrationService()
16+
self.response_class: type[ResponseSchema[Any]] = ResponseSchema
17+
self.error_class = AppException
18+
19+
def _success(
20+
self,
21+
data: Any = None,
22+
message: str = "OK",
23+
status_code: int = status.HTTP_200_OK,
24+
) -> JSONResponse:
25+
msg = message
26+
data_payload = data
27+
28+
if isinstance(data, dict):
29+
msg = data.get("message") or message
30+
if "user" in data:
31+
data_payload = data.get("user")
32+
elif "data" in data:
33+
data_payload = data.get("data")
34+
if isinstance(data_payload, dict) and "message" in data_payload:
35+
data_payload = {
36+
k: v for k, v in data_payload.items() if k != "message"
37+
}
38+
39+
payload = self.response_class(
40+
success=True,
41+
message=msg,
42+
data=data_payload,
43+
errors=None,
44+
meta=None,
45+
).model_dump(exclude_none=True)
46+
47+
return JSONResponse(status_code=status_code, content=payload)
48+
49+
def _error(
50+
self, message: Any = "Error", errors: Any = None, status_code: int | None = None
51+
) -> JSONResponse:
52+
code = status_code
53+
if isinstance(message, self.error_class):
54+
exc = message
55+
fallback_status = getattr(exc, "status_code", status.HTTP_400_BAD_REQUEST)
56+
if code is None:
57+
if isinstance(fallback_status, int):
58+
code = fallback_status
59+
else:
60+
code = status.HTTP_400_BAD_REQUEST
61+
payload = self.response_class(
62+
success=False,
63+
message=getattr(exc, "message", str(exc)),
64+
errors=getattr(exc, "details", None),
65+
data=None,
66+
).model_dump(exclude_none=True)
67+
return JSONResponse(status_code=int(code), content=payload)
68+
69+
code = code if code is not None else status.HTTP_400_BAD_REQUEST
70+
msg = str(message)
71+
72+
payload = self.response_class(
73+
success=False,
74+
message=msg,
75+
errors=errors,
76+
data=None,
77+
).model_dump(exclude_none=True)
78+
79+
return JSONResponse(status_code=int(code), content=payload)
80+
81+
async def connect_account(
82+
self,
83+
request: ExternalAccountCreate,
84+
user_id: uuid.UUID,
85+
) -> JSONResponse:
86+
try:
87+
account = await self.service.connect_account(
88+
user_id=user_id,
89+
provider=request.provider,
90+
provider_account_id=request.provider_account_id,
91+
access_token=request.access_token,
92+
refresh_token=request.refresh_token,
93+
extra_data=request.extra_data,
94+
)
95+
return self._success(data=account, message="Account connected")
96+
except Exception as e:
97+
return self._error(message=e)
98+
99+
async def get_google_drive_auth_url(
100+
self,
101+
user_id: uuid.UUID,
102+
) -> JSONResponse:
103+
"""Get Google Drive OAuth2 authorization URL"""
104+
try:
105+
auth_data = self.service.get_google_drive_auth_url(user_id=user_id)
106+
return self._success(
107+
data=auth_data,
108+
message="Google Drive authorization URL generated",
109+
)
110+
except Exception as e:
111+
return self._error(message=e)
112+
113+
async def google_drive_callback(
114+
self,
115+
code: str,
116+
user_id: uuid.UUID,
117+
state: str | None = None,
118+
) -> JSONResponse:
119+
"""Handle Google Drive OAuth2 callback"""
120+
try:
121+
account = await self.service.exchange_google_drive_code(
122+
code=code,
123+
user_id=user_id,
124+
)
125+
return self._success(
126+
data=account,
127+
message="Google Drive account connected successfully",
128+
)
129+
except Exception as e:
130+
return self._error(message=e)

backend/app/api/deps.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from collections.abc import Generator
22
from typing import Annotated
33

4-
from fastapi import Depends
4+
import jwt
5+
from fastapi import Depends, HTTPException, status
56
from fastapi.security import OAuth2PasswordBearer
67
from sqlmodel import Session
78

9+
from app.core import security
810
from app.core.config import settings
911
from app.core.db import get_engine
1012

@@ -20,3 +22,24 @@ def get_db() -> Generator[Session, None, None]:
2022

2123
SessionDep = Annotated[Session, Depends(get_db)]
2224
TokenDep = Annotated[str, Depends(reusable_oauth2)]
25+
26+
27+
def get_current_user_id(token: str = Depends(reusable_oauth2)) -> str:
28+
try:
29+
payload = jwt.decode(
30+
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
31+
)
32+
sub = payload.get("sub")
33+
if not sub:
34+
raise HTTPException(
35+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload"
36+
)
37+
return str(sub)
38+
except jwt.ExpiredSignatureError:
39+
raise HTTPException(
40+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
41+
)
42+
except jwt.PyJWTError:
43+
raise HTTPException(
44+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
45+
)

backend/app/api/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from fastapi import APIRouter
22

3-
from app.api.routes import auth, utils, ws
3+
from app.api.routes import auth, integrations, utils, ws
44

55
api_router = APIRouter()
66
api_router.include_router(auth.router)
77
api_router.include_router(ws.router)
88
api_router.include_router(utils.router)
9+
api_router.include_router(integrations.router)

backend/app/api/routes/auth.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
LoginSchema,
77
ResendEmailSchema,
88
ResetPasswordSchema,
9+
SocialLoginSchema,
910
VerifySchema,
1011
)
1112

@@ -44,6 +45,11 @@ async def reset_password(request: ResetPasswordSchema) -> JSONResponse:
4445
return await controller.reset_password(request)
4546

4647

48+
@router.post("/social-login")
49+
async def social_login(request: SocialLoginSchema) -> JSONResponse:
50+
return await controller.social_login(request)
51+
52+
4753
@router.post("/logout")
4854
async def logout() -> JSONResponse:
4955
return await controller.logout()
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import uuid
2+
3+
from fastapi import APIRouter, Depends
4+
from fastapi.responses import JSONResponse
5+
6+
from app.api.controllers.integrations_controller import IntegrationsController
7+
from app.api.deps import get_current_user_id
8+
from app.schemas.external_account import ExternalAccountCreate, callback_request
9+
10+
router = APIRouter(prefix="/integrations", tags=["integrations"])
11+
controller = IntegrationsController()
12+
13+
14+
@router.post(
15+
"/connect",
16+
)
17+
async def connect_account(
18+
request: ExternalAccountCreate,
19+
user_id: uuid.UUID = Depends(get_current_user_id),
20+
) -> JSONResponse:
21+
return await controller.connect_account(request, user_id=user_id)
22+
23+
24+
@router.get(
25+
"/google-drive/auth-url",
26+
)
27+
async def get_google_drive_auth_url(
28+
user_id: uuid.UUID = Depends(get_current_user_id),
29+
) -> JSONResponse:
30+
return await controller.get_google_drive_auth_url(user_id=user_id)
31+
32+
33+
@router.get(
34+
"/google-drive/callback",
35+
)
36+
async def google_drive_callback(
37+
request: callback_request,
38+
user_id: uuid.UUID = Depends(get_current_user_id),
39+
) -> JSONResponse:
40+
return await controller.google_drive_callback(
41+
code=request.code, state=request.state, user_id=user_id
42+
)

backend/app/core/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,11 @@ def r2_boto3_config(self) -> dict[str, Any]:
157157
WEBENGAGE_CAMPAIGN_REGISTER_ID: str | None = None
158158
WEBENGAGE_CAMPAIGN_FORGOT_PASSWORD_ID: str | None = None
159159

160+
# Google OAuth2 settings for Google Drive integration
161+
GOOGLE_CLIENT_ID: str | None = None
162+
GOOGLE_CLIENT_SECRET: str | None = None
163+
GOOGLE_REDIRECT_URI: str | None = None
164+
160165
def _check_default_secret(self, var_name: str, value: str | None) -> None:
161166
if value == "changethis":
162167
message = (

0 commit comments

Comments
 (0)