1171 lines
39 KiB
Go
1171 lines
39 KiB
Go
package auth
|
|
|
|
// Reference: P2E §1.6 — SAML 2.0 Service Provider integration
|
|
// Pure Go implementation using encoding/xml for SAML XML parsing.
|
|
// No external SAML library dependency — all XML parsing, request generation,
|
|
// and response validation implemented with Go stdlib.
|
|
// Enterprise SSO feature: SP-initiated SSO with IdP metadata, ACS endpoint,
|
|
// attribute mapping, and replay attack prevention.
|
|
|
|
import (
|
|
"compress/flate"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/config"
|
|
"github.com/gochat/gochat/internal/model"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
"github.com/redis/go-redis/v9"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// SAML errors
|
|
var (
|
|
ErrSAMLEnabled = errors.New("saml authentication is not enabled")
|
|
ErrSAMLInvalidConfig = errors.New("saml configuration is invalid")
|
|
ErrSAMLInvalidResponse = errors.New("saml response validation failed")
|
|
ErrSAMLReplay = errors.New("saml response replay detected")
|
|
ErrSAMLMissingNameID = errors.New("saml response missing NameID")
|
|
ErrSAMLMissingEmail = errors.New("saml response missing email attribute")
|
|
ErrSAMLIdPMetadata = errors.New("failed to load IdP metadata")
|
|
)
|
|
|
|
// SAMLUserInfo represents user info extracted from a SAML assertion.
|
|
type SAMLUserInfo struct {
|
|
NameID string // Subject NameID — unique identifier from IdP
|
|
Email string // Mapped from SAML attribute
|
|
DisplayName string // Mapped from SAML attribute
|
|
FirstName string // Mapped from SAML attribute
|
|
LastName string // Mapped from SAML attribute
|
|
Attributes map[string]string // All SAML attributes for flexibility
|
|
}
|
|
|
|
// IdPMetadata holds parsed IdP metadata fields.
|
|
type IdPMetadata struct {
|
|
EntityID string // IdP entity ID
|
|
SSOURL string // SingleSignOnService redirect binding URL
|
|
SLORedirectURL string // SingleLogoutService redirect binding URL
|
|
SLOPostURL string // SingleLogoutService POST binding URL
|
|
ACSURL string // Assertion Consumer Service URL (from config, not IdP metadata)
|
|
Certificates []string // PEM-encoded X.509 certificates from IdP metadata
|
|
}
|
|
|
|
// SAMLService provides SAML 2.0 SP authentication.
|
|
type SAMLService struct {
|
|
cfg *config.SAMLConfig
|
|
idpMetadata *IdPMetadata
|
|
rdb redis.Cmdable
|
|
db *gorm.DB
|
|
}
|
|
|
|
// GetIdPEntityID returns the configured IdP entity ID.
|
|
// Used by the ACS handler to populate SSO session data.
|
|
func (s *SAMLService) GetIdPEntityID() string {
|
|
if s.idpMetadata != nil {
|
|
return s.idpMetadata.EntityID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// --- SAML XML types for encoding/xml parsing/generation ---
|
|
// These are local types that map to SAML XML namespaces using xml.Name.
|
|
|
|
// SamlpNamespace is the SAML protocol namespace.
|
|
const SamlpNamespace = "urn:oasis:names:tc:SAML:2.0:protocol"
|
|
const SamlNamespace = "urn:oasis:names:tc:SAML:2.0:assertion"
|
|
const MDNamespace = "urn:oasis:names:tc:SAML:2.0:metadata"
|
|
|
|
// --- IdP Metadata XML types ---
|
|
|
|
// EntityDescriptorXML represents a SAML metadata EntityDescriptor.
|
|
type EntityDescriptorXML struct {
|
|
XMLName xml.Name `xml:"EntityDescriptor"`
|
|
EntityID string `xml:"entityID,attr"`
|
|
IDPSSODescriptor *IDPSSODescriptorXML `xml:"IDPSSODescriptor"`
|
|
}
|
|
|
|
// IDPSSODescriptorXML represents an IDPSSODescriptor element.
|
|
type IDPSSODescriptorXML struct {
|
|
XMLName xml.Name `xml:"IDPSSODescriptor"`
|
|
NameIDFormats []NameIDFormatXML `xml:"NameIDFormat"`
|
|
SingleSignOnServices []SingleSignOnServiceXML `xml:"SingleSignOnService"`
|
|
SingleLogoutServices []SingleLogoutServiceXML `xml:"SingleLogoutService"`
|
|
KeyDescriptors []KeyDescriptorXML `xml:"KeyDescriptor"`
|
|
}
|
|
|
|
// SingleLogoutServiceXML represents a SingleLogoutService element in IdP metadata.
|
|
type SingleLogoutServiceXML struct {
|
|
XMLName xml.Name `xml:"SingleLogoutService"`
|
|
Binding string `xml:"Binding,attr"`
|
|
Location string `xml:"Location,attr"`
|
|
}
|
|
|
|
// NameIDFormatXML represents a NameIDFormat element.
|
|
type NameIDFormatXML struct {
|
|
XMLName xml.Name `xml:"NameIDFormat"`
|
|
Value string `xml:",chardata"`
|
|
}
|
|
|
|
// SingleSignOnServiceXML represents a SingleSignOnService element.
|
|
type SingleSignOnServiceXML struct {
|
|
XMLName xml.Name `xml:"SingleSignOnService"`
|
|
Binding string `xml:"Binding,attr"`
|
|
Location string `xml:"Location,attr"`
|
|
}
|
|
|
|
// KeyDescriptorXML represents a KeyDescriptor element in metadata.
|
|
type KeyDescriptorXML struct {
|
|
XMLName xml.Name `xml:"KeyDescriptor"`
|
|
Use string `xml:"use,attr"`
|
|
KeyInfo X509KeyInfoXML `xml:"KeyInfo"`
|
|
}
|
|
|
|
// X509KeyInfoXML represents a KeyInfo element with X509 data.
|
|
type X509KeyInfoXML struct {
|
|
XMLName xml.Name `xml:"KeyInfo"`
|
|
X509Data X509DataXML `xml:"X509Data"`
|
|
}
|
|
|
|
// X509DataXML represents X509Data element.
|
|
type X509DataXML struct {
|
|
XMLName xml.Name `xml:"X509Data"`
|
|
X509Certificates []X509CertXML `xml:"X509Certificate"`
|
|
}
|
|
|
|
// X509CertXML represents a base64-encoded X509 certificate.
|
|
type X509CertXML struct {
|
|
XMLName xml.Name `xml:"X509Certificate"`
|
|
Value string `xml:",chardata"`
|
|
}
|
|
|
|
// --- SAML Response XML types ---
|
|
|
|
// SAMLResponseXML represents a SAML Response element.
|
|
type SAMLResponseXML struct {
|
|
XMLName xml.Name `xml:"Response"`
|
|
ID string `xml:"ID,attr"`
|
|
InResponseTo string `xml:"InResponseTo,attr"`
|
|
IssueInstant string `xml:"IssueInstant,attr"`
|
|
Destination string `xml:"Destination,attr"`
|
|
Assertions []SAMLAssertionXML `xml:"Assertion"`
|
|
}
|
|
|
|
// SAMLAssertionXML represents a SAML Assertion element.
|
|
type SAMLAssertionXML struct {
|
|
XMLName xml.Name `xml:"Assertion"`
|
|
ID string `xml:"ID,attr"`
|
|
IssueInstant string `xml:"IssueInstant,attr"`
|
|
Subject SAMLSubjectXML `xml:"Subject"`
|
|
Conditions SAMLConditionsXML `xml:"Conditions"`
|
|
AttributeStatements []SAMLAttributeStatementXML `xml:"AttributeStatement"`
|
|
}
|
|
|
|
// SAMLSubjectXML represents a SAML Subject element.
|
|
type SAMLSubjectXML struct {
|
|
XMLName xml.Name `xml:"Subject"`
|
|
NameID SAMLNameIDXML `xml:"NameID"`
|
|
}
|
|
|
|
// SAMLNameIDXML represents a SAML NameID element.
|
|
type SAMLNameIDXML struct {
|
|
XMLName xml.Name `xml:"NameID"`
|
|
Format string `xml:"Format,attr"`
|
|
Value string `xml:",chardata"`
|
|
}
|
|
|
|
// SAMLConditionsXML represents SAML Conditions.
|
|
type SAMLConditionsXML struct {
|
|
XMLName xml.Name `xml:"Conditions"`
|
|
NotBefore string `xml:"NotBefore,attr"`
|
|
NotOnOrAfter string `xml:"NotOnOrAfter,attr"`
|
|
AudienceRestrictions []SAMLAudienceRestrictionXML `xml:"AudienceRestriction"`
|
|
}
|
|
|
|
// SAMLAudienceRestrictionXML represents an AudienceRestriction element.
|
|
type SAMLAudienceRestrictionXML struct {
|
|
XMLName xml.Name `xml:"AudienceRestriction"`
|
|
Audiences []SAMLAudienceXML `xml:"Audience"`
|
|
}
|
|
|
|
// SAMLAudienceXML represents an Audience element.
|
|
type SAMLAudienceXML struct {
|
|
XMLName xml.Name `xml:"Audience"`
|
|
Value string `xml:",chardata"`
|
|
}
|
|
|
|
// SAMLAttributeStatementXML represents an AttributeStatement element.
|
|
type SAMLAttributeStatementXML struct {
|
|
XMLName xml.Name `xml:"AttributeStatement"`
|
|
Attributes []SAMLAttributeXML `xml:"Attribute"`
|
|
}
|
|
|
|
// SAMLAttributeXML represents a SAML Attribute element.
|
|
type SAMLAttributeXML struct {
|
|
XMLName xml.Name `xml:"Attribute"`
|
|
Name string `xml:"Name,attr"`
|
|
FriendlyName string `xml:"FriendlyName,attr"`
|
|
Values []SAMLAttributeValueXML `xml:"AttributeValue"`
|
|
}
|
|
|
|
// SAMLAttributeValueXML represents a SAML AttributeValue element.
|
|
type SAMLAttributeValueXML struct {
|
|
XMLName xml.Name `xml:"AttributeValue"`
|
|
Value string `xml:",chardata"`
|
|
}
|
|
|
|
// --- SP Metadata XML types (for generation) ---
|
|
|
|
// SPEntityDescriptorXML represents an SP EntityDescriptor for metadata generation.
|
|
type SPEntityDescriptorXML struct {
|
|
XMLName xml.Name `xml:"EntityDescriptor"`
|
|
EntityID string `xml:"entityID,attr"`
|
|
SPSSODescriptor *SPSSODescriptorXML `xml:"SPSSODescriptor"`
|
|
}
|
|
|
|
// SPSSODescriptorXML represents an SPSSODescriptor for metadata generation.
|
|
type SPSSODescriptorXML struct {
|
|
XMLName xml.Name `xml:"SPSSODescriptor"`
|
|
AuthnRequestsSigned string `xml:"authnRequestsSigned,attr"`
|
|
WantAssertionsSigned string `xml:"wantAssertionsSigned,attr"`
|
|
ProtocolSupportEnum string `xml:"protocolSupportEnumeration,attr"`
|
|
NameIDFormats []SPNameIDFormatXML `xml:"NameIDFormat"`
|
|
AssertionConsumerServices []SPAssertionConsumerServiceXML `xml:"AssertionConsumerService"`
|
|
KeyDescriptors []SPKeyDescriptorXML `xml:"KeyDescriptor"`
|
|
}
|
|
|
|
// SPNameIDFormatXML represents a NameIDFormat for SP metadata.
|
|
type SPNameIDFormatXML struct {
|
|
XMLName xml.Name `xml:"NameIDFormat"`
|
|
Value string `xml:",chardata"`
|
|
}
|
|
|
|
// SPAssertionConsumerServiceXML represents an AssertionConsumerService for SP metadata.
|
|
type SPAssertionConsumerServiceXML struct {
|
|
XMLName xml.Name `xml:"AssertionConsumerService"`
|
|
Binding string `xml:"Binding,attr"`
|
|
Location string `xml:"Location,attr"`
|
|
Index int `xml:"index,attr"`
|
|
}
|
|
|
|
// SPKeyDescriptorXML represents a KeyDescriptor for SP metadata.
|
|
type SPKeyDescriptorXML struct {
|
|
XMLName xml.Name `xml:"KeyDescriptor"`
|
|
Use string `xml:"use,attr"`
|
|
KeyInfo SPKeyInfoXML `xml:"KeyInfo"`
|
|
}
|
|
|
|
// SPKeyInfoXML represents KeyInfo for SP metadata.
|
|
type SPKeyInfoXML struct {
|
|
XMLName xml.Name `xml:"KeyInfo"`
|
|
X509Data SPX509DataXML `xml:"X509Data"`
|
|
}
|
|
|
|
// SPX509DataXML represents X509Data for SP metadata.
|
|
type SPX509DataXML struct {
|
|
XMLName xml.Name `xml:"X509Data"`
|
|
X509Certificates []SPX509CertXML `xml:"X509Certificate"`
|
|
}
|
|
|
|
// SPX509CertXML represents an X509Certificate for SP metadata.
|
|
type SPX509CertXML struct {
|
|
XMLName xml.Name `xml:"X509Certificate"`
|
|
Value string `xml:",chardata"`
|
|
}
|
|
|
|
// --- AuthnRequest XML type (for generation) ---
|
|
|
|
// AuthnRequestXML represents a SAML AuthnRequest for SP-initiated login.
|
|
type AuthnRequestXML struct {
|
|
XMLName xml.Name `xml:"AuthnRequest"`
|
|
ID string `xml:"ID,attr"`
|
|
Version string `xml:"Version,attr"`
|
|
IssueInstant string `xml:"IssueInstant,attr"`
|
|
Destination string `xml:"Destination,attr"`
|
|
AssertionConsumerServiceURL string `xml:"AssertionConsumerServiceURL,attr"`
|
|
Issuer SAMLIssuerXML `xml:"Issuer"`
|
|
}
|
|
|
|
// SAMLIssuerXML represents a SAML Issuer element.
|
|
type SAMLIssuerXML struct {
|
|
XMLName xml.Name `xml:"Issuer"`
|
|
Value string `xml:",chardata"`
|
|
}
|
|
|
|
// --- Public API ---
|
|
|
|
// NewSAMLService creates a SAML SP service from config, Redis, and database.
|
|
// When SAML is disabled, returns a minimal service that rejects all operations.
|
|
func NewSAMLService(cfg *config.SAMLConfig, rdb redis.Cmdable, db *gorm.DB) (*SAMLService, error) {
|
|
if !cfg.Enabled {
|
|
applogger.L().Info("SAML service created (disabled)")
|
|
return &SAMLService{cfg: cfg, rdb: rdb, db: db}, nil
|
|
}
|
|
|
|
// Validate required fields for enabled SAML
|
|
if cfg.SPEntityID == "" {
|
|
return nil, ErrSAMLInvalidConfig
|
|
}
|
|
if cfg.ACSURL == "" {
|
|
return nil, ErrSAMLInvalidConfig
|
|
}
|
|
if cfg.IdPMetadataURL == "" && cfg.IdPMetadataXML == "" {
|
|
return nil, ErrSAMLIdPMetadata
|
|
}
|
|
|
|
// Load and parse IdP metadata
|
|
idpMetadata, err := loadIdPMetadata(cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrSAMLIdPMetadata, err)
|
|
}
|
|
|
|
applogger.L().Infof("SAML service initialized (idpEntityID=%s, ssoURL=%s)", idpMetadata.EntityID, idpMetadata.SSOURL)
|
|
return &SAMLService{
|
|
cfg: cfg,
|
|
idpMetadata: idpMetadata,
|
|
rdb: rdb,
|
|
db: db,
|
|
}, nil
|
|
}
|
|
|
|
// InitiateLogin generates a SAML AuthnRequest redirect URL for SP-initiated SSO.
|
|
// The returned URL includes SAMLRequest (base64-encoded AuthnRequest) and RelayState.
|
|
// Stores InResponseTo in Redis for replay validation (5 min TTL).
|
|
func (s *SAMLService) InitiateLogin(state string) (string, error) {
|
|
if !s.cfg.Enabled || s.idpMetadata == nil {
|
|
return "", ErrSAMLEnabled
|
|
}
|
|
|
|
// Generate AuthnRequest
|
|
requestID := fmt.Sprintf("id_%s", generateRandomID())
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
authnRequest := AuthnRequestXML{
|
|
ID: requestID,
|
|
Version: "2.0",
|
|
IssueInstant: now,
|
|
Destination: s.idpMetadata.SSOURL,
|
|
AssertionConsumerServiceURL: s.cfg.ACSURL,
|
|
Issuer: SAMLIssuerXML{
|
|
Value: s.cfg.SPEntityID,
|
|
},
|
|
}
|
|
|
|
// Marshal to XML
|
|
xmlBytes, err := xml.Marshal(authnRequest)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal AuthnRequest: %w", err)
|
|
}
|
|
|
|
// Wrap in SAML protocol envelope
|
|
xmlStr := fmt.Sprintf("<?xml version=\"1.0\" encoding=\"UTF-8\"?>%s", string(xmlBytes))
|
|
|
|
// Deflate + base64 encode for redirect binding
|
|
encoded, err := deflateAndBase64Encode(xmlStr)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to encode AuthnRequest: %w", err)
|
|
}
|
|
|
|
// Build redirect URL
|
|
redirectURL, err := url.Parse(s.idpMetadata.SSOURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid IdP SSO URL: %w", err)
|
|
}
|
|
q := redirectURL.Query()
|
|
q.Set("SAMLRequest", encoded)
|
|
q.Set("RelayState", state)
|
|
redirectURL.RawQuery = q.Encode()
|
|
|
|
// Store InResponseTo in Redis for replay validation (5 min TTL)
|
|
if s.rdb != nil {
|
|
ctx := context.Background()
|
|
key := fmt.Sprintf("saml:inResponseTo:%s", requestID)
|
|
if err := s.rdb.Set(ctx, key, state, 5*time.Minute).Err(); err != nil {
|
|
applogger.L().Warnf("Failed to store SAML InResponseTo in Redis: %v", err)
|
|
// Non-critical: continue without Redis tracking
|
|
}
|
|
}
|
|
|
|
applogger.L().Infof("SAML AuthnRequest generated (id=%s, destination=%s)", requestID, s.idpMetadata.SSOURL)
|
|
return redirectURL.String(), nil
|
|
}
|
|
|
|
// ProcessResponse validates a SAML Response (base64-encoded XML from IdP),
|
|
// extracts NameID + attributes, validates conditions and replay attacks.
|
|
// Returns SAMLUserInfo on success.
|
|
func (s *SAMLService) ProcessResponse(samlResponse string) (*SAMLUserInfo, error) {
|
|
if !s.cfg.Enabled || s.idpMetadata == nil {
|
|
return nil, ErrSAMLEnabled
|
|
}
|
|
|
|
// Step 1: Base64-decode the SAMLResponse
|
|
xmlBytes, err := base64.StdEncoding.DecodeString(samlResponse)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: failed to base64 decode response: %v", ErrSAMLInvalidResponse, err)
|
|
}
|
|
|
|
// Step 2: Parse XML
|
|
response, err := parseSAMLResponseXML(xmlBytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: failed to parse response XML: %v", ErrSAMLInvalidResponse, err)
|
|
}
|
|
|
|
// Step 3: Extract assertion (first assertion)
|
|
if len(response.Assertions) == 0 {
|
|
return nil, fmt.Errorf("%w: response contains no assertions", ErrSAMLInvalidResponse)
|
|
}
|
|
assertion := response.Assertions[0]
|
|
|
|
// Step 4: Validate conditions
|
|
if err := validateConditions(&assertion.Conditions, s.cfg.SPEntityID, time.Now(), s.cfg.ClockDriftDuration()); err != nil {
|
|
return nil, fmt.Errorf("%w: conditions validation failed: %v", ErrSAMLInvalidResponse, err)
|
|
}
|
|
|
|
// Step 5: Check replay (response ID must not have been used before)
|
|
if err := checkAndTrackReplay(response.ID, s.rdb); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Step 6: Extract NameID
|
|
nameID := assertion.Subject.NameID.Value
|
|
if nameID == "" {
|
|
return nil, ErrSAMLMissingNameID
|
|
}
|
|
|
|
// Step 7: Extract attributes
|
|
attrs := extractAttributesFromXML(assertion.AttributeStatements)
|
|
attrMap := s.cfg.AttributeMap
|
|
|
|
userInfo := &SAMLUserInfo{
|
|
NameID: nameID,
|
|
Attributes: attrs,
|
|
}
|
|
|
|
userInfo.Email = getAttributeFromXML(attrs, attrMap.Email)
|
|
userInfo.DisplayName = getAttributeFromXML(attrs, attrMap.DisplayName)
|
|
userInfo.FirstName = getAttributeFromXML(attrs, attrMap.FirstName)
|
|
userInfo.LastName = getAttributeFromXML(attrs, attrMap.LastName)
|
|
|
|
// If DisplayName not found, compose from FirstName + LastName
|
|
if userInfo.DisplayName == "" && (userInfo.FirstName != "" || userInfo.LastName != "") {
|
|
userInfo.DisplayName = strings.TrimSpace(userInfo.FirstName + " " + userInfo.LastName)
|
|
}
|
|
|
|
// Email is required for user mapping
|
|
if userInfo.Email == "" {
|
|
// Fall back to NameID as email if it looks like an email
|
|
if strings.Contains(nameID, "@") {
|
|
userInfo.Email = nameID
|
|
} else {
|
|
return nil, ErrSAMLMissingEmail
|
|
}
|
|
}
|
|
|
|
applogger.L().Infof("SAML auth successful (nameID=%s, email=%s)", nameID, userInfo.Email)
|
|
return userInfo, nil
|
|
}
|
|
|
|
// GetSPMetadata returns the SP XML metadata for IdP configuration.
|
|
// This endpoint allows IdP admins to import our SP metadata.
|
|
func (s *SAMLService) GetSPMetadata() ([]byte, error) {
|
|
if !s.cfg.Enabled || s.idpMetadata == nil {
|
|
return nil, ErrSAMLEnabled
|
|
}
|
|
|
|
spMetadata := generateSPMetadataXML(s.cfg)
|
|
xmlBytes, err := xml.MarshalIndent(spMetadata, "", " ")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal SP metadata: %w", err)
|
|
}
|
|
|
|
return []byte(fmt.Sprintf("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n%s", string(xmlBytes))), nil
|
|
}
|
|
|
|
// FindOrCreateUser maps SAML user info to a GoChat User model.
|
|
// Looks up user by (provider=saml, uid=nameID) or by email.
|
|
// Creates a new user if not found (auto-provisioning).
|
|
func (s *SAMLService) FindOrCreateUser(userInfo *SAMLUserInfo) (*model.User, error) {
|
|
if s.db == nil {
|
|
return nil, errors.New("database not available for SAML user lookup")
|
|
}
|
|
|
|
var user model.User
|
|
|
|
// First: try to find by provider + UID (SAML NameID)
|
|
err := s.db.Where("provider = ? AND uid = ?", "saml", userInfo.NameID).First(&user).Error
|
|
if err == nil {
|
|
// Update user attributes from latest SAML assertion
|
|
user.Name = userInfo.DisplayName
|
|
user.Email = userInfo.Email
|
|
user.AvatarURL = userInfo.Attributes["avatarURL"]
|
|
if err := s.db.Save(&user).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to update SAML user: %w", err)
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fmt.Errorf("failed to query SAML user: %w", err)
|
|
}
|
|
|
|
// Second: try to find by email (link existing account to SAML)
|
|
err = s.db.Where("email = ?", userInfo.Email).First(&user).Error
|
|
if err == nil {
|
|
// Link existing account to SAML provider
|
|
user.Provider = "saml"
|
|
user.UID = userInfo.NameID
|
|
user.Name = userInfo.DisplayName
|
|
if err := s.db.Save(&user).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to link SAML to existing user: %w", err)
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, fmt.Errorf("failed to query user by email: %w", err)
|
|
}
|
|
|
|
// Third: create new user (auto-provisioning)
|
|
user = model.User{
|
|
Name: userInfo.DisplayName,
|
|
Email: userInfo.Email,
|
|
Provider: "saml",
|
|
UID: userInfo.NameID,
|
|
Role: "agent", // default role for SAML-provisioned users
|
|
Active: true,
|
|
// AccountID must be set by caller or via a default account assignment
|
|
// Password is empty — SAML users don't use password auth
|
|
}
|
|
|
|
if err := s.db.Create(&user).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to create SAML user: %w", err)
|
|
}
|
|
|
|
applogger.L().Infof("SAML auto-provisioned user (id=%d, email=%s)", user.ID, user.Email)
|
|
return &user, nil
|
|
}
|
|
|
|
// --- Helper functions ---
|
|
|
|
// loadIdPMetadata fetches IdP metadata from URL or parses inline XML.
|
|
func loadIdPMetadata(cfg *config.SAMLConfig) (*IdPMetadata, error) {
|
|
var data []byte
|
|
|
|
if cfg.IdPMetadataURL != "" {
|
|
metadataURL, err := url.Parse(cfg.IdPMetadataURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid IdP metadata URL: %w", err)
|
|
}
|
|
resp, err := http.Get(metadataURL.String())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch IdP metadata from URL: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("IdP metadata URL returned status %d", resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read IdP metadata response: %w", err)
|
|
}
|
|
data = body
|
|
} else if cfg.IdPMetadataXML != "" {
|
|
data = []byte(cfg.IdPMetadataXML)
|
|
} else {
|
|
return nil, errors.New("no IdP metadata source configured")
|
|
}
|
|
|
|
return parseIdPMetadataXML(data)
|
|
}
|
|
|
|
// parseIdPMetadataXML parses IdP metadata XML into an IdPMetadata struct.
|
|
func parseIdPMetadataXML(data []byte) (*IdPMetadata, error) {
|
|
var entityDesc EntityDescriptorXML
|
|
if err := xml.Unmarshal(data, &entityDesc); err != nil {
|
|
return nil, fmt.Errorf("failed to parse IdP metadata XML: %w", err)
|
|
}
|
|
|
|
if entityDesc.EntityID == "" {
|
|
return nil, errors.New("IdP metadata missing entityID")
|
|
}
|
|
|
|
metadata := &IdPMetadata{
|
|
EntityID: entityDesc.EntityID,
|
|
}
|
|
|
|
if entityDesc.IDPSSODescriptor != nil {
|
|
// Prefer HTTP-Redirect binding SSO URL
|
|
for _, sso := range entityDesc.IDPSSODescriptor.SingleSignOnServices {
|
|
if sso.Binding == "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" {
|
|
metadata.SSOURL = sso.Location
|
|
break
|
|
}
|
|
}
|
|
// Fallback to any SSO URL if redirect binding not found
|
|
if metadata.SSOURL == "" && len(entityDesc.IDPSSODescriptor.SingleSignOnServices) > 0 {
|
|
metadata.SSOURL = entityDesc.IDPSSODescriptor.SingleSignOnServices[0].Location
|
|
}
|
|
|
|
// Extract signing certificates
|
|
for _, kd := range entityDesc.IDPSSODescriptor.KeyDescriptors {
|
|
if kd.Use == "signing" || kd.Use == "" {
|
|
for _, cert := range kd.KeyInfo.X509Data.X509Certificates {
|
|
// Clean up whitespace from base64 cert data
|
|
certData := strings.TrimSpace(cert.Value)
|
|
// Re-encode as PEM if not already
|
|
if !strings.HasPrefix(certData, "-----BEGIN CERTIFICATE-----") {
|
|
certData = fmt.Sprintf("-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----", certData)
|
|
}
|
|
metadata.Certificates = append(metadata.Certificates, certData)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract SLO (Single Logout) endpoints
|
|
for _, slo := range entityDesc.IDPSSODescriptor.SingleLogoutServices {
|
|
if slo.Binding == "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" {
|
|
metadata.SLORedirectURL = slo.Location
|
|
} else if slo.Binding == "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" {
|
|
metadata.SLOPostURL = slo.Location
|
|
}
|
|
}
|
|
}
|
|
|
|
if entityDesc.IDPSSODescriptor != nil && metadata.SSOURL == "" {
|
|
return nil, errors.New("IdP metadata missing SingleSignOnService URL")
|
|
}
|
|
|
|
return metadata, nil
|
|
}
|
|
|
|
// parseSAMLResponseXML parses a SAML Response XML document.
|
|
func parseSAMLResponseXML(data []byte) (*SAMLResponseXML, error) {
|
|
var response SAMLResponseXML
|
|
if err := xml.Unmarshal(data, &response); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal SAML Response: %w", err)
|
|
}
|
|
return &response, nil
|
|
}
|
|
|
|
// validateConditions validates SAML Conditions (time window + audience restriction).
|
|
func validateConditions(conditions *SAMLConditionsXML, spEntityID string, now time.Time, driftTolerance time.Duration) error {
|
|
if conditions == nil {
|
|
// No conditions = no restrictions to validate
|
|
return nil
|
|
}
|
|
|
|
// Validate NotBefore
|
|
if conditions.NotBefore != "" {
|
|
notBefore, err := parseSAMLTime(conditions.NotBefore)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid NotBefore timestamp: %w", err)
|
|
}
|
|
if now.Before(notBefore.Add(-driftTolerance)) {
|
|
return fmt.Errorf("assertion not yet valid (now=%s, notBefore=%s)", now.Format(time.RFC3339), conditions.NotBefore)
|
|
}
|
|
}
|
|
|
|
// Validate NotOnOrAfter
|
|
if conditions.NotOnOrAfter != "" {
|
|
notOnOrAfter, err := parseSAMLTime(conditions.NotOnOrAfter)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid NotOnOrAfter timestamp: %w", err)
|
|
}
|
|
if now.Add(driftTolerance).After(notOnOrAfter) || now.Add(driftTolerance).Equal(notOnOrAfter) {
|
|
return fmt.Errorf("assertion expired (now=%s, notOnOrAfter=%s)", now.Format(time.RFC3339), conditions.NotOnOrAfter)
|
|
}
|
|
}
|
|
|
|
// Validate AudienceRestriction
|
|
if len(conditions.AudienceRestrictions) > 0 {
|
|
audienceMatch := false
|
|
for _, ar := range conditions.AudienceRestrictions {
|
|
for _, aud := range ar.Audiences {
|
|
if aud.Value == spEntityID {
|
|
audienceMatch = true
|
|
break
|
|
}
|
|
}
|
|
if audienceMatch {
|
|
break
|
|
}
|
|
}
|
|
if !audienceMatch {
|
|
return fmt.Errorf("audience restriction failed (expected=%s)", spEntityID)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// checkAndTrackReplay prevents replay attacks by tracking response IDs in Redis.
|
|
// If the response ID was already processed, returns ErrSAMLReplay.
|
|
// Otherwise, stores the ID with a TTL matching the assertion validity window.
|
|
func checkAndTrackReplay(responseID string, rdb redis.Cmdable) error {
|
|
if rdb == nil {
|
|
// Without Redis, we can't check replay — log warning
|
|
applogger.L().Warn("SAML replay check skipped: Redis not available")
|
|
return nil
|
|
}
|
|
|
|
ctx := context.Background()
|
|
key := fmt.Sprintf("saml:replay:%s", responseID)
|
|
|
|
// Check if response ID was already processed
|
|
exists, err := rdb.Exists(ctx, key).Result()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check SAML replay in Redis: %w", err)
|
|
}
|
|
if exists > 0 {
|
|
return ErrSAMLReplay
|
|
}
|
|
|
|
// Store the response ID with 5 min TTL (should cover assertion validity)
|
|
if err := rdb.Set(ctx, key, "1", 5*time.Minute).Err(); err != nil {
|
|
return fmt.Errorf("failed to store SAML replay tracking in Redis: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// extractAttributesFromXML extracts all SAML assertion attributes into a map.
|
|
func extractAttributesFromXML(statements []SAMLAttributeStatementXML) map[string]string {
|
|
attrs := make(map[string]string)
|
|
for _, statement := range statements {
|
|
for _, attr := range statement.Attributes {
|
|
if len(attr.Values) > 0 {
|
|
attrs[attr.Name] = attr.Values[0].Value
|
|
}
|
|
// Also store by FriendlyName if present
|
|
if attr.FriendlyName != "" && len(attr.Values) > 0 {
|
|
attrs[attr.FriendlyName] = attr.Values[0].Value
|
|
}
|
|
}
|
|
}
|
|
return attrs
|
|
}
|
|
|
|
// getAttributeFromXML retrieves a SAML attribute value by name from the attribute map.
|
|
// Checks both Name and FriendlyName matches.
|
|
func getAttributeFromXML(attrs map[string]string, attrName string) string {
|
|
if v, ok := attrs[attrName]; ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// generateSPMetadataXML generates SP metadata XML structure from config.
|
|
func generateSPMetadataXML(cfg *config.SAMLConfig) *SPEntityDescriptorXML {
|
|
spDesc := &SPEntityDescriptorXML{
|
|
XMLName: xml.Name{Space: MDNamespace, Local: "EntityDescriptor"},
|
|
EntityID: cfg.SPEntityID,
|
|
SPSSODescriptor: &SPSSODescriptorXML{
|
|
XMLName: xml.Name{Space: MDNamespace, Local: "SPSSODescriptor"},
|
|
AuthnRequestsSigned: "false",
|
|
WantAssertionsSigned: "true",
|
|
ProtocolSupportEnum: SamlpNamespace,
|
|
NameIDFormats: []SPNameIDFormatXML{
|
|
{Value: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"},
|
|
{Value: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"},
|
|
{Value: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"},
|
|
},
|
|
AssertionConsumerServices: []SPAssertionConsumerServiceXML{
|
|
{
|
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
Location: cfg.ACSURL,
|
|
Index: 0,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Add SP certificate if configured
|
|
if cfg.SPCertificate != "" {
|
|
// Extract just the base64 data from PEM
|
|
certBase64 := extractCertBase64FromPEM(cfg.SPCertificate)
|
|
spDesc.SPSSODescriptor.KeyDescriptors = []SPKeyDescriptorXML{
|
|
{
|
|
Use: "signing",
|
|
KeyInfo: SPKeyInfoXML{
|
|
X509Data: SPX509DataXML{
|
|
X509Certificates: []SPX509CertXML{
|
|
{Value: certBase64},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
return spDesc
|
|
}
|
|
|
|
// deflateAndBase64Encode deflates then base64-encodes a string (SAML redirect binding).
|
|
func deflateAndBase64Encode(xmlStr string) (string, error) {
|
|
var buf strings.Builder
|
|
writer, err := flate.NewWriter(&buf, flate.DefaultCompression)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := writer.Write([]byte(xmlStr)); err != nil {
|
|
return "", err
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.StdEncoding.EncodeToString([]byte(buf.String())), nil
|
|
}
|
|
|
|
// parseSAMLTime parses a SAML timestamp (ISO 8601 format).
|
|
func parseSAMLTime(s string) (time.Time, error) {
|
|
// SAML uses ISO 8601 format, typically RFC3339
|
|
layouts := []string{
|
|
time.RFC3339,
|
|
time.RFC3339Nano,
|
|
"2006-01-02T15:04:05Z",
|
|
"2006-01-02T15:04:05.000Z",
|
|
}
|
|
for _, layout := range layouts {
|
|
if t, err := time.Parse(layout, s); err == nil {
|
|
return t.UTC(), nil
|
|
}
|
|
}
|
|
return time.Time{}, fmt.Errorf("cannot parse SAML timestamp: %s", s)
|
|
}
|
|
|
|
// generateRandomID creates a random hex string for SAML request IDs.
|
|
func generateRandomID() string {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
// Fallback to timestamp-based ID
|
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
|
}
|
|
return fmt.Sprintf("%x", b)
|
|
}
|
|
|
|
// extractCertBase64FromPEM extracts the base64-encoded certificate data from a PEM block.
|
|
func extractCertBase64FromPEM(pemData string) string {
|
|
block, _ := pem.Decode([]byte(pemData))
|
|
if block == nil {
|
|
return pemData // fallback: return raw data
|
|
}
|
|
return base64.StdEncoding.EncodeToString(block.Bytes)
|
|
}
|
|
|
|
// parseRSAPrivateKey parses a PEM-encoded RSA private key.
|
|
func parseRSAPrivateKey(pemData string) (*rsa.PrivateKey, error) {
|
|
block, _ := pem.Decode([]byte(pemData))
|
|
if block == nil {
|
|
return nil, errors.New("no PEM block found in private key data")
|
|
}
|
|
|
|
switch block.Type {
|
|
case "RSA PRIVATE KEY":
|
|
return x509.ParsePKCS1PrivateKey(block.Bytes)
|
|
case "PRIVATE KEY":
|
|
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rsaKey, ok := key.(*rsa.PrivateKey)
|
|
if !ok {
|
|
return nil, errors.New("PKCS8 key is not RSA")
|
|
}
|
|
return rsaKey, nil
|
|
default:
|
|
return nil, fmt.Errorf("unsupported PEM block type: %s", block.Type)
|
|
}
|
|
}
|
|
|
|
// parseX509Certificate parses a PEM-encoded X.509 certificate.
|
|
func parseX509Certificate(pemData string) (*x509.Certificate, error) {
|
|
block, _ := pem.Decode([]byte(pemData))
|
|
if block == nil {
|
|
return nil, errors.New("no PEM block found in certificate data")
|
|
}
|
|
if block.Type != "CERTIFICATE" {
|
|
return nil, fmt.Errorf("unexpected PEM block type: %s (expected CERTIFICATE)", block.Type)
|
|
}
|
|
return x509.ParseCertificate(block.Bytes)
|
|
}
|
|
|
|
// EncodeSAMLRequest base64-encodes a SAML request string
|
|
// for use in redirect bindings (helper for URL-based SAML flows).
|
|
func EncodeSAMLRequest(xml string) (string, error) {
|
|
b64 := base64.StdEncoding.EncodeToString([]byte(xml))
|
|
return b64, nil
|
|
}
|
|
|
|
// --- SAML Single Logout (SLO) support ---
|
|
// Reference: M13 §1 — SAML 2.0 Single Logout (SLO)
|
|
// SLO enables enterprise users to terminate all active sessions
|
|
// across all accounts when they logout from one application.
|
|
// Two flows: (1) SP-initiated SLO — user clicks logout in GoChat,
|
|
// we send LogoutRequest to IdP, IdP propagates to all SPs.
|
|
// (2) IdP-initiated SLO — IdP sends LogoutRequest to our SLO endpoint,
|
|
// we terminate the local session and respond.
|
|
|
|
// LogoutRequestXML represents a SAML LogoutRequest element.
|
|
type LogoutRequestXML struct {
|
|
XMLName xml.Name `xml:"LogoutRequest"`
|
|
ID string `xml:"ID,attr"`
|
|
Version string `xml:"Version,attr"`
|
|
IssueInstant string `xml:"IssueInstant,attr"`
|
|
Destination string `xml:"Destination,attr"`
|
|
NotOnOrAfter string `xml:"NotOnOrAfter,attr,omitempty"`
|
|
Issuer SAMLIssuerXML `xml:"Issuer"`
|
|
NameID SAMLNameIDXML `xml:"NameID"`
|
|
SessionIndex string `xml:"SessionIndex,omitempty"`
|
|
}
|
|
|
|
// LogoutResponseXML represents a SAML LogoutResponse element.
|
|
type LogoutResponseXML struct {
|
|
XMLName xml.Name `xml:"LogoutResponse"`
|
|
ID string `xml:"ID,attr"`
|
|
InResponseTo string `xml:"InResponseTo,attr"`
|
|
Version string `xml:"Version,attr"`
|
|
IssueInstant string `xml:"IssueInstant,attr"`
|
|
Destination string `xml:"Destination,attr,omitempty"`
|
|
Issuer SAMLIssuerXML `xml:"Issuer"`
|
|
Status SAMLStatusXML `xml:"Status"`
|
|
}
|
|
|
|
// SAMLStatusXML represents a SAML Status element.
|
|
type SAMLStatusXML struct {
|
|
XMLName xml.Name `xml:"Status"`
|
|
StatusCode SAMLStatusCodeXML `xml:"StatusCode"`
|
|
}
|
|
|
|
// SAMLStatusCodeXML represents a SAML StatusCode element.
|
|
type SAMLStatusCodeXML struct {
|
|
XMLName xml.Name `xml:"StatusCode"`
|
|
Value string `xml:"Value,attr"`
|
|
}
|
|
|
|
// SLOEndpoint holds parsed IdP SLO endpoint info.
|
|
type SLOEndpoint struct {
|
|
RedirectURL string // HTTP-Redirect binding SLO URL
|
|
PostURL string // HTTP-POST binding SLO URL
|
|
}
|
|
|
|
// InitiateLogout generates a SAML LogoutRequest redirect URL for SP-initiated SLO.
|
|
// The returned URL includes SAMLRequest (base64-encoded LogoutRequest) and RelayState.
|
|
// Stores InResponseTo in Redis for response validation (5 min TTL).
|
|
// After IdP processes the logout, it will propagate to all SPs in the session.
|
|
func (s *SAMLService) InitiateLogout(sessionID string, nameID string, sessionIndex string, state string) (string, error) {
|
|
if !s.cfg.Enabled || s.idpMetadata == nil {
|
|
return "", ErrSAMLEnabled
|
|
}
|
|
|
|
// Find SLO endpoint from IdP metadata
|
|
sloEndpoint, err := getSLOEndpointFromIdP(s.idpMetadata)
|
|
if err != nil {
|
|
return "", fmt.Errorf("IdP does not support SLO: %w", err)
|
|
}
|
|
|
|
if sloEndpoint.RedirectURL == "" {
|
|
return "", errors.New("IdP SLO endpoint not available (no HTTP-Redirect binding)")
|
|
}
|
|
|
|
// Generate LogoutRequest
|
|
requestID := fmt.Sprintf("id_%s", generateRandomID())
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
notOnOrAfter := time.Now().Add(5 * time.Minute).UTC().Format(time.RFC3339)
|
|
|
|
logoutRequest := LogoutRequestXML{
|
|
ID: requestID,
|
|
Version: "2.0",
|
|
IssueInstant: now,
|
|
Destination: sloEndpoint.RedirectURL,
|
|
NotOnOrAfter: notOnOrAfter,
|
|
Issuer: SAMLIssuerXML{
|
|
Value: s.cfg.SPEntityID,
|
|
},
|
|
NameID: SAMLNameIDXML{
|
|
Format: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
|
|
Value: nameID,
|
|
},
|
|
SessionIndex: sessionIndex,
|
|
}
|
|
|
|
// Marshal to XML
|
|
xmlBytes, err := xml.Marshal(logoutRequest)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal LogoutRequest: %w", err)
|
|
}
|
|
|
|
xmlStr := fmt.Sprintf("<?xml version=\"1.0\" encoding=\"UTF-8\"?>%s", string(xmlBytes))
|
|
|
|
// Deflate + base64 encode for redirect binding
|
|
encoded, err := deflateAndBase64Encode(xmlStr)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to encode LogoutRequest: %w", err)
|
|
}
|
|
|
|
// Build redirect URL
|
|
redirectURL, err := url.Parse(sloEndpoint.RedirectURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid IdP SLO URL: %w", err)
|
|
}
|
|
q := redirectURL.Query()
|
|
q.Set("SAMLRequest", encoded)
|
|
q.Set("RelayState", state)
|
|
redirectURL.RawQuery = q.Encode()
|
|
|
|
// Store InResponseTo in Redis for response validation (5 min TTL)
|
|
if s.rdb != nil {
|
|
ctx := context.Background()
|
|
key := fmt.Sprintf("saml:slo:inResponseTo:%s", requestID)
|
|
if err := s.rdb.Set(ctx, key, sessionID, 5*time.Minute).Err(); err != nil {
|
|
applogger.L().Warnf("Failed to store SAML SLO InResponseTo in Redis: %v", err)
|
|
}
|
|
}
|
|
|
|
applogger.L().Infof("SAML LogoutRequest generated (id=%s, destination=%s, nameID=%s)", requestID, sloEndpoint.RedirectURL, nameID)
|
|
return redirectURL.String(), nil
|
|
}
|
|
|
|
// ProcessLogoutRequest processes an IdP-initiated SAML LogoutRequest.
|
|
// Validates the request, terminates all SSO sessions for the user identified
|
|
// by NameID, and returns a LogoutResponse for the IdP.
|
|
func (s *SAMLService) ProcessLogoutRequest(samlRequest string) (string, error) {
|
|
if !s.cfg.Enabled || s.idpMetadata == nil {
|
|
return "", ErrSAMLEnabled
|
|
}
|
|
|
|
// Base64-decode the SAMLRequest
|
|
xmlBytes, err := base64.StdEncoding.DecodeString(samlRequest)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to base64 decode LogoutRequest: %v", err)
|
|
}
|
|
|
|
// Parse XML
|
|
var logoutReq LogoutRequestXML
|
|
if err := xml.Unmarshal(xmlBytes, &logoutReq); err != nil {
|
|
return "", fmt.Errorf("failed to parse LogoutRequest XML: %w", err)
|
|
}
|
|
|
|
// Validate issuer matches our IdP
|
|
if logoutReq.Issuer.Value != "" && logoutReq.Issuer.Value != s.idpMetadata.EntityID {
|
|
return "", fmt.Errorf("LogoutRequest issuer mismatch (expected=%s, got=%s)", s.idpMetadata.EntityID, logoutReq.Issuer.Value)
|
|
}
|
|
|
|
// Check replay for the request ID
|
|
if err := checkAndTrackReplay(logoutReq.ID, s.rdb); err != nil {
|
|
return "", fmt.Errorf("LogoutRequest replay detected: %w", err)
|
|
}
|
|
|
|
applogger.L().Infof("SAML LogoutRequest received (id=%s, nameID=%s, sessionIndex=%s)", logoutReq.ID, logoutReq.NameID.Value, logoutReq.SessionIndex)
|
|
|
|
// Generate LogoutResponse
|
|
responseID := fmt.Sprintf("id_%s", generateRandomID())
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
sloEndpoint, _ := getSLOEndpointFromIdP(s.idpMetadata)
|
|
|
|
logoutResponse := LogoutResponseXML{
|
|
ID: responseID,
|
|
InResponseTo: logoutReq.ID,
|
|
Version: "2.0",
|
|
IssueInstant: now,
|
|
Destination: sloEndpoint.RedirectURL,
|
|
Issuer: SAMLIssuerXML{
|
|
Value: s.cfg.SPEntityID,
|
|
},
|
|
Status: SAMLStatusXML{
|
|
StatusCode: SAMLStatusCodeXML{
|
|
Value: "urn:oasis:names:tc:SAML:2.0:status:Success",
|
|
},
|
|
},
|
|
}
|
|
|
|
respBytes, err := xml.Marshal(logoutResponse)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal LogoutResponse: %w", err)
|
|
}
|
|
|
|
respStr := fmt.Sprintf("<?xml version=\"1.0\" encoding=\"UTF-8\"?>%s", string(respBytes))
|
|
encodedResp := base64.StdEncoding.EncodeToString([]byte(respStr))
|
|
|
|
applogger.L().Infof("SAML LogoutResponse generated (id=%s, inResponseTo=%s)", responseID, logoutReq.ID)
|
|
return encodedResp, nil
|
|
}
|
|
|
|
// ProcessLogoutResponse validates an IdP LogoutResponse for SP-initiated SLO.
|
|
// Checks that the response is valid and matches our original LogoutRequest.
|
|
func (s *SAMLService) ProcessLogoutResponse(samlResponse string, relayState string) error {
|
|
if !s.cfg.Enabled || s.idpMetadata == nil {
|
|
return ErrSAMLEnabled
|
|
}
|
|
|
|
// Base64-decode the SAMLResponse
|
|
xmlBytes, err := base64.StdEncoding.DecodeString(samlResponse)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to base64 decode LogoutResponse: %v", err)
|
|
}
|
|
|
|
// Parse XML
|
|
var logoutResp LogoutResponseXML
|
|
if err := xml.Unmarshal(xmlBytes, &logoutResp); err != nil {
|
|
return fmt.Errorf("failed to parse LogoutResponse XML: %w", err)
|
|
}
|
|
|
|
// Validate InResponseTo was tracked in Redis
|
|
if s.rdb != nil {
|
|
ctx := context.Background()
|
|
key := fmt.Sprintf("saml:slo:inResponseTo:%s", logoutResp.InResponseTo)
|
|
storedState, err := s.rdb.Get(ctx, key).Result()
|
|
if err == redis.Nil {
|
|
return fmt.Errorf("LogoutResponse InResponseTo not found or expired (inResponseTo=%s)", logoutResp.InResponseTo)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("failed to validate LogoutResponse InResponseTo: %w", err)
|
|
}
|
|
// Clean up the stored state
|
|
s.rdb.Del(ctx, key)
|
|
_ = storedState // session ID from Redis, already processed
|
|
}
|
|
|
|
// Check response status
|
|
if logoutResp.Status.StatusCode.Value != "urn:oasis:names:tc:SAML:2.0:status:Success" {
|
|
return fmt.Errorf("LogoutResponse status not success: %s", logoutResp.Status.StatusCode.Value)
|
|
}
|
|
|
|
// Check replay for the response ID
|
|
if err := checkAndTrackReplay(logoutResp.ID, s.rdb); err != nil {
|
|
return fmt.Errorf("LogoutResponse replay detected: %w", err)
|
|
}
|
|
|
|
applogger.L().Infof("SAML LogoutResponse validated (id=%s, inResponseTo=%s, relayState=%s)", logoutResp.ID, logoutResp.InResponseTo, relayState)
|
|
return nil
|
|
}
|
|
|
|
// getSLOEndpointFromIdP extracts SLO endpoint URLs from IdP metadata.
|
|
// Returns SLOEndpoint with redirect and/or post binding URLs.
|
|
func getSLOEndpointFromIdP(metadata *IdPMetadata) (*SLOEndpoint, error) {
|
|
if metadata == nil {
|
|
return nil, errors.New("no IdP metadata available")
|
|
}
|
|
|
|
slo := &SLOEndpoint{
|
|
RedirectURL: metadata.SLORedirectURL,
|
|
PostURL: metadata.SLOPostURL,
|
|
}
|
|
|
|
if slo.RedirectURL == "" && slo.PostURL == "" {
|
|
return nil, errors.New("IdP metadata does not contain SingleLogoutService endpoints")
|
|
}
|
|
|
|
return slo, nil
|
|
} |