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:
Anthony Stirling
2026-02-18 10:46:15 +00:00
committed by GitHub
parent e97f93924e
commit ddf93d2b1a
2 changed files with 60 additions and 13 deletions

View File

@@ -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) {

View File

@@ -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'));
});
});
}