mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-04 02:20:19 +01:00
fixes for desktop SSO (#5751)
# Description of Changes Race condition: The browser was opened before the deep-link listener was registered. On slow first launches the OAuth callback could arrive before the listener was ready. Fixed by registering the listener first, then opening the browser inside .then() once the listener is confirmed active. Double-handling: Both SetupWizard and authService.waitForDeepLinkCompletion processed the same sso/sso-selfhosted deep links, calling completeSelfHostedSession and onComplete() independently. Fixed by moving all SSO completion into authService and having SetupWizard defer to it. Hardening: Removing SetupWizard's handler entirely left no fallback if the webview reloads while the auth listener's Promise is in flight. A selfHostedDeepLinkFlowActive flag tracks whether authService has an active listener. SetupWizard now acts as a fallback only when the flag is false (i.e. after a JS context reset), preventing duplicate handling in the normal path while preserving resilience on reload. --- ## 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.
This commit is contained in:
@@ -213,12 +213,16 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
const params = new URLSearchParams(hash);
|
||||
const accessToken = params.get('access_token');
|
||||
const type = params.get('type') || parsed.searchParams.get('type');
|
||||
const accessTokenFromHash = params.get('access_token');
|
||||
const accessTokenFromQuery = parsed.searchParams.get('access_token');
|
||||
const serverFromQuery = parsed.searchParams.get('server');
|
||||
|
||||
// Handle self-hosted SSO deep link
|
||||
// Self-hosted SSO deep links are normally handled by authService.loginWithSelfHostedOAuth.
|
||||
// Fallback here only if no in-flight auth listener exists (e.g. renderer reload mid-flow).
|
||||
if (type === 'sso' || type === 'sso-selfhosted') {
|
||||
if (authService.isSelfHostedDeepLinkFlowActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accessTokenFromHash = params.get('access_token');
|
||||
const accessTokenFromQuery = parsed.searchParams.get('access_token');
|
||||
const serverFromQuery = parsed.searchParams.get('server');
|
||||
const token = accessTokenFromHash || accessTokenFromQuery;
|
||||
const serverUrl = serverFromQuery || serverConfig?.url || STIRLING_SAAS_URL;
|
||||
if (!token || !serverUrl) {
|
||||
|
||||
@@ -42,6 +42,7 @@ export class AuthService {
|
||||
private lastTokenSaveTime: number = 0;
|
||||
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
|
||||
private refreshPromise: Promise<boolean> | null = null;
|
||||
private selfHostedDeepLinkFlowActive = false;
|
||||
|
||||
static getInstance(): AuthService {
|
||||
if (!AuthService.instance) {
|
||||
@@ -176,6 +177,10 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
isSelfHostedDeepLinkFlowActive(): boolean {
|
||||
return this.selfHostedDeepLinkFlowActive;
|
||||
}
|
||||
|
||||
private notifyListeners() {
|
||||
this.authListeners.forEach(listener => listener(this.authStatus, this.userInfo));
|
||||
}
|
||||
@@ -782,22 +787,27 @@ export class AuthService {
|
||||
// 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.');
|
||||
// Register deep-link listener before opening browser to avoid callback races on first launch.
|
||||
return this.waitForDeepLinkCompletion(trimmedServer, async () => {
|
||||
if (!(await this.openInSystemBrowser(authUrl))) {
|
||||
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> {
|
||||
private async waitForDeepLinkCompletion(
|
||||
serverUrl: string,
|
||||
startFlow?: () => Promise<void>
|
||||
): Promise<UserInfo> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Deep link authentication is only supported in Tauri desktop app.');
|
||||
}
|
||||
|
||||
this.selfHostedDeepLinkFlowActive = true;
|
||||
|
||||
return new Promise<UserInfo>((resolve, reject) => {
|
||||
let completed = false;
|
||||
let unlisten: (() => void) | null = null;
|
||||
@@ -807,6 +817,7 @@ export class AuthService {
|
||||
completed = true;
|
||||
if (unlisten) unlisten();
|
||||
sessionStorage.removeItem('oauth_nonce');
|
||||
this.selfHostedDeepLinkFlowActive = false;
|
||||
reject(new Error('SSO login timed out. Please try again.'));
|
||||
}
|
||||
}, 120_000);
|
||||
@@ -825,6 +836,7 @@ export class AuthService {
|
||||
if (unlisten) unlisten();
|
||||
clearTimeout(timeoutId);
|
||||
sessionStorage.removeItem('oauth_nonce');
|
||||
this.selfHostedDeepLinkFlowActive = false;
|
||||
reject(new Error(error || 'Authentication was not successful.'));
|
||||
return;
|
||||
}
|
||||
@@ -845,6 +857,7 @@ export class AuthService {
|
||||
if (unlisten) unlisten();
|
||||
clearTimeout(timeoutId);
|
||||
sessionStorage.removeItem('oauth_nonce');
|
||||
this.selfHostedDeepLinkFlowActive = false;
|
||||
console.error('[Desktop AuthService] Nonce validation failed - potential CSRF attack');
|
||||
reject(new Error('Invalid authentication state. Nonce validation failed.'));
|
||||
return;
|
||||
@@ -854,6 +867,7 @@ export class AuthService {
|
||||
if (unlisten) unlisten();
|
||||
clearTimeout(timeoutId);
|
||||
sessionStorage.removeItem('oauth_nonce');
|
||||
this.selfHostedDeepLinkFlowActive = false;
|
||||
console.log('[Desktop AuthService] Nonce validated successfully');
|
||||
|
||||
const userInfo = await this.completeSelfHostedSession(serverUrl, token);
|
||||
@@ -870,10 +884,39 @@ export class AuthService {
|
||||
if (unlisten) unlisten();
|
||||
clearTimeout(timeoutId);
|
||||
sessionStorage.removeItem('oauth_nonce');
|
||||
this.selfHostedDeepLinkFlowActive = false;
|
||||
reject(err instanceof Error ? err : new Error('Failed to complete SSO'));
|
||||
}
|
||||
}).then((fn) => {
|
||||
}).then(async (fn) => {
|
||||
unlisten = fn;
|
||||
|
||||
if (!startFlow || completed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await startFlow();
|
||||
} catch (err) {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
completed = true;
|
||||
if (unlisten) unlisten();
|
||||
clearTimeout(timeoutId);
|
||||
sessionStorage.removeItem('oauth_nonce');
|
||||
this.selfHostedDeepLinkFlowActive = false;
|
||||
reject(err instanceof Error ? err : new Error('Failed to start SSO login'));
|
||||
}
|
||||
}).catch((err) => {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
completed = true;
|
||||
if (unlisten) unlisten();
|
||||
clearTimeout(timeoutId);
|
||||
sessionStorage.removeItem('oauth_nonce');
|
||||
this.selfHostedDeepLinkFlowActive = false;
|
||||
reject(err instanceof Error ? err : new Error('Failed to listen for deep link events'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user