From 9a483774e97f8612b00e3d92c4d5ebc00c21980a Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Fri, 14 Aug 2026 03:40:43 +0200 Subject: fix(client): refresh the token when the node says "not a member" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A member added to a group after they signed in was refused by the node, told "Not a member of this group", and had no way forward but to log out and back in. The hub bakes `groups` into the access token at login and never pushes updates, so the token said they were in nothing while the database said otherwise. This lands on every newly invited member, at their first action, and the message tells them the opposite of the truth — toto2 was a member of newdemo on the hub and read that they were not. The refusal now carries a code the client can act on (`not_a_member`) rather than prose it would have to string-match, and the SPA refreshes the access token once and retries. Refreshing re-reads membership from the database, so the retry succeeds. Once per mount: if a fresh token still says not a member, that is the truth and it gets shown. The SPA had stored a refresh token since Phase 8 and never used it. It does now. Found in a browser, doing the ordinary thing — the automated run never sees it, because e2e.py logs in after being added to the group. Tests: 233 node+common, including a handshake test that the refusal carries the code, and the full e2e run against the live deployment. Co-Authored-By: Claude Opus 5 --- .../meshbay-common/src/meshbay_common/handshake.py | 18 +++++++++++++-- packages/meshbay-common/tests/test_handshake.py | 26 ++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) (limited to 'packages/meshbay-common') diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py index 73a2858..223f064 100644 --- a/packages/meshbay-common/src/meshbay_common/handshake.py +++ b/packages/meshbay-common/src/meshbay_common/handshake.py @@ -53,7 +53,17 @@ NONCE_LEN = 32 class HandshakeError(Exception): - """Refusal, with a message safe to hand to the peer.""" + """ + Refusal, with a message safe to hand to the peer. + + `code` is the same refusal in a form a client can act on. The text is for a + human and may be reworded; matching on it from the client would be a string + comparison that breaks silently the day someone improves the wording. + """ + + def __init__(self, message: str, code: str = ""): + super().__init__(message) + self.code = code class DenylistLike(Protocol): @@ -170,7 +180,11 @@ def authorize_token( raise HandshakeError("Token revoked") if group_id not in decoded.get("groups", []): - raise HandshakeError("Not a member of this group") + # Almost always a token issued before the person was added to the group: + # `groups` is baked in at login and the hub does not push updates. The + # client refreshes and retries on this code rather than telling someone + # who *is* a member that they are not one. + raise HandshakeError("Not a member of this group", code="not_a_member") if hosted_groups is not None and group_id not in hosted_groups: raise HandshakeError("Group not hosted on this node") diff --git a/packages/meshbay-common/tests/test_handshake.py b/packages/meshbay-common/tests/test_handshake.py index ba8788a..8981db8 100644 --- a/packages/meshbay-common/tests/test_handshake.py +++ b/packages/meshbay-common/tests/test_handshake.py @@ -197,3 +197,29 @@ def test_transcript_is_unambiguous(): def test_bindings_differ_by_transport(): """A WebRTC proof must not be replayable on a QUIC connection.""" assert webrtc_binding(b"\xaa" * 32, b"\xbb" * 32) != quic_binding(b"cert-der") + + +def test_membership_refusal_carries_a_code_a_client_can_act_on(): + """ + `groups` is baked into the token at login, so someone added to a group after + signing in is refused although they are a member. The client refreshes and + retries on this code — it must not have to match on the human wording, which + is exactly the kind of coupling that breaks when someone improves a message. + """ + import jwt as _jwt + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives import serialization + + sk = Ed25519PrivateKey.generate() + pem_priv = sk.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, + serialization.NoEncryption()) + pem_pub = sk.public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo) + + token = _jwt.encode({"sub": "u1", "jti": "j1", "scope": "user", "groups": []}, + pem_priv, algorithm="EdDSA") + + with pytest.raises(HandshakeError) as excinfo: + authorize_token(token, pem_pub, group_id="g" * 32) + assert excinfo.value.code == "not_a_member" -- cgit v1.2.3