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;
|
2025-11-10 12:15:39 +00:00
|
|
|
|
2026-02-16 21:57:42 +00:00
|
|
|
function getHttpStatus(error: unknown): number | undefined {
|
|
|
|
|
if (error instanceof AxiosError) {
|
|
|
|
|
return error.response?.status;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
if (error && typeof error === "object" && "response" in error) {
|
2026-02-16 21:57:42 +00:00
|
|
|
const response = (error as { response?: { status?: unknown } }).response;
|
2026-04-10 17:41:19 +01:00
|
|
|
if (response && typeof response.status === "number") {
|
2026-02-16 21:57:42 +00:00
|
|
|
return response.status;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-10 12:15:39 +00:00
|
|
|
// 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
|
|
|
|
|
);
|
2025-11-10 12:15:39 +00:00
|
|
|
}
|
|
|
|
|
return error instanceof Error ? error.message : fallback;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
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 {
|
2026-04-10 17:41:19 +01:00
|
|
|
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);
|
2026-04-10 17:41:19 +01:00
|
|
|
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
|
|
|
}
|
2026-04-10 17:41:19 +01: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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
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
|
|
|
|
2026-04-10 17:41:19 +01: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 {
|
2026-04-10 17:41:19 +01:00
|
|
|
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
|
2026-04-10 17:41:19 +01:00
|
|
|
.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) {
|
2026-04-10 17:41:19 +01:00
|
|
|
throw new Error("Token payload missing");
|
2026-02-16 21:57:42 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-10 17:41:19 +01: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 {
|
2026-04-10 17:41:19 +01:00
|
|
|
const cookies = document.cookie.split(";");
|
2025-10-24 10:49:52 +01:00
|
|
|
for (const cookie of cookies) {
|
2026-04-10 17:41:19 +01:00
|
|
|
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
|
2026-04-10 17:41:19 +01:00
|
|
|
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) {
|
2026-04-10 17:41:19 +01:00
|
|
|
localStorage.removeItem("stirling_jwt");
|
2026-02-16 21:57:42 +00:00
|
|
|
return { data: { session: null }, error: null };
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
const refreshedToken = localStorage.getItem("stirling_jwt");
|
2026-02-16 21:57:42 +00:00
|
|
|
if (!refreshedToken) {
|
2026-04-10 17:41:19 +01:00
|
|
|
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) {
|
2026-04-10 17:41:19 +01:00
|
|
|
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 ?? "",
|
2026-04-10 17:41:19 +01:00
|
|
|
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
|
2025-11-10 12:15:39 +00:00
|
|
|
// 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: {
|
2026-04-10 17:41:19 +01:00
|
|
|
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);
|
2025-11-10 12:15:39 +00:00
|
|
|
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 };
|
2025-11-10 12:15:39 +00:00
|
|
|
} catch (error: unknown) {
|
2026-06-09 09:34:02 +01:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-04-10 17:41:19 +01:00
|
|
|
localStorage.removeItem("stirling_jwt");
|
2025-11-10 12:15:39 +00:00
|
|
|
return { data: { session: null }, error: null };
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 09:34:02 +01:00
|
|
|
console.error("[SpringAuth] getSession error:", error);
|
2025-11-26 13:51:12 +00:00
|
|
|
// 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 },
|
2026-04-10 17:41:19 +01:00
|
|
|
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(
|
2026-04-10 17:41:19 +01:00
|
|
|
"/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
|
|
|
|
2025-11-10 12:15:39 +00:00
|
|
|
const data = response.data;
|
2025-10-24 10:49:52 +01:00
|
|
|
const token = data.session.access_token;
|
|
|
|
|
|
|
|
|
|
// Store JWT in localStorage
|
2026-04-10 17:41:19 +01:00
|
|
|
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
|
2026-04-10 17:41:19 +01:00
|
|
|
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
|
2026-04-10 17:41:19 +01:00
|
|
|
this.notifyListeners("SIGNED_IN", session);
|
2025-10-24 10:49:52 +01:00
|
|
|
|
|
|
|
|
return { user: data.user, session, error: null };
|
2025-11-10 12:15:39 +00:00
|
|
|
} catch (error: unknown) {
|
2026-04-10 17:41:19 +01:00
|
|
|
console.error("[SpringAuth] signInWithPassword error:", error);
|
2026-01-23 22:34:57 +01:00
|
|
|
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";
|
2026-01-23 22:34:57 +01:00
|
|
|
return {
|
|
|
|
|
user: null,
|
|
|
|
|
session: null,
|
|
|
|
|
error: {
|
|
|
|
|
message: errorMessage,
|
|
|
|
|
status: error.response?.status,
|
|
|
|
|
code: errorCode,
|
2026-04-10 17:41:19 +01:00
|
|
|
mfaRequired: errorCode === "mfa_required",
|
2026-01-23 22:34:57 +01:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
2025-10-24 10:49:52 +01:00
|
|
|
return {
|
|
|
|
|
user: null,
|
|
|
|
|
session: null,
|
2026-04-10 17:41:19 +01:00
|
|
|
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(
|
2026-04-10 17:41:19 +01:00
|
|
|
"/api/v1/user/register",
|
|
|
|
|
{
|
|
|
|
|
username: credentials.email,
|
|
|
|
|
password: credentials.password,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
withCredentials: true,
|
|
|
|
|
},
|
|
|
|
|
);
|
2025-10-24 10:49:52 +01:00
|
|
|
|
2025-11-10 12:15:39 +00: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 };
|
2025-11-10 12:15:39 +00:00
|
|
|
} catch (error: unknown) {
|
2026-04-10 17:41:19 +01:00
|
|
|
console.error("[SpringAuth] signUp error:", error);
|
2025-10-24 10:49:52 +01:00
|
|
|
return {
|
|
|
|
|
user: null,
|
|
|
|
|
session: null,
|
2026-04-10 17:41:19 +01:00
|
|
|
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
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-11-10 12:15:39 +00:00
|
|
|
* Sign out user (invalidate session)
|
2025-10-24 10:49:52 +01:00
|
|
|
*/
|
|
|
|
|
async signOut(): Promise<{ error: AuthError | null }> {
|
|
|
|
|
try {
|
2026-04-10 17:41:19 +01:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2025-11-10 12:15:39 +00:00
|
|
|
// Clean up local storage
|
2026-04-10 17:41:19 +01:00
|
|
|
localStorage.removeItem("stirling_jwt");
|
2026-01-09 18:21:16 +00:00
|
|
|
try {
|
|
|
|
|
Object.keys(localStorage)
|
2026-04-10 17:41:19 +01:00
|
|
|
.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 {
|
2026-04-10 17:41:19 +01:00
|
|
|
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) {
|
2026-04-10 17:41:19 +01:00
|
|
|
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
|
2026-04-10 17:41:19 +01:00
|
|
|
this.notifyListeners("SIGNED_OUT", null);
|
2025-10-24 10:49:52 +01:00
|
|
|
|
|
|
|
|
return { error: null };
|
2025-11-10 12:15:39 +00:00
|
|
|
} catch (error: unknown) {
|
2026-04-10 17:41:19 +01:00
|
|
|
console.error("[SpringAuth] signOut error:", error);
|
2025-10-24 10:49:52 +01:00
|
|
|
// Still remove token even if backend call fails
|
2026-04-10 17:41:19 +01:00
|
|
|
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 {
|
2026-04-10 17:41:19 +01:00
|
|
|
error: { message: getErrorMessage(error, "Logout failed") },
|
2025-10-24 10:49:52 +01:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-11-10 12:15:39 +00: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) {
|
2026-04-10 17:41:19 +01:00
|
|
|
localStorage.removeItem("stirling_jwt");
|
2026-02-16 21:57:42 +00:00
|
|
|
return {
|
|
|
|
|
data: { session: null },
|
2026-04-10 17:41:19 +01:00
|
|
|
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
|
2026-04-10 17:41:19 +01:00
|
|
|
const token = localStorage.getItem("stirling_jwt");
|
2026-02-16 21:57:42 +00:00
|
|
|
if (token) {
|
|
|
|
|
this.calculateAdaptiveIntervals(token);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
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: {
|
2026-04-10 17:41:19 +01:00
|
|
|
"X-XSRF-TOKEN": this.getCsrfToken() || "",
|
2025-10-24 10:49:52 +01:00
|
|
|
},
|
2025-11-10 12:15:39 +00: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
|
|
|
|
2025-11-10 12:15:39 +00:00
|
|
|
const data = response.data;
|
|
|
|
|
const token = data.session.access_token;
|
2025-10-24 10:49:52 +01:00
|
|
|
|
2025-11-10 12:15:39 +00:00
|
|
|
// Update local storage with new token
|
2026-04-10 17:41:19 +01:00
|
|
|
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 = {
|
2025-11-10 12:15:39 +00:00
|
|
|
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
|
2026-04-10 17:41:19 +01:00
|
|
|
this.notifyListeners("TOKEN_REFRESHED", session);
|
2025-10-24 10:49:52 +01:00
|
|
|
|
2026-04-10 17:41:19 +01:00
|
|
|
console.debug("[SpringAuth] Token refreshed successfully");
|
2026-01-31 20:42:41 +01:00
|
|
|
|
2025-10-24 10:49:52 +01:00
|
|
|
return { data: { session }, error: null };
|
2025-11-10 12:15:39 +00:00
|
|
|
} catch (error: unknown) {
|
2026-04-10 17:41:19 +01:00
|
|
|
localStorage.removeItem("stirling_jwt");
|
2025-11-10 12:15:39 +00:00
|
|
|
|
2026-06-09 09:34:02 +01:00
|
|
|
// 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" },
|
|
|
|
|
};
|
2025-11-10 12:15:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 09:34:02 +01:00
|
|
|
console.error("[SpringAuth] refreshSession error:", error);
|
2025-10-24 10:49:52 +01:00
|
|
|
return {
|
|
|
|
|
data: { session: null },
|
2026-04-10 17:41:19 +01:00
|
|
|
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
|
|
|
|
|
) {
|
2026-04-10 17:41:19 +01:00
|
|
|
console.log(
|
2026-04-17 10:50:16 +01:00
|
|
|
"[SpringAuth] 🔄 Proactively refreshing token (expires in " +
|
|
|
|
|
Math.floor(timeUntilExpiry / 1000) +
|
|
|
|
|
"s)",
|
2026-04-10 17:41:19 +01:00
|
|
|
);
|
2025-10-24 10:49:52 +01:00
|
|
|
await this.refreshSession();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
2026-04-10 17:41:19 +01:00
|
|
|
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 {
|
2026-04-10 17:41:19 +01:00
|
|
|
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: {
|
2026-04-10 17:41:19 +01:00
|
|
|
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(),
|
2026-04-10 17:41:19 +01:00
|
|
|
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;
|