From 069b3db8964a7e0599d9be1902e5224d8ccb2845 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 1 Oct 2026 22:53:30 +0100 Subject: [PATCH 1/2] fix(auth): SAML endpoints refuse every request (#866) The SAML assertion consumer service read the email out of whatever XML it was posted and opened a session for that account. It checked no signature, issuer, audience, validity window or InResponseTo, and it was mounted on every deployment whether or not SAML was configured. SAML2ACS and SAML2InitiateLogin now redirect to /login?error=provider_not_configured&provider=saml2, which the login screen already explains, and create nothing. The code that turned an assertion into a user, a group-mapped role and a session is removed rather than left unreachable; real SAML support comes back through a maintained library that verifies signed assertions. The metadata endpoint is unchanged. SAML sign-in was never proven end to end, so no working flow is lost. Signed-off-by: alex-dembele --- backend/internal/handler/saml2_handler.go | 331 +----------------- .../internal/handler/saml2_handler_test.go | 81 +++++ 2 files changed, 94 insertions(+), 318 deletions(-) create mode 100644 backend/internal/handler/saml2_handler_test.go diff --git a/backend/internal/handler/saml2_handler.go b/backend/internal/handler/saml2_handler.go index 3156f82b..baa93a01 100644 --- a/backend/internal/handler/saml2_handler.go +++ b/backend/internal/handler/saml2_handler.go @@ -6,335 +6,30 @@ 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"` -} +// 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. -type SAMLStatusCode struct { - Value string `xml:"Value,attr"` -} - -// 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") - if samlResponse == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": "SAML Response not provided", - }) - } - - // Decode base64 - decoded, err := base64.StdEncoding.DecodeString(samlResponse) - if err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": fmt.Sprintf("Failed to decode SAML Response: %v", err), - }) - } - - // Parse XML - var response SAMLResponse - if err := xml.Unmarshal(decoded, &response); err != nil { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": fmt.Sprintf("Failed to parse SAML Response: %v", err), - }) - } - - // Validate response - if response.Status.StatusCode.Value != "urn:oasis:names:tc:SAML:2.0:status:Success" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ - "error": fmt.Sprintf("SAML authentication failed: %s", response.Status.StatusCode.Value), - }) - } - - // Extract user information from assertion - assertion := response.Assertion - email := assertion.Subject.NameID - userInfo := &OAuth2UserInfo{ - Email: email, - Provider: "saml2", - } - - // Extract attributes - for _, attr := range assertion.AttributeStatement.Attributes { - switch attr.Name { - case "email": - if len(attr.Values) > 0 { - userInfo.Email = attr.Values[0].Text - } - case "emailAddress": - if len(attr.Values) > 0 { - userInfo.Email = attr.Values[0].Text - } - case "displayName", "name": - if len(attr.Values) > 0 { - userInfo.Name = attr.Values[0].Text - } - case "groups", "memberOf": - for _, val := range attr.Values { - userInfo.Groups = append(userInfo.Groups, val.Text) - } - } - } - - // Use email as name if name not found - if userInfo.Name == "" { - userInfo.Name = strings.Split(userInfo.Email, "@")[0] - } - - // Provision user - user, err := provisionSAML2User(userInfo) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ - "error": fmt.Sprintf("Failed to provision user: %v", err), - }) - } - - // Apply group-based role mapping if configured - if len(userInfo.Groups) > 0 { - if err := applyGroupRoleMapping(user, userInfo.Groups); err != nil { - log.Printf("Warning: failed to apply group role mapping for user %s: %v", user.ID, err) - } - } - - // Issue an RS256 access+refresh pair via the SAME TokenManager as password - // login (previously HS256, rejected by the RS256 middleware). Onboarding + - // audit happen inside issueSSOSession. - return issueSSOSession(c, user, "saml2") -} - -// provisionSAML2User finds or creates a user from SAML2 assertion -func provisionSAML2User(userInfo *OAuth2UserInfo) (*domain.User, error) { - user := &domain.User{} - - // Find existing user by email - result := database.DB.Preload("Role").Where("email = ?", userInfo.Email).First(user) - - if result.Error == gorm.ErrRecordNotFound { - // Check if auto-provisioning is enabled - autoProvision := os.Getenv("SSO_AUTO_PROVISION") - if autoProvision == "" { - autoProvision = "true" - } - - if autoProvision != "true" { - return nil, fmt.Errorf("user auto-provisioning disabled") - } - - // Get default role - defaultRole := &domain.Role{} - if err := database.DB.Where("name = ?", "viewer").First(defaultRole).Error; err != nil { - return nil, fmt.Errorf("default role not found: %w", err) - } - - // Create new user - user = &domain.User{ - ID: uuid.New(), - Email: userInfo.Email, - Username: userInfo.Email, - FullName: userInfo.Name, - RoleID: defaultRole.ID, - IsActive: true, - } - - if err := database.DB.Create(user).Error; err != nil { - return nil, fmt.Errorf("failed to create user: %w", err) - } - - // Reload with role - database.DB.Preload("Role").First(user) - - return user, nil - } - - if result.Error != nil { - return nil, result.Error - } - - // Update existing user if auto-update is enabled - autoUpdate := os.Getenv("SSO_AUTO_UPDATE_PROFILE") - if autoUpdate == "" { - autoUpdate = "true" - } - - if autoUpdate == "true" { - user.FullName = userInfo.Name - database.DB.Save(user) - } - - return user, nil -} - -// applyGroupRoleMapping maps SAML groups to OpenRisk roles -func applyGroupRoleMapping(user *domain.User, groups []string) error { - // Get role mapping from environment (simple JSON or key:value pairs) - // Format: "admin-group:admin,analyst-group:analyst,viewer-group:viewer" - mappingStr := os.Getenv("SSO_GROUP_ROLE_MAPPING") - if mappingStr == "" { - return nil // No mapping configured - } - - // Parse mapping - mapping := make(map[string]string) - for _, pair := range strings.Split(mappingStr, ",") { - parts := strings.Split(strings.TrimSpace(pair), ":") - if len(parts) == 2 { - mapping[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) - } - } - - // Check if any of the user's groups map to a role - for _, group := range groups { - if roleName, exists := mapping[group]; exists { - // Find the role - role := &domain.Role{} - if err := database.DB.Where("name = ?", roleName).First(role).Error; err == nil { - // Update user role - user.RoleID = role.ID - database.DB.Save(user) - return nil - } - } - } - - return nil + return oauthFailure(c, "provider_not_configured", "saml2", oauthLocale(c)) } // SAMLMetadata generates SAML2 Service Provider metadata 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) +} From 329cf2854632793057f0305aaf1366ac5c806495 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 1 Oct 2026 22:53:30 +0100 Subject: [PATCH 2/2] docs: stop presenting SAML2 sign-in as available (#866) The README listed SAML2 among the SSO options, and the endpoint references said the ACS redirects with a token. Both now say SAML is turned off and what its endpoints answer. The pricing and self-hosting plan tables are left for the owner. Signed-off-by: alex-dembele --- README.md | 6 +++--- docs/API_COMPLETE_ENDPOINTS.md | 4 ++-- docs/API_SECURITY_GUIDE.md | 5 +++++ docs/ENDPOINTS.md | 2 +- docs/SAML_OAUTH2_INTEGRATION.md | 5 +++++ 5 files changed, 16 insertions(+), 6 deletions(-) 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/docs/API_COMPLETE_ENDPOINTS.md b/docs/API_COMPLETE_ENDPOINTS.md index 06a53a6f..002f591e 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 d23389c0..69acfa68 100644 --- a/docs/API_SECURITY_GUIDE.md +++ b/docs/API_SECURITY_GUIDE.md @@ -187,6 +187,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: