From 00136f9e2059e97c34ee9e5a4a7d682568b151f5 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 5 Feb 2026 12:26:41 +0000 Subject: [PATCH] Saml fix (#5651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes When password login is disabled UI changes to have central style SSO button image Auto SSO login functionality Massively increases auth debugging visibility: verbose console logging in ErrorBoundary, AuthProvider, Landing, AuthCallback. Improves OAuth/SAML testability: adds Keycloak docker-compose setups + realm JSON exports + start/validate scripts for OAuth and SAML environments. Hardens license upload path handling: better logs + safer directory traversal protection by normalizing absolute paths before startsWith check. UI polish for SSO-only login: new “single provider” centered layout + updated button styles (pill buttons, variants, icon wrapper, arrow). --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../api/ProprietaryUIDataController.java | 6 +- .../api/AdminLicenseController.java | 16 +- .../core/components/shared/ErrorBoundary.tsx | 67 +++- frontend/src/core/extensions/accountLogout.ts | 3 + frontend/src/core/services/accountService.ts | 1 + frontend/src/proprietary/auth/UseSession.tsx | 52 ++- .../src/proprietary/auth/springAuthClient.ts | 3 + .../proprietary/extensions/accountLogout.ts | 3 + .../src/proprietary/routes/AuthCallback.tsx | 73 +++- frontend/src/proprietary/routes/Landing.tsx | 19 +- frontend/src/proprietary/routes/Login.tsx | 207 ++++++++-- .../proprietary/routes/authShared/auth.css | 209 +++++++++- .../proprietary/routes/login/LoginHeader.tsx | 7 +- .../proprietary/routes/login/OAuthButtons.tsx | 52 ++- .../compose/docker-compose-keycloak-oauth.yml | 127 ++++++ .../compose/docker-compose-keycloak-saml.yml | 148 +++++++ testing/compose/keycloak-realm-oauth.json | 370 ++++++++++++++++++ testing/compose/keycloak-realm-saml.json | 210 ++++++++++ testing/compose/start-oauth-test.sh | 171 ++++++++ testing/compose/start-saml-test.sh | 179 +++++++++ testing/compose/validate-oauth-test.sh | 58 +++ testing/compose/validate-saml-test.sh | 58 +++ 22 files changed, 1951 insertions(+), 88 deletions(-) create mode 100644 testing/compose/docker-compose-keycloak-oauth.yml create mode 100644 testing/compose/docker-compose-keycloak-saml.yml create mode 100644 testing/compose/keycloak-realm-oauth.json create mode 100644 testing/compose/keycloak-realm-saml.json create mode 100644 testing/compose/start-oauth-test.sh create mode 100644 testing/compose/start-saml-test.sh create mode 100644 testing/compose/validate-oauth-test.sh create mode 100644 testing/compose/validate-saml-test.sh 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 dbf68eba1..ecf7b990c 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 @@ -139,6 +139,7 @@ public class ProprietaryUIDataController { // Add enableLogin flag so frontend doesn't need to call /app-config data.setEnableLogin(securityProps.isEnableLogin()); + data.setSsoAutoLogin(applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()); // Check if this is first-time setup with default credentials // The isFirstLogin flag captures: default username/password usage and unchanged state @@ -218,9 +219,7 @@ public class ProprietaryUIDataController { String backendUrl = getBackendBaseUrl(); String fullSamlPath = backendUrl + saml2AuthenticationPath; - if (!applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) { - providerList.put(fullSamlPath, samlIdp + " (SAML 2)"); - } + providerList.put(fullSamlPath, samlIdp + " (SAML 2)"); } // Remove null entries @@ -533,6 +532,7 @@ public class ProprietaryUIDataController { @Data public static class LoginData { private Boolean enableLogin; + private boolean ssoAutoLogin; private Map providerList; private String loginMethod; private boolean altLogin; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java index 018607e4d..42083736f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java @@ -309,10 +309,16 @@ public class AdminLicenseController { } try { + log.info( + "License upload: original filename='{}', size={} bytes, contentType='{}'", + file.getOriginalFilename(), + file.getSize(), + file.getContentType()); // Validate certificate format by reading content byte[] fileBytes = file.getBytes(); String content = new String(fileBytes, StandardCharsets.UTF_8); if (!content.trim().startsWith("-----BEGIN LICENSE FILE-----")) { + log.warn("License upload rejected: invalid certificate header"); return ResponseEntity.badRequest() .body( Map.of( @@ -324,9 +330,15 @@ public class AdminLicenseController { // Get config directory and target path Path configPath = Paths.get(InstallationPathConfig.getConfigPath()); - Path targetPath = configPath.resolve(filename).normalize(); + Path configPathAbs = configPath.toAbsolutePath().normalize(); + Path targetPath = configPathAbs.resolve(filename).normalize(); + log.info( + "License upload paths: configPath='{}', targetPath='{}'", + configPathAbs, + targetPath.toAbsolutePath()); // Prevent directory traversal: ensure targetPath is inside configPath - if (!targetPath.startsWith(configPath.normalize().toAbsolutePath())) { + if (!targetPath.startsWith(configPathAbs)) { + log.warn("License upload rejected: target path outside config path"); return ResponseEntity.badRequest() .body(Map.of("success", false, "error", "Invalid file path")); } diff --git a/frontend/src/core/components/shared/ErrorBoundary.tsx b/frontend/src/core/components/shared/ErrorBoundary.tsx index 3dfd32d27..bc00d278e 100644 --- a/frontend/src/core/components/shared/ErrorBoundary.tsx +++ b/frontend/src/core/components/shared/ErrorBoundary.tsx @@ -22,7 +22,43 @@ export default class ErrorBoundary extends React.Component { @@ -37,14 +73,33 @@ export default class ErrorBoundary extends React.Component + Something went wrong {process.env.NODE_ENV === 'development' && this.state.error && ( - - {this.state.error.message} - + <> + + {this.state.error.message} + + {this.state.error.stack && ( +
+ + Show stack trace + +
+                    {this.state.error.stack}
+                  
+
+ )} + )} -
diff --git a/frontend/src/core/extensions/accountLogout.ts b/frontend/src/core/extensions/accountLogout.ts index 005d86a3c..dba011457 100644 --- a/frontend/src/core/extensions/accountLogout.ts +++ b/frontend/src/core/extensions/accountLogout.ts @@ -12,6 +12,9 @@ interface AccountLogoutDeps { export function useAccountLogout() { return async ({ signOut, redirectToLogin }: AccountLogoutDeps): Promise => { try { + if (typeof window !== 'undefined') { + window.sessionStorage.setItem('stirling_sso_auto_login_logged_out', '1'); + } await signOut(); } finally { redirectToLogin(); diff --git a/frontend/src/core/services/accountService.ts b/frontend/src/core/services/accountService.ts index 3e4bb734e..d40de108f 100644 --- a/frontend/src/core/services/accountService.ts +++ b/frontend/src/core/services/accountService.ts @@ -15,6 +15,7 @@ export interface LoginPageData { showDefaultCredentials: boolean; firstTimeSetup: boolean; enableLogin: boolean; + ssoAutoLogin?: boolean; } /** diff --git a/frontend/src/proprietary/auth/UseSession.tsx b/frontend/src/proprietary/auth/UseSession.tsx index 914708836..6ad1618c5 100644 --- a/frontend/src/proprietary/auth/UseSession.tsx +++ b/frontend/src/proprietary/auth/UseSession.tsx @@ -36,6 +36,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Debug: Track state transitions + useEffect(() => { + console.log('[Auth] State changed:', { + loading, + hasSession: !!session, + hasError: !!error, + userId: session?.user?.id, + timestamp: new Date().toISOString() + }); + }, [loading, session, error]); + /** * Refresh current session */ @@ -92,10 +103,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { */ useEffect(() => { let mounted = true; + const mountId = Math.random().toString(36).substring(7); + console.log(`[Auth:${mountId}] 🔵 AuthProvider mounted`); const initializeAuth = async () => { try { - console.debug('[Auth] Initializing auth...'); + console.debug(`[Auth:${mountId}] Initializing auth...`); // Clear any platform-specific cached auth on login page init. if (typeof window !== 'undefined' && window.location.pathname.startsWith('/login')) { await clearPlatformAuthOnLoginInit(); @@ -134,7 +147,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Listen for jwt-available event (triggered by desktop auth or other sources) const handleJwtAvailable = () => { - console.debug('[Auth] JWT available event received, refreshing session'); + console.log(`[Auth:${mountId}] ════════════════════════════════════`); + console.log(`[Auth:${mountId}] 🔄 JWT available event received`); + console.log(`[Auth:${mountId}] Current state: loading=${loading}, hasSession=${!!session}`); + console.log(`[Auth:${mountId}] Setting loading=true to stabilize auth state`); + setLoading(true); // Prevent unstable renders during auth state transition + setError(null); + console.log(`[Auth:${mountId}] Refreshing session...`); void initializeAuth(); }; @@ -143,38 +162,43 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Subscribe to auth state changes const { data: { subscription } } = springAuth.onAuthStateChange( async (event: AuthChangeEvent, newSession: Session | null) => { - if (!mounted) return; + if (!mounted) { + console.log(`[Auth:${mountId}] ⚠️ Auth state change ignored (unmounted): ${event}`); + return; + } - console.debug('[Auth] Auth state change:', { - event, - hasSession: !!newSession, - userId: newSession?.user?.id, - email: newSession?.user?.email, - timestamp: new Date().toISOString(), - }); + console.log(`[Auth:${mountId}] ════════════════════════════════════`); + console.log(`[Auth:${mountId}] 📢 Auth state change event: ${event}`); + console.log(`[Auth:${mountId}] Has session: ${!!newSession}`); + console.log(`[Auth:${mountId}] User: ${newSession?.user?.email || 'none'}`); + console.log(`[Auth:${mountId}] Timestamp: ${new Date().toISOString()}`); // Schedule state update setTimeout(() => { if (mounted) { + console.log(`[Auth:${mountId}] Applying session update (event: ${event})`); setSession(newSession); setError(null); // Handle specific events if (event === 'SIGNED_OUT') { - console.debug('[Auth] User signed out, clearing session'); + console.log(`[Auth:${mountId}] ✓ User signed out, session cleared`); } else if (event === 'SIGNED_IN') { - console.debug('[Auth] User signed in successfully'); + console.log(`[Auth:${mountId}] ✓ User signed in successfully`); } else if (event === 'TOKEN_REFRESHED') { - console.debug('[Auth] Token refreshed'); + console.log(`[Auth:${mountId}] ✓ Token refreshed`); } else if (event === 'USER_UPDATED') { - console.debug('[Auth] User updated'); + console.log(`[Auth:${mountId}] ✓ User updated`); } + } else { + console.log(`[Auth:${mountId}] ⚠️ Session update skipped (unmounted during timeout)`); } }, 0); } ); return () => { + console.log(`[Auth:${mountId}] 🔴 AuthProvider unmounting`); mounted = false; window.removeEventListener('jwt-available', handleJwtAvailable); subscription.unsubscribe(); diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts index 9d26b8e85..7f7039218 100644 --- a/frontend/src/proprietary/auth/springAuthClient.ts +++ b/frontend/src/proprietary/auth/springAuthClient.ts @@ -312,6 +312,9 @@ class SpringAuthClient { */ async signOut(): Promise<{ error: AuthError | null }> { try { + if (typeof window !== 'undefined') { + window.sessionStorage.setItem('stirling_sso_auto_login_logged_out', '1'); + } const response = await apiClient.post('/api/v1/auth/logout', null, { headers: { 'X-XSRF-TOKEN': this.getCsrfToken() || '', diff --git a/frontend/src/proprietary/extensions/accountLogout.ts b/frontend/src/proprietary/extensions/accountLogout.ts index fbff0108e..237f609b5 100644 --- a/frontend/src/proprietary/extensions/accountLogout.ts +++ b/frontend/src/proprietary/extensions/accountLogout.ts @@ -12,6 +12,9 @@ interface AccountLogoutDeps { export function useAccountLogout() { return async ({ signOut, redirectToLogin }: AccountLogoutDeps): Promise => { try { + if (typeof window !== 'undefined') { + window.sessionStorage.setItem('stirling_sso_auto_login_logged_out', '1'); + } await signOut(); } finally { redirectToLogin(); diff --git a/frontend/src/proprietary/routes/AuthCallback.tsx b/frontend/src/proprietary/routes/AuthCallback.tsx index 205fd3414..ce7313237 100644 --- a/frontend/src/proprietary/routes/AuthCallback.tsx +++ b/frontend/src/proprietary/routes/AuthCallback.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { springAuth } from '@app/auth/springAuthClient'; import { handleAuthCallbackSuccess } from '@app/extensions/authCallback'; @@ -13,11 +13,46 @@ import styles from '@app/routes/AuthCallback.module.css'; */ export default function AuthCallback() { const navigate = useNavigate(); + const processingRef = useRef(false); + + // Log component lifecycle + useEffect(() => { + const mountId = Math.random().toString(36).substring(7); + console.log(`[AuthCallback:${mountId}] 🔵 Component mounted`); + return () => { + console.log(`[AuthCallback:${mountId}] 🔴 Component unmounting`); + }; + }, []); useEffect(() => { const handleCallback = async () => { + const startTime = performance.now(); + const executionId = Math.random().toString(36).substring(7); + + console.log(`[AuthCallback:${executionId}] ════════════════════════════════════`); + console.log(`[AuthCallback:${executionId}] Starting authentication callback`); + console.log(`[AuthCallback:${executionId}] URL: ${window.location.href}`); + console.log(`[AuthCallback:${executionId}] Hash: ${window.location.hash}`); + + if (typeof window !== 'undefined' && window.sessionStorage.getItem('stirling_sso_auto_login_logged_out') === '1') { + console.warn(`[AuthCallback:${executionId}] ⚠️ Logout block active, skipping token processing`); + navigate('/login', { + replace: true, + state: { error: 'You have been signed out. Please sign in again.' } + }); + return; + } + + // Prevent double execution (React 18 Strict Mode + navigate dependency) + if (processingRef.current) { + console.warn(`[AuthCallback:${executionId}] ⚠️ Already processing, skipping duplicate execution`); + console.warn(`[AuthCallback:${executionId}] This is expected in React Strict Mode (development)`); + return; + } + processingRef.current = true; + try { - console.log('[AuthCallback] Handling OAuth callback...'); + console.log(`[AuthCallback:${executionId}] Step 1: Extracting token from URL fragment`); // Extract JWT from URL fragment (#access_token=...) const hash = window.location.hash.substring(1); // Remove '#' @@ -25,7 +60,7 @@ export default function AuthCallback() { const token = params.get('access_token'); if (!token) { - console.error('[AuthCallback] No access_token in URL fragment'); + console.error(`[AuthCallback:${executionId}] ❌ No access_token in URL fragment`); navigate('/login', { replace: true, state: { error: 'OAuth login failed - no token received.' } @@ -33,19 +68,25 @@ export default function AuthCallback() { return; } + console.log(`[AuthCallback:${executionId}] ✓ Token extracted (length: ${token.length})`); + console.log(`[AuthCallback:${executionId}] Step 2: Storing JWT in localStorage`); + // Store JWT in localStorage localStorage.setItem('stirling_jwt', token); - console.log('[AuthCallback] JWT stored in localStorage'); + console.log(`[AuthCallback:${executionId}] ✓ JWT stored in localStorage`); + console.log(`[AuthCallback:${executionId}] Step 3: Dispatching 'jwt-available' event`); // Dispatch custom event for other components to react to JWT availability window.dispatchEvent(new CustomEvent('jwt-available')); + console.log(`[AuthCallback:${executionId}] ✓ Event dispatched`); + console.log(`[AuthCallback:${executionId}] Step 4: Validating token with backend`); // Validate the token and load user info // This calls /api/v1/auth/me with the JWT to get user details const { data, error } = await springAuth.getSession(); if (error || !data.session) { - console.error('[AuthCallback] Failed to validate token:', error); + console.error(`[AuthCallback:${executionId}] ❌ Failed to validate token:`, error); localStorage.removeItem('stirling_jwt'); navigate('/login', { replace: true, @@ -54,14 +95,30 @@ export default function AuthCallback() { return; } + console.log(`[AuthCallback:${executionId}] ✓ Token validated, user: ${data.session.user.username}`); + console.log(`[AuthCallback:${executionId}] Step 5: Running platform-specific callback handlers`); + await handleAuthCallbackSuccess(token); - console.log('[AuthCallback] Token validated, redirecting to home'); + console.log(`[AuthCallback:${executionId}] ✓ Callback handlers complete`); + console.log(`[AuthCallback:${executionId}] Step 6: Navigating to home page`); // Clear the hash from URL and redirect to home page navigate('/', { replace: true }); + + const duration = performance.now() - startTime; + console.log(`[AuthCallback:${executionId}] ✓ Authentication complete (${duration.toFixed(2)}ms)`); + console.log(`[AuthCallback:${executionId}] ════════════════════════════════════`); } catch (error) { - console.error('[AuthCallback] Error:', error); + const duration = performance.now() - startTime; + console.error(`[AuthCallback:${executionId}] ════════════════════════════════════`); + console.error(`[AuthCallback:${executionId}] ❌ FATAL ERROR during authentication`); + console.error(`[AuthCallback:${executionId}] Error:`, error); + console.error(`[AuthCallback:${executionId}] Error name:`, (error as Error)?.name); + console.error(`[AuthCallback:${executionId}] Error message:`, (error as Error)?.message); + console.error(`[AuthCallback:${executionId}] Error stack:`, (error as Error)?.stack); + console.error(`[AuthCallback:${executionId}] Duration before failure: ${duration.toFixed(2)}ms`); + console.error(`[AuthCallback:${executionId}] ════════════════════════════════════`); navigate('/login', { replace: true, state: { error: 'OAuth login failed. Please try again.' } @@ -70,7 +127,7 @@ export default function AuthCallback() { }; handleCallback(); - }, [navigate]); + }, []); // Empty deps - only run once on mount. navigate is stable, processingRef prevents double execution return (
diff --git a/frontend/src/proprietary/routes/Landing.tsx b/frontend/src/proprietary/routes/Landing.tsx index 6fb5fec66..764eea88f 100644 --- a/frontend/src/proprietary/routes/Landing.tsx +++ b/frontend/src/proprietary/routes/Landing.tsx @@ -25,6 +25,15 @@ export default function Landing() { const loading = authLoading || configLoading || backendProbe.loading; + // Debug: Track Landing component lifecycle + useEffect(() => { + const mountId = Math.random().toString(36).substring(7); + console.log(`[Landing:${mountId}] 🔵 Component mounted at ${location.pathname}`); + return () => { + console.log(`[Landing:${mountId}] 🔴 Component unmounting`); + }; + }, [location.pathname]); + // Periodically probe while backend isn't up so the screen can auto-advance when it comes online useEffect(() => { if (backendProbe.status === 'up' || backendProbe.loginDisabled) { @@ -51,12 +60,20 @@ export default function Landing() { } }, [backendProbe.status, refetch]); - console.log('[Landing] State:', { + console.log('[Landing] ════════════════════════════════════'); + console.log('[Landing] Render state:', { pathname: location.pathname, loading, + authLoading, + configLoading, + backendLoading: backendProbe.loading, hasSession: !!session, + hasConfig: !!config, loginEnabled: config?.enableLogin === true && !backendProbe.loginDisabled, + backendStatus: backendProbe.status, + timestamp: new Date().toISOString(), }); + console.log('[Landing] ════════════════════════════════════'); // Show loading while checking auth and config if (loading) { diff --git a/frontend/src/proprietary/routes/Login.tsx b/frontend/src/proprietary/routes/Login.tsx index aea069ecf..f11b8619d 100644 --- a/frontend/src/proprietary/routes/Login.tsx +++ b/frontend/src/proprietary/routes/Login.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react'; -import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { Text, Stack, Alert } from '@mantine/core'; import { springAuth } from '@app/auth/springAuthClient'; import { useAuth } from '@app/auth/UseSession'; @@ -23,6 +23,7 @@ import LoggedInState from '@app/routes/login/LoggedInState'; export default function Login() { const navigate = useNavigate(); + const location = useLocation(); const [searchParams] = useSearchParams(); const { session, loading } = useAuth(); const { refetch } = useAppConfig(); @@ -39,10 +40,84 @@ export default function Login() { const [hasSSOProviders, setHasSSOProviders] = useState(false); const [_enableLogin, setEnableLogin] = useState(null); const [loginMethod, setLoginMethod] = useState('all'); + const [ssoAutoLogin, setSsoAutoLogin] = useState(false); const backendProbe = useBackendProbe(); const [isFirstTimeSetup, setIsFirstTimeSetup] = useState(false); const [showDefaultCredentials, setShowDefaultCredentials] = useState(false); const loginDisabled = backendProbe.loginDisabled === true || _enableLogin === false; + const autoLoginAttempted = useRef(false); + const autoLoginErrorRecorded = useRef(false); + const isUserPassAllowed = loginMethod === 'all' || loginMethod === 'normal'; + const isSsoOnlyMode = loginMethod !== 'all' && loginMethod !== 'normal'; + const isSingleSsoOnly = !isUserPassAllowed && enabledProviders.length === 1; + + const AUTO_LOGIN_ATTEMPTS_KEY = 'stirling_sso_auto_login_attempts'; + const AUTO_LOGIN_ERRORS_KEY = 'stirling_sso_auto_login_errors'; + const AUTO_LOGIN_LOGOUT_KEY = 'stirling_sso_auto_login_logged_out'; + const MAX_AUTO_LOGIN_ATTEMPTS = 2; + const MAX_AUTO_LOGIN_ERRORS = 1; + + const readSessionNumber = (key: string) => { + if (typeof window === 'undefined') { + return 0; + } + const raw = window.sessionStorage.getItem(key); + const value = Number(raw); + return Number.isFinite(value) ? value : 0; + }; + + const writeSessionNumber = (key: string, value: number) => { + if (typeof window === 'undefined') { + return; + } + window.sessionStorage.setItem(key, String(value)); + }; + + const hasLogoutBlock = () => { + if (typeof window === 'undefined') { + return false; + } + return window.sessionStorage.getItem(AUTO_LOGIN_LOGOUT_KEY) === '1'; + }; + + const clearLogoutBlock = () => { + if (typeof window === 'undefined') { + return; + } + window.sessionStorage.removeItem(AUTO_LOGIN_LOGOUT_KEY); + }; + + const recordAutoLoginAttempt = () => { + const attempts = readSessionNumber(AUTO_LOGIN_ATTEMPTS_KEY); + writeSessionNumber(AUTO_LOGIN_ATTEMPTS_KEY, attempts + 1); + }; + + const recordAutoLoginError = () => { + const errors = readSessionNumber(AUTO_LOGIN_ERRORS_KEY); + writeSessionNumber(AUTO_LOGIN_ERRORS_KEY, errors + 1); + }; + + const errorFromState = (location.state as { error?: string } | null)?.error; + const errorFromQuery = useMemo(() => { + if (!searchParams) { + return null; + } + const errorParamKeys = ['error', 'error_description', 'error_code', 'sso_error', 'oauth_error', 'saml_error', 'login_error']; + for (const key of errorParamKeys) { + const value = searchParams.get(key); + if (value) { + return value; + } + } + for (const [key, value] of searchParams.entries()) { + if (key.toLowerCase().includes('error')) { + return value || 'Single sign-on failed. Please try again.'; + } + } + return null; + }, [searchParams]); + + const hasSsoLoginError = Boolean(errorFromState || errorFromQuery); // Periodically probe while backend isn't up so the screen can auto-advance when it comes online useEffect(() => { @@ -102,6 +177,7 @@ export default function Login() { } setEnableLogin(data.enableLogin ?? true); + setSsoAutoLogin(Boolean(data.ssoAutoLogin)); // Set first-time setup flags setIsFirstTimeSetup(data.firstTimeSetup ?? false); @@ -149,6 +225,76 @@ export default function Login() { } }, [enabledProviders, loginMethod]); + const signInWithProvider = async (provider: OAuthProvider) => { + try { + setIsSigningIn(true); + setError(null); + clearLogoutBlock(); + + console.log(`[Login] Signing in with provider: ${provider}`); + + // Redirect to Spring OAuth2 endpoint using the actual provider ID from backend + // The backend returns the correct registration ID (e.g., 'authentik', 'oidc', 'keycloak') + const { error } = await springAuth.signInWithOAuth({ + provider: provider, + options: { redirectTo: `${BASE_PATH}/auth/callback` } + }); + + if (error) { + console.error(`[Login] ${provider} error:`, error); + setError(t('login.failedToSignIn', { provider, message: error.message }) || `Failed to sign in with ${provider}`); + } + } catch (err) { + console.error(`[Login] Unexpected error:`, err); + setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }) || 'An unexpected error occurred'); + } finally { + setIsSigningIn(false); + } + }; + + // Auto-login to SSO when enabled and only one SSO option exists + useEffect(() => { + if (autoLoginAttempted.current) { + return; + } + + const attempts = readSessionNumber(AUTO_LOGIN_ATTEMPTS_KEY); + const errors = readSessionNumber(AUTO_LOGIN_ERRORS_KEY); + const blockedByErrors = errors >= MAX_AUTO_LOGIN_ERRORS; + const blockedByAttempts = attempts >= MAX_AUTO_LOGIN_ATTEMPTS; + const blockedByLogout = hasLogoutBlock(); + + if (!ssoAutoLogin || loginDisabled || loading || session || backendProbe.status !== 'up') { + return; + } + + if (hasSsoLoginError || blockedByErrors || blockedByAttempts || blockedByLogout) { + return; + } + + if (isUserPassAllowed) { + return; + } + + if (enabledProviders.length !== 1) { + return; + } + + autoLoginAttempted.current = true; + recordAutoLoginAttempt(); + void signInWithProvider(enabledProviders[0]); + }, [ + ssoAutoLogin, + loginDisabled, + loading, + session, + backendProbe.status, + loginMethod, + enabledProviders, + signInWithProvider, + hasSsoLoginError, + ]); + // Handle query params (email prefill, success messages, and session expiry) useEffect(() => { try { @@ -177,10 +323,21 @@ export default function Login() { break } } + + if (errorFromState) { + setError(errorFromState); + } else if (errorFromQuery) { + setError(errorFromQuery); + } + + if (hasSsoLoginError && !autoLoginErrorRecorded.current) { + recordAutoLoginError(); + autoLoginErrorRecorded.current = true; + } } catch (_) { // ignore } - }, [searchParams, t]); + }, [searchParams, t, errorFromState, errorFromQuery, hasSsoLoginError]); const baseUrl = window.location.origin + BASE_PATH; @@ -243,32 +400,6 @@ export default function Login() { ); } - const signInWithProvider = async (provider: OAuthProvider) => { - try { - setIsSigningIn(true); - setError(null); - - console.log(`[Login] Signing in with provider: ${provider}`); - - // Redirect to Spring OAuth2 endpoint using the actual provider ID from backend - // The backend returns the correct registration ID (e.g., 'authentik', 'oidc', 'keycloak') - const { error } = await springAuth.signInWithOAuth({ - provider: provider, - options: { redirectTo: `${BASE_PATH}/auth/callback` } - }); - - if (error) { - console.error(`[Login] ${provider} error:`, error); - setError(t('login.failedToSignIn', { provider, message: error.message }) || `Failed to sign in with ${provider}`); - } - } catch (err) { - console.error(`[Login] Unexpected error:`, err); - setError(t('login.unexpectedError', { message: err instanceof Error ? err.message : 'Unknown error' }) || 'An unexpected error occurred'); - } finally { - setIsSigningIn(false); - } - }; - const signInWithEmail = async () => { if (!email || !password) { setError(t('login.pleaseEnterBoth') || 'Please enter both email and password'); @@ -283,6 +414,7 @@ export default function Login() { try { setIsSigningIn(true); setError(null); + clearLogoutBlock(); console.log('[Login] Signing in with email:', email); @@ -300,6 +432,7 @@ export default function Login() { } } else if (user && session) { console.log('[Login] Email sign in successful'); + clearLogoutBlock(); setRequiresMfa(false); setMfaCode(''); // Auth state will update automatically and Landing will redirect to home @@ -320,7 +453,10 @@ export default function Login() { return ( - + {/* Success message */} {successMessage && ( @@ -346,15 +482,18 @@ export default function Login() { isSubmitting={isSigningIn} layout="vertical" enabledProviders={enabledProviders} + ctaPrefix={isSsoOnlyMode ? t('login.signInWith', 'Sign in with') : undefined} + styleVariant="light" + useNewStyle={isSsoOnlyMode} /> {/* Divider between OAuth and Email - only show if SSO is available and username/password is allowed */} - {hasSSOProviders && (loginMethod === 'all' || loginMethod === 'normal') && ( + {hasSSOProviders && isUserPassAllowed && ( )} {/* Sign in with email button - only show if SSO providers exist and username/password is allowed */} - {hasSSOProviders && !showEmailForm && (loginMethod === 'all' || loginMethod === 'normal') && ( + {hasSSOProviders && !showEmailForm && isUserPassAllowed && (
))} diff --git a/testing/compose/docker-compose-keycloak-oauth.yml b/testing/compose/docker-compose-keycloak-oauth.yml new file mode 100644 index 000000000..dd5c09665 --- /dev/null +++ b/testing/compose/docker-compose-keycloak-oauth.yml @@ -0,0 +1,127 @@ +services: + keycloak-oauth-db: + container_name: stirling-keycloak-oauth-db + image: postgres:16-alpine + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: keycloak + healthcheck: + test: ["CMD-SHELL", "pg_isready -U keycloak"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - stirling-oauth-test + + keycloak-oauth: + container_name: stirling-keycloak-oauth + image: quay.io/keycloak/keycloak:24.0 + command: + - start-dev + - --import-realm + environment: + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://keycloak-oauth-db:5432/keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: keycloak + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + # Use a consistent hostname for browser + containers (configure in hosts file) + KC_HOSTNAME: "${KEYCLOAK_HOST:-kubernetes.docker.internal}" + KC_HOSTNAME_PORT: 9080 + KC_HOSTNAME_STRICT: "false" + KC_HTTP_ENABLED: "true" + ports: + - "9080:8080" + volumes: + - ./keycloak-realm-oauth.json:/opt/keycloak/data/import/realm-export.json:ro + depends_on: + keycloak-oauth-db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /realms/stirling-oauth HTTP/1.1\\nHost: localhost\\nConnection: close\\n\\n' >&3 && timeout 2 cat <&3 | head -n 1 | grep -q '200'"] + interval: 10s + timeout: 10s + retries: 30 + start_period: 60s + networks: + - stirling-oauth-test + + stirling-pdf-oauth: + container_name: stirling-pdf-oauth-test + image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest + build: + context: ../.. + dockerfile: docker/embedded/Dockerfile + extra_hosts: + - "localhost:host-gateway" + - "${KEYCLOAK_HOST:-kubernetes.docker.internal}:host-gateway" + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"] + interval: 5s + timeout: 10s + retries: 30 + ports: + - "8080:8080" + volumes: + - ../../../stirling/keycloak-oauth-test/data:/usr/share/tessdata:rw + - ../../../stirling/keycloak-oauth-test/config:/configs:rw + - ../../../stirling/keycloak-oauth-test/logs:/logs:rw + environment: + # Basic settings + DOCKER_ENABLE_SECURITY: "true" + SECURITY_ENABLELOGIN: "true" + SECURITY_LOGINMETHOD: "${SECURITY_LOGINMETHOD:-all}" + SYSTEM_DEFAULTLOCALE: en-US + SYSTEM_BACKENDURL: "http://localhost:8080" + PREMIUM_KEY: "${PREMIUM_KEY:-00000000-0000-0000-0000-000000000000}" + PREMIUM_ENABLED: "true" + PREMIUM_PROFEATURES_SSOAUTOLOGIN: "${PREMIUM_PROFEATURES_SSOAUTOLOGIN:-false}" + UI_APPNAME: Stirling-PDF OAuth Test + UI_HOMEDESCRIPTION: Keycloak OAuth2/OIDC Test Instance + UI_APPNAMENAVBAR: Stirling-PDF OAuth + SYSTEM_MAXFILESIZE: "100" + + # OAuth2 Configuration (Keycloak-specific path) + SECURITY_OAUTH2_ENABLED: "true" + SECURITY_OAUTH2_AUTOCREATEUSER: "true" + # Must match Keycloak's advertised issuer + SECURITY_OAUTH2_CLIENT_KEYCLOAK_ISSUER: "http://${KEYCLOAK_HOST:-kubernetes.docker.internal}:9080/realms/stirling-oauth" + SECURITY_OAUTH2_CLIENT_KEYCLOAK_CLIENTID: "stirling-pdf-client" + SECURITY_OAUTH2_CLIENT_KEYCLOAK_CLIENTSECRET: "test-client-secret-change-in-production" + SECURITY_OAUTH2_CLIENT_KEYCLOAK_USEASUSERNAME: "email" + SECURITY_OAUTH2_CLIENT_KEYCLOAK_SCOPES: "openid,profile,email" + + # Disable SAML (OAuth only) + SECURITY_SAML2_ENABLED: "false" + + # Debug Logging + LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_SECURITY_OAUTH2: DEBUG + LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_SECURITY: DEBUG + + # LibreOffice settings + PROCESS_EXECUTOR_AUTO_UNO_SERVER: "true" + PROCESS_EXECUTOR_SESSION_LIMIT_LIBRE_OFFICE_SESSION_LIMIT: "1" + + # Permissions + PUID: 1002 + PGID: 1002 + UMASK: "022" + + # Features + DISABLE_ADDITIONAL_FEATURES: "false" + METRICS_ENABLED: "true" + SYSTEM_GOOGLEVISIBILITY: "false" + SHOW_SURVEY: "false" + + depends_on: + keycloak-oauth: + condition: service_healthy + networks: + - stirling-oauth-test + restart: on-failure:5 + +networks: + stirling-oauth-test: + driver: bridge diff --git a/testing/compose/docker-compose-keycloak-saml.yml b/testing/compose/docker-compose-keycloak-saml.yml new file mode 100644 index 000000000..d4a909877 --- /dev/null +++ b/testing/compose/docker-compose-keycloak-saml.yml @@ -0,0 +1,148 @@ +services: + keycloak-saml: + container_name: stirling-keycloak-saml + image: quay.io/keycloak/keycloak:24.0 + command: + - start-dev + - --import-realm + environment: + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://keycloak-saml-db:5432/keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: keycloak + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HOSTNAME: localhost + KC_HOSTNAME_PORT: 9080 + KC_HOSTNAME_STRICT: "false" + KC_HTTP_ENABLED: "true" + KC_PROXY: edge + KC_HTTP_RELATIVE_PATH: "/" + ports: + - "9080:8080" + volumes: + - ./keycloak-realm-saml.json:/opt/keycloak/data/import/realm-export.json:ro + depends_on: + keycloak-saml-db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /realms/stirling-saml/protocol/saml/descriptor HTTP/1.1\\nHost: localhost\\nConnection: close\\n\\n' >&3 && timeout 2 cat <&3 | grep -q 'EntityDescriptor'"] + interval: 10s + timeout: 10s + retries: 30 + start_period: 60s + networks: + - stirling-saml-test + + keycloak-saml-db: + container_name: stirling-keycloak-saml-db + image: postgres:16-alpine + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: keycloak + healthcheck: + test: ["CMD-SHELL", "pg_isready -U keycloak"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - stirling-saml-test + + stirling-pdf-saml: + container_name: stirling-pdf-saml-test + image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest + build: + context: ../.. + dockerfile: docker/embedded/Dockerfile + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"] + interval: 5s + timeout: 10s + retries: 30 + ports: + - "8080:8080" + volumes: + - ../../../stirling/keycloak-saml-test/data:/usr/share/tessdata:rw + - ../../../stirling/keycloak-saml-test/config:/configs:rw + - ../../../stirling/keycloak-saml-test/logs:/logs:rw + - ./keycloak-saml-cert.pem:/app/keycloak-saml-cert.pem:ro + - ./saml-private-key.key:/app/saml-private-key.key:ro + - ./saml-public-cert.crt:/app/saml-public-cert.crt:ro + environment: + # Basic settings + DOCKER_ENABLE_SECURITY: "true" + SECURITY_ENABLELOGIN: "true" + SECURITY_LOGINMETHOD: "${SECURITY_LOGINMETHOD:-all}" + SYSTEM_DEFAULTLOCALE: en-US + SYSTEM_BACKENDURL: "http://localhost:8080" + + # Enterprise License (required for SAML) + PREMIUM_KEY: "${PREMIUM_KEY:-00000000-0000-0000-0000-000000000000}" + PREMIUM_ENABLED: "true" + PREMIUM_PROFEATURES_SSOAUTOLOGIN: "${PREMIUM_PROFEATURES_SSOAUTOLOGIN:-false}" + + # Debug Logging + LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_SECURITY_SAML2: DEBUG + LOGGING_LEVEL_ORG_OPENSAML: DEBUG + LOGGING_LEVEL_STIRLING_SOFTWARE_PROPRIETARY_SECURITY: DEBUG + LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_SECURITY: DEBUG + UI_APPNAME: Stirling-PDF SAML Test + UI_HOMEDESCRIPTION: Keycloak SAML Test Instance + UI_APPNAMENAVBAR: Stirling-PDF SAML + SYSTEM_MAXFILESIZE: "100" + + # SAML Configuration (Keycloak) + SECURITY_SAML2_ENABLED: "true" + SECURITY_SAML2_AUTOCREATEUSER: "true" + SECURITY_SAML2_BLOCKREGISTRATION: "false" + SECURITY_SAML2_PROVIDER: "keycloak" + SECURITY_SAML2_REGISTRATIONID: "keycloak" + # IdP Issuer must match what's in the SAML metadata + SECURITY_SAML2_IDP_ISSUER: "http://localhost:9080/realms/stirling-saml" + # Entity ID must match what's configured in Keycloak + SECURITY_SAML2_IDP_ENTITYID: "http://localhost:9080/realms/stirling-saml" + # Metadata URL for Keycloak realm (use service name for internal) + SECURITY_SAML2_IDP_METADATAURI: "http://keycloak-saml:8080/realms/stirling-saml/protocol/saml/descriptor" + # SSO/SLO URLs (required - metadata URI doesn't auto-populate these) + SECURITY_SAML2_IDPSINGLELOGINURL: "http://localhost:9080/realms/stirling-saml/protocol/saml" + SECURITY_SAML2_IDPSINGLELOGOUTURL: "http://localhost:9080/realms/stirling-saml/protocol/saml" + # Certificate file paths + SECURITY_SAML2_IDP_CERT: "/app/keycloak-saml-cert.pem" + SECURITY_SAML2_PRIVATEKEY: "/app/saml-private-key.key" + SECURITY_SAML2_SP_CERT: "/app/saml-public-cert.crt" + # SP Entity ID (this application) + SECURITY_SAML2_SP_ENTITYID: "http://localhost:8080" + # Assertion Consumer Service (ACS) URL + SECURITY_SAML2_SP_ACS: "http://localhost:8080/login/saml2/sso/keycloak" + # Single Logout Service URL + SECURITY_SAML2_SP_SLS: "http://localhost:8080/logout/saml2/slo" + + # Disable OAuth (SAML only) + SECURITY_OAUTH2_ENABLED: "false" + + # LibreOffice settings + PROCESS_EXECUTOR_AUTO_UNO_SERVER: "true" + PROCESS_EXECUTOR_SESSION_LIMIT_LIBRE_OFFICE_SESSION_LIMIT: "1" + + # Permissions + PUID: 1002 + PGID: 1002 + UMASK: "022" + + # Features + DISABLE_ADDITIONAL_FEATURES: "false" + METRICS_ENABLED: "true" + SYSTEM_GOOGLEVISIBILITY: "false" + SHOW_SURVEY: "false" + + depends_on: + keycloak-saml: + condition: service_healthy + networks: + - stirling-saml-test + restart: on-failure:5 + +networks: + stirling-saml-test: + driver: bridge diff --git a/testing/compose/keycloak-realm-oauth.json b/testing/compose/keycloak-realm-oauth.json new file mode 100644 index 000000000..7c97fab17 --- /dev/null +++ b/testing/compose/keycloak-realm-oauth.json @@ -0,0 +1,370 @@ +{ + "id": "stirling-oauth", + "realm": "stirling-oauth", + "displayName": "Stirling PDF OAuth Test", + "displayNameHtml": "
Stirling PDF OAuth
", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": true, + "registrationEmailAsUsername": true, + "rememberMe": true, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "offlineSessionIdleTimeout": 2592000, + "users": [ + { + "username": "oauthuser@example.com", + "email": "oauthuser@example.com", + "emailVerified": true, + "firstName": "OAuth", + "lastName": "TestUser", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "oauthpassword", + "temporary": false + } + ], + "realmRoles": ["user"], + "attributes": { + "phone": ["+1234567890"], + "organization": ["Test Corp"] + } + }, + { + "username": "oauthadmin@example.com", + "email": "oauthadmin@example.com", + "emailVerified": true, + "firstName": "OAuth", + "lastName": "Admin", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "oauthadminpass", + "temporary": false + } + ], + "realmRoles": ["user", "admin"], + "attributes": { + "phone": ["+1987654321"], + "organization": ["Test Corp IT"] + } + } + ], + "roles": { + "realm": [ + { + "name": "user", + "description": "Regular user role", + "composite": false, + "clientRole": false + }, + { + "name": "admin", + "description": "Administrator role", + "composite": false, + "clientRole": false + } + ] + }, + "clients": [ + { + "clientId": "stirling-pdf-client", + "name": "Stirling PDF OAuth2 Client", + "description": "OAuth2/OIDC client for Stirling PDF testing", + "rootUrl": "http://localhost:8080", + "adminUrl": "http://localhost:8080", + "baseUrl": "http://localhost:8080", + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "test-client-secret-change-in-production", + "redirectUris": [ + "http://localhost:8080/*", + "http://localhost:8080/login/oauth2/code/keycloak", + "http://stirling-pdf-oauth:8080/*", + "http://stirling-pdf-oauth:8080/login/oauth2/code/keycloak" + ], + "webOrigins": [ + "http://localhost:8080", + "http://stirling-pdf-oauth:8080" + ], + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "oauth2.device.authorization.grant.enabled": "false", + "display.on.consent.screen": "false", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "fullScopeAllowed": true, + "protocolMappers": [ + { + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "name": "email_verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "name": "given_name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "name": "family_name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "name": "preferred_username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "name": "roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + } + ] + }, + { + "name": "roles", + "description": "OpenID Connect scope for user roles", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins", + "acr" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "eventsEnabled": false, + "eventsListeners": ["jboss-logging"], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "internationalizationEnabled": false, + "supportedLocales": [], + "keycloakVersion": "24.0.0" +} diff --git a/testing/compose/keycloak-realm-saml.json b/testing/compose/keycloak-realm-saml.json new file mode 100644 index 000000000..88d6c8fe5 --- /dev/null +++ b/testing/compose/keycloak-realm-saml.json @@ -0,0 +1,210 @@ +{ + "id": "stirling-saml", + "realm": "stirling-saml", + "displayName": "Stirling PDF SAML Test", + "displayNameHtml": "
Stirling PDF SAML
", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": true, + "registrationEmailAsUsername": true, + "rememberMe": true, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "users": [ + { + "username": "samluser", + "email": "samluser@example.com", + "emailVerified": true, + "firstName": "SAML", + "lastName": "TestUser", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "samlpassword", + "temporary": false + } + ], + "realmRoles": ["user"], + "attributes": { + "department": ["Engineering"], + "employeeId": ["EMP001"] + } + }, + { + "username": "samladmin", + "email": "samladmin@example.com", + "emailVerified": true, + "firstName": "SAML", + "lastName": "Admin", + "enabled": true, + "credentials": [ + { + "type": "password", + "value": "samladminpass", + "temporary": false + } + ], + "realmRoles": ["user", "admin"], + "attributes": { + "department": ["IT"], + "employeeId": ["ADM001"] + } + } + ], + "roles": { + "realm": [ + { + "name": "user", + "description": "Regular user role", + "composite": false, + "clientRole": false + }, + { + "name": "admin", + "description": "Administrator role", + "composite": false, + "clientRole": false + } + ] + }, + "clients": [ + { + "clientId": "http://localhost:8080/saml2/service-provider-metadata/keycloak", + "name": "Stirling PDF SAML Client", + "description": "SAML2 client for Stirling PDF testing", + "rootUrl": "http://localhost:8080", + "adminUrl": "http://localhost:8080", + "baseUrl": "http://localhost:8080", + "enabled": true, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "http://localhost:8080/*" + ], + "webOrigins": [ + "http://localhost:8080" + ], + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "saml", + "attributes": { + "saml.force.post.binding": "true", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "saml.server.signature": "true", + "saml.server.signature.keyinfo.ext": "false", + "exclude.session.state.from.auth.response": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "true", + "display.on.consent.screen": "false", + "saml_name_id_format": "email", + "saml_signature_canonicalization_method": "http://www.w3.org/2001/10/xml-exc-c14n#", + "saml.assertion.signature": "true" + }, + "fullScopeAllowed": true, + "protocolMappers": [ + { + "name": "email", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": false, + "config": { + "attribute.nameformat": "URI Reference", + "user.attribute": "email", + "friendly.name": "email", + "attribute.name": "email" + } + }, + { + "name": "firstName", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": false, + "config": { + "attribute.nameformat": "URI Reference", + "user.attribute": "firstName", + "friendly.name": "firstName", + "attribute.name": "firstName" + } + }, + { + "name": "lastName", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": false, + "config": { + "attribute.nameformat": "URI Reference", + "user.attribute": "lastName", + "friendly.name": "lastName", + "attribute.name": "lastName" + } + }, + { + "name": "username", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "consentRequired": false, + "config": { + "attribute.nameformat": "URI Reference", + "user.attribute": "username", + "friendly.name": "username", + "attribute.name": "username" + } + }, + { + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "true", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + }, + { + "name": "department", + "protocol": "saml", + "protocolMapper": "saml-user-attribute-mapper", + "consentRequired": false, + "config": { + "attribute.nameformat": "Basic", + "user.attribute": "department", + "friendly.name": "department", + "attribute.name": "department" + } + } + ] + } + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "eventsEnabled": false, + "eventsListeners": ["jboss-logging"], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "internationalizationEnabled": false, + "supportedLocales": [], + "keycloakVersion": "24.0.0" +} diff --git a/testing/compose/start-oauth-test.sh b/testing/compose/start-oauth-test.sh new file mode 100644 index 000000000..a4f35d957 --- /dev/null +++ b/testing/compose/start-oauth-test.sh @@ -0,0 +1,171 @@ +#!/bin/bash +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}" +echo -e "${BLUE}║ Stirling PDF + Keycloak OAuth Test Environment ║${NC}" +echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}" +echo "" + +AUTO_LOGIN=false +FORCE_ALL_LOGIN=false +COMPOSE_UP_ARGS=(-d --build) +for arg in "$@"; do + case "$arg" in + --auto) + AUTO_LOGIN=true + ;; + --all) + FORCE_ALL_LOGIN=true + ;; + --nobuild) + COMPOSE_UP_ARGS=(-d) + ;; + -h|--help) + echo "Usage: $0 [--auto] [--nobuild]" + echo "" + echo " --auto Enable SSO auto-login and force OAuth-only login method" + echo " --all Force login method to allow all providers (overrides --auto)" + echo " --nobuild Skip building images (use existing images)" + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $arg${NC}" + exit 1 + ;; + esac +done + +if ! docker info > /dev/null 2>&1; then + echo -e "${RED}✗ Docker is not running${NC}" + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Hostname used by Keycloak issuer (must resolve on host + containers) +KEYCLOAK_HOST="${KEYCLOAK_HOST:-kubernetes.docker.internal}" +export KEYCLOAK_HOST + +# Preflight check: ensure host can resolve the issuer hostname (skippable + bounded timeouts) +if [ "${SKIP_OAUTH_PREFLIGHT:-false}" != "true" ]; then + if ! curl -sf --connect-timeout 2 --max-time 3 "http://${KEYCLOAK_HOST}:9080/realms/stirling-oauth" >/dev/null 2>&1; then + echo -e "${YELLOW}⚠ Cannot reach http://${KEYCLOAK_HOST}:9080 from this machine.${NC}" + echo -e "${YELLOW} Add a hosts entry pointing ${KEYCLOAK_HOST} to 127.0.0.1, then retry.${NC}" + echo "" + echo -e "${BLUE}Windows:${NC} C:\\Windows\\System32\\drivers\\etc\\hosts" + echo -e "${BLUE}macOS/Linux:${NC} /etc/hosts" + echo "" + echo -e "${GREEN}127.0.0.1 ${KEYCLOAK_HOST}${NC}" + echo "" + fi +fi +# Prompt for license key (optional) +if [ -z "$PREMIUM_KEY" ]; then + echo -e "${YELLOW}Enter license key (press Enter to use default test key):${NC}" + read -r LICENSE_INPUT + if [ -n "$LICENSE_INPUT" ]; then + export PREMIUM_KEY="$LICENSE_INPUT" + echo -e "${GREEN}✓ Using provided license key${NC}" + else + echo -e "${BLUE}Using default test license key${NC}" + fi + echo "" +fi + +if [ "$FORCE_ALL_LOGIN" = true ]; then + AUTO_LOGIN=false + export SECURITY_LOGINMETHOD=all + echo -e "${GREEN}✓ Login method forced to all providers${NC}" + echo "" +elif [ "$AUTO_LOGIN" = true ]; then + export PREMIUM_PROFEATURES_SSOAUTOLOGIN=true + export SECURITY_LOGINMETHOD=oauth2 + COMPOSE_UP_ARGS+=(--force-recreate) + echo -e "${GREEN}✓ SSO auto-login enabled (OAuth-only)${NC}" + echo "" +fi + +echo -e "${YELLOW}▶ Starting Keycloak (OAuth) containers...${NC}" +docker-compose -f docker-compose-keycloak-oauth.yml up "${COMPOSE_UP_ARGS[@]}" keycloak-oauth-db keycloak-oauth + +echo "" +echo -e "${YELLOW}▶ Waiting for Keycloak (OAuth)...${NC}" +MAX_WAIT=180 +WAITED=0 +while [ $WAITED -lt $MAX_WAIT ]; do + if curl -sf http://localhost:9080/realms/stirling-oauth > /dev/null 2>&1; then + echo -e "${GREEN}✓ Keycloak is ready${NC}" + break + fi + echo -n "." + sleep 2 + WAITED=$((WAITED + 2)) +done + +if [ $WAITED -ge $MAX_WAIT ]; then + echo -e "${RED}✗ Keycloak failed to start${NC}" + exit 1 +fi + +echo "" +echo -e "${YELLOW}▶ Starting Stirling PDF...${NC}" +docker-compose -f docker-compose-keycloak-oauth.yml up "${COMPOSE_UP_ARGS[@]}" stirling-pdf-oauth + +echo "" +echo -e "${YELLOW}▶ Waiting for Stirling PDF...${NC}" +WAITED=0 +while [ $WAITED -lt $MAX_WAIT ]; do + if curl -sf http://localhost:8080/api/v1/info/status 2>/dev/null | grep -q "UP"; then + echo -e "${GREEN}✓ Stirling PDF is ready${NC}" + break + fi + echo -n "." + sleep 2 + WAITED=$((WAITED + 2)) +done + +if [ $WAITED -ge $MAX_WAIT ]; then + echo -e "${RED}✗ Stirling PDF failed to start${NC}" + exit 1 +fi + +echo "" +echo -e "${GREEN}╔════════════════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ OAuth Test Environment Ready! ✓ ║${NC}" +echo -e "${GREEN}╚════════════════════════════════════════════════════╝${NC}" +echo "" +echo -e "${BLUE}📍 Services:${NC}" +echo -e " Stirling PDF: ${GREEN}http://localhost:8080${NC}" +echo -e " Keycloak Admin: ${GREEN}http://${KEYCLOAK_HOST}:9080/admin${NC}" +echo "" +echo -e "${BLUE}🔑 Keycloak Admin:${NC}" +echo -e " Username: ${GREEN}admin${NC}" +echo -e " Password: ${GREEN}admin${NC}" +echo "" +echo -e "${BLUE}👥 Test Users (OAuth):${NC}" +echo -e " ${YELLOW}Regular User:${NC}" +echo -e " Email: ${GREEN}oauthuser@example.com${NC}" +echo -e " Password: ${GREEN}oauthpassword${NC}" +echo "" +echo -e " ${YELLOW}Admin User:${NC}" +echo -e " Email: ${GREEN}oauthadmin@example.com${NC}" +echo -e " Password: ${GREEN}oauthadminpass${NC}" +echo "" +echo -e "${BLUE}🧪 Test OAuth:${NC}" +echo -e " 1. Go to ${GREEN}http://localhost:8080${NC}" +echo -e " 2. Click 'Login' and select OAuth2" +echo -e " 3. Login with test credentials" +echo "" +echo -e "${BLUE}📊 View logs:${NC}" +echo -e " docker-compose -f docker-compose-keycloak-oauth.yml logs -f" +echo "" +echo -e "${BLUE}⏹ Stop:${NC}" +echo -e " docker-compose -f docker-compose-keycloak-oauth.yml down -v" +echo "" diff --git a/testing/compose/start-saml-test.sh b/testing/compose/start-saml-test.sh new file mode 100644 index 000000000..6a77a03d3 --- /dev/null +++ b/testing/compose/start-saml-test.sh @@ -0,0 +1,179 @@ +#!/bin/bash +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +echo -e "${BLUE}╔════════════════════════════════════════════════════╗${NC}" +echo -e "${BLUE}║ Stirling PDF + Keycloak SAML Test Environment ║${NC}" +echo -e "${BLUE}╚════════════════════════════════════════════════════╝${NC}" +echo "" + +AUTO_LOGIN=false +COMPOSE_UP_ARGS=(-d --build) +for arg in "$@"; do + case "$arg" in + --auto) + AUTO_LOGIN=true + ;; + --nobuild) + COMPOSE_UP_ARGS=(-d) + ;; + -h|--help) + echo "Usage: $0 [--auto] [--nobuild]" + echo "" + echo " --auto Enable SSO auto-login and force SAML-only login method" + echo " --nobuild Skip building images (use existing images)" + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $arg${NC}" + exit 1 + ;; + esac +done + +if ! docker info > /dev/null 2>&1; then + echo -e "${RED}✗ Docker is not running${NC}" + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Prompt for license key (optional) +if [ -z "$PREMIUM_KEY" ]; then + echo -e "${YELLOW}Enter Enterprise license key (press Enter to use default test key):${NC}" + read -r LICENSE_INPUT + if [ -n "$LICENSE_INPUT" ]; then + export PREMIUM_KEY="$LICENSE_INPUT" + echo -e "${GREEN}✓ Using provided license key${NC}" + else + echo -e "${BLUE}Using default test license key${NC}" + fi + echo "" +fi + +if [ "$AUTO_LOGIN" = true ]; then + export PREMIUM_PROFEATURES_SSOAUTOLOGIN=true + export SECURITY_LOGINMETHOD=saml2 + COMPOSE_UP_ARGS+=(--force-recreate) + echo -e "${GREEN}✓ SSO auto-login enabled (SAML-only)${NC}" + echo "" +fi + +echo -e "${YELLOW}▶ Starting Keycloak (SAML) containers...${NC}" +docker-compose -f docker-compose-keycloak-saml.yml up "${COMPOSE_UP_ARGS[@]}" keycloak-saml-db keycloak-saml + +echo "" +echo -e "${YELLOW}▶ Waiting for Keycloak (SAML)...${NC}" +MAX_WAIT=180 +WAITED=0 +while [ $WAITED -lt $MAX_WAIT ]; do + if curl -sf http://localhost:9080/realms/stirling-saml/protocol/saml/descriptor 2>/dev/null | grep -q "EntityDescriptor"; then + echo -e "${GREEN}✓ Keycloak is ready${NC}" + break + fi + echo -n "." + sleep 2 + WAITED=$((WAITED + 2)) +done + +if [ $WAITED -ge $MAX_WAIT ]; then + echo -e "${RED}✗ Keycloak failed to start${NC}" + exit 1 +fi + +echo "" +echo -e "${YELLOW}▶ Generating SAML SP certificates if needed...${NC}" +PRIVATE_KEY="${SCRIPT_DIR}/saml-private-key.key" +PUBLIC_CERT="${SCRIPT_DIR}/saml-public-cert.crt" + +# Remove any directories that Docker might have created +[ -d "$PRIVATE_KEY" ] && rm -rf "$PRIVATE_KEY" +[ -d "$PUBLIC_CERT" ] && rm -rf "$PUBLIC_CERT" + +if [ ! -f "$PRIVATE_KEY" ] || [ ! -f "$PUBLIC_CERT" ]; then + openssl req -x509 -newkey rsa:2048 -keyout "$PRIVATE_KEY" -out "$PUBLIC_CERT" \ + -days 3650 -nodes -subj "/CN=stirling-pdf-saml-sp" >/dev/null 2>&1 + echo -e "${GREEN}✓ Generated SAML SP certificates${NC}" +else + echo -e "${BLUE}Using existing SAML SP certificates${NC}" +fi + +echo "" +echo -e "${YELLOW}▶ Fetching Keycloak SAML signing certificate...${NC}" +CERT_PATH="${SCRIPT_DIR}/keycloak-saml-cert.pem" +CERT_BODY="$(curl -sf http://localhost:9080/realms/stirling-saml/protocol/saml/descriptor \ + | awk 'BEGIN{RS="<[^>]*X509Certificate>|]*X509Certificate>"} NR==2{gsub(/[[:space:]]+/,""); print; exit}')" +if [ -n "$CERT_BODY" ]; then + { + echo "-----BEGIN CERTIFICATE-----" + echo "$CERT_BODY" + echo "-----END CERTIFICATE-----" + } > "$CERT_PATH" +fi +if [ ! -s "$CERT_PATH" ]; then + echo -e "${RED}✗ Failed to fetch Keycloak SAML certificate${NC}" + exit 1 +fi +echo -e "${GREEN}✓ Keycloak SAML certificate updated${NC}" + +echo "" +echo -e "${YELLOW}▶ Starting Stirling PDF...${NC}" +docker-compose -f docker-compose-keycloak-saml.yml up "${COMPOSE_UP_ARGS[@]}" stirling-pdf-saml + +echo "" +echo -e "${YELLOW}▶ Waiting for Stirling PDF...${NC}" +WAITED=0 +while [ $WAITED -lt $MAX_WAIT ]; do + if curl -sf http://localhost:8080/api/v1/info/status 2>/dev/null | grep -q "UP"; then + echo -e "${GREEN}✓ Stirling PDF is ready${NC}" + break + fi + echo -n "." + sleep 2 + WAITED=$((WAITED + 2)) +done + +if [ $WAITED -ge $MAX_WAIT ]; then + echo -e "${RED}✗ Stirling PDF failed to start${NC}" + exit 1 +fi + +echo "" +echo -e "${GREEN}╔════════════════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ SAML Test Environment Ready! ✓ ║${NC}" +echo -e "${GREEN}╚════════════════════════════════════════════════════╝${NC}" +echo "" +echo -e "${BLUE}📍 Services:${NC}" +echo -e " Stirling PDF: ${GREEN}http://localhost:8080${NC}" +echo -e " Keycloak Admin: ${GREEN}http://localhost:9080/admin${NC}" +echo "" +echo -e "${BLUE}🔑 Keycloak Admin:${NC}" +echo -e " Username: ${GREEN}admin${NC}" +echo -e " Password: ${GREEN}admin${NC}" +echo "" +echo -e "${BLUE}👥 Test Users (SAML):${NC}" +echo -e " ${YELLOW}Regular User:${NC}" +echo -e " Email: ${GREEN}samluser@example.com${NC}" +echo -e " Password: ${GREEN}samlpassword${NC}" +echo "" +echo -e " ${YELLOW}Admin User:${NC}" +echo -e " Email: ${GREEN}samladmin@example.com${NC}" +echo -e " Password: ${GREEN}samladminpass${NC}" +echo "" +echo -e "${BLUE}🧪 Test SAML:${NC}" +echo -e " 1. Go to ${GREEN}http://localhost:8080${NC}" +echo -e " 2. Click 'Login' and select SAML" +echo -e " 3. Login with test credentials" +echo "" +echo -e "${BLUE}📊 View logs:${NC}" +echo -e " docker-compose -f docker-compose-keycloak-saml.yml logs -f" +echo "" +echo -e "${BLUE}⏹ Stop:${NC}" +echo -e " docker-compose -f docker-compose-keycloak-saml.yml down -v" +echo "" diff --git a/testing/compose/validate-oauth-test.sh b/testing/compose/validate-oauth-test.sh new file mode 100644 index 000000000..d51998860 --- /dev/null +++ b/testing/compose/validate-oauth-test.sh @@ -0,0 +1,58 @@ +#!/bin/bash +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo -e "${YELLOW}Validating OAuth test environment...${NC}" +echo "" + +# Check Keycloak health +echo -n "Checking Keycloak health... " +if curl -sf http://localhost:9080/health/ready > /dev/null 2>&1; then + echo -e "${GREEN}✓${NC}" +else + echo -e "${RED}✗ Keycloak is not ready${NC}" + exit 1 +fi + +# Check OAuth realm +echo -n "Checking OAuth realm... " +if curl -sf http://localhost:9080/realms/stirling-oauth > /dev/null 2>&1; then + echo -e "${GREEN}✓${NC}" +else + echo -e "${RED}✗ OAuth realm not found${NC}" + exit 1 +fi + +# Check OIDC configuration +echo -n "Checking OIDC configuration endpoint... " +if curl -sf http://localhost:9080/realms/stirling-oauth/.well-known/openid-configuration > /dev/null 2>&1; then + echo -e "${GREEN}✓${NC}" +else + echo -e "${RED}✗ OIDC configuration not available${NC}" + exit 1 +fi + +# Check Stirling PDF +echo -n "Checking Stirling PDF status... " +if curl -sf http://localhost:8080/api/v1/info/status 2>/dev/null | grep -q "UP"; then + echo -e "${GREEN}✓${NC}" +else + echo -e "${RED}✗ Stirling PDF is not ready${NC}" + exit 1 +fi + +# Check OAuth login endpoint +echo -n "Checking OAuth login endpoint... " +if curl -sf http://localhost:8080/oauth2/authorization/keycloak > /dev/null 2>&1; then + echo -e "${GREEN}✓${NC}" +else + echo -e "${RED}✗ OAuth login endpoint not available${NC}" + exit 1 +fi + +echo "" +echo -e "${GREEN}All OAuth environment checks passed!${NC}" diff --git a/testing/compose/validate-saml-test.sh b/testing/compose/validate-saml-test.sh new file mode 100644 index 000000000..98f40db76 --- /dev/null +++ b/testing/compose/validate-saml-test.sh @@ -0,0 +1,58 @@ +#!/bin/bash +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo -e "${YELLOW}Validating SAML test environment...${NC}" +echo "" + +# Check Keycloak health +echo -n "Checking Keycloak health... " +if curl -sf http://localhost:9080/health/ready > /dev/null 2>&1; then + echo -e "${GREEN}✓${NC}" +else + echo -e "${RED}✗ Keycloak is not ready${NC}" + exit 1 +fi + +# Check SAML realm +echo -n "Checking SAML realm... " +if curl -sf http://localhost:9080/realms/stirling-saml > /dev/null 2>&1; then + echo -e "${GREEN}✓${NC}" +else + echo -e "${RED}✗ SAML realm not found${NC}" + exit 1 +fi + +# Check SAML metadata +echo -n "Checking SAML metadata endpoint... " +if curl -sf http://localhost:9080/realms/stirling-saml/protocol/saml/descriptor > /dev/null 2>&1; then + echo -e "${GREEN}✓${NC}" +else + echo -e "${RED}✗ SAML metadata not available${NC}" + exit 1 +fi + +# Check Stirling PDF +echo -n "Checking Stirling PDF status... " +if curl -sf http://localhost:8080/api/v1/info/status 2>/dev/null | grep -q "UP"; then + echo -e "${GREEN}✓${NC}" +else + echo -e "${RED}✗ Stirling PDF is not ready${NC}" + exit 1 +fi + +# Check Stirling PDF SAML metadata +echo -n "Checking Stirling PDF SAML metadata... " +if curl -sf http://localhost:8080/saml2/service-provider-metadata/keycloak > /dev/null 2>&1; then + echo -e "${GREEN}✓${NC}" +else + echo -e "${RED}✗ Stirling PDF SAML metadata not available${NC}" + exit 1 +fi + +echo "" +echo -e "${GREEN}All SAML environment checks passed!${NC}"