From 819ce1cadb7c74c7159040c8a648871b87dd99a0 Mon Sep 17 00:00:00 2001 From: Adrian Elder Date: Sun, 15 Mar 2026 16:03:39 -0400 Subject: [PATCH 1/6] feat: migrate OIDC to AGPLv3 --- .env.example | 9 +- CONTRIBUTING.md | 6 +- README.md | 4 +- .../com/moneat/auth/services/AuthService.kt | 1 + .../com/moneat/events/models/ApiModels.kt | 1 + .../com/moneat/events/routes/ApiRoutes.kt | 28 +- .../main/kotlin/com/moneat/sso/SsoModule.kt | 45 + .../com/moneat}/sso/models/SsoModels.kt | 20 +- .../kotlin/com/moneat/sso/routes/SsoRoutes.kt | 289 ++++++ .../com/moneat/sso/services/SsoService.kt | 820 +++++++++++++++++ .../com.moneat.enterprise.EnterpriseModule | 1 + dashboard/src/components/SsoSettings.tsx | 115 +-- dashboard/src/docs/pages/billing.mdx | 6 +- .../src/docs/pages/sso-authentication.mdx | 20 +- dashboard/src/hooks/useAuth.ts | 1 + dashboard/src/lib/api/modules/user.ts | 1 + dashboard/src/routes/settings.tsx | 11 +- ee/README.md | 9 +- .../sso/{SsoModule.kt => SamlModule.kt} | 15 +- .../enterprise/sso/routes/SamlRoutes.kt | 97 ++ .../moneat/enterprise/sso/routes/SsoRoutes.kt | 218 ----- .../enterprise/sso/services/SamlService.kt | 318 +++++++ .../enterprise/sso/services/SsoService.kt | 825 ------------------ .../com.moneat.enterprise.EnterpriseModule | 2 +- 24 files changed, 1733 insertions(+), 1129 deletions(-) create mode 100644 backend/src/main/kotlin/com/moneat/sso/SsoModule.kt rename {ee/backend/src/main/kotlin/com/moneat/enterprise => backend/src/main/kotlin/com/moneat}/sso/models/SsoModels.kt (72%) create mode 100644 backend/src/main/kotlin/com/moneat/sso/routes/SsoRoutes.kt create mode 100644 backend/src/main/kotlin/com/moneat/sso/services/SsoService.kt rename ee/backend/src/main/kotlin/com/moneat/enterprise/sso/{SsoModule.kt => SamlModule.kt} (58%) create mode 100644 ee/backend/src/main/kotlin/com/moneat/enterprise/sso/routes/SamlRoutes.kt delete mode 100644 ee/backend/src/main/kotlin/com/moneat/enterprise/sso/routes/SsoRoutes.kt create mode 100644 ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SamlService.kt delete mode 100644 ee/backend/src/main/kotlin/com/moneat/enterprise/sso/services/SsoService.kt diff --git a/.env.example b/.env.example index 54853823..69677d0f 100644 --- a/.env.example +++ b/.env.example @@ -113,13 +113,20 @@ LEGAL_PRIVACY_VERSION=2026-02-08 # ---------------------------------------------------------------------------- # Enterprise License -# RSA-signed license key to activate enterprise modules (SSO, On-Call). +# RSA-signed license key to activate enterprise modules (SAML SSO, On-Call). # Without this, only the open-source core is active. # Enterprise modules are subject to the Moneat Enterprise License (see ee/LICENSE). # Contact licensing@moneat.io or visit https://moneat.io/pricing to obtain a key. # ---------------------------------------------------------------------------- MONEAT_LICENSE_KEY= +# ---------------------------------------------------------------------------- +# SSO (OIDC) +# OpenID Connect single sign-on. Works with any OIDC provider (Authentik, +# Authelia, Keycloak, Okta, Azure AD, etc.). No license key required. +# Configure via the dashboard under Settings > SSO. +# ---------------------------------------------------------------------------- + # ---------------------------------------------------------------------------- # Slack Integration # ---------------------------------------------------------------------------- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dd11a22e..6da38b84 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,9 +74,9 @@ cd dashboard && npm install && npm run dev ### Working on Enterprise Features -Enterprise modules (On-Call, SSO) live in the `ee/` directory and are licensed under the [Moneat Enterprise License](ee/LICENSE). The `ee/` directory is a Gradle subproject included as a `runtimeOnly` dependency — enterprise classes are on the classpath at runtime but never referenced at compile time from core code (except the `EnterpriseModule` interface in `FeatureRegistry`). +Enterprise modules (On-Call, SAML SSO) live in the `ee/` directory and are licensed under the [Moneat Enterprise License](ee/LICENSE). OIDC SSO is part of the open core and always available without a license key. The `ee/` directory is a Gradle subproject included as a `runtimeOnly` dependency — enterprise classes are on the classpath at runtime but never referenced at compile time from core code (except the `EnterpriseModule` interface in `FeatureRegistry`). -Enterprise modules are always built with the project. At runtime, the `FeatureRegistry` uses Java `ServiceLoader` to discover `EnterpriseModule` implementations. Licensed modules (SSO, On-Call) only activate when a valid `MONEAT_LICENSE_KEY` is set. +Enterprise modules are always built with the project. At runtime, the `FeatureRegistry` uses Java `ServiceLoader` to discover `EnterpriseModule` implementations. Licensed modules (SAML SSO, On-Call) only activate when a valid `MONEAT_LICENSE_KEY` is set. ```bash # 1. Start databases @@ -93,7 +93,7 @@ cd dashboard && npm install && npm run dev | Variable | Required for | |----------|-------------| -| `MONEAT_LICENSE_KEY` | Activating licensed enterprise modules (SSO, On-Call) | +| `MONEAT_LICENSE_KEY` | Activating licensed enterprise modules (SAML SSO, On-Call) | | `TWILIO_ACCOUNT_SID` / `TWILIO_AUTH_TOKEN` / `TWILIO_FROM_NUMBER` | On-Call voice alerts | | `SAML_CERT` / `SAML_KEY` / `SAML_ENTITY_ID` | SSO (SAML) | diff --git a/README.md b/README.md index 37478434..add4ae9a 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,8 @@ Moneat is Sentry SDK, Datadog Agent, and OpenTelemetry (OTLP) compatible. Point | User Feedback | Sentry-compatible feedback ingestion with status workflows | [Docs](https://moneat.io/docs) | | Datadog Compatibility | Ingest from existing Datadog agents with no code changes | [Docs](https://moneat.io/docs) | | On-Call & Incidents | PagerDuty-style escalations *(Enterprise)* | [Pricing](https://moneat.io/pricing) | +| SSO (OIDC) | Sign in with any OpenID Connect provider | [Docs](https://moneat.io/docs) | +| SSO (SAML) & Enforcement | SAML 2.0 and mandatory SSO *(Enterprise)* | [Pricing](https://moneat.io/pricing) | | Terraform Provider | Manage Moneat resources as code | [Registry](https://registry.terraform.io/providers/moneat-io/moneat/latest) | ### Sentry SDK Compatibility @@ -268,7 +270,7 @@ Copyright © 2026 Moneat | `ee/` | [Moneat Enterprise License](ee/LICENSE) | | Everything else | [GNU AGPLv3](LICENSE) | -Enterprise modules are gated by a signed license key (`MONEAT_LICENSE_KEY`). Without a valid key, only the open-source core is active. The AGPL does not apply to files in `ee/`. +Enterprise modules are gated by a signed license key (`MONEAT_LICENSE_KEY`). Without a valid key, only the open-source core is active. OIDC SSO is part of the open core and always available. SAML SSO and SSO enforcement require an enterprise license. The AGPL does not apply to files in `ee/`. For licensing questions: [licensing@moneat.io](mailto:licensing@moneat.io) diff --git a/backend/src/main/kotlin/com/moneat/auth/services/AuthService.kt b/backend/src/main/kotlin/com/moneat/auth/services/AuthService.kt index 8e76eb0f..2bfb172c 100644 --- a/backend/src/main/kotlin/com/moneat/auth/services/AuthService.kt +++ b/backend/src/main/kotlin/com/moneat/auth/services/AuthService.kt @@ -543,6 +543,7 @@ class AuthService( true, user.isAdmin, finalSlug, + membership.role, null, hiddenItems ) diff --git a/backend/src/main/kotlin/com/moneat/events/models/ApiModels.kt b/backend/src/main/kotlin/com/moneat/events/models/ApiModels.kt index 7503ba5f..0d36a44e 100644 --- a/backend/src/main/kotlin/com/moneat/events/models/ApiModels.kt +++ b/backend/src/main/kotlin/com/moneat/events/models/ApiModels.kt @@ -54,6 +54,7 @@ data class UserResponse( val onboardingCompleted: Boolean = false, val isAdmin: Boolean = false, val organizationSlug: String? = null, + val orgRole: String? = null, val demoEpochMs: Long? = null, val sidebarHiddenItems: List = emptyList(), val phoneNumber: String? = null, diff --git a/backend/src/main/kotlin/com/moneat/events/routes/ApiRoutes.kt b/backend/src/main/kotlin/com/moneat/events/routes/ApiRoutes.kt index a100a737..1cb1c3d7 100644 --- a/backend/src/main/kotlin/com/moneat/events/routes/ApiRoutes.kt +++ b/backend/src/main/kotlin/com/moneat/events/routes/ApiRoutes.kt @@ -17,8 +17,10 @@ package com.moneat.events.routes import com.moneat.auth.routes.accountDeletionRoutes +import com.moneat.auth.services.Quadruple import com.moneat.billing.routes.billingRoutes import com.moneat.billing.routes.publicBillingRoutes +import com.moneat.billing.services.PricingTierService import com.moneat.events.models.AddTargetRequest import com.moneat.events.models.AlertNotificationPreferencesResponse import com.moneat.events.models.CreateProjectRequest @@ -94,6 +96,24 @@ fun Route.apiRoutes() { // Protected billing routes billingRoutes() + // Subscription tier (for SSO visibility, etc.) + get("/subscription") { + val principal = call.principal() + val userId = principal!!.payload.getClaim("userId").asInt() + val pricingTierService = koin.get() + val orgId = + pricingTierService.getPrimaryOrganizationIdForUser(userId) ?: run { + call.respond(HttpStatusCode.NotFound, ErrorResponse("No organization access")) + return@get + } + val context = pricingTierService.getEffectiveTierForOrganization(orgId) + call.respond( + mapOf( + "tier" to mapOf("tierName" to context.tier.tierName) + ) + ) + } + // Integrations integrationRoutes() @@ -103,11 +123,11 @@ fun Route.apiRoutes() { val userId = principal!!.payload.getClaim("userId").asInt() val demoEpochMs = call.getDemoEpochMs() - val (user, orgSlug, sidebarHiddenItems) = + val (user, orgSlug, orgRole, sidebarHiddenItems) = transaction { val userRow = Users.selectAll().where { Users.id eq userId }.firstOrNull() - ?: return@transaction Triple(null, null, emptyList()) + ?: return@transaction Quadruple(null, null, null, emptyList()) val membership = Memberships @@ -124,9 +144,10 @@ fun Route.apiRoutes() { ?.get(Organizations.slug) } + val role = membership?.get(Memberships.role) val hiddenItems = membership?.get(Memberships.sidebar_hidden_items) ?: emptyList() - Triple(userRow, slug, hiddenItems) + Quadruple(userRow, slug, role, hiddenItems) } if (user == null) { @@ -141,6 +162,7 @@ fun Route.apiRoutes() { user[Users.onboarding_completed], user[Users.is_admin], orgSlug, + orgRole, demoEpochMs, sidebarHiddenItems, user[Users.phone_number], diff --git a/backend/src/main/kotlin/com/moneat/sso/SsoModule.kt b/backend/src/main/kotlin/com/moneat/sso/SsoModule.kt new file mode 100644 index 00000000..63d6ce84 --- /dev/null +++ b/backend/src/main/kotlin/com/moneat/sso/SsoModule.kt @@ -0,0 +1,45 @@ +// Moneat - observability platform +// Copyright (C) 2026 Moneat +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package com.moneat.sso + +import com.moneat.enterprise.EnterpriseModule +import com.moneat.sso.routes.ssoRoutes +import io.ktor.server.application.Application +import io.ktor.server.routing.Route + +/** + * Core SSO module providing OIDC single sign-on for all deployments. + * No license required (licenseFeature = null, always loaded). + * + * SAML 2.0 and SSO enforcement ("Require SSO") require the enterprise + * SamlModule (licenseFeature = "sso"). + */ +class SsoModule : EnterpriseModule { + override val name: String = "SSO" + + override fun registerRoutes(route: Route) { + route.ssoRoutes() + } + + override fun startBackgroundJobs(application: Application) { + // SSO has no background jobs + } + + override fun stopBackgroundJobs() { + // No-op + } +} diff --git a/ee/backend/src/main/kotlin/com/moneat/enterprise/sso/models/SsoModels.kt b/backend/src/main/kotlin/com/moneat/sso/models/SsoModels.kt similarity index 72% rename from ee/backend/src/main/kotlin/com/moneat/enterprise/sso/models/SsoModels.kt rename to backend/src/main/kotlin/com/moneat/sso/models/SsoModels.kt index f69c4202..429958e3 100644 --- a/ee/backend/src/main/kotlin/com/moneat/enterprise/sso/models/SsoModels.kt +++ b/backend/src/main/kotlin/com/moneat/sso/models/SsoModels.kt @@ -1,8 +1,20 @@ -// Moneat Enterprise - proprietary module -// Copyright (c) 2026 Moneat. All rights reserved. -// See ee/LICENSE for license terms. +// Moneat - observability platform +// Copyright (C) 2026 Moneat +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . -package com.moneat.enterprise.sso.models +package com.moneat.sso.models import kotlinx.serialization.Serializable diff --git a/backend/src/main/kotlin/com/moneat/sso/routes/SsoRoutes.kt b/backend/src/main/kotlin/com/moneat/sso/routes/SsoRoutes.kt new file mode 100644 index 00000000..6d618d29 --- /dev/null +++ b/backend/src/main/kotlin/com/moneat/sso/routes/SsoRoutes.kt @@ -0,0 +1,289 @@ +// Moneat - observability platform +// Copyright (C) 2026 Moneat +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package com.moneat.sso.routes + +import com.moneat.config.EnvConfig +import com.moneat.enterprise.FeatureRegistry +import com.moneat.sso.models.SsoConfigRequest +import com.moneat.sso.models.SsoInitRequest +import com.moneat.sso.models.SsoProviderType +import com.moneat.sso.services.SsoService +import com.moneat.shared.models.Memberships +import com.moneat.utils.AuthCookieUtils +import com.moneat.utils.ErrorResponse +import com.moneat.utils.MessageResponse +import io.ktor.http.HttpStatusCode +import io.ktor.server.auth.authenticate +import io.ktor.server.auth.jwt.JWTPrincipal +import io.ktor.server.auth.principal +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.response.respondRedirect +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.put +import io.ktor.server.routing.route +import mu.KotlinLogging +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.transactions.transaction + +private val logger = KotlinLogging.logger {} + +fun Route.ssoRoutes() { + val ssoService = SsoService() + val frontendUrl = EnvConfig.get("FRONTEND_URL")!! + + route("/auth/sso") { + // Public OIDC SSO flow endpoints + post("/init") { + try { + val request = call.receive() + val response = ssoService.initSso( + request.email, + request.orgSlug, + ) + call.respond(response) + } catch (e: IllegalArgumentException) { + logger.error(e) { "SSO init failed: ${e.message}" } + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse(e.message), + ) + } catch (e: Exception) { + logger.error(e) { "SSO init error" } + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse("SSO initialization failed") + ) + } + } + + get("/oidc/callback") { + try { + val code = call.parameters["code"] + ?: throw IllegalArgumentException( + "Missing authorization code" + ) + val state = call.parameters["state"] + ?: throw IllegalArgumentException( + "Missing state parameter" + ) + + val callbackData = ssoService.handleOidcCallback(code, state) + + AuthCookieUtils.setAuthCookie(call, callbackData.token) + call.respondRedirect("$frontendUrl/auth/sso/callback") + } catch (e: IllegalArgumentException) { + logger.error(e) { "OIDC callback failed: ${e.message}" } + call.respondRedirect("$frontendUrl/login?error=sso_failed") + } catch (e: Exception) { + logger.error(e) { "OIDC callback error" } + call.respondRedirect("$frontendUrl/login?error=sso_failed") + } + } + } + + // Protected SSO configuration endpoints + route("/v1/sso") { + authenticate("auth-jwt") { + get("/config") { + try { + val principal = call.principal() + val userId = + principal?.payload?.getClaim("userId")?.asInt() + ?: return@get call.respond( + HttpStatusCode.Unauthorized, + ErrorResponse("Invalid token") + ) + + val orgId = + call.parameters["organizationId"]?.toIntOrNull() + ?: return@get call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("Missing organizationId") + ) + + val isMember = + transaction { + Memberships + .selectAll() + .where { + (Memberships.organization_id eq orgId) and + (Memberships.user_id eq userId) + }.firstOrNull() != null + } + + if (!isMember) { + return@get call.respond( + HttpStatusCode.Forbidden, + ErrorResponse("Access denied") + ) + } + + val config = ssoService.getSsoConfig(orgId) + if (config != null) { + call.respond(config) + } else { + call.respond( + HttpStatusCode.NotFound, + ErrorResponse("SSO not configured") + ) + } + } catch (e: Exception) { + logger.error(e) { "Get SSO config error" } + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse( + "Failed to retrieve SSO configuration" + ) + ) + } + } + + put("/config") { + try { + val principal = call.principal() + val userId = + principal?.payload?.getClaim("userId")?.asInt() + ?: return@put call.respond( + HttpStatusCode.Unauthorized, + ErrorResponse("Invalid token") + ) + + val request = call.receive() + 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) + ) + } catch (e: Exception) { + logger.error(e) { "Configure SSO error" } + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse("Failed to configure SSO") + ) + } + } + + delete("/config") { + try { + val principal = call.principal() + val userId = + principal?.payload?.getClaim("userId")?.asInt() + ?: return@delete call.respond( + HttpStatusCode.Unauthorized, + ErrorResponse("Invalid token") + ) + + val orgId = + call.parameters["organizationId"]?.toIntOrNull() + ?: return@delete call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("Missing organizationId") + ) + + val deleted = ssoService.deleteSsoConfig(orgId, userId) + if (deleted) { + call.respond( + HttpStatusCode.OK, + MessageResponse("SSO configuration deleted") + ) + } else { + call.respond( + HttpStatusCode.NotFound, + ErrorResponse("SSO configuration not found") + ) + } + } catch (e: IllegalArgumentException) { + logger.error(e) { + "Delete SSO config failed: ${e.message}" + } + call.respond( + HttpStatusCode.BadRequest, + ErrorResponse(e.message) + ) + } catch (e: Exception) { + logger.error(e) { "Delete SSO config error" } + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse( + "Failed to delete SSO configuration" + ) + ) + } + } + + post("/check-required") { + try { + val request = call.receive>() + val email = + request["email"] + ?: return@post call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("Missing email") + ) + + val required = ssoService.checkSsoRequired(email) + call.respond(mapOf("required" to required)) + } catch (e: Exception) { + logger.error(e) { "Check SSO required error" } + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse( + "Failed to check SSO requirement" + ) + ) + } + } + } + } +} diff --git a/backend/src/main/kotlin/com/moneat/sso/services/SsoService.kt b/backend/src/main/kotlin/com/moneat/sso/services/SsoService.kt new file mode 100644 index 00000000..3e241650 --- /dev/null +++ b/backend/src/main/kotlin/com/moneat/sso/services/SsoService.kt @@ -0,0 +1,820 @@ +// Moneat - observability platform +// Copyright (C) 2026 Moneat +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package com.moneat.sso.services + +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm +import com.moneat.billing.services.PricingTierService +import com.moneat.config.EnvConfig +import com.moneat.config.RedisConfig +import com.moneat.enterprise.FeatureRegistry +import com.moneat.sso.models.SsoCallbackData +import com.moneat.sso.models.SsoConfigRequest +import com.moneat.sso.models.SsoConfigResponse +import com.moneat.sso.models.SsoInitResponse +import com.moneat.sso.models.SsoProviderType +import com.moneat.shared.models.Memberships +import com.moneat.shared.models.Organizations +import com.moneat.shared.models.SsoConfigurations +import com.moneat.shared.models.UserSsoLinks +import com.moneat.shared.models.Users +import com.moneat.utils.UrlValidator +import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant +import com.nimbusds.oauth2.sdk.TokenRequest +import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic +import com.nimbusds.oauth2.sdk.auth.Secret +import com.nimbusds.oauth2.sdk.id.ClientID +import com.nimbusds.openid.connect.sdk.OIDCTokenResponse +import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.server.config.ApplicationConfig +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import mu.KotlinLogging +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.jdbc.update +import java.net.URI +import java.security.SecureRandom +import java.util.Base64 +import java.util.Date +import java.util.concurrent.ConcurrentHashMap +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import kotlin.time.Clock + +private val logger = KotlinLogging.logger {} + +private const val DISCOVERY_CACHE_TTL_MS = 3_600_000L // 1 hour + +@Serializable +data class SsoStateData( + val nonce: String, + val orgId: Int, + val timestamp: Long, +) + +/** + * Cached OIDC discovery endpoints with expiry. + */ +private data class OidcDiscoveryCache( + val authorizationEndpoint: String, + val tokenEndpoint: String, + val fetchedAt: Long, +) + +open class SsoService { + private val config = ApplicationConfig("application.conf") + private val jwtSecret = config.property("jwt.secret").getString() + private val jwtIssuer = config.property("jwt.issuer").getString() + private val jwtAudience = config.property("jwt.audience").getString() + val baseUrl = config.propertyOrNull("app.baseUrl")?.getString() + ?: "https://api.moneat.io" + private val secureRandom = SecureRandom() + private val pricingTierService = PricingTierService() + private val httpClient = HttpClient(CIO) + private val discoveryCache = ConcurrentHashMap() + + private val encryptionKey: ByteArray by lazy { + val key = jwtSecret.toByteArray() + key.copyOf(AES_KEY_LENGTH) + } + + fun initSso( + email: String?, + orgSlug: String?, + ): SsoInitResponse { + if (email == null && orgSlug == null) { + throw IllegalArgumentException("Either email or orgSlug must be provided") + } + + return transaction { + val ssoConfig = + if (email != null) { + val domain = email.substringAfter("@") + SsoConfigurations + .selectAll() + .where { + (SsoConfigurations.emailDomain eq domain) and + (SsoConfigurations.isEnabled eq true) + }.firstOrNull() + } else { + 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.isEnabled eq true) + }.firstOrNull() + } + + if (ssoConfig == null) { + throw IllegalArgumentException( + "SSO is not configured for this email domain or organization" + ) + } + + val providerType = SsoProviderType.fromString( + ssoConfig[SsoConfigurations.providerType] + ) + val orgId = ssoConfig[SsoConfigurations.organizationId] + val state = generateSecureState(orgId) + + when (providerType) { + SsoProviderType.SAML -> { + if (!FeatureRegistry.hasModule("SAML")) { + throw IllegalArgumentException( + "SAML SSO requires an enterprise license" + ) + } + // SAML init is handled by SamlModule; delegate via SsoInitResponse + SsoInitResponse("", "saml", state) + } + + SsoProviderType.OIDC -> { + val redirectUrl = generateOidcRequest(ssoConfig, state) + SsoInitResponse(redirectUrl, "oidc", state) + } + } + } + } + + fun handleOidcCallback( + code: String, + state: String, + ): SsoCallbackData = + transaction { + val stateData = decodeState(state) + val orgId = stateData.orgId + + val ssoConfig = + SsoConfigurations + .selectAll() + .where { SsoConfigurations.organizationId eq orgId } + .firstOrNull() + ?: throw IllegalArgumentException( + "SSO configuration not found" + ) + + val issuerUrl = + ssoConfig[SsoConfigurations.oidcIssuerUrl] + ?: throw IllegalArgumentException( + "OIDC issuer URL not configured" + ) + UrlValidator.validateExternalUrl(issuerUrl) + val clientId = + ssoConfig[SsoConfigurations.oidcClientId] + ?: throw IllegalArgumentException( + "OIDC client ID not configured" + ) + val clientSecret = + decryptSecret(ssoConfig[SsoConfigurations.oidcClientSecret]) + ?: throw IllegalArgumentException( + "OIDC client secret not configured" + ) + + val endpoints = discoverOidcEndpoints(issuerUrl) + val tokenEndpoint = URI(endpoints.tokenEndpoint) + val redirectUri = URI("$baseUrl/auth/sso/oidc/callback") + + val authCode = + com.nimbusds.oauth2.sdk + .AuthorizationCode(code) + val codeGrant = AuthorizationCodeGrant(authCode, redirectUri) + + @Suppress("DEPRECATION") + val tokenRequest = + TokenRequest( + tokenEndpoint, + ClientSecretBasic( + ClientID(clientId), + Secret(clientSecret), + ), + codeGrant, + ) + + val tokenResponse = OIDCTokenResponseParser.parse( + tokenRequest.toHTTPRequest().send() + ) + + if (!tokenResponse.indicatesSuccess()) { + val errorResponse = tokenResponse.toErrorResponse() + logger.error { + "OIDC token exchange failed: ${errorResponse.errorObject}" + } + throw IllegalArgumentException("OIDC token exchange failed") + } + + val successResponse = + tokenResponse.toSuccessResponse() as OIDCTokenResponse + val idToken = successResponse.oidcTokens.idToken + + val email = + idToken.jwtClaimsSet.getStringClaim("email") + ?: throw IllegalArgumentException( + "No email found in ID token" + ) + val name = idToken.jwtClaimsSet.getStringClaim("name") + ?: email.substringBefore("@") + val externalId = idToken.jwtClaimsSet.subject + + val (token, userEmail, userName) = + findOrCreateSsoUser( + email = email, + name = name, + externalId = externalId, + ssoConfigId = ssoConfig[SsoConfigurations.id], + organizationId = orgId, + ) + + SsoCallbackData(token, userEmail, userName) + } + + fun configureSso( + organizationId: Int, + userId: Int, + request: SsoConfigRequest, + ): SsoConfigResponse { + val providerType = SsoProviderType.fromString(request.providerType) + + // Gate based on deployment mode and provider type + validateSsoAccess(organizationId, providerType) + + // Gate "Require SSO" behind enterprise license + if (request.requireSso && !FeatureRegistry.hasModule("SAML")) { + throw IllegalArgumentException( + "SSO enforcement (Require SSO) requires an enterprise license" + ) + } + + // Verify user is owner of the organization + val isOwner = + transaction { + Memberships + .selectAll() + .where { + (Memberships.organization_id eq organizationId) and + (Memberships.user_id eq userId) and + (Memberships.role eq "owner") + }.firstOrNull() != null + } + + if (!isOwner) { + throw IllegalArgumentException( + "Only organization owners can configure SSO" + ) + } + + return transaction { + // Validate required fields + when (providerType) { + SsoProviderType.SAML -> { + if (request.idpEntityId.isNullOrBlank() || + request.idpSsoUrl.isNullOrBlank() || + request.idpCertificate.isNullOrBlank() + ) { + throw IllegalArgumentException( + "SAML requires idpEntityId, idpSsoUrl, " + + "and idpCertificate" + ) + } + UrlValidator.validateExternalUrl(request.idpSsoUrl) + } + + SsoProviderType.OIDC -> { + if (request.oidcIssuerUrl.isNullOrBlank() || + request.oidcClientId.isNullOrBlank() || + request.oidcClientSecret.isNullOrBlank() + ) { + throw IllegalArgumentException( + "OIDC requires oidcIssuerUrl, oidcClientId, " + + "and oidcClientSecret" + ) + } + UrlValidator.validateExternalUrl(request.oidcIssuerUrl) + } + } + + val spEntityId = "$baseUrl/auth/sso/saml/metadata" + val encryptedSecret = + if (request.oidcClientSecret != null) { + encryptSecret(request.oidcClientSecret) + } else { + null + } + + // Persist requireSso only when enterprise license is active + val effectiveRequireSso = request.requireSso && + FeatureRegistry.hasModule("SAML") + + val existing = + SsoConfigurations + .selectAll() + .where { + SsoConfigurations.organizationId eq organizationId + }.firstOrNull() + + if (existing != null) { + SsoConfigurations.update({ + SsoConfigurations.organizationId eq organizationId + }) { + it[SsoConfigurations.providerType] = + request.providerType.lowercase() + it[SsoConfigurations.isEnabled] = request.isEnabled + it[SsoConfigurations.idpEntityId] = request.idpEntityId + it[SsoConfigurations.idpSsoUrl] = request.idpSsoUrl + it[SsoConfigurations.idpCertificate] = + request.idpCertificate + it[SsoConfigurations.spEntityId] = spEntityId + it[SsoConfigurations.oidcIssuerUrl] = + request.oidcIssuerUrl + it[SsoConfigurations.oidcClientId] = request.oidcClientId + if (encryptedSecret != null) { + it[SsoConfigurations.oidcClientSecret] = + encryptedSecret + } + it[SsoConfigurations.emailDomain] = request.emailDomain + it[SsoConfigurations.requireSso] = effectiveRequireSso + it[SsoConfigurations.updatedAt] = Clock.System.now() + } + } else { + SsoConfigurations.insert { + it[SsoConfigurations.organizationId] = organizationId + it[SsoConfigurations.providerType] = + request.providerType.lowercase() + it[SsoConfigurations.isEnabled] = request.isEnabled + it[SsoConfigurations.idpEntityId] = request.idpEntityId + it[SsoConfigurations.idpSsoUrl] = request.idpSsoUrl + it[SsoConfigurations.idpCertificate] = + request.idpCertificate + it[SsoConfigurations.spEntityId] = spEntityId + it[SsoConfigurations.oidcIssuerUrl] = + request.oidcIssuerUrl + it[SsoConfigurations.oidcClientId] = request.oidcClientId + it[SsoConfigurations.oidcClientSecret] = encryptedSecret + it[SsoConfigurations.emailDomain] = request.emailDomain + it[SsoConfigurations.requireSso] = effectiveRequireSso + } + } + + getSsoConfig(organizationId) + ?: throw IllegalStateException( + "Failed to retrieve SSO config after save" + ) + } + } + + fun getSsoConfig(organizationId: Int): SsoConfigResponse? = + transaction { + SsoConfigurations + .selectAll() + .where { SsoConfigurations.organizationId eq organizationId } + .firstOrNull() + ?.let { row -> + SsoConfigResponse( + id = row[SsoConfigurations.id], + organizationId = row[SsoConfigurations.organizationId], + providerType = row[SsoConfigurations.providerType], + isEnabled = row[SsoConfigurations.isEnabled], + idpEntityId = row[SsoConfigurations.idpEntityId], + idpSsoUrl = row[SsoConfigurations.idpSsoUrl], + idpCertificate = row[SsoConfigurations.idpCertificate], + spEntityId = row[SsoConfigurations.spEntityId], + oidcIssuerUrl = row[SsoConfigurations.oidcIssuerUrl], + oidcClientId = row[SsoConfigurations.oidcClientId], + hasClientSecret = row[SsoConfigurations.oidcClientSecret] != null, + emailDomain = row[SsoConfigurations.emailDomain], + requireSso = row[SsoConfigurations.requireSso], + createdAt = row[SsoConfigurations.createdAt].toString(), + updatedAt = row[SsoConfigurations.updatedAt].toString(), + ) + } + } + + fun deleteSsoConfig( + organizationId: Int, + userId: Int, + ): Boolean { + // Verify user is owner + val isOwner = + transaction { + Memberships + .selectAll() + .where { + (Memberships.organization_id eq organizationId) and + (Memberships.user_id eq userId) and + (Memberships.role eq "owner") + }.firstOrNull() != null + } + + if (!isOwner) { + throw IllegalArgumentException( + "Only organization owners can delete SSO configuration" + ) + } + + return transaction { + val deleted = + SsoConfigurations.deleteWhere { + SsoConfigurations.organizationId eq organizationId + } + deleted > 0 + } + } + + fun checkSsoRequired(email: String): Boolean = + transaction { + val domain = email.substringAfter("@") + // Only enforce "Require SSO" when enterprise license is active + if (!FeatureRegistry.hasModule("SAML")) return@transaction false + SsoConfigurations + .selectAll() + .where { + (SsoConfigurations.emailDomain eq domain) and + (SsoConfigurations.isEnabled eq true) and + (SsoConfigurations.requireSso eq true) + }.firstOrNull() != null + } + + fun findOrCreateSsoUser( + email: String, + name: String, + externalId: String, + ssoConfigId: Int, + organizationId: Int, + ): Triple { + val normalizedEmail = email.lowercase().trim() + + return transaction { + val existingLink = + UserSsoLinks + .selectAll() + .where { + (UserSsoLinks.ssoConfigurationId eq ssoConfigId) and + (UserSsoLinks.externalId eq externalId) + }.firstOrNull() + + val userId = + if (existingLink != null) { + existingLink[UserSsoLinks.userId] + } else { + linkOrCreateUser( + normalizedEmail, + name, + externalId, + ssoConfigId, + organizationId, + ) + } + + val user = + Users + .selectAll() + .where { Users.id eq userId } + .first() + + val token = generateToken(userId, user[Users.email]) + Triple(token, user[Users.email], user[Users.name] ?: email) + } + } + + fun generateSecureState(orgId: Int): String { + val nonce = ByteArray(NONCE_LENGTH) + secureRandom.nextBytes(nonce) + val nonceB64 = Base64.getUrlEncoder().withoutPadding() + .encodeToString(nonce) + val timestamp = System.currentTimeMillis() + val payload = "$orgId:$nonceB64:$timestamp" + + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(jwtSecret.toByteArray(), "HmacSHA256")) + val sig = Base64.getUrlEncoder().withoutPadding().encodeToString( + mac.doFinal(payload.toByteArray()) + ) + + val state = "$payload:$sig" + val encoded = Base64.getUrlEncoder().withoutPadding() + .encodeToString(state.toByteArray()) + + try { + if (RedisConfig.isInitialized()) { + RedisConfig.sync().setex( + "$SSO_NONCE_PREFIX$nonceB64", + SSO_NONCE_TTL_SECONDS, + orgId.toString() + ) + } + } catch (e: Exception) { + logger.warn(e) { "Failed to store SSO nonce in Redis" } + } + + return encoded + } + + fun decodeState(state: String): SsoStateData { + try { + val decoded = String(Base64.getUrlDecoder().decode(state)) + val parts = decoded.split(":") + if (parts.size != STATE_PARTS_COUNT) { + throw IllegalArgumentException("Invalid state format") + } + + val orgId = parts[0].toInt() + val nonceB64 = parts[1] + val timestamp = parts[2].toLong() + val signature = parts[STATE_SIGNATURE_INDEX] + + verifyStateSignature(parts, signature) + verifyStateExpiry(timestamp) + consumeNonce(nonceB64) + + return SsoStateData(nonceB64, orgId, timestamp) + } catch (e: IllegalArgumentException) { + throw e + } catch (e: Exception) { + throw IllegalArgumentException("Invalid state parameter") + } + } + + fun encryptSecret(plaintext: String): String { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val iv = ByteArray(IV_LENGTH) + secureRandom.nextBytes(iv) + + val keySpec = SecretKeySpec(encryptionKey, "AES") + val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH, iv) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec) + + val ciphertext = cipher.doFinal(plaintext.toByteArray()) + val combined = iv + ciphertext + return Base64.getEncoder().encodeToString(combined) + } + + fun decryptSecret(encrypted: String?): String? { + if (encrypted == null) return null + + try { + val combined = Base64.getDecoder().decode(encrypted) + val iv = combined.copyOfRange(0, IV_LENGTH) + val ciphertext = combined.copyOfRange(IV_LENGTH, combined.size) + + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val keySpec = SecretKeySpec(encryptionKey, "AES") + val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH, iv) + cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec) + + return String(cipher.doFinal(ciphertext)) + } catch (e: Exception) { + logger.error(e) { "Failed to decrypt SSO secret" } + return null + } + } + + /** + * Discovers OIDC endpoints from the provider's well-known configuration. + * Results are cached in-memory with a 1-hour TTL. + */ + private fun discoverOidcEndpoints(issuerUrl: String): OidcDiscoveryCache { + val cached = discoveryCache[issuerUrl] + val now = System.currentTimeMillis() + if (cached != null && (now - cached.fetchedAt) < DISCOVERY_CACHE_TTL_MS) { + return cached + } + + val discoveryUrl = "${issuerUrl.trimEnd('/')}/.well-known/openid-configuration" + logger.info { "Fetching OIDC discovery from $discoveryUrl" } + + val response = kotlinx.coroutines.runBlocking { + httpClient.get(discoveryUrl).bodyAsText() + } + val json = Json.parseToJsonElement(response) as JsonObject + val authEndpoint = json["authorization_endpoint"]?.jsonPrimitive?.content + ?: throw IllegalArgumentException( + "OIDC discovery missing authorization_endpoint" + ) + val tokenEndpoint = json["token_endpoint"]?.jsonPrimitive?.content + ?: throw IllegalArgumentException( + "OIDC discovery missing token_endpoint" + ) + + val entry = OidcDiscoveryCache(authEndpoint, tokenEndpoint, now) + discoveryCache[issuerUrl] = entry + return entry + } + + private fun generateOidcRequest( + ssoConfig: org.jetbrains.exposed.v1.core.ResultRow, + state: String, + ): String { + 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!!) + return "${endpoints.authorizationEndpoint}?" + + "client_id=$clientId&" + + "redirect_uri=$redirectUri&" + + "response_type=code&" + + "scope=openid%20email%20profile&" + + "state=$state" + } + + private fun validateSsoAccess( + organizationId: Int, + providerType: SsoProviderType, + ) { + val isSelfHosted = EnvConfig.SelfHost.enabled + + if (isSelfHosted) { + // Self-hosted: OIDC always allowed, SAML requires enterprise + if (providerType == SsoProviderType.SAML && + !FeatureRegistry.hasModule("SAML") + ) { + throw IllegalArgumentException( + "SAML SSO requires an enterprise license" + ) + } + } else { + // SaaS: both OIDC and SAML require Team/Business tier + val tierContext = pricingTierService + .getEffectiveTierForOrganization(organizationId) + val tierName = tierContext.tier.tierName + if (tierName != "TEAM" && tierName != "BUSINESS") { + throw IllegalArgumentException( + "SSO is only available on Team and Business plans" + ) + } + } + } + + private fun generateToken( + userId: Int, + email: String, + ): String = + JWT + .create() + .withAudience(jwtAudience) + .withIssuer(jwtIssuer) + .withClaim("userId", userId) + .withClaim("email", email) + .withExpiresAt(Date(System.currentTimeMillis() + TOKEN_TTL_MS)) + .sign(Algorithm.HMAC256(jwtSecret)) + + private fun linkOrCreateUser( + normalizedEmail: String, + name: String, + externalId: String, + ssoConfigId: Int, + organizationId: Int, + ): Int { + val existingUser = + Users + .selectAll() + .where { Users.email eq normalizedEmail } + .firstOrNull() + + return if (existingUser != null) { + val uid = existingUser[Users.id] + UserSsoLinks.insert { + it[UserSsoLinks.userId] = uid + it[UserSsoLinks.ssoConfigurationId] = ssoConfigId + it[UserSsoLinks.externalId] = externalId + } + addToOrgIfNeeded(uid, organizationId) + uid + } else { + val uid = + Users.insert { + it[Users.email] = normalizedEmail + it[Users.name] = name + it[password_hash] = "" + it[email_verified] = true + it[onboarding_completed] = false + }[Users.id] + UserSsoLinks.insert { + it[UserSsoLinks.userId] = uid + it[UserSsoLinks.ssoConfigurationId] = ssoConfigId + it[UserSsoLinks.externalId] = externalId + } + Memberships.insert { + it[user_id] = uid + it[organization_id] = organizationId + it[role] = "member" + } + uid + } + } + + private fun addToOrgIfNeeded(userId: Int, organizationId: Int) { + val isMember = + Memberships + .selectAll() + .where { + (Memberships.user_id eq userId) and + (Memberships.organization_id eq organizationId) + }.firstOrNull() != null + + if (!isMember) { + Memberships.insert { + it[user_id] = userId + it[organization_id] = organizationId + it[role] = "member" + } + } + } + + private fun verifyStateSignature( + parts: List, + signature: String, + ) { + val payload = "${parts[0]}:${parts[1]}:${parts[2]}" + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(jwtSecret.toByteArray(), "HmacSHA256")) + val expected = Base64.getUrlEncoder().withoutPadding() + .encodeToString(mac.doFinal(payload.toByteArray())) + + if (!java.security.MessageDigest.isEqual( + expected.toByteArray(), + signature.toByteArray() + ) + ) { + throw IllegalArgumentException("State signature invalid") + } + } + + private fun verifyStateExpiry(timestamp: Long) { + if (System.currentTimeMillis() - timestamp > + SSO_NONCE_TTL_SECONDS * MILLIS_PER_SECOND + ) { + throw IllegalArgumentException("State expired") + } + } + + private fun consumeNonce(nonceB64: String) { + try { + if (RedisConfig.isInitialized()) { + val redisKey = "$SSO_NONCE_PREFIX$nonceB64" + val stored = RedisConfig.sync().get(redisKey) + if (stored == null) { + throw IllegalArgumentException( + "State already used or expired" + ) + } + RedisConfig.sync().del(redisKey) + } + } catch (e: IllegalArgumentException) { + throw e + } catch (e: Exception) { + logger.warn(e) { "Failed to verify SSO nonce in Redis" } + } + } + + companion object { + private const val SSO_NONCE_PREFIX = "sso:nonce:" + private const val SSO_NONCE_TTL_SECONDS = 600L + private const val AES_KEY_LENGTH = 32 + private const val NONCE_LENGTH = 16 + private const val IV_LENGTH = 12 + private const val GCM_TAG_LENGTH = 128 + private const val STATE_PARTS_COUNT = 4 + private const val STATE_SIGNATURE_INDEX = 3 + private const val TOKEN_TTL_MS = 3_600_000L + private const val MILLIS_PER_SECOND = 1000L + } +} diff --git a/backend/src/main/resources/META-INF/services/com.moneat.enterprise.EnterpriseModule b/backend/src/main/resources/META-INF/services/com.moneat.enterprise.EnterpriseModule index 9e100ba2..ef476f48 100644 --- a/backend/src/main/resources/META-INF/services/com.moneat.enterprise.EnterpriseModule +++ b/backend/src/main/resources/META-INF/services/com.moneat.enterprise.EnterpriseModule @@ -1,3 +1,4 @@ com.moneat.monitoring.MonitoringModule com.moneat.analytics.AnalyticsModule com.moneat.datadog.DatadogModule +com.moneat.sso.SsoModule diff --git a/dashboard/src/components/SsoSettings.tsx b/dashboard/src/components/SsoSettings.tsx index d149d32f..d3b1e9af 100644 --- a/dashboard/src/components/SsoSettings.tsx +++ b/dashboard/src/components/SsoSettings.tsx @@ -24,10 +24,11 @@ import { Textarea } from '@/components/ui/textarea' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' +import { Badge } from '@/components/ui/badge' import { useToast } from '@/hooks/useToast' import { AlertCircle, Check, Loader2, Shield } from 'lucide-react' -export function SsoTab() { +export function SsoTab({ hasSamlModule = false }: { hasSamlModule?: boolean }) { const queryClient = useQueryClient() const { toast } = useToast() const [providerType, setProviderType] = useState<'saml' | 'oidc'>('oidc') @@ -144,56 +145,58 @@ export function SsoTab() { if (configLoading) { return ( - - + + ) } return ( -
+
- +
- - Single Sign-On (SSO) + + Single Sign-On (SSO)
- - Configure SAML 2.0 or OIDC authentication for your organization. SSO allows your team to - log in using your company's identity provider. + + Configure SAML 2.0 or OIDC authentication for your organization.
- -
-
-
- + + +
+
+

{providerType === 'oidc' - ? 'Recommended for modern identity providers like Okta, Auth0, Azure AD' - : 'For legacy enterprise identity providers'} + ? 'Works with any OIDC provider including Authentik, Authelia, Keycloak, Okta, and Azure AD' + : 'For enterprise identity providers. Requires an enterprise license for self-hosted deployments.'}

{providerType === 'saml' ? ( <> -
- +
+ setFormData({ ...formData, idpEntityId: e.target.value })} placeholder="https://idp.example.com/metadata" @@ -201,10 +204,11 @@ export function SsoTab() { />
-
- +
+ setFormData({ ...formData, idpSsoUrl: e.target.value })} placeholder="https://idp.example.com/sso/saml" @@ -212,15 +216,15 @@ export function SsoTab() { />
-
- +
+