From 7b2a049bd306686ec4a636d19ccb5498c3d2f81a Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Fri, 2 May 2025 08:45:00 +0400 Subject: [PATCH 01/31] feat: added google auth button --- frontend/public/locales/en/translation.json | 1 + frontend/public/locales/es/translation.json | 1 + frontend/public/locales/nl/translation.json | 1 + frontend/src/assets/google-icon.svg | 6 + .../components/commonUI/GoogleAuthButton.tsx | 37 +++++ frontend/src/routes/_publicLayout/login.tsx | 119 ++++++++------ frontend/src/routes/_publicLayout/signup.tsx | 146 ++++++++++-------- 7 files changed, 202 insertions(+), 109 deletions(-) create mode 100644 frontend/src/assets/google-icon.svg create mode 100644 frontend/src/components/commonUI/GoogleAuthButton.tsx diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 304e480..1e08b45 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -150,6 +150,7 @@ "github": "GitHub", "medium": "Medium", "noCardsAdded": "No cards added yet.", + "or": "or", "password": "Password", "statistics": "Statistics", "totalCards": "Total Cards", diff --git a/frontend/public/locales/es/translation.json b/frontend/public/locales/es/translation.json index 62f764e..6202d36 100644 --- a/frontend/public/locales/es/translation.json +++ b/frontend/public/locales/es/translation.json @@ -150,6 +150,7 @@ "github": "GitHub", "medium": "Medio", "noCardsAdded": "No se han agregado tarjetas aún.", + "or": "o", "password": "Contraseña", "statistics": "Estadísticas", "totalCards": "Total de Tarjetas", diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index 39a694c..79d7eb0 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -150,6 +150,7 @@ "github": "GitHub", "medium": "Gemiddeld", "noCardsAdded": "Nog geen kaarten toegevoegd.", + "or": "of", "password": "Wachtwoord", "statistics": "Statistieken", "totalCards": "Totaal Aantal Kaarten", diff --git a/frontend/src/assets/google-icon.svg b/frontend/src/assets/google-icon.svg new file mode 100644 index 0000000..b4eafe7 --- /dev/null +++ b/frontend/src/assets/google-icon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/components/commonUI/GoogleAuthButton.tsx b/frontend/src/components/commonUI/GoogleAuthButton.tsx new file mode 100644 index 0000000..f5070fe --- /dev/null +++ b/frontend/src/components/commonUI/GoogleAuthButton.tsx @@ -0,0 +1,37 @@ +import { Button, type ButtonProps, HStack, Image, Text } from '@chakra-ui/react' +import { forwardRef } from 'react' +import GoogleIcon from '@/assets/google-icon.svg' + +interface GoogleAuthButtonProps extends ButtonProps { + action: 'login' | 'signup' +} + +export const GoogleAuthButton = forwardRef( + ({ action, ...props }, ref) => ( + + ) +) + +GoogleAuthButton.displayName = 'GoogleAuthButton' \ No newline at end of file diff --git a/frontend/src/routes/_publicLayout/login.tsx b/frontend/src/routes/_publicLayout/login.tsx index d9cfbc3..561e607 100644 --- a/frontend/src/routes/_publicLayout/login.tsx +++ b/frontend/src/routes/_publicLayout/login.tsx @@ -1,25 +1,31 @@ import Logo from '@/assets/Logo.svg' import useAuth from '@/hooks/useAuth' -import { Container, Field, Fieldset, Image, Text } from '@chakra-ui/react' +import { + Container, + Field, + Fieldset, + HStack, + Image, + Text, + VStack, + Box, +} from '@chakra-ui/react' import { Link, createFileRoute, redirect } from '@tanstack/react-router' import { type SubmitHandler, useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' import type { Body_login_login_access_token as AccessToken } from '../../client' import { DefaultButton } from '../../components/commonUI/Button' +import { GoogleAuthButton } from '../../components/commonUI/GoogleAuthButton' import { DefaultInput } from '../../components/commonUI/Input' import { emailPattern } from '../../utils' export const Route = createFileRoute('/_publicLayout/login')({ component: Login, beforeLoad: async () => { - // NOTE: Direct localStorage access is used here because React context is not available in router guards. - // For all React components, use useAuthContext() from './hooks/useAuthContext' instead. const isGuest = localStorage.getItem('guest_mode') === 'true' const isLoggedIn = Boolean(localStorage.getItem('access_token')) || isGuest if (isLoggedIn) { - throw redirect({ - to: '/collections', - }) + throw redirect({ to: '/collections' }) } }, }) @@ -42,9 +48,7 @@ function Login() { const onSubmit: SubmitHandler = async (data) => { if (isSubmitting) return - resetError() - try { await loginMutation.mutateAsync(data) } catch { @@ -52,6 +56,10 @@ function Login() { } } + const handleGoogleLogin = () => { + console.log('Google login clicked') + } + return ( -
- - - - {t('general.words.email')} - - {errors.username && ( - - {errors.username.message} - - )} + + + + + - {t('general.words.password')} + {t('general.words.email')} - {error && ( + {errors.username && ( - {error} + {errors.username.message} )} + + {t('general.words.password')} + + {error && ( + + {error} + + )} + - - - - {t('general.actions.login')} - - -
- - {t('routes.publicLayout.login.dontHaveAccount')}{' '} - - - {t('general.actions.signUp')} + + + {t('general.actions.login')} + + + + + + + + {t('general.words.or')} - - + + + + + + + + + + {t('routes.publicLayout.login.dontHaveAccount')}{' '} + + + {t('general.actions.signUp')} + + + +
) } diff --git a/frontend/src/routes/_publicLayout/signup.tsx b/frontend/src/routes/_publicLayout/signup.tsx index d7671b9..965aa21 100644 --- a/frontend/src/routes/_publicLayout/signup.tsx +++ b/frontend/src/routes/_publicLayout/signup.tsx @@ -1,11 +1,12 @@ import Logo from '@/assets/Logo.svg' import useAuth from '@/hooks/useAuth' -import { Button, Container, Field, Fieldset, Image, Text } from '@chakra-ui/react' +import { Button, Container, Field, Fieldset, HStack, Image, Text, VStack, Box } from '@chakra-ui/react' import { Link, createFileRoute, redirect } from '@tanstack/react-router' import { type SubmitHandler, useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' import type { UserRegister } from '../../client' import { DefaultInput } from '../../components/commonUI/Input' +import { GoogleAuthButton } from '../../components/commonUI/GoogleAuthButton' import PasswordInput from '../../components/commonUI/PasswordInput' import { confirmPasswordRules, emailPattern, passwordRules } from '../../utils' @@ -52,6 +53,11 @@ function SignUp() { signUpMutation.mutate(data) } + const handleGoogleSignup = () => { + // This function will be implemented later when we add Auth0 integration + console.log('Google signup clicked') + } + return ( Logo -
- - - - {t('general.words.email')} - - {errors.email && ( - - {errors.email.message} - - )} - {error && ( - - {error} - - )} - + + + + + + + {t('general.words.email')} + + {errors.email && ( + + {errors.email.message} + + )} + {error && ( + + {error} + + )} + - - {t('general.words.password')} - - {errors.password && ( - - {errors.password.message} - - )} - + + {t('general.words.password')} + + {errors.password && ( + + {errors.password.message} + + )} + - - {t('general.actions.confirmPassword')} - - {errors.confirm_password && ( - - {errors.confirm_password.message} - - )} - - - - - - - {t('routes.publicLayout.signUp.alreadyHaveAccount')}{' '} - - - {t('general.actions.login')}! + + {t('general.actions.confirmPassword')} + + {errors.confirm_password && ( + + {errors.confirm_password.message} + + )} + + + +
+ + + + + + {t('general.words.or')} - - + + + + + + + + + {t('routes.publicLayout.signUp.alreadyHaveAccount')}{' '} + + + {t('general.actions.login')}! + + + +
) } From fd4386f2b39eb43ac85d8413d7cc674fa45b4e7b Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Fri, 2 May 2025 09:05:20 +0400 Subject: [PATCH 02/31] fix: fixed linting errors --- .../components/commonUI/GoogleAuthButton.tsx | 10 ++++----- frontend/src/routes/_publicLayout/login.tsx | 13 +---------- frontend/src/routes/_publicLayout/signup.tsx | 22 ++++++++++++++----- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/commonUI/GoogleAuthButton.tsx b/frontend/src/components/commonUI/GoogleAuthButton.tsx index f5070fe..873b131 100644 --- a/frontend/src/components/commonUI/GoogleAuthButton.tsx +++ b/frontend/src/components/commonUI/GoogleAuthButton.tsx @@ -1,6 +1,6 @@ +import GoogleIcon from '@/assets/google-icon.svg' import { Button, type ButtonProps, HStack, Image, Text } from '@chakra-ui/react' import { forwardRef } from 'react' -import GoogleIcon from '@/assets/google-icon.svg' interface GoogleAuthButtonProps extends ButtonProps { action: 'login' | 'signup' @@ -26,12 +26,10 @@ export const GoogleAuthButton = forwardRef Google - - {action === 'login' ? 'Continue with Google' : 'Sign up with Google'} - + {action === 'login' ? 'Continue with Google' : 'Sign up with Google'} - ) + ), ) -GoogleAuthButton.displayName = 'GoogleAuthButton' \ No newline at end of file +GoogleAuthButton.displayName = 'GoogleAuthButton' diff --git a/frontend/src/routes/_publicLayout/login.tsx b/frontend/src/routes/_publicLayout/login.tsx index 561e607..4ff7667 100644 --- a/frontend/src/routes/_publicLayout/login.tsx +++ b/frontend/src/routes/_publicLayout/login.tsx @@ -1,15 +1,6 @@ import Logo from '@/assets/Logo.svg' import useAuth from '@/hooks/useAuth' -import { - Container, - Field, - Fieldset, - HStack, - Image, - Text, - VStack, - Box, -} from '@chakra-ui/react' +import { Box, Container, Field, Fieldset, HStack, Image, Text, VStack } from '@chakra-ui/react' import { Link, createFileRoute, redirect } from '@tanstack/react-router' import { type SubmitHandler, useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' @@ -132,8 +123,6 @@ function Login() { - - diff --git a/frontend/src/routes/_publicLayout/signup.tsx b/frontend/src/routes/_publicLayout/signup.tsx index 965aa21..64fe8fe 100644 --- a/frontend/src/routes/_publicLayout/signup.tsx +++ b/frontend/src/routes/_publicLayout/signup.tsx @@ -1,12 +1,22 @@ import Logo from '@/assets/Logo.svg' import useAuth from '@/hooks/useAuth' -import { Button, Container, Field, Fieldset, HStack, Image, Text, VStack, Box } from '@chakra-ui/react' +import { + Box, + Button, + Container, + Field, + Fieldset, + HStack, + Image, + Text, + VStack, +} from '@chakra-ui/react' import { Link, createFileRoute, redirect } from '@tanstack/react-router' import { type SubmitHandler, useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' import type { UserRegister } from '../../client' -import { DefaultInput } from '../../components/commonUI/Input' import { GoogleAuthButton } from '../../components/commonUI/GoogleAuthButton' +import { DefaultInput } from '../../components/commonUI/Input' import PasswordInput from '../../components/commonUI/PasswordInput' import { confirmPasswordRules, emailPattern, passwordRules } from '../../utils' @@ -68,7 +78,7 @@ function SignUp() { centerContent > Logo - +
@@ -126,7 +136,7 @@ function SignUp() {
- + @@ -134,10 +144,10 @@ function SignUp() { - +
- + {t('routes.publicLayout.signUp.alreadyHaveAccount')}{' '} From e3a855b90b3c09823ca54bb6ca9fe3a9c279689f Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sat, 3 May 2025 17:22:19 +0400 Subject: [PATCH 03/31] feat: add auth0 configs --- backend/.env.example | 13 ++++++++++++- backend/src/core/config.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/backend/.env.example b/backend/.env.example index 49dab74..6e64975 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -23,4 +23,15 @@ AI_MODEL= AI_API_KEY= COLLECTION_GENERATION_PROMPT="I want to generate flashcards on a specific topic for efficient studying. Please create a set of flashcards covering key concepts, definitions, important details, and examples, with a focus on progressively building understanding of the topic. The flashcards should aim to provide a helpful learning experience by using structured explanations, real-world examples and formatting. Each flashcard should follow this format: Front (Question/Prompt): A clear and concise question or term to test recall, starting with introductory concepts and moving toward more complex details. Back (Answer): If the front is a concept or topic, provide a detailed explanation, broken down into clear paragraphs with easy-to-understand language. If possible, include a real-world example, analogy or illustrative diagrams to make the concept more memorable and relatable. If the front is a vocabulary word (for language learning), provide a direct translation in the target language. Optional Hint: A short clue to aid recall, especially for more complex concepts. Important: Use valid Markdown format for the back of the flashcard." -CARD_GENERATION_PROMPT="I want to generate a flashcard on a specific topic. The contents of the flashcard should provide helpful information that aim to help the learner retain the concepts given. The flashcard must follow this format: Front (Question/Prompt): A clear and concise question or term to test recall. Back (Answer): If the front is a concept or topic, provide a detailed explanation, broken down into clear paragraphs with easy-to-understand language. If possible, include a real-world example, analogy or illustrative diagrams to make the concept more memorable and relatable. If the front is a vocabulary word (for language learning), provide a direct translation in the target language. Important: Use valid Markdown format for the back of the flashcard." \ No newline at end of file +CARD_GENERATION_PROMPT="I want to generate a flashcard on a specific topic. The contents of the flashcard should provide helpful information that aim to help the learner retain the concepts given. The flashcard must follow this format: Front (Question/Prompt): A clear and concise question or term to test recall. Back (Answer): If the front is a concept or topic, provide a detailed explanation, broken down into clear paragraphs with easy-to-understand language. If possible, include a real-world example, analogy or illustrative diagrams to make the concept more memorable and relatable. If the front is a vocabulary word (for language learning), provide a direct translation in the target language. Important: Use valid Markdown format for the back of the flashcard." + +# Auth0 Configuration +AUTH0_DOMAIN=auth0-domain +AUTH0_CLIENT_ID=auth0-client-id +AUTH0_CLIENT_SECRET=auth0-client-secret +AUTH0_AUDIENCE=auth0-audience +AUTH0_CALLBACK_URL=auth0-callback-url +AUTH_LOGOUT_URL=auth-logout-url + +# Session Configuration +SECRET_KEY=secret-key \ No newline at end of file diff --git a/backend/src/core/config.py b/backend/src/core/config.py index bd66a54..15c8e93 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -46,6 +46,17 @@ class Settings(BaseSettings): POSTGRES_PASSWORD: str POSTGRES_DB: str = "" + # Auth0 Configuration + AUTH0_CLIENT_ID: str + AUTH0_CLIENT_SECRET: str + AUTH0_ISSUER: str + AUTH0_CALLBACK_URL: str + AUTH0_LOGOUT_URL: str + AUTH0_AUDIENCE: str + + # Session Configuration + SESSION_MAX_AGE: int = 60 * 60 * 24 * 7 # 7 days + @computed_field # type: ignore[misc] @property def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: @@ -91,8 +102,33 @@ def _enforce_non_default_secrets(self) -> Self: self._check_default_secret( "FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD ) + self._check_default_secret("AUTH0_CLIENT_SECRET", self.AUTH0_CLIENT_SECRET) return self + @computed_field # type: ignore[prop-decorator] + @property + def auth0_jwks_url(self) -> str: + """Get the JWKS URL for token validation.""" + return f"https://{self.AUTH0_DOMAIN}/.well-known/jwks.json" + + @computed_field # type: ignore[prop-decorator] + @property + def auth0_authorization_url(self) -> str: + """Get the authorization URL for login.""" + return f"https://{self.AUTH0_DOMAIN}/authorize" + + @computed_field # type: ignore[prop-decorator] + @property + def auth0_token_url(self) -> str: + """Get the token URL for token exchange.""" + return f"https://{self.AUTH0_DOMAIN}/oauth/token" + + @computed_field # type: ignore[prop-decorator] + @property + def auth0_userinfo_url(self) -> str: + """Get the userinfo URL for fetching user data.""" + return f"https://{self.AUTH0_DOMAIN}/userinfo" + settings = Settings() # type: ignore From 8200fd3a24f91326330b733a26605207dfdd084a Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sat, 3 May 2025 17:23:10 +0400 Subject: [PATCH 04/31] feat: add middleware for sessions --- backend/src/main.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/src/main.py b/backend/src/main.py index 91ba021..ed9137a 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -2,9 +2,11 @@ from fastapi.routing import APIRoute from fastapi_pagination import add_pagination from starlette.middleware.cors import CORSMiddleware +from starlette.middleware.sessions import SessionMiddleware from src.core.config import settings from src.routers import api_router +from src.routes import auth, user def custom_generate_unique_id(route: APIRoute) -> str: @@ -15,6 +17,8 @@ def custom_generate_unique_id(route: APIRoute) -> str: title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json", generate_unique_id_function=custom_generate_unique_id, + version=settings.VERSION, + description=settings.DESCRIPTION, ) # Set all CORS enabled origins @@ -29,5 +33,14 @@ def custom_generate_unique_id(route: APIRoute) -> str: allow_headers=["*"], ) +# Add session middleware for Auth0 authentication +app.add_middleware( + SessionMiddleware, + secret_key=settings.SECRET_KEY, + max_age=settings.SESSION_MAX_AGE, +) + app.include_router(api_router, prefix=settings.API_V1_STR) +app.include_router(auth.router) +app.include_router(user.router) add_pagination(app) From e419fe7d889c2073534f7960b296d31a592ae038 Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sat, 3 May 2025 18:54:51 +0400 Subject: [PATCH 05/31] feat: update main.py --- backend/src/main.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/src/main.py b/backend/src/main.py index ed9137a..75221d2 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -6,7 +6,6 @@ from src.core.config import settings from src.routers import api_router -from src.routes import auth, user def custom_generate_unique_id(route: APIRoute) -> str: @@ -41,6 +40,4 @@ def custom_generate_unique_id(route: APIRoute) -> str: ) app.include_router(api_router, prefix=settings.API_V1_STR) -app.include_router(auth.router) -app.include_router(user.router) add_pagination(app) From 7bbde575c32eef4a1a75bf8aebcd132468427dd1 Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sat, 3 May 2025 21:04:49 +0400 Subject: [PATCH 06/31] feat: add auth0 api endpoints --- backend/pyproject.toml | 1 + backend/src/auth/auth0_api.py | 85 +++++++++++++++ backend/src/core/config.py | 5 +- backend/src/dependencies/auth0.py | 109 +++++++++++++++++++ backend/src/routers.py | 2 + backend/src/services/auth0.py | 171 ++++++++++++++++++++++++++++++ 6 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 backend/src/auth/auth0_api.py create mode 100644 backend/src/dependencies/auth0.py create mode 100644 backend/src/services/auth0.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e11b525..b45d56c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "fastapi-pagination>=0.12.34", "bcrypt==4.0.1", "google-genai>=1.5.0", + "itsdangerous (>=2.2.0,<3.0.0)", ] [tool.uv] diff --git a/backend/src/auth/auth0_api.py b/backend/src/auth/auth0_api.py new file mode 100644 index 0000000..15d3c33 --- /dev/null +++ b/backend/src/auth/auth0_api.py @@ -0,0 +1,85 @@ +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlmodel import Session +from starlette.responses import RedirectResponse + +from src.core.db import get_db +from src.dependencies.auth0 import ( + get_auth0_service, + get_current_user, + get_current_user_claims +) +from src.services.auth0 import Auth0Service +from src.users.auth0 import get_or_create_user_from_auth0 +from src.users.models import User +from src.users.schemas import UserPublic + +router = APIRouter(prefix="/auth0", tags=["auth0"]) + + +@router.get("/login") +async def login( + request: Request, + auth_service: Annotated[Auth0Service, Depends(get_auth0_service)] +) -> RedirectResponse: + return await auth_service.login(request) + + +@router.get("/callback") +async def callback( + request: Request, + session: Annotated[Session, Depends(get_db)], + auth_service: Annotated[Auth0Service, Depends(get_auth0_service)] +) -> RedirectResponse: + try: + # Exchange auth code for tokens + token_response = await auth_service.callback(request) + access_token = token_response.get("access_token") + + # Store access token in session for later use + request.session["access_token"] = access_token + + # Get user info from Auth0 + user_info = await auth_service.get_user_info(access_token) + + # Get or create user in our database + db_user = await get_or_create_user_from_auth0(session, user_info) + + # Store user ID in session + request.session["user_id"] = str(db_user.id) + + # Redirect to the frontend after successful authentication + return RedirectResponse(url="/") + except Exception as e: + # Log the error and redirect to error page + return RedirectResponse(url=f"/auth0/error?message={str(e)}") + + +@router.get("/logout") +async def logout( + auth_service: Annotated[Auth0Service, Depends(get_auth0_service)] +) -> RedirectResponse: + return auth_service.logout() + + +@router.get("/me", response_model=UserPublic) +async def read_users_me( + current_user: Annotated[User, Depends(get_current_user)] +) -> User: + return current_user + + +@router.get("/validate") +async def validate_token( + claims: Annotated[dict[str, Any], Depends(get_current_user_claims)] +) -> dict[str, Any]: + return claims + + +@router.get("/error") +async def auth_error(message: str = "Authentication error"): + raise HTTPException( + status_code=401, + detail=message + ) diff --git a/backend/src/core/config.py b/backend/src/core/config.py index 15c8e93..72c30cc 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -49,9 +49,10 @@ class Settings(BaseSettings): # Auth0 Configuration AUTH0_CLIENT_ID: str AUTH0_CLIENT_SECRET: str - AUTH0_ISSUER: str + AUTH0_DOMAIN: str + AUTH0_ISSUER: str = "" # Default empty string to make it optional AUTH0_CALLBACK_URL: str - AUTH0_LOGOUT_URL: str + AUTH0_LOGOUT_URL: str = "" # Default empty string to make it optional AUTH0_AUDIENCE: str # Session Configuration diff --git a/backend/src/dependencies/auth0.py b/backend/src/dependencies/auth0.py new file mode 100644 index 0000000..219412c --- /dev/null +++ b/backend/src/dependencies/auth0.py @@ -0,0 +1,109 @@ +from typing import Annotated, Any + +from fastapi import Depends, HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlmodel import Session + +from src.core.db import get_db +from src.services.auth0 import Auth0Service, UserInfo +from src.users.auth0 import get_or_create_user_from_auth0, get_user_by_auth0_id +from src.users.models import User + +from functools import lru_cache + +security = HTTPBearer() + + +# Initialize Auth0Service +# This is a singleton instance of Auth0Service +# to be reused across requests. +@lru_cache() +def get_auth0_service() -> Auth0Service: + """ + Provides a singleton Auth0Service instance using lru_cache. + This is the recommended way in FastAPI for services that should be reused. + """ + return Auth0Service() + + +async def get_token_from_header( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)] +) -> str: + """ + Extract and return the JWT token from Authorization header. + """ + return credentials.credentials + + +async def get_current_user_claims( + token: Annotated[str, Depends(get_token_from_header)], + auth_service: Annotated[Auth0Service, Depends(get_auth0_service)] +) -> dict[str, Any]: + """ + Validate token and return the user claims. + """ + claims = await auth_service.validate_token(token) + + return claims + + +async def get_current_user_info( + request: Request, + token: Annotated[str, Depends(get_token_from_header)], + claims: Annotated[dict[str, Any], Depends(get_current_user_claims)], + auth_service: Annotated[Auth0Service, Depends(get_auth0_service)] +) -> UserInfo: + """ + Get user information from Auth0. + """ + # Access token should be available in the request's session + # after the callback flow + access_token = request.session.get("access_token") + if not access_token: + # If not in session, use the token from header + access_token = token + + return await auth_service.get_user_info(access_token) + + +async def get_current_user( + request: Request, + session: Annotated[Session, Depends(get_db)], + user_info: Annotated[UserInfo, Depends(get_current_user_info)] +) -> User: + """ + Get the current user from the database. + """ + try: + # Check if user_id is in session + user_id = request.session.get("user_id") + if user_id: + # Use the user ID from the session + user = session.get(User, user_id) + if user: + return user + + # If no valid user in session, look up by Auth0 ID + auth0_id = user_info.get("sub") + if not auth0_id: + raise ValueError("Missing Auth0 user ID") + + # Try to find by Auth0 ID + user = await get_user_by_auth0_id(session, auth0_id) + if user: + # Store user ID in session for future requests + request.session["user_id"] = str(user.id) + return user + + # Create or link user if not found + user = await get_or_create_user_from_auth0(session, user_info) + + # Store user ID in session for future requests + request.session["user_id"] = str(user.id) + return user + + except Exception as e: + raise HTTPException( + status_code=401, + detail=f"User integration failed: {str(e)}" + ) diff --git a/backend/src/routers.py b/backend/src/routers.py index 1908455..84e1657 100644 --- a/backend/src/routers.py +++ b/backend/src/routers.py @@ -1,6 +1,7 @@ from fastapi import APIRouter from src.auth.api import router as auth_router +from src.auth.auth0_api import router as auth0_router from src.flashcards.api import router as flashcards_router from src.stats.api import router as stats_router from src.users.api import router as user_router @@ -8,6 +9,7 @@ api_router = APIRouter() api_router.include_router(auth_router, tags=["login"]) +api_router.include_router(auth0_router) api_router.include_router(user_router, prefix="/users", tags=["users"]) api_router.include_router(flashcards_router, tags=["flashcards"]) api_router.include_router(stats_router, tags=["stats"]) diff --git a/backend/src/services/auth0.py b/backend/src/services/auth0.py new file mode 100644 index 0000000..3a287eb --- /dev/null +++ b/backend/src/services/auth0.py @@ -0,0 +1,171 @@ +from typing import Any, TypedDict +import logging + +from authlib.integrations.starlette_client import OAuth +from authlib.jose import jwt +from fastapi import HTTPException, Request +from starlette.responses import RedirectResponse +import httpx + +from src.core.config import settings + +logger = logging.getLogger(__name__) + + +class TokenResponse(TypedDict): + access_token: str + id_token: str + token_type: str + expires_in: int + + +class UserInfo(TypedDict): + sub: str + email: str + name: str + picture: str | None + + +class Auth0Service: + def __init__(self): + self.oauth = OAuth() + self.oauth.register( + 'auth0', + client_id=settings.AUTH0_CLIENT_ID, + client_secret=settings.AUTH0_CLIENT_SECRET, + server_metadata_url=( + f'https://{settings.AUTH0_DOMAIN}/' + '.well-known/openid-configuration' + ), + client_kwargs={ + 'scope': 'openid profile email', + 'audience': settings.AUTH0_AUDIENCE, + }, + ) + self._jwks = None + + async def _get_jwks(self) -> dict[str, Any]: + if self._jwks is None: + try: + jwks_url = ( + f"https://{settings.AUTH0_DOMAIN}/.well-known/jwks.json" + ) + async with httpx.AsyncClient() as client: + response = await client.get(jwks_url) + response.raise_for_status() + self._jwks = response.json() + logger.info("JWKS fetched successfully") + except Exception as e: + logger.error(f"Failed to fetch JWKS: {str(e)}") + raise HTTPException( + status_code=500, + detail="Authentication configuration error" + ) + return self._jwks + + async def login(self, request: Request) -> RedirectResponse: + try: + logger.info("Initiating Auth0 login flow") + return await self.oauth.auth0.authorize_redirect( + request, + settings.AUTH0_CALLBACK_URL, + ) + except Exception as e: + logger.error(f"Failed to initiate login: {str(e)}") + raise HTTPException( + status_code=500, + detail="Failed to initiate login. Please try again." + ) + + async def callback(self, request: Request) -> TokenResponse: + try: + logger.info("Processing Auth0 callback") + token = await self.oauth.auth0.authorize_access_token(request) + return token + except Exception as e: + logger.error(f"Failed to exchange code for token: {str(e)}") + raise HTTPException( + status_code=401, + detail="Authentication failed. Please try again." + ) + + async def validate_token(self, token: str) -> dict[str, Any]: + try: + # Get the JWKS from Auth0 + jwks = await self._get_jwks() + + # Decode and validate the token + claims = jwt.decode( + token, + jwks, + claims_options={ + "iss": {"essential": True, "value": settings.AUTH0_ISSUER}, + "aud": { + "essential": True, + "value": settings.AUTH0_AUDIENCE + }, + "exp": {"essential": True}, + } + ) + jwt.validate_claims( + claims, + { + "iss": {"essential": True, "value": settings.AUTH0_ISSUER}, + "aud": { + "essential": True, + "value": settings.AUTH0_AUDIENCE + }, + "exp": {"essential": True}, + } + ) + + # Additional validation + if not claims.get('sub'): + logger.warning("Token validation failed: missing subject") + raise HTTPException( + status_code=401, + detail="Invalid token: missing subject" + ) + + return claims + except jwt.ExpiredTokenError: + logger.warning("Token has expired") + raise HTTPException( + status_code=401, + detail="Token has expired" + ) + except jwt.InvalidTokenError as e: + logger.warning(f"Invalid token: {str(e)}") + raise HTTPException( + status_code=401, + detail="Invalid token" + ) + except Exception as e: + logger.error(f"Token validation failed: {str(e)}") + raise HTTPException( + status_code=401, + detail="Invalid token" + ) + + async def get_user_info(self, access_token: str) -> UserInfo: + try: + logger.info("Fetching user info from Auth0") + token_dict = {"access_token": access_token} + user_info = await self.oauth.auth0.userinfo(token=token_dict) + return user_info + except Exception as e: + logger.error(f"Failed to get user info: {str(e)}") + raise HTTPException( + status_code=401, + detail="Failed to retrieve user information" + ) + + def logout(self) -> RedirectResponse: + logger.info("Initiating Auth0 logout") + return RedirectResponse( + url=( + f"https://{settings.AUTH0_DOMAIN}/v2/logout?" + f"client_id={settings.AUTH0_CLIENT_ID}&" + f"returnTo={settings.AUTH0_LOGOUT_URL}" + ) + ) From b094f3d998f447befbb5fa9fb7786f226cd08544 Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sat, 3 May 2025 21:12:12 +0400 Subject: [PATCH 07/31] feat: add missing library --- backend/pyproject.toml | 1 + backend/src/main.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b45d56c..3b2e636 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "bcrypt==4.0.1", "google-genai>=1.5.0", "itsdangerous (>=2.2.0,<3.0.0)", + "authlib (>=1.5.2,<2.0.0)", ] [tool.uv] diff --git a/backend/src/main.py b/backend/src/main.py index 75221d2..97ee95f 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -16,8 +16,6 @@ def custom_generate_unique_id(route: APIRoute) -> str: title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json", generate_unique_id_function=custom_generate_unique_id, - version=settings.VERSION, - description=settings.DESCRIPTION, ) # Set all CORS enabled origins From 6f596a04ed22cc1d770fe158d691be47015146dd Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sat, 3 May 2025 21:22:42 +0400 Subject: [PATCH 08/31] feat: minor change --- frontend/src/routes/_publicLayout/login.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/routes/_publicLayout/login.tsx b/frontend/src/routes/_publicLayout/login.tsx index 4ff7667..8e957e6 100644 --- a/frontend/src/routes/_publicLayout/login.tsx +++ b/frontend/src/routes/_publicLayout/login.tsx @@ -13,6 +13,8 @@ import { emailPattern } from '../../utils' export const Route = createFileRoute('/_publicLayout/login')({ component: Login, beforeLoad: async () => { + // NOTE: Direct localStorage access is used here because React context is not available in router guards. + // For all React components, use useAuthContext() from './hooks/useAuthContext' instead. const isGuest = localStorage.getItem('guest_mode') === 'true' const isLoggedIn = Boolean(localStorage.getItem('access_token')) || isGuest if (isLoggedIn) { From 2f7f91ff21bc04129b5e4e1fabd8136769f4a79a Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sat, 3 May 2025 21:30:35 +0400 Subject: [PATCH 09/31] fix: apply ruff formating --- backend/src/auth/auth0_api.py | 18 ++++------ backend/src/dependencies/auth0.py | 13 ++++--- backend/src/services/auth0.py | 59 ++++++++++--------------------- 3 files changed, 31 insertions(+), 59 deletions(-) diff --git a/backend/src/auth/auth0_api.py b/backend/src/auth/auth0_api.py index 15d3c33..340358b 100644 --- a/backend/src/auth/auth0_api.py +++ b/backend/src/auth/auth0_api.py @@ -8,7 +8,7 @@ from src.dependencies.auth0 import ( get_auth0_service, get_current_user, - get_current_user_claims + get_current_user_claims, ) from src.services.auth0 import Auth0Service from src.users.auth0 import get_or_create_user_from_auth0 @@ -20,8 +20,7 @@ @router.get("/login") async def login( - request: Request, - auth_service: Annotated[Auth0Service, Depends(get_auth0_service)] + request: Request, auth_service: Annotated[Auth0Service, Depends(get_auth0_service)] ) -> RedirectResponse: return await auth_service.login(request) @@ -30,7 +29,7 @@ async def login( async def callback( request: Request, session: Annotated[Session, Depends(get_db)], - auth_service: Annotated[Auth0Service, Depends(get_auth0_service)] + auth_service: Annotated[Auth0Service, Depends(get_auth0_service)], ) -> RedirectResponse: try: # Exchange auth code for tokens @@ -58,28 +57,25 @@ async def callback( @router.get("/logout") async def logout( - auth_service: Annotated[Auth0Service, Depends(get_auth0_service)] + auth_service: Annotated[Auth0Service, Depends(get_auth0_service)], ) -> RedirectResponse: return auth_service.logout() @router.get("/me", response_model=UserPublic) async def read_users_me( - current_user: Annotated[User, Depends(get_current_user)] + current_user: Annotated[User, Depends(get_current_user)], ) -> User: return current_user @router.get("/validate") async def validate_token( - claims: Annotated[dict[str, Any], Depends(get_current_user_claims)] + claims: Annotated[dict[str, Any], Depends(get_current_user_claims)], ) -> dict[str, Any]: return claims @router.get("/error") async def auth_error(message: str = "Authentication error"): - raise HTTPException( - status_code=401, - detail=message - ) + raise HTTPException(status_code=401, detail=message) diff --git a/backend/src/dependencies/auth0.py b/backend/src/dependencies/auth0.py index 219412c..f51300a 100644 --- a/backend/src/dependencies/auth0.py +++ b/backend/src/dependencies/auth0.py @@ -27,7 +27,7 @@ def get_auth0_service() -> Auth0Service: async def get_token_from_header( - credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)] + credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)], ) -> str: """ Extract and return the JWT token from Authorization header. @@ -37,7 +37,7 @@ async def get_token_from_header( async def get_current_user_claims( token: Annotated[str, Depends(get_token_from_header)], - auth_service: Annotated[Auth0Service, Depends(get_auth0_service)] + auth_service: Annotated[Auth0Service, Depends(get_auth0_service)], ) -> dict[str, Any]: """ Validate token and return the user claims. @@ -51,12 +51,12 @@ async def get_current_user_info( request: Request, token: Annotated[str, Depends(get_token_from_header)], claims: Annotated[dict[str, Any], Depends(get_current_user_claims)], - auth_service: Annotated[Auth0Service, Depends(get_auth0_service)] + auth_service: Annotated[Auth0Service, Depends(get_auth0_service)], ) -> UserInfo: """ Get user information from Auth0. """ - # Access token should be available in the request's session + # Access token should be available in the request's session # after the callback flow access_token = request.session.get("access_token") if not access_token: @@ -69,7 +69,7 @@ async def get_current_user_info( async def get_current_user( request: Request, session: Annotated[Session, Depends(get_db)], - user_info: Annotated[UserInfo, Depends(get_current_user_info)] + user_info: Annotated[UserInfo, Depends(get_current_user_info)], ) -> User: """ Get the current user from the database. @@ -104,6 +104,5 @@ async def get_current_user( except Exception as e: raise HTTPException( - status_code=401, - detail=f"User integration failed: {str(e)}" + status_code=401, detail=f"User integration failed: {str(e)}" ) diff --git a/backend/src/services/auth0.py b/backend/src/services/auth0.py index 3a287eb..9a547fa 100644 --- a/backend/src/services/auth0.py +++ b/backend/src/services/auth0.py @@ -30,16 +30,15 @@ class Auth0Service: def __init__(self): self.oauth = OAuth() self.oauth.register( - 'auth0', + "auth0", client_id=settings.AUTH0_CLIENT_ID, client_secret=settings.AUTH0_CLIENT_SECRET, server_metadata_url=( - f'https://{settings.AUTH0_DOMAIN}/' - '.well-known/openid-configuration' + f"https://{settings.AUTH0_DOMAIN}/.well-known/openid-configuration" ), client_kwargs={ - 'scope': 'openid profile email', - 'audience': settings.AUTH0_AUDIENCE, + "scope": "openid profile email", + "audience": settings.AUTH0_AUDIENCE, }, ) self._jwks = None @@ -47,9 +46,7 @@ def __init__(self): async def _get_jwks(self) -> dict[str, Any]: if self._jwks is None: try: - jwks_url = ( - f"https://{settings.AUTH0_DOMAIN}/.well-known/jwks.json" - ) + jwks_url = f"https://{settings.AUTH0_DOMAIN}/.well-known/jwks.json" async with httpx.AsyncClient() as client: response = await client.get(jwks_url) response.raise_for_status() @@ -58,8 +55,7 @@ async def _get_jwks(self) -> dict[str, Any]: except Exception as e: logger.error(f"Failed to fetch JWKS: {str(e)}") raise HTTPException( - status_code=500, - detail="Authentication configuration error" + status_code=500, detail="Authentication configuration error" ) return self._jwks @@ -73,8 +69,7 @@ async def login(self, request: Request) -> RedirectResponse: except Exception as e: logger.error(f"Failed to initiate login: {str(e)}") raise HTTPException( - status_code=500, - detail="Failed to initiate login. Please try again." + status_code=500, detail="Failed to initiate login. Please try again." ) async def callback(self, request: Request) -> TokenResponse: @@ -85,8 +80,7 @@ async def callback(self, request: Request) -> TokenResponse: except Exception as e: logger.error(f"Failed to exchange code for token: {str(e)}") raise HTTPException( - status_code=401, - detail="Authentication failed. Please try again." + status_code=401, detail="Authentication failed. Please try again." ) async def validate_token(self, token: str) -> dict[str, Any]: @@ -100,52 +94,36 @@ async def validate_token(self, token: str) -> dict[str, Any]: jwks, claims_options={ "iss": {"essential": True, "value": settings.AUTH0_ISSUER}, - "aud": { - "essential": True, - "value": settings.AUTH0_AUDIENCE - }, + "aud": {"essential": True, "value": settings.AUTH0_AUDIENCE}, "exp": {"essential": True}, - } + }, ) jwt.validate_claims( claims, { "iss": {"essential": True, "value": settings.AUTH0_ISSUER}, - "aud": { - "essential": True, - "value": settings.AUTH0_AUDIENCE - }, + "aud": {"essential": True, "value": settings.AUTH0_AUDIENCE}, "exp": {"essential": True}, - } + }, ) # Additional validation - if not claims.get('sub'): + if not claims.get("sub"): logger.warning("Token validation failed: missing subject") raise HTTPException( - status_code=401, - detail="Invalid token: missing subject" + status_code=401, detail="Invalid token: missing subject" ) return claims except jwt.ExpiredTokenError: logger.warning("Token has expired") - raise HTTPException( - status_code=401, - detail="Token has expired" - ) + raise HTTPException(status_code=401, detail="Token has expired") except jwt.InvalidTokenError as e: logger.warning(f"Invalid token: {str(e)}") - raise HTTPException( - status_code=401, - detail="Invalid token" - ) + raise HTTPException(status_code=401, detail="Invalid token") except Exception as e: logger.error(f"Token validation failed: {str(e)}") - raise HTTPException( - status_code=401, - detail="Invalid token" - ) + raise HTTPException(status_code=401, detail="Invalid token") async def get_user_info(self, access_token: str) -> UserInfo: try: @@ -156,8 +134,7 @@ async def get_user_info(self, access_token: str) -> UserInfo: except Exception as e: logger.error(f"Failed to get user info: {str(e)}") raise HTTPException( - status_code=401, - detail="Failed to retrieve user information" + status_code=401, detail="Failed to retrieve user information" ) def logout(self) -> RedirectResponse: From 0a8f3dece10c877f89c39771d57ed8c0dbde71ba Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sat, 3 May 2025 21:34:43 +0400 Subject: [PATCH 10/31] fix: apply ruff formating --- backend/src/dependencies/auth0.py | 6 ++---- backend/src/services/auth0.py | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/backend/src/dependencies/auth0.py b/backend/src/dependencies/auth0.py index f51300a..cf9d53f 100644 --- a/backend/src/dependencies/auth0.py +++ b/backend/src/dependencies/auth0.py @@ -1,3 +1,4 @@ +from functools import lru_cache from typing import Annotated, Any from fastapi import Depends, HTTPException, Request @@ -9,15 +10,13 @@ from src.users.auth0 import get_or_create_user_from_auth0, get_user_by_auth0_id from src.users.models import User -from functools import lru_cache - security = HTTPBearer() # Initialize Auth0Service # This is a singleton instance of Auth0Service # to be reused across requests. -@lru_cache() +@lru_cache def get_auth0_service() -> Auth0Service: """ Provides a singleton Auth0Service instance using lru_cache. @@ -50,7 +49,6 @@ async def get_current_user_claims( async def get_current_user_info( request: Request, token: Annotated[str, Depends(get_token_from_header)], - claims: Annotated[dict[str, Any], Depends(get_current_user_claims)], auth_service: Annotated[Auth0Service, Depends(get_auth0_service)], ) -> UserInfo: """ diff --git a/backend/src/services/auth0.py b/backend/src/services/auth0.py index 9a547fa..8084f83 100644 --- a/backend/src/services/auth0.py +++ b/backend/src/services/auth0.py @@ -1,11 +1,11 @@ -from typing import Any, TypedDict import logging +from typing import Any, TypedDict +import httpx from authlib.integrations.starlette_client import OAuth from authlib.jose import jwt from fastapi import HTTPException, Request from starlette.responses import RedirectResponse -import httpx from src.core.config import settings From aa7c33904b6b1c9eba5a3a34dc17876bcce46dde Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sun, 11 May 2025 14:49:21 +0400 Subject: [PATCH 11/31] feat: integrate backend --- backend/pyproject.toml | 3 +- ...6d3ef_add_auth0_id_field_to_users_table.py | 37 ++++++ backend/src/auth/auth0_api.py | 115 +++++++----------- backend/src/auth/services.py | 75 ++++++++++-- backend/src/core/config.py | 34 +----- backend/src/main.py | 1 - backend/src/routers.py | 1 + backend/src/users/api.py | 5 +- backend/src/users/models.py | 5 +- backend/src/users/schemas.py | 5 +- backend/src/users/services.py | 12 +- frontend/src/main.tsx | 1 + frontend/src/routes/_layout.tsx | 26 ++-- 13 files changed, 187 insertions(+), 133 deletions(-) create mode 100644 backend/src/alembic/versions/1425c896d3ef_add_auth0_id_field_to_users_table.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3b2e636..656f8c3 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,8 +17,7 @@ dependencies = [ "fastapi-pagination>=0.12.34", "bcrypt==4.0.1", "google-genai>=1.5.0", - "itsdangerous (>=2.2.0,<3.0.0)", - "authlib (>=1.5.2,<2.0.0)", + "starlette (>=0.46.2,<0.47.0)", ] [tool.uv] diff --git a/backend/src/alembic/versions/1425c896d3ef_add_auth0_id_field_to_users_table.py b/backend/src/alembic/versions/1425c896d3ef_add_auth0_id_field_to_users_table.py new file mode 100644 index 0000000..1a265a5 --- /dev/null +++ b/backend/src/alembic/versions/1425c896d3ef_add_auth0_id_field_to_users_table.py @@ -0,0 +1,37 @@ +"""Add auth0_id field to Users table + +Revision ID: 1425c896d3ef +Revises: cb16ae472c1e +Create Date: 2025-05-10 00:12:00.358973 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '1425c896d3ef' +down_revision = 'cb16ae472c1e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('user', 'hashed_password', + existing_type=sa.VARCHAR(), + nullable=True) + op.drop_index('ix_user_auth0_id', table_name='user') + op.create_index(op.f('ix_user_auth0_id'), 'user', ['auth0_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_user_auth0_id'), table_name='user') + op.create_index('ix_user_auth0_id', 'user', ['auth0_id'], unique=True) + op.alter_column('user', 'hashed_password', + existing_type=sa.VARCHAR(), + nullable=False) + # ### end Alembic commands ### diff --git a/backend/src/auth/auth0_api.py b/backend/src/auth/auth0_api.py index 340358b..247f56f 100644 --- a/backend/src/auth/auth0_api.py +++ b/backend/src/auth/auth0_api.py @@ -1,81 +1,58 @@ -from typing import Annotated, Any - -from fastapi import APIRouter, Depends, HTTPException, Request -from sqlmodel import Session -from starlette.responses import RedirectResponse - -from src.core.db import get_db -from src.dependencies.auth0 import ( - get_auth0_service, - get_current_user, - get_current_user_claims, +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse +from authlib.integrations.starlette_client import OAuth +from src.core.config import settings + +router = APIRouter(tags=["auth"]) + +oauth = OAuth() +oauth.register( + name="auth0", + client_id=settings.AUTH0_CLIENT_ID, + client_secret=settings.AUTH0_CLIENT_SECRET, + server_metadata_url=f"https://{settings.AUTH0_DOMAIN}/.well-known/openid-configuration", + client_kwargs={"scope": "openid profile email"}, ) -from src.services.auth0 import Auth0Service -from src.users.auth0 import get_or_create_user_from_auth0 -from src.users.models import User -from src.users.schemas import UserPublic - -router = APIRouter(prefix="/auth0", tags=["auth0"]) @router.get("/login") -async def login( - request: Request, auth_service: Annotated[Auth0Service, Depends(get_auth0_service)] -) -> RedirectResponse: - return await auth_service.login(request) +async def login(request: Request): + redirect_uri = request.url_for("auth0_callback") + return await oauth.auth0.authorize_redirect( + request, + redirect_uri, + prompt="select_account", + connection="google-oauth2" + ) -@router.get("/callback") -async def callback( - request: Request, - session: Annotated[Session, Depends(get_db)], - auth_service: Annotated[Auth0Service, Depends(get_auth0_service)], -) -> RedirectResponse: - try: - # Exchange auth code for tokens - token_response = await auth_service.callback(request) - access_token = token_response.get("access_token") +@router.get("/callback", name="auth0_callback") +async def auth0_callback(request: Request): + token = await oauth.auth0.authorize_access_token(request) - # Store access token in session for later use - request.session["access_token"] = access_token + user = token.get("userinfo") or await oauth.auth0.userinfo(token=token) - # Get user info from Auth0 - user_info = await auth_service.get_user_info(access_token) + request.session["user"] = { + "email": user["email"], + "name": user.get("name"), + "picture": user.get("picture"), + "sub": user.get("sub"), + } - # Get or create user in our database - db_user = await get_or_create_user_from_auth0(session, user_info) - - # Store user ID in session - request.session["user_id"] = str(db_user.id) - - # Redirect to the frontend after successful authentication - return RedirectResponse(url="/") - except Exception as e: - # Log the error and redirect to error page - return RedirectResponse(url=f"/auth0/error?message={str(e)}") + return RedirectResponse(url="http://localhost:5173/collections") @router.get("/logout") -async def logout( - auth_service: Annotated[Auth0Service, Depends(get_auth0_service)], -) -> RedirectResponse: - return auth_service.logout() - - -@router.get("/me", response_model=UserPublic) -async def read_users_me( - current_user: Annotated[User, Depends(get_current_user)], -) -> User: - return current_user - - -@router.get("/validate") -async def validate_token( - claims: Annotated[dict[str, Any], Depends(get_current_user_claims)], -) -> dict[str, Any]: - return claims - - -@router.get("/error") -async def auth_error(message: str = "Authentication error"): - raise HTTPException(status_code=401, detail=message) +async def logout(request: Request): + request.session.clear() + return RedirectResponse( + url=f"https://{settings.AUTH0_DOMAIN}/v2/logout" + f"?client_id={settings.AUTH0_CLIENT_ID}" + f"&returnTo=http://localhost:5173" + ) + + +@router.get("/me") +async def me(request: Request): + user = request.session.get("user") + return {"authenticated": bool(user), "user": user} \ No newline at end of file diff --git a/backend/src/auth/services.py b/backend/src/auth/services.py index 9b98387..baa4cd0 100644 --- a/backend/src/auth/services.py +++ b/backend/src/auth/services.py @@ -2,17 +2,18 @@ from typing import Annotated, Any import jwt -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, status, Request from fastapi.security import OAuth2PasswordBearer from jwt.exceptions import InvalidTokenError from passlib.context import CryptContext from pydantic import ValidationError -from sqlmodel import Session +from sqlmodel import Session, select from src.auth.schemas import TokenPayload from src.core.config import settings from src.core.db import get_db from src.users.models import User +from src.users.schemas import UserPublic ALGORITHM = "HS256" @@ -23,21 +24,50 @@ TokenDep = Annotated[str, Depends(reusable_oauth2)] -def get_current_user(session: SessionDep, token: TokenDep) -> User: +def get_user_from_session(request: Request, session: SessionDep) -> User: + session_user = request.session.get("user") + if not session_user: + raise HTTPException(status_code=401, detail="Not authenticated (no session)") + + user = session.exec(select(User).where(User.email == session_user["email"])).first() + if not user or not user.is_active: + raise HTTPException(status_code=401, detail="Invalid session user") + return UserPublic.model_validate(user) + + +def get_user_from_token( + session: SessionDep, + token: Annotated[str, Depends(reusable_oauth2)], +) -> User: try: payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) token_data = TokenPayload(**payload) + user = session.get(User, token_data.sub) + if not user or not user.is_active: + raise HTTPException(status_code=401, detail="Invalid user") + return user except (InvalidTokenError, ValidationError): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Could not validate credentials", - ) - user = session.get(User, token_data.sub) - if not user: - raise HTTPException(status_code=404, detail="User not found") - if not user.is_active: - raise HTTPException(status_code=400, detail="Inactive user") - return user + raise HTTPException(status_code=403, detail="Invalid token") + + +def get_current_user( + request: Request, + session: SessionDep, + token: Annotated[str | None, Depends(OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/tokens", auto_error=False))] = None, +) -> User: + print("in get current user") + # Prefer session (Auth0 flow) + session_user = request.session.get("user") + if session_user: + print("Session user found:", session_user["email"]) + res = get_user_from_session(request, session) + print("User from session:", res) + return res + # Fallback to token (JWT flow) + if token: + return get_user_from_token(session, token) + + raise HTTPException(status_code=401, detail="Not authenticated") CurrentUser = Annotated[User, Depends(get_current_user)] @@ -53,8 +83,15 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None: db_user = get_user_by_email(session=session, email=email) if not db_user: return None + + # Auth0 users may not have a password + if not db_user.hashed_password: + # Return None for users without a password when using password authentication + return None + if not verify_password(password, db_user.hashed_password): return None + return db_user @@ -67,3 +104,15 @@ def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: def get_password_hash(password: str) -> str: return pwd_context.hash(password) + + +def get_or_create_user_by_email(session: Session, email: str, defaults: dict = {}) -> User: + user = session.exec(select(User).where(User.email == email)).first() + if user: + return user + user = User(email=email, **defaults) + session.add(user) + session.commit() + session.refresh(user) + return user + diff --git a/backend/src/core/config.py b/backend/src/core/config.py index 72c30cc..05e9033 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -46,17 +46,9 @@ class Settings(BaseSettings): POSTGRES_PASSWORD: str POSTGRES_DB: str = "" - # Auth0 Configuration AUTH0_CLIENT_ID: str AUTH0_CLIENT_SECRET: str AUTH0_DOMAIN: str - AUTH0_ISSUER: str = "" # Default empty string to make it optional - AUTH0_CALLBACK_URL: str - AUTH0_LOGOUT_URL: str = "" # Default empty string to make it optional - AUTH0_AUDIENCE: str - - # Session Configuration - SESSION_MAX_AGE: int = 60 * 60 * 24 * 7 # 7 days @computed_field # type: ignore[misc] @property @@ -106,30 +98,6 @@ def _enforce_non_default_secrets(self) -> Self: self._check_default_secret("AUTH0_CLIENT_SECRET", self.AUTH0_CLIENT_SECRET) return self - - @computed_field # type: ignore[prop-decorator] - @property - def auth0_jwks_url(self) -> str: - """Get the JWKS URL for token validation.""" - return f"https://{self.AUTH0_DOMAIN}/.well-known/jwks.json" - - @computed_field # type: ignore[prop-decorator] - @property - def auth0_authorization_url(self) -> str: - """Get the authorization URL for login.""" - return f"https://{self.AUTH0_DOMAIN}/authorize" - - @computed_field # type: ignore[prop-decorator] - @property - def auth0_token_url(self) -> str: - """Get the token URL for token exchange.""" - return f"https://{self.AUTH0_DOMAIN}/oauth/token" - - @computed_field # type: ignore[prop-decorator] - @property - def auth0_userinfo_url(self) -> str: - """Get the userinfo URL for fetching user data.""" - return f"https://{self.AUTH0_DOMAIN}/userinfo" - + settings = Settings() # type: ignore diff --git a/backend/src/main.py b/backend/src/main.py index 97ee95f..bbdc6f8 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -30,7 +30,6 @@ def custom_generate_unique_id(route: APIRoute) -> str: allow_headers=["*"], ) -# Add session middleware for Auth0 authentication app.add_middleware( SessionMiddleware, secret_key=settings.SECRET_KEY, diff --git a/backend/src/routers.py b/backend/src/routers.py index 84e1657..cbfd136 100644 --- a/backend/src/routers.py +++ b/backend/src/routers.py @@ -13,3 +13,4 @@ api_router.include_router(user_router, prefix="/users", tags=["users"]) api_router.include_router(flashcards_router, tags=["flashcards"]) api_router.include_router(stats_router, tags=["stats"]) +api_router.include_router(auth0_router, prefix="/auth0", tags=["auth0"]) diff --git a/backend/src/users/api.py b/backend/src/users/api.py index 4bd7007..a29d640 100644 --- a/backend/src/users/api.py +++ b/backend/src/users/api.py @@ -1,6 +1,6 @@ from typing import Any -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Request from src.auth.services import CurrentUser, SessionDep from src.core.config import settings @@ -12,10 +12,11 @@ @router.get("/me", response_model=UserPublic) -def read_user_me(current_user: CurrentUser) -> Any: +def read_user_me(request: Request, current_user: CurrentUser) -> Any: """ Get current user. """ + print("session content:", request.session) return current_user diff --git a/backend/src/users/models.py b/backend/src/users/models.py index 528005a..b6c6992 100644 --- a/backend/src/users/models.py +++ b/backend/src/users/models.py @@ -1,5 +1,5 @@ import uuid -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from sqlmodel import Field, Relationship @@ -11,7 +11,8 @@ class User(UserBase, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - hashed_password: str + auth0_id: Optional[str] = Field(default=None, index=True) + hashed_password: Optional[str] = Field(default=None) collections: list["Collection"] = Relationship( back_populates="user", cascade_delete=True, diff --git a/backend/src/users/schemas.py b/backend/src/users/schemas.py index 5da1ce6..893a603 100644 --- a/backend/src/users/schemas.py +++ b/backend/src/users/schemas.py @@ -1,4 +1,5 @@ import uuid +from typing import Optional from pydantic import EmailStr from sqlmodel import Field, SQLModel @@ -16,7 +17,8 @@ class UserUpdate(UserBase): class UserCreate(UserBase): - password: str = Field(min_length=8, max_length=40) + password: Optional[str] = Field(default=None, min_length=8, max_length=40) + auth0_id: Optional[str] = None class UserRegister(SQLModel): @@ -26,3 +28,4 @@ class UserRegister(SQLModel): class UserPublic(UserBase): id: uuid.UUID + auth0_id: Optional[str] = None diff --git a/backend/src/users/services.py b/backend/src/users/services.py index 474948e..647e209 100644 --- a/backend/src/users/services.py +++ b/backend/src/users/services.py @@ -9,9 +9,15 @@ def create_user(*, session: Session, user_create: UserCreate) -> User: - db_obj = User.model_validate( - user_create, update={"hashed_password": get_password_hash(user_create.password)} - ) + # Prepare update data + update_data = {} + + # Only hash password if it's provided + if user_create.password: + update_data["hashed_password"] = get_password_hash(user_create.password) + + # Create user object + db_obj = User.model_validate(user_create, update=update_data) session.add(db_obj) session.commit() session.refresh(db_obj) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 5b0e7bd..51b3006 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -12,6 +12,7 @@ import { routeTree } from './routeTree.gen' import { system } from './theme' OpenAPI.BASE = import.meta.env.VITE_API_URL +OpenAPI.WITH_CREDENTIALS = true OpenAPI.TOKEN = async () => { return localStorage.getItem('access_token') || '' } diff --git a/frontend/src/routes/_layout.tsx b/frontend/src/routes/_layout.tsx index feba07d..dd3a101 100644 --- a/frontend/src/routes/_layout.tsx +++ b/frontend/src/routes/_layout.tsx @@ -6,14 +6,26 @@ import { Outlet, createFileRoute, redirect } from '@tanstack/react-router' export const Route = createFileRoute('/_layout')({ component: Layout, beforeLoad: async () => { - // NOTE: Direct localStorage access is used here because React context is not available in router guards. - // For all React components, use useAuthContext() from './hooks/useAuthContext' instead. - const isGuest = localStorage.getItem('guest_mode') === 'true' - const isLoggedIn = Boolean(localStorage.getItem('access_token')) || isGuest - if (!isLoggedIn) { - throw redirect({ - to: '/login', + // ✅ Auth check using backend session-aware endpoint + try { + const res = await fetch('http://localhost:8000/api/v1/users/me', { + credentials: 'include', // 🔒 Required to send session cookie }) + + console.log("res:", res) + + const data = await res.json() + console.log("data:", data) + const isLoggedIn = data?.email != null + const isGuest = localStorage.getItem('guest_mode') === 'true' + + if (!isLoggedIn && !isGuest) { + console.log("Not logged in or guest") + throw redirect({ to: '/login' }) + } + } catch (err) { + console.error('Failed to check auth state:', err) + throw redirect({ to: '/login' }) } }, }) From 2acc74d1d348d6e82016ecc2d62f15562a4ef058 Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sun, 11 May 2025 15:52:27 +0400 Subject: [PATCH 12/31] fix: remove redundant route --- backend/src/dependencies/auth0.py | 106 ------------------------------ backend/src/main.py | 3 +- backend/src/routers.py | 1 - 3 files changed, 2 insertions(+), 108 deletions(-) delete mode 100644 backend/src/dependencies/auth0.py diff --git a/backend/src/dependencies/auth0.py b/backend/src/dependencies/auth0.py deleted file mode 100644 index cf9d53f..0000000 --- a/backend/src/dependencies/auth0.py +++ /dev/null @@ -1,106 +0,0 @@ -from functools import lru_cache -from typing import Annotated, Any - -from fastapi import Depends, HTTPException, Request -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from sqlmodel import Session - -from src.core.db import get_db -from src.services.auth0 import Auth0Service, UserInfo -from src.users.auth0 import get_or_create_user_from_auth0, get_user_by_auth0_id -from src.users.models import User - -security = HTTPBearer() - - -# Initialize Auth0Service -# This is a singleton instance of Auth0Service -# to be reused across requests. -@lru_cache -def get_auth0_service() -> Auth0Service: - """ - Provides a singleton Auth0Service instance using lru_cache. - This is the recommended way in FastAPI for services that should be reused. - """ - return Auth0Service() - - -async def get_token_from_header( - credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)], -) -> str: - """ - Extract and return the JWT token from Authorization header. - """ - return credentials.credentials - - -async def get_current_user_claims( - token: Annotated[str, Depends(get_token_from_header)], - auth_service: Annotated[Auth0Service, Depends(get_auth0_service)], -) -> dict[str, Any]: - """ - Validate token and return the user claims. - """ - claims = await auth_service.validate_token(token) - - return claims - - -async def get_current_user_info( - request: Request, - token: Annotated[str, Depends(get_token_from_header)], - auth_service: Annotated[Auth0Service, Depends(get_auth0_service)], -) -> UserInfo: - """ - Get user information from Auth0. - """ - # Access token should be available in the request's session - # after the callback flow - access_token = request.session.get("access_token") - if not access_token: - # If not in session, use the token from header - access_token = token - - return await auth_service.get_user_info(access_token) - - -async def get_current_user( - request: Request, - session: Annotated[Session, Depends(get_db)], - user_info: Annotated[UserInfo, Depends(get_current_user_info)], -) -> User: - """ - Get the current user from the database. - """ - try: - # Check if user_id is in session - user_id = request.session.get("user_id") - if user_id: - # Use the user ID from the session - user = session.get(User, user_id) - if user: - return user - - # If no valid user in session, look up by Auth0 ID - auth0_id = user_info.get("sub") - if not auth0_id: - raise ValueError("Missing Auth0 user ID") - - # Try to find by Auth0 ID - user = await get_user_by_auth0_id(session, auth0_id) - if user: - # Store user ID in session for future requests - request.session["user_id"] = str(user.id) - return user - - # Create or link user if not found - user = await get_or_create_user_from_auth0(session, user_info) - - # Store user ID in session for future requests - request.session["user_id"] = str(user.id) - return user - - except Exception as e: - raise HTTPException( - status_code=401, detail=f"User integration failed: {str(e)}" - ) diff --git a/backend/src/main.py b/backend/src/main.py index bbdc6f8..d242ad0 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -33,7 +33,8 @@ def custom_generate_unique_id(route: APIRoute) -> str: app.add_middleware( SessionMiddleware, secret_key=settings.SECRET_KEY, - max_age=settings.SESSION_MAX_AGE, + same_site="lax", # adjust for production + https_only=False ) app.include_router(api_router, prefix=settings.API_V1_STR) diff --git a/backend/src/routers.py b/backend/src/routers.py index cbfd136..6718ed5 100644 --- a/backend/src/routers.py +++ b/backend/src/routers.py @@ -9,7 +9,6 @@ api_router = APIRouter() api_router.include_router(auth_router, tags=["login"]) -api_router.include_router(auth0_router) api_router.include_router(user_router, prefix="/users", tags=["users"]) api_router.include_router(flashcards_router, tags=["flashcards"]) api_router.include_router(stats_router, tags=["stats"]) From bb6626f6be35f93e8de465e35049deaac978e1cb Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sun, 11 May 2025 15:55:11 +0400 Subject: [PATCH 13/31] fix: remove redundant service file --- backend/src/services/auth0.py | 148 ---------------------------------- 1 file changed, 148 deletions(-) delete mode 100644 backend/src/services/auth0.py diff --git a/backend/src/services/auth0.py b/backend/src/services/auth0.py deleted file mode 100644 index 8084f83..0000000 --- a/backend/src/services/auth0.py +++ /dev/null @@ -1,148 +0,0 @@ -import logging -from typing import Any, TypedDict - -import httpx -from authlib.integrations.starlette_client import OAuth -from authlib.jose import jwt -from fastapi import HTTPException, Request -from starlette.responses import RedirectResponse - -from src.core.config import settings - -logger = logging.getLogger(__name__) - - -class TokenResponse(TypedDict): - access_token: str - id_token: str - token_type: str - expires_in: int - - -class UserInfo(TypedDict): - sub: str - email: str - name: str - picture: str | None - - -class Auth0Service: - def __init__(self): - self.oauth = OAuth() - self.oauth.register( - "auth0", - client_id=settings.AUTH0_CLIENT_ID, - client_secret=settings.AUTH0_CLIENT_SECRET, - server_metadata_url=( - f"https://{settings.AUTH0_DOMAIN}/.well-known/openid-configuration" - ), - client_kwargs={ - "scope": "openid profile email", - "audience": settings.AUTH0_AUDIENCE, - }, - ) - self._jwks = None - - async def _get_jwks(self) -> dict[str, Any]: - if self._jwks is None: - try: - jwks_url = f"https://{settings.AUTH0_DOMAIN}/.well-known/jwks.json" - async with httpx.AsyncClient() as client: - response = await client.get(jwks_url) - response.raise_for_status() - self._jwks = response.json() - logger.info("JWKS fetched successfully") - except Exception as e: - logger.error(f"Failed to fetch JWKS: {str(e)}") - raise HTTPException( - status_code=500, detail="Authentication configuration error" - ) - return self._jwks - - async def login(self, request: Request) -> RedirectResponse: - try: - logger.info("Initiating Auth0 login flow") - return await self.oauth.auth0.authorize_redirect( - request, - settings.AUTH0_CALLBACK_URL, - ) - except Exception as e: - logger.error(f"Failed to initiate login: {str(e)}") - raise HTTPException( - status_code=500, detail="Failed to initiate login. Please try again." - ) - - async def callback(self, request: Request) -> TokenResponse: - try: - logger.info("Processing Auth0 callback") - token = await self.oauth.auth0.authorize_access_token(request) - return token - except Exception as e: - logger.error(f"Failed to exchange code for token: {str(e)}") - raise HTTPException( - status_code=401, detail="Authentication failed. Please try again." - ) - - async def validate_token(self, token: str) -> dict[str, Any]: - try: - # Get the JWKS from Auth0 - jwks = await self._get_jwks() - - # Decode and validate the token - claims = jwt.decode( - token, - jwks, - claims_options={ - "iss": {"essential": True, "value": settings.AUTH0_ISSUER}, - "aud": {"essential": True, "value": settings.AUTH0_AUDIENCE}, - "exp": {"essential": True}, - }, - ) - jwt.validate_claims( - claims, - { - "iss": {"essential": True, "value": settings.AUTH0_ISSUER}, - "aud": {"essential": True, "value": settings.AUTH0_AUDIENCE}, - "exp": {"essential": True}, - }, - ) - - # Additional validation - if not claims.get("sub"): - logger.warning("Token validation failed: missing subject") - raise HTTPException( - status_code=401, detail="Invalid token: missing subject" - ) - - return claims - except jwt.ExpiredTokenError: - logger.warning("Token has expired") - raise HTTPException(status_code=401, detail="Token has expired") - except jwt.InvalidTokenError as e: - logger.warning(f"Invalid token: {str(e)}") - raise HTTPException(status_code=401, detail="Invalid token") - except Exception as e: - logger.error(f"Token validation failed: {str(e)}") - raise HTTPException(status_code=401, detail="Invalid token") - - async def get_user_info(self, access_token: str) -> UserInfo: - try: - logger.info("Fetching user info from Auth0") - token_dict = {"access_token": access_token} - user_info = await self.oauth.auth0.userinfo(token=token_dict) - return user_info - except Exception as e: - logger.error(f"Failed to get user info: {str(e)}") - raise HTTPException( - status_code=401, detail="Failed to retrieve user information" - ) - - def logout(self) -> RedirectResponse: - logger.info("Initiating Auth0 logout") - return RedirectResponse( - url=( - f"https://{settings.AUTH0_DOMAIN}/v2/logout?" - f"client_id={settings.AUTH0_CLIENT_ID}&" - f"returnTo={settings.AUTH0_LOGOUT_URL}" - ) - ) From bf211a50c737304a88953b58417e6d7bc5b81413 Mon Sep 17 00:00:00 2001 From: "yc.li" <46924491+ryan331913@users.noreply.github.com> Date: Sun, 4 May 2025 23:01:57 +0800 Subject: [PATCH 14/31] feat: add GitHub action for backend testing (#77) --- .github/workflows/test_backend.yml | 47 +++++++++++++++++++ README.md | 6 +++ backend/.env.example | 6 +-- backend/docker-compose.test.yml | 14 ++++++ backend/tests-start.sh | 2 +- docs/how-to-run-github-action-locally.md | 58 ++++++++++++++++++++++++ 6 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/test_backend.yml create mode 100644 backend/docker-compose.test.yml mode change 100644 => 100755 backend/tests-start.sh create mode 100644 docs/how-to-run-github-action-locally.md diff --git a/.github/workflows/test_backend.yml b/.github/workflows/test_backend.yml new file mode 100644 index 0000000..88cdbff --- /dev/null +++ b/.github/workflows/test_backend.yml @@ -0,0 +1,47 @@ +name: Test Backend + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + workflow_dispatch: + +jobs: + test-backend: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v5 + with: + version: "latest" + enable-cache: true + - name: Rename env file + run : mv .env.example .env + working-directory: backend + - run: docker compose -f docker-compose.test.yml down -v --remove-orphans + working-directory: backend + - run: docker compose -f docker-compose.test.yml up -d db + working-directory: backend + - name: Setup environment + run: uv run bash prestart.sh + working-directory: backend + - name: Run tests + run: uv run bash tests-start.sh "Coverage for ${{ github.sha }}" + working-directory: backend + - run: docker compose -f docker-compose.test.yml down -v --remove-orphans + working-directory: backend + - name: Store coverage files + uses: actions/upload-artifact@v4 + with: + name: coverage-html + path: backend/htmlcov + include-hidden-files: true diff --git a/README.md b/README.md index ff45ce8..f694a71 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,12 @@ Explore the API documentation at [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs). +## Running GitHub Actions Locally + +To test your GitHub Actions workflows locally before pushing to GitHub, you can use [`nektos/act`](https://github.com/nektos/act). This tool allows you to simulate GitHub Actions workflows using Docker. + +Here is a simple guide for how to do this [here](./docs/how-to-run-github-action-locally.md). + ## Setup Instructions ### Backend Setup diff --git a/backend/.env.example b/backend/.env.example index 6e64975..4f039f3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,8 +19,8 @@ POSTGRES_USER=postgres POSTGRES_PASSWORD=changethis # AI -AI_MODEL= -AI_API_KEY= +AI_MODEL="dummy_model" +AI_API_KEY="dummy_api_key" COLLECTION_GENERATION_PROMPT="I want to generate flashcards on a specific topic for efficient studying. Please create a set of flashcards covering key concepts, definitions, important details, and examples, with a focus on progressively building understanding of the topic. The flashcards should aim to provide a helpful learning experience by using structured explanations, real-world examples and formatting. Each flashcard should follow this format: Front (Question/Prompt): A clear and concise question or term to test recall, starting with introductory concepts and moving toward more complex details. Back (Answer): If the front is a concept or topic, provide a detailed explanation, broken down into clear paragraphs with easy-to-understand language. If possible, include a real-world example, analogy or illustrative diagrams to make the concept more memorable and relatable. If the front is a vocabulary word (for language learning), provide a direct translation in the target language. Optional Hint: A short clue to aid recall, especially for more complex concepts. Important: Use valid Markdown format for the back of the flashcard." CARD_GENERATION_PROMPT="I want to generate a flashcard on a specific topic. The contents of the flashcard should provide helpful information that aim to help the learner retain the concepts given. The flashcard must follow this format: Front (Question/Prompt): A clear and concise question or term to test recall. Back (Answer): If the front is a concept or topic, provide a detailed explanation, broken down into clear paragraphs with easy-to-understand language. If possible, include a real-world example, analogy or illustrative diagrams to make the concept more memorable and relatable. If the front is a vocabulary word (for language learning), provide a direct translation in the target language. Important: Use valid Markdown format for the back of the flashcard." @@ -34,4 +34,4 @@ AUTH0_CALLBACK_URL=auth0-callback-url AUTH_LOGOUT_URL=auth-logout-url # Session Configuration -SECRET_KEY=secret-key \ No newline at end of file +SECRET_KEY=secret-key diff --git a/backend/docker-compose.test.yml b/backend/docker-compose.test.yml new file mode 100644 index 0000000..72f4cd7 --- /dev/null +++ b/backend/docker-compose.test.yml @@ -0,0 +1,14 @@ +services: + db: + image: postgres:latest + restart: always + ports: + - "5432:5432" + env_file: + - .env + environment: + - PGDATA=/var/lib/postgresql/data/pgdata + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} + - POSTGRES_USER=${POSTGRES_USER?Variable not set} + - POSTGRES_DB=${POSTGRES_DB?Variable not set} + - POSTGRES_SERVER=db diff --git a/backend/tests-start.sh b/backend/tests-start.sh old mode 100644 new mode 100755 index 48c9703..b052d22 --- a/backend/tests-start.sh +++ b/backend/tests-start.sh @@ -9,6 +9,6 @@ alembic upgrade head python tests/tests_pre_start.py -coverage run --source=app -m pytest +coverage run --source=src -m pytest coverage report --show-missing coverage html --title "${@-coverage}" diff --git a/docs/how-to-run-github-action-locally.md b/docs/how-to-run-github-action-locally.md new file mode 100644 index 0000000..030330e --- /dev/null +++ b/docs/how-to-run-github-action-locally.md @@ -0,0 +1,58 @@ +# How to Run GitHub Actions Locally Using `act` + +This guide explains how to use [`nektos/act`](https://github.com/nektos/act) to run GitHub Actions locally on your machine for fast feedback and workflow testing. + +## 🛠 Prerequisites + +- Docker installed and running (compatible with Linux, macOS, or Windows) +- `act` installed: See the official documentation for installation instructions: + 👉 https://github.com/nektos/act#installation + +## ⚙️ Basic Usage + +To run your default workflow locally: + +```bash +act +``` + +To run a specific event: + +```bash +act pull_request +``` + +To run a specific job from your workflow file: + +```bash +act -j job-name +``` + +To run a specific workflow: + +```bash +act -W workflow-file-name +``` + +## 🧪 Dry Run Mode + +To preview what `act` will do without actually running the jobs: + +```bash +act --dryrun +``` + +## 🐛 Troubleshooting + +- `Cannot connect to the Docker daemon`: ensure Docker is running and you are not overriding `DOCKER_HOST`. +- `platform mismatch`: use `--container-architecture linux/amd64` when needed. + +## ✅ Summary + +| Command | Description | +|--------|-------------| +| `act` | Run all jobs locally | +| `act -j ` | Run a specific job | +| `act -W ` | Run a specific workflow | +| `act pull_request` | Simulate a PR event | +| `act --dryrun` | Preview actions without running | From 6b457c6beb0501879af4327ed3fe41c852d093c6 Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Tue, 13 May 2025 13:41:31 +0400 Subject: [PATCH 15/31] fix: update migration file --- ...425c896d3ef_add_auth0_id_field_to_users_table.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/backend/src/alembic/versions/1425c896d3ef_add_auth0_id_field_to_users_table.py b/backend/src/alembic/versions/1425c896d3ef_add_auth0_id_field_to_users_table.py index 1a265a5..870dae8 100644 --- a/backend/src/alembic/versions/1425c896d3ef_add_auth0_id_field_to_users_table.py +++ b/backend/src/alembic/versions/1425c896d3ef_add_auth0_id_field_to_users_table.py @@ -19,10 +19,11 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('auth0_id', sa.String(), nullable=True)) + op.alter_column('user', 'hashed_password', - existing_type=sa.VARCHAR(), - nullable=True) - op.drop_index('ix_user_auth0_id', table_name='user') + existing_type=sa.VARCHAR(), + nullable=True) op.create_index(op.f('ix_user_auth0_id'), 'user', ['auth0_id'], unique=False) # ### end Alembic commands ### @@ -30,8 +31,8 @@ def upgrade(): def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_index(op.f('ix_user_auth0_id'), table_name='user') - op.create_index('ix_user_auth0_id', 'user', ['auth0_id'], unique=True) + op.drop_column('user', 'auth0_id') op.alter_column('user', 'hashed_password', - existing_type=sa.VARCHAR(), - nullable=False) + existing_type=sa.VARCHAR(), + nullable=False) # ### end Alembic commands ### From c9e2ffcd96256df5029b7ef2e683190b026e6f02 Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Tue, 13 May 2025 14:13:36 +0400 Subject: [PATCH 16/31] fix: add missing package --- backend/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 656f8c3..8166e7e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "bcrypt==4.0.1", "google-genai>=1.5.0", "starlette (>=0.46.2,<0.47.0)", + "itsdangerous (>=2.2.0,<3.0.0)", ] [tool.uv] From 0513047762b4e2579cf4dc9e9f33bfa79077a6c3 Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Tue, 13 May 2025 14:16:15 +0400 Subject: [PATCH 17/31] fix: add missing authlib package to pyproject.toml --- backend/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8166e7e..0e2265c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "google-genai>=1.5.0", "starlette (>=0.46.2,<0.47.0)", "itsdangerous (>=2.2.0,<3.0.0)", + "authlib (>=1.5.2,<2.0.0)", ] [tool.uv] From 78d0be701db09579d5b5b62d5f8268afde9b18ef Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Fri, 16 May 2025 23:56:42 +0400 Subject: [PATCH 18/31] feat: connect backend to frontend --- backend/src/auth/api.py | 20 ++++++--- backend/src/auth/auth0_api.py | 34 +++++++++------ backend/src/auth/services.py | 22 +++++----- backend/src/core/config.py | 3 +- backend/src/main.py | 4 +- backend/src/users/api.py | 1 - frontend/src/components/commonUI/Drawer.tsx | 20 ++++++--- frontend/src/hooks/useAuthContext.tsx | 46 ++++++++++++++++---- frontend/src/routes/_layout.tsx | 29 +++++------- frontend/src/routes/_publicLayout/login.tsx | 18 +++++--- frontend/src/routes/_publicLayout/signup.tsx | 2 + 11 files changed, 127 insertions(+), 72 deletions(-) diff --git a/backend/src/auth/api.py b/backend/src/auth/api.py index 2ee4849..52df055 100644 --- a/backend/src/auth/api.py +++ b/backend/src/auth/api.py @@ -1,7 +1,7 @@ from datetime import timedelta from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.security import OAuth2PasswordRequestForm from src.auth.schemas import Token @@ -15,6 +15,7 @@ @router.post("/tokens") def login_access_token( + request: Request, session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] ) -> Token: user = services.authenticate( @@ -25,8 +26,17 @@ def login_access_token( elif not user.is_active: raise HTTPException(status_code=400, detail="Inactive user") access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - return Token( - access_token=services.create_access_token( - user.id, expires_delta=access_token_expires - ) + + # Create token + token = services.create_access_token( + user.id, expires_delta=access_token_expires ) + + # Also store user in session for consistency with Auth0 flow + request.session["user"] = { + "email": user.email, + "id": str(user.id), + } + request.session["user_id"] = str(user.id) + + return Token(access_token=token) diff --git a/backend/src/auth/auth0_api.py b/backend/src/auth/auth0_api.py index 247f56f..c68a7f2 100644 --- a/backend/src/auth/auth0_api.py +++ b/backend/src/auth/auth0_api.py @@ -2,7 +2,8 @@ from fastapi.responses import RedirectResponse from authlib.integrations.starlette_client import OAuth from src.core.config import settings - +from src.core.db import get_db +from src.auth.services import get_or_create_user_by_email # Fix the import path router = APIRouter(tags=["auth"]) oauth = OAuth() @@ -15,7 +16,7 @@ ) -@router.get("/login") +@router.get("/login", name="auth0_login") async def login(request: Request): redirect_uri = request.url_for("auth0_callback") return await oauth.auth0.authorize_redirect( @@ -32,27 +33,32 @@ async def auth0_callback(request: Request): user = token.get("userinfo") or await oauth.auth0.userinfo(token=token) + # Create or get the user from database + db = next(get_db()) + db_user = get_or_create_user_by_email( + session=db, + email=user["email"], + defaults={ + "auth0_id": user["sub"], + "full_name": user.get("name"), + "picture": user.get("picture"), + "is_active": True, + }, + ) + + # Store in session request.session["user"] = { "email": user["email"], "name": user.get("name"), "picture": user.get("picture"), "sub": user.get("sub"), } + request.session["user_id"] = str(db_user.id) return RedirectResponse(url="http://localhost:5173/collections") -@router.get("/logout") +@router.get("/logout", name="auth0_logout") async def logout(request: Request): request.session.clear() - return RedirectResponse( - url=f"https://{settings.AUTH0_DOMAIN}/v2/logout" - f"?client_id={settings.AUTH0_CLIENT_ID}" - f"&returnTo=http://localhost:5173" - ) - - -@router.get("/me") -async def me(request: Request): - user = request.session.get("user") - return {"authenticated": bool(user), "user": user} \ No newline at end of file + return {"detail": "Logged out"} diff --git a/backend/src/auth/services.py b/backend/src/auth/services.py index baa4cd0..4d7784b 100644 --- a/backend/src/auth/services.py +++ b/backend/src/auth/services.py @@ -2,7 +2,7 @@ from typing import Annotated, Any import jwt -from fastapi import Depends, HTTPException, status, Request +from fastapi import Depends, HTTPException, Request from fastapi.security import OAuth2PasswordBearer from jwt.exceptions import InvalidTokenError from passlib.context import CryptContext @@ -42,10 +42,14 @@ def get_user_from_token( try: payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) token_data = TokenPayload(**payload) + user = session.get(User, token_data.sub) + if not user or not user.is_active: raise HTTPException(status_code=401, detail="Invalid user") + return user + except (InvalidTokenError, ValidationError): raise HTTPException(status_code=403, detail="Invalid token") @@ -55,18 +59,12 @@ def get_current_user( session: SessionDep, token: Annotated[str | None, Depends(OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/tokens", auto_error=False))] = None, ) -> User: - print("in get current user") - # Prefer session (Auth0 flow) session_user = request.session.get("user") if session_user: - print("Session user found:", session_user["email"]) - res = get_user_from_session(request, session) - print("User from session:", res) - return res - # Fallback to token (JWT flow) + return get_user_from_session(request, session) if token: return get_user_from_token(session, token) - + raise HTTPException(status_code=401, detail="Not authenticated") @@ -83,15 +81,15 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None: db_user = get_user_by_email(session=session, email=email) if not db_user: return None - + # Auth0 users may not have a password if not db_user.hashed_password: # Return None for users without a password when using password authentication return None - + if not verify_password(password, db_user.hashed_password): return None - + return db_user diff --git a/backend/src/core/config.py b/backend/src/core/config.py index 05e9033..90358e2 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -30,6 +30,7 @@ class Settings(BaseSettings): SECRET_KEY: str = secrets.token_urlsafe(32) # 60 minutes * 24 hours * 8 days = 8 days ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 + AUTH_EXPIRE_MINUTES: int = 60 * 24 * 8 DOMAIN: str = "localhost" ENVIRONMENT: Literal["local", "staging", "production"] = "local" @@ -98,6 +99,6 @@ def _enforce_non_default_secrets(self) -> Self: self._check_default_secret("AUTH0_CLIENT_SECRET", self.AUTH0_CLIENT_SECRET) return self - + settings = Settings() # type: ignore diff --git a/backend/src/main.py b/backend/src/main.py index d242ad0..516cafc 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -30,11 +30,13 @@ def custom_generate_unique_id(route: APIRoute) -> str: allow_headers=["*"], ) +# Setup session middleware app.add_middleware( SessionMiddleware, secret_key=settings.SECRET_KEY, same_site="lax", # adjust for production - https_only=False + https_only=False, + max_age=settings.AUTH_EXPIRE_MINUTES * 60, ) app.include_router(api_router, prefix=settings.API_V1_STR) diff --git a/backend/src/users/api.py b/backend/src/users/api.py index a29d640..c0d8acb 100644 --- a/backend/src/users/api.py +++ b/backend/src/users/api.py @@ -16,7 +16,6 @@ def read_user_me(request: Request, current_user: CurrentUser) -> Any: """ Get current user. """ - print("session content:", request.session) return current_user diff --git a/frontend/src/components/commonUI/Drawer.tsx b/frontend/src/components/commonUI/Drawer.tsx index 1b420e6..4717854 100644 --- a/frontend/src/components/commonUI/Drawer.tsx +++ b/frontend/src/components/commonUI/Drawer.tsx @@ -13,10 +13,11 @@ import { import useAuth from '@/hooks/useAuth' import { getCollections } from '@/services/collections' import { HStack, IconButton, Image, List, Spinner, Text, VStack } from '@chakra-ui/react' -import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' import { Link, useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { FiLogOut, FiMoon, FiSun } from 'react-icons/fi' +import { UsersService } from '@/client' import { DefaultButton } from './Button' import LanguageSelector from './LanguageSelector' @@ -54,14 +55,23 @@ function Drawer({ isOpen, setIsOpen }: { isOpen: boolean; setIsOpen: (open: bool const { t } = useTranslation() const { logout } = useAuth() const { colorMode, toggleColorMode } = useColorMode() - const queryClient = useQueryClient() - const currentUser = queryClient.getQueryData<{ email: string }>(['currentUser']) - const flashcardsService = { getCollections } const navigate = useNavigate() + + const { data: currentUser } = useQuery({ + queryKey: ['currentUser'], + queryFn: async () => { + try { + return await UsersService.readUserMe() + } catch (error) { + return null + } + } + }) + // Fetch collections data const { data, isLoading } = useQuery({ queryKey: ['collections'], - queryFn: flashcardsService.getCollections, + queryFn: getCollections, placeholderData: (prevData) => prevData, }) diff --git a/frontend/src/hooks/useAuthContext.tsx b/frontend/src/hooks/useAuthContext.tsx index 2ecfa75..ed2c4eb 100644 --- a/frontend/src/hooks/useAuthContext.tsx +++ b/frontend/src/hooks/useAuthContext.tsx @@ -1,5 +1,6 @@ import type React from 'react' import { createContext, useContext, useEffect, useState } from 'react' +import { UsersService } from '@/client' const GUEST_MODE_KEY = 'guest_mode' const ACCESS_TOKEN_KEY = 'access_token' @@ -8,26 +9,47 @@ interface AuthContextType { isGuest: boolean isLoggedIn: boolean setGuestMode: (value: boolean) => void - logout: () => void + logout: () => Promise } const AuthContext = createContext(undefined) function AuthProvider({ children }: { children: React.ReactNode }) { const [isGuest, setIsGuest] = useState(() => localStorage.getItem(GUEST_MODE_KEY) === 'true') - const [isLoggedIn, setIsLoggedIn] = useState( - () => - Boolean(localStorage.getItem(ACCESS_TOKEN_KEY)) || - localStorage.getItem(GUEST_MODE_KEY) === 'true', + const [isLoggedIn, setIsLoggedIn] = useState(() => + Boolean(localStorage.getItem(ACCESS_TOKEN_KEY)) || localStorage.getItem(GUEST_MODE_KEY) === 'true' ) useEffect(() => { + console.log("isLoggedIn", isLoggedIn) + + const checkUser = async () => { + if (localStorage.getItem(GUEST_MODE_KEY) === 'true') { + setIsLoggedIn(true) + return + } + + try { + const user = await UsersService.readUserMe() + if (user) { + setIsLoggedIn(true) + } + } catch (error) { + console.error('Error fetching user:', error) + } + } + checkUser() + }, []) + + useEffect(() => { + console.log('AuthProvider mounted') const handleStorage = (e: StorageEvent) => { + console.log('Storage event:', e.key) if (e.key === GUEST_MODE_KEY || e.key === ACCESS_TOKEN_KEY) { setIsGuest(localStorage.getItem(GUEST_MODE_KEY) === 'true') setIsLoggedIn( Boolean(localStorage.getItem(ACCESS_TOKEN_KEY)) || - localStorage.getItem(GUEST_MODE_KEY) === 'true', + localStorage.getItem(GUEST_MODE_KEY) === 'true' ) } } @@ -45,7 +67,15 @@ function AuthProvider({ children }: { children: React.ReactNode }) { setIsLoggedIn(value || Boolean(localStorage.getItem(ACCESS_TOKEN_KEY))) } - const logout = () => { + const logout = async () => { + try { + await fetch('http://localhost:8000/api/v1/auth0/logout', { + method: 'GET', + credentials: 'include', + }) + } catch (e) { + console.error('Logout request failed', e) + } localStorage.removeItem(ACCESS_TOKEN_KEY) setGuestMode(false) setIsLoggedIn(false) @@ -64,4 +94,4 @@ function useAuthContext() { return ctx } -export { AuthProvider, useAuthContext } +export { AuthProvider, useAuthContext } \ No newline at end of file diff --git a/frontend/src/routes/_layout.tsx b/frontend/src/routes/_layout.tsx index dd3a101..6775ac7 100644 --- a/frontend/src/routes/_layout.tsx +++ b/frontend/src/routes/_layout.tsx @@ -1,33 +1,26 @@ import Navbar from '@/components/commonUI/Navbar' import { Toaster } from '@/components/ui/toaster' +import { UsersService } from '@/client' import { Container } from '@chakra-ui/react' import { Outlet, createFileRoute, redirect } from '@tanstack/react-router' export const Route = createFileRoute('/_layout')({ component: Layout, beforeLoad: async () => { - // ✅ Auth check using backend session-aware endpoint + const isGuest = localStorage.getItem('guest_mode') === 'true'; + if (isGuest) { + return; + } try { - const res = await fetch('http://localhost:8000/api/v1/users/me', { - credentials: 'include', // 🔒 Required to send session cookie - }) - - console.log("res:", res) - - const data = await res.json() - console.log("data:", data) - const isLoggedIn = data?.email != null - const isGuest = localStorage.getItem('guest_mode') === 'true' - - if (!isLoggedIn && !isGuest) { - console.log("Not logged in or guest") - throw redirect({ to: '/login' }) + const user = await UsersService.readUserMe(); + if (!user) { + throw redirect({ to: '/login' }); } } catch (err) { - console.error('Failed to check auth state:', err) - throw redirect({ to: '/login' }) + console.error('Failed to check auth state:', err); + throw redirect({ to: '/login' }); } - }, + } }) function Layout() { diff --git a/frontend/src/routes/_publicLayout/login.tsx b/frontend/src/routes/_publicLayout/login.tsx index 8e957e6..1f2b5ee 100644 --- a/frontend/src/routes/_publicLayout/login.tsx +++ b/frontend/src/routes/_publicLayout/login.tsx @@ -9,20 +9,24 @@ import { DefaultButton } from '../../components/commonUI/Button' import { GoogleAuthButton } from '../../components/commonUI/GoogleAuthButton' import { DefaultInput } from '../../components/commonUI/Input' import { emailPattern } from '../../utils' +import { UsersService } from '@/client' export const Route = createFileRoute('/_publicLayout/login')({ component: Login, beforeLoad: async () => { - // NOTE: Direct localStorage access is used here because React context is not available in router guards. - // For all React components, use useAuthContext() from './hooks/useAuthContext' instead. - const isGuest = localStorage.getItem('guest_mode') === 'true' - const isLoggedIn = Boolean(localStorage.getItem('access_token')) || isGuest - if (isLoggedIn) { - throw redirect({ to: '/collections' }) + try { + const user = await UsersService.readUserMe() + const isGuest = localStorage.getItem('guest_mode') === 'true' + if (user || isGuest) { + throw redirect({ to: '/collections' }) + } + } catch (error) { + // Continue to login page if user is not authenticated } }, }) + function Login() { const { t } = useTranslation() const { loginMutation, error, resetError } = useAuth() @@ -50,7 +54,7 @@ function Login() { } const handleGoogleLogin = () => { - console.log('Google login clicked') + window.location.href = 'http://localhost:8000/api/v1/auth0/login' } return ( diff --git a/frontend/src/routes/_publicLayout/signup.tsx b/frontend/src/routes/_publicLayout/signup.tsx index 64fe8fe..80a24d1 100644 --- a/frontend/src/routes/_publicLayout/signup.tsx +++ b/frontend/src/routes/_publicLayout/signup.tsx @@ -66,6 +66,8 @@ function SignUp() { const handleGoogleSignup = () => { // This function will be implemented later when we add Auth0 integration console.log('Google signup clicked') + window.location.href = 'http://localhost:8000/api/v1/auth0/login' + } return ( From cd97756021f7879a0d00eb62394920879e3c4d56 Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sat, 17 May 2025 00:03:23 +0400 Subject: [PATCH 19/31] fix: fix lint issues --- backend/src/auth/auth0_api.py | 6 ++++-- backend/src/users/models.py | 6 +++--- backend/src/users/schemas.py | 7 +++---- backend/src/users/services.py | 4 ++-- frontend/src/components/commonUI/Drawer.tsx | 6 +++--- frontend/src/hooks/useAuthContext.tsx | 14 ++++++++------ frontend/src/routes/_layout.tsx | 16 ++++++++-------- frontend/src/routes/_publicLayout/login.tsx | 3 +-- frontend/src/routes/_publicLayout/signup.tsx | 1 - 9 files changed, 32 insertions(+), 31 deletions(-) diff --git a/backend/src/auth/auth0_api.py b/backend/src/auth/auth0_api.py index c68a7f2..4bc646f 100644 --- a/backend/src/auth/auth0_api.py +++ b/backend/src/auth/auth0_api.py @@ -1,9 +1,11 @@ +from authlib.integrations.starlette_client import OAuth from fastapi import APIRouter, Request from fastapi.responses import RedirectResponse -from authlib.integrations.starlette_client import OAuth + +from src.auth.services import get_or_create_user_by_email # Fix the import path from src.core.config import settings from src.core.db import get_db -from src.auth.services import get_or_create_user_by_email # Fix the import path + router = APIRouter(tags=["auth"]) oauth = OAuth() diff --git a/backend/src/users/models.py b/backend/src/users/models.py index b6c6992..717114d 100644 --- a/backend/src/users/models.py +++ b/backend/src/users/models.py @@ -1,5 +1,5 @@ import uuid -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from sqlmodel import Field, Relationship @@ -11,8 +11,8 @@ class User(UserBase, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - auth0_id: Optional[str] = Field(default=None, index=True) - hashed_password: Optional[str] = Field(default=None) + auth0_id: str | None = Field(default=None, index=True) + hashed_password: str | None = Field(default=None) collections: list["Collection"] = Relationship( back_populates="user", cascade_delete=True, diff --git a/backend/src/users/schemas.py b/backend/src/users/schemas.py index 893a603..d2c5e39 100644 --- a/backend/src/users/schemas.py +++ b/backend/src/users/schemas.py @@ -1,5 +1,4 @@ import uuid -from typing import Optional from pydantic import EmailStr from sqlmodel import Field, SQLModel @@ -17,8 +16,8 @@ class UserUpdate(UserBase): class UserCreate(UserBase): - password: Optional[str] = Field(default=None, min_length=8, max_length=40) - auth0_id: Optional[str] = None + password: str | None = Field(default=None, min_length=8, max_length=40) + auth0_id: str | None = None class UserRegister(SQLModel): @@ -28,4 +27,4 @@ class UserRegister(SQLModel): class UserPublic(UserBase): id: uuid.UUID - auth0_id: Optional[str] = None + auth0_id: str | None = None diff --git a/backend/src/users/services.py b/backend/src/users/services.py index 647e209..2509552 100644 --- a/backend/src/users/services.py +++ b/backend/src/users/services.py @@ -11,11 +11,11 @@ def create_user(*, session: Session, user_create: UserCreate) -> User: # Prepare update data update_data = {} - + # Only hash password if it's provided if user_create.password: update_data["hashed_password"] = get_password_hash(user_create.password) - + # Create user object db_obj = User.model_validate(user_create, update=update_data) session.add(db_obj) diff --git a/frontend/src/components/commonUI/Drawer.tsx b/frontend/src/components/commonUI/Drawer.tsx index 4717854..67d93b7 100644 --- a/frontend/src/components/commonUI/Drawer.tsx +++ b/frontend/src/components/commonUI/Drawer.tsx @@ -1,4 +1,5 @@ import Logo from '@/assets/Logo.svg' +import { UsersService } from '@/client' import type { Collection } from '@/client/types.gen' import { useColorMode } from '@/components/ui/color-mode' import { @@ -17,7 +18,6 @@ import { useQuery } from '@tanstack/react-query' import { Link, useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { FiLogOut, FiMoon, FiSun } from 'react-icons/fi' -import { UsersService } from '@/client' import { DefaultButton } from './Button' import LanguageSelector from './LanguageSelector' @@ -56,7 +56,7 @@ function Drawer({ isOpen, setIsOpen }: { isOpen: boolean; setIsOpen: (open: bool const { logout } = useAuth() const { colorMode, toggleColorMode } = useColorMode() const navigate = useNavigate() - + const { data: currentUser } = useQuery({ queryKey: ['currentUser'], queryFn: async () => { @@ -65,7 +65,7 @@ function Drawer({ isOpen, setIsOpen }: { isOpen: boolean; setIsOpen: (open: bool } catch (error) { return null } - } + }, }) // Fetch collections data diff --git a/frontend/src/hooks/useAuthContext.tsx b/frontend/src/hooks/useAuthContext.tsx index ed2c4eb..059607f 100644 --- a/frontend/src/hooks/useAuthContext.tsx +++ b/frontend/src/hooks/useAuthContext.tsx @@ -1,6 +1,6 @@ +import { UsersService } from '@/client' import type React from 'react' import { createContext, useContext, useEffect, useState } from 'react' -import { UsersService } from '@/client' const GUEST_MODE_KEY = 'guest_mode' const ACCESS_TOKEN_KEY = 'access_token' @@ -16,12 +16,14 @@ const AuthContext = createContext(undefined) function AuthProvider({ children }: { children: React.ReactNode }) { const [isGuest, setIsGuest] = useState(() => localStorage.getItem(GUEST_MODE_KEY) === 'true') - const [isLoggedIn, setIsLoggedIn] = useState(() => - Boolean(localStorage.getItem(ACCESS_TOKEN_KEY)) || localStorage.getItem(GUEST_MODE_KEY) === 'true' + const [isLoggedIn, setIsLoggedIn] = useState( + () => + Boolean(localStorage.getItem(ACCESS_TOKEN_KEY)) || + localStorage.getItem(GUEST_MODE_KEY) === 'true', ) useEffect(() => { - console.log("isLoggedIn", isLoggedIn) + console.log('isLoggedIn', isLoggedIn) const checkUser = async () => { if (localStorage.getItem(GUEST_MODE_KEY) === 'true') { @@ -49,7 +51,7 @@ function AuthProvider({ children }: { children: React.ReactNode }) { setIsGuest(localStorage.getItem(GUEST_MODE_KEY) === 'true') setIsLoggedIn( Boolean(localStorage.getItem(ACCESS_TOKEN_KEY)) || - localStorage.getItem(GUEST_MODE_KEY) === 'true' + localStorage.getItem(GUEST_MODE_KEY) === 'true', ) } } @@ -94,4 +96,4 @@ function useAuthContext() { return ctx } -export { AuthProvider, useAuthContext } \ No newline at end of file +export { AuthProvider, useAuthContext } diff --git a/frontend/src/routes/_layout.tsx b/frontend/src/routes/_layout.tsx index 6775ac7..4b61ec9 100644 --- a/frontend/src/routes/_layout.tsx +++ b/frontend/src/routes/_layout.tsx @@ -1,26 +1,26 @@ +import { UsersService } from '@/client' import Navbar from '@/components/commonUI/Navbar' import { Toaster } from '@/components/ui/toaster' -import { UsersService } from '@/client' import { Container } from '@chakra-ui/react' import { Outlet, createFileRoute, redirect } from '@tanstack/react-router' export const Route = createFileRoute('/_layout')({ component: Layout, beforeLoad: async () => { - const isGuest = localStorage.getItem('guest_mode') === 'true'; + const isGuest = localStorage.getItem('guest_mode') === 'true' if (isGuest) { - return; + return } try { - const user = await UsersService.readUserMe(); + const user = await UsersService.readUserMe() if (!user) { - throw redirect({ to: '/login' }); + throw redirect({ to: '/login' }) } } catch (err) { - console.error('Failed to check auth state:', err); - throw redirect({ to: '/login' }); + console.error('Failed to check auth state:', err) + throw redirect({ to: '/login' }) } - } + }, }) function Layout() { diff --git a/frontend/src/routes/_publicLayout/login.tsx b/frontend/src/routes/_publicLayout/login.tsx index 1f2b5ee..00c82cb 100644 --- a/frontend/src/routes/_publicLayout/login.tsx +++ b/frontend/src/routes/_publicLayout/login.tsx @@ -1,4 +1,5 @@ import Logo from '@/assets/Logo.svg' +import { UsersService } from '@/client' import useAuth from '@/hooks/useAuth' import { Box, Container, Field, Fieldset, HStack, Image, Text, VStack } from '@chakra-ui/react' import { Link, createFileRoute, redirect } from '@tanstack/react-router' @@ -9,7 +10,6 @@ import { DefaultButton } from '../../components/commonUI/Button' import { GoogleAuthButton } from '../../components/commonUI/GoogleAuthButton' import { DefaultInput } from '../../components/commonUI/Input' import { emailPattern } from '../../utils' -import { UsersService } from '@/client' export const Route = createFileRoute('/_publicLayout/login')({ component: Login, @@ -26,7 +26,6 @@ export const Route = createFileRoute('/_publicLayout/login')({ }, }) - function Login() { const { t } = useTranslation() const { loginMutation, error, resetError } = useAuth() diff --git a/frontend/src/routes/_publicLayout/signup.tsx b/frontend/src/routes/_publicLayout/signup.tsx index 80a24d1..a3550b3 100644 --- a/frontend/src/routes/_publicLayout/signup.tsx +++ b/frontend/src/routes/_publicLayout/signup.tsx @@ -67,7 +67,6 @@ function SignUp() { // This function will be implemented later when we add Auth0 integration console.log('Google signup clicked') window.location.href = 'http://localhost:8000/api/v1/auth0/login' - } return ( From 56efd12470b024a53c8dfd409d2fd9ac03880260 Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sat, 17 May 2025 00:15:18 +0400 Subject: [PATCH 20/31] fix: fix lint issues --- backend/src/auth/services.py | 19 +++++++++++++------ frontend/src/hooks/useAuthContext.tsx | 2 -- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/backend/src/auth/services.py b/backend/src/auth/services.py index 4d7784b..2125261 100644 --- a/backend/src/auth/services.py +++ b/backend/src/auth/services.py @@ -17,7 +17,8 @@ ALGORITHM = "HS256" -reusable_oauth2 = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/tokens") +reusable_oauth2 = OAuth2PasswordBearer( + tokenUrl=f"{settings.API_V1_STR}/tokens") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") SessionDep = Annotated[Session, Depends(get_db)] @@ -27,9 +28,11 @@ def get_user_from_session(request: Request, session: SessionDep) -> User: session_user = request.session.get("user") if not session_user: - raise HTTPException(status_code=401, detail="Not authenticated (no session)") + raise HTTPException(status_code=401, + detail="Not authenticated (no session)") - user = session.exec(select(User).where(User.email == session_user["email"])).first() + user = session.exec(select(User).where( + User.email == session_user["email"])).first() if not user or not user.is_active: raise HTTPException(status_code=401, detail="Invalid session user") return UserPublic.model_validate(user) @@ -57,7 +60,8 @@ def get_user_from_token( def get_current_user( request: Request, session: SessionDep, - token: Annotated[str | None, Depends(OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/tokens", auto_error=False))] = None, + token: Annotated[str | None, Depends(OAuth2PasswordBearer( + tokenUrl=f"{settings.API_V1_STR}/tokens", auto_error=False))] = None, ) -> User: session_user = request.session.get("user") if session_user: @@ -104,7 +108,11 @@ def get_password_hash(password: str) -> str: return pwd_context.hash(password) -def get_or_create_user_by_email(session: Session, email: str, defaults: dict = {}) -> User: +def get_or_create_user_by_email( + session: Session, + email: str, + defaults: dict | None = None, +) -> User: user = session.exec(select(User).where(User.email == email)).first() if user: return user @@ -113,4 +121,3 @@ def get_or_create_user_by_email(session: Session, email: str, defaults: dict = { session.commit() session.refresh(user) return user - diff --git a/frontend/src/hooks/useAuthContext.tsx b/frontend/src/hooks/useAuthContext.tsx index 059607f..4e6287f 100644 --- a/frontend/src/hooks/useAuthContext.tsx +++ b/frontend/src/hooks/useAuthContext.tsx @@ -23,8 +23,6 @@ function AuthProvider({ children }: { children: React.ReactNode }) { ) useEffect(() => { - console.log('isLoggedIn', isLoggedIn) - const checkUser = async () => { if (localStorage.getItem(GUEST_MODE_KEY) === 'true') { setIsLoggedIn(true) From c7a13549722d783c6708861913c18e6eb2dc442c Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sat, 17 May 2025 00:20:14 +0400 Subject: [PATCH 21/31] fix: fix ruff issues --- backend/src/auth/api.py | 8 ++++---- backend/src/auth/auth0_api.py | 5 +---- backend/src/auth/services.py | 10 ++++++++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/backend/src/auth/api.py b/backend/src/auth/api.py index 52df055..dee2c1a 100644 --- a/backend/src/auth/api.py +++ b/backend/src/auth/api.py @@ -16,7 +16,8 @@ @router.post("/tokens") def login_access_token( request: Request, - session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] + session: SessionDep, + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], ) -> Token: user = services.authenticate( session=session, email=form_data.username, password=form_data.password @@ -25,12 +26,11 @@ def login_access_token( raise HTTPException(status_code=400, detail="Incorrect email or password") elif not user.is_active: raise HTTPException(status_code=400, detail="Inactive user") + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) # Create token - token = services.create_access_token( - user.id, expires_delta=access_token_expires - ) + token = services.create_access_token(user.id, expires_delta=access_token_expires) # Also store user in session for consistency with Auth0 flow request.session["user"] = { diff --git a/backend/src/auth/auth0_api.py b/backend/src/auth/auth0_api.py index 4bc646f..e594bdd 100644 --- a/backend/src/auth/auth0_api.py +++ b/backend/src/auth/auth0_api.py @@ -22,10 +22,7 @@ async def login(request: Request): redirect_uri = request.url_for("auth0_callback") return await oauth.auth0.authorize_redirect( - request, - redirect_uri, - prompt="select_account", - connection="google-oauth2" + request, redirect_uri, prompt="select_account", connection="google-oauth2" ) diff --git a/backend/src/auth/services.py b/backend/src/auth/services.py index 2125261..fe1fc1d 100644 --- a/backend/src/auth/services.py +++ b/backend/src/auth/services.py @@ -60,8 +60,14 @@ def get_user_from_token( def get_current_user( request: Request, session: SessionDep, - token: Annotated[str | None, Depends(OAuth2PasswordBearer( - tokenUrl=f"{settings.API_V1_STR}/tokens", auto_error=False))] = None, + token: Annotated[ + str | None, + Depends( + OAuth2PasswordBearer( + tokenUrl=f"{settings.API_V1_STR}/tokens", auto_error=False + ) + ), + ] = None, ) -> User: session_user = request.session.get("user") if session_user: From efab9597793e167fcaa178bde4e6db2046de69d2 Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sat, 17 May 2025 00:23:29 +0400 Subject: [PATCH 22/31] fix: fix lint errors in services.py --- backend/src/auth/services.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/backend/src/auth/services.py b/backend/src/auth/services.py index fe1fc1d..4978b95 100644 --- a/backend/src/auth/services.py +++ b/backend/src/auth/services.py @@ -17,8 +17,7 @@ ALGORITHM = "HS256" -reusable_oauth2 = OAuth2PasswordBearer( - tokenUrl=f"{settings.API_V1_STR}/tokens") +reusable_oauth2 = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/tokens") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") SessionDep = Annotated[Session, Depends(get_db)] @@ -28,11 +27,9 @@ def get_user_from_session(request: Request, session: SessionDep) -> User: session_user = request.session.get("user") if not session_user: - raise HTTPException(status_code=401, - detail="Not authenticated (no session)") + raise HTTPException(status_code=401, detail="Not authenticated (no session)") - user = session.exec(select(User).where( - User.email == session_user["email"])).first() + user = session.exec(select(User).where(User.email == session_user["email"])).first() if not user or not user.is_active: raise HTTPException(status_code=401, detail="Invalid session user") return UserPublic.model_validate(user) From c9299ab8d2317d4c7b992cb99f1e7a9f130b2637 Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sat, 17 May 2025 00:27:02 +0400 Subject: [PATCH 23/31] fix: fix lint errors in api.py --- backend/src/users/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/users/api.py b/backend/src/users/api.py index c0d8acb..4bd7007 100644 --- a/backend/src/users/api.py +++ b/backend/src/users/api.py @@ -1,6 +1,6 @@ from typing import Any -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, HTTPException from src.auth.services import CurrentUser, SessionDep from src.core.config import settings @@ -12,7 +12,7 @@ @router.get("/me", response_model=UserPublic) -def read_user_me(request: Request, current_user: CurrentUser) -> Any: +def read_user_me(current_user: CurrentUser) -> Any: """ Get current user. """ From facde11abd44a6968c92136810d4986691e74308 Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sun, 18 May 2025 18:48:31 +0400 Subject: [PATCH 24/31] feat: replace hardcoded urls --- backend/src/auth/auth0_api.py | 12 +++++++----- backend/src/core/config.py | 6 ++++++ backend/tests/flashcards/card/test_api.py | 1 + backend/tests/flashcards/collection/test_api.py | 5 ++++- frontend/src/hooks/useAuthContext.tsx | 7 +++---- frontend/src/routes/_publicLayout/login.tsx | 3 ++- frontend/src/routes/_publicLayout/signup.tsx | 4 +--- 7 files changed, 24 insertions(+), 14 deletions(-) diff --git a/backend/src/auth/auth0_api.py b/backend/src/auth/auth0_api.py index e594bdd..0e3d589 100644 --- a/backend/src/auth/auth0_api.py +++ b/backend/src/auth/auth0_api.py @@ -19,7 +19,8 @@ @router.get("/login", name="auth0_login") -async def login(request: Request): +async def login(request: Request, redirect_to: str = "/collections"): + request.session["redirect_to"] = redirect_to redirect_uri = request.url_for("auth0_callback") return await oauth.auth0.authorize_redirect( request, redirect_uri, prompt="select_account", connection="google-oauth2" @@ -29,10 +30,8 @@ async def login(request: Request): @router.get("/callback", name="auth0_callback") async def auth0_callback(request: Request): token = await oauth.auth0.authorize_access_token(request) - user = token.get("userinfo") or await oauth.auth0.userinfo(token=token) - # Create or get the user from database db = next(get_db()) db_user = get_or_create_user_by_email( session=db, @@ -45,7 +44,6 @@ async def auth0_callback(request: Request): }, ) - # Store in session request.session["user"] = { "email": user["email"], "name": user.get("name"), @@ -54,7 +52,11 @@ async def auth0_callback(request: Request): } request.session["user_id"] = str(db_user.id) - return RedirectResponse(url="http://localhost:5173/collections") + frontend_url = settings.FRONTEND_URL + redirect_to = request.session.pop("redirect_to", "/collections") + redirect_url = f"{frontend_url}{redirect_to}" + + return RedirectResponse(url=redirect_url) @router.get("/logout", name="auth0_logout") diff --git a/backend/src/core/config.py b/backend/src/core/config.py index 90358e2..e1aa8aa 100644 --- a/backend/src/core/config.py +++ b/backend/src/core/config.py @@ -34,6 +34,8 @@ class Settings(BaseSettings): DOMAIN: str = "localhost" ENVIRONMENT: Literal["local", "staging", "production"] = "local" + FRONTEND_URL: str + BACKEND_CORS_ORIGINS: Annotated[ list[AnyUrl] | str, BeforeValidator(parse_cors) ] = [] @@ -51,6 +53,10 @@ class Settings(BaseSettings): AUTH0_CLIENT_SECRET: str AUTH0_DOMAIN: str + ALLOWED_REDIRECT_ORIGINS: Annotated[ + list[str] | str, BeforeValidator(parse_cors) + ] = [] + @computed_field # type: ignore[misc] @property def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: diff --git a/backend/tests/flashcards/card/test_api.py b/backend/tests/flashcards/card/test_api.py index d4222bc..3c4f88d 100644 --- a/backend/tests/flashcards/card/test_api.py +++ b/backend/tests/flashcards/card/test_api.py @@ -133,6 +133,7 @@ def test_different_user_access( ): collection_id = test_collection["id"] card_id = test_card["id"] + client.cookies.clear() rsp = client.get( f"{settings.API_V1_STR}/collections/{collection_id}/cards/{card_id}", diff --git a/backend/tests/flashcards/collection/test_api.py b/backend/tests/flashcards/collection/test_api.py index d2bdff0..a40c728 100644 --- a/backend/tests/flashcards/collection/test_api.py +++ b/backend/tests/flashcards/collection/test_api.py @@ -170,6 +170,7 @@ def test_different_user_access( normal_user_token_headers: dict[str, str], superuser_token_headers: dict[str, str], ): + client.cookies.clear() collection_data = CollectionCreate(name="User Restricted Collection") rsp = client.post( f"{settings.API_V1_STR}/collections/", @@ -271,6 +272,7 @@ def test_different_user_update( superuser_token_headers: dict[str, str], test_collection: dict[str, Any], ): + client.cookies.clear() collection_id = test_collection["id"] update_data = CollectionUpdate(name="Cross User Collection Update") rsp = client.put( @@ -338,13 +340,14 @@ def test_different_user_delete( test_collection: dict[str, Any], ): collection_id = test_collection["id"] + client.cookies.clear() + rsp = client.delete( f"{settings.API_V1_STR}/collections/{collection_id}", headers=superuser_token_headers, ) assert rsp.status_code == 404 - # Verity the data still exists verify_rsp = client.get( f"{settings.API_V1_STR}/collections/{collection_id}", diff --git a/frontend/src/hooks/useAuthContext.tsx b/frontend/src/hooks/useAuthContext.tsx index 4e6287f..1fa3af0 100644 --- a/frontend/src/hooks/useAuthContext.tsx +++ b/frontend/src/hooks/useAuthContext.tsx @@ -31,9 +31,8 @@ function AuthProvider({ children }: { children: React.ReactNode }) { try { const user = await UsersService.readUserMe() - if (user) { - setIsLoggedIn(true) - } + setIsLoggedIn(Boolean(user)) + } catch (error) { console.error('Error fetching user:', error) } @@ -69,7 +68,7 @@ function AuthProvider({ children }: { children: React.ReactNode }) { const logout = async () => { try { - await fetch('http://localhost:8000/api/v1/auth0/logout', { + await fetch(`${import.meta.env.VITE_API_URL}/auth0/logout`, { method: 'GET', credentials: 'include', }) diff --git a/frontend/src/routes/_publicLayout/login.tsx b/frontend/src/routes/_publicLayout/login.tsx index 00c82cb..15fb6c8 100644 --- a/frontend/src/routes/_publicLayout/login.tsx +++ b/frontend/src/routes/_publicLayout/login.tsx @@ -53,7 +53,8 @@ function Login() { } const handleGoogleLogin = () => { - window.location.href = 'http://localhost:8000/api/v1/auth0/login' + window.location.href = `${import.meta.env.VITE_API_URL}/api/v1/auth0/login?redirect_to=/collections` + } return ( diff --git a/frontend/src/routes/_publicLayout/signup.tsx b/frontend/src/routes/_publicLayout/signup.tsx index a3550b3..ef8048c 100644 --- a/frontend/src/routes/_publicLayout/signup.tsx +++ b/frontend/src/routes/_publicLayout/signup.tsx @@ -64,9 +64,7 @@ function SignUp() { } const handleGoogleSignup = () => { - // This function will be implemented later when we add Auth0 integration - console.log('Google signup clicked') - window.location.href = 'http://localhost:8000/api/v1/auth0/login' + window.location.href = `${import.meta.env.VITE_API_URL}/api/v1/auth0/login?redirect_to=/collections` } return ( From 48b08943daab8983fd33d51ac21b5abbb214d22f Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sun, 18 May 2025 18:49:20 +0400 Subject: [PATCH 25/31] feat: add frontend_url and allowed_redirect_origins env variables --- backend/.env.example | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/.env.example b/backend/.env.example index 4f039f3..14801d9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -10,6 +10,8 @@ FIRST_SUPERUSER=admin@example.com FIRST_SUPERUSER_PASSWORD=changethis USERS_OPEN_REGISTRATION=True +# Frontend +FRONTEND_URL="http://localhost:5173" # Postgres POSTGRES_SERVER=localhost @@ -29,9 +31,11 @@ CARD_GENERATION_PROMPT="I want to generate a flashcard on a specific topic. The AUTH0_DOMAIN=auth0-domain AUTH0_CLIENT_ID=auth0-client-id AUTH0_CLIENT_SECRET=auth0-client-secret -AUTH0_AUDIENCE=auth0-audience AUTH0_CALLBACK_URL=auth0-callback-url AUTH_LOGOUT_URL=auth-logout-url +ALLOWED_REDIRECT_ORIGINS=http://localhost:5173 + # Session Configuration SECRET_KEY=secret-key + From 8a20de71f7895c6b0cc219d37e9fc437a182d5ec Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sun, 18 May 2025 18:58:07 +0400 Subject: [PATCH 26/31] fix: fix frontend lint errors --- frontend/src/hooks/useAuthContext.tsx | 1 - frontend/src/routes/_publicLayout/login.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/frontend/src/hooks/useAuthContext.tsx b/frontend/src/hooks/useAuthContext.tsx index 1fa3af0..1423708 100644 --- a/frontend/src/hooks/useAuthContext.tsx +++ b/frontend/src/hooks/useAuthContext.tsx @@ -32,7 +32,6 @@ function AuthProvider({ children }: { children: React.ReactNode }) { try { const user = await UsersService.readUserMe() setIsLoggedIn(Boolean(user)) - } catch (error) { console.error('Error fetching user:', error) } diff --git a/frontend/src/routes/_publicLayout/login.tsx b/frontend/src/routes/_publicLayout/login.tsx index 15fb6c8..e1fdffa 100644 --- a/frontend/src/routes/_publicLayout/login.tsx +++ b/frontend/src/routes/_publicLayout/login.tsx @@ -54,7 +54,6 @@ function Login() { const handleGoogleLogin = () => { window.location.href = `${import.meta.env.VITE_API_URL}/api/v1/auth0/login?redirect_to=/collections` - } return ( From 4ddf3f5f8ae6b847215df6c05535cbec7339e14f Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Sun, 18 May 2025 21:36:01 +0400 Subject: [PATCH 27/31] feat: minor changes --- backend/src/auth/services.py | 4 ++-- backend/tests/flashcards/card/test_api.py | 1 - backend/tests/flashcards/collection/test_api.py | 3 --- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/backend/src/auth/services.py b/backend/src/auth/services.py index 4978b95..8c4ff5c 100644 --- a/backend/src/auth/services.py +++ b/backend/src/auth/services.py @@ -67,10 +67,10 @@ def get_current_user( ] = None, ) -> User: session_user = request.session.get("user") - if session_user: - return get_user_from_session(request, session) if token: return get_user_from_token(session, token) + if session_user: + return get_user_from_session(request, session) raise HTTPException(status_code=401, detail="Not authenticated") diff --git a/backend/tests/flashcards/card/test_api.py b/backend/tests/flashcards/card/test_api.py index 3c4f88d..d4222bc 100644 --- a/backend/tests/flashcards/card/test_api.py +++ b/backend/tests/flashcards/card/test_api.py @@ -133,7 +133,6 @@ def test_different_user_access( ): collection_id = test_collection["id"] card_id = test_card["id"] - client.cookies.clear() rsp = client.get( f"{settings.API_V1_STR}/collections/{collection_id}/cards/{card_id}", diff --git a/backend/tests/flashcards/collection/test_api.py b/backend/tests/flashcards/collection/test_api.py index a40c728..2fd59b4 100644 --- a/backend/tests/flashcards/collection/test_api.py +++ b/backend/tests/flashcards/collection/test_api.py @@ -170,7 +170,6 @@ def test_different_user_access( normal_user_token_headers: dict[str, str], superuser_token_headers: dict[str, str], ): - client.cookies.clear() collection_data = CollectionCreate(name="User Restricted Collection") rsp = client.post( f"{settings.API_V1_STR}/collections/", @@ -272,7 +271,6 @@ def test_different_user_update( superuser_token_headers: dict[str, str], test_collection: dict[str, Any], ): - client.cookies.clear() collection_id = test_collection["id"] update_data = CollectionUpdate(name="Cross User Collection Update") rsp = client.put( @@ -340,7 +338,6 @@ def test_different_user_delete( test_collection: dict[str, Any], ): collection_id = test_collection["id"] - client.cookies.clear() rsp = client.delete( f"{settings.API_V1_STR}/collections/{collection_id}", From 854f9e1ae6af0be33ef08e954edba503afd20a61 Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Mon, 19 May 2025 12:15:13 +0400 Subject: [PATCH 28/31] feat: use dependecy injection for db session in auth0_callback --- backend/.env.example | 1 - backend/src/auth/api.py | 5 ---- backend/src/auth/auth0_api.py | 35 +++++++++++++++------------ backend/src/auth/services.py | 26 ++++++++++++++------ frontend/src/hooks/useAuthContext.tsx | 15 +++++++++--- 5 files changed, 48 insertions(+), 34 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 14801d9..d051cdb 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -32,7 +32,6 @@ AUTH0_DOMAIN=auth0-domain AUTH0_CLIENT_ID=auth0-client-id AUTH0_CLIENT_SECRET=auth0-client-secret AUTH0_CALLBACK_URL=auth0-callback-url -AUTH_LOGOUT_URL=auth-logout-url ALLOWED_REDIRECT_ORIGINS=http://localhost:5173 diff --git a/backend/src/auth/api.py b/backend/src/auth/api.py index dee2c1a..f4448f9 100644 --- a/backend/src/auth/api.py +++ b/backend/src/auth/api.py @@ -32,11 +32,6 @@ def login_access_token( # Create token token = services.create_access_token(user.id, expires_delta=access_token_expires) - # Also store user in session for consistency with Auth0 flow - request.session["user"] = { - "email": user.email, - "id": str(user.id), - } request.session["user_id"] = str(user.id) return Token(access_token=token) diff --git a/backend/src/auth/auth0_api.py b/backend/src/auth/auth0_api.py index 0e3d589..91c78f6 100644 --- a/backend/src/auth/auth0_api.py +++ b/backend/src/auth/auth0_api.py @@ -1,5 +1,6 @@ from authlib.integrations.starlette_client import OAuth -from fastapi import APIRouter, Request +from sqlmodel import Session +from fastapi import APIRouter, Request, Depends, HTTPException from fastapi.responses import RedirectResponse from src.auth.services import get_or_create_user_by_email # Fix the import path @@ -28,33 +29,35 @@ async def login(request: Request, redirect_to: str = "/collections"): @router.get("/callback", name="auth0_callback") -async def auth0_callback(request: Request): +async def auth0_callback(request: Request, db: Session = Depends(get_db)): + # Exchange code for token token = await oauth.auth0.authorize_access_token(request) - user = token.get("userinfo") or await oauth.auth0.userinfo(token=token) - db = next(get_db()) + # Extract user info + user_info = token.get("userinfo") + if not user_info: + user_info = await oauth.auth0.userinfo(token=token) + + if not user_info or "email" not in user_info: + raise HTTPException(status_code=400, detail="Invalid user info from Auth0") + + # Create or get user in local DB db_user = get_or_create_user_by_email( session=db, - email=user["email"], + email=user_info["email"], defaults={ - "auth0_id": user["sub"], - "full_name": user.get("name"), - "picture": user.get("picture"), + "auth0_id": user_info["sub"], + "full_name": user_info.get("name"), "is_active": True, }, ) - request.session["user"] = { - "email": user["email"], - "name": user.get("name"), - "picture": user.get("picture"), - "sub": user.get("sub"), - } + # Store user in session request.session["user_id"] = str(db_user.id) - frontend_url = settings.FRONTEND_URL + # Determine redirect target redirect_to = request.session.pop("redirect_to", "/collections") - redirect_url = f"{frontend_url}{redirect_to}" + redirect_url = f"{settings.FRONTEND_URL}{redirect_to}" return RedirectResponse(url=redirect_url) diff --git a/backend/src/auth/services.py b/backend/src/auth/services.py index 8c4ff5c..b47ab08 100644 --- a/backend/src/auth/services.py +++ b/backend/src/auth/services.py @@ -25,14 +25,21 @@ def get_user_from_session(request: Request, session: SessionDep) -> User: - session_user = request.session.get("user") - if not session_user: + user_id = request.session.get("user_id") + if not user_id: raise HTTPException(status_code=401, detail="Not authenticated (no session)") - user = session.exec(select(User).where(User.email == session_user["email"])).first() - if not user or not user.is_active: - raise HTTPException(status_code=401, detail="Invalid session user") - return UserPublic.model_validate(user) + from src.users.services import get_user_by_id + import uuid + + try: + user_uuid = uuid.UUID(user_id) + user = get_user_by_id(session=session, user_id=user_uuid) + if not user or not user.is_active: + raise HTTPException(status_code=401, detail="Invalid session user") + return UserPublic.model_validate(user) + except (ValueError, TypeError): + raise HTTPException(status_code=401, detail="Invalid user ID in session") def get_user_from_token( @@ -66,12 +73,15 @@ def get_current_user( ), ] = None, ) -> User: - session_user = request.session.get("user") + # Check for token-based authentication first if token: return get_user_from_token(session, token) - if session_user: + + # Check for session-based authentication + if request.session.get("user_id"): return get_user_from_session(request, session) + # No valid authentication method found raise HTTPException(status_code=401, detail="Not authenticated") diff --git a/frontend/src/hooks/useAuthContext.tsx b/frontend/src/hooks/useAuthContext.tsx index 1423708..8879b5f 100644 --- a/frontend/src/hooks/useAuthContext.tsx +++ b/frontend/src/hooks/useAuthContext.tsx @@ -40,9 +40,7 @@ function AuthProvider({ children }: { children: React.ReactNode }) { }, []) useEffect(() => { - console.log('AuthProvider mounted') const handleStorage = (e: StorageEvent) => { - console.log('Storage event:', e.key) if (e.key === GUEST_MODE_KEY || e.key === ACCESS_TOKEN_KEY) { setIsGuest(localStorage.getItem(GUEST_MODE_KEY) === 'true') setIsLoggedIn( @@ -66,15 +64,24 @@ function AuthProvider({ children }: { children: React.ReactNode }) { } const logout = async () => { + const isGuest = localStorage.getItem('guest_mode') === 'true' + const hasToken = Boolean(localStorage.getItem('access_token')) + try { - await fetch(`${import.meta.env.VITE_API_URL}/auth0/logout`, { + if (isGuest) { + localStorage.removeItem('guest_mode') + } else if (hasToken) { + localStorage.removeItem('access_token') + } + + await fetch(`${import.meta.env.VITE_API_URL}/api/v1/auth0/logout`, { method: 'GET', credentials: 'include', }) } catch (e) { console.error('Logout request failed', e) } - localStorage.removeItem(ACCESS_TOKEN_KEY) + setGuestMode(false) setIsLoggedIn(false) } From dcff54fa7f12fc12ac900937a6b076ea199b291e Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Mon, 19 May 2025 12:45:29 +0400 Subject: [PATCH 29/31] fix: fix lint errors --- backend/src/auth/services.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/auth/services.py b/backend/src/auth/services.py index b47ab08..8cf08c9 100644 --- a/backend/src/auth/services.py +++ b/backend/src/auth/services.py @@ -2,6 +2,8 @@ from typing import Annotated, Any import jwt +import uuid + from fastapi import Depends, HTTPException, Request from fastapi.security import OAuth2PasswordBearer from jwt.exceptions import InvalidTokenError @@ -30,7 +32,6 @@ def get_user_from_session(request: Request, session: SessionDep) -> User: raise HTTPException(status_code=401, detail="Not authenticated (no session)") from src.users.services import get_user_by_id - import uuid try: user_uuid = uuid.UUID(user_id) From 9ebfb8325071fc9e763ff30b272753dd381d20f9 Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Mon, 19 May 2025 12:50:38 +0400 Subject: [PATCH 30/31] fix: organize imports --- backend/src/auth/services.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/auth/services.py b/backend/src/auth/services.py index 8cf08c9..fac64dd 100644 --- a/backend/src/auth/services.py +++ b/backend/src/auth/services.py @@ -1,9 +1,8 @@ +import uuid from datetime import datetime, timedelta, timezone from typing import Annotated, Any import jwt -import uuid - from fastapi import Depends, HTTPException, Request from fastapi.security import OAuth2PasswordBearer from jwt.exceptions import InvalidTokenError From 9ee051dade08dd526fc9a70989a854d11d7aa004 Mon Sep 17 00:00:00 2001 From: Victoria Tskhondia Date: Mon, 19 May 2025 12:55:59 +0400 Subject: [PATCH 31/31] fix: fix lint errors in auth0_api --- backend/src/auth/auth0_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/auth/auth0_api.py b/backend/src/auth/auth0_api.py index 91c78f6..28c0d32 100644 --- a/backend/src/auth/auth0_api.py +++ b/backend/src/auth/auth0_api.py @@ -1,7 +1,7 @@ from authlib.integrations.starlette_client import OAuth -from sqlmodel import Session -from fastapi import APIRouter, Request, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import RedirectResponse +from sqlmodel import Session from src.auth.services import get_or_create_user_by_email # Fix the import path from src.core.config import settings