diff --git a/daemon/server.py b/daemon/server.py index 2b06643..0648e2e 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -373,7 +373,7 @@ async def _handle_ws(request: web.Request) -> web.Response: 1. WebSocket subprotocol header (Sec-WebSocket-Protocol: "Bearer ") 2. X-Auth-Token header (nginx-injected) """ - from lib.auth import decode_token, validate_token + from lib.auth import validate_token token_param = None @@ -392,16 +392,11 @@ async def _handle_ws(request: web.Request) -> web.Response: {"ok": False, "error": "authentication required"}, status=401 ) - # Decode token to extract session_id from payload (browsers can't send - # X-Session-Id header on WebSocket connections, only subprotocols) - raw_payload = decode_token(token_param) - if raw_payload is None: - return web.json_response({"ok": False, "error": "unauthorized"}, status=401) - + # Validate token. Session binding is skipped because browsers cannot send + # custom headers on WebSocket connections (no X-Session-Id available). payload = validate_token( token_param, token_type="access", - session_id=raw_payload.get("session_id"), ) if payload is None: return web.json_response({"ok": False, "error": "unauthorized"}, status=401) diff --git a/lib/auth.py b/lib/auth.py index 9502c14..c7e65a8 100644 --- a/lib/auth.py +++ b/lib/auth.py @@ -290,9 +290,10 @@ def validate_token( token_string: The JWT token string. token_type: Expected token type ("access" or "refresh"). session_id: Must match the ``session_id`` claim in the token payload. - Required for access tokens — prevents a stolen token from being - usable without the originating session. Ignored for refresh tokens - which carry no ``session_id`` claim. + When provided, enforces session binding to prevent a stolen token + from being usable without the originating session. When ``None``, + the check is skipped (used by WebSocket auth which cannot carry + the session ID header). Returns: Payload dict including permissions, or None if invalid/blacklisted. @@ -302,13 +303,7 @@ def validate_token( return None if payload.get("type") != token_type: return None - if token_type == "access" and session_id != payload.get("session_id"): - return None - if ( - token_type != "access" - and session_id - and payload.get("session_id") != session_id - ): + if session_id is not None and session_id != payload.get("session_id"): return None jti = payload.get("jti") diff --git a/tests/test_auth.py b/tests/test_auth.py index 3c2d6c2..51ed4e2 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -235,12 +235,22 @@ class TestJWT: payload = validate_token(token, "access", session_id="wrong-session") assert payload is None - def test_session_id_required(self): - """Access token is rejected without session_id parameter.""" + def test_session_id_optional(self): + """Access token validates without session_id (used by WS auth).""" token = generate_access_token( "admin", {"firewall": "rw"}, session_id="my-session" ) + # Without session_id, only type, expiry, and blacklist are checked payload = validate_token(token, "access") + assert payload is not None + assert payload["sub"] == "admin" + + def test_session_id_enforced_when_provided(self): + """Access token is rejected when session_id is provided but doesn't match.""" + token = generate_access_token( + "admin", {"firewall": "rw"}, session_id="my-session" + ) + payload = validate_token(token, "access", session_id="different-session") assert payload is None def test_decode_token(self): @@ -736,6 +746,72 @@ class TestWebAuthnCredentials: remove_credential("testuser", _FAKE_CRED_ID) +class TestWebAuthnBlueprintOwnership: + """Test Flask blueprint enforces JWT username on WebAuthn registration. + + The blueprint must override body["username"] with user_ctx["username"] to + prevent an authenticated user from registering credentials for another user. + + This mirrors the pattern used by the change_password endpoint. + """ + + def test_register_begin_ownership_guard(self) -> None: + """Blueprint forces username from JWT for register-begin.""" + # Simulate the blueprint logic: + # user_ctx = getattr(request, "_user_ctx", None) + # if user_ctx is not None: + # body["username"] = user_ctx["username"] + + # Attacker scenario: alice sends username "bob" + user_ctx = {"username": "alice"} + body = {"username": "bob"} + if user_ctx is not None: + body["username"] = user_ctx["username"] + # Server-side username is "alice", not the attacker-supplied "bob" + assert body["username"] == "alice" + + def test_register_finish_ownership_guard(self) -> None: + """Blueprint forces username from JWT for register-finish.""" + user_ctx = {"username": "alice"} + body = {"username": "malicious", "credential_response": {}} + if user_ctx is not None: + body["username"] = user_ctx["username"] + assert body["username"] == "alice" + + def test_register_no_context(self) -> None: + """Without _user_ctx, body username passes through (daemon direct call).""" + user_ctx = None + body = {"username": "daemon_user"} + if user_ctx is not None: + body["username"] = user_ctx["username"] + # No override — body username preserved + assert body["username"] == "daemon_user" + + +class TestTokenValidationEdgeCases: + """Test token validation edge cases: session_id semantics, refresh tokens.""" + + def test_refresh_token_valid_without_session_id(self) -> None: + """Refresh token validates when no session_id is passed.""" + token = generate_refresh_token("admin") + payload = validate_token(token, "refresh") + assert payload is not None + assert payload["sub"] == "admin" + assert payload["type"] == "refresh" + + def test_refresh_token_valid_with_session_id_none(self) -> None: + """Refresh token validates when session_id=None is passed.""" + token = generate_refresh_token("admin") + payload = validate_token(token, "refresh", session_id=None) + assert payload is not None + + def test_access_token_valid_with_wrong_type_refresh(self) -> None: + """Access token is rejected when validated as refresh type.""" + token = generate_access_token("admin", {"firewall": "rw"}, session_id="sess") + payload = validate_token(token, "refresh") + assert payload is None + + # ═══════════════════════════════════════════════════════════════════════════ # Multi-user tests (Phase 3) # ═══════════════════════════════════════════════════════════════════════════ diff --git a/webui/api/auth.py b/webui/api/auth.py index a50f48a..04bb727 100644 --- a/webui/api/auth.py +++ b/webui/api/auth.py @@ -259,13 +259,14 @@ def webauthn_register_begin(): Endpoint: POST /api/auth/webauthn/register-begin - Body: - { "username": "..." } Returns: Registration options for navigator.credentials.create() """ try: body = request.get_json(silent=True) or {} + user_ctx = getattr(request, "_user_ctx", None) + if user_ctx is not None: + body["username"] = user_ctx["username"] origin, rp_id = _resolve_webauthn_origin() body["webauthn_origin"] = origin body["webauthn_rp_id"] = rp_id @@ -282,12 +283,15 @@ def webauthn_register_finish(): Endpoint: POST /api/auth/webauthn/register-finish Body: - { "username": "...", "credential_response": {...}, "registration_options": {...}, "name": "..." } + { "credential_response": {...}, "registration_options": {...}, "name": "..." } Returns: { "ok": true, "credential": {...} } """ try: body = request.get_json(silent=True) or {} + user_ctx = getattr(request, "_user_ctx", None) + if user_ctx is not None: + body["username"] = user_ctx["username"] origin, rp_id = _resolve_webauthn_origin() body["webauthn_origin"] = origin body["webauthn_rp_id"] = rp_id diff --git a/webui/static/hoover/api.js b/webui/static/hoover/api.js index 388b280..652ee68 100644 --- a/webui/static/hoover/api.js +++ b/webui/static/hoover/api.js @@ -165,6 +165,10 @@ export async function apiFetch(url, options = {}) { if (res.status === 401 && getAuthToken()) { const refreshed = await tryRefreshToken(); if (refreshed) { + if (method === 'POST') { + redirectLogin(); + return { ok: false, data: null, error: 'Session expired', status: 401 }; + } const refreshedStored = getStoredAuth(); headers['Authorization'] = 'Bearer ' + getAuthToken(); headers['X-Session-Id'] = refreshedStored.session_id; diff --git a/webui/static/pages/login.js b/webui/static/pages/login.js index 3f08dd4..1b45574 100644 --- a/webui/static/pages/login.js +++ b/webui/static/pages/login.js @@ -209,21 +209,9 @@ const Page = definePage({ document.title = 'Login — Vacuum Wall'; }, - async load(state, abortController) { + load() { handleLogin(); setupPasskeyButton(); - - if (getAuthToken()) { - try { - const res = await apiFetch('/api/auth/session', { signal: abortController?.signal }); - if (res.ok) { - window.location.hash = '/dashboard'; - return; - } - } catch { - // auth check failed, show login - } - } }, render() { diff --git a/webui/static/pages/passkeys.js b/webui/static/pages/passkeys.js index 45801aa..302daf9 100644 --- a/webui/static/pages/passkeys.js +++ b/webui/static/pages/passkeys.js @@ -91,20 +91,11 @@ function addCredentialModal() { setModalProcessing(true); refreshModals(); - const user = JSON.parse(sessionStorage.getItem('vw:user') || 'null'); - const username = user?.username || ''; - if (!username) { - toast('Username not available', 'error'); - setModalProcessing(false); - refreshModals(); - return; - } - try { - // Step 1: Get registration options + // Step 1: Get registration options (username from JWT) const beginRes = await apiFetch('/api/auth/webauthn/register-begin', { method: 'POST', - body: { username }, + body: {}, }); if (!beginRes.ok) { @@ -117,11 +108,10 @@ function addCredentialModal() { const credentialName = document.getElementById('cred-name')?.value?.trim() || ''; const credentialResponse = await startRegistration(options); - // Step 3: Verify with server + // Step 3: Verify with server (username from JWT) const finishRes = await apiFetch('/api/auth/webauthn/register-finish', { method: 'POST', body: { - username, credential_response: credentialResponse, registration_options: options, name: credentialName,