diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-25 13:48:28 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-25 17:24:16 +0200 |
| commit | cc4cb6b363a24601637f18f8af7821bafd7765e9 (patch) | |
| tree | e82d14fd7dcf9409739fc27f8525225df7c99012 /packages/meshbay-hub/src | |
| parent | c6505677fd121265ab5cc52276ec9d0c1c73b6c9 (diff) | |
| download | meshbay-cc4cb6b363a24601637f18f8af7821bafd7765e9.tar.gz | |
fix(hub): require exp, sub and a known scope when decoding access tokens
One Ed25519 key signs four kinds of token — user access, node access,
revocation broadcasts (no exp, no sub, and returned in the body of
POST /v1/admin/revoke and pushed to every node) and MHP federation tokens
(aud, sub=hub_id, no scope). decode_access_token required none of these, so a
hub-signed token with no exp was accepted and separation between the types
rested only on which fields each consumer happened to read.
Require exp, sub and scope, and reject a scope that is not one of the two
access scopes. A revocation or MHP token can no longer be mistaken for a
session, and no hub-signed token without an expiry is honoured.
A full RFC 5987 aud binding is deliberately not used: the node decodes its own
hub-issued token without passing audience, so adding aud would make every
already-deployed node reject its own token (InvalidAudienceError) — a
coordinated, node-breaking change. scope gives the same purpose separation
among the hub's own token types without it. This is non-breaking: every real
access token already carries exp, sub and scope, so no session is forced to
re-authenticate.
test_token_hardening.py holds the refusals (no exp, no scope, unknown scope, a
revocation token as bearer) and the paths that must keep working (a real login
token, a node token); verified red against the pre-fix auth.py and green after.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/auth.py | 28 |
1 files changed, 26 insertions, 2 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py index d038027..309738c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/auth.py +++ b/packages/meshbay-hub/src/meshbay_hub/auth.py @@ -235,12 +235,36 @@ def issue_access_token( def decode_access_token(token: str) -> dict: - """Verify and decode an access token. Raises on failure.""" + """Verify and decode an access token. Raises on failure. + + `exp`, `sub` and `scope` are **required**, and `scope` must name one of the + two access scopes. One Ed25519 key signs four kinds of token — user access, + node access, revocation broadcasts (no `exp`, no `sub`, and returned in the + body of `POST /v1/admin/revoke` and pushed to every node), and MHP + federation tokens (`aud`, `sub=hub_id`, no `scope`). Without these + requirements a token with no `exp` was accepted, and separation between the + types rested only on which fields each consumer happened to read. Requiring + `scope` here turns a revocation or MHP token away before it can be mistaken + for a session, and requiring `exp` refuses any hub-signed token that never + expires. + + A full RFC 5987 `aud` binding is deliberately not used: the node decodes its + own hub-issued token without passing `audience`, so adding `aud` would make + every already-deployed node reject its own token (`InvalidAudienceError`) — + a coordinated, node-breaking change. `scope` gives the same purpose + separation among the hub's own token types without it. + """ if _hub_pk_pem is None: raise RuntimeError("Hub keypair not loaded") # Clock-skew tolerance (meshbay_common.handshake.JWT_LEEWAY_SECONDS): a # client whose clock is a little fast must still be able to call the API. - return jwt.decode(token, _hub_pk_pem, algorithms=["EdDSA"], leeway=60) + payload = jwt.decode( + token, _hub_pk_pem, algorithms=["EdDSA"], leeway=60, + options={"require": ["exp", "sub", "scope"]}, + ) + if payload.get("scope") not in ("user", "node"): + raise jwt.InvalidTokenError("unrecognised token scope") + return payload # ── Email encryption at rest ────────────────────────────────────────────────── |