Skip to content

feat: migrate OIDC to AGPLv3#253

Open
AdrianLeeElder wants to merge 2 commits intodevelopfrom
migrte-sso-to-open-core
Open

feat: migrate OIDC to AGPLv3#253
AdrianLeeElder wants to merge 2 commits intodevelopfrom
migrte-sso-to-open-core

Conversation

@AdrianLeeElder
Copy link
Contributor

@AdrianLeeElder AdrianLeeElder commented Mar 15, 2026

Description

Per #186, I've decided to allow OIDC without requiring a license, and focus on allowing enterprise-grade features like enforce SSO for the enterprise license.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added OIDC SSO support for all deployments (open-core, always free)
    • Added SAML SSO and SSO enforcement as enterprise features
    • Added subscription endpoint to retrieve organization's pricing tier
    • Enhanced user profile responses to include organization role
  • Documentation

    • Updated documentation to clarify OIDC (free) vs. SAML (enterprise) SSO licensing
    • Added SSO configuration guides for both provider types
    • Updated billing and features documentation

@coderabbitai
Copy link

coderabbitai bot commented Mar 15, 2026

Warning

Rate limit exceeded

@AdrianLeeElder has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 9 minutes and 37 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 83226271-96ca-4ffa-8e0b-de8f7c675315

📥 Commits

Reviewing files that changed from the base of the PR and between 8199bf1 and 23d9776.

📒 Files selected for processing (5)
  • backend/src/main/kotlin/com/moneat/sso/routes/SsoRoutes.kt
  • backend/src/main/kotlin/com/moneat/sso/services/SsoService.kt
  • dashboard/src/components/SsoSettings.tsx
  • ee/backend/src/main/kotlin/com/moneat/enterprise/sso/routes/SamlRoutes.kt
  • ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SamlService.kt
📝 Walkthrough

Walkthrough

This PR restructures SSO functionality by promoting OIDC to open-core availability and establishing a separate enterprise SAML module. It introduces core SSO services, routes, and models; adds organization role tracking to user responses; creates a new subscription endpoint; and updates documentation and dashboard UI to reflect licensing distinctions.

Changes

