Separated session management from its FastAPI-dependent parts, creating authsession.py on main level.

Startup/main/scripts cleanup, now runs with passkey-auth command that takes CLI arguments.
This commit is contained in:
Leo Vasanko 2025-08-05 09:02:49 -06:00
parent b58b7d5350
commit 7f8f77ae1e
10 changed files with 193 additions and 98 deletions

67
authsession.py Normal file
View File

@ -0,0 +1,67 @@
"""
Core session management for WebAuthn authentication.
This module provides generic session management functionality that is
independent of any web framework:
- Session creation and validation
- Token handling and refresh
- Credential management
"""
from datetime import datetime, timedelta
from uuid import UUID
from passkey.db import Session, db
from passkey.util import passphrase
from passkey.util.tokens import create_token, reset_key, session_key
EXPIRES = timedelta(hours=24)
def expires() -> datetime:
return datetime.now() + EXPIRES
async def create_session(user_uuid: UUID, info: dict, credential_uuid: UUID) -> str:
"""Create a new session and return a session token."""
token = create_token()
await db.instance.create_session(
user_uuid=user_uuid,
key=session_key(token),
expires=datetime.now() + EXPIRES,
info=info,
credential_uuid=credential_uuid,
)
return token
async def get_session(token: str, reset_allowed=False) -> Session:
"""Validate a session token and return session data if valid."""
if passphrase.is_well_formed(token):
if not reset_allowed:
raise ValueError("Reset link is not allowed for this endpoint")
key = reset_key(token)
else:
key = session_key(token)
session = await db.instance.get_session(key)
if not session:
raise ValueError("Invalid or expired session token")
return session
async def refresh_session_token(token: str):
"""Refresh a session extending its expiry."""
# Get the current session
s = await db.instance.update_session(
session_key(token), datetime.now() + EXPIRES, {}
)
if not s:
raise ValueError("Session not found or expired")
async def delete_credential(credential_uuid: UUID, auth: str):
"""Delete a specific credential for the current user."""
s = await get_session(auth)
await db.instance.delete_credential(credential_uuid, s.user_uuid)

67
passkey/authsession.py Normal file
View File

@ -0,0 +1,67 @@
"""
Core session management for WebAuthn authentication.
This module provides generic session management functionality that is
independent of any web framework:
- Session creation and validation
- Token handling and refresh
- Credential management
"""
from datetime import datetime, timedelta
from uuid import UUID
from .db import Session, db
from .util import passphrase
from .util.tokens import create_token, reset_key, session_key
EXPIRES = timedelta(hours=24)
def expires() -> datetime:
return datetime.now() + EXPIRES
async def create_session(user_uuid: UUID, info: dict, credential_uuid: UUID) -> str:
"""Create a new session and return a session token."""
token = create_token()
await db.instance.create_session(
user_uuid=user_uuid,
key=session_key(token),
expires=datetime.now() + EXPIRES,
info=info,
credential_uuid=credential_uuid,
)
return token
async def get_session(token: str, reset_allowed=False) -> Session:
"""Validate a session token and return session data if valid."""
if passphrase.is_well_formed(token):
if not reset_allowed:
raise ValueError("Reset link is not allowed for this endpoint")
key = reset_key(token)
else:
key = session_key(token)
session = await db.instance.get_session(key)
if not session:
raise ValueError("Invalid or expired session token")
return session
async def refresh_session_token(token: str):
"""Refresh a session extending its expiry."""
# Get the current session
s = await db.instance.update_session(
session_key(token), datetime.now() + EXPIRES, {}
)
if not s:
raise ValueError("Session not found or expired")
async def delete_credential(credential_uuid: UUID, auth: str):
"""Delete a specific credential for the current user."""
s = await get_session(auth)
await db.instance.delete_credential(credential_uuid, s.user_uuid)

View File

@ -0,0 +1,3 @@
from .mainapp import app
__all__ = ["app"]

View File

