diff --git a/PARITY.md b/PARITY.md index 37ef561..82eaceb 100644 --- a/PARITY.md +++ b/PARITY.md @@ -40,6 +40,8 @@ This Python client implements the current PHP identity contract (`id` and `email ## Runtime adaptations +Successful authentication now regenerates the session ID before saving the identity and invalidates the old session. Unrelated data is preserved, and ordinary restoration keeps its ID. Custom session containers must provide regeneration, and custom runtime stores must support deleting the old ID. + There is no implicit global browser session. Callers provide a per-request container or use a framework adapter. JSON serialization restores tokens and response objects. Session records contain secrets and belong exclusively in server-side storage. Redirects transfer control to adapters, which save before sending a 303. Application callbacks are synchronous. Production stores enforce expiration and should serialize concurrent mutations to the same session, matching the sequential processing assumed by the callback marker. diff --git a/README.md b/README.md index 68b995f..424ae2c 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ The complete provider-issued key belongs on the server. The final underscore sep `MemorySessionStore` is for development. Production applications need a shared persistent `SessionStore` and HTTPS; cookies are Secure by default. Authentication data stays in server-side storage; never put serialized tokens in browser cookies. Authentication callbacks are synchronous and run at most once per exchange under sequential session processing. +Successful authentication replaces the session ID before saving the identity and invalidates the old ID. Other session data is preserved. Custom SessionStore implementations must provide delete(id), and custom GlobalSessionContainer mappings must supply a synchronous regeneration callback as the second argument. The callback must replace the ID, invalidate the old session and preserve the mapping; raising an exception stops authentication. Ordinary session restoration does not rotate the ID. + ## Development ```bash diff --git a/authwave/__init__.py b/authwave/__init__.py index 7d05498..0df1f6d 100644 --- a/authwave/__init__.py +++ b/authwave/__init__.py @@ -5,6 +5,7 @@ from .errors import ( AuthwaveException, SessionNotStartedException, + SessionRegenerationException, NotLoggedInException, InsecureProtocolException, InvalidUserDataSerializationException, diff --git a/authwave/authenticator.py b/authwave/authenticator.py index 310cc06..2133ca1 100644 --- a/authwave/authenticator.py +++ b/authwave/authenticator.py @@ -108,6 +108,7 @@ def fake_login( ) -> None: iv = InitVector() token = Token(self._secret, iv) + self._session.regenerate() self._session.set(SessionData.STORE_KEY, SessionData(token)) data = UserResponseData( user_id, email if email is not None else self.FAKE_EMAIL @@ -167,6 +168,8 @@ def scalar(value): if not isinstance(data, dict): raise TypeError("Expected authentication object") user_data = UserResponseData(scalar(data.get("id")), scalar(data.get("email"))) + if not self.is_logged_in(): + self._session.regenerate() self._user = User(user_data.id, user_data.email) was_invoked = self._session_data.has_invoked_authentication_callback() next_data = SessionData(token, user_data, return_query) diff --git a/authwave/errors.py b/authwave/errors.py index c62e4e2..e24f95a 100644 --- a/authwave/errors.py +++ b/authwave/errors.py @@ -43,3 +43,7 @@ def __init__(self, uri: str, code: int = 303): class RedirectHandler: def redirect(self, uri: str, code: int = 303) -> None: raise RedirectSignal(str(uri), code) + + +class SessionRegenerationException(AuthwaveException): + pass diff --git a/authwave/runtime.py b/authwave/runtime.py index 43fe2e1..52bbc73 100644 --- a/authwave/runtime.py +++ b/authwave/runtime.py @@ -16,6 +16,7 @@ class SessionStore(Protocol): def load(self, id: str) -> dict | None: ... def save(self, id: str, data: dict, expires_at: float) -> None: ... + def delete(self, id: str) -> None: ... class MemorySessionStore: @@ -35,6 +36,10 @@ def load(self, id: str) -> dict | None: return None return copy.deepcopy(entry[1]) + def delete(self, id: str) -> None: + with self._lock: + self._entries.pop(id, None) + def save(self, id: str, data: dict, expires_at: float) -> None: # Reject non-JSON session values before committing anything. value = json.loads(json.dumps(data)) @@ -69,15 +74,23 @@ def __init__(self, cookie_header: str, options: SessionOptions): else None ) self.id = candidate if stored is not None else secrets.token_hex(32) - self.data = stored if stored is not None else {} - self.container = GlobalSessionContainer(self.data) + self.data = copy.deepcopy(stored) if stored is not None else {} + self._previous_ids = [] + self.container = GlobalSessionContainer(self.data, self.regenerate) def authenticator(self, path: str, auth_options: dict) -> Authenticator: return Authenticator( current_uri_path=path, session=self.container, **auth_options ) + def regenerate(self) -> None: + new_id = secrets.token_hex(32) + self._previous_ids.append(self.id) + self.id = new_id + def commit(self) -> None: + for old_id in self._previous_ids: + self.options.store.delete(old_id) self.options.store.save( self.id, self.data, time.time() + self.options.ttl_seconds ) diff --git a/authwave/session.py b/authwave/session.py index d43e6b3..8c70a10 100644 --- a/authwave/session.py +++ b/authwave/session.py @@ -1,4 +1,8 @@ -from .errors import NotLoggedInException, SessionNotStartedException +from .errors import ( + NotLoggedInException, + SessionNotStartedException, + SessionRegenerationException, +) from .token import Token from .user import UserResponseData, BaseResponseData @@ -62,10 +66,11 @@ def from_dict(cls, value: dict) -> "SessionData": class GlobalSessionContainer: """Wrap an existing request-scoped mapping, never a process global.""" - def __init__(self, data=None): + def __init__(self, data=None, regenerate=None): if data is None: raise SessionNotStartedException() self.data = data + self._regenerate = regenerate def get(self, key: str): value = self.data.get(key) @@ -82,5 +87,10 @@ def contains(self, key: str) -> bool: def remove(self, key: str) -> None: self.data.pop(key, None) + def regenerate(self) -> None: + if self._regenerate is None: + raise SessionRegenerationException("Supply a session regeneration callback") + self._regenerate() + SessionArrayWrapper = GlobalSessionContainer diff --git a/tests/fixtures/php-oracle.php b/tests/fixtures/php-oracle.php index 21486f0..88fa555 100644 --- a/tests/fixtures/php-oracle.php +++ b/tests/fixtures/php-oracle.php @@ -9,6 +9,10 @@ class RecordingRedirect extends RedirectHandler { public array $redirects = []; public function redirect(UriInterface $uri, int $code = 303):void { $this->redirects[] = [(string)$uri, $code]; } } +// These fixtures use in-memory state rather than a native browser session ID. +class OracleSession extends \Authwave\GlobalSessionContainer { + public function regenerate():void {} +} $cases = json_decode(stream_get_contents(STDIN), true); $out = []; foreach($cases as $index => $case) { @@ -27,7 +31,7 @@ public function redirect(UriInterface $uri, int $code = 303):void { $this->redir } $error = null; $auth = null; try { - $auth = new Authenticator('deployment_with_underscores_'.$secret, $uri, $case['host'] ?? 'login.authwave.com', redirectHandler:$redirect, onAuthenticated:($case['callback'] ?? false) ? $callback : null); + $auth = new Authenticator('deployment_with_underscores_'.$secret, $uri, $case['host'] ?? 'login.authwave.com', redirectHandler:$redirect, session:new OracleSession(), onAuthenticated:($case['callback'] ?? false) ? $callback : null); if(isset($case['returnUri'])) $auth->setReturnUri($case['returnUri']); if(($case['action'] ?? '') === 'login') $auth->login($token); if(($case['action'] ?? '') === 'logout') $auth->logout($case['redirectTo'] ?? null, $token); diff --git a/tests/test_core.py b/tests/test_core.py index ca2bf8a..d6731cb 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -33,7 +33,7 @@ def test_missing_state(): SessionData().get_token() with pytest.raises(NotLoggedInException): SessionData().get_data() - auth = Authenticator(KEY, "/", session=GlobalSessionContainer({})) + auth = Authenticator(KEY, "/", session=GlobalSessionContainer({}, lambda: None)) with pytest.raises(NotLoggedInException): auth.get_auth_user() with pytest.warns(DeprecationWarning), pytest.raises(NotLoggedInException): @@ -75,7 +75,7 @@ def test_tokens_and_decoding(): def test_session_roundtrip_and_callback_failure(): token = Token(SECRET) session = {} - container = GlobalSessionContainer(session) + container = GlobalSessionContainer(session, lambda: None) container.set(SessionData.STORE_KEY, SessionData(token, return_query="x=%7e&flag")) calls = [] @@ -91,7 +91,7 @@ def callback(user): path = "/?AUTHWAVE_RESPONSE_DATA=" + quote_plus(cipher) with pytest.raises(RuntimeError): Authenticator(KEY, path, session=container, on_authenticated=callback) - restored = GlobalSessionContainer(json.loads(json.dumps(session))) + restored = GlobalSessionContainer(json.loads(json.dumps(session)), lambda: None) assert restored.get(SessionData.STORE_KEY).has_invoked_authentication_callback() record = Record() auth = Authenticator( @@ -113,7 +113,7 @@ def test_fake_login_and_logout(): auth = Authenticator( KEY, "/current?old=1", - session=GlobalSessionContainer(session), + session=GlobalSessionContainer(session, lambda: None), redirect_handler=record, ) auth.fake_login("dev") @@ -121,7 +121,7 @@ def test_fake_login_and_logout(): next_auth = Authenticator( KEY, record.destinations[0][0], - session=GlobalSessionContainer(session), + session=GlobalSessionContainer(session, lambda: None), redirect_handler=record, ) assert next_auth.get_auth_user().email == Authenticator.FAKE_EMAIL @@ -132,7 +132,7 @@ def test_fake_login_and_logout(): def test_default_redirect_and_admin(): - auth = Authenticator(KEY, "/", session=GlobalSessionContainer({})) + auth = Authenticator(KEY, "/", session=GlobalSessionContainer({}, lambda: None)) assert str(auth.get_admin_uri()) == "https://login.authwave.com/admin/" with pytest.raises(RedirectSignal) as result: auth.login() diff --git a/tests/test_integrations.py b/tests/test_integrations.py index 066b718..4b2b214 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -110,11 +110,30 @@ def app(environ, start_response): client = Client(AuthwaveWSGI(app, options, client_key=KEY), Response) invoke = lambda path: client.get(path) body = lambda response: response.json + from http.cookies import SimpleCookie + import time + + def session_id(response): + if framework == "django": + return response.cookies["authwave.sid"].value + values = SimpleCookie() + values.load(response.headers["Set-Cookie"]) + return values["authwave.sid"].value + initial = invoke("/login") + old_id = session_id(initial) + pending = store.load(old_id) + pending["basket"] = ["tea"] + store.save(old_id, pending, time.time() + 86400) assert initial.status_code == 303 completed = invoke(provider_response(initial.headers["Location"])) assert completed.status_code == 303 + new_id = session_id(completed) + assert new_id != old_id + assert store.load(old_id) is None + assert store.load(new_id)["basket"] == ["tea"] restored = invoke("/callback") + assert session_id(restored) == new_id assert body(restored) == {"loggedIn": True, "id": "user-id"} assert restored.headers["Cache-Control"] == "no-store" assert invoke("/logout").status_code == 303 @@ -176,6 +195,9 @@ def test_store_failure_prevents_wsgi_output(): from authwave.integrations.wsgi import AuthwaveWSGI class FailingStore: + def delete(self, id): + pass + def load(self, id): return None diff --git a/tests/test_parity.py b/tests/test_parity.py index a0c5f2a..253aad6 100644 --- a/tests/test_parity.py +++ b/tests/test_parity.py @@ -33,7 +33,7 @@ def test_php_parity(fixture): SECRET, InitVector().with_bytes(b"a" * 24), InitVector().with_bytes(b"f" * 24) ) state = {"basket": ["tea"]} - session = GlobalSessionContainer(state) + session = GlobalSessionContainer(state, lambda: None) if c.get("state") == "pending": session.set( SessionData.STORE_KEY, SessionData(token, return_query=c.get("returnQuery")) diff --git a/tests/test_session_rotation.py b/tests/test_session_rotation.py new file mode 100644 index 0000000..3542d78 --- /dev/null +++ b/tests/test_session_rotation.py @@ -0,0 +1,129 @@ +import json +import time +from urllib.parse import quote_plus +import pytest +from authwave import ( + Authenticator, + Token, + SessionData, + GlobalSessionContainer, + CipherText, + RedirectSignal, + SessionRegenerationException, + NotLoggedInException, + UserResponseData, +) +from authwave.runtime import RequestSession, SessionOptions, MemorySessionStore + +KEY = "deployment_" + "s" * 32 + + +def pending(): + token = Token("s" * 32) + state = {} + GlobalSessionContainer(state).set(SessionData.STORE_KEY, SessionData(token)) + state["basket"] = ["tea"] + cipher = CipherText( + '{"id":"user-id","email":"person@example.com"}', token.get_secret_iv(), "s" * 32 + ) + return state, "/callback?AUTHWAVE_RESPONSE_DATA=" + quote_plus(str(cipher)) + + +def test_core_rotates_before_storing_user(): + state, uri = pending() + events = [] + + def rotate(): + with pytest.raises(NotLoggedInException): + session.get(SessionData.STORE_KEY).get_data() + events.append("rotate") + + session = GlobalSessionContainer(state, rotate) + + def callback(user): + assert events == ["rotate"] + events.append("callback") + + with pytest.raises(RedirectSignal): + Authenticator(KEY, uri, session=session, on_authenticated=callback) + assert events == ["rotate", "callback"] + + +def test_missing_or_failed_rotation_stops_authentication(): + state, uri = pending() + session = GlobalSessionContainer(state) + with pytest.raises(SessionRegenerationException): + Authenticator(KEY, uri, session=session) + with pytest.raises(NotLoggedInException): + session.get(SessionData.STORE_KEY).get_data() + + def fail(): + raise RuntimeError("rotation failed") + + session = GlobalSessionContainer(state, fail) + with pytest.raises(RuntimeError, match="rotation failed"): + Authenticator(KEY, uri, session=session) + with pytest.raises(NotLoggedInException): + session.get(SessionData.STORE_KEY).get_data() + fresh = {} + auth = Authenticator(KEY, "/", session=GlobalSessionContainer(fresh)) + with pytest.raises(SessionRegenerationException): + auth.fake_login("dev") + assert fresh == {} + + +def test_runtime_deletes_old_id_before_saving_identity_and_blocks_replay(): + store = MemorySessionStore() + state, uri = pending() + old_id = "a" * 64 + store.save(old_id, state, time.time() + 60) + context = RequestSession("authwave.sid=" + old_id, SessionOptions(store)) + with pytest.raises(RedirectSignal): + context.authenticator(uri, {"client_key": KEY}) + assert context.id != old_id + assert store.load(old_id)[SessionData.STORE_KEY]["data"] is None + context.commit() + assert store.load(old_id) is None + assert store.load(context.id)["basket"] == ["tea"] + restored = RequestSession("authwave.sid=" + context.id, SessionOptions(store)) + assert ( + restored.authenticator("/", {"client_key": KEY}).get_auth_user().id == "user-id" + ) + assert restored.id == context.id + replay = RequestSession("authwave.sid=" + old_id, SessionOptions(store)) + with pytest.raises(RedirectSignal): + replay.authenticator(uri, {"client_key": KEY}) + assert not replay.container.contains(SessionData.STORE_KEY) + + +def test_failed_invalidation_never_persists_identity(): + class Store(MemorySessionStore): + def delete(self, id): + raise RuntimeError("delete failed") + + store = Store() + state, uri = pending() + old_id = "a" * 64 + store.save(old_id, state, time.time() + 60) + context = RequestSession("authwave.sid=" + old_id, SessionOptions(store)) + with pytest.raises(RedirectSignal): + context.authenticator(uri, {"client_key": KEY}) + with pytest.raises(RuntimeError, match="delete failed"): + context.commit() + assert store.load(old_id)[SessionData.STORE_KEY]["data"] is None + assert store.load(context.id) is None + + +def test_fake_login_rotates_before_its_identity_is_saved(): + store = MemorySessionStore() + options = SessionOptions(store) + original = RequestSession("", options) + original.data["basket"] = ["tea"] + original.commit() + old_id = original.id + with pytest.raises(RedirectSignal): + original.authenticator("/", {"client_key": KEY}).fake_login("dev") + assert original.id != old_id + original.commit() + assert store.load(old_id) is None + assert store.load(original.id)["basket"] == ["tea"]