Skip to content

Add SAML-based SSO to UI & secure API calls#54

Merged
MaximumTrainer merged 4 commits intomainfrom
copilot/add-saml-based-sso
Apr 13, 2026
Merged

Add SAML-based SSO to UI & secure API calls#54
MaximumTrainer merged 4 commits intomainfrom
copilot/add-saml-based-sso

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 13, 2026

  • Backend: Add SAML dependency comment + Shibboleth repo instructions in build.gradle.kts
  • Backend: CorsConfig.kt with CORS configuration
  • Backend: SecurityConfig.kt — two ordered filter chains (API order=1, web fallback order=3)
    • Fix: Changed SessionCreationPolicy.STATELESSNEVER so existing SAML sessions are read by the API chain without creating new sessions
  • Backend: SamlSecurityConfig.kt — conditional SAML filter chain (order=2)
    • Fix: Updated @ConditionalOnProperty to use full property name with matchIfMissing = false; removed empty-default SAML section from application.yml so the property is absent unless the env var is explicitly set
  • Backend: AuthController.kt/api/auth/me endpoint
    • Fix: Changed @AuthenticationPrincipal UserDetails?Authentication? parameter so both JWT (UserDetails) and SAML (Saml2AuthenticatedPrincipal) principals work via Authentication.getName()
  • Backend: application.yml — removed spring.security.saml2 block with empty defaults; added documentation comment describing required env vars
  • Backend: SecurityFilterChainTest.kt
    • Fix: Replaced Kotlin assert() with Assertions.assertNotEquals() (JUnit) so assertions are reliable in CI without -ea flag
  • Backend: AuthControllerTest.kt — tests for /api/auth/me
  • Frontend: api/client.ts
    • Fix: Auth endpoints (/auth/login, /auth/register, /auth/me) are now exempted from the global 401 redirect, so login errors surface correctly and initializeFromSession() can detect "no session" silently without breaking public routes
  • Frontend: api/auth.tsme() function
  • Frontend: store/auth.tsinitializeFromSession()
  • Frontend: router/index.ts — SAML-aware navigation guard
  • Frontend: App.vue — session init on mount
  • Frontend: LoginView.vue — SSO button
  • Frontend: auth.store.test.ts — 6 SAML auth store tests
  • All backend tests pass
  • All frontend tests pass (14/14)

