diff --git a/backend/app/config.py b/backend/app/config.py
index fddf223..2668ef2 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -44,5 +44,12 @@ class Settings(BaseSettings):
BBB_SERVER_URL: str = "" # BigBlueButton server URL (e.g. https://bbb.example.com/bigbluebutton)
BBB_SECRET: str = "" # BigBlueButton shared secret
+ # OIDC / SSO — leave blank to disable
+ OIDC_PROVIDER_URL: str = "" # e.g. https://accounts.google.com, https://login.microsoftonline.com/{tenant}/v2.0
+ OIDC_CLIENT_ID: str = ""
+ OIDC_CLIENT_SECRET: str = ""
+ OIDC_SCOPES: str = "openid email profile" # space-separated
+ OIDC_PROVIDER_NAME: str = "SSO" # Display name on login button
+
settings = Settings()
diff --git a/backend/app/main.py b/backend/app/main.py
index b6b95c0..964b3a3 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -505,6 +505,10 @@ app.add_middleware(
expose_headers=["X-New-Token"], # Allow frontend to read this header
)
+# Session middleware for OIDC state (authlib needs it)
+from starlette.middleware.sessions import SessionMiddleware
+app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
+
# Add token refresh middleware AFTER CORS (middleware applies in reverse)
from app.utils.auth import TokenRefreshMiddleware
app.add_middleware(TokenRefreshMiddleware)
diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py
index 07da10d..f533b4b 100644
--- a/backend/app/routers/admin.py
+++ b/backend/app/routers/admin.py
@@ -401,16 +401,23 @@ def get_settings(admin: User = Depends(require_admin)):
registration_enabled = r.get("settings:registration_enabled")
embedding_model = r.get("settings:embedding_model")
polly_enabled = r.get("settings:polly_enabled")
+ sso_only = r.get("settings:sso_only")
return {
"registration_enabled": registration_enabled != "false",
"embedding_model": embedding_model or settings.LITELLM_EMBEDDING_MODEL or "",
"polly_enabled": polly_enabled != "false",
+ "sso_only": sso_only == "true",
+ "sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
+ "sso_provider_name": settings.OIDC_PROVIDER_NAME,
}
except Exception:
return {
"registration_enabled": True,
"embedding_model": settings.LITELLM_EMBEDDING_MODEL or "",
"polly_enabled": True,
+ "sso_only": False,
+ "sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
+ "sso_provider_name": settings.OIDC_PROVIDER_NAME,
}
@@ -435,6 +442,10 @@ def update_settings(
value = "true" if settings_data["polly_enabled"] else "false"
r.set("settings:polly_enabled", value)
+ if "sso_only" in settings_data:
+ value = "true" if settings_data["sso_only"] else "false"
+ r.set("settings:sso_only", value)
+
return {"success": True, "message": "Settings updated"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update settings: {str(e)}")
diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py
index 05c52c4..39f57c9 100644
--- a/backend/app/routers/attempts.py
+++ b/backend/app/routers/attempts.py
@@ -167,12 +167,13 @@ def submit_attempt(
percentage = (score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
- # Update reminder schedule
- try:
- from app.services.reminder_service import update_reminder_schedule
- update_reminder_schedule(db, current_user.id, attempt.quiz_id, percentage)
- except Exception:
- pass # Don't fail submission if reminder update fails
+ # Update reminder schedule (skip course quizzes)
+ if not is_course_quiz:
+ try:
+ from app.services.reminder_service import update_reminder_schedule
+ update_reminder_schedule(db, current_user.id, attempt.quiz_id, percentage)
+ except Exception:
+ pass # Don't fail submission if reminder update fails
return AttemptDetail(
id=attempt.id,
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index 7a5b77f..8292112 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -145,6 +145,11 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
@router.post("/login", response_model=Token)
async def login(login_data: LoginRequest, db: Session = Depends(get_db), request: Request = None):
+ # Block password login if SSO-only mode
+ sso_settings = _get_sso_settings()
+ if sso_settings["sso_only"]:
+ raise HTTPException(status_code=403, detail="Password login is disabled. Please use SSO.")
+
# Verify Turnstile if configured
from app.config import settings as cfg
if cfg.TURNSTILE_SECRET_KEY:
@@ -314,3 +319,111 @@ def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: U
"id": current_user.id, "email": current_user.email,
"name": current_user.name, "role": current_user.role,
}
+
+
+# ── SSO / OIDC ─────────────────────────────────────────────────────
+
+def _get_sso_settings():
+ """Return SSO admin settings from Redis."""
+ try:
+ import redis as redis_lib
+ from app.config import settings as cfg
+ r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
+ return {
+ "sso_only": r.get("settings:sso_only") == "true",
+ }
+ except Exception:
+ return {"sso_only": False}
+
+
+@router.get("/sso/config")
+def sso_config():
+ """Public endpoint — tells frontend whether SSO is available and login mode."""
+ from app.config import settings as cfg
+ sso_enabled = bool(cfg.OIDC_PROVIDER_URL and cfg.OIDC_CLIENT_ID)
+ sso_settings = _get_sso_settings()
+ return {
+ "sso_enabled": sso_enabled,
+ "sso_only": sso_settings["sso_only"],
+ "provider_name": cfg.OIDC_PROVIDER_NAME if sso_enabled else None,
+ }
+
+
+@router.get("/sso/login")
+def sso_login(request: Request):
+ """Redirect user to the OIDC provider for login."""
+ from authlib.integrations.starlette_client import OAuth
+ from starlette.responses import RedirectResponse
+ from app.config import settings as cfg
+
+ if not cfg.OIDC_PROVIDER_URL or not cfg.OIDC_CLIENT_ID:
+ raise HTTPException(status_code=400, detail="SSO is not configured")
+
+ oauth = OAuth()
+ oauth.register(
+ name="oidc",
+ server_metadata_url=f"{cfg.OIDC_PROVIDER_URL.rstrip('/')}/.well-known/openid-configuration",
+ client_id=cfg.OIDC_CLIENT_ID,
+ client_secret=cfg.OIDC_CLIENT_SECRET,
+ client_kwargs={"scope": cfg.OIDC_SCOPES},
+ )
+
+ redirect_uri = f"{cfg.APP_URL}/api/auth/sso/callback"
+ return oauth.oidc.authorize_redirect(request, redirect_uri)
+
+
+@router.get("/sso/callback")
+async def sso_callback(request: Request, db: Session = Depends(get_db)):
+ """Handle OIDC provider callback — create or login user."""
+ from authlib.integrations.starlette_client import OAuth
+ from starlette.responses import RedirectResponse
+ from app.config import settings as cfg
+
+ if not cfg.OIDC_PROVIDER_URL or not cfg.OIDC_CLIENT_ID:
+ raise HTTPException(status_code=400, detail="SSO is not configured")
+
+ oauth = OAuth()
+ oauth.register(
+ name="oidc",
+ server_metadata_url=f"{cfg.OIDC_PROVIDER_URL.rstrip('/')}/.well-known/openid-configuration",
+ client_id=cfg.OIDC_CLIENT_ID,
+ client_secret=cfg.OIDC_CLIENT_SECRET,
+ client_kwargs={"scope": cfg.OIDC_SCOPES},
+ )
+
+ try:
+ token = await oauth.oidc.authorize_access_token(request)
+ except Exception:
+ return RedirectResponse(url=f"{cfg.APP_URL}/login?error=sso_failed")
+
+ userinfo = token.get("userinfo") or {}
+ email = userinfo.get("email", "").lower().strip()
+ name = userinfo.get("name") or userinfo.get("preferred_username") or email.split("@")[0]
+
+ if not email:
+ return RedirectResponse(url=f"{cfg.APP_URL}/login?error=no_email")
+
+ # Find or create user
+ user = db.query(User).filter(User.email == email).first()
+ if not user:
+ user = User(
+ email=email,
+ hashed_password=get_password_hash(secrets.token_urlsafe(32)), # random password — SSO users don't use it
+ name=name,
+ role="user",
+ )
+ db.add(user)
+ db.flush()
+ # Auto-verify SSO users
+ verification = EmailVerification(
+ user_id=user.id,
+ token=secrets.token_urlsafe(32),
+ expires_at=datetime.utcnow() + timedelta(hours=1),
+ verified_at=datetime.utcnow(),
+ )
+ db.add(verification)
+ db.commit()
+ db.refresh(user)
+
+ access_token = create_access_token(data={"sub": user.email})
+ return RedirectResponse(url=f"{cfg.APP_URL}/sso-callback?token={access_token}")
diff --git a/backend/app/utils/scheduler.py b/backend/app/utils/scheduler.py
index 54c5474..f358d20 100644
--- a/backend/app/utils/scheduler.py
+++ b/backend/app/utils/scheduler.py
@@ -32,10 +32,21 @@ def check_and_send_reminders():
user = db.query(User).filter(User.id == reminder.user_id).first()
quiz = db.query(Quiz).filter(Quiz.id == reminder.quiz_id).first()
- if not user or not quiz:
+ if not user or not quiz or quiz.deleted_at or quiz.course_id:
reminder.is_active = False
continue
+ # Check if user has opted out of reminders
+ try:
+ import redis as redis_lib, json as _json
+ from app.config import settings as cfg
+ r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
+ user_settings = _json.loads(r.get(f"user_settings:{user.id}") or "{}")
+ if user_settings.get("reminders_disabled"):
+ continue
+ except Exception:
+ pass
+
# Send email asynchronously
try:
from app.services.email_service import send_reminder_email
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 7ec55b6..f9f5b56 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -21,3 +21,5 @@ httpx==0.27.0
boto3==1.34.69
pgvector==0.3.6
openpyxl==3.1.2
+authlib==1.3.0
+itsdangerous==2.1.2
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 28e8cda..83bc1be 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -27,6 +27,7 @@ import FlashcardStudyPage from './pages/FlashcardStudyPage'
import CoursesPage from './pages/CoursesPage'
import CourseDetailPage from './pages/CourseDetailPage'
import CourseEditorPage from './pages/CourseEditorPage'
+import SsoCallbackPage from './pages/SsoCallbackPage'
// Layout wrapper for authenticated app pages (Navbar + container + footer)
function AppLayout() {
@@ -65,6 +66,7 @@ function AppRoutes() {