Files
Stirling-PDF/frontend/src/desktop/services/authService.ts
T
0b86dd79d3 feat(security): add TOTP-based multi-factor authentication with backend and UI support (#5417)
# Description of Changes

This pull request introduces several improvements and new features
across the authentication and admin data APIs, with a particular focus
on multi-factor authentication (MFA) support and better handling of user
settings. The changes include integrating MFA status into account data
responses, masking sensitive user settings in admin views, and
refactoring code to use more robust user creation methods. Additionally,
there are minor code cleanups and consistency improvements.

### Multi-factor Authentication (MFA) Integration

* Added `mfaEnabled` and `mfaRequired` fields to the `AccountData`
response, populated using the new `MfaService`, to provide clients with
MFA status information for users.
[[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R430-R431)
[[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R72)
[[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L83-R86)
[[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R98)
[[5]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R574-R575)

### User Settings Handling and Security

* Admin settings API now returns user settings for each user, with the
`mfaSecret` field masked to protect sensitive information. This is
achieved by fetching settings via `findByIdWithSettings` and
copying/masking the relevant field before returning.
[[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R252-R259)
[[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L302-R322)
[[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R378)
[[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R563)

### Refactoring and Code Consistency

* Refactored user creation in `InitialSecuritySetup` to use the
`SaveUserRequest` builder and `saveUserCore` for better maintainability
and clarity, replacing direct calls to `saveUser`.
[[1]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850R22)
[[2]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L116-R124)
[[3]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L130-R144)
[[4]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L140-R160)
* Standardized checks for internal team membership by comparing with
`TeamService.INTERNAL_TEAM_NAME` on the left side for consistency.
[[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L267-R273)
[[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L332-R351)
[[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L421-R443)
[[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L449-R471)
[[5]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L462-R485)

### API and DTO Changes

* Changed the login API to accept `UsernameAndPassMfa` instead of
`UsernameAndPass`, paving the way for MFA code support in authentication
requests.
[[1]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L30-R41)
[[2]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L61-R71)
* Updated import statements and controller dependencies to include new
DTOs and services related to MFA.
[[1]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L14-R19)
[[2]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8R56-R57)

These updates improve security, prepare the system for MFA rollout, and
make admin and authentication APIs more robust and informative.

---

## 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: Copilot <[email protected]>
2026-01-23 21:34:57 +00:00

811 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { invoke, isTauri } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { open as shellOpen } from '@tauri-apps/plugin-shell';
import { connectionModeService } from '@app/services/connectionModeService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import axios from 'axios';
import { DESKTOP_DEEP_LINK_CALLBACK, STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection';
export interface UserInfo {
username: string;
email?: string;
}
export class AuthServiceError extends Error {
code?: string;
constructor(message: string, code?: string) {
super(message);
this.code = code;
}
}
interface LoginResponse {
token: string;
username: string;
email: string | null;
}
interface OAuthCallbackResult {
access_token: string;
refresh_token: string | null;
expires_in: number | null;
}
export type AuthStatus = 'authenticated' | 'unauthenticated' | 'refreshing' | 'oauth_pending';
export class AuthService {
private static instance: AuthService;
private authStatus: AuthStatus = 'unauthenticated';
private userInfo: UserInfo | null = null;
private cachedToken: string | null = null;
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
static getInstance(): AuthService {
if (!AuthService.instance) {
AuthService.instance = new AuthService();
}
return AuthService.instance;
}
/**
* Save token to all storage locations and notify listeners
*/
private async saveTokenEverywhere(token: string): Promise<void> {
// Validate token before caching
if (!token || token.trim().length === 0) {
console.warn('[Desktop AuthService] Attempted to save invalid/empty token');
throw new Error('Invalid token');
}
try {
// Save to Tauri store
await invoke('save_auth_token', { token });
console.log('[Desktop AuthService] ✅ Token saved to Tauri store');
} catch (error) {
console.error('[Desktop AuthService] ❌ Failed to save token to Tauri store:', error);
// Don't throw - we can still use localStorage
}
try {
// Sync to localStorage for web layer
localStorage.setItem('stirling_jwt', token);
console.log('[Desktop AuthService] ✅ Token saved to localStorage');
} catch (error) {
console.error('[Desktop AuthService] ❌ Failed to save token to localStorage:', error);
}
// Cache the valid token in memory
this.cachedToken = token;
console.log('[Desktop AuthService] ✅ Token cached in memory');
// 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
try {
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, checking localStorage...');
} catch (error) {
console.error('[Desktop AuthService] ❌ Failed to read from Tauri store:', error);
}
// Fallback to localStorage
const localStorageToken = localStorage.getItem('stirling_jwt');
if (localStorageToken) {
console.log(`[Desktop AuthService] ✅ Token found in localStorage (length: ${localStorageToken.length})`);
} else {
console.log('[Desktop AuthService] ❌ No token found in any storage');
}
return localStorageToken;
}
/**
* Clear token from all storage locations
*/
private async clearTokenEverywhere(): Promise<void> {
// Invalidate cache
this.cachedToken = null;
console.log('[Desktop AuthService] Cache invalidated');
// Best effort: clear Tauri keyring
try {
await invoke('clear_auth_token');
console.log('[Desktop AuthService] Cleared Tauri keyring token');
} catch (error) {
console.warn('[Desktop AuthService] Failed to clear Tauri keyring token', error);
}
// Best effort: clear web storage
try {
localStorage.removeItem('stirling_jwt');
console.log('[Desktop AuthService] Cleared localStorage token');
} catch (error) {
console.warn('[Desktop AuthService] Failed to clear localStorage token', error);
}
}
/**
* Local clear only (no backend calls) to reset auth state in desktop contexts
*/
async localClearAuth(): Promise<void> {
await this.clearTokenEverywhere().catch(() => {});
try {
await invoke('clear_user_info');
} catch (err) {
console.warn('[Desktop AuthService] Failed to clear user info', err);
}
this.setAuthStatus('unauthenticated', null);
}
subscribeToAuth(listener: (status: AuthStatus, userInfo: UserInfo | null) => void): () => void {
this.authListeners.add(listener);
// Immediately notify new listener of current state
listener(this.authStatus, this.userInfo);
return () => {
this.authListeners.delete(listener);
};
}
private notifyListeners() {
this.authListeners.forEach(listener => listener(this.authStatus, this.userInfo));
}
private setAuthStatus(status: AuthStatus, userInfo: UserInfo | null = null) {
this.authStatus = status;
this.userInfo = userInfo;
this.notifyListeners();
}
async completeSupabaseSession(accessToken: string, serverUrl: string): Promise<UserInfo> {
if (!accessToken || !accessToken.trim()) {
throw new Error('Invalid access token');
}
if (!SUPABASE_KEY) {
throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
}
await this.saveTokenEverywhere(accessToken);
const userInfo = await this.fetchSupabaseUserInfo(serverUrl, accessToken);
await invoke('save_user_info', {
username: userInfo.username,
email: userInfo.email || null,
});
this.setAuthStatus('authenticated', userInfo);
return userInfo;
}
async signUpSaas(email: string, password: string): Promise<void> {
if (!STIRLING_SAAS_URL) {
throw new Error('VITE_SAAS_SERVER_URL is not configured');
}
if (!SUPABASE_KEY) {
throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
}
const redirectParam = encodeURIComponent(DESKTOP_DEEP_LINK_CALLBACK);
const signupUrl = `${STIRLING_SAAS_URL.replace(/\/$/, '')}/auth/v1/signup?redirect_to=${redirectParam}`;
try {
const response = await axios.post(
signupUrl,
{ email, password, email_redirect_to: DESKTOP_DEEP_LINK_CALLBACK },
{
headers: {
'Content-Type': 'application/json;charset=UTF-8',
apikey: SUPABASE_KEY,
Authorization: `Bearer ${SUPABASE_KEY}`,
},
}
);
if (response.status >= 400) {
throw new Error('Sign up failed');
}
} catch (error) {
if (axios.isAxiosError(error)) {
const message =
error.response?.data?.error_description ||
error.response?.data?.msg ||
error.response?.data?.message ||
error.message;
throw new Error(message || 'Sign up failed');
}
throw error instanceof Error ? error : new Error('Sign up failed');
}
}
async login(serverUrl: string, username: string, password: string, mfaCode?: string): Promise<UserInfo> {
console.log(`[Desktop AuthService] 🔐 Starting login to: ${serverUrl}`);
console.log(`[Desktop AuthService] Username: ${username}`);
try {
// Validate SaaS configuration if connecting to SaaS
if (serverUrl === STIRLING_SAAS_URL) {
if (!STIRLING_SAAS_URL) {
console.error('[Desktop AuthService] ❌ VITE_SAAS_SERVER_URL is not configured');
throw new Error('VITE_SAAS_SERVER_URL is not configured');
}
if (!SUPABASE_KEY) {
console.error('[Desktop AuthService] ❌ VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
}
}
console.log('[Desktop AuthService] Invoking Rust login command...');
// Call Rust login command (bypasses CORS)
const response = await invoke<LoginResponse>('login', {
serverUrl,
username,
password,
mfaCode,
supabaseKey: SUPABASE_KEY,
saasServerUrl: STIRLING_SAAS_URL,
});
const { token, username: returnedUsername, email } = response;
console.log('[Desktop AuthService] ✅ Login response received');
console.log(`[Desktop AuthService] Username from response: ${returnedUsername || username}`);
// Save token to all storage locations
try {
console.log('[Desktop AuthService] Saving token to storage...');
await this.saveTokenEverywhere(token);
console.log('[Desktop AuthService] ✅ Token saved successfully');
} catch (error) {
console.error('[Desktop AuthService] ❌ Failed to save token:', error);
throw new Error('Failed to save authentication token');
}
// Save user info to store
console.log('[Desktop AuthService] Saving user info...');
await invoke('save_user_info', {
username: returnedUsername || username,
email,
});
console.log('[Desktop AuthService] ✅ User info saved');
const userInfo: UserInfo = {
username: returnedUsername || username,
email: email || undefined,
};
this.setAuthStatus('authenticated', userInfo);
console.log('[Desktop AuthService] ✅ Login completed successfully');
return userInfo;
} catch (error) {
console.error('[Desktop AuthService] ❌ Login failed:', error);
// Provide more detailed error messages based on the error type
if (error instanceof Error || typeof error === 'string') {
const rawMessage = typeof error === 'string' ? error : error.message;
const errMsg = rawMessage.toLowerCase();
if (errMsg.includes('mfa_required')) {
this.setAuthStatus('unauthenticated', null);
console.error('[Desktop AuthService] Two-factor authentication required');
throw new AuthServiceError('Two-factor code required.', 'mfa_required');
}
if (errMsg.includes('invalid_mfa_code')) {
this.setAuthStatus('unauthenticated', null);
console.error('[Desktop AuthService] Invalid two-factor code provided');
throw new AuthServiceError('Invalid two-factor code.', 'invalid_mfa_code');
}
// Authentication errors
if (errMsg.includes('401') || errMsg.includes('unauthorized') || errMsg.includes('invalid credentials')) {
console.error('[Desktop AuthService] Authentication failed - invalid credentials');
this.setAuthStatus('unauthenticated', null);
throw new Error('Invalid username or password. Please check your credentials and try again.');
}
// Server not found or unreachable
else if (errMsg.includes('connection refused') || errMsg.includes('econnrefused')) {
console.error('[Desktop AuthService] Server connection refused');
this.setAuthStatus('unauthenticated', null);
throw new Error('Cannot connect to server. Please check the server URL and ensure the server is running.');
}
// Timeout
else if (errMsg.includes('timeout') || errMsg.includes('timed out')) {
console.error('[Desktop AuthService] Login request timed out');
this.setAuthStatus('unauthenticated', null);
throw new Error('Login request timed out. Please check your network connection and try again.');
}
// DNS failure
else if (errMsg.includes('getaddrinfo') || errMsg.includes('dns') || errMsg.includes('not found') || errMsg.includes('enotfound')) {
console.error('[Desktop AuthService] DNS resolution failed');
this.setAuthStatus('unauthenticated', null);
throw new Error('Cannot resolve server address. Please check the server URL is correct.');
}
// SSL/TLS errors
else if (errMsg.includes('ssl') || errMsg.includes('tls') || errMsg.includes('certificate') || errMsg.includes('cert')) {
console.error('[Desktop AuthService] SSL/TLS error');
this.setAuthStatus('unauthenticated', null);
throw new Error('SSL/TLS certificate error. Server may have an invalid or self-signed certificate.');
}
// 404 - endpoint not found
else if (errMsg.includes('404') || errMsg.includes('not found')) {
console.error('[Desktop AuthService] Login endpoint not found');
this.setAuthStatus('unauthenticated', null);
throw new Error('Login endpoint not found. Please ensure you are connecting to a valid Stirling PDF server.');
}
// 403 - security disabled
else if (errMsg.includes('403') || errMsg.includes('forbidden')) {
console.error('[Desktop AuthService] Login disabled on server');
this.setAuthStatus('unauthenticated', null);
throw new Error('Login is not enabled on this server. Please enable security mode (DOCKER_ENABLE_SECURITY=true).');
}
}
// Generic error fallback
this.setAuthStatus('unauthenticated', null);
throw error;
}
}
async logout(): Promise<void> {
try {
console.log('Logging out');
// Best-effort backend logout so any server-side session/cookies are cleared
try {
const currentConfig = await connectionModeService.getCurrentConfig().catch(() => null);
const serverUrl = currentConfig?.server_config?.url;
const token = await this.getAuthToken();
if (serverUrl && token) {
const base = serverUrl.replace(/\/+$/, '');
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
// Treat 401/403 as benign (session already expired)
const safePost = async (url: string) => {
try {
const resp = await axios.post(url, null, {
headers,
withCredentials: true,
validateStatus: () => true, // handle status manually
});
if (resp.status >= 400 && ![401, 403].includes(resp.status)) {
console.warn(`[Desktop AuthService] Logout call to ${url} failed: ${resp.status}`);
}
} catch (err) {
console.warn(`[Desktop AuthService] Backend logout failed via ${url}`, err);
}
};
await safePost(`${base}/api/v1/auth/logout`);
// Also attempt framework logout endpoint to clear cookies/sessions
await safePost(`${base}/logout`);
}
} catch (err) {
console.warn('[Desktop AuthService] Failed to call backend logout endpoint', err);
}
// Clear token from all storage locations
await this.clearTokenEverywhere();
// Clear user info from Tauri store
await invoke('clear_user_info');
this.setAuthStatus('unauthenticated', null);
console.log('Logged out successfully');
} catch (error) {
console.error('Error during logout:', error);
// Still set status to unauthenticated even if clear fails
this.setAuthStatus('unauthenticated', null);
// Still try to clear token
await this.clearTokenEverywhere().catch(() => {});
}
}
async getAuthToken(): Promise<string | null> {
try {
// Return cached token if available
if (this.cachedToken) {
console.debug('[Desktop AuthService] ✅ Returning cached token');
return this.cachedToken;
}
console.debug('[Desktop AuthService] Cache miss, fetching from storage...');
const token = await this.getTokenFromAnySource();
// Cache the token if valid
if (token && token.trim().length > 0) {
this.cachedToken = token;
console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval');
}
return token;
} catch (error) {
console.error('[Desktop AuthService] Failed to get auth token:', error);
return null;
}
}
async isAuthenticated(): Promise<boolean> {
const token = await this.getAuthToken();
return token !== null;
}
async getUserInfo(): Promise<UserInfo | null> {
if (this.userInfo) {
console.log('[Desktop AuthService] Using cached user info:', this.userInfo.username);
return this.userInfo;
}
try {
console.log('[Desktop AuthService] Retrieving user info from store...');
const userInfo = await invoke<UserInfo | null>('get_user_info');
if (userInfo) {
console.log('[Desktop AuthService] User info found:', userInfo.username);
this.userInfo = userInfo;
} else {
console.log('[Desktop AuthService] No user info in store');
}
return userInfo;
} catch (error) {
console.error('[Desktop AuthService] Failed to get user info from store:', error);
return null;
}
}
async refreshToken(serverUrl: string): Promise<boolean> {
try {
console.log('[Desktop AuthService] Refreshing auth token');
this.setAuthStatus('refreshing', this.userInfo);
const currentToken = await this.getAuthToken();
if (!currentToken) {
this.setAuthStatus('unauthenticated', null);
return false;
}
// Call the server's refresh endpoint
const response = await axios.post(
`${serverUrl}/api/v1/auth/refresh`,
{},
{
headers: {
Authorization: `Bearer ${currentToken}`,
},
}
);
const { token } = response.data;
// Save token to all storage locations
await this.saveTokenEverywhere(token);
const userInfo = await this.getUserInfo();
this.setAuthStatus('authenticated', userInfo);
console.log('[Desktop AuthService] Token refreshed successfully');
return true;
} catch (error) {
console.error('[Desktop AuthService] Token refresh failed:', error);
this.setAuthStatus('unauthenticated', null);
// Clear stored credentials on refresh failure
await this.logout();
return false;
}
}
async initializeAuthState(): Promise<void> {
console.log('[Desktop AuthService] Initializing auth state...');
// If we are on the login/setup screen, don't auto-restore a previous session; clear instead
const path = typeof window !== 'undefined' ? window.location.pathname : '';
if (path.startsWith('/login') || path.startsWith('/setup')) {
console.log('[Desktop AuthService] On login/setup path, clearing any cached auth');
// Local clear only; avoid backend logout to prevent noisy errors when already unauthenticated
await this.clearTokenEverywhere().catch(() => {});
try {
await invoke('clear_user_info');
} catch (err) {
console.warn('[Desktop AuthService] Failed to clear user info on login/setup init', err);
}
this.setAuthStatus('unauthenticated', null);
return;
}
const token = await this.getAuthToken();
const userInfo = await this.getUserInfo();
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);
console.log('[Desktop AuthService] Auth state initialized as authenticated');
} else {
console.log('[Desktop AuthService] No token or user info found');
this.setAuthStatus('unauthenticated', null);
console.log('[Desktop AuthService] Auth state initialized as unauthenticated');
// Defensive: ensure any partial tokens are purged to prevent auto-login loops
await this.clearTokenEverywhere().catch(() => {});
}
}
/**
* Start OAuth login flow by opening system browser with localhost callback
*/
async loginWithOAuth(provider: string, authServerUrl: string, successHtml: string, errorHtml: string): Promise<UserInfo> {
try {
console.log('[Desktop AuthService] Starting OAuth login with provider:', provider);
this.setAuthStatus('oauth_pending', null);
// Validate Supabase key is configured for OAuth
if (!SUPABASE_KEY) {
throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
}
// Call Rust command which:
// 1. Starts localhost HTTP server on random port
// 2. Opens browser to OAuth provider
// 3. Waits for callback
// 4. Returns tokens
const result = await invoke<OAuthCallbackResult>('start_oauth_login', {
provider,
authServerUrl,
supabaseKey: SUPABASE_KEY,
successHtml,
errorHtml,
});
console.log('[Desktop AuthService] OAuth authentication successful, storing tokens');
// Save token to all storage locations
await this.saveTokenEverywhere(result.access_token);
// Fetch user info from Supabase using the access token
const userInfo = await this.fetchSupabaseUserInfo(authServerUrl, result.access_token);
// Save user info to store
await invoke('save_user_info', {
username: userInfo.username,
email: userInfo.email || null,
});
this.setAuthStatus('authenticated', userInfo);
console.log('[Desktop AuthService] OAuth login successful');
return userInfo;
} catch (error) {
console.error('[Desktop AuthService] Failed to complete OAuth login:', error);
this.setAuthStatus('unauthenticated', null);
throw error;
}
}
/**
* Self-hosted SSO/OAuth2 flow for the desktop app.
1 * Opens the system browser and waits for a deep link callback with the JWT.
*/
async loginWithSelfHostedOAuth(providerPath: string, serverUrl: string): Promise<UserInfo> {
// Generate and store nonce for CSRF protection
const nonce = crypto.randomUUID();
sessionStorage.setItem('oauth_nonce', nonce);
console.log('[Desktop AuthService] Generated OAuth nonce for CSRF protection');
const trimmedServer = serverUrl.replace(/\/+$/, '');
const fullUrl = providerPath.startsWith('http')
? providerPath
: `${trimmedServer}${providerPath.startsWith('/') ? providerPath : `/${providerPath}`}`;
let authUrl = fullUrl;
try {
const parsed = new URL(fullUrl);
parsed.searchParams.set('tauri', '1');
parsed.searchParams.set('nonce', nonce);
authUrl = parsed.toString();
} catch {
// ignore URL parsing failures
}
// Open in system browser and wait for deep link callback
if (await this.openInSystemBrowser(authUrl)) {
return this.waitForDeepLinkCompletion(trimmedServer);
}
throw new Error('Unable to open system browser for SSO. Please check your system settings.');
}
/**
* Wait for a deep-link event to complete self-hosted SSO after system browser OAuth
*/
private async waitForDeepLinkCompletion(serverUrl: string): Promise<UserInfo> {
if (!isTauri()) {
throw new Error('Deep link authentication is only supported in Tauri desktop app.');
}
return new Promise<UserInfo>((resolve, reject) => {
let completed = false;
let unlisten: (() => void) | null = null;
const timeoutId = window.setTimeout(() => {
if (!completed) {
completed = true;
if (unlisten) unlisten();
sessionStorage.removeItem('oauth_nonce');
reject(new Error('SSO login timed out. Please try again.'));
}
}, 120_000);
listen<string>('deep-link', async (event) => {
const url = event.payload;
if (!url || completed) return;
try {
const parsed = new URL(url);
const hash = parsed.hash.replace(/^#/, '');
const params = new URLSearchParams(hash);
const type = params.get('type') || parsed.searchParams.get('type');
const error = params.get('error') || parsed.searchParams.get('error');
if (type === 'sso-error' || error) {
completed = true;
if (unlisten) unlisten();
clearTimeout(timeoutId);
sessionStorage.removeItem('oauth_nonce');
reject(new Error(error || 'Authentication was not successful.'));
return;
}
if (type !== 'sso' && type !== 'sso-selfhosted') {
return;
}
const token = params.get('access_token') || parsed.searchParams.get('access_token');
if (!token) {
return;
}
// CSRF Protection: Validate nonce before accepting token
const nonceFromUrl = params.get('nonce') || parsed.searchParams.get('nonce');
const storedNonce = sessionStorage.getItem('oauth_nonce');
if (!nonceFromUrl || !storedNonce || nonceFromUrl !== storedNonce) {
completed = true;
if (unlisten) unlisten();
clearTimeout(timeoutId);
sessionStorage.removeItem('oauth_nonce');
console.error('[Desktop AuthService] Nonce validation failed - potential CSRF attack');
reject(new Error('Invalid authentication state. Nonce validation failed.'));
return;
}
completed = true;
if (unlisten) unlisten();
clearTimeout(timeoutId);
sessionStorage.removeItem('oauth_nonce');
console.log('[Desktop AuthService] Nonce validated successfully');
const userInfo = await this.completeSelfHostedSession(serverUrl, token);
// Ensure connection mode is set and backend is ready (in case caller doesn't)
try {
await connectionModeService.switchToSelfHosted({ url: serverUrl });
await tauriBackendService.initializeExternalBackend();
} catch (e) {
console.warn('[Desktop AuthService] Failed to initialize backend after deep link:', e);
}
resolve(userInfo);
} catch (err) {
completed = true;
if (unlisten) unlisten();
clearTimeout(timeoutId);
sessionStorage.removeItem('oauth_nonce');
reject(err instanceof Error ? err : new Error('Failed to complete SSO'));
}
}).then((fn) => {
unlisten = fn;
});
});
}
private async openInSystemBrowser(url: string): Promise<boolean> {
if (!isTauri()) {
return false;
}
try {
// Prefer plugin-shell (2.x) if available
await shellOpen(url);
return true;
} catch (err) {
console.error('Failed to open system browser for SSO:', err);
return false;
}
}
/**
* Save JWT + user info for self-hosted SSO logins
*/
async completeSelfHostedSession(serverUrl: string, token: string): Promise<UserInfo> {
const userInfo = await this.fetchSelfHostedUserInfo(serverUrl, token);
await this.saveTokenEverywhere(token);
await invoke('save_user_info', {
username: userInfo.username,
email: userInfo.email || null,
});
this.setAuthStatus('authenticated', userInfo);
return userInfo;
}
private async fetchSelfHostedUserInfo(serverUrl: string, token: string): Promise<UserInfo> {
try {
const response = await axios.get(
`${serverUrl.replace(/\/+$/, '')}/api/v1/auth/me`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
const data = response.data;
const user = data.user || data;
return {
username: user.username || user.email || 'User',
email: user.email || undefined,
};
} catch (error) {
console.error('[Desktop AuthService] Failed to fetch user info after SSO:', error);
throw error;
}
}
/**
* Fetch user info from Supabase using access token
*/
private async fetchSupabaseUserInfo(authServerUrl: string, accessToken: string): Promise<UserInfo> {
try {
const userEndpoint = `${authServerUrl}/auth/v1/user`;
const response = await axios.get(userEndpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
'apikey': SUPABASE_KEY,
},
});
const data = response.data;
console.log('User info fetched:', data.email);
return {
username: data.user_metadata?.full_name || data.email || 'Unknown',
email: data.email,
};
} catch (error) {
console.error('Failed to fetch user info from Supabase:', error);
// Fallback to basic info
return {
username: 'User',
email: undefined,
};
}
}
}
export const authService = AuthService.getInstance();