Copilot AI and others added 2 commits April 13, 2026 15:53
…ontend guards and tests

Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/2dcca1c1-b66b-4760-8bef-db04af8c9658

Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com>
…larify auth/me null check and duplicate matchers

Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/2dcca1c1-b66b-4760-8bef-db04af8c9658

Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com>
Copilot AI changed the title [WIP] Add SAML based SSO to UI and secure API calls Add SAML-based SSO to UI & secure API calls Apr 13, 2026
Copilot AI requested a review from MaximumTrainer April 13, 2026 15:58
@MaximumTrainer MaximumTrainer requested review from Copilot and removed request for MaximumTrainer April 13, 2026 16:00
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an optional SAML2 SSO flow alongside the existing JWT auth, aiming to (a) redirect unauthenticated browser users to the IdP while (b) keeping /api/** endpoints non-redirecting (401) and usable from the SPA with cookies/CSRF.

Changes:

  • Backend: split Spring Security into multiple ordered filter chains, add conditional SAML chain + CORS configuration, and add /api/auth/me session-check endpoint.
  • Frontend: enable cookie-based requests + CSRF header injection, add SAML-aware 401 handling and navigation redirects, and initialize auth state from either local JWT storage or /api/auth/me.
  • Tests/config: add frontend store tests, backend filter-chain tests, and test-profile CORS config.

Reviewed changes

Copilot reviewed 18 out of 19 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
frontend/src/views/LoginView.vue Adds a conditional “Sign in with SSO” entry point when SAML is enabled.
frontend/src/views/tests/auth.store.test.ts Adds vitest coverage for new auth-store session initialization behavior.
frontend/src/store/auth.ts Introduces initializeFromSession() and adjusts auth state derivation to support SAML sessions.
frontend/src/router/index.ts Updates route guard to redirect to SAML IdP when SAML is enabled.
frontend/src/App.vue Switches initialization from storage-only to session-aware initialization.
frontend/src/api/client.ts Enables withCredentials, adds CSRF header injection, and SAML-aware 401 redirect handling.
frontend/src/api/auth.ts Adds /auth/me client helper returning a minimal User for session-based auth.
frontend/package.json Adds jsdom for browser-like test environment.
frontend/package-lock.json Locks jsdom and transitive dependencies.
backend/src/test/resources/application-test.yml Adds test-profile CORS allowed-origins config.
backend/src/test/kotlin/com/opendatamask/infrastructure/security/SecurityFilterChainTest.kt Adds integration tests to validate 401 vs permitted behavior for the new filter chains.
backend/src/test/kotlin/com/opendatamask/adapter/input/rest/AuthControllerTest.kt Adds coverage for the new /api/auth/me endpoint behavior.
backend/src/main/resources/application.yml Adds SAML relying party configuration placeholders and CORS allowed-origins config.
backend/src/main/resources/application-test.yml Adds test-profile CORS allowed-origins config.
backend/src/main/kotlin/com/opendatamask/infrastructure/config/SecurityConfig.kt Introduces separate API vs web filter chains with ordering and CORS/CSRF policy differences.
backend/src/main/kotlin/com/opendatamask/infrastructure/config/SamlSecurityConfig.kt Adds a conditional SAML2 login/logout filter chain (order=2).
backend/src/main/kotlin/com/opendatamask/infrastructure/config/CorsConfig.kt Adds a shared CORS configuration source using opendatamask.cors.allowed-origins.
backend/src/main/kotlin/com/opendatamask/adapter/input/rest/AuthController.kt Adds /api/auth/me session-check endpoint.
backend/build.gradle.kts Documents (commented) SAML dependency + repository needed for OpenSAML transitive deps.
Files not reviewed (1)
  • frontend/package-lock.json: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +56 to 60
.securityMatcher("/api/**")
.cors { it.configurationSource(corsConfig.corsConfigurationSource()) }
.csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/api/** is configured as STATELESS with CSRF disabled, but the frontend now uses withCredentials + CSRF headers and AuthController.me is intended to detect a SAML session. With SessionCreationPolicy.STATELESS, Spring Security will not load the SecurityContext from the HTTP session, so SAML-authenticated browser sessions won’t be recognized on /api/** (likely causing /api/auth/me and other API calls to return 401 and potentially creating redirect loops). Consider using a session policy that can read an existing session (e.g., IF_REQUIRED) and enabling CSRF for cookie-based auth while still ignoring CSRF for Bearer-token requests.

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +37
@GetMapping("/me")
fun me(@AuthenticationPrincipal principal: UserDetails?): ResponseEntity<Map<String, String>> {
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()
}
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AuthenticationPrincipal principal: UserDetails? will only be populated for authentication types whose principal implements UserDetails (e.g., your JWT flow). For SAML2, the principal is typically a Saml2AuthenticatedPrincipal, so this parameter will be null and the endpoint will incorrectly return 401 even when the user is authenticated via SAML. Use a more general type (e.g., Principal, Authentication, or AuthenticatedPrincipal) and extract the name/attributes accordingly.

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +34
@Configuration
@ConditionalOnClass(name = ["org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository"])
@ConditionalOnProperty(prefix = "spring.security.saml2.relyingparty.registration", name = ["default.assertingparty.metadata-uri"])
class SamlSecurityConfig(
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ConditionalOnProperty here only checks for property presence, not that it’s non-empty. Since application.yml defines spring.security.saml2...metadata-uri with a default empty value (${SAML_IDP_METADATA_URI:}), this condition will evaluate true whenever the SAML classes are on the classpath—even when SAML isn’t configured. That undermines the “zero-impact when unconfigured” goal and may fail startup once the dependency is enabled. Consider conditioning on an explicit boolean flag, or use a condition that checks the metadata-uri is not blank, and/or avoid defining the property with an empty default in the baseline config.

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +51
val result = mockMvc.perform(get("/api/workspaces")).andReturn()
val responseStatus = result.response.status
assert(responseStatus != 401 && responseStatus != 403) {
"Expected authenticated request to be permitted but got HTTP $responseStatus"
}
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test uses Kotlin’s assert(...) for verification. JVM assertions are typically disabled unless tests are run with -ea, so this check may be skipped entirely in CI and locally. Prefer JUnit assertions (e.g., Assertions.assertNotEquals / assertTrue) or a MockMvc andExpect matcher to ensure the test fails reliably when the status is 401/403.

Copilot uses AI. Check for mistakes.
Comment thread frontend/src/api/client.ts Outdated
Comment on lines 44 to 61
// Handle 401 globally – clear local credentials and redirect to SAML IdP
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')

// If a SAML IdP is configured (detected by the presence of the
// SAML auth endpoint path in our app), redirect to the IdP.
// Otherwise fall back to the local login page.
const samlEnabled = import.meta.env.VITE_SAML_ENABLED === 'true'
if (samlEnabled) {
window.location.href = SAML_AUTH_ENDPOINT
} else {
router.push('/login')
}
}
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The global 401 interceptor redirects on any 401, including calls to /api/auth/me and potentially /api/auth/login/register. Since App.vue now calls initializeFromSession() on mount, an unauthenticated page load will trigger /auth/me → 401 → redirect to /login, which breaks access to routes marked public (e.g., /register) and can also prevent the login form from showing “invalid credentials” errors (it will redirect instead). Consider exempting auth endpoints from the global redirect (or letting callers handle 401 for /auth/me/login), and only redirecting for 401s from protected API calls.

Copilot uses AI. Check for mistakes.
@MaximumTrainer
Copy link
Copy Markdown
Owner

@copilot apply changes based on the comments in this thread

… ConditionalOnProperty, JUnit assertions, 401 auth exemptions

Agent-Logs-Url: https://github.com/MaximumTrainer/OpenDataMask/sessions/36e94f78-88b6-4b49-825f-84f2061837f9

Co-authored-by: MaximumTrainer <1376575+MaximumTrainer@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 13, 2026

@copilot apply changes based on the comments in this thread

All five review issues addressed in commit 8756f07:

  1. SessionCreationPolicy.STATELESSNEVER (SecurityConfig.kt): The API filter chain now reads existing sessions (e.g. a SAML session) without creating new ones, so SAML-authenticated browser users can call /api/** endpoints using their session cookie.

  2. @AuthenticationPrincipal UserDetails?Authentication? (AuthController.kt): The /api/auth/me endpoint now uses Authentication.getName() which works for both JWT (UserDetails principal) and SAML (Saml2AuthenticatedPrincipal).

  3. @ConditionalOnProperty empty-default fix (SamlSecurityConfig.kt + application.yml): Removed the entire spring.security.saml2 block from application.yml so the metadata-uri property is absent from the Spring Environment unless SAML_IDP_METADATA_URI is explicitly set. Updated @ConditionalOnProperty to use the full property name with matchIfMissing = false. The SAML chain is now truly zero-impact when unconfigured.

  4. Kotlin assert() → JUnit Assertions.assertNotEquals() (SecurityFilterChainTest.kt): Replaced the JVM-assertions-dependent check with Assertions.assertNotEquals(401, ...) and Assertions.assertNotEquals(403, ...) which always run in CI.

  5. Auth endpoint 401 exemption (client.ts): The global 401 interceptor now skips the redirect for /auth/login, /auth/register, and /auth/me. Login errors surface correctly to the form, initializeFromSession() detects "no active session" silently, and public routes like /register remain accessible.

@MaximumTrainer
Copy link
Copy Markdown
Owner

@copilot apply changes based on the comments in this thread

Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 13, 2026

@copilot apply changes based on the comments in this thread

All five changes from the review thread were already applied in commit 8756f07:

  1. SessionCreationPolicy.STATELESSNEVER in SecurityConfig.kt
  2. @AuthenticationPrincipal UserDetails?Authentication? in AuthController.kt
  3. Removed empty-default SAML block from application.yml; tightened @ConditionalOnProperty with matchIfMissing = false in SamlSecurityConfig.kt
  4. Replaced Kotlin assert() with Assertions.assertNotEquals() in SecurityFilterChainTest.kt
  5. Auth endpoints (/auth/login, /auth/register, /auth/me) exempted from global 401 redirect in client.ts

@MaximumTrainer MaximumTrainer marked this pull request as ready for review April 13, 2026 18:01
@MaximumTrainer MaximumTrainer merged commit 68072b9 into main Apr 13, 2026
11 checks passed
@MaximumTrainer MaximumTrainer deleted the copilot/add-saml-based-sso branch April 13, 2026 18:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add SAML based SSO to UI & secure API calls

3 participants