mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Self-hosted desktop SSO (#5265)
# Description of Changes Support SSO in self-hosted desktop app. --------- Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
co-authored by
Anthony Stirling
parent
dd09f7b7cf
commit
18be8f4692
@@ -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,79 @@ 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("/+$", "");
|
||||
boolean hasScheme = trimmedBase.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*");
|
||||
String baseForParsing = hasScheme ? trimmedBase : "http://" + trimmedBase;
|
||||
Integer parsedPort = parsePort(port);
|
||||
|
||||
try {
|
||||
java.net.URI uri = new java.net.URI(baseForParsing);
|
||||
String scheme = uri.getScheme() == null ? "http" : uri.getScheme();
|
||||
String host = uri.getHost();
|
||||
if (host == null) {
|
||||
return appendPortFallback(trimmedBase, parsedPort);
|
||||
}
|
||||
|
||||
boolean defaultHttp =
|
||||
"http".equalsIgnoreCase(scheme) && Integer.valueOf(80).equals(parsedPort);
|
||||
boolean defaultHttps =
|
||||
"https".equalsIgnoreCase(scheme) && Integer.valueOf(443).equals(parsedPort);
|
||||
|
||||
int effectivePort = uri.getPort();
|
||||
if (effectivePort == -1 && parsedPort != null && !defaultHttp && !defaultHttps) {
|
||||
effectivePort = parsedPort;
|
||||
}
|
||||
|
||||
java.net.URI rebuilt =
|
||||
new java.net.URI(
|
||||
scheme,
|
||||
uri.getUserInfo(),
|
||||
host,
|
||||
effectivePort,
|
||||
uri.getPath(),
|
||||
uri.getQuery(),
|
||||
uri.getFragment());
|
||||
return rebuilt.toString();
|
||||
} catch (java.net.URISyntaxException e) {
|
||||
return appendPortFallback(trimmedBase, parsedPort);
|
||||
}
|
||||
}
|
||||
|
||||
private static Integer parsePort(String port) {
|
||||
if (port == null || port.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
int parsed = Integer.parseInt(port);
|
||||
return parsed > 0 ? parsed : null;
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String appendPortFallback(String trimmedBase, Integer port) {
|
||||
if (port == null) {
|
||||
return trimmedBase;
|
||||
}
|
||||
if (trimmedBase.matches(".+:\\d+$")) {
|
||||
return trimmedBase;
|
||||
}
|
||||
return trimmedBase + ":" + port;
|
||||
}
|
||||
}
|
||||
|
||||
+392
-49
@@ -15,84 +15,106 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.util.HtmlUtils;
|
||||
import org.springframework.web.util.JavaScriptUtils;
|
||||
|
||||
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() {
|
||||
log.info("Static files custom path: {}", InstallationPathConfig.getStaticPath());
|
||||
|
||||
// Always initialize callback HTML (used for OAuth desktop flow)
|
||||
this.cachedCallbackHtml = buildCallbackHtml();
|
||||
|
||||
// Check for external index.html first (customFiles/static/)
|
||||
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
|
||||
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.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 +128,28 @@ 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(HttpServletRequest request) {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/auth/callback/tauri", produces = MediaType.TEXT_HTML_VALUE)
|
||||
public ResponseEntity<String> serveTauriAuthCallback(HttpServletRequest request) {
|
||||
// cachedCallbackHtml is always initialized in @PostConstruct
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedCallbackHtml);
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
@@ -126,4 +164,309 @@ public class ReactRoutingController {
|
||||
throws IOException {
|
||||
return serveIndexHtml(request);
|
||||
}
|
||||
|
||||
private String buildFallbackHtml() {
|
||||
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
|
||||
|
||||
// Escape for HTML attribute context
|
||||
String escapedBaseUrlHtml = HtmlUtils.htmlEscape(baseUrl);
|
||||
|
||||
// Escape for JavaScript string context
|
||||
String escapedBaseUrlJs = JavaScriptUtils.javaScriptEscape(baseUrl);
|
||||
|
||||
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
|
||||
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 serverUrl = %s;
|
||||
|
||||
if (token) {
|
||||
// Extract nonce from URL to send back to desktop app for validation
|
||||
const nonceFromUrl = hashParams.get('nonce') || searchParams.get('nonce');
|
||||
|
||||
console.log('[Fallback Auth] Token received, sending to desktop app via deep link');
|
||||
|
||||
// Send token + nonce via deep link to desktop app
|
||||
// Desktop app will validate nonce before accepting token
|
||||
try {
|
||||
const encodedToken = encodeURIComponent(token);
|
||||
const encodedServer = encodeURIComponent(serverUrl);
|
||||
const encodedNonce = nonceFromUrl ? encodeURIComponent(nonceFromUrl) : '';
|
||||
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#access_token=${encodedToken}&nonce=${encodedNonce}&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(escapedBaseUrlHtml, escapedBaseUrlJs, serverUrl);
|
||||
}
|
||||
|
||||
private String buildCallbackHtml() {
|
||||
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
|
||||
|
||||
// Escape for HTML attribute context
|
||||
String escapedBaseUrlHtml = HtmlUtils.htmlEscape(baseUrl);
|
||||
|
||||
// Escape for JavaScript string context
|
||||
String escapedBaseUrlJs = JavaScriptUtils.javaScriptEscape(baseUrl);
|
||||
|
||||
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
|
||||
return """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base href="%s" />
|
||||
<title>Authentication Complete</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
text-align: center;
|
||||
padding: 50px 20px;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
max-width: 420px;
|
||||
width: 100%%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #e5e7eb;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.icon.error {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
background: #ffebee;
|
||||
border: 1px solid #ffcdd2;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
color: #c62828;
|
||||
word-break: break-word;
|
||||
text-align: left;
|
||||
line-height: 1.5;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: #2d2d2d;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border-color: #374151;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: #66bb6a;
|
||||
}
|
||||
|
||||
.icon.error {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
background: #3d2020;
|
||||
border: 1px solid #5d3030;
|
||||
color: #ef9a9a;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
body {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 40px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
const run = () => {
|
||||
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 errorCode = searchParams.get('errorOAuth')
|
||||
|| searchParams.get('error')
|
||||
|| hashParams.get('error')
|
||||
|| searchParams.get('error_description')
|
||||
|| hashParams.get('error_description');
|
||||
const serverUrl = %s;
|
||||
const iconEl = document.getElementById('auth-icon');
|
||||
const titleEl = document.getElementById('auth-title');
|
||||
const messageEl = document.getElementById('auth-message');
|
||||
const detailsEl = document.getElementById('auth-error-details');
|
||||
|
||||
const sendDeepLink = (type, value, key) => {
|
||||
try {
|
||||
const encodedValue = encodeURIComponent(value || '');
|
||||
const encodedServer = encodeURIComponent(serverUrl);
|
||||
const hashKey = key || 'access_token';
|
||||
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#${hashKey}=${encodedValue}&type=${type}`;
|
||||
window.location.href = deepLink;
|
||||
} catch (_) {
|
||||
// ignore deep link errors
|
||||
}
|
||||
};
|
||||
|
||||
const showError = (message, details) => {
|
||||
if (iconEl) {
|
||||
iconEl.textContent = '✗';
|
||||
iconEl.classList.add('error');
|
||||
}
|
||||
if (titleEl) {
|
||||
titleEl.textContent = 'Authentication failed';
|
||||
}
|
||||
if (messageEl) {
|
||||
messageEl.textContent = message;
|
||||
}
|
||||
if (detailsEl && details) {
|
||||
detailsEl.textContent = details;
|
||||
detailsEl.style.display = 'block';
|
||||
}
|
||||
};
|
||||
|
||||
if (token) {
|
||||
// Extract nonce from URL to send back to desktop app for validation
|
||||
// (System browser doesn't have access to desktop app's sessionStorage)
|
||||
const nonceFromUrl = hashParams.get('nonce') || searchParams.get('nonce');
|
||||
|
||||
console.log('[Auth Callback] Token received, sending to desktop app via deep link');
|
||||
|
||||
// Send token + nonce via deep link to desktop app
|
||||
// Desktop app will validate nonce before accepting token
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const encodedToken = encodeURIComponent(token);
|
||||
const encodedServer = encodeURIComponent(serverUrl);
|
||||
const encodedNonce = nonceFromUrl ? encodeURIComponent(nonceFromUrl) : '';
|
||||
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#access_token=${encodedToken}&nonce=${encodedNonce}&type=sso-selfhosted`;
|
||||
window.location.href = deepLink;
|
||||
} catch (err) {
|
||||
console.error('[Auth Callback] Failed to trigger deep link:', err);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorCode) {
|
||||
const isCancelled = errorCode === 'access_denied';
|
||||
sendDeepLink('sso-error', errorCode, 'error');
|
||||
showError(
|
||||
isCancelled
|
||||
? 'Authentication was cancelled. You can close this window and return to the app.'
|
||||
: 'Authentication was not successful. You can close this window and return to the app.',
|
||||
errorCode
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showError(
|
||||
'Authentication did not complete. You can close this window and try again.',
|
||||
'missing_token'
|
||||
);
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', run);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon" id="auth-icon">✓</div>
|
||||
<h1 id="auth-title">Authentication complete</h1>
|
||||
<p id="auth-message">You can close this window and return to Stirling PDF.</p>
|
||||
<div class="error-details" id="auth-error-details"></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
.formatted(escapedBaseUrlHtml, serverUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ public class SPDFApplicationTest {
|
||||
|
||||
sPDFApplication.init();
|
||||
|
||||
assertEquals("http://localhost", SPDFApplication.getStaticBaseUrl());
|
||||
assertEquals("http://localhost:8080", SPDFApplication.getStaticBaseUrl());
|
||||
assertEquals("/app", SPDFApplication.getStaticContextPath());
|
||||
assertEquals("8080", SPDFApplication.getStaticPort());
|
||||
}
|
||||
|
||||
+14
@@ -18,6 +18,7 @@ import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
|
||||
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
|
||||
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
|
||||
@@ -46,6 +47,7 @@ import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
|
||||
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
|
||||
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler;
|
||||
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationSuccessHandler;
|
||||
import stirling.software.proprietary.security.oauth2.TauriAuthorizationRequestResolver;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuccessHandler;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter;
|
||||
@@ -82,6 +84,7 @@ public class SecurityConfiguration {
|
||||
private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver;
|
||||
private final stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService;
|
||||
private final ClientRegistrationRepository clientRegistrationRepository;
|
||||
|
||||
public SecurityConfiguration(
|
||||
PersistentLoginRepository persistentLoginRepository,
|
||||
@@ -102,6 +105,7 @@ public class SecurityConfiguration {
|
||||
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
|
||||
@Autowired(required = false)
|
||||
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver,
|
||||
@Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository,
|
||||
stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService) {
|
||||
this.userDetailsService = userDetailsService;
|
||||
@@ -120,6 +124,7 @@ public class SecurityConfiguration {
|
||||
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
|
||||
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
|
||||
this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver;
|
||||
this.clientRegistrationRepository = clientRegistrationRepository;
|
||||
this.licenseSettingsService = licenseSettingsService;
|
||||
}
|
||||
|
||||
@@ -290,6 +295,15 @@ public class SecurityConfiguration {
|
||||
http.oauth2Login(
|
||||
oauth2 -> {
|
||||
oauth2.loginPage("/login")
|
||||
.authorizationEndpoint(
|
||||
authorizationEndpoint -> {
|
||||
if (clientRegistrationRepository != null) {
|
||||
authorizationEndpoint
|
||||
.authorizationRequestResolver(
|
||||
new TauriAuthorizationRequestResolver(
|
||||
clientRegistrationRepository));
|
||||
}
|
||||
})
|
||||
.successHandler(
|
||||
new CustomOAuth2AuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
|
||||
+62
-1
@@ -1,7 +1,11 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
import org.springframework.security.authentication.DisabledException;
|
||||
import org.springframework.security.authentication.LockedException;
|
||||
@@ -60,9 +64,66 @@ public class CustomOAuth2AuthenticationFailureHandler
|
||||
"OAuth2 Authentication error: {}",
|
||||
errorCode != null ? errorCode : exception.getMessage(),
|
||||
exception);
|
||||
getRedirectStrategy().sendRedirect(request, response, "/login?errorOAuth=" + errorCode);
|
||||
String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError";
|
||||
clearRedirectCookie(response);
|
||||
boolean tauriState = TauriOAuthUtils.isTauriState(request);
|
||||
String redirectUrl;
|
||||
if (tauriState) {
|
||||
String basePath =
|
||||
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
|
||||
redirectUrl = basePath;
|
||||
String stateParam = request.getParameter("state");
|
||||
if (stateParam != null && !stateParam.isBlank()) {
|
||||
redirectUrl = appendQueryParam(redirectUrl, "state", stateParam);
|
||||
// Extract and pass nonce for CSRF validation
|
||||
String nonce = TauriOAuthUtils.extractNonceFromState(stateParam);
|
||||
if (nonce != null) {
|
||||
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
|
||||
}
|
||||
}
|
||||
redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue);
|
||||
} else {
|
||||
redirectUrl = buildFailureRedirectUrl(request, errorValue);
|
||||
}
|
||||
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
|
||||
return;
|
||||
}
|
||||
log.error("Unhandled authentication exception", exception);
|
||||
super.onAuthenticationFailure(request, response, exception);
|
||||
}
|
||||
|
||||
private String buildFailureRedirectUrl(HttpServletRequest request, String errorValue) {
|
||||
String contextPath = request.getContextPath();
|
||||
String cookiePath = TauriOAuthUtils.extractRedirectPathFromCookie(request);
|
||||
String redirectPath =
|
||||
cookiePath != null ? cookiePath : TauriOAuthUtils.defaultCallbackPath(contextPath);
|
||||
if (TauriOAuthUtils.isTauriState(request)) {
|
||||
redirectPath = appendQueryParam(redirectPath, "tauri", "1");
|
||||
}
|
||||
String resolvedPath =
|
||||
redirectPath.startsWith("/")
|
||||
? TauriOAuthUtils.normalizeContextPath(contextPath) + redirectPath
|
||||
: TauriOAuthUtils.normalizeContextPath(contextPath) + "/" + redirectPath;
|
||||
return appendQueryParam(resolvedPath, "errorOAuth", errorValue);
|
||||
}
|
||||
|
||||
private void clearRedirectCookie(HttpServletResponse response) {
|
||||
ResponseCookie cookie =
|
||||
ResponseCookie.from(TauriOAuthUtils.SPA_REDIRECT_COOKIE, "")
|
||||
.path("/")
|
||||
.sameSite("Lax")
|
||||
.maxAge(0)
|
||||
.build();
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
|
||||
}
|
||||
|
||||
private String appendQueryParam(String path, String key, String value) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return path;
|
||||
}
|
||||
String separator = path.contains("?") ? "&" : "?";
|
||||
String encodedKey = URLEncoder.encode(key, StandardCharsets.UTF_8);
|
||||
String encodedValue = value == null ? "" : URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
return path + separator + encodedKey + "=" + encodedValue;
|
||||
}
|
||||
}
|
||||
|
||||
+18
-35
@@ -4,8 +4,6 @@ import static stirling.software.proprietary.security.model.AuthenticationType.OA
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.SSO;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -21,7 +19,6 @@ import org.springframework.security.web.authentication.SavedRequestAwareAuthenti
|
||||
import org.springframework.security.web.savedrequest.SavedRequest;
|
||||
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
@@ -45,9 +42,6 @@ import stirling.software.proprietary.security.service.UserService;
|
||||
public class CustomOAuth2AuthenticationSuccessHandler
|
||||
extends SavedRequestAwareAuthenticationSuccessHandler {
|
||||
|
||||
private static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path";
|
||||
private static final String DEFAULT_CALLBACK_PATH = "/auth/callback";
|
||||
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
|
||||
private final UserService userService;
|
||||
@@ -210,39 +204,28 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
resolveOriginFromReferer(request)
|
||||
.orElseGet(() -> buildOriginFromRequest(request)));
|
||||
clearRedirectCookie(response);
|
||||
return origin + redirectPath + "#access_token=" + jwt;
|
||||
|
||||
// Extract nonce from state for CSRF validation in callback
|
||||
String nonce = TauriOAuthUtils.extractNonceFromRequest(request);
|
||||
String url = origin + redirectPath + "#access_token=" + jwt;
|
||||
if (nonce != null) {
|
||||
url +=
|
||||
"&nonce="
|
||||
+ java.net.URLEncoder.encode(
|
||||
nonce, java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
|
||||
return extractRedirectPathFromCookie(request)
|
||||
.filter(path -> path.startsWith("/"))
|
||||
.orElseGet(() -> defaultCallbackPath(contextPath));
|
||||
}
|
||||
|
||||
private Optional<String> extractRedirectPathFromCookie(HttpServletRequest request) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) {
|
||||
return Optional.empty();
|
||||
if (TauriOAuthUtils.isTauriState(request)) {
|
||||
return TauriOAuthUtils.defaultTauriCallbackPath(contextPath);
|
||||
}
|
||||
for (Cookie cookie : cookies) {
|
||||
if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) {
|
||||
String value = URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8).trim();
|
||||
if (!value.isEmpty()) {
|
||||
return Optional.of(value);
|
||||
}
|
||||
}
|
||||
String cookiePath = TauriOAuthUtils.extractRedirectPathFromCookie(request);
|
||||
if (cookiePath != null && cookiePath.startsWith("/")) {
|
||||
return cookiePath;
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private String defaultCallbackPath(String contextPath) {
|
||||
if (contextPath == null
|
||||
|| contextPath.isBlank()
|
||||
|| "/".equals(contextPath)
|
||||
|| "\\".equals(contextPath)) {
|
||||
return DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
return contextPath + DEFAULT_CALLBACK_PATH;
|
||||
return TauriOAuthUtils.defaultCallbackPath(contextPath);
|
||||
}
|
||||
|
||||
private Optional<String> resolveForwardedOrigin(HttpServletRequest request) {
|
||||
@@ -326,7 +309,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
|
||||
private void clearRedirectCookie(HttpServletResponse response) {
|
||||
ResponseCookie cookie =
|
||||
ResponseCookie.from(SPA_REDIRECT_COOKIE, "")
|
||||
ResponseCookie.from(TauriOAuthUtils.SPA_REDIRECT_COOKIE, "")
|
||||
.path("/")
|
||||
.sameSite("Lax")
|
||||
.maxAge(0)
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
|
||||
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
public class TauriAuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver {
|
||||
|
||||
private static final String TAURI_STATE_PREFIX = "tauri:";
|
||||
|
||||
private final OAuth2AuthorizationRequestResolver delegate;
|
||||
|
||||
public TauriAuthorizationRequestResolver(
|
||||
ClientRegistrationRepository clientRegistrationRepository) {
|
||||
this.delegate =
|
||||
new DefaultOAuth2AuthorizationRequestResolver(
|
||||
clientRegistrationRepository, "/oauth2/authorization");
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
|
||||
return customize(request, delegate.resolve(request));
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2AuthorizationRequest resolve(
|
||||
HttpServletRequest request, String clientRegistrationId) {
|
||||
return customize(request, delegate.resolve(request, clientRegistrationId));
|
||||
}
|
||||
|
||||
private OAuth2AuthorizationRequest customize(
|
||||
HttpServletRequest request, OAuth2AuthorizationRequest authorizationRequest) {
|
||||
if (authorizationRequest == null) {
|
||||
return null;
|
||||
}
|
||||
String tauriParam = request.getParameter("tauri");
|
||||
if (!"1".equals(tauriParam)) {
|
||||
return authorizationRequest;
|
||||
}
|
||||
|
||||
String state = authorizationRequest.getState();
|
||||
if (state == null || state.startsWith(TAURI_STATE_PREFIX)) {
|
||||
return authorizationRequest;
|
||||
}
|
||||
|
||||
// Extract nonce from request for CSRF protection
|
||||
String nonce = request.getParameter("nonce");
|
||||
String customState = TAURI_STATE_PREFIX + state;
|
||||
if (nonce != null && !nonce.isBlank()) {
|
||||
customState = customState + ":" + nonce;
|
||||
}
|
||||
|
||||
return OAuth2AuthorizationRequest.from(authorizationRequest).state(customState).build();
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* Utility class for Tauri desktop OAuth flow handling. Centralizes common logic for OAuth state
|
||||
* management, nonce validation, and callback path construction.
|
||||
*/
|
||||
public final class TauriOAuthUtils {
|
||||
|
||||
public static final String TAURI_STATE_PREFIX = "tauri:";
|
||||
public static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path";
|
||||
public static final String DEFAULT_CALLBACK_PATH = "/auth/callback";
|
||||
public static final String TAURI_CALLBACK_SUFFIX = "/tauri";
|
||||
|
||||
private TauriOAuthUtils() {
|
||||
// Utility class - prevent instantiation
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts nonce from OAuth state parameter for CSRF validation. State format:
|
||||
* tauri:<original-state>:<nonce>
|
||||
*
|
||||
* @param state The state parameter value
|
||||
* @return The nonce if present, null otherwise
|
||||
*/
|
||||
public static String extractNonceFromState(String state) {
|
||||
if (state == null || !state.startsWith(TAURI_STATE_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
// Split by ':' and get the last part (nonce)
|
||||
String[] parts = state.split(":");
|
||||
if (parts.length >= 3) {
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts nonce from request's state parameter.
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @return The nonce if present, null otherwise
|
||||
*/
|
||||
public static String extractNonceFromRequest(HttpServletRequest request) {
|
||||
String state = request.getParameter("state");
|
||||
return extractNonceFromState(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the request has a Tauri state parameter (desktop OAuth flow).
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @return true if this is a Tauri desktop OAuth flow, false otherwise
|
||||
*/
|
||||
public static boolean isTauriState(HttpServletRequest request) {
|
||||
String state = request.getParameter("state");
|
||||
return state != null && state.startsWith(TAURI_STATE_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the default callback path for the given context path.
|
||||
*
|
||||
* @param contextPath The application context path
|
||||
* @return The full callback path
|
||||
*/
|
||||
public static String defaultCallbackPath(String contextPath) {
|
||||
if (contextPath == null
|
||||
|| contextPath.isBlank()
|
||||
|| "/".equals(contextPath)
|
||||
|| "\\".equals(contextPath)) {
|
||||
return DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
return contextPath + DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Tauri-specific callback path (includes /tauri suffix).
|
||||
*
|
||||
* @param contextPath The application context path
|
||||
* @return The full Tauri callback path
|
||||
*/
|
||||
public static String defaultTauriCallbackPath(String contextPath) {
|
||||
return defaultCallbackPath(contextPath) + TAURI_CALLBACK_SUFFIX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes context path by removing trailing slashes and handling empty/root paths.
|
||||
*
|
||||
* @param contextPath The context path to normalize
|
||||
* @return Normalized context path (empty string for root)
|
||||
*/
|
||||
public static String normalizeContextPath(String contextPath) {
|
||||
if (contextPath == null || contextPath.isBlank() || "/".equals(contextPath)) {
|
||||
return "";
|
||||
}
|
||||
return contextPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the SPA redirect cookie value from the request.
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @return The redirect path from cookie, or null if not found
|
||||
*/
|
||||
public static String extractRedirectPathFromCookie(HttpServletRequest request) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) {
|
||||
return null;
|
||||
}
|
||||
for (Cookie cookie : cookies) {
|
||||
if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) {
|
||||
String value =
|
||||
java.net.URLDecoder.decode(
|
||||
cookie.getValue(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
return value.trim().isEmpty() ? null : value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
|
||||
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||
|
||||
class CustomOAuth2AuthenticationFailureHandlerTest {
|
||||
|
||||
@Test
|
||||
void redirectsToTauriCallbackWhenStateMarked() throws Exception {
|
||||
CustomOAuth2AuthenticationFailureHandler handler =
|
||||
new CustomOAuth2AuthenticationFailureHandler();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setContextPath("");
|
||||
request.setParameter("state", "tauri:abc");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
handler.onAuthenticationFailure(
|
||||
request,
|
||||
response,
|
||||
new OAuth2AuthenticationException(new OAuth2Error("access_denied")));
|
||||
|
||||
assertEquals(
|
||||
"/auth/callback/tauri?state=tauri%3Aabc&errorOAuth=access_denied",
|
||||
response.getRedirectedUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void redirectsToDefaultCallbackWithoutTauriState() throws Exception {
|
||||
CustomOAuth2AuthenticationFailureHandler handler =
|
||||
new CustomOAuth2AuthenticationFailureHandler();
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setContextPath("");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
handler.onAuthenticationFailure(
|
||||
request,
|
||||
response,
|
||||
new OAuth2AuthenticationException(new OAuth2Error("access_denied")));
|
||||
|
||||
assertEquals("/auth/callback?errorOAuth=access_denied", response.getRedirectedUrl());
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CustomOAuth2AuthenticationSuccessHandlerTest {
|
||||
|
||||
@Test
|
||||
void redirectsToTauriCallbackWhenStateMarked() throws Exception {
|
||||
LoginAttemptService loginAttemptService = mock(LoginAttemptService.class);
|
||||
UserService userService = mock(UserService.class);
|
||||
JwtServiceInterface jwtService = mock(JwtServiceInterface.class);
|
||||
UserLicenseSettingsService licenseSettingsService = mock(UserLicenseSettingsService.class);
|
||||
|
||||
ApplicationProperties.Security.OAUTH2 oauth2Props =
|
||||
new ApplicationProperties.Security.OAUTH2();
|
||||
oauth2Props.setAutoCreateUser(true);
|
||||
oauth2Props.setBlockRegistration(false);
|
||||
|
||||
CustomOAuth2AuthenticationSuccessHandler handler =
|
||||
new CustomOAuth2AuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
oauth2Props,
|
||||
userService,
|
||||
jwtService,
|
||||
licenseSettingsService);
|
||||
|
||||
when(userService.usernameExistsIgnoreCase("user")).thenReturn(false);
|
||||
when(licenseSettingsService.isOAuthEligible(null)).thenReturn(true);
|
||||
when(userService.isUserDisabled("user")).thenReturn(false);
|
||||
when(jwtService.isJwtEnabled()).thenReturn(true);
|
||||
when(jwtService.generateToken(
|
||||
org.mockito.Mockito.any(
|
||||
org.springframework.security.core.Authentication.class),
|
||||
org.mockito.Mockito.anyMap()))
|
||||
.thenReturn("jwt");
|
||||
|
||||
Map<String, Object> attributes = Map.of("sub", "provider-sub", "name", "user");
|
||||
DefaultOAuth2User oauthUser =
|
||||
new DefaultOAuth2User(
|
||||
List.of(new SimpleGrantedAuthority("ROLE_USER")), attributes, "name");
|
||||
OAuth2AuthenticationToken authentication =
|
||||
new OAuth2AuthenticationToken(oauthUser, oauthUser.getAuthorities(), "google");
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setContextPath("");
|
||||
request.setScheme("http");
|
||||
request.setServerName("localhost");
|
||||
request.setServerPort(8080);
|
||||
request.setParameter("state", "tauri:abc");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
|
||||
handler.onAuthenticationSuccess(request, response, authentication);
|
||||
|
||||
assertEquals(
|
||||
"http://localhost:8080/auth/callback/tauri#access_token=jwt",
|
||||
response.getRedirectedUrl());
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.security.oauth2.client.registration.ClientRegistration;
|
||||
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
|
||||
import org.springframework.security.oauth2.core.AuthorizationGrantType;
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
|
||||
|
||||
class TauriAuthorizationRequestResolverTest {
|
||||
|
||||
private TauriAuthorizationRequestResolver buildResolver() {
|
||||
ClientRegistration registration =
|
||||
ClientRegistration.withRegistrationId("google")
|
||||
.clientId("client-id")
|
||||
.clientSecret("client-secret")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.authorizationUri("https://accounts.example.com/o/oauth2/auth")
|
||||
.tokenUri("https://accounts.example.com/o/oauth2/token")
|
||||
.redirectUri("http://localhost:8080/login/oauth2/code/google")
|
||||
.userInfoUri("https://accounts.example.com/userinfo")
|
||||
.userNameAttributeName("sub")
|
||||
.clientName("Google")
|
||||
.scope("email")
|
||||
.build();
|
||||
|
||||
return new TauriAuthorizationRequestResolver(
|
||||
new InMemoryClientRegistrationRepository(registration));
|
||||
}
|
||||
|
||||
private MockHttpServletRequest buildRequest(boolean tauri) {
|
||||
MockHttpServletRequest request =
|
||||
new MockHttpServletRequest("GET", "/oauth2/authorization/google");
|
||||
request.setServletPath("/oauth2/authorization/google");
|
||||
if (tauri) {
|
||||
request.setParameter("tauri", "1");
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_prefixesStateWhenTauriParamPresent() {
|
||||
TauriAuthorizationRequestResolver resolver = buildResolver();
|
||||
OAuth2AuthorizationRequest authRequest = resolver.resolve(buildRequest(true));
|
||||
assertNotNull(authRequest);
|
||||
assertNotNull(authRequest.getState());
|
||||
assertTrue(authRequest.getState().startsWith("tauri:"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_doesNotPrefixStateWithoutTauriParam() {
|
||||
TauriAuthorizationRequestResolver resolver = buildResolver();
|
||||
OAuth2AuthorizationRequest authRequest = resolver.resolve(buildRequest(false));
|
||||
assertNotNull(authRequest);
|
||||
assertNotNull(authRequest.getState());
|
||||
assertFalse(authRequest.getState().startsWith("tauri:"));
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package stirling.software.proprietary.security.oauth2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
class TauriOAuthUtilsTest {
|
||||
|
||||
@Test
|
||||
void extractNonceFromState_validState() {
|
||||
String state = "tauri:original-state-12345:test-nonce-uuid";
|
||||
String nonce = TauriOAuthUtils.extractNonceFromState(state);
|
||||
assertEquals("test-nonce-uuid", nonce);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractNonceFromState_stateWithColonInNonce() {
|
||||
String state = "tauri:original:complex:nonce-with-colon";
|
||||
String nonce = TauriOAuthUtils.extractNonceFromState(state);
|
||||
assertEquals("nonce-with-colon", nonce);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractNonceFromState_noNonce() {
|
||||
String state = "tauri:original-state";
|
||||
String nonce = TauriOAuthUtils.extractNonceFromState(state);
|
||||
assertNull(nonce);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractNonceFromState_notTauriState() {
|
||||
String state = "regular-state:with-colons";
|
||||
String nonce = TauriOAuthUtils.extractNonceFromState(state);
|
||||
assertNull(nonce);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractNonceFromState_nullState() {
|
||||
String nonce = TauriOAuthUtils.extractNonceFromState(null);
|
||||
assertNull(nonce);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractNonceFromRequest_validRequest() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setParameter("state", "tauri:abc:nonce-123");
|
||||
|
||||
String nonce = TauriOAuthUtils.extractNonceFromRequest(request);
|
||||
assertEquals("nonce-123", nonce);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isTauriState_validTauriState() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setParameter("state", "tauri:original-state");
|
||||
|
||||
assertTrue(TauriOAuthUtils.isTauriState(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isTauriState_notTauriState() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setParameter("state", "regular-state");
|
||||
|
||||
assertFalse(TauriOAuthUtils.isTauriState(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isTauriState_noState() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
|
||||
assertFalse(TauriOAuthUtils.isTauriState(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultCallbackPath_rootContext() {
|
||||
assertEquals("/auth/callback", TauriOAuthUtils.defaultCallbackPath("/"));
|
||||
assertEquals("/auth/callback", TauriOAuthUtils.defaultCallbackPath(""));
|
||||
assertEquals("/auth/callback", TauriOAuthUtils.defaultCallbackPath(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultCallbackPath_withContext() {
|
||||
assertEquals("/myapp/auth/callback", TauriOAuthUtils.defaultCallbackPath("/myapp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultTauriCallbackPath_rootContext() {
|
||||
assertEquals("/auth/callback/tauri", TauriOAuthUtils.defaultTauriCallbackPath("/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultTauriCallbackPath_withContext() {
|
||||
assertEquals(
|
||||
"/myapp/auth/callback/tauri", TauriOAuthUtils.defaultTauriCallbackPath("/myapp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizeContextPath_rootPaths() {
|
||||
assertEquals("", TauriOAuthUtils.normalizeContextPath("/"));
|
||||
assertEquals("", TauriOAuthUtils.normalizeContextPath(""));
|
||||
assertEquals("", TauriOAuthUtils.normalizeContextPath(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizeContextPath_withPath() {
|
||||
assertEquals("/myapp", TauriOAuthUtils.normalizeContextPath("/myapp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractRedirectPathFromCookie_noCookies() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
assertNull(TauriOAuthUtils.extractRedirectPathFromCookie(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractRedirectPathFromCookie_withCookie() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setCookies(
|
||||
new jakarta.servlet.http.Cookie(
|
||||
TauriOAuthUtils.SPA_REDIRECT_COOKIE, "/auth/callback"));
|
||||
|
||||
assertEquals("/auth/callback", TauriOAuthUtils.extractRedirectPathFromCookie(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractRedirectPathFromCookie_emptyCookie() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setCookies(
|
||||
new jakarta.servlet.http.Cookie(TauriOAuthUtils.SPA_REDIRECT_COOKIE, ""));
|
||||
|
||||
assertNull(TauriOAuthUtils.extractRedirectPathFromCookie(request));
|
||||
}
|
||||
}
|
||||
Generated
+10
@@ -50,6 +50,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",
|
||||
@@ -4086,6 +4087,15 @@
|
||||
"@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",
|
||||
|
||||
@@ -46,6 +46,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",
|
||||
|
||||
@@ -531,6 +531,8 @@ accountSettings = "Account Settings"
|
||||
adminSettings = "Admin Settings - View and Add Users"
|
||||
userControlSettings = "User Control Settings"
|
||||
changeUsername = "Change Username"
|
||||
changeUsernameDescription = "Update your username. You will be logged out after updating."
|
||||
newUsernamePlaceholder = "Enter your new username"
|
||||
newUsername = "New Username"
|
||||
password = "Confirmation Password"
|
||||
oldPassword = "Old password"
|
||||
@@ -6233,6 +6235,7 @@ connectingTo = "Connecting to:"
|
||||
submit = "Login"
|
||||
signInWith = "Sign in with"
|
||||
oauthPending = "Opening browser for authentication..."
|
||||
sso = "Single Sign-On"
|
||||
orContinueWith = "Or continue with email"
|
||||
serverRequirement = "Note: The server must have login enabled."
|
||||
showInstructions = "How to enable?"
|
||||
|
||||
@@ -85,7 +85,14 @@ pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> {
|
||||
// Delete the token - ignore error if it doesn't exist
|
||||
match entry.delete_credential() {
|
||||
Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(format!("Failed to clear token: {}", e)),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to delete keyring credential: {}. Attempting overwrite with empty token.", e);
|
||||
// As a fallback, overwrite with an empty token so a stale value cannot be reused
|
||||
match entry.set_password("") {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e2) => Err(format!("Failed to clear token (delete + overwrite failed): {}", e2)),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
|
||||
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
|
||||
import { connectionModeService } from '@desktop/services/connectionModeService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { authService } from '@app/services/authService';
|
||||
|
||||
/**
|
||||
* Desktop application providers
|
||||
@@ -18,12 +19,26 @@ import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
|
||||
const [connectionMode, setConnectionMode] = useState<'saas' | 'selfhosted' | null>(null);
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
// Load connection mode on mount
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFirstLaunch && setupComplete) {
|
||||
authService.isAuthenticated()
|
||||
.then(setIsAuthenticated)
|
||||
.catch(() => setIsAuthenticated(false))
|
||||
.finally(() => setAuthChecked(true));
|
||||
} else if (isFirstLaunch && !setupComplete) {
|
||||
setAuthChecked(true);
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
}, [isFirstLaunch, setupComplete]);
|
||||
|
||||
// Initialize backend health monitoring for self-hosted mode
|
||||
useEffect(() => {
|
||||
if (setupComplete && !isFirstLaunch && connectionMode === 'selfhosted') {
|
||||
@@ -59,6 +74,29 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Show setup wizard when not authenticated (desktop login flow).
|
||||
if (authChecked && !isAuthenticated) {
|
||||
return (
|
||||
<ProprietaryAppProviders
|
||||
appConfigRetryOptions={{
|
||||
maxRetries: 5,
|
||||
initialDelay: 1000,
|
||||
}}
|
||||
appConfigProviderProps={{
|
||||
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
|
||||
bootstrapMode: 'non-blocking',
|
||||
autoFetch: false,
|
||||
}}
|
||||
>
|
||||
<SetupWizard
|
||||
onComplete={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
</ProprietaryAppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal app flow
|
||||
return (
|
||||
<ProprietaryAppProviders
|
||||
|
||||
@@ -3,16 +3,25 @@ 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';
|
||||
type KnownProviderId = 'google' | 'github' | 'keycloak' | 'azure' | 'apple' | 'oidc';
|
||||
export type OAuthProviderId = KnownProviderId | 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 +30,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 +58,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 +79,7 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const providerConfig: Record<OAuthProvider, { label: string; file: string }> = {
|
||||
const providerConfig: Record<KnownProviderId, { label: string; file: string }> = {
|
||||
google: { label: 'Google', file: 'google.svg' },
|
||||
github: { label: 'GitHub', file: 'github.svg' },
|
||||
keycloak: { label: 'Keycloak', file: 'keycloak.svg' },
|
||||
@@ -72,6 +87,9 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
|
||||
apple: { label: 'Apple', file: 'apple.svg' },
|
||||
oidc: { label: 'OpenID', file: 'oidc.svg' },
|
||||
};
|
||||
const isKnownProvider = (id: OAuthProviderId): id is KnownProviderId =>
|
||||
(id as KnownProviderId) in providerConfig;
|
||||
const GENERIC_PROVIDER_ICON = 'oidc.svg';
|
||||
|
||||
if (providers.length === 0) {
|
||||
return null;
|
||||
@@ -80,23 +98,31 @@ 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 = isKnownProvider(providerEntry.id)
|
||||
? providerConfig[providerEntry.id]
|
||||
: undefined;
|
||||
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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
||||
|
||||
type SignOutFn = () => Promise<void>;
|
||||
|
||||
interface AccountLogoutDeps {
|
||||
signOut: SignOutFn;
|
||||
redirectToLogin: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop-specific logout: mirrors Connection Settings flow to avoid stale state.
|
||||
*/
|
||||
export function useAccountLogout() {
|
||||
return async ({ signOut, redirectToLogin }: AccountLogoutDeps): Promise<void> => {
|
||||
try {
|
||||
await signOut();
|
||||
|
||||
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL);
|
||||
await connectionModeService.resetSetupCompletion().catch(() => {});
|
||||
|
||||
window.history.replaceState({}, '', '/');
|
||||
window.location.reload();
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn('[Desktop AccountLogout] Desktop-specific logout failed, falling back to redirect', err);
|
||||
}
|
||||
|
||||
redirectToLogin();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Desktop-specific OAuth callback handling for self-hosted SSO flows.
|
||||
*/
|
||||
export async function handleAuthCallbackSuccess(token: string): Promise<void> {
|
||||
// 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);
|
||||
}
|
||||
|
||||
// No-op beyond popup notification; deep link flow handles desktop completion.
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { authService } from '@app/services/authService';
|
||||
|
||||
/**
|
||||
* Desktop-specific auth cleanup hooks.
|
||||
*/
|
||||
export async function clearPlatformAuthAfterSignOut(): Promise<void> {
|
||||
try {
|
||||
await authService.localClearAuth();
|
||||
} catch (err) {
|
||||
console.warn('[AuthCleanup] Failed to clear desktop auth data after sign out', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearPlatformAuthOnLoginInit(): Promise<void> {
|
||||
try {
|
||||
await authService.localClearAuth();
|
||||
} catch (err) {
|
||||
console.warn('[AuthCleanup] Failed to clear desktop auth data on login init', err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { authService } from '@app/services/authService';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
|
||||
/**
|
||||
* Desktop-specific OAuth navigation: prefer popup/system browser, avoid hijacking main webview.
|
||||
*/
|
||||
export async function startOAuthNavigation(redirectUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const currentConfig = await connectionModeService.getCurrentConfig().catch(() => null);
|
||||
const serverUrl = currentConfig?.server_config?.url;
|
||||
if (!serverUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const providerUrl = new URL(redirectUrl, serverUrl);
|
||||
const providerPath = `${providerUrl.pathname}${providerUrl.search}`;
|
||||
await authService.loginWithSelfHostedOAuth(providerPath, serverUrl);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('[Desktop OAuthNavigation] Failed to start OAuth flow', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
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 { 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';
|
||||
|
||||
@@ -108,8 +112,34 @@ export class AuthService {
|
||||
this.cachedToken = null;
|
||||
console.log('[Desktop AuthService] Cache invalidated');
|
||||
|
||||
await invoke('clear_auth_token');
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
// 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 {
|
||||
@@ -253,6 +283,41 @@ export class AuthService {
|
||||
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();
|
||||
|
||||
@@ -367,6 +432,21 @@ export class AuthService {
|
||||
|
||||
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();
|
||||
|
||||
@@ -382,6 +462,9 @@ export class AuthService {
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,6 +519,180 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
|
||||
import { springAuth } from '@app/auth/springAuthClient';
|
||||
import { clearPlatformAuthOnLoginInit } from '@app/extensions/authSessionCleanup';
|
||||
import type { Session, User, AuthError, AuthChangeEvent } from '@app/auth/springAuthClient';
|
||||
|
||||
/**
|
||||
@@ -79,6 +80,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
console.debug('[Auth] Signed out successfully');
|
||||
setSession(null);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('[Auth] Unexpected error during sign out:', err);
|
||||
setError(err as AuthError);
|
||||
@@ -94,6 +96,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
console.debug('[Auth] Initializing auth...');
|
||||
// Clear any platform-specific cached auth on login page init.
|
||||
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/login')) {
|
||||
await clearPlatformAuthOnLoginInit();
|
||||
}
|
||||
|
||||
// Skip config check entirely - let the app handle login state
|
||||
// The config will be fetched by useAppConfig when needed
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Helper utilities for clearing cached OAuth redirect/session state
|
||||
*/
|
||||
|
||||
const OAUTH_REDIRECT_COOKIE = 'stirling_redirect_path';
|
||||
|
||||
/**
|
||||
* Clear any persisted OAuth redirect path/cached state so the app
|
||||
* does not automatically resume a previous OAuth session after logout.
|
||||
*/
|
||||
export function resetOAuthState(): void {
|
||||
try {
|
||||
// Remove redirect cookie
|
||||
if (typeof document !== 'undefined') {
|
||||
document.cookie = `${OAUTH_REDIRECT_COOKIE}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Lax`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[OAuthStorage] Failed to clear redirect cookie', err);
|
||||
}
|
||||
|
||||
// Remove any related localStorage entries we might have used
|
||||
try {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
window.localStorage.removeItem(OAUTH_REDIRECT_COOKIE);
|
||||
window.localStorage.removeItem('oauth_redirect_path');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[OAuthStorage] Failed to clear OAuth localStorage', err);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
resetOAuthState,
|
||||
};
|
||||
@@ -1,10 +1,14 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { springAuth } from '@app/auth/springAuthClient';
|
||||
import { startOAuthNavigation } from '@app/extensions/oauthNavigation';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('@app/services/apiClient');
|
||||
vi.mock('@app/extensions/oauthNavigation', () => ({
|
||||
startOAuthNavigation: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
describe('SpringAuthClient', () => {
|
||||
beforeEach(() => {
|
||||
@@ -342,13 +346,35 @@ describe('SpringAuthClient', () => {
|
||||
writable: true,
|
||||
});
|
||||
|
||||
vi.mocked(startOAuthNavigation).mockResolvedValueOnce(false);
|
||||
|
||||
const result = await springAuth.signInWithOAuth({
|
||||
provider: '/oauth2/authorization/github',
|
||||
options: { redirectTo: '/auth/callback' },
|
||||
});
|
||||
|
||||
expect(startOAuthNavigation).toHaveBeenCalledWith('/oauth2/authorization/github');
|
||||
expect(mockAssign).toHaveBeenCalledWith('/oauth2/authorization/github');
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should skip redirect when handled by extension', async () => {
|
||||
const mockAssign = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { assign: mockAssign },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
vi.mocked(startOAuthNavigation).mockResolvedValueOnce(true);
|
||||
|
||||
const result = await springAuth.signInWithOAuth({
|
||||
provider: '/oauth2/authorization/github',
|
||||
options: { redirectTo: '/auth/callback' },
|
||||
});
|
||||
|
||||
expect(startOAuthNavigation).toHaveBeenCalledWith('/oauth2/authorization/github');
|
||||
expect(mockAssign).not.toHaveBeenCalled();
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,9 @@ import apiClient from '@app/services/apiClient';
|
||||
import { AxiosError } from 'axios';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { type OAuthProvider } from '@app/auth/oauthTypes';
|
||||
import { resetOAuthState } from '@app/auth/oauthStorage';
|
||||
import { clearPlatformAuthAfterSignOut } from '@app/extensions/authSessionCleanup';
|
||||
import { startOAuthNavigation } from '@app/extensions/oauthNavigation';
|
||||
|
||||
// Helper to extract error message from axios error
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
@@ -267,6 +270,10 @@ class SpringAuthClient {
|
||||
// Use the full path provided by the backend
|
||||
// This supports both OAuth2 (/oauth2/authorization/...) and SAML2 (/saml2/authenticate/...)
|
||||
const redirectUrl = params.provider;
|
||||
const handled = await startOAuthNavigation(redirectUrl);
|
||||
if (handled) {
|
||||
return { error: null };
|
||||
}
|
||||
// console.log('[SpringAuth] Redirecting to SSO:', redirectUrl);
|
||||
// Use window.location.assign for full page navigation
|
||||
window.location.assign(redirectUrl);
|
||||
@@ -296,6 +303,35 @@ class SpringAuthClient {
|
||||
|
||||
// Clean up local storage
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
try {
|
||||
Object.keys(localStorage)
|
||||
.filter((key) => key.startsWith('sb-') || key.includes('supabase'))
|
||||
.forEach((key) => localStorage.removeItem(key));
|
||||
|
||||
// Clear any cached OAuth redirect/session state
|
||||
resetOAuthState();
|
||||
} catch (err) {
|
||||
console.warn('[SpringAuth] Failed to clear Supabase/local auth tokens', err);
|
||||
}
|
||||
|
||||
// Clear cookies that might hold refresh/session tokens
|
||||
try {
|
||||
document.cookie.split(';').forEach(cookie => {
|
||||
const eqPos = cookie.indexOf('=');
|
||||
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
|
||||
if (name) {
|
||||
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;`;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[SpringAuth] Failed to clear cookies on sign out', err);
|
||||
}
|
||||
|
||||
try {
|
||||
await clearPlatformAuthAfterSignOut();
|
||||
} catch (cleanupError) {
|
||||
console.warn('[SpringAuth] Failed to run platform auth cleanup', cleanupError);
|
||||
}
|
||||
|
||||
// Notify listeners
|
||||
this.notifyListeners('SIGNED_OUT', null);
|
||||
@@ -305,6 +341,11 @@ class SpringAuthClient {
|
||||
console.error('[SpringAuth] signOut error:', error);
|
||||
// Still remove token even if backend call fails
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
try {
|
||||
await clearPlatformAuthAfterSignOut();
|
||||
} catch (cleanupError) {
|
||||
console.warn('[SpringAuth] Failed to run platform auth cleanup after error', cleanupError);
|
||||
}
|
||||
return {
|
||||
error: { message: getErrorMessage(error, 'Logout failed') },
|
||||
};
|
||||
|
||||
@@ -6,10 +6,12 @@ import { alert as showToast } from '@app/components/toast';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { accountService } from '@app/services/accountService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import { useAccountLogout } from '@app/extensions/accountLogout';
|
||||
|
||||
const AccountSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user, signOut } = useAuth();
|
||||
const accountLogout = useAccountLogout();
|
||||
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
||||
const [usernameModalOpen, setUsernameModalOpen] = useState(false);
|
||||
|
||||
@@ -42,12 +44,8 @@ const AccountSection: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
await signOut();
|
||||
} finally {
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [redirectToLogin, signOut]);
|
||||
await accountLogout({ signOut, redirectToLogin });
|
||||
}, [accountLogout, redirectToLogin, signOut]);
|
||||
|
||||
const handlePasswordSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
type SignOutFn = () => Promise<void>;
|
||||
|
||||
interface AccountLogoutDeps {
|
||||
signOut: SignOutFn;
|
||||
redirectToLogin: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default (web/proprietary) logout handler: sign out and redirect to /login.
|
||||
* Desktop builds override this file via path resolution.
|
||||
*/
|
||||
export function useAccountLogout() {
|
||||
return async ({ signOut, redirectToLogin }: AccountLogoutDeps): Promise<void> => {
|
||||
try {
|
||||
await signOut();
|
||||
} finally {
|
||||
redirectToLogin();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Extension hook for platform-specific OAuth callback handling.
|
||||
* Proprietary/web builds are no-op.
|
||||
*/
|
||||
export async function handleAuthCallbackSuccess(_token: string): Promise<void> {
|
||||
// no-op for web builds
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Extension hooks for platform-specific auth cleanup.
|
||||
* Proprietary/web builds are no-op.
|
||||
*/
|
||||
export async function clearPlatformAuthAfterSignOut(): Promise<void> {
|
||||
// no-op for web builds
|
||||
}
|
||||
|
||||
export async function clearPlatformAuthOnLoginInit(): Promise<void> {
|
||||
// no-op for web builds
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Extension hook for platform-specific OAuth navigation.
|
||||
* Proprietary/web builds default to in-window navigation.
|
||||
*/
|
||||
export async function startOAuthNavigation(_redirectUrl: string): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 50px 20px;
|
||||
background: #f5f5f5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
color: #1a1a1a;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.iconSuccess {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.iconError {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
.iconNeutral {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.message {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.loadingExtra {
|
||||
color: #6b7280;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.errorBox {
|
||||
margin-top: 20px;
|
||||
background: #ffebee;
|
||||
border: 1px solid #ffcdd2;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
color: #c62828;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.page {
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #2d2d2d;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
color: #e5e7eb;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.iconSuccess {
|
||||
color: #66bb6a;
|
||||
}
|
||||
|
||||
.iconError {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
.iconNeutral {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.message,
|
||||
.loadingExtra {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
.errorBox {
|
||||
background: #3d2020;
|
||||
border: 1px solid #5d3030;
|
||||
color: #ef9a9a;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.page {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 40px;
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,6 @@ describe('AuthCallback', () => {
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
expect(getByText('Completing authentication...')).toBeInTheDocument();
|
||||
expect(getByText('Completing authentication')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { springAuth } from '@app/auth/springAuthClient';
|
||||
import { handleAuthCallbackSuccess } from '@app/extensions/authCallback';
|
||||
import styles from '@app/routes/AuthCallback.module.css';
|
||||
|
||||
/**
|
||||
* OAuth Callback Handler
|
||||
@@ -52,6 +54,8 @@ export default function AuthCallback() {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleAuthCallbackSuccess(token);
|
||||
|
||||
console.log('[AuthCallback] Token validated, redirecting to home');
|
||||
|
||||
// Clear the hash from URL and redirect to home page
|
||||
@@ -69,17 +73,12 @@ export default function AuthCallback() {
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh'
|
||||
}}>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
|
||||
<div className="text-gray-600">
|
||||
Completing authentication...
|
||||
</div>
|
||||
<div className={styles.page}>
|
||||
<div className={styles.card}>
|
||||
<div className={`${styles.icon} ${styles.iconNeutral}`}>...</div>
|
||||
<div className={styles.title}>Completing authentication</div>
|
||||
<div className={styles.message}>Please wait while we finish signing you in.</div>
|
||||
<div className={styles.loadingExtra}>You can close this window once it completes.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user