Compare commits

...
24 Commits
Author SHA1 Message Date
LeoVasanko 632278d4ce Change CLI to use --listen rather than positional hostport argument. Fix listening on all interfaces (runs lifespan twice, which needs to be OK). Update README with various CLI changes and more. 2026-02-05 02:54:07 +00:00
LeoVasanko 3faaeee7be Add missing __init__ that was causing ModuleFoundError trying to import paskia.util. 2026-02-05 02:33:05 +00:00
LeoVasanko 0f51f8501d Console startup box themed as well. 2026-01-31 00:30:14 +00:00
LeoVasanko a7e6eb7341 Missing theme file. Added favicon. 2026-01-31 00:25:38 +00:00
LeoVasanko 1800dc12ae Light/dark selection in user profile, if set this is preferred on the whole system, together with app overrides (the first one on the URL wins). 2026-01-30 23:31:06 +00:00
LeoVasanko cc55474e62 Light/dark override for in-app login dialogs to match app style e.g. light only 2026-01-30 22:59:52 +00:00
LeoVasanko 8ac2c8e5fa Screenshots on README. 2026-01-29 22:50:54 +00:00
LeoVasanko 2ee8ddf1d1 Log proper action for delete_session depending on what function it performs: logout, delete_session (user's own) or admin:delete_session. 2026-01-29 22:44:22 +00:00
LeoVasanko d58b3742b1 Smarter change logging on console, properly display newly created items (not available via jsondiffs). 2026-01-29 22:39:16 +00:00
LeoVasanko 5879be39a5 Better change entry logging. 2026-01-29 22:09:26 +00:00
LeoVasanko 5b3406025c Silence type checker about our unconventional imports. 2026-01-29 22:09:03 +00:00
LeoVasanko dda57ac27d Fix WS access log IP margin size change missed in earlier commit. 2026-01-29 21:39:30 +00:00
LeoVasanko 5e12dcba76 Add paskia-js to Python build; update build-frontend to use fastapi-vue utils for JS_RUNTIME that also can be set via env now. Fix paskia-js compatibility issue with deno, now works with all three runtimes. 2026-01-29 21:33:09 +00:00
LeoVasanko be7a9e7f00 Smarter access log formatting with IPv6. 2026-01-29 21:17:50 +00:00
LeoVasanko 3d2151fed7 Add support for perm=foo+bar to specify multiple scopes that are required. Multiple perm args are acceptable too. Pretty logging of permission denied errors. 2026-01-29 21:11:29 +00:00
LeoVasanko 58b56a09a4 Restrict remote auth to the existing granting user's credentials. 2026-01-29 20:24:26 +00:00
LeoVasanko 731b36b456 Cleanup auth WS code, refactor to remove duplication and pass proper context. 2026-01-29 20:15:01 +00:00
LeoVasanko 8f9cd1124c Add missing context on update_session. 2026-01-29 19:51:56 +00:00
LeoVasanko 7e49ef296a Create a stand-alone paskia npm package (paskia-js). Make the frontend use it (but from source tree to keep synced). 2026-01-29 19:46:26 +00:00
LeoVasanko af35ff3d4c Add a screenshot. 2026-01-29 19:15:17 +00:00
LeoVasanko dac1415a86 More of a normal white path color. 2026-01-29 16:57:30 +00:00
LeoVasanko 433844cf08 Refactor to separate paskia lib functionality generally useful for various apps. 2026-01-29 16:55:46 +00:00
LeoVasanko c9ea1c8948 Consistent and stronger session revalidation checks in Auth profile and Admin App. Fix missing handling of link generation network errors. 2026-01-29 16:02:29 +00:00
LeoVasanko 58f46c6abf Logging cleanup, better colors, suppress more useless messages. Enable reloading in devmode again (needs uvicorn.run to function). 2026-01-29 14:48:59 +00:00
54 changed files with 1328 additions and 742 deletions
+13 -10
View File
@@ -1,5 +1,7 @@
# Paskia # Paskia
![Screenshot](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/forbidden-light.webp)
An easy to install passkey-based authentication service that protects any web application with strong passwordless login. An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
## What is Paskia? ## What is Paskia?
@@ -27,12 +29,12 @@ Single Sign-On (SSO): Users register once and authenticate across all applicatio
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run: Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
```fish ```fish
uvx paskia serve --rp-id example.com uvx paskia --rp-id example.com
``` ```
On the first run it downloads the software and prints a registration link for the Admin. The server will start up on [localhost:4401](http://localhost:4401) *for authentication required*, serving for `*.example.com`. If you are going to be connecting `localhost` directly, for testing, leave out the rp-id. On the first run it downloads the software and prints a registration link for the Admin. The server starts on [localhost:4401](http://localhost:4401), serving authentication for `*.example.com`. For local testing, leave out `--rp-id`.
Otherwise you will need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps (see documentation below). For production you need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps (see documentation below).
For a permanent install of `paskia` CLI command, not needing `uvx`: For a permanent install of `paskia` CLI command, not needing `uvx`:
@@ -42,19 +44,20 @@ uv tool install paskia
## Configuration ## Configuration
There is no config file. Pass only the options on CLI: There is no config file. All settings are passed as CLI options:
```text ```text
paskia serve [options] paskia [options]
paskia reset [user] # Generate passkey reset link
``` ```
| Option | Description | Default | | Option | Description | Default |
|--------|-------------|---------| |--------|-------------|---------|
| Listen address | One of *host***:***port* (default all hosts, port 4401) or **unix:***path***/paskia.socket** (Unix socket) | **localhost:4401** | | -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** |
| --rp-id *domain* | Main/top domain | **localhost** | | --rp-id *domain* | Main/top domain for passkeys | **localhost** |
| --rp-name *"text"* | Name of your company or site | Same as rp-id | | --rp-name *"text"* | Name shown during passkey registration | Same as rp-id |
| --origin *url* | Explicitly list the domain names served | **https://**_rp-id_ | | --origin *url* | Restrict allowed origins for WebSocket auth (repeatable) | All under rp-id |
| --auth-host *domain* | Dedicated authentication site (e.g., **auth.example.com**) | **Unspecified:** we use **/auth/** on **every** site under rp-id.| | --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
## Further Documentation ## Further Documentation
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+14 -48
View File
@@ -13,7 +13,8 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { apiJson, getAuthIframeUrl } from '@/utils/api' import { apiJson, SessionValidator, createAuthIframe, removeAuthIframe } from 'paskia'
import { getAuthIframeUrl } from '@/utils/api'
import StatusMessage from '@/components/StatusMessage.vue' import StatusMessage from '@/components/StatusMessage.vue'
import ProfileView from '@/components/ProfileView.vue' import ProfileView from '@/components/ProfileView.vue'
import HostProfileView from '@/components/HostProfileView.vue' import HostProfileView from '@/components/HostProfileView.vue'
@@ -46,19 +47,23 @@ const isHostMode = computed(() => {
const configuredHost = normalizeHost(authHost) const configuredHost = normalizeHost(authHost)
return currentHost !== configuredHost return currentHost !== configuredHost
}) })
let validationTimer = null const userUuid = computed(() => store.userInfo?.ctx.user.uuid)
let authIframe = null
function terminateSession() { function terminateSession() {
store.userInfo = null store.userInfo = null
viewState.value = 'terminal' viewState.value = 'terminal'
} }
const userUuidGetter = () => store.userInfo?.ctx.user.uuid
const sessionValidator = new SessionValidator(userUuidGetter, terminateSession)
onMounted(() => sessionValidator.start())
onUnmounted(() => sessionValidator.stop())
async function loadUserInfo() { async function loadUserInfo() {
try { try {
store.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' }) store.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
viewState.value = 'profile' viewState.value = 'profile'
startSessionValidation()
return true return true
} catch { } catch {
store.userInfo = null store.userInfo = null
@@ -67,27 +72,11 @@ async function loadUserInfo() {
} }
async function showAuthIframe() { async function showAuthIframe() {
// Remove existing iframe if any
hideAuthIframe()
// Create new iframe for authentication using src URL
const url = await getAuthIframeUrl('login') const url = await getAuthIframeUrl('login')
authIframe = document.createElement('iframe') createAuthIframe(url)
authIframe.id = 'auth-iframe'
authIframe.title = 'Authentication'
authIframe.allow = 'publickey-credentials-get; publickey-credentials-create'
authIframe.src = url
document.body.appendChild(authIframe)
loadingMessage.value = 'Authentication required...' loadingMessage.value = 'Authentication required...'
} }
function hideAuthIframe() {
if (authIframe) {
authIframe.remove()
authIframe = null
}
}
function handleAuthMessage(event) { function handleAuthMessage(event) {
const data = event.data const data = event.data
if (!data?.type) return if (!data?.type) return
@@ -95,7 +84,7 @@ function handleAuthMessage(event) {
switch (data.type) { switch (data.type) {
case 'auth-success': case 'auth-success':
// Authentication successful - reload user info // Authentication successful - reload user info
hideAuthIframe() removeAuthIframe()
viewState.value = 'loading' viewState.value = 'loading'
loadingMessage.value = 'Loading user profile...' loadingMessage.value = 'Loading user profile...'
loadUserInfo() loadUserInfo()
@@ -117,39 +106,17 @@ function handleAuthMessage(event) {
case 'auth-back': case 'auth-back':
// User clicked Back - show terminal state // User clicked Back - show terminal state
hideAuthIframe() removeAuthIframe()
terminateSession() terminateSession()
break break
case 'auth-close-request': case 'auth-close-request':
// Legacy support - treat as back // Legacy support - treat as back
hideAuthIframe() removeAuthIframe()
break break
} }
} }
async function validateSession() {
try {
await apiJson('/auth/api/validate', { method: 'POST' })
} catch {
stopSessionValidation()
terminateSession()
}
}
function startSessionValidation() {
// Validate session every 2 minutes
stopSessionValidation()
validationTimer = setInterval(validateSession, 2 * 60 * 1000)
}
function stopSessionValidation() {
if (validationTimer) {
clearInterval(validationTimer)
validationTimer = null
}
}
onMounted(async () => { onMounted(async () => {
// Listen for postMessage from auth iframe // Listen for postMessage from auth iframe
window.addEventListener('message', handleAuthMessage) window.addEventListener('message', handleAuthMessage)
@@ -178,8 +145,7 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('message', handleAuthMessage) window.removeEventListener('message', handleAuthMessage)
stopSessionValidation() removeAuthIframe()
hideAuthIframe()
}) })
</script> </script>
+17 -7
View File
@@ -12,7 +12,7 @@ import AdminUserDetail from '@/admin/AdminUserDetail.vue'
import AdminDialogs from '@/admin/AdminDialogs.vue' import AdminDialogs from '@/admin/AdminDialogs.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings' import { adminUiPath, makeUiHref } from '@/utils/settings'
import { apiJson } from '@/utils/api' import { apiJson, SessionValidator } from 'paskia'
import { getDirection } from '@/utils/keynav' import { getDirection } from '@/utils/keynav'
import { goBack } from '@/utils/helpers' import { goBack } from '@/utils/helpers'
@@ -157,6 +157,21 @@ function clearSensitiveState() {
authenticated.value = false authenticated.value = false
} }
function onSessionLost(e) {
clearSensitiveState()
if (e.name === 'AuthCancelledError') {
showBackMessage.value = true
} else {
error.value = e.message
}
}
const userUuidGetter = () => info.value?.ctx.user.uuid
const sessionValidator = new SessionValidator(userUuidGetter, onSessionLost)
onMounted(() => sessionValidator.start())
onUnmounted(() => sessionValidator.stop())
async function load() { async function load() {
loading.value = true loading.value = true
loadingMessage.value = 'Loading...' loadingMessage.value = 'Loading...'
@@ -177,12 +192,7 @@ async function load() {
} }
} else parseHash() } else parseHash()
} catch (e) { } catch (e) {
clearSensitiveState() onSessionLost(e)
if (e.name === 'AuthCancelledError') {
showBackMessage.value = true
} else {
error.value = e.message
}
} finally { } finally {
loading.value = false loading.value = false
} }
+3
View File
@@ -1,3 +1,6 @@
import { initThemeFromCache } from '@/utils/theme'
initThemeFromCache()
import '@/assets/style.css' import '@/assets/style.css'
import { createApp } from 'vue' import { createApp } from 'vue'
+3
View File
@@ -1,3 +1,6 @@
import { initThemeFromCache } from '@/utils/theme'
initThemeFromCache()
import '@/assets/style.css' import '@/assets/style.css'
import { createApp } from 'vue' import { createApp } from 'vue'
+4 -9
View File
@@ -8,7 +8,7 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import RestrictedAuth from '@/components/RestrictedAuth.vue' import RestrictedAuth from '@/components/RestrictedAuth.vue'
// Check if this is a remote auth URL: /auth/{token} // Check if this is a remote auth URL: /auth/{token}
@@ -30,14 +30,9 @@ function extractRemoteToken() {
return null return null
} }
// Detect mode from URL hash fragment // Parse URL hash fragment
const authMode = computed(() => { const hashParams = new URLSearchParams(window.location.hash.slice(1))
const params = new URLSearchParams(window.location.hash.slice(1)) const authMode = ['reauth', 'forbidden'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
const mode = params.get('mode')
if (mode === 'reauth') return 'reauth'
if (mode === 'forbidden') return 'forbidden'
return 'login'
})
function postToParent(message) { function postToParent(message) {
if (window.parent && window.parent !== window) { if (window.parent && window.parent !== window) {
+1
View File
@@ -1,3 +1,4 @@
import './theme.js'
import { createApp } from 'vue' import { createApp } from 'vue'
import RestrictedApi from './RestrictedApi.vue' import RestrictedApi from './RestrictedApi.vue'
import '@/assets/style.css' import '@/assets/style.css'
+11
View File
@@ -0,0 +1,11 @@
// Early theme for restricted app - first URL param wins, then localStorage
import { themeColors, applyTheme, getCachedTheme } from '@/utils/theme.js'
function getTheme() {
const params = new URLSearchParams(location.hash.slice(1))
return params.get('theme') || getCachedTheme() || ''
}
// Use .surface selector to preserve transparent background
applyTheme(getTheme(), '.surface')
addEventListener('hashchange', () => applyTheme(getTheme(), '.surface'))
+1 -1
View File
@@ -59,7 +59,7 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings' import { getSettings, uiBasePath } from '@/utils/settings'
import { apiJson, ApiError, getUserFriendlyErrorMessage } from '@/utils/api' import { apiJson, ApiError, getUserFriendlyErrorMessage } from 'paskia'
const status = reactive({ const status = reactive({
show: false, show: false,
+1
View File
@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/browser": "^13.1.2",
"paskia": "file:../paskia-js",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"sirv": "^3.0.2", "sirv": "^3.0.2",
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+1 -1
View File
@@ -5,7 +5,7 @@ import CredentialList from '@/components/CredentialList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import SessionList from '@/components/SessionList.vue' import SessionList from '@/components/SessionList.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { apiJson } from '@/utils/api' import { apiJson } from 'paskia'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav' import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
const props = defineProps({ const props = defineProps({
+1 -37
View File
@@ -78,7 +78,6 @@ html {
} }
body { body {
color-scheme: light dark;
overflow: auto; overflow: auto;
scrollbar-gutter: stable; scrollbar-gutter: stable;
height: 100%; height: 100%;
@@ -132,6 +131,7 @@ a:focus-visible {
} }
.view-root { .view-root {
position: relative;
flex: 1; flex: 1;
width: 100%; width: 100%;
display: flex; display: flex;
@@ -731,42 +731,6 @@ th {
} }
} }
/* Global backdrop controlled by api.js ref-counting */
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 1099;
background: transparent;
backdrop-filter: blur(0) brightness(1);
-webkit-backdrop-filter: blur(0) brightness(1);
pointer-events: none;
visibility: hidden;
transition: all 0.2s ease-out;
}
body.has-backdrop::before {
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.5);
visibility: visible;
}
body.has-backdrop {
overflow: auto;
}
#auth-iframe {
border: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
color-scheme: auto;
background: transparent;
}
.slot-machine { .slot-machine {
padding: 0.875rem 1rem; padding: 0.875rem 1rem;
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03)); background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
+40 -5
View File
@@ -1,5 +1,15 @@
<template> <template>
<section class="view-root" data-view="profile"> <section class="view-root" data-view="profile">
<div class="theme-toggle">
<button class="theme-btn" @click="themeMenuOpen = !themeMenuOpen" :title="themeTitle">
{{ themeEmoji }}
</button>
<div v-if="themeMenuOpen" class="theme-menu" @click="themeMenuOpen = false">
<button class="theme-option top" :class="{ active: selectedTheme === '' }" @click.stop="setTheme('')" title="Auto">🌓</button>
<button class="theme-option left" :class="{ active: selectedTheme === 'light' }" @click.stop="setTheme('light')" title="Light"></button>
<button class="theme-option right" :class="{ active: selectedTheme === 'dark' }" @click.stop="setTheme('dark')" title="Dark">🌙</button>
</div>
</div>
<header class="view-header"> <header class="view-header">
<h1>User Profile</h1> <h1>User Profile</h1>
<Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" /> <Breadcrumbs ref="breadcrumbs" :entries="breadcrumbEntries" @keydown="handleBreadcrumbKeydown" />
@@ -127,8 +137,9 @@ import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings' import { adminUiPath, makeUiHref } from '@/utils/settings'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
import { goBack } from '@/utils/helpers' import { goBack } from '@/utils/helpers'
import { apiJson } from '@/utils/api' import { apiJson } from 'paskia'
import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@/utils/keynav' import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@/utils/keynav'
import { updateThemeFromSession } from '@/utils/theme'
const authStore = useAuthStore() const authStore = useAuthStore()
const updateInterval = ref(null) const updateInterval = ref(null)
@@ -148,6 +159,22 @@ const breadcrumbs = ref(null)
const userBasicInfo = ref(null) const userBasicInfo = ref(null)
const userInfoSection = ref(null) const userInfoSection = ref(null)
// Theme preference
const selectedTheme = ref('')
const themeMenuOpen = ref(false)
const themeEmoji = computed(() => ({ '': '🌓', light: '', dark: '🌙' })[selectedTheme.value] || '🌓')
const themeTitle = computed(() => ({ '': 'Auto (system)', light: 'Light mode', dark: 'Dark mode' })[selectedTheme.value] || 'Theme')
watch(() => authStore.userInfo?.ctx?.user?.theme, (t) => { selectedTheme.value = t || '' }, { immediate: true })
function setTheme(theme) {
selectedTheme.value = theme
themeMenuOpen.value = false
// Apply immediately for instant feedback
updateThemeFromSession({ user: { theme } }, true)
// Save to server in background
apiJson('/auth/api/user/theme', { method: 'PATCH', body: { theme } })
.catch(e => authStore.showMessage(e.message, 'error'))
}
// Check if any modal/dialog is open (blocks arrow key navigation) // Check if any modal/dialog is open (blocks arrow key navigation)
const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value) const hasActiveModal = computed(() => showNameDialog.value || showRegLink.value)
@@ -352,8 +379,16 @@ const saveName = async () => {
.logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; } .logout-note { margin: 0.75rem 0 0; color: var(--color-text-muted); font-size: 0.875rem; }
.remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; } .remote-auth-inline { display: flex; flex-direction: column; gap: 0.5rem; }
.remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; } .remote-auth-label { display: block; margin: 0; font-size: 0.875rem; color: var(--color-text-muted); font-weight: 500; }
.remote-auth-description { .remote-auth-description { font-size: 0.75rem; color: var(--color-text-muted); }
font-size: 0.75rem; .theme-toggle { position: absolute; top: var(--layout-padding); right: var(--layout-padding); }
color: var(--color-text-muted); .theme-btn { background: none; border: none; padding: 0.25rem; font-size: 1.25rem; cursor: pointer; opacity: 0.5; transition: opacity 0.15s; }
} .theme-btn:hover { opacity: 0.8; }
.theme-menu { position: absolute; top: 100%; right: 0; width: 5rem; height: 4rem; margin-top: 0.25rem; }
.theme-option { position: absolute; background: none; border: none; font-size: 1.25rem; cursor: pointer; opacity: 0.5; padding: 0.25rem; border-radius: var(--radius-sm); transition: opacity 0.15s, transform 0.15s; }
.theme-option:hover { opacity: 1; transform: scale(1.2); }
.theme-option.active { opacity: 1; }
.theme-option.top { top: 0; left: 50%; transform: translateX(-50%); }
.theme-option.top:hover { transform: translateX(-50%) scale(1.2); }
.theme-option.left { bottom: 0; left: 0; }
.theme-option.right { bottom: 0; right: 0; }
</style> </style>
@@ -35,9 +35,10 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue' import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import QRCodeDisplay from '@/components/QRCodeDisplay.vue' import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
import { apiJson } from '@/utils/api' import { apiJson } from 'paskia'
import { formatDate } from '@/utils/helpers' import { formatDate } from '@/utils/helpers'
import { getDirection } from '@/utils/keynav' import { getDirection } from '@/utils/keynav'
import { useAuthStore } from '@/stores/auth'
const props = defineProps({ const props = defineProps({
endpoint: { type: String, required: true }, endpoint: { type: String, required: true },
@@ -46,6 +47,7 @@ const props = defineProps({
const emit = defineEmits(['close', 'copied']) const emit = defineEmits(['close', 'copied'])
const authStore = useAuthStore()
const dialog = ref(null) const dialog = ref(null)
const linkUrl = ref(null) const linkUrl = ref(null)
const expiresAt = ref(null) const expiresAt = ref(null)
@@ -73,7 +75,8 @@ async function generateLink() {
} else { } else {
emit('close') emit('close')
} }
} catch { } catch (e) {
authStore.showMessage(e.message || 'Failed to generate link', 'error')
emit('close') emit('close')
} }
} }
+1 -1
View File
@@ -58,7 +58,7 @@
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings' import { getSettings, uiBasePath } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from '@/utils/api' import { fetchJson, getUserFriendlyErrorMessage } from 'paskia'
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue' import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
import { focusDialogButton } from '@/utils/keynav' import { focusDialogButton } from '@/utils/keynav'
+3 -1
View File
@@ -1,7 +1,8 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { register, authenticate } from '@/utils/passkey' import { register, authenticate } from '@/utils/passkey'
import { getSettings } from '@/utils/settings' import { getSettings } from '@/utils/settings'
import { apiJson } from '@/utils/api' import { apiJson } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', {
state: () => ({ state: () => ({
@@ -86,6 +87,7 @@ export const useAuthStore = defineStore('auth', {
async loadUserInfo() { async loadUserInfo() {
try { try {
this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' }) this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
updateThemeFromSession(this.userInfo?.ctx)
console.log('User info loaded:', this.userInfo) console.log('User info loaded:', this.userInfo)
} catch (error) { } catch (error) {
// Suppress toast for 401/403 errors - the auth iframe will handle these // Suppress toast for 401/403 errors - the auth iframe will handle these
-373
View File
@@ -1,77 +1,3 @@
/**
* API fetch wrapper that handles authentication errors with iframe-based re-authentication.
*
* When a 401 or 403 response is received with an `auth` object containing `iframe` URL,
* this wrapper shows an authentication iframe and retries the original request after
* successful authentication.
*/
/** Default timeout for API requests in milliseconds */
const DEFAULT_TIMEOUT_MS = 1000
/**
* Custom error class for API errors with full response context.
*/
export class ApiError extends Error {
constructor(url, response, data) {
super(data?.detail || `Request failed: ${response.status}`)
this.name = 'ApiError'
this.url = url
this.status = response.status
this.statusText = response.statusText
this.data = data
}
}
/**
* Custom error class for network/timeout errors.
*/
export class NetworkError extends Error {
constructor(message, originalError = null) {
super(message)
this.name = 'NetworkError'
this.originalError = originalError
}
}
/**
* Error thrown when user cancels authentication.
*/
export class AuthCancelledError extends Error {
constructor() {
super('Authentication cancelled')
this.name = 'AuthCancelledError'
}
}
let authIframe = null
let authPromise = null
let authResolve = null
let authReject = null
// Global backdrop ref-count (works independently of Pinia store)
let backdropHolders = 0
/**
* Hold global backdrop (increment ref-count).
* Multiple callers can hold the backdrop; it only hides when all release.
*/
export function holdGlobalBackdrop() {
backdropHolders++
document.body.classList.add('has-backdrop')
}
/**
* Release global backdrop (decrement ref-count).
* Backdrop hides only when ref-count reaches zero.
*/
export function releaseGlobalBackdrop() {
backdropHolders = Math.max(0, backdropHolders - 1)
if (backdropHolders === 0) {
document.body.classList.remove('has-backdrop')
}
}
// Cache for auth iframe URL by mode // Cache for auth iframe URL by mode
const authIframeUrlCache = {} const authIframeUrlCache = {}
@@ -104,302 +30,3 @@ export async function getAuthIframeUrl(mode = 'login') {
} }
throw new Error('Unable to fetch auth iframe URL') throw new Error('Unable to fetch auth iframe URL')
} }
/**
* Check if an auth iframe is already open (from any source).
* @returns {boolean}
*/
export function isAuthIframeOpen() {
return !!document.getElementById('auth-iframe')
}
/**
* Show the authentication iframe and return a promise that resolves on success.
* If an auth iframe is already open (from any source), hooks into its completion.
* Uses global backdrop system to avoid flicker between auth and caller's UI.
* @param {string} iframeUrl - The URL for the iframe src
* @returns {Promise<void>}
* @throws {AuthCancelledError} - If authentication is cancelled by user
*/
export function showAuthIframe(iframeUrl) {
// If we already have a promise (from us), return it
if (authPromise) return authPromise
// If there's already an iframe in the DOM (from App.vue or elsewhere),
// create a promise that hooks into the message handler
if (document.getElementById('auth-iframe')) {
authPromise = new Promise((resolve, reject) => {
authResolve = resolve
authReject = reject
})
return authPromise
}
authPromise = new Promise((resolve, reject) => {
authResolve = resolve
authReject = reject
})
// Remove existing iframe if any
hideAuthIframe()
// Hold global backdrop for auth iframe
holdGlobalBackdrop()
// Create new iframe for authentication using src URL
authIframe = document.createElement('iframe')
authIframe.id = 'auth-iframe'
authIframe.title = 'Authentication'
authIframe.allow = 'publickey-credentials-get; publickey-credentials-create'
authIframe.src = iframeUrl
document.body.appendChild(authIframe)
return authPromise
}
function hideAuthIframe() {
if (authIframe) {
authIframe.remove()
authIframe = null
releaseGlobalBackdrop()
}
}
function handleAuthMessage(event) {
const data = event.data
if (!data?.type) return
switch (data.type) {
case 'auth-success':
hideAuthIframe()
if (authResolve) {
authResolve()
authPromise = null
authResolve = null
authReject = null
}
break
case 'auth-back':
case 'auth-close-request':
hideAuthIframe()
if (authReject) {
authReject(new AuthCancelledError())
authPromise = null
authResolve = null
authReject = null
}
break
case 'auth-error':
// Keep iframe open for retry, but if cancelled, treat as back
if (data.cancelled && authReject) {
hideAuthIframe()
authReject(new AuthCancelledError())
authPromise = null
authResolve = null
authReject = null
}
break
}
}
// Install global message listener
if (typeof window !== 'undefined') {
window.addEventListener('message', handleAuthMessage)
}
/**
* Fetch wrapper that handles auth errors with iframe-based re-authentication.
* Loops until successful or user cancels authentication.
*
* @param {string|URL} url - The URL to fetch
* @param {RequestInit} [options] - Fetch options
* @param {number} [options.timeout] - Timeout in ms (default: 10000, use 0 to disable)
* @returns {Promise<Response>} - The fetch response
* @throws {AuthCancelledError} - If authentication is cancelled by user
* @throws {NetworkError} - If network error or timeout occurs
*/
export async function apiFetch(url, options = {}) {
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
// Ensure credentials are included for cookie-based auth
fetchOptions.credentials = fetchOptions.credentials || 'include'
while (true) {
let response
try {
response = await fetch(url, {...fetchOptions, signal: timeout && AbortSignal.timeout(timeout)})
} catch (error) {
// Handle network errors and timeouts
if (error.name === 'TimeoutError') {
throw new NetworkError('Request timed out', error)
}
if (error.name === 'AbortError') {
// Re-throw abort errors as-is (user-initiated cancellation)
throw error
}
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
throw new NetworkError('Unable to connect to server', error)
}
throw new NetworkError(error.message || 'Network error', error)
}
// Check for auth errors (401/403)
if (response.status === 401 || response.status === 403) {
// Try to parse the response to get the iframe URL
let authInfo = null
try {
const data = await response.clone().json()
authInfo = data.auth
} catch {
// If we can't parse JSON, no iframe available
}
// Authenticate via iframe (only in top-level window)
if (authInfo?.iframe && window === window.top) {
// Show auth iframe (or wait for existing one) and retry on success
// showAuthIframe returns existing promise if iframe is already open
await showAuthIframe(authInfo.iframe)
continue // Retry the original request
}
}
return response
}
}
/**
* Convenience method for JSON API calls.
* Automatically sets Accept and Content-Type headers.
* Returns parsed JSON directly if response is ok, throws ApiError otherwise.
*
* @param {string|URL} url - The URL to fetch
* @param {RequestInit} [options] - Fetch options
* @returns {Promise<any>} - Parsed JSON response
* @throws {ApiError} - If response is not ok
* @throws {NetworkError} - If network error or timeout occurs
* @throws {AuthCancelledError} - If authentication is cancelled by user
*/
export async function apiJson(url, options = {}) {
const fetchOptions = { ...options }
// Set default headers, allowing caller overrides
fetchOptions.headers = {
'Accept': 'application/json',
...fetchOptions.headers,
}
// Set Content-Type for requests with JSON body
if (fetchOptions.body && typeof fetchOptions.body === 'object' && !(fetchOptions.body instanceof FormData)) {
fetchOptions.headers = {
'Content-Type': 'application/json',
...fetchOptions.headers,
}
fetchOptions.body = JSON.stringify(fetchOptions.body)
}
const response = await apiFetch(url, fetchOptions)
const data = await response.json()
if (!response.ok) {
throw new ApiError(url, response, data)
}
return data
}
/**
* Simple JSON fetch without auto-auth iframe handling.
* Use this in contexts where showing an auth iframe would be inappropriate
* (e.g., inside the auth iframe itself).
*
* @param {string|URL} url - The URL to fetch
* @param {RequestInit} [options] - Fetch options
* @returns {Promise<any>} - Parsed JSON response
* @throws {ApiError} - If response is not ok
*/
export async function fetchJson(url, options = {}) {
const fetchOptions = {
...options,
headers: {
'Accept': 'application/json',
...options.headers,
},
}
const response = await fetch(url, fetchOptions)
const data = await response.json()
if (!response.ok) {
throw new ApiError(url, response, data)
}
return data
}
/**
* Convert an error to a user-friendly message.
* @param {Error} error - The error to convert
* @returns {string} - User-friendly error message
*/
export function getUserFriendlyErrorMessage(error) {
if (error instanceof NetworkError) {
return error.message
}
if (error instanceof ApiError) {
return error.message
}
if (error.name === 'TimeoutError') {
return 'Request timed out'
}
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
return 'Unable to connect to server'
}
return error.message || 'An error occurred'
}
/**
* Check if an error should show a toast to the user.
* @param {Error} error - The error to check
* @returns {boolean} - Whether to show a toast
*/
export function shouldShowErrorToast(error) {
// Don't show toast for user cancellations
if (error instanceof AuthCancelledError) return false
if (error.name === 'AbortError') return false
// Don't show toast for 401/403 errors - the auth iframe will handle these
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) return false
return true
}
/**
* Create an API caller with error handling (toast + console.error).
* Wraps apiJson calls with consistent error handling for apps.
*
* @param {Function} showMessage - Function to show toast messages: (message, type, duration) => void
* @returns {Function} - Wrapped apiJson that handles errors
*/
export function createApiCaller(showMessage) {
/**
* @param {string|URL} url - The URL to fetch
* @param {RequestInit} [options] - Fetch options
* @returns {Promise<any>} - Parsed JSON response, or undefined on error
*/
return async function apiCall(url, options = {}) {
try {
return await apiJson(url, options)
} catch (error) {
if (!shouldShowErrorToast(error)) {
throw error
}
// Log full error details
console.error(`API error for ${url}:`, error instanceof ApiError ? { status: error.status, statusText: error.statusText, data: error.data } : error)
// Show user-friendly toast
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
throw error
}
}
}
export default apiFetch
+1 -1
View File
@@ -1,7 +1,7 @@
import { startRegistration, startAuthentication } from '@simplewebauthn/browser' import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket' import aWebSocket from '@/utils/awaitable-websocket'
import { getSettings } from '@/utils/settings' import { getSettings } from '@/utils/settings'
import { showAuthIframe } from '@/utils/api' import { showAuthIframe } from 'paskia'
// Generic path normalizer: if an auth_host is configured and differs from current // Generic path normalizer: if an auth_host is configured and differs from current
// host, return absolute URL (scheme derived by aWebSocket). Otherwise, keep as-is. // host, return absolute URL (scheme derived by aWebSocket). Otherwise, keep as-is.
+101
View File
@@ -0,0 +1,101 @@
// Theme override utilities - shared across apps
// User preference or URL hash can force light/dark mode
export const themeColors = {
light: {
'color-canvas': '#ffffff',
'color-surface': '#eff6ff',
'color-surface-subtle': '#dbeafe',
'color-border': '#2563eb',
'color-border-strong': '#1e40af',
'color-heading': '#1e3a8a',
'color-text': '#1e293b',
'color-text-muted': '#475569',
'color-link': '#1d4ed8',
'color-link-hover': '#1e40af',
'color-accent': '#2563eb',
'color-accent-strong': '#1e40af',
'color-accent-contrast': '#ffffff',
'color-success-text': '#166534',
'color-success-bg': '#dcfce7',
'color-error-text': '#b91c1c',
'color-error-bg': '#fee2e2',
'color-info-text': '#1e40af',
'color-info-bg': '#dbeafe',
'color-danger': '#dc2626',
'shadow-soft': '0 10px 30px rgba(30, 64, 175, 0.15)',
},
dark: {
'color-canvas': '#0f172a',
'color-surface': '#141b2f',
'color-surface-subtle': '#1b243b',
'color-border': '#25304a',
'color-border-strong': '#3d4d6b',
'color-heading': '#fff',
'color-text': '#e2e8f0',
'color-text-muted': '#94a3b8',
'color-link': '#60a5fa',
'color-link-hover': '#93c5fd',
'color-accent': '#60a5fa',
'color-accent-strong': '#3b82f6',
'color-accent-contrast': '#0b1120',
'color-success-text': '#34d399',
'color-success-bg': '#1a4d2e',
'color-error-text': '#fca5a5',
'color-error-bg': '#4a1f1f',
'color-info-text': '#bae6fd',
'color-info-bg': '#1e3a5f',
'color-danger': '#f87171',
'shadow-soft': '0 0 0 #000000',
}
}
const STYLE_ID = 'theme-override'
const TRANSITION_ID = 'theme-transition'
const STORAGE_KEY = 'paskia-theme'
/** Apply theme override CSS - selector targets .surface for restricted app, :root for main apps */
export function applyTheme(theme, selector = ':root', animate = false) {
// Add temporary transition for smooth theme change
if (animate) {
let transitionStyle = document.getElementById(TRANSITION_ID)
if (!transitionStyle) {
transitionStyle = document.createElement('style')
transitionStyle.id = TRANSITION_ID
transitionStyle.textContent = '*, *::before, *::after { transition: background-color 0.3s, color 0.3s, border-color 0.3s, box-shadow 0.3s !important; }'
document.head.appendChild(transitionStyle)
}
setTimeout(() => document.getElementById(TRANSITION_ID)?.remove(), 350)
}
document.getElementById(STYLE_ID)?.remove()
if (theme && themeColors[theme]) {
const css = `${selector} { ${Object.entries(themeColors[theme]).map(([k, v]) => `--${k}: ${v}`).join('; ')}; }`
const style = document.createElement('style')
style.id = STYLE_ID
style.textContent = css
document.head.appendChild(style)
}
}
/** Get theme from localStorage cache */
export function getCachedTheme() {
return localStorage.getItem(STORAGE_KEY) || ''
}
/** Cache theme in localStorage */
export function setCachedTheme(theme) {
if (theme) localStorage.setItem(STORAGE_KEY, theme)
else localStorage.removeItem(STORAGE_KEY)
}
/** Initialize theme from user preference (with localStorage cache for fast load) */
export function initThemeFromCache() {
applyTheme(getCachedTheme())
}
/** Update theme from session context (call after login/session load) */
export function updateThemeFromSession(ctx, animate = false) {
const theme = ctx?.user?.theme || ''
setCachedTheme(theme)
applyTheme(theme, ':root', animate)
}
+3 -1
View File
@@ -90,7 +90,9 @@ export default defineConfig(({ command }) => ({
} }
].filter(Boolean), ].filter(Boolean),
resolve: { resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
}
}, },
base: '/', base: '/',
server: { server: {
+30
View File
@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
+131
View File
@@ -0,0 +1,131 @@
# Paskia
![Screenshot](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/forbidden-light.webp)
JavaScript utilities for [Paskia authentication system](https://git.zi.fi/leovasanko/paskia) integration into web apps.
## Installation
### NPM
No framework dependencies. Works with any framework (Vue, React, Svelte, etc.) or vanilla JS. Typescript typing included.
```sh
npm install paskia
```
```js
import { ... } from 'paskia'
```
### Plain JavaScript
Fetch the module directly from a CDN, or [download](https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js) first and host yourself. No Node needed.
```html
<script type="module">
import { ... } from 'https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js'
</script>
```
## Features
### Session Validation
Refresh session and track its validity with automatic polling. Pauses on lack of user activity to avoid useless traffic and to allow session expiry even when the page is left open but idle. This monitors that the same account stays logged in but doesn't do any permission checks.
```js
import { SessionValidator } from 'paskia'
const validator = new SessionValidator(
() => currentUser?.uuid, // getter for current user ID that we track
(error) => handleSessionLost(error) // callback when session is lost
)
validator.start() // call at your app startup/login
validator.stop() // stop the system (optional)
```
### API Fetch Utilities
Enhanced fetch functions with automatic error handling and authentication retry:
```js
import { apiJson, apiFetch } from 'paskia'
// JSON API calls with automatic auth handling
const data = await apiJson('/api/endpoint', { method: 'POST', body: { key: 'value' } })
// Raw fetch with auth handling
const response = await apiFetch('/api/endpoint')
```
When a 401/403 response includes an auth iframe URL, the request automatically pauses, displays the authentication UI, and retries upon success. In case this is not needed, use standard `fetch` or our `fetchJson`.
The JSON variants set headers automatically, with body and response in JSON.
### Authentication Overlay
Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request.
The backend returns 401/403 responses with the correct URL for proper user feedback. Alternatively you may use `/auth/restricted/#mode=login`, `mode=reauth` or `mode=forbidden` to trigger the UX flow you need.
```js
import { showAuthIframe, AuthCancelledError } from 'paskia'
const response = await fetch('/api/protected')
if (response.status === 401 || response.status === 403) {
const data = await response.json()
if (data.auth?.iframe) {
await showAuthIframe(data.auth.iframe) // Raises AuthCancelledError if the user cancels
}
}
```
This resolves after the user authenticates (possibly with another account than previously), and you should usually retry the original API request. Note that successful authentication doesn't guarantee that the user still has rights to what originally failed.
### Shared Blur Backdrop
The authentication dialog displays with a blur backdrop (z-index 1099). The auth iframe uses z-index 9999. Your app dialogs should use z-index 11009998 to appear above the backdrop but below authentication.
The backdrop is also reusable/refcounted, so you can keep consistent visuals for your own dialogs:
```js
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
holdGlobalBackdrop()
try {
await your.own.dialog()
} finally {
releaseGlobalBackdrop()
}
```
The backdrop only disappears after all holders have released it.
## Error Handling
### AuthCancelledError (apiFetch, apiJson, showAuthIframe)
If the user clicks Back in the authentication dialog, refusing to authenticate, `AuthCancelledError` is risen (as a response to postMessage from the iframe). The dialog closes as expected and it is up to the app how to continue from there.
- Do nothing if the app can continue despite the failed operation (no UI notification needed)
- Display a simple Access Denied page with suggestion/button to reload the page to try again
Do not retry automatically.
### UI feedback
A set of small utilities are available for determining whether the user needs a notification and to format the error message.
```js
import { getUserFriendlyErrorMessage, shouldShowErrorToast } from 'paskia'
try {
await apiJson('/api/action')
} catch (e) {
if (shouldShowErrorToast(e)) {
your.message.display(getUserFriendlyErrorMessage(e))
}
}
```
+32
View File
@@ -0,0 +1,32 @@
{
"name": "paskia",
"version": "0.1.2",
"description": "Paskia authentication utilities for JavaScript",
"type": "module",
"main": "./dist/paskia.js",
"types": "./dist/paskia.d.ts",
"exports": {
".": {
"types": "./dist/paskia.d.ts",
"import": "./dist/paskia.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "vite build",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"typescript": "~5.8.0",
"vite": "^7.3.1",
"vite-plugin-dts": "^4.5.4"
},
"keywords": [
"auth",
"authentication",
"paskia"
],
"license": "Unlicense"
}
+146
View File
@@ -0,0 +1,146 @@
import { showAuthIframe, AuthCancelledError } from './overlay'
export { AuthCancelledError }
const DEFAULT_TIMEOUT_MS = 1000
export interface ApiFetchOptions extends RequestInit {
timeout?: number
}
export interface FetchJsonOptions extends Omit<RequestInit, 'body'> {
timeout?: number
body?: BodyInit | Record<string, unknown> | null
}
export class ApiError extends Error {
readonly url: string
readonly status: number
readonly statusText: string
readonly data: unknown
constructor(url: string, response: Response, data: unknown) {
super((data as { detail?: string })?.detail || `Request failed: ${response.status}`)
this.name = 'ApiError'
this.url = url
this.status = response.status
this.statusText = response.statusText
this.data = data
}
}
export class NetworkError extends Error {
readonly originalError: Error | null
constructor(message: string, originalError: Error | null = null) {
super(message)
this.name = 'NetworkError'
this.originalError = originalError
}
}
export async function apiFetch(url: string, options: ApiFetchOptions = {}): Promise<Response> {
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
fetchOptions.credentials = fetchOptions.credentials || 'include'
while (true) {
let response: Response
try {
response = await fetch(url, {...fetchOptions, signal: timeout ? AbortSignal.timeout(timeout) : undefined})
} catch (error) {
const err = error as Error
if (err.name === 'TimeoutError') {
throw new NetworkError('Request timed out', err)
}
if (err.name === 'AbortError') {
throw error
}
if (err.name === 'TypeError' && err.message === 'Failed to fetch') {
throw new NetworkError('Unable to connect to server', err)
}
throw new NetworkError(err.message || 'Network error', err)
}
if (response.status === 401 || response.status === 403) {
let data: { auth?: { iframe?: string } } | null = null
try {
data = await response.clone().json()
} catch {}
if (data?.auth?.iframe && window === window.top) {
await showAuthIframe(data.auth.iframe)
continue // Retry the original request after successful auth
}
}
return response
}
}
type FetchFn = (url: string, options?: RequestInit) => Promise<Response>
export async function apiJson<T = unknown>(url: string, options: FetchJsonOptions = {}): Promise<T> {
return fetchJson<T>(url, options, apiFetch)
}
export async function fetchJson<T = unknown>(url: string, options: FetchJsonOptions = {}, fetchFn: FetchFn = fetch): Promise<T> {
const headers: Record<string, string> = {
'Accept': 'application/json',
...(options.headers as Record<string, string>),
}
let body: BodyInit | undefined
if (options.body && typeof options.body === 'object' && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json'
body = JSON.stringify(options.body)
} else {
body = options.body as BodyInit
}
const opt: RequestInit = { ...options, headers, body }
const response = await fetchFn(url, opt)
const data = await response.json() as T
if (!response.ok) {
throw new ApiError(url, response, data)
}
return data
}
export function getUserFriendlyErrorMessage(error: Error): string {
if (error instanceof NetworkError) return error.message
if (error instanceof ApiError) return error.message
if (error.name === 'TimeoutError') return 'Request timed out'
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
return 'Unable to connect to server'
}
return error.message || 'An error occurred'
}
export function shouldShowErrorToast(error: Error): boolean {
if (error instanceof AuthCancelledError) return false
if (error.name === 'AbortError') return false
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) return false
return true
}
type ShowMessageFn = (message: string, type: string, duration: number) => void
export function createApiCaller(showMessage: ShowMessageFn) {
return async function apiCall<T = unknown>(url: string, options: FetchJsonOptions = {}): Promise<T> {
try {
return await apiJson<T>(url, options)
} catch (error) {
if (!shouldShowErrorToast(error as Error)) {
throw error
}
const err = error as Error
console.error(`API error for ${url}:`, err instanceof ApiError ? { status: err.status, statusText: err.statusText, data: err.data } : err)
showMessage(getUserFriendlyErrorMessage(err), 'error', 4000)
throw error
}
}
}
export default apiFetch
+25
View File
@@ -0,0 +1,25 @@
export {
ApiError,
NetworkError,
AuthCancelledError,
apiFetch,
apiJson,
fetchJson,
getUserFriendlyErrorMessage,
shouldShowErrorToast,
createApiCaller,
} from './fetch'
export type { ApiFetchOptions, FetchJsonOptions } from './fetch'
export {
holdGlobalBackdrop,
releaseGlobalBackdrop,
isAuthIframeOpen,
hideAuthIframe,
showAuthIframe,
createAuthIframe,
removeAuthIframe,
} from './overlay'
export { SessionValidator } from './validate'
+166
View File
@@ -0,0 +1,166 @@
const AUTH_IFRAME_ID = 'paskia-iframe'
const STYLES_ID = 'paskia-dialog'
const STYLES_TEXT = `\
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 1099;
background: transparent;
backdrop-filter: blur(0) brightness(1);
-webkit-backdrop-filter: blur(0) brightness(1);
pointer-events: none;
visibility: hidden;
transition: all 0.2s ease-out;
}
body.paskia-backdrop::before {
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.5);
visibility: visible;
}
body.paskia-backdrop {
overflow: auto;
}
#${AUTH_IFRAME_ID} {
border: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
color-scheme: auto;
background: transparent;
}
`
let authIframe: HTMLIFrameElement | null = null
let authPromise: Promise<void> | null = null
let authResolve: (() => void) | null = null
let authReject: ((error: Error) => void) | null = null
let messageListenerInstalled = false
let backdropHolders = 0
function injectStyles(): void {
if (document.getElementById(STYLES_ID)) return
const style = document.createElement('style')
style.id = STYLES_ID
style.textContent = STYLES_TEXT
document.head.insertBefore(style, document.head.firstChild)
}
export class AuthCancelledError extends Error {
constructor() {
super('Authentication cancelled')
this.name = 'AuthCancelledError'
}
}
export function holdGlobalBackdrop(): void {
backdropHolders++
document.body.classList.add('paskia-backdrop')
}
export function releaseGlobalBackdrop(): void {
backdropHolders = Math.max(0, backdropHolders - 1)
if (backdropHolders === 0) {
document.body.classList.remove('paskia-backdrop')
}
}
export function isAuthIframeOpen(): boolean {
return !!document.getElementById(AUTH_IFRAME_ID)
}
export function hideAuthIframe(): void {
if (authIframe) {
authIframe.remove()
authIframe = null
releaseGlobalBackdrop()
}
}
function handleAuthMessage(event: MessageEvent): void {
const data = event.data as { type?: string }
if (!data?.type) return
switch (data.type) {
case 'auth-success':
hideAuthIframe()
if (authResolve) {
authResolve()
authPromise = null
authResolve = null
authReject = null
}
break
case 'auth-back':
hideAuthIframe()
if (authReject) {
authReject(new AuthCancelledError())
authPromise = null
authResolve = null
authReject = null
}
break
}
}
function ensureMessageListener(): void {
if (messageListenerInstalled) return
if (typeof window !== 'undefined') {
window.addEventListener('message', handleAuthMessage)
messageListenerInstalled = true
}
}
export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Promise<void> {
injectStyles()
ensureMessageListener()
if (authPromise) return authPromise
if (document.getElementById(AUTH_IFRAME_ID)) {
authPromise = new Promise((resolve, reject) => {
authResolve = resolve
authReject = reject
})
return authPromise
}
authPromise = new Promise((resolve, reject) => {
authResolve = resolve
authReject = reject
})
hideAuthIframe()
holdGlobalBackdrop()
authIframe = document.createElement('iframe')
authIframe.id = AUTH_IFRAME_ID
authIframe.title = title
authIframe.src = iframeUrl
document.body.appendChild(authIframe)
return authPromise
}
export function createAuthIframe(iframeUrl: string, title = 'Authentication'): HTMLIFrameElement {
injectStyles()
const existing = document.getElementById(AUTH_IFRAME_ID)
if (existing) existing.remove()
const iframe = document.createElement('iframe')
iframe.id = AUTH_IFRAME_ID
iframe.title = title
iframe.src = iframeUrl
document.body.appendChild(iframe)
return iframe
}
export function removeAuthIframe(): void {
const iframe = document.getElementById(AUTH_IFRAME_ID)
if (iframe) iframe.remove()
}
+66
View File
@@ -0,0 +1,66 @@
import { apiJson } from './fetch'
const POLL_INTERVAL = 60 * 1000
const IDLE_TIMEOUT = 5 * 60 * 1000
export class SessionValidator {
private userUuidGetter: () => string | undefined
private onSessionLost: (error: Error) => void
private pollTimer: ReturnType<typeof setInterval> | null = null
private idleTimer: ReturnType<typeof setTimeout> | null = null
private active = false
constructor(userUuidGetter: () => string | undefined, onSessionLost: (error: Error) => void) {
this.userUuidGetter = userUuidGetter
this.onSessionLost = onSessionLost
this.resetIdleTimer = this.resetIdleTimer.bind(this)
}
resetIdleTimer(): void {
if (this.idleTimer) clearTimeout(this.idleTimer)
if (!this.active) this.startPolling()
this.idleTimer = setTimeout(() => this.stopPolling(), IDLE_TIMEOUT)
}
async validate(): Promise<void> {
try {
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST' })
const newUuid = data.ctx?.user?.uuid
if (newUuid !== this.userUuidGetter()) {
window.location.reload()
}
} catch (error) {
if ((error as Error).name !== 'NetworkError') {
this.stopPolling()
this.onSessionLost(error as Error)
}
}
}
startPolling(): void {
if (this.active) return
this.active = true
this.pollTimer = setInterval(() => this.validate(), POLL_INTERVAL)
}
stopPolling(): void {
this.active = false
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
}
start(): void {
window.addEventListener('pointermove', this.resetIdleTimer)
window.addEventListener('pointerdown', this.resetIdleTimer)
this.resetIdleTimer()
}
stop(): void {
window.removeEventListener('pointermove', this.resetIdleTimer)
window.removeEventListener('pointerdown', this.resetIdleTimer)
if (this.idleTimer) clearTimeout(this.idleTimer)
this.stopPolling()
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"declarationDir": "./dist",
"outDir": "./dist",
"rootDir": "./src",
"lib": ["ES2020", "DOM"],
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import dts from 'vite-plugin-dts'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
export default defineConfig({
plugins: [dts({ rollupTypes: true })],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
fileName: 'paskia',
formats: ['es'],
},
},
})
+8 -7
View File
@@ -15,18 +15,18 @@ from paskia.util import hostutil, passphrase
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Shared log message template for admin reset links # Shared log message template for admin reset links
ADMIN_RESET_MESSAGE = """\ ADMIN_RESET_MESSAGE = """
%s
👤 Admin %s 👤 Admin %s
- Use this link to register a Passkey for the admin user! - Use this link to register a Passkey for the admin user!
""" """
def _log_reset_link(message: str, passphrase: str) -> str: def _log_reset_link(passphrase: str, message: str | None = None) -> str:
"""Log a reset link message and return the URL.""" """Log a reset link message and return the URL."""
reset_link = hostutil.reset_link_url(passphrase) reset_link = hostutil.reset_link_url(passphrase)
logger.info(ADMIN_RESET_MESSAGE, message, reset_link) if message:
logger.info(message)
logger.info(ADMIN_RESET_MESSAGE, reset_link)
return reset_link return reset_link
@@ -41,7 +41,7 @@ async def bootstrap_system() -> None:
reset_passphrase = db.bootstrap() reset_passphrase = db.bootstrap()
# Log the reset link (this is separate from the transaction log) # Log the reset link (this is separate from the transaction log)
_log_reset_link("✅ Bootstrap completed!", reset_passphrase) _log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
async def check_admin_credentials() -> bool: async def check_admin_credentials() -> bool:
@@ -72,6 +72,7 @@ async def check_admin_credentials() -> bool:
if not db.get_user_credential_ids(admin_user.uuid): if not db.get_user_credential_ids(admin_user.uuid):
# Admin exists but has no credentials, create reset link # Admin exists but has no credentials, create reset link
logger.info("⚠️ Admin user has no credentials!")
token = passphrase.generate() token = passphrase.generate()
expiry = authsession.reset_expires() expiry = authsession.reset_expires()
@@ -81,7 +82,7 @@ async def check_admin_credentials() -> bool:
expiry=expiry, expiry=expiry,
token_type="admin registration", token_type="admin registration",
) )
_log_reset_link("⚠️ Admin user has no credentials!", token) _log_reset_link(token)
return True return True
return False return False
+2
View File
@@ -64,6 +64,7 @@ from paskia.db.operations import (
update_user_display_name, update_user_display_name,
update_user_role, update_user_role,
update_user_role_in_organization, update_user_role_in_organization,
update_user_theme,
) )
from paskia.db.structs import ( from paskia.db.structs import (
DB, DB,
@@ -147,4 +148,5 @@ __all__ = [
"update_user_display_name", "update_user_display_name",
"update_user_role", "update_user_role",
"update_user_role_in_organization", "update_user_role_in_organization",
"update_user_theme",
] ]
+5 -8
View File
@@ -74,21 +74,18 @@ async def start_background():
_logger.debug("Background task in different event loop, restarting") _logger.debug("Background task in different event loop, restarting")
_background_task = None _background_task = None
else: else:
# Task is running in the same event loop - this is an error # Task is already running in same loop - idempotent, just return
raise RuntimeError( # This happens with dual IPv4+IPv6 endpoints sharing the same process
"Background task is already running. " _logger.debug(
"start_background() must not be called multiple times in the same event loop." "Background task already running in same loop, skipping"
) )
except RuntimeError: return
raise # Re-raise RuntimeError from above
except Exception as e: except Exception as e:
_logger.debug("Error checking background task loop: %s, restarting", e) _logger.debug("Error checking background task loop: %s, restarting", e)
_background_task = None _background_task = None
if _background_task is None: if _background_task is None:
_background_task = asyncio.create_task(_background_loop()) _background_task = asyncio.create_task(_background_loop())
else:
_logger.debug("Background task already running: %s", _background_task)
async def stop_background(): async def stop_background():
+2 -2
View File
@@ -198,7 +198,6 @@ class JsonlStore:
if not diff: if not diff:
return return
self._pending_changes.append(create_change_record(action, version, diff, user)) self._pending_changes.append(create_change_record(action, version, diff, user))
self._previous_builtins = copy.deepcopy(current)
# Log the change with user display name if available # Log the change with user display name if available
user_display = None user_display = None
@@ -210,7 +209,8 @@ class JsonlStore:
except (ValueError, KeyError): except (ValueError, KeyError):
user_display = user user_display = user
log_change(action, diff, user_display) log_change(action, diff, user_display, self._previous_builtins)
self._previous_builtins = copy.deepcopy(current)
@contextmanager @contextmanager
def transaction( def transaction(
+128 -43
View File
@@ -26,8 +26,7 @@ _RESET = "\033[0m"
_DIM = "\033[2m" _DIM = "\033[2m"
_PATH_PREFIX = "\033[1;30m" # Dark grey for path prefix (like host in access log) _PATH_PREFIX = "\033[1;30m" # Dark grey for path prefix (like host in access log)
_PATH_FINAL = "\033[0m" # Default for final element (like path in access log) _PATH_FINAL = "\033[0m" # Default for final element (like path in access log)
_REPLACE = "\033[0;33m" # Yellow for replacements _DELETE = "\033[1;31m" # Red for deletions
_DELETE = "\033[0;31m" # Red for deletions
_ADD = "\033[0;32m" # Green for additions _ADD = "\033[0;32m" # Green for additions
_ACTION = "\033[1;34m" # Bold blue for action name _ACTION = "\033[1;34m" # Bold blue for action name
_USER = "\033[0;34m" # Blue for user display _USER = "\033[0;34m" # Blue for user display
@@ -93,18 +92,34 @@ def _format_path(path: list[str], use_color: bool) -> str:
return f"{_PATH_PREFIX}{prefix}.{_RESET}{_PATH_FINAL}{final}{_RESET}" return f"{_PATH_PREFIX}{prefix}.{_RESET}{_PATH_FINAL}{final}{_RESET}"
def _get_nested(data: dict | None, path: list[str]) -> Any:
"""Get a nested value from a dict by path, or None if not found."""
if data is None:
return None
current = data
for key in path:
if not isinstance(current, dict) or key not in current:
return None
current = current[key]
return current
def _collect_changes( def _collect_changes(
diff: dict, path: list[str], changes: list[tuple[str, list[str], Any, Any | None]] diff: dict,
path: list[str],
changes: list[tuple[str, list[str], Any]],
previous: dict | None,
) -> None: ) -> None:
""" """
Recursively collect changes from a diff into a flat list. Recursively collect changes from a diff into a flat list.
Each change is a tuple of (change_type, path, new_value, old_value). Each change is a tuple of (change_type, path, new_value).
change_type is one of: 'set', 'replace', 'delete' change_type is one of: 'add', 'update', 'delete'
""" """
if not isinstance(diff, dict): if not isinstance(diff, dict):
# Leaf value - this is a set operation # Leaf value - check if it existed before
changes.append(("set", path, diff, None)) existed = _get_nested(previous, path) is not None
changes.append(("update" if existed else "add", path, diff))
return return
for key, value in diff.items(): for key, value in diff.items():
@@ -112,72 +127,136 @@ def _collect_changes(
# $delete contains a list of keys to delete # $delete contains a list of keys to delete
if isinstance(value, list): if isinstance(value, list):
for deleted_key in value: for deleted_key in value:
changes.append(("delete", path + [str(deleted_key)], None, None)) changes.append(("delete", path + [str(deleted_key)], None))
else: else:
changes.append(("delete", path + [str(value)], None, None)) changes.append(("delete", path + [str(value)], None))
elif key == "$replace": elif key == "$replace":
# $replace contains the new value for this path # $replace replaces the entire collection at this path
# We need to track what was added and what was deleted
old_collection = _get_nested(previous, path)
old_keys = (
set(old_collection.keys())
if isinstance(old_collection, dict)
else set()
)
new_keys = set(value.keys()) if isinstance(value, dict) else set()
# Items that existed before but not in new = deleted
for deleted_key in old_keys - new_keys:
changes.append(("delete", path + [str(deleted_key)], None))
# Items in new collection
if isinstance(value, dict): if isinstance(value, dict):
# Replacing with a dict - show each key as a replacement
for rkey, rval in value.items(): for rkey, rval in value.items():
changes.append(("replace", path + [str(rkey)], rval, None)) existed = rkey in old_keys
if not value: changes.append(
# Empty replacement - clearing the collection ("update" if existed else "add", path + [str(rkey)], rval)
changes.append(("replace", path, {}, None)) )
else: elif value or not old_keys:
changes.append(("replace", path, value, None)) # Non-dict replacement or empty replacement with nothing before
changes.append(
("update" if old_collection is not None else "add", path, value)
)
elif key.startswith("$"): elif key.startswith("$"):
# Other special operations (future-proofing) # Other special operations (future-proofing)
changes.append(("set", path, {key: value}, None)) changes.append(("add", path, {key: value}))
else: else:
# Regular nested key # Regular nested key - check if this item existed before
_collect_changes(value, path + [str(key)], changes) new_path = path + [str(key)]
existed = _get_nested(previous, new_path) is not None
if existed:
# Item exists - recurse to show specific field changes
_collect_changes(value, new_path, changes, previous)
else:
# New item - record as add with full value, don't recurse
changes.append(("add", new_path, value))
def _format_change_line( def _format_change_lines(
change_type: str, path: list[str], value: Any, use_color: bool change_type: str, path: list[str], value: Any, use_color: bool
) -> str: ) -> list[str]:
"""Format a single change as a one-line string.""" """Format a single change as one or more lines."""
path_str = _format_path(path, use_color)
value_str = _format_value(value, use_color)
if change_type == "delete": if change_type == "delete":
if use_color: if not use_color:
return f" {path_str}" return [f" {'.'.join(path)}"]
return f" - {path_str}" if len(path) == 1:
return [f" {_DELETE}{path[0]}{_RESET}"]
prefix = ".".join(path[:-1])
final = path[-1]
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final}{_RESET}"]
if change_type == "replace": if change_type == "add":
if use_color: # New item being created - only final element in green
return f" {_REPLACE}{_RESET} {path_str} {_DIM}={_RESET} {value_str}" # For dict values, show children on separate indented lines
return f" ~ {path_str} = {value_str}" if isinstance(value, dict) and value:
lines = []
# First line: path with green final element and grey =
if not use_color:
lines.append(f" {'.'.join(path)} =")
elif len(path) == 1:
lines.append(f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET}")
else:
prefix = ".".join(path[:-1])
final = path[-1]
lines.append(
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET}"
)
# Child lines: indented key: value, with aligned values
max_key_len = max(len(k) for k in value.keys())
field_width = max(max_key_len, 12) # minimum 12 chars
for k, v in value.items():
v_str = _format_value(v, use_color)
padding = " " * (field_width - len(k))
if use_color:
lines.append(f" {k}{_DIM}:{_RESET}{padding} {v_str}")
else:
lines.append(f" {k}:{padding} {v_str}")
return lines
else:
value_str = _format_value(value, use_color)
if not use_color:
return [f" {'.'.join(path)} = {value_str}"]
if len(path) == 1:
return [f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET} {value_str}"]
prefix = ".".join(path[:-1])
final = path[-1]
return [
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET} {value_str}"
]
# Default: set/add # update: Existing item being updated - normal path colors
value_str = _format_value(value, use_color)
path_str = _format_path(path, use_color)
if use_color: if use_color:
return f" {_ADD}+{_RESET} {path_str} {_DIM}={_RESET} {value_str}" return [f" {path_str} {_DIM}={_RESET} {value_str}"]
return f" + {path_str} = {value_str}" return [f" {path_str} = {value_str}"]
def format_diff(diff: dict) -> list[str]: def format_diff(diff: dict, previous: dict | None = None) -> list[str]:
""" """
Format a JSON diff as human-readable lines. Format a JSON diff as human-readable lines.
Args:
diff: The JSON diff dict
previous: The previous state dict (for determining add vs update)
Returns a list of formatted lines (without newlines). Returns a list of formatted lines (without newlines).
Single changes return one line, multiple changes return multiple lines. Single changes return one line, multiple changes return multiple lines.
""" """
use_color = _use_color() use_color = _use_color()
changes: list[tuple[str, list[str], Any, Any | None]] = [] changes: list[tuple[str, list[str], Any]] = []
_collect_changes(diff, [], changes) _collect_changes(diff, [], changes, previous)
if not changes: if not changes:
return [] return []
# Format each change # Format each change
lines = [] lines = []
for change_type, path, value, _ in changes: for change_type, path, value in changes:
lines.append(_format_change_line(change_type, path, value, use_color)) lines.extend(_format_change_lines(change_type, path, value, use_color))
return lines return lines
@@ -198,7 +277,12 @@ def format_action_header(action: str, user_display: str | None = None) -> str:
return action return action
def log_change(action: str, diff: dict, user_display: str | None = None) -> None: def log_change(
action: str,
diff: dict,
user_display: str | None = None,
previous: dict | None = None,
) -> None:
""" """
Log a database change with pretty-printed diff. Log a database change with pretty-printed diff.
@@ -206,9 +290,10 @@ def log_change(action: str, diff: dict, user_display: str | None = None) -> None
action: The action name (e.g., "login", "admin:delete_user") action: The action name (e.g., "login", "admin:delete_user")
diff: The JSON diff dict diff: The JSON diff dict
user_display: Optional display name of the user who performed the action user_display: Optional display name of the user who performed the action
previous: The previous state dict (for determining add vs update)
""" """
header = format_action_header(action, user_display) header = format_action_header(action, user_display)
diff_lines = format_diff(diff) diff_lines = format_diff(diff, previous)
if not diff_lines: if not diff_lines:
logger.info(header) logger.info(header)
+25 -4
View File
@@ -352,6 +352,23 @@ def update_user_display_name(
_db.users[uuid].display_name = display_name _db.users[uuid].display_name = display_name
def update_user_theme(
uuid: UUID,
theme: str,
*,
ctx: SessionContext | None = None,
) -> None:
"""Update user theme preference ('' for auto, 'light', 'dark')."""
if isinstance(uuid, str):
uuid = UUID(uuid)
if uuid not in _db.users:
raise ValueError(f"User {uuid} not found")
if theme not in ("", "light", "dark"):
raise ValueError(f"Invalid theme: {theme}")
with _db.transaction("update_user_theme", ctx):
_db.users[uuid].theme = theme
def update_user_role( def update_user_role(
uuid: UUID, uuid: UUID,
role_uuid: UUID, role_uuid: UUID,
@@ -517,16 +534,18 @@ def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None)
update_session(key, host=host, ctx=ctx) update_session(key, host=host, ctx=ctx)
def delete_session(key: str, *, ctx: SessionContext | None = None) -> None: def delete_session(
key: str, *, ctx: SessionContext | None = None, action: str = "delete_session"
) -> None:
"""Delete a session. """Delete a session.
The acting user should be logged via ctx. The acting user should be logged via ctx.
For user logout, pass ctx of the user's session. For user logout, pass ctx of the user's session and action="logout".
For admin terminating a session, pass admin's ctx. For admin terminating a session, pass admin's ctx.
""" """
if key not in _db.sessions: if key not in _db.sessions:
raise ValueError("Session not found") raise ValueError("Session not found")
with _db.transaction("delete_session", ctx): with _db.transaction(action, ctx):
del _db.sessions[key] del _db.sessions[key]
@@ -554,6 +573,7 @@ def create_reset_token(
token_type: str, token_type: str,
*, *,
ctx: SessionContext | None = None, ctx: SessionContext | None = None,
user: str | None = None,
) -> None: ) -> None:
"""Create a reset token from a passphrase. """Create a reset token from a passphrase.
@@ -561,13 +581,14 @@ def create_reset_token(
For self-service (user creating own recovery link), pass user's ctx. For self-service (user creating own recovery link), pass user's ctx.
For admin operations, pass admin's ctx. For admin operations, pass admin's ctx.
For system operations (bootstrap), pass neither to log no user. For system operations (bootstrap), pass neither to log no user.
For API operations where ctx is not available but user is known, pass user.
""" """
key = _reset_key(passphrase) key = _reset_key(passphrase)
if key in _db.reset_tokens: if key in _db.reset_tokens:
raise ValueError("Reset token already exists") raise ValueError("Reset token already exists")
if user_uuid not in _db.users: if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found") raise ValueError(f"User {user_uuid} not found")
with _db.transaction("create_reset_token", ctx): with _db.transaction("create_reset_token", ctx, user=user):
_db.reset_tokens[key] = ResetToken( _db.reset_tokens[key] = ResetToken(
user_uuid=user_uuid, expiry=expiry, token_type=token_type user_uuid=user_uuid, expiry=expiry, token_type=token_type
) )
+3 -2
View File
@@ -147,10 +147,10 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
return role return role
class User(msgspec.Struct, dict=True): class User(msgspec.Struct, dict=True, omit_defaults=True):
"""User data structure. """User data structure.
Mutable fields: display_name, role_uuid, last_seen, visits Mutable fields: display_name, role_uuid, last_seen, visits, theme
Immutable fields: created_at (set at creation, never modified) Immutable fields: created_at (set at creation, never modified)
uuid is derived from created_at using uuid7. uuid is derived from created_at using uuid7.
""" """
@@ -160,6 +160,7 @@ class User(msgspec.Struct, dict=True):
created_at: datetime created_at: datetime
last_seen: datetime | None = None last_seen: datetime | None = None
visits: int = 0 visits: int = 0
theme: str = "" # "" or "auto" = OS default, "light", "dark"
def __post_init__(self): def __post_init__(self):
if not hasattr(self, "uuid"): if not hasattr(self, "uuid"):
+33 -19
View File
@@ -7,12 +7,12 @@ from urllib.parse import urlparse
from fastapi_vue.hostutil import parse_endpoint from fastapi_vue.hostutil import parse_endpoint
from uvicorn import Config, Server from uvicorn import Config, Server
from uvicorn import run as uvicorn_run
from paskia import globals as _globals from paskia import globals as _globals
from paskia.bootstrap import bootstrap_if_needed from paskia.bootstrap import bootstrap_if_needed
from paskia.config import PaskiaConfig from paskia.config import PaskiaConfig
from paskia.db.background import flush from paskia.db.background import flush
from paskia.fastapi import app as fastapi_app
from paskia.fastapi import reset as reset_cmd from paskia.fastapi import reset as reset_cmd
from paskia.util import startupbox from paskia.util import startupbox
from paskia.util.hostutil import normalize_origin from paskia.util.hostutil import normalize_origin
@@ -21,10 +21,10 @@ DEFAULT_PORT = 4401
EPILOG = """\ EPILOG = """\
Examples: Examples:
paskia # localhost:4401 paskia # localhost:4401
paskia :8080 # All interfaces, port 8080 paskia -l :8080 # All interfaces, port 8080
paskia unix:/tmp/paskia.sock paskia -l /tmp/paskia.sock # Unix socket
paskia reset [user] # Generate passkey reset link paskia reset [user] # Generate passkey reset link
""" """
@@ -81,32 +81,40 @@ def main():
epilog=EPILOG, epilog=EPILOG,
) )
# Primary argument: either host:port or "reset" subcommand # Subcommand for reset
parser.add_argument( parser.add_argument(
"hostport", "command",
nargs="?", nargs="?",
help=( help="Command: 'reset' for credential reset, or omit to run server",
"Endpoint (default: localhost:4401). Forms: host[:port] | :port | "
"[ipv6][:port] | ipv6 | unix:/path.sock | 'reset' for credential reset"
),
) )
parser.add_argument( parser.add_argument(
"reset_query", "reset_query",
nargs="?", nargs="?",
help="For 'reset' command: user UUID or substring of display name", help="For 'reset' command: user UUID or substring of display name",
) )
parser.add_argument(
"-l",
"--listen",
metavar="LISTEN",
help=(
"Endpoint to listen on (default: localhost:4401). "
"Forms: host:port port :port [ipv6]:port unix:path /path.sock"
),
)
add_common_options(parser) add_common_options(parser)
args = parser.parse_args() args = parser.parse_args()
# Detect "reset" subcommand (first positional is "reset") # Detect "reset" subcommand
is_reset = args.hostport == "reset" is_reset = args.command == "reset"
if is_reset: if is_reset:
endpoints = [] endpoints = []
else: else:
if args.command is not None:
raise SystemExit(f"Unknown command: {args.command}")
# Parse endpoint using fastapi_vue.hostutil # Parse endpoint using fastapi_vue.hostutil
endpoints = parse_endpoint(args.hostport, DEFAULT_PORT) endpoints = parse_endpoint(args.listen, DEFAULT_PORT)
# Extract host/port/uds from first endpoint for config display and site_url # Extract host/port/uds from first endpoint for config display and site_url
ep = endpoints[0] if endpoints else {} ep = endpoints[0] if endpoints else {}
@@ -188,7 +196,7 @@ def main():
devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL")) devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
run_kwargs: dict = { run_kwargs: dict = {
"log_level": "info", "log_level": "warning", # Suppress startup messages; we use custom logging
"access_log": False, # We use custom AccessLogMiddleware instead "access_log": False, # We use custom AccessLogMiddleware instead
} }
@@ -199,8 +207,6 @@ def main():
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}") raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
run_kwargs["reload"] = True run_kwargs["reload"] = True
run_kwargs["reload_dirs"] = ["paskia"] run_kwargs["reload_dirs"] = ["paskia"]
# Suppress uvicorn startup messages in dev mode
run_kwargs["log_level"] = "warning"
async def async_main(): async def async_main():
await _globals.init( await _globals.init(
@@ -220,10 +226,18 @@ def main():
async with asyncio.TaskGroup() as tg: async with asyncio.TaskGroup() as tg:
for ep in endpoints: for ep in endpoints:
tg.create_task( tg.create_task(
Server(Config(app=fastapi_app, **run_kwargs, **ep)).serve() Server(
Config(app="paskia.fastapi:app", **run_kwargs, **ep)
).serve()
) )
elif devmode:
# Use uvicorn.run for proper reload support (it handles subprocess spawning)
ep = endpoints[0]
uvicorn_run("paskia.fastapi:app", **run_kwargs, **ep)
else: else:
server = Server(Config(app=fastapi_app, **run_kwargs, **endpoints[0])) server = Server(
Config(app="paskia.fastapi:app", **run_kwargs, **endpoints[0])
)
await server.serve() await server.serve()
try: try:
+2 -2
View File
@@ -127,7 +127,7 @@ async def admin_create_org(
db.create_org(org, ctx=ctx) db.create_org(org, ctx=ctx)
# Grant requested permissions to the new org # Grant requested permissions to the new org
for perm in permissions: for perm in permissions:
db.add_permission_to_org(str(org.uuid), perm) db.add_permission_to_org(str(org.uuid), perm, ctx=ctx)
return {"uuid": str(org.uuid)} return {"uuid": str(org.uuid)}
@@ -706,7 +706,7 @@ async def admin_delete_user_session(
if not target_session or target_session.user_uuid != user_uuid: if not target_session or target_session.user_uuid != user_uuid:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
db.delete_session(session_id, ctx=ctx) db.delete_session(session_id, ctx=ctx, action="admin:delete_session")
# Check if admin terminated their own session # Check if admin terminated their own session
current_terminated = session_id == auth current_terminated = session_id == auth
+7 -3
View File
@@ -78,7 +78,7 @@ async def validate_token(
try: try:
ctx = await authz.verify( ctx = await authz.verify(
auth, auth,
perm, " ".join(perm).split(),
host=request.headers.get("host"), host=request.headers.get("host"),
max_age=max_age, max_age=max_age,
) )
@@ -94,6 +94,7 @@ async def validate_token(
ip=request.client.host if request.client else "", ip=request.client.host if request.client else "",
user_agent=request.headers.get("user-agent") or "", user_agent=request.headers.get("user-agent") or "",
expiry=expires(), expiry=expires(),
ctx=ctx,
) )
session.set_session_cookie(response, auth) session.set_session_cookie(response, auth)
renewed = True renewed = True
@@ -130,7 +131,10 @@ async def forward_authentication(
""" """
try: try:
ctx = await authz.verify( ctx = await authz.verify(
auth, perm, host=request.headers.get("host"), max_age=max_age auth,
" ".join(perm).split(),
host=request.headers.get("host"),
max_age=max_age,
) )
# Build permission scopes for Remote-Groups header # Build permission scopes for Remote-Groups header
role_permissions = ( role_permissions = (
@@ -248,7 +252,7 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
if not ctx: if not ctx:
return {"message": "Already logged out"} return {"message": "Already logged out"}
with suppress(Exception): with suppress(Exception):
db.delete_session(auth, ctx=ctx) db.delete_session(auth, ctx=ctx, action="logout")
session.clear_session_cookie(response) session.clear_session_cookie(response)
return {"message": "Logged out successfully"} return {"message": "Logged out successfully"}
+11 -9
View File
@@ -2,6 +2,7 @@ import logging
from fastapi import HTTPException from fastapi import HTTPException
from paskia.fastapi.logging import log_permission_denied
from paskia.util import permutil, sessionutil from paskia.util import permutil, sessionutil
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -79,6 +80,9 @@ async def verify(
mode="login", mode="login",
clear_session=True, clear_session=True,
) )
# User's theme preference for iframe (only if explicitly set)
user_theme = ctx.user.theme if ctx.user.theme else None
# Check max_age requirement if specified # Check max_age requirement if specified
if max_age: if max_age:
try: try:
@@ -87,29 +91,27 @@ async def verify(
status_code=401, status_code=401,
detail="Additional authentication required", detail="Additional authentication required",
mode="reauth", mode="reauth",
theme=user_theme,
) )
except ValueError as e: except ValueError as e:
# Invalid max_age format - log but don't fail the request # Invalid max_age format - log but don't fail the request
logger.warning(f"Invalid max_age format '{max_age}': {e}") logger.warning(f"Invalid max_age format '{max_age}': {e}")
if not match(ctx, perm): if not match(ctx, perm):
# Determine which permissions are missing for clearer diagnostics
effective_scopes = ( effective_scopes = (
{p.scope for p in (ctx.permissions or [])} {p.scope for p in (ctx.permissions or [])}
if ctx.permissions if ctx.permissions
else set(ctx.role.permissions or []) else set(ctx.role.permissions or [])
) )
missing = sorted(set(perm) - effective_scopes) missing = sorted(set(perm) - effective_scopes)
logger.warning( log_permission_denied(
"Permission denied: user=%s role=%s missing=%s required=%s granted=%s", # noqa: E501 ctx, perm, missing, require_all=(match == permutil.has_all)
getattr(ctx.user, "uuid", "?"),
getattr(ctx.role, "display_name", "?"),
missing,
perm,
list(effective_scopes),
) )
raise AuthException( raise AuthException(
status_code=403, mode="forbidden", detail="Permission required" status_code=403,
mode="forbidden",
detail="Permission required",
theme=user_theme,
) )
return ctx return ctx
+64 -21
View File
@@ -4,8 +4,12 @@ import logging
import sys import sys
import time import time
from ipaddress import IPv6Address from ipaddress import IPv6Address
from typing import TYPE_CHECKING
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
if TYPE_CHECKING:
from paskia.db.structs import SessionContext
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import Response from starlette.responses import Response
@@ -13,18 +17,24 @@ logger = logging.getLogger("paskia.access")
_RESET = "\033[0m" _RESET = "\033[0m"
_STATUS_INFO = "\033[32m" # 1xx (green) _STATUS_INFO = "\033[32m" # 1xx (green)
_STATUS_OK = "\033[92m" # 2xx (bright green) _STATUS_OK = "\033[1;92m" # 2xx (bright green)
_STATUS_REDIRECT = "\033[32m" # 3xx (green) _STATUS_REDIRECT = "\033[32m" # 3xx (green)
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red) _STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
_STATUS_SERVER_ERR = "\033[1;31m" # 5xx (bright red) _STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue) _METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
_METHOD_WRITE = "\033[1;34m" # POST, PUT, DELETE, PATCH (bright blue) _METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
_HOST = "\033[1;30m" # hostname (dark grey) _HOST = "\033[38;5;242m" # hostname (dark grey)
_PATH = "\033[0m" # path (default) _PATH = "\033[38;5;250m" # path (white)
_TIMING = "\033[2m" # timing (dim) _TIMING = "\033[38;5;242m" # timing/devmode (dark grey)
_WS_OPEN = "\033[1;33m" # WebSocket connect (bright yellow) _WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
_WS_CLOSE = "\033[0;33m" # WebSocket disconnect (yellow) _WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
_WS_STATUS = "\033[1;30m" # WebSocket close status (dark grey) _WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey)
_AUTHZ_DENIED = "\033[0;31m" # Permission denied (red)
_AUTHZ_USER = "\033[1;34m" # User info (light blue)
_AUTHZ_ORG = "\033[34m" # User info (blue)
_AUTHZ_NEEDS = "\033[1;38;5;231m" # Needs (brightest white)
_AUTHZ_MISSING = "\033[1;31m" # Missing scope (bold red)
_AUTHZ_GRANTED = "\033[0;32m" # Granted scope (green)
def format_ipv6_network(ip: str) -> str: def format_ipv6_network(ip: str) -> str:
@@ -41,8 +51,8 @@ def format_ipv6_network(ip: str) -> str:
network_int >>= 16 network_int >>= 16
# Compress consecutive zero groups # Compress consecutive zero groups
result = ":".join(groups) + "::" result = ":".join(groups) + "::"
# Simplify leading zeros in groups and compress # Simplify leading zeros in groups and compress, then strip trailing ::
return str(IPv6Address(result + "0")) return str(IPv6Address(result + "0")).removesuffix("::")
except Exception: except Exception:
return ip return ip
@@ -83,7 +93,7 @@ def format_access_log(
use_color = sys.stderr.isatty() use_color = sys.stderr.isatty()
# Format components with fixed widths for alignment # Format components with fixed widths for alignment
ip = format_client_ip(client).ljust(15) # IPv4 max 15 chars ip = format_client_ip(client).ljust(19) # IPv6 network max 19 chars
timing = f"{duration_ms:.0f}ms" timing = f"{duration_ms:.0f}ms"
method_padded = method.ljust(7) # Longest method is OPTIONS (7) method_padded = method.ljust(7) # Longest method is OPTIONS (7)
@@ -116,25 +126,39 @@ def _next_ws_id() -> int:
return ws_id return ws_id
def log_ws_open(client: str, host: str, path: str) -> int: def log_ws_open(ws) -> int:
"""Log WebSocket connection open. Returns connection ID for use in close.""" """Log WebSocket connection open. Returns connection ID for use in close."""
use_color = sys.stderr.isatty() use_color = sys.stderr.isatty()
ws_id = _next_ws_id() ws_id = _next_ws_id()
ip = format_client_ip(client).ljust(15) client = ws.client.host if ws.client else "-"
host = ws.headers.get("host", "-")
path = ws.url.path
origin = ws.headers.get("origin")
ip = format_client_ip(client).ljust(19)
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars) id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
# Determine if origin should be shown (omit when same as host)
# Origin header includes scheme (e.g., "https://example.com"), compare host part
origin_host = origin.split("://", 1)[-1] if origin else None
show_origin = origin_host and origin_host != host
if use_color: if use_color:
# 🔌 aligned with status (takes ~2 char width), ID aligned with method # 🔌 aligned with status (takes ~2 char width), ID aligned with method
prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}" prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}"
host_str = f"{_HOST}{host}{_RESET}" host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}" path_str = f"{_PATH}{path}{_RESET}"
origin_str = (
f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
)
else: else:
prefix = f"WS+ {id_str}" prefix = f"WS+ {id_str}"
host_str = host host_str = host
path_str = path path_str = path
origin_str = f" from {origin_host}" if show_origin else ""
logger.info(f"{ip} {prefix} {host_str}{path_str}") logger.info(f"{ip} {prefix} {host_str}{path_str}{origin_str}")
return ws_id return ws_id
@@ -158,15 +182,12 @@ WS_CLOSE_CODES = {
} }
def log_ws_close( def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
client: str, ws_id: int, close_code: int | None, duration_ms: float
) -> None:
"""Log WebSocket connection close with duration and status.""" """Log WebSocket connection close with duration and status."""
use_color = sys.stderr.isatty() use_color = sys.stderr.isatty()
ip = format_client_ip(client).ljust(15)
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars) id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
timing = f"{duration_ms:.0f}ms" timing = f"{duration * 1000:.0f}ms"
# Convert close code to status text # Convert close code to status text
if close_code is None: if close_code is None:
@@ -184,7 +205,27 @@ def log_ws_close(
status_str = status status_str = status
timing_str = timing timing_str = timing
logger.info(f"{ip} {prefix} {status_str} {timing_str}") logger.info(f"{' ' * 19} {prefix} {status_str} {timing_str}")
def log_permission_denied(
ctx: "SessionContext", required: list[str], missing: list[str], *, require_all: bool
) -> None:
"""Log permission denied with org, role, user and highlighted missing scopes."""
missing_set = set(missing)
scopes = " ".join(
f"{_AUTHZ_MISSING}{s}{_RESET}"
if s in missing_set
else f"{_AUTHZ_GRANTED}{s}{_RESET}"
for s in required
)
n = "" if len(required) == 1 else " all" if require_all else " any"
logger.warning(
f"{_AUTHZ_DENIED}Permission denied{_RESET} "
f"{_AUTHZ_USER}{ctx.user.display_name}{_RESET} "
f"{_AUTHZ_ORG}({ctx.org.display_name} {ctx.role.display_name}){_RESET} "
f"{_AUTHZ_NEEDS}needs{n}:{_RESET} {scopes}"
)
class AccessLogMiddleware(BaseHTTPMiddleware): class AccessLogMiddleware(BaseHTTPMiddleware):
@@ -216,3 +257,5 @@ def configure_access_logging():
logger.addHandler(handler) logger.addHandler(handler)
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
logger.propagate = False logger.propagate = False
# Suppress watchfiles "X changes detected" INFO messages (keep WARNING for reload notification)
logging.getLogger("watchfiles.main").setLevel(logging.WARNING)
+8 -5
View File
@@ -20,10 +20,13 @@ from paskia.util import hostutil, passphrase, vitedev
configure_access_logging() configure_access_logging()
configure_db_logging() configure_db_logging()
_access_logger = logging.getLogger("paskia.access")
# Vue Frontend static files # Vue Frontend static files
frontend = Frontend( frontend = Frontend(
Path(__file__).parent.parent / "frontend-build", Path(__file__).parent.parent / "frontend-build",
cached=["/auth/assets/"], cached=["/auth/assets/"],
favicon="/paskia.webp",
) )
@@ -59,7 +62,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
if frontend.devmode: if frontend.devmode:
logging.getLogger("uvicorn").setLevel(logging.INFO) logging.getLogger("uvicorn").setLevel(logging.INFO)
logging.getLogger("uvicorn.error").setLevel(logging.WARNING) logging.getLogger("uvicorn.error").setLevel(logging.WARNING)
await frontend.load() await frontend.load()
await start_background() await start_background()
yield yield
@@ -134,6 +136,11 @@ async def examples_page():
return FileResponse(index_file, media_type="text/html") return FileResponse(index_file, media_type="text/html")
# 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 # Note: this catch-all handler must be the last route defined
@app.get("/{token}") @app.get("/{token}")
@app.get("/auth/{token}") @app.get("/auth/{token}")
@@ -146,7 +153,3 @@ async def token_link(token: str):
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
return Response(*await vitedev.read("/int/reset/index.html")) return Response(*await vitedev.read("/int/reset/index.html"))
# Final catch-all route for frontend files (keep at end of file)
frontend.route(app, "/")
+11 -37
View File
@@ -17,10 +17,10 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from paskia import db, remoteauth from paskia import db, remoteauth
from paskia.authsession import expires from paskia.authsession import expires
from paskia.fastapi.session import infodict from paskia.fastapi.session import AUTH_COOKIE, infodict
from paskia.fastapi.wschat import authenticate_chat from paskia.fastapi.wschat import authenticate_and_login
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
from paskia.util import hostutil, passphrase, pow, useragent from paskia.util import passphrase, pow, useragent
# Create a FastAPI subapp for remote auth WebSocket endpoints # Create a FastAPI subapp for remote auth WebSocket endpoints
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -252,7 +252,7 @@ async def websocket_remote_auth_request(ws: WebSocket):
@app.websocket("/permit") @app.websocket("/permit")
@websocket_error_handler @websocket_error_handler
async def websocket_remote_auth_permit(ws: WebSocket): async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
"""Complete a remote authentication request using a 3-word pairing code. """Complete a remote authentication request using a 3-word pairing code.
This endpoint is called from the user's profile on the authenticating device. This endpoint is called from the user's profile on the authenticating device.
@@ -270,7 +270,7 @@ async def websocket_remote_auth_permit(ws: WebSocket):
7. Server sends {status: "success", message: "..."} 7. Server sends {status: "success", message: "..."}
""" """
origin = validate_origin(ws) validate_origin(ws)
if remoteauth.instance is None: if remoteauth.instance is None:
raise ValueError("Remote authentication is not available") raise ValueError("Remote authentication is not available")
@@ -310,56 +310,30 @@ async def websocket_remote_auth_permit(ws: WebSocket):
# Handle authenticate request (no PoW needed - already validated during lookup) # Handle authenticate request (no PoW needed - already validated during lookup)
if msg.get("authenticate") and request is not None: if msg.get("authenticate") and request is not None:
cred, new_sign_count = await authenticate_chat(ws, origin) ctx = await authenticate_and_login(ws, auth)
# Create a session for the REQUESTING device session_token = ctx.session.key
assert cred.uuid is not None
session_token = None
reset_token = None reset_token = None
if request.action == "register": if request.action == "register":
# For registration, create a reset token for device addition # For registration, create a reset token for device addition
token_str = passphrase.generate() token_str = passphrase.generate()
expiry = expires() expiry = expires()
db.create_reset_token( db.create_reset_token(
user_uuid=cred.user_uuid, user_uuid=ctx.user.uuid,
passphrase=token_str, passphrase=token_str,
expiry=expiry, expiry=expiry,
token_type="device addition", token_type="device addition",
user=str(ctx.user.uuid),
) )
reset_token = token_str reset_token = token_str
# Also create a session so the device is logged in
normalized_host = hostutil.normalize_host(request.host)
session_token = db.login(
user_uuid=cred.user_uuid,
credential_uuid=cred.uuid,
sign_count=new_sign_count,
host=normalized_host,
ip=request.ip,
user_agent=request.user_agent,
expiry=expires(),
)
else:
# Default login action
normalized_host = hostutil.normalize_host(request.host)
session_token = db.login(
user_uuid=cred.user_uuid,
credential_uuid=cred.uuid,
sign_count=new_sign_count,
host=normalized_host,
ip=request.ip,
user_agent=request.user_agent,
expiry=expires(),
)
# Complete the remote auth request (notifies the waiting device) # Complete the remote auth request (notifies the waiting device)
cred = db.data().credentials[ctx.session.credential_uuid]
completed = await remoteauth.instance.complete_request( completed = await remoteauth.instance.complete_request(
token=request.key, token=request.key,
session_token=session_token, session_token=session_token,
user_uuid=cred.user_uuid, user_uuid=ctx.user.uuid,
credential_uuid=cred.uuid, credential_uuid=cred.uuid,
reset_token=reset_token, reset_token=reset_token,
) )
+22
View File
@@ -57,6 +57,28 @@ async def user_update_display_name(
return {"status": "ok"} return {"status": "ok"}
@app.patch("/theme")
async def user_update_theme(
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
if not auth:
raise authz.AuthException(
status_code=401, detail="Authentication Required", mode="login"
)
ctx = db.data().session_ctx(auth, request.headers.get("host"))
if not ctx:
raise authz.AuthException(
status_code=401, detail="Session expired", mode="login"
)
theme = payload.get("theme", "")
if theme not in ("", "light", "dark"):
raise HTTPException(status_code=400, detail="Invalid theme")
db.update_user_theme(ctx.user.uuid, theme, ctx=ctx)
return {"status": "ok"}
@app.post("/logout-all") @app.post("/logout-all")
async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE): async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE):
if not auth: if not auth:
+12 -35
View File
@@ -1,13 +1,13 @@
from fastapi import FastAPI, WebSocket from fastapi import FastAPI, WebSocket
from paskia import db from paskia import db
from paskia.authsession import expires, get_reset from paskia.authsession import get_reset
from paskia.fastapi import authz, remote from paskia.fastapi import authz, remote
from paskia.fastapi.session import AUTH_COOKIE, infodict from paskia.fastapi.session import AUTH_COOKIE, infodict
from paskia.fastapi.wschat import authenticate_chat, register_chat from paskia.fastapi.wschat import authenticate_and_login, register_chat
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
from paskia.globals import passkey from paskia.globals import passkey
from paskia.util import hostutil, passphrase from paskia.util import passphrase
# Create a FastAPI subapp for WebSocket endpoints # Create a FastAPI subapp for WebSocket endpoints
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -46,7 +46,7 @@ async def websocket_register_add(
s = ctx.session s = ctx.session
# Get user information and determine effective user_name for this registration # Get user information and determine effective user_name for this registration
user = db.data().users.get(user_uuid) user = db.data().users[user_uuid]
user_name = user.display_name user_name = user.display_name
if name is not None: if name is not None:
stripped = name.strip() stripped = name.strip()
@@ -59,7 +59,7 @@ async def websocket_register_add(
# Create a new session and store everything in database # Create a new session and store everything in database
metadata = infodict(ws, "authenticated") metadata = infodict(ws, "authenticated")
token = db.create_credential_session( # type: ignore[attr-defined] token = db.create_credential_session(
user_uuid=user_uuid, user_uuid=user_uuid,
credential=credential, credential=credential,
reset_key=(s.key if reset is not None else None), reset_key=(s.key if reset is not None else None),
@@ -89,43 +89,20 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
# If there's an existing session, restrict to that user's credentials (reauth) # If there's an existing session, restrict to that user's credentials (reauth)
session_user_uuid = None session_user_uuid = None
credential_ids = None
if auth: if auth:
ctx = db.data().session_ctx(auth, host) existing_ctx = db.data().session_ctx(auth, host)
if ctx: if existing_ctx:
session_user_uuid = ctx.user.uuid session_user_uuid = existing_ctx.user.uuid
credential_ids = db.get_user_credential_ids(session_user_uuid) or None
cred, new_sign_count = await authenticate_chat(ws, origin, credential_ids) ctx = await authenticate_and_login(ws, auth)
# If reauth mode, verify the credential belongs to the session's user # If reauth mode, verify the credential belongs to the session's user
if session_user_uuid and cred.user_uuid != session_user_uuid: if session_user_uuid and ctx.user.uuid != session_user_uuid:
raise ValueError("This passkey belongs to a different account") raise ValueError("This passkey belongs to a different account")
# Create session and update user/credential in a single transaction
assert cred.uuid is not None
metadata = infodict(ws, "auth")
normalized_host = hostutil.normalize_host(host)
if not normalized_host:
raise ValueError("Host required for session creation")
hostname = normalized_host.split(":")[0]
rp_id = passkey.instance.rp_id
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
token = db.login(
user_uuid=cred.user_uuid,
credential_uuid=cred.uuid,
sign_count=new_sign_count,
host=normalized_host,
ip=metadata["ip"],
user_agent=metadata["user_agent"],
expiry=expires(),
)
await ws.send_json( await ws.send_json(
{ {
"user": str(cred.user_uuid), "user": str(ctx.user.uuid),
"session_token": token, "session_token": ctx.session.key,
} }
) )
+55 -2
View File
@@ -7,8 +7,12 @@ from uuid import UUID
from fastapi import WebSocket from fastapi import WebSocket
from paskia import db from paskia import db
from paskia.db import Credential from paskia.authsession import expires
from paskia.db import Credential, SessionContext
from paskia.fastapi.session import infodict
from paskia.fastapi.wsutil import validate_origin
from paskia.globals import passkey from paskia.globals import passkey
from paskia.util import hostutil
async def register_chat( async def register_chat(
@@ -31,7 +35,6 @@ async def register_chat(
async def authenticate_chat( async def authenticate_chat(
ws: WebSocket, ws: WebSocket,
origin: str,
credential_ids: list[bytes] | None = None, credential_ids: list[bytes] | None = None,
) -> tuple[Credential, int]: ) -> tuple[Credential, int]:
"""Run WebAuthn authentication flow and return the credential and new sign count. """Run WebAuthn authentication flow and return the credential and new sign count.
@@ -39,6 +42,7 @@ async def authenticate_chat(
Returns: Returns:
tuple of (credential, new_sign_count) where new_sign_count comes from WebAuthn verification tuple of (credential, new_sign_count) where new_sign_count comes from WebAuthn verification
""" """
origin = validate_origin(ws)
options, challenge = passkey.instance.auth_generate_options( options, challenge = passkey.instance.auth_generate_options(
credential_ids=credential_ids credential_ids=credential_ids
) )
@@ -60,3 +64,52 @@ async def authenticate_chat(
verification = passkey.instance.auth_verify(authcred, challenge, cred, origin) verification = passkey.instance.auth_verify(authcred, challenge, cred, origin)
return cred, verification.new_sign_count return cred, verification.new_sign_count
async def authenticate_and_login(
ws: WebSocket,
auth: str | None = None,
) -> SessionContext:
"""Run WebAuthn authentication flow, create session, and return the session context.
If auth is provided, restrict authentication to credentials of that session's user.
Returns:
SessionContext for the authenticated session
"""
origin = validate_origin(ws)
host = origin.split("://", 1)[1]
normalized_host = hostutil.normalize_host(host)
if not normalized_host:
raise ValueError("Host required for session creation")
hostname = normalized_host.split(":")[0]
rp_id = passkey.instance.rp_id
if not (hostname == rp_id or hostname.endswith(f".{rp_id}")):
raise ValueError(f"Host must be the same as or a subdomain of {rp_id}")
metadata = infodict(ws, "auth")
# Get credential IDs if restricting to a user's credentials
credential_ids = None
if auth:
existing_ctx = db.data().session_ctx(auth, host)
if existing_ctx:
credential_ids = db.get_user_credential_ids(existing_ctx.user.uuid) or None
cred, new_sign_count = await authenticate_chat(ws, credential_ids)
# Create session and update user/credential
token = db.login(
user_uuid=cred.user_uuid,
credential_uuid=cred.uuid,
sign_count=new_sign_count,
host=normalized_host,
ip=metadata["ip"],
user_agent=metadata["user_agent"],
expiry=expires(),
)
# Fetch and return the full session context
ctx = db.data().session_ctx(token, normalized_host)
if not ctx:
raise ValueError("Failed to create session context")
return ctx
+2 -7
View File
@@ -21,12 +21,8 @@ def websocket_error_handler(func):
@wraps(func) @wraps(func)
async def wrapper(ws: WebSocket, *args, **kwargs): async def wrapper(ws: WebSocket, *args, **kwargs):
client = ws.client.host if ws.client else "-"
host = ws.headers.get("host", "-")
path = ws.url.path
start = time.perf_counter() start = time.perf_counter()
ws_id = log_ws_open(client, host, path) ws_id = log_ws_open(ws)
close_code = None close_code = None
try: try:
@@ -47,8 +43,7 @@ def websocket_error_handler(func):
logging.exception("Internal Server Error") logging.exception("Internal Server Error")
await ws.send_json({"status": 500, "detail": "Internal Server Error"}) await ws.send_json({"status": 500, "detail": "Internal Server Error"})
finally: finally:
duration_ms = (time.perf_counter() - start) * 1000 log_ws_close(ws_id, close_code, time.perf_counter() - start)
log_ws_close(client, ws_id, close_code, duration_ms)
return wrapper return wrapper
View File
+35 -7
View File
@@ -1,6 +1,7 @@
"""Startup configuration box formatting utilities.""" """Startup configuration box formatting utilities."""
import os import os
import re
from sys import stderr from sys import stderr
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -11,12 +12,26 @@ if TYPE_CHECKING:
BOX_WIDTH = 60 # Inner width (excluding box chars) BOX_WIDTH = 60 # Inner width (excluding box chars)
# ANSI color codes
RESET = "\033[0m"
YELLOW = "\033[33m" # Dark yellow
BRIGHT_YELLOW = "\033[93m" # Bright yellow
BRIGHT_WHITE = "\033[1;37m" # Bold bright white
def _visible_len(text: str) -> int:
"""Calculate visible length of text, ignoring ANSI escape codes."""
return len(re.sub(r"\033\[[0-9;]*m", "", text))
def line(text: str = "") -> str: def line(text: str = "") -> str:
"""Format a line inside the box with proper padding, truncating if needed.""" """Format a line inside the box with proper padding, truncating if needed."""
if len(text) > BOX_WIDTH: visible = _visible_len(text)
if visible > BOX_WIDTH:
text = text[: BOX_WIDTH - 1] + "" text = text[: BOX_WIDTH - 1] + ""
return f"{text:<{BOX_WIDTH}}\n" visible = BOX_WIDTH
padding = BOX_WIDTH - visible
return f"{text}{' ' * padding}\n"
def top() -> str: def top() -> str:
@@ -29,12 +44,25 @@ def bottom() -> str:
def print_startup_config(config: "PaskiaConfig") -> None: def print_startup_config(config: "PaskiaConfig") -> None:
"""Print server configuration on startup.""" """Print server configuration on startup."""
# Key graphic with yellow shading (bright for highlights, dark for body)
Y = YELLOW # Dark yellow for main body
B = BRIGHT_YELLOW # Bright yellow for highlights/edges
W = BRIGHT_WHITE # Bold white for URL
R = RESET
lines = [top()] lines = [top()]
lines.append(line(" ▄▄▄▄▄")) lines.append(line(f" {B}▄▄▄▄▄{R}"))
lines.append(line("█ █ Paskia " + __version__)) lines.append(line(f"{B}{Y} {B}{R} Paskia " + __version__))
lines.append(line("█ █▄▄▄▄▄▄▄▄▄▄▄▄")) lines.append(line(f"{B}{Y} {B}{Y}▄▄▄▄▄▄▄▄▄▄▄▄{R}"))
lines.append(line("█ █▀▀▀▀█▀▀█▀▀█ " + config.site_url + config.site_path)) lines.append(
lines.append(line(" ▀▀▀▀▀")) line(
f"{B}{Y} {B}{Y}▀▀▀▀{B}{Y}▀▀{B}{Y}▀▀{B}{R} {W}"
+ config.site_url
+ config.site_path
+ R
)
)
lines.append(line(f" {Y}▀▀▀▀▀{R}"))
# Format auth host section # Format auth host section
if config.auth_host: if config.auth_host:
+4 -1
View File
@@ -9,12 +9,15 @@ from paskia.util.apistructs import ApiSession
def build_session_context(ctx: SessionContext) -> dict: def build_session_context(ctx: SessionContext) -> dict:
"""Build session context dict from SessionContext.""" """Build session context dict from SessionContext."""
return { result = {
"user": {"uuid": ctx.user.uuid, "display_name": ctx.user.display_name}, "user": {"uuid": ctx.user.uuid, "display_name": ctx.user.display_name},
"org": {"uuid": ctx.org.uuid, "display_name": ctx.org.display_name}, "org": {"uuid": ctx.org.uuid, "display_name": ctx.org.display_name},
"role": {"uuid": ctx.role.uuid, "display_name": ctx.role.display_name}, "role": {"uuid": ctx.role.uuid, "display_name": ctx.role.display_name},
"permissions": [p.scope for p in ctx.permissions], "permissions": [p.scope for p in ctx.permissions],
} }
if ctx.user.theme:
result["user"]["theme"] = ctx.user.theme
return result
async def build_user_info( async def build_user_info(
+24 -24
View File
@@ -1,43 +1,43 @@
import shutil """Hatch build hook for building paskia-js and Vue frontend during package build."""
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from sys import stderr from sys import stderr
from hatchling.builders.hooks.plugin.interface import BuildHookInterface from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
# Import utilities from fastapi-vue
exec(Path(__file__).parent.joinpath("fastapi-vue", "util.py").read_text("UTF-8")) # noqa: S102
def run(cmd, **kwargs): def run(cmd, **kwargs):
"""Run a command and display it."""
display_cmd = [Path(cmd[0]).name, *cmd[1:]] display_cmd = [Path(cmd[0]).name, *cmd[1:]]
stderr.write(f"### {' '.join(display_cmd)}\n") stderr.write(f"### {' '.join(display_cmd)}\n")
subprocess.run(cmd, check=True, **kwargs) subprocess.run(cmd, check=True, **kwargs)
def find_build_tool():
install = [
("deno", "install", "--allow-scripts=npm:vue-demi"),
("npm", "install"),
("bun", "--bun", "install"),
]
build = [
("deno", "task", "build"),
("npm", "run", "build"),
("bun", "--bun", "run", "build"),
]
for i, b in zip(install, build, strict=False):
if tool := shutil.which(i[0]):
return [tool, *i[1:]], [tool, *b[1:]]
raise RuntimeError("Deno, npm or Bun is required for building but none was found")
class CustomBuildHook(BuildHookInterface): class CustomBuildHook(BuildHookInterface):
"""Build hook that compiles paskia-js and Vue frontend before packaging."""
def initialize(self, version, build_data): def initialize(self, version, build_data):
super().initialize(version, build_data) super().initialize(version, build_data)
stderr.write(">>> Building the frontend\n") stderr.write(">>> Building paskia-js library\n")
install_cmd, build_cmd = find_build_tool() install_cmd, build_cmd = find_build_tool() # noqa: F821 # type: ignore
try:
# Install dependencies for paskia-js
run(install_cmd, cwd="paskia-js")
stderr.write("\n")
# Build paskia-js
run(build_cmd, cwd="paskia-js")
stderr.write("\n")
except Exception as e:
stderr.write(f"Error occurred while building paskia-js: {e}\n")
raise
stderr.write(">>> Building the frontend\n")
try: try:
run(install_cmd, cwd="frontend") run(install_cmd, cwd="frontend")
+13 -7
View File
@@ -6,9 +6,9 @@ not from the installed package. It starts both the Vite frontend dev server
and the FastAPI backend with auto-reload enabled. and the FastAPI backend with auto-reload enabled.
Usage: Usage:
uv run scripts/devserver.py [host:port] [options...] uv run scripts/devserver.py [-l host:port] [options...]
The optional host:port argument sets where the Vite frontend listens. The optional -l/--listen argument sets where the Vite frontend listens.
All other options are forwarded to `paskia`. All other options are forwarded to `paskia`.
Backend always listens on localhost:4402. Backend always listens on localhost:4402.
@@ -376,9 +376,15 @@ def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None:
def main(): def main():
# Parse optional hostport argument for Vite frontend # Parse optional listen argument for Vite frontend
parser = argparse.ArgumentParser(add_help=False) parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("hostport", nargs="?", default=None) parser.add_argument(
"-l",
"--listen",
metavar="ENDPOINT",
default=None,
help="Vite frontend endpoint (default: localhost:4403)",
)
parser.add_argument("--caddy", action="store_true", help="Run Caddy as HTTPS proxy") parser.add_argument("--caddy", action="store_true", help="Run Caddy as HTTPS proxy")
parser.add_argument("--rp-id", default="localhost", help="Relying Party ID") parser.add_argument("--rp-id", default="localhost", help="Relying Party ID")
parser.add_argument( parser.add_argument(
@@ -389,7 +395,7 @@ def main():
# Parse Vite endpoint # Parse Vite endpoint
vite_host, vite_port, vite_uds, all_ifaces = parse_endpoint( vite_host, vite_port, vite_uds, all_ifaces = parse_endpoint(
args.hostport, DEFAULT_VITE_PORT args.listen, DEFAULT_VITE_PORT
) )
if vite_uds: if vite_uds:
@@ -446,8 +452,8 @@ def main():
# Start Vite dev server # Start Vite dev server
run_vite(vite_url, vite_host, vite_port, env, args.auth_host) run_vite(vite_url, vite_host, vite_port, env, args.auth_host)
# Build command with origin args (no serve subcommand, host:port is first arg) # Build command with origin args
cmd = ["paskia", f"localhost:{BACKEND_PORT}"] cmd = ["paskia", "-l", f"localhost:{BACKEND_PORT}"]
# Pass through rp-id (always pass, has default) # Pass through rp-id (always pass, has default)
cmd.extend(["--rp-id", args.rp_id]) cmd.extend(["--rp-id", args.rp_id])