From 3567b7802b3024a1c7489d0aeb1af7090388281a Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 14 Jul 2025 11:54:04 -0600 Subject: [PATCH] A lot of cleanup, restructuring project directory. --- .gitignore | 2 +- Caddyfile | 23 ++ frontend/public/favicon.ico | Bin 4286 -> 0 bytes frontend/vite.config.js | 8 +- passkey/__init__.py | 3 + passkey/aaguid/__init__.py | 32 ++ .../aaguid}/combined_aaguid.json | 0 passkeyauth/db.py => passkey/db/sql.py | 2 +- .../fastapi}/api_handlers.py | 19 +- passkey/fastapi/main.py | 186 +++++++++ .../fastapi}/reset_handlers.py | 14 +- .../fastapi}/session_manager.py | 4 +- passkey/fastapi/ws_handlers.py | 219 ++++++++++ passkeyauth/passkey.py => passkey/sansio.py | 0 .../jwt_manager.py => passkey/util/jwt.py | 10 +- {passkeyauth => passkey/util}/passphrase.py | 0 {passkeyauth => passkey/util}/wordlist.py | 0 passkeyauth/__init__.py | 1 - passkeyauth/aaguid_manager.py | 65 --- passkeyauth/main.py | 381 ------------------ pyproject.toml | 11 +- 21 files changed, 497 insertions(+), 483 deletions(-) create mode 100644 Caddyfile delete mode 100644 frontend/public/favicon.ico create mode 100644 passkey/__init__.py create mode 100644 passkey/aaguid/__init__.py rename {passkeyauth => passkey/aaguid}/combined_aaguid.json (100%) rename passkeyauth/db.py => passkey/db/sql.py (99%) rename {passkeyauth => passkey/fastapi}/api_handlers.py (92%) create mode 100644 passkey/fastapi/main.py rename {passkeyauth => passkey/fastapi}/reset_handlers.py (90%) rename {passkeyauth => passkey/fastapi}/session_manager.py (96%) create mode 100644 passkey/fastapi/ws_handlers.py rename passkeyauth/passkey.py => passkey/sansio.py (100%) rename passkeyauth/jwt_manager.py => passkey/util/jwt.py (95%) rename {passkeyauth => passkey/util}/passphrase.py (100%) rename {passkeyauth => passkey/util}/wordlist.py (100%) delete mode 100644 passkeyauth/__init__.py delete mode 100644 passkeyauth/aaguid_manager.py delete mode 100644 passkeyauth/main.py diff --git a/.gitignore b/.gitignore index e1e7a1b..f0934a4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ dist/ *.lock *.db server-secret.bin -/passkeyauth/frontend-static +/passkey/frontend-build diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..a10d522 --- /dev/null +++ b/Caddyfile @@ -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 + } +} diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico deleted file mode 100644 index df36fcfb72584e00488330b560ebcf34a41c64c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4286 zcmZQzU}RuqP*4ET3Jfa*7#PGD7#K7d7#I{77#JKFAmR)lAOIqU2X5Zs$bgLL=_@3A zjhlBkf-u-E^l}5#e(vTSjvJvE#HNe&P`g3?jcVTE_#KKtY>*hu-2k;;s(FXw$>tr7 z|DhPf28q$seyH6be^xc`aQp|g8{`HM8zcsjqlp`k?AB}E;dmd(Zjk*T3=#v$(Zmf< z`&pZJIL^XiH^_bv2FZccP&Evoc7y!o(Y(X)10MT9av(JzwN!Hh)P8~H9gaKj*bVYO z2!qss)KbNMsNEp{q&4qw{6&QQAT=PhAUzbj0cyWO^A5*L7iKh$o<<{gf0*zB%ZV*ek6akv4b2c(xQH$d$Mg`s)#4##Kc_BU;D{GXL$3C18c zx;#`5NH53?lHCBcpR;*~<1!4hcRKzrpKSX--p3S-L2Mjh0MZLGgCzT*c7xm<)V#y- z3yS?a9sf71bNHW@Xz@SJ!xW4`Y>*fhH-Pkl%mA51v>TxIi#G3YJcMF5D4p$e{9n{+ z^FPkh6a|CCu-Feuiy$*VW)WpS)NYV_3!8U1{z0*Sr{n+HW%mD*!_C3|hP%PT6f6dk z!{P>z86dMjW)gG*)P9ZT9geq9><0OLyW{`dGAmTOVd3Cm3YKf$4zCkIeurU@Ss*j< z+7Gpxxp{}*uwyWnns+ArO_!|@D?-JmqL!|{JXy){Z+gZlMPypL%f2*-Jv z{(*|Y)q(V2GYe`5$S$z`P`g3ysYPp3f$NrjCM-5(c2Q8ptk?oiME5yuZM-3=hU zAT!X-h1vzO6J$So^A5*3=xSPaIsUJhX8S+E&kP=>F!STRO_wBvm~$isnlXSdhz$~h z$-`)nUXU3ev(U|l+6l5d9HUJID&yBX{7+ATmhrH(1){x7pC(=HT=LB0y}A7)UP8)AS^=9*`Lzvp{CT%!kq-J3)5yHScf)ByT?WW^if zV!|8eX^Mgqe9c%vaSpN<8H2>Ya%k#7X5$(!Tt{e|LXt$|6>oq zKji=a2jLI=|NlQ=hu{Ou|Nnz<1LOby3=ClWkAa~cg#R!w*n{v71_t>L3=I4r{D6Uh a9fS`sFffB~17iat17ib}cK|F4vKj!X^7qaF diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 548dbc6..1ba521c 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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' } diff --git a/passkey/__init__.py b/passkey/__init__.py new file mode 100644 index 0000000..6b98a9b --- /dev/null +++ b/passkey/__init__.py @@ -0,0 +1,3 @@ +from .sansio import Passkey + +__all__ = ["Passkey"] diff --git a/passkey/aaguid/__init__.py b/passkey/aaguid/__init__.py new file mode 100644 index 0000000..351702d --- /dev/null +++ b/passkey/aaguid/__init__.py @@ -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} diff --git a/passkeyauth/combined_aaguid.json b/passkey/aaguid/combined_aaguid.json similarity index 100% rename from passkeyauth/combined_aaguid.json rename to passkey/aaguid/combined_aaguid.json diff --git a/passkeyauth/db.py b/passkey/db/sql.py similarity index 99% rename from passkeyauth/db.py rename to passkey/db/sql.py index 37151b6..b21c91a 100644 --- a/passkeyauth/db.py +++ b/passkey/db/sql.py @@ -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" diff --git a/passkeyauth/api_handlers.py b/passkey/fastapi/api_handlers.py similarity index 92% rename from passkeyauth/api_handlers.py rename to passkey/fastapi/api_handlers.py index ad1b8fd..985535b 100644 --- a/passkeyauth/api_handlers.py +++ b/passkey/fastapi/api_handlers.py @@ -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"} diff --git a/passkey/fastapi/main.py b/passkey/fastapi/main.py new file mode 100644 index 0000000..f5b793f --- /dev/null +++ b/passkey/fastapi/main.py @@ -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() diff --git a/passkeyauth/reset_handlers.py b/passkey/fastapi/reset_handlers.py similarity index 90% rename from passkeyauth/reset_handlers.py rename to passkey/fastapi/reset_handlers.py index 1b1fedc..5317ecf 100644 --- a/passkeyauth/reset_handlers.py +++ b/passkey/fastapi/reset_handlers.py @@ -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", diff --git a/passkeyauth/session_manager.py b/passkey/fastapi/session_manager.py similarity index 96% rename from passkeyauth/session_manager.py rename to passkey/fastapi/session_manager.py index a663cdb..be94a3c 100644 --- a/passkeyauth/session_manager.py +++ b/passkey/fastapi/session_manager.py @@ -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 diff --git a/passkey/fastapi/ws_handlers.py b/passkey/fastapi/ws_handlers.py new file mode 100644 index 0000000..17eabb7 --- /dev/null +++ b/passkey/fastapi/ws_handlers.py @@ -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"}) diff --git a/passkeyauth/passkey.py b/passkey/sansio.py similarity index 100% rename from passkeyauth/passkey.py rename to passkey/sansio.py diff --git a/passkeyauth/jwt_manager.py b/passkey/util/jwt.py similarity index 95% rename from passkeyauth/jwt_manager.py rename to passkey/util/jwt.py index a13152c..40eb0a8 100644 --- a/passkeyauth/jwt_manager.py +++ b/passkey/util/jwt.py @@ -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: diff --git a/passkeyauth/passphrase.py b/passkey/util/passphrase.py similarity index 100% rename from passkeyauth/passphrase.py rename to passkey/util/passphrase.py diff --git a/passkeyauth/wordlist.py b/passkey/util/wordlist.py similarity index 100% rename from passkeyauth/wordlist.py rename to passkey/util/wordlist.py diff --git a/passkeyauth/__init__.py b/passkeyauth/__init__.py deleted file mode 100644 index 5770436..0000000 --- a/passkeyauth/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# passkeyauth package diff --git a/passkeyauth/aaguid_manager.py b/passkeyauth/aaguid_manager.py deleted file mode 100644 index 63c5e3c..0000000 --- a/passkeyauth/aaguid_manager.py +++ /dev/null @@ -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 diff --git a/passkeyauth/main.py b/passkeyauth/main.py deleted file mode 100644 index d470c26..0000000 --- a/passkeyauth/main.py +++ /dev/null @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 11c433a..371ad4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"]