"""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_common.tokens import HUB_API_AUD 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", "aud": HUB_API_AUD}, 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", "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) @pytest.mark.asyncio async def test_token_without_scope_is_refused(client): import jwt 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) @pytest.mark.asyncio async def test_unknown_scope_is_refused(client): import jwt 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.""" 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"