diff --git a/PARITY.md b/PARITY.md index 37ef561..2e4dee4 100644 --- a/PARITY.md +++ b/PARITY.md @@ -1,6 +1,6 @@ # PHP client parity -This Python client implements the current PHP identity contract (`id` and `email`), following PHP commit `bcb547d`. The language API uses snake_case methods, frozen dataclass user snapshots and request-scoped mappings. +This Python client implements the current PHP identity contract (`id` and `email`), following PHP commit `72c213ddef1797cd096757e1c8c70d826abd7514`. The language API uses snake_case methods, frozen dataclass user snapshots and request-scoped mappings. | PHP source | Python equivalent | | --- | --- | @@ -40,6 +40,8 @@ This Python client implements the current PHP identity contract (`id` and `email ## Runtime adaptations +Tokens retain the first complete request plaintext and reject different payloads with the same nonce. Repeating an identical request remains deterministic. The binding is persisted in session serialisation. Legacy tokens without a known binding may decrypt responses but cannot encrypt another request. Login builds the request before replacing pending exchange data. + 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..89fcfc4 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ def login(auth): Wrap the return route too. It can read `auth.is_logged_in()` and `auth.get_auth_user().id` / `.email`. The full [examples](examples) include login, logout and callback handling. See the [wiki](https://github.com/Authwave/python-client/wiki) for setup and the API reference. +A token can encrypt only its first complete request. Repeating the same request is allowed; a different payload raises TokenReuseException. Create a fresh token for a different action or return query, and do not copy tokens or reuse supplied IVs. Session serialisation retains the binding. Tokens restored from older sessions without a binding can still decrypt responses, but require a fresh token to start another request. + The complete provider-issued key belongs on the server. The final underscore separates the deployment identifier from the raw 32-byte secret. `authwave_host` defaults to `login.authwave.com`. `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. diff --git a/authwave/__init__.py b/authwave/__init__.py index 7d05498..edca5f7 100644 --- a/authwave/__init__.py +++ b/authwave/__init__.py @@ -11,6 +11,7 @@ ResponseCipherDecryptionException, DecryptionFailureException, EncryptionFailureException, + TokenReuseException, RedirectSignal, RedirectHandler, ) diff --git a/authwave/authenticator.py b/authwave/authenticator.py index 310cc06..1f5d683 100644 --- a/authwave/authenticator.py +++ b/authwave/authenticator.py @@ -68,6 +68,7 @@ def login(self, token: Token | None = None) -> None: if self.is_logged_in(): return token = token if token is not None else Token(self._secret) + login_uri = self.get_login_uri(token) self._session_data = SessionData( token, return_query=( @@ -75,7 +76,7 @@ def login(self, token: Token | None = None) -> None: ).get_query(), ) self._session.set(SessionData.STORE_KEY, self._session_data) - self._redirect.redirect(self.get_login_uri(token)) + self._redirect.redirect(login_uri) def logout( self, redirect_to: str | None = None, token: Token | None = None diff --git a/authwave/errors.py b/authwave/errors.py index c62e4e2..b6df33e 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 TokenReuseException(AuthwaveException): + pass diff --git a/authwave/token.py b/authwave/token.py index a84bd45..26f2b37 100644 --- a/authwave/token.py +++ b/authwave/token.py @@ -1,7 +1,8 @@ import base64 import json +import threading from .cipher import InitVector, CipherText, decrypt -from .errors import InvalidUserDataSerializationException +from .errors import InvalidUserDataSerializationException, TokenReuseException from .user import UserResponseData @@ -13,6 +14,8 @@ def __init__( iv: InitVector | None = None, ): self._secret = secret + self._request_plaintext = None + self._request_lock = threading.Lock() self._session_iv = session_iv if session_iv is not None else InitVector() self._iv = iv if iv is not None else InitVector() @@ -23,9 +26,16 @@ def get_secret_iv(self) -> InitVector: return self._session_iv def generate_request_cipher(self, message: str = "") -> CipherText: - return CipherText( - f"{message}&secretIv={self._session_iv}", self._iv, self._secret - ) + with self._request_lock: + plaintext = f"{message}&secretIv={self._session_iv}" + if ( + self._request_plaintext is not None + and self._request_plaintext != plaintext + ): + raise TokenReuseException("Create a new token for a different request.") + cipher = CipherText(plaintext, self._iv, self._secret) + self._request_plaintext = plaintext + return cipher def decode(self, cipher: str) -> UserResponseData: plain = decrypt(cipher, self._iv, self._secret) @@ -42,16 +52,22 @@ def decode(self, cipher: str) -> UserResponseData: def to_dict(self) -> dict: """Sensitive data: save only in server-side session storage.""" - return { - "secret": self._secret, - "session_iv": str(self._session_iv), - "iv": str(self._iv), - } + with self._request_lock: + return { + "secret": self._secret, + "session_iv": str(self._session_iv), + "iv": str(self._iv), + "request_plaintext": self._request_plaintext, + } @classmethod def from_dict(cls, data: dict) -> "Token": - return cls( + token = cls( data["secret"], InitVector().with_bytes(base64.b64decode(data["session_iv"])), InitVector().with_bytes(base64.b64decode(data["iv"])), ) + + # Legacy records can decrypt responses but cannot safely encrypt another request. + token._request_plaintext = data.get("request_plaintext", "") + return token diff --git a/tests/fixtures/parity.json b/tests/fixtures/parity.json index 649ef96..818d053 100644 --- a/tests/fixtures/parity.json +++ b/tests/fixtures/parity.json @@ -183,10 +183,10 @@ "calls": 0, "error": "InsecureProtocolException", "user": null, - "stored": true, + "stored": false, "data": null, - "returnQuery": "tab=security", - "invoked": false, + "returnQuery": null, + "invoked": null, "basket": [ "tea" ] @@ -228,10 +228,10 @@ "calls": 0, "error": "InsecureProtocolException", "user": null, - "stored": true, + "stored": false, "data": null, - "returnQuery": "tab=security", - "invoked": false, + "returnQuery": null, + "invoked": null, "basket": [ "tea" ] @@ -324,7 +324,7 @@ "user": null, "stored": true, "data": null, - "returnQuery": "tab=security", + "returnQuery": null, "invoked": false, "basket": [ "tea" @@ -369,7 +369,7 @@ "user": null, "stored": true, "data": null, - "returnQuery": "tab=security", + "returnQuery": null, "invoked": false, "basket": [ "tea" 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_token_nonce.py b/tests/test_token_nonce.py new file mode 100644 index 0000000..afa2c4a --- /dev/null +++ b/tests/test_token_nonce.py @@ -0,0 +1,143 @@ +import json +import threading +import time +from concurrent.futures import ThreadPoolExecutor +import pytest +from authwave import ( + Authenticator, + Token, + TokenReuseException, + SessionData, + GlobalSessionContainer, + CipherText, + EncryptionFailureException, + LoginUri, + LogoutUri, + RedirectSignal, + decrypt, +) + +SECRET = "s" * 32 +KEY = "deployment_" + SECRET + + +@pytest.mark.parametrize( + "first,second", + [ + ("action=login", "action=logout"), + ("", "action=login"), + ("action=login", ""), + ("action=login&returnQuery=page%3D1", "action=login&returnQuery=page%3D2"), + ], +) +def test_changed_requests_are_rejected(first, second): + token = Token(SECRET) + iv = token.get_iv().get_bytes() + cipher = str(token.generate_request_cipher(first)) + with pytest.raises(TokenReuseException): + token.generate_request_cipher(second) + assert token.get_iv().get_bytes() == iv + assert str(token.generate_request_cipher(first)) == cipher + assert decrypt(cipher, token.get_iv(), SECRET) == first + "&secretIv=" + str( + token.get_secret_iv() + ) + + +def test_session_serialisation_keeps_binding(): + token = Token(SECRET) + cipher = str(token.generate_request_cipher("action=login")) + restored = SessionData.from_dict( + json.loads(json.dumps(SessionData(token).to_dict())) + ).get_token() + assert str(restored.generate_request_cipher("action=login")) == cipher + with pytest.raises(TokenReuseException): + restored.generate_request_cipher("action=logout") + + +def test_unused_tokens_can_be_restored(): + token = Token.from_dict(Token(SECRET).to_dict()) + assert str(token.generate_request_cipher("action=login")) + + +def test_legacy_tokens_can_decrypt_but_not_encrypt_with_unknown_binding(): + token = Token(SECRET) + legacy = token.to_dict() + del legacy["request_plaintext"] + restored = Token.from_dict(legacy) + cipher = CipherText( + '{"id":"user-id","email":"person@example.com"}', token.get_iv(), SECRET + ) + assert restored.decode(str(cipher)).get_id() == "user-id" + with pytest.raises(TokenReuseException): + restored.generate_request_cipher("action=login") + + +def test_failed_encryption_does_not_bind_token(): + token = Token("") + for message in ["action=login", "action=logout"]: + with pytest.raises(EncryptionFailureException): + token.generate_request_cipher(message) + + +def test_uri_builders_require_separate_tokens(): + token = Token(SECRET) + login = LoginUri(token, "/callback", "deployment") + assert LoginUri(token, "/callback", "deployment") == login + with pytest.raises(TokenReuseException): + LogoutUri(token, "/callback", "deployment") + assert LogoutUri(Token(SECRET), "/callback", "deployment") + + +def test_rejected_login_preserves_pending_exchange(): + pending = SessionData(Token(SECRET)) + state = {} + session = GlobalSessionContainer(state) + session.set(SessionData.STORE_KEY, pending) + state["basket"] = ["tea"] + before = json.loads(json.dumps(state)) + used = Token(SECRET) + used.generate_request_cipher("action=logout") + auth = Authenticator(KEY, "/callback", session=session) + with pytest.raises(TokenReuseException): + auth.login(used) + assert state == before + + +def test_login_saves_bound_token(): + state = {} + session = GlobalSessionContainer(state) + auth = Authenticator(KEY, "/callback", session=session) + with pytest.raises(RedirectSignal): + auth.login() + restored = ( + GlobalSessionContainer(json.loads(json.dumps(state))) + .get(SessionData.STORE_KEY) + .get_token() + ) + with pytest.raises(TokenReuseException): + restored.generate_request_cipher("action=logout") + + +def test_concurrent_changed_requests_cannot_both_encrypt(monkeypatch): + import authwave.token as token_module + + real_cipher = token_module.CipherText + + def slow_cipher(*args): + time.sleep(0.02) + return real_cipher(*args) + + monkeypatch.setattr(token_module, "CipherText", slow_cipher) + barrier = threading.Barrier(2) + token = Token(SECRET) + + def generate(message): + barrier.wait() + try: + return ("cipher", str(token.generate_request_cipher(message))) + except TokenReuseException: + return ("rejected", None) + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(generate, ["action=login", "action=logout"])) + assert sorted(result[0] for result in results) == ["cipher", "rejected"]