Cohort / File(s) Summary
Documentation & Configuration
.env.example, CONTRIBUTING.md, README.md, ee/README.md, dashboard/src/docs/pages/*
Updated SSO feature descriptions distinguishing OIDC (open-core, no license required) from SAML (enterprise, licensed). Added OIDC/SAML subsections and licensing notes to documentation.
Core OIDC SSO Module
backend/src/main/kotlin/com/moneat/sso/, backend/src/main/resources/META-INF/services/com.moneat.enterprise.EnterpriseModule
Introduced new OIDC SSO service infrastructure: SsoService (OIDC/state/token handling), SsoRoutes (public/protected endpoints for OIDC callback, config management), SsoModels (moved from enterprise), and SsoModule (EnterpriseModule registration). Provides initialization, callback handling, config CRUD, and user provisioning.
Enterprise SAML Module Refactoring
ee/backend/src/main/kotlin/com/moneat/enterprise/sso/, ee/backend/src/main/resources/META-INF/services/com.moneat.enterprise.EnterpriseModule
Renamed SsoModule to SamlModule; introduced dedicated SamlService for SAML flows (initSaml, handleSamlResponse, getSamlMetadata) and SamlRoutes for SAML-specific endpoints (/init/saml, /acs, /metadata). Removed legacy SsoService and SsoRoutes from enterprise layer.
User Response & API Changes
backend/src/main/kotlin/com/moneat/events/models/ApiModels.kt, backend/src/main/kotlin/com/moneat/auth/services/AuthService.kt, backend/src/main/kotlin/com/moneat/events/routes/ApiRoutes.kt
Added orgRole field to UserResponse data class; updated signup flow to populate slug and role. Introduced new GET /subscription endpoint returning pricing tier. Modified user/profile retrieval to thread orgRole through transactional result (Triple→Quadruple).
Dashboard Frontend
dashboard/src/components/SsoSettings.tsx, dashboard/src/routes/settings.tsx, dashboard/src/hooks/useAuth.ts, dashboard/src/lib/api/modules/user.ts
Updated SsoSettings component with hasSamlModule prop, disabling SAML UI when enterprise module unavailable. Refactored SSO access logic to gate on SAML module availability and tier requirements. Added orgRole mapping in user state initialization and API types.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant SsoRoutes as SsoRoutes<br/>(Core)
    participant SsoService
    participant IdP as External IdP
    participant DB as Database
    participant AuthService

    Client->>SsoRoutes: POST /auth/sso/init (email/orgSlug)
    SsoRoutes->>SsoService: initSso(email, orgSlug)
    SsoService->>DB: Fetch SSO config
    SsoService->>SsoService: generateSecureState(orgId)
    SsoService-->>SsoRoutes: SsoInitResponse (redirect URL, state)
    SsoRoutes-->>Client: Redirect to IdP

    Client->>IdP: Authenticate
    IdP-->>Client: Redirect with code+state

    Client->>SsoRoutes: GET /auth/sso/oidc/callback?code=X&state=Y
    SsoRoutes->>SsoService: handleOidcCallback(code, state)
    SsoService->>SsoService: decodeState(state)
    SsoService->>SsoService: discoverOidcEndpoints(issuer)
    SsoService->>IdP: Exchange code for token
    IdP-->>SsoService: ID token
    SsoService->>SsoService: Extract user info
    SsoService->>DB: findOrCreateSsoUser()
    SsoService->>DB: addToOrgIfNeeded()
    SsoService->>AuthService: generateToken(userId, email)
    AuthService-->>SsoService: JWT
    SsoService-->>SsoRoutes: SsoCallbackData
    SsoRoutes-->>Client: Set auth cookie, redirect to frontend
Loading
sequenceDiagram
    participant Client
    participant SamlRoutes as SamlRoutes<br/>(Enterprise)
    participant SamlService
    participant IdP as SAML IdP
    participant DB as Database

    Client->>SamlRoutes: POST /auth/sso/init/saml (email/orgSlug)
    SamlRoutes->>SamlService: initSaml(email, orgSlug)
    SamlService->>DB: Fetch SAML config by domain/org
    SamlService->>SamlService: generateSamlRequest(config, state)
    SamlService->>SamlService: generateSecureState(orgId)
    SamlService-->>SamlRoutes: SsoInitResponse
    SamlRoutes-->>Client: Redirect to IdP with SAML request

    Client->>IdP: Authenticate
    IdP-->>Client: SAML Response (POST to ACS)

    Client->>SamlRoutes: POST /auth/sso/acs (SAMLResponse)
    SamlRoutes->>SamlService: handleSamlResponse(response, relayState)
    SamlService->>SamlService: decodeState(relayState)
    SamlService->>DB: Fetch SAML config
    SamlService->>SamlService: buildSamlSettings(config)
    SamlService->>SamlService: Validate & extract assertion
    SamlService->>DB: findOrCreateSsoUser()
    SamlService-->>SamlRoutes: SsoCallbackData
    SamlRoutes-->>Client: Set auth cookie, redirect
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 A clever split of auth's great dance,
OIDC free for all to prance!
While SAML wears its enterprise crown,
Routes reorganized throughout the town. 🔐
With orgRole tracked and state secured,
Our SSO flows are now assured! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main objective of the PR: migrating OIDC SSO functionality to the open-source AGPLv3 codebase, which is the primary purpose of the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch migrte-sso-to-open-core
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can disable sequence diagrams in the walkthrough.

Disable the reviews.sequence_diagrams setting to disable sequence diagrams in the walkthrough.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/src/main/kotlin/com/moneat/auth/services/AuthService.kt (1)

538-548: ⚠️ Potential issue | 🟠 Major

Populate org context in every AuthResponse.user.

This path now sets organizationSlug/orgRole, but signup(), login(), and refreshToken() in this same file still build UserResponse without them. The dashboard now gates Team/SSO off user.orgRole, so the same user can get different UI capabilities depending on which auth response last hydrated state. Please return the same org context from all AuthService UserResponse builders.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/main/kotlin/com/moneat/auth/services/AuthService.kt` around lines
538 - 548, AuthService builds UserResponse without org context in signup(),
login(), and refreshToken() causing inconsistent UI; update those UserResponse
constructors to include the same organizationSlug and orgRole values used
elsewhere (i.e. compute the final organization slug (finalSlug or equivalent
from membership.organization?.slug) and use membership.role for orgRole) and
pass them into the UserResponse instead of the current null, so every auth path
returns a UserResponse populated with organizationSlug and orgRole.
🧹 Nitpick comments (7)
ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SamlService.kt (3)

157-204: Simplify getSamlMetadata with early return and requireNotNull.

The method structure can be simplified. Also, the null check at line 183 is redundant after the query - if orgSlug is null, the exception is thrown at line 178-180, so ssoConfig being null is the actual check needed.

♻️ Proposed fix
     fun getSamlMetadata(orgSlug: String?): String =
         transaction {
-            val ssoConfig =
-                if (orgSlug != null) {
-                    val org =
-                        Organizations
-                            .selectAll()
-                            .where { Organizations.slug eq orgSlug }
-                            .firstOrNull()
-                            ?: throw IllegalArgumentException(
-                                "Organization not found"
-                            )
-
-                    SsoConfigurations
-                        .selectAll()
-                        .where {
-                            (SsoConfigurations.organizationId eq org[Organizations.id]) and
-                                (SsoConfigurations.providerType eq "saml") and
-                                (SsoConfigurations.isEnabled eq true)
-                        }.firstOrNull()
-                } else {
-                    throw IllegalArgumentException(
-                        "Organization slug is required"
-                    )
-                }
-
-            if (ssoConfig == null) {
-                throw IllegalArgumentException(
-                    "SAML SSO is not configured for this organization"
-                )
-            }
+            requireNotNull(orgSlug) { "Organization slug is required" }
+            
+            val org = Organizations
+                .selectAll()
+                .where { Organizations.slug eq orgSlug }
+                .firstOrNull()
+                ?: throw IllegalArgumentException("Organization not found")
+
+            val ssoConfig = requireNotNull(
+                SsoConfigurations
+                    .selectAll()
+                    .where {
+                        (SsoConfigurations.organizationId eq org[Organizations.id]) and
+                            (SsoConfigurations.providerType eq "saml") and
+                            (SsoConfigurations.isEnabled eq true)
+                    }.firstOrNull()
+            ) { "SAML SSO is not configured for this organization" }

             val settings = buildSamlSettings(ssoConfig)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SamlService.kt`
around lines 157 - 204, Refactor getSamlMetadata to simplify control flow:
validate orgSlug early with requireNotNull (or throw) and load the organization
via Organizations.selectAll().where { Organizations.slug eq orgSlug
}.firstOrNull(), then fetch the SSO row from SsoConfigurations (filtering
organizationId, providerType "saml", isEnabled) and use requireNotNull on that
result (instead of the later if (ssoConfig == null) block); keep calling
buildSamlSettings(ssoConfig), validate metadata with
Saml2Settings.validateMetadata, log errors with logger.error and throw if
invalid, and return metadata—this removes nested if/else and the redundant null
check while preserving existing validations.

74-78: Use requireNotNull() for null checks with exceptions.

This pattern can be simplified using the idiomatic requireNotNull().

♻️ Proposed fix
-            if (ssoConfig == null) {
-                throw IllegalArgumentException(
-                    "SAML SSO is not configured for this organization"
-                )
-            }
+            requireNotNull(ssoConfig) {
+                "SAML SSO is not configured for this organization"
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SamlService.kt`
around lines 74 - 78, Replace the manual null check for ssoConfig in SamlService
with the idiomatic Kotlin requireNotNull usage: use requireNotNull(ssoConfig)
with the same message ("SAML SSO is not configured for this organization") and
assign the result to a non-null val (or rebind) so subsequent code uses the
non-null value; this keeps the IllegalArgumentException semantics but simplifies
the check and makes the intent clearer.

38-42: Use require() for argument preconditions.

Per coding guidelines, require() should be used for argument preconditions in helper/library code. This service method validates input arguments and should use the idiomatic Kotlin approach.

♻️ Proposed fix
-        if (email == null && orgSlug == null) {
-            throw IllegalArgumentException(
-                "Either email or orgSlug must be provided"
-            )
-        }
+        require(!(email == null && orgSlug == null)) {
+            "Either email or orgSlug must be provided"
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SamlService.kt`
around lines 38 - 42, Replace the manual null-check and throw with Kotlin's
idiomatic require() precondition: in SamlService.kt where the code currently
checks if (email == null && orgSlug == null) and throws
IllegalArgumentException("Either email or orgSlug must be provided"), change it
to use require(email != null || orgSlug != null) { "Either email or orgSlug must
be provided" } so the argument validation uses the standard Kotlin helper
instead of an explicit throw; update the surrounding method in SamlService
accordingly.
ee/backend/src/main/kotlin/com/moneat/enterprise/sso/routes/SamlRoutes.kt (1)

27-96: Consider extracting error handling to reduce cognitive complexity.

Static analysis reports cognitive complexity of 18 (threshold: 15). The repetitive try-catch patterns across routes contribute to this. While the current code is readable and consistent, you could optionally extract a helper for error handling.

This is a minor concern for a route file with standard error handling patterns. The current implementation is clear and maintainable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/backend/src/main/kotlin/com/moneat/enterprise/sso/routes/SamlRoutes.kt`
around lines 27 - 96, The three route handlers in samlRoutes()
(post("/init/saml"), get("/saml/metadata"), post("/saml/acs")) repeat identical
try/catch logic causing high cognitive complexity; extract a reusable helper
(e.g., withErrorHandling or handleSamlRoute) that accepts a suspend lambda and
centralizes the IllegalArgumentException and generic Exception
handling/logging/response behavior so you can replace the inline try/catch in
each handler while still invoking SamlService.initSaml,
SamlService.getSamlMetadata, and SamlService.handleSamlResponse and preserving
AuthCookieUtils.setAuthCookie and the same redirects/responses.
backend/src/main/kotlin/com/moneat/sso/services/SsoService.kt (2)

112-114: Consider using require() for preconditions in service code.

SonarCloud suggests replacing this if-throw with require(). Since this is service/library code (not a route handler), using require() is idiomatic Kotlin for argument validation. As per coding guidelines, require() is appropriate "for internal argument preconditions in helper/library code."

♻️ Proposed refactor
-if (email == null && orgSlug == null) {
-    throw IllegalArgumentException("Either email or orgSlug must be provided")
-}
+require(email != null || orgSlug != null) {
+    "Either email or orgSlug must be provided"
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/main/kotlin/com/moneat/sso/services/SsoService.kt` around lines
112 - 114, Replace the manual if-throw precondition in SsoService (where the
method checks email and orgSlug) with Kotlin's idiomatic require(): instead of
the current if (email == null && orgSlug == null) throw
IllegalArgumentException(...), call require(email != null || orgSlug != null) {
"Either email or orgSlug must be provided" } so the argument validation uses
require() for this service/library-level precondition.

266-398: High cognitive complexity (24 vs. 15 allowed) — consider decomposition.

SonarCloud flags this method for exceeding cognitive complexity limits. The function handles authorization, validation for multiple provider types, and persistence (both insert and update paths). Consider extracting:

  1. validateSsoConfigRequest() for provider-specific validation
  2. persistSsoConfig() for the insert/update logic

This would improve readability and testability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/main/kotlin/com/moneat/sso/services/SsoService.kt` around lines
266 - 398, The configureSso method in SsoService is too complex; extract
provider-specific validation and DB persistence into helper functions to reduce
cognitive complexity: create validateSsoConfigRequest(providerType, request)
(called from configureSso before transactions) that encapsulates SAML/OIDC field
checks and UrlValidator usage, and create persistSsoConfig(organizationId,
request, providerType, baseUrl) that performs the encryptSecret logic, computes
effectiveRequireSso, and executes the insert-or-update against SsoConfigurations
(preserving current field assignments and updatedAt behavior), then have
configureSso call these helpers and return getSsoConfig(organizationId) as
before. Ensure new helpers reference the same symbols used now (SsoProviderType,
UrlValidator, encryptSecret, FeatureRegistry.hasModule("SAML"),
SsoConfigurations, getSsoConfig) and keep all transactional DB access inside
persistSsoConfig to maintain existing behavior.
dashboard/src/hooks/useAuth.ts (1)

47-51: Use globalThis.sessionStorage for SSR compatibility.

The hook accesses sessionStorage directly. Per coding guidelines, prefer globalThis.sessionStorage over bare globals for SSR compatibility.

♻️ Proposed fix
         // Keep session flag in sync
-        sessionStorage.setItem('authenticated', 'true')
+        globalThis.sessionStorage.setItem('authenticated', 'true')
       })
       .catch(() => {
         setUser(null)
-        sessionStorage.removeItem('authenticated')
+        globalThis.sessionStorage.removeItem('authenticated')
       })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dashboard/src/hooks/useAuth.ts` around lines 47 - 51, The hook uses the
global sessionStorage directly which breaks SSR; update references in useAuth
(the promise handlers that call sessionStorage.setItem and
sessionStorage.removeItem) to use globalThis.sessionStorage instead, i.e.,
replace direct sessionStorage access in the success and .catch blocks (where
setUser is called) with globalThis.sessionStorage to ensure safe access during
server-side rendering.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/src/main/kotlin/com/moneat/sso/routes/SsoRoutes.kt`:
- Around line 161-207: The current catch-all for IllegalArgumentException in the
put("/config") handler misclassifies authorization/licensing failures as 400;
change the error handling so only validation errors are returned as 400 and let
domain/authorization/licensing errors propagate to the central StatusPages
mapper (or be thrown as domain-specific exceptions). Concretely: in SsoRoutes'
put("/config") block stop catching IllegalArgumentException broadly—catch only
BadRequestException (or equivalent validation errors) and respond
HttpStatusCode.BadRequest with ErrorResponse; remove the
IllegalArgumentException catch so configureSso(orgId, userId, request) can throw
domain-specific exceptions (e.g., an AuthorizationException or
LicensingException) or IllegalArgumentException/IllegalStateException to be
mapped by StatusPages to 403/appropriate codes. Ensure ssoService.configureSso
emits domain-specific exceptions for owner/licensing checks rather than
IllegalArgumentException if not already.

In `@backend/src/main/kotlin/com/moneat/sso/services/SsoService.kt`:
- Around line 789-806: The consumeNonce function currently swallows
non-IllegalArgumentExceptions when Redis is unavailable, allowing nonce replay;
change this to "fail closed" by replacing the generic exception handler so that
any Redis-access failure (exceptions from RedisConfig.sync().get/del) results in
throwing a clear runtime exception (e.g., IllegalStateException or a custom
SsoVerificationException) with context including the nonceB64 and
SSO_NONCE_PREFIX rather than only logger.warn; keep the existing behavior of
rethrowing IllegalArgumentException for explicit nonce reuse, but for all other
exceptions from RedisConfig ensure you propagate an error to abort the SSO flow
instead of silently continuing.
- Around line 620-622: The current use of kotlinx.coroutines.runBlocking in
discoverOidcEndpoints blocks the DB transaction thread (called from transaction
contexts like handleOidcCallback) and risks exhausting the connection pool;
change discoverOidcEndpoints to be a suspending function that performs the
httpClient.get call without runBlocking and either (a) invoke it before entering
the transaction in handleOidcCallback, or (b) if it must run inside
transactional logic, call it from within a coroutine-aware transaction using
newSuspendedTransaction so the HTTP call does not block the DB thread.
- Around line 642-649: The code force-unwraps issuerUrl when calling
discoverOidcEndpoints(issuerUrl!!) even though earlier validation only runs if
issuerUrl is non-blank; make issuerUrl non-null before use by either enforcing
an early check (e.g., requireNotNull or throw a descriptive exception) or by
moving UrlValidator.validateExternalUrl to run unconditionally after a non-null
assertion, and then call discoverOidcEndpoints with the guaranteed-non-null
issuerUrl; update references in this block: issuerUrl,
SsoConfigurations.oidcIssuerUrl, UrlValidator.validateExternalUrl, and
discoverOidcEndpoints to reflect the non-null contract.
- Around line 103-106: The current lazy property encryptionKey in SsoService
uses jwtSecret.toByteArray().copyOf(AES_KEY_LENGTH) which pads/truncates
insecurely; replace this with a proper key derivation step (e.g., HKDF or
PBKDF2) that deterministically derives a fixed-length AES key from jwtSecret and
any necessary salt/info, then return the derived key bytes (AES_KEY_LENGTH) as
the encryptionKey; locate the encryptionKey property and update it to call a KDF
helper (implement e.g., deriveAesKeyFromJwtSecret(jwtSecret): ByteArray using
javax.crypto.SecretKeyFactory/PBKDF2 or an HKDF implementation, and wrap result
in a SecretKeySpec or copyOfExact to AES_KEY_LENGTH), ensuring errors are
handled and no silent truncation/padding occurs.

In `@dashboard/src/components/SsoSettings.tsx`:
- Around line 308-325: The switch is disabled whenever hasSamlModule is false,
which prevents downgraded orgs from turning off an already-true
formData.requireSso; update the Switch so it's only disabled when the module is
missing AND the current value is false (e.g. disabled={!hasSamlModule &&
!formData.requireSso}), keeping the existing onCheckedChange (setFormData) logic
so users can flip requireSso off even after a downgrade; ensure you update the
Switch component usage in SsoSettings (checked={formData.requireSso},
onCheckedChange, disabled) accordingly.

In `@dashboard/src/routes/settings.tsx`:
- Around line 172-174: The SSO access logic is too permissive: derive a view
gate and an owner-only configure gate so the UI matches backend entitlements.
Replace the single canUseSso with two booleans, e.g. canViewSsoTab (true if
isSelfHosted && hasSamlModule OR (not isSelfHosted && (tier === 'TEAM' || tier
=== 'BUSINESS'))) and canConfigureSso (true only when user?.orgRole === 'owner'
and likewise scoped to self-hosted/module or SaaS tiers), and update usages of
configureSso() and the tab rendering to use canConfigureSso for editable actions
and canViewSsoTab for visibility; keep hasSamlModule considered only for
self-hosted paths and do not grant SaaS FREE-tier access based on the global
features list.

In `@ee/backend/src/main/kotlin/com/moneat/enterprise/sso/routes/SamlRoutes.kt`:
- Line 29: The code in SamlRoutes.kt uses EnvConfig.get("FRONTEND_URL")!! which
can NPE; replace this with a safe retrieval that either fails fast with a
descriptive exception or provides a sensible dev default. Locate the frontendUrl
assignment in SamlRoutes and change it to use a null-coalescing check on
EnvConfig.get("FRONTEND_URL") (e.g., EnvConfig.get(...) ?: throw
IllegalStateException("FRONTEND_URL must be set for SAML routes")) or supply a
clearly documented default (e.g., "http://localhost:3000") so the app won't
crash with a raw NPE. Ensure the error message references FRONTEND_URL and SAML
startup so logs are actionable.

In
`@ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SamlService.kt`:
- Around line 114-123: The SAML response validation is using relayState
incorrectly; change the flow so that when you create the AuthnRequest
(authnRequest.getId()) you capture and store that requestId alongside the relay
state, and then call samlResponseObj.isValid(requestId) instead of
samlResponseObj.isValid(relayState); update SamlService (the code around
SamlResponse, SamlResponse.loadXmlFromBase64 and the isValid call) to retrieve
the stored requestId and pass it into isValid(requestId), and remove relayState
from the isValid invocation so InResponseTo is validated correctly.

---

Outside diff comments:
In `@backend/src/main/kotlin/com/moneat/auth/services/AuthService.kt`:
- Around line 538-548: AuthService builds UserResponse without org context in
signup(), login(), and refreshToken() causing inconsistent UI; update those
UserResponse constructors to include the same organizationSlug and orgRole
values used elsewhere (i.e. compute the final organization slug (finalSlug or
equivalent from membership.organization?.slug) and use membership.role for
orgRole) and pass them into the UserResponse instead of the current null, so
every auth path returns a UserResponse populated with organizationSlug and
orgRole.

---

Nitpick comments:
In `@backend/src/main/kotlin/com/moneat/sso/services/SsoService.kt`:
- Around line 112-114: Replace the manual if-throw precondition in SsoService
(where the method checks email and orgSlug) with Kotlin's idiomatic require():
instead of the current if (email == null && orgSlug == null) throw
IllegalArgumentException(...), call require(email != null || orgSlug != null) {
"Either email or orgSlug must be provided" } so the argument validation uses
require() for this service/library-level precondition.
- Around line 266-398: The configureSso method in SsoService is too complex;
extract provider-specific validation and DB persistence into helper functions to
reduce cognitive complexity: create validateSsoConfigRequest(providerType,
request) (called from configureSso before transactions) that encapsulates
SAML/OIDC field checks and UrlValidator usage, and create
persistSsoConfig(organizationId, request, providerType, baseUrl) that performs
the encryptSecret logic, computes effectiveRequireSso, and executes the
insert-or-update against SsoConfigurations (preserving current field assignments
and updatedAt behavior), then have configureSso call these helpers and return
getSsoConfig(organizationId) as before. Ensure new helpers reference the same
symbols used now (SsoProviderType, UrlValidator, encryptSecret,
FeatureRegistry.hasModule("SAML"), SsoConfigurations, getSsoConfig) and keep all
transactional DB access inside persistSsoConfig to maintain existing behavior.

In `@dashboard/src/hooks/useAuth.ts`:
- Around line 47-51: The hook uses the global sessionStorage directly which
breaks SSR; update references in useAuth (the promise handlers that call
sessionStorage.setItem and sessionStorage.removeItem) to use
globalThis.sessionStorage instead, i.e., replace direct sessionStorage access in
the success and .catch blocks (where setUser is called) with
globalThis.sessionStorage to ensure safe access during server-side rendering.

In `@ee/backend/src/main/kotlin/com/moneat/enterprise/sso/routes/SamlRoutes.kt`:
- Around line 27-96: The three route handlers in samlRoutes()
(post("/init/saml"), get("/saml/metadata"), post("/saml/acs")) repeat identical
try/catch logic causing high cognitive complexity; extract a reusable helper
(e.g., withErrorHandling or handleSamlRoute) that accepts a suspend lambda and
centralizes the IllegalArgumentException and generic Exception
handling/logging/response behavior so you can replace the inline try/catch in
each handler while still invoking SamlService.initSaml,
SamlService.getSamlMetadata, and SamlService.handleSamlResponse and preserving
AuthCookieUtils.setAuthCookie and the same redirects/responses.

In
`@ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SamlService.kt`:
- Around line 157-204: Refactor getSamlMetadata to simplify control flow:
validate orgSlug early with requireNotNull (or throw) and load the organization
via Organizations.selectAll().where { Organizations.slug eq orgSlug
}.firstOrNull(), then fetch the SSO row from SsoConfigurations (filtering
organizationId, providerType "saml", isEnabled) and use requireNotNull on that
result (instead of the later if (ssoConfig == null) block); keep calling
buildSamlSettings(ssoConfig), validate metadata with
Saml2Settings.validateMetadata, log errors with logger.error and throw if
invalid, and return metadata—this removes nested if/else and the redundant null
check while preserving existing validations.
- Around line 74-78: Replace the manual null check for ssoConfig in SamlService
with the idiomatic Kotlin requireNotNull usage: use requireNotNull(ssoConfig)
with the same message ("SAML SSO is not configured for this organization") and
assign the result to a non-null val (or rebind) so subsequent code uses the
non-null value; this keeps the IllegalArgumentException semantics but simplifies
the check and makes the intent clearer.
- Around line 38-42: Replace the manual null-check and throw with Kotlin's
idiomatic require() precondition: in SamlService.kt where the code currently
checks if (email == null && orgSlug == null) and throws
IllegalArgumentException("Either email or orgSlug must be provided"), change it
to use require(email != null || orgSlug != null) { "Either email or orgSlug must
be provided" } so the argument validation uses the standard Kotlin helper
instead of an explicit throw; update the surrounding method in SamlService
accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 565bd550-8405-415c-8bda-50d57079dd73

📥 Commits

Reviewing files that changed from the base of the PR and between 90ee642 and 8199bf1.

📒 Files selected for processing (24)
  • .env.example
  • CONTRIBUTING.md
  • README.md
  • backend/src/main/kotlin/com/moneat/auth/services/AuthService.kt
  • backend/src/main/kotlin/com/moneat/events/models/ApiModels.kt
  • backend/src/main/kotlin/com/moneat/events/routes/ApiRoutes.kt
  • backend/src/main/kotlin/com/moneat/sso/SsoModule.kt
  • backend/src/main/kotlin/com/moneat/sso/models/SsoModels.kt
  • backend/src/main/kotlin/com/moneat/sso/routes/SsoRoutes.kt
  • backend/src/main/kotlin/com/moneat/sso/services/SsoService.kt
  • backend/src/main/resources/META-INF/services/com.moneat.enterprise.EnterpriseModule
  • dashboard/src/components/SsoSettings.tsx
  • dashboard/src/docs/pages/billing.mdx
  • dashboard/src/docs/pages/sso-authentication.mdx
  • dashboard/src/hooks/useAuth.ts
  • dashboard/src/lib/api/modules/user.ts
  • dashboard/src/routes/settings.tsx
  • ee/README.md
  • ee/backend/src/main/kotlin/com/moneat/enterprise/sso/SamlModule.kt
  • ee/backend/src/main/kotlin/com/moneat/enterprise/sso/routes/SamlRoutes.kt
  • ee/backend/src/main/kotlin/com/moneat/enterprise/sso/routes/SsoRoutes.kt
  • ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SamlService.kt
  • ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SsoService.kt
  • ee/backend/src/main/resources/META-INF/services/com.moneat.enterprise.EnterpriseModule
💤 Files with no reviewable changes (2)
  • ee/backend/src/main/kotlin/com/moneat/enterprise/sso/routes/SsoRoutes.kt
  • ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SsoService.kt

Comment on lines +161 to +207
put("/config") {
try {
val principal = call.principal<JWTPrincipal>()
val userId =
principal?.payload?.getClaim("userId")?.asInt()
?: return@put call.respond(
HttpStatusCode.Unauthorized,
ErrorResponse("Invalid token")
)

val request = call.receive<SsoConfigRequest>()
val orgId =
call.parameters["organizationId"]?.toIntOrNull()
?: return@put call.respond(
HttpStatusCode.BadRequest,
ErrorResponse("Missing organizationId")
)

// Reject SAML config when SAML module is not loaded
val providerType = SsoProviderType.fromString(
request.providerType
)
if (providerType == SsoProviderType.SAML &&
!FeatureRegistry.hasModule("SAML")
) {
return@put call.respond(
HttpStatusCode.Forbidden,
ErrorResponse(
"SAML SSO requires an enterprise license"
)
)
}

val config = ssoService.configureSso(
orgId,
userId,
request,
)
call.respond(config)
} catch (e: IllegalArgumentException) {
logger.error(e) {
"Configure SSO failed: ${e.message}"
}
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse(e.message)
)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find the SsoService file
fd -t f SsoService.kt

Repository: moneat-io/moneat

Length of output: 121


🏁 Script executed:

# Look at the configureSso method to see what exceptions it throws
rg -A 50 "fun configureSso" --type kt

Repository: moneat-io/moneat

Length of output: 86


🏁 Script executed:

# Check for StatusPages configuration in the application
rg -i "statuspage" --type kt -B 2 -A 5

Repository: moneat-io/moneat

Length of output: 86


🏁 Script executed:

# Examine the full put("/config") handler to see the complete context
sed -n '145,220p' backend/src/main/kotlin/com/moneat/sso/routes/SsoRoutes.kt

Repository: moneat-io/moneat

Length of output: 3145


🏁 Script executed:

# Read the SsoService.configureSso implementation
sed -n '1,50p' backend/src/main/kotlin/com/moneat/sso/services/SsoService.kt

Repository: moneat-io/moneat

Length of output: 2230


🏁 Script executed:

# Search for the configureSso method definition with broader context
rg "fun configureSso" -A 100

Repository: moneat-io/moneat

Length of output: 10480


🏁 Script executed:

# Look for exception throwing patterns in SsoService
rg "throw IllegalArgumentException" -B 5 -A 2

Repository: moneat-io/moneat

Length of output: 50373


🏁 Script executed:

# Check for StatusPages module configuration
rg "install\(StatusPages" -A 20

Repository: moneat-io/moneat

Length of output: 3634


🏁 Script executed:

# Get full StatusPages configuration from Monitoring.kt
cat backend/src/main/kotlin/com/moneat/plugins/Monitoring.kt

Repository: moneat-io/moneat

Length of output: 7457


🏁 Script executed:

# Look for BadRequestException or domain exception definitions
rg "BadRequestException|ForbiddenException|UnauthorizedException" --type kt

Repository: moneat-io/moneat

Length of output: 86


🏁 Script executed:

# Search for any exception handler configuration
rg "exception<.*>" --type kt -A 3

Repository: moneat-io/moneat

Length of output: 86


🏁 Script executed:

# Search for exception class definitions
rg "class.*Exception|object.*Exception" | grep -v ".class" | head -20

Repository: moneat-io/moneat

Length of output: 42


🏁 Script executed:

# Check imports in SsoRoutes to see what exception types are available
head -40 backend/src/main/kotlin/com/moneat/sso/routes/SsoRoutes.kt

Repository: moneat-io/moneat

Length of output: 1699


🏁 Script executed:

# Look for any other route handlers that catch IllegalArgumentException to see pattern
rg "catch.*IllegalArgumentException" -B 5 -A 5 | head -80

Repository: moneat-io/moneat

Length of output: 8050


🏁 Script executed:

# Check if BadRequestException is available from Ktor or if it's a custom class
rg "BadRequestException" --type kt | head -10

Repository: moneat-io/moneat

Length of output: 86


🏁 Script executed:

# Look at the full context around the put("/config") catch block to understand all error types
sed -n '145,230p' backend/src/main/kotlin/com/moneat/sso/routes/SsoRoutes.kt

Repository: moneat-io/moneat

Length of output: 3654


Don't report owner/licensing failures as HTTP 400.

configureSso() throws IllegalArgumentException for authorization failures ("Only organization owners can configure SSO") and entitlement failures ("SSO enforcement requires an enterprise license"). Catching all IllegalArgumentException here and returning 400 Bad Request misrepresents both as request validation errors. Per the coding guideline for **/routes/**/*.kt, throw domain-specific exceptions or use BadRequestException only for actual validation errors, and map IllegalArgumentException and IllegalStateException centrally via the StatusPages plugin. Authorization failures should return 403, not 400.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/main/kotlin/com/moneat/sso/routes/SsoRoutes.kt` around lines 161
- 207, The current catch-all for IllegalArgumentException in the put("/config")
handler misclassifies authorization/licensing failures as 400; change the error
handling so only validation errors are returned as 400 and let
domain/authorization/licensing errors propagate to the central StatusPages
mapper (or be thrown as domain-specific exceptions). Concretely: in SsoRoutes'
put("/config") block stop catching IllegalArgumentException broadly—catch only
BadRequestException (or equivalent validation errors) and respond
HttpStatusCode.BadRequest with ErrorResponse; remove the
IllegalArgumentException catch so configureSso(orgId, userId, request) can throw
domain-specific exceptions (e.g., an AuthorizationException or
LicensingException) or IllegalArgumentException/IllegalStateException to be
mapped by StatusPages to 403/appropriate codes. Ensure ssoService.configureSso
emits domain-specific exceptions for owner/licensing checks rather than
IllegalArgumentException if not already.

Comment on lines +103 to +106
private val encryptionKey: ByteArray by lazy {
val key = jwtSecret.toByteArray()
key.copyOf(AES_KEY_LENGTH)
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Encryption key derivation truncates JWT secret unpredictably.

jwtSecret.toByteArray().copyOf(AES_KEY_LENGTH) pads with zeros if the secret is shorter than 32 bytes, which weakens the key. If the secret is longer, it silently truncates. Consider using a proper key derivation function (e.g., HKDF) to derive a fixed-length AES key from the JWT secret.

🔐 Proposed fix using HKDF-like derivation
 private val encryptionKey: ByteArray by lazy {
-    val key = jwtSecret.toByteArray()
-    key.copyOf(AES_KEY_LENGTH)
+    val mac = Mac.getInstance("HmacSHA256")
+    mac.init(SecretKeySpec(jwtSecret.toByteArray(), "HmacSHA256"))
+    mac.doFinal("sso-encryption-key".toByteArray()).copyOf(AES_KEY_LENGTH)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/main/kotlin/com/moneat/sso/services/SsoService.kt` around lines
103 - 106, The current lazy property encryptionKey in SsoService uses
jwtSecret.toByteArray().copyOf(AES_KEY_LENGTH) which pads/truncates insecurely;
replace this with a proper key derivation step (e.g., HKDF or PBKDF2) that
deterministically derives a fixed-length AES key from jwtSecret and any
necessary salt/info, then return the derived key bytes (AES_KEY_LENGTH) as the
encryptionKey; locate the encryptionKey property and update it to call a KDF
helper (implement e.g., deriveAesKeyFromJwtSecret(jwtSecret): ByteArray using
javax.crypto.SecretKeyFactory/PBKDF2 or an HKDF implementation, and wrap result
in a SecretKeySpec or copyOfExact to AES_KEY_LENGTH), ensuring errors are
handled and no silent truncation/padding occurs.

