mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
# Description of Changes <img width="1569" height="980" alt="image" src="https://github.com/user-attachments/assets/dca1c227-ed84-4393-97a1-e3ce6eb1620b" /> <img width="1596" height="935" alt="image" src="https://github.com/user-attachments/assets/2003e1be-034a-4cbb-869e-6d5d912ab61d" /> <img width="1543" height="997" alt="image" src="https://github.com/user-attachments/assets/fe0c4f4b-eeee-4db4-a041-e554f350255a" /> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details.
468 lines
14 KiB
TypeScript
468 lines
14 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';
|
|
|
|
// 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;
|
|
app_metadata?: Record<string, any>;
|
|
}
|
|
|
|
export interface Session {
|
|
user: User;
|
|
access_token: string;
|
|
expires_in: number;
|
|
expires_at?: number;
|
|
}
|
|
|
|
export interface AuthError {
|
|
message: string;
|
|
status?: number;
|
|
}
|
|
|
|
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;
|
|
private readonly SESSION_CHECK_INTERVAL = 60000; // 1 minute
|
|
private readonly TOKEN_REFRESH_THRESHOLD = 300000; // 5 minutes before expiry
|
|
|
|
constructor() {
|
|
// Start periodic session validation
|
|
this.startSessionMonitoring();
|
|
}
|
|
|
|
/**
|
|
* 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 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
|
|
const token = localStorage.getItem('stirling_jwt');
|
|
|
|
if (!token) {
|
|
console.debug('[SpringAuth] getSession: No JWT in localStorage');
|
|
return { data: { session: null }, 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}`,
|
|
},
|
|
});
|
|
|
|
console.debug('[SpringAuth] /me response status:', response.status);
|
|
const data = response.data;
|
|
console.debug('[SpringAuth] /me response data:', data);
|
|
|
|
// Create session object
|
|
const session: Session = {
|
|
user: data.user,
|
|
access_token: token,
|
|
expires_in: 3600,
|
|
expires_at: Date.now() + 3600 * 1000,
|
|
};
|
|
|
|
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 - clear it
|
|
if (error instanceof AxiosError && (error.response?.status === 401 || error.response?.status === 403)) {
|
|
localStorage.removeItem('stirling_jwt');
|
|
console.debug('[SpringAuth] getSession: Not authenticated');
|
|
return { data: { session: null }, error: null };
|
|
}
|
|
|
|
// Clear potentially invalid token on other errors too
|
|
localStorage.removeItem('stirling_jwt');
|
|
return {
|
|
data: { session: null },
|
|
error: { message: getErrorMessage(error, 'Unknown error') },
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sign in with email and password
|
|
*/
|
|
async signInWithPassword(credentials: {
|
|
email: string;
|
|
password: string;
|
|
}): Promise<AuthResponse> {
|
|
try {
|
|
const response = await apiClient.post('/api/v1/auth/login', {
|
|
username: credentials.email,
|
|
password: credentials.password
|
|
}, {
|
|
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');
|
|
|
|
// 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);
|
|
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 provider (GitHub, Google, etc.)
|
|
* This redirects to the Spring OAuth2 authorization endpoint
|
|
*/
|
|
async signInWithOAuth(params: {
|
|
provider: 'github' | 'google' | 'apple' | 'azure' | 'keycloak' | 'oidc';
|
|
options?: { redirectTo?: string; queryParams?: Record<string, any> };
|
|
}): Promise<{ error: AuthError | null }> {
|
|
try {
|
|
const redirectPath = normalizeRedirectPath(params.options?.redirectTo);
|
|
persistRedirectPath(redirectPath);
|
|
|
|
// Redirect to Spring OAuth2 endpoint (Vite will proxy to backend)
|
|
const redirectUrl = `/oauth2/authorization/${params.provider}`;
|
|
console.log('[SpringAuth] Redirecting to OAuth:', 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 : 'OAuth redirect failed' },
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sign out user (invalidate session)
|
|
*/
|
|
async signOut(): Promise<{ error: AuthError | null }> {
|
|
try {
|
|
const response = await apiClient.post('/api/v1/auth/logout', null, {
|
|
headers: {
|
|
'X-CSRF-TOKEN': this.getCsrfToken() || '',
|
|
},
|
|
withCredentials: true,
|
|
});
|
|
|
|
if (response.status === 200) {
|
|
console.debug('[SpringAuth] signOut: Success');
|
|
}
|
|
|
|
// Clean up local storage
|
|
localStorage.removeItem('stirling_jwt');
|
|
|
|
// 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');
|
|
return {
|
|
error: { message: getErrorMessage(error, 'Logout failed') },
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh JWT token
|
|
*/
|
|
async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
|
|
try {
|
|
const response = await apiClient.post('/api/v1/auth/refresh', null, {
|
|
headers: {
|
|
'X-CSRF-TOKEN': this.getCsrfToken() || '',
|
|
},
|
|
withCredentials: true,
|
|
});
|
|
|
|
const data = response.data;
|
|
const token = data.session.access_token;
|
|
|
|
// Update local storage with new token
|
|
localStorage.setItem('stirling_jwt', 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('TOKEN_REFRESHED', session);
|
|
|
|
return { data: { session }, error: null };
|
|
} catch (error: unknown) {
|
|
console.error('[SpringAuth] refreshSession error:', error);
|
|
localStorage.removeItem('stirling_jwt');
|
|
|
|
// Handle different error statuses
|
|
if (error instanceof AxiosError && (error.response?.status === 401 || error.response?.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
|
|
// Since we use HttpOnly cookies, we just need to check with the server
|
|
this.sessionCheckInterval = setInterval(async () => {
|
|
try {
|
|
// Try to get current session
|
|
const { data } = await this.getSession();
|
|
|
|
// If we have a session, proactively refresh if needed
|
|
// (The server will handle token expiry, but we can be proactive)
|
|
if (data.session) {
|
|
const timeUntilExpiry = (data.session.expires_at || 0) - Date.now();
|
|
|
|
// Refresh if token expires soon
|
|
if (timeUntilExpiry > 0 && timeUntilExpiry < this.TOKEN_REFRESH_THRESHOLD) {
|
|
console.log('[SpringAuth] Proactively refreshing token');
|
|
await this.refreshSession();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[SpringAuth] Session monitoring error:', error);
|
|
}
|
|
}, this.SESSION_CHECK_INTERVAL);
|
|
}
|
|
|
|
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;
|