diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index 34c44f9..59b6f31 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -12,12 +12,11 @@ from app.models.email_verification import EmailVerification
from app.models.password_reset import PasswordReset
from app.schemas.auth import (
UserResponse, Token, LoginRequest, LogoutRequest, RefreshRequest,
- UserUpdateMe, ForgotPasswordRequest, ResetPasswordRequest, SsoExchangeRequest,
+ UserUpdateMe, SsoExchangeRequest,
)
from app.services import email_service
from app.utils.auth import (
- check_rate_limit, get_password_hash, verify_password, create_access_token,
- get_current_user,
+ check_rate_limit, verify_password, create_access_token, get_current_user,
)
logger = logging.getLogger(__name__)
@@ -215,95 +214,17 @@ def end_session(family: str, db: Session = Depends(get_db),
refresh_tokens.revoke_family(db, current_user, family)
-@router.get("/verify-email")
-def verify_email(token: str, db: Session = Depends(get_db)):
- record = db.query(EmailVerification).filter(EmailVerification.token == token).first()
- if not record:
- raise HTTPException(status_code=400, detail="Invalid verification token")
- if record.verified_at is not None:
- return {"message": "Email already verified. You can log in."}
- if datetime.utcnow() > record.expires_at:
- raise HTTPException(status_code=400, detail="Verification link has expired. Please register again or request a new link.")
-
- record.verified_at = datetime.utcnow()
- db.commit()
- return {"message": "Email verified successfully! You can now log in."}
-
-
-@router.post("/resend-verification")
-async def resend_verification(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
- _refuse_when_sso_only("Accounts are created through SSO on this site.")
- email_normalized = data.email.lower().strip()
- user = db.query(User).filter(User.email == email_normalized).first()
- if not user:
- return {"message": "If that email exists, a verification link has been sent."}
-
- record = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
- if record and record.verified_at is not None:
- # The same sentence as every other outcome. Saying "already verified"
- # here told anybody who asked that the address has an account and that
- # it is in use — which is the whole of what the wording below is for.
- return {"message": "If that email exists, a verification link has been sent."}
-
- token = secrets.token_urlsafe(32)
- if record:
- record.token = token
- record.expires_at = datetime.utcnow() + timedelta(hours=24)
- else:
- db.add(EmailVerification(user_id=user.id, token=token, expires_at=datetime.utcnow() + timedelta(hours=24)))
- db.commit()
-
- background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token)
- return {"message": "If that email exists, a verification link has been sent."}
-
-
-@router.post("/forgot-password")
-async def forgot_password(data: ForgotPasswordRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db)):
- _refuse_when_sso_only("There is no password to reset. Please use SSO.")
- email_normalized = data.email.lower().strip()
- _check_reset_rate_limit(db, email_normalized)
-
- user = db.query(User).filter(User.email == email_normalized).first()
- # Always return same message to avoid email enumeration
- if not user:
- return {"message": "If that email is registered, a reset link has been sent."}
-
- token = secrets.token_urlsafe(32)
- reset = PasswordReset(
- user_id=user.id,
- token=token,
- expires_at=datetime.utcnow() + timedelta(hours=1),
- )
- db.add(reset)
- db.commit()
-
- background_tasks.add_task(email_service.send_password_reset_email, user.email, user.name, token)
- return {"message": "If that email is registered, a reset link has been sent."}
-
-
-@router.post("/reset-password")
-def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)):
- _refuse_when_sso_only("There is no password to reset. Please use SSO.")
- if len(data.new_password) < 8:
- raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
-
- record = db.query(PasswordReset).filter(
- PasswordReset.token == data.token,
- PasswordReset.used == False,
- ).first()
- if not record:
- raise HTTPException(status_code=400, detail="Invalid or already used reset token")
- if datetime.utcnow() > record.expires_at:
- raise HTTPException(status_code=400, detail="Reset link has expired. Please request a new one.")
-
- user = db.query(User).filter(User.id == record.user_id).first()
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
-
- user.hashed_password = get_password_hash(data.new_password)
- record.used = True
- db.commit()
- return {"message": "Password reset successfully. You can now log in."}
+# ── No password lifecycle ─────────────────────────────────────────────
+#
+# Verifying an address, resending that mail, forgetting a password and
+# resetting one all lived here. Accounts are at the identity provider now: it
+# owns the address, the passkey, the second factor and any password there is,
+# so a reset link minted here would set a credential nothing checks — and an
+# account with no password cannot forget one.
+#
+# /login stays, refused while `sso_only` is set. It is the way back in if the
+# provider is ever unreachable, together with the DEFAULT_ADMIN_EMAIL seed at
+# startup, and taking it out would leave no door at all on a bad day.
@router.get("/me/settings")
@@ -370,25 +291,13 @@ def get_me(current_user: User = Depends(get_current_user)):
@router.put("/me")
def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
- if data.new_password:
- # Setting one is the same door. A password nobody can log in with is
- # not harmless: it is a credential waiting for the flag to be turned
- # off again.
- _refuse_when_sso_only("Passwords are not used on this site. Please use SSO.")
- # A first password is not a change of one. Somebody who signed in with
- # a code or through single sign-on has none to confirm, and asking for
- # it locked them out of ever setting one — the only way through was
- # "forgot password", which is a strange thing to click when you never
- # had one.
- if current_user.hashed_password:
- if not data.current_password:
- raise HTTPException(status_code=400, detail="Current password required to set a new one")
- if not verify_password(data.current_password, current_user.hashed_password):
- raise HTTPException(status_code=400, detail="Current password is incorrect")
- if len(data.new_password) < 8:
- raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
- current_user.hashed_password = get_password_hash(data.new_password)
+ """Your name. Your sign-in belongs to the provider.
+ This used to set a password as well. There is nowhere for one to be used
+ that the provider does not own, so writing one here would store a
+ credential nothing checks — and the settings page that offered it invited
+ somebody to secure their account by a route that secures nothing.
+ """
if data.name:
current_user.name = data.name
diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py
index 716c1eb..fa4395d 100644
--- a/backend/app/schemas/auth.py
+++ b/backend/app/schemas/auth.py
@@ -72,17 +72,6 @@ class UserUpdateRole(BaseModel):
class UserUpdateMe(BaseModel):
name: str | None = None
- current_password: str | None = None
- new_password: str | None = None
-
-
-class ForgotPasswordRequest(BaseModel):
- email: EmailStr
-
-
-class ResetPasswordRequest(BaseModel):
- token: str
- new_password: str
class SsoExchangeRequest(BaseModel):
diff --git a/backend/tests/api-contract.json b/backend/tests/api-contract.json
index be5ad64..7364150 100644
--- a/backend/tests/api-contract.json
+++ b/backend/tests/api-contract.json
@@ -889,16 +889,6 @@
"200"
]
},
- "GET /api/v1/auth/verify-email": {
- "body": false,
- "params": [
- "query:token"
- ],
- "responses": [
- "200",
- "422"
- ]
- },
"GET /api/v1/categories/": {
"body": false,
"params": [],
@@ -2172,14 +2162,6 @@
"422"
]
},
- "POST /api/v1/auth/forgot-password": {
- "body": true,
- "params": [],
- "responses": [
- "200",
- "422"
- ]
- },
"POST /api/v1/auth/login": {
"body": true,
"params": [],
@@ -2204,22 +2186,6 @@
"422"
]
},
- "POST /api/v1/auth/resend-verification": {
- "body": true,
- "params": [],
- "responses": [
- "200",
- "422"
- ]
- },
- "POST /api/v1/auth/reset-password": {
- "body": true,
- "params": [],
- "responses": [
- "200",
- "422"
- ]
- },
"POST /api/v1/auth/sso/exchange": {
"body": true,
"params": [],
diff --git a/backend/tests/test_article_ai.py b/backend/tests/test_article_ai.py
index f6a8f0b..9a74704 100644
--- a/backend/tests/test_article_ai.py
+++ b/backend/tests/test_article_ai.py
@@ -238,9 +238,13 @@ class DraftPromptTests(unittest.TestCase):
ai.return_value = json.dumps(DRAFT_RESPONSE)
generate_article_draft('job-refine', 3, 'Refine me', '', article.id)
prompt = ai.call_args.kwargs['messages'][0]['content']
- self.assertIn('## [clinical] At the bedside', prompt)
- self.assertIn('## [short] High yield', prompt)
+ # Variant and id both travel: the variant so a bedside section is
+ # not rewritten as part of the long read, the id so the links
+ # pointing at it survive the refine.
+ self.assertIn(f"## [clinical] [id:{'a' * 32}] At the bedside", prompt)
+ self.assertIn(f"## [short] [id:{'b' * 32}] High yield", prompt)
self.assertIn("must be kept", prompt)
+ self.assertIn("returned unchanged", prompt)
finally:
patch.stopall()
self.bank.tearDown()
diff --git a/backend/tests/test_sso_hardening.py b/backend/tests/test_sso_hardening.py
index 46f81d3..ad60f36 100644
--- a/backend/tests/test_sso_hardening.py
+++ b/backend/tests/test_sso_hardening.py
@@ -6,6 +6,7 @@ import os
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
import unittest
+import unittest.mock
from unittest.mock import patch
from fastapi import FastAPI
@@ -20,12 +21,15 @@ from app.routers import auth
from app.utils.auth import get_current_user
-class SsoOnlyClosesThePasswordDoors(unittest.TestCase):
- """Login checked the flag. The other password doors did not.
+class SsoOnlyClosesTheLastPasswordDoor(unittest.TestCase):
+ """Password recovery is not guarded any more — it does not exist.
- Registration is gone entirely — accounts are made at the provider — so
- what is left to guard is the password somebody already has: resetting it,
- or setting a new one while signed in.
+ Verifying an address, resending that mail, forgetting a password, resetting
+ one and setting one through the profile have all gone: the identity
+ provider owns the address and the credential, so a reset link minted here
+ would set something nothing checks. What is left is /auth/login, kept as
+ the way back in if the provider is ever unreachable, and refused while the
+ site is single sign-on.
"""
def setUp(self):
@@ -33,17 +37,12 @@ class SsoOnlyClosesThePasswordDoors(unittest.TestCase):
poolclass=StaticPool)
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
- # No password of their own, so the "set a first password" path in
- # PUT /auth/me is the one under test rather than bcrypt's opinion of a
- # fake hash.
- self.user = User(id=1, name="Reader", email="reader@example.com",
- hashed_password=None)
- self.db.add(self.user)
+ self.db.add(User(id=1, name="Reader", email="reader@example.com",
+ hashed_password=None))
self.db.commit()
app = FastAPI()
app.include_router(auth.router, prefix="/auth")
app.dependency_overrides[get_db] = lambda: self.db
- app.dependency_overrides[get_current_user] = lambda: self.user
self.client = TestClient(app)
def tearDown(self):
@@ -51,28 +50,25 @@ class SsoOnlyClosesThePasswordDoors(unittest.TestCase):
self.db.close()
self.engine.dispose()
- def calls(self):
- return [
- ("/auth/forgot-password", {"email": "reader@example.com"}),
- ("/auth/reset-password", {"token": "x" * 20, "new_password": "abcdefgh"}),
- ("/auth/me", {"new_password": "abcdefgh"}),
- ]
+ def test_login_is_refused_while_the_provider_is_the_only_way_in(self):
+ with unittest.mock.patch.object(auth, "_get_sso_settings",
+ return_value={"sso_only": True}):
+ response = self.client.post("/auth/login", json={
+ "email": "reader@example.com", "password": "whatever"})
+ self.assertEqual(response.status_code, 403)
+ self.assertIn("SSO", response.json()["detail"])
- def test_every_password_door_is_shut(self):
- with patch.object(auth, "_get_sso_settings", return_value={"sso_only": True}):
- for path, body in self.calls():
- method = self.client.put if path == "/auth/me" else self.client.post
- response = method(path, json=body)
- self.assertEqual(response.status_code, 403, f"{path}: {response.text}")
- self.assertIn("SSO", response.json()["detail"], path)
+ def test_the_recovery_routes_are_gone_rather_than_guarded(self):
+ for path in ("/auth/forgot-password", "/auth/reset-password",
+ "/auth/resend-verification"):
+ self.assertEqual(self.client.post(path, json={}).status_code, 404, path)
+ self.assertEqual(self.client.get("/auth/verify-email?token=x").status_code, 404)
+
+ def test_the_profile_no_longer_sets_a_password(self):
+ from app.schemas.auth import UserUpdateMe
+ self.assertNotIn("new_password", UserUpdateMe.model_fields)
+ self.assertNotIn("current_password", UserUpdateMe.model_fields)
- def test_and_open_again_when_the_site_is_not_sso_only(self):
- # Not asserting success — these have their own rules — only that the
- # flag is no longer the thing refusing them.
- with patch.object(auth, "_get_sso_settings", return_value={"sso_only": False}):
- for path, body in self.calls():
- method = self.client.put if path == "/auth/me" else self.client.post
- self.assertNotEqual(method(path, json=body).status_code, 403, path)
class OneTimeCodeTests(unittest.TestCase):
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 5e749ca..096614c 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -28,9 +28,6 @@ const AnalysisPage = lazyPage(() => import('./pages/AnalysisPage'))
const JobsPage = lazyPage(() => import('./pages/JobsPage'))
const TrashPage = lazyPage(() => import('./pages/TrashPage'))
const QuizEditPage = lazyPage(() => import('./pages/QuizEditPage'))
-const VerifyEmailPage = lazyPage(() => import('./pages/VerifyEmailPage'))
-const ForgotPasswordPage = lazyPage(() => import('./pages/ForgotPasswordPage'))
-const ResetPasswordPage = lazyPage(() => import('./pages/ResetPasswordPage'))
const NotFoundPage = lazyPage(() => import('./pages/NotFoundPage'))
const LandingPage = lazyPage(() => import('./pages/LandingPage'))
const FlashcardsPage = lazyPage(() => import('./pages/FlashcardsPage'))
@@ -194,9 +191,6 @@ function AppRoutes() {
{/* Accounts are made at the identity provider. The address is kept
only so a bookmark lands somewhere sensible. */}
- If that email is registered, we've sent a password reset link. Check your inbox (and spam folder). -
- Back to login -- Enter your email and we'll send you a reset link. -
- {error &&- Back to login -
- > - )} -Email not verified. Check your inbox.
- -