"""WebAuthn (Passkey) manager — registration and authentication ceremonies. Multi-user support: - Registration is bound to a concrete user id (challenge keyed by user id, user handle = "user_"). - Login ceremonies are credential-driven: after `complete_authentication`, the caller maps the returned credential id to its owner via `models.find_user_by_credential_id`. - `begin_authentication` can be restricted to a single credential for re-authentication ceremonies. Uses the `webauthn` library (v2.x/v3.x). Requires HTTPS (or localhost) to function. """ from dataclasses import asdict from webauthn.helpers import bytes_to_base64url, base64url_to_bytes from webauthn import ( generate_registration_options, verify_registration_response, generate_authentication_options, verify_authentication_response, ) from webauthn.helpers.structs import ( AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationRequirement, PublicKeyCredentialDescriptor, ) from webauthn.helpers.exceptions import InvalidRegistrationResponse, InvalidAuthenticationResponse # snake_case → camelCase mapping for browser WebAuthn API _SNAKE_TO_CAMEL = { "pub_key_cred_params": "pubKeyCredParams", "exclude_credentials": "excludeCredentials", "authenticator_selection": "authenticatorSelection", "allow_credentials": "allowCredentials", "rp_id": "rpId", "user_verification": "userVerification", "resident_key": "residentKey", "authenticator_attachment": "authenticatorAttachment", "require_resident_key": "requireResidentKey", "user_handle": "userHandle", "credential_id": "credentialId", "credential_public_key": "credentialPublicKey", "sign_count": "signCount", "cred_props": "credProps", "public_key": "publicKey", "private_key": "privateKey", "display_name": "displayName", } def _snake_to_camel(name: str) -> str: """Convert snake_case key to camelCase, with known overrides.""" if name in _SNAKE_TO_CAMEL: return _SNAKE_TO_CAMEL[name] parts = name.split("_") return parts[0] + "".join(p.capitalize() for p in parts[1:]) # Relying Party info RP_NAME = "Cal Display" RP_ID = None # set dynamically from request.host ORIGIN = None # set dynamically # Challenges are persisted in PostgreSQL (backend.models.webauthn_challenges) # so a ceremony started on one Gunicorn worker can be completed on another. # Keys: "reg_" for registration, "login" for login, # "reauth_" for re-authentication. from backend.models import save_webauthn_challenge, pop_webauthn_challenge # noqa: E402 def _challenge_db() -> str | None: """Resolve the current DB connection string (app config, like the managers).""" try: from flask import current_app return current_app.config.get("DB_PATH") except Exception: return None def _get_rp_id(request) -> str: """Extract RP ID from the request host (e.g. 'example.com' or '192.168.1.100').""" host = request.host.split(":")[0] # strip port return host def _get_origin(request) -> str: """Extract origin from the request (scheme + host).""" scheme = request.scheme return f"{scheme}://{request.host}" def _serialize_options(options) -> dict: """Serialize a webauthn options dataclass to a browser-ready dict. Converts bytes to base64url strings AND snake_case keys to camelCase. """ d = asdict(options) return _bytes_to_json(d) def _bytes_to_json(obj): """Recursively convert bytes→base64url and snake_case→camelCase.""" if isinstance(obj, bytes): return bytes_to_base64url(obj) elif isinstance(obj, dict): return {_snake_to_camel(k): _bytes_to_json(v) for k, v in obj.items()} elif isinstance(obj, list): return [_bytes_to_json(v) for v in obj] elif isinstance(obj, tuple): return tuple(_bytes_to_json(v) for v in obj) return obj # ─── Registration ──────────────────────────── def begin_registration(request, user_id: int, display_name: str = "", existing_credential_id: str = None) -> dict: """Start WebAuthn registration for a specific user. Returns options dict.""" rp_id = _get_rp_id(request) user_name = f"user_{user_id}" user_handle = user_name.encode("utf-8") # If the user already has a passkey, exclude it so they can't re-register same credential exclude_credentials = [] if existing_credential_id: exclude_credentials.append( PublicKeyCredentialDescriptor(id=bytes.fromhex(existing_credential_id)) ) options = generate_registration_options( rp_id=rp_id, rp_name=RP_NAME, user_id=user_handle, user_name=user_name, user_display_name=display_name or user_name, attestation="none", authenticator_selection=AuthenticatorSelectionCriteria( resident_key=ResidentKeyRequirement.PREFERRED, user_verification=UserVerificationRequirement.PREFERRED, ), exclude_credentials=exclude_credentials, ) # Store challenge for verification (DB-backed, cross-worker safe, 5 min TTL) save_webauthn_challenge( f"reg_{user_id}", bytes_to_base64url(options.challenge), db_path=_challenge_db(), ) return _serialize_options(options) def complete_registration(request, credential: dict, user_id: int = None) -> dict: """Complete WebAuthn registration. Returns {'ok': True, 'user_id': int, 'credential_id': str, 'public_key': str} or raises an exception on failure. user_id: explicit target user. Always pass it from the route when known — admin provisioning runs inside the ADMIN's session (its uid differs from the target user, so deriving the challenge key from the session would never match). When omitted, it is derived from credential.user_name ("user_5" → 5), then from the authenticated request context. """ rp_id = _get_rp_id(request) origin = _get_origin(request) uid = user_id if uid is None: uname = credential.get("user_name", "") if isinstance(uname, str) and uname.startswith("user_"): try: uid = int(uname.split("_")[-1]) except (ValueError, IndexError): uid = None if uid is None: try: from backend.auth import current_user_id uid = current_user_id() except Exception: uid = None if uid is None: raise ValueError("No registration challenge found. Please restart registration.") # Challenge key must match begin_registration's f"reg_{user_id}" exactly. expected_challenge = pop_webauthn_challenge(f"reg_{uid}", db_path=_challenge_db()) if not expected_challenge: raise ValueError("No registration challenge found. Please restart registration.") user_id = uid try: # Library expects rawId field (browser-native camelCase, base64url string) if "rawId" not in credential: credential["rawId"] = credential.get("id", "") verification = verify_registration_response( credential=credential, expected_challenge=base64url_to_bytes(expected_challenge), expected_rp_id=rp_id, expected_origin=origin, ) except (InvalidRegistrationResponse, Exception) as e: raise ValueError(f"Registration verification failed: {e}") return { "ok": True, "user_id": user_id, "credential_id": verification.credential_id.hex(), "public_key": verification.credential_public_key.hex(), } # ─── Authentication (Login / Re-auth) ──────── def begin_authentication(request, allow_credential_ids: list = None, challenge_key: str = "login") -> dict: """Start WebAuthn authentication. Returns options dict for the frontend. allow_credential_ids: optional list of hex credential ids to restrict the ceremony to (used for re-authentication of the current user). challenge_key: DB key under which the challenge is stored; pass a user-scoped key (e.g. "reauth_") for re-authentication ceremonies so concurrent logins/reauths never clobber each other. """ rp_id = _get_rp_id(request) allow_credentials = [] if allow_credential_ids: allow_credentials = [ PublicKeyCredentialDescriptor(id=bytes.fromhex(cid)) for cid in allow_credential_ids ] options = generate_authentication_options( rp_id=rp_id, user_verification=UserVerificationRequirement.PREFERRED, allow_credentials=allow_credentials, ) # Store challenge for verification (DB-backed, cross-worker safe) save_webauthn_challenge(challenge_key, bytes_to_base64url(options.challenge), db_path=_challenge_db()) return _serialize_options(options) def complete_authentication(request, credential: dict, stored_credential_id: str, stored_public_key: str, challenge_key: str = "login") -> dict: """Complete WebAuthn authentication. Returns {'ok': True, 'credential_id': str} or raises on failure. """ rp_id = _get_rp_id(request) origin = _get_origin(request) expected_challenge = pop_webauthn_challenge(challenge_key, db_path=_challenge_db()) if not expected_challenge: raise ValueError("No authentication challenge found. Please start again.") try: # Library expects rawId field (browser-native camelCase, base64url string) if "rawId" not in credential: credential["rawId"] = credential.get("id", "") verification = verify_authentication_response( credential=credential, expected_challenge=base64url_to_bytes(expected_challenge), expected_rp_id=rp_id, expected_origin=origin, credential_public_key=bytes.fromhex(stored_public_key), credential_current_sign_count=0, # we don't track sign count require_user_verification=False, ) except (InvalidAuthenticationResponse, Exception) as e: raise ValueError(f"Authentication verification failed: {e}") return { "ok": True, "credential_id": verification.credential_id.hex(), }