aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-25 13:48:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-25 17:24:16 +0200
commitcc4cb6b363a24601637f18f8af7821bafd7765e9 (patch)
treee82d14fd7dcf9409739fc27f8525225df7c99012 /packages
parentc6505677fd121265ab5cc52276ec9d0c1c73b6c9 (diff)
downloadmeshbay-cc4cb6b363a24601637f18f8af7821bafd7765e9.tar.gz
fix(hub): require exp, sub and a known scope when decoding access tokens
One Ed25519 key signs four kinds of token — user access, node access, revocation broadcasts (no exp, no sub, and returned in the body of POST /v1/admin/revoke and pushed to every node) and MHP federation tokens (aud, sub=hub_id, no scope). decode_access_token required none of these, so a hub-signed token with no exp was accepted and separation between the types rested only on which fields each consumer happened to read. Require exp, sub and scope, and reject a scope that is not one of the two access scopes. A revocation or MHP token can no longer be mistaken for a session, and no hub-signed token without an expiry is honoured. A full RFC 5987 aud binding is deliberately not used: the node decodes its own hub-issued token without passing audience, so adding aud would make every already-deployed node reject its own token (InvalidAudienceError) — a coordinated, node-breaking change. scope gives the same purpose separation among the hub's own token types without it. This is non-breaking: every real access token already carries exp, sub and scope, so no session is forced to re-authenticate. test_token_hardening.py holds the refusals (no exp, no scope, unknown scope, a revocation token as bearer) and the paths that must keep working (a real login token, a node token); verified red against the pre-fix auth.py and green after. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py28
-rw-r--r--packages/meshbay-hub/tests/test_token_hardening.py114
2 files changed, 140 insertions, 2 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py
index d038027..309738c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/auth.py
+++ b/packages/meshbay-hub/src/meshbay_hub/auth.py
@@ -235,12 +235,36 @@ def issue_access_token(
def decode_access_token(token: str) -> dict:
- """Verify and decode an access token. Raises on failure."""
+ """Verify and decode an access token. Raises on failure.
+
+ `exp`, `sub` and `scope` are **required**, and `scope` must name one of the
+ two access scopes. One Ed25519 key signs four kinds of token — user access,
+ node access, revocation broadcasts (no `exp`, no `sub`, and returned in the
+ body of `POST /v1/admin/revoke` and pushed to every node), and MHP
+ federation tokens (`aud`, `sub=hub_id`, no `scope`). Without these
+ requirements a token with no `exp` was accepted, and separation between the
+ types rested only on which fields each consumer happened to read. Requiring
+ `scope` here turns a revocation or MHP token away before it can be mistaken
+ for a session, and requiring `exp` refuses any hub-signed token that never
+ expires.
+
+ A full RFC 5987 `aud` binding is deliberately not used: the node decodes its
+ own hub-issued token without passing `audience`, so adding `aud` would make
+ every already-deployed node reject its own token (`InvalidAudienceError`) —
+ a coordinated, node-breaking change. `scope` gives the same purpose
+ separation among the hub's own token types without it.
+ """
if _hub_pk_pem is None:
raise RuntimeError("Hub keypair not loaded")
# Clock-skew tolerance (meshbay_common.handshake.JWT_LEEWAY_SECONDS): a
# client whose clock is a little fast must still be able to call the API.
- return jwt.decode(token, _hub_pk_pem, algorithms=["EdDSA"], leeway=60)
+ payload = jwt.decode(
+ token, _hub_pk_pem, algorithms=["EdDSA"], leeway=60,
+ options={"require": ["exp", "sub", "scope"]},
+ )
+ if payload.get("scope") not in ("user", "node"):
+ raise jwt.InvalidTokenError("unrecognised token scope")
+ return payload
# ── Email encryption at rest ──────────────────────────────────────────────────
diff --git a/packages/meshbay-hub/tests/test_token_hardening.py b/packages/meshbay-hub/tests/test_token_hardening.py
new file mode 100644
index 0000000..0835180
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_token_hardening.py
@@ -0,0 +1,114 @@
+"""A hub-signed token is a session only if it says so, and only while it lasts.
+
+One Ed25519 key signs four kinds of token: user access, node access, revocation
+broadcasts and MHP federation tokens. `decode_access_token` used to accept any
+of them that carried a valid signature, requiring no `exp` and binding no
+purpose — so a token with no expiry was honoured, and the separation between
+the four rested only on which fields each consumer happened to read. A
+revocation token is even handed back in the body of `POST /v1/admin/revoke` and
+pushed to every node, so nodes hold hub-signed tokens.
+
+These are refusals: `decode_access_token` must require `exp`, `sub` and a known
+`scope`, so a token with no expiry, a revocation token, or an MHP token can
+never be mistaken for a session — while a real login token still works.
+"""
+
+import time
+
+import pytest
+
+from meshbay_hub import auth
+
+
+def _sk_pem_loaded():
+ # The `client` fixture's app runs load_hub_keypair in its lifespan, so the
+ # module key is loaded by the time a test body runs.
+ return auth._hub_sk_pem is not None
+
+
+# ── Unit-level: the decode contract ──────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_token_without_exp_is_refused(client):
+ import jwt
+ assert _sk_pem_loaded()
+ # A hub-signed token with a valid scope and sub but NO exp.
+ forged = jwt.encode({"sub": "u", "scope": "user"},
+ auth.hub_private_key_pem(), algorithm="EdDSA")
+ with pytest.raises(Exception):
+ auth.decode_access_token(forged)
+
+
+@pytest.mark.asyncio
+async def test_expired_token_is_refused(client):
+ import jwt
+ forged = jwt.encode({"sub": "u", "scope": "user", "exp": int(time.time()) - 100},
+ auth.hub_private_key_pem(), algorithm="EdDSA")
+ with pytest.raises(jwt.ExpiredSignatureError):
+ auth.decode_access_token(forged)
+
+
+@pytest.mark.asyncio
+async def test_token_without_scope_is_refused(client):
+ import jwt
+ forged = jwt.encode({"sub": "u", "exp": int(time.time()) + 3600},
+ auth.hub_private_key_pem(), algorithm="EdDSA")
+ with pytest.raises(Exception):
+ auth.decode_access_token(forged)
+
+
+@pytest.mark.asyncio
+async def test_unknown_scope_is_refused(client):
+ import jwt
+ forged = jwt.encode({"sub": "u", "scope": "root", "exp": int(time.time()) + 3600},
+ auth.hub_private_key_pem(), algorithm="EdDSA")
+ with pytest.raises(jwt.InvalidTokenError):
+ auth.decode_access_token(forged)
+
+
+@pytest.mark.asyncio
+async def test_a_revocation_token_is_not_a_session(client):
+ """The concrete cross-type case: revocation tokens are hub-signed, carry no
+ exp/sub/scope, and are handed to admins and pushed to every node."""
+ import jwt
+ from meshbay_hub.api.revocation import _sign_revocation
+ rev = _sign_revocation("user", "some-id", "policy")
+ # It is a genuine hub-signed token (signature verifies) ...
+ jwt.decode(rev, auth.hub_public_key_pem(), algorithms=["EdDSA"])
+ # ... but it is not a session.
+ with pytest.raises(Exception):
+ auth.decode_access_token(rev)
+
+
+# ── Endpoint-level: a revocation token gets no access ────────────────────────
+
+@pytest.mark.asyncio
+async def test_revocation_token_gets_no_api_access(client):
+ from meshbay_hub.api.revocation import _sign_revocation
+ rev = _sign_revocation("user", "x", "policy")
+ r = await client.get("/v1/users/me", headers={"Authorization": f"Bearer {rev}"})
+ assert r.status_code == 401
+
+
+# ── The path that must keep working ──────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_a_real_login_token_still_works(client):
+ await client.post("/v1/users/register", json={
+ "username": "live_token_test", "email": "l@test.local", "auth_key": "k" * 44})
+ tok = (await client.post("/v1/users/login", json={
+ "username": "live_token_test", "auth_key": "k" * 44})).json()["access_token"]
+ dec = auth.decode_access_token(tok)
+ assert dec["scope"] == "user" and "exp" in dec and "sub" in dec
+ r = await client.get("/v1/users/me", headers={"Authorization": f"Bearer {tok}"})
+ assert r.status_code == 200
+
+
+@pytest.mark.asyncio
+async def test_a_node_token_still_decodes(client):
+ """Node access tokens carry scope=node/exp/sub and must keep decoding — the
+ node decodes its own token, and the hub decodes it on the WS."""
+ from meshbay_hub.auth import issue_access_token
+ tok = issue_access_token("nid", ttl=3600, groups=[], scope="node")
+ dec = auth.decode_access_token(tok)
+ assert dec["scope"] == "node"