aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_token_hardening.py
blob: f381f6906cdece0f4a6a399763931c307516d604 (plain) (blame)
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"