A lot of cleanup, restructuring project directory.
This commit is contained in:
		
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -5,4 +5,4 @@ dist/ | |||||||
| *.lock | *.lock | ||||||
| *.db | *.db | ||||||
| server-secret.bin | server-secret.bin | ||||||
| /passkeyauth/frontend-static | /passkey/frontend-build | ||||||
|   | |||||||
							
								
								
									
										23
									
								
								Caddyfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								Caddyfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | |||||||
|  | (auth) { | ||||||
|  | 	# Forward /auth to the authentication service | ||||||
|  | 	redir /auth /auth/ 302 | ||||||
|  | 	@auth path /auth/* | ||||||
|  | 	handle @auth { | ||||||
|  | 		reverse_proxy localhost:4401 | ||||||
|  | 	} | ||||||
|  | 	handle { | ||||||
|  | 		# Check for authentication | ||||||
|  | 		forward_auth localhost:4401 { | ||||||
|  | 			uri /auth/forward-auth | ||||||
|  | 			copy_headers x-auth-user-id | ||||||
|  | 		} | ||||||
|  | 		{block} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | localhost { | ||||||
|  | 	import auth { | ||||||
|  | 		# Proxy authenticated requests to the main application | ||||||
|  | 		reverse_proxy localhost:3000 | ||||||
|  | 	} | ||||||
|  | } | ||||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 4.2 KiB | 
| @@ -13,19 +13,19 @@ export default defineConfig(({ command, mode }) => ({ | |||||||
|       '@': fileURLToPath(new URL('./src', import.meta.url)) |       '@': fileURLToPath(new URL('./src', import.meta.url)) | ||||||
|     }, |     }, | ||||||
|   }, |   }, | ||||||
|   base: command == 'build' ? '/auth/' : '/', |   base: command === 'build' ? '/auth/' : '/', | ||||||
|   server: { |   server: { | ||||||
|     port: 3000, |     port: 4403, | ||||||
|     proxy: { |     proxy: { | ||||||
|       '/auth/': { |       '/auth/': { | ||||||
|         target: 'http://localhost:8000', |         target: 'http://localhost:4401', | ||||||
|         ws: true, |         ws: true, | ||||||
|         changeOrigin: false |         changeOrigin: false | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   build: { |   build: { | ||||||
|     outDir: '../passkeyauth/frontend-static', |     outDir: '../passkey/frontend-build', | ||||||
|     emptyOutDir: true, |     emptyOutDir: true, | ||||||
|     assetsDir: 'assets' |     assetsDir: 'assets' | ||||||
|   } |   } | ||||||
|   | |||||||
							
								
								
									
										3
									
								
								passkey/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								passkey/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | from .sansio import Passkey | ||||||
|  |  | ||||||
|  | __all__ = ["Passkey"] | ||||||
							
								
								
									
										32
									
								
								passkey/aaguid/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								passkey/aaguid/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | |||||||
|  | """ | ||||||
|  | AAGUID (Authenticator Attestation GUID) management for WebAuthn credentials. | ||||||
|  |  | ||||||
|  | This module provides functionality to: | ||||||
|  | - Load AAGUID data from JSON file | ||||||
|  | - Look up authenticator information by AAGUID | ||||||
|  | - Return only relevant AAGUID data for user credentials | ||||||
|  | """ | ||||||
|  |  | ||||||
|  | import json | ||||||
|  | from collections.abc import Iterable | ||||||
|  | from importlib.resources import files | ||||||
|  |  | ||||||
|  | __ALL__ = ["AAGUID", "filter"] | ||||||
|  |  | ||||||
|  | # Path to the AAGUID JSON file | ||||||
|  | AAGUID_FILE = files("passkey") / "aaguid" / "combined_aaguid.json" | ||||||
|  | AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8")) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def filter(aaguids: Iterable[str]) -> dict[str, dict]: | ||||||
|  |     """ | ||||||
|  |     Get AAGUID information only for the provided set of AAGUIDs. | ||||||
|  |  | ||||||
|  |     Args: | ||||||
|  |         aaguids: Set of AAGUID strings that the user has credentials for | ||||||
|  |  | ||||||
|  |     Returns: | ||||||
|  |         Dictionary mapping AAGUID to authenticator information for only | ||||||
|  |         the AAGUIDs that the user has and that we have data for | ||||||
|  |     """ | ||||||
|  |     return {aaguid: AAGUID[aaguid] for aaguid in aaguids if aaguid in AAGUID} | ||||||
| @@ -25,7 +25,7 @@ from sqlalchemy.dialects.sqlite import BLOB | |||||||
| from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine | ||||||
| from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship | from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship | ||||||
| 
 | 
 | ||||||
| from .passkey import StoredCredential | from ..sansio import StoredCredential | ||||||
| 
 | 
 | ||||||
| DB_PATH = "sqlite+aiosqlite:///webauthn.db" | DB_PATH = "sqlite+aiosqlite:///webauthn.db" | ||||||
| 
 | 
 | ||||||
| @@ -10,9 +10,9 @@ This module contains all the HTTP API endpoints for: | |||||||
| 
 | 
 | ||||||
| from fastapi import Request, Response | from fastapi import Request, Response | ||||||
| 
 | 
 | ||||||
| from . import db | from .. import aaguid | ||||||
| from .aaguid_manager import get_aaguid_manager | from ..db import sql | ||||||
| from .jwt_manager import refresh_session_token, validate_session_token | from ..util.jwt import refresh_session_token, validate_session_token | ||||||
| from .session_manager import ( | from .session_manager import ( | ||||||
|     clear_session_cookie, |     clear_session_cookie, | ||||||
|     get_current_user, |     get_current_user, | ||||||
| @@ -59,13 +59,13 @@ async def get_user_credentials(request: Request) -> dict: | |||||||
|                 current_credential_id = token_data.get("credential_id") |                 current_credential_id = token_data.get("credential_id") | ||||||
| 
 | 
 | ||||||
|         # Get all credentials for the user |         # Get all credentials for the user | ||||||
|         credential_ids = await db.get_user_credentials(user.user_id) |         credential_ids = await sql.get_user_credentials(user.user_id) | ||||||
| 
 | 
 | ||||||
|         credentials = [] |         credentials = [] | ||||||
|         user_aaguids = set() |         user_aaguids = set() | ||||||
| 
 | 
 | ||||||
|         for cred_id in credential_ids: |         for cred_id in credential_ids: | ||||||
|             stored_cred = await db.get_credential_by_id(cred_id) |             stored_cred = await sql.get_credential_by_id(cred_id) | ||||||
| 
 | 
 | ||||||
|             # Convert AAGUID to string format |             # Convert AAGUID to string format | ||||||
|             aaguid_str = str(stored_cred.aaguid) |             aaguid_str = str(stored_cred.aaguid) | ||||||
| @@ -91,8 +91,7 @@ async def get_user_credentials(request: Request) -> dict: | |||||||
|             ) |             ) | ||||||
| 
 | 
 | ||||||
|         # Get AAGUID information for only the AAGUIDs that the user has |         # Get AAGUID information for only the AAGUIDs that the user has | ||||||
|         aaguid_manager = get_aaguid_manager() |         aaguid_info = aaguid.filter(user_aaguids) | ||||||
|         aaguid_info = aaguid_manager.get_relevant_aaguids(user_aaguids) |  | ||||||
| 
 | 
 | ||||||
|         # Sort credentials by creation date (earliest first, most recently created last) |         # Sort credentials by creation date (earliest first, most recently created last) | ||||||
|         credentials.sort(key=lambda cred: cred["created_at"]) |         credentials.sort(key=lambda cred: cred["created_at"]) | ||||||
| @@ -208,7 +207,7 @@ async def delete_credential(request: Request) -> dict: | |||||||
| 
 | 
 | ||||||
|         # First, verify the credential belongs to the current user |         # First, verify the credential belongs to the current user | ||||||
|         try: |         try: | ||||||
|             stored_cred = await db.get_credential_by_id(credential_id_bytes) |             stored_cred = await sql.get_credential_by_id(credential_id_bytes) | ||||||
|             if stored_cred.user_id != user.user_id: |             if stored_cred.user_id != user.user_id: | ||||||
|                 return {"error": "Credential not found or access denied"} |                 return {"error": "Credential not found or access denied"} | ||||||
|         except ValueError: |         except ValueError: | ||||||
| @@ -222,12 +221,12 @@ async def delete_credential(request: Request) -> dict: | |||||||
|                 return {"error": "Cannot delete current session credential"} |                 return {"error": "Cannot delete current session credential"} | ||||||
| 
 | 
 | ||||||
|         # Get user's remaining credentials count |         # Get user's remaining credentials count | ||||||
|         remaining_credentials = await db.get_user_credentials(user.user_id) |         remaining_credentials = await sql.get_user_credentials(user.user_id) | ||||||
|         if len(remaining_credentials) <= 1: |         if len(remaining_credentials) <= 1: | ||||||
|             return {"error": "Cannot delete last remaining credential"} |             return {"error": "Cannot delete last remaining credential"} | ||||||
| 
 | 
 | ||||||
|         # Delete the credential |         # Delete the credential | ||||||
|         await db.delete_user_credential(credential_id_bytes) |         await sql.delete_user_credential(credential_id_bytes) | ||||||
| 
 | 
 | ||||||
|         return {"status": "success", "message": "Credential deleted successfully"} |         return {"status": "success", "message": "Credential deleted successfully"} | ||||||
| 
 | 
 | ||||||
							
								
								
									
										186
									
								
								passkey/fastapi/main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										186
									
								
								passkey/fastapi/main.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,186 @@ | |||||||
|  | """ | ||||||
|  | Minimal FastAPI WebAuthn server with WebSocket support for passkey registration and authentication. | ||||||
|  |  | ||||||
|  | This module provides a simple WebAuthn implementation that: | ||||||
|  | - Uses WebSocket for real-time communication | ||||||
|  | - Supports Resident Keys (discoverable credentials) for passwordless authentication | ||||||
|  | - Maintains challenges locally per connection | ||||||
|  | - Uses async SQLite database for persistent storage of users and credentials | ||||||
|  | - Enables true passwordless authentication where users don't need to enter a user_name | ||||||
|  | """ | ||||||
|  |  | ||||||
|  | import logging | ||||||
|  | from contextlib import asynccontextmanager | ||||||
|  | from pathlib import Path | ||||||
|  |  | ||||||
|  | from fastapi import ( | ||||||
|  |     FastAPI, | ||||||
|  |     Request, | ||||||
|  |     Response, | ||||||
|  | ) | ||||||
|  | from fastapi import ( | ||||||
|  |     Path as FastAPIPath, | ||||||
|  | ) | ||||||
|  | from fastapi.responses import ( | ||||||
|  |     FileResponse, | ||||||
|  |     RedirectResponse, | ||||||
|  | ) | ||||||
|  | from fastapi.staticfiles import StaticFiles | ||||||
|  |  | ||||||
|  | from ..db import sql | ||||||
|  | from .api_handlers import ( | ||||||
|  |     delete_credential, | ||||||
|  |     get_user_credentials, | ||||||
|  |     get_user_info, | ||||||
|  |     logout, | ||||||
|  |     refresh_token, | ||||||
|  |     set_session, | ||||||
|  |     validate_token, | ||||||
|  | ) | ||||||
|  | from .reset_handlers import create_device_addition_link, validate_device_addition_token | ||||||
|  | from .ws_handlers import ws_app | ||||||
|  |  | ||||||
|  | STATIC_DIR = Path(__file__).parent.parent / "frontend-build" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @asynccontextmanager | ||||||
|  | async def lifespan(app: FastAPI): | ||||||
|  |     await sql.init_database() | ||||||
|  |     yield | ||||||
|  |  | ||||||
|  |  | ||||||
|  | app = FastAPI(title="Passkey Auth", lifespan=lifespan) | ||||||
|  |  | ||||||
|  | # Mount the WebSocket subapp | ||||||
|  | app.mount("/auth/ws", ws_app) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @app.get("/auth/user-info") | ||||||
|  | async def api_get_user_info(request: Request): | ||||||
|  |     """Get user information from session cookie.""" | ||||||
|  |     return await get_user_info(request) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @app.get("/auth/user-credentials") | ||||||
|  | async def api_get_user_credentials(request: Request): | ||||||
|  |     """Get all credentials for a user using session cookie.""" | ||||||
|  |     return await get_user_credentials(request) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @app.post("/auth/refresh-token") | ||||||
|  | async def api_refresh_token(request: Request, response: Response): | ||||||
|  |     """Refresh the session token.""" | ||||||
|  |     return await refresh_token(request, response) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @app.get("/auth/validate-token") | ||||||
|  | async def api_validate_token(request: Request): | ||||||
|  |     """Validate a session token and return user info.""" | ||||||
|  |     return await validate_token(request) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @app.get("/auth/forward-auth") | ||||||
|  | async def forward_authentication(request: Request): | ||||||
|  |     """A verification endpoint to use with Caddy forward_auth or Nginx auth_request.""" | ||||||
|  |     result = await validate_token(request) | ||||||
|  |     if result.get("status") != "success": | ||||||
|  |         # Serve the index.html of the authentication app if not authenticated | ||||||
|  |         return FileResponse( | ||||||
|  |             STATIC_DIR / "index.html", | ||||||
|  |             status_code=401, | ||||||
|  |             headers={"www-authenticate": "PrivateToken"}, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     # If authenticated, return a success response | ||||||
|  |     return Response( | ||||||
|  |         status_code=204, | ||||||
|  |         headers={"x-auth-user-id": result["user_id"]}, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @app.post("/auth/logout") | ||||||
|  | async def api_logout(response: Response): | ||||||
|  |     """Log out the current user by clearing the session cookie.""" | ||||||
|  |     return await logout(response) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @app.post("/auth/set-session") | ||||||
|  | async def api_set_session(request: Request, response: Response): | ||||||
|  |     """Set session cookie using JWT token from request body or Authorization header.""" | ||||||
|  |     return await set_session(request, response) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @app.post("/auth/delete-credential") | ||||||
|  | async def api_delete_credential(request: Request): | ||||||
|  |     """Delete a specific credential for the current user.""" | ||||||
|  |     return await delete_credential(request) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @app.post("/auth/create-device-link") | ||||||
|  | async def api_create_device_link(request: Request): | ||||||
|  |     """Create a device addition link for the authenticated user.""" | ||||||
|  |     return await create_device_addition_link(request) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @app.post("/auth/validate-device-token") | ||||||
|  | async def api_validate_device_token(request: Request): | ||||||
|  |     """Validate a device addition token.""" | ||||||
|  |     return await validate_device_addition_token(request) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @app.get("/auth/{passphrase}") | ||||||
|  | async def reset_authentication( | ||||||
|  |     passphrase: str = FastAPIPath(pattern=r"^\w+(\.\w+){2,}$"), | ||||||
|  | ): | ||||||
|  |     response = RedirectResponse(url="/", status_code=303) | ||||||
|  |     response.set_cookie( | ||||||
|  |         key="auth-token", | ||||||
|  |         value=passphrase, | ||||||
|  |         httponly=False, | ||||||
|  |         secure=True, | ||||||
|  |         samesite="strict", | ||||||
|  |         max_age=2, | ||||||
|  |     ) | ||||||
|  |     return response | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # 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") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # Catch-all route for SPA - serve index.html for all non-API routes | ||||||
|  | @app.get("/{path:path}") | ||||||
|  | async def spa_handler(request: Request, path: str): | ||||||
|  |     """Serve the Vue SPA for all routes (except API and static)""" | ||||||
|  |     if "text/html" not in request.headers.get("accept", ""): | ||||||
|  |         return Response(content="Not Found", status_code=404) | ||||||
|  |     return FileResponse(STATIC_DIR / "index.html") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def main(): | ||||||
|  |     """Entry point for the application""" | ||||||
|  |     import uvicorn | ||||||
|  |  | ||||||
|  |     uvicorn.run( | ||||||
|  |         "passkey.fastapi.main:app", | ||||||
|  |         host="localhost", | ||||||
|  |         port=4401, | ||||||
|  |         reload=True, | ||||||
|  |         log_level="info", | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | if __name__ == "__main__": | ||||||
|  |     logging.basicConfig(level=logging.DEBUG) | ||||||
|  |     main() | ||||||
| @@ -11,8 +11,8 @@ from datetime import datetime, timedelta | |||||||
| 
 | 
 | ||||||
| from fastapi import Request | from fastapi import Request | ||||||
| 
 | 
 | ||||||
| from . import db | from ..db import sql | ||||||
| from .passphrase import generate | from ..util.passphrase import generate | ||||||
| from .session_manager import get_current_user | from .session_manager import get_current_user | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @@ -28,7 +28,7 @@ async def create_device_addition_link(request: Request) -> dict: | |||||||
|         token = generate(n=4, sep=".")  # e.g., "able-ocean-forest-dawn" |         token = generate(n=4, sep=".")  # e.g., "able-ocean-forest-dawn" | ||||||
| 
 | 
 | ||||||
|         # Create reset token in database |         # Create reset token in database | ||||||
|         await db.create_reset_token(user.user_id, token) |         await sql.create_reset_token(user.user_id, token) | ||||||
| 
 | 
 | ||||||
|         # Generate the device addition link with pretty URL |         # Generate the device addition link with pretty URL | ||||||
|         addition_link = f"{request.headers.get('origin', '')}/auth/{token}" |         addition_link = f"{request.headers.get('origin', '')}/auth/{token}" | ||||||
| @@ -54,7 +54,7 @@ async def validate_device_addition_token(request: Request) -> dict: | |||||||
|             return {"error": "Device addition token is required"} |             return {"error": "Device addition token is required"} | ||||||
| 
 | 
 | ||||||
|         # Get reset token |         # Get reset token | ||||||
|         reset_token = await db.get_reset_token(token) |         reset_token = await sql.get_reset_token(token) | ||||||
|         if not reset_token: |         if not reset_token: | ||||||
|             return {"error": "Invalid or expired device addition token"} |             return {"error": "Invalid or expired device addition token"} | ||||||
| 
 | 
 | ||||||
| @@ -64,7 +64,7 @@ async def validate_device_addition_token(request: Request) -> dict: | |||||||
|             return {"error": "Device addition token has expired"} |             return {"error": "Device addition token has expired"} | ||||||
| 
 | 
 | ||||||
|         # Get user info |         # Get user info | ||||||
|         user = await db.get_user_by_id(reset_token.user_id) |         user = await sql.get_user_by_id(reset_token.user_id) | ||||||
| 
 | 
 | ||||||
|         return { |         return { | ||||||
|             "status": "success", |             "status": "success", | ||||||
| @@ -82,7 +82,7 @@ async def use_device_addition_token(token: str) -> dict: | |||||||
|     """Delete a device addition token after successful use.""" |     """Delete a device addition token after successful use.""" | ||||||
|     try: |     try: | ||||||
|         # Get reset token first to validate it exists and is not expired |         # Get reset token first to validate it exists and is not expired | ||||||
|         reset_token = await db.get_reset_token(token) |         reset_token = await sql.get_reset_token(token) | ||||||
|         if not reset_token: |         if not reset_token: | ||||||
|             return {"error": "Invalid or expired device addition token"} |             return {"error": "Invalid or expired device addition token"} | ||||||
| 
 | 
 | ||||||
| @@ -92,7 +92,7 @@ async def use_device_addition_token(token: str) -> dict: | |||||||
|             return {"error": "Device addition token has expired"} |             return {"error": "Device addition token has expired"} | ||||||
| 
 | 
 | ||||||
|         # Delete the token (it's now used) |         # Delete the token (it's now used) | ||||||
|         await db.delete_reset_token(token) |         await sql.delete_reset_token(token) | ||||||
| 
 | 
 | ||||||
|         return { |         return { | ||||||
|             "status": "success", |             "status": "success", | ||||||
| @@ -11,8 +11,8 @@ from uuid import UUID | |||||||
| 
 | 
 | ||||||
| from fastapi import Request, Response | from fastapi import Request, Response | ||||||
| 
 | 
 | ||||||
| from .db import User, get_user_by_id | from ..db.sql import User, get_user_by_id | ||||||
| from .jwt_manager import validate_session_token | from ..util.jwt import validate_session_token | ||||||
| 
 | 
 | ||||||
| COOKIE_NAME = "auth" | COOKIE_NAME = "auth" | ||||||
| COOKIE_MAX_AGE = 86400  # 24 hours | COOKIE_MAX_AGE = 86400  # 24 hours | ||||||
							
								
								
									
										219
									
								
								passkey/fastapi/ws_handlers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										219
									
								
								passkey/fastapi/ws_handlers.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,219 @@ | |||||||
|  | """ | ||||||
|  | WebSocket handlers for passkey authentication operations. | ||||||
|  |  | ||||||
|  | This module contains all WebSocket endpoints for: | ||||||
|  | - User registration | ||||||
|  | - Adding credentials to existing users | ||||||
|  | - Device credential addition via token | ||||||
|  | - Authentication | ||||||
|  | """ | ||||||
|  |  | ||||||
|  | import logging | ||||||
|  | from datetime import datetime, timedelta | ||||||
|  | from uuid import UUID | ||||||
|  |  | ||||||
|  | import uuid7 | ||||||
|  | from fastapi import FastAPI, WebSocket, WebSocketDisconnect | ||||||
|  | from webauthn.helpers.exceptions import InvalidAuthenticationResponse | ||||||
|  |  | ||||||
|  | from ..db import sql | ||||||
|  | from ..db.sql import User | ||||||
|  | from ..sansio import Passkey | ||||||
|  | from ..util.jwt import create_session_token | ||||||
|  | from .session_manager import get_user_from_cookie_string | ||||||
|  |  | ||||||
|  | # Create a FastAPI subapp for WebSocket endpoints | ||||||
|  | ws_app = FastAPI() | ||||||
|  |  | ||||||
|  | # Initialize the passkey instance | ||||||
|  | passkey = Passkey( | ||||||
|  |     rp_id="localhost", | ||||||
|  |     rp_name="Passkey Auth", | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def register_chat( | ||||||
|  |     ws: WebSocket, | ||||||
|  |     user_id: UUID, | ||||||
|  |     user_name: str, | ||||||
|  |     credential_ids: list[bytes] | None = None, | ||||||
|  |     origin: str | None = None, | ||||||
|  | ): | ||||||
|  |     """Generate registration options and send them to the client.""" | ||||||
|  |     options, challenge = passkey.reg_generate_options( | ||||||
|  |         user_id=user_id, | ||||||
|  |         user_name=user_name, | ||||||
|  |         credential_ids=credential_ids, | ||||||
|  |         origin=origin, | ||||||
|  |     ) | ||||||
|  |     await ws.send_json(options) | ||||||
|  |     response = await ws.receive_json() | ||||||
|  |     return passkey.reg_verify(response, challenge, user_id, origin=origin) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @ws_app.websocket("/register_new") | ||||||
|  | async def websocket_register_new(ws: WebSocket, user_name: str): | ||||||
|  |     """Register a new user and with a new passkey credential.""" | ||||||
|  |     await ws.accept() | ||||||
|  |     origin = ws.headers.get("origin") | ||||||
|  |     try: | ||||||
|  |         user_id = uuid7.create() | ||||||
|  |  | ||||||
|  |         # WebAuthn registration | ||||||
|  |         credential = await register_chat(ws, user_id, user_name, origin=origin) | ||||||
|  |  | ||||||
|  |         # Store the user and credential in the database | ||||||
|  |         await sql.create_user_and_credential( | ||||||
|  |             User(user_id, user_name, created_at=datetime.now()), | ||||||
|  |             credential, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         # Create a session token for the new user | ||||||
|  |         session_token = create_session_token(user_id, credential.credential_id) | ||||||
|  |  | ||||||
|  |         await ws.send_json( | ||||||
|  |             { | ||||||
|  |                 "status": "success", | ||||||
|  |                 "user_id": str(user_id), | ||||||
|  |                 "session_token": session_token, | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |     except ValueError as e: | ||||||
|  |         await ws.send_json({"error": str(e)}) | ||||||
|  |     except WebSocketDisconnect: | ||||||
|  |         pass | ||||||
|  |     except Exception: | ||||||
|  |         logging.exception("Internal Server Error") | ||||||
|  |         await ws.send_json({"error": "Internal Server Error"}) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @ws_app.websocket("/add_credential") | ||||||
|  | async def websocket_register_add(ws: WebSocket): | ||||||
|  |     """Register a new credential for an existing user.""" | ||||||
|  |     await ws.accept() | ||||||
|  |     origin = ws.headers.get("origin") | ||||||
|  |     try: | ||||||
|  |         # Authenticate user via cookie | ||||||
|  |         cookie_header = ws.headers.get("cookie", "") | ||||||
|  |         user_id = await get_user_from_cookie_string(cookie_header) | ||||||
|  |  | ||||||
|  |         if not user_id: | ||||||
|  |             await ws.send_json({"error": "Authentication required"}) | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         # Get user information to get the user_name | ||||||
|  |         user = await sql.get_user_by_id(user_id) | ||||||
|  |         user_name = user.user_name | ||||||
|  |         challenge_ids = await sql.get_user_credentials(user_id) | ||||||
|  |  | ||||||
|  |         # WebAuthn registration | ||||||
|  |         credential = await register_chat( | ||||||
|  |             ws, user_id, user_name, challenge_ids, origin=origin | ||||||
|  |         ) | ||||||
|  |         # Store the new credential in the database | ||||||
|  |         await sql.create_credential_for_user(credential) | ||||||
|  |  | ||||||
|  |         await ws.send_json( | ||||||
|  |             { | ||||||
|  |                 "status": "success", | ||||||
|  |                 "user_id": str(user_id), | ||||||
|  |                 "credential_id": credential.credential_id.hex(), | ||||||
|  |                 "message": "New credential added successfully", | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |     except ValueError as e: | ||||||
|  |         await ws.send_json({"error": str(e)}) | ||||||
|  |     except WebSocketDisconnect: | ||||||
|  |         pass | ||||||
|  |     except Exception: | ||||||
|  |         logging.exception("Internal Server Error") | ||||||
|  |         await ws.send_json({"error": "Internal Server Error"}) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @ws_app.websocket("/add_device_credential") | ||||||
|  | async def websocket_add_device_credential(ws: WebSocket, token: str): | ||||||
|  |     """Add a new credential for an existing user via device addition token.""" | ||||||
|  |     await ws.accept() | ||||||
|  |     origin = ws.headers.get("origin") | ||||||
|  |     try: | ||||||
|  |         reset_token = await sql.get_reset_token(token) | ||||||
|  |         if not reset_token: | ||||||
|  |             await ws.send_json({"error": "Invalid or expired device addition token"}) | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         # Check if token is expired (24 hours) | ||||||
|  |         expiry_time = reset_token.created_at + timedelta(hours=24) | ||||||
|  |         if datetime.now() > expiry_time: | ||||||
|  |             await ws.send_json({"error": "Device addition token has expired"}) | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         # Get user information | ||||||
|  |         user = await sql.get_user_by_id(reset_token.user_id) | ||||||
|  |  | ||||||
|  |         # WebAuthn registration | ||||||
|  |         # Fetch challenge IDs for the user | ||||||
|  |         challenge_ids = await sql.get_user_credentials(reset_token.user_id) | ||||||
|  |  | ||||||
|  |         credential = await register_chat( | ||||||
|  |             ws, reset_token.user_id, user.user_name, challenge_ids, origin=origin | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         # Store the new credential in the database | ||||||
|  |         await sql.create_credential_for_user(credential) | ||||||
|  |  | ||||||
|  |         # Delete the device addition token (it's now used) | ||||||
|  |         await sql.delete_reset_token(token) | ||||||
|  |  | ||||||
|  |         await ws.send_json( | ||||||
|  |             { | ||||||
|  |                 "status": "success", | ||||||
|  |                 "user_id": str(reset_token.user_id), | ||||||
|  |                 "credential_id": credential.credential_id.hex(), | ||||||
|  |                 "message": "New credential added successfully via device addition token", | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |     except ValueError as e: | ||||||
|  |         await ws.send_json({"error": str(e)}) | ||||||
|  |     except WebSocketDisconnect: | ||||||
|  |         pass | ||||||
|  |     except Exception: | ||||||
|  |         logging.exception("Internal Server Error") | ||||||
|  |         await ws.send_json({"error": "Internal Server Error"}) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @ws_app.websocket("/authenticate") | ||||||
|  | async def websocket_authenticate(ws: WebSocket): | ||||||
|  |     await ws.accept() | ||||||
|  |     origin = ws.headers.get("origin") | ||||||
|  |     try: | ||||||
|  |         options, challenge = passkey.auth_generate_options() | ||||||
|  |         await ws.send_json(options) | ||||||
|  |         # Wait for the client to use his authenticator to authenticate | ||||||
|  |         credential = passkey.auth_parse(await ws.receive_json()) | ||||||
|  |         # Fetch from the database by credential ID | ||||||
|  |         stored_cred = await sql.get_credential_by_id(credential.raw_id) | ||||||
|  |         # Verify the credential matches the stored data | ||||||
|  |         passkey.auth_verify(credential, challenge, stored_cred, origin=origin) | ||||||
|  |         # Update both credential and user's last_seen timestamp | ||||||
|  |         await sql.login_user(stored_cred.user_id, stored_cred) | ||||||
|  |  | ||||||
|  |         # Create a session token for the authenticated user | ||||||
|  |         session_token = create_session_token( | ||||||
|  |             stored_cred.user_id, stored_cred.credential_id | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         await ws.send_json( | ||||||
|  |             { | ||||||
|  |                 "status": "success", | ||||||
|  |                 "user_id": str(stored_cred.user_id), | ||||||
|  |                 "session_token": session_token, | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |     except (ValueError, InvalidAuthenticationResponse) as e: | ||||||
|  |         logging.exception("ValueError") | ||||||
|  |         await ws.send_json({"error": str(e)}) | ||||||
|  |     except WebSocketDisconnect: | ||||||
|  |         pass | ||||||
|  |     except Exception: | ||||||
|  |         logging.exception("Internal Server Error") | ||||||
|  |         await ws.send_json({"error": "Internal Server Error"}) | ||||||
| @@ -22,8 +22,8 @@ def load_or_create_secret() -> bytes: | |||||||
|     if SECRET_FILE.exists(): |     if SECRET_FILE.exists(): | ||||||
|         return SECRET_FILE.read_bytes() |         return SECRET_FILE.read_bytes() | ||||||
|     else: |     else: | ||||||
|         # Generate a new 32-byte secret |         # Generate a new 16-byte secret | ||||||
|         secret = secrets.token_bytes(32) |         secret = secrets.token_bytes(16) | ||||||
|         SECRET_FILE.write_bytes(secret) |         SECRET_FILE.write_bytes(secret) | ||||||
|         return secret |         return secret | ||||||
| 
 | 
 | ||||||
| @@ -47,7 +47,7 @@ class JWTManager: | |||||||
|         Returns: |         Returns: | ||||||
|             JWT token string |             JWT token string | ||||||
|         """ |         """ | ||||||
|         now = datetime.utcnow() |         now = datetime.now() | ||||||
|         payload = { |         payload = { | ||||||
|             "user_id": str(user_id), |             "user_id": str(user_id), | ||||||
|             "credential_id": credential_id.hex(), |             "credential_id": credential_id.hex(), | ||||||
| @@ -105,7 +105,7 @@ class JWTManager: | |||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| # Global JWT manager instance | # Global JWT manager instance | ||||||
| _jwt_manager: Optional[JWTManager] = None | _jwt_manager: JWTManager | None = None | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def get_jwt_manager() -> JWTManager: | def get_jwt_manager() -> JWTManager: | ||||||
| @@ -114,7 +114,7 @@ def get_jwt_manager() -> JWTManager: | |||||||
|     if _jwt_manager is None: |     if _jwt_manager is None: | ||||||
|         secret = load_or_create_secret() |         secret = load_or_create_secret() | ||||||
|         _jwt_manager = JWTManager(secret) |         _jwt_manager = JWTManager(secret) | ||||||
|     return _jwt_manager |     return _jwt_manager  # type: ignore | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def create_session_token(user_id: UUID, credential_id: bytes) -> str: | def create_session_token(user_id: UUID, credential_id: bytes) -> str: | ||||||
| @@ -1 +0,0 @@ | |||||||
| # passkeyauth package |  | ||||||
| @@ -1,65 +0,0 @@ | |||||||
| """ |  | ||||||
| AAGUID (Authenticator Attestation GUID) management for WebAuthn credentials. |  | ||||||
|  |  | ||||||
| This module provides functionality to: |  | ||||||
| - Load AAGUID data from JSON file |  | ||||||
| - Look up authenticator information by AAGUID |  | ||||||
| - Return only relevant AAGUID data for user credentials |  | ||||||
| """ |  | ||||||
|  |  | ||||||
| import json |  | ||||||
| from pathlib import Path |  | ||||||
| from typing import Optional |  | ||||||
|  |  | ||||||
| # Path to the AAGUID JSON file |  | ||||||
| AAGUID_FILE = Path(__file__).parent / "combined_aaguid.json" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class AAGUIDManager: |  | ||||||
|     """Manages AAGUID data and lookups.""" |  | ||||||
|  |  | ||||||
|     def __init__(self): |  | ||||||
|         self.aaguid_data: dict[str, dict] = {} |  | ||||||
|         self.load_aaguid_data() |  | ||||||
|  |  | ||||||
|     def load_aaguid_data(self) -> None: |  | ||||||
|         """Load AAGUID data from the JSON file.""" |  | ||||||
|         try: |  | ||||||
|             with open(AAGUID_FILE, encoding="utf-8") as f: |  | ||||||
|                 self.aaguid_data = json.load(f) |  | ||||||
|         except (FileNotFoundError, json.JSONDecodeError) as e: |  | ||||||
|             print(f"Warning: Could not load AAGUID data: {e}") |  | ||||||
|             self.aaguid_data = {} |  | ||||||
|  |  | ||||||
|     def get_authenticator_info(self, aaguid: str) -> Optional[dict]: |  | ||||||
|         """Get authenticator information for a specific AAGUID.""" |  | ||||||
|         return self.aaguid_data.get(aaguid) |  | ||||||
|  |  | ||||||
|     def get_relevant_aaguids(self, aaguids: set[str]) -> dict[str, dict]: |  | ||||||
|         """ |  | ||||||
|         Get AAGUID information only for the provided set of AAGUIDs. |  | ||||||
|  |  | ||||||
|         Args: |  | ||||||
|             aaguids: Set of AAGUID strings that the user has credentials for |  | ||||||
|  |  | ||||||
|         Returns: |  | ||||||
|             Dictionary mapping AAGUID to authenticator information for only |  | ||||||
|             the AAGUIDs that the user has and that we have data for |  | ||||||
|         """ |  | ||||||
|         relevant = {} |  | ||||||
|         for aaguid in aaguids: |  | ||||||
|             if aaguid in self.aaguid_data: |  | ||||||
|                 relevant[aaguid] = self.aaguid_data[aaguid] |  | ||||||
|         return relevant |  | ||||||
|  |  | ||||||
|  |  | ||||||
| # Global AAGUID manager instance |  | ||||||
| _aaguid_manager: Optional[AAGUIDManager] = None |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_aaguid_manager() -> AAGUIDManager: |  | ||||||
|     """Get the global AAGUID manager instance.""" |  | ||||||
|     global _aaguid_manager |  | ||||||
|     if _aaguid_manager is None: |  | ||||||
|         _aaguid_manager = AAGUIDManager() |  | ||||||
|     return _aaguid_manager |  | ||||||
| @@ -1,381 +0,0 @@ | |||||||
| """ |  | ||||||
| Minimal FastAPI WebAuthn server with WebSocket support for passkey registration and authentication. |  | ||||||
|  |  | ||||||
| This module provides a simple WebAuthn implementation that: |  | ||||||
| - Uses WebSocket for real-time communication |  | ||||||
| - Supports Resident Keys (discoverable credentials) for passwordless authentication |  | ||||||
| - Maintains challenges locally per connection |  | ||||||
| - Uses async SQLite database for persistent storage of users and credentials |  | ||||||
| - Enables true passwordless authentication where users don't need to enter a user_name |  | ||||||
| """ |  | ||||||
|  |  | ||||||
| import logging |  | ||||||
| from contextlib import asynccontextmanager |  | ||||||
| from datetime import datetime |  | ||||||
| from pathlib import Path |  | ||||||
| from uuid import UUID, uuid4 |  | ||||||
|  |  | ||||||
| from fastapi import ( |  | ||||||
|     FastAPI, |  | ||||||
|     Request, |  | ||||||
|     Response, |  | ||||||
|     WebSocket, |  | ||||||
|     WebSocketDisconnect, |  | ||||||
| ) |  | ||||||
| from fastapi import ( |  | ||||||
|     Path as FastAPIPath, |  | ||||||
| ) |  | ||||||
| from fastapi.responses import FileResponse, JSONResponse, RedirectResponse |  | ||||||
| from fastapi.staticfiles import StaticFiles |  | ||||||
| from webauthn.helpers.exceptions import InvalidAuthenticationResponse |  | ||||||
|  |  | ||||||
| from . import db |  | ||||||
| from .api_handlers import ( |  | ||||||
|     delete_credential, |  | ||||||
|     get_user_credentials, |  | ||||||
|     get_user_info, |  | ||||||
|     logout, |  | ||||||
|     refresh_token, |  | ||||||
|     set_session, |  | ||||||
|     validate_token, |  | ||||||
| ) |  | ||||||
| from .db import User |  | ||||||
| from .jwt_manager import create_session_token |  | ||||||
| from .passkey import Passkey |  | ||||||
| from .reset_handlers import create_device_addition_link, validate_device_addition_token |  | ||||||
| from .session_manager import get_user_from_cookie_string |  | ||||||
|  |  | ||||||
| STATIC_DIR = Path(__file__).parent / "frontend-static" |  | ||||||
|  |  | ||||||
| passkey = Passkey( |  | ||||||
|     rp_id="localhost", |  | ||||||
|     rp_name="Passkey Auth", |  | ||||||
| ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @asynccontextmanager |  | ||||||
| async def lifespan(app: FastAPI): |  | ||||||
|     await db.init_database() |  | ||||||
|     yield |  | ||||||
|  |  | ||||||
|  |  | ||||||
| app = FastAPI(title="Passkey Auth", lifespan=lifespan) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.websocket("/auth/ws/register_new") |  | ||||||
| async def websocket_register_new(ws: WebSocket, user_name: str): |  | ||||||
|     """Register a new user and with a new passkey credential.""" |  | ||||||
|     await ws.accept() |  | ||||||
|     origin = ws.headers.get("origin") |  | ||||||
|     try: |  | ||||||
|         user_id = uuid4() |  | ||||||
|  |  | ||||||
|         # WebAuthn registration |  | ||||||
|         credential = await register_chat(ws, user_id, user_name, origin=origin) |  | ||||||
|  |  | ||||||
|         # Store the user and credential in the database |  | ||||||
|         await db.create_user_and_credential( |  | ||||||
|             User(user_id, user_name, created_at=datetime.now()), |  | ||||||
|             credential, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         # Create a session token for the new user |  | ||||||
|         session_token = create_session_token(user_id, credential.credential_id) |  | ||||||
|  |  | ||||||
|         await ws.send_json( |  | ||||||
|             { |  | ||||||
|                 "status": "success", |  | ||||||
|                 "user_id": str(user_id), |  | ||||||
|                 "session_token": session_token, |  | ||||||
|             } |  | ||||||
|         ) |  | ||||||
|     except ValueError as e: |  | ||||||
|         await ws.send_json({"error": str(e)}) |  | ||||||
|     except WebSocketDisconnect: |  | ||||||
|         pass |  | ||||||
|     except Exception: |  | ||||||
|         logging.exception("Internal Server Error") |  | ||||||
|         await ws.send_json({"error": "Internal Server Error"}) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.websocket("/auth/ws/add_credential") |  | ||||||
| async def websocket_register_add(ws: WebSocket): |  | ||||||
|     """Register a new credential for an existing user.""" |  | ||||||
|     await ws.accept() |  | ||||||
|     origin = ws.headers.get("origin") |  | ||||||
|     try: |  | ||||||
|         # Authenticate user via cookie |  | ||||||
|         cookie_header = ws.headers.get("cookie", "") |  | ||||||
|         user_id = await get_user_from_cookie_string(cookie_header) |  | ||||||
|  |  | ||||||
|         if not user_id: |  | ||||||
|             await ws.send_json({"error": "Authentication required"}) |  | ||||||
|             return |  | ||||||
|  |  | ||||||
|         # Get user information to get the user_name |  | ||||||
|         user = await db.get_user_by_id(user_id) |  | ||||||
|         user_name = user.user_name |  | ||||||
|         challenge_ids = await db.get_user_credentials(user_id) |  | ||||||
|  |  | ||||||
|         # WebAuthn registration |  | ||||||
|         credential = await register_chat( |  | ||||||
|             ws, user_id, user_name, challenge_ids, origin=origin |  | ||||||
|         ) |  | ||||||
|         # Store the new credential in the database |  | ||||||
|         await db.create_credential_for_user(credential) |  | ||||||
|  |  | ||||||
|         await ws.send_json( |  | ||||||
|             { |  | ||||||
|                 "status": "success", |  | ||||||
|                 "user_id": str(user_id), |  | ||||||
|                 "credential_id": credential.credential_id.hex(), |  | ||||||
|                 "message": "New credential added successfully", |  | ||||||
|             } |  | ||||||
|         ) |  | ||||||
|     except ValueError as e: |  | ||||||
|         await ws.send_json({"error": str(e)}) |  | ||||||
|     except WebSocketDisconnect: |  | ||||||
|         pass |  | ||||||
|     except Exception: |  | ||||||
|         logging.exception("Internal Server Error") |  | ||||||
|         await ws.send_json({"error": "Internal Server Error"}) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.websocket("/auth/ws/add_device_credential") |  | ||||||
| async def websocket_add_device_credential(ws: WebSocket, token: str): |  | ||||||
|     """Add a new credential for an existing user via device addition token.""" |  | ||||||
|     await ws.accept() |  | ||||||
|     origin = ws.headers.get("origin") |  | ||||||
|     try: |  | ||||||
|         reset_token = await db.get_reset_token(token) |  | ||||||
|         if not reset_token: |  | ||||||
|             await ws.send_json({"error": "Invalid or expired device addition token"}) |  | ||||||
|             return |  | ||||||
|  |  | ||||||
|         # Check if token is expired (24 hours) |  | ||||||
|         from datetime import timedelta |  | ||||||
|  |  | ||||||
|         expiry_time = reset_token.created_at + timedelta(hours=24) |  | ||||||
|         if datetime.now() > expiry_time: |  | ||||||
|             await ws.send_json({"error": "Device addition token has expired"}) |  | ||||||
|             return |  | ||||||
|  |  | ||||||
|         # Get user information |  | ||||||
|         user = await db.get_user_by_id(reset_token.user_id) |  | ||||||
|  |  | ||||||
|         # WebAuthn registration |  | ||||||
|         # Fetch challenge IDs for the user |  | ||||||
|         challenge_ids = await db.get_user_credentials(reset_token.user_id) |  | ||||||
|  |  | ||||||
|         credential = await register_chat( |  | ||||||
|             ws, reset_token.user_id, user.user_name, challenge_ids, origin=origin |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         # Store the new credential in the database |  | ||||||
|         await db.create_credential_for_user(credential) |  | ||||||
|  |  | ||||||
|         # Delete the device addition token (it's now used) |  | ||||||
|         await db.delete_reset_token(token) |  | ||||||
|  |  | ||||||
|         await ws.send_json( |  | ||||||
|             { |  | ||||||
|                 "status": "success", |  | ||||||
|                 "user_id": str(reset_token.user_id), |  | ||||||
|                 "credential_id": credential.credential_id.hex(), |  | ||||||
|                 "message": "New credential added successfully via device addition token", |  | ||||||
|             } |  | ||||||
|         ) |  | ||||||
|     except ValueError as e: |  | ||||||
|         await ws.send_json({"error": str(e)}) |  | ||||||
|     except WebSocketDisconnect: |  | ||||||
|         pass |  | ||||||
|     except Exception: |  | ||||||
|         logging.exception("Internal Server Error") |  | ||||||
|         await ws.send_json({"error": "Internal Server Error"}) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| async def register_chat( |  | ||||||
|     ws: WebSocket, |  | ||||||
|     user_id: UUID, |  | ||||||
|     user_name: str, |  | ||||||
|     credential_ids: list[bytes] | None = None, |  | ||||||
|     origin: str | None = None, |  | ||||||
| ): |  | ||||||
|     """Generate registration options and send them to the client.""" |  | ||||||
|     options, challenge = passkey.reg_generate_options( |  | ||||||
|         user_id=user_id, |  | ||||||
|         user_name=user_name, |  | ||||||
|         credential_ids=credential_ids, |  | ||||||
|         origin=origin, |  | ||||||
|     ) |  | ||||||
|     await ws.send_json(options) |  | ||||||
|     response = await ws.receive_json() |  | ||||||
|     return passkey.reg_verify(response, challenge, user_id, origin=origin) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.websocket("/auth/ws/authenticate") |  | ||||||
| async def websocket_authenticate(ws: WebSocket): |  | ||||||
|     await ws.accept() |  | ||||||
|     origin = ws.headers.get("origin") |  | ||||||
|     try: |  | ||||||
|         options, challenge = passkey.auth_generate_options() |  | ||||||
|         await ws.send_json(options) |  | ||||||
|         # Wait for the client to use his authenticator to authenticate |  | ||||||
|         credential = passkey.auth_parse(await ws.receive_json()) |  | ||||||
|         # Fetch from the database by credential ID |  | ||||||
|         stored_cred = await db.get_credential_by_id(credential.raw_id) |  | ||||||
|         # Verify the credential matches the stored data |  | ||||||
|         passkey.auth_verify(credential, challenge, stored_cred, origin=origin) |  | ||||||
|         # Update both credential and user's last_seen timestamp |  | ||||||
|         await db.login_user(stored_cred.user_id, stored_cred) |  | ||||||
|  |  | ||||||
|         # Create a session token for the authenticated user |  | ||||||
|         session_token = create_session_token( |  | ||||||
|             stored_cred.user_id, stored_cred.credential_id |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         await ws.send_json( |  | ||||||
|             { |  | ||||||
|                 "status": "success", |  | ||||||
|                 "user_id": str(stored_cred.user_id), |  | ||||||
|                 "session_token": session_token, |  | ||||||
|             } |  | ||||||
|         ) |  | ||||||
|     except (ValueError, InvalidAuthenticationResponse) as e: |  | ||||||
|         logging.exception("ValueError") |  | ||||||
|         await ws.send_json({"error": str(e)}) |  | ||||||
|     except WebSocketDisconnect: |  | ||||||
|         pass |  | ||||||
|     except Exception: |  | ||||||
|         logging.exception("Internal Server Error") |  | ||||||
|         await ws.send_json({"error": "Internal Server Error"}) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.get("/auth/user-info") |  | ||||||
| async def api_get_user_info(request: Request): |  | ||||||
|     """Get user information from session cookie.""" |  | ||||||
|     return await get_user_info(request) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.get("/auth/user-credentials") |  | ||||||
| async def api_get_user_credentials(request: Request): |  | ||||||
|     """Get all credentials for a user using session cookie.""" |  | ||||||
|     return await get_user_credentials(request) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.post("/auth/refresh-token") |  | ||||||
| async def api_refresh_token(request: Request, response: Response): |  | ||||||
|     """Refresh the session token.""" |  | ||||||
|     return await refresh_token(request, response) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.get("/auth/validate-token") |  | ||||||
| async def api_validate_token(request: Request): |  | ||||||
|     """Validate a session token and return user info.""" |  | ||||||
|     return await validate_token(request) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.get("/auth/forward-auth") |  | ||||||
| async def forward_authentication(request: Request): |  | ||||||
|     """A verification endpoint to use with Caddy forward_auth or Nginx auth_request.""" |  | ||||||
|     result = await validate_token(request) |  | ||||||
|     if result.get("status") != "success": |  | ||||||
|         # Serve the index.html of the authentication app if not authenticated |  | ||||||
|         return FileResponse( |  | ||||||
|             STATIC_DIR / "index.html", |  | ||||||
|             status_code=401, |  | ||||||
|             headers={"www-authenticate": "PrivateToken"}, |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     # If authenticated, return a success response |  | ||||||
|     return JSONResponse( |  | ||||||
|         result, |  | ||||||
|         headers={ |  | ||||||
|             "x-auth-user-id": result["user_id"], |  | ||||||
|         }, |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.post("/auth/logout") |  | ||||||
| async def api_logout(response: Response): |  | ||||||
|     """Log out the current user by clearing the session cookie.""" |  | ||||||
|     return await logout(response) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.post("/auth/set-session") |  | ||||||
| async def api_set_session(request: Request, response: Response): |  | ||||||
|     """Set session cookie using JWT token from request body or Authorization header.""" |  | ||||||
|     return await set_session(request, response) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.post("/auth/delete-credential") |  | ||||||
| async def api_delete_credential(request: Request): |  | ||||||
|     """Delete a specific credential for the current user.""" |  | ||||||
|     return await delete_credential(request) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.post("/auth/create-device-link") |  | ||||||
| async def api_create_device_link(request: Request): |  | ||||||
|     """Create a device addition link for the authenticated user.""" |  | ||||||
|     return await create_device_addition_link(request) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.post("/auth/validate-device-token") |  | ||||||
| async def api_validate_device_token(request: Request): |  | ||||||
|     """Validate a device addition token.""" |  | ||||||
|     return await validate_device_addition_token(request) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.get("/auth/{passphrase}") |  | ||||||
| async def reset_authentication( |  | ||||||
|     passphrase: str = FastAPIPath(pattern=r"^\w+(\.\w+){2,}$"), |  | ||||||
| ): |  | ||||||
|     response = RedirectResponse(url="/", status_code=303) |  | ||||||
|     response.set_cookie( |  | ||||||
|         key="auth-token", |  | ||||||
|         value=passphrase, |  | ||||||
|         httponly=False, |  | ||||||
|         secure=True, |  | ||||||
|         samesite="strict", |  | ||||||
|         max_age=2, |  | ||||||
|     ) |  | ||||||
|     return response |  | ||||||
|  |  | ||||||
|  |  | ||||||
| # 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") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| # Catch-all route for SPA - serve index.html for all non-API routes |  | ||||||
| @app.get("/{path:path}") |  | ||||||
| async def spa_handler(request: Request, path: str): |  | ||||||
|     """Serve the Vue SPA for all routes (except API and static)""" |  | ||||||
|     if "text/html" not in request.headers.get("accept", ""): |  | ||||||
|         return Response(content="Not Found", status_code=404) |  | ||||||
|     return FileResponse(STATIC_DIR / "index.html") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def main(): |  | ||||||
|     """Entry point for the application""" |  | ||||||
|     import uvicorn |  | ||||||
|  |  | ||||||
|     uvicorn.run( |  | ||||||
|         "passkeyauth.main:app", |  | ||||||
|         host="0.0.0.0", |  | ||||||
|         port=8000, |  | ||||||
|         reload=True, |  | ||||||
|         log_level="info", |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| if __name__ == "__main__": |  | ||||||
|     logging.basicConfig(level=logging.DEBUG) |  | ||||||
|     main() |  | ||||||
| @@ -3,15 +3,14 @@ requires = ["hatchling"] | |||||||
| build-backend = "hatchling.build" | build-backend = "hatchling.build" | ||||||
|  |  | ||||||
| [project] | [project] | ||||||
| name = "passkeyauth" | name = "passkey" | ||||||
| version = "0.1.0" | version = "0.1.0" | ||||||
| description = "Minimal FastAPI WebAuthn server with WebSocket support" | description = "Passkey Authentication for Web Services" | ||||||
| authors = [ | authors = [ | ||||||
|     {name = "User", email = "user@example.com"}, |     {name = "Leo Vasanko"}, | ||||||
| ] | ] | ||||||
| dependencies = [ | dependencies = [ | ||||||
|     "fastapi[standard]>=0.104.1", |     "fastapi[standard]>=0.104.1", | ||||||
|     "uvicorn[standard]>=0.24.0", |  | ||||||
|     "websockets>=12.0", |     "websockets>=12.0", | ||||||
|     "webauthn>=1.11.1", |     "webauthn>=1.11.1", | ||||||
|     "base64url>=1.0.0", |     "base64url>=1.0.0", | ||||||
| @@ -34,10 +33,10 @@ select = ["E", "F", "I", "N", "W", "UP"] | |||||||
| ignore = ["E501"]  # Line too long | ignore = ["E501"]  # Line too long | ||||||
|  |  | ||||||
| [tool.ruff.isort] | [tool.ruff.isort] | ||||||
| known-first-party = ["passkeyauth"] | known-first-party = ["passkey"] | ||||||
|  |  | ||||||
| [project.scripts] | [project.scripts] | ||||||
| serve = "passkeyauth.main:main" | serve = "passkey.main:main" | ||||||
|  |  | ||||||
| [tool.hatch.build] | [tool.hatch.build] | ||||||
| artifacts = ["passkeyauth/frontend-static"] | artifacts = ["passkeyauth/frontend-static"] | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Leo Vasanko
					Leo Vasanko