Project renamed to Paskia.

This commit is contained in:
2025-12-05 13:17:52 +00:00
parent 8609f2fe69
commit 208115ebc3
66 changed files with 232 additions and 139 deletions
+3 -4
View File
@@ -3,7 +3,6 @@ dist/
.* .*
!.gitignore !.gitignore
*.lock *.lock
passkey-auth.sqlite paskia.sqlite
/passkey/frontend-build /paskia/frontend-build
/test_*.py /paskia/_version.py
passkey/_version.py
+2 -2
View File
@@ -1,6 +1,6 @@
# PassKey Auth API Documentation # Paskia API Documentation
This document lists the HTTP and WebSocket endpoints exposed by the PassKey Auth This document lists the HTTP and WebSocket endpoints exposed by the Paskia
service and how they behave depending on whether a dedicated authentication host service and how they behave depending on whether a dedicated authentication host
(`--auth-host` / environment `PASSKEY_AUTH_HOST`) is configured. (`--auth-host` / environment `PASSKEY_AUTH_HOST`) is configured.
+12 -13
View File
@@ -1,4 +1,4 @@
# PasskeyAuth # Paskia
A minimal FastAPI WebAuthn server with WebSocket support for passkey registration. This project demonstrates WebAuthn registration flow with Resident Keys (discoverable credentials) using modern Python tooling. A minimal FastAPI WebAuthn server with WebSocket support for passkey registration. This project demonstrates WebAuthn registration flow with Resident Keys (discoverable credentials) using modern Python tooling.
@@ -31,24 +31,23 @@ uv pip install -e .[dev]
### Run (new CLI) ### Run (new CLI)
`passkey-auth` now provides subcommands: `paskia` now provides subcommands:
```text ```text
passkey-auth serve [host:port] [--options] paskia serve [host:port] [--options]
passkey-auth dev [--options]
``` ```
Examples (fish shell shown): Examples (fish shell shown):
```fish ```fish
# Production style (no reload) # Production style (no reload)
passkey-auth serve paskia serve
passkey-auth serve 0.0.0.0:8080 --rp-id example.com --origin https://example.com paskia serve 0.0.0.0:8080 --rp-id example.com --origin https://example.com
# Development (auto-reload) # Development (auto-reload via scripts/dev.py)
passkey-auth dev # localhost:4401 python scripts/dev.py # localhost:4401
passkey-auth dev :5500 # localhost on port 5500 python scripts/dev.py :5500 # localhost on port 5500
passkey-auth dev 127.0.0.1 # host only, default port 4401 python scripts/dev.py 127.0.0.1 # host only, default port 4401
``` ```
Available options (both subcommands): Available options (both subcommands):
@@ -61,7 +60,7 @@ Available options (both subcommands):
### Legacy Invocation ### Legacy Invocation
If you previously used `python -m passkey.fastapi --dev --host ...`, switch to the new form above. The old flags `--host`, `--port`, and `--dev` are replaced by the `[host:port]` positional and the `dev` subcommand. If you previously used `python -m paskia.fastapi --dev --host ...`, switch to the new form above. The old flags `--host`, `--port`, and `--dev` are replaced by using `scripts/dev.py` for development mode.
## Usage (Web) ## Usage (Web)
@@ -90,8 +89,8 @@ hatch run ruff format .
### Project Structure ### Project Structure
``` ```
passkeyauth/ paskia/
├── passkeyauth/ ├── paskia/
│ ├── __init__.py │ ├── __init__.py
│ └── main.py # FastAPI server with WebSocket support │ └── main.py # FastAPI server with WebSocket support
├── static/ ├── static/
+3 -3
View File
@@ -1,6 +1,6 @@
# PasskeyAuth E2E Tests # Paskia E2E Tests
End-to-end tests for PasskeyAuth using [Playwright](https://playwright.dev/) with Chrome's **Virtual Authenticator**. End-to-end tests for Paskia using [Playwright](https://playwright.dev/) with Chrome's **Virtual Authenticator**.
## Overview ## Overview
@@ -33,7 +33,7 @@ npm test
``` ```
This will: This will:
1. Start a fresh PasskeyAuth server with a test database 1. Start a fresh Paskia server with a test database
2. Run all E2E tests against it 2. Run all E2E tests against it
3. Clean up the server when done 3. Clean up the server when done
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"name": "passkey-auth-e2e", "name": "paskia-e2e",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"description": "E2E tests for PasskeyAuth using Playwright with Virtual Authenticator", "description": "E2E tests for Paskia using Playwright with Virtual Authenticator",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "bunx playwright test", "test": "bunx playwright test",
+2 -2
View File
@@ -1,7 +1,7 @@
import { defineConfig, devices } from '@playwright/test' import { defineConfig, devices } from '@playwright/test'
/** /**
* Playwright configuration for PasskeyAuth E2E tests. * Playwright configuration for Paskia E2E tests.
* Uses Chrome's Virtual Authenticator for automated passkey testing. * Uses Chrome's Virtual Authenticator for automated passkey testing.
* *
* Run with: bun run test * Run with: bun run test
@@ -23,7 +23,7 @@ export default defineConfig({
globalTeardown: './tests/global-teardown.ts', globalTeardown: './tests/global-teardown.ts',
use: { use: {
// Base URL for the passkey-auth server // Base URL for the Paskia server
baseURL: process.env.BASE_URL || 'http://localhost:4401', baseURL: process.env.BASE_URL || 'http://localhost:4401',
// Collect trace on failure for debugging // Collect trace on failure for debugging
+1 -1
View File
@@ -45,7 +45,7 @@ export default async function globalSetup() {
// Start the server using Node's spawn // Start the server using Node's spawn
const serverProcess = spawn('uv', [ const serverProcess = spawn('uv', [
'run', 'passkey-auth', 'serve', ':4401', 'run', 'paskia', 'serve', ':4401',
'--rp-id', 'localhost', '--rp-id', 'localhost',
'--origin', 'http://localhost:4401' '--origin', 'http://localhost:4401'
], { ], {
+1 -1
View File
@@ -10,7 +10,7 @@ import {
} from './fixtures/passkey-helpers' } from './fixtures/passkey-helpers'
/** /**
* E2E tests for PasskeyAuth using Chrome's Virtual Authenticator. * E2E tests for Paskia using Chrome's Virtual Authenticator.
* *
* These tests exercise the complete WebAuthn flow: * These tests exercise the complete WebAuthn flow:
* 1. Registration via WebSocket using bootstrap reset token * 1. Registration via WebSocket using bootstrap reset token
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PassKey Auth - Dev Mode</title> <title>Paskia - Dev Mode</title>
<style> <style>
:root { :root {
color-scheme: light dark; /* Automatic themes by browser */ color-scheme: light dark; /* Automatic themes by browser */
@@ -33,7 +33,7 @@
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<h1>🔐 PassKey Auth</h1> <h1>🔐 Paskia</h1>
<p class="subtitle">Development server demonstration page.</p> <p class="subtitle">Development server demonstration page.</p>
</header> </header>
+2 -2
View File
@@ -27,7 +27,7 @@ export default defineConfig(({ command }) => ({
closeBundle() { closeBundle() {
if (command !== 'build') return if (command !== 'build') return
const outDir = resolve(__dirname, '../passkey/frontend-build') const outDir = resolve(__dirname, '../paskia/frontend-build')
const moves = [ const moves = [
{ from: 'auth.html', to: 'auth/index.html' }, { from: 'auth.html', to: 'auth/index.html' },
{ from: 'admin.html', to: 'admin/index.html' }, { from: 'admin.html', to: 'admin/index.html' },
@@ -69,7 +69,7 @@ export default defineConfig(({ command }) => ({
} }
}, },
build: { build: {
outDir: '../passkey/frontend-build', outDir: '../paskia/frontend-build',
emptyOutDir: true, emptyOutDir: true,
rollupOptions: { rollupOptions: {
input: { input: {
+3
View File
@@ -0,0 +1,3 @@
from paskia.sansio import Passkey
__all__ = ["Passkey"]
@@ -14,7 +14,7 @@ from importlib.resources import files
__ALL__ = ["AAGUID", "filter"] __ALL__ = ["AAGUID", "filter"]
# Path to the AAGUID JSON file # Path to the AAGUID JSON file
AAGUID_FILE = files("passkey") / "aaguid" / "combined_aaguid.json" AAGUID_FILE = files("paskia") / "aaguid" / "combined_aaguid.json"
AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8")) AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8"))
@@ -11,11 +11,11 @@ independent of any web framework:
from datetime import datetime, timezone from datetime import datetime, timezone
from uuid import UUID from uuid import UUID
from .config import SESSION_LIFETIME from paskia.config import SESSION_LIFETIME
from .db import ResetToken, Session from paskia.db import ResetToken, Session
from .globals import db, passkey from paskia.globals import db, passkey
from .util import hostutil from paskia.util import hostutil
from .util.tokens import create_token, reset_key, session_key from paskia.util.tokens import create_token, reset_key, session_key
EXPIRES = SESSION_LIFETIME EXPIRES = SESSION_LIFETIME
+3 -3
View File
@@ -12,9 +12,9 @@ from datetime import datetime, timezone
import uuid7 import uuid7
from . import authsession, globals from paskia import authsession, globals
from .db import Org, Permission, Role, User from paskia.db import Org, Permission, Role, User
from .util import hostutil, passphrase, tokens from paskia.util import hostutil, passphrase, tokens
def _init_logger() -> logging.Logger: def _init_logger() -> logging.Logger:
+4 -4
View File
@@ -26,9 +26,8 @@ from sqlalchemy.dialects.sqlite import BLOB
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from ..config import SESSION_LIFETIME from paskia.config import SESSION_LIFETIME
from ..globals import db from paskia.db import (
from . import (
Credential, Credential,
DatabaseInterface, DatabaseInterface,
Org, Org,
@@ -39,8 +38,9 @@ from . import (
SessionContext, SessionContext,
User, User,
) )
from paskia.globals import db
DB_PATH = "sqlite+aiosqlite:///passkey-auth.sqlite" DB_PATH = "sqlite+aiosqlite:///paskia.sqlite"
def _normalize_dt(value: datetime | None) -> datetime | None: def _normalize_dt(value: datetime | None) -> datetime | None:
+3
View File
@@ -0,0 +1,3 @@
from paskia.fastapi.mainapp import app
__all__ = ["app"]
@@ -126,7 +126,7 @@ def main():
logging.basicConfig(level=logging.INFO, format="%(message)s", force=True) logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="passkey-auth", description="Passkey authentication server" prog="paskia", description="Paskia authentication server"
) )
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
@@ -167,41 +167,41 @@ def main():
host = port = uds = all_ifaces = None # type: ignore host = port = uds = all_ifaces = None # type: ignore
# Export configuration via environment for lifespan initialization in each process # Export configuration via environment for lifespan initialization in each process
os.environ.setdefault("PASSKEY_RP_ID", args.rp_id) os.environ.setdefault("PASKIA_RP_ID", args.rp_id)
if args.rp_name: if args.rp_name:
os.environ["PASSKEY_RP_NAME"] = args.rp_name os.environ["PASKIA_RP_NAME"] = args.rp_name
if args.origin: if args.origin:
os.environ["PASSKEY_ORIGIN"] = args.origin os.environ["PASKIA_ORIGIN"] = args.origin
if getattr(args, "auth_host", None): if getattr(args, "auth_host", None):
os.environ["PASSKEY_AUTH_HOST"] = args.auth_host os.environ["PASKIA_AUTH_HOST"] = args.auth_host
else: else:
# Preserve pre-set env variable if CLI option omitted # Preserve pre-set env variable if CLI option omitted
args.auth_host = os.environ.get("PASSKEY_AUTH_HOST") args.auth_host = os.environ.get("PASKIA_AUTH_HOST")
if args.auth_host: if args.auth_host:
validate_auth_host(args.auth_host, args.rp_id) validate_auth_host(args.auth_host, args.rp_id)
from passkey.util import hostutil as _hostutil # local import from paskia.util import hostutil as _hostutil # local import
_hostutil.reload_config() _hostutil.reload_config()
# One-time initialization + bootstrap before starting any server processes. # One-time initialization + bootstrap before starting any server processes.
# Lifespan in worker processes will call globals.init with bootstrap disabled. # Lifespan in worker processes will call globals.init with bootstrap disabled.
from passkey import globals as _globals # local import from paskia import globals as _globals # local import
asyncio.run( asyncio.run(
_globals.init( _globals.init(
rp_id=args.rp_id, rp_id=args.rp_id,
rp_name=args.rp_name, rp_name=args.rp_name,
origin=args.origin, origin=args.origin,
default_admin=os.getenv("PASSKEY_DEFAULT_ADMIN") or None, default_admin=os.getenv("PASKIA_DEFAULT_ADMIN") or None,
default_org=os.getenv("PASSKEY_DEFAULT_ORG") or None, default_org=os.getenv("PASKIA_DEFAULT_ORG") or None,
bootstrap=True, bootstrap=True,
) )
) )
# Handle recover-admin command (no server start) # Handle recover-admin command (no server start)
if args.command == "reset": if args.command == "reset":
from passkey.fastapi import reset as reset_cmd # local import from paskia.fastapi import reset as reset_cmd # local import
exit_code = reset_cmd.run(getattr(args, "query", None)) exit_code = reset_cmd.run(getattr(args, "query", None))
raise SystemExit(exit_code) raise SystemExit(exit_code)
@@ -211,15 +211,15 @@ def main():
"log_level": "info", "log_level": "info",
} }
# Dev mode: enable reload when PASSKEY_DEVMODE is set # Dev mode: enable reload when PASKIA_DEVMODE is set
devmode = os.environ.get("PASSKEY_DEVMODE") == "1" devmode = os.environ.get("PASKIA_DEVMODE") == "1"
if devmode: if devmode:
# Security: dev mode must run on localhost:4402 to prevent # Security: dev mode must run on localhost:4402 to prevent
# accidental public exposure of the Vite dev server # accidental public exposure of the Vite dev server
if host != "localhost" or port != 4402: if host != "localhost" or port != 4402:
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}") raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
run_kwargs["reload"] = True run_kwargs["reload"] = True
run_kwargs["reload_dirs"] = ["passkey"] run_kwargs["reload_dirs"] = ["paskia"]
if uds: if uds:
run_kwargs["uds"] = uds run_kwargs["uds"] = uds
@@ -233,12 +233,12 @@ def main():
if devmode: if devmode:
run_kwargs["host"] = "::" run_kwargs["host"] = "::"
run_kwargs["port"] = port run_kwargs["port"] = port
uvicorn.run("passkey.fastapi:app", **run_kwargs) uvicorn.run("paskia.fastapi:app", **run_kwargs)
else: else:
# Production: run separate servers for IPv4 and IPv6 # Production: run separate servers for IPv4 and IPv6
from uvicorn import Config, Server # noqa: E402 local import from uvicorn import Config, Server # noqa: E402 local import
from passkey.fastapi import ( from paskia.fastapi import (
app as fastapi_app, # noqa: E402 local import app as fastapi_app, # noqa: E402 local import
) )
@@ -261,7 +261,7 @@ def main():
asyncio.run(serve_both()) asyncio.run(serve_both())
else: else:
uvicorn.run("passkey.fastapi:app", **run_kwargs) uvicorn.run("paskia.fastapi:app", **run_kwargs)
if __name__ == "__main__": if __name__ == "__main__":
@@ -5,9 +5,11 @@ from uuid import UUID, uuid4
from fastapi import Body, FastAPI, HTTPException, Request, Response from fastapi import Body, FastAPI, HTTPException, Request, Response
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from ..authsession import reset_expires from paskia.authsession import reset_expires
from ..globals import db from paskia.fastapi import authz
from ..util import ( from paskia.fastapi.session import AUTH_COOKIE
from paskia.globals import db
from paskia.util import (
frontend, frontend,
hostutil, hostutil,
passphrase, passphrase,
@@ -16,9 +18,7 @@ from ..util import (
tokens, tokens,
useragent, useragent,
) )
from ..util.tokens import encode_session_key, session_key from paskia.util.tokens import encode_session_key, session_key
from . import authz
from .session import AUTH_COOKIE
app = FastAPI() app = FastAPI()
@@ -13,21 +13,19 @@ from fastapi import (
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer from fastapi.security import HTTPBearer
from passkey.util import frontend from paskia.authsession import (
from ..authsession import (
EXPIRES, EXPIRES,
get_reset, get_reset,
get_session, get_session,
refresh_session_token, refresh_session_token,
session_expiry, session_expiry,
) )
from ..globals import db from paskia.fastapi import authz, session, user
from ..globals import passkey as global_passkey from paskia.fastapi.session import AUTH_COOKIE
from ..util import hostutil, htmlutil, passphrase, userinfo from paskia.globals import db
from ..util.tokens import session_key from paskia.globals import passkey as global_passkey
from . import authz, session, user from paskia.util import frontend, hostutil, htmlutil, passphrase, userinfo
from .session import AUTH_COOKIE from paskia.util.tokens import session_key
bearer_auth = HTTPBearer(auto_error=True) bearer_auth = HTTPBearer(auto_error=True)
@@ -3,7 +3,7 @@
from fastapi import Request, Response from fastapi import Request, Response
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from passkey.util import hostutil, passphrase from paskia.util import hostutil, passphrase
def is_ui_path(path: str) -> bool: def is_ui_path(path: str) -> bool:
@@ -2,7 +2,7 @@ import logging
from fastapi import HTTPException from fastapi import HTTPException
from ..util import permutil, sessionutil from paskia.util import permutil, sessionutil
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -6,10 +6,9 @@ from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from passkey.util import frontend, hostutil, passphrase from paskia.fastapi import admin, api, auth_host, ws
from paskia.fastapi.session import AUTH_COOKIE
from . import admin, api, auth_host, ws from paskia.util import frontend, hostutil, passphrase
from .session import AUTH_COOKIE
@asynccontextmanager @asynccontextmanager
@@ -19,15 +18,15 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
We populate configuration from environment variables (set by the CLI entrypoint) We populate configuration from environment variables (set by the CLI entrypoint)
so that uvicorn reload / multiprocess workers inherit the settings. so that uvicorn reload / multiprocess workers inherit the settings.
""" """
from .. import globals from paskia import globals
rp_id = os.getenv("PASSKEY_RP_ID", "localhost") rp_id = os.getenv("PASKIA_RP_ID", "localhost")
rp_name = os.getenv("PASSKEY_RP_NAME") or None rp_name = os.getenv("PASKIA_RP_NAME") or None
origin = os.getenv("PASSKEY_ORIGIN") or None origin = os.getenv("PASKIA_ORIGIN") or None
default_admin = ( default_admin = (
os.getenv("PASSKEY_DEFAULT_ADMIN") or None os.getenv("PASKIA_DEFAULT_ADMIN") or None
) # still passed for context ) # still passed for context
default_org = os.getenv("PASSKEY_DEFAULT_ORG") or None default_org = os.getenv("PASKIA_DEFAULT_ORG") or None
try: try:
# CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work # CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work
await globals.init( await globals.init(
@@ -56,7 +55,7 @@ app.mount("/auth/api/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)
# In dev mode (PASSKEY_DEVMODE=1), Vite serves assets directly; skip static files mount # In dev mode (PASKIA_DEVMODE=1), Vite serves assets directly; skip static files mount
if not frontend.is_dev_mode(): if not frontend.is_dev_mode():
app.mount( app.mount(
"/auth/assets/", "/auth/assets/",
@@ -1,7 +1,7 @@
"""CLI support for creating user credential reset links. """CLI support for creating user credential reset links.
Usage (via main CLI): Usage (via main CLI):
passkey-auth reset [query] paskia reset [query]
If query is omitted, the master admin (first Administration role user in If query is omitted, the master admin (first Administration role user in
an organization granting auth:admin) is targeted. Otherwise query is an organization granting auth:admin) is targeted. Otherwise query is
@@ -15,10 +15,10 @@ from __future__ import annotations
import asyncio import asyncio
from uuid import UUID from uuid import UUID
from passkey import authsession as _authsession from paskia import authsession as _authsession
from passkey import globals as _g from paskia import globals as _g
from passkey.util import hostutil, passphrase from paskia.util import hostutil, passphrase
from passkey.util import tokens as _tokens from paskia.util import tokens as _tokens
async def _resolve_targets(query: str | None): async def _resolve_targets(query: str | None):
@@ -10,7 +10,7 @@ Generic session management functions have been moved to authsession.py
from fastapi import Cookie, Request, Response, WebSocket from fastapi import Cookie, Request, Response, WebSocket
from ..authsession import EXPIRES from paskia.authsession import EXPIRES
AUTH_COOKIE_NAME = "__Host-auth" AUTH_COOKIE_NAME = "__Host-auth"
AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME) AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME)
@@ -10,16 +10,16 @@ from fastapi import (
) )
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from ..authsession import ( from paskia.authsession import (
delete_credential, delete_credential,
expires, expires,
get_session, get_session,
) )
from ..globals import db from paskia.fastapi import authz, session
from ..util import hostutil, passphrase, tokens from paskia.fastapi.session import AUTH_COOKIE
from ..util.tokens import decode_session_key, session_key from paskia.globals import db
from . import authz, session from paskia.util import hostutil, passphrase, tokens
from .session import AUTH_COOKIE from paskia.util.tokens import decode_session_key, session_key
app = FastAPI() app = FastAPI()
@@ -5,12 +5,12 @@ from uuid import UUID
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from webauthn.helpers.exceptions import InvalidAuthenticationResponse from webauthn.helpers.exceptions import InvalidAuthenticationResponse
from ..authsession import create_session, get_reset, get_session from paskia.authsession import create_session, get_reset, get_session
from ..globals import db, passkey from paskia.fastapi import authz
from ..util import passphrase from paskia.fastapi.session import AUTH_COOKIE, infodict
from ..util.tokens import create_token, session_key from paskia.globals import db, passkey
from . import authz from paskia.util import passphrase
from .session import AUTH_COOKIE, infodict from paskia.util.tokens import create_token, session_key
# WebSocket error handling decorator # WebSocket error handling decorator
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin</title>
<script type="module" crossorigin src="/auth/assets/admin-D8zxJOk4.js"></script>
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-R4vr2A9I.js">
<link rel="modulepreload" crossorigin href="/auth/assets/helpers-CU0-cyzg.js">
<link rel="modulepreload" crossorigin href="/auth/assets/AccessDenied-guOGfNm-.js">
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-Bx2cFCEC.css">
<link rel="stylesheet" crossorigin href="/auth/assets/AccessDenied-TAST_piX.css">
<link rel="stylesheet" crossorigin href="/auth/assets/admin-DIOoLLHy.css">
</head>
<body>
<div id="admin-app"></div>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{_ as z,G as E,r as g,c as y,o as L,d,e as h,i as f,f as n,t as _,n as N,R as O,C as Y,Y as w,V as T,p as D}from"./_plugin-vue_export-helper-R4vr2A9I.js";const q={class:"app-shell"},G={key:0,class:"global-status",style:{display:"block"}},J={class:"view-root"},W={key:0,class:"surface surface--tight"},j={class:"view-header center"},H={key:0,class:"user-line"},K={class:"view-lede"},Q={class:"section-block"},X={class:"section-body center"},Z={class:"button-row center"},ee=["disabled"],te=["disabled"],ae=["disabled"],se=["disabled"],ne={__name:"RestrictedAuth",props:{mode:{type:String,default:"login",validator:o=>["login","reauth","forbidden"].includes(o)}},emits:["authenticated","forbidden","logout","back","home","auth-error"],setup(o,{expose:V,emit:$}){const v=o,m=$,i=E({show:!1,message:"",type:"info"}),b=g(!0),t=g(!1),S=g(null),r=g(null),l=g("initial");let p=null;const u=y(()=>!!r.value?.authenticated),k=y(()=>b.value?!1:v.mode==="reauth"?!0:l.value!=="forbidden"),U=y(()=>v.mode==="reauth"?"🔐 Additional Authentication":l.value==="forbidden"?"🚫 Forbidden":`🔐 ${S.value?.rp_name||location.origin}`),B=y(()=>v.mode==="reauth"?"Please verify your identity to continue with this action.":l.value==="forbidden"?"You lack the required permissions.":"Please sign in with your passkey."),F=y(()=>r.value?.user?.user_name||"User");function c(e,a="info",s=3e3){i.show=!0,i.message=e,i.type=a,p&&clearTimeout(p),s>0&&(p=setTimeout(()=>{i.show=!1},s))}async function I(){try{const e=await Y();if(S.value=e,e?.rp_name){const a=v.mode==="reauth"?"Verify Identity":u.value?"Forbidden":"Sign In";document.title=`${e.rp_name} · ${a}`}}catch(e){console.warn("Unable to load settings",e)}}async function M(){try{r.value=await w("/auth/api/user-info",{method:"POST"}),u.value&&v.mode!=="reauth"?(l.value="forbidden",m("forbidden",r.value)):l.value="login"}catch(e){console.error("Failed to load user info",e),e.status!==401&&e.status!==403&&c(T(e),"error",4e3),r.value=null,l.value="login"}}async function A(){if(!k.value||t.value)return;t.value=!0,c("Starting authentication…","info");let e;try{e=await D.authenticate()}catch(a){t.value=!1;const s=a?.message||"Passkey authentication cancelled",P=s==="Passkey authentication cancelled";c(s,P?"info":"error",4e3),m("auth-error",{message:s,cancelled:P});return}try{await x(e)}catch(a){t.value=!1;const s=a?.message||"Failed to establish session";c(s,"error",4e3),m("auth-error",{message:s,cancelled:!1});return}t.value=!1,m("authenticated",e)}async function C(){if(!t.value){t.value=!0;try{await w("/auth/api/logout",{method:"POST"}),r.value=null,l.value="login",c("Logged out. You can sign in with a different account.","info",3e3)}catch(e){c(T(e),"error",4e3)}finally{t.value=!1}m("logout")}}function R(){const e=window.open("/auth/","passkey_auth_profile");e&&e.focus()}async function x(e){if(!e?.session_token)throw console.error("setSessionCookie called with missing session_token:",e),new Error("Authentication response missing session_token");return await w("/auth/api/set-session",{method:"POST",headers:{Authorization:`Bearer ${e.session_token}`}})}return L(async()=>{await I(),await M(),b.value=!1}),V({showMessage:c,isAuthenticated:u,userInfo:r}),(e,a)=>(h(),d("div",q,[i.show?(h(),d("div",G,[n("div",{class:N(["status",i.type])},_(i.message),3)])):f("",!0),n("main",J,[b.value?f("",!0):(h(),d("div",W,[n("header",j,[n("h1",null,_(U.value),1),u.value?(h(),d("p",H,"👤 "+_(F.value),1)):f("",!0),n("p",K,_(B.value),1)]),n("section",Q,[n("div",X,[n("div",Z,[O(e.$slots,"actions",{loading:t.value,canAuthenticate:k.value,isAuthenticated:u.value,authenticate:A,logout:C,mode:o.mode},()=>[n("button",{class:"btn-secondary",disabled:t.value,onClick:a[0]||(a[0]=s=>e.$emit("back"))},"Back",8,ee),k.value?(h(),d("button",{key:0,class:"btn-primary",disabled:t.value,onClick:A},_(t.value?o.mode==="reauth"?"Verifying…":"Signing in…":o.mode==="reauth"?"Verify":"Login"),9,te)):f("",!0),u.value&&o.mode!=="reauth"?(h(),d("button",{key:1,class:"btn-danger",disabled:t.value,onClick:C},"Logout",8,ae)):f("",!0),u.value&&o.mode!=="reauth"?(h(),d("button",{key:2,class:"btn-primary",disabled:t.value,onClick:R},"Profile",8,se)):f("",!0)])])])])]))])]))}},ie=z(ne,[["__scopeId","data-v-d00079a6"]]);export{ie as R};
@@ -0,0 +1 @@
.button-row.center[data-v-d00079a6]{display:flex;justify-content:center;gap:.75rem}.user-line[data-v-d00079a6]{margin:.5rem 0 0;font-weight:500;color:var(--color-text)}main.view-root[data-v-d00079a6]{min-height:100vh;align-items:center;justify-content:center;padding:2rem 1rem}.surface.surface--tight[data-v-d00079a6]{max-width:520px;margin:0 auto;width:100%;display:flex;flex-direction:column;gap:1.75rem}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.view-lede[data-v-0cc830bd]{margin:0;color:var(--color-text-muted);font-size:1rem}.section-header[data-v-0cc830bd]{display:flex;flex-direction:column;gap:.4rem}.section-description[data-v-0cc830bd]{margin:0;color:var(--color-text-muted)}.empty-state[data-v-0cc830bd]{margin:0;color:var(--color-text-muted);text-align:center;padding:1rem 0}.logout-button[data-v-0cc830bd]{align-self:flex-start}.logout-row[data-v-0cc830bd]{gap:1rem}.logout-row.single[data-v-0cc830bd]{justify-content:flex-start}.logout-note[data-v-0cc830bd]{margin:.75rem 0 0;color:var(--color-text-muted);font-size:.875rem}@media(max-width:720px){.logout-button[data-v-0cc830bd]{width:100%}}.host-view[data-v-88828278]{padding:3rem 1.5rem 4rem}.host-actions[data-v-88828278]{display:flex;flex-direction:column;gap:.75rem}.host-actions .button-row[data-v-88828278]{gap:.75rem;flex-wrap:wrap}.host-actions .button-row button[data-v-88828278]{flex:0 0 auto}.note[data-v-88828278],.empty-state[data-v-88828278]{margin:0;color:var(--color-text-muted)}@media(max-width:600px){.host-actions .button-row[data-v-88828278]{flex-direction:column}.host-actions .button-row button[data-v-88828278]{width:100%}}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{c as o,W as d,o as i,h as s,e as m,u as l,v as h}from"./_plugin-vue_export-helper-R4vr2A9I.js";import{R as p}from"./RestrictedAuth-BIGLs28V.js";import{g as n}from"./helpers-CU0-cyzg.js";const f={__name:"RestrictedForward",setup(w){const a=o(()=>d()),r=o(()=>{const t=document.documentElement.getAttribute("data-mode");return t==="reauth"?"reauth":t==="forbidden"?"forbidden":"login"});function c(){location.reload()}function u(){const e=a.value||"/auth/";window.location.pathname!==e&&history.replaceState(null,"",e),window.location.href=e}return i(()=>{window.addEventListener("keydown",e=>{e.key==="Escape"&&n()})}),(e,t)=>(m(),s(p,{mode:r.value,onAuthenticated:c,onBack:l(n),onHome:u},null,8,["mode","onBack"]))}};h(f).mount("#app");
@@ -0,0 +1 @@
function f(r){if(!r)return"Never";const s=new Date(r),u=s-new Date,e=u>0,a=Math.abs(u),n=Math.round(a/(1e3*60)),o=Math.round(a/(1e3*60*60)),t=Math.round(a/(1e3*60*60*24));return a<1e3*60?"Now":n<=60?e?`In ${n} minute${n===1?"":"s"}`:n===1?"a minute ago":`${n} minutes ago`:o<=24?e?`In ${o} hour${o===1?"":"s"}`:o===1?"an hour ago":`${o} hours ago`:t<=14?e?`In ${t} day${t===1?"":"s"}`:t===1?"a day ago":`${t} days ago`:s.toLocaleDateString(void 0,{year:"numeric",month:"long",day:"numeric"})}const c=()=>history.back()||window.close();export{f,c as g};
@@ -0,0 +1 @@
.center[data-v-4f202f9a]{text-align:center}.button-row.center[data-v-4f202f9a]{display:flex;justify-content:center}.section-body[data-v-4f202f9a]{gap:1.25rem}.name-edit span[data-v-4f202f9a]{color:var(--color-text-muted);font-size:.9rem}
@@ -0,0 +1 @@
import{_ as M,G as F,r as i,c as v,W as b,o as U,d as c,e as u,i as V,f as t,t as g,n as $,z,A as E,S as I,C as N,q as R,X as D,V as K,p as O,v as j}from"./_plugin-vue_export-helper-R4vr2A9I.js";const q={class:"app-shell"},G={key:0,class:"global-status",style:{display:"block"}},H={class:"view-root"},J={class:"surface surface--tight",style:{"max-width":"560px",margin:"0 auto",width:"100%"}},L={class:"view-header",style:{"text-align":"center"}},W={class:"view-lede"},X={key:0,class:"section-block"},Y={key:1,class:"section-block"},Q={class:"section-body center"},Z={key:2,class:"section-block"},ee={class:"section-body"},se={class:"name-edit"},te=["disabled"],ae=["disabled"],ne={__name:"ResetApp",setup(ie){const o=F({show:!1,message:"",type:"info"}),d=i(!0),n=i(!1),r=i(""),x=i(null),p=i(null),f=i(""),m=i("");let h=null;const P=v(()=>p.value?.session_type||"your enrollment"),S=v(()=>d.value?"Preparing your secure enrollment…":y.value?`Finish up ${P.value}. You may edit the name below if needed, and it will be saved to your passkey.`:"This reset link is no longer valid.");v(()=>b());const y=v(()=>!!(r.value&&p.value));function l(e,s="info",a=3e3){o.show=!0,o.message=e,o.type=s,h&&clearTimeout(h),a>0&&(h=setTimeout(()=>{o.show=!1},a))}async function T(){try{const e=await N();x.value=e,e?.rp_name&&(document.title=`${e.rp_name} · Passkey Setup`)}catch(e){console.warn("Unable to load settings",e)}}async function C(){if(r.value)try{p.value=await R(`/auth/api/user-info?reset=${encodeURIComponent(r.value)}`,{method:"POST"}),f.value=p.value?.user?.user_name||""}catch(e){console.error("Failed to load user info",e);const s=e instanceof D?e.data?.detail||"Reset link is invalid or expired.":K(e);m.value=s,l(s,"error",0)}}async function _(){if(!y.value||n.value)return;n.value=!0,l("Starting passkey registration…","info");let e;try{const s=f.value.trim()||null;e=await O.register(r.value,s)}catch(s){n.value=!1;const a=s?.message||"Passkey registration cancelled",k=a==="Passkey registration cancelled";l(k?a:`Registration failed: ${a}`,k?"info":"error",4e3);return}try{await A(e)}catch(s){n.value=!1;const a=s?.message||"Failed to establish session";l(a,"error",4e3);return}l("Passkey registered successfully!","success",800),setTimeout(()=>{n.value=!1,w()},800)}async function A(e){if(!e?.session_token)throw new Error("Registration response missing session_token");return await R("/auth/api/set-session",{method:"POST",headers:{Authorization:`Bearer ${e.session_token}`}})}function w(){const e=b.value||"/auth/";window.location.pathname!==e&&history.replaceState(null,"",e),window.location.reload()}function B(){const e=window.location.pathname.split("/").filter(Boolean);if(!e.length)return"";const s=e[e.length-1],a=e.slice(0,-1);return a.length>1||a.length===1&&a[0]!=="auth"||!s.includes(".")?"":s}return U(async()=>{if(r.value=B(),await T(),!r.value){const e="Reset link is missing or malformed.";m.value=e,l(e,"error",0),d.value=!1;return}await C(),d.value=!1}),(e,s)=>(u(),c("div",q,[o.show?(u(),c("div",G,[t("div",{class:$(["status",o.type])},g(o.message),3)])):V("",!0),t("main",H,[t("div",J,[t("header",L,[s[1]||(s[1]=t("h1",null,"🔑 Registration",-1)),t("p",W,g(S.value),1)]),d.value?(u(),c("section",X,[...s[2]||(s[2]=[t("div",{class:"section-body center"},[t("p",null,"Loading reset details…")],-1)])])):y.value?(u(),c("section",Z,[t("div",ee,[t("label",se,[s[3]||(s[3]=t("span",null,"👤 Name",-1)),z(t("input",{type:"text","onUpdate:modelValue":s[0]||(s[0]=a=>f.value=a),disabled:n.value,maxlength:"64",onKeyup:I(_,["enter"])},null,40,te),[[E,f.value]])]),t("button",{class:"btn-primary",disabled:n.value,onClick:_},g(n.value?"Registering…":"Register Passkey"),9,ae)])])):(u(),c("section",Y,[t("div",Q,[t("p",null,g(m.value),1),t("div",{class:"button-row center",style:{"justify-content":"center"}},[t("button",{class:"btn-secondary",onClick:w},"Return to sign-in")])])]))])])]))}},oe=M(ne,[["__scopeId","data-v-4f202f9a"]]);j(oe).mount("#app");
@@ -0,0 +1 @@
import{c as r,o as c,h as i,e as d,v as u}from"./_plugin-vue_export-helper-R4vr2A9I.js";import{R as p}from"./RestrictedAuth-BIGLs28V.js";const h={__name:"RestrictedApi",setup(m){const a=r(()=>{const n=new URLSearchParams(window.location.hash.slice(1)).get("mode");return n==="reauth"?"reauth":n==="forbidden"?"forbidden":"login"});function t(e){window.parent&&window.parent!==window&&window.parent.postMessage(e,"*")}function s(e){t({type:"auth-success",authenticated:!0,sessionToken:e.session_token})}function o(){t({type:"auth-back"})}return c(()=>{t({type:"auth-ready"}),window.addEventListener("keydown",e=>{e.key==="Escape"&&o()})}),(e,n)=>(d(),i(p,{mode:a.value,onAuthenticated:s,onBack:o},null,8,["mode"]))}};u(h).mount("#app");
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auth Profile</title>
<script type="module" crossorigin src="/auth/assets/auth-a0yJ_sei.js"></script>
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-R4vr2A9I.js">
<link rel="modulepreload" crossorigin href="/auth/assets/helpers-CU0-cyzg.js">
<link rel="modulepreload" crossorigin href="/auth/assets/AccessDenied-guOGfNm-.js">
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-Bx2cFCEC.css">
<link rel="stylesheet" crossorigin href="/auth/assets/AccessDenied-TAST_piX.css">
<link rel="stylesheet" crossorigin href="/auth/assets/auth-CBojJKUK.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
@@ -0,0 +1,9 @@
<html style="background: transparent">
<script type="module" crossorigin src="/auth/assets/restricted-DVCvYFGN.js"></script>
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-R4vr2A9I.js">
<link rel="modulepreload" crossorigin href="/auth/assets/RestrictedAuth-BIGLs28V.js">
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-Bx2cFCEC.css">
<link rel="stylesheet" crossorigin href="/auth/assets/RestrictedAuth-CMHKrNJh.css">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<div id="app"></div>
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Access Restricted</title>
<script type="module" crossorigin src="/auth/assets/forward-BHNzlQhM.js"></script>
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-R4vr2A9I.js">
<link rel="modulepreload" crossorigin href="/auth/assets/RestrictedAuth-BIGLs28V.js">
<link rel="modulepreload" crossorigin href="/auth/assets/helpers-CU0-cyzg.js">
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-Bx2cFCEC.css">
<link rel="stylesheet" crossorigin href="/auth/assets/RestrictedAuth-CMHKrNJh.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Complete Passkey Setup</title>
<script type="module" crossorigin src="/auth/assets/reset-YnZxhnI5.js"></script>
<link rel="modulepreload" crossorigin href="/auth/assets/_plugin-vue_export-helper-R4vr2A9I.js">
<link rel="stylesheet" crossorigin href="/auth/assets/_plugin-vue_export-helper-Bx2cFCEC.css">
<link rel="stylesheet" crossorigin href="/auth/assets/reset-DXzuKgh6.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+2 -2
View File
@@ -1,7 +1,7 @@
from typing import Generic, TypeVar from typing import Generic, TypeVar
from .db import DatabaseInterface from paskia.db import DatabaseInterface
from .sansio import Passkey from paskia.sansio import Passkey
T = TypeVar("T") T = TypeVar("T")
+1 -1
View File
@@ -37,7 +37,7 @@ from webauthn.helpers.structs import (
UserVerificationRequirement, UserVerificationRequirement,
) )
from .db import Credential from paskia.db import Credential
class Passkey: class Passkey:
@@ -14,7 +14,7 @@ DEV_SERVER = "http://localhost:4403"
def _resolve_static_dir() -> Path: def _resolve_static_dir() -> Path:
# Try packaged path via importlib.resources (works for wheel/installed). # Try packaged path via importlib.resources (works for wheel/installed).
try: # pragma: no cover - trivial path resolution try: # pragma: no cover - trivial path resolution
pkg_dir = resources.files("passkey") / "frontend-build" pkg_dir = resources.files("paskia") / "frontend-build"
fs_path = Path(str(pkg_dir)) fs_path = Path(str(pkg_dir))
if fs_path.is_dir(): if fs_path.is_dir():
return fs_path return fs_path
@@ -34,7 +34,7 @@ def file(*parts: str) -> Path:
def is_dev_mode() -> bool: def is_dev_mode() -> bool:
"""Check if we're running in dev mode (Vite frontend server).""" """Check if we're running in dev mode (Vite frontend server)."""
return os.environ.get("PASSKEY_DEVMODE") == "1" return os.environ.get("PASKIA_DEVMODE") == "1"
async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]: async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
@@ -4,9 +4,9 @@ import os
from functools import lru_cache from functools import lru_cache
from urllib.parse import urlparse, urlsplit from urllib.parse import urlparse, urlsplit
from ..globals import passkey as global_passkey from paskia.globals import passkey as global_passkey
_AUTH_HOST_ENV = "PASSKEY_AUTH_HOST" _AUTH_HOST_ENV = "PASKIA_AUTH_HOST"
def _default_origin_scheme() -> str: def _default_origin_scheme() -> str:
@@ -1,6 +1,6 @@
import secrets import secrets
from .wordlist import words from paskia.util.wordlist import words
N_WORDS = 5 N_WORDS = 5
@@ -3,9 +3,9 @@
from collections.abc import Sequence from collections.abc import Sequence
from fnmatch import fnmatchcase from fnmatch import fnmatchcase
from ..globals import db from paskia.globals import db
from .hostutil import normalize_host from paskia.util.hostutil import normalize_host
from .tokens import session_key from paskia.util.tokens import session_key
__all__ = ["has_any", "has_all", "session_context"] __all__ = ["has_any", "has_all", "session_context"]
@@ -2,8 +2,8 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from ..db import SessionContext from paskia.db import SessionContext
from .timeutil import parse_duration from paskia.util.timeutil import parse_duration
def check_session_age(ctx: SessionContext, max_age: str | None) -> bool: def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
@@ -2,7 +2,7 @@ import base64
import hashlib import hashlib
import secrets import secrets
from .passphrase import is_well_formed from paskia.util.passphrase import is_well_formed
def create_token() -> str: def create_token() -> str:
@@ -2,12 +2,10 @@
from datetime import timezone from datetime import timezone
from passkey.util import useragent from paskia import aaguid
from paskia.authsession import session_key
from .. import aaguid from paskia.globals import db
from ..authsession import session_key from paskia.util import hostutil, permutil, tokens, useragent
from ..globals import db
from . import hostutil, permutil, tokens
def _format_datetime(dt): def _format_datetime(dt):
-3
View File
@@ -1,3 +0,0 @@
from .sansio import Passkey
__all__ = ["Passkey"]
-3
View File
@@ -1,3 +0,0 @@
from .mainapp import app
__all__ = ["app"]
+5 -5
View File
@@ -3,7 +3,7 @@ requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[project] [project]
name = "passkey" name = "paskia"
dynamic = ["version"] dynamic = ["version"]
description = "Passkey Authentication for Web Services" description = "Passkey Authentication for Web Services"
authors = [ authors = [
@@ -26,7 +26,7 @@ requires-python = ">=3.10"
source = "vcs" source = "vcs"
[tool.hatch.build.hooks.vcs] [tool.hatch.build.hooks.vcs]
version-file = "passkey/_version.py" version-file = "paskia/_version.py"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
@@ -40,11 +40,11 @@ line-length = 88
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"] select = ["E", "F", "I", "N", "W", "UP"]
ignore = ["E501"] # Line too long ignore = ["E501"] # Line too long
isort.known-first-party = ["passkey"] isort.known-first-party = ["paskia"]
[project.scripts] [project.scripts]
passkey-auth = "passkey.fastapi.__main__:main" paskia = "paskia.fastapi.__main__:main"
[tool.hatch.build] [tool.hatch.build]
artifacts = ["passkey/frontend-build"] artifacts = ["paskia/frontend-build"]
targets.sdist.hooks.custom.path = "scripts/build-frontend.py" targets.sdist.hooks.custom.path = "scripts/build-frontend.py"