Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6257071efe | ||
|
|
7958b6f365 | ||
|
|
b3cb540098 | ||
|
|
22ba7231b1 | ||
|
|
9a9979fb62 | ||
|
|
9b7855c0af | ||
|
|
dfc4c76d43 | ||
|
|
e1f0fdf664 | ||
|
|
f26ac8f33b | ||
|
|
880ced3b8c | ||
|
|
af80b5eefc | ||
|
|
fa1e69d58b | ||
|
|
39000ef831 | ||
|
|
49119fac81 |
@@ -216,7 +216,7 @@ test.describe('API Mode - 401 Login Flow', () => {
|
||||
await clearSessionCookie(page)
|
||||
|
||||
// Make API call that triggers 401 (don't await - it blocks until iframe resolves)
|
||||
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'POST').catch(e => e)
|
||||
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'GET').catch(e => e)
|
||||
console.log('✓ Auth iframe appeared on 401')
|
||||
|
||||
// Verify it's in login mode (not reauth)
|
||||
@@ -268,7 +268,7 @@ test.describe('API Mode - 401 Login Flow', () => {
|
||||
await setupTestHarness(page)
|
||||
|
||||
// Make API call that triggers 401
|
||||
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'POST')
|
||||
const apiCallPromise = makeApiCall(page, '/auth/api/user-info', 'GET')
|
||||
|
||||
// Wait for auth iframe to appear
|
||||
await waitForAuthIframe(page)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn } from 'child_process'
|
||||
import { execSync, spawn } from 'child_process'
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
@@ -31,6 +31,11 @@ export default async function globalSetup() {
|
||||
mkdirSync(testDataDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Build the package first
|
||||
console.log(' Building package with uv build...')
|
||||
execSync('uv build', { cwd: projectRoot, stdio: 'inherit' })
|
||||
console.log(' ✅ Build complete\n')
|
||||
|
||||
console.log(' Starting server with in-memory database...')
|
||||
if (COLLECT_COVERAGE) {
|
||||
console.log(' 📊 Coverage collection enabled for Python backend')
|
||||
|
||||
+20
-71
@@ -13,8 +13,8 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiJson, SessionValidator, createAuthIframe, removeAuthIframe } from 'paskia'
|
||||
import { getAuthIframeUrl } from '@/utils/api'
|
||||
import { apiJson, SessionValidator } from 'paskia'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
import StatusMessage from '@/components/StatusMessage.vue'
|
||||
import ProfileView from '@/components/ProfileView.vue'
|
||||
import HostProfileView from '@/components/HostProfileView.vue'
|
||||
@@ -48,90 +48,49 @@ const isHostMode = computed(() => {
|
||||
return currentHost !== configuredHost
|
||||
})
|
||||
|
||||
function terminateSession() {
|
||||
function onSessionLost(e) {
|
||||
store.userInfo = null
|
||||
store.ctx = null
|
||||
if (e?.name === 'AuthCancelledError') {
|
||||
viewState.value = 'terminal'
|
||||
} else {
|
||||
store.showMessage(e?.message || 'Session lost', 'error', 5000)
|
||||
viewState.value = 'terminal'
|
||||
}
|
||||
}
|
||||
|
||||
const userUuidGetter = () => store.ctx?.user.uuid
|
||||
const sessionValidator = new SessionValidator(userUuidGetter, terminateSession)
|
||||
const sessionValidator = new SessionValidator(userUuidGetter, onSessionLost)
|
||||
|
||||
onMounted(() => sessionValidator.start())
|
||||
onUnmounted(() => sessionValidator.stop())
|
||||
|
||||
async function loadUserInfo() {
|
||||
viewState.value = 'loading'
|
||||
loadingMessage.value = 'Loading...'
|
||||
try {
|
||||
// apiJson handles 401/403 with auth.iframe automatically:
|
||||
// shows overlay iframe, waits for auth, retries the request.
|
||||
const [validateData, userInfoData] = await Promise.all([
|
||||
apiJson('/auth/api/validate', { method: 'POST' }),
|
||||
apiJson('/auth/api/user-info', { method: 'GET' })
|
||||
])
|
||||
store.userInfo = userInfoData
|
||||
store.ctx = validateData.ctx
|
||||
updateThemeFromSession(store.userInfo)
|
||||
// Verify that the user UUIDs match between user-info and validate responses
|
||||
if (store.userInfo.user.uuid !== store.ctx.user.uuid) {
|
||||
console.error('User UUID mismatch between user-info and validate responses')
|
||||
window.location.reload()
|
||||
return false
|
||||
return
|
||||
}
|
||||
viewState.value = 'profile'
|
||||
return true
|
||||
} catch {
|
||||
store.userInfo = null
|
||||
store.ctx = null
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function showAuthIframe() {
|
||||
const url = await getAuthIframeUrl('login')
|
||||
createAuthIframe(url)
|
||||
loadingMessage.value = 'Authentication required...'
|
||||
}
|
||||
|
||||
function handleAuthMessage(event) {
|
||||
const data = event.data
|
||||
if (!data?.type) return
|
||||
|
||||
switch (data.type) {
|
||||
case 'auth-success':
|
||||
// Authentication successful - reload user info
|
||||
removeAuthIframe()
|
||||
viewState.value = 'loading'
|
||||
loadingMessage.value = 'Loading user profile...'
|
||||
loadUserInfo()
|
||||
break
|
||||
|
||||
case 'auth-error':
|
||||
// Authentication failed - keep iframe open so user can retry
|
||||
if (data.cancelled) {
|
||||
console.log('Authentication cancelled by user')
|
||||
} else {
|
||||
store.showMessage(data.message || 'Authentication failed', 'error', 5000)
|
||||
}
|
||||
break
|
||||
|
||||
case 'auth-cancelled':
|
||||
// Legacy support - treat as auth-error with cancelled flag
|
||||
console.log('Authentication cancelled')
|
||||
break
|
||||
|
||||
case 'auth-back':
|
||||
// User clicked Back - show terminal state
|
||||
removeAuthIframe()
|
||||
terminateSession()
|
||||
break
|
||||
|
||||
case 'auth-close-request':
|
||||
// Legacy support - treat as back
|
||||
removeAuthIframe()
|
||||
break
|
||||
} catch (e) {
|
||||
onSessionLost(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Listen for postMessage from auth iframe
|
||||
window.addEventListener('message', handleAuthMessage)
|
||||
|
||||
// Load settings
|
||||
await store.loadSettings()
|
||||
|
||||
@@ -145,17 +104,7 @@ onMounted(async () => {
|
||||
document.title = inHostMode ? `${rpName} · Account summary` : rpName
|
||||
}
|
||||
|
||||
// Try to load user info
|
||||
const success = await loadUserInfo()
|
||||
|
||||
if (!success) {
|
||||
// Need authentication - show login iframe
|
||||
showAuthIframe()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('message', handleAuthMessage)
|
||||
removeAuthIframe()
|
||||
// Load user info (apiJson handles auth iframe if needed)
|
||||
await loadUserInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -14,6 +14,7 @@ import AdminDialogs from '@/admin/AdminDialogs.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
||||
import { apiJson, SessionValidator } from 'paskia'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
import { uuidv7 } from 'uuidv7'
|
||||
import { getDirection } from '@/utils/keynav'
|
||||
import { goBack } from '@/utils/helpers'
|
||||
@@ -197,6 +198,7 @@ function orgUserCount(org) {
|
||||
async function loadUserInfo() {
|
||||
const data = await apiJson('/auth/api/validate', { method: 'POST' })
|
||||
info.value = data
|
||||
updateThemeFromSession(data.ctx)
|
||||
authenticated.value = true
|
||||
}
|
||||
|
||||
@@ -337,24 +339,9 @@ async function moveUserToRole(userUuid, user, targetRoleUuid) {
|
||||
}
|
||||
}
|
||||
|
||||
function onUserDragStart(e, userUuid, org) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ user_uuid: userUuid, org }))
|
||||
}
|
||||
|
||||
function onRoleDragOver(e) {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
function onRoleDrop(e, org, role) {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('text/plain'))
|
||||
if (data.org !== org.uuid) return // only within same org
|
||||
const user = org.users[data.user_uuid]
|
||||
if (user) moveUserToRole(data.user_uuid, user, role.uuid)
|
||||
} catch (_) { /* ignore */ }
|
||||
function moveUserToRoleFromDrag(userUuid, newRoleUuid) {
|
||||
const user = selectedOrg.value?.users?.[userUuid]
|
||||
if (user) moveUserToRole(userUuid, user, newRoleUuid)
|
||||
}
|
||||
|
||||
// Role actions
|
||||
@@ -999,10 +986,8 @@ async function submitDialog() {
|
||||
@create-user-in-role="createUserInRole"
|
||||
@open-user="openUser"
|
||||
@toggle-role-permission="toggleRolePermission"
|
||||
@on-role-drag-over="onRoleDragOver"
|
||||
@move-user-to-role="moveUserToRoleFromDrag"
|
||||
@navigate-out="handlePanelNavigateOut"
|
||||
@on-role-drop="onRoleDrop"
|
||||
@on-user-drag-start="onUserDragStart"
|
||||
/>
|
||||
|
||||
<AdminOidcDetail
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script>{let t=localStorage.getItem('paskia-theme');if(t!=='light'&&t!=='dark')t=new URLSearchParams(location.hash.slice(1)).get('theme');(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark');if(window.location.pathname==='/auth/restricted/iframe')document.documentElement.style.background='transparent'}</script>
|
||||
<script>{let t=localStorage.getItem('paskia-theme');if(!t){let p=new URLSearchParams(location.hash.slice(1)).get('theme');if(p==='light'||p==='dark')t=p}(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark');if(window.location.pathname==='/auth/restricted/iframe')document.documentElement.style.background='transparent'}</script>
|
||||
<link rel="stylesheet" href="/src/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Early theme for restricted app - first URL param wins, then localStorage
|
||||
// Early theme for restricted app - user preference (localStorage) wins, then URL param
|
||||
import { applyTheme, getCachedTheme } from '@/utils/theme.js'
|
||||
|
||||
function getTheme() {
|
||||
const params = new URLSearchParams(location.hash.slice(1))
|
||||
return params.get('theme') || getCachedTheme() || ''
|
||||
return getCachedTheme() || params.get('theme') || ''
|
||||
}
|
||||
|
||||
// Apply theme class to document root
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<main class="view-root">
|
||||
<div class="surface surface--tight reset-container">
|
||||
<header class="view-header reset-header">
|
||||
<header class="view-header center">
|
||||
<h1>🔑 Registration</h1>
|
||||
<p class="view-lede">
|
||||
{{ subtitleMessage }}
|
||||
@@ -60,6 +60,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import passkey from '@/utils/passkey'
|
||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||
import { apiJson, ApiError, getUserFriendlyErrorMessage } from 'paskia'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
|
||||
const status = reactive({
|
||||
show: false,
|
||||
@@ -80,7 +81,7 @@ const sessionDescriptor = computed(() => tokenInfo.value?.token_type || 'your en
|
||||
const subtitleMessage = computed(() => {
|
||||
if (initializing.value) return 'Preparing your secure enrollment…'
|
||||
if (!canRegister.value) return 'This authentication link is no longer valid.'
|
||||
return `Finish up ${sessionDescriptor.value}. You may edit the name below if needed, and it will be saved to your passkey.`
|
||||
return `Finish up ${sessionDescriptor.value}. The name entered will be stored on your passkey and on our system.`
|
||||
})
|
||||
|
||||
const basePath = computed(() => uiBasePath())
|
||||
@@ -117,6 +118,7 @@ async function fetchTokenInfo() {
|
||||
headers: { 'Authorization': `Bearer ${token.value}` },
|
||||
})
|
||||
displayName.value = tokenInfo.value.display_name
|
||||
if (tokenInfo.value.theme) updateThemeFromSession({ user: { theme: tokenInfo.value.theme } })
|
||||
} catch (error) {
|
||||
console.error('Failed to load token info', error)
|
||||
const message = error instanceof ApiError
|
||||
@@ -201,14 +203,14 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
main.view-root { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||
.reset-container {
|
||||
max-width: 560px;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.reset-header {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
}
|
||||
|
||||
.section-body {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"sirv": "^3.0.2",
|
||||
"uuidv7": "^1.1.0",
|
||||
"vue": "^3.5.17"
|
||||
"vue": "^3.5.17",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import { getDirection, navigateButtonRow, focusPreferred } from '@/utils/keynav'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -8,7 +9,7 @@ const props = defineProps({
|
||||
navigationDisabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'onRoleDragOver', 'onRoleDrop', 'onUserDragStart', 'navigateOut'])
|
||||
const emit = defineEmits(['updateOrg', 'createRole', 'updateRole', 'deleteRole', 'createUserInRole', 'openUser', 'toggleRolePermission', 'moveUserToRole', 'navigateOut'])
|
||||
|
||||
// Template refs for navigation
|
||||
const orgTitleRef = ref(null)
|
||||
@@ -50,6 +51,14 @@ function roleUserCount(roleUuid) {
|
||||
return Object.values(props.selectedOrg.users).filter(u => u.role === roleUuid).length
|
||||
}
|
||||
|
||||
function onUserChange(evt, targetRoleUuid) {
|
||||
// Only handle 'added' events (when a user is dropped into this role)
|
||||
if (evt.added) {
|
||||
const userUuid = evt.added.element.uuid
|
||||
emit('moveUserToRole', userUuid, targetRoleUuid)
|
||||
}
|
||||
}
|
||||
|
||||
function permissionDisplayName(scope) {
|
||||
return props.permissions.find(p => p.scope === scope)?.display_name || scope
|
||||
}
|
||||
@@ -350,8 +359,6 @@ defineExpose({ focusFirstElement })
|
||||
v-for="(r, roleIndex) in sortedRoles"
|
||||
:key="r.uuid"
|
||||
class="role-column"
|
||||
@dragover="$emit('onRoleDragOver', $event)"
|
||||
@drop="e => $emit('onRoleDrop', e, selectedOrg, r)"
|
||||
>
|
||||
<div class="role-header" @keydown="e => handleRoleHeaderKeydown(e, roleIndex)">
|
||||
<strong class="role-name" :title="r.uuid">
|
||||
@@ -363,15 +370,20 @@ defineExpose({ focusFirstElement })
|
||||
<button @click="$emit('createUserInRole', selectedOrg, r)" class="plus-btn" aria-label="Add user" title="Add user">➕</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="roleUserCount(r.uuid) > 0">
|
||||
<ul class="user-list" @keydown="handleUserListKeydown">
|
||||
<div class="user-list-wrapper">
|
||||
<draggable
|
||||
:list="roleUsers(r.uuid)"
|
||||
group="users"
|
||||
item-key="uuid"
|
||||
tag="ul"
|
||||
class="user-list"
|
||||
@change="evt => onUserChange(evt, r.uuid)"
|
||||
@keydown="handleUserListKeydown"
|
||||
>
|
||||
<template #item="{ element: u }">
|
||||
<li
|
||||
v-for="u in roleUsers(r.uuid)"
|
||||
:key="u.uuid"
|
||||
class="user-chip"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
@dragstart="e => $emit('onUserDragStart', e, u.uuid, selectedOrg.uuid)"
|
||||
@click="$emit('openUser', u)"
|
||||
@keydown.enter="$emit('openUser', u)"
|
||||
:title="u.uuid"
|
||||
@@ -379,35 +391,40 @@ defineExpose({ focusFirstElement })
|
||||
<span class="name">{{ u.display_name }}</span>
|
||||
<span class="meta">{{ u.last_seen ? new Date(u.last_seen).toLocaleDateString() : '—' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<div v-else class="empty-role">
|
||||
</draggable>
|
||||
<div v-if="roleUserCount(r.uuid) === 0" class="empty-role">
|
||||
<p class="empty-text muted">No members</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="sortedRoles.length >= 2" class="roles-hint muted">Members can be drag&dropped to different roles.</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card.surface { padding: var(--space-lg); }
|
||||
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); }
|
||||
.org-name { font-size: 1.5rem; font-weight: 600; color: var(--color-heading); }
|
||||
.org-title { display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-lg); font-size: 1.65rem; }
|
||||
.org-name { font-weight: 600; color: var(--color-heading); }
|
||||
.perm-matrix-grid .role-head { display: flex; align-items: flex-end; justify-content: center; }
|
||||
.perm-matrix-grid .role-head span { writing-mode: vertical-rl; transform: rotate(180deg); font-size: 0.65rem; }
|
||||
.perm-matrix-grid .add-role-head { cursor: pointer; }
|
||||
.roles-grid { display: flex; gap: var(--space-lg); margin-top: var(--space-lg); }
|
||||
.role-column { flex: 1; min-width: 200px; border-radius: var(--radius-md); padding: var(--space-md); }
|
||||
.roles-grid { display: flex; flex-wrap: wrap; gap: var(--space-lg); margin-top: var(--space-lg); justify-content: flex-start; align-items: stretch; }
|
||||
.role-column { flex: 0 0 240px; border-radius: var(--radius-md); padding: var(--space-md); display: flex; flex-direction: column; }
|
||||
.role-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-md); }
|
||||
.role-name { display: flex; align-items: center; gap: var(--space-xs); font-size: 1.1rem; color: var(--color-heading); }
|
||||
.role-actions { display: flex; gap: var(--space-xs); }
|
||||
.plus-btn { background: none; color: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: 0.25rem 0.45rem; font-size: 1.1rem; cursor: pointer; }
|
||||
.plus-btn:hover { background: rgba(37, 99, 235, 0.18); }
|
||||
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); }
|
||||
.user-chip { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
|
||||
.user-list-wrapper { position: relative; flex: 1; display: flex; flex-direction: column; min-height: 5.5rem; }
|
||||
.user-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-xs); flex: 1; }
|
||||
.user-chip { background: var(--color-accent-strong); color: white; border: none; border-radius: var(--radius-md); padding: 0.45rem 0.6rem; display: flex; justify-content: space-between; gap: var(--space-sm); cursor: grab; }
|
||||
.user-chip:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
|
||||
.user-chip .meta { font-size: 0.7rem; color: var(--color-text-muted); }
|
||||
.empty-role { border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); padding: var(--space-sm); display: flex; flex-direction: column; gap: var(--space-xs); align-items: flex-start; }
|
||||
.user-chip .meta { font-size: 0.7rem; color: rgba(255, 255, 255, 0.8); }
|
||||
.user-chip.sortable-ghost { opacity: 0.5; }
|
||||
.user-chip.sortable-chosen { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); }
|
||||
.empty-role { position: absolute; inset: 0; border: 1px dashed var(--color-border-strong); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
||||
.user-list:has(.sortable-ghost) + .empty-role { display: none; }
|
||||
.empty-text { margin: 0; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
|
||||
@@ -823,9 +823,6 @@ th {
|
||||
|
||||
.user-info {
|
||||
display: grid;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<UserBasicInfo
|
||||
v-if="ctx"
|
||||
:name="ctx.user.display_name"
|
||||
:visits="authStore.userInfo?.visits || 0"
|
||||
:created-at="authStore.userInfo?.created_at"
|
||||
:last-seen="authStore.userInfo?.last_seen"
|
||||
:visits="authStore.userInfo.user.visits"
|
||||
:created-at="authStore.userInfo.user.created_at"
|
||||
:last-seen="authStore.userInfo.user.last_seen"
|
||||
:email="ctx.user.email"
|
||||
:telephone="ctx.user.telephone"
|
||||
:org-display-name="orgDisplayName"
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
:created-at="authStore.userInfo.user.created_at"
|
||||
:last-seen="authStore.userInfo.user.last_seen"
|
||||
:loading="authStore.isLoading"
|
||||
:org-display-name="authStore.ctx?.org.display_name"
|
||||
:role-name="authStore.ctx?.role.display_name"
|
||||
:org-display-name="authStore.userInfo.org.display_name"
|
||||
:role-name="authStore.userInfo.role.display_name"
|
||||
update-endpoint="/auth/api/user/info"
|
||||
@saved="authStore.loadUserInfo()"
|
||||
@edit="openEditDialog"
|
||||
@@ -53,7 +53,7 @@
|
||||
<CredentialList
|
||||
ref="credentialList"
|
||||
:credentials="credentials"
|
||||
:aaguid-info="authStore.userInfo?.aaguid_info || {}"
|
||||
:aaguid-info="authStore.userInfo.aaguid_info"
|
||||
:loading="authStore.isLoading"
|
||||
:hovered-credential-uuid="hoveredCredentialUuid"
|
||||
:hovered-session-credential-uuid="hoveredSession?.credential"
|
||||
@@ -184,11 +184,11 @@ const hasActiveModal = computed(() => showEditDialog.value || showRegLink.value)
|
||||
|
||||
watch(showEditDialog, (open) => {
|
||||
if (!open) return
|
||||
const user = authStore.userInfo?.user
|
||||
editName.value = user?.display_name ?? ''
|
||||
editEmail.value = user?.email ?? ''
|
||||
editUsername.value = user?.preferred_username ?? ''
|
||||
editTelephone.value = user?.telephone ?? ''
|
||||
const user = authStore.userInfo.user
|
||||
editName.value = user.display_name ?? ''
|
||||
editEmail.value = user.email ?? ''
|
||||
editUsername.value = user.preferred_username ?? ''
|
||||
editTelephone.value = user.telephone ?? ''
|
||||
editError.value = ''
|
||||
})
|
||||
|
||||
@@ -341,7 +341,7 @@ const handleDelete = async (credential) => {
|
||||
|
||||
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
|
||||
const paskiaVersion = computed(() => authStore.settings?.version || '')
|
||||
const sessions = computed(() => authStore.userInfo?.sessions || {})
|
||||
const sessions = computed(() => authStore.userInfo.sessions)
|
||||
const currentSessionHost = computed(() => {
|
||||
const currentSession = Object.values(sessions.value).find(session => session.is_current)
|
||||
return currentSession?.host || 'this host'
|
||||
@@ -365,12 +365,12 @@ const logoutEverywhere = async () => { await authStore.logoutEverywhere() }
|
||||
const logout = async () => { await authStore.logout() }
|
||||
const openEditDialog = () => { showEditDialog.value = true }
|
||||
const isAdmin = computed(() => {
|
||||
const perms = authStore.ctx?.permissions
|
||||
return perms?.includes('auth:admin') || perms?.includes('auth:org:admin')
|
||||
const perms = Object.values(authStore.userInfo.permissions).map(p => p.scope)
|
||||
return perms.includes('auth:admin') || perms.includes('auth:org:admin')
|
||||
})
|
||||
const hasMultipleSessions = computed(() => Object.keys(sessions.value).length > 1)
|
||||
const credentials = computed(() =>
|
||||
Object.entries(authStore.userInfo?.credentials || {}).map(([uuid, c]) => ({ ...c, credential: uuid }))
|
||||
Object.entries(authStore.userInfo.credentials).map(([uuid, c]) => ({ ...c, credential: uuid }))
|
||||
)
|
||||
const useWideLayout = computed(() => {
|
||||
// Check if any single site has more than 8 sessions
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
|
||||
import { apiJson, holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||
import { apiJson, AuthCancelledError, holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
import { getDirection } from '@/utils/keynav'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -90,7 +90,9 @@ async function generateLink() {
|
||||
emit('close')
|
||||
}
|
||||
} catch (e) {
|
||||
if (!(e instanceof AuthCancelledError)) {
|
||||
authStore.showMessage(e.message || 'Failed to generate link', 'error')
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,9 @@ async function startRemoteAuth() {
|
||||
|
||||
// PoW challenge
|
||||
const powChallenge = await ws.receive_json()
|
||||
if (powChallenge.status) {
|
||||
throw new Error(powChallenge.detail || `Failed to connect: ${powChallenge.status}`)
|
||||
}
|
||||
if (powChallenge.pow) {
|
||||
const challenge = b64dec(powChallenge.pow.challenge)
|
||||
const nonces = await solvePoW(challenge, powChallenge.pow.work)
|
||||
|
||||
@@ -61,6 +61,7 @@ import { getSettings, uiBasePath } from '@/utils/settings'
|
||||
import { fetchJson, getUserFriendlyErrorMessage } from 'paskia'
|
||||
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
|
||||
import { focusDialogButton } from '@/utils/keynav'
|
||||
import { updateThemeFromSession } from '@/utils/theme'
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
@@ -147,6 +148,7 @@ async function fetchSettings() {
|
||||
async function validateSession() {
|
||||
try {
|
||||
session.value = await fetchJson('/auth/api/validate', { method: 'POST' })
|
||||
updateThemeFromSession(session.value?.ctx)
|
||||
if (isAuthenticated.value && props.mode !== 'reauth') {
|
||||
currentView.value = 'forbidden'
|
||||
emit('forbidden', session.value)
|
||||
|
||||
@@ -118,7 +118,7 @@ const userLoaded = computed(() => !!props.name)
|
||||
.user-info-extra { grid-area: extra; padding-left: 1rem; border-left: 1px solid var(--color-border); flex-shrink: 0; }
|
||||
.user-name-row { display: inline-flex; align-items: center; gap: 0.35rem; max-width: 100%; min-width: 0; }
|
||||
.display-name { font-weight: 600; font-size: 1.05em; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; }
|
||||
.mini-btn { width: auto; padding: 4px 6px; margin: 0; font-size: 0.75em; line-height: 1; cursor: pointer; background: transparent; }
|
||||
.mini-btn:hover:not(:disabled) { background: var(--color-accent-soft); color: var(--color-accent); }
|
||||
.mini-btn:active:not(:disabled) { transform: translateY(1px); }
|
||||
.mini-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
@@ -88,7 +88,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
async loadUserInfo() {
|
||||
try {
|
||||
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET' })
|
||||
updateThemeFromSession(this.ctx)
|
||||
updateThemeFromSession(this.userInfo)
|
||||
console.log('User info loaded:', this.userInfo)
|
||||
} catch (error) {
|
||||
// Suppress toast for 401/403 errors - the auth iframe will handle these
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// Cache for auth iframe URL by mode
|
||||
const authIframeUrlCache = {}
|
||||
|
||||
/**
|
||||
* Get the auth iframe URL for a given mode.
|
||||
* Fetches from /auth/api/forward which returns URL in the auth.iframe field.
|
||||
* Results are cached per mode.
|
||||
* @param {string} mode - The auth mode ('login', 'reauth', 'forbidden')
|
||||
* @returns {Promise<string>} - The URL for the iframe
|
||||
*/
|
||||
export async function getAuthIframeUrl(mode = 'login') {
|
||||
if (authIframeUrlCache[mode]) {
|
||||
return authIframeUrlCache[mode]
|
||||
}
|
||||
|
||||
// Fetch from forward endpoint - it returns URL in auth.iframe on 401/403
|
||||
const response = await fetch('/auth/api/forward')
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const data = await response.json()
|
||||
if (data.auth?.iframe) {
|
||||
// The iframe field now contains a URL with hash fragment
|
||||
// If mode differs, update the hash param
|
||||
let url = data.auth.iframe
|
||||
if (mode !== data.auth.mode) {
|
||||
url = url.replace(/mode=[^&]*/, `mode=${mode}`)
|
||||
}
|
||||
authIframeUrlCache[mode] = url
|
||||
return url
|
||||
}
|
||||
}
|
||||
throw new Error('Unable to fetch auth iframe URL')
|
||||
}
|
||||
@@ -18,8 +18,6 @@ export {
|
||||
isAuthIframeOpen,
|
||||
hideAuthIframe,
|
||||
showAuthIframe,
|
||||
createAuthIframe,
|
||||
removeAuthIframe,
|
||||
} from './overlay'
|
||||
|
||||
export { SessionValidator } from './validate'
|
||||
|
||||
+33
-70
@@ -1,19 +1,14 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi_vue import server
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
from fastapi_vue.hostutil import parse_endpoints
|
||||
|
||||
from paskia import db
|
||||
from paskia import globals as _globals
|
||||
from paskia.bootstrap import bootstrap_if_needed
|
||||
from paskia.config import PaskiaConfig
|
||||
from paskia.db.background import flush
|
||||
from paskia.db.structs import Config
|
||||
from paskia.db.jsonl import load_readonly
|
||||
from paskia.util import startupbox
|
||||
from paskia.util.hostutil import normalize_origin
|
||||
|
||||
@@ -56,6 +51,7 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
|
||||
"--origin",
|
||||
action="append",
|
||||
dest="origins",
|
||||
default=[],
|
||||
metavar="URL",
|
||||
help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.",
|
||||
)
|
||||
@@ -103,9 +99,10 @@ def main():
|
||||
if getattr(args, "listen", None) == "":
|
||||
args.listen = None
|
||||
|
||||
# Init db and load stored config
|
||||
asyncio.run(db.init(rp_id=args.rp_id))
|
||||
stored_config = db.data().config
|
||||
# Read-only load to get stored config (no writes, no global state)
|
||||
db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb")
|
||||
stored_db = load_readonly(db_path, rp_id=args.rp_id)
|
||||
stored_config = stored_db.config
|
||||
|
||||
# Apply defaults from stored config
|
||||
if args.rp_name is None and stored_config.rp_name is not None:
|
||||
@@ -118,42 +115,25 @@ def main():
|
||||
args.listen = stored_config.listen
|
||||
|
||||
# Parse first endpoint for config display and site_url
|
||||
first_listen = args.listen[0] if isinstance(args.listen, list) else args.listen
|
||||
endpoints = parse_endpoint(first_listen, DEFAULT_PORT)
|
||||
ep = next(iter(parse_endpoints(args.listen, DEFAULT_PORT)), {})
|
||||
host, port, uds = ep.get("host"), ep.get("port"), ep.get("uds")
|
||||
|
||||
# Extract host/port/uds from first endpoint for config display and site_url
|
||||
ep = endpoints[0] if endpoints else {}
|
||||
host = ep.get("host")
|
||||
port = ep.get("port")
|
||||
uds = ep.get("uds")
|
||||
|
||||
# Collect and normalize origins, handle auth_host
|
||||
origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])]
|
||||
# Process and normalize auth_host
|
||||
if args.auth_host:
|
||||
# Normalize auth_host with scheme
|
||||
if "://" not in args.auth_host:
|
||||
args.auth_host = f"https://{args.auth_host}"
|
||||
|
||||
args.auth_host = args.auth_host.rstrip("/")
|
||||
validate_auth_host(args.auth_host, args.rp_id)
|
||||
args.origins.insert(0, args.auth_host) # Ensure first in origins
|
||||
|
||||
# If origins are configured, ensure auth_host is included at top
|
||||
if origins:
|
||||
# Insert auth_host at the beginning
|
||||
origins.insert(0, args.auth_host)
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
seen = set()
|
||||
origins = [x for x in origins if not (x in seen or seen.add(x))]
|
||||
# Normalize, strip trailing slashes, and deduplicate while preserving order
|
||||
origins = list({normalize_origin(o).rstrip("/"): ... for o in (args.origins)})
|
||||
|
||||
# Compute site_url and site_path for reset links
|
||||
# Priority: PASKIA_SITE_URL (explicit) > auth_host > first origin with localhost > http://localhost:port
|
||||
explicit_site_url = os.environ.get("PASKIA_SITE_URL")
|
||||
if explicit_site_url:
|
||||
# Explicit site URL from devserver or deployment config
|
||||
site_url = explicit_site_url.rstrip("/")
|
||||
site_path = "/" if args.auth_host else "/auth/"
|
||||
elif args.auth_host:
|
||||
site_url = args.auth_host.rstrip("/")
|
||||
# Priority: auth_host > first configured origin > PASKIA_VITE_URL (devserver) > http://localhost:port > https://rp_id
|
||||
site_path = "/auth/"
|
||||
if args.auth_host:
|
||||
site_url = args.auth_host
|
||||
site_path = "/"
|
||||
elif origins:
|
||||
# Find localhost origin if rp_id is localhost, else use first origin
|
||||
@@ -162,15 +142,13 @@ def main():
|
||||
if args.rp_id == "localhost"
|
||||
else None
|
||||
)
|
||||
site_url = (localhost_origin or origins[0]).rstrip("/")
|
||||
site_path = "/auth/"
|
||||
site_url = localhost_origin or origins[0]
|
||||
elif vite_url := os.environ.get("PASKIA_VITE_URL"):
|
||||
site_url = vite_url.rstrip("/") # Devserver
|
||||
elif args.rp_id == "localhost" and port:
|
||||
# Dev mode: use http with port
|
||||
site_url = f"http://localhost:{port}"
|
||||
site_path = "/auth/"
|
||||
site_url = f"http://localhost:{port}" # Backend directly if we can
|
||||
else:
|
||||
site_url = f"https://{args.rp_id}"
|
||||
site_path = "/auth/"
|
||||
site_url = f"https://{args.rp_id}" # Assume external reverse proxy
|
||||
|
||||
# Build runtime configuration
|
||||
config = PaskiaConfig(
|
||||
@@ -186,6 +164,14 @@ def main():
|
||||
)
|
||||
|
||||
# Export configuration via single JSON env variable for worker processes
|
||||
# Include cli_config and save flag so lifespan can handle bootstrap/persistence
|
||||
cli_config = {
|
||||
"rp_id": args.rp_id,
|
||||
"rp_name": args.rp_name,
|
||||
"origins": args.origins,
|
||||
"auth_host": args.auth_host,
|
||||
"listen": args.listen,
|
||||
}
|
||||
config_json = {
|
||||
"rp_id": config.rp_id,
|
||||
"rp_name": config.rp_name,
|
||||
@@ -193,36 +179,13 @@ def main():
|
||||
"auth_host": config.auth_host,
|
||||
"site_url": config.site_url,
|
||||
"site_path": config.site_path,
|
||||
"save": args.save,
|
||||
"cli_config": cli_config,
|
||||
}
|
||||
os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
|
||||
|
||||
startupbox.print_startup_config(config)
|
||||
|
||||
# Build config to save (for bootstrap or explicit --save)
|
||||
cli_config = Config(
|
||||
rp_id=args.rp_id,
|
||||
rp_name=args.rp_name,
|
||||
origins=args.origins,
|
||||
auth_host=args.auth_host,
|
||||
listen=args.listen,
|
||||
)
|
||||
|
||||
async def startup():
|
||||
await _globals.init(
|
||||
rp_id=config.rp_id,
|
||||
rp_name=config.rp_name,
|
||||
origins=config.origins,
|
||||
bootstrap=False,
|
||||
)
|
||||
# Pass config to bootstrap - it will be saved within the bootstrap transaction
|
||||
await bootstrap_if_needed(config=cli_config)
|
||||
# Also save config if --save was explicitly used (even without bootstrap)
|
||||
if args.save:
|
||||
await db.update_config(cli_config)
|
||||
await flush()
|
||||
|
||||
asyncio.run(startup())
|
||||
|
||||
dev = {"reload": True, "reload_dirs": ["paskia"]} if DEVMODE else {}
|
||||
server.run(
|
||||
"paskia.fastapi.mainapp:app",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Database module for WebAuthn passkey authentication.
|
||||
|
||||
Read: Access data() directly, use build_* to convert to public structs.
|
||||
Read: Access data() directly for structs.
|
||||
CTX: data().session_ctx(key) returns SessionContext with effective permissions.
|
||||
Write: Functions validate and commit, or raise ValueError.
|
||||
|
||||
@@ -10,7 +10,6 @@ Usage:
|
||||
|
||||
# Read (after init)
|
||||
user_data = db.data().users[user_uuid]
|
||||
user = db.build_user(user_uuid)
|
||||
|
||||
# Context
|
||||
ctx = db.data().session_ctx(session_key)
|
||||
@@ -27,6 +26,7 @@ from paskia.db.background import (
|
||||
stop_cleanup,
|
||||
)
|
||||
from paskia.db.bootstrap import bootstrap
|
||||
from paskia.db.jsonl import load_readonly
|
||||
from paskia.db.lifecycle import cleanup_expired, init
|
||||
from paskia.db.operations import (
|
||||
add_permission_to_org,
|
||||
@@ -102,18 +102,12 @@ __all__ = [
|
||||
# Instance
|
||||
"data",
|
||||
"init",
|
||||
"load_readonly",
|
||||
# Background
|
||||
"start_background",
|
||||
"stop_background",
|
||||
"start_cleanup",
|
||||
"stop_cleanup",
|
||||
# Builders
|
||||
"build_credential",
|
||||
"build_permission",
|
||||
"build_reset_token",
|
||||
"build_role",
|
||||
"build_session",
|
||||
"build_user",
|
||||
# Read ops
|
||||
# Write ops
|
||||
"add_permission_to_org",
|
||||
|
||||
@@ -90,7 +90,7 @@ async def start_background():
|
||||
|
||||
|
||||
async def stop_background():
|
||||
"""Stop the background task and flush any pending changes."""
|
||||
"""Stop the background task, flush pending changes, and release the file lock."""
|
||||
global _background_task
|
||||
if _background_task:
|
||||
_background_task.cancel()
|
||||
@@ -99,6 +99,7 @@ async def stop_background():
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_background_task = None
|
||||
_ops._store.close()
|
||||
|
||||
|
||||
# Aliases for backwards compatibility
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import UTC, datetime
|
||||
import uuid7
|
||||
|
||||
import paskia.db.operations as _ops
|
||||
from paskia.authsession import reset_expires
|
||||
from paskia.db.structs import Config, Org, Permission, ResetToken, Role, User
|
||||
from paskia.util.crypto import secret_key
|
||||
|
||||
@@ -59,8 +60,6 @@ def bootstrap(
|
||||
|
||||
# Set reset token expiry (passphrase generated by ResetToken.create)
|
||||
if reset_expiry is None:
|
||||
from paskia.authsession import reset_expires # noqa: PLC0415
|
||||
|
||||
reset_expiry = reset_expires()
|
||||
|
||||
with _ops._db.transaction("bootstrap"):
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Cross-platform locked file for the database (no separate .lock files).
|
||||
|
||||
Unix: open() + fcntl.flock (advisory, cooperative among processes that flock).
|
||||
Windows: CreateFileW with FILE_SHARE_READ (OS-enforced, allows readers, blocks writers).
|
||||
|
||||
A single file descriptor is opened once for both reading and writing.
|
||||
The lock is acquired atomically (on Windows) or immediately after open (on Unix),
|
||||
and the same descriptor is used for the lifetime of the process: first to read
|
||||
the existing content, then to append new writes.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _fatal(msg: str) -> None:
|
||||
"""Log a fatal error and exit immediately, bypassing exception handlers."""
|
||||
_logger.critical(msg)
|
||||
os._exit(1)
|
||||
|
||||
|
||||
if sys.platform == "win32":
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
|
||||
_GENERIC_READ = 0x80000000
|
||||
_GENERIC_WRITE = 0x40000000
|
||||
_FILE_SHARE_READ = 0x00000001
|
||||
_OPEN_EXISTING = 3
|
||||
_OPEN_ALWAYS = 4
|
||||
_FILE_ATTRIBUTE_NORMAL = 0x80
|
||||
_FILE_BEGIN = 0
|
||||
_FILE_END = 2
|
||||
_ERROR_SHARING_VIOLATION = 32
|
||||
_INVALID_FILE_SIZE = 0xFFFFFFFF
|
||||
|
||||
_kernel32.CreateFileW.restype = wintypes.HANDLE
|
||||
_kernel32.CreateFileW.argtypes = [
|
||||
wintypes.LPCWSTR,
|
||||
wintypes.DWORD,
|
||||
wintypes.DWORD,
|
||||
ctypes.c_void_p,
|
||||
wintypes.DWORD,
|
||||
wintypes.DWORD,
|
||||
wintypes.HANDLE,
|
||||
]
|
||||
_kernel32.ReadFile.restype = wintypes.BOOL
|
||||
_kernel32.ReadFile.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
ctypes.c_void_p,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(wintypes.DWORD),
|
||||
ctypes.c_void_p,
|
||||
]
|
||||
_kernel32.WriteFile.restype = wintypes.BOOL
|
||||
_kernel32.WriteFile.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
ctypes.c_void_p,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(wintypes.DWORD),
|
||||
ctypes.c_void_p,
|
||||
]
|
||||
_kernel32.GetFileSize.restype = wintypes.DWORD
|
||||
_kernel32.GetFileSize.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
ctypes.POINTER(wintypes.DWORD),
|
||||
]
|
||||
_kernel32.SetFilePointer.restype = wintypes.DWORD
|
||||
_kernel32.SetFilePointer.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
wintypes.LONG,
|
||||
ctypes.POINTER(wintypes.LONG),
|
||||
wintypes.DWORD,
|
||||
]
|
||||
_kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
_kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
|
||||
def _is_invalid_handle(handle) -> bool:
|
||||
return ctypes.c_void_p(handle).value == ctypes.c_void_p(-1).value
|
||||
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
|
||||
class LockedFile:
|
||||
"""A file opened with an exclusive write lock.
|
||||
|
||||
Usage::
|
||||
|
||||
f = LockedFile()
|
||||
f.open(path) # open + lock (read+write)
|
||||
content = f.read() # read entire content
|
||||
f.write(data) # append data (seeks to end first)
|
||||
f.close() # release lock + close fd
|
||||
|
||||
Unix: fcntl.flock (advisory) — read-only callers that don't flock are unaffected.
|
||||
Windows: CreateFileW with FILE_SHARE_READ — OS blocks other writers.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._fd: int | None = None # Unix fd or Windows HANDLE
|
||||
|
||||
def open(self, path: Path, *, create: bool = False) -> None:
|
||||
"""Open *path* for read+write with an exclusive lock.
|
||||
|
||||
Args:
|
||||
path: File to open and lock.
|
||||
create: If True, create the file if it doesn't exist (bootstrap).
|
||||
|
||||
Raises:
|
||||
SystemExit: If the file is locked by another process or not found.
|
||||
"""
|
||||
if self._fd is not None:
|
||||
return # Already open (idempotent)
|
||||
|
||||
if sys.platform == "win32":
|
||||
self._open_win32(path, create)
|
||||
else:
|
||||
self._open_unix(path, create)
|
||||
|
||||
def open_and_read(self, path: Path) -> bytes:
|
||||
"""Open *path* with exclusive lock and read all content.
|
||||
|
||||
Combined operation for efficient use with asyncio.to_thread().
|
||||
"""
|
||||
self.open(path)
|
||||
return self.read()
|
||||
|
||||
def read(self) -> bytes:
|
||||
"""Read the entire file content from the beginning."""
|
||||
if self._fd is None:
|
||||
raise RuntimeError("LockedFile.read() called on a closed file")
|
||||
|
||||
if sys.platform == "win32":
|
||||
return self._read_win32()
|
||||
else:
|
||||
return self._read_unix()
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
"""Append *data* to the end of the file."""
|
||||
if self._fd is None:
|
||||
raise RuntimeError("LockedFile.write() called on a closed file")
|
||||
|
||||
if sys.platform == "win32":
|
||||
self._write_win32(data)
|
||||
else:
|
||||
self._write_unix(data)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the lock and close the file."""
|
||||
if self._fd is None:
|
||||
return
|
||||
if sys.platform == "win32":
|
||||
_kernel32.CloseHandle(self._fd)
|
||||
else:
|
||||
os.close(self._fd)
|
||||
self._fd = None
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self._fd is not None
|
||||
|
||||
# -- Unix ----------------------------------------------------------------
|
||||
|
||||
def _open_unix(self, path: Path, create: bool) -> None:
|
||||
flags = os.O_RDWR | (os.O_CREAT if create else 0)
|
||||
try:
|
||||
fd = os.open(path, flags, 0o666)
|
||||
except FileNotFoundError:
|
||||
_fatal(f"Database file not found: {path.resolve()}")
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
os.close(fd)
|
||||
_fatal(f"🛑 {path.resolve()}: database already locked by another instance")
|
||||
self._fd = fd
|
||||
|
||||
def _read_unix(self) -> bytes:
|
||||
os.lseek(self._fd, 0, os.SEEK_SET)
|
||||
chunks = []
|
||||
while True:
|
||||
chunk = os.read(self._fd, 1 << 20) # 1 MiB
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
def _write_unix(self, data: bytes) -> None:
|
||||
os.lseek(self._fd, 0, os.SEEK_END)
|
||||
os.write(self._fd, data)
|
||||
|
||||
# -- Windows -------------------------------------------------------------
|
||||
|
||||
def _open_win32(self, path: Path, create: bool) -> None:
|
||||
disposition = _OPEN_ALWAYS if create else _OPEN_EXISTING
|
||||
handle = _kernel32.CreateFileW(
|
||||
str(path),
|
||||
_GENERIC_READ | _GENERIC_WRITE,
|
||||
_FILE_SHARE_READ,
|
||||
None,
|
||||
disposition,
|
||||
_FILE_ATTRIBUTE_NORMAL,
|
||||
None,
|
||||
)
|
||||
if _is_invalid_handle(handle):
|
||||
err = ctypes.get_last_error()
|
||||
if err == _ERROR_SHARING_VIOLATION:
|
||||
_fatal(
|
||||
f"🛑 {path.resolve()}: database already locked by another instance"
|
||||
)
|
||||
_fatal(f"Failed to open database {path.resolve()}: Windows error {err}")
|
||||
self._fd = handle
|
||||
|
||||
def _read_win32(self) -> bytes:
|
||||
_kernel32.SetFilePointer(self._fd, 0, None, _FILE_BEGIN)
|
||||
size = _kernel32.GetFileSize(self._fd, None)
|
||||
if size == _INVALID_FILE_SIZE:
|
||||
raise OSError(
|
||||
f"GetFileSize failed: Windows error {ctypes.get_last_error()}"
|
||||
)
|
||||
if size == 0:
|
||||
return b""
|
||||
buf = ctypes.create_string_buffer(size)
|
||||
bytes_read = wintypes.DWORD()
|
||||
ok = _kernel32.ReadFile(self._fd, buf, size, ctypes.byref(bytes_read), None)
|
||||
if not ok:
|
||||
raise OSError(f"ReadFile failed: Windows error {ctypes.get_last_error()}")
|
||||
return buf.raw[: bytes_read.value]
|
||||
|
||||
def _write_win32(self, data: bytes) -> None:
|
||||
_kernel32.SetFilePointer(self._fd, 0, None, _FILE_END)
|
||||
written = wintypes.DWORD()
|
||||
ok = _kernel32.WriteFile(
|
||||
self._fd,
|
||||
data,
|
||||
len(data),
|
||||
ctypes.byref(written),
|
||||
None,
|
||||
)
|
||||
if not ok:
|
||||
raise OSError(f"WriteFile failed: Windows error {ctypes.get_last_error()}")
|
||||
+98
-56
@@ -2,6 +2,7 @@
|
||||
JSONL persistence layer for the database.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
@@ -13,13 +14,18 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiofiles
|
||||
import jsondiff
|
||||
import msgspec
|
||||
|
||||
from paskia.db.filelock import LockedFile
|
||||
from paskia.db.logging import log_change
|
||||
from paskia.db.migrations import DBVER, apply_all_migrations
|
||||
from paskia.db.structs import DB, SessionContext
|
||||
from paskia.db.migrations import (
|
||||
DBVER,
|
||||
MigrationCtx,
|
||||
apply_all_migrations,
|
||||
apply_migrations_readonly,
|
||||
)
|
||||
from paskia.db.structs import DB, Config, SessionContext
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,6 +33,47 @@ _logger = logging.getLogger(__name__)
|
||||
DB_PATH_DEFAULT = "paskia.jsonl"
|
||||
|
||||
|
||||
def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
|
||||
"""Replay JSONL and apply migrations to produce a DB, without writing anything.
|
||||
|
||||
This is suitable for reading settings before the server starts.
|
||||
Migrations are applied in-memory only; nothing is queued or flushed.
|
||||
"""
|
||||
path = Path(db_path)
|
||||
if not path.exists():
|
||||
return DB(config=Config(rp_id=rp_id))
|
||||
|
||||
data_dict: dict = {}
|
||||
version = 0
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
content = f.read()
|
||||
for line_num, line in enumerate(content.split(b"\n"), 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
change = msgspec.json.decode(line)
|
||||
data_dict = jsondiff.patch(data_dict, change["diff"], marshal=True)
|
||||
version = change.get("v", 0)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Error parsing line {line_num}: {e}")
|
||||
except OSError as e:
|
||||
raise SystemExit(f"Failed to load database: {e}")
|
||||
except (ValueError, msgspec.DecodeError) as e:
|
||||
raise SystemExit(f"Failed to load database: {e}")
|
||||
|
||||
if not data_dict:
|
||||
return DB(config=Config(rp_id=rp_id))
|
||||
|
||||
# Apply migrations in-memory (no persistence)
|
||||
apply_migrations_readonly(data_dict, version, MigrationCtx(rp_id=rp_id))
|
||||
|
||||
# Decode to msgspec struct
|
||||
db = msgspec.json.decode(msgspec.json.encode(data_dict), type=DB)
|
||||
return db
|
||||
|
||||
|
||||
class _ChangeRecord(msgspec.Struct, omit_defaults=True):
|
||||
"""A single change record in the JSONL file."""
|
||||
|
||||
@@ -71,54 +118,6 @@ def create_change_record(
|
||||
# Actions that are allowed to create a new database file
|
||||
_BOOTSTRAP_ACTIONS = frozenset({"bootstrap"})
|
||||
|
||||
# Flag to prevent duplicate error messages on fatal flush failure
|
||||
_flush_failed = False
|
||||
|
||||
|
||||
async def flush_changes(
|
||||
db_path: Path,
|
||||
pending_changes: deque[_ChangeRecord],
|
||||
) -> None:
|
||||
"""Write all pending changes to disk.
|
||||
|
||||
Args:
|
||||
db_path: Path to the JSONL database file
|
||||
pending_changes: Queue of pending change records (will be cleared on success)
|
||||
|
||||
On failure, logs an error and sends SIGTERM to trigger graceful shutdown.
|
||||
"""
|
||||
global _flush_failed
|
||||
if _flush_failed or not pending_changes:
|
||||
return
|
||||
|
||||
if not db_path.exists():
|
||||
first_action = pending_changes[0].a
|
||||
if first_action not in _BOOTSTRAP_ACTIONS:
|
||||
_logger.error(
|
||||
"Refusing to create database file with action '%s' - "
|
||||
"only bootstrap can create a new database",
|
||||
first_action,
|
||||
)
|
||||
_flush_failed = True
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
return
|
||||
|
||||
changes_to_write = list(pending_changes)
|
||||
|
||||
try:
|
||||
lines = [_change_encoder.encode(change) for change in changes_to_write]
|
||||
if not lines:
|
||||
pending_changes.clear()
|
||||
return
|
||||
|
||||
async with aiofiles.open(db_path, "ab") as f:
|
||||
await f.write(b"\n".join(lines) + b"\n")
|
||||
pending_changes.clear()
|
||||
except OSError as e:
|
||||
_logger.error("Failed to flush database: %s", e)
|
||||
_flush_failed = True
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
|
||||
|
||||
class JsonlStore:
|
||||
"""JSONL persistence layer for a DB instance."""
|
||||
@@ -126,6 +125,8 @@ class JsonlStore:
|
||||
def __init__(self, db: DB, db_path: str = DB_PATH_DEFAULT):
|
||||
self.db: DB = db
|
||||
self.db_path = Path(db_path)
|
||||
self._file = LockedFile()
|
||||
self._flush_failed = False
|
||||
self._previous_builtins: dict[str, Any] = {}
|
||||
self._pending_changes: deque[_ChangeRecord] = deque()
|
||||
self._current_action: str = "system"
|
||||
@@ -144,11 +145,12 @@ class JsonlStore:
|
||||
if not self.db_path.exists():
|
||||
return
|
||||
|
||||
# Open with exclusive write lock and read contents — single threadpool call
|
||||
content = await asyncio.to_thread(self._file.open_and_read, self.db_path)
|
||||
|
||||
# Replay change log to reconstruct state
|
||||
data_dict: dict = {}
|
||||
try:
|
||||
async with aiofiles.open(self.db_path, "rb") as f:
|
||||
content = await f.read()
|
||||
for line_num, line in enumerate(content.split(b"\n"), 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
@@ -179,7 +181,10 @@ class JsonlStore:
|
||||
|
||||
# Apply schema migrations one at a time
|
||||
await apply_all_migrations(
|
||||
data_dict, self._current_version, persist_migration, rp_id=rp_id
|
||||
data_dict,
|
||||
self._current_version,
|
||||
persist_migration,
|
||||
MigrationCtx(rp_id=rp_id),
|
||||
)
|
||||
|
||||
# Decode to msgspec struct
|
||||
@@ -289,5 +294,42 @@ class JsonlStore:
|
||||
self._transaction_snapshot = None
|
||||
|
||||
async def flush(self) -> None:
|
||||
"""Write all pending changes to disk."""
|
||||
await flush_changes(self.db_path, self._pending_changes)
|
||||
"""Write all pending changes to disk.
|
||||
|
||||
On failure, logs an error and sends SIGTERM to trigger graceful shutdown.
|
||||
"""
|
||||
if self._flush_failed or not self._pending_changes:
|
||||
return
|
||||
|
||||
if not self._file.is_open:
|
||||
first_action = self._pending_changes[0].a
|
||||
if first_action not in _BOOTSTRAP_ACTIONS:
|
||||
_logger.error(
|
||||
"Refusing to create database file with action '%s' - "
|
||||
"only bootstrap can create a new database",
|
||||
first_action,
|
||||
)
|
||||
self._flush_failed = True
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
return
|
||||
# Bootstrap: create and open the file with lock
|
||||
await asyncio.to_thread(self._file.open, self.db_path, create=True)
|
||||
|
||||
changes_to_write = list(self._pending_changes)
|
||||
|
||||
try:
|
||||
lines = [_change_encoder.encode(change) for change in changes_to_write]
|
||||
if not lines:
|
||||
self._pending_changes.clear()
|
||||
return
|
||||
|
||||
await asyncio.to_thread(self._file.write, b"\n".join(lines) + b"\n")
|
||||
self._pending_changes.clear()
|
||||
except OSError as e:
|
||||
_logger.error("Failed to flush database: %s", e)
|
||||
self._flush_failed = True
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the file lock and close the file."""
|
||||
self._file.close()
|
||||
|
||||
@@ -7,6 +7,7 @@ import os
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import paskia.db.operations as _ops
|
||||
from paskia import oidc_notify
|
||||
from paskia.authsession import EXPIRES
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -31,8 +32,6 @@ def cleanup_expired() -> int:
|
||||
limit = now - EXPIRES
|
||||
expired_sessions = [k for k, s in _ops._db.sessions.items() if s.validated < limit]
|
||||
if expired_sessions:
|
||||
from paskia import oidc_notify # noqa: PLC0415
|
||||
|
||||
oidc_notify.schedule_notifications(expired_sessions)
|
||||
with _ops._db.transaction("expiry"):
|
||||
for k in expired_sessions:
|
||||
|
||||
+30
-8
@@ -8,28 +8,36 @@ Each migration should be idempotent and only run when needed.
|
||||
import base64
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import msgspec
|
||||
|
||||
from paskia.util.crypto import secret_key
|
||||
|
||||
|
||||
def migrate_v1(d: dict, **kwargs) -> None:
|
||||
class MigrationCtx(msgspec.Struct):
|
||||
"""Context passed to each migration function."""
|
||||
|
||||
rp_id: str
|
||||
|
||||
|
||||
def migrate_v1(d: dict, ctx: MigrationCtx) -> None:
|
||||
"""Remove Org.created_at fields."""
|
||||
for org_data in d["orgs"].values():
|
||||
org_data.pop("created_at", None)
|
||||
|
||||
|
||||
def migrate_v2(d: dict, *, rp_id: str = "localhost") -> None:
|
||||
def migrate_v2(d: dict, ctx: MigrationCtx) -> None:
|
||||
"""Add config field if missing."""
|
||||
if "config" not in d:
|
||||
d["config"] = {"rp_id": rp_id}
|
||||
d["config"] = {"rp_id": ctx.rp_id}
|
||||
|
||||
|
||||
def migrate_v3(d: dict, **kwargs) -> None:
|
||||
def migrate_v3(d: dict, ctx: MigrationCtx) -> None:
|
||||
"""Ensure all users have visits field."""
|
||||
for user_data in d["users"].values():
|
||||
user_data.setdefault("visits", 0)
|
||||
|
||||
|
||||
def migrate_v4(d: dict, **kwargs) -> None:
|
||||
def migrate_v4(d: dict, ctx: MigrationCtx) -> None:
|
||||
"""OpenID Connect support and hardened session keys."""
|
||||
# Session keys changed to hashes, drop old sessions
|
||||
d["sessions"] = {}
|
||||
@@ -45,14 +53,28 @@ migrations = sorted(
|
||||
DBVER = len(migrations) # Used by bootstrap to set initial version
|
||||
|
||||
|
||||
def apply_migrations_readonly(
|
||||
data_dict: dict,
|
||||
current_version: int,
|
||||
ctx: MigrationCtx,
|
||||
) -> int:
|
||||
"""Apply migration functions in-place without persistence.
|
||||
|
||||
Returns the new version after all migrations.
|
||||
"""
|
||||
while current_version < DBVER:
|
||||
migrations[current_version](data_dict, ctx)
|
||||
current_version += 1
|
||||
return current_version
|
||||
|
||||
|
||||
async def apply_all_migrations(
|
||||
data_dict: dict,
|
||||
current_version: int,
|
||||
persist: Callable[[str, int, dict], Awaitable[None]],
|
||||
*,
|
||||
rp_id: str = "localhost",
|
||||
ctx: MigrationCtx,
|
||||
) -> None:
|
||||
while current_version < DBVER:
|
||||
migrations[current_version](data_dict, rp_id=rp_id)
|
||||
migrations[current_version](data_dict, ctx)
|
||||
current_version += 1
|
||||
await persist(f"migrate:v{current_version}", current_version, data_dict)
|
||||
|
||||
@@ -11,9 +11,9 @@ import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
import base64url
|
||||
import uuid7
|
||||
|
||||
from paskia import oidc_notify
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db.jsonl import (
|
||||
JsonlStore,
|
||||
@@ -484,7 +484,6 @@ def delete_session(
|
||||
"""
|
||||
if key not in _db.sessions:
|
||||
raise ValueError("Session not found")
|
||||
from paskia import oidc_notify # noqa: PLC0415
|
||||
|
||||
oidc_notify.schedule_notifications([key])
|
||||
with _db.transaction(action, ctx):
|
||||
@@ -503,7 +502,6 @@ def delete_sessions_for_user(
|
||||
user = _db.users.get(user_uuid)
|
||||
if not user:
|
||||
return
|
||||
from paskia import oidc_notify # noqa: PLC0415
|
||||
|
||||
keys = [s.key for s in user.sessions]
|
||||
oidc_notify.schedule_notifications(keys)
|
||||
@@ -589,7 +587,7 @@ def login(
|
||||
session = Session.create(
|
||||
user=user_uuid,
|
||||
credential=credential_uuid,
|
||||
key=base64url.enc(hash_secret("cookie", token)),
|
||||
key=hash_secret("cookie", token),
|
||||
host=host,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
@@ -657,7 +655,7 @@ def create_credential_session(
|
||||
|
||||
# Generate token and derive key
|
||||
token = secrets.token_urlsafe(12)
|
||||
key = base64url.enc(hash_secret("cookie", token))
|
||||
key = hash_secret("cookie", token)
|
||||
|
||||
session = Session.create(
|
||||
user=user_uuid,
|
||||
|
||||
@@ -5,7 +5,6 @@ import secrets
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
import base64url
|
||||
import msgspec
|
||||
import uuid7
|
||||
|
||||
@@ -434,7 +433,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True):
|
||||
"""Create a new Session with the provided key.
|
||||
|
||||
Args:
|
||||
key: The base64url-encoded hashed session key (derived from secret via hash_secret then base64url.enc)
|
||||
key: The hashed session key (derived from secret via hash_secret)
|
||||
|
||||
Returns:
|
||||
Session object with key set
|
||||
@@ -471,7 +470,7 @@ class ResetToken(msgspec.Struct, dict=True):
|
||||
|
||||
def __post_init__(self):
|
||||
if not hasattr(self, "key"):
|
||||
self.key: bytes = b""
|
||||
self.key: str = ""
|
||||
|
||||
@property
|
||||
def user(self) -> User:
|
||||
@@ -487,15 +486,15 @@ class ResetToken(msgspec.Struct, dict=True):
|
||||
del db.data().reset_tokens[self.key]
|
||||
|
||||
@staticmethod
|
||||
def hash(passphrase: str) -> bytes:
|
||||
"""Hash a passphrase to bytes for reset token storage."""
|
||||
def hash(passphrase: str) -> str:
|
||||
"""Hash a passphrase to string for reset token storage."""
|
||||
if not passphrase_util.is_well_formed(passphrase):
|
||||
raise ValueError(
|
||||
"Trying to reset with a session token in place of a passphrase"
|
||||
if len(passphrase) == 16
|
||||
else "Invalid passphrase format"
|
||||
)
|
||||
return hashlib.sha512(passphrase.encode()).digest()[:9]
|
||||
return hash_secret("reset", passphrase)
|
||||
|
||||
@classmethod
|
||||
def by_passphrase(cls, passphrase: str) -> ResetToken | None:
|
||||
@@ -627,7 +626,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
users: dict[UUID, User] = {}
|
||||
credentials: dict[UUID, Credential] = {}
|
||||
sessions: dict[str, Session] = {}
|
||||
reset_tokens: dict[bytes, ResetToken] = {}
|
||||
reset_tokens: dict[str, ResetToken] = {}
|
||||
# OIDC provider data
|
||||
oidc: OIDC = msgspec.field(default_factory=lambda: OIDC())
|
||||
|
||||
@@ -670,7 +669,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
SessionContext if valid, None if session not found, expired, or host mismatch
|
||||
"""
|
||||
|
||||
key = base64url.enc(hash_secret("cookie", session_secret))
|
||||
key = hash_secret("cookie", session_secret)
|
||||
try:
|
||||
s = self.sessions[key]
|
||||
except KeyError:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Body, FastAPI, HTTPException, Query, Request, Response
|
||||
from fastapi import Body, FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from paskia import aaguid as aaguid_mod
|
||||
@@ -14,6 +14,7 @@ from paskia.db import User as UserDC
|
||||
from paskia.db.operations import _UNSET
|
||||
from paskia.db.structs import Client
|
||||
from paskia.fastapi import authz
|
||||
from paskia.fastapi.front import frontend
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.globals import passkey
|
||||
@@ -78,7 +79,7 @@ async def general_exception_handler(_request, exc: Exception): # pragma: no cov
|
||||
|
||||
@app.get("/")
|
||||
async def adminapp(request: Request, auth=AUTH_COOKIE):
|
||||
return Response(*await vitedev.read("/auth/admin/index.html"))
|
||||
return await vitedev.handle(request, frontend, "/auth/admin/")
|
||||
|
||||
|
||||
# -------------------- Organizations --------------------
|
||||
|
||||
+13
-17
@@ -20,7 +20,7 @@ from paskia.fastapi import authz, session, user
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
|
||||
from paskia.globals import passkey as global_passkey
|
||||
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
|
||||
from paskia.util import hostutil, htmlutil, passphrase, userinfo
|
||||
from paskia.util.apistructs import ApiSettings, ApiTokenInfo, ApiValidateResponse
|
||||
|
||||
bearer_auth = HTTPBearer(auto_error=False)
|
||||
@@ -161,24 +161,14 @@ async def forward_authentication(
|
||||
# Clear cookie only if session is invalid (not for reauth)
|
||||
if e.clear_session:
|
||||
session.clear_session_cookie(response)
|
||||
|
||||
# Check Accept header to decide response format
|
||||
accept = request.headers.get("accept", "")
|
||||
wants_html = "text/html" in accept
|
||||
|
||||
if wants_html:
|
||||
# Browser request - return full-page HTML with metadata
|
||||
data_attrs = {"mode": e.mode, **e.metadata}
|
||||
html = (await vitedev.read("/int/forward/index.html"))[0]
|
||||
html = htmlutil.patch_html_data_attrs(html, **data_attrs)
|
||||
return Response(
|
||||
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
|
||||
# Browser request? - return full-page HTML with metadata patched into data attrs
|
||||
if "text/html" in request.headers.get("accept", ""):
|
||||
return await htmlutil.patched_html_response(
|
||||
request, "/int/forward/", e.status_code, mode=e.mode, **e.metadata
|
||||
)
|
||||
else:
|
||||
# API request - return JSON with iframe srcdoc HTML
|
||||
return JSONResponse(
|
||||
status_code=e.status_code,
|
||||
content=await authz.auth_error_content(e),
|
||||
status_code=e.status_code, content=await authz.auth_error_content(e)
|
||||
)
|
||||
|
||||
|
||||
@@ -214,7 +204,12 @@ async def api_user_info(
|
||||
)
|
||||
ctx = db.data().session_ctx(auth, request.headers.get("host"))
|
||||
if not ctx:
|
||||
raise HTTPException(401, "Session expired")
|
||||
raise authz.AuthException(
|
||||
status_code=401,
|
||||
detail="Session expired",
|
||||
mode="login",
|
||||
clear_session=True,
|
||||
)
|
||||
|
||||
return MsgspecResponse(
|
||||
await userinfo.build_user_info(
|
||||
@@ -244,6 +239,7 @@ async def token_info(credentials=Depends(bearer_auth)):
|
||||
ApiTokenInfo(
|
||||
token_type=reset_token.token_type,
|
||||
display_name=u.display_name,
|
||||
theme=u.theme,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi_vue import Frontend
|
||||
|
||||
# Vue Frontend static files
|
||||
frontend = Frontend(
|
||||
Path(__file__).parent.parent / "frontend-build",
|
||||
cached=["/auth/assets/"],
|
||||
favicon="/paskia.webp",
|
||||
)
|
||||
+21
-17
@@ -6,13 +6,18 @@ from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi_vue import Frontend
|
||||
|
||||
from paskia import authcode, globals
|
||||
from paskia import authcode, db, globals
|
||||
from paskia.__main__ import DEVMODE
|
||||
from paskia.bootstrap import bootstrap_if_needed
|
||||
from paskia.db import start_background, stop_background
|
||||
from paskia.db.background import flush
|
||||
from paskia.db.logging import configure_db_logging
|
||||
from paskia.db.structs import Config
|
||||
from paskia.fastapi import admin, api, auth_host, oid, ws
|
||||
|
||||
# Import frontend instance
|
||||
from paskia.fastapi.front import frontend
|
||||
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.util import hostutil, passphrase, vitedev
|
||||
@@ -23,14 +28,6 @@ configure_db_logging()
|
||||
|
||||
_access_logger = logging.getLogger("paskia.access")
|
||||
|
||||
# Vue Frontend static files
|
||||
frontend = Frontend(
|
||||
Path(__file__).parent.parent / "frontend-build",
|
||||
cached=["/auth/assets/"],
|
||||
favicon="/paskia.webp",
|
||||
)
|
||||
|
||||
|
||||
# Path to examples/index.html when running from source tree
|
||||
_EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples"
|
||||
|
||||
@@ -46,7 +43,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
config = json.loads(os.environ["PASKIA_CONFIG"])
|
||||
|
||||
try:
|
||||
# CLI (__main__) performs bootstrap once; here we skip to avoid duplicate work
|
||||
await globals.init(
|
||||
rp_id=config["rp_id"],
|
||||
rp_name=config["rp_name"],
|
||||
@@ -58,6 +54,15 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
||||
# Re-raise to fail fast
|
||||
raise
|
||||
|
||||
# Bootstrap and persist config now that the full DB is loaded
|
||||
cli_config_data = config.get("cli_config")
|
||||
if cli_config_data:
|
||||
cli_config = Config(**cli_config_data)
|
||||
await bootstrap_if_needed(config=cli_config)
|
||||
if config.get("save"):
|
||||
await db.update_config(cli_config)
|
||||
await flush()
|
||||
|
||||
# Restore uvicorn info logging (suppressed during startup in dev mode)
|
||||
# Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages
|
||||
if app.debug:
|
||||
@@ -131,9 +136,9 @@ async def openid_configuration(request: Request):
|
||||
|
||||
@app.get("/auth/restricted/iframe")
|
||||
@app.get("/auth/restricted/oidc")
|
||||
async def restricted_view():
|
||||
async def restricted_view(request: Request):
|
||||
"""Serve the restricted/authentication UI for iframe or OpenID Connect."""
|
||||
return Response(*await vitedev.read("/auth/restricted/index.html"))
|
||||
return await vitedev.handle(request, frontend, "/auth/restricted/")
|
||||
|
||||
|
||||
# Navigable URLs are defined here. We support both / and /auth/ as the base path
|
||||
@@ -148,7 +153,7 @@ async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE):
|
||||
The frontend handles mode detection (host mode vs full profile) based on settings.
|
||||
Access control is handled via APIs.
|
||||
"""
|
||||
return Response(*await vitedev.read("/auth/index.html"))
|
||||
return await vitedev.handle(request, frontend, "/auth/")
|
||||
|
||||
|
||||
@app.get("/admin", include_in_schema=False)
|
||||
@@ -180,14 +185,13 @@ async def examples_page():
|
||||
|
||||
|
||||
# Frontend static files - must be before /{token} catch-all routes
|
||||
# (actual routes registered during lifespan after frontend.load())
|
||||
frontend.route(app, "/")
|
||||
|
||||
|
||||
# Note: this catch-all handler must be the last route defined
|
||||
@app.get("/{token}")
|
||||
@app.get("/auth/{token}")
|
||||
async def token_link(token: str):
|
||||
async def token_link(request: Request, token: str):
|
||||
"""Serve the reset app for reset tokens (password reset / device addition).
|
||||
|
||||
The frontend will validate the token via /auth/api/token-info.
|
||||
@@ -195,4 +199,4 @@ async def token_link(token: str):
|
||||
if not passphrase.is_well_formed(token):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
return Response(*await vitedev.read("/int/reset/index.html"))
|
||||
return await vitedev.handle(request, frontend, "/int/reset/")
|
||||
|
||||
@@ -40,7 +40,7 @@ def _oidc_session_by_token(
|
||||
token: str, client_uuid: UUID | None = None
|
||||
) -> Session | None:
|
||||
"""Look up an OIDC session by token (refresh token value)."""
|
||||
key = base64url.enc(hash_secret("oidc", token))
|
||||
key = hash_secret("oidc", token)
|
||||
s = db.data().sessions.get(key)
|
||||
if not s or s.client_uuid is None:
|
||||
return None
|
||||
|
||||
@@ -3,7 +3,6 @@ from datetime import UTC, datetime
|
||||
from urllib.parse import urlencode
|
||||
from uuid import UUID
|
||||
|
||||
import base64url
|
||||
from fastapi import FastAPI, WebSocket
|
||||
|
||||
from paskia import authcode, db
|
||||
@@ -218,7 +217,7 @@ async def websocket_authenticate(
|
||||
session = Session.create(
|
||||
user=cred.user_uuid,
|
||||
credential=cred.uuid,
|
||||
key=base64url.enc(hash_secret("oidc", token)),
|
||||
key=hash_secret("oidc", token),
|
||||
host=normalized_host,
|
||||
ip=metadata["ip"],
|
||||
user_agent=metadata["user_agent"],
|
||||
|
||||
@@ -171,11 +171,12 @@ class ApiSettings(msgspec.Struct):
|
||||
version: str
|
||||
|
||||
|
||||
class ApiTokenInfo(msgspec.Struct):
|
||||
class ApiTokenInfo(msgspec.Struct, omit_defaults=True):
|
||||
"""Token info response struct."""
|
||||
|
||||
token_type: str
|
||||
display_name: str
|
||||
theme: str = ""
|
||||
|
||||
|
||||
class ApiUuidResponse(msgspec.Struct):
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import hashlib
|
||||
|
||||
import base64url
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
|
||||
def hash_secret(*data) -> bytes:
|
||||
"""A custom HMAC that securily combines and hashes the given data (context, secrets). The first argument should be a namespacing string."""
|
||||
inner = bytearray(len(data).to_bytes(8, "big"))
|
||||
for d in data:
|
||||
if isinstance(d, str):
|
||||
d = d.encode()
|
||||
inner += hashlib.sha256(d).digest()
|
||||
return hashlib.sha256(inner).digest()[:12]
|
||||
def hash_secret(*data: str | bytes, length=12) -> str:
|
||||
"""A custom HMAC that securily combines and hashes the given data. The first argument should be a namespacing string."""
|
||||
p = [d.encode() if hasattr(d, "encode") else d for d in data]
|
||||
p += [len(x).to_bytes(8, "little") for x in [p, *p]]
|
||||
return base64url.enc(hashlib.sha256(b"".join(p)).digest()[:length])
|
||||
|
||||
|
||||
def secret_key() -> bytes:
|
||||
|
||||
@@ -11,7 +11,7 @@ __all__ = ["path", "file", "read", "is_dev_mode"]
|
||||
|
||||
def _get_dev_server() -> str | None:
|
||||
"""Get the dev server URL from environment, or None if not in dev mode."""
|
||||
return os.environ.get("FASTAPI_VUE_FRONTEND_URL") or None
|
||||
return os.environ.get("PASKIA_VITE_URL") or None
|
||||
|
||||
|
||||
def _resolve_static_dir() -> Path:
|
||||
|
||||
@@ -2,6 +2,45 @@
|
||||
|
||||
import re
|
||||
|
||||
from paskia.fastapi.front import frontend
|
||||
from paskia.util import vitedev
|
||||
|
||||
|
||||
async def patched_html_response(request, filepath: str, status_code: int, **data_attrs):
|
||||
"""Fetch HTML from vitedev and patch with data attributes.
|
||||
|
||||
Strips caching/compression headers from request to get raw content,
|
||||
patches the HTML body with data attributes, and strips caching headers
|
||||
from response.
|
||||
|
||||
Args:
|
||||
request: The FastAPI Request object
|
||||
filepath: Path to HTML file, e.g. "/int/forward/"
|
||||
status_code: HTTP status code for the response
|
||||
**data_attrs: Key-value pairs for data attributes
|
||||
|
||||
Returns:
|
||||
Patched Response object, or original response if not 200.
|
||||
"""
|
||||
# Strip caching/compression headers to get raw uncompressed content
|
||||
cache_headers = {b"if-none-match", b"if-modified-since", b"accept-encoding"}
|
||||
request.scope["headers"] = [
|
||||
(k, v) for k, v in request.scope["headers"] if k.lower() not in cache_headers
|
||||
]
|
||||
|
||||
resp = await vitedev.handle(request, frontend, filepath)
|
||||
# Pass through non-200 responses
|
||||
if resp.status_code != 200:
|
||||
return resp
|
||||
# Patch HTML with data attrs and strip caching headers from response
|
||||
resp.body = patch_html_data_attrs(resp.body, **data_attrs)
|
||||
resp.status_code = status_code
|
||||
strip_headers = {b"etag", b"last-modified", b"content-length"}
|
||||
resp.raw_headers = [
|
||||
(k, v) for k, v in resp.raw_headers if k.lower() not in strip_headers
|
||||
]
|
||||
return resp
|
||||
|
||||
|
||||
def patch_html_data_attrs(html: bytes, **data_attrs: str) -> bytes:
|
||||
"""Patch HTML by adding data attributes to the <html> tag.
|
||||
|
||||
@@ -69,7 +69,7 @@ def print_startup_config(config: "PaskiaConfig") -> None:
|
||||
lines.append(line(f"Auth Host: {config.auth_host}"))
|
||||
|
||||
# Show frontend URL if in dev mode
|
||||
devmode = os.environ.get("FASTAPI_VUE_FRONTEND_URL")
|
||||
devmode = os.environ.get("PASKIA_VITE_URL")
|
||||
if devmode:
|
||||
lines.append(line(f"Dev Frontend: {devmode}"))
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ from paskia.db import SessionContext
|
||||
from paskia.util import hostutil
|
||||
from paskia.util.apistructs import (
|
||||
ApiAaguidInfo,
|
||||
ApiOrg,
|
||||
ApiOrgContext,
|
||||
ApiPermission,
|
||||
ApiRole,
|
||||
ApiRoleContext,
|
||||
ApiSessionContext,
|
||||
ApiUser,
|
||||
@@ -64,4 +66,6 @@ async def build_user_info(
|
||||
permissions={p.uuid: ApiPermission.from_db(p) for p in ctx.permissions}
|
||||
if ctx
|
||||
else {},
|
||||
org=ApiOrg.from_db(ctx.org) if ctx else None,
|
||||
role=ApiRole.from_db(ctx.role) if ctx else None,
|
||||
)
|
||||
|
||||
+22
-27
@@ -1,39 +1,29 @@
|
||||
"""Vite dev server proxy for fetching frontend files during development.
|
||||
|
||||
In dev mode (FASTAPI_VUE_FRONTEND_URL set), fetches files from Vite.
|
||||
In dev mode (PASKIA_VITE_URL set), fetches files from Vite.
|
||||
In production, reads from the static build directory.
|
||||
|
||||
This complements fastapi_vue.Frontend which handles static file serving
|
||||
but doesn't provide server-side fetching of HTML content.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import os
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import Response
|
||||
|
||||
__all__ = ["read"]
|
||||
|
||||
|
||||
def _get_dev_server() -> str | None:
|
||||
"""Get the dev server URL from environment, or None if not in dev mode."""
|
||||
return os.environ.get("FASTAPI_VUE_FRONTEND_URL") or None
|
||||
__all__ = ["handle"]
|
||||
|
||||
|
||||
def _resolve_static_dir() -> Path:
|
||||
"""Resolve the static files directory."""
|
||||
|
||||
# Try packaged path via importlib.resources (works for wheel/installed).
|
||||
try: # pragma: no cover - trivial path resolution
|
||||
pkg_dir = resources.files("paskia") / "frontend-build"
|
||||
fs_path = Path(str(pkg_dir))
|
||||
if fs_path.is_dir():
|
||||
return fs_path
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pass
|
||||
# Fallback for editable/development before build.
|
||||
return Path(__file__).parent.parent / "frontend-build"
|
||||
|
||||
@@ -41,31 +31,36 @@ def _resolve_static_dir() -> Path:
|
||||
_static_dir: Path = _resolve_static_dir()
|
||||
|
||||
|
||||
async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]:
|
||||
"""Read file content and return response tuple.
|
||||
async def handle(request, frontend, filepath: str):
|
||||
"""Read file content and return Response.
|
||||
|
||||
In dev mode, fetches from the Vite dev server.
|
||||
In production, reads from the static build directory.
|
||||
In production, uses frontend.handle.
|
||||
|
||||
Args:
|
||||
request: The FastAPI Request object
|
||||
frontend: The fastapi_vue.Frontend instance
|
||||
filepath: Path relative to frontend root, e.g. "/auth/index.html"
|
||||
|
||||
Returns:
|
||||
Tuple of (content, status_code, headers) suitable for
|
||||
FastAPI Response(*args).
|
||||
FastAPI Response object.
|
||||
"""
|
||||
dev_server = _get_dev_server()
|
||||
if dev_server:
|
||||
if dev_server := os.environ.get("PASKIA_VITE_URL"):
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(f"{dev_server}{filepath}")
|
||||
resp.raise_for_status()
|
||||
mime = resp.headers.get("content-type", "application/octet-stream")
|
||||
# Strip charset suffix if present
|
||||
mime = mime.split(";")[0].strip()
|
||||
return resp.content, resp.status_code, {"content-type": mime}
|
||||
else:
|
||||
# Production: read from static build
|
||||
file_path = _static_dir / filepath.lstrip("/")
|
||||
content = await asyncio.to_thread(file_path.read_bytes)
|
||||
mime, _ = mimetypes.guess_type(str(file_path))
|
||||
return content, 200, {"content-type": mime or "application/octet-stream"}
|
||||
return Response(resp.content, resp.status_code, {"content-type": mime})
|
||||
|
||||
# Read from frontend cache directly to bypass any compression/processing
|
||||
cached_content = getattr(frontend, "_files", {}).get(filepath)
|
||||
if cached_content is not None:
|
||||
mime, _ = mimetypes.guess_type(filepath)
|
||||
return Response(
|
||||
cached_content, 200, {"content-type": mime or "application/octet-stream"}
|
||||
)
|
||||
|
||||
# Fallback to frontend.handle for cache negotiation
|
||||
return frontend.handle(request, filepath)
|
||||
|
||||
+17
-28
@@ -11,20 +11,28 @@ keywords = [ "forward_auth", "auth_request", "FastAPI" ]
|
||||
authors = [
|
||||
{name = "Leo Vasanko"},
|
||||
]
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.104.1",
|
||||
"websockets>=12.0",
|
||||
"webauthn>=1.11.1",
|
||||
"base64url>=1.0.0",
|
||||
"uuid7-standard>=1.0.0",
|
||||
"pyjwt[crypto]>=2.8.0",
|
||||
"fastapi[standard]>=0.129.0",
|
||||
"websockets>=16.0",
|
||||
"webauthn>=2.7.1",
|
||||
"base64url>=1.1.1",
|
||||
"uuid7-standard>=1.1.0",
|
||||
"pyjwt[crypto]>=2.11.0",
|
||||
"jsondiff>=2.2.1",
|
||||
"msgspec>=0.20.0",
|
||||
"aiofiles>=25.1.0",
|
||||
"fastapi-vue>=0.3.0",
|
||||
"fastapi-vue>=1.1.0",
|
||||
"ua-parser[regex]>=1.0.1",
|
||||
]
|
||||
requires-python = ">=3.11"
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"coverage>=7.13.4",
|
||||
"httpx>=0.28.1",
|
||||
"pytest>=9.0.2",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-cov>=7.0.0",
|
||||
"ruff>=0.15.1",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://git.zi.fi/LeoVasanko/paskia"
|
||||
@@ -36,15 +44,6 @@ source = "vcs"
|
||||
[tool.hatch.build.hooks.vcs]
|
||||
version-file = "paskia/_version.py"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff>=0.1.0",
|
||||
"coverage[toml]>=7.0.0",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["paskia"]
|
||||
branch = true
|
||||
@@ -75,16 +74,6 @@ select = ["E", "F", "I", "N", "W", "UP", "PLC0415"]
|
||||
ignore = ["E501"] # Line too long
|
||||
isort.known-first-party = ["paskia"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"coverage>=7.12.0",
|
||||
"httpx>=0.28.1",
|
||||
"pytest>=9.0.1",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-cov>=7.0.0",
|
||||
"ruff>=0.14.8",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
paskia = "paskia.__main__:main"
|
||||
|
||||
|
||||
+12
-16
@@ -153,7 +153,16 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
|
||||
paskia.extend(["--origin", origin])
|
||||
paskia.extend(remaining)
|
||||
|
||||
# Compute origins for Caddy
|
||||
# Set environment for subprocesses
|
||||
os.environ["PASKIA_VITE_URL"] = viteurl
|
||||
os.environ["PASKIA_BACKEND_URL"] = backurl
|
||||
os.environ["PASKIA_DEV"] = "1"
|
||||
if args.auth_host:
|
||||
os.environ["PASKIA_AUTH_HOST"] = args.auth_host
|
||||
|
||||
async with ProcessGroup() as pg:
|
||||
# Start Caddy first if requested (needs to bind ports)
|
||||
if args.caddy:
|
||||
caddy_origins = []
|
||||
if args.auth_host:
|
||||
auth_host = args.auth_host
|
||||
@@ -166,23 +175,10 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
|
||||
if "://" not in origin:
|
||||
origin = f"https://{origin}"
|
||||
caddy_origins.append(origin)
|
||||
if not args.auth_host and not args.origins:
|
||||
if not caddy_origins:
|
||||
caddy_origins.append(f"https://{args.rp_id}")
|
||||
# Remove duplicates while preserving order
|
||||
seen = set()
|
||||
seen: set = set()
|
||||
caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))]
|
||||
|
||||
# Set environment for subprocesses
|
||||
os.environ["PASKIA_VITE_URL"] = viteurl
|
||||
os.environ["PASKIA_BACKEND_URL"] = backurl
|
||||
os.environ["PASKIA_SITE_URL"] = caddy_origins[0] if args.caddy else viteurl
|
||||
os.environ["PASKIA_DEV"] = "1"
|
||||
if args.auth_host:
|
||||
os.environ["PASKIA_AUTH_HOST"] = args.auth_host
|
||||
|
||||
async with ProcessGroup() as pg:
|
||||
# Start Caddy first if requested (needs to bind ports)
|
||||
if args.caddy:
|
||||
caddy_proc = await run_caddy(caddy_origins, viteurl, backurl)
|
||||
pg._procs.append(caddy_proc)
|
||||
pg._cmds[caddy_proc.pid] = "caddy"
|
||||
|
||||
+1
-2
@@ -19,7 +19,6 @@ from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
import base64url
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
@@ -268,7 +267,7 @@ def create_test_session(
|
||||
|
||||
# Generate token and derive key
|
||||
token = secrets.token_urlsafe(12)
|
||||
key = base64url.enc(hash_secret("cookie", token))
|
||||
key = hash_secret("cookie", token)
|
||||
|
||||
session = Session.create(
|
||||
user=user_uuid,
|
||||
|
||||
+1
-21
@@ -16,7 +16,6 @@ import secrets
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
import base64url
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
@@ -188,25 +187,6 @@ class TestExceptionHandlers:
|
||||
assert "iframe" in data["auth"]
|
||||
|
||||
|
||||
# -------------------- Admin App Root --------------------
|
||||
|
||||
|
||||
class TestAdminAppRoot:
|
||||
"""Tests for the admin app root endpoint"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_app_root_with_auth(
|
||||
self, client: httpx.AsyncClient, session_token: str
|
||||
):
|
||||
"""Admin app root returns HTML when authenticated."""
|
||||
response = await client.get(
|
||||
"/auth/api/admin/",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers.get("content-type", "")
|
||||
|
||||
|
||||
# -------------------- Organization Tests --------------------
|
||||
|
||||
|
||||
@@ -1320,7 +1300,7 @@ class TestAdminSessions:
|
||||
test_user,
|
||||
):
|
||||
"""Admin can delete their own current session."""
|
||||
session_db_key = base64url.enc(hash_secret("cookie", session_token))
|
||||
session_db_key = hash_secret("cookie", session_token)
|
||||
response = await client.delete(
|
||||
f"/auth/api/admin/users/{test_user.uuid}/sessions/{session_db_key}",
|
||||
headers={**auth_headers(session_token), "Host": "localhost:4401"},
|
||||
|
||||
@@ -355,34 +355,6 @@ class TestErrorHandling:
|
||||
class TestForwardAuthHtmlResponse:
|
||||
"""Tests for forward auth HTML responses"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_401_html_response(self, client: httpx.AsyncClient):
|
||||
"""Forward auth 401 should return HTML page for browser requests."""
|
||||
response = await client.get(
|
||||
"/auth/api/forward",
|
||||
headers={"Accept": "text/html"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert "text/html" in response.headers.get("content-type", "")
|
||||
# HTML response should contain the mode data attribute
|
||||
assert b"data-mode" in response.content or b"mode" in response.content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_403_html_response(
|
||||
self, client: httpx.AsyncClient, regular_session_token: str
|
||||
):
|
||||
"""Forward auth 403 should return HTML page for browser requests."""
|
||||
response = await client.get(
|
||||
"/auth/api/forward?perm=auth:admin",
|
||||
headers={
|
||||
**auth_headers(regular_session_token),
|
||||
"Host": "localhost:4401",
|
||||
"Accept": "text/html",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "text/html" in response.headers.get("content-type", "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forward_with_expired_session_clears_cookie(
|
||||
self, client: httpx.AsyncClient
|
||||
|
||||
Reference in New Issue
Block a user