1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
|
"""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"
|