Compare commits

...
17 Commits
Author SHA1 Message Date
LeoVasanko c4360df110 Release 2.1.0 2026-09-18 01:32:45 +00:00
LeoVasanko c676665795 Add release script with version bump, tagging and rollback 2026-09-18 01:32:09 +00:00
LeoVasanko 55dd43661c Rewritten README. 2026-09-18 01:11:45 +00:00
LeoVasanko 753ce868c6 Examples: simplify profile demo to print the result code 2026-09-18 00:27:21 +00:00
LeoVasanko 3e0152e688 Profile iframe handles no-session internally with in-place login
The restricted entry validates the session before rendering anything
(no load-time flash): 401/403 switches to the existing login component
in place of the profile, success renders the panel fully populated via
props, other failures show a minimal card with Back only. The dialog
drops the standalone page's heading and help text.
2026-09-18 00:27:21 +00:00
LeoVasanko c5efa03908 profile(): resolve 'login' when login flow completes inside the dialog
The overlay now tracks which dialog kind is open: auth-success from a
profile dialog resolves 'login', auth-back only rejects
AuthCancelledError for auth dialogs.
2026-09-18 00:27:21 +00:00
LeoVasanko 0ca4e07e23 Dev server: serve paskia-js module, rework examples page
The Vite dev server now maps /paskia-js/ to the local paskia-js build
(the examples page's module import has 404'd since it was introduced,
leaving all buttons dead). Navigation actions on the examples page are
now plain same-window links so the flows' back navigation returns to
the page; added a Profile Summary button exercising profile().
2026-09-17 19:17:00 +00:00
LeoVasanko baa993e586 Theme precedence: URL param, then localStorage, then browser default
The restricted page's early script now honors the URL theme parameter
before the cached profile theme, so a fresh server-provided override
wins and the host color scheme applies without a flash. After session
load, an empty profile theme clears the cache but keeps the URL
parameter in effect instead of reverting to the system default.
2026-09-17 19:17:00 +00:00
LeoVasanko 42240dd2c7 Unify host profile view as framed dialog, add profile iframe mode
HostProfileView now renders the same centered frame card as the login
flows, whether shown full-page at /auth/ (host mode) or inside the new
#mode=profile restricted iframe. The component self-fetches its data
when the parent does not provide it, and emits back/logout so each
context reacts appropriately: the full page reloads, the iframe posts
auth-back / auth-logout to the host.
2026-09-17 19:17:00 +00:00
LeoVasanko 2ec709905e paskia-js: profile() dialog, auth-logout message, host color-scheme adoption
New profile() function opens the minimal profile in a compact dialog
iframe and always resolves ('logout' | 'back'), keeping the auth flow's
resolve/reject contract separate and unchanged. The overlay now injects
the host page's computed color-scheme into the iframe URL theme param
when the server has not provided one.
2026-09-17 19:17:00 +00:00
LeoVasanko 6eb862278f Add tests for startup box sign-in summary wildcard pruning 2026-09-10 19:57:37 +00:00
LeoVasanko dbd697772a Updated systemd unit in README for better messages. 2026-09-10 01:55:44 +00:00
LeoVasanko 17abcc48c0 Replace ua-parser wrapper with uarite uaparse 2026-09-09 19:22:59 +00:00
LeoVasanko 97ce10dd6f Improved formatting of origin configuration in startup box. 2026-09-09 19:10:01 +00:00
LeoVasanko 3a7ba09ddd Fix legacy conversion dropping 'empty origins = allow all' when an auth host was set
The **.{rp-id} wildcard was only added when the resulting origins dict
was empty, so a legacy database with a dedicated auth host but no
configured origins ended up allowing only the auth host.
2026-09-09 17:35:04 +00:00
LeoVasanko 0da04ac3e9 Restore --save option to persist CLI setting --listen as the default 2026-09-09 17:25:51 +00:00
LeoVasanko ae1928241e Migrate command: merge legacy and current databases into existing paskia.kantadb
- paskia migrate accepts an rp-id, a legacy *.paskiadb path, or a
  current-format *.kantadb path; with an existing target database the
  incoming data is merged (uuid-keyed records make conflicts a non-issue,
  domains merge per rp-id with a union of origins)
- Migration transactions are labeled migrate:cli:{rp-id} (slash-joined
  for multi-domain sources) instead of 'bootstrap'
