Enable IdP-initiated SAML flows and simplify authentication

Simplifies SAML authentication by using RelayState to contain the SAML
config ID for both SP-initiated and IdP-initiated flows, removing the
need for the relay_states table and associated token management.

Key changes:
- Enable IdP-initiated flows with AllowIDPInitiated flag
- Use RelayState for SAML config ID instead of secure tokens
- Remove auth_saml_relay_states table and related code
- Maintain InResponseTo validation for SP-initiated flows
- Fix MetadataURL to use entity ID instead of ACS URL

This enables IdP-initiated SAML logins (e.g., from Google Workspace,
Azure Entra ID, Okta) while maintaining security through request ID
validation and assertion replay prevention.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-11-14 01:41:14 +01:00
parent 80349a3b3b
commit e41997a04d
7 changed files with 107 additions and 325 deletions

View File

@@ -74,7 +74,7 @@ func (c *Cleaner) Run(ctx context.Context) error {
}
func (c *Cleaner) cleanup(ctx context.Context) error {
var assertionsDeleted, requestsDeleted, relayStatesDeleted int64
var assertionsDeleted, requestsDeleted int64
err := c.pg.WithConn(
ctx,
@@ -91,12 +91,6 @@ func (c *Cleaner) cleanup(ctx context.Context) error {
}
requestsDeleted = count
count, err = CleanupExpiredRelayStates(ctx, conn)
if err != nil {
return err
}
relayStatesDeleted = count
return nil
},
)
@@ -105,11 +99,10 @@ func (c *Cleaner) cleanup(ctx context.Context) error {
return err
}
if assertionsDeleted > 0 || requestsDeleted > 0 || relayStatesDeleted > 0 {
if assertionsDeleted > 0 || requestsDeleted > 0 {
c.logger.InfoCtx(ctx, "cleaned up expired SAML data",
log.Int64("assertions", assertionsDeleted),
log.Int64("requests", requestsDeleted),
log.Int64("relay_states", relayStatesDeleted))
log.Int64("requests", requestsDeleted))
}
return nil

View File

