diff --git a/README.md b/README.md index 9be3640..8c9fcff 100644 --- a/README.md +++ b/README.md @@ -19,52 +19,55 @@ A minimal FastAPI WebAuthn server with WebSocket support for passkey registratio ## Quick Start -### Using uv (recommended) +### Install (editable dev mode) ```fish -# Install uv if you haven't already -curl -LsSf https://astral.sh/uv/install.sh | sh - -# Clone/navigate to the project directory -cd passkeyauth - -# Install dependencies and run -uv run passkeyauth.main:main +uv pip install -e .[dev] ``` -### Using pip +### Run (new CLI) + +`passkey-auth` now provides subcommands: + +```text +passkey-auth serve [host:port] [--options] +passkey-auth dev [--options] +``` + +Examples (fish shell shown): ```fish -# Create and activate virtual environment -python -m venv venv -source venv/bin/activate.fish # or venv/bin/activate for bash +# Production style (no reload) +passkey-auth serve +passkey-auth serve 0.0.0.0:8080 --rp-id example.com --origin https://example.com -# Install the package in development mode -pip install -e ".[dev]" - -# Run the server -python -m passkeyauth.main +# Development (auto-reload) +passkey-auth dev # localhost:4401 +passkey-auth dev :5500 # localhost on port 5500 +passkey-auth dev 127.0.0.1 # host only, default port 4401 ``` -### Using hatch +Available options (both subcommands): -```fish -# Install hatch if you haven't already -pip install hatch - -# Run the development server -hatch run python -m passkeyauth.main +```text +--rp-id Relying Party ID (default: localhost) +--rp-name Relying Party name (default: same as rp-id) +--origin Explicit origin (default: https://) ``` -## Usage +### Legacy Invocation -1. Start the server using one of the methods above -2. Open your browser to `http://localhost:8000` +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. + +## Usage (Web) + +1. Start the server with one of the commands above +2. Open your browser to `http://localhost:4401/auth/` (or your chosen host/port) 3. Enter a username (or use the default) 4. Click "Register Passkey" -5. Follow your authenticator's prompts to create a passkey +5. Follow your authenticator's prompts -The WebSocket connection will show real-time status updates as you progress through the registration flow. +Real-time status updates stream over WebSocket. ## Development diff --git a/frontend/package.json b/frontend/package.json index 2d61b90..5724a63 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --clearScreen false", "build": "vite build", "preview": "vite preview" }, diff --git a/frontend/src/admin/AdminApp.vue b/frontend/src/admin/AdminApp.vue index 47a6f26..e1d3a31 100644 --- a/frontend/src/admin/AdminApp.vue +++ b/frontend/src/admin/AdminApp.vue @@ -4,6 +4,22 @@ import { ref, onMounted } from 'vue' const info = ref(null) const loading = ref(true) const error = ref(null) +const orgs = ref([]) +const permissions = ref([]) + +async function loadOrgs() { + const res = await fetch('/auth/admin/orgs') + const data = await res.json() + if (data.detail) throw new Error(data.detail) + orgs.value = data +} + +async function loadPermissions() { + const res = await fetch('/auth/admin/permissions') + const data = await res.json() + if (data.detail) throw new Error(data.detail) + permissions.value = data +} async function load() { loading.value = true @@ -13,6 +29,9 @@ async function load() { const data = await res.json() if (data.detail) throw new Error(data.detail) info.value = data + if (data.authenticated && (data.is_global_admin || data.is_org_admin)) { + await Promise.all([loadOrgs(), loadPermissions()]) + } } catch (e) { error.value = e.message } finally { @@ -20,6 +39,133 @@ async function load() { } } +// Org actions +async function createOrg() { + const name = prompt('New organization display name:') + if (!name) return + const res = await fetch('/auth/admin/orgs', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ display_name: name, permissions: [] }) + }) + const data = await res.json() + if (data.detail) return alert(data.detail) + await loadOrgs() +} + +async function updateOrg(org) { + const name = prompt('Organization display name:', org.display_name) + if (!name) return + const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ display_name: name, permissions: org.permissions }) + }) + const data = await res.json() + if (data.detail) return alert(data.detail) + await loadOrgs() +} + +async function deleteOrg(org) { + if (!confirm(`Delete organization ${org.display_name}?`)) return + const res = await fetch(`/auth/admin/orgs/${org.uuid}`, { method: 'DELETE' }) + const data = await res.json() + if (data.detail) return alert(data.detail) + await loadOrgs() +} + +async function addOrgPermission(org) { + const id = prompt('Permission ID to add:', permissions.value[0]?.id || '') + if (!id) return + const res = await fetch(`/auth/admin/orgs/${org.uuid}/permissions/${encodeURIComponent(id)}`, { method: 'POST' }) + const data = await res.json() + if (data.detail) return alert(data.detail) + await loadOrgs() +} + +async function removeOrgPermission(org, permId) { + const res = await fetch(`/auth/admin/orgs/${org.uuid}/permissions/${encodeURIComponent(permId)}`, { method: 'DELETE' }) + const data = await res.json() + if (data.detail) return alert(data.detail) + await loadOrgs() +} + +// Role actions +async function createRole(org) { + const name = prompt('New role display name:') + if (!name) return + const csv = prompt('Permission IDs (comma-separated):', '') || '' + const perms = csv.split(',').map(s => s.trim()).filter(Boolean) + const res = await fetch(`/auth/admin/orgs/${org.uuid}/roles`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ display_name: name, permissions: perms }) + }) + const data = await res.json() + if (data.detail) return alert(data.detail) + await loadOrgs() +} + +async function updateRole(role) { + const name = prompt('Role display name:', role.display_name) + if (!name) return + const csv = prompt('Permission IDs (comma-separated):', role.permissions.join(', ')) || '' + const perms = csv.split(',').map(s => s.trim()).filter(Boolean) + const res = await fetch(`/auth/admin/roles/${role.uuid}`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ display_name: name, permissions: perms }) + }) + const data = await res.json() + if (data.detail) return alert(data.detail) + await loadOrgs() +} + +async function deleteRole(role) { + if (!confirm(`Delete role ${role.display_name}?`)) return + const res = await fetch(`/auth/admin/roles/${role.uuid}`, { method: 'DELETE' }) + const data = await res.json() + if (data.detail) return alert(data.detail) + await loadOrgs() +} + +// Permission actions +async function createPermission() { + const id = prompt('Permission ID (e.g., auth/example):') + if (!id) return + const name = prompt('Permission display name:') + if (!name) return + const res = await fetch('/auth/admin/permissions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id, display_name: name }) + }) + const data = await res.json() + if (data.detail) return alert(data.detail) + await loadPermissions() +} + +async function updatePermission(p) { + const name = prompt('Permission display name:', p.display_name) + if (!name) return + const res = await fetch(`/auth/admin/permissions/${encodeURIComponent(p.id)}`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ display_name: name }) + }) + const data = await res.json() + if (data.detail) return alert(data.detail) + await loadPermissions() +} + +async function deletePermission(p) { + if (!confirm(`Delete permission ${p.id}?`)) return + const res = await fetch(`/auth/admin/permissions/${encodeURIComponent(p.id)}`, { method: 'DELETE' }) + const data = await res.json() + if (data.detail) return alert(data.detail) + await loadPermissions() +} + onMounted(load) @@ -38,15 +184,10 @@ onMounted(load)

