mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Stop attempting to refresh Spring tokens in desktop (#5610)
# Description of Changes <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## 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. --------- Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
parent
36358fc139
commit
2ae413c5ea
+1
@@ -182,6 +182,7 @@ class AuthControllerLoginTest {
|
|||||||
|
|
||||||
mockMvc.perform(post("/api/v1/auth/refresh"))
|
mockMvc.perform(post("/api/v1/auth/refresh"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.user").exists())
|
||||||
.andExpect(jsonPath("$.session.access_token").value("new-token"))
|
.andExpect(jsonPath("$.session.access_token").value("new-token"))
|
||||||
.andExpect(jsonPath("$.session.expires_in").value(3600));
|
.andExpect(jsonPath("$.session.expires_in").value(3600));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ use rand::distributions::Alphanumeric;
|
|||||||
|
|
||||||
const STORE_FILE: &str = "connection.json";
|
const STORE_FILE: &str = "connection.json";
|
||||||
const USER_INFO_KEY: &str = "user_info";
|
const USER_INFO_KEY: &str = "user_info";
|
||||||
|
const TOKENS_STORE_FILE: &str = "tokens.json";
|
||||||
|
const REFRESH_TOKEN_STORE_KEY: &str = "refresh_token";
|
||||||
const KEYRING_SERVICE: &str = "stirling-pdf";
|
const KEYRING_SERVICE: &str = "stirling-pdf";
|
||||||
const KEYRING_TOKEN_KEY: &str = "auth-token";
|
const KEYRING_TOKEN_KEY: &str = "auth-token";
|
||||||
|
const KEYRING_REFRESH_TOKEN_KEY: &str = "refresh-token";
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct UserInfo {
|
pub struct UserInfo {
|
||||||
@@ -31,6 +34,11 @@ fn get_keyring_entry() -> Result<Entry, String> {
|
|||||||
Ok(entry)
|
Ok(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_refresh_token_keyring_entry() -> Result<Entry, String> {
|
||||||
|
Entry::new(KEYRING_SERVICE, KEYRING_REFRESH_TOKEN_KEY)
|
||||||
|
.map_err(|e| format!("Failed to access keyring: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> {
|
pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> {
|
||||||
let trimmed = token.trim();
|
let trimmed = token.trim();
|
||||||
@@ -101,6 +109,112 @@ pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_refresh_token(app_handle: AppHandle, token: String) -> Result<(), String> {
|
||||||
|
log::info!("Saving refresh token - trying keyring first");
|
||||||
|
|
||||||
|
let entry = get_refresh_token_keyring_entry()?;
|
||||||
|
|
||||||
|
// Try keyring (works in production with code signing)
|
||||||
|
match entry.set_password(&token) {
|
||||||
|
Ok(_) => {
|
||||||
|
// Verify it persists (fails in unsigned dev builds)
|
||||||
|
match entry.get_password() {
|
||||||
|
Ok(saved) if saved == token => {
|
||||||
|
log::info!("✅ Refresh token saved to keyring (production mode)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
log::info!("Keyring doesn't persist - using Tauri Store fallback (dev mode)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::info!("Keyring failed: {} - using Tauri Store fallback", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to Tauri Store (dev mode without code signing)
|
||||||
|
let store = app_handle
|
||||||
|
.store(TOKENS_STORE_FILE)
|
||||||
|
.map_err(|e| format!("Failed to access tokens store: {}", e))?;
|
||||||
|
|
||||||
|
store.set(
|
||||||
|
REFRESH_TOKEN_STORE_KEY,
|
||||||
|
serde_json::to_value(&token)
|
||||||
|
.map_err(|e| format!("Failed to serialize token: {}", e))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
store
|
||||||
|
.save()
|
||||||
|
.map_err(|e| format!("Failed to save tokens store: {}", e))?;
|
||||||
|
|
||||||
|
log::info!("✅ Refresh token saved to Tauri Store (fallback)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_refresh_token(app_handle: AppHandle) -> Result<Option<String>, String> {
|
||||||
|
// Try keyring first (production)
|
||||||
|
let entry = get_refresh_token_keyring_entry()?;
|
||||||
|
match entry.get_password() {
|
||||||
|
Ok(token) => {
|
||||||
|
log::info!("✅ Refresh token retrieved from keyring");
|
||||||
|
return Ok(Some(token));
|
||||||
|
}
|
||||||
|
Err(keyring::Error::NoEntry) => {
|
||||||
|
log::debug!("No token in keyring, trying Tauri Store");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Keyring error: {} - trying Tauri Store", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to Tauri Store (dev)
|
||||||
|
let store = app_handle
|
||||||
|
.store(TOKENS_STORE_FILE)
|
||||||
|
.map_err(|e| format!("Failed to access tokens store: {}", e))?;
|
||||||
|
|
||||||
|
let token: Option<String> = store
|
||||||
|
.get(REFRESH_TOKEN_STORE_KEY)
|
||||||
|
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||||
|
|
||||||
|
if token.is_some() {
|
||||||
|
log::info!("✅ Refresh token retrieved from Tauri Store");
|
||||||
|
} else {
|
||||||
|
log::info!("No refresh token found");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_refresh_token(app_handle: AppHandle) -> Result<(), String> {
|
||||||
|
log::info!("Clearing refresh token from all storage");
|
||||||
|
|
||||||
|
// Clear from keyring
|
||||||
|
let entry = get_refresh_token_keyring_entry()?;
|
||||||
|
match entry.delete_credential() {
|
||||||
|
Ok(_) => log::info!("Cleared from keyring"),
|
||||||
|
Err(keyring::Error::NoEntry) => log::debug!("Not in keyring"),
|
||||||
|
Err(e) => log::warn!("Keyring clear error: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear from Tauri Store
|
||||||
|
let store = app_handle
|
||||||
|
.store(TOKENS_STORE_FILE)
|
||||||
|
.map_err(|e| format!("Failed to access tokens store: {}", e))?;
|
||||||
|
|
||||||
|
store.delete(REFRESH_TOKEN_STORE_KEY);
|
||||||
|
|
||||||
|
store
|
||||||
|
.save()
|
||||||
|
.map_err(|e| format!("Failed to save tokens store: {}", e))?;
|
||||||
|
|
||||||
|
log::info!("✅ Refresh token cleared");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn save_user_info(
|
pub async fn save_user_info(
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ pub use connection::{
|
|||||||
};
|
};
|
||||||
pub use auth::{
|
pub use auth::{
|
||||||
clear_auth_token,
|
clear_auth_token,
|
||||||
|
clear_refresh_token,
|
||||||
clear_user_info,
|
clear_user_info,
|
||||||
get_auth_token,
|
get_auth_token,
|
||||||
|
get_refresh_token,
|
||||||
get_user_info,
|
get_user_info,
|
||||||
login,
|
login,
|
||||||
save_auth_token,
|
save_auth_token,
|
||||||
|
save_refresh_token,
|
||||||
save_user_info,
|
save_user_info,
|
||||||
start_oauth_login,
|
start_oauth_login,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,17 +9,20 @@ use commands::{
|
|||||||
cleanup_backend,
|
cleanup_backend,
|
||||||
clear_auth_token,
|
clear_auth_token,
|
||||||
clear_opened_files,
|
clear_opened_files,
|
||||||
|
clear_refresh_token,
|
||||||
clear_user_info,
|
clear_user_info,
|
||||||
is_default_pdf_handler,
|
is_default_pdf_handler,
|
||||||
get_auth_token,
|
get_auth_token,
|
||||||
get_backend_port,
|
get_backend_port,
|
||||||
get_connection_config,
|
get_connection_config,
|
||||||
get_opened_files,
|
get_opened_files,
|
||||||
|
get_refresh_token,
|
||||||
get_user_info,
|
get_user_info,
|
||||||
is_first_launch,
|
is_first_launch,
|
||||||
login,
|
login,
|
||||||
reset_setup_completion,
|
reset_setup_completion,
|
||||||
save_auth_token,
|
save_auth_token,
|
||||||
|
save_refresh_token,
|
||||||
save_user_info,
|
save_user_info,
|
||||||
set_connection_mode,
|
set_connection_mode,
|
||||||
set_as_default_pdf_handler,
|
set_as_default_pdf_handler,
|
||||||
@@ -143,6 +146,9 @@ pub fn run() {
|
|||||||
save_auth_token,
|
save_auth_token,
|
||||||
get_auth_token,
|
get_auth_token,
|
||||||
clear_auth_token,
|
clear_auth_token,
|
||||||
|
save_refresh_token,
|
||||||
|
get_refresh_token,
|
||||||
|
clear_refresh_token,
|
||||||
save_user_info,
|
save_user_info,
|
||||||
get_user_info,
|
get_user_info,
|
||||||
clear_user_info,
|
clear_user_info,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createBackendNotReadyError } from '@app/constants/backendErrors';
|
|||||||
import { operationRouter } from '@app/services/operationRouter';
|
import { operationRouter } from '@app/services/operationRouter';
|
||||||
import { authService } from '@app/services/authService';
|
import { authService } from '@app/services/authService';
|
||||||
import { connectionModeService } from '@app/services/connectionModeService';
|
import { connectionModeService } from '@app/services/connectionModeService';
|
||||||
|
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
||||||
import i18n from '@app/i18n';
|
import i18n from '@app/i18n';
|
||||||
|
|
||||||
const BACKEND_TOAST_COOLDOWN_MS = 4000;
|
const BACKEND_TOAST_COOLDOWN_MS = 4000;
|
||||||
@@ -34,11 +35,9 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
|||||||
async (config: InternalAxiosRequestConfig) => {
|
async (config: InternalAxiosRequestConfig) => {
|
||||||
const extendedConfig = config as ExtendedRequestConfig;
|
const extendedConfig = config as ExtendedRequestConfig;
|
||||||
|
|
||||||
// Get the operation name from config if provided
|
try {
|
||||||
const operation = extendedConfig.operationName;
|
// Get the appropriate base URL for this request
|
||||||
|
const baseUrl = await operationRouter.getBaseUrl(extendedConfig.url);
|
||||||
// Get the appropriate base URL for this operation
|
|
||||||
const baseUrl = await operationRouter.getBaseUrl(operation);
|
|
||||||
|
|
||||||
// Build the full URL
|
// Build the full URL
|
||||||
if (extendedConfig.url && !extendedConfig.url.startsWith('http')) {
|
if (extendedConfig.url && !extendedConfig.url.startsWith('http')) {
|
||||||
@@ -66,6 +65,11 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
|||||||
// SaaS mode: disable credentials (security disabled on local backend)
|
// SaaS mode: disable credentials (security disabled on local backend)
|
||||||
extendedConfig.withCredentials = false;
|
extendedConfig.withCredentials = false;
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[apiClientSetup] Error in request interceptor:', error);
|
||||||
|
// Continue with request even if routing/auth logic fails
|
||||||
|
// This ensures requests aren't blocked by interceptor errors
|
||||||
|
}
|
||||||
|
|
||||||
// Backend readiness check (for local backend)
|
// Backend readiness check (for local backend)
|
||||||
const skipCheck = extendedConfig.skipBackendReadyCheck === true;
|
const skipCheck = extendedConfig.skipBackendReadyCheck === true;
|
||||||
@@ -108,23 +112,37 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
|||||||
}
|
}
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
console.debug(`[apiClientSetup] 401 error, attempting token refresh for: ${originalRequest.url}`);
|
||||||
|
|
||||||
const isRemote = await operationRouter.isSelfHostedMode();
|
const isRemote = await operationRouter.isSelfHostedMode();
|
||||||
|
let refreshed = false;
|
||||||
|
|
||||||
if (isRemote) {
|
if (isRemote) {
|
||||||
|
// Self-hosted mode: use Spring Boot refresh endpoint
|
||||||
const serverConfig = await connectionModeService.getServerConfig();
|
const serverConfig = await connectionModeService.getServerConfig();
|
||||||
if (serverConfig) {
|
if (serverConfig) {
|
||||||
const refreshed = await authService.refreshToken(serverConfig.url);
|
refreshed = await authService.refreshToken(serverConfig.url);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// SaaS mode: use Supabase refresh endpoint
|
||||||
|
refreshed = await authService.refreshSupabaseToken(STIRLING_SAAS_URL);
|
||||||
|
}
|
||||||
|
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
// Retry the original request with new token
|
// Retry the original request with new token
|
||||||
const token = await authService.getAuthToken();
|
const token = await authService.getAuthToken();
|
||||||
|
console.debug(`[apiClientSetup] Token refreshed, retrying request to: ${originalRequest.url}`);
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
} else {
|
||||||
return client(originalRequest);
|
console.error(`[apiClientSetup] No token available after successful refresh!`);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh failed or not in remote mode - user needs to login again
|
return client.request(originalRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh failed - user needs to login again
|
||||||
alert({
|
alert({
|
||||||
alertType: 'error',
|
alertType: 'error',
|
||||||
title: i18n.t('auth.sessionExpired', 'Session Expired'),
|
title: i18n.t('auth.sessionExpired', 'Session Expired'),
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export class AuthService {
|
|||||||
private userInfo: UserInfo | null = null;
|
private userInfo: UserInfo | null = null;
|
||||||
private cachedToken: string | null = null;
|
private cachedToken: string | null = null;
|
||||||
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
|
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
|
||||||
|
private refreshPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
static getInstance(): AuthService {
|
static getInstance(): AuthService {
|
||||||
if (!AuthService.instance) {
|
if (!AuthService.instance) {
|
||||||
@@ -51,15 +52,17 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Save token to all storage locations and notify listeners
|
* Save token to all storage locations and notify listeners
|
||||||
*/
|
*/
|
||||||
private async saveTokenEverywhere(token: string): Promise<void> {
|
private async saveTokenEverywhere(token: string, refreshToken?: string | null): Promise<void> {
|
||||||
// Validate token before caching
|
// Validate token before caching
|
||||||
if (!token || token.trim().length === 0) {
|
if (!token || token.trim().length === 0) {
|
||||||
console.warn('[Desktop AuthService] Attempted to save invalid/empty token');
|
console.warn('[Desktop AuthService] Attempted to save invalid/empty token');
|
||||||
throw new Error('Invalid token');
|
throw new Error('Invalid token');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[Desktop AuthService] Saving token (length: ${token.length})`);
|
||||||
|
|
||||||
|
// Save access token to Tauri secure store (primary)
|
||||||
try {
|
try {
|
||||||
// Save to Tauri store
|
|
||||||
await invoke('save_auth_token', { token });
|
await invoke('save_auth_token', { token });
|
||||||
console.log('[Desktop AuthService] ✅ Token saved to Tauri store');
|
console.log('[Desktop AuthService] ✅ Token saved to Tauri store');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -67,8 +70,8 @@ export class AuthService {
|
|||||||
// Don't throw - we can still use localStorage
|
// Don't throw - we can still use localStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync to localStorage for web layer (fallback)
|
||||||
try {
|
try {
|
||||||
// Sync to localStorage for web layer
|
|
||||||
localStorage.setItem('stirling_jwt', token);
|
localStorage.setItem('stirling_jwt', token);
|
||||||
console.log('[Desktop AuthService] ✅ Token saved to localStorage');
|
console.log('[Desktop AuthService] ✅ Token saved to localStorage');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -79,6 +82,19 @@ export class AuthService {
|
|||||||
this.cachedToken = token;
|
this.cachedToken = token;
|
||||||
console.log('[Desktop AuthService] ✅ Token cached in memory');
|
console.log('[Desktop AuthService] ✅ Token cached in memory');
|
||||||
|
|
||||||
|
// Save refresh token if provided (keyring with Tauri Store fallback)
|
||||||
|
if (refreshToken) {
|
||||||
|
console.log('[Desktop AuthService] Saving refresh token to secure storage...');
|
||||||
|
try {
|
||||||
|
await invoke('save_refresh_token', { token: refreshToken });
|
||||||
|
console.log('[Desktop AuthService] ✅ Refresh token saved to secure storage');
|
||||||
|
// Only remove from localStorage after successful save
|
||||||
|
localStorage.removeItem('stirling_refresh_token');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Desktop AuthService] ❌ Failed to save refresh token:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Notify other parts of the system
|
// Notify other parts of the system
|
||||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||||
console.log('[Desktop AuthService] Dispatched jwt-available event');
|
console.log('[Desktop AuthService] Dispatched jwt-available event');
|
||||||
@@ -112,6 +128,19 @@ export class AuthService {
|
|||||||
return localStorageToken;
|
return localStorageToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get refresh token from secure storage (keyring or Tauri Store fallback)
|
||||||
|
*/
|
||||||
|
private async getRefreshToken(): Promise<string | null> {
|
||||||
|
const token = await invoke<string | null>('get_refresh_token');
|
||||||
|
if (token) {
|
||||||
|
console.log('[Desktop AuthService] ✅ Refresh token retrieved from secure storage');
|
||||||
|
} else {
|
||||||
|
console.log('[Desktop AuthService] No refresh token in secure storage');
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear token from all storage locations
|
* Clear token from all storage locations
|
||||||
*/
|
*/
|
||||||
@@ -120,20 +149,28 @@ export class AuthService {
|
|||||||
this.cachedToken = null;
|
this.cachedToken = null;
|
||||||
console.log('[Desktop AuthService] Cache invalidated');
|
console.log('[Desktop AuthService] Cache invalidated');
|
||||||
|
|
||||||
// Best effort: clear Tauri keyring
|
// Best effort: clear Tauri keyring (both access and refresh tokens)
|
||||||
try {
|
try {
|
||||||
await invoke('clear_auth_token');
|
await invoke('clear_auth_token');
|
||||||
console.log('[Desktop AuthService] Cleared Tauri keyring token');
|
console.log('[Desktop AuthService] Cleared Tauri keyring access token');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Desktop AuthService] Failed to clear Tauri keyring token', error);
|
console.warn('[Desktop AuthService] Failed to clear Tauri keyring access token', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke('clear_refresh_token');
|
||||||
|
console.log('[Desktop AuthService] Cleared Tauri keyring refresh token');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Desktop AuthService] Failed to clear Tauri keyring refresh token', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best effort: clear web storage
|
// Best effort: clear web storage
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem('stirling_jwt');
|
localStorage.removeItem('stirling_jwt');
|
||||||
console.log('[Desktop AuthService] Cleared localStorage token');
|
localStorage.removeItem('stirling_refresh_token');
|
||||||
|
console.log('[Desktop AuthService] Cleared localStorage tokens');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Desktop AuthService] Failed to clear localStorage token', error);
|
console.warn('[Desktop AuthService] Failed to clear localStorage tokens', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,6 +506,21 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refreshToken(serverUrl: string): Promise<boolean> {
|
async refreshToken(serverUrl: string): Promise<boolean> {
|
||||||
|
// Prevent concurrent refresh attempts - reuse in-flight refresh
|
||||||
|
if (this.refreshPromise) {
|
||||||
|
console.log('[Desktop AuthService] Refresh already in progress, awaiting existing refresh');
|
||||||
|
return this.refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshPromise = this._doRefreshToken(serverUrl);
|
||||||
|
try {
|
||||||
|
return await this.refreshPromise;
|
||||||
|
} finally {
|
||||||
|
this.refreshPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _doRefreshToken(serverUrl: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
console.log('[Desktop AuthService] Refreshing auth token');
|
console.log('[Desktop AuthService] Refreshing auth token');
|
||||||
this.setAuthStatus('refreshing', this.userInfo);
|
this.setAuthStatus('refreshing', this.userInfo);
|
||||||
@@ -511,6 +563,68 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async refreshSupabaseToken(authServerUrl: string): Promise<boolean> {
|
||||||
|
// Prevent concurrent refresh attempts - reuse in-flight refresh
|
||||||
|
if (this.refreshPromise) {
|
||||||
|
console.log('[Desktop AuthService] Refresh already in progress, awaiting existing refresh');
|
||||||
|
return this.refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshPromise = this._doRefreshSupabaseToken(authServerUrl);
|
||||||
|
try {
|
||||||
|
return await this.refreshPromise;
|
||||||
|
} finally {
|
||||||
|
this.refreshPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _doRefreshSupabaseToken(authServerUrl: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
console.log('[Desktop AuthService] Refreshing Supabase token');
|
||||||
|
this.setAuthStatus('refreshing', this.userInfo);
|
||||||
|
|
||||||
|
const refreshToken = await this.getRefreshToken();
|
||||||
|
if (!refreshToken) {
|
||||||
|
console.error('[Desktop AuthService] No refresh token available');
|
||||||
|
this.setAuthStatus('unauthenticated', null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call Supabase refresh endpoint
|
||||||
|
const response = await axios.post(
|
||||||
|
`${authServerUrl}/auth/v1/token?grant_type=refresh_token`,
|
||||||
|
{
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'apikey': SUPABASE_KEY,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { access_token, refresh_token: newRefreshToken } = response.data;
|
||||||
|
|
||||||
|
// Save new tokens
|
||||||
|
await this.saveTokenEverywhere(access_token, newRefreshToken);
|
||||||
|
|
||||||
|
const userInfo = await this.getUserInfo();
|
||||||
|
this.setAuthStatus('authenticated', userInfo);
|
||||||
|
|
||||||
|
console.log('[Desktop AuthService] Supabase token refreshed successfully');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Desktop AuthService] Supabase token refresh failed:', error);
|
||||||
|
this.setAuthStatus('unauthenticated', null);
|
||||||
|
|
||||||
|
// Clear stored credentials on refresh failure
|
||||||
|
await this.logout();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async initializeAuthState(): Promise<void> {
|
async initializeAuthState(): Promise<void> {
|
||||||
console.log('[Desktop AuthService] Initializing auth state...');
|
console.log('[Desktop AuthService] Initializing auth state...');
|
||||||
// If we are on the login/setup screen, don't auto-restore a previous session; clear instead
|
// If we are on the login/setup screen, don't auto-restore a previous session; clear instead
|
||||||
@@ -532,11 +646,7 @@ export class AuthService {
|
|||||||
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');
|
console.log('[Desktop AuthService] Found existing token and user info');
|
||||||
|
|
||||||
// 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');
|
console.log('[Desktop AuthService] Auth state initialized as authenticated');
|
||||||
} else {
|
} else {
|
||||||
@@ -576,9 +686,12 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log('[Desktop AuthService] OAuth authentication successful, storing tokens');
|
console.log('[Desktop AuthService] OAuth authentication successful, storing tokens');
|
||||||
|
console.log('[Desktop AuthService] OAuth result - has access_token:', !!result.access_token);
|
||||||
|
console.log('[Desktop AuthService] OAuth result - has refresh_token:', !!result.refresh_token);
|
||||||
|
console.log('[Desktop AuthService] OAuth result - expires_in:', result.expires_in);
|
||||||
|
|
||||||
// Save token to all storage locations
|
// Save token and refresh token to all storage locations
|
||||||
await this.saveTokenEverywhere(result.access_token);
|
await this.saveTokenEverywhere(result.access_token, result.refresh_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);
|
||||||
|
|||||||
Reference in New Issue
Block a user