Create a stand-alone paskia npm package (paskia-js). Make the frontend use it (but from source tree to keep synced).

This commit is contained in:
Leo Vasanko
2026-01-29 19:46:26 +00:00
parent 9b9086b52e
commit 5fc5226480
21 changed files with 438 additions and 166 deletions
+1 -3
View File
@@ -13,10 +13,8 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { apiJson } from '@/paskia/fetch'
import { apiJson, SessionValidator, createAuthIframe, removeAuthIframe } from 'paskia'
import { getAuthIframeUrl } from '@/utils/api'
import { SessionValidator } from '@/paskia/validate'
import { createAuthIframe, removeAuthIframe } from '@/paskia/overlay'
import StatusMessage from '@/components/StatusMessage.vue'
import ProfileView from '@/components/ProfileView.vue'
import HostProfileView from '@/components/HostProfileView.vue'
+1 -2
View File
@@ -12,8 +12,7 @@ import AdminUserDetail from '@/admin/AdminUserDetail.vue'
import AdminDialogs from '@/admin/AdminDialogs.vue'
import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings'
import { apiJson } from '@/paskia/fetch'
import { SessionValidator } from '@/paskia/validate'
import { apiJson, SessionValidator } from 'paskia'
import { getDirection } from '@/utils/keynav'
import { goBack } from '@/utils/helpers'
+1 -1
View File
@@ -59,7 +59,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings'
import { apiJson, ApiError, getUserFriendlyErrorMessage } from '@/paskia/fetch'
import { apiJson, ApiError, getUserFriendlyErrorMessage } from 'paskia'
const status = reactive({
show: false,
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@simplewebauthn/browser": "^13.1.2",
"paskia": "file:../paskia-js",
"pinia": "^3.0.3",
"qrcode": "^1.5.4",
"sirv": "^3.0.2",
+1 -1
View File
@@ -5,7 +5,7 @@ import CredentialList from '@/components/CredentialList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import SessionList from '@/components/SessionList.vue'
import { useAuthStore } from '@/stores/auth'
import { apiJson } from '@/paskia/fetch'
import { apiJson } from 'paskia'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
const props = defineProps({
+1 -1
View File
@@ -127,7 +127,7 @@ import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings'
import passkey from '@/utils/passkey'
import { goBack } from '@/utils/helpers'
import { apiJson } from '@/paskia/fetch'
import { apiJson } from 'paskia'
import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@/utils/keynav'
const authStore = useAuthStore()
@@ -35,7 +35,7 @@
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
import { apiJson } from '@/paskia/fetch'
import { apiJson } from 'paskia'
import { formatDate } from '@/utils/helpers'
import { getDirection } from '@/utils/keynav'
import { useAuthStore } from '@/stores/auth'
+1 -1
View File
@@ -58,7 +58,7 @@
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from '@/paskia/fetch'
import { fetchJson, getUserFriendlyErrorMessage } from 'paskia'
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
import { focusDialogButton } from '@/utils/keynav'
-124
View File
@@ -1,124 +0,0 @@
import { showAuthIframe, AuthCancelledError } from './overlay'
export { AuthCancelledError }
const DEFAULT_TIMEOUT_MS = 1000
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
}
}
export class NetworkError extends Error {
constructor(message, originalError = null) {
super(message)
this.name = 'NetworkError'
this.originalError = originalError
}
}
export async function apiFetch(url, options = {}) {
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
fetchOptions.credentials = fetchOptions.credentials || 'include'
while (true) {
let response
try {
response = await fetch(url, {...fetchOptions, signal: timeout && AbortSignal.timeout(timeout)})
} catch (error) {
if (error.name === 'TimeoutError') {
throw new NetworkError('Request timed out', error)
}
if (error.name === 'AbortError') {
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)
}
if (response.status === 401 || response.status === 403) {
let data = 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
}
}
export async function apiJson(url, options = {}) {
return fetchJson(url, options, apiFetch)
}
export async function fetchJson(url, options = {}, fetchFn = fetch) {
const opt = { ...options }
opt.headers = {
'Accept': 'application/json',
...opt.headers,
}
if (opt.body && typeof opt.body === 'object' && !(opt.body instanceof FormData)) {
opt.headers = {
'Content-Type': 'application/json',
...opt.headers,
}
opt.body = JSON.stringify(opt.body)
}
const response = await fetchFn(url, opt)
const data = await response.json()
if (!response.ok) {
throw new ApiError(url, response, data)
}
return data
}
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'
}
export function shouldShowErrorToast(error) {
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
}
export function createApiCaller(showMessage) {
return async function apiCall(url, options = {}) {
try {
return await apiJson(url, options)
} catch (error) {
if (!shouldShowErrorToast(error)) {
throw error
}
console.error(`API error for ${url}:`, error instanceof ApiError ? { status: error.status, statusText: error.statusText, data: error.data } : error)
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
throw error
}
}
}
export default apiFetch
+1 -1
View File
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { register, authenticate } from '@/utils/passkey'
import { getSettings } from '@/utils/settings'
import { apiJson } from '@/paskia/fetch'
import { apiJson } from 'paskia'
export const useAuthStore = defineStore('auth', {
state: () => ({
+1 -1
View File
@@ -1,7 +1,7 @@
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket'
import { getSettings } from '@/utils/settings'
import { showAuthIframe } from '@/paskia/overlay'
import { showAuthIframe } from 'paskia'
// 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.
+3 -1
View File
@@ -90,7 +90,9 @@ export default defineConfig(({ command }) => ({
}
].filter(Boolean),
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
}
},
base: '/',
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
![Paskia](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'
@@ -34,14 +34,14 @@ body.paskia-backdrop {
}
`
let authIframe = null
let authPromise = null
let authResolve = null
let authReject = null
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() {
function injectStyles(): void {
if (document.getElementById(STYLES_ID)) return
const style = document.createElement('style')
style.id = STYLES_ID
@@ -56,23 +56,23 @@ export class AuthCancelledError extends Error {
}
}
export function holdGlobalBackdrop() {
export function holdGlobalBackdrop(): void {
backdropHolders++
document.body.classList.add('paskia-backdrop')
}
export function releaseGlobalBackdrop() {
export function releaseGlobalBackdrop(): void {
backdropHolders = Math.max(0, backdropHolders - 1)
if (backdropHolders === 0) {
document.body.classList.remove('paskia-backdrop')
}
}
export function isAuthIframeOpen() {
export function isAuthIframeOpen(): boolean {
return !!document.getElementById(AUTH_IFRAME_ID)
}
export function hideAuthIframe() {
export function hideAuthIframe(): void {
if (authIframe) {
authIframe.remove()
authIframe = null
@@ -80,8 +80,8 @@ export function hideAuthIframe() {
}
}
function handleAuthMessage(event) {
const data = event.data
function handleAuthMessage(event: MessageEvent): void {
const data = event.data as { type?: string }
if (!data?.type) return
switch (data.type) {
@@ -107,7 +107,7 @@ function handleAuthMessage(event) {
}
}
function ensureMessageListener() {
function ensureMessageListener(): void {
if (messageListenerInstalled) return
if (typeof window !== 'undefined') {
window.addEventListener('message', handleAuthMessage)
@@ -115,7 +115,7 @@ function ensureMessageListener() {
}
}
export function showAuthIframe(iframeUrl, title = 'Authentication') {
export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Promise<void> {
injectStyles()
ensureMessageListener()
@@ -146,7 +146,7 @@ export function showAuthIframe(iframeUrl, title = 'Authentication') {
return authPromise
}
export function createAuthIframe(iframeUrl, title = 'Authentication') {
export function createAuthIframe(iframeUrl: string, title = 'Authentication'): HTMLIFrameElement {
injectStyles()
const existing = document.getElementById(AUTH_IFRAME_ID)
if (existing) existing.remove()
@@ -160,7 +160,7 @@ export function createAuthIframe(iframeUrl, title = 'Authentication') {
return iframe
}
export function removeAuthIframe() {
export function removeAuthIframe(): void {
const iframe = document.getElementById(AUTH_IFRAME_ID)
if (iframe) iframe.remove()
}
@@ -1,46 +1,49 @@
import { apiJson } from './fetch.js'
import { apiJson } from './fetch'
const POLL_INTERVAL = 60 * 1000
const IDLE_TIMEOUT = 5 * 60 * 1000
export class SessionValidator {
constructor(userUuidGetter, onSessionLost) {
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.pollTimer = null
this.idleTimer = null
this.active = false
this.resetIdleTimer = this.resetIdleTimer.bind(this)
}
resetIdleTimer() {
resetIdleTimer(): void {
if (this.idleTimer) clearTimeout(this.idleTimer)
if (!this.active) this.startPolling()
this.idleTimer = setTimeout(() => this.stopPolling(), IDLE_TIMEOUT)
}
async validate() {
async validate(): Promise<void> {
try {
const data = await apiJson('/auth/api/validate', { method: 'POST' })
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.name !== 'NetworkError') {
if ((error as Error).name !== 'NetworkError') {
this.stopPolling()
this.onSessionLost(error)
this.onSessionLost(error as Error)
}
}
}
startPolling() {
startPolling(): void {
if (this.active) return
this.active = true
this.pollTimer = setInterval(() => this.validate(), POLL_INTERVAL)
}
stopPolling() {
stopPolling(): void {
this.active = false
if (this.pollTimer) {
clearInterval(this.pollTimer)
@@ -48,13 +51,13 @@ export class SessionValidator {
}
}
start() {
start(): void {
window.addEventListener('pointermove', this.resetIdleTimer)
window.addEventListener('pointerdown', this.resetIdleTimer)
this.resetIdleTimer()
}
stop() {
stop(): void {
window.removeEventListener('pointermove', this.resetIdleTimer)
window.removeEventListener('pointerdown', this.resetIdleTimer)
if (this.idleTimer) clearTimeout(this.idleTimer)
+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"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import { resolve } from 'path'
import dts from 'vite-plugin-dts'
export default defineConfig({
plugins: [dts({ rollupTypes: true })],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
fileName: 'paskia',
formats: ['es'],
},
},
})