Insufficient permissions.

-
-

User

-
{{ info.user.user_name }} ({{ info.user.user_uuid }})
-
Role: {{ info.role?.display_name }}
-

Organization

-
{{ info.org?.display_name }} ({{ info.org?.uuid }})
+
{{ info.org?.display_name }}
Role permissions: {{ info.role?.permissions?.join(', ') }}
Org grantable: {{ info.org?.permissions?.join(', ') }}
@@ -57,6 +198,83 @@ onMounted(load)
Global admin: {{ info.is_global_admin ? 'yes' : 'no' }}
Org admin: {{ info.is_org_admin ? 'yes' : 'no' }}
+ +
+

Organizations

+
+ +
+
+
+ {{ o.display_name }} +
+
+ + +
+
+
Grantable permissions:
+
+ + {{ permissions.find(x => x.id === p)?.display_name || p }} + + +
+ +
+
+
Roles:
+
+
+ {{ r.display_name }} +
+ {{ r.display_name }} +
+ + +
+
+ +
+
+
Users:
+ + + + + + + + + + + + + + + + + +
UserRoleLast SeenVisits
{{ u.display_name }}{{ u.role }}{{ u.last_seen ? new Date(u.last_seen).toLocaleString() : '—' }}{{ u.visits }}
+
+
+
+ +
+

All Permissions

