A lot of cleanup, restructuring project directory.

This commit is contained in:
Leo Vasanko 2025-07-14 11:54:04 -06:00
parent 1c79132e22
commit 3567b7802b
21 changed files with 497 additions and 483 deletions

2
.gitignore vendored
View File

@ -5,4 +5,4 @@ dist/
*.lock
*.db
server-secret.bin
/passkeyauth/frontend-static
/passkey/frontend-build

23
Caddyfile Normal file
View 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

View File

@ -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
View File

@ -0,0 +1,3 @@
from .sansio import Passkey
__all__ = ["Passkey"]

View 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}

View File

@ -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"

View File

@ -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
View 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()

View File

@ -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",

View File

@ -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

View 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"})

View File

@ -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:

View File

@ -1 +0,0 @@
# passkeyauth package

View File

@ -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

View File

@ -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()

View File

@ -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"]