Compare commits

...
6 Commits
17 changed files with 104 additions and 40 deletions
+3 -3
View File
@@ -13,7 +13,7 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { apiJson, SessionValidator } from 'paskia'
import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
import StatusMessage from '@/components/StatusMessage.vue'
import ProfileView from '@/components/ProfileView.vue'
@@ -72,8 +72,8 @@ async function loadUserInfo() {
// apiJson handles 401/403 with auth.iframe automatically:
// shows overlay iframe, waits for auth, retries the request.
const [validateData, userInfoData] = await Promise.all([
apiJson('/auth/api/validate', { method: 'POST' }),
apiJson('/auth/api/user-info', { method: 'GET' })
apiJson('/auth/api/validate', { method: 'POST', timeout: paskiaSettings.auth_ms }),
apiJson('/auth/api/user-info', { method: 'GET', timeout: paskiaSettings.auth_ms })
])
store.userInfo = userInfoData
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 { useAuthStore } from '@/stores/auth'
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 { uuidv7 } from 'uuidv7'
import { getDirection } from '@/utils/keynav'
@@ -196,7 +196,7 @@ function orgUserCount(org) {
}
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
updateThemeFromSession(data.ctx)
authenticated.value = true
+3 -2
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'
import { apiJson, ApiError, getUserFriendlyErrorMessage, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
const status = reactive({
@@ -164,7 +164,8 @@ async function exchangeCode(result) {
}
return await apiJson('/auth/api/set-session', {
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 passkey from '@/utils/passkey'
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 { focusDialogButton } from '@/utils/keynav'
import { updateThemeFromSession } from '@/utils/theme'
@@ -147,7 +147,7 @@ async function fetchSettings() {
async function validateSession() {
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)
if (isAuthenticated.value && props.mode !== 'reauth') {
currentView.value = 'forbidden'
@@ -198,7 +198,7 @@ async function logoutUser() {
if (loading.value) return
loading.value = true
try {
await fetchJson('/auth/api/logout', { method: 'POST' })
await fetchJson('/auth/api/logout', { method: 'POST', timeout: paskiaSettings.auth_ms })
session.value = null
currentView.value = 'login'
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')
}
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 { register, authenticate } from '@/utils/passkey'
import { getSettings } from '@/utils/settings'
import { apiJson } from 'paskia'
import { apiJson, settings as paskiaSettings } from 'paskia'
import { updateThemeFromSession } from '@/utils/theme'
export const useAuthStore = defineStore('auth', {
@@ -50,6 +50,7 @@ export const useAuthStore = defineStore('auth', {
return await apiJson('/auth/api/set-session', {
method: 'POST',
headers: {'Authorization': `Bearer ${result.session_token}`},
timeout: paskiaSettings.auth_ms,
})
},
async register() {
@@ -87,7 +88,7 @@ export const useAuthStore = defineStore('auth', {
},
async loadUserInfo() {
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)
console.log('User info loaded:', this.userInfo)
} catch (error) {
@@ -121,7 +122,7 @@ export const useAuthStore = defineStore('auth', {
},
async logout() {
try {
await apiJson('/auth/api/logout', {method: 'POST'})
await apiJson('/auth/api/logout', {method: 'POST', timeout: paskiaSettings.auth_ms})
sessionStorage.clear()
location.reload()
} catch (error) {
@@ -134,7 +135,7 @@ export const useAuthStore = defineStore('auth', {
},
async logoutEverywhere() {
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()
location.reload()
} 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.
### 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
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",
"version": "1.1.0",
"version": "1.4.0",
"description": "Paskia authentication utilities for JavaScript",
"author": "Leo Vasanko",
"license": "Unlicense",
+2 -3
View File
@@ -1,9 +1,8 @@
import { showAuthIframe, AuthCancelledError } from './overlay'
import settings from './settings'
export { AuthCancelledError }
const DEFAULT_TIMEOUT_MS = 1000
export interface ApiFetchOptions extends RequestInit {
timeout?: number
}
@@ -40,7 +39,7 @@ export class NetworkError extends Error {
}
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'
while (true) {
+2
View File
@@ -12,6 +12,8 @@ export {
export type { ApiFetchOptions, FetchJsonOptions } from './fetch'
export { default as settings } from './settings'
export {
holdGlobalBackdrop,
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'
const POLL_INTERVAL = 60 * 1000
const IDLE_TIMEOUT = 5 * 60 * 1000
import settings from './settings'
export class SessionValidator {
private userUuidGetter: () => string | undefined
@@ -19,12 +17,12 @@ export class SessionValidator {
resetIdleTimer(): void {
if (this.idleTimer) clearTimeout(this.idleTimer)
if (!this.active) this.startPolling()
this.idleTimer = setTimeout(() => this.stopPolling(), IDLE_TIMEOUT)
this.idleTimer = setTimeout(() => this.stopPolling(), settings.idle_ms)
}
async validate(): Promise<void> {
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
if (newUuid !== this.userUuidGetter()) {
window.location.reload()
@@ -40,7 +38,7 @@ export class SessionValidator {
startPolling(): void {
if (this.active) return
this.active = true
this.pollTimer = setInterval(() => this.validate(), POLL_INTERVAL)
this.pollTimer = setInterval(() => this.validate(), settings.poll_ms)
}
stopPolling(): void {
+2 -2
View File
@@ -437,7 +437,7 @@ def delete_credential(
def update_session(
key: bytes,
key: str,
host: str | None = None,
ip: str | None = None,
user_agent: str | None = None,
@@ -461,7 +461,7 @@ def update_session(
def set_session_host(
key: bytes, host: str, *, ctx: SessionContext | None = None
key: str, host: str, *, ctx: SessionContext | None = None
) -> None:
"""Set the host for a session (first-time binding)."""
update_session(key, host=host, ctx=ctx)
+10 -1
View File
@@ -203,6 +203,15 @@ async def forward_authentication(
- Otherwise: JSON response with error details and an `iframe` field
pointing to /auth/restricted/iframe#mode=... for iframe-based authentication.
"""
forwarded_method = request.headers.get("x-forwarded-method", "").strip()
forwarded_uri = request.headers.get("x-forwarded-uri", "").strip()
forwarded = (
f"{forwarded_method} {forwarded_uri}"
if forwarded_method and forwarded_uri
else ""
)
_set_log_extra(request, forwarded)
try:
ctx = await authz.verify(
auth,
@@ -210,7 +219,7 @@ async def forward_authentication(
host=request.headers.get("host"),
max_age=max_age,
)
_set_log_extra(request, request.headers.get("x-forwarded-uri", ""), ctx.session.key)
_set_log_extra(request, forwarded, ctx.session.key)
# Build permission scopes for Remote-Groups header
role_permissions = (
{p.scope for p in ctx.permissions} if ctx.permissions else set()
+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)
_PATH = "\033[38;5;250m" # path (white)
_TIMING = "\033[38;5;242m" # timing/devmode (dark grey)
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
_WS_OPEN = "\033[38;5;226m" # WebSocket connect (brightest yellow from 6x6x6 cube)
_WS_CLOSE = "\033[38;5;142m" # WebSocket disconnect (significantly dimmer yellow)
_WS_STATUS = "\033[38;5;242m" # WebSocket close status (dark grey)
_AUTHZ_DENIED = "\033[0;31m" # Permission denied (red)
_AUTHZ_USER = "\033[1;34m" # User info (light blue)
+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)
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
+23 -5
View File
@@ -69,11 +69,22 @@ async def authenticate_chat(
async def authenticate_and_login(
ws: WebSocket,
auth: str | None = None,
*,
session_host: str | None = None,
session_ip: str | None = None,
session_user_agent: str | None = None,
) -> tuple[SessionContext, str]:
"""Run WebAuthn authentication flow, create session, and return the session context.
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:
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)
# 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
secret = db.login(
user_uuid=cred.user_uuid,
credential_uuid=cred.uuid,
sign_count=new_sign_count,
host=normalized_host,
ip=metadata["ip"],
user_agent=metadata["user_agent"],
host=login_host,
ip=login_ip,
user_agent=login_user_agent,
)
# Fetch and return the full session context
ctx = session_ctx(secret, host)
# Fetch and return the full session context (using the same host the session was created with)
ctx = session_ctx(secret, login_host)
if not ctx:
raise ValueError("Failed to create session context")
return ctx, secret
+4 -4
View File
@@ -19,8 +19,8 @@ BOX_WIDTH = 60 # Inner width (excluding box chars)
# ANSI color codes
RESET = "\033[0m"
YELLOW = "\033[33m" # Dark yellow
BRIGHT_YELLOW = "\033[93m" # Bright yellow
YELLOW = "\033[38;5;184m" # Bright yellow (6x6x6 cube, r=4 g=4)
BRIGHT_YELLOW = "\033[38;5;226m" # Brightest yellow (6x6x6 cube)
BRIGHT_WHITE = "\033[1;37m" # Bold bright white
@@ -50,8 +50,8 @@ def bottom() -> str:
def print_startup_config(runtime: RuntimeConfig) -> None:
"""Print server configuration on startup."""
# Key graphic with yellow shading (bright for highlights, dark for body)
y = YELLOW # Dark yellow for main body
b = BRIGHT_YELLOW # Bright yellow for highlights/edges
y = YELLOW # Bright golden yellow for main body
b = BRIGHT_YELLOW # Brightest yellow for highlights/edges
w = BRIGHT_WHITE # Bold white for URL
r = RESET