@ -0,0 +1,32 @@
import argparse
import uvicorn
def main():
parser = argparse.ArgumentParser(
description="Run the passkey authentication server"
)
parser.add_argument(
"--host", default="localhost", help="Host to bind to (default: localhost)"
)
parser.add_argument(
"--port", type=int, default=4401, help="Port to bind to (default: 4401)"
)
parser.add_argument(
"--dev", action="store_true", help="Enable development mode with auto-reload"
)
args = parser.parse_args()
uvicorn.run(
"passkey.fastapi:app",
host=args.host,
port=args.port,
reload=args.dev,
log_level="debug" if args.dev else "info",
)
if __name__ == "__main__":
main()

View File

@ -14,6 +14,7 @@ from fastapi import Cookie, Depends, FastAPI, Request, Response
from fastapi.security import HTTPBearer
from .. import aaguid
from ..authsession import delete_credential, get_session
from ..db import db
from ..util.tokens import session_key
from . import session
@ -28,7 +29,7 @@ def register_api_routes(app: FastAPI):
async def validate_token(request: Request, response: Response, auth=Cookie(None)):
"""Lightweight token validation endpoint."""
try:
s = await session.get_session(auth)
s = await get_session(auth)
return {
"status": "success",
"valid": True,
@ -41,7 +42,7 @@ def register_api_routes(app: FastAPI):
async def api_user_info(auth=Cookie(None)):
"""Get full user information for the authenticated user."""
try:
s = await session.get_session(auth, reset_allowed=True)
s = await get_session(auth, reset_allowed=True)
u = await db.instance.get_user_by_user_uuid(s.user_uuid)
# Get all credentials for the user
credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid)
@ -110,7 +111,7 @@ def register_api_routes(app: FastAPI):
async def api_set_session(response: Response, auth=Depends(bearer_auth)):
"""Set session cookie from Authorization header. Fetched after login by WebSocket."""
try:
user = await session.get_session(auth.credentials)
user = await get_session(auth.credentials)
if not user:
raise ValueError("Invalid Authorization header.")
session.set_session_cookie(response, auth.credentials)
@ -130,7 +131,7 @@ def register_api_routes(app: FastAPI):
async def api_delete_credential(uuid: UUID, auth: str = Cookie(None)):
"""Delete a specific credential for the current user."""
try:
await session.delete_credential(uuid, auth)
await delete_credential(uuid, auth)
return {"status": "success", "message": "Credential deleted successfully"}
except ValueError as e:

View File

