Utility module for accessing frontend in backend code.

This commit is contained in:
Leo Vasanko 2025-09-02 16:05:20 -06:00
parent dd20e7e7f8
commit 3cd6a59b26
5 changed files with 75 additions and 58 deletions

View File

@ -1,17 +1,14 @@
import argparse import argparse
import asyncio import asyncio
import atexit
import contextlib
import ipaddress import ipaddress
import logging import logging
import os import os
import signal
import subprocess
from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
import uvicorn import uvicorn
from passkey.util import frontend
DEFAULT_HOST = "localhost" DEFAULT_HOST = "localhost"
DEFAULT_SERVE_PORT = 4401 DEFAULT_SERVE_PORT = 4401
DEFAULT_DEV_PORT = 4402 DEFAULT_DEV_PORT = 4402
@ -180,36 +177,11 @@ def main():
run_kwargs["host"] = host run_kwargs["host"] = host
run_kwargs["port"] = port run_kwargs["port"] = port
bun_process: subprocess.Popen | None = None
if devmode: if devmode:
# Spawn frontend dev server (bun) only in the original parent (avoid duplicates on reload) # Spawn frontend dev server (bun or npm) only once in parent process
if os.environ.get("PASSKEY_BUN_PARENT") != "1": if os.environ.get("PASSKEY_BUN_PARENT") != "1":
os.environ["PASSKEY_BUN_PARENT"] = "1" os.environ["PASSKEY_BUN_PARENT"] = "1"
frontend_dir = Path(__file__).parent.parent.parent / "frontend" frontend.run_dev()
if (frontend_dir / "package.json").exists():
try:
bun_process = subprocess.Popen(
["bun", "--bun", "run", "dev"], cwd=str(frontend_dir)
)
logging.info("Started bun dev server")
except FileNotFoundError:
logging.warning(
"bun not found: skipping frontend dev server (install bun)"
)
def _terminate_bun(): # pragma: no cover
if bun_process and bun_process.poll() is None:
with contextlib.suppress(Exception):
bun_process.terminate()
atexit.register(_terminate_bun)
def _signal_handler(signum, frame): # pragma: no cover
_terminate_bun()
raise SystemExit(0)
signal.signal(signal.SIGINT, _signal_handler)
signal.signal(signal.SIGTERM, _signal_handler)
if all_ifaces and not uds: if all_ifaces and not uds:
# If reload enabled, fallback to single dual-stack attempt (::) to keep reload simple # If reload enabled, fallback to single dual-stack attempt (::) to keep reload simple

View File

