Major changes to server startup. Admin page tuning.

This commit is contained in:
Leo Vasanko 2025-08-29 20:41:38 -06:00
parent 6e80011eed
commit 7380f09458
12 changed files with 1077 additions and 143 deletions

View File

@ -19,52 +19,55 @@ A minimal FastAPI WebAuthn server with WebSocket support for passkey registratio
## Quick Start ## Quick Start
### Using uv (recommended) ### Install (editable dev mode)
```fish ```fish
# Install uv if you haven't already uv pip install -e .[dev]
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
``` ```
### 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 ```fish
# Create and activate virtual environment # Production style (no reload)
python -m venv venv passkey-auth serve
source venv/bin/activate.fish # or venv/bin/activate for bash passkey-auth serve 0.0.0.0:8080 --rp-id example.com --origin https://example.com
# Install the package in development mode # Development (auto-reload)
pip install -e ".[dev]" passkey-auth dev # localhost:4401
passkey-auth dev :5500 # localhost on port 5500
# Run the server passkey-auth dev 127.0.0.1 # host only, default port 4401
python -m passkeyauth.main
``` ```
### Using hatch Available options (both subcommands):
```fish ```text
# Install hatch if you haven't already --rp-id <id> Relying Party ID (default: localhost)
pip install hatch --rp-name <name> Relying Party name (default: same as rp-id)
--origin <url> Explicit origin (default: https://<rp-id>)
# Run the development server
hatch run python -m passkeyauth.main
``` ```
## Usage ### Legacy Invocation
1. Start the server using one of the methods above 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.
2. Open your browser to `http://localhost:8000`
## 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) 3. Enter a username (or use the default)
4. Click "Register Passkey" 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 ## Development

View File

@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --clearScreen false",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },

View File

@ -4,6 +4,22 @@ import { ref, onMounted } from 'vue'
const info = ref(null) const info = ref(null)
const loading = ref(true) const loading = ref(true)
const error = ref(null) 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() { async function load() {
loading.value = true loading.value = true
@ -13,6 +29,9 @@ async function load() {
const data = await res.json() const data = await res.json()
if (data.detail) throw new Error(data.detail) if (data.detail) throw new Error(data.detail)
info.value = data info.value = data
if (data.authenticated && (data.is_global_admin || data.is_org_admin)) {
await Promise.all([loadOrgs(), loadPermissions()])
}
} catch (e) { } catch (e) {
error.value = e.message error.value = e.message
} finally { } 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) onMounted(load)
</script> </script>
@ -38,15 +184,10 @@ onMounted(load)
<p>Insufficient permissions.</p> <p>Insufficient permissions.</p>
</div> </div>
<div v-else> <div v-else>
<div class="card">
<h2>User</h2>
<div>{{ info.user.user_name }} ({{ info.user.user_uuid }})</div>
<div>Role: {{ info.role?.display_name }}</div>
</div>
<div class="card"> <div class="card">
<h2>Organization</h2> <h2>Organization</h2>
<div>{{ info.org?.display_name }} ({{ info.org?.uuid }})</div> <div>{{ info.org?.display_name }}</div>
<div>Role permissions: {{ info.role?.permissions?.join(', ') }}</div> <div>Role permissions: {{ info.role?.permissions?.join(', ') }}</div>
<div>Org grantable: {{ info.org?.permissions?.join(', ') }}</div> <div>Org grantable: {{ info.org?.permissions?.join(', ') }}</div>
</div> </div>
@ -57,6 +198,83 @@ onMounted(load)
<div>Global admin: {{ info.is_global_admin ? 'yes' : 'no' }}</div> <div>Global admin: {{ info.is_global_admin ? 'yes' : 'no' }}</div>
<div>Org admin: {{ info.is_org_admin ? 'yes' : 'no' }}</div> <div>Org admin: {{ info.is_org_admin ? 'yes' : 'no' }}</div>
</div> </div>
<div v-if="info.is_global_admin || info.is_org_admin" class="card">
<h2>Organizations</h2>
<div class="actions">
<button @click="createOrg" v-if="info.is_global_admin">+ Create Org</button>
</div>
<div v-for="o in orgs" :key="o.uuid" class="org">
<div class="org-header">
<strong>{{ o.display_name }}</strong>
</div>
<div class="org-actions">
<button @click="updateOrg(o)">Edit</button>
<button @click="deleteOrg(o)" v-if="info.is_global_admin">Delete</button>
</div>
<div>
<div class="muted">Grantable permissions:</div>
<div class="pill-list">
<span v-for="p in o.permissions" :key="p" class="pill" :title="p">
{{ permissions.find(x => x.id === p)?.display_name || p }}
<button class="pill-x" @click="removeOrgPermission(o, p)" :title="'Remove ' + p">×</button>
</span>
</div>
<button @click="addOrgPermission(o)" title="Add permission by ID">+ Add permission</button>
</div>
<div class="roles">
<div class="muted">Roles:</div>
<div v-for="r in o.roles" :key="r.uuid" class="role-item">
<div>
<strong>{{ r.display_name }}</strong>
</div>
<strong :title="r.uuid">{{ r.display_name }}</strong>
<div class="role-actions">
<button @click="updateRole(r)">Edit</button>
<button @click="deleteRole(r)">Delete</button>
</div>
</div>
<button @click="createRole(o)">+ Create role</button>
</div>
<div class="users" v-if="o.users?.length">
<div class="muted">Users:</div>
<table class="users-table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Last Seen</th>
<th>Visits</th>
</tr>
</thead>
<tbody>
<tr v-for="u in o.users" :key="u.uuid" :title="u.uuid">
<td>{{ u.display_name }}</td>
<td>{{ u.role }}</td>
<td>{{ u.last_seen ? new Date(u.last_seen).toLocaleString() : '—' }}</td>
<td>{{ u.visits }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div v-if="info.is_global_admin || info.is_org_admin" class="card">
<h2>All Permissions</h2>
<div class="actions">
<button @click="createPermission">+ Create Permission</button>
</div>
<div v-for="p in permissions" :key="p.id" class="perm" :title="p.id">
<div>
{{ p.display_name }}
</div>
<div class="perm-actions">
<button @click="updatePermission(p)">Edit</button>
<button @click="deletePermission(p)">Delete</button>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -67,4 +285,20 @@ onMounted(load)
.subtitle { color: #888 } .subtitle { color: #888 }
.card { margin: 1rem 0; padding: 1rem; border: 1px solid #eee; border-radius: 8px; } .card { margin: 1rem 0; padding: 1rem; border: 1px solid #eee; border-radius: 8px; }
.error { color: #a00 } .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 }
</style> </style>

View File

@ -20,9 +20,35 @@ export default defineConfig(({ command, mode }) => ({
port: 4403, port: 4403,
proxy: { proxy: {
'/auth/': { '/auth/': {
target: 'http://localhost:4401', target: 'http://localhost:4402',
ws: true, 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'), index: resolve(__dirname, 'index.html'),
admin: resolve(__dirname, 'admin/index.html') admin: resolve(__dirname, 'admin/index.html')
}, },
output: { output: {}
// Ensure HTML files land as /auth/index.html and /auth/admin.html -> we will serve /auth/admin mapping in backend
}
} }
} }
})) }))

View File

@ -16,7 +16,18 @@ from . import authsession, globals
from .db import Org, Permission, Role, User from .db import Org, Permission, Role, User
from .util import passphrase, tokens 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 # Shared log message template for admin reset links
ADMIN_RESET_MESSAGE = """\ ADMIN_RESET_MESSAGE = """\
@ -61,17 +72,18 @@ async def bootstrap_system(
org = Org(uuid7.create(), org_name or "Organization") org = Org(uuid7.create(), org_name or "Organization")
await globals.db.instance.create_organization(org) await globals.db.instance.create_organization(org)
perm1 = Permission( # After creation, org.permissions now includes the auto-created org admin permission
id=f"auth/org:{org.uuid}", display_name=f"{org.display_name} Admin" # Allow this org to grant global admin explicitly
)
await globals.db.instance.create_permission(perm1)
# Allow this org to grant admin permissions
await globals.db.instance.add_permission_to_organization(str(org.uuid), perm0.id) 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 # 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) await globals.db.instance.create_role(role)
user = User( user = User(
@ -92,7 +104,10 @@ async def bootstrap_system(
"user": user, "user": user,
"org": org, "org": org,
"role": role, "role": role,
"permissions": [perm0, perm1], "permissions": [
perm0,
*[Permission(id=p, display_name="") for p in org.permissions],
],
"reset_link": reset_link, "reset_link": reset_link,
} }

View File

@ -105,6 +105,14 @@ class DatabaseInterface(ABC):
async def create_role(self, role: Role) -> None: async def create_role(self, role: Role) -> None:
"""Create new role.""" """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 # Credential operations
@abstractmethod @abstractmethod
async def create_credential(self, credential: Credential) -> None: async def create_credential(self, credential: Credential) -> None:
@ -165,6 +173,10 @@ class DatabaseInterface(ABC):
async def get_organization(self, org_id: str) -> Org: async def get_organization(self, org_id: str) -> Org:
"""Get organization by ID, including its permission IDs and roles (with their permission IDs).""" """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 @abstractmethod
async def update_organization(self, org: Org) -> None: async def update_organization(self, org: Org) -> None:
"""Update organization options.""" """Update organization options."""
@ -214,6 +226,10 @@ class DatabaseInterface(ABC):
async def get_permission(self, permission_id: str) -> Permission: async def get_permission(self, permission_id: str) -> Permission:
"""Get permission by ID.""" """Get permission by ID."""
@abstractmethod
async def list_permissions(self) -> list[Permission]:
"""List all permissions."""
@abstractmethod @abstractmethod
async def update_permission(self, permission: Permission) -> None: async def update_permission(self, permission: Permission) -> None:
"""Update permission details.""" """Update permission details."""
@ -248,7 +264,9 @@ class DatabaseInterface(ABC):
"""Add a permission to a role.""" """Add a permission to a role."""
@abstractmethod @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.""" """Remove a permission from a role."""
@abstractmethod @abstractmethod
@ -259,6 +277,10 @@ class DatabaseInterface(ABC):
async def get_permission_roles(self, permission_id: str) -> list[Role]: async def get_permission_roles(self, permission_id: str) -> list[Role]:
"""List all roles that grant a permission.""" """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 # Combined operations
@abstractmethod @abstractmethod
async def login(self, user_uuid: UUID, credential: Credential) -> None: async def login(self, user_uuid: UUID, credential: Credential) -> None:

View File

@ -441,13 +441,42 @@ class DB(DatabaseInterface):
display_name=org.display_name, display_name=org.display_name,
) )
session.add(org_model) session.add(org_model)
# Persist org permissions the org is allowed to grant # Persist any explicitly provided org grantable permissions
if org.permissions: if org.permissions:
for perm_id in org.permissions: for perm_id in set(org.permissions):
session.add( session.add(
OrgPermission(org_uuid=org.uuid.bytes, permission_id=perm_id) OrgPermission(org_uuid=org.uuid.bytes, permission_id=perm_id)
) )
# Automatically create an organization admin permission if not present.
# Pattern: auth/org:<org-uuid>
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 def get_organization(self, org_id: str) -> Org:
async with self.session() as session: async with self.session() as session:
# Convert string ID to UUID bytes for lookup # Convert string ID to UUID bytes for lookup
@ -488,6 +517,48 @@ class DB(DatabaseInterface):
return org_dc 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 def update_organization(self, org: Org) -> None:
async with self.session() as session: async with self.session() as session:
stmt = ( stmt = (
@ -505,9 +576,7 @@ class DB(DatabaseInterface):
if org.permissions: if org.permissions:
for perm_id in org.permissions: for perm_id in org.permissions:
await session.merge( await session.merge(
OrgPermission( OrgPermission(org_uuid=org.uuid.bytes, permission_id=perm_id)
org_uuid=org.uuid.bytes, permission_id=perm_id
)
) )
async def delete_organization(self, org_uuid: UUID) -> None: async def delete_organization(self, org_uuid: UUID) -> None:
@ -686,6 +755,11 @@ class DB(DatabaseInterface):
stmt = delete(PermissionModel).where(PermissionModel.id == permission_id) stmt = delete(PermissionModel).where(PermissionModel.id == permission_id)
await session.execute(stmt) 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 def add_permission_to_role(self, role_uuid: UUID, permission_id: str) -> None:
async with self.session() as session: async with self.session() as session:
# Ensure role exists # Ensure role exists
@ -696,7 +770,9 @@ class DB(DatabaseInterface):
raise ValueError("Role not found") raise ValueError("Role not found")
# Ensure permission exists # 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) perm_result = await session.execute(perm_stmt)
if not perm_result.scalar_one_or_none(): if not perm_result.scalar_one_or_none():
raise ValueError("Permission not found") raise ValueError("Permission not found")
@ -705,7 +781,9 @@ class DB(DatabaseInterface):
RolePermission(role_uuid=role_uuid.bytes, permission_id=permission_id) 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: async with self.session() as session:
await session.execute( await session.execute(
delete(RolePermission) delete(RolePermission)
@ -717,7 +795,9 @@ class DB(DatabaseInterface):
async with self.session() as session: async with self.session() as session:
stmt = ( stmt = (
select(PermissionModel) select(PermissionModel)
.join(RolePermission, PermissionModel.id == RolePermission.permission_id) .join(
RolePermission, PermissionModel.id == RolePermission.permission_id
)
.where(RolePermission.role_uuid == role_uuid.bytes) .where(RolePermission.role_uuid == role_uuid.bytes)
) )
result = await session.execute(stmt) result = await session.execute(stmt)
@ -733,6 +813,57 @@ class DB(DatabaseInterface):
result = await session.execute(stmt) result = await session.execute(stmt)
return [r.as_dataclass() for r in result.scalars().all()] 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( async def add_permission_to_organization(
self, org_id: str, permission_id: str self, org_id: str, permission_id: str
) -> None: ) -> None:
@ -844,7 +975,9 @@ class DB(DatabaseInterface):
.join(RoleModel, UserModel.role_uuid == RoleModel.uuid) .join(RoleModel, UserModel.role_uuid == RoleModel.uuid)
.join(OrgModel, RoleModel.org_uuid == OrgModel.uuid) .join(OrgModel, RoleModel.org_uuid == OrgModel.uuid)
.outerjoin(RolePermission, RoleModel.uuid == RolePermission.role_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) .where(SessionModel.key == session_key)
) )

View File

@ -1,51 +1,248 @@
import argparse import argparse
import asyncio import asyncio
import atexit
import contextlib
import ipaddress
import logging import logging
import os
import signal
import subprocess
from pathlib import Path
from urllib.parse import urlparse
import uvicorn 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://<rp-id>)")
def main(): def main():
# Configure logging to remove the "ERROR:root:" prefix # Configure logging to remove the "ERROR:root:" prefix
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(
description="Run the passkey authentication server" prog="passkey-auth", description="Passkey authentication server"
) )
parser.add_argument( sub = parser.add_subparsers(dest="command", required=True)
"--host", default="localhost", help="Host to bind to (default: localhost)"
# serve subcommand
serve = sub.add_parser(
"serve", help="Run the server (production style, no auto-reload)"
) )
parser.add_argument( serve.add_argument(
"--port", type=int, default=4401, help="Port to bind to (default: 4401)" "hostport",
nargs="?",
help=(
"Endpoint (default: localhost:4401). Forms: host[:port] | :port | "
"[ipv6][:port] | ipv6 | unix:/path.sock"
),
) )
parser.add_argument( add_common_options(serve)
"--dev", action="store_true", help="Enable development mode with auto-reload"
# 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( add_common_options(dev)
"--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://<rp-id>)")
args = parser.parse_args() args = parser.parse_args()
# Initialize the application default_port = DEFAULT_DEV_PORT if args.command == "dev" else DEFAULT_SERVE_PORT
try: host, port, uds, all_ifaces = parse_endpoint(args.hostport, default_port)
from .. import globals reload_enabled = args.command == "dev"
# 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( asyncio.run(
globals.init(rp_id=args.rp_id, rp_name=args.rp_name, origin=args.origin) _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( run_kwargs: dict = {
"passkey.fastapi:app", "reload": reload_enabled,
host=args.host, "log_level": "info",
port=args.port, }
reload=args.dev, 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", 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__": if __name__ == "__main__":

View File

@ -8,9 +8,9 @@ This module contains all the HTTP API endpoints for:
- Login/logout functionality - 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 fastapi.security import HTTPBearer
from passkey.util import passphrase from passkey.util import passphrase
@ -27,6 +27,19 @@ bearer_auth = HTTPBearer(auto_error=True)
def register_api_routes(app: FastAPI): def register_api_routes(app: FastAPI):
"""Register all API routes on the FastAPI app.""" """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") @app.post("/auth/validate")
async def validate_token(response: Response, auth=Cookie(None)): async def validate_token(response: Response, auth=Cookie(None)):
"""Lightweight token validation endpoint.""" """Lightweight token validation endpoint."""
@ -38,29 +51,50 @@ def register_api_routes(app: FastAPI):
@app.post("/auth/user-info") @app.post("/auth/user-info")
async def api_user_info(response: Response, auth=Cookie(None)): async def api_user_info(response: Response, auth=Cookie(None)):
"""Get full user information for the authenticated user.""" """Get user information.
reset = passphrase.is_well_formed(auth)
- 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) s = await (get_reset if reset else get_session)(auth)
# Session context (org, role, permissions) except ValueError:
ctx = await db.instance.get_session_context(session_key(auth)) raise HTTPException(
# Fallback if context not available (e.g., reset session) 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))
u = await db.instance.get_user_by_uuid(s.user_uuid) 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) credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid)
credentials = [] credentials: list[dict] = []
user_aaguids = set() user_aaguids: set[str] = set()
for cred_id in credential_ids: for cred_id in credential_ids:
try:
c = await db.instance.get_credential_by_id(cred_id) c = await db.instance.get_credential_by_id(cred_id)
except ValueError:
# Convert AAGUID to string format continue # Skip dangling IDs
aaguid_str = str(c.aaguid) aaguid_str = str(c.aaguid)
user_aaguids.add(aaguid_str) user_aaguids.add(aaguid_str)
# Check if this is the current session credential
is_current_session = s.credential_uuid == c.uuid
credentials.append( credentials.append(
{ {
"credential_uuid": str(c.uuid), "credential_uuid": str(c.uuid),
@ -71,37 +105,31 @@ def register_api_routes(app: FastAPI):
if c.last_verified if c.last_verified
else None, else None,
"sign_count": c.sign_count, "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) 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 role_info = None
org_info = None org_info = None
effective_permissions = [] effective_permissions: list[str] = []
is_global_admin = False is_global_admin = False
is_org_admin = False is_org_admin = False
if ctx: if ctx:
role_info = { role_info = {
"uuid": str(ctx.role.uuid), "uuid": str(ctx.role.uuid),
"display_name": ctx.role.display_name, "display_name": ctx.role.display_name,
"permissions": ctx.role.permissions, # IDs "permissions": ctx.role.permissions,
} }
org_info = { org_info = {
"uuid": str(ctx.org.uuid), "uuid": str(ctx.org.uuid),
"display_name": ctx.org.display_name, "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 [])] effective_permissions = [p.id for p in (ctx.permissions or [])]
is_global_admin = "auth/admin" in role_info["permissions"] is_global_admin = "auth/admin" in role_info["permissions"]
# org admin permission is auth/org:<org_uuid>
is_org_admin = ( is_org_admin = (
f"auth/org:{org_info['uuid']}" in role_info["permissions"] f"auth/org:{org_info['uuid']}" in role_info["permissions"]
if org_info if org_info
@ -109,8 +137,8 @@ def register_api_routes(app: FastAPI):
) )
return { return {
"authenticated": not reset, "authenticated": True,
"session_type": s.info["type"], "session_type": s.info.get("type"),
"user": { "user": {
"user_uuid": str(u.uuid), "user_uuid": str(u.uuid),
"user_name": u.display_name, "user_name": u.display_name,
@ -127,6 +155,232 @@ def register_api_routes(app: FastAPI):
"aaguid_info": aaguid_info, "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") @app.post("/auth/logout")
async def api_logout(response: Response, auth=Cookie(None)): async def api_logout(response: Response, auth=Cookie(None)):
"""Log out the current user by clearing the session cookie and deleting from database.""" """Log out the current user by clearing the session cookie and deleting from database."""

View File

@ -1,5 +1,7 @@
import contextlib import contextlib
import logging import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import Cookie, FastAPI, Request, Response 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" 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 # Global exception handlers
@ -71,16 +107,24 @@ async def redirect_to_index():
@app.get("/auth/admin") @app.get("/auth/admin")
async def serve_admin(): async def serve_admin(auth=Cookie(None)):
"""Serve the admin app entry point.""" """Serve the admin app entry point if an authenticated session exists.
# Vite MPA builds admin as admin.html in the same outDir
admin_html = STATIC_DIR / "admin.html" If no valid authenticated session cookie is present, return a 401 with the
# If configured to emit admin/index.html, support that too main app's index.html so the frontend can initiate login/registration flow.
if not admin_html.exists(): """
alt = STATIC_DIR / "admin" / "index.html" if auth:
if alt.exists(): with contextlib.suppress(ValueError):
return FileResponse(alt) s = await get_session(auth)
return FileResponse(admin_html) 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 # Register API routes

View File

@ -5,6 +5,7 @@ from fastapi.responses import RedirectResponse
from ..authsession import expires, get_session from ..authsession import expires, get_session
from ..globals import db from ..globals import db
from ..globals import passkey as global_passkey
from ..util import passphrase, tokens from ..util import passphrase, tokens
from . import session from . import session
@ -43,10 +44,9 @@ def register_reset_routes(app):
reset_token: str, reset_token: str,
): ):
"""Verifies the token and redirects to auth app for credential registration.""" """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): if not passphrase.is_well_formed(reset_token):
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
origin = global_passkey.instance.origin
try: try:
# Get session token to validate it exists and get user_id # Get session token to validate it exists and get user_id
key = tokens.reset_key(reset_token) key = tokens.reset_key(reset_token)
@ -54,7 +54,7 @@ def register_reset_routes(app):
if not sess: if not sess:
raise ValueError("Invalid or expired registration token") 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) session.set_session_cookie(response, reset_token)
return response return response
@ -65,4 +65,4 @@ def register_reset_routes(app):
else: else:
logging.exception("Internal Server Error in reset_authentication") logging.exception("Internal Server Error in reset_authentication")
msg = "Internal Server Error" msg = "Internal Server Error"
return RedirectResponse(url=f"/auth/#{msg}", status_code=303) return RedirectResponse(url=f"{origin}/auth/#{msg}", status_code=303)

View File

@ -32,8 +32,15 @@ async def init(
origin: str | None = None, origin: str | None = None,
default_admin: str | None = None, default_admin: str | None = None,
default_org: str | None = None, default_org: str | None = None,
*,
bootstrap: bool = True,
) -> None: ) -> 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 # Initialize passkey instance with provided parameters
passkey.instance = Passkey( passkey.instance = Passkey(
rp_id=rp_id, rp_id=rp_id,
@ -49,6 +56,7 @@ async def init(
await sql.init() await sql.init()
if bootstrap:
# Bootstrap system if needed # Bootstrap system if needed
from .bootstrap import bootstrap_if_needed from .bootstrap import bootstrap_if_needed