Tahasaif3 commited on
Commit
f113aa5
·
1 Parent(s): 1307382
README.md CHANGED
@@ -19,4 +19,53 @@ If your backend is hosted on a different domain (e.g., Hugging Face Spaces) than
19
  - Frontend fetch calls that rely on cookies must use `credentials: 'include'` (e.g. `fetch(url, { method, credentials: 'include' })`).
20
  - Backend CORS must have `allow_credentials=True` and the exact frontend origin (not `*`) in allowed origins. This project reads `FRONTEND_URL` for that purpose.
21
 
22
- If cookies are still not sent/received, open DevTools network tab and inspect the `Set-Cookie` response header and browser cookie/journal to see why it was blocked (e.g., SameSite/Secure/Domain issues).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  - Frontend fetch calls that rely on cookies must use `credentials: 'include'` (e.g. `fetch(url, { method, credentials: 'include' })`).
20
  - Backend CORS must have `allow_credentials=True` and the exact frontend origin (not `*`) in allowed origins. This project reads `FRONTEND_URL` for that purpose.
21
 
22
+ ### Refresh token flow (implemented)
23
+ - On login the backend now returns a **short-lived access token** (in the response) and sets a **HttpOnly refresh cookie** (`/api/auth` path).
24
+ - To get a new access token, call `POST /api/auth/refresh` with `credentials: 'include'`. The server validates and rotates the refresh token and returns a new access token in the response body.
25
+ - On logout the server revokes the refresh token and clears the refresh cookie.
26
+
27
+ Client example (login + using refresh) — Vercel frontend:
28
+
29
+ 1) Login (receives access token in response body and a HttpOnly refresh cookie):
30
+
31
+ ```js
32
+ const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/login`, {
33
+ method: 'POST',
34
+ headers: { 'Content-Type': 'application/json' },
35
+ credentials: 'include',
36
+ body: JSON.stringify({ email, password }),
37
+ })
38
+ const data = await res.json()
39
+ const accessToken = data.access_token
40
+ // Store accessToken in memory (React state)
41
+ ```
42
+
43
+ 2) Use access token for protected calls (in memory, sent in Authorization header):
44
+
45
+ ```js
46
+ await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/tasks`, {
47
+ headers: { 'Authorization': `Bearer ${accessToken}` }
48
+ })
49
+ ```
50
+
51
+ 3) Obtain a fresh access token when it expires:
52
+
53
+ ```js
54
+ const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/refresh`, {
55
+ method: 'POST',
56
+ credentials: 'include', // sends HttpOnly refresh cookie
57
+ })
58
+ const data = await res.json()
59
+ const accessToken = data.access_token
60
+ ```
61
+
62
+ If cookies are still not sent/received, open DevTools network tab and inspect the `Set-Cookie` response header and browser cookie/journal to see why it was blocked (e.g., SameSite/Secure/Domain issues).
63
+
64
+ Database migration note:
65
+ - After pulling these changes, run Alembic migrations to create the `refreshtoken` table:
66
+
67
+ ```bash
68
+ alembic upgrade head
69
+ ```
70
+
71
+ (If you deploy to Spaces, ensure your deployment runs the migration step or create the table manually.)
alembic/__pycache__/env.cpython-312.pyc CHANGED
Binary files a/alembic/__pycache__/env.cpython-312.pyc and b/alembic/__pycache__/env.cpython-312.pyc differ
 
alembic/env.py CHANGED
@@ -14,6 +14,7 @@ sys.path.append(os.path.dirname(os.path.dirname(__file__)))
14
  from src.models.user import User # Import your models
15
  from src.models.task import Task # Import your models
16
  from src.models.project import Project # Import your models
 
17
 
18
 
19
  # this is the Alembic Config object, which provides
 
14
  from src.models.user import User # Import your models
15
  from src.models.task import Task # Import your models
16
  from src.models.project import Project # Import your models
17
+ from src.models.refresh_token import RefreshToken # Add RefreshToken model for migrations
18
 
19
 
20
  # this is the Alembic Config object, which provides
alembic/versions/20251222_add_refresh_token_table.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """add refresh token table
2
+
3
+ Revision ID: 20251222_add_refresh_token_table
4
+ Revises:
5
+ Create Date: 2025-12-22 00:00:00.000000
6
+ """
7
+ from alembic import op
8
+ import sqlalchemy as sa
9
+ from sqlalchemy.dialects import postgresql
10
+
11
+ # revision identifiers, used by Alembic.
12
+ revision = '20251222_add_refresh_token_table'
13
+ down_revision = '4ac448e3f100' # depends on last migration in the main chain
14
+ branch_labels = None
15
+ depends_on = None
16
+
17
+
18
+ def upgrade() -> None:
19
+ op.create_table(
20
+ 'refreshtoken',
21
+ sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
22
+ sa.Column('token_hash', sa.String(length=128), nullable=False),
23
+ sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('user.id'), nullable=True),
24
+ sa.Column('revoked', sa.Boolean(), nullable=False, server_default=sa.text('false')),
25
+ sa.Column('created_at', sa.DateTime(), nullable=True),
26
+ sa.Column('expires_at', sa.DateTime(), nullable=True),
27
+ )
28
+ # create index separately (SQLAlchemy/Alembic prefers explicit index creation)
29
+ op.create_index('ix_refreshtoken_token_hash', 'refreshtoken', ['token_hash'])
30
+
31
+
32
+ def downgrade() -> None:
33
+ # drop index then table
34
+ op.drop_index('ix_refreshtoken_token_hash', table_name='refreshtoken')
35
+ op.drop_table('refreshtoken')
alembic/versions/__pycache__/20251222_add_refresh_token_table.cpython-312.pyc ADDED
Binary file (2.13 kB). View file
 
alembic/versions/__pycache__/3b6c60669e48_add_project_model_and_relationship_to_.cpython-312.pyc CHANGED
Binary files a/alembic/versions/__pycache__/3b6c60669e48_add_project_model_and_relationship_to_.cpython-312.pyc and b/alembic/versions/__pycache__/3b6c60669e48_add_project_model_and_relationship_to_.cpython-312.pyc differ
 
alembic/versions/__pycache__/ec70eaafa7b6_initial_schema_with_users_and_tasks_.cpython-312.pyc CHANGED
Binary files a/alembic/versions/__pycache__/ec70eaafa7b6_initial_schema_with_users_and_tasks_.cpython-312.pyc and b/alembic/versions/__pycache__/ec70eaafa7b6_initial_schema_with_users_and_tasks_.cpython-312.pyc differ
 
src/models/__pycache__/refresh_token.cpython-312.pyc ADDED
Binary file (1.41 kB). View file
 
src/models/refresh_token.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlmodel import SQLModel, Field, Relationship
2
+ from typing import Optional
3
+ import uuid
4
+ from datetime import datetime
5
+ from sqlalchemy import Column, DateTime, Boolean, String, ForeignKey
6
+
7
+
8
+ class RefreshToken(SQLModel, table=True):
9
+ id: Optional[uuid.UUID] = Field(default_factory=uuid.uuid4, primary_key=True)
10
+ # Use a plain sa_column without `index=True` (SQLModel does not allow index=True when sa_column is provided)
11
+ token_hash: str = Field(sa_column=Column(String(length=128)))
12
+ user_id: Optional[uuid.UUID] = Field(foreign_key="user.id")
13
+ revoked: bool = Field(default=False, sa_column=Column(Boolean, default=False))
14
+ created_at: datetime = Field(sa_column=Column(DateTime, default=datetime.utcnow))
15
+ expires_at: datetime = Field(sa_column=Column(DateTime))
16
+
17
+ # Relationship back to user is optional and not required for our use-case
src/routers/auth.py CHANGED
@@ -4,8 +4,10 @@ from typing import Annotated
4
  from datetime import datetime, timedelta
5
  from uuid import uuid4
6
  import secrets
 
7
 
8
  from ..models.user import User, UserCreate, UserRead
 
9
  from ..schemas.auth import RegisterRequest, RegisterResponse, LoginRequest, LoginResponse, ForgotPasswordRequest, ResetPasswordRequest
10
  from ..utils.security import hash_password, create_access_token, verify_password
11
  from ..utils.deps import get_current_user
@@ -15,6 +17,10 @@ from ..config import settings
15
 
16
  router = APIRouter(prefix="/api/auth", tags=["auth"])
17
 
 
 
 
 
18
  # NOTE: For cross-site cookie auth to work (backend on a different domain than the frontend):
19
  # - The frontend must call login/register endpoints with `fetch(..., credentials: 'include')`
20
  # - The backend must have CORS allow_credentials=True and the exact frontend origin in allow_origins
@@ -24,7 +30,10 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
24
 
25
  @router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED)
26
  def register(user_data: RegisterRequest, response: Response, session: Session = Depends(get_session_dep)):
27
- """Register a new user with email and password."""
 
 
 
28
 
29
  # Check if user already exists
30
  existing_user = session.exec(select(User).where(User.email == user_data.email)).first()
@@ -54,21 +63,29 @@ def register(user_data: RegisterRequest, response: Response, session: Session =
54
  session.commit()
55
  session.refresh(user)
56
 
57
- # Create access token
58
- access_token = create_access_token(data={"sub": str(user.id)})
59
-
60
- # Set the token as an httpOnly cookie
 
 
 
 
 
 
 
 
 
61
  response.set_cookie(
62
- key="access_token",
63
- value=access_token,
64
  httponly=True,
65
- secure=settings.JWT_COOKIE_SECURE, # True in production, False in development
66
- samesite="none", # Allow cross-site cookies; browsers require Secure for SameSite=None
67
- max_age=settings.ACCESS_TOKEN_EXPIRE_DAYS * 24 * 60 * 60, # Convert days to seconds
68
- path="/"
69
  )
70
 
71
- # Return response
72
  return RegisterResponse(
73
  id=user.id,
74
  email=user.email,
@@ -78,7 +95,11 @@ def register(user_data: RegisterRequest, response: Response, session: Session =
78
 
79
  @router.post("/login", response_model=LoginResponse)
80
  def login(login_data: LoginRequest, response: Response, session: Session = Depends(get_session_dep)):
81
- """Authenticate user with email and password, return JWT token."""
 
 
 
 
82
 
83
  # Find user by email
84
  user = session.exec(select(User).where(User.email == login_data.email)).first()
@@ -90,24 +111,32 @@ def login(login_data: LoginRequest, response: Response, session: Session = Depen
90
  headers={"WWW-Authenticate": "Bearer"},
91
  )
92
 
93
- # Create access token
94
- access_token = create_access_token(data={"sub": str(user.id)})
95
-
96
- # Set the token as an httpOnly cookie
 
 
 
 
 
 
 
 
 
97
  response.set_cookie(
98
- key="access_token",
99
- value=access_token,
100
  httponly=True,
101
- secure=settings.JWT_COOKIE_SECURE, # True in production, False in development
102
- samesite="none", # Allow cross-site cookies; browsers require Secure for SameSite=None
103
- max_age=settings.ACCESS_TOKEN_EXPIRE_DAYS * 24 * 60 * 60, # Convert days to seconds
104
- path="/"
105
  )
106
-
107
- # Debug: Print cookie attributes (do NOT log token value in production)
108
- print(f"Cookie set (httponly={True}, secure={settings.JWT_COOKIE_SECURE}, samesite=none, max_age={settings.ACCESS_TOKEN_EXPIRE_DAYS * 24 * 60 * 60})")
109
 
110
- # Return response
 
 
111
  return LoginResponse(
112
  access_token=access_token,
113
  token_type="bearer",
@@ -119,29 +148,93 @@ def login(login_data: LoginRequest, response: Response, session: Session = Depen
119
  )
120
 
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  @router.post("/logout")
123
- def logout(response: Response):
124
- """Logout user by clearing the access token cookie."""
125
- # Clear the access_token cookie
 
 
 
 
 
 
 
 
 
126
  response.set_cookie(
127
- key="access_token",
128
  value="",
129
  httponly=True,
130
  secure=settings.JWT_COOKIE_SECURE,
131
- samesite="none", # Allow cross-site cookies; browsers require Secure for SameSite=None
132
  max_age=0, # Expire immediately
133
- path="/"
134
  )
135
-
136
  return {"message": "Logged out successfully"}
137
 
138
 
139
  @router.get("/me", response_model=RegisterResponse)
140
  def get_current_user_profile(request: Request, current_user: User = Depends(get_current_user)):
141
  """Get the current authenticated user's profile."""
142
- # Debug: Print the cookies received
143
- print(f"Received cookies: {request.cookies}")
144
- print(f"Access token cookie: {request.cookies.get('access_token')}")
145
 
146
  return RegisterResponse(
147
  id=current_user.id,
 
4
  from datetime import datetime, timedelta
5
  from uuid import uuid4
6
  import secrets
7
+ import hashlib
8
 
9
  from ..models.user import User, UserCreate, UserRead
10
+ from ..models.refresh_token import RefreshToken
11
  from ..schemas.auth import RegisterRequest, RegisterResponse, LoginRequest, LoginResponse, ForgotPasswordRequest, ResetPasswordRequest
12
  from ..utils.security import hash_password, create_access_token, verify_password
13
  from ..utils.deps import get_current_user
 
17
 
18
  router = APIRouter(prefix="/api/auth", tags=["auth"])
19
 
20
+ # Refresh token settings
21
+ REFRESH_TOKEN_EXPIRE_DAYS = 30
22
+ REFRESH_TOKEN_COOKIE_NAME = "refresh_token"
23
+
24
  # NOTE: For cross-site cookie auth to work (backend on a different domain than the frontend):
25
  # - The frontend must call login/register endpoints with `fetch(..., credentials: 'include')`
26
  # - The backend must have CORS allow_credentials=True and the exact frontend origin in allow_origins
 
30
 
31
  @router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED)
32
  def register(user_data: RegisterRequest, response: Response, session: Session = Depends(get_session_dep)):
33
+ """Register a new user with email and password.
34
+
35
+ This returns a short-lived access token in the response and sets a HttpOnly refresh cookie.
36
+ """
37
 
38
  # Check if user already exists
39
  existing_user = session.exec(select(User).where(User.email == user_data.email)).first()
 
63
  session.commit()
64
  session.refresh(user)
65
 
66
+ # Issue short-lived access token and refresh token
67
+ access_token = create_access_token(data={"sub": str(user.id)}, expires_delta=timedelta(minutes=15))
68
+
69
+ # Create refresh token, store hashed token in DB
70
+ raw_refresh = secrets.token_urlsafe(64)
71
+ token_hash = hashlib.sha256(raw_refresh.encode()).hexdigest()
72
+ expires_at = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
73
+
74
+ refresh_record = RefreshToken(token_hash=token_hash, user_id=user.id, expires_at=expires_at)
75
+ session.add(refresh_record)
76
+ session.commit()
77
+
78
+ # Set HttpOnly refresh cookie
79
  response.set_cookie(
80
+ key=REFRESH_TOKEN_COOKIE_NAME,
81
+ value=raw_refresh,
82
  httponly=True,
83
+ secure=settings.JWT_COOKIE_SECURE,
84
+ samesite="none",
85
+ max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
86
+ path="/api/auth"
87
  )
88
 
 
89
  return RegisterResponse(
90
  id=user.id,
91
  email=user.email,
 
95
 
96
  @router.post("/login", response_model=LoginResponse)
97
  def login(login_data: LoginRequest, response: Response, session: Session = Depends(get_session_dep)):
98
+ """Authenticate user with email and password.
99
+
100
+ Returns a short-lived access token in the response body and sets a HttpOnly refresh cookie.
101
+ Frontend should store access token in memory and call protected APIs with Authorization header, or let frontend call `/api/auth/refresh` (with `credentials: 'include'`) to obtain a new access token.
102
+ """
103
 
104
  # Find user by email
105
  user = session.exec(select(User).where(User.email == login_data.email)).first()
 
111
  headers={"WWW-Authenticate": "Bearer"},
112
  )
113
 
114
+ # Create short-lived access token (e.g., 15 minutes)
115
+ access_token = create_access_token(data={"sub": str(user.id)}, expires_delta=timedelta(minutes=15))
116
+
117
+ # Create refresh token, store hashed token in DB
118
+ raw_refresh = secrets.token_urlsafe(64)
119
+ token_hash = hashlib.sha256(raw_refresh.encode()).hexdigest()
120
+ expires_at = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
121
+
122
+ refresh_record = RefreshToken(token_hash=token_hash, user_id=user.id, expires_at=expires_at)
123
+ session.add(refresh_record)
124
+ session.commit()
125
+
126
+ # Set HttpOnly refresh cookie (sent to frontend with credentials: 'include')
127
  response.set_cookie(
128
+ key=REFRESH_TOKEN_COOKIE_NAME,
129
+ value=raw_refresh,
130
  httponly=True,
131
+ secure=settings.JWT_COOKIE_SECURE,
132
+ samesite="none",
133
+ max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
134
+ path="/api/auth"
135
  )
 
 
 
136
 
137
+ print(f"Refresh cookie set (httponly={True}, secure={settings.JWT_COOKIE_SECURE}, samesite=none, max_age={REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60})")
138
+
139
+ # Return short-lived access token in response body for immediate use
140
  return LoginResponse(
141
  access_token=access_token,
142
  token_type="bearer",
 
148
  )
149
 
150
 
151
+ @router.post("/refresh")
152
+ def refresh_token(request: Request, response: Response, session: Session = Depends(get_session_dep)):
153
+ """Rotate refresh token and return a new short-lived access token.
154
+
155
+ This endpoint reads the HttpOnly refresh cookie, validates it, rotates it (revokes old),
156
+ and sets a new refresh cookie (cookie rotation) and returns a fresh access token.
157
+ Frontend must call this with `credentials: 'include'`.
158
+ """
159
+ raw_refresh = request.cookies.get(REFRESH_TOKEN_COOKIE_NAME)
160
+ if not raw_refresh:
161
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No refresh token provided")
162
+
163
+ token_hash = hashlib.sha256(raw_refresh.encode()).hexdigest()
164
+ token_record = session.exec(select(RefreshToken).where(RefreshToken.token_hash == token_hash)).first()
165
+
166
+ if not token_record or token_record.revoked:
167
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
168
+
169
+ if token_record.expires_at < datetime.utcnow():
170
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expired")
171
+
172
+ # Get user
173
+ user = session.get(User, token_record.user_id)
174
+ if not user:
175
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
176
+
177
+ # Revoke old token and rotate
178
+ token_record.revoked = True
179
+ session.add(token_record)
180
+
181
+ # Create new refresh token
182
+ new_raw_refresh = secrets.token_urlsafe(64)
183
+ new_token_hash = hashlib.sha256(new_raw_refresh.encode()).hexdigest()
184
+ new_expires_at = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
185
+
186
+ new_refresh_record = RefreshToken(token_hash=new_token_hash, user_id=user.id, expires_at=new_expires_at)
187
+ session.add(new_refresh_record)
188
+ session.commit()
189
+
190
+ # Set new refresh cookie
191
+ response.set_cookie(
192
+ key=REFRESH_TOKEN_COOKIE_NAME,
193
+ value=new_raw_refresh,
194
+ httponly=True,
195
+ secure=settings.JWT_COOKIE_SECURE,
196
+ samesite="none",
197
+ max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60,
198
+ path="/api/auth"
199
+ )
200
+
201
+ # Create new short-lived access token and return it
202
+ access_token = create_access_token(data={"sub": str(user.id)}, expires_delta=timedelta(minutes=15))
203
+
204
+ return {"access_token": access_token, "token_type": "bearer"}
205
+
206
+
207
  @router.post("/logout")
208
+ def logout(request: Request, response: Response, session: Session = Depends(get_session_dep)):
209
+ """Logout user by revoking the refresh token cookie and clearing the cookie."""
210
+ raw_refresh = request.cookies.get(REFRESH_TOKEN_COOKIE_NAME)
211
+ if raw_refresh:
212
+ token_hash = hashlib.sha256(raw_refresh.encode()).hexdigest()
213
+ token_record = session.exec(select(RefreshToken).where(RefreshToken.token_hash == token_hash)).first()
214
+ if token_record:
215
+ token_record.revoked = True
216
+ session.add(token_record)
217
+ session.commit()
218
+
219
+ # Clear the refresh cookie
220
  response.set_cookie(
221
+ key=REFRESH_TOKEN_COOKIE_NAME,
222
  value="",
223
  httponly=True,
224
  secure=settings.JWT_COOKIE_SECURE,
225
+ samesite="none",
226
  max_age=0, # Expire immediately
227
+ path="/api/auth"
228
  )
229
+
230
  return {"message": "Logged out successfully"}
231
 
232
 
233
  @router.get("/me", response_model=RegisterResponse)
234
  def get_current_user_profile(request: Request, current_user: User = Depends(get_current_user)):
235
  """Get the current authenticated user's profile."""
236
+ # Debug: Print the cookies received (do not print token values)
237
+ print(f"Received cookies: { {k: '***' for k in request.cookies.keys()} }")
 
238
 
239
  return RegisterResponse(
240
  id=current_user.id,