Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions authwave/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .errors import (
AuthwaveException,
SessionNotStartedException,
SessionRegenerationException,
NotLoggedInException,
InsecureProtocolException,
InvalidUserDataSerializationException,
Expand Down
3 changes: 3 additions & 0 deletions authwave/authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions authwave/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 15 additions & 2 deletions authwave/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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))
Expand Down Expand Up @@ -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
)
Expand Down
14 changes: 12 additions & 2 deletions authwave/session.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
from .errors import NotLoggedInException, SessionNotStartedException
from .errors import (
NotLoggedInException,
SessionNotStartedException,
SessionRegenerationException,
)
from .token import Token
from .user import UserResponseData, BaseResponseData

Expand Down Expand Up @@ -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)
Expand All @@ -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
6 changes: 5 additions & 1 deletion tests/fixtures/php-oracle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
12 changes: 6 additions & 6 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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 = []

Expand All @@ -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(
Expand All @@ -113,15 +113,15 @@ 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")
assert not auth.is_logged_in()
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
Expand All @@ -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()
Expand Down
22 changes: 22 additions & 0 deletions tests/test_integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion tests/test_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
129 changes: 129 additions & 0 deletions tests/test_session_rotation.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading