Initial SSO self hosted desktop

This commit is contained in:
James Brunton 2025-12-17 17:01:03 +00:00
parent 49bea34576
commit e3a0ff73ec
12 changed files with 622 additions and 118 deletions

View File

@ -141,10 +141,10 @@ public class SPDFApplication {
String backendUrl = appConfig.getBackendUrl();
String contextPath = appConfig.getContextPath();
String serverPort = appConfig.getServerPort();
baseUrlStatic = backendUrl;
baseUrlStatic = normalizeBackendUrl(backendUrl, serverPort);
contextPathStatic = contextPath;
serverPortStatic = serverPort;
String url = backendUrl + ":" + getStaticPort() + contextPath;
String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic);
// Log Tauri mode information
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
@ -210,7 +210,7 @@ public class SPDFApplication {
private static void printStartupLogs() {
log.info("Stirling-PDF Started.");
String url = baseUrlStatic + ":" + getStaticPort() + contextPathStatic;
String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic);
log.info("Navigate to {}", url);
}
@ -258,4 +258,40 @@ public class SPDFApplication {
public static String getStaticContextPath() {
return contextPathStatic;
}
private static String buildFullUrl(String backendUrl, String port, String contextPath) {
String normalizedBase = normalizeBackendUrl(backendUrl, port);
String normalizedContextPath =
(contextPath == null || contextPath.isBlank() || "/".equals(contextPath))
? "/"
: (contextPath.startsWith("/") ? contextPath : "/" + contextPath);
return normalizedBase + normalizedContextPath;
}
private static String normalizeBackendUrl(String backendUrl, String port) {
String trimmedBase =
(backendUrl == null || backendUrl.isBlank())
? "http://localhost"
: backendUrl.trim().replaceAll("/+$", "");
try {
java.net.URI uri = new java.net.URI(trimmedBase);
boolean hasPort = uri.getPort() != -1;
boolean defaultHttp = "http".equalsIgnoreCase(uri.getScheme()) && "80".equals(port);
boolean defaultHttps = "https".equalsIgnoreCase(uri.getScheme()) && "443".equals(port);
if (hasPort || defaultHttp || defaultHttps) {
return trimmedBase;
}
} catch (java.net.URISyntaxException e) {
// If parsing fails, fall back to a simple suffix check
if (trimmedBase.matches(".+:\\d+$")) {
return trimmedBase;
}
}
return trimmedBase + ":" + port;
}
}

View File

@ -19,20 +19,22 @@ import org.springframework.web.bind.annotation.GetMapping;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
@Slf4j
@Controller
public class ReactRoutingController {
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(ReactRoutingController.class);
@Value("${server.servlet.context-path:/}")
private String contextPath;
private String cachedIndexHtml;
private String cachedCallbackHtml;
private boolean indexHtmlExists = false;
private boolean useExternalIndexHtml = false;
private boolean loggedMissingIndex = false;
@PostConstruct
public void init() {
@ -43,56 +45,72 @@ public class ReactRoutingController {
log.debug("Checking for custom index.html at: {}", externalIndexPath);
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
log.info("Using custom index.html from: {}", externalIndexPath);
try {
this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true;
this.useExternalIndexHtml = true;
return;
} catch (IOException e) {
log.warn("Failed to load custom index.html, falling back to classpath", e);
}
this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true;
this.useExternalIndexHtml = true;
return;
}
// Fall back to classpath index.html
ClassPathResource resource = new ClassPathResource("static/index.html");
if (resource.exists()) {
try {
this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true;
this.useExternalIndexHtml = false;
} catch (IOException e) {
// Failed to cache, will process on each request
log.warn("Failed to cache index.html", e);
this.indexHtmlExists = false;
this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true;
this.useExternalIndexHtml = false;
return;
}
// Neither external nor classpath index.html exists - cache fallback once
this.cachedIndexHtml = buildFallbackHtml();
this.cachedCallbackHtml = buildCallbackHtml();
this.indexHtmlExists = true;
this.useExternalIndexHtml = false;
this.loggedMissingIndex = true;
log.warn(
"index.html not found in classpath or custom path; using lightweight fallback page");
}
private String processIndexHtml() {
try {
Resource resource = getIndexHtmlResource();
if (!resource.exists()) {
if (!loggedMissingIndex) {
log.warn("index.html not found, using lightweight fallback page");
loggedMissingIndex = true;
}
return buildFallbackHtml();
}
try (InputStream inputStream = resource.getInputStream()) {
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
// Replace %BASE_URL% with the actual context path for base href
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
html = html.replace("%BASE_URL%", baseUrl);
// Also rewrite any existing <base> tag (Vite may have baked one in)
html =
html.replaceFirst(
"<base href=\\\"[^\\\"]*\\\"\\s*/?>",
"<base href=\\\"" + baseUrl + "\\\" />");
// Inject context path as a global variable for API calls
String contextPathScript =
"<script>window.STIRLING_PDF_API_BASE_URL = '" + baseUrl + "';</script>";
html = html.replace("</head>", contextPathScript + "</head>");
return html;
}
} catch (Exception ex) {
if (!loggedMissingIndex) {
log.warn("index.html not found, using lightweight fallback page", ex);
loggedMissingIndex = true;
}
return buildFallbackHtml();
}
}
private String processIndexHtml() throws IOException {
Resource resource = getIndexHtmlResource();
try (InputStream inputStream = resource.getInputStream()) {
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
// Replace %BASE_URL% with the actual context path for base href
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
html = html.replace("%BASE_URL%", baseUrl);
// Also rewrite any existing <base> tag (Vite may have baked one in)
html =
html.replaceFirst(
"<base href=\\\"[^\\\"]*\\\"\\s*/?>",
"<base href=\\\"" + baseUrl + "\\\" />");
// Inject context path as a global variable for API calls
String contextPathScript =
"<script>window.STIRLING_PDF_API_BASE_URL = '" + baseUrl + "';</script>";
html = html.replace("</head>", contextPathScript + "</head>");
return html;
}
}
private Resource getIndexHtmlResource() throws IOException {
private Resource getIndexHtmlResource() {
// Check external location first
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
@ -106,12 +124,25 @@ public class ReactRoutingController {
@GetMapping(
value = {"/", "/index.html"},
produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) throws IOException {
if (indexHtmlExists && cachedIndexHtml != null) {
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) {
try {
if (indexHtmlExists && cachedIndexHtml != null) {
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
}
// Fallback: process on each request (dev mode or cache failed)
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml());
} catch (Exception ex) {
log.error("Failed to serve index.html, returning fallback", ex);
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(buildFallbackHtml());
}
// Fallback: process on each request (dev mode or cache failed)
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml());
}
@GetMapping(value = "/auth/callback", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveAuthCallback() {
if (cachedCallbackHtml == null) {
cachedCallbackHtml = buildCallbackHtml();
}
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedCallbackHtml);
}
@GetMapping(
@ -126,4 +157,104 @@ public class ReactRoutingController {
throws IOException {
return serveIndexHtml(request);
}
private String buildFallbackHtml() {
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
String serverUrl = "(window.location.origin + '" + baseUrl + "')";
return """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<base href="%s" />
<title>Stirling PDF</title>
<script>
// Minimal handler for SSO callback when index.html is missing (desktop fallback)
(function() {
const baseUrl = '%s';
window.STIRLING_PDF_API_BASE_URL = baseUrl;
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const searchParams = new URLSearchParams(window.location.search);
const token = hashParams.get('access_token') || hashParams.get('token') || searchParams.get('access_token');
const isDesktopPopup = window.opener && window.name === 'stirling-desktop-sso';
const serverUrl = %s;
if (token) {
try { localStorage.setItem('stirling_jwt', token); } catch (_) {}
try { window.dispatchEvent(new Event('jwt-available')); } catch (_) {}
if (isDesktopPopup) {
try { window.opener.postMessage({ type: 'stirling-desktop-sso', token }, '*'); } catch (_) {}
setTimeout(() => { try { window.close(); } catch (_) {} }, 150);
return;
}
// Trigger deep link back to desktop app with token + server info
try {
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodeURIComponent(serverUrl)}#access_token=${encodeURIComponent(token)}&type=sso-selfhosted`;
window.location.href = deepLink;
return;
} catch (_) {
// ignore deep link errors
}
}
// No redirect to avoid loops when index.html is missing
})();
</script>
</head>
<body>
<p>Stirling PDF is running.</p>
</body>
</html>
"""
.formatted(baseUrl, baseUrl, serverUrl);
}
private String buildCallbackHtml() {
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
String serverUrl = "(window.location.origin + '" + baseUrl + "')";
return """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<base href="%s" />
<title>Authentication Complete</title>
<script>
(function() {
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const searchParams = new URLSearchParams(window.location.search);
const token = hashParams.get('access_token') || hashParams.get('token') || searchParams.get('access_token');
const isDesktopPopup = window.opener && window.name === 'stirling-desktop-sso';
const serverUrl = %s;
if (token) {
try { localStorage.setItem('stirling_jwt', token); } catch (_) {}
try { window.dispatchEvent(new Event('jwt-available')); } catch (_) {}
if (isDesktopPopup) {
try { window.opener.postMessage({ type: 'stirling-desktop-sso', token }, '*'); } catch (_) {}
setTimeout(() => { try { window.close(); } catch (_) {} }, 150);
return;
}
try {
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodeURIComponent(serverUrl)}#access_token=${encodeURIComponent(token)}&type=sso-selfhosted`;
window.location.href = deepLink;
return;
} catch (_) {
// ignore deep link errors
}
}
})();
</script>
</head>
<body>
<p>Authentication complete. You can close this window.</p>
</body>
</html>
"""
.formatted(baseUrl, serverUrl);
}
}

View File

@ -49,6 +49,7 @@
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-fs": "^2.4.0",
"@tauri-apps/plugin-http": "^2.5.4",
"@tauri-apps/plugin-shell": "^2.3.3",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"globals": "^16.4.0",
@ -457,7 +458,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -501,7 +501,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -582,7 +581,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.5.0.tgz",
"integrity": "sha512-Yrh9XoVaT8cUgzgqpJ7hx5wg6BqQrCFirqqlSwVb+Ly9oNn4fZbR9GycIWmzJOU5XBnaOJjXfQSaDyoNP0woNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "1.5.0",
"@embedpdf/models": "1.5.0"
@ -682,7 +680,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.5.0.tgz",
"integrity": "sha512-p7PTNNaIr4gH3jLwX+eLJe1DeUXgi21kVGN6SRx/pocH8esg4jqoOeD/YiRRZoZnPOiy0jBXVhkPkwSmY7a2hQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -699,7 +696,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.5.0.tgz",
"integrity": "sha512-ckHgTfvkW6c5Ta7Mc+Dl9C2foVnvEpqEJ84wyBnqrU0OWbe/jsiPhyKBVeartMGqNI/kVfaQTXupyrKhekAVmg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -717,7 +713,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.5.0.tgz",
"integrity": "sha512-P4YpIZfaW69etYIjphyaL4cGl2pB14h3OdTE0tRQ2pZYZHFLTvlt4q9B3PVSdhlSrHK5nob7jfLGon2U7xCslg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -771,7 +766,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.5.0.tgz",
"integrity": "sha512-ywwSj0ByrlkvrJIHKRzqxARkOZriki8VJUC+T4MV8fGyF4CzvCRJyKlPktahFz+VxhoodqTh7lBCib68dH+GvA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -806,7 +800,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.5.0.tgz",
"integrity": "sha512-RNmTZCZ8X1mA8cw9M7TMDuhO9GtkOalGha2bBL3En3D1IlDRS7PzNNMSMV7eqT7OQICSTltlpJ8p8Qi5esvL/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -843,7 +836,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.5.0.tgz",
"integrity": "sha512-zrxLBAZQoPswDuf9q9DrYaQc6B0Ysc2U1hueTjNH/4+ydfl0BFXZkKR63C2e3YmWtXvKjkoIj0GyPzsiBORLUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -919,7 +911,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.5.0.tgz",
"integrity": "sha512-G8GDyYRhfehw72+r4qKkydnA5+AU8qH67g01Y12b0DzI0VIzymh/05Z4dK8DsY3jyWPXJfw2hlg5+KDHaMBHgQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -1075,7 +1066,6 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -1119,7 +1109,6 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -2150,7 +2139,6 @@
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz",
"integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
@ -2201,7 +2189,6 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz",
"integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.x || ^19.x"
}
@ -2269,7 +2256,6 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
"integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.5",
@ -3202,7 +3188,6 @@
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.16"
}
@ -3321,6 +3306,7 @@
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
"integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"acorn": "^8.9.0"
}
@ -4091,13 +4077,21 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-shell": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.3.tgz",
"integrity": "sha512-Xod+pRcFxmOWFWEnqH5yZcA7qwAMuaaDkMR1Sply+F8VfBj++CGnj2xf5UoialmjZ2Cvd8qrvSCbU+7GgNVsKQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@ -4426,7 +4420,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -4437,7 +4430,6 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -4507,7 +4499,6 @@
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.3",
"@typescript-eslint/types": "8.46.3",
@ -5221,6 +5212,7 @@
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.24"
}
@ -5230,6 +5222,7 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/shared": "3.5.24"
@ -5240,6 +5233,7 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/runtime-core": "3.5.24",
@ -5252,6 +5246,7 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24"
@ -5278,7 +5273,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -5686,6 +5680,7 @@
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@ -5962,7 +5957,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@ -7010,8 +7004,7 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
"integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true
"license": "BSD-3-Clause"
},
"node_modules/dezalgo": {
"version": "1.0.4",
@ -7406,7 +7399,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -7577,7 +7569,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -7744,7 +7735,8 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/espree": {
"version": "10.4.0",
@ -7809,6 +7801,7 @@
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz",
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
}
@ -8899,7 +8892,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@ -9376,6 +9368,7 @@
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "^1.0.6"
}
@ -9696,7 +9689,6 @@
"integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.19",
"@asamuzakjp/dom-selector": "^6.7.3",
@ -10283,7 +10275,8 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/locate-path": {
"version": "6.0.0",
@ -11442,7 +11435,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -11722,7 +11714,6 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@ -12105,7 +12096,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -12115,7 +12105,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -13627,6 +13616,7 @@
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@ -13835,7 +13825,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -14137,7 +14126,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -14219,7 +14207,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@ -14424,7 +14411,6 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@ -14595,7 +14581,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -14609,7 +14594,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@ -15221,7 +15205,8 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/zod": {
"version": "3.25.76",

