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
4 changes: 3 additions & 1 deletion PARITY.md
Original file line number Diff line number Diff line change
@@ -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 |
| --- | --- |
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions authwave/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ResponseCipherDecryptionException,
DecryptionFailureException,
EncryptionFailureException,
TokenReuseException,
RedirectSignal,
RedirectHandler,
)
Expand Down
3 changes: 2 additions & 1 deletion authwave/authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,15 @@ 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=(
self._return_uri if self._return_uri is not None else self._current_uri
).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
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 TokenReuseException(AuthwaveException):
pass
36 changes: 26 additions & 10 deletions authwave/token.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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()

Expand All @@ -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)
Expand All @@ -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
16 changes: 8 additions & 8 deletions tests/fixtures/parity.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
Expand Down Expand Up @@ -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"
]
Expand Down Expand Up @@ -324,7 +324,7 @@
"user": null,
"stored": true,
"data": null,
"returnQuery": "tab=security",
"returnQuery": null,
"invoked": false,
"basket": [
"tea"
Expand Down Expand Up @@ -369,7 +369,7 @@
"user": null,
"stored": true,
"data": null,
"returnQuery": "tab=security",
"returnQuery": null,
"invoked": false,
"basket": [
"tea"
Expand Down
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
143 changes: 143 additions & 0 deletions tests/test_token_nonce.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading