diff --git a/frontend/auth/restricted/RestrictedApi.vue b/frontend/auth/restricted/RestrictedApi.vue index 950aa2e..e3eeeb3 100644 --- a/frontend/auth/restricted/RestrictedApi.vue +++ b/frontend/auth/restricted/RestrictedApi.vue @@ -1,5 +1,14 @@ @@ -45,6 +58,7 @@ import { computed, onMounted, reactive, ref } from 'vue' import passkey from '@/utils/passkey' import { getSettings } from '@/utils/settings' import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api' +import RemoteAuthLinkModal from '@/components/RemoteAuthLinkModal.vue' const props = defineProps({ mode: { @@ -62,6 +76,7 @@ const loading = ref(false) const settings = ref(null) const userInfo = ref(null) const currentView = ref('initial') // 'initial', 'login', 'forbidden' +const showRemoteAuth = ref(false) let statusTimer = null const isAuthenticated = computed(() => !!userInfo.value?.authenticated) @@ -196,6 +211,30 @@ async function setSessionCookie(result) { }) } +// Remote authentication from another device +function startRemoteAuth() { + showRemoteAuth.value = true +} + +async function handleRemoteAuthenticated(result) { + showRemoteAuth.value = false + showMessage('Authenticated from another device!', 'success', 2000) + try { + await setSessionCookie(result) + } catch (error) { + const message = error?.message || 'Failed to establish session' + showMessage(message, 'error', 4000) + emit('auth-error', { message, cancelled: false }) + return + } + emit('authenticated', result) +} + +function handleRemoteAuthError(errorMsg) { + showRemoteAuth.value = false + showMessage(errorMsg || 'Remote authentication failed', 'error', 4000) +} + onMounted(async () => { await fetchSettings() await fetchUserInfo() diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py index 95c159f..5928645 100644 --- a/paskia/fastapi/mainapp.py +++ b/paskia/fastapi/mainapp.py @@ -7,6 +7,7 @@ from fastapi import FastAPI, HTTPException, Request, Response from fastapi.responses import FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles +from paskia import remoteauth from paskia.fastapi import admin, api, auth_host, ws from paskia.fastapi.session import AUTH_COOKIE from paskia.util import frontend, hostutil, passphrase @@ -37,13 +38,17 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path origins=config["origins"], bootstrap=False, ) + # Initialize remote authentication manager + await remoteauth.init() except ValueError as e: logging.error(f"⚠️ {e}") # Re-raise to fail fast raise yield - # (Optional) add shutdown cleanup here later + + # Shutdown cleanup + await remoteauth.shutdown() app = FastAPI(lifespan=lifespan) @@ -113,6 +118,15 @@ async def examples_page(): # Note: this catch-all handler must be the last route defined +@app.get("/remote/{token}") +@app.get("/auth/remote/{token}") +async def remote_auth_link(token: str): + """Serve the restricted app for cross-device login.""" + if not passphrase.is_well_formed(token): + raise HTTPException(status_code=404) + return Response(*await frontend.read("/auth/restricted/index.html")) + + @app.get("/{reset}") @app.get("/auth/{reset}") async def reset_link(reset: str): diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index 4efca50..a600abf 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -1,3 +1,4 @@ +import asyncio import logging from functools import wraps from uuid import UUID @@ -5,11 +6,12 @@ from uuid import UUID from fastapi import FastAPI, WebSocket, WebSocketDisconnect from webauthn.helpers.exceptions import InvalidAuthenticationResponse +from paskia import remoteauth from paskia.authsession import create_session, get_reset, get_session from paskia.fastapi import authz from paskia.fastapi.session import AUTH_COOKIE, infodict from paskia.globals import db, passkey -from paskia.util import passphrase +from paskia.util import hostutil, passphrase from paskia.util.tokens import create_token, session_key @@ -198,3 +200,291 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): "session_token": token, } ) + + +@app.websocket("/remote-auth/request") +@websocket_error_handler +async def websocket_remote_auth_request(ws: WebSocket): + """Request authentication from another device. + + This endpoint is called by the device that wants to be authenticated. + It creates a remote auth request and waits for another device to authenticate. + + Flow: + 1. Client connects + 2. Server creates a remote auth token and sends it with URL/expiry/pairing_code + 3. Server waits for another device to authenticate via /remote-auth/complete + 4. When auth completes, server sends session_token to this client + 5. Client can then use the session token to set a cookie + """ + origin = _validate_origin(ws) + host = origin.split("://", 1)[1] + + if remoteauth.instance is None: + raise ValueError("Remote authentication is not available") + + metadata = infodict(ws, "remote-auth-request") + + # Create the remote auth request + token, pairing_code, expiry = await remoteauth.instance.create_request( + host=host, + ip=metadata.get("ip") or "", + user_agent=metadata.get("user_agent") or "", + ) + + # Build the URL for the authenticating device + url = hostutil.auth_site_base_url() + f"remote/{token}" + + # Send the token, pairing code, and URL to the client + await ws.send_json( + { + "token": token, + "pairing_code": pairing_code, + "url": url, + "expires": expiry.isoformat().replace("+00:00", "Z"), + } + ) + + # Set up async notification + result_event = asyncio.Event() + result_data: dict = {} + + def on_complete( + session_token: str | None, + user_uuid: UUID | None, + credential_uuid: UUID | None, + ): + result_data["session_token"] = session_token + result_data["user_uuid"] = user_uuid + result_data["credential_uuid"] = credential_uuid + result_event.set() + + await remoteauth.instance.set_notify_callback(token, on_complete) + + try: + # Wait for either: + # 1. Authentication to complete (result_event set) + # 2. Client to disconnect + # 3. Client to send a cancel message + # 4. Timeout (handled by remoteauth cleanup) + + while True: + # Use asyncio.wait to handle both event and websocket + receive_task = asyncio.create_task(ws.receive_json()) + event_task = asyncio.create_task(result_event.wait()) + + done, pending = await asyncio.wait( + [receive_task, event_task], + return_when=asyncio.FIRST_COMPLETED, + ) + + # Cancel pending tasks + for task in pending: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + if event_task in done: + # Authentication completed (or expired/cancelled) + if result_data.get("session_token"): + await ws.send_json( + { + "status": "authenticated", + "user_uuid": str(result_data["user_uuid"]), + "session_token": result_data["session_token"], + } + ) + else: + await ws.send_json( + { + "status": "expired", + "detail": "Remote authentication request expired or was cancelled", + } + ) + break + + if receive_task in done: + # Client sent a message + msg = receive_task.result() + if msg.get("action") == "cancel": + await remoteauth.instance.cancel_request(token) + await ws.send_json({"status": "cancelled"}) + break + # Ignore other messages + + except WebSocketDisconnect: + # Client disconnected, cancel the request + await remoteauth.instance.cancel_request(token) + except Exception: + await remoteauth.instance.cancel_request(token) + raise + + +@app.websocket("/remote-auth/complete/{token}") +@websocket_error_handler +async def websocket_remote_auth_complete(ws: WebSocket, token: str): + """Complete a remote authentication request. + + This endpoint is called by the authenticating device (the one with the passkey). + It performs WebAuthn authentication and notifies the requesting device. + + Flow: + 1. Client opens the remote auth link and connects here + 2. Server verifies the token is valid + 3. Server sends WebAuthn options + 4. Client authenticates with passkey + 5. Server creates session for the REQUESTING device's host + 6. Server notifies the requesting device via the callback + 7. Server sends confirmation to this client + """ + origin = _validate_origin(ws) + + if remoteauth.instance is None: + raise ValueError("Remote authentication is not available") + + # Validate the remote auth token + request = await remoteauth.instance.get_request(token) + if request is None: + raise ValueError("This remote authentication link is invalid or has expired") + + if request.completed: + raise ValueError("This remote authentication has already been completed") + + # The session will be created for the requesting device's host, not this device's + target_host = request.host + + # Generate authentication options (no credential restriction for remote auth) + options, challenge = passkey.instance.auth_generate_options(credential_ids=None) + await ws.send_json({"optionsJSON": options}) + + # Wait for client authentication response + credential = passkey.instance.auth_parse(await ws.receive_json()) + + # Fetch and verify credential + try: + stored_cred = await db.instance.get_credential_by_id(credential.raw_id) + except ValueError: + raise ValueError( + f"This passkey is no longer registered with {passkey.instance.rp_name}" + ) + + # Verify the credential + passkey.instance.auth_verify(credential, challenge, stored_cred, origin) + + # Update credential last_used + await db.instance.login(stored_cred.user_uuid, stored_cred) + + # Create a session for the REQUESTING device + assert stored_cred.uuid is not None + session_token = await create_session( + user_uuid=stored_cred.user_uuid, + credential_uuid=stored_cred.uuid, + host=target_host, + ip=request.ip, + user_agent=request.user_agent, + ) + + # Complete the remote auth request (notifies the waiting device) + completed = await remoteauth.instance.complete_request( + token=token, + session_token=session_token, + user_uuid=stored_cred.user_uuid, + credential_uuid=stored_cred.uuid, + ) + + if not completed: + raise ValueError("Failed to complete remote authentication") + + # Send confirmation to the authenticating device + await ws.send_json( + { + "status": "success", + "message": "Authentication successful. The other device is now logged in.", + } + ) + + +@app.websocket("/remote-auth/pair/{code}") +@websocket_error_handler +async def websocket_remote_auth_pair(ws: WebSocket, code: str): + """Complete a remote authentication request using a pairing code. + + This endpoint is called from the user's profile on the authenticating device. + The user enters the pairing code displayed on the requesting device. + + Flow: + 1. User on Device B (with passkey) enters pairing code from Device A + 2. Server looks up the remote auth request by pairing code + 3. Server sends WebAuthn options + 4. User authenticates with passkey + 5. Server creates session for Device A's host with Device A's metadata + 6. Server notifies Device A via the callback + 7. Server sends confirmation to Device B + """ + origin = _validate_origin(ws) + + if remoteauth.instance is None: + raise ValueError("Remote authentication is not available") + + # Look up the remote auth request by pairing code + request = await remoteauth.instance.get_request_by_pairing_code(code) + if request is None: + raise ValueError("Invalid or expired pairing code") + + if request.completed: + raise ValueError("This remote authentication has already been completed") + + # The session will be created for the requesting device's host + target_host = request.host + + # Generate authentication options (no credential restriction for remote auth) + options, challenge = passkey.instance.auth_generate_options(credential_ids=None) + await ws.send_json({"optionsJSON": options}) + + # Wait for client authentication response + credential = passkey.instance.auth_parse(await ws.receive_json()) + + # Fetch and verify credential + try: + stored_cred = await db.instance.get_credential_by_id(credential.raw_id) + except ValueError: + raise ValueError( + f"This passkey is no longer registered with {passkey.instance.rp_name}" + ) + + # Verify the credential + passkey.instance.auth_verify(credential, challenge, stored_cred, origin) + + # Update credential last_used + await db.instance.login(stored_cred.user_uuid, stored_cred) + + # Create a session for the REQUESTING device (with their IP/user-agent) + assert stored_cred.uuid is not None + session_token = await create_session( + user_uuid=stored_cred.user_uuid, + credential_uuid=stored_cred.uuid, + host=target_host, + ip=request.ip, + user_agent=request.user_agent, + ) + + # Complete the remote auth request (notifies the waiting device) + completed = await remoteauth.instance.complete_request( + token=request.key, + session_token=session_token, + user_uuid=stored_cred.user_uuid, + credential_uuid=stored_cred.uuid, + ) + + if not completed: + raise ValueError("Failed to complete remote authentication") + + # Send confirmation to the authenticating device + await ws.send_json( + { + "status": "success", + "message": "Authentication successful. The other device is now logged in.", + } + ) diff --git a/paskia/remoteauth.py b/paskia/remoteauth.py new file mode 100644 index 0000000..7607083 --- /dev/null +++ b/paskia/remoteauth.py @@ -0,0 +1,284 @@ +""" +Cross-device (remote) authentication support. + +This module manages the flow for authenticating from another device: +1. Device A (requesting) creates a remote auth request and displays QR/link +2. Device B (authenticating) opens the link and authenticates with passkey +3. Device A receives the session via WebSocket notification + +Alternative flow (initiated from profile/authenticating device): +1. Device A (requesting) creates request and displays short pairing code +2. Device B (authenticating) enters the pairing code in their profile +3. Device B authenticates, Device A receives the session + +The requests are stored in-memory with short expiration (5 minutes). +""" + +import asyncio +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Callable +from uuid import UUID + +from paskia.util import passphrase + +# Remote auth requests expire after this duration +REMOTE_AUTH_LIFETIME = timedelta(minutes=5) + +# Number of words for the short pairing code (easier to communicate than alphanumeric) +PAIRING_CODE_WORDS = 3 + + +@dataclass +class RemoteAuthRequest: + """A pending remote authentication request.""" + + key: str # The passphrase token + pairing_code: str # Short alphanumeric code for manual entry + created_at: datetime + host: str # The host where the session should be created + ip: str # IP of the requesting device + user_agent: str # User agent of the requesting device + # Callback to notify the requesting device when auth completes + # Takes (session_token, user_uuid, credential_uuid) or (None, None, None) on cancel/expire + notify: Callable[[str | None, UUID | None, UUID | None], None] | None = None + # Set when authentication completes + completed: bool = False + session_token: str | None = None + user_uuid: UUID | None = None + credential_uuid: UUID | None = None + + +def _generate_pairing_code() -> str: + """Generate a short, easy-to-communicate pairing code using words.""" + return passphrase.generate(n=PAIRING_CODE_WORDS) + + +class RemoteAuthManager: + """Manages pending remote authentication requests.""" + + def __init__(self): + self._requests: dict[str, RemoteAuthRequest] = {} # keyed by passphrase token + self._by_pairing_code: dict[str, str] = {} # pairing_code -> token + self._cleanup_task: asyncio.Task | None = None + self._lock = asyncio.Lock() + + async def start(self): + """Start the cleanup background task.""" + if self._cleanup_task is None: + self._cleanup_task = asyncio.create_task(self._cleanup_loop()) + + async def stop(self): + """Stop the cleanup background task.""" + if self._cleanup_task: + self._cleanup_task.cancel() + try: + await self._cleanup_task + except asyncio.CancelledError: + pass + self._cleanup_task = None + + async def _cleanup_loop(self): + """Periodically clean up expired requests.""" + while True: + try: + await asyncio.sleep(60) # Check every minute + await self._cleanup_expired() + except asyncio.CancelledError: + break + except Exception: + logging.exception("Error in remote auth cleanup loop") + + async def _cleanup_expired(self): + """Remove expired requests and notify waiting clients.""" + now = datetime.now(timezone.utc) + expired_keys = [] + async with self._lock: + for key, req in self._requests.items(): + if now > req.created_at + REMOTE_AUTH_LIFETIME: + expired_keys.append(key) + for key in expired_keys: + req = self._requests.pop(key) + # Also remove from pairing code index + self._by_pairing_code.pop(req.pairing_code, None) + if req.notify and not req.completed: + try: + req.notify(None, None, None) + except Exception: + pass + + async def create_request( + self, + host: str, + ip: str, + user_agent: str, + ) -> tuple[str, str, datetime]: + """Create a new remote auth request. + + Returns: + (token, pairing_code, expiry) - The passphrase token, short pairing code, and expiration time + """ + token = passphrase.generate() + pairing_code = _generate_pairing_code() + now = datetime.now(timezone.utc) + expiry = now + REMOTE_AUTH_LIFETIME + + request = RemoteAuthRequest( + key=token, + pairing_code=pairing_code, + created_at=now, + host=host, + ip=ip, + user_agent=user_agent, + ) + + async with self._lock: + # Ensure pairing code is unique (regenerate if collision) + while pairing_code in self._by_pairing_code: + pairing_code = _generate_pairing_code() + request.pairing_code = pairing_code + self._requests[token] = request + self._by_pairing_code[pairing_code] = token + + return token, pairing_code, expiry + + async def get_request(self, token: str) -> RemoteAuthRequest | None: + """Get a pending request by token, if valid and not expired.""" + if not passphrase.is_well_formed(token): + return None + async with self._lock: + req = self._requests.get(token) + if req is None: + return None + now = datetime.now(timezone.utc) + if now > req.created_at + REMOTE_AUTH_LIFETIME: + # Expired + del self._requests[token] + self._by_pairing_code.pop(req.pairing_code, None) + return None + return req + + async def get_request_by_pairing_code(self, code: str) -> RemoteAuthRequest | None: + """Get a pending request by pairing code, if valid and not expired.""" + # Normalize: lowercase, dot-separated words + normalized = code.lower().strip().replace(" ", ".") + # Validate it's a well-formed short passphrase + if not passphrase.is_well_formed(normalized, n=PAIRING_CODE_WORDS): + return None + async with self._lock: + token = self._by_pairing_code.get(normalized) + if token is None: + return None + req = self._requests.get(token) + if req is None: + self._by_pairing_code.pop(normalized, None) + return None + now = datetime.now(timezone.utc) + if now > req.created_at + REMOTE_AUTH_LIFETIME: + # Expired + del self._requests[token] + self._by_pairing_code.pop(normalized, None) + return None + return req + + async def set_notify_callback( + self, + token: str, + callback: Callable[[str | None, UUID | None, UUID | None], None], + ) -> bool: + """Set the notification callback for a request. + + Returns True if the request exists and callback was set. + """ + async with self._lock: + req = self._requests.get(token) + if req is None: + return False + req.notify = callback + # If already completed, notify immediately + if req.completed: + try: + callback(req.session_token, req.user_uuid, req.credential_uuid) + except Exception: + pass + return True + + async def complete_request( + self, + token: str, + session_token: str, + user_uuid: UUID, + credential_uuid: UUID, + ) -> bool: + """Mark a request as completed with the authentication result. + + Returns True if the request existed and was completed. + """ + async with self._lock: + req = self._requests.get(token) + if req is None: + return False + if req.completed: + return False # Already completed + req.completed = True + req.session_token = session_token + req.user_uuid = user_uuid + req.credential_uuid = credential_uuid + if req.notify: + try: + req.notify(session_token, user_uuid, credential_uuid) + except Exception: + pass + return True + + async def cancel_request(self, token: str) -> bool: + """Cancel and remove a request. + + Returns True if the request existed and was removed. + """ + async with self._lock: + req = self._requests.pop(token, None) + if req is None: + return False + self._by_pairing_code.pop(req.pairing_code, None) + if req.notify and not req.completed: + try: + req.notify(None, None, None) + except Exception: + pass + return True + + async def consume_request(self, token: str) -> RemoteAuthRequest | None: + """Get and remove a request (for use by the authenticating device).""" + if not passphrase.is_well_formed(token): + return None + async with self._lock: + req = self._requests.get(token) + if req is None: + return None + now = datetime.now(timezone.utc) + if now > req.created_at + REMOTE_AUTH_LIFETIME: + del self._requests[token] + return None + # Don't remove yet - wait until completion + return req + + +# Global instance +instance: RemoteAuthManager | None = None + + +async def init(): + """Initialize the global remote auth manager.""" + global instance + instance = RemoteAuthManager() + await instance.start() + + +async def shutdown(): + """Shutdown the global remote auth manager.""" + global instance + if instance: + await instance.stop() + instance = None