Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ A reload is always explicit. Two things cause one:
| MCP servers (`mcp_servers`) | ✅ new sessions get the new set |
| Skills (`skills/`) | ✅ re-scanned |
| `lockdown` | ✅ the write guards and the layer stack both follow |
| Web gateway auth (`auth.*`) | ✅ read per request. Only the gateway's own auth: the MCP endpoint checks `/mcp/v1` against the `auth.jwt_secret` it was mounted with, so rotating that secret is half-hot (see the restart table) |
| Web gateway auth (`auth.*`) | ✅ read per request. Only the gateway's own auth: the MCP endpoint checks `/mcp/v1` against the `auth.jwt_secret` it was mounted with, so rotating that secret is half-hot (see the restart table). `auth.jwt_expiry_hours` governs tokens minted *after* the reload; already-issued tokens keep the window they were signed with until they next slide |
| `notifications.*` | ✅ read per notification |
| `workspace_sync.*` | ✅ from the next sync cycle |
| `retention.*`, `backup.*`, and the `sessions.*` the background loops read | ✅ from the next cycle of that loop |
Expand Down
7 changes: 7 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,16 @@ telegram:
auth:
password_hash: "$2b$12$..." # Generate below
jwt_secret: "..." # Generate below
jwt_expiry_hours: 720 # Optional — web-session idle timeout (default 30 days)
EOF
```

`jwt_expiry_hours` is an **idle** timeout, not a cap on a working session: the
gateway re-mints the token whenever a request arrives past half its lifetime,
so a tab in continuous use is never logged out. Only a tab left untouched for
the whole window comes back to a password prompt. Lower it if the browser is
somewhere you don't fully trust.

### Generate auth credentials

```bash
Expand Down
7 changes: 7 additions & 0 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1871,13 +1871,20 @@ def from_dict(cls, d: dict) -> RetentionConfig:
class AuthConfig:
password_hash: str = ""
jwt_secret: str = ""
# Web-session lifetime. This is an *idle* timeout, not a cap on a working
# session: the gateway slides the token forward on every authenticated
# request (see gateway/auth.py), so an actively-used browser tab is never
# logged out mid-work. Only a tab left untouched for the whole window
# comes back to a password prompt.
jwt_expiry_hours: int = 720 # 30 days

@classmethod
@_coerced
def from_dict(cls, d: dict) -> AuthConfig:
return cls(
password_hash=d.get("password_hash", ""),
jwt_secret=d.get("jwt_secret", ""),
jwt_expiry_hours=max(1, _lenient_int(d.get("jwt_expiry_hours"), 720)),
)


Expand Down
73 changes: 67 additions & 6 deletions nerve/gateway/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,24 @@
logger = logging.getLogger(__name__)

JWT_ALGORITHM = "HS256"
JWT_EXPIRY_HOURS = 24

# Fallback web-session lifetime, used only when the config can't be read.
# The real value is ``auth.jwt_expiry_hours`` (default 720h / 30 days).
#
# Session tokens *slide*: ``require_auth`` re-mints one that is past
# REFRESH_AFTER_RATIO of its lifetime and the gateway hands the fresh token
# back on the response, so continuous use never expires. The configured
# window is therefore an idle timeout — the old fixed 24h constant logged
# you out mid-work exactly one day after login no matter what you were doing.
DEFAULT_JWT_EXPIRY_HOURS = 720

# Re-mint once a token is this far into its lifetime. At 0.5 an active
# session is refreshed about every half-window (so it never dies), while a
# fresh token costs no crypto on the vast majority of requests.
REFRESH_AFTER_RATIO = 0.5

# Response header carrying a slid session token back to the browser.
SESSION_TOKEN_HEADER = "X-Nerve-Token"

# Audience claim on session-bound MCP tokens (see create_mcp_session_token).
MCP_AUDIENCE = "nerve-mcp"
Expand All @@ -32,16 +49,53 @@ def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))