Comment on lines +620 to +622
val response = kotlinx.coroutines.runBlocking {
httpClient.get(discoveryUrl).bodyAsText()
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

runBlocking inside a transaction blocks thread pool — potential for thread starvation.

discoverOidcEndpoints is called from within transaction blocks (e.g., handleOidcCallback at line 209). Using runBlocking here blocks the database connection thread while waiting for the HTTP request, which can exhaust the connection pool under load. Consider:

  1. Making the HTTP call before entering the transaction, or
  2. Using a suspending function with newSuspendedTransaction.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/main/kotlin/com/moneat/sso/services/SsoService.kt` around lines
620 - 622, The current use of kotlinx.coroutines.runBlocking in
discoverOidcEndpoints blocks the DB transaction thread (called from transaction
contexts like handleOidcCallback) and risks exhausting the connection pool;
change discoverOidcEndpoints to be a suspending function that performs the
httpClient.get call without runBlocking and either (a) invoke it before entering
the transaction in handleOidcCallback, or (b) if it must run inside
transactional logic, call it from within a coroutine-aware transaction using
newSuspendedTransaction so the HTTP call does not block the DB thread.

Comment on lines +642 to +649
val issuerUrl = ssoConfig[SsoConfigurations.oidcIssuerUrl]
if (!issuerUrl.isNullOrBlank()) {
UrlValidator.validateExternalUrl(issuerUrl)
}
val clientId = ssoConfig[SsoConfigurations.oidcClientId]
val redirectUri = "$baseUrl/auth/sso/oidc/callback"

val endpoints = discoverOidcEndpoints(issuerUrl!!)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Potential NPE: issuerUrl!! force-unwrap when issuerUrl could be null.

Line 643 validates only if !issuerUrl.isNullOrBlank(), so if issuerUrl is null, validation is skipped. However, line 649 then force-unwraps with issuerUrl!!, which would throw an NPE. The null check should throw early, or the validation should be unconditional.

🐛 Proposed fix
 private fun generateOidcRequest(
     ssoConfig: org.jetbrains.exposed.v1.core.ResultRow,
     state: String,
 ): String {
     val issuerUrl = ssoConfig[SsoConfigurations.oidcIssuerUrl]
-    if (!issuerUrl.isNullOrBlank()) {
-        UrlValidator.validateExternalUrl(issuerUrl)
-    }
+    requireNotNull(issuerUrl) { "OIDC issuer URL not configured" }
+    UrlValidator.validateExternalUrl(issuerUrl)
     val clientId = ssoConfig[SsoConfigurations.oidcClientId]
     val redirectUri = "$baseUrl/auth/sso/oidc/callback"

-    val endpoints = discoverOidcEndpoints(issuerUrl!!)
+    val endpoints = discoverOidcEndpoints(issuerUrl)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val issuerUrl = ssoConfig[SsoConfigurations.oidcIssuerUrl]
if (!issuerUrl.isNullOrBlank()) {
UrlValidator.validateExternalUrl(issuerUrl)
}
val clientId = ssoConfig[SsoConfigurations.oidcClientId]
val redirectUri = "$baseUrl/auth/sso/oidc/callback"
val endpoints = discoverOidcEndpoints(issuerUrl!!)
val issuerUrl = ssoConfig[SsoConfigurations.oidcIssuerUrl]
requireNotNull(issuerUrl) { "OIDC issuer URL not configured" }
UrlValidator.validateExternalUrl(issuerUrl)
val clientId = ssoConfig[SsoConfigurations.oidcClientId]
val redirectUri = "$baseUrl/auth/sso/oidc/callback"
val endpoints = discoverOidcEndpoints(issuerUrl)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/main/kotlin/com/moneat/sso/services/SsoService.kt` around lines
642 - 649, The code force-unwraps issuerUrl when calling
discoverOidcEndpoints(issuerUrl!!) even though earlier validation only runs if
issuerUrl is non-blank; make issuerUrl non-null before use by either enforcing
an early check (e.g., requireNotNull or throw a descriptive exception) or by
moving UrlValidator.validateExternalUrl to run unconditionally after a non-null
assertion, and then call discoverOidcEndpoints with the guaranteed-non-null
issuerUrl; update references in this block: issuerUrl,
SsoConfigurations.oidcIssuerUrl, UrlValidator.validateExternalUrl, and
discoverOidcEndpoints to reflect the non-null contract.

Comment on lines +308 to 325
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="requireSso" className="text-base font-medium">
<Label htmlFor="requireSso" className="text-sm font-medium">
Require SSO
{!hasSamlModule && (
<Badge variant="secondary" className="ml-2 text-[10px]">Enterprise</Badge>
)}
</Label>
<p className="text-sm text-muted-foreground">
<p className="text-xs text-muted-foreground">
Block password login for users in this organization
</p>
</div>
<Switch
id="requireSso"
checked={formData.requireSso}
onCheckedChange={(checked) => setFormData({ ...formData, requireSso: checked })}
disabled={!hasSamlModule}
/>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Let downgraded orgs turn Require SSO off.

If requireSso is already true and hasSamlModule flips to false, this switch renders checked and disabled, so every save still posts requireSso: true. The backend rejects that flag without the module, which leaves the user unable to clear enforcement from the UI or save unrelated SSO edits.

Suggested fix
-                <Switch
-                  id="requireSso"
-                  checked={formData.requireSso}
-                  onCheckedChange={(checked) => setFormData({ ...formData, requireSso: checked })}
-                  disabled={!hasSamlModule}
-                />
+                <Switch
+                  id="requireSso"
+                  checked={formData.requireSso}
+                  onCheckedChange={(checked) => setFormData({ ...formData, requireSso: checked })}
+                  disabled={!hasSamlModule && !formData.requireSso}
+                />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="requireSso" className="text-base font-medium">
<Label htmlFor="requireSso" className="text-sm font-medium">
Require SSO
{!hasSamlModule && (
<Badge variant="secondary" className="ml-2 text-[10px]">Enterprise</Badge>
)}
</Label>
<p className="text-sm text-muted-foreground">
<p className="text-xs text-muted-foreground">
Block password login for users in this organization
</p>
</div>
<Switch
id="requireSso"
checked={formData.requireSso}
onCheckedChange={(checked) => setFormData({ ...formData, requireSso: checked })}
disabled={!hasSamlModule}
/>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y.5">
<Label htmlFor="requireSso" className="text-sm font-medium">
Require SSO
{!hasSamlModule && (
<Badge variant="secondary" className="ml-2 text-[10px]">Enterprise</Badge>
)}
</Label>
<p className="text-xs text-muted-foreground">
Block password login for users in this organization
</p>
</div>
<Switch
id="requireSso"
checked={formData.requireSso}
onCheckedChange={(checked) => setFormData({ ...formData, requireSso: checked })}
disabled={!hasSamlModule && !formData.requireSso}
/>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dashboard/src/components/SsoSettings.tsx` around lines 308 - 325, The switch
is disabled whenever hasSamlModule is false, which prevents downgraded orgs from
turning off an already-true formData.requireSso; update the Switch so it's only
disabled when the module is missing AND the current value is false (e.g.
disabled={!hasSamlModule && !formData.requireSso}), keeping the existing
onCheckedChange (setFormData) logic so users can flip requireSso off even after
a downgrade; ensure you update the Switch component usage in SsoSettings
(checked={formData.requireSso}, onCheckedChange, disabled) accordingly.

Comment on lines +172 to +174
const hasSamlModule = hasEnterpriseModule(features, 'saml')
const canManageTeam = user?.orgRole === 'admin' || user?.orgRole === 'owner'
const canUseSso = canManageTeam && (isSelfHosted || hasSamlModule || (!isSelfHosted && (tier === 'TEAM' || tier === 'BUSINESS')))
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Tighten the SSO tab gate to match backend entitlements and ownership.

hasEnterpriseModule(features, 'saml') is derived from the deployment-wide /v1/features modules list, so on SaaS it can make canUseSso true for FREE-tier orgs whenever the SAML module is loaded. canManageTeam also includes admins, but configureSso() only allows organization owners. As written, users can land on a fully editable SSO tab that the API will reject. Split this into a view gate and an owner-only configure gate, and keep SaaS access tied to TEAM/BUSINESS.

Also applies to: 325-327

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dashboard/src/routes/settings.tsx` around lines 172 - 174, The SSO access
logic is too permissive: derive a view gate and an owner-only configure gate so
the UI matches backend entitlements. Replace the single canUseSso with two
booleans, e.g. canViewSsoTab (true if isSelfHosted && hasSamlModule OR (not
isSelfHosted && (tier === 'TEAM' || tier === 'BUSINESS'))) and canConfigureSso
(true only when user?.orgRole === 'owner' and likewise scoped to
self-hosted/module or SaaS tiers), and update usages of configureSso() and the
tab rendering to use canConfigureSso for editable actions and canViewSsoTab for
visibility; keep hasSamlModule considered only for self-hosted paths and do not
grant SaaS FREE-tier access based on the global features list.


fun Route.samlRoutes() {
val samlService = SamlService()
val frontendUrl = EnvConfig.get("FRONTEND_URL")!!
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Handle missing FRONTEND_URL gracefully.

Using !! on EnvConfig.get("FRONTEND_URL") will throw a NullPointerException at runtime if the environment variable is not set. Consider failing fast with a descriptive error or providing a sensible default for development.

🛡️ Proposed fix
-    val frontendUrl = EnvConfig.get("FRONTEND_URL")!!
+    val frontendUrl = EnvConfig.get("FRONTEND_URL")
+        ?: error("FRONTEND_URL environment variable is required for SAML SSO")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val frontendUrl = EnvConfig.get("FRONTEND_URL")!!
val frontendUrl = EnvConfig.get("FRONTEND_URL")
?: error("FRONTEND_URL environment variable is required for SAML SSO")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/backend/src/main/kotlin/com/moneat/enterprise/sso/routes/SamlRoutes.kt` at
line 29, The code in SamlRoutes.kt uses EnvConfig.get("FRONTEND_URL")!! which
can NPE; replace this with a safe retrieval that either fails fast with a
descriptive exception or provides a sensible dev default. Locate the frontendUrl
assignment in SamlRoutes and change it to use a null-coalescing check on
EnvConfig.get("FRONTEND_URL") (e.g., EnvConfig.get(...) ?: throw
IllegalStateException("FRONTEND_URL must be set for SAML routes")) or supply a
clearly documented default (e.g., "http://localhost:3000") so the app won't
crash with a raw NPE. Ensure the error message references FRONTEND_URL and SAML
startup so logs are actionable.

Comment on lines +114 to +123
val samlResponseObj = SamlResponse(settings, null)
samlResponseObj.loadXmlFromBase64(samlResponse)

if (!samlResponseObj.isValid(relayState)) {
val error = samlResponseObj.error
logger.error { "SAML authentication failed: $error" }
throw IllegalArgumentException(
"SAML authentication failed: $error"
)
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

OneLogin java-saml SamlResponse isValid method signature parameters

💡 Result:

In OneLogin’s java-saml toolkit, com.onelogin.saml2.authn.SamlResponse exposes these isValid overloads:

  • public boolean isValid() [1]
  • public boolean isValid(String requestId) — where requestId is the AuthNRequest ID your SP sent to the IdP [1]

Notes on the requestId parameter: passing null affects how InResponseTo is accepted/rejected depending on Saml2Settings.setRejectUnsolicitedResponsesWithInResponseTo(...) [2].

Sources: [1] [2]


🏁 Script executed:

# Read the SamlService.kt file around lines 114-123
sed -n '100,140p' ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SamlService.kt

Repository: moneat-io/moneat

Length of output: 1585


🏁 Script executed:

# Find the method signature containing this code
sed -n '70,120p' ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SamlService.kt

Repository: moneat-io/moneat

Length of output: 1866


🏁 Script executed:

# Search for generateSamlRequest method and AuthnRequest handling
sed -n '1,70p' ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SamlService.kt

Repository: moneat-io/moneat

Length of output: 2783


🏁 Script executed:

# Search for generateSamlRequest and AuthnRequest usage to see if requestId is captured
rg -A 15 "fun generateSamlRequest" ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SamlService.kt

Repository: moneat-io/moneat

Length of output: 579


🏁 Script executed:

# Check what's in the AuthnRequest to understand how to get its ID
rg -A 20 "val authnRequest = AuthnRequest" ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SamlService.kt

Repository: moneat-io/moneat

Length of output: 902


🏁 Script executed:

# Search for how AuthnRequest ID is used elsewhere or stored
rg "AuthnRequest" ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SamlService.kt

Repository: moneat-io/moneat

Length of output: 218


🏁 Script executed:

# Check what AuthnRequest provides - look for getId method
rg "getId\|getAuthnRequest" ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/

Repository: moneat-io/moneat

Length of output: 42


🏁 Script executed:

# Check the method signature more broadly - what parameters does handleSamlResponse receive
sed -n '85,125p' ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SamlService.kt

Repository: moneat-io/moneat

Length of output: 1425


Use isValid(requestId) instead of isValid(relayState) for proper SAML response validation.

The OneLogin java-saml library's SamlResponse.isValid() method accepts either no parameters or a String requestId parameter (the ID from the AuthnRequest) for InResponseTo validation. The code currently passes relayState, which is the CSRF state token and not a valid parameter.

To fix this, capture the AuthnRequest ID when creating the AuthnRequest (via authnRequest.getId()), store it alongside the relay state, and pass it to isValid(requestId) to properly validate the InResponseTo attribute.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SamlService.kt`
around lines 114 - 123, The SAML response validation is using relayState
incorrectly; change the flow so that when you create the AuthnRequest
(authnRequest.getId()) you capture and store that requestId alongside the relay
state, and then call samlResponseObj.isValid(requestId) instead of
samlResponseObj.isValid(relayState); update SamlService (the code around
SamlResponse, SamlResponse.loadXmlFromBase64 and the isValid call) to retrieve
the stored requestId and pass it into isValid(requestId), and remove relayState
from the isValid invocation so InResponseTo is validated correctly.

@sonarqubecloud
Copy link

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.

1 participant