@ -11,7 +11,6 @@ from __future__ import annotations
import contextlib import contextlib
import logging import logging
from pathlib import Path
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import Body, Cookie, FastAPI, HTTPException from fastapi import Body, Cookie, FastAPI, HTTPException
@ -20,11 +19,9 @@ from fastapi.responses import FileResponse, JSONResponse
from ..authsession import expires, get_session from ..authsession import expires, get_session
from ..globals import db from ..globals import db
from ..globals import passkey as global_passkey from ..globals import passkey as global_passkey
from ..util import passphrase, querysafe, tokens from ..util import frontend, passphrase, querysafe, tokens
from ..util.tokens import session_key from ..util.tokens import session_key
STATIC_DIR = Path(__file__).parent.parent / "frontend-build"
app = FastAPI() app = FastAPI()
@ -60,9 +57,9 @@ async def admin_frontend(auth=Cookie(None)):
with contextlib.suppress(ValueError): with contextlib.suppress(ValueError):
s = await get_session(auth) s = await get_session(auth)
if s.info and s.info.get("type") == "authenticated": if s.info and s.info.get("type") == "authenticated":
return FileResponse(STATIC_DIR / "admin" / "index.html") return FileResponse(frontend.file("admin/index.html"))
return FileResponse( return FileResponse(
STATIC_DIR / "index.html", frontend.file("index.html"),
status_code=401, status_code=401,
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )

View File

@ -1,18 +1,15 @@
import logging import logging
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from passkey.util import passphrase from passkey.util import frontend, passphrase
from . import admin, api, ws from . import admin, api, ws
STATIC_DIR = Path(__file__).parent.parent / "frontend-build"
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): # pragma: no cover - startup path async def lifespan(app: FastAPI): # pragma: no cover - startup path
@ -52,27 +49,18 @@ app = FastAPI(lifespan=lifespan)
app.mount("/auth/admin", admin.app) app.mount("/auth/admin", admin.app)
app.mount("/auth/api", api.app) app.mount("/auth/api", api.app)
app.mount("/auth/ws", ws.app) app.mount("/auth/ws", ws.app)
app.mount("/auth/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets") app.mount("/auth/assets", StaticFiles(directory=frontend.file("assets")), name="assets")
@app.get("/auth/") @app.get("/auth/")
async def frontend(): async def frontapp():
"""Serve the main authentication app.""" """Serve the main authentication app."""
return FileResponse(STATIC_DIR / "index.html") return FileResponse(frontend.file("index.html"))
@app.get("/auth/{reset}") @app.get("/auth/{reset}")
async def reset_authentication(request: Request, reset: str): async def reset_link(request: Request, reset: str):
"""Validate reset token and redirect with it as query parameter (no cookies). """Pretty URL for reset links."""
After validation we 303 redirect to /auth/?reset=<token>. The frontend will:
- Read the token from location.search
- Use it via Authorization header or websocket query param
- history.replaceState to remove it from the address bar/history
"""
if not passphrase.is_well_formed(reset): if not passphrase.is_well_formed(reset):
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
return RedirectResponse(request.url_for("frontend", reset=reset), status_code=303) return RedirectResponse(request.url_for("frontapp", reset=reset), status_code=303)
## forward-auth endpoint moved to /auth/api/forward in api.py

60
passkey/util/frontend.py Normal file
View File

@ -0,0 +1,60 @@
from importlib import resources
from pathlib import Path
__all__ = ["path", "file", "run_dev"]
def _resolve_static_dir() -> Path:
# Try packaged path via importlib.resources (works for wheel/installed).
try: # pragma: no cover - trivial path resolution
pkg_dir = resources.files("passkey") / "frontend-build"
fs_path = Path(str(pkg_dir))
if fs_path.is_dir():
return fs_path
except Exception: # pragma: no cover - defensive
pass
# Fallback for editable/development before build.
return Path(__file__).parent.parent / "frontend-build"
path: Path = _resolve_static_dir()
def file(*parts: str) -> Path:
"""Return a child path under the static root."""
return path.joinpath(*parts)
def run_dev():
"""Spawn the frontend dev server (bun or npm) as a background process."""
import atexit
import shutil
import signal
import subprocess
devpath = Path(__file__).parent.parent.parent / "frontend"
if not (devpath / "package.json").exists():
raise RuntimeError(
"Dev frontend is only available when running from git."
if "site-packages" in devpath.parts
else f"Frontend source code not found at {devpath}"
)
bun = shutil.which("bun")
npm = shutil.which("npm") if bun is None else None
if not bun and not npm:
raise RuntimeError("Neither bun nor npm found on PATH for dev server")
cmd: list[str] = [bun, "--bun", "run", "dev"] if bun else [npm, "run", "dev"] # type: ignore[list-item]
proc = subprocess.Popen(cmd, cwd=str(devpath))
def _terminate():
if proc.poll() is None:
proc.terminate()
atexit.register(_terminate)
def _signal_handler(signum, frame):
_terminate()
raise SystemExit(0)
for sig in (signal.SIGINT, signal.SIGTERM):
signal.signal(sig, _signal_handler)

View File

@ -38,5 +38,5 @@ isort.known-first-party = ["passkey"]
passkey-auth = "passkey.fastapi.__main__:main" passkey-auth = "passkey.fastapi.__main__:main"
[tool.hatch.build] [tool.hatch.build]
artifacts = ["passkeyauth/frontend-static"] artifacts = ["passkey/frontend-build"]
targets.sdist.hooks.custom.path = "scripts/build-frontend.py" targets.sdist.hooks.custom.path = "scripts/build-frontend.py"