Compare commits

...
Author SHA1 Message Date
Dario Ghunney Ware eaafa21842 Merge branch 'main' into fix/saml-login 2026-01-30 16:16:32 +00:00
Dario Ghunney Ware 1a62680dcc cleanup 2026-01-26 11:07:39 +00:00
DarioGii 789642a824 pr comments 2026-01-26 11:07:39 +00:00
DarioGii e1199a1fd3 updated deprecated version 2026-01-26 11:07:39 +00:00
DarioGii c0f9c9570e added missing check 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware a8c4a0bfb1 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-26 11:07:38 +00:00
Ludy 99d728d815 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-26 11:07:38 +00:00
Ludy 3317babe5c 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-26 11:07:38 +00:00
Ludy 5ad5d851b1 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-26 11:07:38 +00:00
Anthony Stirling 6cedc8c994 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-26 11:07:38 +00:00
Dario Ghunney Ware eba24b4d6c resolving conflicts 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware 68e95c2652 eslint fix 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware de23752b44 clean up 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware b44bb7c50c Improved SAML config settings 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware 537aee3ab6 Streamlined config with metadata uri 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware a6ae74e794 Addressing XML vulnerability 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware 50ec2d82d4 Hardening 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware f95e2fdaa3 Fixing tests 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware 70c9cb36c3 Implemented SP-initiated SLO enabled with flag
# Conflicts:
#	frontend/src/proprietary/routes/Login.tsx
2026-01-26 11:07:38 +00:00
Dario Ghunney Ware 1b737cbce5 Fixing SAML logout 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware 4492fa39be 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-26 11:07:38 +00:00
39 changed files with 1405 additions and 583 deletions
@@ -215,75 +215,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);
}
}
}
}
@@ -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,9 +165,9 @@ 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("/logout")
|| trimmedUri.startsWith(
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers
// enableLogin)
|| trimmedUri.startsWith(
"/api/v1/ui-data/footer-info") // Public footer configuration
@@ -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
@@ -210,7 +210,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
@@ -1,53 +1,58 @@
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.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import org.springframework.core.io.Resource;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
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 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.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 final ApplicationProperties.Security securityProperties;
private final AppConfig appConfig;
private final JwtServiceInterface jwtService;
private final LogoutSuccessHandler samlLogoutHandler;
public CustomLogoutSuccessHandler(
ApplicationProperties.Security securityProperties, JwtServiceInterface jwtService) {
this(securityProperties, jwtService, null);
}
public CustomLogoutSuccessHandler(
ApplicationProperties.Security securityProperties,
JwtServiceInterface jwtService,
LogoutSuccessHandler samlLogoutHandler) {
this.securityProperties = securityProperties;
this.jwtService = jwtService;
this.samlLogoutHandler = samlLogoutHandler;
}
@Override
@Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC)
@@ -56,13 +61,14 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
throws IOException {
if (!response.isCommitted()) {
if (handleSamlLogout(request, response, authentication)) {
return;
}
if (authentication != null) {
if (authentication instanceof Saml2Authentication samlAuthentication) {
// Handle SAML2 logout redirection
getRedirect_saml2(request, response, samlAuthentication);
} else if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
// Handle OAuth2 logout redirection
getRedirect_oauth2(request, response, oAuthToken);
getRedirectOauth2(request, response, oAuthToken);
} else if (authentication instanceof UsernamePasswordAuthenticationToken) {
// Handle Username/Password logout
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
@@ -74,71 +80,152 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
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;
}
}
// Redirect to login page after logout
String path = checkForErrors(request);
getRedirectStrategy().sendRedirect(request, response, path);
// Redirect to login page after logout (handles error parameters if present)
String queryParams = checkForErrors(request);
getRedirectStrategy().sendRedirect(request, response, "/login?" + queryParams);
}
}
}
// Redirect for SAML2 authentication logout
private void getRedirect_saml2(
HttpServletRequest request,
HttpServletResponse response,
Saml2Authentication samlAuthentication)
/**
* Handles SAML logout - either via IdP Single Logout (SLO) or local logout.
*
* @return true if this was a SAML user and logout was handled, false otherwise
*/
private boolean handleSamlLogout(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
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);
return true;
}
}
SAML2 samlConf = securityProperties.getSaml2();
String registrationId = samlConf.getRegistrationId();
if (securityProperties.getSaml2().getEnableSingleLogout()) {
log.info("SP-initiated SLO detected, logging out via IdP");
CustomSaml2AuthenticatedPrincipal principal =
(CustomSaml2AuthenticatedPrincipal) samlAuthentication.getPrincipal();
if (authentication instanceof Saml2Authentication samlAuthentication) {
if (samlLogoutHandler != null) {
try {
samlLogoutHandler.onLogoutSuccess(request, response, samlAuthentication);
} catch (Exception e) {
log.error("SP-initiated SLO failed, falling back to local logout", e);
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
} else {
log.warn(
"SAML SLO enabled but handler not configured, performing local logout only");
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
String nameIdValue = principal.name();
return true;
} else {
// Reconstruct Saml2Authentication from JWT claims for SLO
Optional<Saml2Authentication> reconstructedAuth =
reconstructSaml2AuthenticationFromJwt(request);
if (reconstructedAuth.isPresent()) {
Saml2Authentication samlAuth = reconstructedAuth.get();
if (samlLogoutHandler != null) {
try {
samlLogoutHandler.onLogoutSuccess(request, response, samlAuth);
} catch (Exception e) {
log.error("SP-initiated SLO failed, falling back to local logout", e);
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
} else {
log.warn(
"SAML SLO enabled but handler not configured, performing local logout only");
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
return true;
}
}
}
return false;
}
/**
* 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.
*/
@SuppressWarnings("unchecked")
private Optional<Saml2Authentication> reconstructSaml2AuthenticationFromJwt(
HttpServletRequest request) {
try {
// Read certificate from the resource
Resource certificateResource = samlConf.getSpCert();
X509Certificate certificate = CertificateUtils.readCertificate(certificateResource);
String token = jwtService.extractToken(request);
if (token == null || token.isBlank()) {
return Optional.empty();
}
List<X509Certificate> certificates = new ArrayList<>();
certificates.add(certificate);
Map<String, Object> claims = jwtService.extractClaims(token);
Object authType = claims.get("authType");
// Construct URLs required for SAML configuration
SamlClient samlClient = getSamlClient(registrationId, samlConf, certificates);
if (authType == null || !"SAML2".equalsIgnoreCase(authType.toString())) {
return Optional.empty();
}
// Read private key for service provider
Resource privateKeyResource = samlConf.getPrivateKey();
RSAPrivateKey privateKey = CertificateUtils.readPrivateKey(privateKeyResource);
// 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");
// Set service provider keys for the SamlClient
samlClient.setSPKeys(certificate, privateKey);
if (nameId == null || registrationId == null) {
log.debug(
"Missing required SAML claims for SLO reconstruction: nameId={}, registrationId={}",
nameId,
registrationId);
return Optional.empty();
}
// Build relay state to return user to login page after IdP logout
String relayState =
UrlUtils.getOrigin(request) + request.getContextPath() + LOGOUT_PATH;
List<String> sessionIndexes = Collections.emptyList();
if (sessionIndexesObj instanceof List<?>) {
sessionIndexes =
((List<?>) sessionIndexesObj).stream().map(Object::toString).toList();
}
// 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);
// Create principal with all SAML attributes needed for SLO
CustomSaml2AuthenticatedPrincipal principal =
new CustomSaml2AuthenticatedPrincipal(
username,
Collections.emptyMap(), // Attributes not needed for logout
nameId,
sessionIndexes,
registrationId);
// 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")));
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(
private void getRedirectOauth2(
HttpServletRequest request,
HttpServletResponse response,
OAuth2AuthenticationToken oAuthToken)
@@ -169,7 +256,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
logoutUrl +=
"/protocol/openid-connect/logout"
+ "?client_id="
+ oauth.getClientId()
+ keycloak.getClientId()
+ "&post_logout_redirect_uri="
+ response.encodeRedirectURL(redirectUrl);
log.info("Redirecting to Keycloak logout URL: {}", logoutUrl);
@@ -196,30 +283,6 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
}
}
private SamlClient getSamlClient(
String registrationId, SAML2 samlConf, List<X509Certificate> certificates)
throws SamlException {
String serverUrl = appConfig.getBackendUrl() + ":" + appConfig.getServerPort();
String relyingPartyIdentifier =
serverUrl + "/saml2/service-provider-metadata/" + registrationId;
String assertionConsumerServiceUrl = serverUrl + "/login/saml2/sso/" + registrationId;
String idpSLOUrl = samlConf.getIdpSingleLogoutUrl();
String idpIssuer = samlConf.getIdpIssuer();
// Create SamlClient instance for SAML logout
return new SamlClient(
relyingPartyIdentifier,
assertionConsumerServiceUrl,
idpSLOUrl,
idpIssuer,
certificates,
SamlClient.SamlIdpBinding.POST);
}
/**
* Handles different error scenarios during logout. Will return a <code>String</code> containing
* the error request parameter.
@@ -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,7 +194,7 @@ 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);
@@ -227,10 +225,10 @@ public class SecurityConfiguration {
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 ->
@@ -250,17 +248,38 @@ 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,
jwtService,
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
@@ -384,9 +403,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();
@@ -231,29 +231,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
*
@@ -61,6 +61,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;
@@ -174,7 +179,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
userDetails, null, 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);
}
@@ -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"),
@@ -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 =
"""
@@ -258,6 +258,24 @@ public class JwtService implements JwtServiceInterface {
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;
}
@@ -6,9 +6,9 @@ import static org.mockito.Mockito.when;
import java.io.IOException;
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.junit.jupiter.MockitoExtension;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
@@ -26,17 +26,24 @@ class CustomLogoutSuccessHandlerTest {
@Mock private JwtServiceInterface jwtService;
@InjectMocks private CustomLogoutSuccessHandler customLogoutSuccessHandler;
@Mock private ApplicationProperties.Security.SAML2 saml2;
private CustomLogoutSuccessHandler customLogoutSuccessHandler;
@BeforeEach
void setUp() {
customLogoutSuccessHandler = new CustomLogoutSuccessHandler(securityProperties, jwtService);
}
@Test
void testSuccessfulLogout() 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(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
@@ -50,10 +57,10 @@ class CustomLogoutSuccessHandlerTest {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
String logoutPath = "/login?logout=true";
String token = "token";
when(response.isCommitted()).thenReturn(false);
when(jwtService.extractToken(request)).thenReturn(token);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
@@ -71,6 +78,9 @@ class CustomLogoutSuccessHandlerTest {
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("SAMLResponse")).thenReturn(null);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getScheme()).thenReturn("http");
@@ -96,6 +106,9 @@ class CustomLogoutSuccessHandlerTest {
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("SAMLResponse")).thenReturn(null);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
@@ -128,6 +141,9 @@ class CustomLogoutSuccessHandlerTest {
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("SAMLResponse")).thenReturn(null);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getParameter(error)).thenReturn("true");
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
@@ -152,6 +168,9 @@ class CustomLogoutSuccessHandlerTest {
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("SAMLResponse")).thenReturn(null);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn("!!!" + error + "!!!");
when(request.getScheme()).thenReturn("http");
@@ -177,10 +196,12 @@ class CustomLogoutSuccessHandlerTest {
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("SAMLResponse")).thenReturn(null);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).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);
@@ -204,6 +225,9 @@ class CustomLogoutSuccessHandlerTest {
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("SAMLResponse")).thenReturn(null);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
@@ -236,6 +260,9 @@ class CustomLogoutSuccessHandlerTest {
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("SAMLResponse")).thenReturn(null);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
@@ -269,6 +296,9 @@ class CustomLogoutSuccessHandlerTest {
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("SAMLResponse")).thenReturn(null);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
+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',
},
{
@@ -104,8 +104,8 @@ 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,