Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42b54cf645 | ||
|
|
232d0e1ae0 | ||
|
|
e97a2b3291 | ||
|
|
cde709e252 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
fetch_ms: 10000,
|
||||||
|
auth_ms: 1000,
|
||||||
|
poll_ms: 60000,
|
||||||
|
idle_ms: 300000,
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
+10
-1
@@ -203,6 +203,15 @@ async def forward_authentication(
|
|||||||
- Otherwise: JSON response with error details and an `iframe` field
|
- Otherwise: JSON response with error details and an `iframe` field
|
||||||
pointing to /auth/restricted/iframe#mode=... for iframe-based authentication.
|
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:
|
try:
|
||||||
ctx = await authz.verify(
|
ctx = await authz.verify(
|
||||||
auth,
|
auth,
|
||||||
@@ -210,7 +219,7 @@ async def forward_authentication(
|
|||||||
host=request.headers.get("host"),
|
host=request.headers.get("host"),
|
||||||
max_age=max_age,
|
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
|
# Build permission scopes for Remote-Groups header
|
||||||
role_permissions = (
|
role_permissions = (
|
||||||
{p.scope for p in ctx.permissions} if ctx.permissions else set()
|
{p.scope for p in ctx.permissions} if ctx.permissions else set()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user