diff options
| -rw-r--r-- | CLAUDE.md | 26 | ||||
| -rw-r--r-- | docs/USERGUIDE.md | 37 |
2 files changed, 54 insertions, 9 deletions
@@ -302,6 +302,30 @@ keypair bundle, or anything that looks like a user's public key. reproduced the defect in one run. Model the environment, never the code under test +- **A refresh token that rotates must be stored, or it is spent once.** The hub + revokes the refresh token presented, returns a replacement, and treats a + revoked one presented again as theft — revoking the whole family. The SPA kept + only the access token out of that response, so renewal worked once and the + second attempt destroyed the session, which is why signing out and in was the + only cure. Nothing used the path at all: `hubFetch` reported 401 like any + other error, and watching a film is an hour in which the hub hears nothing, + because the video is WebRTC. Renew on a margin, on returning to the tab, and + on a 401 with a replay; coalesce concurrent renewals, or the second presents + what the first just spent and looks exactly like theft. + `tests/harness/session_harness.mjs` runs it against a hub that enforces + rotation — a lax stub would pass the broken client + +- **An effect keyed on a value that used to be constant.** The WebRTC dial + listed `token` among its dependencies. Harmless while a token only ever + expired; once the session renewed itself the string rotated, and the effect + tore the connection down and rebuilt it — worst at mount, where a stale token + is renewed exactly while ICE is negotiating, so the browser abandoned the + handshake and the node sat in `connecting` for ever. Depend on whether there + is a token, not which one, and read the live one where it is used. Before + making something vary that never varied before, grep the dependency arrays it + appears in — this and the hook-ordering fault above are the same shape: code + that is correct read on its own and wrong against the component lifecycle + - **A stylesheet does not tell you where anything lands.** The responsive tests pinned numbers out of `style.css` and said in their own docstring that a layout could not be measured because there was no browser in the suite. There @@ -509,6 +533,8 @@ SFR residential Fedora 44 → meshbay.org OVH VPS: | Seeking (node) | `webrtc_server.py` | `start` on `stream_req`; `-ss` **before** `-i` (index seek, not decode-and-discard), clamped away from the end, echoed in `stream_init` | | Resume position | `static/app.js` | `readResumePosition` / `writeResumePosition` — localStorage, per file, per browser. No protocol, and nothing new learns what you watch | | Layout, measured | `tests/harness/layout_probe.py` | Renders `style.css` in Chrome at any width and returns bounding boxes. Use it for layout, not `test_layout_responsive.py`, which only pins CSS values | +| Session renewal (browser) | `static/app.js` | `refreshAccessToken` / `ensureFreshToken` — one writer (`setAuth`), one in-flight renewal, rotated refresh token stored. `hubFetch` renews on 401 and replays | +| Token lifetimes (hub) | `meshbay_hub.config` | `[jwt] access_token_ttl` 4 h, `refresh_token_ttl` 30 days. **Production sets both in `~/.config/meshbay/hub.toml`** — changing the code default alone does nothing there | ## meshbay.org server (état cible) diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md index abfc404..7bd695b 100644 --- a/docs/USERGUIDE.md +++ b/docs/USERGUIDE.md @@ -98,10 +98,10 @@ POST /v1/users/register POST /v1/users/login {"username": "yourname", "password": "yourpassword"} → { - "access_token": "JWT (Ed25519, 1 hour validity)", + "access_token": "JWT (Ed25519, 4 hour validity)", "refresh_token": "opaque 256-bit token (30 days)", "token_type": "bearer", - "expires_in": 3600, + "expires_in": 14400, } ``` @@ -117,12 +117,20 @@ curl -s -X POST https://meshbay.org/v1/users/login \ ### Token refresh -Access tokens are valid for 1 hour. When one expires, use the refresh token to get a new one without re-entering your password: +Access tokens are valid for 4 hours. **The web app does this for itself** — it +renews ten minutes before expiry, on returning to the tab, and on any 401, then +replays the request. Nobody should meet an expired token in the browser; what +follows is for other clients. + +The endpoint **rotates**: it revokes the refresh token you present and returns a +new one, so store the replacement. Presenting a revoked token is treated as +theft and revokes the whole family, which is a full sign-out. ``` POST /v1/users/token/refresh {"refresh_token": "your-refresh-token"} -→ {"access_token": "new JWT", "token_type": "bearer", "expires_in": 3600} +→ {"access_token": "new JWT", "refresh_token": "USE THIS NEXT TIME", + "token_type": "bearer", "expires_in": 14400} ``` ```bash @@ -131,7 +139,13 @@ curl -s -X POST https://meshbay.org/v1/users/token/refresh \ -d '{"refresh_token":"YOUR_REFRESH_TOKEN"}' ``` -Refresh tokens are valid for 30 days and can be immediately invalidated by the hub on account compromise. Revoking the refresh token means the next access token renewal will fail; any active access token expires within 1 hour at most. +Refresh tokens are valid for 30 days — that is the session — and can be +invalidated by the hub at once on account compromise. Revoking one means the +next renewal fails; an access token already issued keeps working for up to 4 +hours. That window is not the whole story: the hub reloads the account on every +request and refuses a suspended one immediately, and it pushes signed +revocations to nodes, so suspending an account or revoking a membership takes +effect at once regardless of the token's remaining life. ### Access token structure @@ -146,7 +160,7 @@ The JWT payload contains: | `groups` | The `group_id`s you are a member of, for node-side authorization | | `scope` | `user` for a browser, `node` for a daemon | | `iat` | Issued at (Unix timestamp) | -| `exp` | Expires at (Unix timestamp, 1 hour from issue) | +| `exp` | Expires at (Unix timestamp, 4 hours from issue — `[jwt] access_token_ttl`) | The token carries **no public key of yours**. It used to carry `pk_user`, and a node recorded that key as the uploader of a file — which meant the party issuing tokens @@ -166,7 +180,7 @@ What deletion does: - Releases the username — someone else may register it afterwards - Clears the email and password hash, and drops the node linking key - Removes group memberships, notifications and refresh tokens -- Refuses any access token still in its hour of validity, immediately +- Refuses any access token still within its validity, immediately What deletion does **not** do: @@ -867,7 +881,7 @@ Two layers: - `jti` (UUID4) is mandatory in every token — prevents replay (Ed25519 signing is deterministic; without `jti`, two tokens issued in the same second are byte-for-byte identical) and enables individual revocation - Nodes verify JWTs offline using the hub's cached public key — no hub roundtrip, no hub downtime dependency -- Revocation: hub invalidates refresh token → next access token renewal fails → node access expires within 1 hour. For immediate revocation: hub adds `jti` to a denylist that nodes periodically fetch +- Revocation: hub invalidates refresh token → next access token renewal fails → node access expires within the access token's life. For immediate revocation: hub adds `jti` to a denylist that nodes periodically fetch ### What node compromise exposes @@ -961,7 +975,12 @@ These logs are not used for any purpose other than responding to legal requests. **Symptom:** node returns 401, error says "expired" or "Token signature expired". -**Fix:** your access token is over 1 hour old. Refresh it: +In the web app this should not happen: it renews before expiry and retries once +on a 401. If you see it there, the renewal path itself is broken — check the +browser console rather than the token. + +**Fix (other clients):** your access token is over 4 hours old. Refresh it, and +**keep the refresh token that comes back** — the one you sent is now revoked: ```bash curl -s -X POST https://meshbay.org/v1/users/token/refresh \ |