diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 337db54..c698b6f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -20,12 +20,22 @@ import ResetView from '@/components/ResetView.vue' const store = useAuthStore() onMounted(async () => { - // Was an error message passed in the URL? + // Was an error message passed in the URL hash? const message = location.hash.substring(1) if (message) { store.showMessage(decodeURIComponent(message), 'error') history.replaceState(null, '', location.pathname) } + // Capture reset token from query parameter and then remove it + const params = new URLSearchParams(location.search) + const reset = params.get('reset') + if (reset) { + store.resetToken = reset + // Remove query param to avoid lingering in history / clipboard + const targetPath = '/auth/' + const currentPath = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/' + history.replaceState(null, '', currentPath.startsWith('/auth') ? '/auth/' : targetPath) + } try { await store.loadUserInfo() } catch (error) { diff --git a/frontend/src/components/ResetView.vue b/frontend/src/components/ResetView.vue index bc9c7b9..904bd37 100644 --- a/frontend/src/components/ResetView.vue +++ b/frontend/src/components/ResetView.vue @@ -27,12 +27,14 @@ async function register() { authStore.showMessage('Starting registration...', 'info') try { - const result = await passkey.register() - console.log("Result", result) - await authStore.setSessionCookie(result.session_token) - - authStore.showMessage('Passkey registered successfully!', 'success', 2000) - authStore.loadUserInfo().then(authStore.selectView) + const result = await passkey.register(authStore.resetToken) + console.log("Result", result) + await authStore.setSessionCookie(result.session_token) + // resetToken cleared by setSessionCookie; ensure again + authStore.resetToken = null + authStore.showMessage('Passkey registered successfully!', 'success', 2000) + await authStore.loadUserInfo() + authStore.selectView() } catch (error) { authStore.showMessage(`Registration failed: ${error.message}`, 'error') } finally { diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 68a5d93..64d619b 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -6,6 +6,7 @@ export const useAuthStore = defineStore('auth', { // Auth State userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated} isLoading: false, + resetToken: null, // transient reset token (never stored in cookie) // UI State currentView: 'login', // 'login', 'profile', 'device-link', 'reset' @@ -37,6 +38,9 @@ export const useAuthStore = defineStore('auth', { if (result.detail) { throw new Error(result.detail) } + // On successful session establishment, discard any reset token to avoid + // sending stale Authorization headers on subsequent API calls. + this.resetToken = null return result }, async register() { @@ -69,9 +73,25 @@ export const useAuthStore = defineStore('auth', { else this.currentView = 'reset' }, async loadUserInfo() { - const response = await fetch('/auth/user-info', {method: 'POST'}) - const result = await response.json() - if (result.detail) throw new Error(`Server: ${result.detail}`) + const headers = {} + // Reset tokens are only passed via query param now, not Authorization header + const url = this.resetToken ? `/auth/user-info?reset=${encodeURIComponent(this.resetToken)}` : '/auth/user-info' + const response = await fetch(url, { method: 'POST', headers }) + let result = null + try { + result = await response.json() + } catch (_) { + // ignore JSON parse errors (unlikely) + } + if (response.status === 401 && result?.detail) { + this.showMessage(result.detail, 'error', 5000) + throw new Error(result.detail) + } + if (result?.detail) { + // Other error style + this.showMessage(result.detail, 'error', 5000) + throw new Error(result.detail) + } this.userInfo = result console.log('User info loaded:', result) }, diff --git a/frontend/src/utils/passkey.js b/frontend/src/utils/passkey.js index dbccf08..37e32f9 100644 --- a/frontend/src/utils/passkey.js +++ b/frontend/src/utils/passkey.js @@ -1,8 +1,9 @@ import { startRegistration, startAuthentication } from '@simplewebauthn/browser' import aWebSocket from '@/utils/awaitable-websocket' -export async function register() { - const ws = await aWebSocket("/auth/ws/register") +export async function register(resetToken = null) { + const url = resetToken ? `/auth/ws/register?reset=${encodeURIComponent(resetToken)}` : "/auth/ws/register" + const ws = await aWebSocket(url) try { const optionsJSON = await ws.receive_json() const registrationResponse = await startRegistration({ optionsJSON }) diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 32dc92d..fe9f9ee 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -27,7 +27,9 @@ export default defineConfig(({ command, mode }) => ({ // and static assets so that HMR works. Bypass tells http-proxy to skip // proxying when we return a (possibly rewritten) local path. bypass(req) { - const url = req.url || '' + const rawUrl = req.url || '' + // Strip query/hash to match path-only for SPA entrypoints with query params (e.g. ?reset=token) + const url = rawUrl.split('?')[0].split('#')[0] // Bypass only root SPA entrypoints + static assets so Vite serves them for HMR. // Admin API endpoints (e.g., /auth/admin/orgs) must still hit backend. if (url === '/auth/' || url === '/auth') return '/' diff --git a/passkey/fastapi/api.py b/passkey/fastapi/api.py index 9de8c71..90593d5 100644 --- a/passkey/fastapi/api.py +++ b/passkey/fastapi/api.py @@ -52,47 +52,42 @@ def register_api_routes(app: FastAPI): } @app.post("/auth/user-info") - async def api_user_info(response: Response, auth=Cookie(None)): + async def api_user_info(reset: str | None = None, auth=Cookie(None)): """Get user information. - For authenticated sessions: return full context (org/role/permissions/credentials) - For reset tokens: return only basic user information to drive reset flow """ - reset = False + authenticated = False try: - if auth is None: - raise ValueError("Auth cookie missing") - reset = passphrase.is_well_formed(auth) - s = await (get_reset if reset else get_session)(auth) - except ValueError as e: if reset: - print(e, reset, auth, tokens.reset_key(auth).hex()) + if not passphrase.is_well_formed(reset): + raise ValueError("Invalid reset token") + s = await get_reset(reset) else: - print(e, reset, auth) - raise HTTPException( - status_code=401, - detail="Authentication Required", - headers={"WWW-Authenticate": "Bearer"}, - ) + if auth is None: + raise ValueError("Authentication Required") + s = await get_session(auth) + authenticated = True + except ValueError as e: + raise HTTPException(401, str(e)) + + u = await db.instance.get_user_by_uuid(s.user_uuid) # Minimal response for reset tokens - if reset: - u = await db.instance.get_user_by_uuid(s.user_uuid) + if not authenticated: return { "authenticated": False, "session_type": s.info.get("type"), "user": { "user_uuid": str(u.uuid), "user_name": u.display_name, - "created_at": u.created_at.isoformat() if u.created_at else None, - "last_seen": u.last_seen.isoformat() if u.last_seen else None, - "visits": u.visits, }, } # Full context for authenticated sessions + assert authenticated and auth is not None ctx = await db.instance.get_session_context(session_key(auth)) - u = await db.instance.get_user_by_uuid(s.user_uuid) credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid) credentials: list[dict] = [] @@ -224,25 +219,22 @@ def register_api_routes(app: FastAPI): async def admin_update_org( org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) ): - ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth) - if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)): - raise ValueError("Insufficient permissions") - from ..db import Org as OrgDC + # Only global admins can modify org definitions (simpler rule) + _, is_global_admin, _ = await _get_ctx_and_admin_flags(auth) + if not is_global_admin: + raise ValueError("Global admin required") + from ..db import Org as OrgDC # local import to avoid cycles current = await db.instance.get_organization(str(org_uuid)) display_name = payload.get("display_name") or current.display_name - permissions = ( - payload.get("permissions") - if "permissions" in payload - else current.permissions - ) or [] + permissions = payload.get("permissions") or current.permissions or [] org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) await db.instance.update_organization(org) return {"status": "ok"} @app.delete("/auth/admin/orgs/{org_uuid}") async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)): - ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth) + ctx, is_global_admin, _ = await _get_ctx_and_admin_flags(auth) if not is_global_admin: # Org admins cannot delete at all (avoid self-lockout) raise ValueError("Global admin required") @@ -372,7 +364,6 @@ def register_api_routes(app: FastAPI): role_uuid=role_obj.uuid, visits=0, created_at=None, - last_seen=None, ) await db.instance.create_user(user) return {"uuid": str(user_uuid)} @@ -582,10 +573,8 @@ def register_api_routes(app: FastAPI): @app.post("/auth/set-session") async def api_set_session(response: Response, auth=Depends(bearer_auth)): - """Set session cookie from Authorization header. Fetched after login by WebSocket.""" + """Set session cookie from Authorization Bearer session token (never via query).""" user = await get_session(auth.credentials) - if not user: - raise ValueError("Invalid Authorization header.") session.set_session_cookie(response, auth.credentials) return { diff --git a/passkey/fastapi/reset.py b/passkey/fastapi/reset.py index 44ae662..e938775 100644 --- a/passkey/fastapi/reset.py +++ b/passkey/fastapi/reset.py @@ -1,4 +1,4 @@ -import logging +from pathlib import Path from fastapi import Cookie, HTTPException, Request, Response from fastapi.responses import RedirectResponse @@ -9,6 +9,9 @@ from ..globals import passkey as global_passkey from ..util import passphrase, tokens from . import session +# Local copy to avoid circular import with mainapp +STATIC_DIR = Path(__file__).parent.parent / "frontend-build" + def register_reset_routes(app): """Register all device addition/reset routes on the FastAPI app.""" @@ -28,9 +31,10 @@ def register_reset_routes(app): info=session.infodict(request, "device addition"), ) - # Generate the device addition link with pretty URL - path = request.url.path.removesuffix("create-link") + token - url = f"{request.headers['origin']}{path}" + # Generate the device addition link with pretty URL using configured origin + origin = global_passkey.instance.origin.rstrip("/") + path = request.url.path.removesuffix("create-link") + token # /auth/ + url = f"{origin}{path}" return { "message": "Registration link generated successfully", @@ -39,31 +43,17 @@ def register_reset_routes(app): } @app.get("/auth/{reset_token}") - async def reset_authentication( - request: Request, - reset_token: str, - ): - """Verifies the token and redirects to auth app for credential registration.""" + async def reset_authentication(request: Request, reset_token: str): + """Validate reset token and redirect with it as query parameter (no cookies). + + After validation we 303 redirect to /auth/?reset=. The frontend will: + - Read the token from location.search + - Use it via Authorization header or websocket query param + - history.replaceState to remove it from the address bar/history + """ if not passphrase.is_well_formed(reset_token): raise HTTPException(status_code=404) origin = global_passkey.instance.origin - try: - # Get session token to validate it exists and get user_id - key = tokens.reset_key(reset_token) - sess = await db.instance.get_session(key) - if not sess: - raise ValueError("Invalid or expired registration token") - - response = RedirectResponse(url=f"{origin}/auth/", status_code=303) - session.set_session_cookie(response, reset_token) - print(response.headers) - return response - - except Exception as e: - # On any error, redirect to auth app - if isinstance(e, ValueError): - msg = str(e) - else: - logging.exception("Internal Server Error in reset_authentication") - msg = "Internal Server Error" - return RedirectResponse(url=f"{origin}/auth/#{msg}", status_code=303) + # Do not verify existence/expiry here; frontend + user-info endpoint will handle invalid tokens. + redirect_url = f"{origin}/auth/?reset={reset_token}" + return RedirectResponse(url=redirect_url, status_code=303) diff --git a/passkey/fastapi/ws.py b/passkey/fastapi/ws.py index d331650..49325b4 100644 --- a/passkey/fastapi/ws.py +++ b/passkey/fastapi/ws.py @@ -54,12 +54,26 @@ async def register_chat( @app.websocket("/register") @websocket_error_handler -async def websocket_register_add(ws: WebSocket, auth=Cookie(None)): - """Register a new credential for an existing user.""" +async def websocket_register_add( + ws: WebSocket, reset: str | None = None, auth=Cookie(None) +): + """Register a new credential for an existing user. + + Supports either: + - Normal session via auth cookie + - Reset token supplied as ?reset=... (auth cookie ignored) + """ origin = ws.headers["origin"] - # Try to get either a regular session or a reset session - reset = passphrase.is_well_formed(auth) - s = await (get_reset if reset else get_session)(auth) + is_reset = False + if reset is not None: + if not passphrase.is_well_formed(reset): + raise ValueError("Invalid reset token") + is_reset = True + s = await get_reset(reset) + else: + if not auth: + raise ValueError("Authentication Required") + s = await get_session(auth) user_uuid = s.user_uuid # Get user information to get the user_name @@ -73,23 +87,19 @@ async def websocket_register_add(ws: WebSocket, auth=Cookie(None)): # to satisfy the sessions.credential_uuid foreign key (now enforced). await db.instance.create_credential(credential) - if reset: + if is_reset: # Invalidate the one-time reset session only after credential persisted await db.instance.delete_session(s.key) - token = await create_session( + auth = await create_session( user_uuid, credential.uuid, infodict(ws, "authenticated") ) - else: - # Existing session continues; we don't need to create a new one here. - token = auth - - assert isinstance(token, str) and len(token) == 16 + assert isinstance(auth, str) and len(auth) == 16 await ws.send_json( { "user_uuid": str(user.uuid), "credential_uuid": str(credential.uuid), - "session_token": token, + "session_token": auth, "message": "New credential added successfully", } )