def create_token(jwt_secret: str) -> str:
"""Create a JWT token."""
def session_expiry_hours() -> int:
"""Configured web-session lifetime, in hours (never below 1)."""
try:
hours = int(get_config().auth.jwt_expiry_hours)
except Exception: # config unreadable (very early boot / tests)
hours = DEFAULT_JWT_EXPIRY_HOURS
return max(1, hours)


def create_token(jwt_secret: str, expiry_hours: int | None = None) -> str:
"""Create a web-session JWT token."""
hours = max(1, int(expiry_hours)) if expiry_hours else session_expiry_hours()
now = datetime.now(timezone.utc)
payload = {
"exp": datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRY_HOURS),
"iat": datetime.now(timezone.utc),
"exp": now + timedelta(hours=hours),
"iat": now,
"sub": "user",
}
return jwt.encode(payload, jwt_secret, algorithm=JWT_ALGORITHM)


def maybe_refresh_token(payload: dict, jwt_secret: str) -> str | None:
"""Re-mint a session token that is past its refresh threshold.

Sliding expiry: each authenticated request carries the session further
into the future, so a tab in continuous use never hits the wall. Returns
``None`` while the token is still fresh — the common case, and the reason
this costs nothing on most requests.

Only ordinary web-session tokens slide. Audience-scoped tokens (MCP
session/worker credentials) are minted per-process with deliberately
short TTLs and must expire on schedule.
"""
if payload.get("aud") or payload.get("sub") != "user":
return None
iat, exp = payload.get("iat"), payload.get("exp")
if not isinstance(iat, (int, float)) or not isinstance(exp, (int, float)):
return None
lifetime = exp - iat
if lifetime <= 0:
return None
age = datetime.now(timezone.utc).timestamp() - iat
if age < lifetime * REFRESH_AFTER_RATIO:
return None
return create_token(jwt_secret)


