"""Staying signed in without keeping a password. A browser can send a person back to a login form; an app on a phone cannot, and must not keep the password to avoid it. So sign-in can hand back a refresh token: a row rather than a signature, which means it can be listed, rotated and withdrawn — none of which is true of the access token beside it. The properties worth pinning are the ones that only show up when they fail: a token works exactly once, a spent one presented again ends the whole session rather than being politely ignored, and signing out of a lost phone stops it getting another token even though the one it holds is still cryptographically valid. Disposable SQLite, a Mock for Redis, no network. """ import sys import unittest from datetime import datetime, timedelta from types import ModuleType from unittest.mock import patch import test_quiz_builder as fixtures from app.models.refresh_token import RefreshToken from app.routers import auth as auth_router from app.services import refresh_tokens from app.utils.auth import get_current_user class MemoryRedis: def __init__(self): self.values = {} def get(self, key): return self.values.get(key) def set(self, key, value, **kwargs): self.values[key] = value def setex(self, key, ttl, value): self.values[key] = value def incr(self, key): self.values[key] = int(self.values.get(key, 0)) + 1 return self.values[key] def ttl(self, key): return 60 def expire(self, *args): return True def delete(self, *args): return True class RefreshTokenTests(unittest.TestCase): def setUp(self): self.bank = fixtures.BuilderTests() self.bank.setUp() self.bank.owner.email = "owner@example.com" self.bank.db.commit() self.client = self.bank.client self.client.app.include_router(auth_router.router, prefix="/auth") # The fixture pins every request to one user; these tests are about who # the token says you are, so the real dependency goes back in. self.client.app.dependency_overrides.pop(get_current_user, None) self.client.app.dependency_overrides[get_current_user] = lambda: self.bank.user redis = ModuleType("redis") redis.from_url = lambda *a, **k: MemoryRedis() modules = patch.dict(sys.modules, {"redis": redis}) modules.start() self.addCleanup(modules.stop) def tearDown(self): self.bank.tearDown() def sign_in(self, **extra): return refresh_tokens.issue(self.bank.db, self.bank.owner, **extra) def refresh(self, token): return self.client.post("/auth/refresh", json={"refresh_token": token}) # ── Issuing ──────────────────────────────────────────────────── def test_a_browser_is_not_given_something_it_will_never_use(self): # Sign-in only mints a refresh token for a client that says it wants # one. Handing every browser a sixty-day credential it has nowhere to # put is a row in a table and a liability, for nothing. self.assertEqual(self.bank.db.query(RefreshToken).count(), 0) def test_only_the_hash_is_kept(self): token = self.sign_in() rows = self.bank.db.query(RefreshToken).all() self.assertEqual(len(rows), 1) self.assertNotIn(token, [row.token_hash for row in rows]) self.assertEqual(len(rows[0].token_hash), 64) # ── Spending ─────────────────────────────────────────────────── def test_a_token_is_traded_for_a_new_pair_and_cannot_be_used_twice(self): token = self.sign_in() first = self.refresh(token) self.assertEqual(first.status_code, 200, first.text) rotated = first.json()["refresh_token"] self.assertTrue(first.json()["access_token"]) self.assertNotEqual(rotated, token) self.assertEqual(self.refresh(rotated).status_code, 200) def test_a_spent_token_coming_back_ends_the_whole_session(self): # Either it was copied or a client is replaying; from here those look # the same, so the safe reading is the unsafe one. The thief and the # owner both stop working, and the owner signs in again knowing why. token = self.sign_in() rotated = self.refresh(token).json()["refresh_token"] self.assertEqual(self.refresh(token).status_code, 401) self.assertEqual(self.refresh(rotated).status_code, 401) self.assertEqual(refresh_tokens.sessions(self.bank.db, self.bank.owner), []) def test_an_expired_or_unknown_token_is_refused_without_a_hint(self): self.assertEqual(self.refresh("never-issued").status_code, 401) token = self.sign_in() row = self.bank.db.query(RefreshToken).one() row.expires_at = datetime.utcnow() - timedelta(seconds=1) self.bank.db.commit() self.assertEqual(self.refresh(token).status_code, 401) # ── Withdrawing ──────────────────────────────────────────────── def test_signing_out_stops_the_lost_phone_getting_another_token(self): phone = self.sign_in(label="iPhone") laptop = self.sign_in(label="Laptop") self.assertEqual(len(refresh_tokens.sessions(self.bank.db, self.bank.owner)), 2) self.assertEqual(self.client.post("/auth/logout", json={"refresh_token": phone}).status_code, 204) self.assertEqual(self.refresh(phone).status_code, 401) self.assertEqual(self.refresh(laptop).status_code, 200) def test_signing_out_everywhere_ends_every_session(self): tokens = [self.sign_in(label=f"Device {i}") for i in range(3)] self.assertEqual(self.client.post("/auth/logout", json={"everywhere": True}).status_code, 204) for token in tokens: self.assertEqual(self.refresh(token).status_code, 401) def test_a_person_can_see_where_they_are_signed_in_and_end_one(self): self.sign_in(label="iPhone") self.sign_in(label="Laptop") rows = self.client.get("/auth/sessions").json() self.assertEqual({row["label"] for row in rows}, {"iPhone", "Laptop"}) # One row per session, not one per rotation: a token refreshed nightly # for two months is one place you are signed in, not sixty. phone = next(row for row in rows if row["label"] == "iPhone") self.refresh(self.sign_in(label="iPhone", family=phone["family"])) self.assertEqual(len(self.client.get("/auth/sessions").json()), 2) self.assertEqual(self.client.delete(f"/auth/sessions/{phone['family']}").status_code, 204) self.assertEqual([row["label"] for row in self.client.get("/auth/sessions").json()], ["Laptop"]) def test_one_person_cannot_end_another_person_s_session(self): theirs = refresh_tokens.issue(self.bank.db, self.bank.peer, label="Their laptop") rows = refresh_tokens.sessions(self.bank.db, self.bank.peer) self.bank.user = self.bank.owner self.assertEqual(self.client.delete(f"/auth/sessions/{rows[0]['family']}").status_code, 204) # Answered 204 because there was nothing of theirs by that name to end; # what matters is that the other person is still signed in. self.assertEqual(self.refresh(theirs).status_code, 200) # ── Housekeeping ─────────────────────────────────────────────── def test_dead_rows_are_swept_and_live_ones_are_not(self): live = self.sign_in(label="Live") old = self.sign_in(label="Old") row = self.bank.db.query(RefreshToken).filter(RefreshToken.label == "Old").one() row.revoked_at = datetime.utcnow() - timedelta(days=30) self.bank.db.commit() self.assertEqual(refresh_tokens.purge(self.bank.db), 1) self.bank.db.commit() self.assertEqual(self.refresh(live).status_code, 200) if __name__ == "__main__": unittest.main()