A lot of cleanup, restructuring project directory.
This commit is contained in:
parent
1c79132e22
commit
3567b7802b
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,4 +5,4 @@ dist/
|
|||||||
*.lock
|
*.lock
|
||||||
*.db
|
*.db
|
||||||
server-secret.bin
|
server-secret.bin
|
||||||
/passkeyauth/frontend-static
|
/passkey/frontend-build
|
||||||
|
23
Caddyfile
Normal file
23
Caddyfile
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
(auth) {
|
||||||
|
# Forward /auth to the authentication service
|
||||||
|
redir /auth /auth/ 302
|
||||||
|
@auth path /auth/*
|
||||||
|
handle @auth {
|
||||||
|
reverse_proxy localhost:4401
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
# Check for authentication
|
||||||
|
forward_auth localhost:4401 {
|
||||||
|
uri /auth/forward-auth
|
||||||
|
copy_headers x-auth-user-id
|
||||||
|
}
|
||||||
|
{block}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localhost {
|
||||||
|
import auth {
|
||||||
|
# Proxy authenticated requests to the main application
|
||||||
|
reverse_proxy localhost:3000
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
Before Width: | Height: | Size: 4.2 KiB |
@ -13,19 +13,19 @@ export default defineConfig(({ command, mode }) => ({
|
|||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
base: command == 'build' ? '/auth/' : '/',
|
base: command === 'build' ? '/auth/' : '/',
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 4403,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/auth/': {
|
'/auth/': {
|
||||||
target: 'http://localhost:8000',
|
target: 'http://localhost:4401',
|
||||||
ws: true,
|
ws: true,
|
||||||
changeOrigin: false
|
changeOrigin: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: '../passkeyauth/frontend-static',
|
outDir: '../passkey/frontend-build',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
assetsDir: 'assets'
|
assetsDir: 'assets'
|
||||||
}
|
}
|
||||||
|
3
passkey/__init__.py
Normal file
3
passkey/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from .sansio import Passkey
|
||||||
|
|
||||||
|
__all__ = ["Passkey"]
|
32
passkey/aaguid/__init__.py
Normal file
32
passkey/aaguid/__init__.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
AAGUID (Authenticator Attestation GUID) management for WebAuthn credentials.
|
||||||
|
|
||||||
|
This module provides functionality to:
|
||||||
|
- Load AAGUID data from JSON file
|
||||||
|
- Look up authenticator information by AAGUID
|
||||||
|
- Return only relevant AAGUID data for user credentials
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from importlib.resources import files
|
||||||
|
|
||||||
|
__ALL__ = ["AAGUID", "filter"]
|
||||||
|
|
||||||
|
# Path to the AAGUID JSON file
|
||||||
|
AAGUID_FILE = files("passkey") / "aaguid" / "combined_aaguid.json"
|
||||||
|
AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def filter(aaguids: Iterable[str]) -> dict[str, dict]:
|
||||||
|
"""
|
||||||
|
Get AAGUID information only for the provided set of AAGUIDs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
aaguids: Set of AAGUID strings that the user has credentials for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping AAGUID to authenticator information for only
|
||||||
|
the AAGUIDs that the user has and that we have data for
|
||||||
|
"""
|
||||||
|
return {aaguid: AAGUID[aaguid] for aaguid in aaguids if aaguid in AAGUID}
|
@ -25,7 +25,7 @@ from sqlalchemy.dialects.sqlite import BLOB
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from .passkey import StoredCredential
|
from ..sansio import StoredCredential
|
||||||
|
|
||||||
DB_PATH = "sqlite+aiosqlite:///webauthn.db"
|
DB_PATH = "sqlite+aiosqlite:///webauthn.db"
|
||||||
|
|
@ -10,9 +10,9 @@ This module contains all the HTTP API endpoints for:
|
|||||||
|
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
|
|
||||||
from . import db
|
from .. import aaguid
|
||||||
from .aaguid_manager import get_aaguid_manager
|
from ..db import sql
|
||||||
from .jwt_manager import refresh_session_token, validate_session_token
|
from ..util.jwt import refresh_session_token, validate_session_token
|
||||||
from .session_manager import (
|
from .session_manager import (
|
||||||
clear_session_cookie,
|
clear_session_cookie,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
@ -59,13 +59,13 @@ async def get_user_credentials(request: Request) -> dict:
|
|||||||
current_credential_id = token_data.get("credential_id")
|
current_credential_id = token_data.get("credential_id")
|
||||||
|
|
||||||
# Get all credentials for the user
|
# Get all credentials for the user
|
||||||
credential_ids = await db.get_user_credentials(user.user_id)
|
credential_ids = await sql.get_user_credentials(user.user_id)
|
||||||
|
|
||||||
credentials = []
|
credentials = []
|
||||||
user_aaguids = set()
|
user_aaguids = set()
|
||||||
|
|
||||||
for cred_id in credential_ids:
|
for cred_id in credential_ids:
|
||||||
stored_cred = await db.get_credential_by_id(cred_id)
|
stored_cred = await sql.get_credential_by_id(cred_id)
|
||||||
|
|
||||||
# Convert AAGUID to string format
|
# Convert AAGUID to string format
|
||||||
aaguid_str = str(stored_cred.aaguid)
|
aaguid_str = str(stored_cred.aaguid)
|
||||||
@ -91,8 +91,7 @@ async def get_user_credentials(request: Request) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get AAGUID information for only the AAGUIDs that the user has
|
# Get AAGUID information for only the AAGUIDs that the user has
|
||||||
aaguid_manager = get_aaguid_manager()
|
aaguid_info = aaguid.filter(user_aaguids)
|
||||||
aaguid_info = aaguid_manager.get_relevant_aaguids(user_aaguids)
|
|
||||||
|
|
||||||
# Sort credentials by creation date (earliest first, most recently created last)
|
# Sort credentials by creation date (earliest first, most recently created last)
|
||||||
credentials.sort(key=lambda cred: cred["created_at"])
|
credentials.sort(key=lambda cred: cred["created_at"])
|
||||||
@ -208,7 +207,7 @@ async def delete_credential(request: Request) -> dict:
|
|||||||
|
|
||||||
# First, verify the credential belongs to the current user
|
# First, verify the credential belongs to the current user
|
||||||
try:
|
try:
|
||||||
stored_cred = await db.get_credential_by_id(credential_id_bytes)
|
stored_cred = await sql.get_credential_by_id(credential_id_bytes)
|
||||||
if stored_cred.user_id != user.user_id:
|
if stored_cred.user_id != user.user_id:
|
||||||
return {"error": "Credential not found or access denied"}
|
return {"error": "Credential not found or access denied"}
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@ -222,12 +221,12 @@ async def delete_credential(request: Request) -> dict:
|
|||||||
return {"error": "Cannot delete current session credential"}
|
return {"error": "Cannot delete current session credential"}
|
||||||
|
|
||||||
# Get user's remaining credentials count
|
# Get user's remaining credentials count
|
||||||
remaining_credentials = await db.get_user_credentials(user.user_id)
|
remaining_credentials = await sql.get_user_credentials(user.user_id)
|
||||||
if len(remaining_credentials) <= 1:
|
if len(remaining_credentials) <= 1:
|
||||||
return {"error": "Cannot delete last remaining credential"}
|
return {"error": "Cannot delete last remaining credential"}
|
||||||
|
|
||||||
# Delete the credential
|
# Delete the credential
|
||||||
await db.delete_user_credential(credential_id_bytes)
|
await sql.delete_user_credential(credential_id_bytes)
|
||||||
|
|
||||||
return {"status": "success", "message": "Credential deleted successfully"}
|
return {"status": "success", "message": "Credential deleted successfully"}
|
||||||
|
|
186
passkey/fastapi/main.py
Normal file
186
passkey/fastapi/main.py
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
"""
|
||||||
|
Minimal FastAPI WebAuthn server with WebSocket support for passkey registration and authentication.
|
||||||
|
|
||||||
|
This module provides a simple WebAuthn implementation that:
|
||||||
|
- Uses WebSocket for real-time communication
|
||||||
|
- Supports Resident Keys (discoverable credentials) for passwordless authentication
|
||||||
|
- Maintains challenges locally per connection
|
||||||
|
- Uses async SQLite database for persistent storage of users and credentials
|
||||||
|
- Enables true passwordless authentication where users don't need to enter a user_name
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import (
|
||||||
|
FastAPI,
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
)
|
||||||
|
from fastapi import (
|
||||||
|
Path as FastAPIPath,
|
||||||
|
)
|
||||||
|
from fastapi.responses import (
|
||||||
|
FileResponse,
|
||||||
|
RedirectResponse,
|
||||||
|
)
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from ..db import sql
|
||||||
|
from .api_handlers import (
|
||||||
|
delete_credential,
|
||||||
|
get_user_credentials,
|
||||||
|
get_user_info,
|
||||||
|
logout,
|
||||||
|
refresh_token,
|
||||||
|
set_session,
|
||||||
|
validate_token,
|
||||||
|
)
|
||||||
|
from .reset_handlers import create_device_addition_link, validate_device_addition_token
|
||||||
|
from .ws_handlers import ws_app
|
||||||
|
|
||||||
|
STATIC_DIR = Path(__file__).parent.parent / "frontend-build"
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await sql.init_database()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Passkey Auth", lifespan=lifespan)
|
||||||
|
|
||||||
|
# Mount the WebSocket subapp
|
||||||
|
app.mount("/auth/ws", ws_app)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/user-info")
|
||||||
|
async def api_get_user_info(request: Request):
|
||||||
|
"""Get user information from session cookie."""
|
||||||
|
return await get_user_info(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/user-credentials")
|
||||||
|
async def api_get_user_credentials(request: Request):
|
||||||
|
"""Get all credentials for a user using session cookie."""
|
||||||
|
return await get_user_credentials(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/auth/refresh-token")
|
||||||
|
async def api_refresh_token(request: Request, response: Response):
|
||||||
|
"""Refresh the session token."""
|
||||||
|
return await refresh_token(request, response)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/validate-token")
|
||||||
|
async def api_validate_token(request: Request):
|
||||||
|
"""Validate a session token and return user info."""
|
||||||
|
return await validate_token(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/forward-auth")
|
||||||
|
async def forward_authentication(request: Request):
|
||||||
|
"""A verification endpoint to use with Caddy forward_auth or Nginx auth_request."""
|
||||||
|
result = await validate_token(request)
|
||||||
|
if result.get("status") != "success":
|
||||||
|
# Serve the index.html of the authentication app if not authenticated
|
||||||
|
return FileResponse(
|
||||||
|
STATIC_DIR / "index.html",
|
||||||
|
status_code=401,
|
||||||
|
headers={"www-authenticate": "PrivateToken"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# If authenticated, return a success response
|
||||||
|
return Response(
|
||||||
|
status_code=204,
|
||||||
|
headers={"x-auth-user-id": result["user_id"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/auth/logout")
|
||||||
|
async def api_logout(response: Response):
|
||||||
|
"""Log out the current user by clearing the session cookie."""
|
||||||
|
return await logout(response)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/auth/set-session")
|
||||||
|
async def api_set_session(request: Request, response: Response):
|
||||||
|
"""Set session cookie using JWT token from request body or Authorization header."""
|
||||||
|
return await set_session(request, response)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/auth/delete-credential")
|
||||||
|
async def api_delete_credential(request: Request):
|
||||||
|
"""Delete a specific credential for the current user."""
|
||||||
|
return await delete_credential(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/auth/create-device-link")
|
||||||
|
async def api_create_device_link(request: Request):
|
||||||
|
"""Create a device addition link for the authenticated user."""
|
||||||
|
return await create_device_addition_link(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/auth/validate-device-token")
|
||||||
|
async def api_validate_device_token(request: Request):
|
||||||
|
"""Validate a device addition token."""
|
||||||
|
return await validate_device_addition_token(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/{passphrase}")
|
||||||
|
async def reset_authentication(
|
||||||
|
passphrase: str = FastAPIPath(pattern=r"^\w+(\.\w+){2,}$"),
|
||||||
|
):
|
||||||
|
response = RedirectResponse(url="/", status_code=303)
|
||||||
|
response.set_cookie(
|
||||||
|
key="auth-token",
|
||||||
|
value=passphrase,
|
||||||
|
httponly=False,
|
||||||
|
secure=True,
|
||||||
|
samesite="strict",
|
||||||
|
max_age=2,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# Serve static files
|
||||||
|
app.mount(
|
||||||
|
"/auth/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="static assets"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth")
|
||||||
|
async def redirect_to_index():
|
||||||
|
"""Serve the main authentication app."""
|
||||||
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
# Catch-all route for SPA - serve index.html for all non-API routes
|
||||||
|
@app.get("/{path:path}")
|
||||||
|
async def spa_handler(request: Request, path: str):
|
||||||
|
"""Serve the Vue SPA for all routes (except API and static)"""
|
||||||
|
if "text/html" not in request.headers.get("accept", ""):
|
||||||
|
return Response(content="Not Found", status_code=404)
|
||||||
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Entry point for the application"""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"passkey.fastapi.main:app",
|
||||||
|
host="localhost",
|
||||||
|
port=4401,
|
||||||
|
reload=True,
|
||||||
|
log_level="info",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
main()
|
@ -11,8 +11,8 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
from . import db
|
from ..db import sql
|
||||||
from .passphrase import generate
|
from ..util.passphrase import generate
|
||||||
from .session_manager import get_current_user
|
from .session_manager import get_current_user
|
||||||
|
|
||||||
|
|
||||||
@ -28,7 +28,7 @@ async def create_device_addition_link(request: Request) -> dict:
|
|||||||
token = generate(n=4, sep=".") # e.g., "able-ocean-forest-dawn"
|
token = generate(n=4, sep=".") # e.g., "able-ocean-forest-dawn"
|
||||||
|
|
||||||
# Create reset token in database
|
# Create reset token in database
|
||||||
await db.create_reset_token(user.user_id, token)
|
await sql.create_reset_token(user.user_id, token)
|
||||||
|
|
||||||
# Generate the device addition link with pretty URL
|
# Generate the device addition link with pretty URL
|
||||||
addition_link = f"{request.headers.get('origin', '')}/auth/{token}"
|
addition_link = f"{request.headers.get('origin', '')}/auth/{token}"
|
||||||
@ -54,7 +54,7 @@ async def validate_device_addition_token(request: Request) -> dict:
|
|||||||
return {"error": "Device addition token is required"}
|
return {"error": "Device addition token is required"}
|
||||||
|
|
||||||
# Get reset token
|
# Get reset token
|
||||||
reset_token = await db.get_reset_token(token)
|
reset_token = await sql.get_reset_token(token)
|
||||||
if not reset_token:
|
if not reset_token:
|
||||||
return {"error": "Invalid or expired device addition token"}
|
return {"error": "Invalid or expired device addition token"}
|
||||||
|
|
||||||
@ -64,7 +64,7 @@ async def validate_device_addition_token(request: Request) -> dict:
|
|||||||
return {"error": "Device addition token has expired"}
|
return {"error": "Device addition token has expired"}
|
||||||
|
|
||||||
# Get user info
|
# Get user info
|
||||||
user = await db.get_user_by_id(reset_token.user_id)
|
user = await sql.get_user_by_id(reset_token.user_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@ -82,7 +82,7 @@ async def use_device_addition_token(token: str) -> dict:
|
|||||||
"""Delete a device addition token after successful use."""
|
"""Delete a device addition token after successful use."""
|
||||||
try:
|
try:
|
||||||
# Get reset token first to validate it exists and is not expired
|
# Get reset token first to validate it exists and is not expired
|
||||||
reset_token = await db.get_reset_token(token)
|
reset_token = await sql.get_reset_token(token)
|
||||||
if not reset_token:
|
if not reset_token:
|
||||||
return {"error": "Invalid or expired device addition token"}
|
return {"error": "Invalid or expired device addition token"}
|
||||||
|
|
||||||
@ -92,7 +92,7 @@ async def use_device_addition_token(token: str) -> dict:
|
|||||||
return {"error": "Device addition token has expired"}
|
return {"error": "Device addition token has expired"}
|
||||||
|
|
||||||
# Delete the token (it's now used)
|
# Delete the token (it's now used)
|
||||||
await db.delete_reset_token(token)
|
await sql.delete_reset_token(token)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
@ -11,8 +11,8 @@ from uuid import UUID
|
|||||||
|
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
|
|
||||||
from .db import User, get_user_by_id
|
from ..db.sql import User, get_user_by_id
|
||||||
from .jwt_manager import validate_session_token
|
from ..util.jwt import validate_session_token
|
||||||
|
|
||||||
COOKIE_NAME = "auth"
|
COOKIE_NAME = "auth"
|
||||||
COOKIE_MAX_AGE = 86400 # 24 hours
|
COOKIE_MAX_AGE = 86400 # 24 hours
|
219
passkey/fastapi/ws_handlers.py
Normal file
219
passkey/fastapi/ws_handlers.py
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
"""
|
||||||
|
WebSocket handlers for passkey authentication operations.
|
||||||
|
|
||||||
|
This module contains all WebSocket endpoints for:
|
||||||
|
- User registration
|
||||||
|
- Adding credentials to existing users
|
||||||
|
- Device credential addition via token
|
||||||
|
- Authentication
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import uuid7
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
||||||
|
|
||||||
|
from ..db import sql
|
||||||
|
from ..db.sql import User
|
||||||
|
from ..sansio import Passkey
|
||||||
|
from ..util.jwt import create_session_token
|
||||||
|
from .session_manager import get_user_from_cookie_string
|
||||||
|
|
||||||
|
# Create a FastAPI subapp for WebSocket endpoints
|
||||||
|
ws_app = FastAPI()
|
||||||
|
|
||||||
|
# Initialize the passkey instance
|
||||||
|
passkey = Passkey(
|
||||||
|
rp_id="localhost",
|
||||||
|
rp_name="Passkey Auth",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def register_chat(
|
||||||
|
ws: WebSocket,
|
||||||
|
user_id: UUID,
|
||||||
|
user_name: str,
|
||||||
|
credential_ids: list[bytes] | None = None,
|
||||||
|
origin: str | None = None,
|
||||||
|
):
|
||||||
|
"""Generate registration options and send them to the client."""
|
||||||
|
options, challenge = passkey.reg_generate_options(
|
||||||
|
user_id=user_id,
|
||||||
|
user_name=user_name,
|
||||||
|
credential_ids=credential_ids,
|
||||||
|
origin=origin,
|
||||||
|
)
|
||||||
|
await ws.send_json(options)
|
||||||
|
response = await ws.receive_json()
|
||||||
|
return passkey.reg_verify(response, challenge, user_id, origin=origin)
|
||||||
|
|
||||||
|
|
||||||
|
@ws_app.websocket("/register_new")
|
||||||
|
async def websocket_register_new(ws: WebSocket, user_name: str):
|
||||||
|
"""Register a new user and with a new passkey credential."""
|
||||||
|
await ws.accept()
|
||||||
|
origin = ws.headers.get("origin")
|
||||||
|
try:
|
||||||
|
user_id = uuid7.create()
|
||||||
|
|
||||||
|
# WebAuthn registration
|
||||||
|
credential = await register_chat(ws, user_id, user_name, origin=origin)
|
||||||
|
|
||||||
|
# Store the user and credential in the database
|
||||||
|
await sql.create_user_and_credential(
|
||||||
|
User(user_id, user_name, created_at=datetime.now()),
|
||||||
|
credential,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a session token for the new user
|
||||||
|
session_token = create_session_token(user_id, credential.credential_id)
|
||||||
|
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"session_token": session_token,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
await ws.send_json({"error": str(e)})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Internal Server Error")
|
||||||
|
await ws.send_json({"error": "Internal Server Error"})
|
||||||
|
|
||||||
|
|
||||||
|
@ws_app.websocket("/add_credential")
|
||||||
|
async def websocket_register_add(ws: WebSocket):
|
||||||
|
"""Register a new credential for an existing user."""
|
||||||
|
await ws.accept()
|
||||||
|
origin = ws.headers.get("origin")
|
||||||
|
try:
|
||||||
|
# Authenticate user via cookie
|
||||||
|
cookie_header = ws.headers.get("cookie", "")
|
||||||
|
user_id = await get_user_from_cookie_string(cookie_header)
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
await ws.send_json({"error": "Authentication required"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get user information to get the user_name
|
||||||
|
user = await sql.get_user_by_id(user_id)
|
||||||
|
user_name = user.user_name
|
||||||
|
challenge_ids = await sql.get_user_credentials(user_id)
|
||||||
|
|
||||||
|
# WebAuthn registration
|
||||||
|
credential = await register_chat(
|
||||||
|
ws, user_id, user_name, challenge_ids, origin=origin
|
||||||
|
)
|
||||||
|
# Store the new credential in the database
|
||||||
|
await sql.create_credential_for_user(credential)
|
||||||
|
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"credential_id": credential.credential_id.hex(),
|
||||||
|
"message": "New credential added successfully",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
await ws.send_json({"error": str(e)})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Internal Server Error")
|
||||||
|
await ws.send_json({"error": "Internal Server Error"})
|
||||||
|
|
||||||
|
|
||||||
|
@ws_app.websocket("/add_device_credential")
|
||||||
|
async def websocket_add_device_credential(ws: WebSocket, token: str):
|
||||||
|
"""Add a new credential for an existing user via device addition token."""
|
||||||
|
await ws.accept()
|
||||||
|
origin = ws.headers.get("origin")
|
||||||
|
try:
|
||||||
|
reset_token = await sql.get_reset_token(token)
|
||||||
|
if not reset_token:
|
||||||
|
await ws.send_json({"error": "Invalid or expired device addition token"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if token is expired (24 hours)
|
||||||
|
expiry_time = reset_token.created_at + timedelta(hours=24)
|
||||||
|
if datetime.now() > expiry_time:
|
||||||
|
await ws.send_json({"error": "Device addition token has expired"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get user information
|
||||||
|
user = await sql.get_user_by_id(reset_token.user_id)
|
||||||
|
|
||||||
|
# WebAuthn registration
|
||||||
|
# Fetch challenge IDs for the user
|
||||||
|
challenge_ids = await sql.get_user_credentials(reset_token.user_id)
|
||||||
|
|
||||||
|
credential = await register_chat(
|
||||||
|
ws, reset_token.user_id, user.user_name, challenge_ids, origin=origin
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the new credential in the database
|
||||||
|
await sql.create_credential_for_user(credential)
|
||||||
|
|
||||||
|
# Delete the device addition token (it's now used)
|
||||||
|
await sql.delete_reset_token(token)
|
||||||
|
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"user_id": str(reset_token.user_id),
|
||||||
|
"credential_id": credential.credential_id.hex(),
|
||||||
|
"message": "New credential added successfully via device addition token",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
await ws.send_json({"error": str(e)})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Internal Server Error")
|
||||||
|
await ws.send_json({"error": "Internal Server Error"})
|
||||||
|
|
||||||
|
|
||||||
|
@ws_app.websocket("/authenticate")
|
||||||
|
async def websocket_authenticate(ws: WebSocket):
|
||||||
|
await ws.accept()
|
||||||
|
origin = ws.headers.get("origin")
|
||||||
|
try:
|
||||||
|
options, challenge = passkey.auth_generate_options()
|
||||||
|
await ws.send_json(options)
|
||||||
|
# Wait for the client to use his authenticator to authenticate
|
||||||
|
credential = passkey.auth_parse(await ws.receive_json())
|
||||||
|
# Fetch from the database by credential ID
|
||||||
|
stored_cred = await sql.get_credential_by_id(credential.raw_id)
|
||||||
|
# Verify the credential matches the stored data
|
||||||
|
passkey.auth_verify(credential, challenge, stored_cred, origin=origin)
|
||||||
|
# Update both credential and user's last_seen timestamp
|
||||||
|
await sql.login_user(stored_cred.user_id, stored_cred)
|
||||||
|
|
||||||
|
# Create a session token for the authenticated user
|
||||||
|
session_token = create_session_token(
|
||||||
|
stored_cred.user_id, stored_cred.credential_id
|
||||||
|
)
|
||||||
|
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"user_id": str(stored_cred.user_id),
|
||||||
|
"session_token": session_token,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (ValueError, InvalidAuthenticationResponse) as e:
|
||||||
|
logging.exception("ValueError")
|
||||||
|
await ws.send_json({"error": str(e)})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Internal Server Error")
|
||||||
|
await ws.send_json({"error": "Internal Server Error"})
|
@ -22,8 +22,8 @@ def load_or_create_secret() -> bytes:
|
|||||||
if SECRET_FILE.exists():
|
if SECRET_FILE.exists():
|
||||||
return SECRET_FILE.read_bytes()
|
return SECRET_FILE.read_bytes()
|
||||||
else:
|
else:
|
||||||
# Generate a new 32-byte secret
|
# Generate a new 16-byte secret
|
||||||
secret = secrets.token_bytes(32)
|
secret = secrets.token_bytes(16)
|
||||||
SECRET_FILE.write_bytes(secret)
|
SECRET_FILE.write_bytes(secret)
|
||||||
return secret
|
return secret
|
||||||
|
|
||||||
@ -47,7 +47,7 @@ class JWTManager:
|
|||||||
Returns:
|
Returns:
|
||||||
JWT token string
|
JWT token string
|
||||||
"""
|
"""
|
||||||
now = datetime.utcnow()
|
now = datetime.now()
|
||||||
payload = {
|
payload = {
|
||||||
"user_id": str(user_id),
|
"user_id": str(user_id),
|
||||||
"credential_id": credential_id.hex(),
|
"credential_id": credential_id.hex(),
|
||||||
@ -105,7 +105,7 @@ class JWTManager:
|
|||||||
|
|
||||||
|
|
||||||
# Global JWT manager instance
|
# Global JWT manager instance
|
||||||
_jwt_manager: Optional[JWTManager] = None
|
_jwt_manager: JWTManager | None = None
|
||||||
|
|
||||||
|
|
||||||
def get_jwt_manager() -> JWTManager:
|
def get_jwt_manager() -> JWTManager:
|
||||||
@ -114,7 +114,7 @@ def get_jwt_manager() -> JWTManager:
|
|||||||
if _jwt_manager is None:
|
if _jwt_manager is None:
|
||||||
secret = load_or_create_secret()
|
secret = load_or_create_secret()
|
||||||
_jwt_manager = JWTManager(secret)
|
_jwt_manager = JWTManager(secret)
|
||||||
return _jwt_manager
|
return _jwt_manager # type: ignore
|
||||||
|
|
||||||
|
|
||||||
def create_session_token(user_id: UUID, credential_id: bytes) -> str:
|
def create_session_token(user_id: UUID, credential_id: bytes) -> str:
|
@ -1 +0,0 @@
|
|||||||
# passkeyauth package
|
|
@ -1,65 +0,0 @@
|
|||||||
"""
|
|
||||||
AAGUID (Authenticator Attestation GUID) management for WebAuthn credentials.
|
|
||||||
|
|
||||||
This module provides functionality to:
|
|
||||||
- Load AAGUID data from JSON file
|
|
||||||
- Look up authenticator information by AAGUID
|
|
||||||
- Return only relevant AAGUID data for user credentials
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
# Path to the AAGUID JSON file
|
|
||||||
AAGUID_FILE = Path(__file__).parent / "combined_aaguid.json"
|
|
||||||
|
|
||||||
|
|
||||||
class AAGUIDManager:
|
|
||||||
"""Manages AAGUID data and lookups."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.aaguid_data: dict[str, dict] = {}
|
|
||||||
self.load_aaguid_data()
|
|
||||||
|
|
||||||
def load_aaguid_data(self) -> None:
|
|
||||||
"""Load AAGUID data from the JSON file."""
|
|
||||||
try:
|
|
||||||
with open(AAGUID_FILE, encoding="utf-8") as f:
|
|
||||||
self.aaguid_data = json.load(f)
|
|
||||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
|
||||||
print(f"Warning: Could not load AAGUID data: {e}")
|
|
||||||
self.aaguid_data = {}
|
|
||||||
|
|
||||||
def get_authenticator_info(self, aaguid: str) -> Optional[dict]:
|
|
||||||
"""Get authenticator information for a specific AAGUID."""
|
|
||||||
return self.aaguid_data.get(aaguid)
|
|
||||||
|
|
||||||
def get_relevant_aaguids(self, aaguids: set[str]) -> dict[str, dict]:
|
|
||||||
"""
|
|
||||||
Get AAGUID information only for the provided set of AAGUIDs.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
aaguids: Set of AAGUID strings that the user has credentials for
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary mapping AAGUID to authenticator information for only
|
|
||||||
the AAGUIDs that the user has and that we have data for
|
|
||||||
"""
|
|
||||||
relevant = {}
|
|
||||||
for aaguid in aaguids:
|
|
||||||
if aaguid in self.aaguid_data:
|
|
||||||
relevant[aaguid] = self.aaguid_data[aaguid]
|
|
||||||
return relevant
|
|
||||||
|
|
||||||
|
|
||||||
# Global AAGUID manager instance
|
|
||||||
_aaguid_manager: Optional[AAGUIDManager] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_aaguid_manager() -> AAGUIDManager:
|
|
||||||
"""Get the global AAGUID manager instance."""
|
|
||||||
global _aaguid_manager
|
|
||||||
if _aaguid_manager is None:
|
|
||||||
_aaguid_manager = AAGUIDManager()
|
|
||||||
return _aaguid_manager
|
|
@ -1,381 +0,0 @@
|
|||||||
"""
|
|
||||||
Minimal FastAPI WebAuthn server with WebSocket support for passkey registration and authentication.
|
|
||||||
|
|
||||||
This module provides a simple WebAuthn implementation that:
|
|
||||||
- Uses WebSocket for real-time communication
|
|
||||||
- Supports Resident Keys (discoverable credentials) for passwordless authentication
|
|
||||||
- Maintains challenges locally per connection
|
|
||||||
- Uses async SQLite database for persistent storage of users and credentials
|
|
||||||
- Enables true passwordless authentication where users don't need to enter a user_name
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from fastapi import (
|
|
||||||
FastAPI,
|
|
||||||
Request,
|
|
||||||
Response,
|
|
||||||
WebSocket,
|
|
||||||
WebSocketDisconnect,
|
|
||||||
)
|
|
||||||
from fastapi import (
|
|
||||||
Path as FastAPIPath,
|
|
||||||
)
|
|
||||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
|
||||||
|
|
||||||
from . import db
|
|
||||||
from .api_handlers import (
|
|
||||||
delete_credential,
|
|
||||||
get_user_credentials,
|
|
||||||
get_user_info,
|
|
||||||
logout,
|
|
||||||
refresh_token,
|
|
||||||
set_session,
|
|
||||||
validate_token,
|
|
||||||
)
|
|
||||||
from .db import User
|
|
||||||
from .jwt_manager import create_session_token
|
|
||||||
from .passkey import Passkey
|
|
||||||
from .reset_handlers import create_device_addition_link, validate_device_addition_token
|
|
||||||
from .session_manager import get_user_from_cookie_string
|
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent / "frontend-static"
|
|
||||||
|
|
||||||
passkey = Passkey(
|
|
||||||
rp_id="localhost",
|
|
||||||
rp_name="Passkey Auth",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
await db.init_database()
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="Passkey Auth", lifespan=lifespan)
|
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/auth/ws/register_new")
|
|
||||||
async def websocket_register_new(ws: WebSocket, user_name: str):
|
|
||||||
"""Register a new user and with a new passkey credential."""
|
|
||||||
await ws.accept()
|
|
||||||
origin = ws.headers.get("origin")
|
|
||||||
try:
|
|
||||||
user_id = uuid4()
|
|
||||||
|
|
||||||
# WebAuthn registration
|
|
||||||
credential = await register_chat(ws, user_id, user_name, origin=origin)
|
|
||||||
|
|
||||||
# Store the user and credential in the database
|
|
||||||
await db.create_user_and_credential(
|
|
||||||
User(user_id, user_name, created_at=datetime.now()),
|
|
||||||
credential,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a session token for the new user
|
|
||||||
session_token = create_session_token(user_id, credential.credential_id)
|
|
||||||
|
|
||||||
await ws.send_json(
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"user_id": str(user_id),
|
|
||||||
"session_token": session_token,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
await ws.send_json({"error": str(e)})
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
logging.exception("Internal Server Error")
|
|
||||||
await ws.send_json({"error": "Internal Server Error"})
|
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/auth/ws/add_credential")
|
|
||||||
async def websocket_register_add(ws: WebSocket):
|
|
||||||
"""Register a new credential for an existing user."""
|
|
||||||
await ws.accept()
|
|
||||||
origin = ws.headers.get("origin")
|
|
||||||
try:
|
|
||||||
# Authenticate user via cookie
|
|
||||||
cookie_header = ws.headers.get("cookie", "")
|
|
||||||
user_id = await get_user_from_cookie_string(cookie_header)
|
|
||||||
|
|
||||||
if not user_id:
|
|
||||||
await ws.send_json({"error": "Authentication required"})
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get user information to get the user_name
|
|
||||||
user = await db.get_user_by_id(user_id)
|
|
||||||
user_name = user.user_name
|
|
||||||
challenge_ids = await db.get_user_credentials(user_id)
|
|
||||||
|
|
||||||
# WebAuthn registration
|
|
||||||
credential = await register_chat(
|
|
||||||
ws, user_id, user_name, challenge_ids, origin=origin
|
|
||||||
)
|
|
||||||
# Store the new credential in the database
|
|
||||||
await db.create_credential_for_user(credential)
|
|
||||||
|
|
||||||
await ws.send_json(
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"user_id": str(user_id),
|
|
||||||
"credential_id": credential.credential_id.hex(),
|
|
||||||
"message": "New credential added successfully",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
await ws.send_json({"error": str(e)})
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
logging.exception("Internal Server Error")
|
|
||||||
await ws.send_json({"error": "Internal Server Error"})
|
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/auth/ws/add_device_credential")
|
|
||||||
async def websocket_add_device_credential(ws: WebSocket, token: str):
|
|
||||||
"""Add a new credential for an existing user via device addition token."""
|
|
||||||
await ws.accept()
|
|
||||||
origin = ws.headers.get("origin")
|
|
||||||
try:
|
|
||||||
reset_token = await db.get_reset_token(token)
|
|
||||||
if not reset_token:
|
|
||||||
await ws.send_json({"error": "Invalid or expired device addition token"})
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if token is expired (24 hours)
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
expiry_time = reset_token.created_at + timedelta(hours=24)
|
|
||||||
if datetime.now() > expiry_time:
|
|
||||||
await ws.send_json({"error": "Device addition token has expired"})
|
|
||||||
return
|
|
||||||
|
|
||||||
# Get user information
|
|
||||||
user = await db.get_user_by_id(reset_token.user_id)
|
|
||||||
|
|
||||||
# WebAuthn registration
|
|
||||||
# Fetch challenge IDs for the user
|
|
||||||
challenge_ids = await db.get_user_credentials(reset_token.user_id)
|
|
||||||
|
|
||||||
credential = await register_chat(
|
|
||||||
ws, reset_token.user_id, user.user_name, challenge_ids, origin=origin
|
|
||||||
)
|
|
||||||
|
|
||||||
# Store the new credential in the database
|
|
||||||
await db.create_credential_for_user(credential)
|
|
||||||
|
|
||||||
# Delete the device addition token (it's now used)
|
|
||||||
await db.delete_reset_token(token)
|
|
||||||
|
|
||||||
await ws.send_json(
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"user_id": str(reset_token.user_id),
|
|
||||||
"credential_id": credential.credential_id.hex(),
|
|
||||||
"message": "New credential added successfully via device addition token",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except ValueError as e:
|
|
||||||
await ws.send_json({"error": str(e)})
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
logging.exception("Internal Server Error")
|
|
||||||
await ws.send_json({"error": "Internal Server Error"})
|
|
||||||
|
|
||||||
|
|
||||||
async def register_chat(
|
|
||||||
ws: WebSocket,
|
|
||||||
user_id: UUID,
|
|
||||||
user_name: str,
|
|
||||||
credential_ids: list[bytes] | None = None,
|
|
||||||
origin: str | None = None,
|
|
||||||
):
|
|
||||||
"""Generate registration options and send them to the client."""
|
|
||||||
options, challenge = passkey.reg_generate_options(
|
|
||||||
user_id=user_id,
|
|
||||||
user_name=user_name,
|
|
||||||
credential_ids=credential_ids,
|
|
||||||
origin=origin,
|
|
||||||
)
|
|
||||||
await ws.send_json(options)
|
|
||||||
response = await ws.receive_json()
|
|
||||||
return passkey.reg_verify(response, challenge, user_id, origin=origin)
|
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/auth/ws/authenticate")
|
|
||||||
async def websocket_authenticate(ws: WebSocket):
|
|
||||||
await ws.accept()
|
|
||||||
origin = ws.headers.get("origin")
|
|
||||||
try:
|
|
||||||
options, challenge = passkey.auth_generate_options()
|
|
||||||
await ws.send_json(options)
|
|
||||||
# Wait for the client to use his authenticator to authenticate
|
|
||||||
credential = passkey.auth_parse(await ws.receive_json())
|
|
||||||
# Fetch from the database by credential ID
|
|
||||||
stored_cred = await db.get_credential_by_id(credential.raw_id)
|
|
||||||
# Verify the credential matches the stored data
|
|
||||||
passkey.auth_verify(credential, challenge, stored_cred, origin=origin)
|
|
||||||
# Update both credential and user's last_seen timestamp
|
|
||||||
await db.login_user(stored_cred.user_id, stored_cred)
|
|
||||||
|
|
||||||
# Create a session token for the authenticated user
|
|
||||||
session_token = create_session_token(
|
|
||||||
stored_cred.user_id, stored_cred.credential_id
|
|
||||||
)
|
|
||||||
|
|
||||||
await ws.send_json(
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"user_id": str(stored_cred.user_id),
|
|
||||||
"session_token": session_token,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except (ValueError, InvalidAuthenticationResponse) as e:
|
|
||||||
logging.exception("ValueError")
|
|
||||||
await ws.send_json({"error": str(e)})
|
|
||||||
except WebSocketDisconnect:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
logging.exception("Internal Server Error")
|
|
||||||
await ws.send_json({"error": "Internal Server Error"})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/user-info")
|
|
||||||
async def api_get_user_info(request: Request):
|
|
||||||
"""Get user information from session cookie."""
|
|
||||||
return await get_user_info(request)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/user-credentials")
|
|
||||||
async def api_get_user_credentials(request: Request):
|
|
||||||
"""Get all credentials for a user using session cookie."""
|
|
||||||
return await get_user_credentials(request)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/auth/refresh-token")
|
|
||||||
async def api_refresh_token(request: Request, response: Response):
|
|
||||||
"""Refresh the session token."""
|
|
||||||
return await refresh_token(request, response)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/validate-token")
|
|
||||||
async def api_validate_token(request: Request):
|
|
||||||
"""Validate a session token and return user info."""
|
|
||||||
return await validate_token(request)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/forward-auth")
|
|
||||||
async def forward_authentication(request: Request):
|
|
||||||
"""A verification endpoint to use with Caddy forward_auth or Nginx auth_request."""
|
|
||||||
result = await validate_token(request)
|
|
||||||
if result.get("status") != "success":
|
|
||||||
# Serve the index.html of the authentication app if not authenticated
|
|
||||||
return FileResponse(
|
|
||||||
STATIC_DIR / "index.html",
|
|
||||||
status_code=401,
|
|
||||||
headers={"www-authenticate": "PrivateToken"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# If authenticated, return a success response
|
|
||||||
return JSONResponse(
|
|
||||||
result,
|
|
||||||
headers={
|
|
||||||
"x-auth-user-id": result["user_id"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/auth/logout")
|
|
||||||
async def api_logout(response: Response):
|
|
||||||
"""Log out the current user by clearing the session cookie."""
|
|
||||||
return await logout(response)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/auth/set-session")
|
|
||||||
async def api_set_session(request: Request, response: Response):
|
|
||||||
"""Set session cookie using JWT token from request body or Authorization header."""
|
|
||||||
return await set_session(request, response)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/auth/delete-credential")
|
|
||||||
async def api_delete_credential(request: Request):
|
|
||||||
"""Delete a specific credential for the current user."""
|
|
||||||
return await delete_credential(request)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/auth/create-device-link")
|
|
||||||
async def api_create_device_link(request: Request):
|
|
||||||
"""Create a device addition link for the authenticated user."""
|
|
||||||
return await create_device_addition_link(request)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/auth/validate-device-token")
|
|
||||||
async def api_validate_device_token(request: Request):
|
|
||||||
"""Validate a device addition token."""
|
|
||||||
return await validate_device_addition_token(request)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/{passphrase}")
|
|
||||||
async def reset_authentication(
|
|
||||||
passphrase: str = FastAPIPath(pattern=r"^\w+(\.\w+){2,}$"),
|
|
||||||
):
|
|
||||||
response = RedirectResponse(url="/", status_code=303)
|
|
||||||
response.set_cookie(
|
|
||||||
key="auth-token",
|
|
||||||
value=passphrase,
|
|
||||||
httponly=False,
|
|
||||||
secure=True,
|
|
||||||
samesite="strict",
|
|
||||||
max_age=2,
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
# Serve static files
|
|
||||||
app.mount(
|
|
||||||
"/auth/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="static assets"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth")
|
|
||||||
async def redirect_to_index():
|
|
||||||
"""Serve the main authentication app."""
|
|
||||||
return FileResponse(STATIC_DIR / "index.html")
|
|
||||||
|
|
||||||
|
|
||||||
# Catch-all route for SPA - serve index.html for all non-API routes
|
|
||||||
@app.get("/{path:path}")
|
|
||||||
async def spa_handler(request: Request, path: str):
|
|
||||||
"""Serve the Vue SPA for all routes (except API and static)"""
|
|
||||||
if "text/html" not in request.headers.get("accept", ""):
|
|
||||||
return Response(content="Not Found", status_code=404)
|
|
||||||
return FileResponse(STATIC_DIR / "index.html")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Entry point for the application"""
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
uvicorn.run(
|
|
||||||
"passkeyauth.main:app",
|
|
||||||
host="0.0.0.0",
|
|
||||||
port=8000,
|
|
||||||
reload=True,
|
|
||||||
log_level="info",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
|
||||||
main()
|
|
@ -3,15 +3,14 @@ requires = ["hatchling"]
|
|||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "passkeyauth"
|
name = "passkey"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Minimal FastAPI WebAuthn server with WebSocket support"
|
description = "Passkey Authentication for Web Services"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "User", email = "user@example.com"},
|
{name = "Leo Vasanko"},
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]>=0.104.1",
|
"fastapi[standard]>=0.104.1",
|
||||||
"uvicorn[standard]>=0.24.0",
|
|
||||||
"websockets>=12.0",
|
"websockets>=12.0",
|
||||||
"webauthn>=1.11.1",
|
"webauthn>=1.11.1",
|
||||||
"base64url>=1.0.0",
|
"base64url>=1.0.0",
|
||||||
@ -34,10 +33,10 @@ select = ["E", "F", "I", "N", "W", "UP"]
|
|||||||
ignore = ["E501"] # Line too long
|
ignore = ["E501"] # Line too long
|
||||||
|
|
||||||
[tool.ruff.isort]
|
[tool.ruff.isort]
|
||||||
known-first-party = ["passkeyauth"]
|
known-first-party = ["passkey"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
serve = "passkeyauth.main:main"
|
serve = "passkey.main:main"
|
||||||
|
|
||||||
[tool.hatch.build]
|
[tool.hatch.build]
|
||||||
artifacts = ["passkeyauth/frontend-static"]
|
artifacts = ["passkeyauth/frontend-static"]
|
||||||
|
Loading…
x
Reference in New Issue
Block a user