aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 03:40:43 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 03:40:43 +0200
commit9a483774e97f8612b00e3d92c4d5ebc00c21980a (patch)
tree8d08923d08f68b862565c6b52e1eb7e3102665c1 /packages
parent48124ac249b0bff42c84ab6aee9270cf7ee134d8 (diff)
downloadmeshbay-9a483774e97f8612b00e3d92c4d5ebc00c21980a.tar.gz
fix(client): refresh the token when the node says "not a member"
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 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py18
-rw-r--r--packages/meshbay-common/tests/test_handshake.py26
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js7
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py3
5 files changed, 87 insertions, 13 deletions
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"
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 1ef878a..6fefe0f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -765,7 +765,7 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk
return results;
}
-function GroupPage({ groupId, group, token, username, userId }) {
+function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) {
const [status, setStatus] = useState('idle');
const [entries, setEntries] = useState([]);
const [cached, setCached] = useState(false);
@@ -786,6 +786,9 @@ function GroupPage({ groupId, group, token, username, userId }) {
const [retryKey, setRetryKey] = useState(0);
const transportRef = useRef(null);
const gekRef = useRef(null);
+ // One refresh per mount: if a fresh token still says we are not a member, we
+ // really are not, and retrying forever would hide that.
+ const refreshedRef = useRef(false);
const submitJoinCode = useCallback((e) => {
e.preventDefault();
@@ -902,14 +905,24 @@ function GroupPage({ groupId, group, token, username, userId }) {
cacheGroupIndex(groupId, group ? group.name : groupId, freshEntries);
} catch (err) {
- if (!cancelled) {
- // The node has never seen this browser for this account: it needs a
- // one-time code from the operator before it will hand over the group
- // key. Not an error to shout about — a step in joining.
- if (err.reason === 'code_required') setNeedsCode(true);
- setError(err.message);
- setStatus('error');
+ if (cancelled) return;
+
+ // Our token predates being added to this group. Refresh once and retry
+ // rather than telling someone who was just invited that they are not a
+ // member — which is what the node honestly sees, and is useless to them.
+ if (err.reason === 'not_a_member' && !refreshedRef.current && onRefreshAuth) {
+ refreshedRef.current = true;
+ try {
+ if (await onRefreshAuth()) return; // new token → effect re-runs
+ } catch { /* fall through to the message below */ }
}
+
+ // The node has never seen this browser for this account: it needs a
+ // one-time code from the operator before it will hand over the group
+ // key. Not an error to shout about — a step in joining.
+ if (err.reason === 'code_required') setNeedsCode(true);
+ setError(err.message);
+ setStatus('error');
}
};
@@ -2653,6 +2666,20 @@ function App() {
},
};
+ // Group membership is baked into the access token at login and the hub does not
+ // push updates, so someone invited after they signed in carries a token that
+ // says they are in nothing. Refreshing re-reads membership from the database.
+ const refreshAuth = useCallback(async () => {
+ if (!user || !user.refreshToken) return null;
+ const data = await hubFetch('/v1/users/token/refresh', {
+ method: 'POST', body: { refresh_token: user.refreshToken },
+ });
+ const u = { ...user, token: data.access_token };
+ setUser(u);
+ saveAuth(u);
+ return data.access_token;
+ }, [user]);
+
let page;
if (route === '/login' || route === '/register') {
page = route === '/register'
@@ -2677,7 +2704,8 @@ function App() {
const group = groups.find(g => g.id === groupId);
page = html`<${GroupPage}
groupId=${groupId} group=${group} token=${user.token}
- username=${user.username} userId=${user.userId} />`;
+ username=${user.username} userId=${user.userId}
+ onRefreshAuth=${refreshAuth} />`;
} else if (route === '/admin') {
page = (user.role === 'moderator' || user.role === 'admin')
? html`<${AdminPage} token=${user.token} />`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 271d11f..c200674 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -314,8 +314,13 @@ class MeshBayTransport {
// A node that answers a handshake with anything other than a challenge is not
// running the mutual protocol. Accepting a bare handshake_ack here would let a
// peer skip proving GEK possession entirely (C3/C6).
- throw new Error(
+ const rejected = new Error(
'MNP handshake rejected: ' + (reply.detail || `unexpected ${reply.type}`));
+ // `not_a_member` usually means our token predates being added to the group;
+ // the caller refreshes it and tries again rather than showing that to someone
+ // who was invited thirty seconds ago.
+ rejected.reason = reply.code || '';
+ throw rejected;
}
/**
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 416e84c..46dda64 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -356,7 +356,8 @@ class WebRTCPeerSession:
except HandshakeError as refusal:
# HandshakeError messages are authored to be peer-safe, unlike arbitrary
# exception text (L3) — the client needs to know *why* it was refused.
- self._send({"type": "error", "detail": str(refusal)})
+ self._send({"type": "error", "detail": str(refusal),
+ "code": getattr(refusal, "code", "")})
self._audit_auth_failed(group_id, str(refusal))
return