"""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 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"] async def _own_group(client, tok, name="g"): return (await client.post("/v1/groups", headers={"Authorization": f"Bearer {tok}"}, json={"name": name, "visibility": "private", "join_policy": "invite"})).json()["group_id"] @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) gid = await _own_group(client, tok) r = await client.post("/v1/nodes/mnp-token", json={"group_id": gid}, 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") gid = await _own_group(client, tok) mnp = (await client.post("/v1/nodes/mnp-token", json={"group_id": gid}, 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={"group_id": gid})).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 @pytest.mark.asyncio async def test_the_mnp_token_is_bound_to_the_node_it_names(client): """E10: a token minted for node A is refused by node B, so an operator who captures a member's token cannot replay it to another of the member's nodes.""" from meshbay_hub.auth import hub_public_key_pem tok = await _session_token(client, "mnp_bind_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"] # A token bound to node A's key. mnp = (await client.post("/v1/nodes/mnp-token", headers=H, json={"node_pk": "node-A-pk", "group_id": gid})).json()["mnp_token"] pk = hub_public_key_pem() # Node B refuses it; node A accepts it. with pytest.raises(HandshakeError, match="this node"): authorize_token(mnp, pk, group_id=gid, node_pk_b64="node-B-pk") peer = authorize_token(mnp, pk, group_id=gid, node_pk_b64="node-A-pk") assert peer.group_id == gid @pytest.mark.asyncio async def test_the_mnp_token_names_only_the_group_asked_for(client): """It is handed to that group's node operator, who has no business learning every other group the member belongs to.""" import jwt as _jwt tok = await _session_token(client, "mnp_scope_test") H = {"Authorization": f"Bearer {tok}"} one = await _own_group(client, tok, "one") await _own_group(client, tok, "two") await _own_group(client, tok, "three") mnp = (await client.post("/v1/nodes/mnp-token", headers=H, json={"group_id": one})).json()["mnp_token"] claims = _jwt.decode(mnp, options={"verify_signature": False}) assert claims["groups"] == [one] @pytest.mark.asyncio async def test_no_group_is_named_for_a_non_member(client): from meshbay_hub.auth import hub_public_key_pem owner = await _session_token(client, "mnp_owner_test") gid = await _own_group(client, owner) stranger = await _session_token(client, "mnp_stranger") mnp = (await client.post("/v1/nodes/mnp-token", json={"group_id": gid}, headers={"Authorization": f"Bearer {stranger}"})).json()["mnp_token"] with pytest.raises(HandshakeError) as refused: authorize_token(mnp, hub_public_key_pem(), group_id=gid) assert refused.value.code == "not_a_member" @pytest.mark.asyncio async def test_a_token_must_name_its_group(client): tok = await _session_token(client, "mnp_nogroup_test") r = await client.post("/v1/nodes/mnp-token", json={}, headers={"Authorization": f"Bearer {tok}"}) assert r.status_code == 422