Major refactor: HTTP-only cookies, passkey management, and UI improvements

- Refactor session management from WebSocket tokens to HTTP-only cookies
- Move user/credential endpoints from WebSocket to HTTP REST API
- Add comprehensive passkey management (add/delete with safety checks)
- Implement AAGUID-based authenticator info with icons and names
- Add human-readable date formatting and clean grid layout
- Create modular architecture with session_manager, api_handlers, aaguid_manager
This commit is contained in:
Leo Vasanko 2025-07-06 19:45:33 -06:00
parent 9c2b7cf450
commit eb56c000e8
11 changed files with 1380 additions and 93 deletions

View File

@ -0,0 +1,65 @@
"""
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

247
passkeyauth/api_handlers.py Normal file
View File

@ -0,0 +1,247 @@
"""
API endpoints for user management and session handling.
This module contains all the HTTP API endpoints for:
- User information retrieval
- User credentials management
- Session token validation and refresh
- Login/logout functionality
"""
from fastapi import Request, Response
from .aaguid_manager import get_aaguid_manager
from .db import connect
from .jwt_manager import refresh_session_token, validate_session_token
from .session_manager import (
clear_session_cookie,
get_current_user,
get_session_token_from_auth_header_or_body,
get_session_token_from_request,
set_session_cookie,
)
async def get_user_info(request: Request) -> dict:
"""Get user information from session cookie."""
try:
user = await get_current_user(request)
if not user:
return {"error": "Not authenticated"}
return {
"status": "success",
"user": {
"user_id": str(user.user_id),
"user_name": user.user_name,
"created_at": user.created_at.isoformat() if user.created_at else None,
"last_seen": user.last_seen.isoformat() if user.last_seen else None,
},
}
except Exception as e:
return {"error": f"Failed to get user info: {str(e)}"}
async def get_user_credentials(request: Request) -> dict:
"""Get all credentials for a user using session cookie."""
try:
user = await get_current_user(request)
if not user:
return {"error": "Not authenticated"}
# Get current session credential ID
current_credential_id = None
session_token = get_session_token_from_request(request)
if session_token:
token_data = validate_session_token(session_token)
if token_data:
current_credential_id = token_data.get("credential_id")
async with connect() as db:
# Get all credentials for the user
credential_ids = await db.get_credentials_by_user_id(user.user_id.bytes)
credentials = []
user_aaguids = set()
for cred_id in credential_ids:
try:
stored_cred = await db.get_credential_by_id(cred_id)
# Convert AAGUID to string format
aaguid_str = str(stored_cred.aaguid)
user_aaguids.add(aaguid_str)
# Check if this is the current session credential
is_current_session = (
current_credential_id == stored_cred.credential_id
)
credentials.append(
{
"credential_id": stored_cred.credential_id.hex(),
"aaguid": aaguid_str,
"created_at": stored_cred.created_at.isoformat(),
"last_used": stored_cred.last_used.isoformat()
if stored_cred.last_used
else None,
"last_verified": stored_cred.last_verified.isoformat()
if stored_cred.last_verified
else None,
"sign_count": stored_cred.sign_count,
"is_current_session": is_current_session,
}
)
except ValueError:
# Skip invalid credentials
continue
# 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)
# Sort credentials by creation date (earliest first, most recently created last)
credentials.sort(key=lambda cred: cred["created_at"])
return {
"status": "success",
"credentials": credentials,
"aaguid_info": aaguid_info,
}
except Exception as e:
return {"error": f"Failed to get credentials: {str(e)}"}
async def refresh_token(request: Request, response: Response) -> dict:
"""Refresh the session token."""
try:
session_token = get_session_token_from_request(request)
if not session_token:
return {"error": "No session token found"}
# Validate and refresh the token
new_token = refresh_session_token(session_token)
if new_token:
set_session_cookie(response, new_token)
return {"status": "success", "refreshed": True}
else:
clear_session_cookie(response)
return {"error": "Invalid or expired session token"}
except Exception as e:
return {"error": f"Failed to refresh token: {str(e)}"}
async def validate_token(request: Request) -> dict:
"""Validate a session token and return user info."""
try:
session_token = get_session_token_from_request(request)
if not session_token:
return {"error": "No session token found"}
# Validate the session token
token_data = validate_session_token(session_token)
if not token_data:
return {"error": "Invalid or expired session token"}
return {
"status": "success",
"valid": True,
"user_id": str(token_data["user_id"]),
"credential_id": token_data["credential_id"].hex(),
"issued_at": token_data["issued_at"],
"expires_at": token_data["expires_at"],
}
except Exception as e:
return {"error": f"Failed to validate token: {str(e)}"}
async def logout(response: Response) -> dict:
"""Log out the current user by clearing the session cookie."""
clear_session_cookie(response)
return {"status": "success", "message": "Logged out successfully"}
async def set_session(request: Request, response: Response) -> dict:
"""Set session cookie using JWT token from request body or Authorization header."""
try:
session_token = await get_session_token_from_auth_header_or_body(request)
if not session_token:
return {"error": "No session token provided"}
# Validate the session token
token_data = validate_session_token(session_token)
if not token_data:
return {"error": "Invalid or expired session token"}
# Set the HTTP-only cookie
set_session_cookie(response, session_token)
return {
"status": "success",
"message": "Session cookie set successfully",
"user_id": str(token_data["user_id"]),
}
except Exception as e:
return {"error": f"Failed to set session: {str(e)}"}
async def delete_credential(request: Request) -> dict:
"""Delete a specific credential for the current user."""
try:
user = await get_current_user(request)
if not user:
return {"error": "Not authenticated"}
# Get the credential ID from the request body
try:
body = await request.json()
credential_id = body.get("credential_id")
if not credential_id:
return {"error": "credential_id is required"}
except Exception:
return {"error": "Invalid request body"}
# Convert credential_id from hex string to bytes
try:
credential_id_bytes = bytes.fromhex(credential_id)
except ValueError:
return {"error": "Invalid credential_id format"}
async with connect() as db:
# First, verify the credential belongs to the current user
try:
stored_cred = await db.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:
return {"error": "Credential not found"}
# Check if this is the current session credential
session_token = get_session_token_from_request(request)
if session_token:
token_data = validate_session_token(session_token)
if (
token_data
and token_data.get("credential_id") == credential_id_bytes
):
return {"error": "Cannot delete current session credential"}
# Get user's remaining credentials count
remaining_credentials = await db.get_credentials_by_user_id(
user.user_id.bytes
)
if len(remaining_credentials) <= 1:
return {"error": "Cannot delete last remaining credential"}
# Delete the credential
await db.delete_credential(credential_id_bytes)
return {"status": "success", "message": "Credential deleted successfully"}
except Exception as e:
return {"error": f"Failed to delete credential: {str(e)}"}

File diff suppressed because one or more lines are too long

View File

@ -54,22 +54,11 @@ SQL_STORE_CREDENTIAL = """
""" """
SQL_GET_CREDENTIAL_BY_ID = """ SQL_GET_CREDENTIAL_BY_ID = """
SELECT credential_id, user_id, aaguid, public_key, sign_count, created_at, last_used, last_verified SELECT * FROM credentials WHERE credential_id = ?
FROM credentials
WHERE credential_id = ?
""" """
SQL_GET_USER_CREDENTIALS = """ SQL_GET_USER_CREDENTIALS = """
SELECT c.credential_id SELECT credential_id FROM credentials WHERE user_id = ?
FROM credentials c
JOIN users u ON c.user_id = u.user_id
WHERE u.user_id = ?
"""
SQL_UPDATE_CREDENTIAL_SIGN_COUNT = """
UPDATE credentials
SET sign_count = ?, last_used = CURRENT_TIMESTAMP
WHERE credential_id = ?
""" """
SQL_UPDATE_CREDENTIAL = """ SQL_UPDATE_CREDENTIAL = """
@ -78,11 +67,13 @@ SQL_UPDATE_CREDENTIAL = """
WHERE credential_id = ? WHERE credential_id = ?
""" """
SQL_DELETE_CREDENTIAL = """
DELETE FROM credentials WHERE credential_id = ?
"""
@dataclass @dataclass
class User: class User:
"""User data model."""
user_id: UUID user_id: UUID
user_name: str user_name: str
created_at: datetime | None = None created_at: datetime | None = None
@ -118,8 +109,8 @@ class DB:
return User( return User(
user_id=UUID(bytes=row[0]), user_id=UUID(bytes=row[0]),
user_name=row[1], user_name=row[1],
created_at=row[2], created_at=_convert_datetime(row[2]),
last_seen=row[3], last_seen=_convert_datetime(row[3]),
) )
raise ValueError("User not found") raise ValueError("User not found")
@ -127,7 +118,12 @@ class DB:
"""Create a new user and return the User dataclass.""" """Create a new user and return the User dataclass."""
await self.conn.execute( await self.conn.execute(
SQL_CREATE_USER, SQL_CREATE_USER,
(user.user_id.bytes, user.user_name, user.created_at, user.last_seen), (
user.user_id.bytes,
user.user_name,
user.created_at or datetime.now(),
user.last_seen,
),
) )
async def create_credential(self, credential: StoredCredential) -> None: async def create_credential(self, credential: StoredCredential) -> None:
@ -159,9 +155,9 @@ class DB:
aaguid=UUID(bytes=row[2]), aaguid=UUID(bytes=row[2]),
public_key=row[3], public_key=row[3],
sign_count=row[4], sign_count=row[4],
created_at=row[5], created_at=datetime.fromisoformat(row[5]),
last_used=row[6], last_used=_convert_datetime(row[6]),
last_verified=row[7], last_verified=_convert_datetime(row[7]),
) )
raise ValueError("Credential not registered") raise ValueError("Credential not registered")
@ -192,3 +188,13 @@ class DB:
"UPDATE users SET last_seen = ? WHERE user_id = ?", "UPDATE users SET last_seen = ? WHERE user_id = ?",
(credential.last_used, user_id), (credential.last_used, user_id),
) )
async def delete_credential(self, credential_id: bytes) -> None:
"""Delete a credential by its ID."""
await self.conn.execute(SQL_DELETE_CREDENTIAL, (credential_id,))
await self.conn.commit()
def _convert_datetime(val):
"""Convert string from SQLite to datetime object (pass through None)."""
return val and datetime.fromisoformat(val)

132
passkeyauth/jwt_manager.py Normal file
View File

@ -0,0 +1,132 @@
"""
JWT session management for WebAuthn authentication.
This module provides JWT token generation and validation for managing user sessions
after successful WebAuthn authentication. Tokens contain user ID and credential ID
for session validation.
"""
import secrets
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from uuid import UUID
import jwt
SECRET_FILE = Path("server-secret.bin")
def load_or_create_secret() -> bytes:
"""Load JWT secret from file or create a new one."""
if SECRET_FILE.exists():
return SECRET_FILE.read_bytes()
else:
# Generate a new 32-byte secret
secret = secrets.token_bytes(32)
SECRET_FILE.write_bytes(secret)
return secret
class JWTManager:
"""Manages JWT tokens for user sessions."""
def __init__(self, secret_key: bytes, algorithm: str = "HS256"):
self.secret_key = secret_key
self.algorithm = algorithm
self.token_expiry = timedelta(hours=24) # Tokens expire after 24 hours
def create_token(self, user_id: UUID, credential_id: bytes) -> str:
"""
Create a JWT token for a user session.
Args:
user_id: The user's UUID
credential_id: The credential ID used for authentication
Returns:
JWT token string
"""
now = datetime.utcnow()
payload = {
"user_id": str(user_id),
"credential_id": credential_id.hex(),
"iat": now,
"exp": now + self.token_expiry,
"iss": "passkeyauth",
}
return jwt.encode(payload, self.secret_key, algorithm=self.algorithm)
def validate_token(self, token: str) -> Optional[dict]:
"""
Validate a JWT token and return the payload.
Args:
token: JWT token string
Returns:
Dictionary with user_id and credential_id, or None if invalid
"""
try:
payload = jwt.decode(
token,
self.secret_key,
algorithms=[self.algorithm],
issuer="passkeyauth",
)
return {
"user_id": UUID(payload["user_id"]),
"credential_id": bytes.fromhex(payload["credential_id"]),
"issued_at": payload["iat"],
"expires_at": payload["exp"],
}
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def refresh_token(self, token: str) -> Optional[str]:
"""
Refresh a JWT token if it's still valid.
Args:
token: Current JWT token
Returns:
New JWT token string, or None if the current token is invalid
"""
payload = self.validate_token(token)
if payload is None:
return None
return self.create_token(payload["user_id"], payload["credential_id"])
# Global JWT manager instance
_jwt_manager: Optional[JWTManager] = None
def get_jwt_manager() -> JWTManager:
"""Get the global JWT manager instance."""
global _jwt_manager
if _jwt_manager is None:
secret = load_or_create_secret()
_jwt_manager = JWTManager(secret)
return _jwt_manager
def create_session_token(user_id: UUID, credential_id: bytes) -> str:
"""Create a session token for a user."""
return get_jwt_manager().create_token(user_id, credential_id)
def validate_session_token(token: str) -> Optional[dict]:
"""Validate a session token."""
return get_jwt_manager().validate_token(token)
def refresh_session_token(token: str) -> Optional[str]:
"""Refresh a session token."""
return get_jwt_manager().refresh_token(token)

View File

@ -10,18 +10,31 @@ This module provides a simple WebAuthn implementation that:
""" """
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path from pathlib import Path
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from .api_handlers import (
delete_credential,
get_user_credentials,
get_user_info,
logout,
refresh_token,
set_session,
validate_token,
)
from .db import User, connect from .db import User, connect
from .jwt_manager import create_session_token
from .passkey import Passkey from .passkey import Passkey
from .session_manager import get_user_from_cookie_string
STATIC_DIR = Path(__file__).parent.parent / "static" STATIC_DIR = Path(__file__).parent.parent / "static"
passkey = Passkey( passkey = Passkey(
rp_id="localhost", rp_id="localhost",
rp_name="Passkey Auth", rp_name="Passkey Auth",
@ -55,14 +68,66 @@ async def websocket_register_new(ws: WebSocket):
# Store the user in the database # Store the user in the database
async with connect() as db: async with connect() as db:
await db.conn.execute("BEGIN") await db.conn.execute("BEGIN")
await db.create_user(User(user_id, user_name)) await db.create_user(User(user_id, user_name, created_at=datetime.now()))
await db.create_credential(credential) await db.create_credential(credential)
await ws.send_json({"status": "success", "user_id": str(user_id)}) # 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: except WebSocketDisconnect:
pass pass
@app.websocket("/ws/add_credential")
async def websocket_register_add(ws: WebSocket):
"""Register a new credential for an existing user."""
await ws.accept()
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
async with connect() as db:
user = await db.get_user_by_user_id(user_id.bytes)
user_name = user.user_name
# WebAuthn registration
credential = await register_chat(ws, user_id, user_name)
print(f"New credential for user {user_id}: {credential}")
# Store the new credential in the database
async with connect() as db:
await db.create_credential(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 as e:
await ws.send_json({"error": f"Server error: {str(e)}"})
async def register_chat(ws: WebSocket, user_id: UUID, user_name: str): async def register_chat(ws: WebSocket, user_id: UUID, user_name: str):
"""Generate registration options and send them to the client.""" """Generate registration options and send them to the client."""
options, challenge = passkey.reg_generate_options( options, challenge = passkey.reg_generate_options(
@ -71,6 +136,7 @@ async def register_chat(ws: WebSocket, user_id: UUID, user_name: str):
) )
await ws.send_json(options) await ws.send_json(options)
response = await ws.receive_json() response = await ws.receive_json()
print(response)
return passkey.reg_verify(response, challenge, user_id) return passkey.reg_verify(response, challenge, user_id)
@ -87,14 +153,69 @@ async def websocket_authenticate(ws: WebSocket):
stored_cred = await db.get_credential_by_id(credential.raw_id) stored_cred = await db.get_credential_by_id(credential.raw_id)
# Verify the credential matches the stored data # Verify the credential matches the stored data
await passkey.auth_verify(credential, challenge, stored_cred) await passkey.auth_verify(credential, challenge, stored_cred)
await db.update_credential(stored_cred) # Update both credential and user's last_seen timestamp
await ws.send_json({"status": "success"}) await db.login(stored_cred.user_id.bytes, 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 as e: except ValueError as e:
await ws.send_json({"error": str(e)}) await ws.send_json({"error": str(e)})
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
@app.get("/api/user-info")
async def api_get_user_info(request: Request):
"""Get user information from session cookie."""
return await get_user_info(request)
@app.get("/api/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("/api/refresh-token")
async def api_refresh_token(request: Request, response: Response):
"""Refresh the session token."""
return await refresh_token(request, response)
@app.get("/api/validate-token")
async def api_validate_token(request: Request):
"""Validate a session token and return user info."""
return await validate_token(request)
@app.post("/api/logout")
async def api_logout(response: Response):
"""Log out the current user by clearing the session cookie."""
return await logout(response)
@app.post("/api/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("/api/delete-credential")
async def api_delete_credential(request: Request):
"""Delete a specific credential for the current user."""
return await delete_credential(request)
# Serve static files # Serve static files
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")

View File

@ -28,6 +28,7 @@ from webauthn.helpers import (
) )
from webauthn.helpers.cose import COSEAlgorithmIdentifier from webauthn.helpers.cose import COSEAlgorithmIdentifier
from webauthn.helpers.structs import ( from webauthn.helpers.structs import (
AttestationConveyancePreference,
AuthenticationCredential, AuthenticationCredential,
AuthenticatorSelectionCriteria, AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor, PublicKeyCredentialDescriptor,
@ -109,6 +110,7 @@ class Passkey:
rp_name=self.rp_name, rp_name=self.rp_name,
user_id=user_id.bytes, user_id=user_id.bytes,
user_name=user_name, user_name=user_name,
attestation=AttestationConveyancePreference.DIRECT,
authenticator_selection=AuthenticatorSelectionCriteria( authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.REQUIRED, resident_key=ResidentKeyRequirement.REQUIRED,
user_verification=UserVerificationRequirement.PREFERRED, user_verification=UserVerificationRequirement.PREFERRED,

View File

@ -0,0 +1,107 @@
"""
Session management for WebAuthn authentication.
This module provides session management functionality including:
- Getting current user from session cookies
- Setting and clearing HTTP-only cookies
- Session validation and token handling
"""
from typing import Optional
from uuid import UUID
from fastapi import Request, Response
from .db import User, connect
from .jwt_manager import validate_session_token
COOKIE_NAME = "session_token"
COOKIE_MAX_AGE = 86400 # 24 hours
async def get_current_user(request: Request) -> Optional[User]:
"""Get the current user from the session cookie."""
session_token = request.cookies.get(COOKIE_NAME)
if not session_token:
return None
token_data = validate_session_token(session_token)
if not token_data:
return None
async with connect() as db:
try:
user = await db.get_user_by_user_id(token_data["user_id"].bytes)
return user
except Exception:
return None
def set_session_cookie(response: Response, session_token: str) -> None:
"""Set the session token as an HTTP-only cookie."""
response.set_cookie(
key=COOKIE_NAME,
value=session_token,
max_age=COOKIE_MAX_AGE,
httponly=True,
secure=False, # Set to True in production with HTTPS
samesite="lax",
)
def clear_session_cookie(response: Response) -> None:
"""Clear the session cookie."""
response.delete_cookie(key=COOKIE_NAME)
def get_session_token_from_request(request: Request) -> Optional[str]:
"""Extract session token from request cookies."""
return request.cookies.get(COOKIE_NAME)
async def validate_session_from_request(request: Request) -> Optional[dict]:
"""Validate session token from request and return token data."""
session_token = get_session_token_from_request(request)
if not session_token:
return None
return validate_session_token(session_token)
async def get_session_token_from_auth_header_or_body(request: Request) -> Optional[str]:
"""Extract session token from Authorization header or request body."""
# Try to get token from Authorization header first
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
return auth_header[7:] # Remove "Bearer " prefix
# Try to get from request body
try:
body = await request.json()
return body.get("session_token")
except Exception:
return None
async def get_user_from_cookie_string(cookie_header: str) -> Optional[UUID]:
"""Parse cookie header and return user ID if valid session exists."""
if not cookie_header:
return None
# Parse cookies from header (simple implementation)
cookies = {}
for cookie in cookie_header.split(";"):
cookie = cookie.strip()
if "=" in cookie:
name, value = cookie.split("=", 1)
cookies[name] = value
session_token = cookies.get(COOKIE_NAME)
if not session_token:
return None
token_data = validate_session_token(session_token)
if not token_data:
return None
return token_data["user_id"]

View File

@ -17,6 +17,7 @@ dependencies = [
"base64url>=1.0.0", "base64url>=1.0.0",
"aiosqlite>=0.19.0", "aiosqlite>=0.19.0",
"uuid7-standard>=1.0.0", "uuid7-standard>=1.0.0",
"pyjwt>=2.8.0",
] ]
requires-python = ">=3.10" requires-python = ">=3.10"

View File

@ -1,54 +1,434 @@
const { startRegistration, startAuthentication } = SimpleWebAuthnBrowser const { startRegistration, startAuthentication } = SimpleWebAuthnBrowser
// Global state
let currentUser = null
let currentCredentials = []
let aaguidInfo = {}
// Session management - now using HTTP-only cookies
async function validateStoredToken() {
try {
const response = await fetch('/api/validate-token', {
method: 'GET',
credentials: 'include'
})
const result = await response.json()
if (result.status === 'success') {
return true
} else {
return false
}
} catch (error) {
return false
}
}
// Helper function to set session cookie using JWT token
async function setSessionCookie(sessionToken) {
try {
const response = await fetch('/api/set-session', {
method: 'POST',
headers: {
'Authorization': `Bearer ${sessionToken}`,
'Content-Type': 'application/json'
},
credentials: 'include'
})
const result = await response.json()
if (result.error) {
throw new Error(result.error)
}
return result
} catch (error) {
throw new Error(`Failed to set session cookie: ${error.message}`)
}
}
// View management
function showView(viewId) {
document.querySelectorAll('.view').forEach(view => view.classList.remove('active'))
document.getElementById(viewId).classList.add('active')
}
function showLoginView() {
showView('loginView')
clearStatus('loginStatus')
}
function showRegisterView() {
showView('registerView')
clearStatus('registerStatus')
}
// Update dashboard view to load user info
function showDashboardView() {
showView('dashboardView')
clearStatus('dashboardStatus')
loadUserInfo().then(() => {
updateUserInfo()
loadCredentials()
}).catch(error => {
showStatus('dashboardStatus', `Failed to load user info: ${error.message}`, 'error')
})
}
// Status management
function showStatus(elementId, message, type = 'info') {
const statusEl = document.getElementById(elementId)
statusEl.innerHTML = `<div class="status ${type}">${message}</div>`
}
function clearStatus(elementId) {
document.getElementById(elementId).innerHTML = ''
}
// User registration
async function register(user_name) { async function register(user_name) {
const ws = await aWebSocket('/ws/new_user_registration') try {
ws.send(JSON.stringify({user_name})) const ws = await aWebSocket('/ws/new_user_registration')
// Registration chat ws.send(JSON.stringify({user_name}))
const optionsJSON = JSON.parse(await ws.recv())
if (optionsJSON.error) throw new Error(optionsJSON.error) // Registration chat
ws.send(JSON.stringify(await startRegistration({optionsJSON}))) const optionsJSON = JSON.parse(await ws.recv())
const result = JSON.parse(await ws.recv()) if (optionsJSON.error) throw new Error(optionsJSON.error)
if (result.error) throw new Error(`Server: ${result.error}`)
showStatus('registerStatus', 'Save to your authenticator...', 'info')
const registrationResponse = await startRegistration({optionsJSON})
ws.send(JSON.stringify(registrationResponse))
const result = JSON.parse(await ws.recv())
if (result.error) throw new Error(`Server: ${result.error}`)
ws.close()
// Set session cookie using the JWT token
await setSessionCookie(result.session_token)
// Set current user from registration result
currentUser = {
user_id: result.user_id,
user_name: user_name,
last_seen: new Date().toISOString()
}
return result
} catch (error) {
throw error
}
} }
// User authentication
async function authenticate() { async function authenticate() {
// Authentication chat try {
const ws = await aWebSocket('/ws/authenticate') const ws = await aWebSocket('/ws/authenticate')
const optionsJSON = JSON.parse(await ws.recv()) const optionsJSON = JSON.parse(await ws.recv())
if (optionsJSON.error) throw new Error(optionsJSON.error) if (optionsJSON.error) throw new Error(optionsJSON.error)
await ws.send(JSON.stringify(await startAuthentication({optionsJSON})))
const result = JSON.parse(await ws.recv()) showStatus('loginStatus', 'Please touch your authenticator...', 'info')
if (result.error) throw new Error(`Server: ${result.error}`)
return result const authResponse = await startAuthentication({optionsJSON})
await ws.send(JSON.stringify(authResponse))
const result = JSON.parse(await ws.recv())
if (result.error) throw new Error(`Server: ${result.error}`)
ws.close()
// Set session cookie using the JWT token
await setSessionCookie(result.session_token)
// Authentication successful, now get user info using HTTP endpoint
const userResponse = await fetch('/api/user-info', {
method: 'GET',
credentials: 'include'
})
const userInfo = await userResponse.json()
if (userInfo.error) throw new Error(`Server: ${userInfo.error}`)
currentUser = userInfo.user
return result
} catch (error) {
throw error
}
} }
(function() { // Load user credentials
async function loadCredentials() {
try {
showStatus('dashboardStatus', 'Loading credentials...', 'info')
const response = await fetch('/api/user-credentials', {
method: 'GET',
credentials: 'include'
})
const result = await response.json()
if (result.error) throw new Error(`Server: ${result.error}`)
currentCredentials = result.credentials
aaguidInfo = result.aaguid_info || {}
updateCredentialList()
clearStatus('dashboardStatus')
} catch (error) {
showStatus('dashboardStatus', `Failed to load credentials: ${error.message}`, 'error')
}
}
// Load user info using HTTP endpoint
async function loadUserInfo() {
try {
const response = await fetch('/api/user-info', {
method: 'GET',
credentials: 'include'
})
const result = await response.json()
if (result.error) throw new Error(`Server: ${result.error}`)
currentUser = result.user
} catch (error) {
throw error
}
}
// Update user info display
function updateUserInfo() {
const userInfoEl = document.getElementById('userInfo')
if (currentUser) {
userInfoEl.innerHTML = `
<h3>👤 ${currentUser.user_name}</h3>
`
}
}
// Update credential list display
function updateCredentialList() {
const credentialListEl = document.getElementById('credentialList')
if (currentCredentials.length === 0) {
credentialListEl.innerHTML = '<p>No passkeys found.</p>'
return
}
credentialListEl.innerHTML = currentCredentials.map(cred => {
// Get authenticator information from AAGUID
const authInfo = aaguidInfo[cred.aaguid]
const authName = authInfo ? authInfo.name : 'Unknown Authenticator'
// Determine which icon to use based on current theme (you can implement theme detection)
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
const authIcon = authInfo && authInfo[iconKey] ? authInfo[iconKey] : null
// Check if this is the current session credential
const isCurrentSession = cred.is_current_session || false
return `
<div class="credential-item${isCurrentSession ? ' current-session' : ''}">
<div class="credential-header">
<div class="credential-icon">
${authIcon ? `<img src="${authIcon}" alt="${authName}" class="auth-icon" width="32" height="32">` : '<span class="auth-emoji">🔑</span>'}
</div>
<div class="credential-info">
<h4>${authName}</h4>
</div>
<div class="credential-dates">
<span class="date-label">Created:</span>
<span class="date-value">${formatHumanReadableDate(cred.created_at)}</span>
<span class="date-label">Last used:</span>
<span class="date-value">${formatHumanReadableDate(cred.last_used)}</span>
</div>
<div class="credential-actions">
<button onclick="deleteCredential('${cred.credential_id}')"
class="btn-delete-credential"
${isCurrentSession ? 'disabled title="Cannot delete current session credential"' : ''}>
🗑
</button>
</div>
</div>
</div>
`
}).join('')
}
// Helper function to format dates in a human-readable way
function formatHumanReadableDate(dateString) {
if (!dateString) return 'Never'
const date = new Date(dateString)
const now = new Date()
const diffMs = now - date
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffHours < 1) {
return 'Just now'
} else if (diffHours < 24) {
return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`
} else if (diffDays <= 7) {
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`
} else {
// For dates older than 7 days, show just the date without time
return date.toLocaleDateString()
}
}
// Logout
async function logout() {
try {
await fetch('/api/logout', {
method: 'POST',
credentials: 'include'
})
} catch (error) {
console.error('Logout error:', error)
}
currentUser = null
currentCredentials = []
aaguidInfo = {}
showLoginView()
}
// Check if user is already logged in on page load
async function checkExistingSession() {
if (await validateStoredToken()) {
showDashboardView()
} else {
showLoginView()
}
}
// Add new credential for logged-in user
async function addNewCredential() {
try {
showStatus('dashboardStatus', 'Starting new passkey registration...', 'info')
const ws = await aWebSocket('/ws/add_credential')
// Registration chat - no need to send user data since we're authenticated
const optionsJSON = JSON.parse(await ws.recv())
if (optionsJSON.error) throw new Error(optionsJSON.error)
showStatus('dashboardStatus', 'Save new passkey to your authenticator...', 'info')
const registrationResponse = await startRegistration({optionsJSON})
ws.send(JSON.stringify(registrationResponse))
const result = JSON.parse(await ws.recv())
if (result.error) throw new Error(`Server: ${result.error}`)
ws.close()
showStatus('dashboardStatus', 'New passkey added successfully!', 'success')
// Refresh credentials list to show the new credential
await loadCredentials()
clearStatus('dashboardStatus')
} catch (error) {
showStatus('dashboardStatus', `Failed to add new passkey: ${error.message}`, 'error')
}
}
// Delete credential
async function deleteCredential(credentialId) {
if (!confirm('Are you sure you want to delete this passkey? This action cannot be undone.')) {
return
}
try {
showStatus('dashboardStatus', 'Deleting passkey...', 'info')
const response = await fetch('/api/delete-credential', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
credential_id: credentialId
})
})
const result = await response.json()
if (result.error) {
throw new Error(result.error)
}
showStatus('dashboardStatus', 'Passkey deleted successfully!', 'success')
// Refresh credentials list
await loadCredentials()
clearStatus('dashboardStatus')
} catch (error) {
showStatus('dashboardStatus', `Failed to delete passkey: ${error.message}`, 'error')
}
}
// Form event handlers
document.addEventListener('DOMContentLoaded', function() {
// Check for existing session on page load
checkExistingSession()
// Registration form
const regForm = document.getElementById('registrationForm') const regForm = document.getElementById('registrationForm')
const regSubmitBtn = regForm.querySelector('button[type="submit"]') const regSubmitBtn = regForm.querySelector('button[type="submit"]')
regForm.addEventListener('submit', ev => {
regForm.addEventListener('submit', async (ev) => {
ev.preventDefault() ev.preventDefault()
regSubmitBtn.disabled = true regSubmitBtn.disabled = true
clearStatus('registerStatus')
const user_name = (new FormData(regForm)).get('username') const user_name = (new FormData(regForm)).get('username')
register(user_name).then(() => {
alert(`Registration successful for ${user_name}!`) try {
}).catch(err => { showStatus('registerStatus', 'Starting registration...', 'info')
alert(`Registration failed: ${err.message}`) await register(user_name)
}).finally(() => { showStatus('registerStatus', `Registration successful for ${user_name}!`, 'success')
// Auto-login after successful registration
setTimeout(() => {
showDashboardView()
}, 1500)
} catch (err) {
showStatus('registerStatus', `Registration failed: ${err.message}`, 'error')
} finally {
regSubmitBtn.disabled = false regSubmitBtn.disabled = false
}) }
}) })
// Authentication form
const authForm = document.getElementById('authenticationForm') const authForm = document.getElementById('authenticationForm')
const authSubmitBtn = authForm.querySelector('button[type="submit"]') const authSubmitBtn = authForm.querySelector('button[type="submit"]')
authForm.addEventListener('submit', ev => {
authForm.addEventListener('submit', async (ev) => {
ev.preventDefault() ev.preventDefault()
authSubmitBtn.disabled = true authSubmitBtn.disabled = true
authenticate().then(result => { clearStatus('loginStatus')
alert(`Authentication successful!`)
}).catch(err => { try {
alert(`Authentication failed: ${err.message}`) showStatus('loginStatus', 'Starting authentication...', 'info')
}).finally(() => { await authenticate()
showStatus('loginStatus', 'Authentication successful!', 'success')
// Navigate to dashboard
setTimeout(() => {
showDashboardView()
}, 1000)
} catch (err) {
showStatus('loginStatus', `Authentication failed: ${err.message}`, 'error')
} finally {
authSubmitBtn.disabled = false authSubmitBtn.disabled = false
}) }
}) })
})() })

View File

@ -1,65 +1,290 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>WebAuthn Registration Demo</title> <title>Passkey Authentication</title>
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js"></script> <script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js"></script>
<script src="/static/awaitable-websocket.js"></script> <script src="/static/awaitable-websocket.js"></script>
<style> <style>
body { body {
font-family: Arial, sans-serif; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 600px; margin: 0;
margin: 50px auto; padding: 0;
padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
} }
.container { .container {
text-align: center;
background: white; background: white;
padding: 30px; padding: 40px;
border-radius: 10px; border-radius: 15px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1); box-shadow: 0 10px 30px rgba(0,0,0,0.2);
width: 100%;
max-width: 400px;
text-align: center;
}
.view {
display: none;
}
.view.active {
display: block;
}
h1 {
color: #333;
margin-bottom: 30px;
font-weight: 300;
font-size: 28px;
}
h2 {
color: #555;
margin-bottom: 20px;
font-weight: 400;
font-size: 22px;
} }
input[type="text"] { input[type="text"] {
padding: 10px; width: 100%;
border: 2px solid #ddd; padding: 15px;
border-radius: 5px; border: 2px solid #e1e5e9;
border-radius: 8px;
font-size: 16px; font-size: 16px;
margin: 10px; margin-bottom: 20px;
width: 250px; box-sizing: border-box;
transition: border-color 0.3s ease;
}
input[type="text"]:focus {
outline: none;
border-color: #667eea;
} }
button { button {
padding: 12px 24px; width: 100%;
margin: 10px; padding: 15px;
font-size: 16px; margin-bottom: 15px;
font-size: 16px;
font-weight: 500;
cursor: pointer; cursor: pointer;
background: #007bff;
color: white;
border: none; border: none;
border-radius: 5px; border-radius: 8px;
transition: all 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: transparent;
color: #667eea;
border: 2px solid #667eea;
}
.btn-secondary:hover:not(:disabled) {
background: #667eea;
color: white;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c82333;
} }
button:disabled { button:disabled {
background: #ccc; background: #ccc !important;
cursor: not-allowed !important;
transform: none !important;
box-shadow: none !important;
}
.status {
padding: 10px;
margin: 15px 0;
border-radius: 5px;
font-size: 14px;
}
.status.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.status.info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.credential-list {
max-height: 300px;
overflow-y: auto;
margin: 20px 0;
}
.credential-item {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 15px;
margin: 10px 0;
text-align: left;
}
.credential-item.current-session {
border: 2px solid #007bff;
background: #f8f9ff;
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.2);
}
.credential-item.current-session .credential-info h4 {
color: #0056b3;
}
.credential-header {
display: grid;
grid-template-columns: 32px 1fr auto auto;
gap: 12px;
align-items: center;
margin-bottom: 10px;
}
.credential-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.auth-icon {
border-radius: 4px;
width: 32px;
height: 32px;
}
.auth-emoji {
font-size: 24px;
display: block;
text-align: center;
}
.credential-info {
min-width: 0;
}
.credential-info h4 {
margin: 0;
color: #333;
font-size: 16px;
}
.credential-dates {
text-align: right;
flex-shrink: 0;
margin-left: 20px;
display: grid;
grid-template-columns: auto auto;
gap: 5px 10px;
align-items: center;
}
.date-label {
color: #666;
font-weight: normal;
font-size: 12px;
text-align: right;
}
.date-value {
color: #333;
font-size: 12px;
text-align: left;
}
.user-info {
background: #e7f3ff;
border: 1px solid #bee5eb;
border-radius: 8px;
padding: 15px;
margin: 20px 0;
}
.user-info h3 {
margin: 0 0 10px 0;
color: #0c5460;
}
.user-info p {
margin: 5px 0;
color: #0c5460;
}
.toggle-link {
color: #667eea;
text-decoration: underline;
cursor: pointer;
font-size: 14px;
}
.toggle-link:hover {
color: #764ba2;
}
.hidden {
display: none;
}
.credential-actions {
display: flex;
align-items: center;
}
.btn-delete-credential {
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
font-size: 16px;
color: #dc3545;
transition: background-color 0.2s;
}
.btn-delete-credential:hover:not(:disabled) {
background-color: #f8d7da;
}
.btn-delete-credential:disabled {
opacity: 0.3;
cursor: not-allowed; cursor: not-allowed;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h1>WebAuthn Demo</h1> <!-- Login View -->
<div id="loginView" class="view active">
<div style="margin-bottom: 30px;"> <h1>🔐 Passkey Login</h1>
<h2>Register</h2> <div id="loginStatus"></div>
<form id="registrationForm"> <form id="authenticationForm">
<input type="text" name="username" placeholder="Username" required> <button type="submit" class="btn-primary">Login with Your Device</button>
<br>
<button type="submit">Register Passkey</button>
</form> </form>
<p class="toggle-link" onclick="showRegisterView()">
Don't have an account? Register here
</p>
</div> </div>
<div> <!-- Register View -->
<h2>Authenticate</h2> <div id="registerView" class="view">
<form id="authenticationForm"> <h1>🔐 Create Account</h1>
<button type="submit">Authenticate with Passkey</button> <div id="registerStatus"></div>
<form id="registrationForm">
<input type="text" name="username" placeholder="Enter username" required>
<button type="submit" class="btn-primary">Register Passkey</button>
</form> </form>
<p class="toggle-link" onclick="showLoginView()">
Already have an account? Login here
</p>
</div>
<!-- Dashboard View -->
<div id="dashboardView" class="view">
<h1>👋 Welcome!</h1>
<div id="userInfo" class="user-info"></div>
<div id="dashboardStatus"></div>
<h2>Your Passkeys</h2>
<div id="credentialList" class="credential-list">
<p>Loading credentials...</p>
</div>
<button onclick="addNewCredential()" class="btn-primary">
Add New Passkey
</button>
<button onclick="logout()" class="btn-danger">
Logout
</button>
</div> </div>
</div> </div>