From 58f7ac61db66196d874031f8db74fc0623535ebf Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Thu, 3 Jul 2025 17:01:41 -0600 Subject: [PATCH] A non-functional draft, saving to allow reverts. --- README.md | 122 +++++++++++++ dev.py | 9 + main.py | 326 ++++++++++++++++++++++++++++++++++ passkeyauth/.gitignore | 6 + passkeyauth/TODO.txt | 6 + passkeyauth/__init__.py | 1 + passkeyauth/db.py | 149 ++++++++++++++++ passkeyauth/main.py | 106 +++++++++++ passkeyauth/passkey.py | 166 +++++++++++++++++ pyproject.toml | 36 ++++ static/app.js | 55 ++++++ static/awaitable-websocket.js | 43 +++++ static/index.html | 68 +++++++ 13 files changed, 1093 insertions(+) create mode 100644 README.md create mode 100644 dev.py create mode 100644 main.py create mode 100644 passkeyauth/.gitignore create mode 100644 passkeyauth/TODO.txt create mode 100644 passkeyauth/__init__.py create mode 100644 passkeyauth/db.py create mode 100644 passkeyauth/main.py create mode 100644 passkeyauth/passkey.py create mode 100644 pyproject.toml create mode 100644 static/app.js create mode 100644 static/awaitable-websocket.js create mode 100644 static/index.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..9be3640 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# PasskeyAuth + +A minimal FastAPI WebAuthn server with WebSocket support for passkey registration. This project demonstrates WebAuthn registration flow with Resident Keys (discoverable credentials) using modern Python tooling. + +## Features + +- ๐Ÿ” WebAuthn registration with Resident Keys support +- ๐Ÿ”Œ WebSocket-based communication for real-time interaction +- ๐Ÿš€ Modern Python packaging with `pyproject.toml` +- ๐ŸŽจ Clean, responsive HTML interface using @simplewebauthn/browser +- ๐Ÿ“ฆ No database required - challenges stored locally per connection +- ๐Ÿ› ๏ธ Development tools: `ruff` for linting and formatting +- ๐Ÿงน Clean architecture with local challenge management + +## Requirements + +- Python 3.9+ +- A WebAuthn-compatible authenticator (security key, biometric device, etc.) + +## Quick Start + +### Using uv (recommended) + +```fish +# Install uv if you haven't already +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Clone/navigate to the project directory +cd passkeyauth + +# Install dependencies and run +uv run passkeyauth.main:main +``` + +### Using pip + +```fish +# Create and activate virtual environment +python -m venv venv +source venv/bin/activate.fish # or venv/bin/activate for bash + +# Install the package in development mode +pip install -e ".[dev]" + +# Run the server +python -m passkeyauth.main +``` + +### Using hatch + +```fish +# Install hatch if you haven't already +pip install hatch + +# Run the development server +hatch run python -m passkeyauth.main +``` + +## Usage + +1. Start the server using one of the methods above +2. Open your browser to `http://localhost:8000` +3. Enter a username (or use the default) +4. Click "Register Passkey" +5. Follow your authenticator's prompts to create a passkey + +The WebSocket connection will show real-time status updates as you progress through the registration flow. + +## Development + +### Code Quality + +```fish +# Run linting and formatting with ruff +uv run ruff check . +uv run ruff format . + +# Or with hatch +hatch run ruff check . +hatch run ruff format . +``` + +### Project Structure + +``` +passkeyauth/ +โ”œโ”€โ”€ passkeyauth/ +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ””โ”€โ”€ main.py # FastAPI server with WebSocket support +โ”œโ”€โ”€ static/ +โ”‚ โ””โ”€โ”€ index.html # Frontend interface +โ”œโ”€โ”€ pyproject.toml # Modern Python packaging configuration +โ””โ”€โ”€ README.md +``` + +## Technical Details + +### WebAuthn Configuration + +- **Relying Party ID**: `localhost` (for development) +- **Resident Keys**: Required (enables discoverable credentials) +- **User Verification**: Preferred +- **Supported Algorithms**: ECDSA-SHA256, RSASSA-PKCS1-v1_5-SHA256 + +### WebSocket Message Flow + +1. Client connects to `/ws/{client_id}` +2. Client sends `registration_challenge` message +3. Server responds with `registration_challenge_response` +4. Client completes WebAuthn ceremony and sends `registration_response` +5. Server verifies and responds with `registration_success` or `error` + +### Security Notes + +- This is a minimal demo - challenges are stored locally per WebSocket connection +- For production use, implement proper user storage and session management +- Consider using Redis or similar for challenge storage in production with multiple server instances +- Ensure HTTPS in production environments + +## License + +MIT License - feel free to use this as a starting point for your own WebAuthn implementations! diff --git a/dev.py b/dev.py new file mode 100644 index 0000000..409f1a0 --- /dev/null +++ b/dev.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +""" +Development server runner +""" + +if __name__ == "__main__": + from passkeyauth.main import main + + main() diff --git a/main.py b/main.py new file mode 100644 index 0000000..bd40f53 --- /dev/null +++ b/main.py @@ -0,0 +1,326 @@ +import json +import uuid +from typing import Dict + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from webauthn import generate_registration_options, verify_registration_response +from webauthn.helpers.cose import COSEAlgorithmIdentifier +from webauthn.helpers.structs import ( + AuthenticatorSelectionCriteria, + ResidentKeyRequirement, + UserVerificationRequirement, +) + +app = FastAPI(title="WebAuthn Registration Server") + +# In-memory storage for challenges (in production, use Redis or similar) +active_challenges: Dict[str, str] = {} + +# WebAuthn configuration +RP_ID = "localhost" +RP_NAME = "WebAuthn Demo" +ORIGIN = "http://localhost:8000" + + +class ConnectionManager: + def __init__(self): + self.active_connections: Dict[str, WebSocket] = {} + + async def connect(self, websocket: WebSocket, client_id: str): + await websocket.accept() + self.active_connections[client_id] = websocket + + def disconnect(self, client_id: str): + if client_id in self.active_connections: + del self.active_connections[client_id] + + async def send_message(self, message: dict, client_id: str): + if client_id in self.active_connections: + await self.active_connections[client_id].send_text(json.dumps(message)) + + +manager = ConnectionManager() + + +@app.websocket("/ws/{client_id}") +async def websocket_endpoint(websocket: WebSocket, client_id: str): + await manager.connect(websocket, client_id) + try: + while True: + data = await websocket.receive_text() + message = json.loads(data) + + if message["type"] == "registration_challenge": + await handle_registration_challenge(message, client_id) + elif message["type"] == "registration_response": + await handle_registration_response(message, client_id) + else: + await manager.send_message( + { + "type": "error", + "message": f"Unknown message type: {message['type']}", + }, + client_id, + ) + + except WebSocketDisconnect: + manager.disconnect(client_id) + + +async def handle_registration_challenge(message: dict, client_id: str): + """Handle registration challenge request""" + try: + username = message.get("username", "user@example.com") + user_id = str(uuid.uuid4()).encode() + + # Generate registration options with Resident Key support + options = generate_registration_options( + rp_id=RP_ID, + rp_name=RP_NAME, + user_id=user_id, + user_name=username, + user_display_name=username, + # Enable Resident Keys (discoverable credentials) + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.REQUIRED, + user_verification=UserVerificationRequirement.PREFERRED, + ), + # Support common algorithms + supported_pub_key_algs=[ + COSEAlgorithmIdentifier.ECDSA_SHA_256, + COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, + ], + ) + + # Store challenge for this client + active_challenges[client_id] = options.challenge + + # Convert options to dict for JSON serialization + options_dict = { + "challenge": options.challenge, + "rp": { + "name": options.rp.name, + "id": options.rp.id, + }, + "user": { + "id": options.user.id, + "name": options.user.name, + "displayName": options.user.display_name, + }, + "pubKeyCredParams": [ + {"alg": param.alg, "type": param.type} + for param in options.pub_key_cred_params + ], + "timeout": options.timeout, + "attestation": options.attestation, + "authenticatorSelection": { + "residentKey": options.authenticator_selection.resident_key.value, + "userVerification": options.authenticator_selection.user_verification.value, + }, + } + + await manager.send_message( + {"type": "registration_challenge_response", "options": options_dict}, + client_id, + ) + + except Exception as e: + await manager.send_message( + {"type": "error", "message": f"Failed to generate challenge: {str(e)}"}, + client_id, + ) + + +async def handle_registration_response(message: dict, client_id: str): + """Handle registration response verification""" + try: + # Get the stored challenge + if client_id not in active_challenges: + await manager.send_message( + {"type": "error", "message": "No active challenge found"}, client_id + ) + return + + expected_challenge = active_challenges[client_id] + credential = message["credential"] + + # Verify the registration response + verification = verify_registration_response( + credential=credential, + expected_challenge=expected_challenge, + expected_origin=ORIGIN, + expected_rp_id=RP_ID, + ) + + if verification.verified: + # Clean up the challenge + del active_challenges[client_id] + + await manager.send_message( + { + "type": "registration_success", + "message": "Registration successful!", + "credentialId": verification.credential_id, + "credentialPublicKey": verification.credential_public_key, + }, + client_id, + ) + else: + await manager.send_message( + {"type": "error", "message": "Registration verification failed"}, + client_id, + ) + + except Exception as e: + await manager.send_message( + {"type": "error", "message": f"Registration failed: {str(e)}"}, client_id + ) + + +# Serve static files +app.mount("/static", StaticFiles(directory="static"), name="static") + + +@app.get("/") +async def get_index(): + return HTMLResponse( + content=""" + + + + WebAuthn Registration Demo + + + + +
+

