aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/test_availability_between_members.py5
-rw-r--r--packages/meshbay-hub/tests/test_hub_api.py9
-rw-r--r--packages/meshbay-hub/tests/test_mnp_token.py71
-rw-r--r--packages/meshbay-hub/tests/test_token_hardening.py22
4 files changed, 97 insertions, 10 deletions
diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py
index 4f5cbfb..cd10046 100644
--- a/packages/meshbay-hub/tests/test_availability_between_members.py
+++ b/packages/meshbay-hub/tests/test_availability_between_members.py
@@ -482,13 +482,14 @@ async def test_changing_your_address_cannot_mail_strangers_at_will(
user = await _make_user(client, "av_mailer")
headers = {"Authorization": f"Bearer {user['token']}"}
+ ak = base64.b64encode(b"k" * 32).decode() # the auth_key _make_user signs up with
r = await client.patch("/v1/users/me", headers=headers,
- json={"email": "a-stranger@example.test"})
+ json={"email": "a-stranger@example.test", "auth_key": ak})
assert r.status_code == 200, r.text
assert len(sent) == 1
r = await client.patch("/v1/users/me", headers=headers,
- json={"email": "another-stranger@example.test"})
+ json={"email": "another-stranger@example.test", "auth_key": ak})
assert r.status_code == 429, r.text
assert len(sent) == 1, "the hub mailed a second stranger on demand"
diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py
index 2afd27b..162b074 100644
--- a/packages/meshbay-hub/tests/test_hub_api.py
+++ b/packages/meshbay-hub/tests/test_hub_api.py
@@ -3,6 +3,7 @@ Integration tests for the Hub API.
Uses SQLite in-memory + httpx.AsyncClient — no PostgreSQL, no network.
"""
+from meshbay_common.tokens import HUB_API_AUD
from datetime import UTC
import pytest
@@ -142,7 +143,7 @@ async def test_jwt_offline_verify(client, hub_key_path):
r_pk = await client.get("/v1/hub/pubkey")
hub_pk_pem = r_pk.json()["pk_hub_pem"].encode()
- decoded = pyjwt.decode(token, hub_pk_pem, algorithms=["EdDSA"])
+ decoded = pyjwt.decode(token, hub_pk_pem, algorithms=["EdDSA"], audience=HUB_API_AUD)
assert "jti" in decoded # mandatory
# The token carries no user key. It used to, and the node recorded it as the
# uploader's identity — so whoever issued tokens decided who could delete a
@@ -339,7 +340,7 @@ async def test_jwt_contains_groups_claim(client):
token_pre = r.json()["access_token"]
r_pk = await client.get("/v1/hub/pubkey")
hub_pk = r_pk.json()["pk_hub_pem"].encode()
- decoded_pre = pyjwt.decode(token_pre, hub_pk, algorithms=["EdDSA"])
+ decoded_pre = pyjwt.decode(token_pre, hub_pk, algorithms=["EdDSA"], audience=HUB_API_AUD)
assert decoded_pre["groups"] == []
# Alice creates a group and adds Bob
@@ -359,13 +360,13 @@ async def test_jwt_contains_groups_claim(client):
r = await client.post("/v1/users/login", json={
"username": "grp_bob_test", "password": "bobpass99"})
token_post = r.json()["access_token"]
- decoded_post = pyjwt.decode(token_post, hub_pk, algorithms=["EdDSA"])
+ decoded_post = pyjwt.decode(token_post, hub_pk, algorithms=["EdDSA"], audience=HUB_API_AUD)
assert group_id in decoded_post["groups"]
# Alice (admin) should also have the group in her JWT
r = await client.post("/v1/users/login", json={
"username": "grp_alice", "password": "alicepass99"})
- decoded_alice = pyjwt.decode(r.json()["access_token"], hub_pk, algorithms=["EdDSA"])
+ decoded_alice = pyjwt.decode(r.json()["access_token"], hub_pk, algorithms=["EdDSA"], audience=HUB_API_AUD)
assert group_id in decoded_alice["groups"]
diff --git a/packages/meshbay-hub/tests/test_mnp_token.py b/packages/meshbay-hub/tests/test_mnp_token.py
new file mode 100644
index 0000000..7b483ff
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_mnp_token.py
@@ -0,0 +1,71 @@
+"""The MNP token: a member's credential to a node, useless at the hub API.
+
+A member hands whatever token it presents to every node it connects to (the MNP
+handshake). That must not be the hub session token, which opens the hub API —
+otherwise a node operator holds a live credential for the member. `POST
+/v1/nodes/mnp-token` mints a short-lived, node-audience token for that purpose;
+these tests pin that it authorises to a node and is refused by the hub API.
+"""
+
+import pytest
+
+from meshbay_common.handshake import HandshakeError, authorize_token
+from meshbay_common.tokens import MNP_AUD
+
+
+async def _session_token(client, username="mnp_user_test"):
+ await client.post("/v1/users/register", json={
+ "username": username, "email": f"{username}@test.local", "auth_key": "k" * 44})
+ r = await client.post("/v1/users/login", json={
+ "username": username, "auth_key": "k" * 44})
+ return r.json()["access_token"]
+
+
+@pytest.mark.asyncio
+async def test_mnp_token_endpoint_needs_a_session(client):
+ # No Authorization header at all — FastAPI rejects the required header (422),
+ # like every other authenticated route; the point is it is not minted anonymously.
+ r = await client.post("/v1/nodes/mnp-token")
+ assert r.status_code in (401, 403, 422)
+
+
+@pytest.mark.asyncio
+async def test_mnp_token_is_minted_for_a_member(client):
+ tok = await _session_token(client)
+ r = await client.post("/v1/nodes/mnp-token",
+ headers={"Authorization": f"Bearer {tok}"})
+ assert r.status_code == 200
+ assert r.json().get("mnp_token")
+
+
+@pytest.mark.asyncio
+async def test_mnp_token_is_refused_at_the_hub_api(client):
+ """The whole point: the credential a node receives opens nothing at the hub."""
+ tok = await _session_token(client, "mnp_api_test")
+ mnp = (await client.post("/v1/nodes/mnp-token",
+ headers={"Authorization": f"Bearer {tok}"})).json()["mnp_token"]
+ # Presenting it to a hub endpoint fails.
+ r = await client.get("/v1/users/me", headers={"Authorization": f"Bearer {mnp}"})
+ assert r.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_a_session_token_is_refused_by_a_node_but_the_mnp_token_is_not(client):
+ """The mirror image, at the node's decode: the session token (what the API
+ accepts) is refused by `authorize_token`, and the MNP token is accepted."""
+ from meshbay_hub.auth import hub_public_key_pem
+
+ # Make the member a member of a group so the MNP token carries it.
+ tok = await _session_token(client, "mnp_node_test")
+ H = {"Authorization": f"Bearer {tok}"}
+ gid = (await client.post("/v1/groups", headers=H, json={
+ "name": "g", "visibility": "private", "join_policy": "invite"})).json()["group_id"]
+ mnp = (await client.post("/v1/nodes/mnp-token", headers=H)).json()["mnp_token"]
+ pk = hub_public_key_pem()
+
+ # The session token is refused by the node handshake (wrong audience).
+ with pytest.raises(HandshakeError):
+ authorize_token(tok, pk, group_id=gid)
+ # The MNP token authorises the member to the node.
+ peer = authorize_token(mnp, pk, group_id=gid)
+ assert peer.group_id == gid
diff --git a/packages/meshbay-hub/tests/test_token_hardening.py b/packages/meshbay-hub/tests/test_token_hardening.py
index 0835180..f381f69 100644
--- a/packages/meshbay-hub/tests/test_token_hardening.py
+++ b/packages/meshbay-hub/tests/test_token_hardening.py
@@ -17,6 +17,7 @@ import time
import pytest
+from meshbay_common.tokens import HUB_API_AUD
from meshbay_hub import auth
@@ -33,7 +34,7 @@ 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"},
+ forged = jwt.encode({"sub": "u", "scope": "user", "aud": HUB_API_AUD},
auth.hub_private_key_pem(), algorithm="EdDSA")
with pytest.raises(Exception):
auth.decode_access_token(forged)
@@ -42,7 +43,8 @@ async def test_token_without_exp_is_refused(client):
@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},
+ forged = jwt.encode({"sub": "u", "scope": "user", "aud": HUB_API_AUD,
+ "exp": int(time.time()) - 100},
auth.hub_private_key_pem(), algorithm="EdDSA")
with pytest.raises(jwt.ExpiredSignatureError):
auth.decode_access_token(forged)
@@ -51,7 +53,7 @@ async def test_expired_token_is_refused(client):
@pytest.mark.asyncio
async def test_token_without_scope_is_refused(client):
import jwt
- forged = jwt.encode({"sub": "u", "exp": int(time.time()) + 3600},
+ forged = jwt.encode({"sub": "u", "exp": int(time.time()) + 3600, "aud": HUB_API_AUD},
auth.hub_private_key_pem(), algorithm="EdDSA")
with pytest.raises(Exception):
auth.decode_access_token(forged)
@@ -60,13 +62,25 @@ async def test_token_without_scope_is_refused(client):
@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},
+ forged = jwt.encode({"sub": "u", "scope": "root", "aud": HUB_API_AUD,
+ "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_an_mnp_token_is_refused_at_the_hub_api(client):
+ """The MNP token (aud=MNP_AUD) authorises a member to a node; it must not be
+ a session at the hub. A node operator holds one, and this is what stops them
+ replaying it against the hub API."""
+ from meshbay_hub.auth import issue_mnp_token
+ mnp = issue_mnp_token("some-user", groups=[])
+ with pytest.raises(Exception):
+ auth.decode_access_token(mnp)
+
+
+@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."""