Skip to content

Commit 8f7ca7f

Browse files
committed
feat: implement refresh token handling and improve authentication flow; add email stats endpoint
1 parent 02af9b1 commit 8f7ca7f

19 files changed

Lines changed: 477 additions & 90 deletions

File tree

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"WebFetch(domain:sarvam.ai)",
2020
"WebFetch(domain:relay.app)",
2121
"WebFetch(domain:stack-ai.com)",
22-
"Bash(xargs basename:*)"
22+
"Bash(xargs basename:*)",
23+
"Bash(ls:*)"
2324
]
2425
}
2526
}

backend/app/api/middleware/csrf.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,11 @@
77

88
from __future__ import annotations
99

10-
import hashlib
1110
import hmac
1211
import logging
1312
import secrets
14-
from datetime import UTC, datetime, timedelta
1513

16-
from fastapi import Request, Response
14+
from fastapi import Request
1715
from starlette.middleware.base import BaseHTTPMiddleware
1816

1917
logger = logging.getLogger(__name__)
@@ -27,6 +25,9 @@
2725
# Paths that are exempt from CSRF protection (e.g., webhook endpoints)
2826
EXEMPT_PATHS = frozenset({
2927
"/api/v1/auth/gmail/callback", # OAuth callback doesn't need CSRF
28+
"/api/v1/auth/login",
29+
"/api/v1/auth/refresh", # Uses httpOnly cookie — already secure
30+
"/api/v1/auth/logout", # Must work even when session is expired
3031
"/health",
3132
"/docs",
3233
"/redoc",
@@ -55,12 +56,12 @@ def _should_skip_csrf(request: Request) -> bool:
5556
# Skip for safe methods
5657
if request.method in SAFE_METHODS:
5758
return True
58-
59+
5960
# Skip for exempt paths
6061
path = request.url.path
6162
if any(path.startswith(exempt) for exempt in EXEMPT_PATHS):
6263
return True
63-
64+
6465
return False
6566

6667

@@ -80,19 +81,19 @@ class CSRFMiddleware(BaseHTTPMiddleware):
8081

8182
async def dispatch(self, request: Request, call_next):
8283
"""Process the request with CSRF protection."""
83-
84+
8485
# Get existing CSRF token from cookie or generate new one
8586
csrf_cookie = request.cookies.get(CSRF_COOKIE_NAME)
8687
if not csrf_cookie:
8788
csrf_cookie = _generate_csrf_token()
88-
89+
8990
# Store token in request state for response handler
9091
request.state.csrf_token = csrf_cookie
91-
92+
9293
# Check if we need to validate CSRF token
9394
if not _should_skip_csrf(request):
9495
csrf_header = request.headers.get(CSRF_HEADER_NAME)
95-
96+
9697
if not csrf_header:
9798
logger.warning(
9899
"CSRF token missing in header for %s %s",
@@ -107,7 +108,7 @@ async def dispatch(self, request: Request, call_next):
107108
"code": "CSRF_TOKEN_MISSING"
108109
}
109110
)
110-
111+
111112
if not hmac.compare_digest(csrf_cookie, csrf_header):
112113
logger.warning(
113114
"CSRF token mismatch for %s %s",
@@ -122,19 +123,19 @@ async def dispatch(self, request: Request, call_next):
122123
"code": "CSRF_TOKEN_INVALID"
123124
}
124125
)
125-
126+
126127
# Process the request
127128
response = await call_next(request)
128-
129+
129130
# Set/update CSRF cookie
130131
# Use SameSite=Strict to prevent cross-site requests from sending the cookie
131132
# Use HttpOnly=False so JavaScript can read it (needed for double-submit pattern)
132133
# Use Secure in production
133134
from app.core.config import settings
134-
135+
135136
cookie_value = request.state.csrf_token
136137
max_age = CSRF_TOKEN_EXPIRY_HOURS * 3600
137-
138+
138139
response.set_cookie(
139140
key=CSRF_COOKIE_NAME,
140141
value=cookie_value,
@@ -144,7 +145,7 @@ async def dispatch(self, request: Request, call_next):
144145
samesite="strict",
145146
path="/",
146147
)
147-
148+
148149
return response
149150

150151

backend/app/api/routes/auth.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@
88
from sqlalchemy import select
99
from sqlalchemy.ext.asyncio import AsyncSession
1010