@ -1,5 +1,4 @@
import contextlib
import logging
from contextlib import asynccontextmanager
from pathlib import Path
@ -9,7 +8,8 @@ from fastapi.responses import (
)
from fastapi.staticfiles import StaticFiles
from . import session, ws
from ..authsession import get_session
from . import ws
from .api import register_api_routes
from .reset import register_reset_routes
@ -38,7 +38,7 @@ register_reset_routes(app)
async def forward_authentication(request: Request, auth=Cookie(None)):
"""A validation endpoint to use with Caddy forward_auth or Nginx auth_request."""
with contextlib.suppress(ValueError):
s = await session.get_session(auth)
s = await get_session(auth)
# If authenticated, return a success response
if s.info and s.info["type"] == "authenticated":
return Response(
@ -66,21 +66,3 @@ app.mount(
async def redirect_to_index():
"""Serve the main authentication app."""
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()

View File

@ -3,6 +3,7 @@ import logging
from fastapi import Cookie, HTTPException, Request
from fastapi.responses import RedirectResponse
from ..authsession import expires, get_session
from ..db import db
from ..util import passphrase, tokens
from . import session
@ -16,14 +17,14 @@ def register_reset_routes(app):
"""Create a device addition link for the authenticated user."""
try:
# Require authentication
s = await session.get_session(auth)
s = await get_session(auth)
# Generate a human-readable token
token = passphrase.generate() # e.g., "cross.rotate.yin.note.evoke"
await db.instance.create_session(
user_uuid=s.user_uuid,
key=tokens.reset_key(token),
expires=session.expires(),
expires=expires(),
info=session.infodict(request, "device addition"),
)
@ -35,7 +36,7 @@ def register_reset_routes(app):
"status": "success",
"message": "Registration link generated successfully",
"url": url,
"expires": session.expires().isoformat(),
"expires": expires().isoformat(),
}
except ValueError:

View File

@ -1,28 +1,16 @@
"""
Session management for WebAuthn authentication.
FastAPI-specific 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
- Device addition token management
- Device addition route handlers
This module provides FastAPI-specific session management functionality:
- Extracting client information from FastAPI requests
- Setting and clearing HTTP-only cookies via FastAPI Response objects
Generic session management functions have been moved to authsession.py
"""
from datetime import datetime, timedelta
from uuid import UUID
from fastapi import Request, Response, WebSocket
from ..db import Session, db
from ..util import passphrase
from ..util.tokens import create_token, reset_key, session_key
EXPIRES = timedelta(hours=24)
def expires() -> datetime:
return datetime.now() + EXPIRES
from ..authsession import EXPIRES
def infodict(request: Request | WebSocket, type: str) -> dict:
@ -34,45 +22,6 @@ def infodict(request: Request | WebSocket, type: str) -> dict:
}
async def create_session(user_uuid: UUID, info: dict, credential_uuid: UUID) -> str:
"""Create a new session and return a session token."""
token = create_token()
await db.instance.create_session(
user_uuid=user_uuid,
key=session_key(token),
expires=datetime.now() + EXPIRES,
info=info,
credential_uuid=credential_uuid,
)
return token
async def get_session(token: str, reset_allowed=False) -> Session:
"""Validate a session token and return session data if valid."""
if passphrase.is_well_formed(token):
if not reset_allowed:
raise ValueError("Reset link is not allowed for this endpoint")
key = reset_key(token)
else:
key = session_key(token)
session = await db.instance.get_session(key)
if not session:
raise ValueError("Invalid or expired session token")
return session
async def refresh_session_token(token: str):
"""Refresh a session extending its expiry."""
# Get the current session
s = await db.instance.update_session(
session_key(token), datetime.now() + EXPIRES, {}
)
if not s:
raise ValueError("Session not found or expired")
def set_session_cookie(response: Response, token: str) -> None:
"""Set the session token as an HTTP-only cookie."""
response.set_cookie(
@ -83,9 +32,3 @@ def set_session_cookie(response: Response, token: str) -> None:
secure=True,
path="/auth/",
)
async def delete_credential(credential_uuid: UUID, auth: str):
"""Delete a specific credential for the current user."""
s = await get_session(auth)
await db.instance.delete_credential(credential_uuid, s.user_uuid)

View File

@ -16,12 +16,11 @@ import uuid7
from fastapi import Cookie, FastAPI, Query, WebSocket, WebSocketDisconnect
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
from passkey.fastapi import session
from ..authsession import EXPIRES, create_session, get_session
from ..db import User, db
from ..sansio import Passkey
from ..util.tokens import create_token, session_key
from .session import create_session, infodict
from .session import infodict
# Create a FastAPI subapp for WebSocket endpoints
app = FastAPI()
@ -74,7 +73,7 @@ async def websocket_register_new(
await db.instance.create_session(
user_uuid=user_uuid,
key=session_key(token),
expires=datetime.now() + session.EXPIRES,
expires=datetime.now() + EXPIRES,
info=infodict(ws, "authenticated"),
credential_uuid=credential.uuid,
)
@ -102,7 +101,7 @@ async def websocket_register_add(ws: WebSocket, auth=Cookie(None)):
await ws.accept()
origin = ws.headers.get("origin")
try:
s = await session.get_session(auth, reset_allowed=True)
s = await get_session(auth, reset_allowed=True)
user_uuid = s.user_uuid
# Get user information to get the user_name

View File

@ -36,7 +36,7 @@ ignore = ["E501"] # Line too long
known-first-party = ["passkey"]
[project.scripts]
serve = "passkey.main:main"
passkey-auth = "passkey.fastapi.__main__:main"
[tool.hatch.build]
artifacts = ["passkeyauth/frontend-static"]