Compare commits

...
Author SHA1 Message Date
DarioGii 94da0519c4 cleanup 2026-01-24 20:06:56 +00:00
Dario Ghunney Ware 5e1c73a6f5 resolving conflicts 2026-01-24 12:46:41 +00:00
DarioGii 299edf876e cleanup 2026-01-24 12:46:41 +00:00
DarioGii d92079f59e Using jwt to determine logout strategy 2026-01-24 12:46:41 +00:00
Dario Ghunney Ware c008198e13 moar cleanup 2026-01-24 12:46:41 +00:00
Dario Ghunney Ware 43df0bea37 cleanup 2026-01-24 12:46:41 +00:00
Dario Ghunney Ware 9d895ae0ca Return JSON for API requests, redirect for browser requests 2026-01-24 12:46:41 +00:00
Dario Ghunney Ware dba8c1f962 use correct token for jwt filter & proper validation of auth tokens 2026-01-24 12:46:41 +00:00
Dario Ghunney Ware ec72cfd7e2 removing /logout from filters 2026-01-24 12:46:41 +00:00
DarioGii 817f43613f refactored OIDC logout 2026-01-24 12:46:41 +00:00
DarioGii 7e102cd08f correcting keycloak logout URL
# Conflicts:
#	app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java
2026-01-24 12:46:41 +00:00
Dario Ghunney Ware c2a77ad90a removed redundant logout endpoint. allow Spring to handle logout 2026-01-24 12:46:41 +00:00
Dario Ghunney Ware c7996a5f55 Implemented SP-initiated SLO enabled with flag 2026-01-24 12:46:41 +00:00
Dario Ghunney Ware 67ab101a5b Fixing SAML logout 2026-01-24 12:46:41 +00:00
Dario Ghunney Ware 8cfc563381 resolving conflicts 2026-01-24 12:46:39 +00:00
DarioGii 8d6d0b0cdd pr comments 2026-01-24 12:43:52 +00:00
DarioGii 7f4aa8db9b updated deprecated version 2026-01-24 12:43:52 +00:00
DarioGii 188d4c8db2 added missing check 2026-01-24 12:43:52 +00:00
Dario Ghunney Ware 26eb133ced Testing saml login/logout
# Conflicts:
#	app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java

# Conflicts:
#	frontend/src/proprietary/routes/AuthCallback.tsx

