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: