Files
Stirling-PDF/frontend/shared/auth/spring/springAuthClient.ts
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

878 lines
27 KiB
TypeScript
Raw Normal View History

2025-10-24 10:49:52 +01:00
/**
2026-06-22 17:22:36 +01:00
* Spring Auth Client (shared engine)
2025-10-24 10:49:52 +01:00
*
2026-06-22 17:22:36 +01:00
* Integrates with the Spring Security + JWT backend.
2025-10-24 10:49:52 +01:00
* - Uses localStorage for JWT storage (sent via Authorization header)
* - JWT validation handled server-side
* - No email confirmation flow (auto-confirmed on registration)
2026-06-22 17:22:36 +01:00
*
* This is the platform-agnostic engine. The HTTP transport, base path and
* platform-specific behaviour are injected via `@shared/auth/config` so the
* same code backs the editor (which injects its apiClient + desktop bridge)
* and the portal (web defaults).
2025-10-24 10:49:52 +01:00
*/
2026-06-22 17:22:36 +01:00
import { AxiosError, type AxiosRequestConfig } from "axios";
import { getSpringAuthConfig } from "@shared/auth/config";
import { type OAuthProvider } from "@shared/auth/spring/oauthTypes";
import { resetOAuthState } from "@shared/auth/spring/oauthStorage";
import type {
AuthUser as User,
AuthSession as Session,
AuthError,
AuthResponse,
AuthChangeEvent,
} from "@shared/auth/types";
export type { User, Session, AuthError, AuthResponse, AuthChangeEvent };
/** Axios config plus the editor's custom request flags (ignored by the portal). */
type AuthRequestConfig = AxiosRequestConfig & {
suppressErrorToast?: boolean;
skipAuthRedirect?: boolean;
};
const http = () => getSpringAuthConfig().http;
const platform = () => getSpringAuthConfig().platform;
const basePath = () => getSpringAuthConfig().basePath;
2026-02-16 21:57:42 +00:00
function getHttpStatus(error: unknown): number | undefined {
if (error instanceof AxiosError) {
return error.response?.status;
}
if (error && typeof error === "object" && "response" in error) {
2026-02-16 21:57:42 +00:00
const response = (error as { response?: { status?: unknown } }).response;
if (response && typeof response.status === "number") {
2026-02-16 21:57:42 +00:00
return response.status;
}
}
return undefined;
}
// Helper to extract error message from axios error
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof AxiosError) {
2026-04-17 10:50:16 +01:00
return (
error.response?.data?.error ||
error.response?.data?.message ||
error.message ||
fallback
);
}
return error instanceof Error ? error.message : fallback;
}
const OAUTH_REDIRECT_COOKIE = "stirling_redirect_path";
2025-11-06 15:42:22 +00:00
const OAUTH_REDIRECT_COOKIE_MAX_AGE = 60 * 5; // 5 minutes
2026-06-22 17:22:36 +01:00
function defaultRedirectPath(): string {
return `${basePath() || ""}/auth/callback`;
}
2025-11-06 15:42:22 +00:00
2026-04-23 14:52:25 +01:00
export const POST_LOGIN_REDIRECT_STORAGE_KEY = "stirling_post_login_path";
2025-11-06 15:42:22 +00:00
function normalizeRedirectPath(target?: string): string {
if (!target || typeof target !== "string") {
2026-06-22 17:22:36 +01:00
return defaultRedirectPath();
2025-11-06 15:42:22 +00:00
}
try {
const parsed = new URL(target, window.location.origin);
const path = parsed.pathname || "/";
const query = parsed.search || "";
2025-11-06 15:42:22 +00:00
return `${path}${query}`;
} catch {
const trimmed = target.trim();
if (!trimmed) {
2026-06-22 17:22:36 +01:00
return defaultRedirectPath();
2025-11-06 15:42:22 +00:00
}
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
2025-11-06 15:42:22 +00:00
}
}
function persistRedirectPath(path: string): void {
try {
document.cookie = `${OAUTH_REDIRECT_COOKIE}=${encodeURIComponent(path)}; path=/; max-age=${OAUTH_REDIRECT_COOKIE_MAX_AGE}; SameSite=Lax`;
2025-11-24 14:15:02 +00:00
} catch (_error) {
// console.warn('[SpringAuth] Failed to persist OAuth redirect path', _error);
2025-11-06 15:42:22 +00:00
}
}
2026-04-23 14:52:25 +01:00
// Same-origin relative path, not pointing at auth plumbing. Rejects protocol-relative
// URLs to guard against open-redirect abuse if the stored value is tampered with.
export function isSafePostLoginRedirect(path: unknown): path is string {
if (typeof path !== "string" || path.length === 0) return false;
if (!path.startsWith("/") || path.startsWith("//")) return false;
if (path.startsWith("/\\")) return false;
const lowered = path.toLowerCase();
if (
lowered.startsWith("/login") ||
lowered.startsWith("/auth/") ||
lowered.startsWith("/oauth2") ||
lowered.startsWith("/saml2")
) {
return false;
}
return true;
}
export function setPostLoginRedirectPath(
path: string | null | undefined,
): void {
try {
if (typeof window === "undefined") return;
if (isSafePostLoginRedirect(path)) {
window.sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, path);
} else {
window.sessionStorage.removeItem(POST_LOGIN_REDIRECT_STORAGE_KEY);
}
} catch (_error) {
2026-06-22 17:22:36 +01:00
// sessionStorage unavailable (private mode): fail open
2026-04-23 14:52:25 +01:00
}
}
export function consumePostLoginRedirectPath(): string | null {
try {
if (typeof window === "undefined") return null;
const value = window.sessionStorage.getItem(
POST_LOGIN_REDIRECT_STORAGE_KEY,
);
window.sessionStorage.removeItem(POST_LOGIN_REDIRECT_STORAGE_KEY);
return isSafePostLoginRedirect(value) ? value : null;
} catch (_error) {
return null;
}
}
2026-04-17 10:50:16 +01:00
type AuthChangeCallback = (
event: AuthChangeEvent,
session: Session | null,
) => void;
2025-10-24 10:49:52 +01:00
class SpringAuthClient {
private listeners: AuthChangeCallback[] = [];
2026-06-22 17:22:36 +01:00
private sessionCheckInterval: ReturnType<typeof setInterval> | null = null;
2026-02-16 21:57:42 +00:00
// Adaptive intervals - calculated based on actual JWT token lifetime
// Defaults for initial startup (will be recalculated on first token)
private sessionCheckIntervalMs = 10000; // 10 seconds default
private tokenRefreshThresholdMs = 30000; // 30 seconds default
private readonly DESKTOP_SAAS_REFRESH_EARLY_SECONDS = 60;
2025-10-24 10:49:52 +01:00
constructor() {
// Start periodic session validation
this.startSessionMonitoring();
}
2026-02-16 21:57:42 +00:00
/**
* Calculate optimal check interval and refresh threshold based on token lifetime.
* - Check interval: token lifetime / 6 (check 6 times during token life)
* - Refresh threshold: token lifetime / 4 (refresh when 25% remaining)
* - Applies min/max bounds for sanity
*/
private calculateAdaptiveIntervals(token: string): void {
try {
const payload = this.decodeJwtPayload(token);
if (!payload) {
2026-04-17 10:50:16 +01:00
console.warn(
"[SpringAuth] Cannot decode token for adaptive intervals, using defaults",
);
2026-02-16 21:57:42 +00:00
return;
}
const expSeconds = typeof payload?.exp === "number" ? payload.exp : 0;
const iatSeconds = typeof payload?.iat === "number" ? payload.iat : 0;
2026-02-16 21:57:42 +00:00
if (expSeconds <= 0 || iatSeconds <= 0) {
2026-04-17 10:50:16 +01:00
console.warn(
"[SpringAuth] Token missing exp/iat claims, using default intervals",
);
2026-02-16 21:57:42 +00:00
return;
}
const tokenLifetimeMs = (expSeconds - iatSeconds) * 1000;
// Check interval: check 6 times during token lifetime
// Min: 5 seconds (for very short tokens)
// Max: 60 seconds (don't check too infrequently)
2026-04-17 10:50:16 +01:00
this.sessionCheckIntervalMs = Math.max(
5000,
Math.min(60000, tokenLifetimeMs / 6),
);
2026-02-16 21:57:42 +00:00
// Refresh threshold: refresh when 25% of lifetime remaining
// Min: 30 seconds (give buffer for refresh to complete)
// Max: 5 minutes (don't wait too long for long-lived tokens)
2026-04-17 10:50:16 +01:00
this.tokenRefreshThresholdMs = Math.max(
30000,
Math.min(300000, tokenLifetimeMs / 4),
);
2026-02-16 21:57:42 +00:00
console.log("[SpringAuth] 📊 Adaptive intervals calculated:", {
tokenLifetime: Math.floor(tokenLifetimeMs / 1000) + "s",
checkInterval: Math.floor(this.sessionCheckIntervalMs / 1000) + "s",
refreshThreshold: Math.floor(this.tokenRefreshThresholdMs / 1000) + "s",
2026-02-16 21:57:42 +00:00
});
// Restart monitoring with new interval
this.restartSessionMonitoring();
} catch (error) {
2026-04-17 10:50:16 +01:00
console.warn(
"[SpringAuth] Failed to calculate adaptive intervals:",
error,
);
2026-02-16 21:57:42 +00:00
}
}
private decodeJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split(".");
2026-02-16 21:57:42 +00:00
if (parts.length < 2) {
return null;
}
const base64Url = parts[1];
const base64 = base64Url
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(Math.ceil(base64Url.length / 4) * 4, "=");
2026-02-16 21:57:42 +00:00
return JSON.parse(atob(base64));
}
2026-04-17 10:50:16 +01:00
private getTokenExpiry(token: string): {
expiresIn: number;
expiresAt: number;
} {
2026-02-16 21:57:42 +00:00
try {
const payload = this.decodeJwtPayload(token);
if (!payload) {
throw new Error("Token payload missing");
2026-02-16 21:57:42 +00:00
}
const expSeconds = typeof payload?.exp === "number" ? payload.exp : 0;
2026-04-17 10:50:16 +01:00
const expiresAt =
expSeconds > 0 ? expSeconds * 1000 : Date.now() + 3600 * 1000;
const expiresIn = Math.max(
0,
Math.floor((expiresAt - Date.now()) / 1000),
);
2026-02-16 21:57:42 +00:00
return { expiresIn, expiresAt };
} catch {
// Fallback for non-JWT or malformed tokens.
const expiresAt = Date.now() + 3600 * 1000;
return { expiresIn: 3600, expiresAt };
}
}
2025-10-24 10:49:52 +01:00
/**
* Helper to get CSRF token from cookie
*/
private getCsrfToken(): string | null {
const cookies = document.cookie.split(";");
2025-10-24 10:49:52 +01:00
for (const cookie of cookies) {
const [name, value] = cookie.trim().split("=");
if (name === "XSRF-TOKEN") {
2025-11-17 12:05:03 +00:00
return decodeURIComponent(value);
2025-10-24 10:49:52 +01:00
}
}
return null;
}
/**
* Get current session
* JWT is stored in localStorage and sent via Authorization header
*/
2026-04-17 10:50:16 +01:00
async getSession(): Promise<{
data: { session: Session | null };
error: AuthError | null;
}> {
2025-10-24 10:49:52 +01:00
try {
// Get JWT from localStorage
let token = localStorage.getItem("stirling_jwt");
2025-10-24 10:49:52 +01:00
if (!token) {
2025-11-24 14:15:02 +00:00
// console.debug('[SpringAuth] getSession: No JWT in localStorage');
2025-10-24 10:49:52 +01:00
return { data: { session: null }, error: null };
}
2026-06-22 17:22:36 +01:00
if (await platform().isDesktopSaaSAuthMode()) {
2026-02-16 21:57:42 +00:00
let tokenExpiry = this.getTokenExpiry(token);
if (tokenExpiry.expiresIn <= this.DESKTOP_SAAS_REFRESH_EARLY_SECONDS) {
2026-06-22 17:22:36 +01:00
const refreshed = await platform().refreshPlatformSession();
2026-02-16 21:57:42 +00:00
if (!refreshed) {
localStorage.removeItem("stirling_jwt");
2026-02-16 21:57:42 +00:00
return { data: { session: null }, error: null };
}
const refreshedToken = localStorage.getItem("stirling_jwt");
2026-02-16 21:57:42 +00:00
if (!refreshedToken) {
localStorage.removeItem("stirling_jwt");
2026-02-16 21:57:42 +00:00
return { data: { session: null }, error: null };
}
token = refreshedToken;
tokenExpiry = this.getTokenExpiry(token);
}
if (tokenExpiry.expiresIn <= 0) {
localStorage.removeItem("stirling_jwt");
2026-02-16 21:57:42 +00:00
return { data: { session: null }, error: null };
}
2026-06-22 17:22:36 +01:00
const platformUser = await platform().getPlatformSessionUser();
2026-02-16 21:57:42 +00:00
const session: Session = {
user: {
2026-04-17 10:50:16 +01:00
id:
platformUser?.email ||
platformUser?.username ||
"desktop-saas-user",
2026-05-29 15:35:47 +01:00
email: platformUser?.email ?? "",
// Username may be empty when the platform layer can't identify
// the user - downstream displayName derivation handles that
// case and falls back to a generic placeholder.
username: platformUser?.username ?? "",
role: "USER",
2026-05-29 15:35:47 +01:00
is_anonymous: platformUser?.is_anonymous,
2026-02-16 21:57:42 +00:00
},
access_token: token,
expires_in: tokenExpiry.expiresIn,
expires_at: tokenExpiry.expiresAt,
};
return { data: { session }, error: null };
}
2025-10-24 10:49:52 +01:00
// Verify with backend
// Note: We pass the token explicitly here, overriding the interceptor's default
2025-11-24 14:15:02 +00:00
// console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
2026-06-22 17:22:36 +01:00
const meConfig: AuthRequestConfig = {
2025-10-24 10:49:52 +01:00
headers: {
Authorization: `Bearer ${token}`,
2025-10-24 10:49:52 +01:00
},
2025-11-20 20:51:53 +00:00
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
2026-02-16 21:57:42 +00:00
// Session bootstrap should not trigger global 401 refresh/redirect loops.
skipAuthRedirect: true,
2026-06-22 17:22:36 +01:00
};
const response = await http().get("/api/v1/auth/me", meConfig);
2025-10-24 10:49:52 +01:00
2025-11-24 14:15:02 +00:00
// console.debug('[SpringAuth] /me response status:', response.status);
const data = response.data;
2025-11-24 14:15:02 +00:00
// console.debug('[SpringAuth] /me response data:', data);
2025-10-24 10:49:52 +01:00
// Create session object
2026-02-16 21:57:42 +00:00
const tokenExpiry = this.getTokenExpiry(token);
2025-10-24 10:49:52 +01:00
const session: Session = {
user: data.user,
access_token: token,
2026-02-16 21:57:42 +00:00
expires_in: tokenExpiry.expiresIn,
expires_at: tokenExpiry.expiresAt,
2025-10-24 10:49:52 +01:00
};
2025-11-24 14:15:02 +00:00
// console.debug('[SpringAuth] getSession: Session retrieved successfully');
2025-10-24 10:49:52 +01:00
return { data: { session }, error: null };
} catch (error: unknown) {
// 401/403 during getSession is the normal "token expired or invalid"
// path - handled via refresh + JWT clear.
2026-02-16 21:57:42 +00:00
const status = getHttpStatus(error);
if (status === 401 || status === 403) {
const refreshResult = await this.refreshSession();
if (!refreshResult.error && refreshResult.data.session) {
return refreshResult;
}
localStorage.removeItem("stirling_jwt");
return { data: { session: null }, error: null };
}
console.error("[SpringAuth] getSession error:", error);
// Don't clear token for other errors (e.g., backend not ready, network issues)
// The token is still valid, just can't verify it right now
2025-10-24 10:49:52 +01:00
return {
data: { session: null },
error: { message: getErrorMessage(error, "Unknown error") },
2025-10-24 10:49:52 +01:00
};
}
}
/**
* Sign in with email and password
*/
2026-04-17 10:50:16 +01:00
async signInWithPassword(credentials: {
email: string;
password: string;
mfaCode?: string;
}): Promise<AuthResponse> {
2025-10-24 10:49:52 +01:00
try {
2026-06-22 17:22:36 +01:00
const response = await http().post(
"/api/v1/auth/login",
{
username: credentials.email,
password: credentials.password,
mfaCode: credentials.mfaCode,
},
{
withCredentials: true, // Include cookies for CSRF
},
);
2025-10-24 10:49:52 +01:00
const data = response.data;
2025-10-24 10:49:52 +01:00
const token = data.session.access_token;
// Store JWT in localStorage
localStorage.setItem("stirling_jwt", token);
2025-11-24 14:15:02 +00:00
// console.log('[SpringAuth] JWT stored in localStorage');
2025-10-24 10:49:52 +01:00
2026-02-16 21:57:42 +00:00
// Sync token to platform-specific storage (Tauri store for desktop)
2026-06-22 17:22:36 +01:00
await platform().savePlatformToken(token);
2026-02-16 21:57:42 +00:00
// Calculate adaptive monitoring intervals based on token lifetime
this.calculateAdaptiveIntervals(token);
2025-11-06 15:42:22 +00:00
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent("jwt-available"));
2025-11-06 15:42:22 +00:00
2025-10-24 10:49:52 +01:00
const session: Session = {
user: data.user,
access_token: token,
expires_in: data.session.expires_in,
expires_at: Date.now() + data.session.expires_in * 1000,
};
// Notify listeners
this.notifyListeners("SIGNED_IN", session);
2025-10-24 10:49:52 +01:00
return { user: data.user, session, error: null };
} catch (error: unknown) {
console.error("[SpringAuth] signInWithPassword error:", error);
if (error instanceof AxiosError) {
const errorCode = error.response?.data?.error as string | undefined;
2026-04-17 10:50:16 +01:00
const errorMessage =
error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
"Login failed";
return {
user: null,
session: null,
error: {
message: errorMessage,
status: error.response?.status,
code: errorCode,
mfaRequired: errorCode === "mfa_required",
},
};
}
2025-10-24 10:49:52 +01:00
return {
user: null,
session: null,
error: { message: getErrorMessage(error, "Login failed") },
2025-10-24 10:49:52 +01:00
};
}
}
/**
* Sign up new user
*/
async signUp(credentials: {
email: string;
password: string;
options?: { data?: { full_name?: string }; emailRedirectTo?: string };
}): Promise<AuthResponse> {
try {
2026-06-22 17:22:36 +01:00
const response = await http().post(
"/api/v1/user/register",
{
username: credentials.email,
password: credentials.password,
},
{
withCredentials: true,
},
);
2025-10-24 10:49:52 +01:00
const data = response.data;
2025-10-24 10:49:52 +01:00
// Note: Spring backend auto-confirms users (no email verification)
// Return user but no session (user needs to login)
return { user: data.user, session: null, error: null };
} catch (error: unknown) {
console.error("[SpringAuth] signUp error:", error);
2025-10-24 10:49:52 +01:00
return {
user: null,
session: null,
error: { message: getErrorMessage(error, "Registration failed") },
2025-10-24 10:49:52 +01:00
};
}
}
/**
2025-12-15 23:54:25 +00:00
* Sign in with OAuth/SAML provider (GitHub, Google, Authentik, etc.)
* This redirects to the Spring OAuth2/SAML2 authorization endpoint
2025-12-05 23:19:41 +00:00
*
2025-12-15 23:54:25 +00:00
* @param params.provider - Full auth path from backend (e.g., '/oauth2/authorization/google', '/saml2/authenticate/stirling')
* The backend provides the complete path including the auth type and provider ID
2025-10-24 10:49:52 +01:00
*/
async signInWithOAuth(params: {
2025-12-05 23:19:41 +00:00
provider: OAuthProvider;
2026-04-01 09:21:26 +01:00
options?: { redirectTo?: string; queryParams?: Record<string, string> };
2025-10-24 10:49:52 +01:00
}): Promise<{ error: AuthError | null }> {
try {
2025-11-06 15:42:22 +00:00
const redirectPath = normalizeRedirectPath(params.options?.redirectTo);
persistRedirectPath(redirectPath);
2025-12-15 23:54:25 +00:00
// Use the full path provided by the backend
// This supports both OAuth2 (/oauth2/authorization/...) and SAML2 (/saml2/authenticate/...)
const redirectUrl = params.provider;
2026-06-22 17:22:36 +01:00
const handled = await platform().startOAuthNavigation(redirectUrl);
2026-01-09 18:21:16 +00:00
if (handled) {
return { error: null };
}
2025-12-15 23:54:25 +00:00
// console.log('[SpringAuth] Redirecting to SSO:', redirectUrl);
2025-10-24 10:49:52 +01:00
// Use window.location.assign for full page navigation
window.location.assign(redirectUrl);
return { error: null };
} catch (error) {
return {
2026-04-17 10:50:16 +01:00
error: {
message:
error instanceof Error ? error.message : "SSO redirect failed",
},
2025-10-24 10:49:52 +01:00
};
}
}
/**
* Sign out user (invalidate session)
2025-10-24 10:49:52 +01:00
*/
async signOut(): Promise<{ error: AuthError | null }> {
try {
if (typeof window !== "undefined") {
2026-04-17 10:50:16 +01:00
window.sessionStorage.setItem(
"stirling_sso_auto_login_logged_out",
"1",
);
2026-02-05 12:26:41 +00:00
}
2025-10-24 10:49:52 +01:00
2026-05-29 15:35:47 +01:00
// Only call the backend logout endpoint when the platform tells us
// the current backend implements it. In desktop SaaS mode the
// apiClient points at the SaaS gateway, which doesn't expose
// `/api/v1/auth/logout` (Supabase manages session lifecycle); POSTing
// there returns 500 and pollutes error toasts even though the local
// cleanup below succeeds.
2026-06-22 17:22:36 +01:00
if (await platform().shouldCallBackendLogout()) {
const response = await http().post("/api/v1/auth/logout", null, {
2026-05-29 15:35:47 +01:00
headers: {
"X-XSRF-TOKEN": this.getCsrfToken() || "",
},
withCredentials: true,
});
if (response.status === 200) {
// console.debug('[SpringAuth] signOut: Success');
}
2025-10-24 10:49:52 +01:00
}
// Clean up local storage
localStorage.removeItem("stirling_jwt");
2026-01-09 18:21:16 +00:00
try {
Object.keys(localStorage)
.filter((key) => key.startsWith("sb-") || key.includes("supabase"))
2026-01-09 18:21:16 +00:00
.forEach((key) => localStorage.removeItem(key));
// Clear any cached OAuth redirect/session state
resetOAuthState();
} catch (err) {
2026-04-17 10:50:16 +01:00
console.warn(
"[SpringAuth] Failed to clear Supabase/local auth tokens",
err,
);
2026-01-09 18:21:16 +00:00
}
// Clear cookies that might hold refresh/session tokens
try {
document.cookie.split(";").forEach((cookie) => {
const eqPos = cookie.indexOf("=");
2026-04-17 10:50:16 +01:00
const name =
eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
2026-01-09 18:21:16 +00:00
if (name) {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;`;
}
});
} catch (err) {
console.warn("[SpringAuth] Failed to clear cookies on sign out", err);
2026-01-09 18:21:16 +00:00
}
try {
2026-06-22 17:22:36 +01:00
await platform().clearPlatformAuthAfterSignOut();
2026-01-09 18:21:16 +00:00
} catch (cleanupError) {
2026-04-17 10:50:16 +01:00
console.warn(
"[SpringAuth] Failed to run platform auth cleanup",
cleanupError,
);
2026-01-09 18:21:16 +00:00
}
2025-10-24 10:49:52 +01:00
// Notify listeners
this.notifyListeners("SIGNED_OUT", null);
2025-10-24 10:49:52 +01:00
return { error: null };
} catch (error: unknown) {
console.error("[SpringAuth] signOut error:", error);
2025-10-24 10:49:52 +01:00
// Still remove token even if backend call fails
localStorage.removeItem("stirling_jwt");
2026-01-09 18:21:16 +00:00
try {
2026-06-22 17:22:36 +01:00
await platform().clearPlatformAuthAfterSignOut();
2026-01-09 18:21:16 +00:00
} catch (cleanupError) {
2026-04-17 10:50:16 +01:00
console.warn(
"[SpringAuth] Failed to run platform auth cleanup after error",
cleanupError,
);
2026-01-09 18:21:16 +00:00
}
2026-05-29 15:35:47 +01:00
// The user is logged out *locally* even if the backend call failed
// (token + platform user_info are gone). The previous version skipped
// this notification on error - the AuthProvider then never cleared
// its session state, leaving the UI claiming the user was still signed
// in until a full reload.
this.notifyListeners("SIGNED_OUT", null);
2025-10-24 10:49:52 +01:00
return {
error: { message: getErrorMessage(error, "Logout failed") },
2025-10-24 10:49:52 +01:00
};
}
}
/**
* Refresh JWT token
2025-10-24 10:49:52 +01:00
*/
2026-04-17 10:50:16 +01:00
async refreshSession(): Promise<{
data: { session: Session | null };
error: AuthError | null;
}> {
2025-10-24 10:49:52 +01:00
try {
2026-06-22 17:22:36 +01:00
if (await platform().isDesktopSaaSAuthMode()) {
const refreshed = await platform().refreshPlatformSession();
2026-02-16 21:57:42 +00:00
if (!refreshed) {
localStorage.removeItem("stirling_jwt");
2026-02-16 21:57:42 +00:00
return {
data: { session: null },
error: { message: "Token refresh failed - please log in again" },
2026-02-16 21:57:42 +00:00
};
}
const { data, error } = await this.getSession();
if (error || !data.session) {
return {
data: { session: null },
2026-04-17 10:50:16 +01:00
error: error || {
message: "Token refresh failed - please log in again",
},
2026-02-16 21:57:42 +00:00
};
}
// Calculate adaptive intervals for desktop SaaS mode
const token = localStorage.getItem("stirling_jwt");
2026-02-16 21:57:42 +00:00
if (token) {
this.calculateAdaptiveIntervals(token);
}
this.notifyListeners("TOKEN_REFRESHED", data.session);
2026-02-16 21:57:42 +00:00
return { data, error: null };
}
2026-06-22 17:22:36 +01:00
const refreshConfig: AuthRequestConfig = {
2025-10-24 10:49:52 +01:00
headers: {
"X-XSRF-TOKEN": this.getCsrfToken() || "",
2025-10-24 10:49:52 +01:00
},
withCredentials: true,
2025-11-20 20:51:53 +00:00
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
2026-06-22 17:22:36 +01:00
};
const response = await http().post(
"/api/v1/auth/refresh",
null,
refreshConfig,
);
2025-10-24 10:49:52 +01:00
const data = response.data;
const token = data.session.access_token;
2025-10-24 10:49:52 +01:00
// Update local storage with new token
localStorage.setItem("stirling_jwt", token);
2025-10-24 10:49:52 +01:00
2026-02-16 21:57:42 +00:00
// Sync token to platform-specific storage (Tauri store for desktop)
2026-06-22 17:22:36 +01:00
await platform().savePlatformToken(token);
2026-02-16 21:57:42 +00:00
// Calculate adaptive monitoring intervals based on token lifetime
this.calculateAdaptiveIntervals(token);
2025-11-06 15:42:22 +00:00
2025-10-24 10:49:52 +01:00
const session: Session = {
user: data.user,
access_token: token,
expires_in: data.session.expires_in,
expires_at: Date.now() + data.session.expires_in * 1000,
2025-10-24 10:49:52 +01:00
};
// Notify listeners
this.notifyListeners("TOKEN_REFRESHED", session);
2025-10-24 10:49:52 +01:00
console.debug("[SpringAuth] Token refreshed successfully");
2025-10-24 10:49:52 +01:00
return { data: { session }, error: null };
} catch (error: unknown) {
localStorage.removeItem("stirling_jwt");
// 401/403 means the refresh token is no longer valid - normal expired
// state, not an error worth surfacing. Other statuses (network, backend
// down) ARE worth logging.
2026-02-16 21:57:42 +00:00
const status = getHttpStatus(error);
if (status === 401 || status === 403) {
2026-04-17 10:50:16 +01:00
return {
data: { session: null },
error: { message: "Token refresh failed - please log in again" },
};
}
console.error("[SpringAuth] refreshSession error:", error);
2025-10-24 10:49:52 +01:00
return {
data: { session: null },
error: { message: getErrorMessage(error, "Token refresh failed") },
2025-10-24 10:49:52 +01:00
};
}
}
/**
* Listen to auth state changes
*/
2026-04-17 10:50:16 +01:00
onAuthStateChange(callback: AuthChangeCallback): {
data: { subscription: { unsubscribe: () => void } };
} {
2025-10-24 10:49:52 +01:00
this.listeners.push(callback);
return {
data: {
subscription: {
unsubscribe: () => {
this.listeners = this.listeners.filter((cb) => cb !== callback);
},
},
},
};
}
// Private helper methods
private notifyListeners(event: AuthChangeEvent, session: Session | null) {
// Use setTimeout to avoid calling callbacks synchronously
setTimeout(() => {
this.listeners.forEach((callback) => {
try {
callback(event, session);
} catch (error) {
2026-04-17 10:50:16 +01:00
console.error(
"[SpringAuth] Error in auth state change listener:",
error,
);
2025-10-24 10:49:52 +01:00
}
});
}, 0);
}
private startSessionMonitoring() {
// Periodically check session validity
2026-02-16 21:57:42 +00:00
// Interval is adaptive based on token lifetime (calculated when token is received)
2025-10-24 10:49:52 +01:00
this.sessionCheckInterval = setInterval(async () => {
try {
// Try to get current session
const { data } = await this.getSession();
// If we have a session, proactively refresh if needed
if (data.session) {
const timeUntilExpiry = (data.session.expires_at || 0) - Date.now();
2026-02-16 21:57:42 +00:00
// Refresh if token expires soon (threshold is adaptive)
2026-04-17 10:50:16 +01:00
if (
timeUntilExpiry > 0 &&
timeUntilExpiry < this.tokenRefreshThresholdMs
) {
console.log(
2026-04-17 10:50:16 +01:00
"[SpringAuth] 🔄 Proactively refreshing token (expires in " +
Math.floor(timeUntilExpiry / 1000) +
"s)",
);
2025-10-24 10:49:52 +01:00
await this.refreshSession();
}
}
} catch (error) {
console.error("[SpringAuth] Session monitoring error:", error);
2025-10-24 10:49:52 +01:00
}
2026-02-16 21:57:42 +00:00
}, this.sessionCheckIntervalMs);
}
private restartSessionMonitoring() {
// Stop existing interval
if (this.sessionCheckInterval) {
clearInterval(this.sessionCheckInterval);
this.sessionCheckInterval = null;
}
// Start with new interval
this.startSessionMonitoring();
2025-10-24 10:49:52 +01:00
}
public destroy() {
if (this.sessionCheckInterval) {
clearInterval(this.sessionCheckInterval);
}
}
}
export const springAuth = new SpringAuthClient();
/**
* Get current user
*/
export const getCurrentUser = async () => {
const { data } = await springAuth.getSession();
return data.session?.user || null;
};
/**
* Check if user is anonymous
*/
export const isUserAnonymous = (user: User | null) => {
return user?.is_anonymous === true;
};
/**
* Create an anonymous user object for use when login is disabled
* This provides a consistent User interface throughout the app
*/
export const createAnonymousUser = (): User => {
return {
id: "anonymous",
email: "anonymous@local",
username: "Anonymous User",
role: "USER",
2025-10-24 10:49:52 +01:00
enabled: true,
is_anonymous: true,
app_metadata: {
provider: "anonymous",
2025-10-24 10:49:52 +01:00
},
};
};
/**
* Create an anonymous session for use when login is disabled
*/
export const createAnonymousSession = (): Session => {
return {
user: createAnonymousUser(),
access_token: "",
2025-10-24 10:49:52 +01:00
expires_in: Number.MAX_SAFE_INTEGER,
expires_at: Number.MAX_SAFE_INTEGER,
};
};
// Export auth client as default for convenience
export default springAuth;