2026-09-09 17:23:27 +00:00
23 changed files with 1072 additions and 303 deletions
+2 -1
View File
@@ -188,11 +188,12 @@ Paste the following and save:
```ini ```ini
[Unit] [Unit]
Description=Paskia Description=Paskia authentication system
[Service] [Service]
Type=simple Type=simple
User=paskia User=paskia
SyslogIdentifier=paskia
WorkingDirectory=/srv/paskia WorkingDirectory=/srv/paskia
ExecStart=uvx paskia@latest ExecStart=uvx paskia@latest
+37 -26
View File
@@ -8,6 +8,17 @@
:root { :root {
color-scheme: light dark; /* Automatic themes by browser */ color-scheme: light dark; /* Automatic themes by browser */
} }
.section a, .section button {
display: inline-block;
padding: 0.4em;
margin-right: 0.3em;
border: none;
background: #aaa2;
color: inherit;
font: inherit;
text-decoration: none;
cursor: pointer;
}
</style> </style>
</head> </head>
<body> <body>
@@ -20,13 +31,14 @@
<div class="content"> <div class="content">
<div class="section"> <div class="section">
<h2>Management Site</h2> <h2>Management Site</h2>
<button onclick="window.open('/auth/', '_blank')">👤 User Profile</button> <a href="/auth/">👤 User Profile</a>
<button onclick="window.open('/auth/admin/', '_blank')">⚙️ Admin Panel</button> <a href="/auth/admin/">⚙️ Admin Panel</a>
</div> </div>
<div class="section"> <div class="section">
<h2>API Mode (not leaving the page)</h2> <h2>API Mode (not leaving the page)</h2>
<p>For SPAs and fetch() calls - shows auth in an iframe overlay:</p> <p>For SPAs and fetch() calls - shows auth in an iframe overlay:</p>
<button onclick="profileDemo()">👤 Login/Profile</button>
<button onclick="apiCall('/auth/api/user-info', 'GET')">📋 Get User Info</button> <button onclick="apiCall('/auth/api/user-info', 'GET')">📋 Get User Info</button>
<button onclick="apiCall('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button> <button onclick="apiCall('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button>
<button onclick="apiCall('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button> <button onclick="apiCall('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button>
@@ -35,10 +47,10 @@
<div class="section"> <div class="section">
<h2>Browser Mode (full page)</h2> <h2>Browser Mode (full page)</h2>
<p>Block access to otherwise open site - intended for forward-auth mechanism (Caddy, Nginx):</p> <p>Block access to otherwise open site - intended for forward-auth mechanism (Caddy, Nginx). If not authenticated, you'll see the login page; after auth, a 204 response (blank page = success). Back returns here:</p>
<button onclick="browserNav('/auth/api/forward')">🔐 Basic Auth</button> <a href="/auth/api/forward">🔐 Basic Auth</a>
<button onclick="browserNav('/auth/api/forward?max_age=10s')">🔄 Reauth (max_age=10s)</button> <a href="/auth/api/forward?max_age=10s">🔄 Reauth (max_age=10s)</a>
<button onclick="browserNav('/auth/api/forward?perm=auth:admin')">🛡️ Admin Only</button> <a href="/auth/api/forward?perm=auth:admin">🛡️ Admin Only</a>
</div> </div>
<pre id="output">Click a button to test...</pre> <pre id="output">Click a button to test...</pre>
@@ -46,52 +58,51 @@
</div> </div>
<script type="module"> <script type="module">
import { apiFetch, apiJson, AuthCancelledError } from '/paskia-js/dist/paskia.js' import { apiFetch, apiJson, AuthCancelledError, profile } from '/paskia-js/dist/paskia.js'
const output = document.getElementById('output');
function log(msg) { function log(msg) {
output.textContent = msg; console.log(msg)
document.getElementById('output').textContent = msg
} }
// Make an API call using paskia module (handles 401/403 automatically) // Make an API call using paskia module (handles 401/403 automatically)
window.apiCall = async function(url, method = 'GET') { window.apiCall = async function(url, method = 'GET') {
log(`${method} ${url}...`); log(`${method} ${url}...`)
try { try {
const response = await apiFetch(url, { method }); const response = await apiFetch(url, { method })
// Forward endpoint returns 204 on success // Forward endpoint returns 204 on success
if (response.status === 204) { if (response.status === 204) {
log('✓ Success (204 No Content)'); log('✓ Success (204 No Content)')
return; return
} }
if (!response.ok) { if (!response.ok) {
log(`Error: ${response.status} ${response.statusText}`); log(`Error: ${response.status} ${response.statusText}`)
return; return
} }
const data = await response.json(); const data = await response.json()
log('✓ Response:\n' + JSON.stringify(data, null, 2)); log('✓ Response:\n' + JSON.stringify(data, null, 2))
} catch (e) { } catch (e) {
if (e instanceof AuthCancelledError) { if (e instanceof AuthCancelledError) {
log('Authentication cancelled'); log('Authentication cancelled')
} else { } else {
log(`Error: ${e.message}`); log(`Error: ${e.message}`)
} }
} }
} }
window.logout = async function() { window.logout = async function() {
await fetch('/auth/api/logout', { method: 'POST' }); await fetch('/auth/api/logout', { method: 'POST' })
log('Logged out'); log('Logged out')
} }
// Browser mode: open the forward endpoint directly in a new window. // Profile dialog: resolves 'login' / 'logout' / 'back'
window.browserNav = function(url) { window.profileDemo = async function() {
log('Opening in new window...\nIf not authenticated, you\'ll see the login page.\nAfter auth, you\'ll see a 204 response (blank page = success).'); log(`Profile return: ${await profile()}`)
window.open(url, '_blank');
} }
</script> </script>
</body> </body>
</html> </html>
+15 -1
View File
@@ -2,7 +2,14 @@
<div class="app-shell"> <div class="app-shell">
<StatusMessage /> <StatusMessage />
<main class="app-main"> <main class="app-main">
<HostProfileView v-if="viewState === 'profile' && isHostMode" /> <HostProfileView
v-if="viewState === 'profile' && isHostMode"
:ctx="store.ctx"
:user-info="store.userInfo"
:settings="store.settings"
@back="goBack"
@logout="onHostLogout"
/>
<ProfileView v-else-if="viewState === 'profile'" /> <ProfileView v-else-if="viewState === 'profile'" />
<LoadingView v-else-if="viewState === 'loading'" :message="loadingMessage" /> <LoadingView v-else-if="viewState === 'loading'" :message="loadingMessage" />
<AccessDenied v-else-if="viewState === 'terminal'" /> <AccessDenied v-else-if="viewState === 'terminal'" />
@@ -15,6 +22,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia' import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme' import { updateThemeFromSession } from '@/utils/theme'
import { goBack } from '@/utils/helpers'
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'
@@ -48,6 +56,12 @@ const isHostMode = computed(() => {
return currentHost !== configuredHost return currentHost !== configuredHost
}) })
// HostProfileView already posted /auth/api/logout; clear local state and reload.
function onHostLogout() {
sessionStorage.clear()
window.location.reload()
}
function onSessionLost(e) { function onSessionLost(e) {
store.userInfo = null store.userInfo = null
store.ctx = null store.ctx = null
+81 -2
View File
@@ -1,5 +1,36 @@
<template> <template>
<template v-if="authMode === 'profile'">
<!--
Profile mode: render nothing until the session check completes (avoids
a load-time flash of the wrong view). Without a session, the login flow
runs in place of the profile; on success auth-success is posted and the
host resolves profile() with 'login'.
-->
<RestrictedAuth
v-if="profileState === 'login'"
mode="login"
@authenticated="handleAuthenticated"
@back="handleBack"
/>
<HostProfileView
v-else-if="profileState === 'ready'"
:ctx="profileCtx"
:user-info="profileInfo"
:settings="profileSettings"
@back="handleBack"
@logout="handleLogout"
/>
<div v-else class="view-root profile-pending">
<div class="surface surface--tight">
<p class="view-lede">{{ profileState === 'error' ? 'Could not load your account.' : 'Loading your account…' }}</p>
<div class="button-row">
<button type="button" class="btn-secondary" @click="handleBack">Back</button>
</div>
</div>
</div>
</template>
<RestrictedAuth <RestrictedAuth
v-else
:mode="authMode" :mode="authMode"
:remote-auth-token="remoteAuthToken" :remote-auth-token="remoteAuthToken"
:oidc-query-string="oidcQueryString" :oidc-query-string="oidcQueryString"
@@ -11,6 +42,10 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import RestrictedAuth from '@/components/RestrictedAuth.vue' import RestrictedAuth from '@/components/RestrictedAuth.vue'
import HostProfileView from '@/components/HostProfileView.vue'
import { fetchJson, settings as paskiaSettings } from 'paskia'
import { getSettings } from '@/utils/settings'
import { updateThemeFromSession } from '@/utils/theme'
// Check if this is a remote auth URL: /auth/{token} // Check if this is a remote auth URL: /auth/{token}
// The token is a 5-word passphrase like "word1.word2.word3.word4.word5" // The token is a 5-word passphrase like "word1.word2.word3.word4.word5"
@@ -45,8 +80,32 @@ let authMode
if (window.location.pathname === '/auth/restricted/oidc') { if (window.location.pathname === '/auth/restricted/oidc') {
authMode = 'oidc' authMode = 'oidc'
} else { } else {
// Both iframe and forward auth use hash params for mode (forbidden/login/reauth) // Both iframe and forward auth use hash params for mode (forbidden/login/reauth/profile)
authMode = ['reauth', 'forbidden'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login' authMode = ['reauth', 'forbidden', 'profile'].includes(hashParams.get('mode')) ? hashParams.get('mode') : 'login'
}
// Profile mode state: 'loading' | 'login' | 'ready' | 'error'
const profileState = ref('loading')
const profileCtx = ref(null)
const profileInfo = ref(null)
const profileSettings = ref(null)
async function loadProfile() {
try {
const [validateData, infoData, settingsData] = await Promise.all([
fetchJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
fetchJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms }),
getSettings()
])
profileCtx.value = validateData.ctx
profileInfo.value = infoData
profileSettings.value = settingsData
updateThemeFromSession(validateData.ctx)
profileState.value = 'ready'
} catch (error) {
// No/expired session: run the login flow in place of the profile
profileState.value = error.status === 401 || error.status === 403 ? 'login' : 'error'
}
} }
function postToParent(message) { function postToParent(message) {
@@ -74,10 +133,18 @@ function handleBack() {
}) })
} }
function handleLogout() {
postToParent({
type: 'auth-logout'
})
}
onMounted(() => { onMounted(() => {
// Check for remote auth token in URL // Check for remote auth token in URL
remoteAuthToken.value = extractRemoteToken() remoteAuthToken.value = extractRemoteToken()
if (authMode === 'profile') loadProfile()
postToParent({ postToParent({
type: 'auth-ready' type: 'auth-ready'
}) })
@@ -89,3 +156,15 @@ onMounted(() => {
}) })
}) })
</script> </script>
<style scoped>
.view-root.profile-pending { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
.profile-pending .surface {
max-width: 520px;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
</style>
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>{let t=localStorage.getItem('paskia-theme');if(!t){let p=new URLSearchParams(location.hash.slice(1)).get('theme');if(p==='light'||p==='dark')t=p}(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark');if(window.location.pathname==='/auth/restricted/iframe')document.documentElement.style.background='transparent'}</script> <script>{let t=new URLSearchParams(location.hash.slice(1)).get('theme');if(t!=='light'&&t!=='dark')t=localStorage.getItem('paskia-theme');(t==='dark'||t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches)&&document.documentElement.classList.add('dark');if(window.location.pathname==='/auth/restricted/iframe')document.documentElement.style.background='transparent'}</script>
<link rel="stylesheet" href="/src/assets/style.css"> <link rel="stylesheet" href="/src/assets/style.css">
</head> </head>
<body> <body>
+151 -78
View File
@@ -1,92 +1,116 @@
<template> <template>
<section class="view-root view-root--wide host-view" data-view="host-profile"> <div class="view-root host-profile" data-view="host-profile">
<header class="view-header"> <div class="surface surface--tight">
<h1>{{ headingTitle }}</h1> <!-- Heading/lede belong to the standalone page; in the dialog the host
<p class="view-lede">{{ subheading }}</p> page already provides the surrounding context. -->
</header> <header v-if="!inIframe" class="view-header center">
<h1>{{ headingTitle }}</h1>
<p class="view-lede">{{ subheading }}</p>
</header>
<section class="section-block" ref="userInfoSection"> <section class="section-block">
<div class="section-body"> <div class="section-body">
<UserBasicInfo <UserBasicInfo
v-if="ctx" v-if="sessionCtx && info"
:name="ctx.user.display_name" :name="sessionCtx.user.display_name"
:avatar-url="authStore.userInfo.user.avatar_url" :avatar-url="info.user.avatar_url"
:visits="authStore.userInfo.user.visits" :visits="info.user.visits"
:created-at="authStore.userInfo.user.created_at" :created-at="info.user.created_at"
:last-seen="authStore.userInfo.user.last_seen" :last-seen="info.user.last_seen"
:email="ctx.user.email" :email="sessionCtx.user.email"
:telephone="ctx.user.telephone" :telephone="sessionCtx.user.telephone"
:org-display-name="orgDisplayName" :org-display-name="orgDisplayName"
:role-name="roleDisplayName" :role-name="roleDisplayName"
:can-edit="false" :can-edit="false"
/> />
<p v-else class="empty-state"> <p v-else class="empty-state">
{{ initializing ? 'Loading your account…' : 'No active session found.' }} {{ loading ? 'Loading your account…' : 'No active session found.' }}
</p> </p>
</div>
</section>
<section class="section-block">
<div class="section-body host-actions">
<div class="button-row" ref="buttonRow" @keydown="handleButtonRowKeydown">
<button
type="button"
class="btn-secondary"
@click="goBack"
>
Back
</button>
<button
type="button"
class="btn-danger"
:disabled="authStore.isLoading"
@click="logout"
>
{{ authStore.isLoading ? 'Signing out…' : 'Logout' }}
</button>
<button
v-if="authSiteUrl"
type="button"
class="btn-primary"
:disabled="authStore.isLoading"
@click="goToAuthSite"
>
Full Profile
</button>
</div> </div>
<p class="note"><strong>Logout</strong> from {{ currentHost }}, or access your <strong>Full Profile</strong> at {{ authSiteHost }} (you may need to sign in again).</p> </section>
</div>
</section> <section class="section-block">
</section> <div class="section-body host-actions">
<div class="button-row" ref="buttonRow" @keydown="handleButtonRowKeydown">
<button
type="button"
class="btn-secondary"
@click="$emit('back')"
>
Back
</button>
<button
v-if="sessionCtx"
type="button"
class="btn-danger"
:disabled="busy"
@click="logout"
>
{{ busy ? 'Signing out…' : 'Logout' }}
</button>
<button
type="button"
class="btn-primary"
:disabled="busy"
@click="goToAuthSite"
>
Full Profile
</button>
</div>
<p v-if="!inIframe" class="note"><strong>Logout</strong> from {{ currentHost }}, or view your <strong>Full Profile</strong> at {{ authSiteHost }} (you may need to sign in again).</p>
</div>
</section>
</div>
</div>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import UserBasicInfo from '@/components/UserBasicInfo.vue' import UserBasicInfo from '@/components/UserBasicInfo.vue'
import { useAuthStore } from '@/stores/auth' import { getSettings } from '@/utils/settings'
import { goBack } from '@/utils/helpers' import { fetchJson, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
import { getDirection, navigateButtonRow } from '@/utils/keynav' import { getDirection, navigateButtonRow } from '@/utils/keynav'
defineProps({ // Data may be provided by the parent (full-page /auth/ app already loaded it
initializing: { // into the store); otherwise the component fetches it itself (restricted iframe).
type: Boolean, const props = defineProps({
default: false ctx: {
type: Object,
default: null
},
userInfo: {
type: Object,
default: null
},
settings: {
type: Object,
default: null
} }
}) })
const authStore = useAuthStore() const emit = defineEmits(['back', 'logout'])
const inIframe = window.parent !== window
const currentHost = window.location.host const currentHost = window.location.host
const fetchedCtx = ref(null)
const fetchedInfo = ref(null)
const fetchedSettings = ref(null)
const loading = ref(!(props.ctx && props.userInfo))
const busy = ref(false)
// Template refs for navigation // Template refs for navigation
const userInfoSection = ref(null)
const buttonRow = ref(null) const buttonRow = ref(null)
const ctx = computed(() => authStore.userInfo || null) const sessionCtx = computed(() => props.ctx || fetchedCtx.value)
const orgDisplayName = computed(() => ctx.value?.org?.display_name ?? '') const info = computed(() => props.userInfo || fetchedInfo.value)
const roleDisplayName = computed(() => ctx.value?.role?.display_name ?? '') const settingsData = computed(() => props.settings || fetchedSettings.value)
const orgDisplayName = computed(() => sessionCtx.value?.org?.display_name ?? '')
const roleDisplayName = computed(() => sessionCtx.value?.role?.display_name ?? '')
const headingTitle = computed(() => { const headingTitle = computed(() => {
const service = authStore.settings?.rp_name const service = settingsData.value?.rp_name
return service ? `${service} account` : 'Account overview' return service ? `${service} account` : 'Account overview'
}) })
@@ -94,11 +118,12 @@ const subheading = computed(() => {
return `You're signed in to ${currentHost}.` return `You're signed in to ${currentHost}.`
}) })
const authSiteHost = computed(() => authStore.settings?.auth_host || '') const authSiteHost = computed(() => settingsData.value?.auth_host || '')
const authSiteUrl = computed(() => { const authSiteUrl = computed(() => {
const host = authSiteHost.value // Fall back to the current host when no separate auth host is configured;
if (!host) return '' // the full profile is at ui_base_path either way.
let path = authStore.settings?.ui_base_path ?? '/auth/' const host = authSiteHost.value || currentHost
let path = settingsData.value?.ui_base_path ?? '/auth/'
if (!path.startsWith('/')) path = `/${path}` if (!path.startsWith('/')) path = `/${path}`
if (!path.endsWith('/')) path = `${path}/` if (!path.endsWith('/')) path = `${path}/`
const protocol = window.location.protocol || 'https:' const protocol = window.location.protocol || 'https:'
@@ -107,11 +132,27 @@ const authSiteUrl = computed(() => {
const goToAuthSite = () => { const goToAuthSite = () => {
if (!authSiteUrl.value) return if (!authSiteUrl.value) return
window.location.href = authSiteUrl.value // Inside an iframe, open the full profile in a new window and close the
// frame (auth-back) so the host page regains focus.
if (inIframe) {
window.open(authSiteUrl.value, '_blank')
emit('back')
} else {
window.location.href = authSiteUrl.value
}
} }
const logout = async () => { const logout = async () => {
await authStore.logout() if (busy.value) return
busy.value = true
try {
await fetchJson('/auth/api/logout', { method: 'POST', timeout: paskiaSettings.auth_ms })
} catch (error) {
console.error('Logout error:', error)
}
// The parent decides how to react: the full-page app reloads, the iframe
// host receives auth-logout and closes the frame.
emit('logout')
} }
// Keyboard navigation for button row // Keyboard navigation for button row
@@ -124,7 +165,39 @@ const handleButtonRowKeydown = (event) => {
if (direction === 'left' || direction === 'right') { if (direction === 'left' || direction === 'right') {
navigateButtonRow(buttonRow.value, event.target, direction, { itemSelector: 'button' }) navigateButtonRow(buttonRow.value, event.target, direction, { itemSelector: 'button' })
} }
// Up does nothing (no elements above to navigate to)
// Down does nothing (no elements below to navigate to)
} }
onMounted(async () => {
if (!props.settings) {
getSettings().then((data) => { fetchedSettings.value = data })
}
if (props.ctx && props.userInfo) return
try {
const [validateData, infoData] = await Promise.all([
fetchJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
fetchJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
])
fetchedCtx.value = validateData.ctx
fetchedInfo.value = infoData
updateThemeFromSession(validateData.ctx)
} catch (error) {
if (error.status !== 401 && error.status !== 403) {
console.error('Failed to load account summary:', error)
}
} finally {
loading.value = false
}
})
</script> </script>
<style scoped>
.view-root.host-profile { min-height: 100vh; align-items: center; justify-content: center; padding: 2rem 1rem; }
.surface.surface--tight {
max-width: 520px;
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.75rem;
}
</style>
+12 -1
View File
@@ -38,9 +38,20 @@ export function initThemeFromCache() {
applyTheme(getCachedTheme()) applyTheme(getCachedTheme())
} }
/** Theme default from the URL hash (restricted iframe/forward pages only) */
function getHashTheme() {
const theme = new URLSearchParams(window.location.hash.slice(1)).get('theme')
return theme === 'light' || theme === 'dark' ? theme : ''
}
/** Update theme from session context (call after login/session load) */ /** Update theme from session context (call after login/session load) */
export function updateThemeFromSession(ctx, animate = false) { export function updateThemeFromSession(ctx, animate = false) {
const theme = ctx?.user?.theme || '' const theme = ctx?.user?.theme || ''
// Always keep the cache in sync with the profile: empty override clears it
// so stale values never mask future server-provided themes.
setCachedTheme(theme) setCachedTheme(theme)
applyTheme(theme, document.documentElement, animate) // Without a profile override, stay consistent with the initial paint: a
// theme parameter on the URL (e.g. host page color scheme injected by
// paskia-js) remains in effect before the browser/desktop default.
applyTheme(theme || getHashTheme(), document.documentElement, animate)
} }
+8
View File
@@ -64,6 +64,14 @@ export default defineConfig(({ command }) => ({
}) })
} }
}, },
{
name: 'serve-paskia-js',
configureServer(server) {
// Serve the locally built paskia-js module for the examples page
const serve = sirv(resolve(__dirname, '../paskia-js'), { dev: true })
server.middlewares.use('/paskia-js', serve)
}
},
{ {
name: 'serve-examples', name: 'serve-examples',
configureServer(server) { configureServer(server) {
+84 -78
View File
@@ -1,14 +1,14 @@
# Paskia
![Screenshot](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/forbidden-light.webp) ![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. # Paskia
JavaScript utilities for integrating the [Paskia authentication system](https://git.zi.fi/leovasanko/paskia) into web apps.
## Installation ## Installation
### NPM ### npm
No framework dependencies. Works with any framework (Vue, React, Svelte, etc.) or vanilla JS. Typescript typing included. No framework dependencies. Works with Vue, React, Svelte, vanilla JavaScript and other frontend stacks. TypeScript types are included.
```sh ```sh
npm install paskia npm install paskia
@@ -20,7 +20,7 @@ import { ... } from 'paskia'
### Plain JavaScript ### 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. Import directly from a CDN, or [download](https://cdn.jsdelivr.net/npm/paskia@latest/dist/paskia.js) and host it yourself. No Node.js is required.
```html ```html
<script type="module"> <script type="module">
@@ -28,91 +28,102 @@ Fetch the module directly from a CDN, or [download](https://cdn.jsdelivr.net/npm
</script> </script>
``` ```
## Features ## Authentication
### Session Validation ### API requests
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. `apiFetch` wraps `fetch` with Paskia authentication handling, while `apiJson` adds automatic JSON request/response handling. Both support request timeouts. For the same JSON and timeout handling without prompting the user for authentication, use `fetchJson`.
```js
import { apiJson, apiFetch } from 'paskia'
const data = await apiJson('/api/endpoint', {
method: 'POST',
body: { key: 'value' }
})
const response = await apiFetch('/api/endpoint')
```
With `apiJson`, a provided `body` is JSON-encoded with the appropriate content type and the response is parsed as JSON.
When the server requests authentication, the API call pauses while the appropriate Paskia dialog is shown and retries after successful authentication.
> Paskia uses `401` and `403` responses to trigger the appropriate **login**, **reauthentication** or **access denied** flow. The backend supplies the authentication URL and context; see the main Paskia documentation for the full response protocol.
### Account and Profile
`profile()` provides a single dialog for an application's login/profile button that allows the user to sign in, view who they are and sign out without ever leaving the page.
```js
import { profile } from 'paskia'
const result = await profile()
if (result !== 'back') // Refresh application state
```
When signed out, it presents the login flow and returns `'login'` on success. When signed in, it shows the profile and returns `'logout'` after logout. `'back'` is returned when the dialog is closed without an expected session change.
Authentication and profile dialogs follow the user's theme override when set in profile, otherwise the host page's light/dark `color-scheme` to remain in the application's color scheme, then the browser/OS preference.
### Lower-level Authentication
`apiFetch` and `apiJson` call `showAuthIframe()` internally. Applications using plain `fetch` or `fetchJson` can call it directly with an authentication URL returned by the backend:
```js
import { showAuthIframe } from 'paskia'
await showAuthIframe(data.auth.iframe)
```
## Session Validation
`SessionValidator` periodically checks that the active Paskia session is still valid and still belongs to the user your application currently has loaded. Validation also refreshes the session to avoid expiry during use.
```js ```js
import { SessionValidator } from 'paskia' import { SessionValidator } from 'paskia'
const validator = new SessionValidator( const validator = new SessionValidator(
() => currentUser?.uuid, // getter for current user ID that we track () => currentUser?.uuid, // User ID currently known by your app
(error) => handleSessionLost(error) // callback when session is lost error => handleSessionLost(error)
) )
validator.start() // call at your app startup/login validator.start()
validator.stop() // stop the system (optional) validator.stop()
``` ```
### API Fetch Utilities The first callback is read on each check, so a logout, expired session or switch to another account invalidates the session your app is currently using. Polling pauses while the user is inactive, avoiding unnecessary traffic and allowing idle sessions to expire.
Enhanced fetch functions with automatic error handling and authentication retry: ## Timeout Settings
```js Paskia exports mutable defaults for network and session timers:
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.
### Timeout Settings
Paskia exports a mutable settings object for defaults used by fetch/auth/session validation timers. Default values shown below.
```js ```js
import { settings } from 'paskia' import { settings } from 'paskia'
// General fetch timeout used by apiFetch/apiJson/fetchJson when no timeout is passed settings.fetch_ms = 10000 // apiFetch, apiJson and fetchJson timeout
settings.fetch_ms = 10000 settings.auth_ms = 1000 // Session validation request timeout
settings.poll_ms = 60000 // Session validation interval
// Fetch timeout used by SessionValidator (/auth/api/validate is fast) settings.idle_ms = 300000 // Inactivity before validation pauses
settings.auth_ms = 1000
// SessionValidator polling and idle timers
settings.poll_ms = 60000
settings.idle_ms = 300000
``` ```
You can still override timeout per request: Request timeout can also be overridden per call:
```js ```js
await apiJson('/api/upload', { method: 'POST', body: data, timeout: 30000 }) await apiJson('/api/upload', {
method: 'POST',
body: data,
timeout: 30000
})
``` ```
### Authentication Overlay ## Shared Blur Backdrop
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. A shared backdrop provides consistent UX across your application, avoiding different things stacking with their own backdrops and dialogs in unexpected manner.
The backend returns 401/403 responses with the correct URL for proper user feedback. Alternatively you may use `/auth/restricted/iframe#mode=login`, `mode=reauth` or `mode=forbidden` to trigger the UX flow you need. Paskia dialogs use a shared blurred backdrop at z-index `1099` and the authentication iframe at `9999`. Application dialogs can use `1100``9998` to appear between them.
```js The same refcounted backdrop can be used by application UI:
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 ```js
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia' import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
@@ -125,31 +136,26 @@ try {
} }
``` ```
The backdrop only disappears after all holders have released it. It disappears after all holders release it, also avoiding awkward fade/appear animations when changing between multiple dialogs.
## Error Handling ## Error Handling
### AuthCancelledError (apiFetch, apiJson, showAuthIframe) ### `AuthCancelledError`
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. `apiFetch`, `apiJson` and `showAuthIframe` raise `AuthCancelledError` when the user cancels required authentication with Back or Escape. This means the user does not wish to authenticate, and should not be asked again.
- Do nothing if the app can continue despite the failed operation (no UI notification needed) Continue without the failed operation when possible, or show an appropriate terminal view when authentication is required to continue.
- Display a simple Access Denied page with suggestion/button to reload the page to try again
Do not retry automatically. When the error is a direct result of a user action, we don't want to show an additional message for that, while in other situations we should. Helpers determine whether an error needs user notification and provide a suitable message:
### UI feedback
A set of small utilities are available for determining whether the user needs a notification and to format the error message.
```js ```js
import { getUserFriendlyErrorMessage, shouldShowErrorToast } from 'paskia' import { getUserFriendlyErrorMessage, shouldShowErrorToast } from 'paskia'
try { try {
await apiJson('/api/action') await apiJson('/api/action')
} catch (e) { } catch (error) {
if (shouldShowErrorToast(e)) { if (shouldShowErrorToast(error)) {
your.message.display(getUserFriendlyErrorMessage(e)) your.message.display(getUserFriendlyErrorMessage(error))
} }
} }
``` ```
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paskia", "name": "paskia",
"version": "1.4.0", "version": "2.1.0",
"description": "Paskia authentication utilities for JavaScript", "description": "Paskia authentication utilities for JavaScript",
"author": "Leo Vasanko", "author": "Leo Vasanko",
"license": "Unlicense", "license": "Unlicense",
+1
View File
@@ -20,6 +20,7 @@ export {
isAuthIframeOpen, isAuthIframeOpen,
hideAuthIframe, hideAuthIframe,
showAuthIframe, showAuthIframe,
profile,
} from './overlay' } from './overlay'
export { SessionValidator } from './validate' export { SessionValidator } from './validate'
+79 -5
View File
@@ -32,12 +32,27 @@ body.paskia-backdrop {
color-scheme: auto; color-scheme: auto;
background: transparent; background: transparent;
} }
#${AUTH_IFRAME_ID}.paskia-dialog {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(36rem, 100%);
height: min(42rem, 100%);
}
` `
type DialogResult = 'login' | 'logout' | 'back'
type DialogKind = 'auth' | 'profile'
let authIframe: HTMLIFrameElement | null = null let authIframe: HTMLIFrameElement | null = null
let authPromise: Promise<void> | null = null let authPromise: Promise<DialogResult | undefined> | null = null
let authResolve: (() => void) | null = null let authResolve: ((result?: DialogResult) => void) | null = null
let authReject: ((error: Error) => void) | null = null let authReject: ((error: Error) => void) | null = null
// Auth flows reject AuthCancelledError on auth-back (callers rely on it to
// abort request retries) and resolve void on auth-success. The profile dialog
// never rejects: auth-back resolves 'back', and auth-success (the user logged
// in while the profile dialog was open) resolves 'login'.
let dialogKind: DialogKind = 'auth'
let messageListenerInstalled = false let messageListenerInstalled = false
let backdropHolders = 0 let backdropHolders = 0
@@ -89,7 +104,7 @@ function handleAuthMessage(event: MessageEvent): void {
case 'auth-success': case 'auth-success':
hideAuthIframe() hideAuthIframe()
if (authResolve) { if (authResolve) {
authResolve() authResolve(dialogKind === 'profile' ? 'login' : undefined)
authPromise = null authPromise = null
authResolve = null authResolve = null
authReject = null authReject = null
@@ -98,8 +113,20 @@ function handleAuthMessage(event: MessageEvent): void {
case 'auth-back': case 'auth-back':
hideAuthIframe() hideAuthIframe()
if (authReject) { if (dialogKind === 'auth' && authReject) {
authReject(new AuthCancelledError()) authReject(new AuthCancelledError())
} else if (authResolve) {
authResolve('back')
}
authPromise = null
authResolve = null
authReject = null
break
case 'auth-logout':
hideAuthIframe()
if (authResolve) {
authResolve('logout')
authPromise = null authPromise = null
authResolve = null authResolve = null
authReject = null authReject = null
@@ -116,12 +143,15 @@ function ensureMessageListener(): void {
} }
} }
export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Promise<void> { function openIframe(iframeUrl: string, title: string, kind: DialogKind): Promise<DialogResult | undefined> {
injectStyles() injectStyles()
ensureMessageListener() ensureMessageListener()
if (authPromise) return authPromise if (authPromise) return authPromise
dialogKind = kind
iframeUrl = withAppTheme(iframeUrl)
if (document.getElementById(AUTH_IFRAME_ID)) { if (document.getElementById(AUTH_IFRAME_ID)) {
authPromise = new Promise((resolve, reject) => { authPromise = new Promise((resolve, reject) => {
authResolve = resolve authResolve = resolve
@@ -140,6 +170,7 @@ export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Pro
authIframe = document.createElement('iframe') authIframe = document.createElement('iframe')
authIframe.id = AUTH_IFRAME_ID authIframe.id = AUTH_IFRAME_ID
if (kind === 'profile') authIframe.classList.add('paskia-dialog')
authIframe.title = title authIframe.title = title
authIframe.src = iframeUrl authIframe.src = iframeUrl
document.body.appendChild(authIframe) document.body.appendChild(authIframe)
@@ -147,6 +178,49 @@ export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Pro
return authPromise return authPromise
} }
// Detect the host page's own color scheme (CSS color-scheme on body) as an
// implicit app-level default. Only an unambiguous 'light' or 'dark' counts;
// 'normal', 'light dark' etc. mean the page adapts, so no override is needed.
function detectColorScheme(): string {
if (typeof window === 'undefined' || !document.body) return ''
const scheme = getComputedStyle(document.body).colorScheme
return scheme === 'light' || scheme === 'dark' ? scheme : ''
}
// Apply the host page's own color scheme to the iframe URL hash — only when
// the URL has no theme parameter yet (a server-provided user theme override
// is authoritative). The restricted UI's precedence is: URL parameter (user
// override from the server, else host color scheme) > cached profile theme
// (localStorage) > browser/desktop default.
function withAppTheme(iframeUrl: string): string {
const theme = detectColorScheme()
if (!theme) return iframeUrl
const hashIndex = iframeUrl.indexOf('#')
const base = hashIndex === -1 ? iframeUrl : iframeUrl.slice(0, hashIndex)
const params = new URLSearchParams(hashIndex === -1 ? '' : iframeUrl.slice(hashIndex + 1))
if (params.has('theme')) return iframeUrl
params.set('theme', theme)
return `${base}#${params}`
}
export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Promise<void> {
return openIframe(iframeUrl, title, 'auth').then(() => undefined)
}
/**
* Show the minimal profile of the logged-in user in a compact dialog iframe.
*
* Unlike the auth flows, this always resolves — 'login' when the user was
* signed out and completed the login flow inside the frame, 'logout' when
* they signed out inside the frame, 'back' when they closed it otherwise.
* The caller decides from context how to react to each (e.g. whether to
* start a new login attempt with showAuthIframe).
*/
export function profile(): Promise<DialogResult> {
return openIframe('/auth/restricted/iframe#mode=profile', 'Profile', 'profile')
.then((result) => result ?? 'back')
}
export function createAuthIframe(iframeUrl: string, title = 'Authentication'): HTMLIFrameElement { export function createAuthIframe(iframeUrl: string, title = 'Authentication'): HTMLIFrameElement {
injectStyles() injectStyles()
const existing = document.getElementById(AUTH_IFRAME_ID) const existing = document.getElementById(AUTH_IFRAME_ID)
+36 -7
View File
@@ -24,6 +24,7 @@ EPILOG = """\
Examples: Examples:
paskia init example.com "Example Corporation" paskia init example.com "Example Corporation"
paskia migrate example.com paskia migrate example.com
paskia --listen 4402 --save
paskia paskia
""" """
@@ -178,9 +179,23 @@ def cmd_init(args: argparse.Namespace) -> None:
def cmd_migrate(args: argparse.Namespace) -> None: def cmd_migrate(args: argparse.Namespace) -> None:
"""Convert a legacy <rp-id>.paskiadb database to paskia.kantadb.""" """Convert or merge a legacy/current database into paskia.kantadb."""
rp_id = legacy.migrate_legacy_database(args.rp_id) merging = db_file_path().exists()
print(f"✅ Converted legacy database to {db_file_path()} (domain: {rp_id})") rp_ids = legacy.migrate_database(args.source)
action = "Merged into existing" if merging else "Converted to"
print(f"{action} {db_file_path()} (domains: {', '.join(rp_ids)})")
def _save_listen(db_path: Path, listen: list[str] | None) -> None:
"""Persist the listen endpoints to the stored configuration."""
kanta = Kanta(str(db_path), DB())
async def _write() -> None:
async with kanta:
with kanta.transaction("serve:save_listen"):
kanta.data.config.listen = listen
asyncio.run(_write())
def cmd_serve(args: argparse.Namespace) -> None: def cmd_serve(args: argparse.Namespace) -> None:
@@ -195,6 +210,10 @@ def cmd_serve(args: argparse.Namespace) -> None:
) )
raise SystemExit(f"Database {db_path} not found — run 'paskia init' first.") raise SystemExit(f"Database {db_path} not found — run 'paskia init' first.")
if args.save and args.listen is not None:
# '--listen ""' clears the stored endpoints (back to the default)
_save_listen(db_path, _split_multi(args.listen) or None)
config = _load_stored_config(db_path) config = _load_stored_config(db_path)
listen = _split_multi(args.listen) or config.listen listen = _split_multi(args.listen) or config.listen
@@ -237,6 +256,12 @@ def main():
epilog=EPILOG, epilog=EPILOG,
) )
_add_listen_option(parser) _add_listen_option(parser)
parser.add_argument(
"--save",
action="store_true",
help="Save --listen to the database for future runs. "
"Use --listen \"\" to clear the stored endpoints.",
)
init_parser = argparse.ArgumentParser( init_parser = argparse.ArgumentParser(
prog="paskia init", prog="paskia init",
@@ -263,14 +288,18 @@ def main():
migrate_parser = argparse.ArgumentParser( migrate_parser = argparse.ArgumentParser(
prog="paskia migrate", prog="paskia migrate",
description="Convert a legacy <rp-id>.paskiadb database to paskia.kantadb", description="Convert a legacy <rp-id>.paskiadb database to paskia.kantadb, "
"or merge a legacy database / another paskia.kantadb into an existing one",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
) )
migrate_parser.add_argument( migrate_parser.add_argument(
"rp_id", "source",
nargs="?", nargs="?",
help="rp-id of the legacy database to convert, selecting " help="rp-id of the legacy database to convert, or path to a legacy "
"<rp-id>.paskiadb when several legacy candidates exist.", "<rp-id>.paskiadb directory/file or a current-format paskia.kantadb "
"file. When paskia.kantadb already exists, the source data is merged "
"into it. Without an argument, a single legacy *.paskiadb candidate "
"in the current directory is selected automatically.",
) )
argv = sys.argv[1:] argv = sys.argv[1:]
+186 -60
View File
@@ -1,14 +1,15 @@
"""Legacy database format reader and converter. """Legacy database format reader, converter and database merging.
Retains the msgspec structs used by the old ``<rp-id>.paskiadb/main.db`` Retains the msgspec structs used by the old ``<rp-id>.paskiadb/main.db``
format so existing databases can be opened and converted to the combined format so existing databases can be opened and converted to the combined
``paskia.kantadb`` format. Only the structs whose shape differs from the ``paskia.kantadb`` format, and implements the merge of incoming data
current schema are redefined here; unchanged structs are imported from (legacy or current format) into an existing ``paskia.kantadb``. Only the
``paskia.db.structs``. structs whose shape differs from the current schema are redefined here;
unchanged structs are imported from ``paskia.db.structs``.
Assumes the on-disk records are in the latest legacy format (schema Assumes the on-disk records are in the latest legacy format (schema
migrations were discarded together with the old format). This module will migrations were discarded together with the old format). The legacy
be deleted once legacy conversion is no longer supported. structs will be deleted once legacy conversion is no longer supported.
""" """
from __future__ import annotations from __future__ import annotations
@@ -101,16 +102,23 @@ def _read_legacy(path: Path) -> LegacyDB:
return asyncio.run(_read()) return asyncio.run(_read())
def convert_legacy_database(src: Path, dst: Path) -> Config: def _read_kantadb(path: Path) -> DB:
"""Convert a legacy main.db file into the combined kantadb format. """Open a current-format database read-only and return its contents."""
kanta = Kanta(str(path), DB())
Reads the legacy database at ``src`` and writes a fresh database at async def _read() -> DB:
``dst``. All credentials and sessions are stamped with the legacy await kanta.open(readonly=True)
database's rp-id; the OIDC provider carries over as-is (it is return kanta.data
instance-global).
Returns the converted (new-format) configuration. return asyncio.run(_read())
def _legacy_to_db(old: LegacyDB) -> DB:
"""Convert legacy database contents to the combined kantadb format.
All credentials and sessions are stamped with the legacy database's
rp-id; the OIDC provider carries over as-is (it is instance-global).
""" """
old = _read_legacy(src)
rp_id = old.config.rp_id rp_id = old.config.rp_id
from paskia.domains import origin_key # noqa: PLC0415 (import cycle) from paskia.domains import origin_key # noqa: PLC0415 (import cycle)
@@ -120,9 +128,10 @@ def convert_legacy_database(src: Path, dst: Path) -> Config:
origins[origin_key(origin)] = True origins[origin_key(origin)] = True
if old.config.auth_host: if old.config.auth_host:
origins[origin_key(old.config.auth_host)] = OriginEntry(auth_host=True) origins[origin_key(old.config.auth_host)] = OriginEntry(auth_host=True)
if not origins: if not old.config.origins:
# Legacy semantics: no origins configured = the whole rp-id domain # Legacy semantics: no origins configured = the whole rp-id domain
# allowed. The new format requires explicit entries. # allowed, regardless of a dedicated auth host. The new format
# requires explicit entries.
origins[f"**.{rp_id}"] = True origins[f"**.{rp_id}"] = True
new_config = Config( new_config = Config(
@@ -169,28 +178,94 @@ def convert_legacy_database(src: Path, dst: Path) -> Config:
reset_tokens=old.reset_tokens, reset_tokens=old.reset_tokens,
oidc=old.oidc, oidc=old.oidc,
) )
return converted
def _migration_label(incoming: DB) -> str:
"""Transaction label for a migration; multiple rp-ids join with slashes."""
return f"migrate:cli:{'/'.join(incoming.config.domains)}"
def _write_fresh(data: DB, dst: Path, label: str) -> None:
"""Write a fresh database at ``dst`` with the given contents."""
new_db = DB() new_db = DB()
kanta = Kanta(str(dst), new_db) kanta = Kanta(str(dst), new_db)
@kanta.bootstrap @kanta.bootstrap(action=label)
def _seed(data: DB) -> None: def _seed(target: DB) -> None:
data.config = converted.config target.config = data.config
data.permissions = converted.permissions target.permissions = data.permissions
data.orgs = converted.orgs target.orgs = data.orgs
data.roles = converted.roles target.roles = data.roles
data.users = converted.users target.users = data.users
data.credentials = converted.credentials target.credentials = data.credentials
data.sessions = converted.sessions target.sessions = data.sessions
data.reset_tokens = converted.reset_tokens target.reset_tokens = data.reset_tokens
data.oidc = converted.oidc target.oidc = data.oidc
async def _write() -> None: async def _write() -> None:
async with kanta: async with kanta:
pass pass
asyncio.run(_write()) asyncio.run(_write())
return new_config
def convert_legacy_database(src: Path, dst: Path) -> Config:
"""Convert a legacy main.db file into the combined kantadb format.
Reads the legacy database at ``src`` and writes a fresh database at
``dst``. Returns the converted (new-format) configuration.
"""
converted = _legacy_to_db(_read_legacy(src))
_write_fresh(converted, dst, _migration_label(converted))
return converted.config
def _merge_data(data: DB, incoming: DB) -> None:
"""Merge ``incoming`` contents into the live ``data`` object.
Records are uuid-keyed (or hash-keyed for sessions/reset tokens), so
identical keys denote the same item: existing entries win, new entries
are added. Domains merge per rp-id with a union of allowed origins;
the existing instance's listen endpoints and OIDC signing key win.
"""
for rp_id, domain in incoming.config.domains.items():
existing = data.config.domains.get(rp_id)
if existing is None:
data.config.domains[rp_id] = domain
continue
for origin, entry in domain.origins.items():
existing.origins.setdefault(origin, entry)
if existing.rp_name is None:
existing.rp_name = domain.rp_name
for bucket in (
"permissions",
"orgs",
"roles",
"users",
"credentials",
"sessions",
"reset_tokens",
):
target_map = getattr(data, bucket)
for key, value in getattr(incoming, bucket).items():
target_map.setdefault(key, value)
for uuid, client in incoming.oidc.clients.items():
data.oidc.clients.setdefault(uuid, client)
if data.oidc.key is None:
data.oidc.key = incoming.oidc.key
def merge_database(dst: Path, incoming: DB) -> None:
"""Merge ``incoming`` contents into the existing database at ``dst``."""
kanta = Kanta(str(dst), DB())
async def _merge() -> None:
async with kanta:
with kanta.transaction(_migration_label(incoming)):
_merge_data(kanta.data, incoming)
asyncio.run(_merge())
def find_legacy_databases(cwd: Path | None = None) -> list[Path]: def find_legacy_databases(cwd: Path | None = None) -> list[Path]:
@@ -211,50 +286,101 @@ def find_legacy_databases(cwd: Path | None = None) -> list[Path]:
return candidates return candidates
def migrate_legacy_database(rp_id: str | None = None) -> str: def _resolve_source(source: str | None) -> tuple[Path, bool, Path, Path | None]:
"""Convert a legacy database to ``paskia.kantadb``. """Resolve the migrate source.
With ``rp_id``, selects the ``<rp-id>.paskiadb`` candidate by name; ``source`` may be an rp-id (selecting ``<rp-id>.paskiadb`` in the
without it, exactly one candidate must exist. Returns the migrated current directory), a path to a legacy ``*.paskiadb`` directory or
domain's rp-id. The converted legacy directory/file is renamed aside file, or a path to a current-format ``*.kantadb`` file. Without
to ``<name>.converted-bak`` rather than deleted. ``source``, exactly one legacy candidate must exist in the current
directory.
Raises SystemExit when ``paskia.kantadb`` already exists, when no Returns ``(db_file, is_legacy, users_dir, rename_target)`` where
candidate matches, or when several candidates exist and no ``rp_id`` ``users_dir`` holds auxiliary user files (avatars) and
was given to select one. ``rename_target`` is the legacy directory/file to rename aside after
a successful migration (None for current-format sources).
""" """
target = db_file_path()
if target.exists(): def legacy(src: Path) -> tuple[Path, bool, Path, Path]:
raise SystemExit(f"Database {target} already exists — nothing to migrate.") return (
candidates = find_legacy_databases() src / "main.db" if src.is_dir() else src,
if rp_id is not None: True,
name = f"{rp_id}.paskiadb" src / "users" if src.is_dir() else src.parent / "users",
matches = [c for c in candidates if c.name == name] src,
)
if source is not None:
path = Path(source)
if path.is_dir():
if (path / "main.db").is_file():
return legacy(path)
raise SystemExit(f"No legacy main.db found in directory {path}.")
if path.is_file():
if path.suffix == ".paskiadb":
return legacy(path)
return path, False, path.parent / "paskia.data" / "users", None
# Not a path: treat as rp-id selecting a legacy candidate by name
name = f"{source}.paskiadb"
matches = [c for c in find_legacy_databases() if c.name == name]
if not matches: if not matches:
found = ", ".join(str(c) for c in candidates) or "none" found = ", ".join(str(c) for c in find_legacy_databases()) or "none"
raise SystemExit( raise SystemExit(
f"No legacy database {name} in this directory (candidates: {found})." f"No legacy database {name} in this directory (candidates: {found})."
) )
src = matches[0] return legacy(matches[0])
elif not candidates: candidates = find_legacy_databases()
if not candidates:
raise SystemExit("No legacy *.paskiadb database found — nothing to migrate.") raise SystemExit("No legacy *.paskiadb database found — nothing to migrate.")
elif len(candidates) > 1: if len(candidates) > 1:
names = ", ".join(str(c) for c in candidates) names = ", ".join(str(c) for c in candidates)
raise SystemExit( raise SystemExit(
f"Multiple legacy databases found ({names}) — select one with " f"Multiple legacy databases found ({names}) — select one with "
"'paskia migrate <rp-id>'." "'paskia migrate <rp-id>'."
) )
return legacy(candidates[0])
def _move_user_files(src_users: Path) -> None:
"""Move persisted user files (avatars) to the new data root."""
if not src_users.is_dir():
return
target_users = users_root_path(create_root=True)
for child in src_users.iterdir():
if (target_users / child.name).exists():
continue
shutil.move(str(child), str(target_users / child.name))
def migrate_database(source: str | None = None) -> list[str]:
"""Convert or merge a database into ``paskia.kantadb``.
The source may be a legacy ``<rp-id>.paskiadb`` database (selected by
rp-id or path) or a current-format ``*.kantadb`` file given by path.
When ``paskia.kantadb`` already exists, the incoming data is merged
into it (uuid-keyed records make conflicts a non-issue); otherwise a
fresh database is written. Returns the migrated domains' rp-ids. A
migrated legacy source is renamed aside to ``<name>.converted-bak``
rather than deleted; a merged kantadb source is left in place.
"""
target = db_file_path()
db_file, is_legacy, users_dir, rename_target = _resolve_source(source)
if db_file.resolve() == target.resolve():
raise SystemExit(f"{db_file} is the active database — nothing to migrate.")
incoming = (
_legacy_to_db(_read_legacy(db_file)) if is_legacy else _read_kantadb(db_file)
)
rp_ids = list(incoming.config.domains)
if target.exists():
merge_database(target, incoming)
else: else:
src = candidates[0] _write_fresh(incoming, target, _migration_label(incoming))
legacy_file = src / "main.db" if src.is_dir() else src
config = convert_legacy_database(legacy_file, target)
# Move persisted user files (avatars) to the new data root _move_user_files(users_dir)
legacy_users = src / "users" if src.is_dir() else None if rename_target is not None and rename_target.exists():
if legacy_users is not None and legacy_users.is_dir(): shutil.move(
target_users = users_root_path(create_root=True) str(rename_target),
for child in legacy_users.iterdir(): str(rename_target.with_name(rename_target.name + ".converted-bak")),
shutil.move(str(child), str(target_users / child.name)) )
return rp_ids
shutil.move(str(src), str(src.with_name(src.name + ".converted-bak")))
return next(iter(config.domains))
+3 -4
View File
@@ -15,6 +15,7 @@ from uuid import UUID
import base64url import base64url
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from uarite import uaparse
from paskia import authcode, db, remoteauth from paskia import authcode, db, remoteauth
from paskia.authcode import CookieCode from paskia.authcode import CookieCode
@@ -23,7 +24,7 @@ from paskia.domains import current_domain, registry
from paskia.fastapi.session import AUTH_COOKIE, infodict from paskia.fastapi.session import AUTH_COOKIE, infodict
from paskia.fastapi.wschat import authenticate_and_login 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 pow, useragent from paskia.util import pow
# 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)
@@ -458,9 +459,7 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
if requesting_domain if requesting_domain
else request.rp_id else request.rp_id
), ),
"user_agent_pretty": useragent.compact_user_agent( "user_agent_pretty": uaparse(request.user_agent).pretty,
request.user_agent
),
"client_ip": request.ip, "client_ip": request.ip,
"action": request.action, "action": request.action,
"pow": { "pow": {
+2 -2
View File
@@ -11,10 +11,10 @@ from datetime import datetime
from uuid import UUID from uuid import UUID
import msgspec import msgspec
from uarite import uaparse
from paskia import db from paskia import db
from paskia.db.structs import Credential, Org, OriginEntry, Permission, Role, User from paskia.db.structs import Credential, Org, OriginEntry, Permission, Role, User
from paskia.util import useragent
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# API structs - inherit from db structs, add uuid for serialization # API structs - inherit from db structs, add uuid for serialization
@@ -124,7 +124,7 @@ class ApiUserSession(msgspec.Struct, omit_defaults=True):
credential_uuid=s.credential_uuid, credential_uuid=s.credential_uuid,
host=s.host, host=s.host,
ip=s.ip, ip=s.ip,
user_agent=useragent.compact_user_agent(s.user_agent), user_agent=uaparse(s.user_agent).pretty,
validated=s.validated, validated=s.validated,
last_renewed=s.validated, last_renewed=s.validated,
is_current=s.key == current_key, is_current=s.key == current_key,
+41 -1
View File
@@ -6,11 +6,13 @@ import os
import re import re
from sys import stderr from sys import stderr
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from urllib.parse import urlparse
from fastapi_vue.hostutil import parse_endpoints from fastapi_vue.hostutil import parse_endpoints
from paskia._version import __version__ from paskia._version import __version__
from paskia.domains import auth_host_url, origin_url, partition_origins from paskia.domains import auth_host_url, origin_url, partition_origins
from paskia.util import hostutil
from paskia.util.constants import DEFAULT_PORT, DEVMODE from paskia.util.constants import DEFAULT_PORT, DEVMODE
from paskia.util.hostutil import format_endpoint, wildcard_base from paskia.util.hostutil import format_endpoint, wildcard_base
@@ -85,9 +87,47 @@ def _origin_phrase(key: str, rp_id: str) -> str:
return _compact_url(origin_url(key)) return _compact_url(origin_url(key))
def _covered_by_wildcard(key: str, pattern: str) -> bool:
"""Whether an origins-table key is redundant given a wildcard key.
Mirrors DomainConfig matching (sansio._allowlisted): a wildcard covers
hostnames under its base over https (any port), except under localhost
where any scheme and any port match. Plain http entries outside
localhost are therefore never covered and stay listed.
"""
base = wildcard_base(pattern)
if base is None:
return False
# Keys are bare hosts (https:// and '/' stripped by origin_key, port
# kept) or full origins; urlparse needs a scheme or '//' prefix.
hostname = urlparse(key if "://" in key else f"//{key}").hostname
if not hostname:
return False
if pattern.startswith("**."):
matched = hostutil.is_subdomain(hostname, base)
else:
# '*.base' covers exactly one subdomain level
matched = hostname.endswith(f".{base}") and "." not in hostname[
: -len(base) - 1
]
if not matched:
return False
if hostutil.is_subdomain(base, "localhost"):
return True # localhost: any scheme, any port
return "://" not in key or key.startswith("https://")
def _signin_summary(in_domain: list[str], rp_id: str) -> str: def _signin_summary(in_domain: list[str], rp_id: str) -> str:
"""Compact summary of a domain's in-domain sign-in sites.""" """Compact summary of a domain's in-domain sign-in sites."""
phrases = [_origin_phrase(key, rp_id) for key in sorted(in_domain)] # Prune entries already covered by a reported wildcard (e.g. the auth
# host under '**.{rp-id}'); http origins outside localhost survive.
wildcards = [k for k in in_domain if wildcard_base(k)]
keys = [
k
for k in in_domain
if wildcard_base(k) or not any(_covered_by_wildcard(k, w) for w in wildcards)
]
phrases = [_origin_phrase(key, rp_id) for key in sorted(keys)]
if len(phrases) > 2: if len(phrases) > 2:
n = len(phrases) - 1 n = len(phrases) - 1
return f"{phrases[0]}, +{n} site{'s' if n > 1 else ''}" return f"{phrases[0]}, +{n} site{'s' if n > 1 else ''}"
-29
View File
@@ -1,29 +0,0 @@
from ua_parser import parse
def compact_user_agent(ua: str | None) -> str:
"""Format user agent string into a compact display format.
Returns empty string for empty/missing user agents.
Returns original UA for unrecognized ones.
"""
if not ua or not ua.strip() or ua == "-":
return ""
r = parse(ua)
browser = r.user_agent.family if r.user_agent else None
ver = r.user_agent.major if r.user_agent else ""
os_name = r.os.family if r.os else None
dev = r.device.family if r.device else None
# If browser is unrecognized, return original UA
if browser in (None, "Other") and os_name in (None, "Other"):
return ua
# Filter out "Other" values
browser = browser if browser and browser != "Other" else ""
os_name = os_name if os_name and os_name != "Other" else ""
# Exclude device if it's "Other" or matches browser family (parser bug)
if dev in (None, "Other") or dev == browser:
dev = ""
# Build compact string, filtering empty parts
parts = [f"{browser}/{ver}" if browser else "", os_name, dev]
result = " ".join(p for p in parts if p).strip()
return result
+1 -1
View File
@@ -22,8 +22,8 @@ dependencies = [
"jsondiff>=2.2.1", "jsondiff>=2.2.1",
"msgspec>=0.20.0", "msgspec>=0.20.0",
"fastapi-vue~=1.4.2", "fastapi-vue~=1.4.2",
"ua-parser[regex]>=1.0.1",
"kanta>=0.7.0", "kanta>=0.7.0",
"uarite>=0.2.1",
] ]
[dependency-groups] [dependency-groups]
dev = [ dev = [
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env -S uv run
"""Release build for paskia (PyPI) and paskia-js (npm).
Usage: release.py [patch|minor|major] (default: patch)
Bumps the version from the latest vX.Y.Z tag, commits "Release x.y.z" with
the paskia-js version bump and tags it, then builds both packages from a
clean slate. On failure the release commit and tag are rolled back.
Publishing is left to the user; the command is printed on success.
"""
import json
import shutil
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
# Build outputs removed before building
ARTIFACTS = ["dist", "paskia-js/dist", "paskia/frontend-build", "build"]
# Dependency state removed for a fresh upstream resolve (all untracked)
JS_DIRS = ["paskia-js", "frontend"]
JS_JUNK = ["node_modules", "package-lock.json", "deno.lock", "bun.lock"]
BUMPS = ("patch", "minor", "major")
def run(cmd: list[str], cwd: Path = REPO_ROOT) -> None:
print(f"### {' '.join(cmd)}")
subprocess.run(cmd, cwd=cwd, check=True) # noqa: S603
def abort(msg: str) -> None:
print(f"error: {msg}", file=sys.stderr)
raise SystemExit(1)
def git(*args: str) -> str:
return subprocess.run( # noqa: S603
["git", *args], cwd=REPO_ROOT, check=True, capture_output=True, text=True
).stdout.strip()
def check_clean_tree() -> None:
status = git("status", "--porcelain")
if status:
print(status, file=sys.stderr)
abort("working tree is not clean; commit or stash all changes first")
def latest_version() -> tuple[int, int, int]:
"""Highest vX.Y.Z tag, as a tuple."""
tags = []
for tag in git("tag", "--list", "v*").splitlines():
parts = tag.removeprefix("v").split(".")
if len(parts) == 3 and all(p.isdigit() for p in parts):
tags.append(tuple(int(p) for p in parts))
if not tags:
abort("no existing vX.Y.Z tags found")
return max(tags)
def next_version(bump: str) -> tuple[int, int, int]:
major, minor, patch = latest_version()
if bump == "major":
return (major + 1, 0, 0)
if bump == "minor":
return (major, minor + 1, 0)
return (major, minor, patch + 1)
def set_js_version(version: str) -> None:
pkg_path = REPO_ROOT / "paskia-js/package.json"
pkg = json.loads(pkg_path.read_text())
if pkg.get("version") == version:
return
print(f"paskia-js/package.json: {pkg.get('version')} -> {version}")
pkg["version"] = version
pkg_path.write_text(json.dumps(pkg, indent=2) + "\n")
def remove(path: Path, rel: str) -> None:
if not path.exists():
return
print(f"rm -rf {rel}")
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
def clean() -> None:
for rel in ARTIFACTS:
remove(REPO_ROOT / rel, rel)
for d in JS_DIRS:
for junk in JS_JUNK:
remove(REPO_ROOT / d / junk, f"{d}/{junk}")
def main() -> None:
bump = sys.argv[1] if len(sys.argv) == 2 else "patch"
if len(sys.argv) > 2 or bump not in BUMPS:
print(__doc__)
raise SystemExit(1)
check_clean_tree()
version_tuple = next_version(bump)
version = ".".join(str(p) for p in version_tuple)
tag = f"v{version}"
if tag in git("tag", "--list", tag).splitlines():
abort(f"tag {tag} already exists")
print(f"Release version: {version}")
previous_head = git("rev-parse", "HEAD")
released = False
try:
# Clean before committing: only the uv build (hatch-vcs) depends on
# the tag, so the release commit can be made from a clean slate.
clean()
set_js_version(version)
run(["git", "add", "paskia-js/package.json"])
run(["git", "commit", "-m", f"Release {version}"])
run(["git", "tag", tag])
released = True
# uv build runs the hatch hook that builds paskia-js and the Vue
# frontend into paskia/frontend-build with fresh dependencies.
run(["uv", "build"])
# Explicit paskia-js build: verifies the package standalone and
# leaves paskia-js/dist ready for npm publish.
run(["npm", "install"], cwd=REPO_ROOT / "paskia-js")
run(["npm", "run", "build"], cwd=REPO_ROOT / "paskia-js")
except BaseException:
if released:
print("Build failed; rolling back the release commit and tag.", file=sys.stderr)
subprocess.run(["git", "tag", "-d", tag], cwd=REPO_ROOT, check=False) # noqa: S603
# The tree was clean before the release commit, so a hard reset
# back to it is safe.
subprocess.run(["git", "reset", "--hard", previous_head], cwd=REPO_ROOT, check=False) # noqa: S603
raise
# Push the release commit to the tracking remote, then the new tag.
# Not rolled back on failure: the local release is intact, just push again.
try:
run(["git", "push"])
run(["git", "push", "--tags"])
except subprocess.CalledProcessError:
abort("push failed; the release commit and tag exist locally, push manually")
print(f"\nBuild completed successfully for version {version}.")
print("To publish, review the artifacts and run:")
print(" uv publish && cd paskia-js && npm publish")
if __name__ == "__main__":
main()
+106 -5
View File
@@ -2,7 +2,8 @@
The CLI is split into ``paskia init`` (create the combined paskia.kantadb The CLI is split into ``paskia init`` (create the combined paskia.kantadb
with the initial domain(s)), ``paskia migrate`` (convert a legacy with the initial domain(s)), ``paskia migrate`` (convert a legacy
``<rp-id>.paskiadb`` database), and bare ``paskia`` (serve the stored ``<rp-id>.paskiadb`` database, or merge a legacy/current database into an
existing paskia.kantadb), and bare ``paskia`` (serve the stored
domains; never migrates). domains; never migrates).
""" """
@@ -20,7 +21,7 @@ from kanta import Kanta
from paskia.__main__ import _load_stored_config, main from paskia.__main__ import _load_stored_config, main
from paskia.db import legacy from paskia.db import legacy
from paskia.db.structs import DB, Config from paskia.db.structs import DB, Config, DomainConfig
from paskia.util.runtime import ServeConfig, clear_cache from paskia.util.runtime import ServeConfig, clear_cache
@@ -184,6 +185,22 @@ def test_serve_listen_override_not_persisted(run_cli, tmp_path):
assert stored_config(tmp_path).listen == ["4402"] assert stored_config(tmp_path).listen == ["4402"]
def test_serve_listen_save_persists(run_cli, tmp_path):
run_cli("init", "--listen", "4402")
calls = run_cli("--listen", "4403", "--save")
assert calls["listen"] == ["4403"]
assert stored_config(tmp_path).listen == ["4403"]
def test_serve_listen_save_clear(run_cli, tmp_path):
"""--listen "" --save clears the stored endpoints (back to default)."""
run_cli("init", "--listen", "4402")
run_cli("--listen", "", "--save")
assert stored_config(tmp_path).listen is None
def test_serve_suggests_migrate_when_legacy_present(run_cli, tmp_path): def test_serve_suggests_migrate_when_legacy_present(run_cli, tmp_path):
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="example.com")) write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="example.com"))
with pytest.raises(SystemExit, match="paskia migrate"): with pytest.raises(SystemExit, match="paskia migrate"):
@@ -204,6 +221,8 @@ def test_migrate_converts_legacy_database(run_cli, tmp_path):
config = stored_config(tmp_path) config = stored_config(tmp_path)
assert list(config.domains) == ["example.com"] assert list(config.domains) == ["example.com"]
assert config.domains["example.com"].rp_name == "Legacy Name" assert config.domains["example.com"].rp_name == "Legacy Name"
# Migration transaction is labeled with the migrated rp-id
assert b"migrate:cli:example.com" in (tmp_path / "paskia.kantadb").read_bytes()
# Legacy directory renamed aside, user files moved over # Legacy directory renamed aside, user files moved over
assert not src_dir.exists() assert not src_dir.exists()
assert (tmp_path / "example.com.paskiadb.converted-bak").is_dir() assert (tmp_path / "example.com.paskiadb.converted-bak").is_dir()
@@ -242,10 +261,92 @@ def test_migrate_unknown_rp_id(run_cli, tmp_path):
run_cli("migrate", "nope.com") run_cli("migrate", "nope.com")
def test_migrate_refuses_existing_database(run_cli): def test_migrate_merges_legacy_into_existing_database(run_cli, tmp_path):
"""An existing paskia.kantadb is not refused — data is merged in."""
run_cli("init", "company.com", "Company")
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="example.com", rp_name="Ex"))
run_cli("migrate")
config = stored_config(tmp_path)
assert list(config.domains) == ["company.com", "example.com"]
assert config.domains["example.com"].rp_name == "Ex"
assert (tmp_path / "example.com.paskiadb.converted-bak").is_dir()
def write_kantadb(root: Path, domains: dict, name: str = "paskia.kantadb") -> Path:
"""Create a current-format database file with the given config domains."""
db_file = root / name
config = Config(
domains={rp_id: DomainConfig(rp_name=name_) for rp_id, name_ in domains.items()}
)
async def _write() -> None:
new_db = DB()
kanta = Kanta(str(db_file), new_db)
@kanta.bootstrap
def _seed(data: DB) -> None:
data.config = config
async with kanta:
pass
asyncio.run(_write())
return db_file
def test_migrate_merges_kantadb_into_existing_database(run_cli, tmp_path):
run_cli("init", "company.com", "Company")
src = write_kantadb(tmp_path, {"other.com": "Other"}, name="other.kantadb")
run_cli("migrate", str(src))
config = stored_config(tmp_path)
assert list(config.domains) == ["company.com", "other.com"]
assert config.domains["other.com"].rp_name == "Other"
# Current-format sources are left in place
assert src.is_file()
assert b"migrate:cli:other.com" in (tmp_path / "paskia.kantadb").read_bytes()
def test_migrate_merge_label_combines_rp_ids(run_cli, tmp_path):
"""A multi-domain source merges in one transaction, rp-ids slash-joined."""
run_cli("init", "company.com")
src = write_kantadb(tmp_path, {"one.com": "One", "two.com": "Two"}, name="x.kantadb")
run_cli("migrate", str(src))
assert b"migrate:cli:one.com/two.com" in (tmp_path / "paskia.kantadb").read_bytes()
def test_migrate_merges_shared_domain_origins(run_cli, tmp_path):
"""Same rp-id in both databases: origins union, existing rp-name wins."""
run_cli("init", "example.com", "Existing Name")
src = write_kantadb(tmp_path, {"example.com": "Incoming Name"}, name="x.kantadb")
run_cli("migrate", str(src))
domain = stored_config(tmp_path).domains["example.com"]
assert domain.rp_name == "Existing Name"
assert set(domain.origins) == {"**.example.com"}
def test_migrate_refuses_active_database_as_source(run_cli):
run_cli("init") run_cli("init")
with pytest.raises(SystemExit, match="already exists"): with pytest.raises(SystemExit, match="active database"):
run_cli("migrate") run_cli("migrate", "paskia.kantadb")
def test_migrate_kantadb_to_fresh_target(run_cli, tmp_path):
src_dir = tmp_path / "elsewhere"
src_dir.mkdir()
src = write_kantadb(src_dir, {"other.com": "Other"})
run_cli("migrate", str(src))
assert list(stored_config(tmp_path).domains) == ["other.com"]
def test_migrate_without_legacy_database(run_cli): def test_migrate_without_legacy_database(run_cli):
+16
View File
@@ -941,6 +941,22 @@ class TestLegacyConversion:
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb") config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
assert config.domains["example.com"].origins == {"**.example.com": True} assert config.domains["example.com"].origins == {"**.example.com": True}
def test_convert_auth_host_with_empty_origins_keeps_wildcard(self, tmp_path):
"""A dedicated auth host with no configured origins still allowed
the whole rp-id domain in the legacy format — the auth host must
not become the only allowed origin."""
src_file = tmp_path / "main.db"
asyncio.run(
_write_legacy(
src_file,
LegacyConfig(rp_id="example.com", auth_host="auth.example.com"),
)
)
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
origins = config.domains["example.com"].origins
assert origins["**.example.com"] is True
assert origins["auth.example.com"] == OriginEntry(auth_host=True)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Transaction log censoring # Transaction log censoring
+51
View File
@@ -0,0 +1,51 @@
"""Tests for startup box sign-in summaries (paskia/util/startupbox.py).
Pruning must mirror the actual origin matching in sansio._allowlisted:
wildcards cover https origins (any port) under their base, except under
localhost where any scheme and any port match.
"""
from paskia.util.startupbox import _signin_summary
def test_auth_host_pruned_under_full_wildcard():
"""'**.vasanko.com' already covers auth.vasanko.com."""
keys = ["**.vasanko.com", "auth.vasanko.com"]
assert _signin_summary(keys, "vasanko.com") == "all subdomains"
def test_http_origin_not_covered_by_https_wildcard():
"""Plain http outside localhost is not wildcard-covered; stays listed."""
keys = ["**.example.com", "http://app.example.com"]
summary = _signin_summary(keys, "example.com")
assert summary == "all subdomains, http://app.example.com"
def test_localhost_wildcard_covers_any_scheme_and_port():
keys = ["**.localhost", "http://localhost:3000", "localhost:8080"]
assert _signin_summary(keys, "localhost") == "all subdomains"
def test_https_port_key_covered_by_wildcard():
"""Wildcards match https origins at any port, so 'host:8443' is redundant."""
keys = ["**.example.com", "app.example.com:8443"]
assert _signin_summary(keys, "example.com") == "all subdomains"
def test_single_level_wildcard_pruning():
"""'*.example.com' covers one subdomain level only."""
keys = ["*.example.com", "app.example.com", "deep.app.example.com"]
summary = _signin_summary(keys, "example.com")
assert summary == "subdomains, deep.app.example.com"
def test_no_wildcard_keeps_all_entries():
keys = ["auth.example.com", "app.example.com"]
summary = _signin_summary(keys, "example.com")
assert summary == "app.example.com, auth.example.com"
def test_entries_outside_wildcard_base_kept():
keys = ["**.app.example.com", "auth.example.com"]
summary = _signin_summary(keys, "example.com")
assert summary == "all subdomains of app.example.com, auth.example.com"