A lot of cleanup, restructuring project directory.
This commit is contained in:
		
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -5,4 +5,4 @@ dist/ | ||||
| *.lock | ||||
| *.db | ||||
| 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)) | ||||
|     }, | ||||
|   }, | ||||
|   base: command == 'build' ? '/auth/' : '/', | ||||
|   base: command === 'build' ? '/auth/' : '/', | ||||
|   server: { | ||||
|     port: 3000, | ||||
|     port: 4403, | ||||
|     proxy: { | ||||
|       '/auth/': { | ||||
|         target: 'http://localhost:8000', | ||||
|         target: 'http://localhost:4401', | ||||
|         ws: true, | ||||
|         changeOrigin: false | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   build: { | ||||
|     outDir: '../passkeyauth/frontend-static', | ||||
|     outDir: '../passkey/frontend-build', | ||||
|     emptyOutDir: true, | ||||
|     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.orm import DeclarativeBase, Mapped, mapped_column, relationship | ||||
| 
 | ||||
| from .passkey import StoredCredential | ||||
| from ..sansio import StoredCredential | ||||
| 
 | ||||
| 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 . import db | ||||
| from .aaguid_manager import get_aaguid_manager | ||||
| from .jwt_manager import refresh_session_token, validate_session_token | ||||
| from .. import aaguid | ||||
| from ..db import sql | ||||
| from ..util.jwt import refresh_session_token, validate_session_token | ||||
| from .session_manager import ( | ||||
|     clear_session_cookie, | ||||
|     get_current_user, | ||||
| @@ -59,13 +59,13 @@ async def get_user_credentials(request: Request) -> dict: | ||||
|                 current_credential_id = token_data.get("credential_id") | ||||
| 
 | ||||
|         # 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 = [] | ||||
|         user_aaguids = set() | ||||
| 
 | ||||
|         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 | ||||
|             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 | ||||
|         aaguid_manager = get_aaguid_manager() | ||||
|         aaguid_info = aaguid_manager.get_relevant_aaguids(user_aaguids) | ||||
|         aaguid_info = aaguid.filter(user_aaguids) | ||||
| 
 | ||||
|         # Sort credentials by creation date (earliest first, most recently created last) | ||||
|         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 | ||||
|         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: | ||||
|                 return {"error": "Credential not found or access denied"} | ||||
|         except ValueError: | ||||
| @@ -222,12 +221,12 @@ async def delete_credential(request: Request) -> dict: | ||||
|                 return {"error": "Cannot delete current session credential"} | ||||
| 
 | ||||
|         # 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: | ||||
|             return {"error": "Cannot delete last remaining 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"} | ||||
| 
 | ||||
							
								
								
									
										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 . import db | ||||
| from .passphrase import generate | ||||
| from ..db import sql | ||||
| from ..util.passphrase import generate | ||||
| 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" | ||||
| 
 | ||||
|         # 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 | ||||
|         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"} | ||||
| 
 | ||||
|         # Get reset token | ||||
|         reset_token = await db.get_reset_token(token) | ||||
|         reset_token = await sql.get_reset_token(token) | ||||
|         if not reset_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"} | ||||
| 
 | ||||
|         # 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 { | ||||
|             "status": "success", | ||||
| @@ -82,7 +82,7 @@ async def use_device_addition_token(token: str) -> dict: | ||||
|     """Delete a device addition token after successful use.""" | ||||
|     try: | ||||
|         # 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: | ||||
|             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"} | ||||
| 
 | ||||
|         # Delete the token (it's now used) | ||||
|         await db.delete_reset_token(token) | ||||
|         await sql.delete_reset_token(token) | ||||
| 
 | ||||
|         return { | ||||
|             "status": "success", | ||||
| @@ -11,8 +11,8 @@ from uuid import UUID | ||||
| 
 | ||||
| from fastapi import Request, Response | ||||
| 
 | ||||
| from .db import User, get_user_by_id | ||||
| from .jwt_manager import validate_session_token | ||||
| from ..db.sql import User, get_user_by_id | ||||
| from ..util.jwt import validate_session_token | ||||
| 
 | ||||
| COOKIE_NAME = "auth" | ||||
| 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(): | ||||
|         return SECRET_FILE.read_bytes() | ||||
|     else: | ||||
|         # Generate a new 32-byte secret | ||||
|         secret = secrets.token_bytes(32) | ||||
|         # Generate a new 16-byte secret | ||||
|         secret = secrets.token_bytes(16) | ||||
|         SECRET_FILE.write_bytes(secret) | ||||
|         return secret | ||||
| 
 | ||||
| @@ -47,7 +47,7 @@ class JWTManager: | ||||
|         Returns: | ||||
|             JWT token string | ||||
|         """ | ||||
|         now = datetime.utcnow() | ||||
|         now = datetime.now() | ||||
|         payload = { | ||||
|             "user_id": str(user_id), | ||||
|             "credential_id": credential_id.hex(), | ||||
| @@ -105,7 +105,7 @@ class JWTManager: | ||||
| 
 | ||||
| 
 | ||||
| # Global JWT manager instance | ||||
| _jwt_manager: Optional[JWTManager] = None | ||||
| _jwt_manager: JWTManager | None = None | ||||
| 
 | ||||
| 
 | ||||
| def get_jwt_manager() -> JWTManager: | ||||
| @@ -114,7 +114,7 @@ def get_jwt_manager() -> JWTManager: | ||||
|     if _jwt_manager is None: | ||||
|         secret = load_or_create_secret() | ||||
|         _jwt_manager = JWTManager(secret) | ||||
|     return _jwt_manager | ||||
|     return _jwt_manager  # type: ignore | ||||
| 
 | ||||
| 
 | ||||
| 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" | ||||
|  | ||||
| [project] | ||||
| name = "passkeyauth" | ||||
| name = "passkey" | ||||
| version = "0.1.0" | ||||
| description = "Minimal FastAPI WebAuthn server with WebSocket support" | ||||
| description = "Passkey Authentication for Web Services" | ||||
| authors = [ | ||||
|     {name = "User", email = "user@example.com"}, | ||||
|     {name = "Leo Vasanko"}, | ||||
| ] | ||||
| dependencies = [ | ||||
|     "fastapi[standard]>=0.104.1", | ||||
|     "uvicorn[standard]>=0.24.0", | ||||
|     "websockets>=12.0", | ||||
|     "webauthn>=1.11.1", | ||||
|     "base64url>=1.0.0", | ||||
| @@ -34,10 +33,10 @@ select = ["E", "F", "I", "N", "W", "UP"] | ||||
| ignore = ["E501"]  # Line too long | ||||
|  | ||||
| [tool.ruff.isort] | ||||
| known-first-party = ["passkeyauth"] | ||||
| known-first-party = ["passkey"] | ||||
|  | ||||
| [project.scripts] | ||||
| serve = "passkeyauth.main:main" | ||||
| serve = "passkey.main:main" | ||||
|  | ||||
| [tool.hatch.build] | ||||
| artifacts = ["passkeyauth/frontend-static"] | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Leo Vasanko
					Leo Vasanko