def create_mcp_session_token(
jwt_secret: str,
session_id: str,
Expand Down Expand Up @@ -153,7 +207,14 @@ async def require_auth(request: Request) -> dict:
return {"sub": "user"}

token = get_token_from_request(request)
return decode_token(token, config.auth.jwt_secret)
payload = decode_token(token, config.auth.jwt_secret)
# Slide the session forward. Stashed on request.state rather than returned
# so every existing caller of this dependency is unaffected; the gateway's
# http middleware picks it up and emits SESSION_TOKEN_HEADER.
refreshed = maybe_refresh_token(payload, config.auth.jwt_secret)
if refreshed:
request.state.refreshed_token = refreshed
return payload


async def authenticate_websocket(websocket: WebSocket) -> bool:
Expand Down
22 changes: 20 additions & 2 deletions nerve/gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
Expand All @@ -25,7 +25,7 @@
from nerve.agent.streaming import broadcaster
from nerve.config import NerveConfig, get_config
from nerve.db import Database, init_db, close_db
from nerve.gateway.auth import authenticate_websocket
from nerve.gateway.auth import SESSION_TOKEN_HEADER, authenticate_websocket
from nerve.gateway.routes import (
init_deps,
register_all_routes,
Expand Down Expand Up @@ -752,13 +752,31 @@ async def _lockdown_handler(request, exc: LockdownError): # noqa: ANN001
async def _skill_id_handler(request, exc: SkillIdError): # noqa: ANN001
return JSONResponse(status_code=400, content={"detail": str(exc)})

# Sliding session tokens. `require_auth` re-mints a session token once it
# is past half its lifetime and stashes it on request.state; hand it back
# on the response so the browser can swap it in. Net effect: a tab in
# continuous use is never logged out, and the configured
# `auth.jwt_expiry_hours` becomes an idle timeout instead of a hard
# egg timer that fires mid-typing.
@app.middleware("http")
async def _slide_session_token(request: Request, call_next): # noqa: ANN001
response = await call_next(request)
token = getattr(request.state, "refreshed_token", None)
if token:
response.headers[SESSION_TOKEN_HEADER] = token
return response

# CORS for development
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
# Browsers hide non-safelisted response headers from JS unless the
# server explicitly exposes them — without this the refreshed token
# is invisible to fetch() on any cross-origin (dev) setup.
expose_headers=[SESSION_TOKEN_HEADER],
)

# Compress JSON responses. Sessions with heavy tool-call blobs can
Expand Down
118 changes: 118 additions & 0 deletions tests/test_auth_session_header.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""End-to-end test for the slid-token response header.

``require_auth`` stashes a refreshed token on ``request.state`` and an http
middleware turns that into ``X-Nerve-Token`` on the way out. That hand-off
crosses Starlette's middleware boundary, so it is asserted against a real ASGI
round-trip rather than reasoned about: if the header ever stops coming back,
every browser tab silently returns to being logged out on a fixed timer.
"""

from __future__ import annotations

from datetime import datetime, timedelta, timezone

import jwt
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient

from nerve.config import AuthConfig, NerveConfig, set_config
from nerve.gateway.auth import (
JWT_ALGORITHM,
SESSION_TOKEN_HEADER,
create_token,
maybe_refresh_token,
require_auth,
)

_SECRET = "test-secret-for-session-header-padded-32"


@pytest.fixture
def client():
"""Minimal app wired exactly like the gateway: dependency + middleware."""
set_config(NerveConfig(auth=AuthConfig(jwt_secret=_SECRET, jwt_expiry_hours=720)))
app = FastAPI()

from fastapi import Request

@app.middleware("http")
async def _slide_session_token(request: Request, call_next):
response = await call_next(request)
token = getattr(request.state, "refreshed_token", None)
if token:
response.headers[SESSION_TOKEN_HEADER] = token
return response

@app.get("/api/thing")
async def thing(user: dict = Depends(require_auth)):
return {"ok": True}

with TestClient(app) as c:
yield c
set_config(NerveConfig())


def _token(*, age_hours: float, lifetime_hours: int = 720) -> str:
iat = datetime.now(timezone.utc) - timedelta(hours=age_hours)
return jwt.encode(
{
"iat": iat,
"exp": iat + timedelta(hours=lifetime_hours),
"sub": "user",
},
_SECRET,
algorithm=JWT_ALGORITHM,
)


def test_fresh_token_gets_no_refresh_header(client):
res = client.get("/api/thing", headers={"Authorization": f"Bearer {create_token(_SECRET)}"})
assert res.status_code == 200
assert SESSION_TOKEN_HEADER not in res.headers


def test_half_spent_token_comes_back_refreshed(client):
stale = _token(age_hours=400)
res = client.get("/api/thing", headers={"Authorization": f"Bearer {stale}"})
assert res.status_code == 200

fresh = res.headers.get(SESSION_TOKEN_HEADER)
assert fresh, "an aging session must be handed a replacement token"
assert fresh != stale

decoded = jwt.decode(fresh, _SECRET, algorithms=[JWT_ALGORITHM])
old = jwt.decode(stale, _SECRET, algorithms=[JWT_ALGORITHM])
assert decoded["exp"] > old["exp"]
# And the replacement must itself authenticate.
assert client.get(
"/api/thing", headers={"Authorization": f"Bearer {fresh}"},
).status_code == 200


def test_expired_token_is_rejected(client):
dead = _token(age_hours=800) # older than its own 720h window
res = client.get("/api/thing", headers={"Authorization": f"Bearer {dead}"})
assert res.status_code == 401
assert SESSION_TOKEN_HEADER not in res.headers


def test_refresh_is_not_triggered_by_an_unauthenticated_call(client):
res = client.get("/api/thing")
assert res.status_code == 401
assert SESSION_TOKEN_HEADER not in res.headers


def test_query_param_token_also_slides(client):
"""`<img src>`/downloads authenticate via ?token= and must slide too."""
stale = _token(age_hours=400)
res = client.get(f"/api/thing?token={stale}")
assert res.status_code == 200
assert res.headers.get(SESSION_TOKEN_HEADER)


def test_maybe_refresh_agrees_with_the_route(client):
"""Guard against the dependency and the helper drifting apart."""
stale = _token(age_hours=400)
payload = jwt.decode(stale, _SECRET, algorithms=[JWT_ALGORITHM])
assert maybe_refresh_token(payload, _SECRET) is not None
Loading
Loading