# Conflicts:
#	app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java
#	app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java
2026-01-24 12:43:52 +00:00
Ludy 3acb96740a refactor(security): clean up SSO handling, harden URL parsing, and bump dependencies (#5523)
# Description of Changes

This PR performs a broad cleanup and refactor across the security, SSO,
and dependency layers to improve correctness, maintainability, and
robustness.

### What was changed

- **SSO / Authentication cleanup**
- Removed deprecated and ambiguous `SSO` authentication handling in
favor of explicit `OAUTH2` and `SAML2`.
- Introduced a centralized helper (`isSsoAuthenticationTypeByUsername`)
to consistently detect SSO-backed users.
- Hardened user creation logic to strictly validate authentication types
and reject invalid values.
- Updated OAuth2 and SAML2 authentication success handlers to use
unified SSO detection logic and clearer control flow.
  - Adjusted tests to reflect the new canonical authentication types.

- **Security & robustness improvements**
- Replaced direct `new URL(...)` usage with `URI.create(...).toURL()` to
avoid malformed URL edge cases.
- Hardened `Referer` parsing logic to safely handle invalid or host-less
URIs.
- Improved string comparison patterns (`"literal".equals(x)`) to avoid
potential `NullPointerException`s.

- **Controller and API cleanup**
- Removed large blocks of unused and legacy admin settings endpoints
from `SettingsController`.
- Updated OpenAPI annotations to use `requiredMode` instead of
deprecated `required`.

- **Dependency and build maintenance**
  - Updated Spring Boot from `3.5.7` to `3.5.9`.
- Updated multiple dependencies (Spring Security, Jackson, Micrometer,
Jetty, Hibernate, SnakeYAML, Springdoc, Swagger UI, etc.).
- Synced dependency versions in `3rdPartyLicenses.json` and removed
duplicate or obsolete entries.
- Modernized Gradle DSL usage (`url =`, `username =`,
`allowInsecureProtocol = true`).
- Ensured Spotless disabling applies consistently across all
subprojects.
  - Added `.build-cache` to `.gitignore`.

### Why the change was made

- To eliminate legacy and ambiguous SSO handling that could lead to
incorrect authentication decisions.
- To improve security and stability when dealing with user-controlled
URLs and headers.
- To reduce technical debt by removing unused controllers and deprecated
patterns.
- To keep dependencies up to date and aligned with the current Spring
Boot release.
- To improve overall code clarity, consistency, and long-term
maintainability.

---

This pull request contains dependency updates, minor code cleanups, and
some refactoring to improve maintainability and correctness. The most
significant change is the removal of all admin settings endpoints
(GET/POST) from the `SettingsController`, which impacts how application
settings can be managed via the API. Additionally, there are dependency
version bumps, minor improvements to static resource checks, and small
refactors in certificate download logic and Telegram bot service.

**Major API changes:**

* Removed all admin settings endpoints (general, security, connections,
privacy, advanced) from `SettingsController`, including both GET and
POST handlers for updating and retrieving settings. This eliminates the
ability to manage these settings via the API.

**Dependency updates:**

* Upgraded `snakeyaml-engine` from 2.10 to 3.0.1 and
`springdoc-openapi-starter-webmvc-ui` from 2.8.14 to 2.8.15 in
`build.gradle`.

**Refactoring and bug fixes:**

* Refactored static resource check in `RequestUriUtils.isStaticResource`
to use constant-first string comparison for better null safety and
clarity.
* Updated certificate download logic in `CertificateValidationService`
to use `URI.create(urlStr).toURL()` instead of `new URL(urlStr)` for
improved URL parsing and error handling.
[[1]](diffhunk://#diff-d2646f37bfd3e0963cbce16ab13edb52f2092795f54203b999dd82651154f26dL513-R514)
[[2]](diffhunk://#diff-d2646f37bfd3e0963cbce16ab13edb52f2092795f54203b999dd82651154f26dL703-R704)
* Refactored `TelegramPipelineBot` to consistently use
`telegramProperties.getBotToken()` instead of `getBotToken()`, and
removed the `getBotToken()` method override.
[[1]](diffhunk://#diff-a2466b92f58750ea37960cd1533e3194d9ecc3b4ef5ad7b64a017ee0e636ad93L85-R85)
[[2]](diffhunk://#diff-a2466b92f58750ea37960cd1533e3194d9ecc3b4ef5ad7b64a017ee0e636ad93L395-R395)
[[3]](diffhunk://#diff-a2466b92f58750ea37960cd1533e3194d9ecc3b4ef5ad7b64a017ee0e636ad93L519-L523)

---

## 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.
2026-01-24 12:43:52 +00:00
Ludy dd0686a680 Add sync-versions job to GitHub Actions workflow (#5228)
# Description of Changes

This pull request updates the `.github/workflows/build.yml` file to
improve automation and consistency in the build process. The most
significant change is the addition of a new `sync-versions` job that
automatically checks for and synchronizes version differences across key
files, and posts a comment on the pull request if mismatches are
detected. Additionally, there are minor formatting updates for
consistency.

**Automation and version synchronization:**

* Added a new `sync-versions` job that runs after the build, checks for
version mismatches between `build.gradle` and several frontend files,
and posts or updates a comment on the pull request to notify
contributors if differences are found. This job uses a GitHub App bot
for authentication and ensures that versioning remains consistent across
the project.

**Formatting consistency:**

* Updated the YAML formatting for `node-version`, `cache`, and
`python-version` fields to use double quotes for consistency in the
`build.yml` workflow.
[[1]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721L150-R151)
[[2]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721L244-R338)


<img width="1127" height="401" alt="image"
src="https://github.com/user-attachments/assets/6930a72d-f72b-4909-97e2-51f0e0f61613"
/>


---

## 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)

### 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.
2026-01-24 12:43:52 +00:00
Ludy 40b6eb0389 chore(ci): enable Gradle dependency caching across GitHub workflows (#5400)
# Description of Changes

This pull request updates the CI/CD workflows and Gradle configuration
to improve build reproducibility, security, and external dependency
management. The main changes include standardizing Gradle setup across
workflows, securely injecting Maven credentials, and enabling Gradle
build caching. There are also minor improvements to dependency version
management and plugin repository configuration.

**CI/CD Workflow Improvements:**

- Standardized Gradle setup across all GitHub Actions workflows by
explicitly adding a `Setup Gradle` step using
`gradle/actions/setup-gradle@v5.0.0` and specifying Gradle version 8.14.
This replaces previous usages and ensures consistency.
[[1]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R71-R81)
[[2]](diffhunk://#diff-8d23782ae5caff72d55828bb25814854f5f2523f299d7dbcda4a3537dd84c5c3L157-R176)
[[3]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R134-R144)
[[4]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R206-R216)
[[5]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R260-R264)
[[6]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R331-R341)
[[7]](diffhunk://#diff-3c0f521958c53ad27c967692b4d5480ead136acb33622ee97d39df814b1b202eR339-R351)
[[8]](diffhunk://#diff-895b214ee023c8c26048a2a3b946cfb1ebc4f26fbc8a9c2fa54b77c12e763b6bL53-R54)
[[9]](diffhunk://#diff-895b214ee023c8c26048a2a3b946cfb1ebc4f26fbc8a9c2fa54b77c12e763b6bL121-R127)
[[10]](diffhunk://#diff-895b214ee023c8c26048a2a3b946cfb1ebc4f26fbc8a9c2fa54b77c12e763b6bR206-R217)
[[11]](diffhunk://#diff-6a2e9fb077e57351f4a7e10d03b114e256298babdf06e7e7ae666781a5cf36a1R60-R70)
[[12]](diffhunk://#diff-62dcbe64a950b4efb54d691e1e87451a8cd535400aa9ea1e40893de5b57cd73bL45-R46)
[[13]](diffhunk://#diff-76056236de05155107f6a660f1e3956059e37338011b8f0e72188afcb9b17b6fL46-R56)
[[14]](diffhunk://#diff-fd60dc2adec58c1005c4e4164e9c24362fd6082fd3ab0403e54d276d9835fa6eL42-R65)
[[15]](diffhunk://#diff-b34ab107dd4bc92075b2e89b6f16e4a2813e267ca7c2afebdb1931a0a3900d5aR102-R114)
[[16]](diffhunk://#diff-98b618771a57e1758961359ecacbac2cff7cfef29aa021c3bc294ae926c4ce5bL47-R51)

- Enabled Gradle build cache (`--build-cache`) for all build-related
commands in workflows, improving build performance and consistency. Also
removed unnecessary `clean` commands before builds to further optimize
workflow times.
[[1]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R71-R81)
[[2]](diffhunk://#diff-8d23782ae5caff72d55828bb25814854f5f2523f299d7dbcda4a3537dd84c5c3L157-R176)
[[3]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R134-R144)
[[4]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R206-R216)
[[5]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R331-R341)
[[6]](diffhunk://#diff-3c0f521958c53ad27c967692b4d5480ead136acb33622ee97d39df814b1b202eR339-R351)
[[7]](diffhunk://#diff-895b214ee023c8c26048a2a3b946cfb1ebc4f26fbc8a9c2fa54b77c12e763b6bL134-R144)
[[8]](diffhunk://#diff-895b214ee023c8c26048a2a3b946cfb1ebc4f26fbc8a9c2fa54b77c12e763b6bR206-R217)
[[9]](diffhunk://#diff-6a2e9fb077e57351f4a7e10d03b114e256298babdf06e7e7ae666781a5cf36a1R60-R70)
[[10]](diffhunk://#diff-76056236de05155107f6a660f1e3956059e37338011b8f0e72188afcb9b17b6fL46-R56)
[[11]](diffhunk://#diff-fd60dc2adec58c1005c4e4164e9c24362fd6082fd3ab0403e54d276d9835fa6eL42-R65)
[[12]](diffhunk://#diff-b34ab107dd4bc92075b2e89b6f16e4a2813e267ca7c2afebdb1931a0a3900d5aR102-R114)
[[13]](diffhunk://#diff-98b618771a57e1758961359ecacbac2cff7cfef29aa021c3bc294ae926c4ce5bL47-R51)

**Security and Dependency Management:**

- Injected Maven credentials (`MAVEN_USER`, `MAVEN_PASSWORD`,
`MAVEN_PUBLIC_URL`) as environment variables in all relevant workflow
steps, supporting secure access to private or custom Maven repositories.
[[1]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R71-R81)
[[2]](diffhunk://#diff-8d23782ae5caff72d55828bb25814854f5f2523f299d7dbcda4a3537dd84c5c3L157-R176)
[[3]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R134-R144)
[[4]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R206-R216)
[[5]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R290-R293)
[[6]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R331-R341)
[[7]](diffhunk://#diff-3c0f521958c53ad27c967692b4d5480ead136acb33622ee97d39df814b1b202eR339-R351)
[[8]](diffhunk://#diff-895b214ee023c8c26048a2a3b946cfb1ebc4f26fbc8a9c2fa54b77c12e763b6bR66-R69)
[[9]](diffhunk://#diff-895b214ee023c8c26048a2a3b946cfb1ebc4f26fbc8a9c2fa54b77c12e763b6bL134-R144)
[[10]](diffhunk://#diff-895b214ee023c8c26048a2a3b946cfb1ebc4f26fbc8a9c2fa54b77c12e763b6bR281-R283)
[[11]](diffhunk://#diff-62dcbe64a950b4efb54d691e1e87451a8cd535400aa9ea1e40893de5b57cd73bR57-R60)
[[12]](diffhunk://#diff-76056236de05155107f6a660f1e3956059e37338011b8f0e72188afcb9b17b6fR73-R76)
[[13]](diffhunk://#diff-fd60dc2adec58c1005c4e4164e9c24362fd6082fd3ab0403e54d276d9835fa6eL42-R65)
[[14]](diffhunk://#diff-b34ab107dd4bc92075b2e89b6f16e4a2813e267ca7c2afebdb1931a0a3900d5aR178-R180)
[[15]](diffhunk://#diff-98b618771a57e1758961359ecacbac2cff7cfef29aa021c3bc294ae926c4ce5bL47-R51)

- Added a `pluginManagement` block in `settings.gradle` to allow Gradle
plugins to be resolved from a custom Maven repository if specified by
environment variables, increasing flexibility for plugin sourcing.

**Build and Dependency Versioning:**

- Updated `app/proprietary/build.gradle` to use the
`bouncycastleVersion` variable for the Bouncy Castle dependency version,
improving maintainability and consistency of dependency versioning.

**Workflow Trigger Improvements:**

- Expanded the file path triggers in
`.github/workflows/sync_files_v2.yml` to include additional Gradle build
files, ensuring the workflow runs when any core build files are changed.

---

## 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.
2026-01-24 12:43:52 +00:00
Anthony Stirling 7284c5225a Libre threads (#5303)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## 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.
2026-01-24 12:43:52 +00:00
Dario Ghunney Ware 41ae76eeda resolving conflicts 2026-01-24 12:43:50 +00:00
Dario Ghunney Ware 46a8d1bea8 eslint fix 2026-01-24 12:43:19 +00:00
Dario Ghunney Ware aa742c3ed2 clean up 2026-01-24 12:43:19 +00:00
Dario Ghunney Ware f498632d67 Improved SAML config settings 2026-01-24 12:43:19 +00:00
Dario Ghunney Ware 08b1f15677 Streamlined config with metadata uri 2026-01-24 12:43:19 +00:00
Dario Ghunney Ware a75af13304 Addressing XML vulnerability 2026-01-24 12:43:19 +00:00
Dario Ghunney Ware a2ae30d260 Hardening 2026-01-24 12:43:19 +00:00
Dario Ghunney Ware df58816baf Fixing tests 2026-01-24 12:43:19 +00:00
Dario Ghunney Ware 5f0652caca Implemented SP-initiated SLO enabled with flag
# Conflicts:
#	frontend/src/proprietary/routes/Login.tsx
2026-01-24 12:42:00 +00:00
Dario Ghunney Ware 99c57bdf23 Fixing SAML logout 2026-01-24 12:41:10 +00:00
Dario Ghunney Ware bf51895b8e Testing saml login/logout
# Conflicts:
#	app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java

# Conflicts:
#	frontend/src/proprietary/routes/AuthCallback.tsx

# Conflicts:
#	app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java
#	app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java
2026-01-24 12:41:10 +00:00
43 changed files with 1958 additions and 803 deletions
@@ -214,75 +214,182 @@ public class ApplicationProperties {
@Setter
@ToString
public static class SAML2 {
private String provider;
private Boolean enabled = false;
private Boolean autoCreateUser = false;
private Boolean blockRegistration = false;
private String registrationId = "stirling";
private Boolean enableSingleLogout = false;
@ToString.Exclude
@JsonProperty("idpMetadataUri")
@ToString.Exclude private String metadataUri;
private Provider provider = new Provider();
private SP sp = new SP();
// Legacy field mappings for backward compatibility
@Deprecated(since = "2.3.1", forRemoval = true)
@JsonIgnore
private String idpMetadataUri;
@Deprecated(since = "2.3.1", forRemoval = true)
@JsonIgnore
private String idpSingleLogoutUrl;
@Deprecated(since = "2.3.1", forRemoval = true)
@JsonIgnore
private String idpSingleLoginUrl;
@Deprecated(since = "2.3.1", forRemoval = true)
@JsonIgnore
private String idpIssuer;
@JsonProperty("idpCert")
@Deprecated(since = "2.3.1", forRemoval = true)
@JsonIgnore
private String idpEntityId;
@Deprecated(since = "2.3.1", forRemoval = true)
@JsonIgnore
private String idpCert;
@ToString.Exclude
@JsonProperty("privateKey")
@Deprecated(since = "2.3.1", forRemoval = true)
@JsonIgnore
private String privateKey;
@ToString.Exclude
@JsonProperty("spCert")
@Deprecated(since = "2.3.1", forRemoval = true)
@JsonIgnore
private String spCert;
/** Migrate legacy flat properties to new nested structure on set. */
public void setIdpMetadataUri(String value) {
this.idpMetadataUri = value;
if (value != null
&& !value.isBlank()
&& (metadataUri == null || metadataUri.isBlank())) {
this.metadataUri = value;
}
}
public void setIdpSingleLoginUrl(String value) {
this.idpSingleLoginUrl = value;
if (value != null && !value.isBlank()) {
this.provider.setSingleLoginUrl(value);
}
}
public void setIdpSingleLogoutUrl(String value) {
this.idpSingleLogoutUrl = value;
if (value != null && !value.isBlank()) {
this.provider.setSingleLogoutUrl(value);
}
}
public void setIdpIssuer(String value) {
this.idpIssuer = value;
if (value != null && !value.isBlank()) {
this.provider.setEntityId(value);
}
}
public void setIdpEntityId(String value) {
this.idpEntityId = value;
if (value != null && !value.isBlank()) {
this.provider.setEntityId(value);
}
}
public void setIdpCert(String value) {
this.idpCert = value;
if (value != null && !value.isBlank()) {
this.provider.setCert(value);
}
}
public void setPrivateKey(String value) {
this.privateKey = value;
if (value != null && !value.isBlank()) {
this.sp.setPrivateKey(value);
}
}
public void setSpCert(String value) {
this.spCert = value;
if (value != null && !value.isBlank()) {
this.sp.setCert(value);
}
}
@JsonIgnore
public InputStream getIdpMetadataUri() throws IOException {
if (idpMetadataUri.startsWith("classpath:")) {
return new ClassPathResource(idpMetadataUri.substring("classpath:".length()))
public InputStream getMetadataUriAsStream() throws IOException {
String uri = getEffectiveMetadataUri();
if (uri == null || uri.isBlank()) {
throw new IOException("security.saml2.metadataUri is not configured");
}
if (uri.startsWith("classpath:")) {
return new ClassPathResource(uri.substring("classpath:".length()))
.getInputStream();
}
try {
URI uri = new URI(idpMetadataUri);
URL url = uri.toURL();
URI parsedUri = new URI(uri);
URL url = parsedUri.toURL();
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
return connection.getInputStream();
} catch (URISyntaxException e) {
throw new IOException("Invalid URI format: " + idpMetadataUri, e);
throw new IOException("Invalid URI format: " + uri, e);
}
}
@JsonIgnore
public Resource getSpCert() {
if (spCert == null) return null;
if (spCert.startsWith("classpath:")) {
return new ClassPathResource(spCert.substring("classpath:".length()));
} else {
return new FileSystemResource(spCert);
public String getEffectiveMetadataUri() {
if (metadataUri != null && !metadataUri.isBlank()) {
return metadataUri;
}
return idpMetadataUri; // Legacy fallback
}
/** IdP configuration - manual fallback when metadata is unavailable. */
@Data
public static class Provider {
private String name = ""; // Display name only
private String singleLoginUrl;
private String singleLogoutUrl;
private String entityId;
@ToString.Exclude private String cert;
@JsonIgnore
public Resource getCertResource() {
if (cert == null) return null;
if (cert.startsWith("classpath:")) {
return new ClassPathResource(cert.substring("classpath:".length()));
} else {
return new FileSystemResource(cert);
}
}
}
@JsonIgnore
public Resource getIdpCert() {
if (idpCert == null) return null;
if (idpCert.startsWith("classpath:")) {
return new ClassPathResource(idpCert.substring("classpath:".length()));
} else {
return new FileSystemResource(idpCert);
}
}
/** Service Provider (SP) credentials for signing SAML requests. */
@Data
public static class SP {
@ToString.Exclude private String privateKey;
@ToString.Exclude private String cert;
@JsonIgnore
public Resource getPrivateKey() {
if (privateKey == null) return null;
if (privateKey.startsWith("classpath:")) {
return new ClassPathResource(privateKey.substring("classpath:".length()));
} else {
return new FileSystemResource(privateKey);
@JsonIgnore
public Resource getPrivateKeyResource() {
if (privateKey == null) return null;
if (privateKey.startsWith("classpath:")) {
return new ClassPathResource(privateKey.substring("classpath:".length()));
} else {
return new FileSystemResource(privateKey);
}
}
@JsonIgnore
public Resource getCertResource() {
if (cert == null) return null;
if (cert.startsWith("classpath:")) {
return new ClassPathResource(cert.substring("classpath:".length()));
} else {
return new FileSystemResource(cert);
}
}
}
}
@@ -327,6 +434,7 @@ public class ApplicationProperties {
private GoogleProvider google = new GoogleProvider();
private GitHubProvider github = new GitHubProvider();
private KeycloakProvider keycloak = new KeycloakProvider();
private String endSessionEndpoint;
public Provider get(String registrationId) throws UnsupportedProviderException {
return switch (registrationId.toLowerCase(Locale.ROOT)) {
@@ -29,7 +29,7 @@ public class RequestUriUtils {
|| normalizedUri.startsWith("/pdfium/")
|| normalizedUri.startsWith("/assets/")
|| normalizedUri.startsWith("/locales/")
|| normalizedUri.startsWith("/Login/")
|| normalizedUri.startsWith("/login/")
|| normalizedUri.startsWith("/samples/")
|| normalizedUri.startsWith("/classic-logo/")
|| normalizedUri.startsWith("/modern-logo/")
@@ -165,7 +165,6 @@ public class RequestUriUtils {
|| trimmedUri.contains("/oauth2/authorization/") // OAuth2 authorization endpoint
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith(
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
// enableLogin)
@@ -31,9 +31,9 @@ class ApplicationPropertiesSaml2HttpTest {
String url = server.url("/meta").toString();
var s = new ApplicationProperties.Security.SAML2();
s.setIdpMetadataUri(url);
s.setMetadataUri(url);
try (InputStream in = s.getIdpMetadataUri()) {
try (InputStream in = s.getMetadataUriAsStream()) {
String body = new String(in.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(body.contains("EntityDescriptor"));
}
@@ -44,9 +44,9 @@ class ApplicationPropertiesSaml2HttpTest {
void idpMetadataUri_invalidUri_triggers_catch_and_throwsIOException() {
// Ungültige URI -> new URI(...) wirft URISyntaxException -> catch -> IOException
var s = new ApplicationProperties.Security.SAML2();
s.setIdpMetadataUri("http:##invalid uri"); // absichtlich kaputt (Leerzeichen + ##)
s.setMetadataUri("http:##invalid uri"); // absichtlich kaputt (Leerzeichen + ##)
assertThrows(IOException.class, s::getIdpMetadataUri);
assertThrows(IOException.class, s::getMetadataUriAsStream);
}
@Test
@@ -57,8 +57,8 @@ class ApplicationPropertiesSaml2HttpTest {
Path tmp = Files.createTempFile("spdf-spcert-", ".crt");
Files.writeString(tmp, "CERT");
s.setSpCert(tmp.toString());
Resource r = s.getSpCert();
s.getSp().setCert(tmp.toString());
Resource r = s.getSp().getCertResource();
assertNotNull(r);
assertInstanceOf(FileSystemResource.class, r, "Expected FileSystemResource for FS path");
@@ -71,8 +71,8 @@ class ApplicationPropertiesSaml2HttpTest {
// bewusst nicht existierender Pfad -> else-Zweig wird trotzdem genommen
String missing = "/this/path/does/not/exist/idp.crt";
s.setIdpCert(missing);
Resource r = s.getIdpCert();
s.getProvider().setCert(missing);
Resource r = s.getProvider().getCertResource();
assertNotNull(r);
assertInstanceOf(FileSystemResource.class, r, "Expected FileSystemResource for FS path");
@@ -15,9 +15,9 @@ class ApplicationPropertiesSaml2ResourceTest {
@Test
void idpMetadataUri_classpath_is_resolved() throws Exception {
var s = new ApplicationProperties.Security.SAML2();
s.setIdpMetadataUri("classpath:saml/dummy.txt");
s.setMetadataUri("classpath:saml/dummy.txt");
try (InputStream in = s.getIdpMetadataUri()) {
try (InputStream in = s.getMetadataUriAsStream()) {
assertNotNull(in, "Classpath InputStream should not be null");
String txt = new String(in.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(txt.contains("ok"));
@@ -28,27 +28,26 @@ class ApplicationPropertiesSaml2ResourceTest {
void spCert_idpCert_privateKey_null_classpath_and_filesystem() throws Exception {
var s = new ApplicationProperties.Security.SAML2();
s.setSpCert(null);
s.setIdpCert(null);
s.setPrivateKey(null);
assertNull(s.getSpCert());
assertNull(s.getIdpCert());
assertNull(s.getPrivateKey());
// Test null values via the nested structure
assertNull(s.getSp().getCertResource());
assertNull(s.getProvider().getCertResource());
assertNull(s.getSp().getPrivateKeyResource());
s.setSpCert("classpath:saml/dummy.txt");
s.setIdpCert("classpath:saml/dummy.txt");
s.setPrivateKey("classpath:saml/dummy.txt");
Resource sp = s.getSpCert();
Resource idp = s.getIdpCert();
Resource pk = s.getPrivateKey();
// Set classpath resources via the nested structure
s.getSp().setCert("classpath:saml/dummy.txt");
s.getProvider().setCert("classpath:saml/dummy.txt");
s.getSp().setPrivateKey("classpath:saml/dummy.txt");
Resource sp = s.getSp().getCertResource();
Resource idp = s.getProvider().getCertResource();
Resource pk = s.getSp().getPrivateKeyResource();
assertTrue(sp.exists());
assertTrue(idp.exists());
assertTrue(pk.exists());
Path tmp = Files.createTempFile("spdf-key-", ".pem");
Files.writeString(tmp, "KEY");
s.setPrivateKey(tmp.toString());
Resource pkFs = s.getPrivateKey();
s.getSp().setPrivateKey(tmp.toString());
Resource pkFs = s.getSp().getPrivateKeyResource();
assertNotNull(pkFs);
assertTrue(pkFs.exists());
}
@@ -1,10 +1,9 @@
multipart.enabled=true
logging.level.org.springframework=WARN
logging.level.org.springframework.security=WARN
logging.level.org.hibernate=WARN
logging.level.org.eclipse.jetty=WARN
#logging.level.org.springframework.security.oauth2=DEBUG
#logging.level.org.springframework.security=DEBUG
#logging.level.org.springframework.security.oauth2=DEBUG
#logging.level.org.opensaml=DEBUG
#logging.level.stirling.software.proprietary.security=DEBUG
logging.level.com.zaxxer.hikari=WARN
@@ -47,20 +47,23 @@ security:
provider: google # set this to your OAuth Provider's name, e.g., 'google' or 'keycloak'
saml2:
enabled: false # Only enabled for paid enterprise clients (enterpriseEdition.enabled must be true)
provider: "" # The name of your Provider
enableSingleLogout: false # set to 'true' to enable Single Logout (SP-initiated SLO). Logs the user out from the IdP
autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users
blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin
registrationId: stirling # The name of your Service Provider (SP) app name. Should match the name in the path for your SSO & SLO URLs
idpMetadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata # The uri for your Provider's metadata
idpSingleLoginUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml # The URL for initiating SSO. Provided by your Provider
idpSingleLogoutUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml # The URL for initiating SLO. Provided by your Provider
idpIssuer: "" # The ID of your Provider
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
metadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata # RECOMMENDED: Your IdP's metadata URI. When provided, IdP config is auto-discovered.
provider: # IdP manual configuration - use if metadataUri is not available
name: "" # Display name for your IdP (optional)
singleLoginUrl: "" # SSO URL
singleLogoutUrl: "" # SLO URL
entityId: "" # IdP Entity ID
cert: classpath:okta.cert # IdP signing certificate
sp: # Service Provider (your app) credentials for signing SAML requests. Generated from your keypair
privateKey: classpath:saml-private-key.key # Your private key.
cert: classpath:saml-public-cert.crt # Your signing certificate.
# 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.
jwt:
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
+1
View File
@@ -46,6 +46,7 @@ dependencies {
api 'org.springframework.boot:spring-boot-starter-security'
api 'org.springframework.boot:spring-boot-starter-data-jpa'
api 'org.springframework.boot:spring-boot-starter-oauth2-client'
api 'org.springframework.security:spring-security-oauth2-resource-server'
api 'org.springframework.boot:spring-boot-starter-mail'
api 'org.springframework.boot:spring-boot-starter-cache'
api 'com.github.ben-manes.caffeine:caffeine'
@@ -12,6 +12,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -210,7 +211,10 @@ public class ProprietaryUIDataController {
SAML2 saml2 = securityProps.getSaml2();
// Only add SAML2 providers if loginMethod allows it
if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) {
String samlIdp = saml2.getProvider();
String samlIdp =
saml2.getProvider().getEntityId() != null
? saml2.getProvider().getEntityId()
: saml2.getIdpIssuer(); // legacy fallback
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
// For SAML, we need to use the backend URL directly, not a relative path
@@ -395,6 +399,13 @@ public class ProprietaryUIDataController {
if (principal instanceof UserDetails detailsUser) {
username = detailsUser.getUsername();
} else if (principal instanceof Jwt jwt) {
username = jwt.getSubject();
switch (jwt.getClaimAsString("authType")) {
case "OAUTH2" -> isOAuth2Login = true;
case "SAML2" -> isSaml2Login = true;
}
} else if (principal instanceof OAuth2User oAuth2User) {
username = oAuth2User.getName();
isOAuth2Login = true;
@@ -1,53 +1,53 @@
package stirling.software.proprietary.security;
import java.io.IOException;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPrivateKey;
import java.util.ArrayList;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.core.io.Resource;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
import com.coveo.saml.SamlClient;
import com.coveo.saml.SamlException;
import org.springframework.web.client.RestClient;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
import stirling.software.common.model.oauth2.KeycloakProvider;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.UrlUtils;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.saml2.CertificateUtils;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.JwtServiceInterface;
@Slf4j
@RequiredArgsConstructor
public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
public static final String LOGOUT_PATH = "/login?logout=true";
private static final Map<String, String> endSessionEndpointCache = new ConcurrentHashMap<>();
private final ApplicationProperties.Security securityProperties;
private final LogoutSuccessHandler samlLogoutHandler;
private final AppConfig appConfig;
private final JwtServiceInterface jwtService;
public CustomLogoutSuccessHandler(
ApplicationProperties.Security securityProperties,
LogoutSuccessHandler samlLogoutHandler) {
this.securityProperties = securityProperties;
this.samlLogoutHandler = samlLogoutHandler;
}
@Override
@Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC)
@@ -57,30 +57,25 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
if (!response.isCommitted()) {
if (authentication != null) {
if (authentication instanceof Saml2Authentication samlAuthentication) {
// Handle SAML2 logout redirection
getRedirect_saml2(request, response, samlAuthentication);
} else if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
// Handle OAuth2 logout redirection
getRedirect_oauth2(request, response, oAuthToken);
} else if (authentication instanceof UsernamePasswordAuthenticationToken) {
// Handle Username/Password logout
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
} else {
// Handle unknown authentication types
log.error(
"Authentication class unknown: {}",
authentication.getClass().getSimpleName());
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
} else {
if (jwtService != null) {
String token = jwtService.extractToken(request);
if (token != null && !token.isBlank()) {
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
return;
// Extract authType claim and determine logout strategy
String authType;
if (authentication instanceof JwtAuthenticationToken jwtAuthToken) {
authType =
(String)
jwtAuthToken
.getToken()
.getClaims()
.getOrDefault("authType", AuthenticationType.WEB);
log.debug("{} logout detected", authType);
switch (authType) {
case "OAUTH2" -> handleOidcLogout(request, response, jwtAuthToken);
case "SAML2" -> handleSamlLogout(request, response, jwtAuthToken);
default ->
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
}
} else {
// Redirect to login page after logout
String path = checkForErrors(request);
getRedirectStrategy().sendRedirect(request, response, path);
@@ -88,136 +83,303 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
}
}
// Redirect for SAML2 authentication logout
private void getRedirect_saml2(
/** Handles SAML logout - either via IdP Single Logout (SLO) or local logout. */
private void handleSamlLogout(
HttpServletRequest request,
HttpServletResponse response,
Saml2Authentication samlAuthentication)
JwtAuthenticationToken jwtAuthenticationToken)
throws IOException {
// Logout locally if this is a SAMLResponse from to /logout instead of /logout/saml2/slo
String samlResponse = request.getParameter("SAMLResponse");
if (samlResponse != null && !samlResponse.isBlank()) {
if (samlResponse.contains("/saml2/slo")) {
log.info(
"Received SAML LogoutResponse at /logout endpoint, completing logout locally");
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
}
if (securityProperties.getSaml2().getEnableSingleLogout()) {
log.info("SP-initiated SLO detected, logging out via IdP");
SAML2 samlConf = securityProperties.getSaml2();
String registrationId = samlConf.getRegistrationId();
// Reconstruct Saml2Authentication from JWT claims for SLO
Optional<Saml2Authentication> reconstructedAuth =
reconstructSaml2AuthenticationFromJwt(jwtAuthenticationToken);
CustomSaml2AuthenticatedPrincipal principal =
(CustomSaml2AuthenticatedPrincipal) samlAuthentication.getPrincipal();
if (reconstructedAuth.isPresent()) {
Saml2Authentication samlAuth = reconstructedAuth.get();
String nameIdValue = principal.name();
if (samlLogoutHandler != null) {
try {
samlLogoutHandler.onLogoutSuccess(request, response, samlAuth);
return;
} catch (Exception e) {
log.error("SP-initiated SLO failed, falling back to local logout", e);
}
} else {
log.warn(
"SAML SLO enabled but handler not configured, performing local logout only");
}
}
}
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
/**
* Reconstructs a Saml2Authentication from JWT claims for SAML Single Logout. This allows SLO to
* work even with stateless JWT sessions by extracting the SAML attributes that were stored in
* the JWT during initial authentication.
*
* @param jwtAuthenticationToken The JWT authentication token containing SAML claims
* @return Optional containing reconstructed Saml2Authentication, or empty if reconstruction
* fails
*/
private Optional<Saml2Authentication> reconstructSaml2AuthenticationFromJwt(
JwtAuthenticationToken jwtAuthenticationToken) {
try {
// Read certificate from the resource
Resource certificateResource = samlConf.getSpCert();
X509Certificate certificate = CertificateUtils.readCertificate(certificateResource);
Map<String, Object> claims = jwtAuthenticationToken.getToken().getClaims();
List<X509Certificate> certificates = new ArrayList<>();
certificates.add(certificate);
// Extract SAML claims from JWT
String username = (String) claims.get("sub");
String nameId = (String) claims.get("samlNameId");
String registrationId = (String) claims.get("samlRegistrationId");
Object sessionIndexesObj = claims.get("samlSessionIndexes");
// Construct URLs required for SAML configuration
SamlClient samlClient = getSamlClient(registrationId, samlConf, certificates);
if (nameId == null || registrationId == null) {
log.debug(
"Missing required SAML claims for SLO reconstruction: nameId={}, registrationId={}",
nameId,
registrationId);
return Optional.empty();
}
// Read private key for service provider
Resource privateKeyResource = samlConf.getPrivateKey();
RSAPrivateKey privateKey = CertificateUtils.readPrivateKey(privateKeyResource);
List<String> sessionIndexes = Collections.emptyList();
if (sessionIndexesObj instanceof List<?>) {
sessionIndexes =
((List<?>) sessionIndexesObj).stream().map(Object::toString).toList();
}
// Set service provider keys for the SamlClient
samlClient.setSPKeys(certificate, privateKey);
// Create principal with all SAML attributes needed for SLO
CustomSaml2AuthenticatedPrincipal principal =
new CustomSaml2AuthenticatedPrincipal(
username,
Collections.emptyMap(), // Attributes not needed for logout
nameId,
sessionIndexes,
registrationId);
// Build relay state to return user to login page after IdP logout
String relayState =
UrlUtils.getOrigin(request) + request.getContextPath() + LOGOUT_PATH;
// Create Saml2Authentication with the reconstructed principal
// The saml2Response parameter is not used by the logout handler, but constructor
// requires non-empty value, so we provide a placeholder
Saml2Authentication samlAuth =
new Saml2Authentication(
principal,
"<!-- reconstructed for logout -->",
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")));
// Redirect to identity provider for logout with relay state
samlClient.redirectToIdentityProvider(response, relayState, nameIdValue);
} catch (Exception e) {
log.error(
"Error retrieving logout URL from Provider {} for user {}",
samlConf.getProvider(),
nameIdValue,
e);
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
log.debug(
"Reconstructed Saml2Authentication from JWT for user {} with registrationId {}",
username,
registrationId);
return Optional.of(samlAuth);
} catch (Exception ex) {
log.error("Unable to reconstruct Saml2Authentication from JWT", ex);
return Optional.empty();
}
}
// Redirect for OAuth2 authentication logout
private void getRedirect_oauth2(
// Redirect for JWT-based OAuth2 authentication logout
private void handleOidcLogout(
HttpServletRequest request,
HttpServletResponse response,
OAuth2AuthenticationToken oAuthToken)
JwtAuthenticationToken jwtAuthenticationToken)
throws IOException {
String registrationId;
OAUTH2 oauth = securityProperties.getOauth2();
String path = checkForErrors(request);
String redirectUrl = UrlUtils.getOrigin(request) + "/login?" + path;
registrationId = oAuthToken.getAuthorizedClientRegistrationId();
boolean isApi = isApiRequest(request);
// Redirect based on OAuth2 provider
switch (registrationId.toLowerCase(Locale.ROOT)) {
case "keycloak" -> {
String issuer = null;
String clientId = null;
var jwtIssuer = jwtAuthenticationToken.getToken().getIssuer();
if (jwtIssuer != null) {
issuer = jwtIssuer.toString();
log.debug("Using issuer from validated JWT token: {}", issuer);
}
// Fallback: Use configured issuer if JWT doesn't contain one
if (issuer == null) {
if (oauth.getClient() != null && oauth.getClient().getKeycloak() != null) {
KeycloakProvider keycloak = oauth.getClient().getKeycloak();
boolean isKeycloak = !keycloak.getIssuer().isBlank();
boolean isCustomOAuth = !oauth.getIssuer().isBlank();
String logoutUrl = redirectUrl;
if (isKeycloak) {
logoutUrl = keycloak.getIssuer();
} else if (isCustomOAuth) {
logoutUrl = oauth.getIssuer();
}
if (isKeycloak || isCustomOAuth) {
logoutUrl +=
"/protocol/openid-connect/logout"
+ "?client_id="
+ oauth.getClientId()
+ "&post_logout_redirect_uri="
+ response.encodeRedirectURL(redirectUrl);
log.info("Redirecting to Keycloak logout URL: {}", logoutUrl);
} else {
log.info(
"No redirect URL for {} available. Redirecting to default logout URL:"
+ " {}",
registrationId,
logoutUrl);
if (keycloak.getIssuer() != null && !keycloak.getIssuer().isBlank()) {
issuer = keycloak.getIssuer();
}
}
if (issuer == null && oauth.getIssuer() != null && !oauth.getIssuer().isBlank()) {
issuer = oauth.getIssuer();
}
if (issuer != null) {
log.debug("Using issuer from configuration: {}", issuer);
}
}
if (oauth.getClient() != null && oauth.getClient().getKeycloak() != null) {
clientId = oauth.getClient().getKeycloak().getClientId();
}
if (clientId == null && oauth.getClientId() != null) {
clientId = oauth.getClientId();
}
String endSessionEndpoint = getEndSessionEndpoint(oauth, issuer);
if (endSessionEndpoint != null) {
StringBuilder logoutUrlBuilder = new StringBuilder(endSessionEndpoint);
logoutUrlBuilder.append(endSessionEndpoint.contains("?") ? "&" : "?");
// Extract id_token from JWT claims for proper OIDC logout
String idToken = (String) jwtAuthenticationToken.getToken().getClaims().get("id_token");
if (idToken != null && !idToken.isBlank()) {
logoutUrlBuilder.append("id_token_hint=").append(idToken).append("&");
log.debug("Including id_token_hint in OIDC logout for proper SSO logout");
}
// Use client_id and post_logout_redirect_uri
if (clientId != null && !clientId.isBlank()) {
logoutUrlBuilder.append("client_id=").append(clientId).append("&");
}
String encodedRedirectUri = URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8);
logoutUrlBuilder.append("post_logout_redirect_uri=").append(encodedRedirectUri);
String logoutUrl = logoutUrlBuilder.toString();
log.info("JWT-based OAuth2 logout URL: {}", logoutUrl);
// Return JSON for API requests, redirect for browser requests
if (isApi) {
sendJsonLogoutResponse(response, logoutUrl);
} else {
response.sendRedirect(logoutUrl);
}
case "github", "google" -> {
log.info(
"No redirect URL for {} available. Redirecting to default logout URL: {}",
registrationId,
redirectUrl);
response.sendRedirect(redirectUrl);
}
default -> {
log.info("Redirecting to default logout URL: {}", redirectUrl);
} else {
// No OIDC logout endpoint available - fallback to local logout
log.info(
"No OIDC logout endpoint available for issuer: {}. Using local logout: {}",
issuer,
redirectUrl);
if (isApi) {
sendJsonLogoutResponse(response, redirectUrl);
} else {
response.sendRedirect(redirectUrl);
}
}
}
private SamlClient getSamlClient(
String registrationId, SAML2 samlConf, List<X509Certificate> certificates)
throws SamlException {
String serverUrl = appConfig.getBackendUrl() + ":" + appConfig.getServerPort();
/**
* Gets the OIDC end_session_endpoint from: 1. Configuration first 2. Fall back to discovery 3.
* Return null if not available
*
* @param oauth The OAuth2 configuration
* @param issuer The OIDC issuer URL
* @return The end_session_endpoint URL, or null if not available
*/
private String getEndSessionEndpoint(
ApplicationProperties.Security.OAUTH2 oauth, String issuer) {
if (oauth != null && oauth.getClient() != null) {
String configuredEndpoint = oauth.getClient().getEndSessionEndpoint();
String relyingPartyIdentifier =
serverUrl + "/saml2/service-provider-metadata/" + registrationId;
if (configuredEndpoint != null && !configuredEndpoint.isBlank()) {
log.debug("Using configured end_session_endpoint: {}", configuredEndpoint);
return configuredEndpoint;
}
}
String assertionConsumerServiceUrl = serverUrl + "/login/saml2/sso/" + registrationId;
if (issuer != null && !issuer.isBlank()) {
return discoverEndSessionEndpoint(issuer);
}
String idpSLOUrl = samlConf.getIdpSingleLogoutUrl();
return null;
}
String idpIssuer = samlConf.getIdpIssuer();
/**
* Discovers the OIDC end_session_endpoint from the provider's .well-known/openid-configuration
* Uses a cache to avoid repeated HTTP calls
*
* @param issuer The OIDC issuer URL
* @return The end_session_endpoint URL, or null if not found/supported
*/
private String discoverEndSessionEndpoint(String issuer) {
if (endSessionEndpointCache.containsKey(issuer)) {
return endSessionEndpointCache.get(issuer);
}
// Create SamlClient instance for SAML logout
return new SamlClient(
relyingPartyIdentifier,
assertionConsumerServiceUrl,
idpSLOUrl,
idpIssuer,
certificates,
SamlClient.SamlIdpBinding.POST);
try {
String discoveryUrl = issuer;
if (!discoveryUrl.endsWith("/")) {
discoveryUrl += "/";
}
discoveryUrl += ".well-known/openid-configuration";
log.debug("Discovery URL: {}", discoveryUrl);
RestClient restClient =
RestClient.builder()
.baseUrl(discoveryUrl)
.defaultHeaders(headers -> headers.set("Accept", "application/json"))
.build();
// Fetch and parse OIDC discovery document
Map discoveryDoc =
restClient
.get()
.retrieve()
.onStatus(
status -> !status.is2xxSuccessful(),
(request, response) ->
log.warn(
"Failed to discover OIDC endpoints for {}: HTTP status {}",
issuer,
response.getStatusCode().value()))
.body(Map.class);
if (discoveryDoc != null && discoveryDoc.containsKey("end_session_endpoint")) {
String endpoint = (String) discoveryDoc.get("end_session_endpoint");
if (endpoint != null && !endpoint.isBlank()) {
log.info("Discovered end_session_endpoint : {}", endpoint);
endSessionEndpointCache.put(issuer, endpoint);
return endpoint;
}
}
log.info(
"Provider {} does not advertise end_session_endpoint in OIDC discovery",
issuer);
// Cache null result to avoid repeated failed attempts
endSessionEndpointCache.put(issuer, null);
return null;
} catch (Exception e) {
log.warn("Error discovering end_session_endpoint for {}: {}", issuer, e.getMessage());
return null;
}
}
/** Check if the request expects a JSON response (API/XHR request) */
private boolean isApiRequest(HttpServletRequest request) {
String accept = request.getHeader("Accept");
String xRequestedWith = request.getHeader("X-Requested-With");
return (accept != null && accept.contains("application/json"))
|| "XMLHttpRequest".equals(xRequestedWith);
}
/** Send JSON response with logout URL for API requests */
private void sendJsonLogoutResponse(HttpServletResponse response, String logoutUrl)
throws IOException {
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
// Escape the URL for JSON
String escapedUrl = logoutUrl.replace("\\", "\\\\").replace("\"", "\\\"");
response.getWriter().write("{\"logoutUrl\":\"" + escapedUrl + "\"}");
}
/**
@@ -8,11 +8,13 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Lazy;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
@@ -22,8 +24,12 @@ import org.springframework.security.oauth2.client.registration.ClientRegistratio
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml4LogoutRequestResolver;
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2RelyingPartyInitiatedLogoutSuccessHandler;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
@@ -33,7 +39,6 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.CustomAuthenticationFailureHandler;
@@ -56,7 +61,6 @@ import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@Slf4j
@Configuration
@@ -72,12 +76,10 @@ public class SecurityConfiguration {
private final ApplicationProperties applicationProperties;
private final ApplicationProperties.Security securityProperties;
private final AppConfig appConfig;
private final UserAuthenticationFilter userAuthenticationFilter;
private final JwtServiceInterface jwtService;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final LoginAttemptService loginAttemptService;
private final SessionPersistentRegistry sessionRegistry;
private final PersistentLoginRepository persistentLoginRepository;
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations;
@@ -92,14 +94,12 @@ public class SecurityConfiguration {
@Lazy UserService userService,
@Qualifier("loginEnabled") boolean loginEnabledValue,
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
AppConfig appConfig,
ApplicationProperties applicationProperties,
ApplicationProperties.Security securityProperties,
UserAuthenticationFilter userAuthenticationFilter,
JwtServiceInterface jwtService,
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
LoginAttemptService loginAttemptService,
SessionPersistentRegistry sessionRegistry,
@Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper,
@Autowired(required = false)
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
@@ -112,14 +112,12 @@ public class SecurityConfiguration {
this.userService = userService;
this.loginEnabledValue = loginEnabledValue;
this.runningProOrHigher = runningProOrHigher;
this.appConfig = appConfig;
this.applicationProperties = applicationProperties;
this.securityProperties = securityProperties;
this.userAuthenticationFilter = userAuthenticationFilter;
this.jwtService = jwtService;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.loginAttemptService = loginAttemptService;
this.sessionRegistry = sessionRegistry;
this.persistentLoginRepository = persistentLoginRepository;
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
@@ -196,17 +194,17 @@ public class SecurityConfiguration {
http.cors(cors -> cors.configurationSource(corsSource));
} else {
// Explicitly disable CORS when no origins are configured
http.cors(cors -> cors.disable());
http.cors(AbstractHttpConfigurer::disable);
}
http.csrf(CsrfConfigurer::disable);
if (loginEnabledValue) {
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
http.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, LogoutFilter.class)
.addFilterAfter(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
http.sessionManagement(
sessionManagement ->
@@ -226,17 +224,36 @@ public class SecurityConfiguration {
return requestURI.startsWith(contextPath + "/api/");
}));
// Create SAML logout handler if SAML SLO is enabled
final LogoutSuccessHandler samlLogoutHandler;
if (securityProperties.isSaml2Active()
&& Boolean.TRUE.equals(securityProperties.getSaml2().getEnableSingleLogout())
&& saml2RelyingPartyRegistrations != null) {
log.info("Creating SAML2 SLO handler for SP-initiated logout");
OpenSaml4LogoutRequestResolver logoutRequestResolver =
new OpenSaml4LogoutRequestResolver(saml2RelyingPartyRegistrations);
samlLogoutHandler =
new Saml2RelyingPartyInitiatedLogoutSuccessHandler(logoutRequestResolver);
} else {
samlLogoutHandler = null;
}
http.logout(
logout ->
// Require POST to prevent logout CSRF attacks
logout.logoutRequestMatcher(
PathPatternRequestMatcher.withDefaults()
.matcher("/logout"))
.matcher(HttpMethod.POST, "/logout"))
.logoutSuccessHandler(
new CustomLogoutSuccessHandler(
securityProperties, appConfig, jwtService))
securityProperties, samlLogoutHandler))
.clearAuthentication(true)
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID", "remember-me", "stirling_jwt"));
.deleteCookies(
"JSESSIONID",
"remember-me",
"stirling_jwt",
"stirling_logout_token"));
http.rememberMe(
rememberMeConfigurer -> // Use the configurator directly
rememberMeConfigurer
@@ -360,9 +377,18 @@ public class SecurityConfiguration {
}
})
.saml2Metadata(metadata -> {});
// Configure SAML2 Single Logout if enabled
// This sets up endpoints for:
// - IdP-initiated logout: IdP sends LogoutRequest to /logout/saml2/slo
// - SP-initiated logout response: IdP sends LogoutResponse to /logout/saml2/slo
if (Boolean.TRUE.equals(securityProperties.getSaml2().getEnableSingleLogout())) {
log.debug("SAML2 Single Logout (SLO) is enabled");
http.saml2Logout(logout -> logout.logoutUrl("/logout/saml2/slo"));
}
}
} else {
log.debug("Login is not enabled.");
log.info("Login is not enabled.");
http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll());
}
return http.build();
@@ -31,6 +31,7 @@ import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.MfaCodeRequest;
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
@@ -219,11 +220,30 @@ public class AuthController {
.body(Map.of("error", "Not authenticated"));
}
UserDetails userDetails = (UserDetails) auth.getPrincipal();
User user = (User) userDetails;
Object principal = auth.getPrincipal();
User user;
if (principal instanceof User u) {
user = u;
} else {
// JWT case - get User from Authority
user =
auth.getAuthorities().stream()
.filter(Authority.class::isInstance)
.map(Authority.class::cast)
.findFirst()
.map(Authority::getUser)
.orElseThrow(
() ->
new IllegalStateException(
"User not found in authentication"));
}
return ResponseEntity.ok(Map.of("user", buildUserResponse(user)));
} catch (IllegalStateException e) {
log.error("User not found in authentication context", e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "User not found"));
} catch (Exception e) {
log.error("Get current user error", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
@@ -231,29 +251,6 @@ public class AuthController {
}
}
/**
* Logout endpoint
*
* @param response HTTP response
* @return Success message
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletResponse response) {
try {
SecurityContextHolder.clearContext();
log.debug("User logged out successfully");
return ResponseEntity.ok(Map.of("message", "Logged out successfully"));
} catch (Exception e) {
log.error("Logout error", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Internal server error"));
}
}
/**
* Refresh token
*
@@ -507,6 +504,7 @@ public class AuthController {
*/
private Map<String, Object> buildUserResponse(User user) {
Map<String, Object> userMap = new HashMap<>();
userMap.put("id", user.getId());
userMap.put("email", user.getUsername()); // Use username as email
userMap.put("username", user.getUsername());
@@ -8,25 +8,28 @@ import static stirling.software.proprietary.security.model.AuthenticationType.WE
import java.io.IOException;
import java.sql.SQLException;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import io.jsonwebtoken.Jwts;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@@ -40,7 +43,6 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.UserService;
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtServiceInterface jwtService;
@@ -49,6 +51,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final AuthenticationEntryPoint authenticationEntryPoint;
private final ApplicationProperties.Security securityProperties;
public JwtAuthenticationFilter(
JwtServiceInterface jwtService,
UserService userService,
CustomUserDetailsService userDetailsService,
AuthenticationEntryPoint authenticationEntryPoint,
ApplicationProperties.Security securityProperties) {
this.jwtService = jwtService;
this.userService = userService;
this.userDetailsService = userDetailsService;
this.authenticationEntryPoint = authenticationEntryPoint;
this.securityProperties = securityProperties;
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
@@ -61,6 +76,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
if (isPublicAuthEndpoint(requestURI, contextPath)) {
filterChain.doFilter(request, response);
return;
}
if (isStaticResource(contextPath, requestURI)) {
filterChain.doFilter(request, response);
return;
@@ -106,7 +126,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String tokenUsername = claims.get("sub").toString();
try {
authenticate(request, claims);
authenticate(request, jwtToken, claims);
} catch (SQLException | UnsupportedProviderException e) {
log.error("Error processing user authentication for user: {}", tokenUsername, e);
handleAuthenticationFailure(
@@ -160,7 +180,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
return true;
}
private void authenticate(HttpServletRequest request, Map<String, Object> claims)
private void authenticate(
HttpServletRequest request, String jwtToken, Map<String, Object> claims)
throws SQLException, UnsupportedProviderException {
String username = claims.get("sub").toString();
@@ -169,12 +190,25 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (userDetails != null) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
convertTimestampToLong(claims, "iat");
convertTimestampToLong(claims, "exp");
convertTimestampToLong(claims, "nbf");
Jwt jwt =
Jwt.withTokenValue(jwtToken)
.headers(headers -> headers.put("alg", Jwts.SIG.RS256.getId()))
.claims(claimsMap -> claimsMap.putAll(claims))
.build();
JwtAuthenticationToken authToken =
new JwtAuthenticationToken(jwt, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
log.debug("Setting authentication for user: {}", username);
SecurityContextHolder.getContext().setAuthentication(authToken);
log.debug(
"Authentication set successfully: {}",
SecurityContextHolder.getContext().getAuthentication());
} else {
throw new UsernameNotFoundException("User not found: " + username);
}
@@ -208,6 +242,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
}
}
private void convertTimestampToLong(Map<String, Object> claims, String claimName) {
Long timestamp = (Long) claims.get(claimName);
claims.put(claimName, Instant.ofEpochSecond(timestamp));
}
private void handleAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,
@@ -0,0 +1,64 @@
package stirling.software.proprietary.security.filter;
import java.io.IOException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
/**
* Filter that redirects browser requests to /login to the frontend URL when configured. This is
* needed for development mode where frontend runs on a different port, and for SAML logout which
* redirects to /login?logout after SLO completes.
*/
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
@RequiredArgsConstructor
public class LoginRedirectFilter extends OncePerRequestFilter {
private final ApplicationProperties applicationProperties;
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String path = request.getRequestURI();
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
// Only process /login requests when frontendUrl is configured
if (path.equals("/login") && frontendUrl != null && !frontendUrl.isBlank()) {
// Check if this is a browser request (Accept: text/html) vs API request
// (Accept: application/json)
String acceptHeader = request.getHeader("Accept");
boolean isBrowserRequest =
acceptHeader != null
&& acceptHeader.contains("text/html")
&& !acceptHeader.contains("application/json");
if (isBrowserRequest) {
// Preserve query parameters (e.g., ?logout=true, ?error=xxx)
String queryString = request.getQueryString();
String redirectUrl =
frontendUrl + "/login" + (queryString != null ? "?" + queryString : "");
log.debug("Redirecting browser request to frontend: {}", redirectUrl);
response.sendRedirect(redirectUrl);
return;
}
}
filterChain.doFilter(request, response);
}
}
@@ -4,6 +4,7 @@ import static stirling.software.common.util.RequestUriUtils.isPublicAuthEndpoint
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -32,6 +33,7 @@ import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@@ -43,16 +45,19 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
private final UserService userService;
private final SessionPersistentRegistry sessionPersistentRegistry;
private final boolean loginEnabledValue;
private final JwtServiceInterface jwtService;
public UserAuthenticationFilter(
@Lazy ApplicationProperties.Security securityProp,
@Lazy UserService userService,
SessionPersistentRegistry sessionPersistentRegistry,
@Qualifier("loginEnabled") boolean loginEnabledValue) {
@Qualifier("loginEnabled") boolean loginEnabledValue,
@Lazy JwtServiceInterface jwtService) {
this.securityProp = securityProp;
this.userService = userService;
this.sessionPersistentRegistry = sessionPersistentRegistry;
this.loginEnabledValue = loginEnabledValue;
this.jwtService = jwtService;
}
@Override
@@ -135,7 +140,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
}
// Check if the authenticated user is disabled and invalidate their session if so
if (authentication != null && authentication.isAuthenticated()) {
if (authentication.isAuthenticated()) {
UserLoginType loginMethod = UserLoginType.UNKNOWN;
@@ -146,7 +151,14 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
String username = null;
if (principal instanceof UserDetails detailsUser) {
username = detailsUser.getUsername();
loginMethod = UserLoginType.USERDETAILS;
loginMethod = resolveLoginTypeFromJwt(request);
if (loginMethod == UserLoginType.SAML2USER) {
SAML2 saml2 = securityProp.getSaml2();
blockRegistration = saml2 != null && saml2.getBlockRegistration();
} else if (loginMethod == UserLoginType.OAUTH2USER) {
OAUTH2 oAuth = securityProp.getOauth2();
blockRegistration = oAuth != null && oAuth.getBlockRegistration();
}
} else if (principal instanceof OAuth2User oAuth2User) {
username = oAuth2User.getName();
loginMethod = UserLoginType.OAUTH2USER;
@@ -241,6 +253,30 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
}
private UserLoginType resolveLoginTypeFromJwt(HttpServletRequest request) {
if (jwtService == null) {
return UserLoginType.USERDETAILS;
}
try {
String token = jwtService.extractToken(request);
if (token != null && !token.isBlank()) {
Map<String, Object> claims = jwtService.extractClaims(token);
Object authType = claims.get("authType");
if (authType != null) {
String type = authType.toString().toUpperCase();
if ("SAML2".equals(type)) {
return UserLoginType.SAML2USER;
} else if ("OAUTH2".equals(type)) {
return UserLoginType.OAUTH2USER;
}
}
}
} catch (Exception e) {
log.debug("Unable to resolve login type from JWT claims", e);
}
return UserLoginType.USERDETAILS;
}
private enum UserLoginType {
USERDETAILS("UserDetails"),
OAUTH2USER("OAuth2User"),
@@ -14,6 +14,7 @@ import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.SavedRequest;
@@ -33,6 +34,7 @@ import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
@@ -68,8 +70,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
// Check if user is eligible for OAuth (grandfathered or system has paid license)
if (userExists) {
stirling.software.proprietary.security.model.User user =
userService.findByUsernameIgnoreCase(username).orElse(null);
User user = userService.findByUsernameIgnoreCase(username).orElse(null);
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
// User is not grandfathered and no paid license - block OAuth login
@@ -148,11 +149,21 @@ public class CustomOAuth2AuthenticationSuccessHandler
OAUTH2);
}
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication, Map.of("authType", AuthenticationType.OAUTH2));
// Build JWT claims with authType and optionally id_token for OIDC logout
Map<String, Object> claims = new java.util.HashMap<>();
claims.put("authType", AuthenticationType.OAUTH2);
// Store OIDC id_token for proper SSO logout
if (principal instanceof OidcUser oidcUser) {
String idToken = oidcUser.getIdToken().getTokenValue();
if (idToken != null && !idToken.isBlank()) {
claims.put("id_token", idToken);
log.debug("Stored OIDC id_token in JWT claims for SSO logout");
}
}
String jwt = jwtService.generateToken(authentication, claims);
// Build context-aware redirect URL based on the original request
String redirectUrl =
@@ -53,9 +53,6 @@ public class OAuth2Configuration {
ApplicationProperties applicationProperties, @Lazy UserService userService) {
this.userService = userService;
this.applicationProperties = applicationProperties;
log.info(
"OAuth2Configuration initialized - OAuth2 enabled: {}",
applicationProperties.getSecurity().getOauth2().getEnabled());
}
@Bean
@@ -222,8 +219,6 @@ public class OAuth2Configuration {
name,
oauth.getIssuer(),
REDIRECT_URI_PATH + name);
} else {
log.warn("OIDC OAuth2 provider validation failed - provider will not be registered");
}
return isValid
@@ -12,9 +12,22 @@ public record CustomSaml2AuthenticatedPrincipal(
String name,
Map<String, List<Object>> attributes,
String nameId,
List<String> sessionIndexes)
List<String> sessionIndexes,
String relyingPartyRegistrationId)
implements Saml2AuthenticatedPrincipal, Serializable {
/**
* Constructor without relyingPartyRegistrationId for backwards compatibility. Sets
* relyingPartyRegistrationId to null.
*/
public CustomSaml2AuthenticatedPrincipal(
String name,
Map<String, List<Object>> attributes,
String nameId,
List<String> sessionIndexes) {
this(name, attributes, nameId, sessionIndexes, null);
}
@Override
public String getName() {
return this.name;
@@ -24,4 +37,14 @@ public record CustomSaml2AuthenticatedPrincipal(
public Map<String, List<Object>> getAttributes() {
return this.attributes;
}
@Override
public List<String> getSessionIndexes() {
return this.sessionIndexes;
}
@Override
public String getRelyingPartyRegistrationId() {
return this.relyingPartyRegistrationId;
}
}
@@ -7,6 +7,8 @@ import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -33,6 +35,7 @@ import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
@@ -70,8 +73,7 @@ public class CustomSaml2AuthenticationSuccessHandler
// Check if user is eligible for SAML (grandfathered or system has ENTERPRISE license)
if (userExists) {
stirling.software.proprietary.security.model.User user =
userService.findByUsernameIgnoreCase(username).orElse(null);
User user = userService.findByUsernameIgnoreCase(username).orElse(null);
if (user != null && !licenseSettingsService.isSamlEligible(user)) {
// User is not grandfathered and no ENTERPRISE license - block SAML login
@@ -172,7 +174,11 @@ public class CustomSaml2AuthenticationSuccessHandler
// Extract SSO provider information from SAML2 assertion
String ssoProviderId = saml2Principal.nameId();
String ssoProvider = "saml2"; // fixme
String ssoProvider =
(saml2Properties.getIdpIssuer() != null
&& !saml2Properties.getIdpIssuer().isBlank())
? saml2Properties.getIdpIssuer()
: saml2Properties.getRegistrationId();
log.debug(
"Processing SSO post-login for user: {} (Provider: {}, ProviderId: {})",
@@ -188,12 +194,21 @@ public class CustomSaml2AuthenticationSuccessHandler
SAML2);
log.debug("Successfully processed authentication for user: {}", username);
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication,
Map.of("authType", AuthenticationType.SAML2));
Map<String, Object> claims = new HashMap<>();
claims.put("authType", AuthenticationType.SAML2);
claims.put("samlNameId", saml2Principal.nameId());
List<String> sessionIndexes = saml2Principal.sessionIndexes();
if (sessionIndexes != null && !sessionIndexes.isEmpty()) {
claims.put("samlSessionIndexes", sessionIndexes);
}
if (ssoProvider != null) {
claims.put("samlProvider", ssoProvider);
}
if (saml2Properties.getRegistrationId() != null) {
claims.put("samlRegistrationId", saml2Properties.getRegistrationId());
}
String jwt = jwtService.generateToken(authentication, claims);
// Build context-aware redirect URL based on the original request
String redirectUrl =
@@ -94,7 +94,7 @@ public class JwtSaml2AuthenticationRequestRepository
if (token != null) {
tokenStore.remove(authnRequestId);
log.debug("Retrieved SAMLRequest token for RelayState ID: {}", authnRequestId);
log.info("Retrieved SAMLRequest token for RelayState ID: {}", authnRequestId);
return token;
} else {
log.warn("No SAMLRequest token found for RelayState ID: {}", authnRequestId);
@@ -1,9 +1,22 @@
package stirling.software.proprietary.security.saml2;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Base64;
import java.util.Collections;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import org.opensaml.saml.saml2.core.AuthnRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -19,6 +32,10 @@ import org.springframework.security.saml2.provider.service.registration.RelyingP
import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository;
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import jakarta.servlet.http.HttpServletRequest;
@@ -27,6 +44,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.service.JwtServiceInterface;
@Configuration
@@ -35,45 +53,34 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
@RequiredArgsConstructor
public class Saml2Configuration {
private static final String SAML_METADATA_NS = "urn:oasis:names:tc:SAML:2.0:metadata";
private static final String XML_DSIG_NS = "http://www.w3.org/2000/09/xmldsig#";
private final ApplicationProperties applicationProperties;
@Bean
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception {
public RelyingPartyRegistrationRepository relyingPartyRegistrations() {
SAML2 samlConf = applicationProperties.getSecurity().getSaml2();
Optional<IdpMetadataInfo> metadataInfo = loadIdpMetadata(samlConf);
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);
}
// Load IdP certificate either from metadata or fallback resource
X509Certificate idpCert =
metadataInfo
.map(IdpMetadataInfo::signingCertificate)
.orElseGet(() -> loadIdpCertificateFromResource(samlConf));
Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert);
// Load SP private key and certificate
Resource privateKeyResource = samlConf.getPrivateKey();
Resource certificateResource = samlConf.getSpCert();
Resource privateKeyResource = samlConf.getSp().getPrivateKeyResource();
Resource certificateResource = samlConf.getSp().getCertResource();
log.info("Loading SP private key from: {}", privateKeyResource.getDescription());
log.debug("Loading SP private key from: {}", privateKeyResource.getDescription());
if (!privateKeyResource.exists()) {
log.error("SAML2 SP private key not found at: {}", privateKeyResource.getDescription());
throw new IllegalStateException(
@@ -81,7 +88,7 @@ public class Saml2Configuration {
+ privateKeyResource.getDescription());
}
log.info("Loading SP certificate from: {}", certificateResource.getDescription());
log.debug("Loading SP certificate from: {}", certificateResource.getDescription());
if (!certificateResource.exists()) {
log.error(
"SAML2 SP certificate not found at: {}", certificateResource.getDescription());
@@ -102,6 +109,43 @@ public class Saml2Configuration {
log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e);
throw new IllegalStateException("Failed to load SAML2 SP credentials", e);
}
// Apply metadata overrides - metadata takes precedence over manual config
metadataInfo.ifPresent(info -> applyMetadataOverrides(samlConf, info));
// Get IdP configuration - prefer values from metadata, fall back to manual config
String idpEntityId =
metadataInfo
.map(IdpMetadataInfo::entityId)
.filter(id -> id != null && !id.isBlank())
.orElseGet(() -> samlConf.getProvider().getEntityId());
String idpSingleLoginUrl =
metadataInfo
.map(IdpMetadataInfo::singleSignOnServiceUrl)
.filter(url -> url != null && !url.isBlank())
.orElseGet(() -> samlConf.getProvider().getSingleLoginUrl());
String idpSingleLogoutUrl =
metadataInfo
.map(IdpMetadataInfo::singleLogoutServiceUrl)
.filter(url -> url != null && !url.isBlank())
.orElseGet(() -> samlConf.getProvider().getSingleLogoutUrl());
// Validate required IdP configuration
if (idpEntityId == null || idpEntityId.isBlank()) {
throw new IllegalStateException(
"SAML2 IdP Entity ID is required. Set security.saml2.entityId or provide security.saml2.metadataUri");
}
if (idpSingleLoginUrl == null || idpSingleLoginUrl.isBlank()) {
throw new IllegalStateException(
"SAML2 IdP Single Sign-On URL is required. Set security.saml2.provider.singleLoginUrl or provide security.saml2.metadataUri");
}
log.info(
"SAML2 IdP configuration: entityId={}, ssoUrl={}, sloUrl={}",
idpEntityId,
idpSingleLoginUrl,
idpSingleLogoutUrl);
// Get backend URL from configuration (for SAML endpoints)
String backendUrl = applicationProperties.getSystem().getBackendUrl();
@@ -116,40 +160,38 @@ public class Saml2Configuration {
String entityId =
backendUrl + "/saml2/service-provider-metadata/" + samlConf.getRegistrationId();
String acsLocation = backendUrl + "/login/saml2/sso/{registrationId}";
String sloResponseLocation = backendUrl + "/login";
// SP's Single Logout Service endpoint (where SP receives logout requests/responses from
// IdP)
String spSloLocation = backendUrl + "/logout/saml2/slo";
RelyingPartyRegistration rp =
RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId())
.signingX509Credentials(c -> c.add(signingCredential))
.entityId(entityId)
.singleLogoutServiceBinding(Saml2MessageBinding.POST)
.singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceResponseLocation(sloResponseLocation)
.singleLogoutServiceLocation(spSloLocation)
.singleLogoutServiceResponseLocation(spSloLocation)
.assertionConsumerServiceBinding(Saml2MessageBinding.POST)
.assertionConsumerServiceLocation(acsLocation)
.authnRequestsSigned(true)
.assertingPartyMetadata(
metadata ->
metadata.entityId(samlConf.getIdpIssuer())
metadata.entityId(idpEntityId)
.verificationX509Credentials(
c -> c.add(verificationCredential))
.singleSignOnServiceBinding(
Saml2MessageBinding.POST)
.singleSignOnServiceLocation(
samlConf.getIdpSingleLoginUrl())
.singleSignOnServiceLocation(idpSingleLoginUrl)
.singleLogoutServiceBinding(
Saml2MessageBinding.POST)
.singleLogoutServiceLocation(
samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceResponseLocation(
sloResponseLocation)
.singleLogoutServiceLocation(idpSingleLogoutUrl)
.wantAuthnRequestsSigned(true))
.build();
log.info(
"SAML2 configuration initialized successfully. Registration ID: {}, IdP: {}",
samlConf.getRegistrationId(),
samlConf.getIdpIssuer());
idpEntityId);
return new InMemoryRelyingPartyRegistrationRepository(rp);
}
@@ -206,6 +248,212 @@ public class Saml2Configuration {
return resolver;
}
private X509Certificate loadIdpCertificateFromResource(SAML2 samlConf) {
try {
Resource idpCertResource = samlConf.getProvider().getCertResource();
if (idpCertResource == null) {
throw new IllegalStateException("SAML2 IdP certificate resource is not defined");
}
log.info("Loading IdP certificate from: {}", idpCertResource.getDescription());
if (!idpCertResource.exists()) {
throw new IllegalStateException(
"SAML2 IdP certificate file does not exist: "
+ idpCertResource.getDescription());
}
X509Certificate certificate = CertificateUtils.readCertificate(idpCertResource);
log.info(
"Successfully loaded IdP certificate. Subject: {}",
certificate.getSubjectX500Principal().getName());
return certificate;
} catch (Exception e) {
log.error("Failed to load SAML2 IdP certificate: {}", e.getMessage(), e);
throw new IllegalStateException("Failed to load SAML2 IdP certificate", e);
}
}
private void applyMetadataOverrides(SAML2 samlConf, IdpMetadataInfo metadataInfo) {
log.info(
"Applying IdP metadata overrides for registration: {}",
samlConf.getRegistrationId());
SAML2.Provider provider = samlConf.getProvider();
overrideIfPresent(metadataInfo.entityId(), provider::setEntityId);
overrideIfPresent(metadataInfo.singleSignOnServiceUrl(), provider::setSingleLoginUrl);
overrideIfPresent(metadataInfo.singleLogoutServiceUrl(), provider::setSingleLogoutUrl);
// Persist discovered metadata values to settings.yml
persistMetadataToSettings(metadataInfo);
}
/**
* Persists IdP metadata discovered values to settings.yml. This ensures the discovered
* configuration is saved for future reference and survives restarts even if the metadata
* endpoint becomes unavailable.
*/
private void persistMetadataToSettings(IdpMetadataInfo metadataInfo) {
log.info(
"Migrating discovered IdP metadata to SAML configuration. Existing configuration will be overridden.");
try {
boolean anyPersisted = false;
if (hasText(metadataInfo.entityId())) {
GeneralUtils.saveKeyToSettings(
"security.saml2.provider.entityId", metadataInfo.entityId());
log.info(" -> Persisted provider.entityId: {}", metadataInfo.entityId());
anyPersisted = true;
}
if (hasText(metadataInfo.singleSignOnServiceUrl())) {
GeneralUtils.saveKeyToSettings(
"security.saml2.provider.singleLoginUrl",
metadataInfo.singleSignOnServiceUrl());
log.info(
" -> Persisted provider.singleLoginUrl: {}",
metadataInfo.singleSignOnServiceUrl());
anyPersisted = true;
}
if (hasText(metadataInfo.singleLogoutServiceUrl())) {
GeneralUtils.saveKeyToSettings(
"security.saml2.provider.singleLogoutUrl",
metadataInfo.singleLogoutServiceUrl());
log.info(
" -> Persisted provider.singleLogoutUrl: {}",
metadataInfo.singleLogoutServiceUrl());
anyPersisted = true;
}
if (anyPersisted) {
log.info(
"IdP metadata successfully persisted to settings.yml. These values will be used as fallback if metadataUri becomes unavailable.");
}
} catch (Exception e) {
log.warn(
"Failed to persist IdP metadata to settings.yml: {}. SAML will still work but discovered values won't be saved.",
e.getMessage());
}
}
private Optional<IdpMetadataInfo> loadIdpMetadata(SAML2 samlConf) {
String metadataLocation = samlConf.getEffectiveMetadataUri();
if (metadataLocation == null || metadataLocation.isBlank()) {
return Optional.empty();
}
try (InputStream metadataStream = samlConf.getMetadataUriAsStream()) {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
// XXE prevention - disable all external entities and DTD processing
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature(
"http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(metadataStream);
Element entityDescriptor = doc.getDocumentElement();
if (entityDescriptor == null) {
log.warn("No EntityDescriptor found in SAML metadata: {}", metadataLocation);
return Optional.empty();
}
String entityId = entityDescriptor.getAttribute("entityID");
NodeList idpDescriptors =
entityDescriptor.getElementsByTagNameNS(SAML_METADATA_NS, "IDPSSODescriptor");
if (idpDescriptors.getLength() == 0) {
log.warn("No IDPSSODescriptor found in SAML metadata: {}", metadataLocation);
return Optional.empty();
}
Element idpDescriptor = (Element) idpDescriptors.item(0);
String ssoUrl = extractServiceLocation(idpDescriptor, "SingleSignOnService");
String sloUrl = extractServiceLocation(idpDescriptor, "SingleLogoutService");
X509Certificate signingCert = extractSigningCertificate(idpDescriptor);
log.info("Loaded IdP metadata from: {}", metadataLocation);
return Optional.of(new IdpMetadataInfo(entityId, ssoUrl, sloUrl, signingCert));
} catch (IOException
| ParserConfigurationException
| SAXException
| CertificateException e) {
log.warn("Failed to parse SAML metadata from {}: {}", metadataLocation, e.getMessage());
return Optional.empty();
}
}
private String extractServiceLocation(Element descriptor, String tagName) {
NodeList services = descriptor.getElementsByTagNameNS(SAML_METADATA_NS, tagName);
String fallback = null;
for (int i = 0; i < services.getLength(); i++) {
Element service = (Element) services.item(i);
String location = service.getAttribute("Location");
String binding = service.getAttribute("Binding");
if (!hasText(location)) {
continue;
}
if (Saml2MessageBinding.POST.getUrn().equals(binding)) {
return location;
}
if (fallback == null) {
fallback = location;
}
}
return fallback;
}
private X509Certificate extractSigningCertificate(Element descriptor)
throws CertificateException {
NodeList keyDescriptors =
descriptor.getElementsByTagNameNS(SAML_METADATA_NS, "KeyDescriptor");
for (int i = 0; i < keyDescriptors.getLength(); i++) {
Element keyDescriptor = (Element) keyDescriptors.item(i);
String use = keyDescriptor.getAttribute("use");
if (hasText(use) && !"signing".equalsIgnoreCase(use)) {
continue;
}
NodeList certificateNodes =
keyDescriptor.getElementsByTagNameNS(XML_DSIG_NS, "X509Certificate");
if (certificateNodes.getLength() == 0) {
continue;
}
String certificateValue = certificateNodes.item(0).getTextContent();
if (!hasText(certificateValue)) {
continue;
}
byte[] decoded =
Base64.getMimeDecoder().decode(certificateValue.replaceAll("\\s+", ""));
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
return (X509Certificate)
certificateFactory.generateCertificate(new ByteArrayInputStream(decoded));
}
return null;
}
private boolean hasText(String value) {
return value != null && !value.trim().isEmpty();
}
private void overrideIfPresent(String value, Consumer<String> setter) {
if (hasText(value)) {
setter.accept(value.trim());
}
}
private record IdpMetadataInfo(
String entityId,
String singleSignOnServiceUrl,
String singleLogoutServiceUrl,
X509Certificate signingCertificate) {}
private static void logAuthnRequestDetails(AuthnRequest authnRequest) {
String message =
"""
@@ -79,13 +79,14 @@ public class JwtService implements JwtServiceInterface {
}
KeyPair keyPair = keyPairOpt.get();
Date now = new Date();
var builder =
Jwts.builder()
.claims(claims)
.subject(username)
.issuer(ISSUER)
.issuedAt(new Date())
.issuedAt(now)
.notBefore(now)
.expiration(new Date(System.currentTimeMillis() + EXPIRATION))
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
@@ -251,13 +252,30 @@ public class JwtService implements JwtServiceInterface {
@Override
public String extractToken(HttpServletRequest request) {
// Extract from Authorization header Bearer token
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7); // Remove "Bearer " prefix
return token;
}
// Check for logout cookie (set by frontend before redirecting to /logout for SAML SLO)
if (request.getCookies() != null) {
for (jakarta.servlet.http.Cookie cookie : request.getCookies()) {
if ("stirling_logout_token".equals(cookie.getName())) {
String value = cookie.getValue();
if (value != null && !value.isBlank()) {
try {
return java.net.URLDecoder.decode(
value, java.nio.charset.StandardCharsets.UTF_8);
} catch (Exception e) {
log.debug("Failed to decode logout token cookie", e);
return value;
}
}
}
}
}
return null;
}
@@ -1,44 +1,70 @@
package stirling.software.proprietary.security;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.net.URL;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClient.RequestHeadersUriSpec;
import org.springframework.web.client.RestClient.ResponseSpec;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.service.JwtServiceInterface;
/**
* Tests for CustomLogoutSuccessHandler.
*
* <p>The handler uses JWT-based authentication to determine logout strategy: - OAUTH2 authType:
* Redirects to OIDC provider's end_session_endpoint - SAML2 authType: Delegates to SAML logout
* handler - Other/null: Local logout redirect
*/
@ExtendWith(MockitoExtension.class)
class CustomLogoutSuccessHandlerTest {
@Mock private ApplicationProperties.Security securityProperties;
@Mock private JwtServiceInterface jwtService;
@Mock private ApplicationProperties.Security.SAML2 saml2;
@InjectMocks private CustomLogoutSuccessHandler customLogoutSuccessHandler;
@Mock private LogoutSuccessHandler logoutSuccessHandler;
private CustomLogoutSuccessHandler customLogoutSuccessHandler;
@BeforeEach
void setUp() {
customLogoutSuccessHandler =
new CustomLogoutSuccessHandler(securityProperties, logoutSuccessHandler);
}
@Test
void testSuccessfulLogout() throws IOException {
void testSuccessfulLogout_NullAuthentication() throws IOException {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
String token = "token";
String logoutPath = "/login?logout=true";
when(response.isCommitted()).thenReturn(false);
when(jwtService.extractToken(request)).thenReturn(token);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
when(response.encodeRedirectURL(anyString())).thenReturn(logoutPath);
customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
@@ -46,29 +72,40 @@ class CustomLogoutSuccessHandlerTest {
}
@Test
void testSuccessfulLogoutViaJWT() throws IOException {
void testJwtLogout_WebAuthType_RedirectsToLocalLogout() throws IOException {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
Jwt jwt = mock(Jwt.class);
String logoutPath = "/login?logout=true";
String token = "token";
when(response.isCommitted()).thenReturn(false);
when(jwtService.extractToken(request)).thenReturn(token);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
when(response.encodeRedirectURL(anyString())).thenReturn(logoutPath);
customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
when(jwtAuth.getToken()).thenReturn(jwt);
when(jwt.getClaims()).thenReturn(Map.of("authType", "WEB"));
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
verify(response).sendRedirect(logoutPath);
}
@Test
void testSuccessfulLogoutViaOAuth2() throws IOException {
void testJwtLogout_OAuth2AuthType_WithConfiguredEndpoint_RedirectsToProvider()
throws IOException {
String issuerUrl = "https://keycloak.example.com/realms/test";
String clientId = "stirling-pdf";
String endSessionEndpoint = issuerUrl + "/protocol/openid-connect/logout";
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
OAuth2AuthenticationToken oAuth2AuthenticationToken = mock(OAuth2AuthenticationToken.class);
JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
Jwt jwt = mock(Jwt.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
ApplicationProperties.Security.OAUTH2.Client client =
mock(ApplicationProperties.Security.OAUTH2.Client.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
@@ -77,165 +114,286 @@ class CustomLogoutSuccessHandlerTest {
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(securityProperties.getOauth2()).thenReturn(oauth);
when(oAuth2AuthenticationToken.getAuthorizedClientRegistrationId()).thenReturn("test");
when(request.getHeader("Accept")).thenReturn("text/html");
when(request.getHeader("X-Requested-With")).thenReturn(null);
customLogoutSuccessHandler.onLogoutSuccess(request, response, oAuth2AuthenticationToken);
when(jwtAuth.getToken()).thenReturn(jwt);
when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
when(jwt.getIssuer()).thenReturn(new URL(issuerUrl));
when(securityProperties.getOauth2()).thenReturn(oauth);
when(oauth.getClient()).thenReturn(client);
when(client.getEndSessionEndpoint()).thenReturn(endSessionEndpoint);
when(oauth.getClientId()).thenReturn(clientId);
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
verify(response).sendRedirect(contains(endSessionEndpoint));
verify(response).sendRedirect(contains("client_id=" + clientId));
verify(response).sendRedirect(contains("post_logout_redirect_uri="));
}
@Test
void testJwtLogout_OAuth2AuthType_WithIdToken_IncludesIdTokenHint() throws IOException {
String issuerUrl = "https://keycloak.example.com/realms/test";
String clientId = "stirling-pdf";
String idTokenValue = "test.id.token";
String endSessionEndpoint = issuerUrl + "/protocol/openid-connect/logout";
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
Jwt jwt = mock(Jwt.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
ApplicationProperties.Security.OAUTH2.Client client =
mock(ApplicationProperties.Security.OAUTH2.Client.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(request.getHeader("Accept")).thenReturn("text/html");
when(request.getHeader("X-Requested-With")).thenReturn(null);
when(jwtAuth.getToken()).thenReturn(jwt);
when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2", "id_token", idTokenValue));
when(jwt.getIssuer()).thenReturn(new URL(issuerUrl));
when(securityProperties.getOauth2()).thenReturn(oauth);
when(oauth.getClient()).thenReturn(client);
when(client.getEndSessionEndpoint()).thenReturn(endSessionEndpoint);
when(oauth.getClientId()).thenReturn(clientId);
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
verify(response).sendRedirect(contains("id_token_hint=" + idTokenValue));
}
@Test
void testJwtLogout_OAuth2AuthType_NoEndpoint_FallsBackToLocalLogout() throws IOException {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
Jwt jwt = mock(Jwt.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
ApplicationProperties.Security.OAUTH2.Client client =
mock(ApplicationProperties.Security.OAUTH2.Client.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(request.getHeader("Accept")).thenReturn("text/html");
when(request.getHeader("X-Requested-With")).thenReturn(null);
when(jwtAuth.getToken()).thenReturn(jwt);
when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
when(jwt.getIssuer()).thenReturn(null);
when(securityProperties.getOauth2()).thenReturn(oauth);
when(oauth.getClient()).thenReturn(client);
when(client.getEndSessionEndpoint()).thenReturn(null);
when(client.getKeycloak()).thenReturn(null);
when(oauth.getIssuer()).thenReturn("");
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
verify(response).sendRedirect("http://localhost:8080/login?logout=true");
}
@Test
void testUserIsDisabledRedirect() throws IOException {
String error = "userIsDisabled";
String url = "http://localhost:8080";
void testJwtLogout_ApiRequest_ReturnsJsonWithLogoutUrl() throws IOException {
String issuerUrl = "https://keycloak.example.com/realms/test";
String clientId = "stirling-pdf";
String endSessionEndpoint = issuerUrl + "/protocol/openid-connect/logout";
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
Jwt jwt = mock(Jwt.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
ApplicationProperties.Security.OAUTH2.Client client =
mock(ApplicationProperties.Security.OAUTH2.Client.class);
StringWriter stringWriter = new StringWriter();
PrintWriter printWriter = new PrintWriter(stringWriter);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(request.getHeader("Accept")).thenReturn("application/json");
when(request.getHeader("X-Requested-With")).thenReturn(null);
when(response.getWriter()).thenReturn(printWriter);
when(jwtAuth.getToken()).thenReturn(jwt);
when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
when(jwt.getIssuer()).thenReturn(new URL(issuerUrl));
when(securityProperties.getOauth2()).thenReturn(oauth);
when(oauth.getClient()).thenReturn(client);
when(client.getEndSessionEndpoint()).thenReturn(endSessionEndpoint);
when(oauth.getClientId()).thenReturn(clientId);
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
verify(response).setStatus(HttpServletResponse.SC_OK);
verify(response).setContentType("application/json");
verify(response).setCharacterEncoding("UTF-8");
String jsonResponse = stringWriter.toString();
assert jsonResponse.contains("\"logoutUrl\":");
assert jsonResponse.contains(issuerUrl);
}
@Test
void testJwtLogout_XhrRequest_ReturnsJsonWithLogoutUrl() throws IOException {
String issuerUrl = "https://keycloak.example.com/realms/test";
String clientId = "stirling-pdf";
String endSessionEndpoint = issuerUrl + "/protocol/openid-connect/logout";
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
Jwt jwt = mock(Jwt.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
ApplicationProperties.Security.OAUTH2.Client client =
mock(ApplicationProperties.Security.OAUTH2.Client.class);
StringWriter stringWriter = new StringWriter();
PrintWriter printWriter = new PrintWriter(stringWriter);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(request.getHeader("Accept")).thenReturn("text/html");
when(request.getHeader("X-Requested-With")).thenReturn("XMLHttpRequest");
when(response.getWriter()).thenReturn(printWriter);
when(jwtAuth.getToken()).thenReturn(jwt);
when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
when(jwt.getIssuer()).thenReturn(new URL(issuerUrl));
when(securityProperties.getOauth2()).thenReturn(oauth);
when(oauth.getClient()).thenReturn(client);
when(client.getEndSessionEndpoint()).thenReturn(endSessionEndpoint);
when(oauth.getClientId()).thenReturn(clientId);
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
verify(response).setStatus(HttpServletResponse.SC_OK);
verify(response).setContentType("application/json");
}
@Test
void testJwtLogout_Saml2AuthType_WithSloEnabled_LocalLogout() throws IOException {
// When SLO is enabled but no SAML logout handler is configured, falls back to local logout
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
Jwt jwt = mock(Jwt.class);
String logoutPath = "/login?logout=true";
// Create handler with null samlLogoutHandler to test fallback behavior
CustomLogoutSuccessHandler handlerWithoutSaml =
new CustomLogoutSuccessHandler(securityProperties, null);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("SAMLResponse")).thenReturn(null);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(anyString())).thenReturn(logoutPath);
when(jwtAuth.getToken()).thenReturn(jwt);
when(jwt.getClaims())
.thenReturn(
Map.of(
"authType", "SAML2",
"sub", "testuser",
"samlNameId", "testuser@example.com",
"samlRegistrationId", "test-idp"));
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(true);
handlerWithoutSaml.onLogoutSuccess(request, response, jwtAuth);
// Falls back to local logout since no SAML handler is configured
verify(response).sendRedirect(logoutPath);
}
@Test
void testJwtLogout_Saml2AuthType_WithSloDisabled_LocalLogout() throws IOException {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
Jwt jwt = mock(Jwt.class);
String logoutPath = "/login?logout=true";
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("SAMLResponse")).thenReturn(null);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(anyString())).thenReturn(logoutPath);
when(jwtAuth.getToken()).thenReturn(jwt);
when(jwt.getClaims()).thenReturn(Map.of("authType", "SAML2"));
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
verify(response).sendRedirect(logoutPath);
}
@Test
void testErrorParameterHandling_UserIsDisabled() throws IOException {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
String logoutPath = "/login?errorOAuth=userIsDisabled";
when(response.isCommitted()).thenReturn(false);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(anyString())).thenReturn(logoutPath);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
when(request.getParameter("maxUsersReached")).thenReturn(null);
when(request.getParameter(error)).thenReturn("true");
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(securityProperties.getOauth2()).thenReturn(oauth);
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
when(request.getParameter("userIsDisabled")).thenReturn("true");
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
verify(response).sendRedirect(logoutPath);
}
@Test
void testUserAlreadyExistsWebRedirect() throws IOException {
String error = "oAuth2AuthenticationErrorWeb";
String errorPath = "userAlreadyExistsWeb";
String url = "http://localhost:8080";
void testErrorParameterHandling_BadCredentials() throws IOException {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
String logoutPath = "/login?errorOAuth=badCredentials";
when(response.isCommitted()).thenReturn(false);
when(request.getParameter(error)).thenReturn("true");
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(securityProperties.getOauth2()).thenReturn(oauth);
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
verify(response).sendRedirect(url + "/login?errorOAuth=" + errorPath);
}
@Test
void testErrorOAuthRedirect() throws IOException {
String error = "testError";
String url = "http://localhost:8080";
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn("!!!" + error + "!!!");
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(securityProperties.getOauth2()).thenReturn(oauth);
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
}
@Test
void testOAuth2AutoCreateDisabled() throws IOException {
String error = "oAuth2AutoCreateDisabled";
String url = "http://localhost:8080";
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter(error)).thenReturn("true");
when(request.getContextPath()).thenReturn(url);
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(securityProperties.getOauth2()).thenReturn(oauth);
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
}
@Test
void testOAuth2Error() throws IOException {
String error = "test";
String url = "http://localhost:8080";
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
when(request.getParameter("maxUsersReached")).thenReturn(null);
when(request.getParameter("userIsDisabled")).thenReturn(null);
when(request.getParameter("error")).thenReturn("!@$!@£" + error + "£$%^*$");
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(securityProperties.getOauth2()).thenReturn(oauth);
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
}
@Test
void testOAuth2BadCredentialsError() throws IOException {
String error = "badCredentials";
String url = "http://localhost:8080";
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(response.encodeRedirectURL(anyString())).thenReturn(logoutPath);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
@@ -245,43 +403,71 @@ class CustomLogoutSuccessHandlerTest {
when(request.getParameter("maxUsersReached")).thenReturn(null);
when(request.getParameter("userIsDisabled")).thenReturn(null);
when(request.getParameter("error")).thenReturn(null);
when(request.getParameter(error)).thenReturn("true");
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(securityProperties.getOauth2()).thenReturn(oauth);
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
when(request.getParameter("badCredentials")).thenReturn("true");
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
verify(response).sendRedirect(logoutPath);
}
@Test
void testOAuth2AdminBlockedUser() throws IOException {
String error = "oAuth2AdminBlockedUser";
String url = "http://localhost:8080";
void testOidcDiscovery_CachesEndpoint() throws IOException {
String issuerUrl = "https://authentik.example.com/application/o/stirling-pdf";
String discoveredEndpoint = "https://authentik.example.com/application/o/end-session/";
String clientId = "stirling-pdf";
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class);
JwtAuthenticationToken jwtAuth = mock(JwtAuthenticationToken.class);
Jwt jwt = mock(Jwt.class);
ApplicationProperties.Security.OAUTH2 oauth =
mock(ApplicationProperties.Security.OAUTH2.class);
ApplicationProperties.Security.OAUTH2.Client client =
mock(ApplicationProperties.Security.OAUTH2.Client.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
when(request.getParameter(error)).thenReturn("true");
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
when(request.getContextPath()).thenReturn("");
when(request.getHeader("Accept")).thenReturn("text/html");
when(request.getHeader("X-Requested-With")).thenReturn(null);
when(jwtAuth.getToken()).thenReturn(jwt);
when(jwt.getClaims()).thenReturn(Map.of("authType", "OAUTH2"));
when(jwt.getIssuer()).thenReturn(new URL(issuerUrl));
when(securityProperties.getOauth2()).thenReturn(oauth);
when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test");
when(oauth.getClient()).thenReturn(client);
when(client.getEndSessionEndpoint()).thenReturn(null);
when(oauth.getClientId()).thenReturn(clientId);
customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication);
try (MockedStatic<RestClient> restClientStatic = mockStatic(RestClient.class)) {
@SuppressWarnings({"rawtypes", "unchecked"})
RestClient.Builder mockBuilder = mock(RestClient.Builder.class);
@SuppressWarnings({"rawtypes", "unchecked"})
RestClient mockRestClient = mock(RestClient.class);
@SuppressWarnings({"rawtypes", "unchecked"})
RequestHeadersUriSpec mockRequestSpec = mock(RequestHeadersUriSpec.class);
@SuppressWarnings({"rawtypes", "unchecked"})
ResponseSpec mockResponseSpec = mock(ResponseSpec.class);
verify(response).sendRedirect(url + "/login?errorOAuth=" + error);
restClientStatic.when(RestClient::builder).thenReturn(mockBuilder);
when(mockBuilder.baseUrl(anyString())).thenReturn(mockBuilder);
when(mockBuilder.defaultHeaders(any())).thenReturn(mockBuilder);
when(mockBuilder.build()).thenReturn(mockRestClient);
when(mockRestClient.get()).thenReturn(mockRequestSpec);
when(mockRequestSpec.retrieve()).thenReturn(mockResponseSpec);
when(mockResponseSpec.onStatus(any(), any())).thenReturn(mockResponseSpec);
Map<String, Object> discoveryDoc = Map.of("end_session_endpoint", discoveredEndpoint);
when(mockResponseSpec.body(Map.class)).thenReturn(discoveryDoc);
customLogoutSuccessHandler.onLogoutSuccess(request, response, jwtAuth);
verify(response).sendRedirect(contains(discoveredEndpoint));
}
}
}
+1 -1
View File
@@ -537,4 +537,4 @@ tasks.register('buildRestartHelper', Jar) {
doLast {
println "restart-helper.jar created at: ${destinationDirectory.get()}/restart-helper.jar"
}
}
}
+1 -1
View File
@@ -58,7 +58,7 @@
"pdfjs-dist": "^5.4.149",
"peerjs": "^1.5.5",
"posthog-js": "^1.268.0",
"qrcode.react": "^4.1.0",
"qrcode.react": "^4.2.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-i18next": "^15.7.3",
+59
View File
@@ -0,0 +1,59 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0.69 5.4 400 390.62">
<g clip-path="url(#clip0_10641_164)">
<path d="M0.685165 65.8523C0.685165 14.7648 67.4855 -13.2029 111.616 19.4083L207.551 90.2535L288.514 44.373C331.305 21.0394 399.874 37.7748 400.685 101.559L400.685 336.622C400.685 387.71 335.845 414.343 291.715 381.732L194.031 309.34L112.973 355.23C69.7783 378.784 0.752625 360.714 0.752625 298.637C0.752654 224.124 0.685165 252.734 0.685165 65.8523Z" fill="url(#paint0_linear_10641_164)"/>
<path d="M0.685165 65.8523C0.685165 14.7648 67.4855 -13.2029 111.616 19.4083L207.551 90.2535L288.514 44.373C331.305 21.0394 399.874 37.7748 400.685 101.559L400.685 336.622C400.685 387.71 335.845 414.343 291.715 381.732L194.031 309.34L112.973 355.23C69.7783 378.784 0.752625 360.714 0.752625 298.637C0.752654 224.124 0.685165 252.734 0.685165 65.8523Z" fill="url(#paint1_radial_10641_164)"/>
<path d="M0.685165 65.8523C0.685165 14.7648 67.4855 -13.2029 111.616 19.4083L207.551 90.2535L288.514 44.373C331.305 21.0394 399.874 37.7748 400.685 101.559L400.685 336.622C400.685 387.71 335.845 414.343 291.715 381.732L194.031 309.34L112.973 355.23C69.7783 378.784 0.752625 360.714 0.752625 298.637C0.752654 224.124 0.685165 252.734 0.685165 65.8523Z" fill="url(#paint2_radial_10641_164)"/>
<path d="M111.616 19.4083C67.4855 -13.2029 0.685165 14.7648 0.685165 65.8523V295.742C0.685165 290.153 3.5327 279.026 3.5327 279.026C16.6954 236.001 72.6487 219.869 111.36 248.223L291.715 381.732C335.845 414.343 400.685 387.71 400.685 336.622V101.559C400.685 145.201 343.643 189.011 289.755 151.051L111.616 19.4083Z" fill="url(#paint3_radial_10641_164)"/>
<path d="M111.616 19.4083C67.4855 -13.2029 0.685165 14.7648 0.685165 65.8523V295.742C0.685165 290.153 3.5327 279.026 3.5327 279.026C16.6954 236.001 72.6487 219.869 111.36 248.223L291.715 381.732C335.845 414.343 400.685 387.71 400.685 336.622V101.559C400.685 145.201 343.643 189.011 289.755 151.051L111.616 19.4083Z" fill="url(#paint4_radial_10641_164)" fill-opacity="0.25"/>
<path d="M111.616 19.4083C67.4855 -13.2029 0.685165 14.7648 0.685165 65.8523V295.742C0.685165 290.153 3.5327 279.026 3.5327 279.026C16.6954 236.001 72.6487 219.869 111.36 248.223L291.715 381.732C335.845 414.343 400.685 387.71 400.685 336.622V101.559C400.685 145.201 343.643 189.011 289.755 151.051L111.616 19.4083Z" fill="url(#paint5_radial_10641_164)" fill-opacity="0.55"/>
<path d="M111.616 149.691C67.4855 117.08 0.685165 145.048 0.685165 196.135L0.685179 65.8523C0.685183 14.7648 67.4855 -13.2029 111.616 19.4083C170.996 63.2888 230.374 107.171 289.755 151.051C343.643 189.011 400.685 145.201 400.685 101.559V234.89C400.685 285.978 333.885 313.946 289.755 281.334C230.374 237.454 170.996 193.572 111.616 149.691Z" fill="url(#paint6_linear_10641_164)"/>
<path d="M111.616 149.691C67.4855 117.08 0.685165 145.048 0.685165 196.135L0.685179 65.8523C0.685183 14.7648 67.4855 -13.2029 111.616 19.4083C170.996 63.2888 230.374 107.171 289.755 151.051C343.643 189.011 400.685 145.201 400.685 101.559V234.89C400.685 285.978 333.885 313.946 289.755 281.334C230.374 237.454 170.996 193.572 111.616 149.691Z" fill="url(#paint7_radial_10641_164)" fill-opacity="0.35"/>
</g>
<defs>
<linearGradient id="paint0_linear_10641_164" x1="171.501" y1="396.026" x2="314.256" y2="90.5646" gradientUnits="userSpaceOnUse">
<stop offset="0.02" stop-color="#1169DA"/>
<stop offset="0.434784" stop-color="#0151BD"/>
<stop offset="0.614436" stop-color="#014DB7"/>
<stop offset="1" stop-color="#126AD9"/>
</linearGradient>
<radialGradient id="paint1_radial_10641_164" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(358.656 -48.704) rotate(110.224) scale(292.248 218.917)">
<stop offset="0.422966" stop-color="#004AFF" stop-opacity="0.1"/>
<stop offset="0.728672" stop-color="#014DB9"/>
<stop offset="0.836135" stop-color="#014DB9" stop-opacity="0.9"/>
<stop offset="0.955" stop-color="#014DB9" stop-opacity="0"/>
</radialGradient>
<radialGradient id="paint2_radial_10641_164" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(54.0821 384.087) rotate(-73.6432) scale(232.668 283.419)">
<stop offset="0.0908532" stop-color="#004AFF" stop-opacity="0.1"/>
<stop offset="0.56036" stop-color="#014DB9"/>
<stop offset="0.749466" stop-color="#014DB9" stop-opacity="0.99"/>
<stop offset="1" stop-color="#014DB9" stop-opacity="0"/>
</radialGradient>
<radialGradient id="paint3_radial_10641_164" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(439.444 5.40101) rotate(134.068) scale(563.957 434.374)">
<stop offset="0.249322" stop-color="#23C0FE"/>
<stop offset="0.717207" stop-color="#23C0FE"/>
<stop offset="0.995168" stop-color="#1C91FF"/>
</radialGradient>
<radialGradient id="paint4_radial_10641_164" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(119.961 37.1103) rotate(45.9092) scale(403.457 95.9789)">
<stop offset="0.165" stop-color="#096DD6" stop-opacity="0"/>
<stop offset="0.484233" stop-color="#096DD6"/>
<stop offset="0.900505" stop-color="#0876DE" stop-opacity="0.813868"/>
<stop offset="1" stop-color="#029AFF" stop-opacity="0"/>
</radialGradient>
<radialGradient id="paint5_radial_10641_164" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(385.776 245.022) rotate(91.6099) scale(64.799 58.3555)">
<stop stop-color="#0068B3"/>
<stop offset="0.93" stop-color="#006CB8" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paint6_linear_10641_164" x1="114.72" y1="97.5227" x2="182.067" y2="383.699" gradientUnits="userSpaceOnUse">
<stop stop-color="#92EEFE"/>
<stop offset="0.564559" stop-color="#35DDFF"/>
<stop offset="1" stop-color="#08B1F9"/>
</linearGradient>
<radialGradient id="paint7_radial_10641_164" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(67.7807 5.4011) rotate(69.461) scale(99.6637 80.4988)">
<stop stop-color="#CCF9FF" stop-opacity="0.93"/>
<stop offset="1" stop-color="#35DDFF"/>
</radialGradient>
<clipPath id="clip0_10641_164">
<rect width="400" height="400" fill="white" transform="translate(0.685181 0.713501)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><circle cx="256" cy="256" r="256" fill="#fff"/><path d="M268.6 102.4c64.4 0 116.8 52.4 116.8 116.7 0 25.3-8 49.4-23 69.6-14.8 19.9-35 34.3-58.4 41.7l-6.5 2-15.5-76.2 4.3-2c14-6.7 23-21.1 23-36.6 0-22.4-18.2-40.6-40.6-40.6S228 195.2 228 217.6c0 15.5 9 29.8 23 36.6l4.2 2-25 153.4h-69.5V102.4z" style="fill:#191919"/></svg>

After

Width:  |  Height:  |  Size: 404 B

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
<g fill="#000000">
<path d="M7.754 2l.463.41c.343.304.687.607 1.026.915C11.44 5.32 13.3 7.565 14.7 10.149c.072.132.137.268.202.403l.098.203-.108.057-.081-.115-.21-.299-.147-.214c-1.019-1.479-2.04-2.96-3.442-4.145a6.563 6.563 0 00-1.393-.904c-1.014-.485-1.916-.291-2.69.505-.736.757-1.118 1.697-1.463 2.653-.045.123-.092.245-.139.367l-.082.215-.172-.055c.1-.348.192-.698.284-1.049.21-.795.42-1.59.712-2.356.31-.816.702-1.603 1.093-2.39.169-.341.338-.682.5-1.025h.092z"/>
<path d="M8.448 11.822c-1.626.77-5.56 1.564-7.426 1.36C.717 11.576 3.71 4.05 5.18 2.91l-.095.218a4.638 4.638 0 01-.138.303l-.066.129c-.76 1.462-1.519 2.926-1.908 4.53a7.482 7.482 0 00-.228 1.689c-.01 1.34.824 2.252 2.217 2.309.67.027 1.347-.043 2.023-.114.294-.03.587-.061.88-.084.108-.008.214-.021.352-.039l.231-.028z"/>
<path d="M3.825 14.781c-.445.034-.89.068-1.333.108 4.097.39 8.03-.277 11.91-1.644-1.265-2.23-2.97-3.991-4.952-5.522.026.098.084.169.141.239l.048.06c.17.226.348.448.527.67.409.509.818 1.018 1.126 1.578.778 1.42.356 2.648-1.168 3.296-1.002.427-2.097.718-3.18.892-1.03.164-2.075.243-3.119.323z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -486,12 +486,6 @@ export const SAML2_PROVIDER: Provider = {
description: 'Enable SAML2 authentication (Enterprise only)',
defaultValue: false,
},
{
key: 'provider',
type: 'text',
label: 'Provider Name',
description: 'The name of your SAML2 provider',
},
{
key: 'registrationId',
type: 'text',
@@ -500,51 +494,66 @@ export const SAML2_PROVIDER: Provider = {
defaultValue: 'stirling',
},
{
key: 'idpMetadataUri',
key: 'metadataUri',
type: 'text',
label: 'IDP Metadata URI',
description: 'The URI for your provider\'s metadata',
label: 'Metadata URI',
description: 'Your IdP\'s metadata URI (recommended - auto-discovers IdP config)',
placeholder: 'https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata',
},
{
key: 'idpSingleLoginUrl',
key: 'enableSingleLogout',
type: 'switch',
label: 'Enable Single Logout',
description: 'Enable SP-initiated Single Logout (SLO)',
defaultValue: false,
},
// IdP Provider settings (manual config - only needed if metadataUri not available)
{
key: 'provider.name',
type: 'text',
label: 'IDP Single Login URL',
description: 'The URL for initiating SSO',
placeholder: 'https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml',
label: 'Provider Name',
description: 'Display name for your IdP (optional, for reference only)',
},
{
key: 'idpSingleLogoutUrl',
key: 'provider.singleLoginUrl',
type: 'text',
label: 'IDP Single Logout URL',
description: 'The URL for initiating SLO',
placeholder: 'https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml',
label: 'SSO URL',
description: 'IdP Single Sign-On URL (auto-discovered from metadata if set)',
placeholder: 'https://dev-XXXXXXXX.okta.com/app/.../sso/saml',
},
{
key: 'idpIssuer',
key: 'provider.singleLogoutUrl',
type: 'text',
label: 'IDP Issuer',
description: 'The ID of your provider',
label: 'SLO URL',
description: 'IdP Single Logout URL (auto-discovered from metadata if set)',
placeholder: 'https://dev-XXXXXXXX.okta.com/app/.../slo/saml',
},
{
key: 'idpCert',
key: 'provider.entityId',
type: 'text',
label: 'IDP Certificate',
description: 'The certificate path (e.g., classpath:okta.cert)',
label: 'IdP Entity ID',
description: 'IdP Entity ID (auto-discovered from metadata if set)',
},
{
key: 'provider.cert',
type: 'text',
label: 'IdP Certificate',
description: 'IdP signing certificate path (auto-discovered from metadata if set)',
placeholder: 'classpath:okta.cert',
},
// SP credentials
{
key: 'privateKey',
key: 'sp.privateKey',
type: 'text',
label: 'Private Key',
description: 'Your private key path',
label: 'SP Private Key',
description: 'Your Service Provider private key path',
placeholder: 'classpath:saml-private-key.key',
},
{
key: 'spCert',
key: 'sp.cert',
type: 'text',
label: 'SP Certificate',
description: 'Your signing certificate path',
description: 'Your Service Provider signing certificate path',
placeholder: 'classpath:saml-public-cert.crt',
},
{
@@ -100,12 +100,21 @@ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
</MantineProvider>
);
// Helper to create mock API responses with full response structure
const _createMockResponse = <T,>(data: T, status = 200, statusText = 'OK') => ({
data,
status,
statusText,
headers: {},
config: {},
});
describe('Convert Tool Integration Tests', () => {
beforeEach(() => {
vi.clearAllMocks();
// Setup default apiClient mock
mockedApiClient.post = vi.fn();
// Reset the post mock - use type assertion for compatibility with both axios and TauriHttpClient
(mockedApiClient.post as ReturnType<typeof vi.fn>).mockReset();
});
afterEach(() => {
+6 -1
View File
@@ -134,10 +134,15 @@ 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] JWT available event received, refreshing session');
// Set loading true while we re-validate
setLoading(true);
void initializeAuth();
};
// Log that we're setting up the listener
console.debug('[Auth] Setting up jwt-available event listener');
window.addEventListener('jwt-available', handleJwtAvailable);
// Subscribe to auth state changes
@@ -251,40 +251,63 @@ describe('SpringAuthClient', () => {
});
describe('signOut', () => {
let mockForm: { method: string; action: string; style: { display: string }; submit: ReturnType<typeof vi.fn> };
let appendChildSpy: any;
beforeEach(() => {
// Mock form creation and submission
mockForm = {
method: '',
action: '',
style: { display: '' },
submit: vi.fn(),
};
vi.spyOn(document, 'createElement').mockReturnValue(mockForm as unknown as HTMLElement);
appendChildSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should successfully sign out and clear JWT', async () => {
const mockToken = 'jwt-to-clear';
localStorage.setItem('stirling_jwt', mockToken);
vi.mocked(apiClient.post).mockResolvedValueOnce({
status: 200,
data: {},
} as any);
const result = await springAuth.signOut();
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/auth/logout',
null,
expect.objectContaining({ withCredentials: true })
);
// JWT should be cleared from localStorage
expect(localStorage.getItem('stirling_jwt')).toBeNull();
// Should submit POST form to /logout for Spring Security logout handler
expect(mockForm.method).toBe('POST');
expect(mockForm.action).toBe('/logout');
expect(mockForm.submit).toHaveBeenCalled();
// Should return no error on successful signOut
expect(result.error).toBeNull();
});
it('should clear JWT even if logout request fails', async () => {
it('should create hidden form and submit POST to /logout', async () => {
const mockToken = 'jwt-to-clear';
localStorage.setItem('stirling_jwt', mockToken);
vi.mocked(apiClient.post).mockRejectedValueOnce({
isAxiosError: true,
response: { status: 500 },
message: 'Server error',
});
await springAuth.signOut();
// Verify form was created with correct attributes
expect(document.createElement).toHaveBeenCalledWith('form');
expect(mockForm.style.display).toBe('none');
expect(appendChildSpy).toHaveBeenCalled();
});
it('should handle signOut when no JWT is present', async () => {
localStorage.removeItem('stirling_jwt');
const result = await springAuth.signOut();
expect(localStorage.getItem('stirling_jwt')).toBeNull();
expect(result.error).toBeTruthy();
// Should still submit POST form to /logout
expect(mockForm.method).toBe('POST');
expect(mockForm.action).toBe('/logout');
expect(mockForm.submit).toHaveBeenCalled();
expect(result.error).toBeNull();
});
});
@@ -309,18 +309,25 @@ class SpringAuthClient {
/**
* Sign out user (invalidate session)
*
* For SAML/OAuth users, this navigates to /logout which triggers
* Spring Security's logout handler and initiates SSO logout.
* The JWT is passed via a short-lived cookie so the backend can
* extract the SAML NameID for single logout (SLO).
*/
async signOut(): Promise<{ error: AuthError | null }> {
try {
const response = await apiClient.post('/api/v1/auth/logout', null, {
headers: {
'X-XSRF-TOKEN': this.getCsrfToken() || '',
},
withCredentials: true,
});
// Get the JWT before removing it - we need to pass it to the backend
// so it can extract the SAML NameID for single logout (SLO)
const token = localStorage.getItem('stirling_jwt');
if (response.status === 200) {
// console.debug('[SpringAuth] signOut: Success');
// Store JWT in a short-lived cookie for the logout request
// This avoids exposing the token in URL params while ensuring
// it's sent with the redirect request
if (token) {
// Cookie expires in 30 seconds - just long enough for the logout redirect
// Secure: only sent over HTTPS; Path=/logout: scoped to logout endpoint only
document.cookie = `stirling_logout_token=${encodeURIComponent(token)}; path=/logout; max-age=30; SameSite=Lax; Secure`;
}
// Clean up local storage
@@ -355,13 +362,24 @@ class SpringAuthClient {
console.warn('[SpringAuth] Failed to run platform auth cleanup', cleanupError);
}
// Notify listeners
// Notify listeners before redirect
this.notifyListeners('SIGNED_OUT', null);
// Submit a POST form to /logout to trigger Spring Security's logout handler
// Using POST prevents logout CSRF attacks (GET-based logout can be triggered by any site)
console.log('[SpringAuth] Submitting POST to /logout for session invalidation');
const form = document.createElement('form');
form.method = 'POST';
form.action = `${BASE_PATH}/logout`;
form.style.display = 'none';
document.body.appendChild(form);
form.submit();
// This won't be reached if redirect happens, but return for type safety
return { error: null };
} catch (error: unknown) {
console.error('[SpringAuth] signOut error:', error);
// Still remove token even if backend call fails
// Still remove token even if the call fails
localStorage.removeItem('stirling_jwt');
try {
await clearPlatformAuthAfterSignOut();
@@ -1,20 +1,16 @@
import { Text, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@app/auth/UseSession';
import { useNavigate } from 'react-router-dom';
export function OverviewHeader() {
const { t } = useTranslation();
const { signOut, user } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
try {
await signOut();
navigate('/login');
} catch (error) {
console.error('Logout error:', error);
}
console.log('[OverviewHeader] Logout button clicked, calling signOut()');
// signOut() handles navigation to /logout for SAML/OAuth SLO
// Do NOT navigate after signOut - it will redirect the page
await signOut();
};
return (
@@ -7,14 +7,12 @@ import { useAuth } from '@app/auth/UseSession';
import { accountService } from '@app/services/accountService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { QRCodeSVG } from 'qrcode.react';
import { useAccountLogout } from '@app/extensions/accountLogout';
import { BASE_PATH } from '@app/constants/app';
import { MfaSetupResponse } from '@app/responses/Mfa/MfaResponse';
const AccountSection: React.FC = () => {
const { t } = useTranslation();
const { user, signOut } = useAuth();
const accountLogout = useAccountLogout();
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [usernameModalOpen, setUsernameModalOpen] = useState(false);
@@ -53,13 +51,12 @@ const AccountSection: React.FC = () => {
const userIdentifier = useMemo(() => user?.email || user?.username || '', [user?.email, user?.username]);
const redirectToLogin = useCallback(() => {
window.location.assign('/login');
}, []);
const handleLogout = useCallback(async () => {
await accountLogout({ signOut, redirectToLogin });
}, [accountLogout, redirectToLogin, signOut]);
console.log('[AccountSection] Logout button clicked, calling signOut()');
// signOut() handles navigation to /logout for SAML/OAuth SLO
// Do NOT navigate after signOut - it will redirect the page
await signOut();
}, [signOut]);
const handlePasswordSubmit = async (event: React.FormEvent) => {
event.preventDefault();
@@ -209,9 +209,20 @@ export default function AdminConnectionsSection() {
return !!(providerSettings?.clientId);
};
// Helper to get nested value from object using dot notation
const getNestedValue = (obj: Record<string, any>, path: string): any => {
return path.split('.').reduce((acc, part) => acc?.[part], obj);
};
const getProviderSettings = (provider: Provider): Record<string, any> => {
if (provider.id === 'saml2') {
return settings?.saml2 || {};
const saml2 = settings?.saml2 || {};
// Flatten nested structure to match field keys with dot notation
const result: Record<string, any> = {};
provider.fields.forEach((field) => {
result[field.key] = getNestedValue(saml2, field.key);
});
return result;
}
if (provider.id === 'smtp') {
@@ -297,8 +308,9 @@ export default function AdminConnectionsSection() {
const deltaSettings: Record<string, any> = {};
if (provider.id === 'saml2') {
// SAML2 settings
// SAML2 settings - keys may use dot notation (e.g., 'provider.name')
Object.keys(providerSettings).forEach((key) => {
// Key already has dot notation for nested paths, just prepend security.saml2.
deltaSettings[`security.saml2.${key}`] = providerSettings[key];
});
} else if (provider.id === 'oauth2-generic') {
@@ -1,15 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { render, cleanup, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import AuthCallback from '@app/routes/AuthCallback';
import { springAuth } from '@app/auth/springAuthClient';
// Mock springAuth
vi.mock('@app/auth/springAuthClient', () => ({
springAuth: {
getSession: vi.fn(),
},
}));
// Mock useNavigate
const mockNavigate = vi.fn();
@@ -21,38 +13,38 @@ vi.mock('react-router-dom', async () => {
};
});
// Mock useAuth hook
const mockUseAuth = vi.fn();
vi.mock('@app/auth/UseSession', () => ({
useAuth: () => mockUseAuth(),
}));
describe('AuthCallback', () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
sessionStorage.clear();
vi.clearAllMocks();
// Reset window.location.hash
window.location.hash = '';
// Default mock: no session, not loading
mockUseAuth.mockReturnValue({ session: null, loading: false });
});
it('should extract JWT from URL hash and validate it', async () => {
afterEach(() => {
cleanup();
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
it('should extract JWT from URL hash and store it', async () => {
const mockToken = 'oauth-jwt-token';
const mockUser = {
id: '123',
email: 'oauth@example.com',
username: 'oauthuser',
role: 'USER',
};
// Set URL hash with access token
window.location.hash = `#access_token=${mockToken}`;
// Mock successful session validation
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
data: {
session: {
user: mockUser,
access_token: mockToken,
expires_in: 3600,
expires_at: Date.now() + 3600000,
},
},
error: null,
});
// Mock useAuth returning loading state (validation in progress)
mockUseAuth.mockReturnValue({ session: null, loading: true });
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
@@ -62,21 +54,16 @@ describe('AuthCallback', () => {
</BrowserRouter>
);
await waitFor(() => {
// Verify JWT was stored
expect(localStorage.getItem('stirling_jwt')).toBe(mockToken);
// Advance timers to trigger the delayed tokenStored update (50ms delay)
await vi.advanceTimersByTimeAsync(100);
// Verify jwt-available event was dispatched
expect(dispatchEventSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'jwt-available' })
);
// Verify JWT was stored
expect(localStorage.getItem('stirling_jwt')).toBe(mockToken);
// Verify getSession was called to validate token
expect(springAuth.getSession).toHaveBeenCalled();
// Verify navigation to home
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
});
// Verify jwt-available event was dispatched
expect(dispatchEventSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'jwt-available' })
);
});
it('should redirect to login when no access token in hash', async () => {
@@ -89,89 +76,29 @@ describe('AuthCallback', () => {
</BrowserRouter>
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/login', {
replace: true,
state: { error: 'OAuth login failed - no token received.' },
});
expect(localStorage.getItem('stirling_jwt')).toBeNull();
// Advance timers to trigger the delayed navigation (2000ms delay in component)
await vi.advanceTimersByTimeAsync(2500);
expect(mockNavigate).toHaveBeenCalledWith('/login', {
replace: true,
state: { error: 'OAuth login failed - no token received.' },
});
expect(localStorage.getItem('stirling_jwt')).toBeNull();
});
it('should redirect to login when token validation fails', async () => {
const invalidToken = 'invalid-oauth-token';
window.location.hash = `#access_token=${invalidToken}`;
// Mock failed session validation
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
data: { session: null },
error: { message: 'Invalid token' },
});
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>
);
await waitFor(() => {
// JWT should be stored initially
expect(localStorage.getItem('stirling_jwt')).toBeNull(); // Cleared after validation failure
// Verify redirect to login
expect(mockNavigate).toHaveBeenCalledWith('/login', {
replace: true,
state: { error: 'OAuth login failed - invalid token.' },
});
});
});
it('should handle errors gracefully', async () => {
const mockToken = 'error-token';
window.location.hash = `#access_token=${mockToken}`;
// Mock getSession throwing error
vi.mocked(springAuth.getSession).mockRejectedValueOnce(
new Error('Network error')
);
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/login', {
replace: true,
state: { error: 'OAuth login failed. Please try again.' },
});
});
});
it('should display loading state while processing', () => {
it('should display loading state initially', () => {
window.location.hash = '#access_token=processing-token';
vi.mocked(springAuth.getSession).mockImplementationOnce(
() =>
new Promise((resolve) =>
setTimeout(
() =>
resolve({
data: { session: null },
error: { message: 'Token expired' },
}),
100
)
)
);
// Mock useAuth returning loading state
mockUseAuth.mockReturnValue({ session: null, loading: true });
const { getByText } = render(
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>
);
expect(getByText('Completing authentication')).toBeInTheDocument();
// Should show loading message
expect(screen.getByText('Completing authentication')).toBeInTheDocument();
});
});
+150 -52
View File
@@ -1,77 +1,175 @@
import { useEffect } from 'react';
import { useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { springAuth } from '@app/auth/springAuthClient';
import { handleAuthCallbackSuccess } from '@app/extensions/authCallback';
import styles from '@app/routes/AuthCallback.module.css';
import { useAuth } from '@app/auth/UseSession';
// Use sessionStorage to track callback handling across React StrictMode double-renders
// Timestamp-based to allow retry after 10 seconds (e.g., if user tries again)
const AUTH_CALLBACK_TIMESTAMP_KEY = 'stirling_auth_callback_ts';
/**
* OAuth Callback Handler
* OAuth/SAML Callback Handler
*
* This component is rendered after OAuth providers (GitHub, Google, etc.) redirect back.
* This component is rendered after OAuth/SAML providers redirect back.
* The JWT is passed in the URL fragment (#access_token=...) by the Spring backend.
* We extract it, store in localStorage, and redirect to the home page.
*
* Flow:
* 1. Extract JWT from URL hash
* 2. Store in localStorage
* 3. Fire jwt-available event (AuthProvider will validate)
* 4. Wait for AuthProvider to confirm session
* 5. Navigate to home only after session is confirmed
*/
export default function AuthCallback() {
const navigate = useNavigate();
const { session, loading: authLoading } = useAuth();
const [status, setStatus] = useState<'extracting' | 'validating' | 'error'>('extracting');
const [_errorMessage, setErrorMessage] = useState<string | null>(null);
const [tokenStored, setTokenStored] = useState(false);
const processingRef = useRef(false);
// Step 1: Extract and store the token
useEffect(() => {
const handleCallback = async () => {
try {
console.log('[AuthCallback] Handling OAuth callback...');
// Check if we've already handled this callback recently (within 10 seconds)
const handledTimestamp = sessionStorage.getItem(AUTH_CALLBACK_TIMESTAMP_KEY);
const now = Date.now();
if (handledTimestamp && (now - parseInt(handledTimestamp, 10)) < 10000) {
console.debug('[AuthCallback] Already handled recently, checking session state');
setTokenStored(true);
setStatus('validating');
return;
}
// Extract JWT from URL fragment (#access_token=...)
const hash = window.location.hash.substring(1); // Remove '#'
const params = new URLSearchParams(hash);
const token = params.get('access_token');
// Use ref to prevent concurrent processing
if (processingRef.current) {
console.debug('[AuthCallback] Already processing, skipping');
return;
}
processingRef.current = true;
if (!token) {
console.error('[AuthCallback] No access_token in URL fragment');
navigate('/login', {
replace: true,
state: { error: 'OAuth login failed - no token received.' }
});
return;
}
console.log('[AuthCallback] Handling OAuth/SAML callback...');
console.debug('[AuthCallback] Current URL:', window.location.href);
console.debug('[AuthCallback] Hash:', window.location.hash);
// Store JWT in localStorage
localStorage.setItem('stirling_jwt', token);
console.log('[AuthCallback] JWT stored in localStorage');
// Extract JWT from URL fragment (#access_token=...)
const hash = window.location.hash.substring(1); // Remove '#'
const params = new URLSearchParams(hash);
const token = params.get('access_token');
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent('jwt-available'));
if (!token) {
console.error('[AuthCallback] No access_token in URL fragment');
console.debug('[AuthCallback] Full hash was:', hash);
setStatus('error');
setErrorMessage('Authentication failed - no token received from server.');
// 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);
localStorage.removeItem('stirling_jwt');
navigate('/login', {
replace: true,
state: { error: 'OAuth login failed - invalid token.' }
});
return;
}
await handleAuthCallbackSuccess(token);
console.log('[AuthCallback] Token validated, redirecting to home');
// Clear the hash from URL and redirect to home page
navigate('/', { replace: true });
} catch (error) {
console.error('[AuthCallback] Error:', error);
setTimeout(() => {
navigate('/login', {
replace: true,
state: { error: 'OAuth login failed. Please try again.' }
state: { error: 'OAuth login failed - no token received.' }
});
}
};
}, 2000);
return;
}
handleCallback();
// Mark as handled BEFORE storing token
sessionStorage.setItem(AUTH_CALLBACK_TIMESTAMP_KEY, now.toString());
// Store JWT in localStorage
localStorage.setItem('stirling_jwt', token);
console.log('[AuthCallback] JWT stored in localStorage');
// Clear the hash from URL to prevent re-processing on page refresh
if (window.history.replaceState) {
window.history.replaceState(null, '', window.location.pathname);
}
// Update status
setStatus('validating');
// Dispatch jwt-available event - AuthProvider will validate the token
console.log('[AuthCallback] Firing jwt-available event for AuthProvider to validate');
window.dispatchEvent(new CustomEvent('jwt-available'));
// Small delay to allow AuthProvider to receive the event and start loading
// before we set tokenStored (which triggers the session watching effect)
setTimeout(() => {
console.log('[AuthCallback] Setting tokenStored=true to start watching session');
setTokenStored(true);
}, 50);
return () => {
processingRef.current = false;
};
}, [navigate]);
// Step 2: Wait for AuthProvider to validate and provide session
useEffect(() => {
if (!tokenStored) {
return; // Token not stored yet, wait
}
console.debug('[AuthCallback] Checking auth state:', {
authLoading,
hasSession: !!session,
tokenStored
});
// Still loading - wait for AuthProvider to finish validation
if (authLoading) {
return;
}
// AuthProvider finished loading
if (session) {
// Session validated successfully!
console.log('[AuthCallback] Session confirmed, user:', session.user?.email);
console.log('[AuthCallback] Redirecting to home...');
navigate('/', { replace: true });
} else {
// No session after AuthProvider finished - token was invalid
console.error('[AuthCallback] No session after validation - token may be invalid');
// Check if JWT is still in localStorage (AuthProvider removes it on 401)
const jwtStillExists = !!localStorage.getItem('stirling_jwt');
if (!jwtStillExists) {
setStatus('error');
setErrorMessage('Authentication failed - invalid or expired token.');
setTimeout(() => {
navigate('/login', {
replace: true,
state: { error: 'Authentication failed - please try again.' }
});
}, 2000);
}
// If JWT still exists but no session, AuthProvider might still be initializing
// Wait a bit more before giving up
}
}, [tokenStored, authLoading, session, navigate]);
// Timeout: If we've been validating for too long, show error
useEffect(() => {
if (status !== 'validating' || !tokenStored) {
return;
}
const timeoutId = setTimeout(() => {
if (!session) {
setStatus('error');
setErrorMessage('Authentication timed out. Please try again.');
setTimeout(() => {
navigate('/login', {
replace: true,
state: { error: 'Authentication timed out. Please try again.' }
});
}, 2000);
}
}, 10000); // 10 second timeout
return () => clearTimeout(timeoutId);
}, [status, tokenStored, session, navigate]);
return (
<div className={styles.page}>
<div className={styles.card}>
+41 -46
View File
@@ -61,6 +61,15 @@ vi.mock('@app/services/apiClient', () => ({
},
}));
// Helper to create mock API responses with TauriHttpResponse structure
const createMockResponse = <T,>(data: T) => ({
data,
status: 200,
statusText: 'OK',
headers: {},
config: {},
});
const mockNavigate = vi.fn();
const mockBackendProbeState = {
status: 'up' as const,
@@ -112,12 +121,10 @@ describe('Login', () => {
});
// Mock apiClient for login UI data
vi.mocked(apiClient.get).mockResolvedValue({
data: {
enableLogin: true,
providerList: {},
},
});
vi.mocked(apiClient.get).mockResolvedValue(createMockResponse({
enableLogin: true,
providerList: {},
}));
});
it('should render login form', async () => {
@@ -264,14 +271,12 @@ describe('Login', () => {
const user = userEvent.setup();
// Mock provider list with authentik
vi.mocked(apiClient.get).mockResolvedValue({
data: {
enableLogin: true,
providerList: {
'/oauth2/authorization/authentik': 'Authentik',
},
vi.mocked(apiClient.get).mockResolvedValue(createMockResponse({
enableLogin: true,
providerList: {
'/oauth2/authorization/authentik': 'Authentik',
},
});
}));
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
error: null,
@@ -307,14 +312,12 @@ describe('Login', () => {
const user = userEvent.setup();
// Mock provider list with custom provider 'mycompany'
vi.mocked(apiClient.get).mockResolvedValue({
data: {
enableLogin: true,
providerList: {
'/oauth2/authorization/mycompany': 'My Company SSO',
},
vi.mocked(apiClient.get).mockResolvedValue(createMockResponse({
enableLogin: true,
providerList: {
'/oauth2/authorization/mycompany': 'My Company SSO',
},
});
}));
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
error: null,
@@ -351,14 +354,12 @@ describe('Login', () => {
const user = userEvent.setup();
// Mock provider list with 'oidc'
vi.mocked(apiClient.get).mockResolvedValue({
data: {
enableLogin: true,
providerList: {
'/oauth2/authorization/oidc': 'OIDC',
},
vi.mocked(apiClient.get).mockResolvedValue(createMockResponse({
enableLogin: true,
providerList: {
'/oauth2/authorization/oidc': 'OIDC',
},
});
}));
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
error: null,
@@ -510,12 +511,10 @@ describe('Login', () => {
it('should redirect to home when login disabled', async () => {
mockBackendProbeState.loginDisabled = true;
mockProbe.mockResolvedValueOnce({ status: 'up', loginDisabled: true, loading: false });
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: {
enableLogin: false,
providerList: {},
},
});
vi.mocked(apiClient.get).mockResolvedValueOnce(createMockResponse({
enableLogin: false,
providerList: {},
}));
render(
<TestWrapper>
@@ -531,14 +530,12 @@ describe('Login', () => {
});
it('should handle OAuth provider click', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: {
enableLogin: true,
providerList: {
'/oauth2/authorization/github': 'GitHub',
},
vi.mocked(apiClient.get).mockResolvedValueOnce(createMockResponse({
enableLogin: true,
providerList: {
'/oauth2/authorization/github': 'GitHub',
},
});
}));
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
error: null,
@@ -565,12 +562,10 @@ describe('Login', () => {
});
it('should show email form by default when no SSO providers', async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: {
enableLogin: true,
providerList: {}, // No providers
},
});
vi.mocked(apiClient.get).mockResolvedValueOnce(createMockResponse({
enableLogin: true,
providerList: {}, // No providers
}));
render(
<TestWrapper>
+34 -12
View File
@@ -35,7 +35,8 @@ export default function Login() {
const [password, setPassword] = useState('');
const [mfaCode, setMfaCode] = useState('');
const [requiresMfa, setRequiresMfa] = useState(false);
const [enabledProviders, setEnabledProviders] = useState<OAuthProvider[]>([]);
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([]);
const [samlProviders, setSamlProviders] = useState<OAuthProvider[]>([]);
const [hasSSOProviders, setHasSSOProviders] = useState(false);
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
const [loginMethod, setLoginMethod] = useState<string>('all');
@@ -117,7 +118,11 @@ export default function Login() {
// We'll use these full paths so the auth client knows where to redirect
const providerPaths = Object.keys(data.providerList || {});
setEnabledProviders(providerPaths);
const oauth = providerPaths.filter(path => path.includes('/oauth2/'));
const saml = providerPaths.filter(path => path.includes('/saml2/'));
setOauthProviders(oauth);
setSamlProviders(saml);
setLoginMethod(data.loginMethod || 'all');
} catch (err) {
console.error('[Login] Failed to fetch enabled providers:', err);
@@ -134,7 +139,7 @@ export default function Login() {
// In debug mode, check if any providers exist in the config
const hasProviders = DEBUG_SHOW_ALL_PROVIDERS
? Object.keys(oauthProviderConfig).length > 0
: enabledProviders.length > 0;
: (oauthProviders.length > 0 || samlProviders.length > 0);
setHasSSOProviders(hasProviders);
// Check if username/password authentication is allowed
@@ -147,7 +152,7 @@ export default function Login() {
// Hide email form if username/password auth is not allowed
setShowEmailForm(false);
}
}, [enabledProviders, loginMethod]);
}, [oauthProviders, samlProviders, loginMethod]);
// Handle query params (email prefill, success messages, and session expiry)
useEffect(() => {
@@ -340,15 +345,32 @@ export default function Login() {
<ErrorMessage error={error} />
{/* OAuth first */}
<OAuthButtons
onProviderClick={signInWithProvider}
isSubmitting={isSigningIn}
layout="vertical"
enabledProviders={enabledProviders}
/>
{/* OAuth section */}
{oauthProviders.length > 0 && (
<OAuthButtons
onProviderClick={signInWithProvider}
isSubmitting={isSigningIn}
layout="vertical"
enabledProviders={oauthProviders}
/>
)}
{/* Divider between OAuth and Email - only show if SSO is available and username/password is allowed */}
{/* SAML section - show with divider if there are also OAuth providers */}
{samlProviders.length > 0 && (
<>
{oauthProviders.length > 0 && (
<DividerWithText text="SAML" respondsToDarkMode={false} opacity={0.4} />
)}
<OAuthButtons
onProviderClick={signInWithProvider}
isSubmitting={isSigningIn}
layout="vertical"
enabledProviders={samlProviders}
/>
</>
)}
{/* Divider between SSO and Email - only show if SSO is available and username/password is allowed */}
{hasSSOProviders && (loginMethod === 'all' || loginMethod === 'normal') && (
<DividerWithText text={t('signup.or', 'or')} respondsToDarkMode={false} opacity={0.4} />
)}
@@ -142,7 +142,14 @@
.oauth-container-vertical {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem; /* 12px */
width: 100%;
}
.oauth-container-vertical > div {
width: 100%;
max-width: 320rem; /* 320px - consistent width for all buttons */
}
.oauth-button-icon {
@@ -185,22 +192,24 @@
width: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0.75rem 1rem; /* 12px 16px */
border: 1px solid #d1d5db;
justify-content: center; /* Center icon and text */
padding: 0.875rem 1.25rem !important; /* 14px 20px - increased for better visibility */
min-height: 3rem !important; /* 48px - ensure readable height */
height: auto !important; /* Override Mantine default height */
border: 1px solid #d1d5db !important;
border-radius: 0.75rem; /* 12px */
background-color: var(--auth-card-bg-light-only);
background-color: var(--auth-card-bg-light-only) !important;
font-size: 1rem; /* 16px */
font-weight: 500;
color: var(--auth-text-primary-light-only);
color: var(--auth-text-primary-light-only) !important;
cursor: pointer;
gap: 0.75rem; /* 12px */
gap: 1rem !important; /* 16px - increased spacing between icon and text */
font-family: inherit;
transition: background-color 0.2s ease;
}
.oauth-button-vertical:hover:not(:disabled) {
background-color: #f3f4f6;
background-color: #f3f4f6 !important;
}
.oauth-button-vertical:disabled {
@@ -243,12 +252,16 @@
}
.oauth-icon-tiny {
width: 1.25rem; /* 20px */
height: 1.25rem; /* 20px */
width: 1.5rem; /* 24px - increased for better visibility */
height: 1.5rem; /* 24px */
display: block;
flex-shrink: 0;
}
.oauth-button-vertical span {
margin-left: 0.5rem; /* 8px - additional spacing between icon and text */
}
/* Login Header Styles */
.login-header {
margin-bottom: 1rem; /* 16px */
@@ -17,11 +17,15 @@ export const oauthProviderConfig: Record<string, { label: string; file: string }
keycloak: { label: 'Keycloak', file: 'keycloak.svg' },
cloudron: { label: 'Cloudron', file: 'cloudron.svg' },
authentik: { label: 'Authentik', file: 'authentik.svg' },
oidc: { label: 'OIDC', file: 'oidc.svg' }
intune: { label: 'Intune', file: 'intune.svg' },
pocketId: { label: 'Pocket ID', file: 'pocketid.svg' },
oidc: { label: 'OIDC', file: 'oidc.svg' },
saml: { label: 'SAML', file: 'saml.svg' },
};
// Generic fallback for unknown providers
const GENERIC_PROVIDER_ICON = 'oidc.svg';
// Generic fallback icons for unknown providers
const GENERIC_OAUTH_ICON = 'oidc.svg';
const GENERIC_SAML_ICON = 'saml.svg';
interface OAuthButtonsProps {
onProviderClick: (provider: OAuthProvider) => void
@@ -42,6 +46,7 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
const providers = providersToShow.map(pathOrId => {
// Extract provider ID from full path (e.g., '/saml2/authenticate/stirling' -> 'stirling')
const providerId = pathOrId.split('/').pop() || pathOrId;
const isSamlProvider = pathOrId.includes('/saml2/');
if (providerId in oauthProviderConfig) {
// Known provider - use predefined icon and label
@@ -51,12 +56,12 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
...oauthProviderConfig[providerId]
};
}
// Unknown provider - use generic icon and capitalize ID for label
// Unknown provider - use appropriate generic icon based on auth type
return {
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
file: isSamlProvider ? GENERIC_SAML_ICON : GENERIC_OAUTH_ICON
};
});
@@ -31,10 +31,14 @@ export function setupApiInterceptors(client: AxiosInstance): void {
(config) => {
const jwtToken = getJwtTokenFromStorage();
const xsrfToken = getXsrfToken();
const url = config.url || 'unknown';
if (jwtToken && !config.headers.Authorization) {
config.headers.Authorization = `Bearer ${jwtToken}`;
console.debug('[API Client] Added JWT token from localStorage to Authorization header');
console.debug('[API Client] Added JWT to request:', url);
} else if (!jwtToken) {
// Log when JWT is missing - helps debug auth issues
console.debug('[API Client] No JWT in localStorage for request:', url);
}
if (xsrfToken && !config.headers['X-XSRF-TOKEN']) {
+12
View File
@@ -78,6 +78,18 @@ export default defineConfig(({ mode }) => {
secure: false,
xfwd: true,
},
'/logout': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
xfwd: true,
},
'/logout/saml2': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
xfwd: true,
},
'/swagger-ui': {
target: 'http://localhost:8080',
changeOrigin: true,