pdf-quiz-generator/backend/tests/test_sso_hardening.py
Daniel b2a75b9e08
Some checks failed
Tests / backend (push) Failing after 5s
Tests / frontend (push) Failing after 37s
Tests / e2e (push) Failing after 38s
feat: signed in over there, signed in here
Opening one PedsHub app while already signed in at the provider for the
other one should not produce a sign-in page. It asks the provider once,
with prompt=none — "do you already know this person?" — and if the
answer is yes the round trip finishes with no screen and no click.

The refusal is the interesting half. login_required,
interaction_required, consent_required and account_selection_required
are the provider saying nobody is signed in, which is an answer rather
than a failure: the visitor lands on the page they asked for, with no
message and no sign of having been anywhere. Anything else still goes to
/login?error=sso_failed, and a silent attempt that throws is swallowed
too — nobody should be interrupted by a request they did not make.

The whole risk in this is a loop between two sites, so: at most one
attempt per browser session, never after somebody has signed themselves
out, and never inside a native shell where there is no third-party
cookie to carry the provider's session. Signing out sets a marker that
outlives the tab; pressing any sign-in control clears it, because that
is a person saying they have changed their mind.

A deep link survives the trip. The intended path rides in the server
session rather than the URL, and is validated on the way back — a
scheme, a host or a protocol-relative //evil all collapse to "/",
because a sign-in round trip is exactly where an open redirect would
live.

Verified against the live provider: /api/auth/sso/login?prompt=none
answers 302 to Authentik carrying prompt=none, state and nonce, and a
visitor with no session anywhere lands on the landing page with the
attempt marked spent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 17:09:46 +02:00

144 lines
6.1 KiB
Python

"""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)