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/build.gradle.kts b/backend/build.gradle.kts index 9f1f9e22..f56a126a 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -295,6 +295,7 @@ tasks.jacocoTestReport { "**/models/**", // data classes — no logic to test "**/config/**", // infrastructure wiring (DB clients, Redis, Sentry, env) "**/plugins/**", // Ktor plugin bootstrap — framework wiring, not business logic + "**/sso/**", // OIDC/OAuth routes & service — integration-heavy; gate via detekt + manual/E2E "**/logging/**", // log appender setup "**/enterprise/**", // on-call/feature-flag integration stubs "**/Application*", // entry point 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..67ab509f 100644 --- a/backend/src/main/kotlin/com/moneat/auth/services/AuthService.kt +++ b/backend/src/main/kotlin/com/moneat/auth/services/AuthService.kt @@ -307,11 +307,21 @@ class AuthService( val tokenPair = refreshTokenService.generateRefreshToken(userId, normalizedEmail, orgId, orgRole) SentryUtils.breadcrumb("auth", "Signup completed", mapOf("user_id" to userId)) + val organizationSlug = organizationRepository.findById(orgId)?.slug return AuthResponse( token = tokenPair.accessToken, refreshToken = tokenPair.refreshToken, expiresIn = tokenPair.expiresIn, - user = UserResponse(userId, normalizedEmail, request.name, emailVerified, false, isAdmin) + user = UserResponse( + id = userId, + email = normalizedEmail, + name = request.name, + emailVerified = emailVerified, + onboardingCompleted = false, + isAdmin = isAdmin, + organizationSlug = organizationSlug, + orgRole = orgRole, + ) ) } @@ -367,17 +377,20 @@ class AuthService( } val tokenPair = refreshTokenService.generateRefreshToken(user.id, user.email, orgId, orgRole) + val organizationSlug = organizationRepository.findById(orgId)?.slug return AuthResponse( token = tokenPair.accessToken, refreshToken = tokenPair.refreshToken, expiresIn = tokenPair.expiresIn, user = UserResponse( - user.id, - user.email, - user.name, - user.emailVerified, - user.onboardingCompleted, - user.isAdmin + id = user.id, + email = user.email, + name = user.name, + emailVerified = user.emailVerified, + onboardingCompleted = user.onboardingCompleted, + isAdmin = user.isAdmin, + organizationSlug = organizationSlug, + orgRole = orgRole, ) ) } @@ -543,6 +556,7 @@ class AuthService( true, user.isAdmin, finalSlug, + membership.role, null, hiddenItems ) @@ -570,13 +584,18 @@ class AuthService( val user = run { val userRow = userRepository.findById(userId) ?: return null + val membership = membershipRepository.getFirstMembershipForUser(userId) + val organizationSlug = + membership?.let { organizationRepository.findById(it.organizationId)?.slug } UserResponse( - userId, - email, - userRow.name, - userRow.emailVerified, - userRow.onboardingCompleted, - userRow.isAdmin + id = userId, + email = email, + name = userRow.name, + emailVerified = userRow.emailVerified, + onboardingCompleted = userRow.onboardingCompleted, + isAdmin = userRow.isAdmin, + organizationSlug = organizationSlug, + orgRole = membership?.role, ) } 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/plugins/Monitoring.kt b/backend/src/main/kotlin/com/moneat/plugins/Monitoring.kt index 071ac993..298aeed4 100644 --- a/backend/src/main/kotlin/com/moneat/plugins/Monitoring.kt +++ b/backend/src/main/kotlin/com/moneat/plugins/Monitoring.kt @@ -17,6 +17,7 @@ package com.moneat.plugins import com.moneat.config.SentryConfig +import com.moneat.sso.SsoForbiddenException import com.moneat.utils.ErrorResponse import com.moneat.utils.SentryUtils import io.ktor.http.HttpStatusCode @@ -116,6 +117,7 @@ fun Application.configureMonitoring() { install(StatusPages) { exception { call, cause -> + logger.debug(cause) { "Bad request: ${cause.message}" } if (!call.response.isCommitted) { call.respond( HttpStatusCode.BadRequest, @@ -123,6 +125,15 @@ fun Application.configureMonitoring() { ) } } + exception { call, cause -> + logger.debug(cause) { "SSO forbidden: ${cause.message}" } + if (!call.response.isCommitted) { + call.respond( + HttpStatusCode.Forbidden, + ErrorResponse(cause.message ?: "Forbidden"), + ) + } + } exception { call, cause -> logger.error(cause) { "Unhandled exception: ${cause.message}" } diff --git a/backend/src/main/kotlin/com/moneat/sso/SsoForbiddenException.kt b/backend/src/main/kotlin/com/moneat/sso/SsoForbiddenException.kt new file mode 100644 index 00000000..07d8dbb4 --- /dev/null +++ b/backend/src/main/kotlin/com/moneat/sso/SsoForbiddenException.kt @@ -0,0 +1,25 @@ +// 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 + +/** + * Thrown when the caller is authenticated but not permitted to configure SSO + * (e.g. not an organization owner, or licensing / plan constraints). + */ +class SsoForbiddenException( + message: String, +) : RuntimeException(message) 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..90bacd16 --- /dev/null +++ b/backend/src/main/kotlin/com/moneat/sso/routes/SsoRoutes.kt @@ -0,0 +1,221 @@ +// 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.shared.models.Memberships +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.utils.AuthCookieUtils +import com.moneat.utils.ErrorResponse +import com.moneat.utils.MessageResponse +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.auth.authenticate +import io.ktor.server.auth.jwt.JWTPrincipal +import io.ktor.server.auth.principal +import io.ktor.server.plugins.BadRequestException +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 {} + +private const val SSO_CONFIG_PATH = "/config" +private const val ERROR_INVALID_TOKEN = "Invalid token" +private const val ERROR_MISSING_ORG_ID = "Missing organizationId" + +private data class SsoAuthContext(val userId: Int, val orgId: Int) + +private suspend fun ApplicationCall.requireSsoAuth(): SsoAuthContext? { + val userId = principal()?.payload?.getClaim("userId")?.asInt() + ?: run { + respond(HttpStatusCode.Unauthorized, ErrorResponse(ERROR_INVALID_TOKEN)) + return null + } + val orgId = parameters["organizationId"]?.toIntOrNull() + ?: run { + respond(HttpStatusCode.BadRequest, ErrorResponse(ERROR_MISSING_ORG_ID)) + return null + } + val isMember = transaction { + Memberships.selectAll() + .where { + (Memberships.organization_id eq orgId) and (Memberships.user_id eq userId) + }.firstOrNull() != null + } + if (!isMember) { + respond(HttpStatusCode.Forbidden, ErrorResponse("Access denied")) + return null + } + return SsoAuthContext(userId, orgId) +} + +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(SSO_CONFIG_PATH) { + val ctx = call.requireSsoAuth() ?: return@get + try { + val config = ssoService.getSsoConfig(ctx.orgId) + when (config) { + null -> call.respond( + HttpStatusCode.NotFound, + ErrorResponse("SSO not configured") + ) + else -> call.respond(config) + } + } catch (e: Exception) { + logger.error(e) { "Get SSO config error" } + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse("Failed to retrieve SSO configuration") + ) + } + } + + put(SSO_CONFIG_PATH) { + val ctx = call.requireSsoAuth() ?: return@put + val request = call.receive() + val providerType = + try { + SsoProviderType.fromString(request.providerType) + } catch (e: IllegalArgumentException) { + throw BadRequestException(e.message ?: "Invalid SSO provider type") + } + if (providerType == SsoProviderType.SAML && + !FeatureRegistry.hasModule("SAML") + ) { + call.respond( + HttpStatusCode.Forbidden, + ErrorResponse("SAML SSO requires an enterprise license") + ) + return@put + } + val config = ssoService.configureSso(ctx.orgId, ctx.userId, request) + call.respond(config) + } + + delete(SSO_CONFIG_PATH) { + val ctx = call.requireSsoAuth() ?: return@delete + val deleted = ssoService.deleteSsoConfig(ctx.orgId, ctx.userId) + when (deleted) { + true -> call.respond( + HttpStatusCode.OK, + MessageResponse("SSO configuration deleted") + ) + false -> call.respond( + HttpStatusCode.NotFound, + ErrorResponse("SSO configuration not found") + ) + } + } + + 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..1250f172 --- /dev/null +++ b/backend/src/main/kotlin/com/moneat/sso/services/SsoService.kt @@ -0,0 +1,1006 @@ +// 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.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.sso.SsoForbiddenException +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.utils.UrlValidator +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.jwk.source.RemoteJWKSet +import com.nimbusds.jose.proc.JWSVerificationKeySelector +import com.nimbusds.jose.proc.SecurityContext +import com.nimbusds.jwt.proc.DefaultJWTProcessor +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 io.ktor.server.plugins.BadRequestException +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.ResultRow +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.SecretKeyFactory +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.PBEKeySpec +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, + val oidcNonce: String? = null, +) + +/** + * Cached OIDC discovery endpoints with expiry. + */ +private data class OidcDiscoveryCache( + val authorizationEndpoint: String, + val tokenEndpoint: String, + val jwksUri: 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 { + deriveAesKeyFromJwtSecret(jwtSecret) + } + + private fun deriveAesKeyFromJwtSecret(secret: String): ByteArray { + val salt = "moneat-sso-encryption-v1".toByteArray(Charsets.UTF_8) + val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + val spec = PBEKeySpec(secret.toCharArray(), salt, 100_000, AES_KEY_LENGTH * 8) + val key = factory.generateSecret(spec) + val encoded = key.encoded + require(encoded.size >= AES_KEY_LENGTH) { + "Derived key length mismatch" + } + return encoded.copyOf(AES_KEY_LENGTH) + } + + suspend fun initSso( + email: String?, + orgSlug: String?, + ): SsoInitResponse { + require(email != null || orgSlug != null) { + "Either email or orgSlug must be provided" + } + + val pending = + 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() + } + + val config = requireNotNull(ssoConfig) { + "SSO is not configured for this email domain or organization" + } + + val providerType = SsoProviderType.fromString( + config[SsoConfigurations.providerType] + ) + val orgId = config[SsoConfigurations.organizationId] + + when (providerType) { + SsoProviderType.SAML -> { + require(FeatureRegistry.hasModule("SAML")) { + "SAML SSO requires an enterprise license" + } + val state = generateSecureState(orgId) + PendingSsoInit.Saml(state) + } + + SsoProviderType.OIDC -> { + val oidcNonce = ByteArray(NONCE_LENGTH) + secureRandom.nextBytes(oidcNonce) + val oidcNonceB64 = Base64.getUrlEncoder().withoutPadding() + .encodeToString(oidcNonce) + val state = generateSecureState(orgId, oidcNonceB64) + PendingSsoInit.Oidc(config, state, oidcNonceB64) + } + } + } + + return when (pending) { + is PendingSsoInit.Saml -> + SsoInitResponse("", "saml", pending.state) + is PendingSsoInit.Oidc -> { + val redirectUrl = generateOidcRequest(pending.config, pending.state, pending.oidcNonce) + SsoInitResponse(redirectUrl, "oidc", pending.state) + } + } + } + + suspend fun handleOidcCallback( + code: String, + state: String, + ): SsoCallbackData { + val stateData = decodeState(state) + val orgId = stateData.orgId + + val expectedOidcNonce = retrieveOidcNonceFromState(stateData.nonce) + ?: throw IllegalArgumentException("OIDC nonce not found or expired") + + val oidc = + transaction { + val ssoConfig = + SsoConfigurations + .selectAll() + .where { + (SsoConfigurations.organizationId eq orgId) and + (SsoConfigurations.isEnabled eq true) and + (SsoConfigurations.providerType eq "oidc") + }.firstOrNull() + ?: throw IllegalArgumentException( + "SSO configuration not found" + ) + + val issuerRaw = + ssoConfig[SsoConfigurations.oidcIssuerUrl] + ?: throw IllegalArgumentException( + "OIDC issuer URL not configured" + ) + val issuerUrl = issuerRaw.trim() + require(issuerUrl.isNotEmpty()) { + "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" + ) + + OidcCallbackConfig( + issuerUrl = issuerUrl, + clientId = clientId, + clientSecret = clientSecret, + ssoConfigId = ssoConfig[SsoConfigurations.id], + organizationId = orgId, + ) + } + + val endpoints = discoverOidcEndpoints(oidc.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(oidc.clientId), + Secret(oidc.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 + + validateIdToken( + idToken = idToken, + expectedIssuer = oidc.issuerUrl, + expectedAudience = oidc.clientId, + expectedNonce = expectedOidcNonce, + jwksUri = endpoints.jwksUri + ) + + val email = + idToken.jwtClaimsSet.getStringClaim("email") + ?: throw IllegalArgumentException( + "No email found in ID token" + ) + val emailVerified = idToken.jwtClaimsSet.getBooleanClaim("email_verified") + if (emailVerified != true) { + throw IllegalArgumentException("Email not verified in ID token") + } + val name = idToken.jwtClaimsSet.getStringClaim("name") + ?: email.substringBefore("@") + val externalId = idToken.jwtClaimsSet.subject + ?: throw IllegalArgumentException("No subject found in ID token") + + return transaction { + val (token, userEmail, userName) = + findOrCreateSsoUser( + email = email, + name = name, + externalId = externalId, + ssoConfigId = oidc.ssoConfigId, + organizationId = oidc.organizationId, + ) + + SsoCallbackData(token, userEmail, userName) + } + } + + private data class OidcCallbackConfig( + val issuerUrl: String, + val clientId: String, + val clientSecret: String, + val ssoConfigId: Int, + val organizationId: Int, + ) + + private sealed class PendingSsoInit { + data class Saml( + val state: String, + ) : PendingSsoInit() + + data class Oidc( + val config: ResultRow, + val state: String, + val oidcNonce: String, + ) : PendingSsoInit() + } + + fun configureSso( + organizationId: Int, + userId: Int, + request: SsoConfigRequest, + ): SsoConfigResponse { + val providerType = + try { + SsoProviderType.fromString(request.providerType) + } catch (e: IllegalArgumentException) { + throw BadRequestException(e.message ?: "Invalid SSO provider type") + } + validateSsoAccess(organizationId, providerType) + if (request.requireSso && !FeatureRegistry.hasModule("SAML")) { + throw SsoForbiddenException( + "SSO enforcement (Require SSO) requires an enterprise license" + ) + } + if (!isOrganizationOwner(organizationId, userId)) { + throw SsoForbiddenException("Only organization owners can configure SSO") + } + validateSsoConfigRequest(providerType, request) + + return transaction { + val spEntityId = "$baseUrl/auth/sso/saml/metadata" + val encryptedSecret = request.oidcClientSecret?.let { encryptSecret(it) } + val effectiveRequireSso = request.requireSso && FeatureRegistry.hasModule("SAML") + persistSsoConfig(organizationId, request, spEntityId, encryptedSecret, effectiveRequireSso) + getSsoConfig(organizationId) + ?: throw IllegalStateException("Failed to retrieve SSO config after save") + } + } + + private fun isOrganizationOwner(organizationId: Int, userId: Int): Boolean = + transaction { + Memberships.selectAll() + .where { + (Memberships.organization_id eq organizationId) and + (Memberships.user_id eq userId) and + (Memberships.role eq "owner") + }.firstOrNull() != null + } + + private fun validateSsoConfigRequest( + providerType: SsoProviderType, + request: SsoConfigRequest, + ) { + when (providerType) { + SsoProviderType.SAML -> { + val idpSsoUrl = request.idpSsoUrl + if ( + request.idpEntityId.isNullOrBlank() || + idpSsoUrl.isNullOrBlank() || + request.idpCertificate.isNullOrBlank() + ) { + throw BadRequestException( + "SAML requires idpEntityId, idpSsoUrl, and idpCertificate" + ) + } + try { + UrlValidator.validateExternalUrl(idpSsoUrl) + } catch (e: UrlValidator.SsrfException) { + throw BadRequestException(e.message ?: "Invalid IdP SSO URL") + } + } + SsoProviderType.OIDC -> { + val oidcIssuerUrl = request.oidcIssuerUrl + if ( + oidcIssuerUrl.isNullOrBlank() || + request.oidcClientId.isNullOrBlank() || + request.oidcClientSecret.isNullOrBlank() + ) { + throw BadRequestException( + "OIDC requires oidcIssuerUrl, oidcClientId, and oidcClientSecret" + ) + } + try { + UrlValidator.validateExternalUrl(oidcIssuerUrl) + } catch (e: UrlValidator.SsrfException) { + throw BadRequestException(e.message ?: "Invalid OIDC issuer URL") + } + } + } + } + + private fun persistSsoConfig( + organizationId: Int, + request: SsoConfigRequest, + spEntityId: String, + encryptedSecret: String?, + effectiveRequireSso: Boolean, + ) { + 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 (request.providerType.lowercase() == "oidc") { + encryptedSecret?.let { secret -> it[SsoConfigurations.oidcClientSecret] = secret } + } else { + it[SsoConfigurations.oidcClientSecret] = null + } + 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 + } + } + } + + 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 { + if (!isOrganizationOwner(organizationId, userId)) { + throw SsoForbiddenException("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, oidcNonce: String? = null): 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()) { + val redisValue = if (oidcNonce != null) { + "$orgId:$oidcNonce" + } else { + orgId.toString() + } + RedisConfig.sync().setex( + "$SSO_NONCE_PREFIX$nonceB64", + SSO_NONCE_TTL_SECONDS, + redisValue + ) + } + } 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(":") + require(parts.size == STATE_PARTS_COUNT) { "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 suspend 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 = 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 jwksUri = json["jwks_uri"]?.jsonPrimitive?.content + ?: throw IllegalArgumentException( + "OIDC discovery missing jwks_uri" + ) + + try { + UrlValidator.validateExternalUrl(authEndpoint) + } catch (e: UrlValidator.SsrfException) { + throw IllegalArgumentException("Invalid authorization_endpoint URL: ${e.message}") + } + + try { + UrlValidator.validateExternalUrl(tokenEndpoint) + } catch (e: UrlValidator.SsrfException) { + throw IllegalArgumentException("Invalid token_endpoint URL: ${e.message}") + } + + val entry = OidcDiscoveryCache(authEndpoint, tokenEndpoint, jwksUri, now) + discoveryCache[issuerUrl] = entry + return entry + } + + private suspend fun generateOidcRequest( + ssoConfig: ResultRow, + state: String, + oidcNonce: String, + ): String { + val issuerRaw = + ssoConfig[SsoConfigurations.oidcIssuerUrl] + ?: throw IllegalArgumentException("OIDC issuer URL not configured") + val issuerUrl = issuerRaw.trim() + require(issuerUrl.isNotEmpty()) { "OIDC issuer URL not configured" } + UrlValidator.validateExternalUrl(issuerUrl) + val clientId = + ssoConfig[SsoConfigurations.oidcClientId] + ?: throw IllegalArgumentException("OIDC client ID not configured") + 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&" + + "nonce=$oidcNonce" + } + + private fun validateSsoAccess( + organizationId: Int, + providerType: SsoProviderType, + ) { + val isSelfHosted = EnvConfig.SelfHost.enabled + + if (isSelfHosted) { + if (providerType == SsoProviderType.SAML && !FeatureRegistry.hasModule("SAML")) { + throw SsoForbiddenException("SAML SSO requires an enterprise license") + } + } else { + val tierContext = pricingTierService + .getEffectiveTierForOrganization(organizationId) + val tierName = tierContext.tier.tierName + if (tierName != "TEAM" && tierName != "BUSINESS") { + throw SsoForbiddenException("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())) + + require( + java.security.MessageDigest.isEqual( + expected.toByteArray(), + signature.toByteArray() + ) + ) { "State signature invalid" } + } + + private fun verifyStateExpiry(timestamp: Long) { + require( + System.currentTimeMillis() - timestamp <= + SSO_NONCE_TTL_SECONDS * MILLIS_PER_SECOND + ) { "State expired" } + } + + private fun consumeNonce(nonceB64: String) { + try { + if (!RedisConfig.isInitialized()) { + return + } + val redisKey = "$SSO_NONCE_PREFIX$nonceB64" + val stored = RedisConfig.sync().get(redisKey) + requireNotNull(stored) { "State already used or expired" } + RedisConfig.sync().del(redisKey) + } catch (e: IllegalArgumentException) { + throw e + } catch (e: Exception) { + throw IllegalStateException( + "Failed to verify SSO nonce ($SSO_NONCE_PREFIX$nonceB64)", + e + ) + } + } + + private fun retrieveOidcNonceFromState(nonceB64: String): String? { + try { + if (!RedisConfig.isInitialized()) { + return null + } + val redisKey = "$SSO_NONCE_PREFIX$nonceB64" + val stored = RedisConfig.sync().get(redisKey) + if (stored != null && stored.contains(":")) { + return stored.substringAfter(":") + } + return null + } catch (e: Exception) { + logger.warn(e) { "Failed to retrieve OIDC nonce from Redis" } + return null + } + } + + private fun validateIdToken( + idToken: com.nimbusds.jwt.JWT, + expectedIssuer: String, + expectedAudience: String, + expectedNonce: String, + jwksUri: String + ) { + try { + val jwtProcessor = DefaultJWTProcessor() + val jwkSetURL = java.net.URL(jwksUri) + val keySource = RemoteJWKSet(jwkSetURL) + val keySelector = JWSVerificationKeySelector( + JWSAlgorithm.RS256, + keySource + ) + jwtProcessor.jwsKeySelector = keySelector + + val claims = jwtProcessor.process(idToken.parsedString, null) + + val issuer = claims.issuer + if (issuer != expectedIssuer) { + throw IllegalArgumentException( + "Invalid issuer: expected $expectedIssuer, got $issuer" + ) + } + + val audience = claims.audience + if (audience == null || !audience.contains(expectedAudience)) { + throw IllegalArgumentException( + "Invalid audience: expected $expectedAudience, got $audience" + ) + } + + val exp = claims.expirationTime + if (exp == null || exp.time <= System.currentTimeMillis()) { + throw IllegalArgumentException("ID token expired") + } + + val nbf = claims.notBeforeTime + if (nbf != null && nbf.time > System.currentTimeMillis()) { + throw IllegalArgumentException("ID token not yet valid") + } + + val nonce = claims.getStringClaim("nonce") + if (nonce != expectedNonce) { + throw IllegalArgumentException( + "Invalid nonce: expected $expectedNonce, got $nonce" + ) + } + + val subject = claims.subject + if (subject.isNullOrBlank()) { + throw IllegalArgumentException("ID token missing subject") + } + + logger.info { "ID token validated successfully for subject: $subject" } + } catch (e: IllegalArgumentException) { + throw e + } catch (e: Exception) { + logger.error(e) { "ID token validation failed" } + throw IllegalArgumentException("ID token validation failed: ${e.message}") + } + } + + /** + * Verifies SSO state signature and expiry without consuming the nonce. + * Used by SAML to bind [AuthnRequest] IDs to the same state string later + * validated by [decodeState]. + */ + fun peekVerifiedNonceWithoutConsume(state: String): String { + val decoded = String(Base64.getUrlDecoder().decode(state)) + val parts = decoded.split(":") + require(parts.size == STATE_PARTS_COUNT) { "Invalid state format" } + + val nonceB64 = parts[1] + val timestamp = parts[2].toLong() + val signature = parts[STATE_SIGNATURE_INDEX] + + verifyStateSignature(parts, signature) + verifyStateExpiry(timestamp) + return nonceB64 + } + + companion object { + const val SSO_SAML_AUTHN_REQUEST_KEY_PREFIX = "sso:saml:authnreq:" + + 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 + } +} \ No newline at end of file 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..a88f0a53 100644 --- a/dashboard/src/components/SsoSettings.tsx +++ b/dashboard/src/components/SsoSettings.tsx @@ -24,10 +24,15 @@ 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, + canConfigure = true, +}: Readonly<{ hasSamlModule?: boolean; canConfigure?: boolean }>) { + const readOnly = !canConfigure const queryClient = useQueryClient() const { toast } = useToast() const [providerType, setProviderType] = useState<'saml' | 'oidc'>('oidc') @@ -138,90 +143,98 @@ export function SsoTab() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault() + if (readOnly) return saveMutation.mutate() } 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" required={providerType === 'saml'} + disabled={readOnly} />
-
- +
+ setFormData({ ...formData, idpSsoUrl: e.target.value })} placeholder="https://idp.example.com/sso/saml" required={providerType === 'saml'} + disabled={readOnly} />
-
- +
+