From decda8becc6c6c0c19123313a60d53cd49904de7 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Thu, 19 Feb 2026 00:18:32 -0500 Subject: [PATCH 1/2] feat: cli tenant support Signed-off-by: Sebastian Krupinski --- core/lib/Service/TenantService.php | 5 +++++ core/lib/SessionTenant.php | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/core/lib/Service/TenantService.php b/core/lib/Service/TenantService.php index f8d9a3e..57b0311 100644 --- a/core/lib/Service/TenantService.php +++ b/core/lib/Service/TenantService.php @@ -16,4 +16,9 @@ class TenantService return $this->store->fetchByDomain($domain); } + public function fetchById(string $identifier): ?TenantObject + { + return $this->store->fetch($identifier); + } + } diff --git a/core/lib/SessionTenant.php b/core/lib/SessionTenant.php index 7400c0e..9a3244f 100644 --- a/core/lib/SessionTenant.php +++ b/core/lib/SessionTenant.php @@ -37,6 +37,26 @@ class SessionTenant } } + /** + * Configure the tenant by its identifier (for console / CLI usage). + */ + public function configureById(string $identifier): void + { + if ($this->configured) { + return; + } + $tenant = $this->tenantService->fetchById($identifier); + if ($tenant) { + $this->domain = $identifier; + $this->tenant = $tenant; + $this->configured = true; + } else { + $this->domain = null; + $this->tenant = null; + $this->configured = false; + } + } + /** * Is the tenant configured */ -- 2.39.5 From 99fa707eb3defab09981e994a2ed71638647289c Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Thu, 19 Feb 2026 23:03:09 -0500 Subject: [PATCH 2/2] feat: improve authentication Signed-off-by: Sebastian Krupinski --- .../Controllers/AuthenticationController.php | 19 +- core/src/private.ts | 22 +- core/src/services/authManager.ts | 82 +++++++ core/src/services/authenticationService.ts | 12 +- core/src/utils/helpers/fetch-wrapper-core.ts | 219 ++++++------------ core/src/utils/helpers/fetch-wrapper.ts | 9 +- core/src/utils/helpers/shared.ts | 6 +- 7 files changed, 194 insertions(+), 175 deletions(-) create mode 100644 core/src/services/authManager.ts diff --git a/core/lib/Controllers/AuthenticationController.php b/core/lib/Controllers/AuthenticationController.php index aebce0b..a3c8023 100644 --- a/core/lib/Controllers/AuthenticationController.php +++ b/core/lib/Controllers/AuthenticationController.php @@ -225,7 +225,7 @@ class AuthenticationController extends ControllerAbstract return $this->clearTokenCookies($httpResponse); } - $httpResponse = new JsonResponse(['status' => 'success', 'message' => 'Token refreshed']); + $httpResponse = new JsonResponse(['status' => 'success', 'message' => 'Token refreshed', 'expires_in' => 900]); if ($response->tokens && isset($response->tokens['access'])) { $httpResponse->headers->setCookie( @@ -242,6 +242,15 @@ class AuthenticationController extends ControllerAbstract return $httpResponse; } + /** + * Session health check + */ + #[AuthenticatedRoute('/auth/ping', name: 'auth.ping', methods: ['GET'])] + public function ping(): JsonResponse + { + return new JsonResponse(['status' => 'ok']); + } + /** * Logout current device */ @@ -281,14 +290,16 @@ class AuthenticationController extends ControllerAbstract */ private function buildJsonResponse(AuthenticationResponse $response): JsonResponse { - $httpResponse = new JsonResponse($response->toArray(), $response->httpStatus); + $data = $response->toArray(); - // Set token cookies if present + // Set token cookies and expose expires_in if present if ($response->hasTokens()) { + $data['expires_in'] = 900; + $httpResponse = new JsonResponse($data, $response->httpStatus); return $this->setTokenCookies($httpResponse, $response->tokens, true); } - return $httpResponse; + return new JsonResponse($data, $response->httpStatus); } /** diff --git a/core/src/private.ts b/core/src/private.ts index 4b94c30..6b16502 100644 --- a/core/src/private.ts +++ b/core/src/private.ts @@ -9,15 +9,12 @@ import { useTenantStore } from '@KTXC/stores/tenantStore' import { useUserStore } from '@KTXC/stores/userStore' import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper' import { initializeModules } from '@KTXC/utils/modules' +import { createSessionMonitor } from '@KTXC/services/authManager' import App from './App.vue' import router from './router' import vuetify from './plugins/vuetify/index' import '@KTXC/scss/style.scss' - -// Material Design Icons (Vuetify mdi icon set) import '@mdi/font/css/materialdesignicons.min.css' - -// google-fonts import '@fontsource/public-sans/index.css' const app = createApp(App) @@ -26,8 +23,6 @@ app.use(pinia) app.use(PerfectScrollbarPlugin) app.use(vuetify) -// Note: Router is registered AFTER modules are loaded to prevent premature route matching - const globalWindow = window as typeof window & { [key: string]: unknown } @@ -37,7 +32,6 @@ globalWindow.vue = Vue globalWindow.VueRouter = VueRouterLib globalWindow.Pinia = PiniaLib as unknown -// Bootstrap initial private UI state (modules, tenant, user) before mounting (async () => { const moduleStore = useModuleStore(); const tenantStore = useTenantStore(); @@ -48,23 +42,23 @@ globalWindow.Pinia = PiniaLib as unknown moduleStore.init(payload?.modules ?? {}); tenantStore.init(payload?.tenant ?? null); userStore.init(payload?.user ?? {}); + + // Initialize auth session monitor + const sessionMonitor = createSessionMonitor({ onLogout: () => userStore.logout() }); + sessionMonitor.start(); - // Initialize registered modules (following reference app's bootstrap pattern) + // Initialize registered modules await initializeModules(app); - // Add 404 catch-all route AFTER all modules are loaded - // This ensures module routes are registered before the catch-all + // Register a catch-all and router router.addRoute({ name: 'NotFound', path: '/:pathMatch(.*)*', component: () => import('@KTXC/views/pages/maintenance/error/Error404Page.vue') }); - - // Register router AFTER modules are loaded app.use(router); - await router.isReady(); - // Home redirect handled by router beforeEnter + app.mount('#app'); } catch (e) { console.error('Bootstrap failed:', e); diff --git a/core/src/services/authManager.ts b/core/src/services/authManager.ts new file mode 100644 index 0000000..4e17929 --- /dev/null +++ b/core/src/services/authManager.ts @@ -0,0 +1,82 @@ +/** + * Authentication Manager + */ + +const REFRESH_URL = '/auth/refresh'; +const PING_URL = '/auth/ping'; +const TOKEN_EXPIRY_BUFFER_MS = 60 * 1000; +const PING_INTERVAL_MS = 60 * 1000; + +let tokenExpiresAt: number | null = null; +let refreshPromise: Promise | null = null; + +export function recordExpiry(expiresIn: number | null | undefined): void { + tokenExpiresAt = expiresIn != null ? Date.now() + expiresIn * 1000 : null; +} + +function isTokenExpiredOrExpiring(): boolean { + if (tokenExpiresAt === null) return false; + return Date.now() >= tokenExpiresAt - TOKEN_EXPIRY_BUFFER_MS; +} + +async function refreshToken(): Promise { + if (refreshPromise) return refreshPromise; + + refreshPromise = fetch(REFRESH_URL, { method: 'POST', credentials: 'include' }) + .then(async r => { + if (!r.ok) return false; + try { + const body = await r.clone().json(); + recordExpiry(body?.expires_in); + } catch { /* ignore parse errors */ } + return true; + }) + .catch(() => false) + .finally(() => { refreshPromise = null; }); + + return refreshPromise; +} + +/** + * Ensures the token is fresh before a request fires. + */ +export async function ensureFreshToken(): Promise { + if (isTokenExpiredOrExpiring()) { + await refreshToken(); + } +} + +export interface SessionMonitorOptions { + onLogout: () => void | Promise; +} + +export function createSessionMonitor(options: SessionMonitorOptions) { + let intervalId: ReturnType | null = null; + + async function ping(): Promise { + // GET ping — no body, no CSRF needed, just confirm the session cookie is still valid + const response = await fetch(PING_URL, { credentials: 'include' }).catch(() => null); + + if (!response?.ok) { + // Session may be expired — attempt a token refresh before giving up + const refreshed = await refreshToken(); + if (!refreshed) { + stop(); + await options.onLogout(); + } + } + } + + function start(): void { + if (intervalId !== null) return; + intervalId = setInterval(ping, PING_INTERVAL_MS); + } + + function stop(): void { + if (intervalId === null) return; + clearInterval(intervalId); + intervalId = null; + } + + return { start, stop }; +} diff --git a/core/src/services/authenticationService.ts b/core/src/services/authenticationService.ts index c5a51e5..a5594f9 100644 --- a/core/src/services/authenticationService.ts +++ b/core/src/services/authenticationService.ts @@ -6,7 +6,7 @@ export const authenticationService = { * Initialize authentication - get session and available methods */ async start(): Promise { - return fetchWrapper.get('/auth/start', undefined, { skipLogoutOnError: true }); + return fetchWrapper.get('/auth/start'); }, /** @@ -20,7 +20,7 @@ export const authenticationService = { return fetchWrapper.post('/auth/identify', { session, identity, - }, { skipLogoutOnError: true }); + }); }, /** @@ -42,7 +42,7 @@ export const authenticationService = { method, response, ...(identity && { identity }), - }, { autoRetry: false, skipLogoutOnError: true }); + }); }, /** @@ -57,7 +57,7 @@ export const authenticationService = { session, method, return_url: returnUrl, - }, { skipLogoutOnError: true }); + }); }, /** @@ -67,14 +67,14 @@ export const authenticationService = { return fetchWrapper.post('/auth/challenge', { session, method, - }, { skipLogoutOnError: true }); + }); }, /** * Get current session status */ async getStatus(session: string): Promise { - return fetchWrapper.get(`/auth/status?session=${encodeURIComponent(session)}`, undefined, { skipLogoutOnError: true }); + return fetchWrapper.get(`/auth/status?session=${encodeURIComponent(session)}`); }, /** diff --git a/core/src/utils/helpers/fetch-wrapper-core.ts b/core/src/utils/helpers/fetch-wrapper-core.ts index 578219d..0b81bd8 100644 --- a/core/src/utils/helpers/fetch-wrapper-core.ts +++ b/core/src/utils/helpers/fetch-wrapper-core.ts @@ -1,161 +1,92 @@ /** * Core fetch wrapper - reusable across modules - * Does not depend on stores to avoid bundling issues in library builds */ export interface FetchWrapperOptions { - /** - * Optional callback to handle logout on auth failure - * If not provided, only logs error without redirecting - */ - onLogout?: () => void | Promise; - /** - * Enable automatic retry of failed requests after token refresh - * @default true - */ - autoRetry?: boolean; + /** Additional headers merged into every request */ + headers?: Record; + /** Override default Content-Type (default: application/json) */ + contentType?: string; + /** Override default Accept type (default: application/json) */ + accept?: string; + /** Called before every request */ + beforeRequest?: () => Promise; + /** Called after every successful non-stream response */ + afterResponse?: (data: any) => void; } -// Mutex to prevent multiple simultaneous refresh attempts -class RefreshMutex { - private promise: Promise | null = null; - - async acquire(): Promise { - if (this.promise) { - return this.promise; - } - - this.promise = this.performRefresh(); - const result = await this.promise; - this.promise = null; - return result; - } - - private async performRefresh(): Promise { - try { - const response = await fetch('/security/refresh', { - method: 'POST', - credentials: 'include' - }); - - return response.ok; - } catch (error) { - console.error('Token refresh failed:', error); - return false; - } - } -} - -const tokenRefreshMutex = new RefreshMutex(); - export interface RequestCallOptions { - /** - * Override autoRetry for this specific request - * @default true - */ - autoRetry?: boolean; - /** - * Skip calling onLogout callback on 401/403 errors - * Useful for authentication endpoints where 401 means invalid credentials, not session expiry - * @default false - */ - skipLogoutOnError?: boolean; + /** When set, the raw Response is forwarded to this handler instead of being JSON-parsed */ + onStream?: (response: Response) => Promise; + /** Per-call header overrides — highest priority, wins over factory-level headers */ + headers?: Record; +} + +function getCsrfToken(): string | null { + if (typeof document === 'undefined') return null; + const cookie = document.cookie.split('; ').find(r => r.startsWith('X-CSRF-TOKEN=')); + return cookie ? (cookie.split('=')[1] ?? null) : null; } export function createFetchWrapper(options: FetchWrapperOptions = {}) { - const { autoRetry: defaultAutoRetry = true } = options; - - return { - get: request('GET', options, defaultAutoRetry), - post: request('POST', options, defaultAutoRetry), - put: request('PUT', options, defaultAutoRetry), - delete: request('DELETE', options, defaultAutoRetry) - }; -} + async function request( + method: string, + url: string, + body?: object, + callOptions?: RequestCallOptions + ): Promise { + if (options.beforeRequest) { + await options.beforeRequest(); + } -interface RequestOptions { - method: string; - headers: Record; - body?: string; - credentials: 'include'; -} - -function request(method: string, options: FetchWrapperOptions, defaultAutoRetry: boolean) { - return async (url: string, body?: object, callOptions?: RequestCallOptions): Promise => { - const autoRetry = callOptions?.autoRetry ?? defaultAutoRetry; - - const requestOptions: RequestOptions = { - method, - headers: getHeaders(url), - credentials: 'include' + // Header priority: defaults < factory options.headers < callOptions.headers + const headers: Record = { + 'Content-Type': options.contentType ?? 'application/json', + 'Accept': options.accept ?? 'application/json', + ...options.headers, + ...callOptions?.headers, }; - - if (body) { - requestOptions.headers['Content-Type'] = 'application/json'; - requestOptions.body = JSON.stringify(body); + + const csrf = getCsrfToken(); + if (csrf) headers['X-CSRF-TOKEN'] = csrf; + + const requestOptions: RequestInit = { + method, + headers, + credentials: 'include', + body: body ? JSON.stringify(body) : undefined, + }; + + const response = await fetch(url, requestOptions); + + if (!response.ok) { + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + throw new Error(data?.message || response.statusText); } - - try { - const response = await fetch(url, requestOptions); - - if (response.status === 401 && autoRetry) { - // Try to refresh the token - const refreshSuccess = await tokenRefreshMutex.acquire(); - - if (refreshSuccess) { - // Retry the original request with the new token - const retryResponse = await fetch(url, requestOptions); - return handleResponse(retryResponse, options, callOptions?.skipLogoutOnError); - } - } - - return handleResponse(response, options, callOptions?.skipLogoutOnError); - } catch (error) { - console.error('API error:', error); - throw error; + + if (callOptions?.onStream) { + return callOptions.onStream(response); } + + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + + options.afterResponse?.(data); + + return data; + } + + return { + get: (url: string, callOptions?: RequestCallOptions) => + request('GET', url, undefined, callOptions), + post: (url: string, body?: object, callOptions?: RequestCallOptions) => + request('POST', url, body, callOptions), + put: (url: string, body?: object, callOptions?: RequestCallOptions) => + request('PUT', url, body, callOptions), + patch: (url: string, body?: object, callOptions?: RequestCallOptions) => + request('PATCH', url, body, callOptions), + delete: (url: string, callOptions?: RequestCallOptions) => + request('DELETE', url, undefined, callOptions), }; } - -function getHeaders(_url: string): Record { - const headers: Record = {}; - - // Add CSRF token if available - const csrfToken = getCsrfTokenFromCookie(); - if (csrfToken) { - headers['X-CSRF-TOKEN'] = csrfToken; - } - - return headers; -} - -function getCsrfTokenFromCookie(): string | null { - if (typeof document === 'undefined') return null; - - const csrfCookie = document.cookie - .split('; ') - .find(row => row.startsWith('X-CSRF-TOKEN=')); - - return csrfCookie ? csrfCookie.split('=')[1] : null; -} - -async function handleResponse(response: Response, options: FetchWrapperOptions, skipLogoutOnError?: boolean): Promise { - const text = await response.text(); - const data = text && JSON.parse(text); - - if (!response.ok) { - if ([401, 403].includes(response.status) && !skipLogoutOnError) { - // Call logout callback if provided - if (options.onLogout) { - await options.onLogout(); - } else { - console.error('Authentication failed. Please log in again.'); - } - } - - const error: string = (data && data.message) || response.statusText; - throw new Error(error); - } - - return data; -} diff --git a/core/src/utils/helpers/fetch-wrapper.ts b/core/src/utils/helpers/fetch-wrapper.ts index 4e92c39..c19f24c 100644 --- a/core/src/utils/helpers/fetch-wrapper.ts +++ b/core/src/utils/helpers/fetch-wrapper.ts @@ -1,10 +1,7 @@ -import { useUserStore } from '@KTXC/stores/userStore'; import { createFetchWrapper } from './fetch-wrapper-core'; +import { ensureFreshToken, recordExpiry } from '@KTXC/services/authManager'; -// Create fetch wrapper with user store logout callback export const fetchWrapper = createFetchWrapper({ - onLogout: () => { - const { logout } = useUserStore(); - logout(); - } + beforeRequest: ensureFreshToken, + afterResponse: (data) => recordExpiry(data?.expires_in), }); diff --git a/core/src/utils/helpers/shared.ts b/core/src/utils/helpers/shared.ts index 1889e05..598600f 100644 --- a/core/src/utils/helpers/shared.ts +++ b/core/src/utils/helpers/shared.ts @@ -3,4 +3,8 @@ * This file is built separately and exposed via import map */ -export { createFetchWrapper, type FetchWrapperOptions } from './fetch-wrapper-core'; +export { + createFetchWrapper, + type FetchWrapperOptions, + type RequestCallOptions, +} from './fetch-wrapper-core'; -- 2.39.5