feat: improve authentication
All checks were successful
Build Test / build (pull_request) Successful in 43s
JS Unit Tests / test (pull_request) Successful in 41s
PHP Unit Tests / test (pull_request) Successful in 49s

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-02-19 23:03:09 -05:00
parent decda8becc
commit 99fa707eb3
7 changed files with 194 additions and 175 deletions

View File

@@ -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<boolean> | 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<boolean> {
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<void> {
if (isTokenExpiredOrExpiring()) {
await refreshToken();
}
}
export interface SessionMonitorOptions {
onLogout: () => void | Promise<void>;
}
export function createSessionMonitor(options: SessionMonitorOptions) {
let intervalId: ReturnType<typeof setInterval> | null = null;
async function ping(): Promise<void> {
// 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 };
}

View File

@@ -6,7 +6,7 @@ export const authenticationService = {
* Initialize authentication - get session and available methods
*/
async start(): Promise<StartResponse> {
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<SessionStatus> {
return fetchWrapper.get(`/auth/status?session=${encodeURIComponent(session)}`, undefined, { skipLogoutOnError: true });
return fetchWrapper.get(`/auth/status?session=${encodeURIComponent(session)}`);
},
/**