"""The password doors while a site is single sign-on, and the token in the URL. Run: DATABASE_URL=sqlite:///:memory: PYTHONPATH=backend python -m unittest discover -s backend/tests """ import os os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") import unittest import unittest.mock from unittest.mock import patch from fastapi import FastAPI from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool from app.database import Base, get_db from app.models.user import User from app.routers import auth from app.utils.auth import get_current_user class SsoOnlyClosesTheLastPasswordDoor(unittest.TestCase): """Password recovery is not guarded any more — it does not exist. 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): self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) Base.metadata.create_all(self.engine) self.db = Session(self.engine) 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 self.client = TestClient(app) def tearDown(self): self.client.close() self.db.close() self.engine.dispose() 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_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) class OneTimeCodeTests(unittest.TestCase): """The redirect carries a code, and the code is worth exactly one exchange. It used to carry the access token itself, in a query string that nginx writes to its access log on every sign-in — a live bearer token, good for a day, in a file, in the browser's history, and in the next Referer. """ def setUp(self): app = FastAPI() app.include_router(auth.router, prefix="/auth") self.client = TestClient(app) self.store = {} def tearDown(self): self.client.close() class FakeRedis: def __init__(self, store): self.store = store def setex(self, key, ttl, value): self.store[key] = value def getdel(self, key): return self.store.pop(key, None) def test_a_code_is_spent_once(self): with patch.object(auth, "_sso_store", return_value=self.FakeRedis(self.store)): code = auth._stash_sso_token("a.jwt.value") self.assertTrue(code and "a.jwt.value" not in code) first = self.client.post("/auth/sso/exchange", json={"code": code}) self.assertEqual(first.status_code, 200, first.text) self.assertEqual(first.json()["access_token"], "a.jwt.value") # Replayed from a log, a history entry or a Referer: worth nothing. again = self.client.post("/auth/sso/exchange", json={"code": code}) self.assertEqual(again.status_code, 400) def test_an_unknown_code_is_refused(self): with patch.object(auth, "_sso_store", return_value=self.FakeRedis(self.store)): response = self.client.post("/auth/sso/exchange", json={"code": "y" * 40}) self.assertEqual(response.status_code, 400) if __name__ == "__main__": unittest.main() class SilentSignInTests(unittest.TestCase): """Signed in at the provider for the other app means signed in here. `prompt=none` asks "do you already know this person?". If the provider does, the round trip completes with no screen and no click. If it does not, it refuses in the query string — and that refusal is the answer, not a failure to report. """ def test_a_quiet_refusal_is_not_an_error(self): from app.routers.auth import QUIET_REFUSALS for said in ("login_required", "interaction_required", "consent_required", "account_selection_required"): self.assertIn(said, QUIET_REFUSALS) # A real failure still is one. self.assertNotIn("invalid_client", QUIET_REFUSALS) self.assertNotIn("server_error", QUIET_REFUSALS) def test_the_return_path_cannot_leave_the_site(self): """A sign-in round trip is exactly where an open redirect would live.""" from app.routers.auth import _safe_next self.assertEqual(_safe_next("/articles/95?section=abc"), "/articles/95?section=abc") self.assertEqual(_safe_next("//evil.example"), "/") self.assertEqual(_safe_next("https://evil.example/x"), "/") self.assertEqual(_safe_next("javascript:alert(1)"), "/") self.assertEqual(_safe_next(None), "/") self.assertEqual(_safe_next(""), "/") self.assertEqual(len(_safe_next("/" + "a" * 900)), 500)