WebAuthn Registration Demo

+

Test WebAuthn registration with Resident Keys support

+ +
+ + +
+ + + +
+
+
+ + + + + """ + ) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/passkeyauth/.gitignore b/passkeyauth/.gitignore new file mode 100644 index 0000000..e0fe315 --- /dev/null +++ b/passkeyauth/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +dist/ +.* +!.gitignore +*.lock +*.db \ No newline at end of file diff --git a/passkeyauth/TODO.txt b/passkeyauth/TODO.txt new file mode 100644 index 0000000..95fa21c --- /dev/null +++ b/passkeyauth/TODO.txt @@ -0,0 +1,6 @@ +Practical checks: +- Check how user verification works + +Details: +- Extract authenticator type +- Extract user verified flag (present always required) diff --git a/passkeyauth/__init__.py b/passkeyauth/__init__.py new file mode 100644 index 0000000..5770436 --- /dev/null +++ b/passkeyauth/__init__.py @@ -0,0 +1 @@ +# passkeyauth package diff --git a/passkeyauth/db.py b/passkeyauth/db.py new file mode 100644 index 0000000..0fabd5e --- /dev/null +++ b/passkeyauth/db.py @@ -0,0 +1,149 @@ +import sqlite3 + +DB_PATH = "webauthn.db" + + +def init_database(): + """Initialize the SQLite database with required tables""" + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Create users table + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + user_id BLOB NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + # Create credentials table + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + credential_id BLOB NOT NULL, + public_key BLOB NOT NULL, + sign_count INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id), + UNIQUE(credential_id) + ) + """ + ) + + conn.commit() + conn.close() + + +def get_user_by_username(username: str) -> dict | None: + """Get user record by username""" + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute( + "SELECT id, username, user_id FROM users WHERE username = ?", (username,) + ) + row = cursor.fetchone() + conn.close() + + if row: + return {"id": row[0], "username": row[1], "user_id": row[2]} + return None + + +def get_user_by_user_id(user_id: bytes) -> dict | None: + """Get user record by WebAuthn user ID""" + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute( + "SELECT id, username, user_id FROM users WHERE user_id = ?", (user_id,) + ) + row = cursor.fetchone() + conn.close() + + if row: + return {"id": row[0], "username": row[1], "user_id": row[2]} + return None + + +def create_user(username: str, user_id: bytes) -> int: + """Create a new user and return the user ID""" + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute( + "INSERT INTO users (username, user_id) VALUES (?, ?)", (username, user_id) + ) + user_db_id = cursor.lastrowid + conn.commit() + conn.close() + if user_db_id is None: + raise RuntimeError("Failed to create user") + return user_db_id + + +def store_credential(user_db_id: int, credential_id: bytes, public_key: bytes) -> None: + """Store a credential for a user""" + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute( + "INSERT INTO credentials (user_id, credential_id, public_key) VALUES (?, ?, ?)", + (user_db_id, credential_id, public_key), + ) + conn.commit() + conn.close() + + +def get_credential_by_id(credential_id: bytes) -> dict | None: + """Get credential by credential ID""" + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute( + """ + SELECT c.public_key, c.sign_count, u.username + FROM credentials c + JOIN users u ON c.user_id = u.id + WHERE c.credential_id = ? + """, + (credential_id,), + ) + row = cursor.fetchone() + conn.close() + + if row: + return {"public_key": row[0], "sign_count": row[1], "username": row[2]} + return None + + +def get_user_credentials(username: str) -> list[bytes]: + """Get all credential IDs for a user""" + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute( + """ + SELECT c.credential_id + FROM credentials c + JOIN users u ON c.user_id = u.id + WHERE u.username = ? + """, + (username,), + ) + rows = cursor.fetchall() + conn.close() + + return [row[0] for row in rows] + + +def update_credential_sign_count(credential_id: bytes, sign_count: int) -> None: + """Update the sign count for a credential""" + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + cursor.execute( + "UPDATE credentials SET sign_count = ? WHERE credential_id = ?", + (sign_count, credential_id), + ) + conn.commit() + conn.close() diff --git a/passkeyauth/main.py b/passkeyauth/main.py new file mode 100644 index 0000000..295436a --- /dev/null +++ b/passkeyauth/main.py @@ -0,0 +1,106 @@ +""" +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 SQLite database for persistent storage of users and credentials +- Enables true passwordless authentication where users don't need to enter a username +""" + +from pathlib import Path + +import db +import uuid7 +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from passkeyauth.passkey import Passkey + +STATIC_DIR = Path(__file__).parent.parent / "static" + +passkey = Passkey( + rp_id="localhost", + rp_name="Passkey Auth", + origin="http://localhost:8000", +) + +app = FastAPI(title="Passkey Auth") + + +@app.websocket("/ws/new_user_registration") +async def websocket_register_new(ws: WebSocket): + """Register a new user and with a new passkey credential.""" + await ws.accept() + try: + form = await ws.receive_json() + user_id = uuid7.create().bytes + user_name = form["user_name"] + await register_chat(ws, user_id, username) + # Store the user in the database + await db.create_user(user_name, user_id) + await ws.send_json({"status": "success", "user_id": user_id.hex()}) + except WebSocketDisconnect: + pass + + +async def register_chat(ws: WebSocket, user_id: bytes, username: str): + """Generate registration options and send them to the client.""" + options, challenge = passkey.reg_generate_options( + user_id=user_id, + username=username, + ) + await ws.send_text(options) + # Wait for the client to use his authenticator to register + credential = passkey.reg_credential(await ws.receive_json()) + passkey.reg_verify(credential, challenge) + + +@app.websocket("/ws/authenticate") +async def websocket_authenticate(ws: WebSocket): + await ws.accept() + try: + options = passkey.auth_generate_options() + await ws.send_json(options) + # Wait for the client to use his authenticator to authenticate + credential = passkey.auth_credential(await ws.receive_json()) + # Fetch from the database by credential ID + stored_cred = await db.fetch_credential(credential.raw_id) + # Verify the credential matches the stored data, that is also updated + passkey.auth_verify(credential, stored_cred) + # Update the credential in the database + await db.update_credential(stored_cred) + except WebSocketDisconnect: + pass + + +# Serve static files +app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + + +@app.get("/") +async def get_index(): + """Serve the main HTML page""" + 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", + ) + + +# Initialize database on startup +db.init_database() + +if __name__ == "__main__": + main() diff --git a/passkeyauth/passkey.py b/passkeyauth/passkey.py new file mode 100644 index 0000000..7d58ccd --- /dev/null +++ b/passkeyauth/passkey.py @@ -0,0 +1,166 @@ +""" +WebAuthn handler class that combines registration and authentication functionality. + +This module provides a unified interface for WebAuthn operations including: +- Registration challenge generation and verification +- Authentication challenge generation and verification +- Credential validation +""" + +from webauthn import ( + generate_authentication_options, + generate_registration_options, + verify_authentication_response, + verify_registration_response, +) +from webauthn.helpers import ( + options_to_json, + parse_authentication_credential_json, + parse_registration_credential_json, +) +from webauthn.helpers.cose import COSEAlgorithmIdentifier +from webauthn.helpers.structs import ( + AuthenticationCredential, + AuthenticatorSelectionCriteria, + RegistrationCredential, + ResidentKeyRequirement, + UserVerificationRequirement, +) +from webauthn.registration.verify_registration_response import VerifiedRegistration + + +class Passkey: + """WebAuthn handler for registration and authentication operations.""" + + def __init__( + self, + rp_id: str, + rp_name: str, + origin: str, + supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None, + ): + """ + Initialize the WebAuthn handler. + + Args: + rp_id: The relying party identifier + rp_name: The relying party name + origin: The origin URL of the application + supported_pub_key_algs: List of supported COSE algorithms + """ + self.rp_id = rp_id + self.rp_name = rp_name + self.origin = origin + self.supported_pub_key_algs = supported_pub_key_algs or [ + COSEAlgorithmIdentifier.EDDSA, + # COSEAlgorithmIdentifier.ECDSA_SHA_256, + # COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, + ] + + ### Registration Methods ### + + def reg_generate_options( + self, user_id: bytes, username: str, display_name="", **regopts + ) -> tuple[str, bytes]: + """ + Generate registration options for WebAuthn registration. + + Args: + user_id: The user ID as bytes + username: The username + display_name: The display name (defaults to username if empty) + + Returns: + JSON string containing registration options + """ + options = generate_registration_options( + rp_id=self.rp_id, + rp_name=self.rp_name, + user_id=user_id, + user_name=username, + user_display_name=display_name or username, + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.REQUIRED, + user_verification=UserVerificationRequirement.PREFERRED, + ), + supported_pub_key_algs=self.supported_pub_key_algs, + **regopts, + ) + return options_to_json(options), options.challenge + + @staticmethod + def reg_credential(credential: dict | str) -> RegistrationCredential: + return parse_registration_credential_json(credential) + + def reg_verify( + self, + credential: RegistrationCredential, + expected_challenge: bytes, + ) -> VerifiedRegistration: + """ + Verify registration response. + + Args: + credential: The credential response from the client + expected_challenge: The expected challenge bytes + + Returns: + Registration verification result + """ + registration = verify_registration_response( + credential=credential, + expected_challenge=expected_challenge, + expected_origin=self.origin, + expected_rp_id=self.rp_id, + ) + return registration + + ### Authentication Methods ### + + async def auth_generate_options( + self, user_verification_required=False, **kwopts + ) -> str: + """ + Generate authentication options for WebAuthn authentication. + + Args: + user_verification_required: The user will have to re-enter PIN or use biometrics for this operation. Useful when accessing security settings etc. + Returns: + JSON string containing authentication options + """ + options = generate_authentication_options( + rp_id=self.rp_id, + user_verification=( + UserVerificationRequirement.REQUIRED + if user_verification_required + else UserVerificationRequirement.PREFERRED + ), + **kwopts, + ) + return options_to_json(options) + + @staticmethod + def auth_credential(credential: dict | str) -> AuthenticationCredential: + """Convert the authentication credential from JSON to a dataclass instance.""" + return parse_authentication_credential_json(credential) + + async def auth_verify( + self, + credential: AuthenticationCredential, + expected_challenge: bytes, + stored_cred: dict, + ): + """ + Verify authentication response against locally stored credential data. + """ + # Verify the authentication response + verification = verify_authentication_response( + credential=credential, + expected_challenge=expected_challenge, + expected_origin=self.origin, + expected_rp_id=self.rp_id, + credential_public_key=stored_cred["public_key"], + credential_current_sign_count=stored_cred["sign_count"], + ) + stored_cred["sign_count"] = verification.new_sign_count + return verification diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5f636d4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "passkeyauth" +version = "0.1.0" +description = "Minimal FastAPI WebAuthn server with WebSocket support" +authors = [ + {name = "User", email = "user@example.com"}, +] +dependencies = [ + "fastapi[standard]>=0.104.1", + "uvicorn[standard]>=0.24.0", + "websockets>=12.0", + "webauthn>=1.11.1", + "base64url>=1.0.0", +] +requires-python = ">=3.10" + +[project.optional-dependencies] +dev = [ + "ruff>=0.1.0", +] + +[tool.ruff] +target-version = "py39" +line-length = 88 +select = ["E", "F", "I", "N", "W", "UP"] +ignore = ["E501"] # Line too long + +[tool.ruff.isort] +known-first-party = ["passkeyauth"] + +[project.scripts] +serve = "passkeyauth.main:main" diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..1490df3 --- /dev/null +++ b/static/app.js @@ -0,0 +1,55 @@ +const { startRegistration, startAuthentication } = SimpleWebAuthnBrowser + +async function register(username) { + // Registration chat + const ws = await aWebSocket('/ws/register') + ws.send(username) + const optionsJSON = JSON.parse(await ws.recv()) + if (optionsJSON.error) throw new Error(optionsJSON.error) + ws.send(JSON.stringify(await startRegistration({optionsJSON}))) + const result = JSON.parse(await ws.recv()) + if (result.error) throw new Error(`Server: ${result.error}`) +} + +async function authenticate() { + // Authentication chat + const ws = await aWebSocket('/ws/authenticate') + ws.send('') // Send empty string to trigger authentication + const optionsJSON = JSON.parse(await ws.recv()) + if (optionsJSON.error) throw new Error(optionsJSON.error) + ws.send(JSON.stringify(await startAuthentication({optionsJSON}))) + const result = JSON.parse(await ws.recv()) + if (result.error) throw new Error(`Server: ${result.error}`) + return result +} + +(function() { + const regForm = document.getElementById('registrationForm') + const regSubmitBtn = regForm.querySelector('button[type="submit"]') + regForm.addEventListener('submit', ev => { + ev.preventDefault() + regSubmitBtn.disabled = true + const username = (new FormData(regForm)).get('username') + register(username).then(() => { + alert(`Registration successful for ${username}!`) + }).catch(err => { + alert(`Registration failed: ${err.message}`) + }).finally(() => { + regSubmitBtn.disabled = false + }) + }) + + const authForm = document.getElementById('authenticationForm') + const authSubmitBtn = authForm.querySelector('button[type="submit"]') + authForm.addEventListener('submit', ev => { + ev.preventDefault() + authSubmitBtn.disabled = true + authenticate().then(result => { + alert(`Authentication successful! Welcome ${result.username}`) + }).catch(err => { + alert(`Authentication failed: ${err.message}`) + }).finally(() => { + authSubmitBtn.disabled = false + }) + }) +})() diff --git a/static/awaitable-websocket.js b/static/awaitable-websocket.js new file mode 100644 index 0000000..e0ead53 --- /dev/null +++ b/static/awaitable-websocket.js @@ -0,0 +1,43 @@ +class AwaitableWebSocket extends WebSocket { + #received = [] + #waiting = [] + #err = null + #opened = false + + constructor(resolve, reject, url, protocols) { + super(url, protocols) + this.onopen = () => { + this.#opened = true + resolve(this) + } + this.onmessage = e => { + if (this.#waiting.length) this.#waiting.shift().resolve(e.data) + else this.#received.push(e.data) + } + this.onclose = e => { + if (!this.#opened) { + reject(new Error(`WebSocket ${this.url} failed to connect, code ${e.code}`)) + return + } + this.#err = e.wasClean + ? new Error(`Websocket ${this.url} closed ${e.code}`) + : new Error(`WebSocket ${this.url} closed with error ${e.code}`) + this.#waiting.splice(0).forEach(p => p.reject(this.#err)) + } + } + + recv() { + // If we have a message already received, return it immediately + if (this.#received.length) return Promise.resolve(this.#received.shift()) + // Wait for incoming messages, if we have an error, reject immediately + if (this.#err) return Promise.reject(this.#err) + return new Promise((resolve, reject) => this.#waiting.push({ resolve, reject })) + } +} + +// Construct an async WebSocket with await aWebSocket(url) +function aWebSocket(url, protocols) { + return new Promise((resolve, reject) => { + new AwaitableWebSocket(resolve, reject, url, protocols) + }) +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..3524246 --- /dev/null +++ b/static/index.html @@ -0,0 +1,68 @@ + + + + WebAuthn Registration Demo + + + + + +
+

WebAuthn Demo

+ +
+

Register

+
+ +
+ +
+
+ +
+

Authenticate

+
+ +
+
+
+ + + +