diff --git a/README.md b/README.md
index 882544ac..e582cb9b 100644
--- a/README.md
+++ b/README.md
@@ -54,7 +54,7 @@ OpenRisk allows every organization to:
### Key Capabilities
- ๐ฒ **Risk Assessment** - Comprehensive risk identification and scoring
- ๐ก๏ธ **Mitigation Tracking** - Monitor and track risk mitigations in real-time
-- ๐ **Enterprise Security** - RBAC, audit logging, OAuth2/SAML2 SSO
+- ๐ **Enterprise Security** - RBAC, audit logging, OAuth2 SSO
- ๐๏ธ **Asset Inventory** - Track assets, their criticality and dependencies
- ๐ **Compliance Management** - Manage controls, evidence and compliance reports
- ๐ถ **Financial Risk Quantification** - Quantify exposure using SLE, ARO, ALE and ROSI
@@ -343,7 +343,7 @@ PATCH /api/mitigations/:id/sub-actions/:aid - Toggle completion
POST /auth/login - JWT authentication
POST /auth/register - User registration
POST /auth/oauth2/:provider - OAuth2 login
-POST /auth/saml/acs - SAML assertion endpoint
+POST /auth/saml2/acs - SAML assertion endpoint (turned off, see below)
GET /api/tokens - List API tokens
POST /api/tokens - Create new token
@@ -382,7 +382,7 @@ OpenRisk implements enterprise-grade security:
- **Password Hashing**: Argon2id (m=64MB, t=3, p=4) - never SHA256 or bcrypt alone
- **Encryption**: AES-256-GCM for sensitive data at rest
- **Audit Trail**: Complete audit logging for all operations (append-only)
-- **SSO**: OAuth2 (Google, GitHub) and SAML2 support
+- **SSO**: OAuth2 (Google, GitHub, Microsoft Entra). SAML2 is turned off until signed assertions are verified; its endpoints redirect to the login screen
- **Rate Limiting**: Per-IP and per-tenant quotas across the API, with a stricter throttle on credential endpoints
- **Security Headers**: CSP, HSTS, X-Frame-Options, Referrer-Policy and nosniff
- **Input Validation**: Server-side request validation (go-playground/validator); Zod on the frontend
diff --git a/backend/internal/handler/saml2_handler.go b/backend/internal/handler/saml2_handler.go
index a53358ea..dba210f7 100644
--- a/backend/internal/handler/saml2_handler.go
+++ b/backend/internal/handler/saml2_handler.go
@@ -6,152 +6,28 @@
package handler
import (
- "encoding/base64"
- "encoding/xml"
"fmt"
- "log"
"os"
- "strings"
- "time"
"github.com/gofiber/fiber/v2"
- "github.com/google/uuid"
- "gorm.io/gorm"
-
- "github.com/opendefender/openrisk/internal/domain"
- "github.com/opendefender/openrisk/internal/infrastructure/database"
)
-// SAMLAssertion represents a SAML2 assertion
-type SAMLAssertion struct {
- XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Assertion"`
- ID string `xml:"ID,attr"`
- Version string `xml:"Version,attr"`
- IssueInstant string `xml:"IssueInstant,attr"`
- Subject SAMLSubject `xml:"urn:oasis:names:tc:SAML:2.0:assertion Subject"`
- Issuer SAMLIssuer `xml:"urn:oasis:names:tc:SAML:2.0:assertion Issuer"`
- Conditions SAMLConditions `xml:"urn:oasis:names:tc:SAML:2.0:assertion Conditions"`
- AttributeStatement SAMLAttributeStatement `xml:"urn:oasis:names:tc:SAML:2.0:assertion AttributeStatement"`
- AuthnStatement SAMLAuthnStatement `xml:"urn:oasis:names:tc:SAML:2.0:assertion AuthnStatement"`
-}
-
-type SAMLSubject struct {
- NameID string `xml:"urn:oasis:names:tc:SAML:2.0:assertion NameID"`
- SubjectConfirmation SAMLSubjectConfirmation `xml:"urn:oasis:names:tc:SAML:2.0:assertion SubjectConfirmation"`
-}
-
-type SAMLSubjectConfirmation struct {
- Method string `xml:"Method,attr"`
- SubjectConfirmationData SAMLSubjectConfirmationData `xml:"urn:oasis:names:tc:SAML:2.0:assertion SubjectConfirmationData"`
-}
-
-type SAMLSubjectConfirmationData struct {
- NotOnOrAfter string `xml:"NotOnOrAfter,attr"`
- Recipient string `xml:"Recipient,attr"`
-}
-
-type SAMLIssuer struct {
- Format string `xml:"Format,attr"`
- Text string `xml:",chardata"`
-}
-
-type SAMLConditions struct {
- NotBefore string `xml:"NotBefore,attr"`
- NotOnOrAfter string `xml:"NotOnOrAfter,attr"`
-}
-
-type SAMLAttributeStatement struct {
- Attributes []SAMLAttribute `xml:"urn:oasis:names:tc:SAML:2.0:assertion Attribute"`
-}
-
-type SAMLAttribute struct {
- Name string `xml:"Name,attr"`
- Values []SAMLAttributeValue `xml:"urn:oasis:names:tc:SAML:2.0:assertion AttributeValue"`
-}
-
-type SAMLAttributeValue struct {
- Text string `xml:",chardata"`
-}
-
-type SAMLAuthnStatement struct {
- AuthnInstant string `xml:"AuthnInstant,attr"`
- SessionIndex string `xml:"SessionIndex,attr"`
- AuthnContext SAMLAuthnContext `xml:"urn:oasis:names:tc:SAML:2.0:assertion AuthnContext"`
-}
-
-type SAMLAuthnContext struct {
- AuthnContextClassRef string `xml:"urn:oasis:names:tc:SAML:2.0:assertion AuthnContextClassRef"`
-}
-
-// SAMLResponse represents a SAML Response
-type SAMLResponse struct {
- XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol Response"`
- ID string `xml:"ID,attr"`
- Version string `xml:"Version,attr"`
- IssueInstant string `xml:"IssueInstant,attr"`
- Destination string `xml:"Destination,attr"`
- InResponseTo string `xml:"InResponseTo,attr"`
- Status SAMLStatus `xml:"urn:oasis:names:tc:SAML:2.0:protocol Status"`
- Assertion SAMLAssertion `xml:"urn:oasis:names:tc:SAML:2.0:assertion Assertion"`
-}
-
-type SAMLStatus struct {
- StatusCode SAMLStatusCode `xml:"urn:oasis:names:tc:SAML:2.0:protocol StatusCode"`
-}
-
-type SAMLStatusCode struct {
- Value string `xml:"Value,attr"`
-}
+// SAML sign-in is turned off (#866).
+//
+// The assertion consumer service used to read the email out of whatever XML it
+// was posted and open a session for that account, with no signature, issuer,
+// audience, validity-window or InResponseTo check. Both entry points now refuse
+// every request and send the browser to the login screen, which already says
+// "this sign-in method is not configured". SAML comes back through a maintained
+// library that verifies signed assertions, not by patching a hand-rolled parser.
-// SAML2InitiateLogin initiates SAML2 login flow
+// SAML2InitiateLogin refuses: there is no ACS that could finish the flow.
func SAML2InitiateLogin(c *fiber.Ctx) error {
- idpURL := os.Getenv("SAML2_IDP_URL")
- entityID := os.Getenv("SAML2_SP_ENTITY_ID")
- acsURL := os.Getenv("SAML2_ACS_URL")
-
- if idpURL == "" || entityID == "" || acsURL == "" {
- return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
- "error": "SAML2 not properly configured",
- })
- }
-
- // Generate AuthnRequest
- requestID := uuid.New().String()
- now := time.Now().UTC()
-
- // Build simple AuthnRequest (in production, use a proper SAML library)
- authRequest := fmt.Sprintf(`
-
- %s
-
-
- urn:oasis:names:tc:SAML:2.0:ac:classes:Password
-
-`, requestID, now.Format("2006-01-02T15:04:05Z"), idpURL, acsURL, entityID)
-
- // Encode request
- encodedRequest := base64.StdEncoding.EncodeToString([]byte(authRequest))
-
- // Build redirect URL
- redirectURL := fmt.Sprintf("%s/app/login?SAMLRequest=%s", idpURL, encodedRequest)
-
- return c.JSON(fiber.Map{
- "redirect_url": redirectURL,
- "request_id": requestID,
- })
+ return oauthFailure(c, "provider_not_configured", "saml2", oauthLocale(c))
}
-// SAML2ACS handles SAML2 Assertion Consumer Service (callback)
+// SAML2ACS refuses every assertion, well-formed or not. It creates no user and
+// no session.
func SAML2ACS(c *fiber.Ctx) error {
// Get SAML Response from POST
samlResponse := c.FormValue("SAMLResponse")
diff --git a/backend/internal/handler/saml2_handler_test.go b/backend/internal/handler/saml2_handler_test.go
new file mode 100644
index 00000000..7d8ea70b
--- /dev/null
+++ b/backend/internal/handler/saml2_handler_test.go
@@ -0,0 +1,81 @@
+// Copyright (c) 2026 OpenDefender Contributors
+// SPDX-License-Identifier: LicenseRef-OpenRisk-Commercial
+
+package handler
+
+import (
+ "encoding/base64"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/gofiber/fiber/v2"
+ "github.com/stretchr/testify/require"
+)
+
+// SAML sign-in is off until assertions are verified (#866). Every request to
+// the two entry points must end on the login screen, with no session.
+
+func newSAMLTestApp(t *testing.T) *fiber.App {
+ t.Helper()
+ prevBase := oauthAppBaseURL
+ t.Cleanup(func() { oauthAppBaseURL = prevBase })
+ oauthAppBaseURL = "https://app.test"
+
+ app := fiber.New()
+ app.Get("/api/v1/auth/saml2/login", SAML2InitiateLogin)
+ app.Post("/api/v1/auth/saml2/acs", SAML2ACS)
+ return app
+}
+
+func requireSAMLRefused(t *testing.T, resp *http.Response) {
+ t.Helper()
+ require.Equal(t, http.StatusFound, resp.StatusCode)
+ loc, err := url.Parse(resp.Header.Get("Location"))
+ require.NoError(t, err)
+ require.Equal(t, "https://app.test/login", loc.Scheme+"://"+loc.Host+loc.Path)
+ require.Equal(t, "provider_not_configured", loc.Query().Get("error"))
+ require.Equal(t, "saml2", loc.Query().Get("provider"))
+ for _, ck := range resp.Cookies() {
+ require.NotContains(t, []string{"or_access", "or_refresh", "or_csrf"}, ck.Name, "a refused SAML request must not set a session")
+ }
+}
+
+func postACS(t *testing.T, app *fiber.App, form url.Values) *http.Response {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/saml2/acs", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ resp, err := app.Test(req, -1)
+ require.NoError(t, err)
+ return resp
+}
+
+func TestSAML2ACS_RefusesAWellFormedSuccessResponse(t *testing.T) {
+ app := newSAMLTestApp(t)
+ response := `
+
+ admin@opendefender.io
+`
+
+ resp := postACS(t, app, url.Values{"SAMLResponse": {base64.StdEncoding.EncodeToString([]byte(response))}})
+
+ requireSAMLRefused(t, resp)
+}
+
+func TestSAML2ACS_RefusesAnEmptyPost(t *testing.T) {
+ requireSAMLRefused(t, postACS(t, newSAMLTestApp(t), url.Values{}))
+}
+
+func TestSAML2InitiateLogin_RedirectsToTheLoginScreen(t *testing.T) {
+ app := newSAMLTestApp(t)
+ t.Setenv("SAML2_IDP_URL", "https://idp.example")
+ t.Setenv("SAML2_SP_ENTITY_ID", "openrisk")
+ t.Setenv("SAML2_ACS_URL", "https://app.test/api/v1/auth/saml2/acs")
+
+ resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/v1/auth/saml2/login", nil), -1)
+ require.NoError(t, err)
+
+ requireSAMLRefused(t, resp)
+}
diff --git a/docs/API_COMPLETE_ENDPOINTS.md b/docs/API_COMPLETE_ENDPOINTS.md
index 889d7fa4..a59f6760 100644
--- a/docs/API_COMPLETE_ENDPOINTS.md
+++ b/docs/API_COMPLETE_ENDPOINTS.md
@@ -60,13 +60,13 @@
### 6. SAML2 Login
- **Endpoint**: `GET /auth/saml2/login`
- **Auth**: โ No
-- **Response**: Redirects to SAML IdP
+- **Response**: `302` to `/login?error=provider_not_configured&provider=saml2`. SAML sign-in is turned off until signed assertions are verified
### 7. SAML2 ACS
- **Endpoint**: `POST /auth/saml2/acs`
- **Auth**: โ No
- **Body**: SAML response from IdP
-- **Response**: Redirects with JWT token
+- **Response**: `302` to `/login?error=provider_not_configured&provider=saml2`, whatever the body. No user or session is created
### 8. SAML2 Metadata
- **Endpoint**: `GET /auth/saml2/metadata`
diff --git a/docs/API_SECURITY_GUIDE.md b/docs/API_SECURITY_GUIDE.md
index 91eef083..1bb4dec6 100644
--- a/docs/API_SECURITY_GUIDE.md
+++ b/docs/API_SECURITY_GUIDE.md
@@ -203,6 +203,11 @@ forged request cannot use up the real user's flow.
#### SAML2 Flow
+> **Turned off.** `GET /saml2/login` and `POST /saml2/acs` redirect to
+> `/login?error=provider_not_configured&provider=saml2` and create nothing. SAML
+> returns through a library that verifies signed assertions. The steps below
+> describe that intended flow, not current behaviour.
+
1. Initiate: `GET /api/v1/auth/saml2/login`
2. Redirects to SAML IdP
3. User authenticates
diff --git a/docs/ENDPOINTS.md b/docs/ENDPOINTS.md
index 5145c8d8..8d503493 100644
--- a/docs/ENDPOINTS.md
+++ b/docs/ENDPOINTS.md
@@ -23,7 +23,7 @@ Last updated: 2026-07-13.
| POST | `/api/v1/auth/logout` | Revoke the current session |
| GET | `/api/v1/auth/me` | Current user claims |
| GET | `/api/v1/auth/oauth2/login/:provider` ยท `/auth/oauth2/callback/:provider` | OAuth2 (design/partial) |
-| GET | `/api/v1/auth/saml2/login` ยท `/auth/saml2/metadata` ยท POST `/auth/saml2/acs` | SAML2 (design/partial) |
+| GET | `/api/v1/auth/saml2/login` ยท `/auth/saml2/metadata` ยท POST `/auth/saml2/acs` | SAML2, turned off: login and ACS redirect to `/login?error=provider_not_configured` |
| GET | `/api/v1/health` | Liveness โ `{db,status,version}` (public) |
## Risks
diff --git a/docs/SAML_OAUTH2_INTEGRATION.md b/docs/SAML_OAUTH2_INTEGRATION.md
index a2fa8dbb..3c79126d 100644
--- a/docs/SAML_OAUTH2_INTEGRATION.md
+++ b/docs/SAML_OAUTH2_INTEGRATION.md
@@ -1,5 +1,10 @@
# SAML/OAuth2 Enterprise SSO Integration Guide
+> **SAML2 is turned off.** Its login and assertion endpoints redirect to the
+> login screen and create no session, until OpenRisk verifies signed assertions
+> through a maintained SAML library. The OAuth2 sections of this guide apply
+> today; the SAML2 sections do not.
+
## Overview
This guide covers integrating OpenRisk with enterprise authentication providers using: