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:
		
							
								
								
									
										65
									
								
								passkeyauth/aaguid_manager.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								passkeyauth/aaguid_manager.py
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										247
									
								
								passkeyauth/api_handlers.py
									
									
									
									
									
										Normal 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)}"} | ||||||
							
								
								
									
										1
									
								
								passkeyauth/combined_aaguid.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								passkeyauth/combined_aaguid.json
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -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
									
								
							
							
						
						
									
										132
									
								
								passkeyauth/jwt_manager.py
									
									
									
									
									
										Normal 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) | ||||||
| @@ -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") | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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, | ||||||
|   | |||||||
							
								
								
									
										107
									
								
								passkeyauth/session_manager.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								passkeyauth/session_manager.py
									
									
									
									
									
										Normal 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"] | ||||||
| @@ -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" | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										426
									
								
								static/app.js
									
									
									
									
									
								
							
							
						
						
									
										426
									
								
								static/app.js
									
									
									
									
									
								
							| @@ -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) { | ||||||
|  |   try { | ||||||
|     const ws = await aWebSocket('/ws/new_user_registration') |     const ws = await aWebSocket('/ws/new_user_registration') | ||||||
|     ws.send(JSON.stringify({user_name})) |     ws.send(JSON.stringify({user_name})) | ||||||
|  |      | ||||||
|     // Registration chat |     // Registration chat | ||||||
|     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) | ||||||
|   ws.send(JSON.stringify(await startRegistration({optionsJSON}))) |      | ||||||
|  |     showStatus('registerStatus', 'Save to your authenticator...', 'info') | ||||||
|  |      | ||||||
|  |     const registrationResponse = await startRegistration({optionsJSON}) | ||||||
|  |     ws.send(JSON.stringify(registrationResponse)) | ||||||
|  |      | ||||||
|     const result = JSON.parse(await ws.recv()) |     const result = JSON.parse(await ws.recv()) | ||||||
|     if (result.error) throw new Error(`Server: ${result.error}`) |     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}))) |      | ||||||
|  |     showStatus('loginStatus', 'Please touch your authenticator...', 'info') | ||||||
|  |      | ||||||
|  |     const authResponse = await startAuthentication({optionsJSON}) | ||||||
|  |     await ws.send(JSON.stringify(authResponse)) | ||||||
|  |      | ||||||
|     const result = JSON.parse(await ws.recv()) |     const result = JSON.parse(await ws.recv()) | ||||||
|     if (result.error) throw new Error(`Server: ${result.error}`) |     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 |     return result | ||||||
|  |   } catch (error) { | ||||||
|  |     throw error | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| (function() { | // Load user credentials | ||||||
|   const regForm = document.getElementById('registrationForm') | async function loadCredentials() { | ||||||
|   const regSubmitBtn = regForm.querySelector('button[type="submit"]')   |   try { | ||||||
|   regForm.addEventListener('submit', ev => { |     showStatus('dashboardStatus', 'Loading credentials...', 'info') | ||||||
|     ev.preventDefault() |      | ||||||
|     regSubmitBtn.disabled = true |     const response = await fetch('/api/user-credentials', { | ||||||
|     const user_name = (new FormData(regForm)).get('username') |       method: 'GET', | ||||||
|     register(user_name).then(() => { |       credentials: 'include' | ||||||
|       alert(`Registration successful for ${user_name}!`) |     }) | ||||||
|     }).catch(err => { |      | ||||||
|       alert(`Registration failed: ${err.message}`) |     const result = await response.json() | ||||||
|     }).finally(() => { |     if (result.error) throw new Error(`Server: ${result.error}`) | ||||||
|       regSubmitBtn.disabled = false |      | ||||||
|  |     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 regSubmitBtn = regForm.querySelector('button[type="submit"]') | ||||||
|  |    | ||||||
|  |   regForm.addEventListener('submit', async (ev) => { | ||||||
|  |     ev.preventDefault() | ||||||
|  |     regSubmitBtn.disabled = true | ||||||
|  |     clearStatus('registerStatus') | ||||||
|  |      | ||||||
|  |     const user_name = (new FormData(regForm)).get('username') | ||||||
|  |      | ||||||
|  |     try { | ||||||
|  |       showStatus('registerStatus', 'Starting registration...', 'info') | ||||||
|  |       await register(user_name) | ||||||
|  |       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 | ||||||
|  |     } | ||||||
|  |   }) | ||||||
|  |  | ||||||
|  |   // 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 | ||||||
|  |     } | ||||||
|   }) |   }) | ||||||
| }) | }) | ||||||
| })() |  | ||||||
|   | |||||||
| @@ -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; | ||||||
|  |             margin-bottom: 15px; | ||||||
|             font-size: 16px; |             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> | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Leo Vasanko
					Leo Vasanko