diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index b672298ca..1a817e858 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -409,10 +409,12 @@ public class ApplicationProperties { private TempFileManagement tempFileManagement = new TempFileManagement(); private DatabaseBackup databaseBackup = new DatabaseBackup(); private List corsAllowedOrigins = new ArrayList<>(); - private String - frontendUrl; // Base URL for frontend (used for invite links, etc.). If not set, + private String backendUrl; // Backend base URL for SAML/OAuth/API callbacks (e.g. + // 'http://localhost:8080', 'https://api.example.com'). Required for + // SSO. + private String frontendUrl; // Frontend URL for invite email links (e.g. - // falls back to backend URL. + // 'https://app.example.com'). If not set, falls back to backendUrl. public boolean isAnalyticsEnabled() { return this.getEnableAnalytics() != null && this.getEnableAnalytics(); diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index ad1ee4c8a..dffebe1bb 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -58,6 +58,8 @@ security: idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair + # IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId} + # Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration. jwt: # This feature is currently under development and not yet fully supported. Do not use in production. persistence: true # Set to 'true' to enable JWT key store enableKeyRotation: true # Set to 'true' to enable key pair rotation @@ -132,8 +134,9 @@ system: enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML) maxDPI: 500 # Maximum allowed DPI for PDF to image conversion - corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. - frontendUrl: '' # Base URL for frontend (e.g. 'https://pdf.example.com'). Used for generating invite links in emails. If empty, falls back to backend URL. + corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173' + backendUrl: '' # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development. + frontendUrl: '' # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails. serverCertificate: enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option organizationName: Stirling-PDF # Organization name for generated certificates diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index 88fddb7ea..e13d807da 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -94,6 +94,22 @@ public class ProprietaryUIDataController { this.auditRepository = auditRepository; } + /** + * Get the backend base URL for SAML/OAuth redirects. Uses system.backendUrl from config if set, + * otherwise defaults to http://localhost:8080 + */ + private String getBackendBaseUrl() { + String backendUrl = applicationProperties.getSystem().getBackendUrl(); + + // If backendUrl is configured, use it + if (backendUrl != null && !backendUrl.trim().isEmpty()) { + return backendUrl.trim(); + } + + // For development, default to localhost:8080 (backend port) + return "http://localhost:8080"; + } + @GetMapping("/audit-dashboard") @PreAuthorize("hasRole('ADMIN')") @EnterpriseEndpoint @@ -185,14 +201,17 @@ public class ProprietaryUIDataController { } SAML2 saml2 = securityProps.getSaml2(); - if (securityProps.isSaml2Active() - && applicationProperties.getSystem().getEnableAlphaFunctionality() - && applicationProperties.getPremium().isEnabled()) { + if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) { String samlIdp = saml2.getProvider(); String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); + // For SAML, we need to use the backend URL directly, not a relative path + // This ensures Spring Security generates the correct ACS URL + String backendUrl = getBackendBaseUrl(); + String fullSamlPath = backendUrl + saml2AuthenticationPath; + if (!applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) { - providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)"); + providerList.put(fullSamlPath, samlIdp + " (SAML 2)"); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java index d035dbc58..17857fc85 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java @@ -120,9 +120,7 @@ public class AccountWebController { SAML2 saml2 = securityProps.getSaml2(); - if (securityProps.isSaml2Active() - && applicationProperties.getSystem().getEnableAlphaFunctionality() - && applicationProperties.getPremium().isEnabled()) { + if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) { String samlIdp = saml2.getProvider(); String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 257d243ea..1226237c8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -334,7 +334,8 @@ public class SecurityConfiguration { securityProperties.getSaml2(), userService, jwtService, - licenseSettingsService)) + licenseSettingsService, + applicationProperties)) .failureHandler( new CustomSaml2AuthenticationFailureHandler()) .authenticationRequestResolver( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index e8bce579a..3c63f1bf4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -51,6 +51,7 @@ public class CustomSaml2AuthenticationSuccessHandler private final JwtServiceInterface jwtService; private final stirling.software.proprietary.service.UserLicenseSettingsService licenseSettingsService; + private final ApplicationProperties applicationProperties; @Override @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) @@ -77,8 +78,8 @@ public class CustomSaml2AuthenticationSuccessHandler log.warn( "SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)", username); - response.sendRedirect( - request.getContextPath() + "/logout?saml2RequiresLicense=true"); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/logout?saml2RequiresLicense=true"); return; } } else if (!licenseSettingsService.isSamlEligible(null)) { @@ -86,8 +87,8 @@ public class CustomSaml2AuthenticationSuccessHandler log.warn( "SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)", username); - response.sendRedirect( - request.getContextPath() + "/logout?saml2RequiresLicense=true"); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/logout?saml2RequiresLicense=true"); return; } @@ -144,20 +145,28 @@ public class CustomSaml2AuthenticationSuccessHandler log.debug( "User {} exists with password but is not SSO user, redirecting to logout", username); - response.sendRedirect( - contextPath + "/logout?oAuth2AuthenticationErrorWeb=true"); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/logout?oAuth2AuthenticationErrorWeb=true"); return; } try { - if (!userExists || saml2Properties.getBlockRegistration()) { - log.debug("Registration blocked for new user: {}", username); - response.sendRedirect( - contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser"); + // Block new users only if: blockRegistration is true OR autoCreateUser is false + if (!userExists + && (saml2Properties.getBlockRegistration() + || !saml2Properties.getAutoCreateUser())) { + log.debug( + "Registration blocked for new user '{}' (blockRegistration: {}, autoCreateUser: {})", + username, + saml2Properties.getBlockRegistration(), + saml2Properties.getAutoCreateUser()); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/login?errorOAuth=oAuth2AdminBlockedUser"); return; } if (!userExists && licenseSettingsService.wouldExceedLimit(1)) { - response.sendRedirect(contextPath + "/logout?maxUsersReached=true"); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/logout?maxUsersReached=true"); return; } @@ -222,16 +231,30 @@ public class CustomSaml2AuthenticationSuccessHandler String contextPath, String jwt) { String redirectPath = resolveRedirectPath(request, contextPath); - String origin = - resolveForwardedOrigin(request) - .orElseGet( - () -> - resolveOriginFromReferer(request) - .orElseGet(() -> buildOriginFromRequest(request))); + String origin = resolveOrigin(request); clearRedirectCookie(response); return origin + redirectPath + "#access_token=" + jwt; } + /** + * Resolve the origin (frontend URL) for redirects. First checks system.frontendUrl from config, + * then falls back to detecting from request headers. + */ + private String resolveOrigin(HttpServletRequest request) { + // First check if frontendUrl is configured + String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl(); + if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) { + return configuredFrontendUrl.trim(); + } + + // Fall back to auto-detection from request headers + return resolveForwardedOrigin(request) + .orElseGet( + () -> + resolveOriginFromReferer(request) + .orElseGet(() -> buildOriginFromRequest(request))); + } + private String resolveRedirectPath(HttpServletRequest request, String contextPath) { return extractRedirectPathFromCookie(request) .filter(path -> path.startsWith("/")) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java index 9d21f88a3..99be4b5b0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java @@ -41,22 +41,74 @@ public class Saml2Configuration { @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception { SAML2 samlConf = applicationProperties.getSecurity().getSaml2(); - X509Certificate idpCert = CertificateUtils.readCertificate(samlConf.getIdpCert()); + + log.info( + "Initializing SAML2 configuration with registration ID: {}", + samlConf.getRegistrationId()); + + // Load IdP certificate + X509Certificate idpCert; + try { + Resource idpCertResource = samlConf.getIdpCert(); + log.info("Loading IdP certificate from: {}", idpCertResource.getDescription()); + if (!idpCertResource.exists()) { + log.error( + "SAML2 IdP certificate not found at: {}", idpCertResource.getDescription()); + throw new IllegalStateException( + "SAML2 IdP certificate file does not exist: " + + idpCertResource.getDescription()); + } + idpCert = CertificateUtils.readCertificate(idpCertResource); + log.info( + "Successfully loaded IdP certificate. Subject: {}", + idpCert.getSubjectX500Principal().getName()); + } catch (Exception e) { + log.error("Failed to load SAML2 IdP certificate: {}", e.getMessage(), e); + throw new IllegalStateException("Failed to load SAML2 IdP certificate", e); + } + Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert); + + // Load SP private key and certificate Resource privateKeyResource = samlConf.getPrivateKey(); Resource certificateResource = samlConf.getSpCert(); - Saml2X509Credential signingCredential = - new Saml2X509Credential( - CertificateUtils.readPrivateKey(privateKeyResource), - CertificateUtils.readCertificate(certificateResource), - Saml2X509CredentialType.SIGNING); + + log.info("Loading SP private key from: {}", privateKeyResource.getDescription()); + if (!privateKeyResource.exists()) { + log.error("SAML2 SP private key not found at: {}", privateKeyResource.getDescription()); + throw new IllegalStateException( + "SAML2 SP private key file does not exist: " + + privateKeyResource.getDescription()); + } + + log.info("Loading SP certificate from: {}", certificateResource.getDescription()); + if (!certificateResource.exists()) { + log.error( + "SAML2 SP certificate not found at: {}", certificateResource.getDescription()); + throw new IllegalStateException( + "SAML2 SP certificate file does not exist: " + + certificateResource.getDescription()); + } + + Saml2X509Credential signingCredential; + try { + signingCredential = + new Saml2X509Credential( + CertificateUtils.readPrivateKey(privateKeyResource), + CertificateUtils.readCertificate(certificateResource), + Saml2X509CredentialType.SIGNING); + log.info("Successfully loaded SP credentials"); + } catch (Exception e) { + log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e); + throw new IllegalStateException("Failed to load SAML2 SP credentials", e); + } RelyingPartyRegistration rp = RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId()) .signingX509Credentials(c -> c.add(signingCredential)) .entityId(samlConf.getIdpIssuer()) .singleLogoutServiceBinding(Saml2MessageBinding.POST) .singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl()) - .singleLogoutServiceResponseLocation("http://localhost:8080/login") + .singleLogoutServiceResponseLocation("{baseUrl}/login") .assertionConsumerServiceBinding(Saml2MessageBinding.POST) .assertionConsumerServiceLocation( "{baseUrl}/login/saml2/sso/{registrationId}") @@ -75,9 +127,14 @@ public class Saml2Configuration { .singleLogoutServiceLocation( samlConf.getIdpSingleLogoutUrl()) .singleLogoutServiceResponseLocation( - "http://localhost:8080/login") + "{baseUrl}/login") .wantAuthnRequestsSigned(true)) .build(); + + log.info( + "SAML2 configuration initialized successfully. Registration ID: {}, IdP: {}", + samlConf.getRegistrationId(), + samlConf.getIdpIssuer()); return new InMemoryRelyingPartyRegistrationRepository(rp); } diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts index 646b71182..1ba2b909a 100644 --- a/frontend/src/proprietary/auth/springAuthClient.ts +++ b/frontend/src/proprietary/auth/springAuthClient.ts @@ -249,11 +249,11 @@ class SpringAuthClient { } /** - * Sign in with OAuth provider (GitHub, Google, Authentik, etc.) - * This redirects to the Spring OAuth2 authorization endpoint + * Sign in with OAuth/SAML provider (GitHub, Google, Authentik, etc.) + * This redirects to the Spring OAuth2/SAML2 authorization endpoint * - * @param params.provider - OAuth provider ID (e.g., 'github', 'google', 'authentik', 'mycompany') - * Can be any known provider or custom string - the backend determines available providers + * @param params.provider - Full auth path from backend (e.g., '/oauth2/authorization/google', '/saml2/authenticate/stirling') + * The backend provides the complete path including the auth type and provider ID */ async signInWithOAuth(params: { provider: OAuthProvider; @@ -263,15 +263,16 @@ class SpringAuthClient { const redirectPath = normalizeRedirectPath(params.options?.redirectTo); persistRedirectPath(redirectPath); - // Redirect to Spring OAuth2 endpoint (Vite will proxy to backend) - const redirectUrl = `/oauth2/authorization/${params.provider}`; - // console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl); + // Use the full path provided by the backend + // This supports both OAuth2 (/oauth2/authorization/...) and SAML2 (/saml2/authenticate/...) + const redirectUrl = params.provider; + // console.log('[SpringAuth] Redirecting to SSO:', redirectUrl); // Use window.location.assign for full page navigation window.location.assign(redirectUrl); return { error: null }; } catch (error) { return { - error: { message: error instanceof Error ? error.message : 'OAuth redirect failed' }, + error: { message: error instanceof Error ? error.message : 'SSO redirect failed' }, }; } } diff --git a/frontend/src/proprietary/routes/Login.tsx b/frontend/src/proprietary/routes/Login.tsx index 4bca34d43..d25b70c58 100644 --- a/frontend/src/proprietary/routes/Login.tsx +++ b/frontend/src/proprietary/routes/Login.tsx @@ -109,13 +109,12 @@ export default function Login() { updateSupportedLanguages(data.languages, data.defaultLocale); } - // Extract provider IDs from the providerList map - // The keys are like "/oauth2/authorization/google" - extract the last part - const providerIds = Object.keys(data.providerList || {}) - .map(key => key.split('/').pop()) - .filter((id): id is string => id !== undefined); + // Use the full paths from providerList as provider identifiers + // The backend provides paths like "/oauth2/authorization/google" or "/saml2/authenticate/stirling" + // We'll use these full paths so the auth client knows where to redirect + const providerPaths = Object.keys(data.providerList || {}); - setEnabledProviders(providerIds); + setEnabledProviders(providerPaths); } catch (err) { console.error('[Login] Failed to fetch enabled providers:', err); } diff --git a/frontend/src/proprietary/routes/login/OAuthButtons.tsx b/frontend/src/proprietary/routes/login/OAuthButtons.tsx index d62edfdc1..4a9cc3cc3 100644 --- a/frontend/src/proprietary/routes/login/OAuthButtons.tsx +++ b/frontend/src/proprietary/routes/login/OAuthButtons.tsx @@ -26,7 +26,7 @@ interface OAuthButtonsProps { onProviderClick: (provider: OAuthProvider) => void isSubmitting: boolean layout?: 'vertical' | 'grid' | 'icons' - enabledProviders?: OAuthProvider[] // List of enabled provider IDs from backend + enabledProviders?: OAuthProvider[] // List of full auth paths from backend (e.g., '/oauth2/authorization/google', '/saml2/authenticate/stirling') } export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical', enabledProviders = [] }: OAuthButtonsProps) { @@ -37,19 +37,24 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = ' ? Object.keys(oauthProviderConfig) : enabledProviders; - // Build provider list - use provider ID to determine icon and label - const providers = providersToShow.map(id => { - if (id in oauthProviderConfig) { + // Build provider list - extract provider ID from full path for display + const providers = providersToShow.map(pathOrId => { + // Extract provider ID from full path (e.g., '/saml2/authenticate/stirling' -> 'stirling') + const providerId = pathOrId.split('/').pop() || pathOrId; + + if (providerId in oauthProviderConfig) { // Known provider - use predefined icon and label return { - id, - ...oauthProviderConfig[id] + id: pathOrId, // Keep full path for redirect + providerId, // Store extracted ID for display lookup + ...oauthProviderConfig[providerId] }; } // Unknown provider - use generic icon and capitalize ID for label return { - id, - label: id.charAt(0).toUpperCase() + id.slice(1), + id: pathOrId, // Keep full path for redirect + providerId, // Store extracted ID for display lookup + label: providerId.charAt(0).toUpperCase() + providerId.slice(1), file: GENERIC_PROVIDER_ICON }; }); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f8d52908d..56de53e37 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -55,12 +55,24 @@ export default defineConfig(({ mode }) => { secure: false, xfwd: true, }, + '/saml2': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, + }, '/login/oauth2': { target: 'http://localhost:8080', changeOrigin: true, secure: false, xfwd: true, }, + '/login/saml2': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, + }, '/swagger-ui': { target: 'http://localhost:8080', changeOrigin: true,