JWT Authentication (#3921)

This PR introduces JWT (JSON Web Token) authentication for Stirling-PDF,
allowing for stateless authentication capabilities alongside the
existing session-based authentication system.

### Key Features & Changes

  JWT Authentication System
- Core Service: JwtService.java - Token generation, validation, and
cookie management
- Authentication Filter: JwtAuthenticationFilter.java - Request
interceptor for JWT validation
- Key Management: KeyPersistenceService.java +
KeyPairCleanupService.java - RSA key rotation and persistence
  - Frontend: jwt-init.js - Client-side JWT handling and URL cleanup

  Security Integration
- SAML2: JwtSaml2AuthenticationRequestRepository.java - JWT-backed SAML
request storage
- OAuth2: Updated CustomAuthenticationSuccessHandler. java,
CustomOAuth2AuthenticationSuccessHandler.java &
CustomSaml2AuthenticationSuccessHandler.java for JWT integration
- Configuration: Enhanced SecurityConfiguration.java with JWT filter
chain

  Infrastructure
  - Caching: CacheConfig.java - Caffeine cache for JWT keys
  - Database: New JwtVerificationKey.java entity for key storage
- Error Handling: JwtAuthenticationEntryPoint.java for unauthorized
access

### Challenges Encountered

- Configured SecurityConfiguration to use either
`UsernamePasswordAuthenticationFilter` or `JWTAuthenticationFilter`
based on whether JWTs are enabled to prevent the former intercepting
requests while in stateless mode.
- Removed the `.defaultSuccessUrl("/")` from login configuration as its
inclusion was preventing overriding the use of the
`CustomAuthenticationSuccessHandler` and preventing proper
authentication flows.
---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [x] 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)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [x] 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)
- [x] 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)

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
<img width="599" height="515" alt="Screenshot 2025-07-10 at 13 35 56"
src="https://github.com/user-attachments/assets/4126b752-ad0d-4ffa-b295-6714c43381e1"
/>

<img width="392" height="376" alt="Screenshot 2025-07-10 at 13 36 10"
src="https://github.com/user-attachments/assets/c681bc43-68ff-4934-8245-d544e2ad7b9c"
/>

<img width="1870" height="986" alt="eb750e8c3954fc47b2dd2e6e76ddb7d5"
src="https://github.com/user-attachments/assets/fca9b23d-b0b6-4884-8a26-98a441b641ef"
/>

<img width="1299" height="702" alt="Screenshot 2025-07-10 at 13 30 57"
src="https://github.com/user-attachments/assets/9415d8bf-fac4-4d38-8c3a-985d043d1076"
/>

### Testing (if applicable)

- [x] 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.