+
+ +
+
+
+ {{ p.display_name }} +
+
+ + +
+
+
@@ -67,4 +285,20 @@ onMounted(load) .subtitle { color: #888 } .card { margin: 1rem 0; padding: 1rem; border: 1px solid #eee; border-radius: 8px; } .error { color: #a00 } +.actions { margin-bottom: .5rem } +.org { border-top: 1px dashed #eee; padding: .5rem 0 } +.org-header { display: flex; gap: .5rem; align-items: baseline } +.user-item { display: flex; gap: .5rem; margin: .15rem 0 } +.users-table { width: 100%; border-collapse: collapse; margin-top: .25rem; } +.users-table th, .users-table td { padding: .25rem .4rem; text-align: left; border-bottom: 1px solid #eee; font-weight: normal; } +.users-table th { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: #555; } +.users-table tbody tr:hover { background: #fafafa; } +.org-actions, .role-actions, .perm-actions { display: flex; gap: .5rem; margin: .25rem 0 } +.muted { color: #666 } +.small { font-size: .9em } +.pill-list { display: flex; flex-wrap: wrap; gap: .25rem } +.pill { background: #f3f3f3; border: 1px solid #e2e2e2; border-radius: 999px; padding: .1rem .5rem; display: inline-flex; align-items: center; gap: .25rem } +.pill-x { background: transparent; border: none; color: #900; cursor: pointer } +button { padding: .25rem .5rem; border-radius: 6px; border: 1px solid #ddd; background: #fff; cursor: pointer } +button:hover { background: #f7f7f7 } diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 837b2bb..10b984f 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -20,9 +20,35 @@ export default defineConfig(({ command, mode }) => ({ port: 4403, proxy: { '/auth/': { - target: 'http://localhost:4401', + target: 'http://localhost:4402', ws: true, - changeOrigin: false + changeOrigin: false, + // We proxy API + WS under /auth/, but want Vite to serve the SPA entrypoints + // and static assets so that HMR works. Bypass tells http-proxy to skip + // proxying when we return a (possibly rewritten) local path. + bypass(req) { + const url = req.url || '' + // Paths to serve locally (not proxied): + // - /auth/ (root SPA) + // - /auth/assets/* (dev static assets) + // - /auth/admin/* (admin SPA) + // NOTE: Keep /auth/ws/* and all other API endpoints proxied. + if (url === '/auth/' || url === '/auth') { + return '/' + } + if (url.startsWith('/auth/assets')) { + // Map /auth/assets/* -> /assets/* + return url.replace(/^\/auth/, '') + } + if (url === '/auth/admin' || url === '/auth/admin/') { + return '/admin/' + } + if (url.startsWith('/auth/admin/')) { + // Map /auth/admin/* -> /admin/* + return url.replace(/^\/auth\/admin/, '/admin') + } + // Otherwise proxy (API, ws, etc.) + } } } }, @@ -35,9 +61,7 @@ export default defineConfig(({ command, mode }) => ({ index: resolve(__dirname, 'index.html'), admin: resolve(__dirname, 'admin/index.html') }, - output: { - // Ensure HTML files land as /auth/index.html and /auth/admin.html -> we will serve /auth/admin mapping in backend - } + output: {} } } })) diff --git a/passkey/bootstrap.py b/passkey/bootstrap.py index f5841ff..7f099a2 100644 --- a/passkey/bootstrap.py +++ b/passkey/bootstrap.py @@ -16,7 +16,18 @@ from . import authsession, globals from .db import Org, Permission, Role, User from .util import passphrase, tokens -logger = logging.getLogger(__name__) + +def _init_logger() -> logging.Logger: + l = logging.getLogger(__name__) + if not l.handlers and not logging.getLogger().handlers: + h = logging.StreamHandler() + h.setFormatter(logging.Formatter("%(message)s")) + l.addHandler(h) + l.setLevel(logging.INFO) + return l + + +logger = _init_logger() # Shared log message template for admin reset links ADMIN_RESET_MESSAGE = """\ @@ -61,17 +72,18 @@ async def bootstrap_system( org = Org(uuid7.create(), org_name or "Organization") await globals.db.instance.create_organization(org) - perm1 = Permission( - id=f"auth/org:{org.uuid}", display_name=f"{org.display_name} Admin" - ) - await globals.db.instance.create_permission(perm1) - - # Allow this org to grant admin permissions + # After creation, org.permissions now includes the auto-created org admin permission + # Allow this org to grant global admin explicitly await globals.db.instance.add_permission_to_organization(str(org.uuid), perm0.id) - await globals.db.instance.add_permission_to_organization(str(org.uuid), perm1.id) # Create an Administration role granting both org and global admin - role = Role(uuid7.create(), org.uuid, "Administration", permissions=[perm0.id, perm1.id]) + # Compose permissions for Administration role: global admin + org admin auto-perm + role = Role( + uuid7.create(), + org.uuid, + "Administration", + permissions=[perm0.id, *org.permissions], + ) await globals.db.instance.create_role(role) user = User( @@ -92,7 +104,10 @@ async def bootstrap_system( "user": user, "org": org, "role": role, - "permissions": [perm0, perm1], + "permissions": [ + perm0, + *[Permission(id=p, display_name="") for p in org.permissions], + ], "reset_link": reset_link, } diff --git a/passkey/db/__init__.py b/passkey/db/__init__.py index 94f6e4c..a4500be 100644 --- a/passkey/db/__init__.py +++ b/passkey/db/__init__.py @@ -105,6 +105,14 @@ class DatabaseInterface(ABC): async def create_role(self, role: Role) -> None: """Create new role.""" + @abstractmethod + async def update_role(self, role: Role) -> None: + """Update a role's display name and synchronize its permissions.""" + + @abstractmethod + async def delete_role(self, role_uuid: UUID) -> None: + """Delete a role by UUID. Implementations may prevent deletion if users exist.""" + # Credential operations @abstractmethod async def create_credential(self, credential: Credential) -> None: @@ -165,6 +173,10 @@ class DatabaseInterface(ABC): async def get_organization(self, org_id: str) -> Org: """Get organization by ID, including its permission IDs and roles (with their permission IDs).""" + @abstractmethod + async def list_organizations(self) -> list[Org]: + """List all organizations with their roles and permission IDs.""" + @abstractmethod async def update_organization(self, org: Org) -> None: """Update organization options.""" @@ -175,7 +187,7 @@ class DatabaseInterface(ABC): @abstractmethod async def add_user_to_organization( - self, user_uuid: UUID, org_id: str, role: str + self, user_uuid: UUID, org_id: str, role: str ) -> None: """Set a user's organization and role.""" @@ -214,6 +226,10 @@ class DatabaseInterface(ABC): async def get_permission(self, permission_id: str) -> Permission: """Get permission by ID.""" + @abstractmethod + async def list_permissions(self) -> list[Permission]: + """List all permissions.""" + @abstractmethod async def update_permission(self, permission: Permission) -> None: """Update permission details.""" @@ -248,7 +264,9 @@ class DatabaseInterface(ABC): """Add a permission to a role.""" @abstractmethod - async def remove_permission_from_role(self, role_uuid: UUID, permission_id: str) -> None: + async def remove_permission_from_role( + self, role_uuid: UUID, permission_id: str + ) -> None: """Remove a permission from a role.""" @abstractmethod @@ -259,6 +277,10 @@ class DatabaseInterface(ABC): async def get_permission_roles(self, permission_id: str) -> list[Role]: """List all roles that grant a permission.""" + @abstractmethod + async def get_role(self, role_uuid: UUID) -> Role: + """Get a role by UUID, including its permission IDs.""" + # Combined operations @abstractmethod async def login(self, user_uuid: UUID, credential: Credential) -> None: diff --git a/passkey/db/sql.py b/passkey/db/sql.py index 528bb57..9b3ef96 100644 --- a/passkey/db/sql.py +++ b/passkey/db/sql.py @@ -441,13 +441,42 @@ class DB(DatabaseInterface): display_name=org.display_name, ) session.add(org_model) - # Persist org permissions the org is allowed to grant + # Persist any explicitly provided org grantable permissions if org.permissions: - for perm_id in org.permissions: + for perm_id in set(org.permissions): session.add( OrgPermission(org_uuid=org.uuid.bytes, permission_id=perm_id) ) + # Automatically create an organization admin permission if not present. + # Pattern: auth/org: + auto_perm_id = f"auth/org:{org.uuid}" + # Only create if it does not already exist (in case caller passed it) + existing_perm = await session.execute( + select(PermissionModel).where(PermissionModel.id == auto_perm_id) + ) + if not existing_perm.scalar_one_or_none(): + session.add( + PermissionModel( + id=auto_perm_id, + display_name=f"{org.display_name} Admin", + ) + ) + # Ensure org is allowed to grant its own admin permission (insert if missing) + existing_org_perm = await session.execute( + select(OrgPermission).where( + OrgPermission.org_uuid == org.uuid.bytes, + OrgPermission.permission_id == auto_perm_id, + ) + ) + if not existing_org_perm.scalar_one_or_none(): + session.add( + OrgPermission(org_uuid=org.uuid.bytes, permission_id=auto_perm_id) + ) + # Reflect the automatically added permission in the dataclass instance + if auto_perm_id not in org.permissions: + org.permissions.append(auto_perm_id) + async def get_organization(self, org_id: str) -> Org: async with self.session() as session: # Convert string ID to UUID bytes for lookup @@ -488,6 +517,48 @@ class DB(DatabaseInterface): return org_dc + async def list_organizations(self) -> list[Org]: + async with self.session() as session: + # Load all orgs + orgs_result = await session.execute(select(OrgModel)) + org_models = orgs_result.scalars().all() + if not org_models: + return [] + + # Preload org permissions mapping + org_perms_result = await session.execute(select(OrgPermission)) + org_perms = org_perms_result.scalars().all() + perms_by_org: dict[bytes, list[str]] = {} + for op in org_perms: + perms_by_org.setdefault(op.org_uuid, []).append(op.permission_id) + + # Preload roles + roles_result = await session.execute(select(RoleModel)) + role_models = roles_result.scalars().all() + + # Preload role permissions mapping + rp_result = await session.execute(select(RolePermission)) + rps = rp_result.scalars().all() + perms_by_role: dict[bytes, list[str]] = {} + for rp in rps: + perms_by_role.setdefault(rp.role_uuid, []).append(rp.permission_id) + + # Build org dataclasses with roles and permission IDs + roles_by_org: dict[bytes, list[Role]] = {} + for rm in role_models: + r_dc = rm.as_dataclass() + r_dc.permissions = perms_by_role.get(rm.uuid, []) + roles_by_org.setdefault(rm.org_uuid, []).append(r_dc) + + orgs: list[Org] = [] + for om in org_models: + o_dc = om.as_dataclass() + o_dc.permissions = perms_by_org.get(om.uuid, []) + o_dc.roles = roles_by_org.get(om.uuid, []) + orgs.append(o_dc) + + return orgs + async def update_organization(self, org: Org) -> None: async with self.session() as session: stmt = ( @@ -505,9 +576,7 @@ class DB(DatabaseInterface): if org.permissions: for perm_id in org.permissions: await session.merge( - OrgPermission( - org_uuid=org.uuid.bytes, permission_id=perm_id - ) + OrgPermission(org_uuid=org.uuid.bytes, permission_id=perm_id) ) async def delete_organization(self, org_uuid: UUID) -> None: @@ -557,9 +626,9 @@ class DB(DatabaseInterface): async def transfer_user_to_organization( self, user_uuid: UUID, new_org_id: str, new_role: str | None = None ) -> None: - # Users are members of an org that never changes after creation. - # Disallow transfers across organizations to enforce invariant. - raise ValueError("Users cannot be transferred to a different organization") + # Users are members of an org that never changes after creation. + # Disallow transfers across organizations to enforce invariant. + raise ValueError("Users cannot be transferred to a different organization") async def get_user_organization(self, user_uuid: UUID) -> tuple[Org, str]: async with self.session() as session: @@ -686,6 +755,11 @@ class DB(DatabaseInterface): stmt = delete(PermissionModel).where(PermissionModel.id == permission_id) await session.execute(stmt) + async def list_permissions(self) -> list[Permission]: + async with self.session() as session: + result = await session.execute(select(PermissionModel)) + return [p.as_dataclass() for p in result.scalars().all()] + async def add_permission_to_role(self, role_uuid: UUID, permission_id: str) -> None: async with self.session() as session: # Ensure role exists @@ -696,7 +770,9 @@ class DB(DatabaseInterface): raise ValueError("Role not found") # Ensure permission exists - perm_stmt = select(PermissionModel).where(PermissionModel.id == permission_id) + perm_stmt = select(PermissionModel).where( + PermissionModel.id == permission_id + ) perm_result = await session.execute(perm_stmt) if not perm_result.scalar_one_or_none(): raise ValueError("Permission not found") @@ -705,7 +781,9 @@ class DB(DatabaseInterface): RolePermission(role_uuid=role_uuid.bytes, permission_id=permission_id) ) - async def remove_permission_from_role(self, role_uuid: UUID, permission_id: str) -> None: + async def remove_permission_from_role( + self, role_uuid: UUID, permission_id: str + ) -> None: async with self.session() as session: await session.execute( delete(RolePermission) @@ -717,7 +795,9 @@ class DB(DatabaseInterface): async with self.session() as session: stmt = ( select(PermissionModel) - .join(RolePermission, PermissionModel.id == RolePermission.permission_id) + .join( + RolePermission, PermissionModel.id == RolePermission.permission_id + ) .where(RolePermission.role_uuid == role_uuid.bytes) ) result = await session.execute(stmt) @@ -733,6 +813,57 @@ class DB(DatabaseInterface): result = await session.execute(stmt) return [r.as_dataclass() for r in result.scalars().all()] + async def update_role(self, role: Role) -> None: + async with self.session() as session: + # Update role display_name + await session.execute( + update(RoleModel) + .where(RoleModel.uuid == role.uuid.bytes) + .values(display_name=role.display_name) + ) + # Sync role permissions: delete all then insert current set + await session.execute( + delete(RolePermission).where( + RolePermission.role_uuid == role.uuid.bytes + ) + ) + if role.permissions: + for perm_id in set(role.permissions): + session.add( + RolePermission(role_uuid=role.uuid.bytes, permission_id=perm_id) + ) + + async def delete_role(self, role_uuid: UUID) -> None: + async with self.session() as session: + # Prevent deleting a role that still has users + # Quick existence check for users assigned to the role + existing_user = await session.execute( + select(UserModel.uuid).where(UserModel.role_uuid == role_uuid.bytes) + ) + if existing_user.first() is not None: + raise ValueError("Cannot delete role with assigned users") + + await session.execute( + delete(RoleModel).where(RoleModel.uuid == role_uuid.bytes) + ) + + async def get_role(self, role_uuid: UUID) -> Role: + async with self.session() as session: + result = await session.execute( + select(RoleModel).where(RoleModel.uuid == role_uuid.bytes) + ) + role_model = result.scalar_one_or_none() + if not role_model: + raise ValueError("Role not found") + r_dc = role_model.as_dataclass() + perms_result = await session.execute( + select(RolePermission.permission_id).where( + RolePermission.role_uuid == role_uuid.bytes + ) + ) + r_dc.permissions = [row[0] for row in perms_result.fetchall()] + return r_dc + async def add_permission_to_organization( self, org_id: str, permission_id: str ) -> None: @@ -844,7 +975,9 @@ class DB(DatabaseInterface): .join(RoleModel, UserModel.role_uuid == RoleModel.uuid) .join(OrgModel, RoleModel.org_uuid == OrgModel.uuid) .outerjoin(RolePermission, RoleModel.uuid == RolePermission.role_uuid) - .outerjoin(PermissionModel, RolePermission.permission_id == PermissionModel.id) + .outerjoin( + PermissionModel, RolePermission.permission_id == PermissionModel.id + ) .where(SessionModel.key == session_key) ) diff --git a/passkey/fastapi/__main__.py b/passkey/fastapi/__main__.py index 5bdab3a..9003fb4 100644 --- a/passkey/fastapi/__main__.py +++ b/passkey/fastapi/__main__.py @@ -1,52 +1,249 @@ import argparse import asyncio +import atexit +import contextlib +import ipaddress import logging +import os +import signal +import subprocess +from pathlib import Path +from urllib.parse import urlparse import uvicorn +DEFAULT_HOST = "localhost" +DEFAULT_SERVE_PORT = 4401 +DEFAULT_DEV_PORT = 4402 + + +def parse_endpoint( + value: str | None, default_port: int +) -> tuple[str | None, int | None, str | None, bool]: + """Parse an endpoint using stdlib (urllib.parse, ipaddress). + + Returns (host, port, uds_path). If uds_path is not None, host/port are None. + + Supported forms: + - host[:port] + - :port (uses default host) + - [ipv6][:port] (bracketed for port usage) + - ipv6 (unbracketed, no port allowed -> default port) + - unix:/path/to/socket.sock + - None -> defaults (localhost:4401) + + Notes: + - For IPv6 with an explicit port you MUST use brackets (e.g. [::1]:8080) + - Unbracketed IPv6 like ::1 implies the default port. + """ + if not value: + return DEFAULT_HOST, default_port, None, False + + # Port only (numeric) -> localhost:port + if value.isdigit(): + try: + port_only = int(value) + except ValueError: # pragma: no cover (isdigit guards) + raise SystemExit(f"Invalid port '{value}'") + return DEFAULT_HOST, port_only, None, False + + # Leading colon :port -> bind all interfaces (0.0.0.0 + ::) + if value.startswith(":") and value != ":": + port_part = value[1:] + if not port_part.isdigit(): + raise SystemExit(f"Invalid port in '{value}'") + return None, int(port_part), None, True + + # UNIX domain socket + if value.startswith("unix:"): + uds_path = value[5:] or None + if uds_path is None: + raise SystemExit("unix: path must not be empty") + return None, None, uds_path, False + + # Unbracketed IPv6 (cannot safely contain a port) -> detect by multiple colons + if value.count(":") > 1 and not value.startswith("["): + try: + ipaddress.IPv6Address(value) + except ValueError as e: # pragma: no cover + raise SystemExit(f"Invalid IPv6 address '{value}': {e}") + return value, default_port, None, False + + # Use urllib.parse for everything else (host[:port], :port, [ipv6][:port]) + parsed = urlparse(f"//{value}") # // prefix lets urlparse treat it as netloc + host = parsed.hostname + port = parsed.port + + # Host may be None if empty (e.g. ':5500') + if not host: + host = DEFAULT_HOST + if port is None: + port = default_port + + # Validate IP literals (optional; hostname passes through) + try: + # Strip brackets if somehow present (urlparse removes them already) + ipaddress.ip_address(host) + except ValueError: + # Not an IP address -> treat as hostname; no action + pass + + return host, port, None, False + + +def add_common_options(p: argparse.ArgumentParser) -> None: + p.add_argument( + "--rp-id", default="localhost", help="Relying Party ID (default: localhost)" + ) + p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)") + p.add_argument("--origin", help="Origin URL (default: https://)") + def main(): # Configure logging to remove the "ERROR:root:" prefix logging.basicConfig(level=logging.INFO, format="%(message)s", force=True) + parser = argparse.ArgumentParser( - description="Run the passkey authentication server" + prog="passkey-auth", description="Passkey authentication server" ) - parser.add_argument( - "--host", default="localhost", help="Host to bind to (default: localhost)" + sub = parser.add_subparsers(dest="command", required=True) + + # serve subcommand + serve = sub.add_parser( + "serve", help="Run the server (production style, no auto-reload)" ) - parser.add_argument( - "--port", type=int, default=4401, help="Port to bind to (default: 4401)" + serve.add_argument( + "hostport", + nargs="?", + help=( + "Endpoint (default: localhost:4401). Forms: host[:port] | :port | " + "[ipv6][:port] | ipv6 | unix:/path.sock" + ), ) - parser.add_argument( - "--dev", action="store_true", help="Enable development mode with auto-reload" + add_common_options(serve) + + # dev subcommand + dev = sub.add_parser("dev", help="Run the server in development (auto-reload)") + dev.add_argument( + "hostport", + nargs="?", + help=( + "Endpoint (default: localhost:4402). Forms: host[:port] | :port | " + "[ipv6][:port] | ipv6 | unix:/path.sock" + ), ) - parser.add_argument( - "--rp-id", default="localhost", help="Relying Party ID (default: localhost)" - ) - parser.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)") - parser.add_argument("--origin", help="Origin URL (default: https://)") + add_common_options(dev) args = parser.parse_args() - # Initialize the application - try: - from .. import globals + default_port = DEFAULT_DEV_PORT if args.command == "dev" else DEFAULT_SERVE_PORT + host, port, uds, all_ifaces = parse_endpoint(args.hostport, default_port) + reload_enabled = args.command == "dev" - asyncio.run( - globals.init(rp_id=args.rp_id, rp_name=args.rp_name, origin=args.origin) + # Determine origin (dev mode default override) + effective_origin = args.origin + if reload_enabled and not effective_origin: + # Use a distinct port (4403) for RP origin in dev if not explicitly provided + effective_origin = "http://localhost:4403" + + # Export configuration via environment for lifespan initialization in each process + os.environ.setdefault("PASSKEY_RP_ID", args.rp_id) + if args.rp_name: + os.environ["PASSKEY_RP_NAME"] = args.rp_name + if effective_origin: + os.environ["PASSKEY_ORIGIN"] = effective_origin + + # One-time initialization + bootstrap before starting any server processes. + # Lifespan in worker processes will call globals.init with bootstrap disabled. + from passkey import globals as _globals # local import + + asyncio.run( + _globals.init( + rp_id=args.rp_id, + rp_name=args.rp_name, + origin=effective_origin, + default_admin=os.getenv("PASSKEY_DEFAULT_ADMIN") or None, + default_org=os.getenv("PASSKEY_DEFAULT_ORG") or None, + bootstrap=True, ) - except ValueError as e: - logging.error(f"⚠️ {e}") - return - - uvicorn.run( - "passkey.fastapi:app", - host=args.host, - port=args.port, - reload=args.dev, - log_level="info", ) + run_kwargs: dict = { + "reload": reload_enabled, + "log_level": "info", + } + if uds: + run_kwargs["uds"] = uds + else: + # For :port form (all interfaces) we will handle separately + if not all_ifaces: + run_kwargs["host"] = host + run_kwargs["port"] = port + + bun_process: subprocess.Popen | None = None + if reload_enabled: + # Spawn frontend dev server (bun) only in the original parent (avoid duplicates on reload) + if os.environ.get("PASSKEY_BUN_PARENT") != "1": + os.environ["PASSKEY_BUN_PARENT"] = "1" + frontend_dir = Path(__file__).parent.parent.parent / "frontend" + if (frontend_dir / "package.json").exists(): + try: + bun_process = subprocess.Popen( + ["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 reload enabled, fallback to single dual-stack attempt (::) to keep reload simple + if reload_enabled: + run_kwargs["host"] = "::" + run_kwargs["port"] = port + uvicorn.run("passkey.fastapi:app", **run_kwargs) + else: + # Start two servers concurrently: IPv4 and IPv6 + from uvicorn import Config, Server # noqa: E402 local import + + from passkey.fastapi import app as fastapi_app # noqa: E402 local import + + async def serve_both(): + servers = [] + assert port is not None + for h in ("0.0.0.0", "::"): + try: + cfg = Config( + app=fastapi_app, + host=h, + port=port, + log_level="info", + ) + servers.append(Server(cfg)) + except Exception as e: # pragma: no cover + logging.warning(f"Failed to configure server for {h}: {e}") + tasks = [asyncio.create_task(s.serve()) for s in servers] + await asyncio.gather(*tasks) + + asyncio.run(serve_both()) + else: + uvicorn.run("passkey.fastapi:app", **run_kwargs) + if __name__ == "__main__": main() diff --git a/passkey/fastapi/api.py b/passkey/fastapi/api.py index 77f4887..9236e33 100644 --- a/passkey/fastapi/api.py +++ b/passkey/fastapi/api.py @@ -8,9 +8,9 @@ This module contains all the HTTP API endpoints for: - Login/logout functionality """ -from uuid import UUID +from uuid import UUID, uuid4 -from fastapi import Cookie, Depends, FastAPI, Response +from fastapi import Body, Cookie, Depends, FastAPI, HTTPException, Response from fastapi.security import HTTPBearer from passkey.util import passphrase @@ -27,6 +27,19 @@ bearer_auth = HTTPBearer(auto_error=True) def register_api_routes(app: FastAPI): """Register all API routes on the FastAPI app.""" + async def _get_ctx_and_admin_flags(auth_cookie: str): + """Helper to get session context and admin flags from cookie.""" + if not auth_cookie: + raise ValueError("Not authenticated") + ctx = await db.instance.get_session_context(session_key(auth_cookie)) + if not ctx: + raise ValueError("Not authenticated") + role_perm_ids = set(ctx.role.permissions or []) + org_uuid_str = str(ctx.org.uuid) + is_global_admin = "auth/admin" in role_perm_ids + is_org_admin = f"auth/org:{org_uuid_str}" in role_perm_ids + return ctx, is_global_admin, is_org_admin + @app.post("/auth/validate") async def validate_token(response: Response, auth=Cookie(None)): """Lightweight token validation endpoint.""" @@ -38,29 +51,50 @@ def register_api_routes(app: FastAPI): @app.post("/auth/user-info") async def api_user_info(response: Response, auth=Cookie(None)): - """Get full user information for the authenticated user.""" - reset = passphrase.is_well_formed(auth) - s = await (get_reset if reset else get_session)(auth) - # Session context (org, role, permissions) + """Get user information. + + - For authenticated sessions: return full context (org/role/permissions/credentials) + - For reset tokens: return only basic user information to drive reset flow + """ + try: + reset = auth and passphrase.is_well_formed(auth) + s = await (get_reset if reset else get_session)(auth) + except ValueError: + raise HTTPException( + status_code=401, + detail="Authentication Required", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Minimal response for reset tokens + if reset: + u = await db.instance.get_user_by_uuid(s.user_uuid) + return { + "authenticated": False, + "session_type": s.info.get("type"), + "user": { + "user_uuid": str(u.uuid), + "user_name": u.display_name, + "created_at": u.created_at.isoformat() if u.created_at else None, + "last_seen": u.last_seen.isoformat() if u.last_seen else None, + "visits": u.visits, + }, + } + + # Full context for authenticated sessions ctx = await db.instance.get_session_context(session_key(auth)) - # Fallback if context not available (e.g., reset session) u = await db.instance.get_user_by_uuid(s.user_uuid) - # Get all credentials for the user credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid) - credentials = [] - user_aaguids = set() - + credentials: list[dict] = [] + user_aaguids: set[str] = set() for cred_id in credential_ids: - c = await db.instance.get_credential_by_id(cred_id) - - # Convert AAGUID to string format + try: + c = await db.instance.get_credential_by_id(cred_id) + except ValueError: + continue # Skip dangling IDs aaguid_str = str(c.aaguid) user_aaguids.add(aaguid_str) - - # Check if this is the current session credential - is_current_session = s.credential_uuid == c.uuid - credentials.append( { "credential_uuid": str(c.uuid), @@ -71,37 +105,31 @@ def register_api_routes(app: FastAPI): if c.last_verified else None, "sign_count": c.sign_count, - "is_current_session": is_current_session, + "is_current_session": s.credential_uuid == c.uuid, } ) - # Get AAGUID information for only the AAGUIDs that the user has + credentials.sort(key=lambda cred: cred["created_at"]) # chronological aaguid_info = aaguid.filter(user_aaguids) - # Sort credentials by creation date (earliest first, most recently created last) - credentials.sort(key=lambda cred: cred["created_at"]) - - # Permissions and roles role_info = None org_info = None - effective_permissions = [] + effective_permissions: list[str] = [] is_global_admin = False is_org_admin = False if ctx: role_info = { "uuid": str(ctx.role.uuid), "display_name": ctx.role.display_name, - "permissions": ctx.role.permissions, # IDs + "permissions": ctx.role.permissions, } org_info = { "uuid": str(ctx.org.uuid), "display_name": ctx.org.display_name, - "permissions": ctx.org.permissions, # IDs the org can grant + "permissions": ctx.org.permissions, } - # Effective permissions are role permissions; API also returns full objects for convenience effective_permissions = [p.id for p in (ctx.permissions or [])] is_global_admin = "auth/admin" in role_info["permissions"] - # org admin permission is auth/org: is_org_admin = ( f"auth/org:{org_info['uuid']}" in role_info["permissions"] if org_info @@ -109,8 +137,8 @@ def register_api_routes(app: FastAPI): ) return { - "authenticated": not reset, - "session_type": s.info["type"], + "authenticated": True, + "session_type": s.info.get("type"), "user": { "user_uuid": str(u.uuid), "user_name": u.display_name, @@ -127,6 +155,232 @@ def register_api_routes(app: FastAPI): "aaguid_info": aaguid_info, } + # -------------------- Admin API: Organizations -------------------- + + @app.get("/auth/admin/orgs") + async def admin_list_orgs(auth=Cookie(None)): + ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth) + if not (is_global_admin or is_org_admin): + raise ValueError("Insufficient permissions") + orgs = await db.instance.list_organizations() + # If only org admin, filter to their org + if not is_global_admin: + orgs = [o for o in orgs if o.uuid == ctx.org.uuid] + + def role_to_dict(r): + return { + "uuid": str(r.uuid), + "org_uuid": str(r.org_uuid), + "display_name": r.display_name, + "permissions": r.permissions, + } + + async def org_to_dict(o): + # Fetch users for each org + users = await db.instance.get_organization_users(str(o.uuid)) + return { + "uuid": str(o.uuid), + "display_name": o.display_name, + "permissions": o.permissions, + "roles": [role_to_dict(r) for r in o.roles], + "users": [ + { + "uuid": str(u.uuid), + "display_name": u.display_name, + "role": role_name, + "visits": u.visits, + "last_seen": u.last_seen.isoformat() if u.last_seen else None, + } + for (u, role_name) in users + ], + } + + return [await org_to_dict(o) for o in orgs] + + @app.post("/auth/admin/orgs") + async def admin_create_org(payload: dict = Body(...), auth=Cookie(None)): + _, is_global_admin, _ = await _get_ctx_and_admin_flags(auth) + if not is_global_admin: + raise ValueError("Global admin required") + from ..db import Org as OrgDC # local import to avoid cycles in typing + + org_uuid = uuid4() + display_name = payload.get("display_name") or "New Organization" + permissions = payload.get("permissions") or [] + org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) + await db.instance.create_organization(org) + return {"uuid": str(org_uuid)} + + @app.put("/auth/admin/orgs/{org_uuid}") + async def admin_update_org( + org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) + ): + ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth) + if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)): + raise ValueError("Insufficient permissions") + from ..db import Org as OrgDC + + current = await db.instance.get_organization(str(org_uuid)) + display_name = payload.get("display_name") or current.display_name + permissions = ( + payload.get("permissions") + if "permissions" in payload + else current.permissions + ) or [] + org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions) + await db.instance.update_organization(org) + return {"status": "ok"} + + @app.delete("/auth/admin/orgs/{org_uuid}") + async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)): + _, is_global_admin, _ = await _get_ctx_and_admin_flags(auth) + if not is_global_admin: + raise ValueError("Global admin required") + await db.instance.delete_organization(org_uuid) + return {"status": "ok"} + + # Manage an org's grantable permissions + @app.post("/auth/admin/orgs/{org_uuid}/permissions/{permission_id}") + async def admin_add_org_permission( + org_uuid: UUID, permission_id: str, auth=Cookie(None) + ): + ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth) + if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)): + raise ValueError("Insufficient permissions") + await db.instance.add_permission_to_organization(str(org_uuid), permission_id) + return {"status": "ok"} + + @app.delete("/auth/admin/orgs/{org_uuid}/permissions/{permission_id}") + async def admin_remove_org_permission( + org_uuid: UUID, permission_id: str, auth=Cookie(None) + ): + ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth) + if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)): + raise ValueError("Insufficient permissions") + await db.instance.remove_permission_from_organization( + str(org_uuid), permission_id + ) + return {"status": "ok"} + + # -------------------- Admin API: Roles -------------------- + + @app.post("/auth/admin/orgs/{org_uuid}/roles") + async def admin_create_role( + org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) + ): + ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth) + if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)): + raise ValueError("Insufficient permissions") + from ..db import Role as RoleDC + + role_uuid = uuid4() + display_name = payload.get("display_name") or "New Role" + permissions = payload.get("permissions") or [] + # Validate that permissions exist and are allowed by org + org = await db.instance.get_organization(str(org_uuid)) + grantable = set(org.permissions or []) + for pid in permissions: + await db.instance.get_permission(pid) # raises if not found + if pid not in grantable: + raise ValueError(f"Permission not grantable by org: {pid}") + role = RoleDC( + uuid=role_uuid, + org_uuid=org_uuid, + display_name=display_name, + permissions=permissions, + ) + await db.instance.create_role(role) + return {"uuid": str(role_uuid)} + + @app.put("/auth/admin/roles/{role_uuid}") + async def admin_update_role( + role_uuid: UUID, payload: dict = Body(...), auth=Cookie(None) + ): + ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth) + role = await db.instance.get_role(role_uuid) + # Only org admins for that org or global admin can update + if not (is_global_admin or (is_org_admin and role.org_uuid == ctx.org.uuid)): + raise ValueError("Insufficient permissions") + from ..db import Role as RoleDC + + display_name = payload.get("display_name") or role.display_name + permissions = payload.get("permissions") or role.permissions + # Validate against org grantable permissions + org = await db.instance.get_organization(str(role.org_uuid)) + grantable = set(org.permissions or []) + for pid in permissions: + await db.instance.get_permission(pid) # raises if not found + if pid not in grantable: + raise ValueError(f"Permission not grantable by org: {pid}") + updated = RoleDC( + uuid=role_uuid, + org_uuid=role.org_uuid, + display_name=display_name, + permissions=permissions, + ) + await db.instance.update_role(updated) + return {"status": "ok"} + + @app.delete("/auth/admin/roles/{role_uuid}") + async def admin_delete_role(role_uuid: UUID, auth=Cookie(None)): + ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth) + role = await db.instance.get_role(role_uuid) + if not (is_global_admin or (is_org_admin and role.org_uuid == ctx.org.uuid)): + raise ValueError("Insufficient permissions") + await db.instance.delete_role(role_uuid) + return {"status": "ok"} + + # -------------------- Admin API: Permissions (global) -------------------- + + @app.get("/auth/admin/permissions") + async def admin_list_permissions(auth=Cookie(None)): + _, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth) + if not (is_global_admin or is_org_admin): + raise ValueError("Insufficient permissions") + perms = await db.instance.list_permissions() + return [{"id": p.id, "display_name": p.display_name} for p in perms] + + @app.post("/auth/admin/permissions") + async def admin_create_permission(payload: dict = Body(...), auth=Cookie(None)): + _, is_global_admin, _ = await _get_ctx_and_admin_flags(auth) + if not is_global_admin: + raise ValueError("Global admin required") + from ..db import Permission as PermDC + + perm_id = payload.get("id") + display_name = payload.get("display_name") + if not perm_id or not display_name: + raise ValueError("id and display_name are required") + await db.instance.create_permission( + PermDC(id=perm_id, display_name=display_name) + ) + return {"status": "ok"} + + @app.put("/auth/admin/permissions/{permission_id}") + async def admin_update_permission( + permission_id: str, payload: dict = Body(...), auth=Cookie(None) + ): + _, is_global_admin, _ = await _get_ctx_and_admin_flags(auth) + if not is_global_admin: + raise ValueError("Global admin required") + from ..db import Permission as PermDC + + display_name = payload.get("display_name") + if not display_name: + raise ValueError("display_name is required") + await db.instance.update_permission( + PermDC(id=permission_id, display_name=display_name) + ) + return {"status": "ok"} + + @app.delete("/auth/admin/permissions/{permission_id}") + async def admin_delete_permission(permission_id: str, auth=Cookie(None)): + _, is_global_admin, _ = await _get_ctx_and_admin_flags(auth) + if not is_global_admin: + raise ValueError("Global admin required") + await db.instance.delete_permission(permission_id) + return {"status": "ok"} + @app.post("/auth/logout") async def api_logout(response: Response, auth=Cookie(None)): """Log out the current user by clearing the session cookie and deleting from database.""" diff --git a/passkey/fastapi/mainapp.py b/passkey/fastapi/mainapp.py index 6feaec3..d5071c8 100644 --- a/passkey/fastapi/mainapp.py +++ b/passkey/fastapi/mainapp.py @@ -1,5 +1,7 @@ import contextlib import logging +import os +from contextlib import asynccontextmanager from pathlib import Path from fastapi import Cookie, FastAPI, Request, Response @@ -14,7 +16,41 @@ from .reset import register_reset_routes STATIC_DIR = Path(__file__).parent.parent / "frontend-build" -app = FastAPI() +@asynccontextmanager +async def lifespan(app: FastAPI): # pragma: no cover - startup path + """Application lifespan to ensure globals (DB, passkey) are initialized in each process. + + We populate configuration from environment variables (set by the CLI entrypoint) + so that uvicorn reload / multiprocess workers inherit the settings. + """ + from .. import globals + + rp_id = os.getenv("PASSKEY_RP_ID", "localhost") + rp_name = os.getenv("PASSKEY_RP_NAME") or None + origin = os.getenv("PASSKEY_ORIGIN") or None + default_admin = ( + os.getenv("PASSKEY_DEFAULT_ADMIN") or None + ) # still passed for context + default_org = os.getenv("PASSKEY_DEFAULT_ORG") or None + try: + # CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work + await globals.init( + rp_id=rp_id, + rp_name=rp_name, + origin=origin, + default_admin=default_admin, + default_org=default_org, + bootstrap=False, + ) + except ValueError as e: + logging.error(f"⚠️ {e}") + # Re-raise to fail fast + raise + yield + # (Optional) add shutdown cleanup here later + + +app = FastAPI(lifespan=lifespan) # Global exception handlers @@ -71,16 +107,24 @@ async def redirect_to_index(): @app.get("/auth/admin") -async def serve_admin(): - """Serve the admin app entry point.""" - # Vite MPA builds admin as admin.html in the same outDir - admin_html = STATIC_DIR / "admin.html" - # If configured to emit admin/index.html, support that too - if not admin_html.exists(): - alt = STATIC_DIR / "admin" / "index.html" - if alt.exists(): - return FileResponse(alt) - return FileResponse(admin_html) +async def serve_admin(auth=Cookie(None)): + """Serve the admin app entry point if an authenticated session exists. + + If no valid authenticated session cookie is present, return a 401 with the + main app's index.html so the frontend can initiate login/registration flow. + """ + if auth: + with contextlib.suppress(ValueError): + s = await get_session(auth) + if s.info and s.info.get("type") == "authenticated": + return FileResponse(STATIC_DIR / "admin" / "index.html") + + # Not authenticated: serve main index with 401 + return FileResponse( + STATIC_DIR / "index.html", + status_code=401, + headers={"WWW-Authenticate": "Bearer"}, + ) # Register API routes diff --git a/passkey/fastapi/reset.py b/passkey/fastapi/reset.py index 01e0dbd..91d4422 100644 --- a/passkey/fastapi/reset.py +++ b/passkey/fastapi/reset.py @@ -5,6 +5,7 @@ from fastapi.responses import RedirectResponse from ..authsession import expires, get_session from ..globals import db +from ..globals import passkey as global_passkey from ..util import passphrase, tokens from . import session @@ -43,10 +44,9 @@ def register_reset_routes(app): reset_token: str, ): """Verifies the token and redirects to auth app for credential registration.""" - # This route should only match to exact passphrases - print(f"Reset handler called with url: {request.url.path}") if not passphrase.is_well_formed(reset_token): raise HTTPException(status_code=404) + origin = global_passkey.instance.origin try: # Get session token to validate it exists and get user_id key = tokens.reset_key(reset_token) @@ -54,7 +54,7 @@ def register_reset_routes(app): if not sess: raise ValueError("Invalid or expired registration token") - response = RedirectResponse(url="/auth/", status_code=303) + response = RedirectResponse(url=f"{origin}/auth/", status_code=303) session.set_session_cookie(response, reset_token) return response @@ -65,4 +65,4 @@ def register_reset_routes(app): else: logging.exception("Internal Server Error in reset_authentication") msg = "Internal Server Error" - return RedirectResponse(url=f"/auth/#{msg}", status_code=303) + return RedirectResponse(url=f"{origin}/auth/#{msg}", status_code=303) diff --git a/passkey/globals.py b/passkey/globals.py index 2e88f67..0c10552 100644 --- a/passkey/globals.py +++ b/passkey/globals.py @@ -32,8 +32,15 @@ async def init( origin: str | None = None, default_admin: str | None = None, default_org: str | None = None, + *, + bootstrap: bool = True, ) -> None: - """Initialize the global database, passkey instance, and bootstrap the system if needed.""" + """Initialize global passkey + database. + + If bootstrap=True (default) the system bootstrap_if_needed() will be invoked. + In FastAPI lifespan we call with bootstrap=False to avoid duplicate bootstrapping + since the CLI performs it once before servers start. + """ # Initialize passkey instance with provided parameters passkey.instance = Passkey( rp_id=rp_id, @@ -49,10 +56,11 @@ async def init( await sql.init() - # Bootstrap system if needed - from .bootstrap import bootstrap_if_needed + if bootstrap: + # Bootstrap system if needed + from .bootstrap import bootstrap_if_needed - await bootstrap_if_needed(default_admin, default_org) + await bootstrap_if_needed(default_admin, default_org) # Global instances