@@ -20,7 +20,6 @@ import (
"crypto/x509"
"encoding/base64"
"encoding/pem"
"encoding/xml"
"errors"
"fmt"
"net/http"
@@ -232,27 +231,6 @@ func (s *SAMLService) GetAcsURL() string {
return fmt.Sprintf("%s/connect/saml/consume", s.baseURL)
}
func parseRawSAMLResponse(encodedResponse string) (*saml.Assertion, error) {
rawResponseBuf, err := base64.StdEncoding.DecodeString(encodedResponse)
if err != nil {
return nil, fmt.Errorf("cannot decode base64: %w", err)
}
var response saml.Response
if err := xml.Unmarshal(rawResponseBuf, &response); err != nil {
return nil, fmt.Errorf("cannot unmarshal response: %w", err)
}
if response.Assertion == nil {
if response.EncryptedAssertion != nil {
return nil, fmt.Errorf("response contains encrypted assertion which cannot be parsed without SP private key")
}
return nil, fmt.Errorf("response contains no assertion")
}
return response.Assertion, nil
}
func (s *SAMLService) GetServiceProvider(
ctx context.Context,
config *coredata.SAMLConfiguration,
@@ -271,18 +249,24 @@ func (s *SAMLService) GetServiceProvider(
return nil, ErrInvalidURL{Field: "ACS", URL: s.GetAcsURL(), Err: err}
}
metadataURL, err := url.Parse(s.GetEntityID())
if err != nil {
return nil, ErrInvalidURL{Field: "Metadata", URL: s.GetEntityID(), Err: err}
}
idpSSOURL, err := url.Parse(config.IdPSsoURL)
if err != nil {
return nil, ErrInvalidURL{Field: "IdP SSO", URL: config.IdPSsoURL, Err: err}
}
sp := &saml.ServiceProvider{
EntityID: s.GetEntityID(),
Key: s.privateKey,
Certificate: s.certificate,
MetadataURL: *acsURL,
AcsURL: *acsURL,
SloURL: *acsURL,
EntityID: s.GetEntityID(),
Key: s.privateKey,
Certificate: s.certificate,
MetadataURL: *metadataURL,
AcsURL: *acsURL,
SloURL: *acsURL,
AllowIDPInitiated: true,
IDPMetadata: &saml.EntityDescriptor{
EntityID: config.IdPEntityID,
IDPSSODescriptors: []saml.IDPSSODescriptor{
@@ -355,14 +339,8 @@ func (s *SAMLService) InitiateSAMLLogin(
return "", ErrCannotCreateAuthRequest{Err: err}
}
relayStateToken, err := coredata.GenerateSecureToken()
if err != nil {
return "", fmt.Errorf("cannot generate relay state token: %w", err)
}
now := time.Now()
requestExpiry := now.Add(10 * time.Minute)
relayStateExpiry := now.Add(15 * time.Minute)
err = s.pg.WithTx(
ctx,
@@ -373,20 +351,9 @@ func (s *SAMLService) InitiateSAMLLogin(
CreatedAt: now,
ExpiresAt: requestExpiry,
}
if err := samlRequest.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot store SAML request: %w", err)
}
relayState := coredata.SAMLRelayState{
Token: relayStateToken,
OrganizationID: organizationID,
SAMLConfigID: config.ID,
RequestID: authReq.ID,
CreatedAt: now,
ExpiresAt: relayStateExpiry,
}
if err := relayState.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot store relay state: %w", err)
if err := samlRequest.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert SAML request: %w", err)
}
return nil
@@ -396,7 +363,7 @@ func (s *SAMLService) InitiateSAMLLogin(
return "", err
}
redirectURL, err := authReq.Redirect(relayStateToken, sp)
redirectURL, err := authReq.Redirect(config.ID.String(), sp)
if err != nil {
return "", ErrCannotGenerateRedirectURL{Err: err}
}
@@ -413,85 +380,36 @@ type SAMLUserInfo struct {
SAMLConfigID gid.GID
}
func (s *SAMLService) loadContextForSPInitiated(
func (s *SAMLService) loadConfigFromRelayState(
ctx context.Context,
relayStateToken string,
now time.Time,
) (*coredata.SAMLConfiguration, *coredata.Organization, string, error) {
var relayState coredata.SAMLRelayState
var samlRequest coredata.SAMLRequest
var org coredata.Organization
var config coredata.SAMLConfiguration
err := s.pg.WithTx(ctx, func(tx pg.Conn) error {
if err := relayState.Load(ctx, tx, relayStateToken); err != nil {
return fmt.Errorf("invalid relay state: %w", err)
}
if relayState.IsExpired(now) {
return coredata.ErrRelayStateExpired{Token: relayStateToken, ExpiresAt: relayState.ExpiresAt}
}
if err := samlRequest.Load(ctx, tx, relayState.RequestID, relayState.OrganizationID); err != nil {
return fmt.Errorf("invalid SAML request: %w", err)
}
if samlRequest.IsExpired(now) {
return coredata.ErrSAMLRequestExpired{RequestID: relayState.RequestID, ExpiresAt: samlRequest.ExpiresAt}
}
if err := org.LoadByID(ctx, tx, coredata.NewNoScope(), relayState.OrganizationID); err != nil {
return fmt.Errorf("organization not found: %w", err)
}
scope := coredata.NewScope(org.TenantID)
if err := config.LoadByID(ctx, tx, scope, relayState.SAMLConfigID); err != nil {
return fmt.Errorf("cannot load SAML configuration: %w", err)
}
if err := relayState.Delete(ctx, tx); err != nil {
return fmt.Errorf("cannot delete relay state: %w", err)
}
if err := samlRequest.Delete(ctx, tx); err != nil {
return fmt.Errorf("cannot delete SAML request: %w", err)
}
return nil
})
if err != nil {
return nil, nil, "", err
}
return &config, &org, samlRequest.ID, nil
}
func (s *SAMLService) loadContextForIDPInitiated(
ctx context.Context,
samlConfigIDParam string,
relayStateValue string,
) (*coredata.SAMLConfiguration, *coredata.Organization, error) {
if samlConfigIDParam == "" {
return nil, nil, fmt.Errorf("IDP-initiated login requires 'c' query parameter with SAML config ID")
if relayStateValue == "" {
return nil, nil, fmt.Errorf("RelayState is required and must contain SAML config ID")
}
samlConfigID, err := gid.ParseGID(samlConfigIDParam)
samlConfigID, err := gid.ParseGID(relayStateValue)
if err != nil {
return nil, nil, fmt.Errorf("invalid 'c' parameter: %w", err)
return nil, nil, fmt.Errorf("invalid SAML config ID in RelayState: %w", err)
}
var config coredata.SAMLConfiguration
var org coredata.Organization
err = s.pg.WithConn(ctx, func(conn pg.Conn) error {
if err := config.LoadByID(ctx, conn, coredata.NewNoScope(), samlConfigID); err != nil {
return fmt.Errorf("cannot load SAML configuration: %w", err)
}
err = s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := config.LoadByID(ctx, conn, coredata.NewNoScope(), samlConfigID); err != nil {
return fmt.Errorf("cannot load SAML configuration: %w", err)
}
if err := org.LoadByID(ctx, conn, coredata.NewNoScope(), config.OrganizationID); err != nil {
return fmt.Errorf("organization not found: %w", err)
}
if err := org.LoadByID(ctx, conn, coredata.NewNoScope(), config.OrganizationID); err != nil {
return fmt.Errorf("organization not found: %w", err)
}
return nil
})
return nil
},
)
if err != nil {
return nil, nil, err
}
@@ -508,28 +426,10 @@ func (s *SAMLService) HandleSAMLAssertion(
return nil, fmt.Errorf("missing SAMLResponse in request")
}
relayStateToken := req.FormValue("RelayState")
samlConfigIDParam := req.URL.Query().Get("c")
now := time.Now()
var config *coredata.SAMLConfiguration
var org *coredata.Organization
var possibleRequestIDs []string
var err error
if relayStateToken != "" {
var requestID string
config, org, requestID, err = s.loadContextForSPInitiated(ctx, relayStateToken, now)
if err != nil {
return nil, err
}
possibleRequestIDs = []string{requestID}
} else {
config, org, err = s.loadContextForIDPInitiated(ctx, samlConfigIDParam)
if err != nil {
return nil, err
}
possibleRequestIDs = []string{}
relayStateValue := req.FormValue("RelayState")
config, org, err := s.loadConfigFromRelayState(ctx, relayStateValue)
if err != nil {
return nil, err
}
if !config.Enabled {
@@ -548,14 +448,28 @@ func (s *SAMLService) HandleSAMLAssertion(
req.URL.Host = req.Host
}
now := time.Now()
var possibleRequestIDs []string
err = s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
requestIDs, err := coredata.LoadValidRequestIDsForOrganization(ctx, conn, config.OrganizationID, now)
if err != nil {
return err
}
possibleRequestIDs = requestIDs
return nil
},
)
if err != nil {
return nil, fmt.Errorf("cannot load valid request IDs: %w", err)
}
assertion, err := sp.ParseResponse(req, possibleRequestIDs)
if err != nil {
return nil, fmt.Errorf(
"cannot parse SAML response (SP EntityID: %s, IdP EntityID: %s): %w",
s.GetEntityID(),
config.IdPEntityID,
err,
)
return nil, fmt.Errorf("cannot parse SAML response: %w", err)
}
if err := ValidateAssertion(assertion, s.GetEntityID(), now); err != nil {
@@ -571,14 +485,22 @@ func (s *SAMLService) HandleSAMLAssertion(
}
scope := coredata.NewScope(org.TenantID)
err = s.pg.WithTx(ctx, func(tx pg.Conn) error {
return PreventReplayAttack(ctx, tx, scope, assertion.ID, config.OrganizationID, expiresAt)
})
err = s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := PreventReplayAttack(ctx, tx, scope, assertion.ID, config.OrganizationID, expiresAt); err != nil {
return fmt.Errorf("cannot prevent replay attack: %w", err)
}
return nil
},
)
if err != nil {
var replayAttackErr *coredata.ErrAssertionAlreadyUsed
if errors.As(err, &replayAttackErr) {
return nil, ErrReplayAttackDetected{AssertionID: assertion.ID, Err: replayAttackErr}
}
return nil, fmt.Errorf("cannot prevent replay attack: %w", err)
}
}
@@ -598,6 +520,7 @@ func (s *SAMLService) HandleSAMLAssertion(
if err != nil {
return nil, ErrCannotExtractUserAttributes{Err: fmt.Errorf("cannot extract domain from email: %w", err)}
}
if actualEmailDomain != config.EmailDomain {
return nil, fmt.Errorf("email domain mismatch: assertion contains email with domain %s but SAML config is for domain %s", actualEmailDomain, config.EmailDomain)
}

View File

@@ -101,7 +101,3 @@ func CleanupExpiredAssertions(ctx context.Context, conn pg.Conn) (int64, error)
func CleanupExpiredRequests(ctx context.Context, conn pg.Conn) (int64, error) {
return coredata.DeleteExpiredSAMLRequests(ctx, conn, time.Now())
}
func CleanupExpiredRelayStates(ctx context.Context, conn pg.Conn) (int64, error) {
return coredata.DeleteExpiredSAMLRelayStates(ctx, conn, time.Now())
}