---------

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ludy <[email protected]>
Co-authored-by: EthanHealy01 <[email protected]>
Co-authored-by: Ethan <[email protected]>
Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
Dario Ghunney Ware
2025-08-11 12:27:42 +01:00
committed by GitHub
co-authored by dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Ludy EthanHealy01 Ethan Anthony Stirling
parent 74c92ef215
commit 6699facc24
52 changed files with 2827 additions and 196 deletions
@@ -5,7 +5,7 @@ logging.level.org.eclipse.jetty=WARN
#logging.level.org.springframework.security.saml2=TRACE
#logging.level.org.springframework.security=DEBUG
#logging.level.org.opensaml=DEBUG
#logging.level.stirling.software.SPDF.config.security: DEBUG
#logging.level.stirling.software.proprietary.security=DEBUG
logging.level.com.zaxxer.hikari=WARN
spring.jpa.open-in-view=false
server.forward-headers-strategy=NATIVE
@@ -47,4 +47,7 @@ posthog.host=https://eu.i.posthog.com
spring.main.allow-bean-definition-overriding=true
# Set up a consistent temporary directory location
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
# V2 features
v2=false
@@ -893,7 +893,7 @@ login.rememberme=Remember me
login.invalid=Invalid username or password.
login.locked=Your account has been locked.
login.signinTitle=Please sign in
login.ssoSignIn=Login via Single Sign-on
login.ssoSignIn=Login via Single Sign-On
login.oAuth2AutoCreateDisabled=OAUTH2 Auto-Create User Disabled
login.oAuth2AdminBlockedUser=Registration or logging in of non-registered users is currently blocked. Please contact the administrator.
login.oauth2RequestNotFound=Authorization request not found
@@ -908,6 +908,7 @@ login.alreadyLoggedIn=You are already logged in to
login.alreadyLoggedIn2=devices. Please log out of the devices and try again.
login.toManySessions=You have too many active sessions
login.logoutMessage=You have been logged out.
login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator.
#auto-redact
autoRedact.title=Auto Redact
@@ -59,12 +59,17 @@ 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
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
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
secureCookie: false # Set to 'true' to use secure cookies for JWTs
premium:
key: 00000000-0000-0000-0000-000000000000
enabled: false # Enable license key checks for pro/enterprise features
proFeatures:
database: true # Enable database features
SSOAutoLogin: false
CustomMetadata:
autoUpdateMetadata: false
@@ -46,10 +46,9 @@ export class DecryptFile {
formData.append('password', password);
}
// Send decryption request
const response = await fetch('/api/v1/security/remove-password', {
const response = await fetchWithCsrf('/api/v1/security/remove-password', {
method: 'POST',
body: formData,
headers: csrfToken ? {'X-XSRF-TOKEN': csrfToken} : undefined,
});
if (response.ok) {
@@ -218,7 +218,7 @@
formData.append('password', password);
// Use handleSingleDownload to send the request
const decryptionResult = await fetch(removePasswordUrl, {method: 'POST', body: formData});
const decryptionResult = await fetchWithCsrf(removePasswordUrl, {method: 'POST', body: formData});
if (decryptionResult && decryptionResult.blob) {
const decryptedBlob = await decryptionResult.blob();
@@ -1,3 +1,29 @@
// Authentication utility for cookie-based JWT
window.JWTManager = {
// Logout - clear cookies and redirect to login
logout: function() {
// Clear JWT cookie manually (fallback)
document.cookie = 'stirling_jwt=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; SameSite=None; Secure';
// Perform logout request to clear server-side session
fetch('/logout', {
method: 'POST',
credentials: 'include'
}).then(response => {
if (response.redirected) {
window.location.href = response.url;
} else {
window.location.href = '/login?logout=true';
}
}).catch(() => {
// If logout fails, let server handle it
window.location.href = '/logout';
});
}
};
window.fetchWithCsrf = async function(url, options = {}) {
function getCsrfToken() {
const cookieValue = document.cookie
@@ -24,5 +50,19 @@ window.fetchWithCsrf = async function(url, options = {}) {
fetchOptions.headers['X-XSRF-TOKEN'] = csrfToken;
}
return fetch(url, fetchOptions);
// Always include credentials to send JWT cookies
fetchOptions.credentials = 'include';
// Make the request
const response = await fetch(url, fetchOptions);
// Handle 401 responses (unauthorized)
if (response.status === 401) {
console.warn('Authentication failed, redirecting to login');
window.JWTManager.logout();
return response;
}
return response;
}
@@ -0,0 +1,44 @@
// JWT Authentication Management Script
// This script handles cookie-based JWT authentication and page access control
(function() {
// Clean up JWT token from URL parameters after OAuth/Login flows
function cleanupTokenFromUrl() {
const urlParams = new URLSearchParams(window.location.search);
const hasToken = urlParams.get('jwt') || urlParams.get('token');
if (hasToken) {
// Clean up URL by removing token parameter
// Token should now be set as cookie by server
urlParams.delete('jwt');
urlParams.delete('token');
const newUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : '');
window.history.replaceState({}, '', newUrl);
}
}
// Initialize JWT handling when page loads
function initializeJWT() {
// Clean up any JWT tokens from URL (OAuth flow)
cleanupTokenFromUrl();
// Authentication is handled server-side
// If user is not authenticated, server will redirect to login
console.log('JWT initialization complete - authentication handled server-side');
}
// No form enhancement needed for cookie-based JWT
// Cookies are automatically sent with form submissions
function enhanceFormSubmissions() {
// Cookie-based JWT is automatically included in form submissions
// No additional processing needed
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
initializeJWT();
});
} else {
initializeJWT();
}
})();
@@ -138,5 +138,19 @@ document.addEventListener('DOMContentLoaded', () => {
tooltipSetup();
setupDropdowns();
fixNavbarDropdownStyles();
// Setup logout button functionality
const logoutButton = document.querySelector('a[href="/logout"]');
if (logoutButton) {
logoutButton.addEventListener('click', function(event) {
event.preventDefault();
if (window.JWTManager) {
window.JWTManager.logout();
} else {
// Fallback if JWTManager is not available
window.location.href = '/logout';
}
});
}
});
window.addEventListener('resize', fixNavbarDropdownStyles);
@@ -102,7 +102,7 @@ async function fetchEndpointData() {
refreshBtn.classList.add('refreshing');
refreshBtn.disabled = true;
const response = await fetch('/api/v1/info/load/all');
const response = await fetchWithCsrf('/api/v1/info/load/all');
if (!response.ok) {
throw new Error('Network response was not ok');
}
@@ -390,8 +390,13 @@
key.includes('clientSubmissionOrder') ||
key.includes('lastSubmitTime') ||
key.includes('lastClientId') ||
key.includes('stirling_jwt') ||
key.includes('JSESSIONID') ||
key.includes('XSRF-TOKEN') ||
key.includes('remember-me') ||
key.includes('auth') ||
key.includes('token') ||
key.includes('session') ||
key.includes('posthog') || key.includes('ssoRedirectAttempts') || key.includes('lastRedirectAttempt') || key.includes('surveyVersion') ||
key.includes('pageViews');
}