Compare commits

..
5 Commits
16 changed files with 94 additions and 39 deletions
+3 -3
View File
@@ -13,7 +13,7 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { apiJson, SessionValidator } from 'paskia' import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme' import { updateThemeFromSession } from '@/utils/theme'
import StatusMessage from '@/components/StatusMessage.vue' import StatusMessage from '@/components/StatusMessage.vue'
import ProfileView from '@/components/ProfileView.vue' import ProfileView from '@/components/ProfileView.vue'
@@ -72,8 +72,8 @@ async function loadUserInfo() {
// apiJson handles 401/403 with auth.iframe automatically: // apiJson handles 401/403 with auth.iframe automatically:
// shows overlay iframe, waits for auth, retries the request. // shows overlay iframe, waits for auth, retries the request.
const [validateData, userInfoData] = await Promise.all([ const [validateData, userInfoData] = await Promise.all([
apiJson('/auth/api/validate', { method: 'POST' }), apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
apiJson('/auth/api/user-info', { method: 'GET' }) apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
]) ])
store.userInfo = userInfoData store.userInfo = userInfoData
store.ctx = validateData.ctx store.ctx = validateData.ctx
+2 -2
View File
@@ -13,7 +13,7 @@ import AdminOidcDetail from '@/admin/AdminOidcDetail.vue'
import AdminDialogs from '@/admin/AdminDialogs.vue' import AdminDialogs from '@/admin/AdminDialogs.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings' import { adminUiPath, makeUiHref } from '@/utils/settings'
import { apiJson, SessionValidator } from 'paskia' import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme' import { updateThemeFromSession } from '@/utils/theme'
import { uuidv7 } from 'uuidv7' import { uuidv7 } from 'uuidv7'
import { getDirection } from '@/utils/keynav' import { getDirection } from '@/utils/keynav'
@@ -196,7 +196,7 @@ function orgUserCount(org) {
} }
async function loadUserInfo() { async function loadUserInfo() {
const data = await apiJson('/auth/api/validate', { method: 'POST' }) const data = await apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
info.value = data info.value = data
updateThemeFromSession(data.ctx) updateThemeFromSession(data.ctx)
authenticated.value = true authenticated.value = true
+3 -2
View File
@@ -59,7 +59,7 @@
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings' import { getSettings, uiBasePath } from '@/utils/settings'
import { apiJson, ApiError, getUserFriendlyErrorMessage } from 'paskia' import { apiJson, ApiError, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme' import { updateThemeFromSession } from '@/utils/theme'
const status = reactive({ const status = reactive({
@@ -164,7 +164,8 @@ async function exchangeCode(result) {
} }
return await apiJson('/auth/api/set-session', { return await apiJson('/auth/api/set-session', {
method: 'POST', method: 'POST',
headers: { 'Authorization': `Bearer ${result.exchange_code}` } headers: { 'Authorization': `Bearer ${result.exchange_code}` },
timeout: paskiaSettings.auth_ms,
}) })
} }
+4 -4
View File
@@ -58,7 +58,7 @@
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import passkey from '@/utils/passkey' import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings' import { getSettings, uiBasePath } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from 'paskia' import { fetchJson, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue' import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
import { focusDialogButton } from '@/utils/keynav' import { focusDialogButton } from '@/utils/keynav'
import { updateThemeFromSession } from '@/utils/theme' import { updateThemeFromSession } from '@/utils/theme'
@@ -147,7 +147,7 @@ async function fetchSettings() {
async function validateSession() { async function validateSession() {
try { try {
session.value = await fetchJson('/auth/api/validate', { method: 'POST' }) session.value = await fetchJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms })
updateThemeFromSession(session.value?.ctx) updateThemeFromSession(session.value?.ctx)
if (isAuthenticated.value && props.mode !== 'reauth') { if (isAuthenticated.value && props.mode !== 'reauth') {
currentView.value = 'forbidden' currentView.value = 'forbidden'
@@ -198,7 +198,7 @@ async function logoutUser() {
if (loading.value) return if (loading.value) return
loading.value = true loading.value = true
try { try {
await fetchJson('/auth/api/logout', { method: 'POST' }) await fetchJson('/auth/api/logout', { method: 'POST', timeout: paskiaSettings.auth_ms })
session.value = null session.value = null
currentView.value = 'login' currentView.value = 'login'
showMessage('Logged out. You can sign in with a different account.', 'info', 3000) showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
@@ -220,7 +220,7 @@ async function exchangeCode(result) {
throw new Error('Authentication response missing exchange_code') throw new Error('Authentication response missing exchange_code')
} }
return await fetchJson('/auth/api/set-session', { return await fetchJson('/auth/api/set-session', {
method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` } method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }, timeout: paskiaSettings.auth_ms
}) })
} }
+5 -4
View File
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { register, authenticate } from '@/utils/passkey' import { register, authenticate } from '@/utils/passkey'
import { getSettings } from '@/utils/settings' import { getSettings } from '@/utils/settings'
import { apiJson } from 'paskia' import { apiJson, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme' import { updateThemeFromSession } from '@/utils/theme'
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', {
@@ -50,6 +50,7 @@ export const useAuthStore = defineStore('auth', {
return await apiJson('/auth/api/set-session', { return await apiJson('/auth/api/set-session', {
method: 'POST', method: 'POST',
headers: {'Authorization': `Bearer ${result.session_token}`}, headers: {'Authorization': `Bearer ${result.session_token}`},
timeout: paskiaSettings.auth_ms,
}) })
}, },
async register() { async register() {
@@ -87,7 +88,7 @@ export const useAuthStore = defineStore('auth', {
}, },
async loadUserInfo() { async loadUserInfo() {
try { try {
this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET' }) this.userInfo = await apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
updateThemeFromSession(this.userInfo) updateThemeFromSession(this.userInfo)
console.log('User info loaded:', this.userInfo) console.log('User info loaded:', this.userInfo)
} catch (error) { } catch (error) {
@@ -121,7 +122,7 @@ export const useAuthStore = defineStore('auth', {
}, },
async logout() { async logout() {
try { try {
await apiJson('/auth/api/logout', {method: 'POST'}) await apiJson('/auth/api/logout', {method: 'POST', timeout: paskiaSettings.auth_ms})
sessionStorage.clear() sessionStorage.clear()
location.reload() location.reload()
} catch (error) { } catch (error) {
@@ -134,7 +135,7 @@ export const useAuthStore = defineStore('auth', {
}, },
async logoutEverywhere() { async logoutEverywhere() {
try { try {
await apiJson('/auth/api/user/logout-all', {method: 'POST'}) await apiJson('/auth/api/user/logout-all', {method: 'POST', timeout: paskiaSettings.auth_ms})
sessionStorage.clear() sessionStorage.clear()
location.reload() location.reload()
} catch (error) { } catch (error) {
+24
View File
@@ -64,6 +64,30 @@ When a 401/403 response includes an auth iframe URL, the request automatically p
The JSON variants set headers automatically, with body and response in JSON. 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
import { settings } from 'paskia'
// General fetch timeout used by apiFetch/apiJson/fetchJson when no timeout is passed
settings.fetch_ms = 10000
// Fetch timeout used by SessionValidator (/auth/api/validate is fast)
settings.auth_ms = 1000
// SessionValidator polling and idle timers
settings.poll_ms = 60000
settings.idle_ms = 300000
```
You can still override timeout per request:
```js
await apiJson('/api/upload', { method: 'POST', body: data, timeout: 30000 })
```
### Authentication Overlay ### 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. 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.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paskia", "name": "paskia",
"version": "1.1.0", "version": "1.4.0",
"description": "Paskia authentication utilities for JavaScript", "description": "Paskia authentication utilities for JavaScript",
"author": "Leo Vasanko", "author": "Leo Vasanko",
"license": "Unlicense", "license": "Unlicense",
+2 -3
View File
@@ -1,9 +1,8 @@
import { showAuthIframe, AuthCancelledError } from './overlay' import { showAuthIframe, AuthCancelledError } from './overlay'
import settings from './settings'
export { AuthCancelledError } export { AuthCancelledError }
const DEFAULT_TIMEOUT_MS = 1000
export interface ApiFetchOptions extends RequestInit { export interface ApiFetchOptions extends RequestInit {
timeout?: number timeout?: number
} }
@@ -40,7 +39,7 @@ export class NetworkError extends Error {
} }
export async function apiFetch(url: string, options: ApiFetchOptions = {}): Promise<Response> { export async function apiFetch(url: string, options: ApiFetchOptions = {}): Promise<Response> {
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options const { timeout = settings.fetch_ms, ...fetchOptions } = options
fetchOptions.credentials = fetchOptions.credentials || 'include' fetchOptions.credentials = fetchOptions.credentials || 'include'
while (true) { while (true) {
+2
View File
@@ -12,6 +12,8 @@ export {
export type { ApiFetchOptions, FetchJsonOptions } from './fetch' export type { ApiFetchOptions, FetchJsonOptions } from './fetch'
export { default as settings } from './settings'
export { export {
holdGlobalBackdrop, holdGlobalBackdrop,
releaseGlobalBackdrop, releaseGlobalBackdrop,
+6
View File
@@ -0,0 +1,6 @@
export default {
fetch_ms: 10000,
auth_ms: 1000,
poll_ms: 60000,
idle_ms: 300000,
}
+4 -6
View File
@@ -1,7 +1,5 @@
import { apiJson } from './fetch' import { apiJson } from './fetch'
import settings from './settings'
const POLL_INTERVAL = 60 * 1000
const IDLE_TIMEOUT = 5 * 60 * 1000
export class SessionValidator { export class SessionValidator {
private userUuidGetter: () => string | undefined private userUuidGetter: () => string | undefined
@@ -19,12 +17,12 @@ export class SessionValidator {
resetIdleTimer(): void { resetIdleTimer(): void {
if (this.idleTimer) clearTimeout(this.idleTimer) if (this.idleTimer) clearTimeout(this.idleTimer)
if (!this.active) this.startPolling() if (!this.active) this.startPolling()
this.idleTimer = setTimeout(() => this.stopPolling(), IDLE_TIMEOUT) this.idleTimer = setTimeout(() => this.stopPolling(), settings.idle_ms)
} }
async validate(): Promise<void> { async validate(): Promise<void> {
try { try {
const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST' }) const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST', timeout: settings.auth_ms })
const newUuid = data.ctx?.user?.uuid const newUuid = data.ctx?.user?.uuid
if (newUuid !== this.userUuidGetter()) { if (newUuid !== this.userUuidGetter()) {
window.location.reload() window.location.reload()
@@ -40,7 +38,7 @@ export class SessionValidator {
startPolling(): void { startPolling(): void {
if (this.active) return if (this.active) return
this.active = true this.active = true
this.pollTimer = setInterval(() => this.validate(), POLL_INTERVAL) this.pollTimer = setInterval(() => this.validate(), settings.poll_ms)
} }
stopPolling(): void { stopPolling(): void {
+2 -2
View File
@@ -437,7 +437,7 @@ def delete_credential(
def update_session( def update_session(
key: bytes, key: str,
host: str | None = None, host: str | None = None,
ip: str | None = None, ip: str | None = None,
user_agent: str | None = None, user_agent: str | None = None,
@@ -461,7 +461,7 @@ def update_session(
def set_session_host( def set_session_host(
key: bytes, host: str, *, ctx: SessionContext | None = None key: str, host: str, *, ctx: SessionContext | None = None
) -> None: ) -> None:
"""Set the host for a session (first-time binding).""" """Set the host for a session (first-time binding)."""
update_session(key, host=host, ctx=ctx) update_session(key, host=host, ctx=ctx)
+2 -2
View File
@@ -26,8 +26,8 @@ _METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
_HOST = "\033[38;5;242m" # hostname (dark grey) _HOST = "\033[38;5;242m" # hostname (dark grey)
_PATH = "\033[38;5;250m" # path (white) _PATH = "\033[38;5;250m" # path (white)
_TIMING = "\033[38;5;242m" # timing/devmode (dark grey) _TIMING = "\033[38;5;242m" # timing/devmode (dark grey)
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow) _WS_OPEN = "\033[38;5;226m" # WebSocket connect (brightest yellow from 6x6x6 cube)
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow) _WS_CLOSE = "\033[38;5;142m" # WebSocket disconnect (significantly dimmer yellow)
_WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey) _WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey)
_AUTHZ_DENIED = "\033[0;31m" # Permission denied (red) _AUTHZ_DENIED = "\033[0;31m" # Permission denied (red)
_AUTHZ_USER = "\033[1;34m" # User info (light blue) _AUTHZ_USER = "\033[1;34m" # User info (light blue)
+7 -1
View File
@@ -312,7 +312,13 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
# Handle authenticate request (no PoW needed - already validated during lookup) # Handle authenticate request (no PoW needed - already validated during lookup)
if msg.get("authenticate") and request is not None: if msg.get("authenticate") and request is not None:
ctx, secret = await authenticate_and_login(ws, auth) ctx, secret = await authenticate_and_login(
ws,
auth,
session_host=request.host,
session_ip=request.ip,
session_user_agent=request.user_agent,
)
reset_token = None reset_token = None
+23 -5
View File
@@ -69,11 +69,22 @@ async def authenticate_chat(
async def authenticate_and_login( async def authenticate_and_login(
ws: WebSocket, ws: WebSocket,
auth: str | None = None, auth: str | None = None,
*,
session_host: str | None = None,
session_ip: str | None = None,
session_user_agent: str | None = None,
) -> tuple[SessionContext, str]: ) -> tuple[SessionContext, str]:
"""Run WebAuthn authentication flow, create session, and return the session context. """Run WebAuthn authentication flow, create session, and return the session context.
If auth is provided, restrict authentication to credentials of that session's user. If auth is provided, restrict authentication to credentials of that session's user.
Args:
ws: The WebSocket connection (used for WebAuthn and origin validation)
auth: Existing session cookie for re-auth credential restriction
session_host: Override host for the new session (defaults to ws origin)
session_ip: Override IP for the new session (defaults to ws client IP)
session_user_agent: Override user-agent for the new session (defaults to ws headers)
Returns: Returns:
Tuple of (SessionContext for the authenticated session, session secret) Tuple of (SessionContext for the authenticated session, session secret)
""" """
@@ -97,18 +108,25 @@ async def authenticate_and_login(
cred, new_sign_count = await authenticate_chat(ws, credential_ids) cred, new_sign_count = await authenticate_chat(ws, credential_ids)
# Use overrides if provided, otherwise use websocket metadata
login_host = hostutil.normalize_host(session_host) if session_host is not None else normalized_host
if not login_host:
raise ValueError("Host required for session creation")
login_ip = session_ip if session_ip is not None else metadata["ip"]
login_user_agent = session_user_agent if session_user_agent is not None else metadata["user_agent"]
# Create session and update user/credential # Create session and update user/credential
secret = db.login( secret = db.login(
user_uuid=cred.user_uuid, user_uuid=cred.user_uuid,
credential_uuid=cred.uuid, credential_uuid=cred.uuid,
sign_count=new_sign_count, sign_count=new_sign_count,
host=normalized_host, host=login_host,
ip=metadata["ip"], ip=login_ip,
user_agent=metadata["user_agent"], user_agent=login_user_agent,
) )
# Fetch and return the full session context # Fetch and return the full session context (using the same host the session was created with)
ctx = session_ctx(secret, host) ctx = session_ctx(secret, login_host)
if not ctx: if not ctx:
raise ValueError("Failed to create session context") raise ValueError("Failed to create session context")
return ctx, secret return ctx, secret
+4 -4
View File
@@ -19,8 +19,8 @@ BOX_WIDTH = 60 # Inner width (excluding box chars)
# ANSI color codes # ANSI color codes
RESET = "\033[0m" RESET = "\033[0m"
YELLOW = "\033[33m" # Dark yellow YELLOW = "\033[38;5;184m" # Bright yellow (6x6x6 cube, r=4 g=4)
BRIGHT_YELLOW = "\033[93m" # Bright yellow BRIGHT_YELLOW = "\033[38;5;226m" # Brightest yellow (6x6x6 cube)
BRIGHT_WHITE = "\033[1;37m" # Bold bright white BRIGHT_WHITE = "\033[1;37m" # Bold bright white
@@ -50,8 +50,8 @@ def bottom() -> str:
def print_startup_config(runtime: RuntimeConfig) -> None: def print_startup_config(runtime: RuntimeConfig) -> None:
"""Print server configuration on startup.""" """Print server configuration on startup."""
# Key graphic with yellow shading (bright for highlights, dark for body) # Key graphic with yellow shading (bright for highlights, dark for body)
y = YELLOW # Dark yellow for main body y = YELLOW # Bright golden yellow for main body
b = BRIGHT_YELLOW # Bright yellow for highlights/edges b = BRIGHT_YELLOW # Brightest yellow for highlights/edges
w = BRIGHT_WHITE # Bold white for URL w = BRIGHT_WHITE # Bold white for URL
r = RESET r = RESET