From a05d4aec8113aad3449183fa3319c6523136f0de Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 2 Dec 2025 16:37:27 +0000 Subject: [PATCH] Better error messages from backend, avoid bad toasts, cleanup of session validation. --- frontend/src/admin/AdminApp.vue | 6 +-- frontend/src/components/RestrictedAuth.vue | 2 +- .../src/restricted-api/RestrictedApiApp.vue | 10 ----- frontend/src/stores/auth.js | 2 +- frontend/src/utils/awaitable-websocket.js | 2 +- passkey/authsession.py | 38 +++++++------------ passkey/db/sql.py | 2 +- passkey/fastapi/ws.py | 15 ++++++-- 8 files changed, 33 insertions(+), 44 deletions(-) diff --git a/frontend/src/admin/AdminApp.vue b/frontend/src/admin/AdminApp.vue index 8c2260b..23d3126 100644 --- a/frontend/src/admin/AdminApp.vue +++ b/frontend/src/admin/AdminApp.vue @@ -574,10 +574,10 @@ async function submitDialog() {
-
diff --git a/frontend/src/components/RestrictedAuth.vue b/frontend/src/components/RestrictedAuth.vue index 530548e..e32430b 100644 --- a/frontend/src/components/RestrictedAuth.vue +++ b/frontend/src/components/RestrictedAuth.vue @@ -135,7 +135,7 @@ async function authenticateUser() { loading.value = false const message = error?.message || 'Passkey authentication cancelled' const cancelled = message === 'Passkey authentication cancelled' - showMessage(cancelled ? message : `Authentication failed: ${message}`, cancelled ? 'info' : 'error', 4000) + showMessage(message, cancelled ? 'info' : 'error', 4000) emit('auth-error', { message, cancelled }) return } diff --git a/frontend/src/restricted-api/RestrictedApiApp.vue b/frontend/src/restricted-api/RestrictedApiApp.vue index 6edc4ae..0d1c9cc 100644 --- a/frontend/src/restricted-api/RestrictedApiApp.vue +++ b/frontend/src/restricted-api/RestrictedApiApp.vue @@ -4,7 +4,6 @@ @authenticated="handleAuthenticated" @forbidden="handleForbidden" @logout="handleLogout" - @auth-error="handleAuthError" @back="handleBack" /> @@ -51,15 +50,6 @@ function handleLogout() { }) } -function handleAuthError({ message, cancelled }) { - // Notify parent that authentication failed or was cancelled - postToParent({ - type: 'auth-error', - message: message || 'Authentication failed', - cancelled - }) -} - function handleBack() { console.log('[RestrictedApiApp] Back clicked') // Notify parent that user wants to go back diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 0fbf693..1df01fd 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -112,7 +112,7 @@ export const useAuthStore = defineStore('auth', { throw new Error('Authentication required') } const result = await response.json() - if (result.detail) throw new Error(`Server: ${result.detail}`) + if (result.detail) throw new Error(result.detail) await this.loadUserInfo() }, diff --git a/frontend/src/utils/awaitable-websocket.js b/frontend/src/utils/awaitable-websocket.js index 9ca5f4f..6f050b8 100644 --- a/frontend/src/utils/awaitable-websocket.js +++ b/frontend/src/utils/awaitable-websocket.js @@ -59,7 +59,7 @@ class AwaitableWebSocket extends WebSocket { throw new Error("Failed to parse JSON from WebSocket message") } if (parsed.detail) { - throw new Error(`Server: ${parsed.detail}`) + throw new Error(parsed.detail) } return parsed } diff --git a/passkey/authsession.py b/passkey/authsession.py index c505f4e..17dd2f5 100644 --- a/passkey/authsession.py +++ b/passkey/authsession.py @@ -69,36 +69,26 @@ async def create_session( async def get_reset(token: str) -> ResetToken: """Validate a credential reset token. Returns None if the token is not well formed (i.e. it is another type of token).""" record = await db.instance.get_reset_token(reset_key(token)) - if not record: - raise ValueError("Invalid or expired session token") - if record.expiry < datetime.now(timezone.utc): - await db.instance.delete_reset_token(record.key) - raise ValueError("Invalid or expired session token") - return record + if record and record.expiry >= datetime.now(timezone.utc): + return record + raise ValueError("This reset link is invalid or has expired") async def get_session(token: str, host: str | None = None) -> Session: """Validate a session token and return session data if valid.""" + host = hostutil.normalize_host(host) + if not host: + raise ValueError("Invalid host") session = await db.instance.get_session(session_key(token)) - if not session: - raise ValueError("Invalid or expired session token") - if session_expiry(session) < datetime.now(timezone.utc): - await db.instance.delete_session(session.key) - raise ValueError("Invalid or expired session token") - if host is not None: - normalized_host = hostutil.normalize_host(host) - if not normalized_host: - raise ValueError("Invalid host") - current = session.host - if current is None: + if session and session_expiry(session) >= datetime.now(timezone.utc): + if session.host is None: # First time binding: store exact host:port (or IPv6 form) now. - await db.instance.set_session_host(session.key, normalized_host) - session.host = normalized_host - elif current == normalized_host: - pass # exact match ok - else: - raise ValueError("Invalid or expired session token") - return session + await db.instance.set_session_host(session.key, host) + session.host = host + elif session.host != host: + raise ValueError("Session host mismatch") + return session + raise ValueError("Your session has expired. Please sign in again!") async def refresh_session_token(token: str, *, ip: str, user_agent: str): diff --git a/passkey/db/sql.py b/passkey/db/sql.py index 57ef29b..f2b9cc0 100644 --- a/passkey/db/sql.py +++ b/passkey/db/sql.py @@ -439,7 +439,7 @@ class DB(DatabaseInterface): credential_model = result.scalar_one_or_none() if not credential_model: - raise ValueError("Credential not registered") + raise ValueError("This passkey is not registered with this service") return Credential( uuid=UUID(bytes=credential_model.uuid), credential_id=credential_model.credential_id, diff --git a/passkey/fastapi/ws.py b/passkey/fastapi/ws.py index ebd0871..5ade9d4 100644 --- a/passkey/fastapi/ws.py +++ b/passkey/fastapi/ws.py @@ -71,11 +71,15 @@ async def websocket_register_add( host = origin.split("://", 1)[1] if reset is not None: if not passphrase.is_well_formed(reset): - raise ValueError("Invalid reset token") + raise ValueError( + f"The reset link for {passkey.instance.rp_name} is invalid or has expired" + ) s = await get_reset(reset) else: if not auth: - raise ValueError("Authentication Required") + raise ValueError( + f"You must be signed in to {passkey.instance.rp_name} to add a new passkey" + ) s = await get_session(auth, host=host) user_uuid = s.user_uuid @@ -127,7 +131,12 @@ async def websocket_authenticate(ws: WebSocket): # Wait for the client to use his authenticator to authenticate credential = passkey.instance.auth_parse(await ws.receive_json()) # Fetch from the database by credential ID - stored_cred = await db.instance.get_credential_by_id(credential.raw_id) + 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 matches the stored data passkey.instance.auth_verify(credential, challenge, stored_cred, origin=origin) # Update both credential and user's last_seen timestamp