Files
Stirling-PDF/frontend/src/proprietary/auth/springAuthClient.ts
T
James BruntonandGitHub a3e45bc182 Add frontend autoformatting and set CI to require formatted code for all languages (#6052)
# Description of Changes
Changes the strategy for autoformatting to reject PRs if they are not
formatted correctly instead of allowing them to merge and then spawning
a new PR to fix the formatting. The old strategy just caused more work
for us because we'd have to manually approve the followup PR and get it
merged, which required 2 reviewers so in practice it rarely got done and
just meant everyone's PRs ended up containing reformatting for unrelated
files, which makes code review unnecessarily difficult. If the PR's code
is not formatted correctly after this PR, a comment will be added
automatically to tell the author how to run the formatter script to fix
their code so it can go in.

This also enables autoformatting for the frontend code, using Prettier.
I've enabled it for pretty much everything in the frontend folder, other
than 3rd party files and files it doesn't make sense for. I also
excluded Markdown because it sounds likely to be more annoying to have
to autoformat the Markdown in the frontend folder but nowhere else. Open
to changing this though if people disagree.

> [!note]
> 
> Advice to reviewers: The first commit contains all of the actual logic
I've introduced (CI changes, Prettier config, etc.)
> The second commit is just the reformatting of the entire frontend
folder.
> The first commit needs proper review, the second one just give it a
spot-check that it's doing what you'd expect.
2026-04-10 17:41:19 +01:00

747 lines
24 KiB
TypeScript

/**
* Spring Auth Client
*
* This client integrates with the Spring Security + JWT backend.
* - Uses localStorage for JWT storage (sent via Authorization header)
* - JWT validation handled server-side
* - No email confirmation flow (auto-confirmed on registration)
*/
import apiClient from "@app/services/apiClient";
import { AxiosError } from "axios";
import { BASE_PATH } from "@app/constants/app";
import { type OAuthProvider } from "@app/auth/oauthTypes";
import { resetOAuthState } from "@app/auth/oauthStorage";
import { clearPlatformAuthAfterSignOut } from "@app/extensions/authSessionCleanup";
import {
getPlatformSessionUser,
isDesktopSaaSAuthMode,
refreshPlatformSession,
savePlatformToken,
} from "@app/extensions/platformSessionBridge";
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
function getHttpStatus(error: unknown): number | undefined {
if (error instanceof AxiosError) {
return error.response?.status;
}
if (error && typeof error === "object" && "response" in error) {
const response = (error as { response?: { status?: unknown } }).response;
if (response && typeof response.status === "number") {
return response.status;
}
}
return undefined;
}
// Helper to extract error message from axios error
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof AxiosError) {
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";
const OAUTH_REDIRECT_COOKIE_MAX_AGE = 60 * 5; // 5 minutes
const DEFAULT_REDIRECT_PATH = `${BASE_PATH || ""}/auth/callback`;
function normalizeRedirectPath(target?: string): string {
if (!target || typeof target !== "string") {
return DEFAULT_REDIRECT_PATH;
}
try {
const parsed = new URL(target, window.location.origin);
const path = parsed.pathname || "/";
const query = parsed.search || "";
return `${path}${query}`;
} catch {
const trimmed = target.trim();
if (!trimmed) {
return DEFAULT_REDIRECT_PATH;
}
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
}
function persistRedirectPath(path: string): void {
try {
document.cookie = `${OAUTH_REDIRECT_COOKIE}=${encodeURIComponent(path)}; path=/; max-age=${OAUTH_REDIRECT_COOKIE_MAX_AGE}; SameSite=Lax`;
} catch (_error) {
// console.warn('[SpringAuth] Failed to persist OAuth redirect path', _error);
}
}
// Auth types
export interface User {
id: string;
email: string;
username: string;
role: string;
enabled?: boolean;
is_anonymous?: boolean;
isFirstLogin?: boolean;
authenticationType?: string;
app_metadata?: Record<string, unknown>;
}
export interface Session {
user: User;
access_token: string;
expires_in: number;
expires_at?: number;
}
export interface AuthError {
message: string;
status?: number;
code?: string;
mfaRequired?: boolean;
}
export interface AuthResponse {
user: User | null;
session: Session | null;
error: AuthError | null;
}
export type AuthChangeEvent = "SIGNED_IN" | "SIGNED_OUT" | "TOKEN_REFRESHED" | "USER_UPDATED";
type AuthChangeCallback = (event: AuthChangeEvent, session: Session | null) => void;
class SpringAuthClient {
private listeners: AuthChangeCallback[] = [];
private sessionCheckInterval: NodeJS.Timeout | null = null;
// 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;
constructor() {
// Start periodic session validation
this.startSessionMonitoring();
}
/**
* 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) {
console.warn("[SpringAuth] Cannot decode token for adaptive intervals, using defaults");
return;
}
const expSeconds = typeof payload?.exp === "number" ? payload.exp : 0;
const iatSeconds = typeof payload?.iat === "number" ? payload.iat : 0;
if (expSeconds <= 0 || iatSeconds <= 0) {
console.warn("[SpringAuth] Token missing exp/iat claims, using default intervals");
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)
this.sessionCheckIntervalMs = Math.max(5000, Math.min(60000, tokenLifetimeMs / 6));
// 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)
this.tokenRefreshThresholdMs = Math.max(30000, Math.min(300000, tokenLifetimeMs / 4));
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",
});
// Restart monitoring with new interval
this.restartSessionMonitoring();
} catch (error) {
console.warn("[SpringAuth] Failed to calculate adaptive intervals:", error);
}
}
private decodeJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split(".");
if (parts.length < 2) {
return null;
}
const base64Url = parts[1];
const base64 = base64Url
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(Math.ceil(base64Url.length / 4) * 4, "=");
return JSON.parse(atob(base64));
}
private getTokenExpiry(token: string): { expiresIn: number; expiresAt: number } {
try {
const payload = this.decodeJwtPayload(token);
if (!payload) {
throw new Error("Token payload missing");
}
const expSeconds = typeof payload?.exp === "number" ? payload.exp : 0;
const expiresAt = expSeconds > 0 ? expSeconds * 1000 : Date.now() + 3600 * 1000;
const expiresIn = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
return { expiresIn, expiresAt };
} catch {
// Fallback for non-JWT or malformed tokens.
const expiresAt = Date.now() + 3600 * 1000;
return { expiresIn: 3600, expiresAt };
}
}
/**
* Helper to get CSRF token from cookie
*/
private getCsrfToken(): string | null {
const cookies = document.cookie.split(";");
for (const cookie of cookies) {
const [name, value] = cookie.trim().split("=");
if (name === "XSRF-TOKEN") {
return decodeURIComponent(value);
}
}
return null;
}
/**
* Get current session
* JWT is stored in localStorage and sent via Authorization header
*/
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
try {
// Get JWT from localStorage
let token = localStorage.getItem("stirling_jwt");
if (!token) {
// console.debug('[SpringAuth] getSession: No JWT in localStorage');
return { data: { session: null }, error: null };
}
if (await isDesktopSaaSAuthMode()) {
let tokenExpiry = this.getTokenExpiry(token);
if (tokenExpiry.expiresIn <= this.DESKTOP_SAAS_REFRESH_EARLY_SECONDS) {
const refreshed = await refreshPlatformSession();
if (!refreshed) {
localStorage.removeItem("stirling_jwt");
return { data: { session: null }, error: null };
}
const refreshedToken = localStorage.getItem("stirling_jwt");
if (!refreshedToken) {
localStorage.removeItem("stirling_jwt");
return { data: { session: null }, error: null };
}
token = refreshedToken;
tokenExpiry = this.getTokenExpiry(token);
}
if (tokenExpiry.expiresIn <= 0) {
localStorage.removeItem("stirling_jwt");
return { data: { session: null }, error: null };
}
const platformUser = await getPlatformSessionUser();
const session: Session = {
user: {
id: platformUser?.email || platformUser?.username || "desktop-saas-user",
email: platformUser?.email || "",
username: platformUser?.username || platformUser?.email || "User",
role: "USER",
},
access_token: token,
expires_in: tokenExpiry.expiresIn,
expires_at: tokenExpiry.expiresAt,
};
return { data: { session }, error: null };
}
// Verify with backend
// Note: We pass the token explicitly here, overriding the interceptor's default
// console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
const response = await apiClient.get("/api/v1/auth/me", {
headers: {
Authorization: `Bearer ${token}`,
},
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
// Session bootstrap should not trigger global 401 refresh/redirect loops.
skipAuthRedirect: true,
});
// console.debug('[SpringAuth] /me response status:', response.status);
const data = response.data;
// console.debug('[SpringAuth] /me response data:', data);
// Create session object
const tokenExpiry = this.getTokenExpiry(token);
const session: Session = {
user: data.user,
access_token: token,
expires_in: tokenExpiry.expiresIn,
expires_at: tokenExpiry.expiresAt,
};
// console.debug('[SpringAuth] getSession: Session retrieved successfully');
return { data: { session }, error: null };
} catch (error: unknown) {
console.error("[SpringAuth] getSession error:", error);
// If 401/403, token is invalid - try explicit refresh
const status = getHttpStatus(error);
if (status === 401 || status === 403) {
// A 401 during startup can be a race with a concurrent refresh. Try one
// explicit refresh before treating the session as invalid.
const refreshResult = await this.refreshSession();
if (!refreshResult.error && refreshResult.data.session) {
return refreshResult;
}
localStorage.removeItem("stirling_jwt");
console.debug("[SpringAuth] getSession: Not authenticated");
return { data: { session: null }, error: null };
}
// 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
return {
data: { session: null },
error: { message: getErrorMessage(error, "Unknown error") },
};
}
}
/**
* Sign in with email and password
*/
async signInWithPassword(credentials: { email: string; password: string; mfaCode?: string }): Promise<AuthResponse> {
try {
const response = await apiClient.post(
"/api/v1/auth/login",
{
username: credentials.email,
password: credentials.password,
mfaCode: credentials.mfaCode,
},
{
withCredentials: true, // Include cookies for CSRF
},
);
const data = response.data;
const token = data.session.access_token;
// Store JWT in localStorage
localStorage.setItem("stirling_jwt", token);
// console.log('[SpringAuth] JWT stored in localStorage');
// Sync token to platform-specific storage (Tauri store for desktop)
await savePlatformToken(token);
// Calculate adaptive monitoring intervals based on token lifetime
this.calculateAdaptiveIntervals(token);
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent("jwt-available"));
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);
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;
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",
},
};
}
return {
user: null,
session: null,
error: { message: getErrorMessage(error, "Login failed") },
};
}
}
/**
* Sign up new user
*/
async signUp(credentials: {
email: string;
password: string;
options?: { data?: { full_name?: string }; emailRedirectTo?: string };
}): Promise<AuthResponse> {
try {
const response = await apiClient.post(
"/api/v1/user/register",
{
username: credentials.email,
password: credentials.password,
},
{
withCredentials: true,
},
);
const data = response.data;
// 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);
return {
user: null,
session: null,
error: { message: getErrorMessage(error, "Registration failed") },
};
}
}
/**
* Sign in with OAuth/SAML provider (GitHub, Google, Authentik, etc.)
* This redirects to the Spring OAuth2/SAML2 authorization endpoint
*
* @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
*/
async signInWithOAuth(params: {
provider: OAuthProvider;
options?: { redirectTo?: string; queryParams?: Record<string, string> };
}): Promise<{ error: AuthError | null }> {
try {
const redirectPath = normalizeRedirectPath(params.options?.redirectTo);
persistRedirectPath(redirectPath);
// Use the full path provided by the backend
// This supports both OAuth2 (/oauth2/authorization/...) and SAML2 (/saml2/authenticate/...)
const redirectUrl = params.provider;
const handled = await startOAuthNavigation(redirectUrl);
if (handled) {
return { error: null };
}
// console.log('[SpringAuth] Redirecting to SSO:', redirectUrl);
// Use window.location.assign for full page navigation
window.location.assign(redirectUrl);
return { error: null };
} catch (error) {
return {
error: { message: error instanceof Error ? error.message : "SSO redirect failed" },
};
}
}
/**
* Sign out user (invalidate session)
*/
async signOut(): Promise<{ error: AuthError | null }> {
try {
if (typeof window !== "undefined") {
window.sessionStorage.setItem("stirling_sso_auto_login_logged_out", "1");
}
const response = await apiClient.post("/api/v1/auth/logout", null, {
headers: {
"X-XSRF-TOKEN": this.getCsrfToken() || "",
},
withCredentials: true,
});
if (response.status === 200) {
// console.debug('[SpringAuth] signOut: Success');
}
// Clean up local storage
localStorage.removeItem("stirling_jwt");
try {
Object.keys(localStorage)
.filter((key) => key.startsWith("sb-") || key.includes("supabase"))
.forEach((key) => localStorage.removeItem(key));
// Clear any cached OAuth redirect/session state
resetOAuthState();
} catch (err) {
console.warn("[SpringAuth] Failed to clear Supabase/local auth tokens", err);
}
// Clear cookies that might hold refresh/session tokens
try {
document.cookie.split(";").forEach((cookie) => {
const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
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);
}
try {
await clearPlatformAuthAfterSignOut();
} catch (cleanupError) {
console.warn("[SpringAuth] Failed to run platform auth cleanup", cleanupError);
}
// Notify listeners
this.notifyListeners("SIGNED_OUT", null);
return { error: null };
} catch (error: unknown) {
console.error("[SpringAuth] signOut error:", error);
// Still remove token even if backend call fails
localStorage.removeItem("stirling_jwt");
try {
await clearPlatformAuthAfterSignOut();
} catch (cleanupError) {
console.warn("[SpringAuth] Failed to run platform auth cleanup after error", cleanupError);
}
return {
error: { message: getErrorMessage(error, "Logout failed") },
};
}
}
/**
* Refresh JWT token
*/
async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
try {
if (await isDesktopSaaSAuthMode()) {
const refreshed = await refreshPlatformSession();
if (!refreshed) {
localStorage.removeItem("stirling_jwt");
return {
data: { session: null },
error: { message: "Token refresh failed - please log in again" },
};
}
const { data, error } = await this.getSession();
if (error || !data.session) {
return {
data: { session: null },
error: error || { message: "Token refresh failed - please log in again" },
};
}
// Calculate adaptive intervals for desktop SaaS mode
const token = localStorage.getItem("stirling_jwt");
if (token) {
this.calculateAdaptiveIntervals(token);
}
this.notifyListeners("TOKEN_REFRESHED", data.session);
return { data, error: null };
}
const response = await apiClient.post("/api/v1/auth/refresh", null, {
headers: {
"X-XSRF-TOKEN": this.getCsrfToken() || "",
},
withCredentials: true,
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
});
const data = response.data;
const token = data.session.access_token;
// Update local storage with new token
localStorage.setItem("stirling_jwt", token);
// Sync token to platform-specific storage (Tauri store for desktop)
await savePlatformToken(token);
// Calculate adaptive monitoring intervals based on token lifetime
this.calculateAdaptiveIntervals(token);
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("TOKEN_REFRESHED", session);
console.debug("[SpringAuth] Token refreshed successfully");
return { data: { session }, error: null };
} catch (error: unknown) {
console.error("[SpringAuth] refreshSession error:", error);
localStorage.removeItem("stirling_jwt");
// Handle different error statuses
const status = getHttpStatus(error);
if (status === 401 || status === 403) {
return { data: { session: null }, error: { message: "Token refresh failed - please log in again" } };
}
return {
data: { session: null },
error: { message: getErrorMessage(error, "Token refresh failed") },
};
}
}
/**
* Listen to auth state changes
*/
onAuthStateChange(callback: AuthChangeCallback): { data: { subscription: { unsubscribe: () => void } } } {
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) {
console.error("[SpringAuth] Error in auth state change listener:", error);
}
});
}, 0);
}
private startSessionMonitoring() {
// Periodically check session validity
// Interval is adaptive based on token lifetime (calculated when token is received)
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();
// Refresh if token expires soon (threshold is adaptive)
if (timeUntilExpiry > 0 && timeUntilExpiry < this.tokenRefreshThresholdMs) {
console.log(
"[SpringAuth] 🔄 Proactively refreshing token (expires in " + Math.floor(timeUntilExpiry / 1000) + "s)",
);
await this.refreshSession();
}
}
} catch (error) {
console.error("[SpringAuth] Session monitoring error:", error);
}
}, this.sessionCheckIntervalMs);
}
private restartSessionMonitoring() {
// Stop existing interval
if (this.sessionCheckInterval) {
clearInterval(this.sessionCheckInterval);
this.sessionCheckInterval = null;
}
// Start with new interval
this.startSessionMonitoring();
}
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",
enabled: true,
is_anonymous: true,
app_metadata: {
provider: "anonymous",
},
};
};
/**
* Create an anonymous session for use when login is disabled
*/
export const createAnonymousSession = (): Session => {
return {
user: createAnonymousUser(),
access_token: "",
expires_in: Number.MAX_SAFE_INTEGER,
expires_at: Number.MAX_SAFE_INTEGER,
};
};
// Export auth client as default for convenience
export default springAuth;