11+
from app.api.middleware.rate_limit import auth_rate_limit
12+
from app.core.config import settings
1113
from app.core.database import get_db
12-
from app.core.deps import get_current_user, oauth2_scheme
14+
from app.core.deps import oauth2_scheme
1315
from app.core.security import (
1416
create_access_token,
1517
create_password_reset_token,
18+
create_refresh_token,
1619
decode_token,
1720
hash_password,
1821
)
@@ -28,9 +31,7 @@
2831
RegisterResponse,
2932
ResetPasswordRequest,
3033
TokenResponse,
31-
TokenResponseWithRefresh,
3234
)
33-
from app.api.middleware.rate_limit import auth_rate_limit
3435
from app.services import auth_service
3536

3637
logger = logging.getLogger(__name__)
@@ -78,7 +79,7 @@ async def login(
7879
Set use_cookies=false to receive the refresh token in the response body (less secure).
7980
"""
8081
result = await auth_service.login(db, body.email, body.password)
81-
82+
8283
# Set refresh token as httpOnly cookie for security (SEC-3 fix)
8384
if use_cookies and result.refresh_token:
8485
response.set_cookie(
@@ -90,7 +91,7 @@ async def login(
9091
max_age=7 * 24 * 60 * 60, # 7 days
9192
path="/api/v1/auth/refresh", # Only sent to refresh endpoint
9293
)
93-
94+
9495
return TokenResponse(
9596
access_token=result.access_token,
9697
token_type=result.token_type,
@@ -116,16 +117,16 @@ async def refresh(
116117
"""
117118
# Try to get refresh token from cookie first (more secure)
118119
refresh_token = request.cookies.get("refresh_token") or token
119-
120+
120121
if not refresh_token:
121122
raise HTTPException(
122123
status_code=status.HTTP_401_UNAUTHORIZED,
123124
detail="Refresh token required",
124125
headers={"WWW-Authenticate": "Bearer"},
125126
)
126-
127+
127128
result = await auth_service.refresh_token(db, refresh_token)
128-
129+
129130
# Update refresh token cookie if using cookie-based auth
130131
if request.cookies.get("refresh_token") and result.refresh_token:
131132
response.set_cookie(
@@ -137,7 +138,7 @@ async def refresh(
137138
max_age=7 * 24 * 60 * 60,
138139
path="/api/v1/auth/refresh",
139140
)
140-
141+
141142
return TokenResponse(
142143
access_token=result.access_token,
143144
token_type=result.token_type,
@@ -185,6 +186,7 @@ async def gmail_oauth_url() -> GmailUrlResponse:
185186
async def gmail_callback(
186187
body: GmailCallbackRequest,
187188
request: Request,
189+
response: Response,
188190
db: AsyncSession = Depends(get_db),
189191
) -> GmailCallbackResponse:
190192
"""Exchange the Google OAuth authorization code.
@@ -218,6 +220,21 @@ async def gmail_callback(
218220

219221
# Unauthenticated flow – login / register via Gmail.
220222
result = await auth_service.handle_gmail_login(db, body.code)
223+
224+
# Mirror the login endpoint: issue a refresh token and deliver it as an
225+
# httpOnly cookie so the client can silently renew the access token.
226+
if result.get("access_token"):
227+
refresh_token = create_refresh_token({"sub": result["email"]})
228+
response.set_cookie(
229+
key="refresh_token",
230+
value=refresh_token,
231+
httponly=True,
232+
secure=settings.is_production,
233+
samesite="strict",
234+
max_age=7 * 24 * 60 * 60, # 7 days
235+
path="/api/v1/auth/refresh",
236+
)
237+
221238
return GmailCallbackResponse(
222239
status=result["status"],
223240
email=result["email"],

backend/app/api/routes/emails.py

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,34 @@ async def get_sync_status(
100100
}
101101

102102

103+
@router.get("/stats", response_model=EmailStatsResponse, summary="Get email counts by status")
104+
async def get_email_stats(
105+
db: AsyncSession = Depends(get_db),
106+
user: User = Depends(get_current_user),
107+
) -> EmailStatsResponse:
108+
"""Get email counts by status for the current user."""
109+
result = await db.execute(
110+
select(Email.status, func.count())
111+
.where(Email.user_id == user.id)
112+
.group_by(Email.status)
113+
)
114+
115+
counts = {status: 0 for status in EmailStatus}
116+
for status, count in result.all():
117+
counts[status] = count
118+
119+
return EmailStatsResponse(
120+
pending=counts.get(EmailStatus.PENDING, 0),
121+
processing=counts.get(EmailStatus.PROCESSING, 0),
122+
drafted=counts.get(EmailStatus.DRAFTED, 0),
123+
needs_review=counts.get(EmailStatus.NEEDS_REVIEW, 0),
124+
approved=counts.get(EmailStatus.APPROVED, 0),
125+
sent=counts.get(EmailStatus.SENT, 0),
126+
rejected=counts.get(EmailStatus.REJECTED, 0),
127+
total=sum(counts.values()),
128+
)
129+
130+
103131
@router.get(
104132
"/{email_id}",
105133
response_model=EmailDetailResponse,
@@ -133,31 +161,3 @@ async def process_email(
133161
trace_id=result["trace_id"],
134162
status=result["status"],
135163
)
136-
137-
138-
@router.get("/stats", response_model=EmailStatsResponse, summary="Get email counts by status")
139-
async def get_email_stats(
140-
db: AsyncSession = Depends(get_db),
141-
user: User = Depends(get_current_user),
142-
) -> EmailStatsResponse:
143-
"""Get email counts by status for the current user."""
144-
result = await db.execute(
145-
select(Email.status, func.count())
146-
.where(Email.user_id == user.id)
147-
.group_by(Email.status)
148-
)
149-
150-
counts = {status: 0 for status in EmailStatus}
151-
for status, count in result.all():
152-
counts[status] = count
153-
154-
return EmailStatsResponse(
155-
pending=counts.get(EmailStatus.PENDING, 0),
156-
processing=counts.get(EmailStatus.PROCESSING, 0),
157-
drafted=counts.get(EmailStatus.DRAFTED, 0),
158-
needs_review=counts.get(EmailStatus.NEEDS_REVIEW, 0),
159-
approved=counts.get(EmailStatus.APPROVED, 0),
160-
sent=counts.get(EmailStatus.SENT, 0),
161-
rejected=counts.get(EmailStatus.REJECTED, 0),
162-
total=sum(counts.values()),
163-
)

backend/app/api/routes/notifications.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
1212
from jose import JWTError, jwt
1313
from sqlalchemy import select
14+
from starlette.websockets import WebSocketState
1415

1516
from app.core.config import settings
1617
from app.core.database import get_db
@@ -97,7 +98,7 @@ async def _heartbeat(websocket: WebSocket) -> None:
9798
while True:
9899
await asyncio.sleep(HEARTBEAT_INTERVAL)
99100
# Check if connection is still open before sending
100-
if websocket.client_state.CONNECTED:
101+
if websocket.client_state == WebSocketState.CONNECTED:
101102
await websocket.send_json({"type": "ping"})
102103
except asyncio.CancelledError:
103104
# Task was cancelled, exit gracefully

backend/app/services/auth_service.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
)
2323
from app.integrations.gmail.oauth import exchange_code
2424
from app.models.user import User
25-
from app.schemas.auth import TokenResponse
25+
from app.schemas.auth import TokenResponse, TokenResponseWithRefresh
2626

2727
logger = logging.getLogger(__name__)
2828

@@ -53,8 +53,8 @@ async def register(db: AsyncSession, email: str, password: str) -> User:
5353
return user
5454

5555

56-
async def login(db: AsyncSession, email: str, password: str) -> TokenResponse:
57-
"""Verify credentials and return a JWT access token.
56+
async def login(db: AsyncSession, email: str, password: str) -> TokenResponseWithRefresh:
57+
"""Verify credentials and return a JWT access token plus refresh token.
5858
5959
Raises HTTP 401 if the email is not found or the password is wrong.
6060
"""
@@ -72,7 +72,7 @@ async def login(db: AsyncSession, email: str, password: str) -> TokenResponse:
7272
access_token = create_access_token(token_data)
7373
refresh_token = create_refresh_token(token_data)
7474
logger.info("User logged in email=%s", email)
75-
return TokenResponse(
75+
return TokenResponseWithRefresh(
7676
access_token=access_token,
7777
refresh_token=refresh_token,
7878
token_type="bearer",
@@ -282,7 +282,6 @@ async def refresh_user_gmail_token(db: AsyncSession, user_id: uuid.UUID) -> bool
282282
True if token was refreshed (or still valid), False if refresh failed
283283
or user has no refresh token.
284284
"""
285-
import time
286285

287286
from app.core.config import settings
288287

backend/app/services/email_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,8 @@ def on_token_refresh(updated_creds: dict[str, Any]) -> None:
260260
except Exception as crm_exc:
261261
logger.warning("CRM auto-populate failed: %s", crm_exc)
262262

263+
return {"fetched": len(raw_emails), "created": created}
264+
263265

264266
def _enrich_and_create_contact(
265267
crm,

backend/app/services/scheduler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ async def _sync_all_users() -> None:
4040
for user in users:
4141
try:
4242
async with async_session_factory() as db:
43-
sync_result = await _sync_emails_core(db, user)
43+
sync_result = await _sync_emails_core(db, user) or {}
4444
await db.commit()
4545

4646
# Auto-classify if new emails were found

0 commit comments

Comments
 (0)