diff --git a/frontend/src/admin/AdminApp.vue b/frontend/src/admin/AdminApp.vue index e65b392..591cf7d 100644 --- a/frontend/src/admin/AdminApp.vue +++ b/frontend/src/admin/AdminApp.vue @@ -167,7 +167,7 @@ async function load() { loading.value = true error.value = null try { - const res = await fetch('/auth/user-info', { method: 'POST' }) + const res = await fetch('/auth/api/user-info', { method: 'POST' }) const data = await res.json() if (data.detail) throw new Error(data.detail) info.value = data diff --git a/frontend/src/components/DeviceLinkView.vue b/frontend/src/components/DeviceLinkView.vue index 5a1a73b..7655dfa 100644 --- a/frontend/src/components/DeviceLinkView.vue +++ b/frontend/src/components/DeviceLinkView.vue @@ -46,7 +46,7 @@ const copyLink = async (event) => { onMounted(async () => { try { - const response = await fetch('/auth/create-link', { method: 'POST' }) + const response = await fetch('/auth/api/create-link', { method: 'POST' }) const result = await response.json() if (result.detail) throw new Error(result.detail) diff --git a/frontend/src/components/ProfileView.vue b/frontend/src/components/ProfileView.vue index 3e894c4..a128e57 100644 --- a/frontend/src/components/ProfileView.vue +++ b/frontend/src/components/ProfileView.vue @@ -9,7 +9,7 @@ :created-at="authStore.userInfo.user.created_at" :last-seen="authStore.userInfo.user.last_seen" :loading="authStore.isLoading" - update-endpoint="/auth/user/display-name" + update-endpoint="/auth/api/user/display-name" @saved="authStore.loadUserInfo()" /> diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 614660d..c32b086 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -32,7 +32,7 @@ export const useAuthStore = defineStore('auth', { } }, async setSessionCookie(sessionToken) { - const response = await fetch('/auth/set-session', { + const response = await fetch('/auth/api/set-session', { method: 'POST', headers: {'Authorization': `Bearer ${sessionToken}`}, }) @@ -87,7 +87,7 @@ export const useAuthStore = defineStore('auth', { async loadUserInfo() { 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 url = this.resetToken ? `/auth/api/user-info?reset=${encodeURIComponent(this.resetToken)}` : '/auth/api/user-info' const response = await fetch(url, { method: 'POST', headers }) let result = null try { @@ -109,7 +109,7 @@ export const useAuthStore = defineStore('auth', { }, async loadSettings() { try { - const res = await fetch('/auth/settings') + const res = await fetch('/auth/api/settings') if (!res.ok) return const data = await res.json() this.settings = data @@ -121,7 +121,7 @@ export const useAuthStore = defineStore('auth', { } }, async deleteCredential(uuid) { - const response = await fetch(`/auth/credential/${uuid}`, {method: 'Delete'}) + const response = await fetch(`/auth/api/credential/${uuid}`, {method: 'Delete'}) const result = await response.json() if (result.detail) throw new Error(`Server: ${result.detail}`) @@ -129,7 +129,7 @@ export const useAuthStore = defineStore('auth', { }, async logout() { try { - await fetch('/auth/logout', {method: 'POST'}) + await fetch('/auth/api/logout', {method: 'POST'}) } catch (error) { console.error('Logout error:', error) } diff --git a/passkey/fastapi/api.py b/passkey/fastapi/api.py index 3abc038..009bd11 100644 --- a/passkey/fastapi/api.py +++ b/passkey/fastapi/api.py @@ -1,180 +1,193 @@ -"""Core (non-admin) HTTP API routes. - -Contains endpoints for: -* Session validation -* Basic runtime settings -* Authenticated user info & credential listing -* User self-service updates (display name) -* Logout / set-session / credential deletion - -Admin endpoints have been moved to `adminapp.py` and mounted at -`/auth/admin` by the main application. -""" - +from contextlib import suppress from uuid import UUID -from fastapi import Body, Cookie, Depends, FastAPI, HTTPException, Query, Response +from fastapi import ( + Body, + Cookie, + Depends, + FastAPI, + HTTPException, + Query, + Request, + Response, +) from fastapi.security import HTTPBearer from .. import aaguid -from ..authsession import delete_credential, get_reset, get_session +from ..authsession import delete_credential, expires, get_reset, get_session from ..globals import db from ..globals import passkey as global_passkey -from ..util import tokens +from ..util import passphrase, tokens from ..util.tokens import session_key from . import authz, session bearer_auth = HTTPBearer(auto_error=True) +app = FastAPI() -def register_api_routes(app: FastAPI): - """Register non-admin API routes on the FastAPI app.""" - @app.post("/auth/validate") - async def validate_token(perm=Query(None), auth=Cookie(None)): - s = await authz.verify(auth, perm) - return {"valid": True, "user_uuid": str(s.user_uuid)} +@app.post("/validate") +async def validate_token(perm=Query(None), auth=Cookie(None)): + s = await authz.verify(auth, perm) + return {"valid": True, "user_uuid": str(s.user_uuid)} - @app.get("/auth/settings") - async def get_settings(): - pk = global_passkey.instance - return {"rp_id": pk.rp_id, "rp_name": pk.rp_name} - @app.post("/auth/user-info") - async def api_user_info(reset: str | None = None, auth=Cookie(None)): - authenticated = False - try: - if reset: - if not tokens.passphrase.is_well_formed(reset): # type: ignore[attr-defined] - raise ValueError("Invalid reset token") - s = await get_reset(reset) - else: - if auth is None: - raise ValueError("Authentication Required") - s = await get_session(auth) - authenticated = True - except ValueError as e: - raise HTTPException(401, str(e)) +@app.get("/settings") +async def get_settings(): + pk = global_passkey.instance + return {"rp_id": pk.rp_id, "rp_name": pk.rp_name} - u = await db.instance.get_user_by_uuid(s.user_uuid) - if not authenticated: # minimal response for reset tokens - return { - "authenticated": False, - "session_type": s.info.get("type"), - "user": {"user_uuid": str(u.uuid), "user_name": u.display_name}, - } +@app.post("/user-info") +async def api_user_info(reset: str | None = None, auth=Cookie(None)): + authenticated = False + try: + if reset: + if not passphrase.is_well_formed(reset): + raise ValueError("Invalid reset token") + s = await get_reset(reset) + else: + if auth is None: + raise ValueError("Authentication Required") + s = await get_session(auth) + authenticated = True + except ValueError as e: + raise HTTPException(401, str(e)) - assert authenticated and auth is not None - ctx = await db.instance.get_session_context(session_key(auth)) - credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid) - credentials: list[dict] = [] - user_aaguids: set[str] = set() - for cred_id in credential_ids: - try: - c = await db.instance.get_credential_by_id(cred_id) - except ValueError: - continue - aaguid_str = str(c.aaguid) - user_aaguids.add(aaguid_str) - credentials.append( - { - "credential_uuid": str(c.uuid), - "aaguid": aaguid_str, - "created_at": c.created_at.isoformat(), - "last_used": c.last_used.isoformat() if c.last_used else None, - "last_verified": c.last_verified.isoformat() - if c.last_verified - else None, - "sign_count": c.sign_count, - "is_current_session": s.credential_uuid == c.uuid, - } - ) - credentials.sort(key=lambda cred: cred["created_at"]) - aaguid_info = aaguid.filter(user_aaguids) - - role_info = None - org_info = None - effective_permissions: list[str] = [] - is_global_admin = False - is_org_admin = False - if ctx: - role_info = { - "uuid": str(ctx.role.uuid), - "display_name": ctx.role.display_name, - "permissions": ctx.role.permissions, - } - org_info = { - "uuid": str(ctx.org.uuid), - "display_name": ctx.org.display_name, - "permissions": ctx.org.permissions, - } - effective_permissions = [p.id for p in (ctx.permissions or [])] - is_global_admin = "auth:admin" in (role_info["permissions"] or []) - if org_info: - is_org_admin = f"auth:org:{org_info['uuid']}" in ( - role_info["permissions"] or [] - ) + u = await db.instance.get_user_by_uuid(s.user_uuid) + if not authenticated: # minimal response for reset tokens return { - "authenticated": True, + "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, - }, - "org": org_info, - "role": role_info, - "permissions": effective_permissions, - "is_global_admin": is_global_admin, - "is_org_admin": is_org_admin, - "credentials": credentials, - "aaguid_info": aaguid_info, + "user": {"user_uuid": str(u.uuid), "user_name": u.display_name}, } - @app.put("/auth/user/display-name") - async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)): - if not auth: - raise HTTPException(status_code=401, detail="Authentication Required") - s = await get_session(auth) - new_name = (payload.get("display_name") or "").strip() - if not new_name: - raise HTTPException(status_code=400, detail="display_name required") - if len(new_name) > 64: - raise HTTPException(status_code=400, detail="display_name too long") - await db.instance.update_user_display_name(s.user_uuid, new_name) - return {"status": "ok"} - - @app.post("/auth/logout") - async def api_logout(response: Response, auth=Cookie(None)): - """Log out the current user by clearing the session cookie and deleting from database.""" - if not auth: - return {"message": "Already logged out"} - # Remove from database if possible + assert authenticated and auth is not None + ctx = await db.instance.get_session_context(session_key(auth)) + credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid) + credentials: list[dict] = [] + user_aaguids: set[str] = set() + for cred_id in credential_ids: try: - await db.instance.delete_session(session_key(auth)) - except Exception: - ... - response.delete_cookie("auth") - return {"message": "Logged out successfully"} + c = await db.instance.get_credential_by_id(cred_id) + except ValueError: + continue + aaguid_str = str(c.aaguid) + user_aaguids.add(aaguid_str) + credentials.append( + { + "credential_uuid": str(c.uuid), + "aaguid": aaguid_str, + "created_at": c.created_at.isoformat(), + "last_used": c.last_used.isoformat() if c.last_used else None, + "last_verified": c.last_verified.isoformat() + if c.last_verified + else None, + "sign_count": c.sign_count, + "is_current_session": s.credential_uuid == c.uuid, + } + ) + credentials.sort(key=lambda cred: cred["created_at"]) + aaguid_info = aaguid.filter(user_aaguids) - @app.post("/auth/set-session") - async def api_set_session(response: Response, auth=Depends(bearer_auth)): - """Set session cookie from Authorization Bearer session token (never via query).""" - user = await get_session(auth.credentials) - session.set_session_cookie(response, auth.credentials) - - return { - "message": "Session cookie set successfully", - "user_uuid": str(user.user_uuid), + role_info = None + org_info = None + effective_permissions: list[str] = [] + is_global_admin = False + is_org_admin = False + if ctx: + role_info = { + "uuid": str(ctx.role.uuid), + "display_name": ctx.role.display_name, + "permissions": ctx.role.permissions, } + org_info = { + "uuid": str(ctx.org.uuid), + "display_name": ctx.org.display_name, + "permissions": ctx.org.permissions, + } + effective_permissions = [p.id for p in (ctx.permissions or [])] + is_global_admin = "auth:admin" in (role_info["permissions"] or []) + if org_info: + is_org_admin = f"auth:org:{org_info['uuid']}" in ( + role_info["permissions"] or [] + ) - @app.delete("/auth/credential/{uuid}") - async def api_delete_credential( - response: Response, uuid: UUID, auth: str = Cookie(None) - ): - await delete_credential(uuid, auth) - return {"message": "Credential deleted successfully"} + return { + "authenticated": True, + "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, + }, + "org": org_info, + "role": role_info, + "permissions": effective_permissions, + "is_global_admin": is_global_admin, + "is_org_admin": is_org_admin, + "credentials": credentials, + "aaguid_info": aaguid_info, + } + + +@app.put("/user/display-name") +async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)): + if not auth: + raise HTTPException(status_code=401, detail="Authentication Required") + s = await get_session(auth) + new_name = (payload.get("display_name") or "").strip() + if not new_name: + raise HTTPException(status_code=400, detail="display_name required") + if len(new_name) > 64: + raise HTTPException(status_code=400, detail="display_name too long") + await db.instance.update_user_display_name(s.user_uuid, new_name) + return {"status": "ok"} + + +@app.post("/logout") +async def api_logout(response: Response, auth=Cookie(None)): + if not auth: + return {"message": "Already logged out"} + with suppress(Exception): + await db.instance.delete_session(session_key(auth)) + response.delete_cookie("auth") + return {"message": "Logged out successfully"} + + +@app.post("/set-session") +async def api_set_session(response: Response, auth=Depends(bearer_auth)): + user = await get_session(auth.credentials) + session.set_session_cookie(response, auth.credentials) + return { + "message": "Session cookie set successfully", + "user_uuid": str(user.user_uuid), + } + + +@app.delete("/credential/{uuid}") +async def api_delete_credential(uuid: UUID, auth: str = Cookie(None)): + await delete_credential(uuid, auth) + return {"message": "Credential deleted successfully"} + + +@app.post("/create-link") +async def api_create_link(request: Request, auth=Cookie(None)): + s = await get_session(auth) + token = passphrase.generate() + await db.instance.create_session( + user_uuid=s.user_uuid, + key=tokens.reset_key(token), + expires=expires(), + info=session.infodict(request, "device addition"), + ) + origin = global_passkey.instance.origin.rstrip("/") + url = f"{origin}/auth/{token}" + return { + "message": "Registration link generated successfully", + "url": url, + "expires": expires().isoformat(), + } diff --git a/passkey/fastapi/mainapp.py b/passkey/fastapi/mainapp.py index b120ac9..fb05226 100644 --- a/passkey/fastapi/mainapp.py +++ b/passkey/fastapi/mainapp.py @@ -4,12 +4,13 @@ from contextlib import asynccontextmanager from pathlib import Path from fastapi import Cookie, FastAPI, HTTPException, Query, Request, Response -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles -from . import admin, authz, ws -from .api import register_api_routes -from .reset import register_reset_routes +from passkey.util import passphrase + +from ..globals import passkey as global_passkey +from . import admin, api, authz, ws STATIC_DIR = Path(__file__).parent.parent / "frontend-build" @@ -51,6 +52,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path app = FastAPI(lifespan=lifespan) app.mount("/auth/ws", ws.app) app.mount("/auth/admin", admin.app) +app.mount("/auth/api", api.app) # Global exception handlers @@ -67,6 +69,35 @@ async def general_exception_handler(request: Request, exc: Exception): return JSONResponse(status_code=500, content={"detail": "Internal server error"}) +# Serve static files +app.mount( + "/auth/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="static assets" +) + + +@app.get("/auth/") +async def redirect_to_index(): + """Serve the main authentication app.""" + return FileResponse(STATIC_DIR / "index.html") + + +@app.get("/auth/{reset_token}") +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 + # Do not verify existence/expiry here; frontend + user-info endpoint will handle invalid tokens. + url = f"{origin}/auth/?reset={reset_token}" + return RedirectResponse(url=url, status_code=303) + + @app.get("/auth/forward-auth") async def forward_authentication(request: Request, perm=Query(None), auth=Cookie(None)): """A validation endpoint to use with Caddy forward_auth or Nginx auth_request. @@ -86,20 +117,3 @@ async def forward_authentication(request: Request, perm=Query(None), auth=Cookie ) except HTTPException as e: return FileResponse(STATIC_DIR / "index.html", e.status_code) - - -# Serve static files -app.mount( - "/auth/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="static assets" -) - - -@app.get("/auth/") -async def redirect_to_index(): - """Serve the main authentication app.""" - return FileResponse(STATIC_DIR / "index.html") - - -# Register API routes -register_api_routes(app) -register_reset_routes(app) diff --git a/passkey/fastapi/reset.py b/passkey/fastapi/reset.py deleted file mode 100644 index e938775..0000000 --- a/passkey/fastapi/reset.py +++ /dev/null @@ -1,59 +0,0 @@ -from pathlib import Path - -from fastapi import Cookie, HTTPException, Request, Response -from fastapi.responses import RedirectResponse - -from ..authsession import expires, get_session -from ..globals import db -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.""" - - @app.post("/auth/create-link") - async def api_create_link(request: Request, response: Response, auth=Cookie(None)): - """Create a device addition link for the authenticated user.""" - # Require authentication - s = await get_session(auth) - - # Generate a human-readable token - token = passphrase.generate() # e.g., "cross.rotate.yin.note.evoke" - await db.instance.create_session( - user_uuid=s.user_uuid, - key=tokens.reset_key(token), - expires=expires(), - info=session.infodict(request, "device addition"), - ) - - # 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", - "url": url, - "expires": expires().isoformat(), - } - - @app.get("/auth/{reset_token}") - 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 - # 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)