Compare commits

...

7 Commits

Author SHA1 Message Date
Leo Vasanko
2b03fa74cd Only allow safe characters in permission IDs 2025-08-30 19:10:00 -06:00
Leo Vasanko
d045e1c520 Make default permissions use only : as separator. 2025-08-30 18:43:49 -06:00
Leo Vasanko
326a7664d3 Formatting 2025-08-30 18:43:27 -06:00
Leo Vasanko
c422f59b2e Extended demo Caddyfile 2025-08-30 18:41:28 -06:00
Leo Vasanko
4a0fbd8199 Implement Permission Denied handling. 2025-08-30 18:38:48 -06:00
Leo Vasanko
16de7b5f1f Allow specifying multiple permissions. 2025-08-30 16:47:38 -06:00
Leo Vasanko
cb17a332a3 Add permission check on forward-auth and validate. 2025-08-30 16:14:39 -06:00
13 changed files with 237 additions and 88 deletions

View File

@ -1,22 +1,35 @@
(auth) {
# Forward /auth/ to the authentication service
@auth path /auth/*
handle @auth {
reverse_proxy localhost:4401
}
handle {
# Check for authentication
# Permission check (named arg: perm=...)
forward_auth localhost:4401 {
uri /auth/forward-auth
copy_headers x-auth*
}
{block}
uri /auth/forward-auth?{args.0}
copy_headers x-auth-*
}
}
localhost {
import auth {
# Proxy authenticated requests to the main application
reverse_proxy localhost:3000
# Single definition for auth service endpoints (avoid duplicate matcher names)
@auth_api path /auth/*
handle @auth_api {
reverse_proxy localhost:4401
}
# Admin-protected paths
handle_path /admin/* {
import auth perm=auth:admin
# Respond with a message for the admin area
respond "Admin area (protected)" 200
}
# Reports-protected paths
handle_path /reports/* {
import auth perm=reports:view
# Respond with a message for the reports area
respond "Reports area (protected)" 200
}
# Unprotected (fallback)
handle {
# Respond with a public content message
respond "Public content" 200
}
}

View File

@ -5,6 +5,7 @@
<ProfileView v-if="store.currentView === 'profile'" />
<DeviceLinkView v-if="store.currentView === 'device-link'" />
<ResetView v-if="store.currentView === 'reset'" />
<PermissionDeniedView v-if="store.currentView === 'permission-denied'" />
</div>
</template>
@ -16,10 +17,15 @@ import LoginView from '@/components/LoginView.vue'
import ProfileView from '@/components/ProfileView.vue'
import DeviceLinkView from '@/components/DeviceLinkView.vue'
import ResetView from '@/components/ResetView.vue'
import PermissionDeniedView from '@/components/PermissionDeniedView.vue'
const store = useAuthStore()
onMounted(async () => {
// Detect restricted mode: any path not starting with /auth/
if (!location.pathname.startsWith('/auth/')) {
store.setRestrictedMode(true)
}
// Was an error message passed in the URL hash?
const message = location.hash.substring(1)
if (message) {

View File

@ -17,6 +17,16 @@ const userLink = ref(null) // latest generated registration link
const userLinkExpires = ref(null)
const authStore = useAuthStore()
const addingOrgForPermission = ref(null)
const PERMISSION_ID_PATTERN = '^[A-Za-z0-9:._~-]+$'
const showCreatePermission = ref(false)
const newPermId = ref('')
const newPermName = ref('')
const editingPermId = ref(null)
const renameIdValue = ref('')
const safeIdRegex = /[^A-Za-z0-9:._~-]/g
function sanitizeNewId() { if (newPermId.value) newPermId.value = newPermId.value.replace(safeIdRegex, '') }
function sanitizeRenameId() { if (renameIdValue.value) renameIdValue.value = renameIdValue.value.replace(safeIdRegex, '') }
function handleGlobalClick(e) {
if (!addingOrgForPermission.value) return
@ -88,22 +98,22 @@ async function renamePermissionDisplay(p) {
}
}
async function renamePermissionId(p) {
const newId = prompt('New permission id', p.id)
if (!newId || newId === p.id) return
function startRenamePermissionId(p) {
editingPermId.value = p.id
renameIdValue.value = p.id
}
function cancelRenameId() { editingPermId.value = null; renameIdValue.value = '' }
async function submitRenamePermissionId(p) {
const newId = renameIdValue.value.trim()
if (!newId || newId === p.id) { cancelRenameId(); return }
try {
const body = { old_id: p.id, new_id: newId, display_name: p.display_name }
const res = await fetch('/auth/admin/permission/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
let data
try { data = await res.json() } catch(_) { data = {} }
const res = await fetch('/auth/admin/permission/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
let data; try { data = await res.json() } catch(_) { data = {} }
if (!res.ok || data.detail) throw new Error(data.detail || data.error || `Failed (${res.status})`)
await refreshPermissionsContext()
await refreshPermissionsContext(); cancelRenameId()
} catch (e) {
alert((e && e.message) ? e.message : 'Failed to rename permission id')
alert(e?.message || 'Failed to rename permission id')
}
}
@ -336,20 +346,15 @@ async function deleteRole(role) {
}
// 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 submitCreatePermission() {
const id = newPermId.value.trim()
const name = newPermName.value.trim()
if (!id || !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) { alert(data.detail); return }
await loadPermissions(); newPermId.value=''; newPermName.value=''; showCreatePermission.value=false
}
function cancelCreatePermission() { newPermId.value=''; newPermName.value=''; showCreatePermission.value=false }
async function updatePermission(p) {
const name = prompt('Permission display name:', p.display_name)
@ -623,7 +628,13 @@ async function toggleRolePermission(role, permId, checked) {
<div v-if="!selectedUser && !selectedOrg && (info.is_global_admin || info.is_org_admin)" class="card">
<h2>All Permissions</h2>
<div class="actions">
<button @click="createPermission">+ Create Permission</button>
<button v-if="!showCreatePermission" @click="showCreatePermission = true">+ Create Permission</button>
<form v-else class="inline-form" @submit.prevent="submitCreatePermission">
<input v-model="newPermId" @input="sanitizeNewId" required :pattern="PERMISSION_ID_PATTERN" placeholder="permission id" title="Allowed: A-Za-z0-9:._~-" />
<input v-model="newPermName" required placeholder="display name" />
<button type="submit">Save</button>
<button type="button" @click="cancelCreatePermission">Cancel</button>
</form>
</div>
<div class="permission-grid">
<div class="perm-grid-head">Permission</div>
@ -672,9 +683,16 @@ async function toggleRolePermission(role, permId, checked) {
</div>
<div class="perm-cell perm-users center">{{ permissionSummary[p.id]?.userCount || 0 }}</div>
<div class="perm-cell perm-actions center">
<template v-if="editingPermId !== p.id">
<button @click="renamePermissionDisplay(p)" class="icon-btn" aria-label="Change display name" title="Change display name"></button>
<button @click="renamePermissionId(p)" class="icon-btn" aria-label="Change id" title="Change id">🆔</button>
<button @click="startRenamePermissionId(p)" class="icon-btn" aria-label="Change id" title="Change id">🆔</button>
<button @click="deletePermission(p)" class="icon-btn delete-icon" aria-label="Delete permission" title="Delete permission"></button>
</template>
<form v-else class="inline-id-form" @submit.prevent="submitRenamePermissionId(p)">
<input v-model="renameIdValue" @input="sanitizeRenameId" required :pattern="PERMISSION_ID_PATTERN" class="id-input" title="Allowed: A-Za-z0-9:._~-" />
<button type="submit" class="icon-btn" aria-label="Save"></button>
<button type="button" class="icon-btn" @click="cancelRenameId" aria-label="Cancel"></button>
</form>
</div>
</template>
</div>

View File

@ -26,7 +26,10 @@ const handleLogin = async () => {
authStore.showMessage('Starting authentication...', 'info')
await authStore.authenticate()
authStore.showMessage('Authentication successful!', 'success', 2000)
if (location.pathname.startsWith('/auth/')) {
if (authStore.restrictedMode) {
// In restricted mode after successful auth show permission denied (no profile outside /auth/)
authStore.currentView = 'permission-denied'
} else if (location.pathname.startsWith('/auth/')) {
authStore.currentView = 'profile'
} else {
location.reload()

View File

@ -0,0 +1,44 @@
<template>
<div class="container">
<div class="view active">
<h1>🚫 Forbidden</h1>
<div v-if="authStore.userInfo?.authenticated" class="user-header">
<span class="user-emoji" aria-hidden="true">{{ userEmoji }}</span>
<span class="user-name">{{ displayName }}</span>
</div>
<p>You lack the permissions required for this page.</p>
<div class="actions">
<button class="btn-secondary" @click="back">Back</button>
<button class="btn-primary" @click="goAuth">Account</button>
<button class="btn-danger" @click="logout">Logout</button>
</div>
</div>
</div>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const userEmoji = '👤' // Placeholder / could be extended later if backend provides one
const displayName = authStore.userInfo?.user?.user_name || 'User'
function goAuth() {
location.href = '/auth/'
}
function back() {
if (history.length > 1) history.back()
else authStore.currentView = 'login'
}
async function logout() {
await authStore.logout()
authStore.currentView = 'login'
}
</script>
<style scoped>
.user-header { display:flex; align-items:center; gap:.5rem; font-size:1.1rem; margin-bottom:.75rem; }
.user-emoji { font-size:1.5rem; line-height:1; }
.user-name { font-weight:600; }
.actions { margin-top:1.5rem; display:flex; gap:.5rem; flex-wrap:nowrap; }
.hint { font-size:.9rem; opacity:.85; }
</style>

View File

@ -6,10 +6,11 @@ export const useAuthStore = defineStore('auth', {
// Auth State
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated}
isLoading: false,
resetToken: null, // transient reset token (never stored in cookie)
resetToken: null, // transient reset token
restrictedMode: false, // If true, app loaded outside /auth/ and should restrict to login or permission denied
// UI State
currentView: 'login', // 'login', 'profile', 'device-link', 'reset'
currentView: 'login',
status: {
message: '',
type: 'info',
@ -68,10 +69,20 @@ export const useAuthStore = defineStore('auth', {
}
},
selectView() {
if (this.restrictedMode) {
// In restricted mode only allow login or show permission denied if already authenticated
if (!this.userInfo) this.currentView = 'login'
else if (this.userInfo.authenticated) this.currentView = 'permission-denied'
else this.currentView = 'login' // do not expose reset/registration flows outside /auth/
return
}
if (!this.userInfo) this.currentView = 'login'
else if (this.userInfo.authenticated) this.currentView = 'profile'
else this.currentView = 'reset'
},
setRestrictedMode(flag) {
this.restrictedMode = !!flag
},
async loadUserInfo() {
const headers = {}
// Reset tokens are only passed via query param now, not Authorization header

View File

@ -66,7 +66,7 @@ async def bootstrap_system(
dict: Contains information about created entities and reset link
"""
# Create permission first - will fail if already exists
perm0 = Permission(id="auth/admin", display_name="Master Admin")
perm0 = Permission(id="auth:admin", display_name="Master Admin")
await globals.db.instance.create_permission(perm0)
org = Org(uuid7.create(), org_name or "Organization")
@ -122,7 +122,7 @@ async def check_admin_credentials() -> bool:
try:
# Get permission organizations to find admin users
permission_orgs = await globals.db.instance.get_permission_organizations(
"auth/admin"
"auth:admin"
)
if not permission_orgs:
@ -173,7 +173,7 @@ async def bootstrap_if_needed(
"""
try:
# Check if the admin permission exists - if it does, system is already bootstrapped
await globals.db.instance.get_permission("auth/admin")
await globals.db.instance.get_permission("auth:admin")
# Permission exists, system is already bootstrapped
# Check if admin needs credentials (only for already-bootstrapped systems)
await check_admin_credentials()

View File

@ -462,8 +462,7 @@ class DB(DatabaseInterface):
)
# Automatically create an organization admin permission if not present.
# Pattern: auth/org:<org-uuid>
auto_perm_id = f"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)

View File

@ -10,7 +10,7 @@ This module contains all the HTTP API endpoints for:
from uuid import UUID, uuid4
from fastapi import Body, Cookie, Depends, FastAPI, HTTPException, Response
from fastapi import Body, Cookie, Depends, FastAPI, HTTPException, Query, Response
from fastapi.security import HTTPBearer
from passkey.util import passphrase
@ -19,9 +19,9 @@ from .. import aaguid
from ..authsession import delete_credential, expires, get_reset, get_session
from ..globals import db
from ..globals import passkey as global_passkey
from ..util import tokens
from ..util import querysafe, tokens
from ..util.tokens import session_key
from . import session
from . import authz, session
bearer_auth = HTTPBearer(auto_error=True)
@ -38,18 +38,20 @@ def register_api_routes(app: FastAPI):
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
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."""
s = await get_session(auth)
return {
"valid": True,
"user_uuid": str(s.user_uuid),
}
async def validate_token(perm=Query(None), auth=Cookie(None)):
"""Lightweight token validation endpoint.
Query Params:
- perm: repeated permission IDs the caller must possess (ALL required)
"""
s = await authz.verify(auth, perm)
return {"valid": True, "user_uuid": str(s.user_uuid)}
@app.post("/auth/user-info")
async def api_user_info(reset: str | None = None, auth=Cookie(None)):
@ -133,9 +135,9 @@ def register_api_routes(app: FastAPI):
"permissions": ctx.org.permissions,
}
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"]
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
else False
)
@ -258,6 +260,7 @@ def register_api_routes(app: FastAPI):
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")
querysafe.assert_safe(permission_id, field="permission_id")
await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
return {"status": "ok"}
@ -268,6 +271,7 @@ def register_api_routes(app: FastAPI):
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")
querysafe.assert_safe(permission_id, field="permission_id")
await db.instance.remove_permission_from_organization(
str(org_uuid), permission_id
)
@ -504,6 +508,7 @@ def register_api_routes(app: FastAPI):
display_name = payload.get("display_name")
if not perm_id or not display_name:
raise ValueError("id and display_name are required")
querysafe.assert_safe(perm_id, field="id")
await db.instance.create_permission(
PermDC(id=perm_id, display_name=display_name)
)
@ -520,6 +525,7 @@ def register_api_routes(app: FastAPI):
if not display_name:
raise ValueError("display_name is required")
querysafe.assert_safe(permission_id, field="permission_id")
await db.instance.update_permission(
PermDC(id=permission_id, display_name=display_name)
)
@ -539,6 +545,8 @@ def register_api_routes(app: FastAPI):
display_name = payload.get("display_name")
if not old_id or not new_id:
raise ValueError("old_id and new_id required")
querysafe.assert_safe(old_id, field="old_id")
querysafe.assert_safe(new_id, field="new_id")
if display_name is None:
# Fetch old to retain display name
perm = await db.instance.get_permission(old_id)
@ -555,6 +563,7 @@ def register_api_routes(app: FastAPI):
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
if not is_global_admin:
raise ValueError("Global admin required")
querysafe.assert_safe(permission_id, field="permission_id")
await db.instance.delete_permission(permission_id)
return {"status": "ok"}

39
passkey/fastapi/authz.py Normal file
View File

@ -0,0 +1,39 @@
"""Authorization utilities shared across FastAPI endpoints.
Provides helper(s) to validate a session token (from cookie) and optionally
enforce that the user possesses a given permission (either via their role or
their organization level permissions).
"""
from fastapi import HTTPException
from ..authsession import get_session
from ..globals import db
from ..util.tokens import session_key
async def verify(auth: str | None, perm: list[str] | str | None):
"""Validate session token and optional list of required permissions.
Returns the Session object on success. Raises HTTPException on failure.
401: unauthenticated / invalid session
403: one or more required permissions missing
"""
if not auth:
raise HTTPException(status_code=401, detail="Authentication required")
session = await get_session(auth)
if perm is not None:
if isinstance(perm, str):
perm = [perm]
ctx = await db.instance.get_session_context(session_key(auth))
if not ctx:
raise HTTPException(status_code=401, detail="Session not found")
available = set(ctx.role.permissions or []) | (
set(ctx.org.permissions or []) if ctx.org else set()
)
if any(p not in available for p in perm):
raise HTTPException(status_code=403, detail="Permission required")
return session
__all__ = ["verify"]

View File

@ -4,12 +4,12 @@ import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Cookie, FastAPI, Request, Response
from fastapi import Cookie, FastAPI, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from ..authsession import get_session
from . import ws
from . import authz, ws
from .api import register_api_routes
from .reset import register_reset_routes
@ -72,26 +72,24 @@ app.mount("/auth/ws", ws.app)
@app.get("/auth/forward-auth")
async def forward_authentication(request: Request, auth=Cookie(None)):
"""A validation endpoint to use with Caddy forward_auth or Nginx auth_request."""
if auth:
with contextlib.suppress(ValueError):
s = await get_session(auth)
# If authenticated, return a success response
if s.info and s.info["type"] == "authenticated":
async def forward_authentication(request: Request, perm=Query(None), auth=Cookie(None)):
"""A validation endpoint to use with Caddy forward_auth or Nginx auth_request.
Query Params:
- perm: repeated permission IDs the authenticated user must possess (ALL required).
Success: 204 No Content with x-auth-user-uuid header.
Failure (unauthenticated / unauthorized): 4xx with index.html body so the
client (reverse proxy or browser) can initiate auth flow.
"""
try:
s = await authz.verify(auth, perm)
return Response(
status_code=204,
headers={
"x-auth-user-uuid": str(s.user_uuid),
},
)
# Serve the index.html of the authentication app if not authenticated
return FileResponse(
STATIC_DIR / "index.html",
status_code=401,
headers={"www-authenticate": "PrivateToken"},
headers={"x-auth-user-uuid": str(s.user_uuid)},
)
except HTTPException as e:
return FileResponse(STATIC_DIR / "index.html", e.status_code)
# Serve static files

View File

@ -30,5 +30,4 @@ def set_session_cookie(response: Response, token: str) -> None:
max_age=int(EXPIRES.total_seconds()),
httponly=True,
secure=True,
path="/auth/",
)

10
passkey/util/querysafe.py Normal file
View File

@ -0,0 +1,10 @@
import re
_SAFE_RE = re.compile(r"^[A-Za-z0-9:._~-]+$")
def assert_safe(value: str, *, field: str = "value") -> None:
if not isinstance(value, str) or not value or not _SAFE_RE.match(value):
raise ValueError(f"{field} must match ^[A-Za-z0-9:._~-]+$")
__all__ = ["assert_safe"]