Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
148 changes: 12 additions & 136 deletions backend/internal/handler/saml2_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<?xml version="1.0" encoding="UTF-8"?>
<samlp:AuthnRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="%s"
Version="2.0"
IssueInstant="%s"
Destination="%s/app/login"
AssertionConsumerServiceURL="%s"
ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">
<saml:Issuer>%s</saml:Issuer>
<samlp:NameIDPolicy
Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
AllowCreate="true"/>
<samlp:RequestedAuthnContext Comparison="exact">
<saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:Password</saml:AuthnContextClassRef>
</samlp:RequestedAuthnContext>
</samlp:AuthnRequest>`, 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")
Expand Down
81 changes: 81 additions & 0 deletions backend/internal/handler/saml2_handler_test.go
Original file line number Diff line number Diff line change
@@ -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 := `<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="r1" Version="2.0">
<samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
<saml:Assertion><saml:Subject><saml:NameID>admin@opendefender.io</saml:NameID></saml:Subject></saml:Assertion>
</samlp:Response>`

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)
}
4 changes: 2 additions & 2 deletions docs/API_COMPLETE_ENDPOINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
5 changes: 5 additions & 0 deletions docs/API_SECURITY_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/ENDPOINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/SAML_OAUTH2_INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
Loading