diff --git a/authsession.py b/authsession.py new file mode 100644 index 0000000..a375b86 --- /dev/null +++ b/authsession.py @@ -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) diff --git a/passkey/authsession.py b/passkey/authsession.py new file mode 100644 index 0000000..216ce29 --- /dev/null +++ b/passkey/authsession.py @@ -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) diff --git a/passkey/fastapi/__init__.py b/passkey/fastapi/__init__.py new file mode 100644 index 0000000..5036a58 --- /dev/null +++ b/passkey/fastapi/__init__.py @@ -0,0 +1,3 @@ +from .mainapp import app + +__all__ = ["app"] diff --git a/passkey/fastapi/__main__.py b/passkey/fastapi/__main__.py new file mode 100644 index 0000000..0257d84 --- /dev/null +++ b/passkey/fastapi/__main__.py @@ -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() diff --git a/passkey/fastapi/api.py b/passkey/fastapi/api.py index c0f0acb..46fd9ba 100644 --- a/passkey/fastapi/api.py +++ b/passkey/fastapi/api.py @@ -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: diff --git a/passkey/fastapi/main.py b/passkey/fastapi/mainapp.py similarity index 80% rename from passkey/fastapi/main.py rename to passkey/fastapi/mainapp.py index 448c986..a799c43 100644 --- a/passkey/fastapi/main.py +++ b/passkey/fastapi/mainapp.py @@ -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() diff --git a/passkey/fastapi/reset.py b/passkey/fastapi/reset.py index f26a4aa..806fe7b 100644 --- a/passkey/fastapi/reset.py +++ b/passkey/fastapi/reset.py @@ -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: diff --git a/passkey/fastapi/session.py b/passkey/fastapi/session.py index 0ab9926..3c96917 100644 --- a/passkey/fastapi/session.py +++ b/passkey/fastapi/session.py @@ -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) diff --git a/passkey/fastapi/ws.py b/passkey/fastapi/ws.py index c7025c8..e8488c2 100644 --- a/passkey/fastapi/ws.py +++ b/passkey/fastapi/ws.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 371ad4a..f8e0e8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"]