feat: improve authentication
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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<void>;
|
||||
/**
|
||||
* Enable automatic retry of failed requests after token refresh
|
||||
* @default true
|
||||
*/
|
||||
autoRetry?: boolean;
|
||||
/** Additional headers merged into every request */
|
||||
headers?: Record<string, string>;
|
||||
/** Override default Content-Type (default: application/json) */
|
||||
contentType?: string;
|
||||
/** Override default Accept type (default: application/json) */
|
||||
accept?: string;
|
||||
/** Called before every request */
|
||||
beforeRequest?: () => Promise<void>;
|
||||
/** Called after every successful non-stream response */
|
||||
afterResponse?: (data: any) => void;
|
||||
}
|
||||
|
||||
// Mutex to prevent multiple simultaneous refresh attempts
|
||||
class RefreshMutex {
|
||||
private promise: Promise<boolean> | null = null;
|
||||
|
||||
async acquire(): Promise<boolean> {
|
||||
if (this.promise) {
|
||||
return this.promise;
|
||||
}
|
||||
|
||||
this.promise = this.performRefresh();
|
||||
const result = await this.promise;
|
||||
this.promise = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async performRefresh(): Promise<boolean> {
|
||||
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<void>;
|
||||
/** Per-call header overrides — highest priority, wins over factory-level headers */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
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<any> {
|
||||
if (options.beforeRequest) {
|
||||
await options.beforeRequest();
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
credentials: 'include';
|
||||
}
|
||||
|
||||
function request(method: string, options: FetchWrapperOptions, defaultAutoRetry: boolean) {
|
||||
return async (url: string, body?: object, callOptions?: RequestCallOptions): Promise<any> => {
|
||||
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<string, string> = {
|
||||
'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<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// 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<any> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user