View File

@ -45,6 +45,7 @@
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-fs": "^2.4.0",
"@tauri-apps/plugin-http": "^2.5.4",
"@tauri-apps/plugin-shell": "^2.3.3",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"globals": "^16.4.0",

View File

@ -3,16 +3,24 @@ import { useTranslation } from 'react-i18next';
import { authService, UserInfo } from '@app/services/authService';
import { buildOAuthCallbackHtml } from '@app/utils/oauthCallbackHtml';
import { BASE_PATH } from '@app/constants/app';
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
import '@app/routes/authShared/auth.css';
export type OAuthProvider = 'google' | 'github' | 'keycloak' | 'azure' | 'apple' | 'oidc';
export type OAuthProviderId = 'google' | 'github' | 'keycloak' | 'azure' | 'apple' | 'oidc' | string;
export interface DesktopSSOProvider {
id: OAuthProviderId;
path?: string;
label?: string;
}
interface DesktopOAuthButtonsProps {
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
onError: (error: string) => void;
isDisabled: boolean;
serverUrl: string;
providers: OAuthProvider[];
providers: DesktopSSOProvider[];
mode?: 'saas' | 'selfHosted';
}
export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
@ -21,11 +29,12 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
isDisabled,
serverUrl,
providers,
mode = 'saas',
}) => {
const { t } = useTranslation();
const [oauthLoading, setOauthLoading] = useState(false);
const handleOAuthLogin = async (provider: OAuthProvider) => {
const handleOAuthLogin = async (provider: DesktopSSOProvider) => {
// Prevent concurrent OAuth attempts
if (oauthLoading || isDisabled) {
return;
@ -48,7 +57,12 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
errorPlaceholder: true, // {error} will be replaced by Rust
});
const userInfo = await authService.loginWithOAuth(provider, serverUrl, successHtml, errorHtml);
const normalizedServer = serverUrl.replace(/\/+$/, '');
const usingSupabaseFlow =
mode === 'saas' || normalizedServer === STIRLING_SAAS_URL.replace(/\/+$/, '');
const userInfo = usingSupabaseFlow
? await authService.loginWithOAuth(provider.id, serverUrl, successHtml, errorHtml)
: await authService.loginWithSelfHostedOAuth(provider.path || provider.id, serverUrl);
// Call the onOAuthSuccess callback to complete setup
await onOAuthSuccess(userInfo);
@ -64,7 +78,7 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
}
};
const providerConfig: Record<OAuthProvider, { label: string; file: string }> = {
const providerConfig: Record<string, { label: string; file: string }> = {
google: { label: 'Google', file: 'google.svg' },
github: { label: 'GitHub', file: 'github.svg' },
keycloak: { label: 'Keycloak', file: 'keycloak.svg' },
@ -72,6 +86,7 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
apple: { label: 'Apple', file: 'apple.svg' },
oidc: { label: 'OpenID', file: 'oidc.svg' },
};
const GENERIC_PROVIDER_ICON = 'oidc.svg';
if (providers.length === 0) {
return null;
@ -80,23 +95,29 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
return (
<div className="oauth-container-vertical">
{providers
.filter((providerId) => providerId in providerConfig)
.map((providerId) => {
const provider = providerConfig[providerId];
.filter((providerConfigEntry) => providerConfigEntry && providerConfigEntry.id)
.map((providerEntry) => {
const iconConfig = providerConfig[providerEntry.id];
const label =
providerEntry.label ||
iconConfig?.label ||
(providerEntry.id
? providerEntry.id.charAt(0).toUpperCase() + providerEntry.id.slice(1)
: t('setup.login.sso', 'Single Sign-On'));
return (
<button
key={providerId}
onClick={() => handleOAuthLogin(providerId)}
key={providerEntry.id}
onClick={() => handleOAuthLogin(providerEntry)}
disabled={isDisabled || oauthLoading}
className="oauth-button-vertical"
title={provider.label}
title={label}
>
<img
src={`${BASE_PATH}/Login/${provider.file}`}
alt={provider.label}
src={`${BASE_PATH}/Login/${iconConfig?.file || GENERIC_PROVIDER_ICON}`}
alt={label}
className="oauth-icon-tiny"
/>
{provider.label}
{label}
</button>
);
})}

View File

@ -66,7 +66,11 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
onError={handleOAuthError}
isDisabled={loading}
serverUrl={serverUrl}
providers={['google', 'github']}
mode="saas"
providers={[
{ id: 'google' },
{ id: 'github' },
]}
/>
<DividerWithText

View File

@ -5,13 +5,14 @@ import LoginHeader from '@app/routes/login/LoginHeader';
import ErrorMessage from '@app/routes/login/ErrorMessage';
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm';
import DividerWithText from '@app/components/shared/DividerWithText';
import { DesktopOAuthButtons, OAuthProvider } from '@app/components/SetupWizard/DesktopOAuthButtons';
import { DesktopOAuthButtons } from '@app/components/SetupWizard/DesktopOAuthButtons';
import { UserInfo } from '@app/services/authService';
import { SSOProviderConfig } from '@app/services/connectionModeService';
import '@app/routes/authShared/auth.css';
interface SelfHostedLoginScreenProps {
serverUrl: string;
enabledOAuthProviders?: string[];
enabledOAuthProviders?: SSOProviderConfig[];
onLogin: (username: string, password: string) => Promise<void>;
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
loading: boolean;
@ -74,7 +75,8 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
onError={handleOAuthError}
isDisabled={loading}
serverUrl={serverUrl}
providers={enabledOAuthProviders as OAuthProvider[]}
mode="selfHosted"
providers={enabledOAuthProviders}
/>
<DividerWithText

View File

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Stack, Button, TextInput, Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ServerConfig } from '@app/services/connectionModeService';
import { ServerConfig, SSOProviderConfig } from '@app/services/connectionModeService';
import { connectionModeService } from '@app/services/connectionModeService';
import LocalIcon from '@app/components/shared/LocalIcon';
@ -43,7 +43,7 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
}
// Fetch OAuth providers and check if login is enabled
let enabledProviders: string[] = [];
const enabledProviders: SSOProviderConfig[] = [];
try {
const response = await fetch(`${url}/api/v1/proprietary/ui-data/login`);
@ -76,9 +76,19 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
// Extract provider IDs from authorization URLs
// Example: "/oauth2/authorization/google" → "google"
enabledProviders = Object.keys(data.providerList || {})
.map(key => key.split('/').pop())
.filter((id): id is string => id !== undefined);
const providerEntries = Object.entries(data.providerList || {});
providerEntries.forEach(([path, label]) => {
const id = path.split('/').pop();
if (!id) {
return;
}
enabledProviders.push({
id,
path,
label: typeof label === 'string' ? label : undefined,
});
});
console.log('[ServerSelection] Detected OAuth providers:', enabledProviders);
} catch (err) {

View File

@ -155,6 +155,28 @@ 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
if (type === 'sso' || type === 'sso-selfhosted') {
const token = accessTokenFromHash || accessTokenFromQuery;
const serverUrl = serverFromQuery || serverConfig?.url || STIRLING_SAAS_URL;
if (!token || !serverUrl) {
console.error('[SetupWizard] Deep link missing token or server for SSO completion');
return;
}
setLoading(true);
setError(null);
await authService.completeSelfHostedSession(serverUrl, token);
await connectionModeService.switchToSelfHosted({ url: serverUrl });
await tauriBackendService.initializeExternalBackend();
onComplete();
return;
}
if (!type || (type !== 'signup' && type !== 'recovery' && type !== 'magiclink')) {
return;

View File

@ -1,4 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
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 axios from 'axios';
import { DESKTOP_DEEP_LINK_CALLBACK, STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection';
@ -436,6 +438,250 @@ export class AuthService {
}
}
/**
* Self-hosted SSO/OAuth2 flow for the desktop app.
* Opens a popup to the server's auth endpoint and listens for the AuthCallback page
* to postMessage the JWT back to the main window.
*/
async loginWithSelfHostedOAuth(providerPath: string, serverUrl: string): Promise<UserInfo> {
const trimmedServer = serverUrl.replace(/\/+$/, '');
const fullUrl = providerPath.startsWith('http')
? providerPath
: `${trimmedServer}${providerPath.startsWith('/') ? providerPath : `/${providerPath}`}`;
// Ensure backend redirects back to /auth/callback
try {
document.cookie = `stirling_redirect_path=${encodeURIComponent('/auth/callback')}; path=/; max-age=300; SameSite=Lax`;
} catch {
// ignore cookie errors
}
// Force a real popup so the main webview stays on the app
const authWindow = window.open(fullUrl, '_blank', 'width=900,height=900');
// Fallback: use Tauri shell.open and wait for deep link back
if (!authWindow) {
if (await this.openInSystemBrowser(fullUrl)) {
return this.waitForDeepLinkCompletion(trimmedServer);
}
throw new Error('Unable to open browser window for SSO. Please allow pop-ups and try again.');
}
const expectedOrigin = new URL(fullUrl).origin;
// Always also listen for deep link completion in case the opener messaging path fails
const deepLinkPromise = this.waitForDeepLinkCompletion(trimmedServer).catch(() => null);
return new Promise<UserInfo>((resolve, reject) => {
let completed = false;
const cleanup = () => {
window.removeEventListener('message', handleMessage);
clearInterval(windowCheck);
clearInterval(localTokenCheck);
clearTimeout(timeoutId);
};
const handleMessage = async (event: MessageEvent) => {
if (event.origin !== expectedOrigin) {
return;
}
const data = event.data as { type?: string; token?: string; access_token?: string };
if (!data || data.type !== 'stirling-desktop-sso') {
return;
}
const token = data.token || data.access_token;
if (!token) {
cleanup();
reject(new Error('No token returned from SSO'));
return;
}
completed = true;
cleanup();
try {
const userInfo = await this.completeSelfHostedSession(trimmedServer, token);
try {
authWindow.close();
} catch (closeError) {
console.warn('Could not close auth window:', closeError);
}
resolve(userInfo);
} catch (err) {
reject(err instanceof Error ? err : new Error('Failed to complete login'));
}
};
// If deep link finishes first, resolve
deepLinkPromise.then(async (dlResult) => {
if (completed || !dlResult) return;
completed = true;
cleanup();
resolve(dlResult);
}).catch(() => {
// ignore deep link errors here
});
window.addEventListener('message', handleMessage);
const windowCheck = window.setInterval(() => {
if (authWindow.closed && !completed) {
cleanup();
reject(new Error('Authentication window was closed before completion'));
}
}, 500);
const localTokenCheck = window.setInterval(async () => {
if (completed) {
clearInterval(localTokenCheck);
return;
}
const token = localStorage.getItem('stirling_jwt');
if (token) {
completed = true;
cleanup();
try {
const userInfo = await this.completeSelfHostedSession(trimmedServer, token);
try {
authWindow.close();
} catch (_) {
// ignore close errors
}
resolve(userInfo);
} catch (err) {
reject(err instanceof Error ? err : new Error('Failed to complete login'));
}
}
}, 1000);
const timeoutId = window.setTimeout(() => {
if (!completed) {
cleanup();
try {
authWindow.close();
} catch {
// ignore close errors
}
reject(new Error('SSO login timed out. Please try again.'));
}
}, 120_000);
});
}
/**
* Wait for a deep-link event to complete self-hosted SSO (used when popup cannot open)
*/
private async waitForDeepLinkCompletion(serverUrl: string): Promise<UserInfo> {
if (!isTauri()) {
throw new Error('Unable to open browser window for SSO. Please allow pop-ups and try again.');
}
return new Promise<UserInfo>((resolve, reject) => {
let completed = false;
let unlisten: (() => void) | null = null;
const timeoutId = window.setTimeout(() => {
if (!completed) {
if (unlisten) unlisten();
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');
if (type !== 'sso' && type !== 'sso-selfhosted') {
return;
}
const token = params.get('access_token') || parsed.searchParams.get('access_token');
if (!token) {
return;
}
completed = true;
if (unlisten) unlisten();
clearTimeout(timeoutId);
const userInfo = await this.completeSelfHostedSession(serverUrl, token);
resolve(userInfo);
} catch (err) {
completed = true;
if (unlisten) unlisten();
clearTimeout(timeoutId);
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> {
await this.saveTokenEverywhere(token);
const userInfo = await this.fetchSelfHostedUserInfo(serverUrl, 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);
return {
username: 'User',
email: undefined,
};
}
}
/**
* Fetch user info from Supabase using access token
*/

View File

@ -3,9 +3,15 @@ import { fetch } from '@tauri-apps/plugin-http';
export type ConnectionMode = 'saas' | 'selfhosted';
export interface SSOProviderConfig {
id: string;
path: string;
label?: string;
}
export interface ServerConfig {
url: string;
enabledOAuthProviders?: string[];
enabledOAuthProviders?: SSOProviderConfig[];
}
export interface ConnectionConfig {

View File

@ -1,6 +1,8 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { springAuth } from '@app/auth/springAuthClient';
import { connectionModeService } from '@app/services/connectionModeService';
import { tauriBackendService } from '@app/services/tauriBackendService';
/**
* OAuth Callback Handler
@ -52,6 +54,44 @@ export default function AuthCallback() {
return;
}
// Notify desktop popup listeners (self-hosted SSO flow)
const isDesktopPopup = typeof window !== 'undefined' && window.opener && window.name === 'stirling-desktop-sso';
if (isDesktopPopup) {
try {
window.opener.postMessage(
{ type: 'stirling-desktop-sso', token },
'*'
);
} catch (postError) {
console.error('[AuthCallback] Failed to notify desktop window:', postError);
}
// Give the message a moment to flush before attempting to close
setTimeout(() => {
try {
window.close();
} catch (_) {
// ignore close errors
}
}, 150);
}
// Desktop fallback flow (when popup was blocked and we navigated directly)
try {
const pending = localStorage.getItem('desktop_self_hosted_sso_pending');
const hasTauri = typeof window !== 'undefined' && (window as any).__TAURI_INTERNALS__;
if (pending && hasTauri) {
const parsed = JSON.parse(pending) as { serverUrl?: string } | null;
if (parsed?.serverUrl) {
await connectionModeService.switchToSelfHosted({ url: parsed.serverUrl });
await tauriBackendService.initializeExternalBackend();
}
localStorage.removeItem('desktop_self_hosted_sso_pending');
}
} catch (desktopError) {
console.error('[AuthCallback] Desktop fallback completion failed:', desktopError);
}
console.log('[AuthCallback] Token validated, redirecting to home');
// Clear the hash from URL and redirect to home page