mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Fix login loop on self-hosted desktop (#5022)
# Description of Changes Fix #5017 Changes the handling of jwt tokens to be stored in local storage as well as OS keyring so the rest of the app knows that you're logged in.
This commit is contained in:
@@ -34,6 +34,55 @@ export class AuthService {
|
|||||||
return AuthService.instance;
|
return AuthService.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save token to all storage locations and notify listeners
|
||||||
|
*/
|
||||||
|
private async saveTokenEverywhere(token: string): Promise<void> {
|
||||||
|
// Save to Tauri store
|
||||||
|
await invoke('save_auth_token', { token });
|
||||||
|
console.log('[Desktop AuthService] Token saved to Tauri store');
|
||||||
|
|
||||||
|
// Sync to localStorage for web layer
|
||||||
|
localStorage.setItem('stirling_jwt', token);
|
||||||
|
console.log('[Desktop AuthService] Token saved to localStorage');
|
||||||
|
|
||||||
|
// Notify other parts of the system
|
||||||
|
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||||
|
console.log('[Desktop AuthService] Dispatched jwt-available event');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get token from any available source (Tauri store or localStorage)
|
||||||
|
*/
|
||||||
|
private async getTokenFromAnySource(): Promise<string | null> {
|
||||||
|
// Try Tauri store first
|
||||||
|
console.log('[Desktop AuthService] Retrieving token from Tauri store...');
|
||||||
|
const token = await invoke<string | null>('get_auth_token');
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
console.log(`[Desktop AuthService] Token found in Tauri store (length: ${token.length})`);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Desktop AuthService] No token in Tauri store');
|
||||||
|
|
||||||
|
// Fallback to localStorage
|
||||||
|
const localStorageToken = localStorage.getItem('stirling_jwt');
|
||||||
|
if (localStorageToken) {
|
||||||
|
console.log('[Desktop AuthService] Token found in localStorage (length:', localStorageToken.length, ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
return localStorageToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear token from all storage locations
|
||||||
|
*/
|
||||||
|
private async clearTokenEverywhere(): Promise<void> {
|
||||||
|
await invoke('clear_auth_token');
|
||||||
|
localStorage.removeItem('stirling_jwt');
|
||||||
|
}
|
||||||
|
|
||||||
subscribeToAuth(listener: (status: AuthStatus, userInfo: UserInfo | null) => void): () => void {
|
subscribeToAuth(listener: (status: AuthStatus, userInfo: UserInfo | null) => void): () => void {
|
||||||
this.authListeners.add(listener);
|
this.authListeners.add(listener);
|
||||||
// Immediately notify new listener of current state
|
// Immediately notify new listener of current state
|
||||||
@@ -78,8 +127,15 @@ export class AuthService {
|
|||||||
|
|
||||||
const { token, username: returnedUsername, email } = response;
|
const { token, username: returnedUsername, email } = response;
|
||||||
|
|
||||||
// Save the token to keyring
|
console.log('[Desktop AuthService] Login successful, saving token...');
|
||||||
await invoke('save_auth_token', { token });
|
|
||||||
|
// Save token to all storage locations
|
||||||
|
try {
|
||||||
|
await this.saveTokenEverywhere(token);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Desktop AuthService] Failed to save token:', error);
|
||||||
|
throw new Error('Failed to save authentication token');
|
||||||
|
}
|
||||||
|
|
||||||
// Save user info to store
|
// Save user info to store
|
||||||
await invoke('save_user_info', {
|
await invoke('save_user_info', {
|
||||||
@@ -107,10 +163,10 @@ export class AuthService {
|
|||||||
try {
|
try {
|
||||||
console.log('Logging out');
|
console.log('Logging out');
|
||||||
|
|
||||||
// Clear token from keyring
|
// Clear token from all storage locations
|
||||||
await invoke('clear_auth_token');
|
await this.clearTokenEverywhere();
|
||||||
|
|
||||||
// Clear user info from store
|
// Clear user info from Tauri store
|
||||||
await invoke('clear_user_info');
|
await invoke('clear_user_info');
|
||||||
|
|
||||||
this.setAuthStatus('unauthenticated', null);
|
this.setAuthStatus('unauthenticated', null);
|
||||||
@@ -120,15 +176,16 @@ export class AuthService {
|
|||||||
console.error('Error during logout:', error);
|
console.error('Error during logout:', error);
|
||||||
// Still set status to unauthenticated even if clear fails
|
// Still set status to unauthenticated even if clear fails
|
||||||
this.setAuthStatus('unauthenticated', null);
|
this.setAuthStatus('unauthenticated', null);
|
||||||
|
// Still try to clear token
|
||||||
|
await this.clearTokenEverywhere().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAuthToken(): Promise<string | null> {
|
async getAuthToken(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const token = await invoke<string | null>('get_auth_token');
|
return await this.getTokenFromAnySource();
|
||||||
return token || null;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get auth token:', error);
|
console.error('[Desktop AuthService] Failed to get auth token:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,15 +197,22 @@ export class AuthService {
|
|||||||
|
|
||||||
async getUserInfo(): Promise<UserInfo | null> {
|
async getUserInfo(): Promise<UserInfo | null> {
|
||||||
if (this.userInfo) {
|
if (this.userInfo) {
|
||||||
|
console.log('[Desktop AuthService] Using cached user info:', this.userInfo.username);
|
||||||
return this.userInfo;
|
return this.userInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('[Desktop AuthService] Retrieving user info from store...');
|
||||||
const userInfo = await invoke<UserInfo | null>('get_user_info');
|
const userInfo = await invoke<UserInfo | null>('get_user_info');
|
||||||
|
if (userInfo) {
|
||||||
|
console.log('[Desktop AuthService] User info found:', userInfo.username);
|
||||||
this.userInfo = userInfo;
|
this.userInfo = userInfo;
|
||||||
|
} else {
|
||||||
|
console.log('[Desktop AuthService] No user info in store');
|
||||||
|
}
|
||||||
return userInfo;
|
return userInfo;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get user info:', error);
|
console.error('[Desktop AuthService] Failed to get user info from store:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,8 +241,8 @@ export class AuthService {
|
|||||||
|
|
||||||
const { token } = response.data;
|
const { token } = response.data;
|
||||||
|
|
||||||
// Save the new token
|
// Save token to all storage locations
|
||||||
await invoke('save_auth_token', { token });
|
await this.saveTokenEverywhere(token);
|
||||||
|
|
||||||
const userInfo = await this.getUserInfo();
|
const userInfo = await this.getUserInfo();
|
||||||
this.setAuthStatus('authenticated', userInfo);
|
this.setAuthStatus('authenticated', userInfo);
|
||||||
@@ -197,13 +261,22 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initializeAuthState(): Promise<void> {
|
async initializeAuthState(): Promise<void> {
|
||||||
|
console.log('[Desktop AuthService] Initializing auth state...');
|
||||||
const token = await this.getAuthToken();
|
const token = await this.getAuthToken();
|
||||||
const userInfo = await this.getUserInfo();
|
const userInfo = await this.getUserInfo();
|
||||||
|
|
||||||
if (token && userInfo) {
|
if (token && userInfo) {
|
||||||
|
console.log('[Desktop AuthService] Found token, syncing to all storage locations');
|
||||||
|
|
||||||
|
// Ensure token is in both Tauri store and localStorage
|
||||||
|
await this.saveTokenEverywhere(token);
|
||||||
|
|
||||||
this.setAuthStatus('authenticated', userInfo);
|
this.setAuthStatus('authenticated', userInfo);
|
||||||
|
console.log('[Desktop AuthService] Auth state initialized as authenticated');
|
||||||
} else {
|
} else {
|
||||||
|
console.log('[Desktop AuthService] No token or user info found');
|
||||||
this.setAuthStatus('unauthenticated', null);
|
this.setAuthStatus('unauthenticated', null);
|
||||||
|
console.log('[Desktop AuthService] Auth state initialized as unauthenticated');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,8 +308,8 @@ export class AuthService {
|
|||||||
|
|
||||||
console.log('OAuth authentication successful, storing tokens');
|
console.log('OAuth authentication successful, storing tokens');
|
||||||
|
|
||||||
// Save the access token to keyring
|
// Save token to all storage locations
|
||||||
await invoke('save_auth_token', { token: result.access_token });
|
await this.saveTokenEverywhere(result.access_token);
|
||||||
|
|
||||||
// Fetch user info from Supabase using the access token
|
// Fetch user info from Supabase using the access token
|
||||||
const userInfo = await this.fetchSupabaseUserInfo(authServerUrl, result.access_token);
|
const userInfo = await this.fetchSupabaseUserInfo(authServerUrl, result.access_token);
|
||||||
|
|||||||
@@ -117,6 +117,25 @@ export class TauriBackendService {
|
|||||||
throw new Error('Failed to detect backend port after 15 seconds');
|
throw new Error('Failed to detect backend port after 15 seconds');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get auth token from any available source (localStorage or Tauri store)
|
||||||
|
*/
|
||||||
|
private async getAuthToken(): Promise<string | null> {
|
||||||
|
// Check localStorage first (web layer token)
|
||||||
|
const localStorageToken = localStorage.getItem('stirling_jwt');
|
||||||
|
if (localStorageToken) {
|
||||||
|
return localStorageToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to Tauri store
|
||||||
|
try {
|
||||||
|
return await invoke<string | null>('get_auth_token');
|
||||||
|
} catch {
|
||||||
|
console.debug('[TauriBackendService] No auth token available');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private beginHealthMonitoring() {
|
private beginHealthMonitoring() {
|
||||||
if (this.healthMonitor) {
|
if (this.healthMonitor) {
|
||||||
return;
|
return;
|
||||||
@@ -158,9 +177,20 @@ export class TauriBackendService {
|
|||||||
// Check if backend is ready (dependencies checked)
|
// Check if backend is ready (dependencies checked)
|
||||||
try {
|
try {
|
||||||
const configUrl = `${baseUrl}/api/v1/config/app-config`;
|
const configUrl = `${baseUrl}/api/v1/config/app-config`;
|
||||||
|
|
||||||
|
// For self-hosted mode, include auth token if available
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (mode === 'selfhosted') {
|
||||||
|
const token = await this.getAuthToken();
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(configUrl, {
|
const response = await fetch(configUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
connectTimeout: 5000,
|
connectTimeout: 5000,
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -126,6 +126,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
initializeAuth();
|
initializeAuth();
|
||||||
|
|
||||||
|
// Listen for jwt-available event (triggered by desktop auth or other sources)
|
||||||
|
const handleJwtAvailable = () => {
|
||||||
|
console.debug('[Auth] JWT available event received, refreshing session');
|
||||||
|
void initializeAuth();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('jwt-available', handleJwtAvailable);
|
||||||
|
|
||||||
// Subscribe to auth state changes
|
// Subscribe to auth state changes
|
||||||
const { data: { subscription } } = springAuth.onAuthStateChange(
|
const { data: { subscription } } = springAuth.onAuthStateChange(
|
||||||
async (event: AuthChangeEvent, newSession: Session | null) => {
|
async (event: AuthChangeEvent, newSession: Session | null) => {
|
||||||
@@ -162,6 +170,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
|
window.removeEventListener('jwt-available', handleJwtAvailable);
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -161,8 +161,8 @@ class SpringAuthClient {
|
|||||||
return { data: { session: null }, error: null };
|
return { data: { session: null }, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear potentially invalid token on other errors too
|
// Don't clear token for other errors (e.g., backend not ready, network issues)
|
||||||
localStorage.removeItem('stirling_jwt');
|
// The token is still valid, just can't verify it right now
|
||||||
return {
|
return {
|
||||||
data: { session: null },
|
data: { session: null },
|
||||||
error: { message: getErrorMessage(error, 'Unknown error') },
|
error: { message: getErrorMessage(error, 'Unknown error') },
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
|
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
|
||||||
import AuthLayout from '@app/routes/authShared/AuthLayout';
|
import AuthLayout from '@app/routes/authShared/AuthLayout';
|
||||||
import { useBackendProbe } from '@app/hooks/useBackendProbe';
|
import { useBackendProbe } from '@app/hooks/useBackendProbe';
|
||||||
|
import apiClient from '@app/services/apiClient';
|
||||||
|
import { BASE_PATH } from '@app/constants/app';
|
||||||
|
|
||||||
// Import login components
|
// Import login components
|
||||||
import LoginHeader from '@app/routes/login/LoginHeader';
|
import LoginHeader from '@app/routes/login/LoginHeader';
|
||||||
@@ -16,7 +18,6 @@ import EmailPasswordForm from '@app/routes/login/EmailPasswordForm';
|
|||||||
import OAuthButtons, { DEBUG_SHOW_ALL_PROVIDERS, oauthProviderConfig } from '@app/routes/login/OAuthButtons';
|
import OAuthButtons, { DEBUG_SHOW_ALL_PROVIDERS, oauthProviderConfig } from '@app/routes/login/OAuthButtons';
|
||||||
import DividerWithText from '@app/components/shared/DividerWithText';
|
import DividerWithText from '@app/components/shared/DividerWithText';
|
||||||
import LoggedInState from '@app/routes/login/LoggedInState';
|
import LoggedInState from '@app/routes/login/LoggedInState';
|
||||||
import { BASE_PATH } from '@app/constants/app';
|
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -85,9 +86,8 @@ export default function Login() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchProviders = async () => {
|
const fetchProviders = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BASE_PATH}/api/v1/proprietary/ui-data/login`);
|
const response = await apiClient.get('/api/v1/proprietary/ui-data/login');
|
||||||
if (response.ok) {
|
const data = response.data;
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Check if login is disabled - if so, redirect to home
|
// Check if login is disabled - if so, redirect to home
|
||||||
if (data.enableLogin === false) {
|
if (data.enableLogin === false) {
|
||||||
@@ -108,7 +108,6 @@ export default function Login() {
|
|||||||
.map(key => key.split('/').pop())
|
.map(key => key.split('/').pop())
|
||||||
.filter((id): id is string => id !== undefined);
|
.filter((id): id is string => id !== undefined);
|
||||||
setEnabledProviders(providerIds);
|
setEnabledProviders(providerIds);
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Login] Failed to fetch enabled providers:', err);
|
console.error('[Login] Failed to fetch enabled providers:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user