Compare commits

..

No commits in common. "2b03fa74cd113ff8754052e1e025135081d0b287" and "3e5c0065d5f6b357ce550e877094b67d09c56453" have entirely different histories.

13 changed files with 88 additions and 237 deletions

View File

@ -1,35 +1,22 @@
(auth) { (auth) {
# Permission check (named arg: perm=...) # Forward /auth/ to the authentication service
forward_auth localhost:4401 { @auth path /auth/*
uri /auth/forward-auth?{args.0} handle @auth {
copy_headers x-auth-* reverse_proxy localhost:4401
}
handle {
# Check for authentication
forward_auth localhost:4401 {
uri /auth/forward-auth
copy_headers x-auth*
}
{block}
} }
} }
localhost { localhost {
# Single definition for auth service endpoints (avoid duplicate matcher names) import auth {
@auth_api path /auth/* # Proxy authenticated requests to the main application
handle @auth_api { reverse_proxy localhost:3000
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,7 +5,6 @@
<ProfileView v-if="store.currentView === 'profile'" /> <ProfileView v-if="store.currentView === 'profile'" />
<DeviceLinkView v-if="store.currentView === 'device-link'" /> <DeviceLinkView v-if="store.currentView === 'device-link'" />
<ResetView v-if="store.currentView === 'reset'" /> <ResetView v-if="store.currentView === 'reset'" />
<PermissionDeniedView v-if="store.currentView === 'permission-denied'" />
</div> </div>
</template> </template>
@ -17,15 +16,10 @@ import LoginView from '@/components/LoginView.vue'
import ProfileView from '@/components/ProfileView.vue' import ProfileView from '@/components/ProfileView.vue'
import DeviceLinkView from '@/components/DeviceLinkView.vue' import DeviceLinkView from '@/components/DeviceLinkView.vue'
import ResetView from '@/components/ResetView.vue' import ResetView from '@/components/ResetView.vue'
import PermissionDeniedView from '@/components/PermissionDeniedView.vue'
const store = useAuthStore() const store = useAuthStore()
onMounted(async () => { 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? // Was an error message passed in the URL hash?
const message = location.hash.substring(1) const message = location.hash.substring(1)
if (message) { if (message) {

View File

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

View File

@ -26,10 +26,7 @@ const handleLogin = async () => {
authStore.showMessage('Starting authentication...', 'info') authStore.showMessage('Starting authentication...', 'info')
await authStore.authenticate() await authStore.authenticate()
authStore.showMessage('Authentication successful!', 'success', 2000) authStore.showMessage('Authentication successful!', 'success', 2000)
if (authStore.restrictedMode) { if (location.pathname.startsWith('/auth/')) {
// 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' authStore.currentView = 'profile'
} else { } else {
location.reload() location.reload()

View File

@ -1,44 +0,0 @@
<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,11 +6,10 @@ export const useAuthStore = defineStore('auth', {
// Auth State // Auth State
userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated} userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated}
isLoading: false, isLoading: false,
resetToken: null, // transient reset token resetToken: null, // transient reset token (never stored in cookie)
restrictedMode: false, // If true, app loaded outside /auth/ and should restrict to login or permission denied
// UI State // UI State
currentView: 'login', currentView: 'login', // 'login', 'profile', 'device-link', 'reset'
status: { status: {
message: '', message: '',
type: 'info', type: 'info',
@ -69,20 +68,10 @@ export const useAuthStore = defineStore('auth', {
} }
}, },
selectView() { 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' if (!this.userInfo) this.currentView = 'login'
else if (this.userInfo.authenticated) this.currentView = 'profile' else if (this.userInfo.authenticated) this.currentView = 'profile'
else this.currentView = 'reset' else this.currentView = 'reset'
}, },
setRestrictedMode(flag) {
this.restrictedMode = !!flag
},
async loadUserInfo() { async loadUserInfo() {
const headers = {} const headers = {}
// Reset tokens are only passed via query param now, not Authorization header // 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 dict: Contains information about created entities and reset link
""" """
# Create permission first - will fail if already exists # 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) await globals.db.instance.create_permission(perm0)
org = Org(uuid7.create(), org_name or "Organization") org = Org(uuid7.create(), org_name or "Organization")
@ -122,7 +122,7 @@ async def check_admin_credentials() -> bool:
try: try:
# Get permission organizations to find admin users # Get permission organizations to find admin users
permission_orgs = await globals.db.instance.get_permission_organizations( permission_orgs = await globals.db.instance.get_permission_organizations(
"auth:admin" "auth/admin"
) )
if not permission_orgs: if not permission_orgs:
@ -173,7 +173,7 @@ async def bootstrap_if_needed(
""" """
try: try:
# Check if the admin permission exists - if it does, system is already bootstrapped # 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 # Permission exists, system is already bootstrapped
# Check if admin needs credentials (only for already-bootstrapped systems) # Check if admin needs credentials (only for already-bootstrapped systems)
await check_admin_credentials() await check_admin_credentials()

View File

@ -462,7 +462,8 @@ class DB(DatabaseInterface):
) )
# Automatically create an organization admin permission if not present. # Automatically create an organization admin permission if not present.
auto_perm_id = f"auth:org:{org.uuid}" # 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) # Only create if it does not already exist (in case caller passed it)
existing_perm = await session.execute( existing_perm = await session.execute(
select(PermissionModel).where(PermissionModel.id == auto_perm_id) 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 uuid import UUID, uuid4
from fastapi import Body, Cookie, Depends, FastAPI, HTTPException, Query, 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
@ -19,9 +19,9 @@ from .. import aaguid
from ..authsession import delete_credential, expires, get_reset, get_session from ..authsession import delete_credential, expires, get_reset, get_session
from ..globals import db from ..globals import db
from ..globals import passkey as global_passkey from ..globals import passkey as global_passkey
from ..util import querysafe, tokens from ..util import tokens
from ..util.tokens import session_key from ..util.tokens import session_key
from . import authz, session from . import session
bearer_auth = HTTPBearer(auto_error=True) bearer_auth = HTTPBearer(auto_error=True)
@ -38,20 +38,18 @@ def register_api_routes(app: FastAPI):
raise ValueError("Not authenticated") raise ValueError("Not authenticated")
role_perm_ids = set(ctx.role.permissions or []) role_perm_ids = set(ctx.role.permissions or [])
org_uuid_str = str(ctx.org.uuid) org_uuid_str = str(ctx.org.uuid)
is_global_admin = "auth:admin" 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 is_org_admin = f"auth/org:{org_uuid_str}" in role_perm_ids
return ctx, is_global_admin, is_org_admin return ctx, is_global_admin, is_org_admin
@app.post("/auth/validate") @app.post("/auth/validate")
async def validate_token(perm=Query(None), auth=Cookie(None)): async def validate_token(response: Response, auth=Cookie(None)):
"""Lightweight token validation endpoint. """Lightweight token validation endpoint."""
s = await get_session(auth)
Query Params: return {
- perm: repeated permission IDs the caller must possess (ALL required) "valid": True,
""" "user_uuid": str(s.user_uuid),
}
s = await authz.verify(auth, perm)
return {"valid": True, "user_uuid": str(s.user_uuid)}
@app.post("/auth/user-info") @app.post("/auth/user-info")
async def api_user_info(reset: str | None = None, auth=Cookie(None)): async def api_user_info(reset: str | None = None, auth=Cookie(None)):
@ -135,9 +133,9 @@ def register_api_routes(app: FastAPI):
"permissions": ctx.org.permissions, "permissions": ctx.org.permissions,
} }
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"]
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
else False else False
) )
@ -260,7 +258,6 @@ def register_api_routes(app: FastAPI):
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth) 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)): if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
raise ValueError("Insufficient permissions") raise ValueError("Insufficient permissions")
querysafe.assert_safe(permission_id, field="permission_id")
await db.instance.add_permission_to_organization(str(org_uuid), permission_id) await db.instance.add_permission_to_organization(str(org_uuid), permission_id)
return {"status": "ok"} return {"status": "ok"}
@ -271,7 +268,6 @@ def register_api_routes(app: FastAPI):
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth) 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)): if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
raise ValueError("Insufficient permissions") raise ValueError("Insufficient permissions")
querysafe.assert_safe(permission_id, field="permission_id")
await db.instance.remove_permission_from_organization( await db.instance.remove_permission_from_organization(
str(org_uuid), permission_id str(org_uuid), permission_id
) )
@ -508,7 +504,6 @@ def register_api_routes(app: FastAPI):
display_name = payload.get("display_name") display_name = payload.get("display_name")
if not perm_id or not display_name: if not perm_id or not display_name:
raise ValueError("id and display_name are required") raise ValueError("id and display_name are required")
querysafe.assert_safe(perm_id, field="id")
await db.instance.create_permission( await db.instance.create_permission(
PermDC(id=perm_id, display_name=display_name) PermDC(id=perm_id, display_name=display_name)
) )
@ -525,7 +520,6 @@ def register_api_routes(app: FastAPI):
if not display_name: if not display_name:
raise ValueError("display_name is required") raise ValueError("display_name is required")
querysafe.assert_safe(permission_id, field="permission_id")
await db.instance.update_permission( await db.instance.update_permission(
PermDC(id=permission_id, display_name=display_name) PermDC(id=permission_id, display_name=display_name)
) )
@ -545,8 +539,6 @@ def register_api_routes(app: FastAPI):
display_name = payload.get("display_name") display_name = payload.get("display_name")
if not old_id or not new_id: if not old_id or not new_id:
raise ValueError("old_id and new_id required") 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: if display_name is None:
# Fetch old to retain display name # Fetch old to retain display name
perm = await db.instance.get_permission(old_id) perm = await db.instance.get_permission(old_id)
@ -563,7 +555,6 @@ def register_api_routes(app: FastAPI):
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth) _, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
if not is_global_admin: if not is_global_admin:
raise ValueError("Global admin required") raise ValueError("Global admin required")
querysafe.assert_safe(permission_id, field="permission_id")
await db.instance.delete_permission(permission_id) await db.instance.delete_permission(permission_id)
return {"status": "ok"} return {"status": "ok"}

View File

@ -1,39 +0,0 @@
"""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 contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import Cookie, FastAPI, HTTPException, Query, Request, Response from fastapi import Cookie, FastAPI, Request, Response
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from ..authsession import get_session from ..authsession import get_session
from . import authz, ws from . import ws
from .api import register_api_routes from .api import register_api_routes
from .reset import register_reset_routes from .reset import register_reset_routes
@ -72,24 +72,26 @@ app.mount("/auth/ws", ws.app)
@app.get("/auth/forward-auth") @app.get("/auth/forward-auth")
async def forward_authentication(request: Request, perm=Query(None), auth=Cookie(None)): async def forward_authentication(request: Request, auth=Cookie(None)):
"""A validation endpoint to use with Caddy forward_auth or Nginx auth_request. """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":
return Response(
status_code=204,
headers={
"x-auth-user-uuid": str(s.user_uuid),
},
)
Query Params: # Serve the index.html of the authentication app if not authenticated
- perm: repeated permission IDs the authenticated user must possess (ALL required). return FileResponse(
STATIC_DIR / "index.html",
Success: 204 No Content with x-auth-user-uuid header. status_code=401,
Failure (unauthenticated / unauthorized): 4xx with index.html body so the headers={"www-authenticate": "PrivateToken"},
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)},
)
except HTTPException as e:
return FileResponse(STATIC_DIR / "index.html", e.status_code)
# Serve static files # Serve static files

View File

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

View File

@ -1,10 +0,0 @@
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"]