113
pkg/auth/saml_cleanup.go
Normal file
113
pkg/auth/saml_cleanup.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultCleanupInterval = 1 * time.Hour
|
||||
)
|
||||
|
||||
type (
|
||||
Cleaner struct {
|
||||
pg *pg.Client
|
||||
interval time.Duration
|
||||
logger *log.Logger
|
||||
}
|
||||
)
|
||||
|
||||
func NewCleaner(
|
||||
pg *pg.Client,
|
||||
interval time.Duration,
|
||||
logger *log.Logger,
|
||||
) *Cleaner {
|
||||
if interval == 0 {
|
||||
interval = DefaultCleanupInterval
|
||||
}
|
||||
|
||||
return &Cleaner{
|
||||
pg: pg,
|
||||
interval: interval,
|
||||
logger: logger.Named("saml.cleaner"),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cleaner) Run(ctx context.Context) error {
|
||||
c.logger.InfoCtx(ctx, "SAML cleaner starting", log.Duration("interval", c.interval))
|
||||
|
||||
if err := c.cleanup(ctx); err != nil {
|
||||
c.logger.ErrorCtx(ctx, "initial cleanup failed", log.Error(err))
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(c.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.logger.InfoCtx(ctx, "SAML cleaner shutting down")
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
if err := c.cleanup(ctx); err != nil {
|
||||
c.logger.ErrorCtx(ctx, "periodic cleanup failed", log.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cleaner) cleanup(ctx context.Context) error {
|
||||
var assertionsDeleted, requestsDeleted, relayStatesDeleted int64
|
||||
|
||||
err := c.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||
count, err := CleanupExpiredAssertions(ctx, conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
assertionsDeleted = count
|
||||
|
||||
count, err = CleanupExpiredRequests(ctx, conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
requestsDeleted = count
|
||||
|
||||
count, err = CleanupExpiredRelayStates(ctx, conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relayStatesDeleted = count
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if assertionsDeleted > 0 || requestsDeleted > 0 || relayStatesDeleted > 0 {
|
||||
c.logger.InfoCtx(ctx, "cleaned up expired SAML data",
|
||||
log.Int64("assertions", assertionsDeleted),
|
||||
log.Int64("requests", requestsDeleted),
|
||||
log.Int64("relay_states", relayStatesDeleted))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
127
pkg/auth/saml_config_validator.go
Normal file
127
pkg/auth/saml_config_validator.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e ValidationError) Error() string {
|
||||
return fmt.Sprintf("%s: %s", e.Field, e.Message)
|
||||
}
|
||||
|
||||
// ValidateIdPConfiguration validates only the IdP (Identity Provider) configuration.
|
||||
// This validates user-provided data from the IdP.
|
||||
// SP (Service Provider) configuration is generated by the application and doesn't need validation.
|
||||
func ValidateIdPConfiguration(
|
||||
idpEntityID string,
|
||||
idpSsoURL string,
|
||||
idpCertificate string,
|
||||
) []ValidationError {
|
||||
var errors []ValidationError
|
||||
|
||||
// Validate IdP Entity ID
|
||||
if idpEntityID == "" {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: "idp_entity_id",
|
||||
Message: "IdP Entity ID cannot be empty",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate IdP SSO URL - accept both HTTP and HTTPS
|
||||
if err := validateURL(idpSsoURL, "idp_sso_url"); err != nil {
|
||||
errors = append(errors, *err)
|
||||
}
|
||||
|
||||
// Validate IdP certificate
|
||||
if err := validateCertificate(idpCertificate); err != nil {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: "idp_certificate",
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
func validateURL(urlStr string, fieldName string) *ValidationError {
|
||||
if urlStr == "" {
|
||||
return &ValidationError{
|
||||
Field: fieldName,
|
||||
Message: "URL cannot be empty",
|
||||
}
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return &ValidationError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("invalid URL format: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
if parsedURL.Scheme == "" {
|
||||
return &ValidationError{
|
||||
Field: fieldName,
|
||||
Message: "URL must have a scheme (http or https)",
|
||||
}
|
||||
}
|
||||
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
return &ValidationError{
|
||||
Field: fieldName,
|
||||
Message: "URL scheme must be http or https (found: " + parsedURL.Scheme + ")",
|
||||
}
|
||||
}
|
||||
|
||||
if parsedURL.Host == "" {
|
||||
return &ValidationError{
|
||||
Field: fieldName,
|
||||
Message: "URL must have a host",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCertificate(certPEM string) error {
|
||||
if certPEM == "" {
|
||||
return fmt.Errorf("certificate cannot be empty")
|
||||
}
|
||||
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
return fmt.Errorf("failed to parse certificate PEM")
|
||||
}
|
||||
|
||||
if block.Type != "CERTIFICATE" {
|
||||
return fmt.Errorf("PEM block type must be CERTIFICATE (found: %s)", block.Type)
|
||||
}
|
||||
|
||||
_, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse X.509 certificate: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
304
pkg/auth/saml_configuration_service.go
Normal file
304
pkg/auth/saml_configuration_service.go
Normal file
@@ -0,0 +1,304 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type (
|
||||
CreateSAMLConfigurationRequest struct {
|
||||
OrganizationID gid.GID
|
||||
EmailDomain string
|
||||
EnforcementPolicy coredata.SAMLEnforcementPolicy
|
||||
IdPEntityID string
|
||||
IdPSsoURL string
|
||||
IdPCertificate string
|
||||
IdPMetadataURL *string
|
||||
AttributeEmail string
|
||||
AttributeFirstname string
|
||||
AttributeLastname string
|
||||
AttributeRole string
|
||||
DefaultRole string
|
||||
AutoSignupEnabled bool
|
||||
}
|
||||
|
||||
UpdateSAMLConfigurationRequest struct {
|
||||
ID gid.GID
|
||||
Enabled *bool
|
||||
EnforcementPolicy *coredata.SAMLEnforcementPolicy
|
||||
IdPEntityID *string
|
||||
IdPSsoURL *string
|
||||
IdPCertificate *string
|
||||
IdPMetadataURL *string
|
||||
AttributeEmail *string
|
||||
AttributeFirstname *string
|
||||
AttributeLastname *string
|
||||
AttributeRole *string
|
||||
DefaultRole *string
|
||||
AutoSignupEnabled *bool
|
||||
}
|
||||
)
|
||||
|
||||
func (s TenantAuthService) CreateSAMLConfiguration(
|
||||
ctx context.Context,
|
||||
req CreateSAMLConfigurationRequest,
|
||||
) (*coredata.SAMLConfiguration, error) {
|
||||
// Validate only the IdP configuration (user-provided data)
|
||||
validationErrors := ValidateIdPConfiguration(
|
||||
req.IdPEntityID,
|
||||
req.IdPSsoURL,
|
||||
req.IdPCertificate,
|
||||
)
|
||||
|
||||
if len(validationErrors) > 0 {
|
||||
var errMsgs []string
|
||||
for _, err := range validationErrors {
|
||||
errMsgs = append(errMsgs, err.Error())
|
||||
}
|
||||
return nil, fmt.Errorf("SAML configuration validation failed: %s", strings.Join(errMsgs, "; "))
|
||||
}
|
||||
|
||||
var config *coredata.SAMLConfiguration
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
now := time.Now()
|
||||
tenantID := s.scope.GetTenantID()
|
||||
|
||||
var org coredata.Organization
|
||||
if err := org.LoadByID(ctx, tx, s.scope, req.OrganizationID); err != nil {
|
||||
return fmt.Errorf("organization not found: %w", err)
|
||||
}
|
||||
|
||||
config = &coredata.SAMLConfiguration{
|
||||
ID: gid.New(tenantID, coredata.SAMLConfigurationEntityType),
|
||||
OrganizationID: org.ID,
|
||||
EmailDomain: req.EmailDomain,
|
||||
EnforcementPolicy: req.EnforcementPolicy,
|
||||
Enabled: false,
|
||||
IdPEntityID: req.IdPEntityID,
|
||||
IdPSsoURL: req.IdPSsoURL,
|
||||
IdPCertificate: req.IdPCertificate,
|
||||
IdPMetadataURL: req.IdPMetadataURL,
|
||||
AttributeEmail: req.AttributeEmail,
|
||||
AttributeFirstname: req.AttributeFirstname,
|
||||
AttributeLastname: req.AttributeLastname,
|
||||
AttributeRole: req.AttributeRole,
|
||||
DefaultRole: req.DefaultRole,
|
||||
AutoSignupEnabled: req.AutoSignupEnabled,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := config.Insert(ctx, tx, s.scope); err != nil {
|
||||
return fmt.Errorf("cannot insert saml configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s TenantAuthService) UpdateSAMLConfiguration(
|
||||
ctx context.Context,
|
||||
req UpdateSAMLConfigurationRequest,
|
||||
) (*coredata.SAMLConfiguration, error) {
|
||||
var config *coredata.SAMLConfiguration
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
var cfg coredata.SAMLConfiguration
|
||||
if err := cfg.LoadByID(ctx, tx, s.scope, req.ID); err != nil {
|
||||
return fmt.Errorf("cannot load saml configuration: %w", err)
|
||||
}
|
||||
|
||||
if req.Enabled != nil {
|
||||
cfg.Enabled = *req.Enabled
|
||||
}
|
||||
if req.EnforcementPolicy != nil {
|
||||
cfg.EnforcementPolicy = *req.EnforcementPolicy
|
||||
}
|
||||
if req.IdPEntityID != nil {
|
||||
cfg.IdPEntityID = *req.IdPEntityID
|
||||
}
|
||||
if req.IdPSsoURL != nil {
|
||||
cfg.IdPSsoURL = *req.IdPSsoURL
|
||||
}
|
||||
if req.IdPCertificate != nil {
|
||||
cfg.IdPCertificate = *req.IdPCertificate
|
||||
}
|
||||
if req.IdPMetadataURL != nil {
|
||||
cfg.IdPMetadataURL = req.IdPMetadataURL
|
||||
}
|
||||
if req.AttributeEmail != nil {
|
||||
cfg.AttributeEmail = *req.AttributeEmail
|
||||
}
|
||||
if req.AttributeFirstname != nil {
|
||||
cfg.AttributeFirstname = *req.AttributeFirstname
|
||||
}
|
||||
if req.AttributeLastname != nil {
|
||||
cfg.AttributeLastname = *req.AttributeLastname
|
||||
}
|
||||
if req.AttributeRole != nil {
|
||||
cfg.AttributeRole = *req.AttributeRole
|
||||
}
|
||||
if req.DefaultRole != nil {
|
||||
cfg.DefaultRole = *req.DefaultRole
|
||||
}
|
||||
if req.AutoSignupEnabled != nil {
|
||||
cfg.AutoSignupEnabled = *req.AutoSignupEnabled
|
||||
}
|
||||
|
||||
cfg.UpdatedAt = time.Now()
|
||||
|
||||
if err := cfg.Update(ctx, tx, s.scope); err != nil {
|
||||
return fmt.Errorf("cannot update saml configuration: %w", err)
|
||||
}
|
||||
|
||||
config = &cfg
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s TenantAuthService) DeleteSAMLConfiguration(
|
||||
ctx context.Context,
|
||||
configID gid.GID,
|
||||
) error {
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
var config coredata.SAMLConfiguration
|
||||
if err := config.LoadByID(ctx, tx, s.scope, configID); err != nil {
|
||||
return fmt.Errorf("cannot load saml configuration: %w", err)
|
||||
}
|
||||
|
||||
if err := config.Delete(ctx, tx, s.scope); err != nil {
|
||||
return fmt.Errorf("cannot delete saml configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s TenantAuthService) EnableSAMLConfiguration(
|
||||
ctx context.Context,
|
||||
configID gid.GID,
|
||||
) (*coredata.SAMLConfiguration, error) {
|
||||
enabled := true
|
||||
return s.UpdateSAMLConfiguration(ctx, UpdateSAMLConfigurationRequest{
|
||||
ID: configID,
|
||||
Enabled: &enabled,
|
||||
})
|
||||
}
|
||||
|
||||
func (s TenantAuthService) DisableSAMLConfiguration(
|
||||
ctx context.Context,
|
||||
configID gid.GID,
|
||||
) (*coredata.SAMLConfiguration, error) {
|
||||
disabled := false
|
||||
return s.UpdateSAMLConfiguration(ctx, UpdateSAMLConfigurationRequest{
|
||||
ID: configID,
|
||||
Enabled: &disabled,
|
||||
})
|
||||
}
|
||||
|
||||
func (s TenantAuthService) GetSAMLConfigurationByID(
|
||||
ctx context.Context,
|
||||
configID gid.GID,
|
||||
) (*coredata.SAMLConfiguration, error) {
|
||||
var config coredata.SAMLConfiguration
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
return config.LoadByID(ctx, conn, s.scope, configID)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load saml configuration: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func (s TenantAuthService) GetSAMLConfigurationsByOrganizationID(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
) ([]*coredata.SAMLConfiguration, error) {
|
||||
var configs []*coredata.SAMLConfiguration
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
var err error
|
||||
configs, err = coredata.LoadSAMLConfigurationsByOrganizationID(ctx, conn, s.scope, organizationID)
|
||||
return err
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load saml configurations: %w", err)
|
||||
}
|
||||
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
func (s Service) CheckSSOAvailabilityByEmail(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
) ([]*coredata.SAMLConfiguration, error) {
|
||||
// Extract domain from email
|
||||
parts := strings.Split(email, "@")
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid email format")
|
||||
}
|
||||
domain := parts[1]
|
||||
|
||||
var configs []*coredata.SAMLConfiguration
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
var err error
|
||||
configs, err = coredata.LoadAllEnabledSAMLConfigurationsByEmailDomain(ctx, conn, domain)
|
||||
return err
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load saml configurations: %w", err)
|
||||
}
|
||||
|
||||
return configs, nil
|
||||
}
|
||||
147
pkg/auth/saml_mapper.go
Normal file
147
pkg/auth/saml_mapper.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
)
|
||||
|
||||
func ExtractAttributeValue(assertion *saml.Assertion, attributeName string) (string, error) {
|
||||
if len(assertion.AttributeStatements) == 0 {
|
||||
return "", fmt.Errorf("no attribute statement in assertion")
|
||||
}
|
||||
|
||||
for _, attr := range assertion.AttributeStatements[0].Attributes {
|
||||
if attr.Name == attributeName {
|
||||
if len(attr.Values) == 0 {
|
||||
return "", fmt.Errorf("attribute %q has no values", attributeName)
|
||||
}
|
||||
return attr.Values[0].Value, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("attribute %q not found in assertion", attributeName)
|
||||
}
|
||||
|
||||
func ExtractEmailFromAssertion(assertion *saml.Assertion) (string, error) {
|
||||
commonEmailAttributes := []string{
|
||||
"email",
|
||||
"Email",
|
||||
"emailAddress",
|
||||
"mail",
|
||||
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
||||
"http://schemas.xmlsoap.org/claims/EmailAddress",
|
||||
}
|
||||
|
||||
for _, attrName := range commonEmailAttributes {
|
||||
email, err := ExtractAttributeValue(assertion, attrName)
|
||||
if err == nil && email != "" {
|
||||
return email, nil
|
||||
}
|
||||
}
|
||||
|
||||
if assertion.Subject != nil && assertion.Subject.NameID != nil && assertion.Subject.NameID.Value != "" {
|
||||
return assertion.Subject.NameID.Value, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not extract email from assertion")
|
||||
}
|
||||
|
||||
func ExtractEmailDomain(email string) (string, error) {
|
||||
parts := strings.Split(email, "@")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid email address: %s", email)
|
||||
}
|
||||
domain := strings.ToLower(strings.TrimSpace(parts[1]))
|
||||
if domain == "" {
|
||||
return "", fmt.Errorf("empty domain in email address: %s", email)
|
||||
}
|
||||
return domain, nil
|
||||
}
|
||||
|
||||
func MapSAMLRoleToSystemRole(samlRole string, defaultRole string) (string, error) {
|
||||
if samlRole != "" && isValidRole(samlRole) {
|
||||
return samlRole, nil
|
||||
}
|
||||
|
||||
if !isValidRole(defaultRole) {
|
||||
return "", fmt.Errorf("invalid default role %q", defaultRole)
|
||||
}
|
||||
|
||||
return defaultRole, nil
|
||||
}
|
||||
|
||||
func isValidRole(role string) bool {
|
||||
switch role {
|
||||
case "OWNER", "ADMIN", "MEMBER", "VIEWER":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ExtractUserAttributes(
|
||||
assertion *saml.Assertion,
|
||||
attributeEmail, attributeFirstname, attributeLastname, attributeRole string,
|
||||
) (email, fullname, role string, err error) {
|
||||
if len(assertion.AttributeStatements) == 0 {
|
||||
if assertion.Subject != nil && assertion.Subject.NameID != nil {
|
||||
email = assertion.Subject.NameID.Value
|
||||
fullname = email
|
||||
role = ""
|
||||
return email, fullname, role, nil
|
||||
}
|
||||
return "", "", "", fmt.Errorf("no attribute statement and no NameID in assertion")
|
||||
}
|
||||
|
||||
email, err = ExtractAttributeValue(assertion, attributeEmail)
|
||||
if err != nil {
|
||||
if assertion.Subject != nil && assertion.Subject.NameID != nil {
|
||||
email = assertion.Subject.NameID.Value
|
||||
} else {
|
||||
return "", "", "", fmt.Errorf("failed to extract email: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
firstname, err := ExtractAttributeValue(assertion, attributeFirstname)
|
||||
if err != nil {
|
||||
firstname = ""
|
||||
}
|
||||
|
||||
lastname, err := ExtractAttributeValue(assertion, attributeLastname)
|
||||
if err != nil {
|
||||
lastname = ""
|
||||
}
|
||||
|
||||
if firstname != "" && lastname != "" {
|
||||
fullname = strings.TrimSpace(firstname + " " + lastname)
|
||||
} else if firstname != "" {
|
||||
fullname = firstname
|
||||
} else if lastname != "" {
|
||||
fullname = lastname
|
||||
} else {
|
||||
fullname = email
|
||||
}
|
||||
|
||||
role, err = ExtractAttributeValue(assertion, attributeRole)
|
||||
if err != nil {
|
||||
role = ""
|
||||
}
|
||||
|
||||
return email, fullname, role, nil
|
||||
}
|
||||
204
pkg/auth/saml_metadata.go
Normal file
204
pkg/auth/saml_metadata.go
Normal file
@@ -0,0 +1,204 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
)
|
||||
|
||||
func GenerateServiceProviderMetadata(
|
||||
entityID string,
|
||||
acsURL string,
|
||||
spCert *x509.Certificate,
|
||||
) ([]byte, error) {
|
||||
certData := base64.StdEncoding.EncodeToString(spCert.Raw)
|
||||
|
||||
trueVal := true
|
||||
|
||||
metadata := &saml.EntityDescriptor{
|
||||
EntityID: entityID,
|
||||
SPSSODescriptors: []saml.SPSSODescriptor{
|
||||
{
|
||||
SSODescriptor: saml.SSODescriptor{
|
||||
RoleDescriptor: saml.RoleDescriptor{
|
||||
ProtocolSupportEnumeration: "urn:oasis:names:tc:SAML:2.0:protocol",
|
||||
KeyDescriptors: []saml.KeyDescriptor{
|
||||
{
|
||||
Use: "signing",
|
||||
KeyInfo: saml.KeyInfo{
|
||||
X509Data: saml.X509Data{
|
||||
X509Certificates: []saml.X509Certificate{
|
||||
{Data: certData},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Use: "encryption",
|
||||
KeyInfo: saml.KeyInfo{
|
||||
X509Data: saml.X509Data{
|
||||
X509Certificates: []saml.X509Certificate{
|
||||
{Data: certData},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AuthnRequestsSigned: &trueVal,
|
||||
WantAssertionsSigned: &trueVal,
|
||||
AssertionConsumerServices: []saml.IndexedEndpoint{
|
||||
{
|
||||
Binding: saml.HTTPPostBinding,
|
||||
Location: acsURL,
|
||||
Index: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
xmlBytes, err := xml.MarshalIndent(metadata, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal SP metadata to XML: %w", err)
|
||||
}
|
||||
|
||||
return xmlBytes, nil
|
||||
}
|
||||
|
||||
func ParseIdPCertificate(certPEM string) (*x509.Certificate, error) {
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("failed to decode PEM block from IdP certificate")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse X.509 certificate: %w", err)
|
||||
}
|
||||
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
type IdPMetadata struct {
|
||||
EntityID string
|
||||
SsoURL string
|
||||
Certificate string
|
||||
MetadataURL *string
|
||||
}
|
||||
|
||||
func ParseIdPMetadata(metadataXML string) (*IdPMetadata, error) {
|
||||
var entityDescriptor saml.EntityDescriptor
|
||||
if err := xml.Unmarshal([]byte(metadataXML), &entityDescriptor); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse IdP metadata XML: %w", err)
|
||||
}
|
||||
|
||||
if len(entityDescriptor.IDPSSODescriptors) == 0 {
|
||||
return nil, fmt.Errorf("no IDPSSODescriptor found in metadata")
|
||||
}
|
||||
|
||||
idpDescriptor := entityDescriptor.IDPSSODescriptors[0]
|
||||
|
||||
var ssoURL string
|
||||
for _, sso := range idpDescriptor.SingleSignOnServices {
|
||||
if sso.Binding == saml.HTTPPostBinding || sso.Binding == saml.HTTPRedirectBinding {
|
||||
ssoURL = sso.Location
|
||||
break
|
||||
}
|
||||
}
|
||||
if ssoURL == "" && len(idpDescriptor.SingleSignOnServices) > 0 {
|
||||
ssoURL = idpDescriptor.SingleSignOnServices[0].Location
|
||||
}
|
||||
if ssoURL == "" {
|
||||
return nil, fmt.Errorf("no SingleSignOnService found in metadata")
|
||||
}
|
||||
|
||||
var certPEM string
|
||||
for _, keyDescriptor := range idpDescriptor.KeyDescriptors {
|
||||
if keyDescriptor.Use == "signing" || keyDescriptor.Use == "" {
|
||||
if len(keyDescriptor.KeyInfo.X509Data.X509Certificates) > 0 {
|
||||
certData := keyDescriptor.KeyInfo.X509Data.X509Certificates[0].Data
|
||||
certDER, err := base64.StdEncoding.DecodeString(certData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode certificate: %w", err)
|
||||
}
|
||||
certPEM = string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: certDER,
|
||||
}))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if certPEM == "" {
|
||||
return nil, fmt.Errorf("no signing certificate found in metadata")
|
||||
}
|
||||
|
||||
return &IdPMetadata{
|
||||
EntityID: entityDescriptor.EntityID,
|
||||
SsoURL: ssoURL,
|
||||
Certificate: certPEM,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GenerateSelfSignedCertificate(entityID string) (*x509.Certificate, *rsa.PrivateKey, error) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to generate RSA private key: %w", err)
|
||||
}
|
||||
|
||||
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to generate serial number: %w", err)
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
CommonName: entityID,
|
||||
Organization: []string{"Probo"},
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create certificate: %w", err)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse created certificate: %w", err)
|
||||
}
|
||||
|
||||
return cert, privateKey, nil
|
||||
}
|
||||
585
pkg/auth/saml_service.go
Normal file
585
pkg/auth/saml_service.go
Normal file
@@ -0,0 +1,585 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/crypto/cipher"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type (
|
||||
SAMLService struct {
|
||||
pg *pg.Client
|
||||
encryptionKey cipher.EncryptionKey
|
||||
baseURL string
|
||||
sessionDuration time.Duration
|
||||
cookieName string
|
||||
cookieSecret string
|
||||
certificate *x509.Certificate
|
||||
privateKey *rsa.PrivateKey
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
ErrSPCertificateNotConfigured struct{}
|
||||
|
||||
ErrSAMLConfigurationNotFound struct {
|
||||
OrganizationID gid.GID
|
||||
}
|
||||
|
||||
ErrSAMLDisabled struct {
|
||||
OrganizationID gid.GID
|
||||
}
|
||||
|
||||
ErrInvalidIdPCertificate struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
ErrInvalidURL struct {
|
||||
Field string
|
||||
URL string
|
||||
Err error
|
||||
}
|
||||
|
||||
ErrCannotCreateServiceProvider struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
ErrCannotCreateAuthRequest struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
ErrCannotGenerateRedirectURL struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
ErrCannotParseSAMLResponse struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
ErrCannotValidateAssertion struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
ErrCannotExtractUserAttributes struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
ErrCannotMapRole struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
ErrReplayAttackDetected struct {
|
||||
AssertionID string
|
||||
Err error
|
||||
}
|
||||
)
|
||||
|
||||
func (e ErrSPCertificateNotConfigured) Error() string {
|
||||
return "SP certificate and private key are not configured"
|
||||
}
|
||||
|
||||
func (e ErrSAMLConfigurationNotFound) Error() string {
|
||||
return fmt.Sprintf("SAML configuration not found for organization %s", e.OrganizationID)
|
||||
}
|
||||
|
||||
func (e ErrSAMLDisabled) Error() string {
|
||||
return fmt.Sprintf("SAML is disabled for organization %s", e.OrganizationID)
|
||||
}
|
||||
|
||||
func (e ErrInvalidIdPCertificate) Error() string {
|
||||
return fmt.Sprintf("cannot parse IdP certificate: %v", e.Err)
|
||||
}
|
||||
|
||||
func (e ErrInvalidURL) Error() string {
|
||||
return fmt.Sprintf("cannot parse %s URL %q: %v", e.Field, e.URL, e.Err)
|
||||
}
|
||||
|
||||
func (e ErrCannotCreateServiceProvider) Error() string {
|
||||
return fmt.Sprintf("cannot create service provider: %v", e.Err)
|
||||
}
|
||||
|
||||
func (e ErrCannotCreateAuthRequest) Error() string {
|
||||
return fmt.Sprintf("cannot create AuthnRequest: %v", e.Err)
|
||||
}
|
||||
|
||||
func (e ErrCannotGenerateRedirectURL) Error() string {
|
||||
return fmt.Sprintf("cannot generate redirect URL: %v", e.Err)
|
||||
}
|
||||
|
||||
func (e ErrCannotParseSAMLResponse) Error() string {
|
||||
return fmt.Sprintf("cannot parse SAML response: %v", e.Err)
|
||||
}
|
||||
|
||||
func (e ErrCannotValidateAssertion) Error() string {
|
||||
return fmt.Sprintf("cannot validate assertion: %v", e.Err)
|
||||
}
|
||||
|
||||
func (e ErrCannotExtractUserAttributes) Error() string {
|
||||
return fmt.Sprintf("cannot extract user attributes: %v", e.Err)
|
||||
}
|
||||
|
||||
func (e ErrCannotMapRole) Error() string {
|
||||
return fmt.Sprintf("cannot map role: %v", e.Err)
|
||||
}
|
||||
|
||||
func (e ErrReplayAttackDetected) Error() string {
|
||||
return fmt.Sprintf("replay attack detected for assertion %s: %v", e.AssertionID, e.Err)
|
||||
}
|
||||
|
||||
func NewSAMLService(
|
||||
pg *pg.Client,
|
||||
encryptionKey cipher.EncryptionKey,
|
||||
baseURL string,
|
||||
sessionDuration time.Duration,
|
||||
cookieName string,
|
||||
cookieSecret string,
|
||||
certificatePEM string,
|
||||
privateKeyPEM string,
|
||||
logger *log.Logger,
|
||||
) (*SAMLService, error) {
|
||||
var certificate *x509.Certificate
|
||||
var privateKey *rsa.PrivateKey
|
||||
|
||||
if certificatePEM != "" {
|
||||
block, _ := pem.Decode([]byte(certificatePEM))
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return nil, fmt.Errorf("invalid certificate PEM format")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse certificate: %w", err)
|
||||
}
|
||||
certificate = cert
|
||||
}
|
||||
|
||||
if privateKeyPEM != "" {
|
||||
block, _ := pem.Decode([]byte(privateKeyPEM))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("invalid private key PEM format")
|
||||
}
|
||||
|
||||
var key *rsa.PrivateKey
|
||||
var err error
|
||||
switch block.Type {
|
||||
case "RSA PRIVATE KEY":
|
||||
key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse PKCS1 private key: %w", err)
|
||||
}
|
||||
case "PRIVATE KEY":
|
||||
parsedKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse PKCS8 private key: %w", err)
|
||||
}
|
||||
var ok bool
|
||||
key, ok = parsedKey.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("private key is not RSA")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported private key type: %s", block.Type)
|
||||
}
|
||||
privateKey = key
|
||||
}
|
||||
|
||||
return &SAMLService{
|
||||
pg: pg,
|
||||
encryptionKey: encryptionKey,
|
||||
baseURL: baseURL,
|
||||
sessionDuration: sessionDuration,
|
||||
cookieName: cookieName,
|
||||
cookieSecret: cookieSecret,
|
||||
certificate: certificate,
|
||||
privateKey: privateKey,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SAMLService) GetEntityID() string {
|
||||
return fmt.Sprintf("%s/auth/saml/metadata", s.baseURL)
|
||||
}
|
||||
|
||||
func (s *SAMLService) GetAcsURL() string {
|
||||
return fmt.Sprintf("%s/auth/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,
|
||||
) (*saml.ServiceProvider, error) {
|
||||
if s.certificate == nil || s.privateKey == nil {
|
||||
return nil, ErrSPCertificateNotConfigured{}
|
||||
}
|
||||
|
||||
idpCert, err := ParseIdPCertificate(config.IdPCertificate)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidIdPCertificate{Err: err}
|
||||
}
|
||||
|
||||
acsURL, err := url.Parse(s.GetAcsURL())
|
||||
if err != nil {
|
||||
return nil, ErrInvalidURL{Field: "ACS", URL: s.GetAcsURL(), 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,
|
||||
IDPMetadata: &saml.EntityDescriptor{
|
||||
EntityID: config.IdPEntityID,
|
||||
IDPSSODescriptors: []saml.IDPSSODescriptor{
|
||||
{
|
||||
SSODescriptor: saml.SSODescriptor{
|
||||
RoleDescriptor: saml.RoleDescriptor{
|
||||
ProtocolSupportEnumeration: "urn:oasis:names:tc:SAML:2.0:protocol",
|
||||
KeyDescriptors: []saml.KeyDescriptor{
|
||||
{
|
||||
Use: "signing",
|
||||
KeyInfo: saml.KeyInfo{
|
||||
X509Data: saml.X509Data{
|
||||
X509Certificates: []saml.X509Certificate{
|
||||
{Data: base64.StdEncoding.EncodeToString(idpCert.Raw)},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SingleSignOnServices: []saml.Endpoint{
|
||||
{
|
||||
Binding: saml.HTTPRedirectBinding,
|
||||
Location: idpSSOURL.String(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return sp, nil
|
||||
}
|
||||
|
||||
func (s *SAMLService) InitiateSAMLLogin(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
tenantID gid.TenantID,
|
||||
emailDomain string,
|
||||
) (string, error) {
|
||||
var config coredata.SAMLConfiguration
|
||||
scope := coredata.NewScope(tenantID)
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
return config.LoadByOrganizationIDAndEmailDomain(ctx, conn, scope, organizationID, emailDomain)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", ErrSAMLConfigurationNotFound{OrganizationID: organizationID}
|
||||
}
|
||||
|
||||
if !config.Enabled {
|
||||
return "", ErrSAMLDisabled{OrganizationID: organizationID}
|
||||
}
|
||||
|
||||
sp, err := s.GetServiceProvider(ctx, &config)
|
||||
if err != nil {
|
||||
return "", ErrCannotCreateServiceProvider{Err: err}
|
||||
}
|
||||
|
||||
authReq, err := sp.MakeAuthenticationRequest(
|
||||
config.IdPSsoURL,
|
||||
saml.HTTPRedirectBinding,
|
||||
saml.HTTPPostBinding,
|
||||
)
|
||||
if err != nil {
|
||||
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,
|
||||
func(tx pg.Conn) error {
|
||||
samlRequest := coredata.SAMLRequest{
|
||||
ID: authReq.ID,
|
||||
OrganizationID: organizationID,
|
||||
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)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
redirectURL, err := authReq.Redirect(relayStateToken, sp)
|
||||
if err != nil {
|
||||
return "", ErrCannotGenerateRedirectURL{Err: err}
|
||||
}
|
||||
|
||||
return redirectURL.String(), nil
|
||||
}
|
||||
|
||||
type SAMLUserInfo struct {
|
||||
Email string
|
||||
FullName string
|
||||
Role string
|
||||
SAMLSubject string
|
||||
OrganizationID gid.GID
|
||||
TenantID gid.TenantID
|
||||
SAMLConfigID gid.GID
|
||||
}
|
||||
|
||||
func (s *SAMLService) HandleSAMLAssertion(
|
||||
ctx context.Context,
|
||||
req *http.Request,
|
||||
) (*SAMLUserInfo, error) {
|
||||
relayStateToken := req.FormValue("RelayState")
|
||||
if relayStateToken == "" {
|
||||
return nil, fmt.Errorf("missing RelayState in SAML response")
|
||||
}
|
||||
|
||||
var relayState coredata.SAMLRelayState
|
||||
var samlRequest coredata.SAMLRequest
|
||||
var config coredata.SAMLConfiguration
|
||||
var org coredata.Organization
|
||||
|
||||
now := time.Now()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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, err
|
||||
}
|
||||
|
||||
samlResponseEncoded := req.FormValue("SAMLResponse")
|
||||
if samlResponseEncoded == "" {
|
||||
return nil, fmt.Errorf("missing SAMLResponse in request")
|
||||
}
|
||||
|
||||
scope := coredata.NewScope(org.TenantID)
|
||||
err = s.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
return config.LoadByID(ctx, conn, scope, relayState.SAMLConfigID)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, ErrSAMLConfigurationNotFound{OrganizationID: relayState.OrganizationID}
|
||||
}
|
||||
|
||||
if !config.Enabled {
|
||||
return nil, ErrSAMLDisabled{OrganizationID: relayState.OrganizationID}
|
||||
}
|
||||
|
||||
sp, err := s.GetServiceProvider(ctx, &config)
|
||||
if err != nil {
|
||||
return nil, ErrCannotCreateServiceProvider{Err: err}
|
||||
}
|
||||
|
||||
if req.URL.Scheme == "" {
|
||||
req.URL.Scheme = "https"
|
||||
}
|
||||
if req.URL.Host == "" {
|
||||
req.URL.Host = req.Host
|
||||
}
|
||||
|
||||
possibleRequestIDs := []string{samlRequest.ID}
|
||||
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)
|
||||
}
|
||||
|
||||
if err := ValidateAssertion(assertion, s.GetEntityID(), now); err != nil {
|
||||
return nil, ErrCannotValidateAssertion{Err: err}
|
||||
}
|
||||
if assertion.ID != "" {
|
||||
var expiresAt time.Time
|
||||
if assertion.Conditions != nil && !assertion.Conditions.NotOnOrAfter.IsZero() {
|
||||
expiresAt = assertion.Conditions.NotOnOrAfter
|
||||
} else {
|
||||
expiresAt = now.Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
scope := coredata.NewScope(org.TenantID)
|
||||
err = s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
return PreventReplayAttack(ctx, tx, scope, assertion.ID, relayState.OrganizationID, expiresAt)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, ErrReplayAttackDetected{AssertionID: assertion.ID, Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
email, fullname, samlRole, err := ExtractUserAttributes(
|
||||
assertion,
|
||||
config.AttributeEmail,
|
||||
config.AttributeFirstname,
|
||||
config.AttributeLastname,
|
||||
config.AttributeRole,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, ErrCannotExtractUserAttributes{Err: err}
|
||||
}
|
||||
|
||||
actualEmailDomain, err := ExtractEmailDomain(email)
|
||||
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)
|
||||
}
|
||||
|
||||
systemRole, err := MapSAMLRoleToSystemRole(samlRole, config.DefaultRole)
|
||||
if err != nil {
|
||||
return nil, ErrCannotMapRole{Err: err}
|
||||
}
|
||||
|
||||
samlSubject := ""
|
||||
if assertion.Subject != nil && assertion.Subject.NameID != nil {
|
||||
samlSubject = assertion.Subject.NameID.Value
|
||||
}
|
||||
|
||||
return &SAMLUserInfo{
|
||||
Email: email,
|
||||
FullName: fullname,
|
||||
Role: systemRole,
|
||||
SAMLSubject: samlSubject,
|
||||
OrganizationID: relayState.OrganizationID,
|
||||
TenantID: org.TenantID,
|
||||
SAMLConfigID: relayState.SAMLConfigID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SAMLService) GetMetadataURL(organizationID gid.GID) string {
|
||||
return fmt.Sprintf("%s/auth/saml/metadata/%s", s.baseURL, organizationID)
|
||||
}
|
||||
|
||||
func (s *SAMLService) GenerateMetadata() ([]byte, error) {
|
||||
if s.certificate == nil {
|
||||
return nil, ErrSPCertificateNotConfigured{}
|
||||
}
|
||||
|
||||
return GenerateServiceProviderMetadata(
|
||||
s.GetEntityID(),
|
||||
s.GetAcsURL(),
|
||||
s.certificate,
|
||||
)
|
||||
}
|
||||
110
pkg/auth/saml_validator.go
Normal file
110
pkg/auth/saml_validator.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/crewjam/saml"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
func PreventReplayAttack(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope coredata.Scoper,
|
||||
assertionID string,
|
||||
organizationID gid.GID,
|
||||
expiresAt time.Time,
|
||||
) error {
|
||||
var assertion coredata.SAMLAssertion
|
||||
exists, err := assertion.CheckExists(ctx, conn, assertionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check assertion ID: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
return coredata.ErrAssertionAlreadyUsed{AssertionID: assertionID}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
assertion = coredata.SAMLAssertion{
|
||||
ID: assertionID,
|
||||
OrganizationID: organizationID,
|
||||
UsedAt: now,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
if err := assertion.Insert(ctx, conn, scope); err != nil {
|
||||
return fmt.Errorf("failed to store assertion ID: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateAssertion(
|
||||
assertion *saml.Assertion,
|
||||
expectedAudience string,
|
||||
now time.Time,
|
||||
) error {
|
||||
const clockSkewTolerance = 5 * time.Minute
|
||||
|
||||
if assertion.Conditions != nil && !assertion.Conditions.NotBefore.IsZero() {
|
||||
if now.Add(clockSkewTolerance).Before(assertion.Conditions.NotBefore) {
|
||||
return fmt.Errorf("assertion not yet valid (NotBefore: %v, now: %v, tolerance: %v)",
|
||||
assertion.Conditions.NotBefore, now, clockSkewTolerance)
|
||||
}
|
||||
}
|
||||
|
||||
if assertion.Conditions != nil && !assertion.Conditions.NotOnOrAfter.IsZero() {
|
||||
if now.Add(-clockSkewTolerance).After(assertion.Conditions.NotOnOrAfter) ||
|
||||
now.Add(-clockSkewTolerance).Equal(assertion.Conditions.NotOnOrAfter) {
|
||||
return fmt.Errorf("assertion expired (NotOnOrAfter: %v, now: %v, tolerance: %v)",
|
||||
assertion.Conditions.NotOnOrAfter, now, clockSkewTolerance)
|
||||
}
|
||||
}
|
||||
|
||||
if assertion.Conditions != nil && len(assertion.Conditions.AudienceRestrictions) > 0 {
|
||||
audienceValid := false
|
||||
for _, restriction := range assertion.Conditions.AudienceRestrictions {
|
||||
if restriction.Audience.Value == expectedAudience {
|
||||
audienceValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !audienceValid {
|
||||
return fmt.Errorf("assertion audience restriction does not match expected audience %q", expectedAudience)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CleanupExpiredAssertions(ctx context.Context, conn pg.Conn) (int64, error) {
|
||||
return coredata.DeleteExpiredSAMLAssertions(ctx, conn, time.Now())
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -16,17 +16,22 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/packages/emails"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/crypto/cipher"
|
||||
"github.com/getprobo/probo/pkg/crypto/passwdhash"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/statelesstoken"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
@@ -35,13 +40,26 @@ type (
|
||||
// No organization-related logic - that belongs to authz service
|
||||
Service struct {
|
||||
pg *pg.Client
|
||||
encryptionKey cipher.EncryptionKey
|
||||
hp *passwdhash.Profile
|
||||
hostname string
|
||||
baseURL string
|
||||
tokenSecret string
|
||||
disableSignup bool
|
||||
invitationTokenValidity time.Duration
|
||||
}
|
||||
|
||||
// TenantAuthService handles tenant-scoped authentication operations
|
||||
TenantAuthService struct {
|
||||
pg *pg.Client
|
||||
encryptionKey cipher.EncryptionKey
|
||||
hp *passwdhash.Profile
|
||||
hostname string
|
||||
baseURL string
|
||||
tokenSecret string
|
||||
scope coredata.Scoper
|
||||
}
|
||||
|
||||
ErrInvalidCredentials struct {
|
||||
message string
|
||||
}
|
||||
@@ -137,22 +155,38 @@ func (e ErrSignupDisabled) Error() string {
|
||||
func NewService(
|
||||
ctx context.Context,
|
||||
pgClient *pg.Client,
|
||||
encryptionKey cipher.EncryptionKey,
|
||||
hp *passwdhash.Profile,
|
||||
tokenSecret string,
|
||||
hostname string,
|
||||
baseURL string,
|
||||
disableSignup bool,
|
||||
invitationTokenValidity time.Duration,
|
||||
) (*Service, error) {
|
||||
return &Service{
|
||||
pg: pgClient,
|
||||
encryptionKey: encryptionKey,
|
||||
hp: hp,
|
||||
hostname: hostname,
|
||||
baseURL: baseURL,
|
||||
tokenSecret: tokenSecret,
|
||||
disableSignup: disableSignup,
|
||||
invitationTokenValidity: invitationTokenValidity,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) WithTenant(tenantID gid.TenantID) *TenantAuthService {
|
||||
return &TenantAuthService{
|
||||
pg: s.pg,
|
||||
encryptionKey: s.encryptionKey,
|
||||
hp: s.hp,
|
||||
hostname: s.hostname,
|
||||
baseURL: s.baseURL,
|
||||
tokenSecret: s.tokenSecret,
|
||||
scope: coredata.NewScope(tenantID),
|
||||
}
|
||||
}
|
||||
|
||||
func (s Service) ForgetPassword(
|
||||
ctx context.Context,
|
||||
email string,
|
||||
@@ -265,7 +299,7 @@ func (s Service) SignUp(
|
||||
err = s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := user.Insert(ctx, tx); err != nil {
|
||||
if err := user.Insert(ctx, tx, coredata.NewNoScope()); err != nil {
|
||||
var errUserAlreadyExists *coredata.ErrUserAlreadyExists
|
||||
if errors.As(err, &errUserAlreadyExists) {
|
||||
return &ErrUserAlreadyExists{errUserAlreadyExists.Error()}
|
||||
@@ -328,6 +362,116 @@ func (s Service) SignUp(
|
||||
return user, session, nil
|
||||
}
|
||||
|
||||
func (s Service) CreateOrGetSAMLUser(
|
||||
ctx context.Context,
|
||||
emailAddress string,
|
||||
fullName string,
|
||||
samlSubject string,
|
||||
) (*coredata.User, error) {
|
||||
if _, err := mail.ParseAddress(emailAddress); err != nil {
|
||||
return nil, &ErrInvalidEmail{emailAddress}
|
||||
}
|
||||
|
||||
if fullName == "" {
|
||||
return nil, &ErrInvalidFullName{fullName}
|
||||
}
|
||||
|
||||
if samlSubject == "" {
|
||||
return nil, fmt.Errorf("SAML subject cannot be empty")
|
||||
}
|
||||
|
||||
var user coredata.User
|
||||
now := time.Now()
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
// Try to load existing user by email
|
||||
if err := user.LoadByEmail(ctx, tx, emailAddress); err == nil {
|
||||
// User exists - update SAML subject and full name if needed
|
||||
needsUpdate := false
|
||||
|
||||
if user.SAMLSubject == nil || *user.SAMLSubject != samlSubject {
|
||||
user.SAMLSubject = &samlSubject
|
||||
needsUpdate = true
|
||||
}
|
||||
if user.FullName != fullName {
|
||||
user.FullName = fullName
|
||||
needsUpdate = true
|
||||
}
|
||||
if !user.EmailAddressVerified {
|
||||
user.EmailAddressVerified = true
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
if needsUpdate {
|
||||
user.UpdatedAt = now
|
||||
if err := user.Update(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot update user: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// No existing user, create new user (all users are global now)
|
||||
user = coredata.User{
|
||||
ID: gid.New(gid.NilTenant, coredata.UserEntityType),
|
||||
EmailAddress: emailAddress,
|
||||
HashedPassword: nil, // SAML users don't have passwords initially
|
||||
EmailAddressVerified: true, // SAML users are verified by IdP
|
||||
FullName: fullName,
|
||||
SAMLSubject: &samlSubject,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := user.Insert(ctx, tx, coredata.NewNoScope()); err != nil {
|
||||
return fmt.Errorf("cannot insert SAML user: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s Service) CreateSessionForUser(
|
||||
ctx context.Context,
|
||||
userID gid.GID,
|
||||
sessionDuration time.Duration,
|
||||
) (*coredata.Session, error) {
|
||||
now := time.Now()
|
||||
session := &coredata.Session{
|
||||
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
|
||||
UserID: userID,
|
||||
Data: coredata.SessionData{},
|
||||
ExpiredAt: now.Add(sessionDuration),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
if err := session.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert session: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s Service) SignIn(
|
||||
ctx context.Context,
|
||||
emailAddress string,
|
||||
@@ -340,6 +484,69 @@ func (s Service) SignIn(
|
||||
user := &coredata.User{}
|
||||
session := &coredata.Session{}
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
// Load user by email (all users are global now)
|
||||
if err := user.LoadByEmail(ctx, tx, emailAddress); err != nil {
|
||||
var errUserNotFound *coredata.ErrUserNotFound
|
||||
if errors.As(err, &errUserNotFound) {
|
||||
return &ErrInvalidCredentials{"invalid email or password"}
|
||||
}
|
||||
return fmt.Errorf("cannot load user by email: %w", err)
|
||||
}
|
||||
|
||||
// Verify password
|
||||
match, err := s.hp.ComparePasswordAndHash([]byte(password), user.HashedPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot verify password: %w", err)
|
||||
}
|
||||
if !match {
|
||||
return &ErrInvalidCredentials{"invalid email or password"}
|
||||
}
|
||||
|
||||
// Create new session with password authentication flag set
|
||||
now := time.Now()
|
||||
session = &coredata.Session{
|
||||
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
|
||||
UserID: user.ID,
|
||||
Data: coredata.SessionData{
|
||||
PasswordAuthenticated: true,
|
||||
SAMLAuthenticatedOrgs: make(map[string]coredata.SAMLAuthInfo),
|
||||
},
|
||||
ExpiredAt: now.Add(24 * time.Hour * 7), // 7 days
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := session.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return session, user, nil
|
||||
}
|
||||
|
||||
func (s Service) SignInWithExistingSession(
|
||||
ctx context.Context,
|
||||
emailAddress string,
|
||||
password string,
|
||||
existingSession *coredata.Session,
|
||||
) (*coredata.Session, *coredata.User, error) {
|
||||
if _, err := mail.ParseAddress(emailAddress); err != nil {
|
||||
return nil, nil, &ErrInvalidCredentials{"invalid email or password"}
|
||||
}
|
||||
|
||||
user := &coredata.User{}
|
||||
session := &coredata.Session{}
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
@@ -359,18 +566,38 @@ func (s Service) SignIn(
|
||||
return &ErrInvalidCredentials{"invalid email or password"}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
session = &coredata.Session{
|
||||
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
|
||||
UserID: user.ID,
|
||||
Data: coredata.SessionData{},
|
||||
ExpiredAt: now.Add(24 * time.Hour * 7), // 7 days
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if existingSession != nil && existingSession.UserID == user.ID {
|
||||
session = &coredata.Session{}
|
||||
if err := session.LoadByID(ctx, tx, existingSession.ID); err != nil {
|
||||
return fmt.Errorf("cannot load session: %w", err)
|
||||
}
|
||||
|
||||
if err := session.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert session: %w", err)
|
||||
session.Data.PasswordAuthenticated = true
|
||||
if session.Data.SAMLAuthenticatedOrgs == nil {
|
||||
session.Data.SAMLAuthenticatedOrgs = make(map[string]coredata.SAMLAuthInfo)
|
||||
}
|
||||
session.UpdatedAt = time.Now()
|
||||
|
||||
if err := session.Update(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot update session: %w", err)
|
||||
}
|
||||
} else {
|
||||
now := time.Now()
|
||||
session = &coredata.Session{
|
||||
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
|
||||
UserID: user.ID,
|
||||
Data: coredata.SessionData{
|
||||
PasswordAuthenticated: true,
|
||||
SAMLAuthenticatedOrgs: make(map[string]coredata.SAMLAuthInfo),
|
||||
},
|
||||
ExpiredAt: now.Add(24 * time.Hour * 7),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := session.Insert(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot insert session: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -510,6 +737,30 @@ func (s Service) UpdateSession(ctx context.Context, sessionID gid.GID) (*coredat
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s Service) UpdateSessionData(ctx context.Context, sessionID gid.GID, data coredata.SessionData) error {
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
session := &coredata.Session{}
|
||||
if err := session.LoadByID(ctx, tx, sessionID); err != nil {
|
||||
return &ErrSessionNotFound{"session not found"}
|
||||
}
|
||||
|
||||
if time.Now().After(session.ExpiredAt) {
|
||||
return &ErrSessionExpired{"session expired"}
|
||||
}
|
||||
|
||||
session.Data = data
|
||||
session.UpdatedAt = time.Now()
|
||||
if err := session.Update(ctx, tx); err != nil {
|
||||
return fmt.Errorf("cannot update session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (s Service) ConfirmEmail(ctx context.Context, tokenString string) error {
|
||||
payload, err := statelesstoken.ValidateToken[EmailConfirmationData](
|
||||
s.tokenSecret,
|
||||
@@ -655,7 +906,7 @@ func (s Service) SignupFromInvitation(
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := user.Insert(ctx, tx); err != nil {
|
||||
if err := user.Insert(ctx, tx, coredata.NewNoScope()); err != nil {
|
||||
var errUserAlreadyExists *coredata.ErrUserAlreadyExists
|
||||
if errors.As(err, &errUserAlreadyExists) {
|
||||
return &ErrUserAlreadyExists{errUserAlreadyExists.Error()}
|
||||
@@ -686,3 +937,342 @@ func (s Service) SignupFromInvitation(
|
||||
|
||||
return user, session, nil
|
||||
}
|
||||
|
||||
// IsTenantUser removed - all users are now global (no tenant distinction)
|
||||
|
||||
func (s Service) GetUserAuthMethod(
|
||||
ctx context.Context,
|
||||
scope coredata.Scoper,
|
||||
userID gid.GID,
|
||||
organizationID gid.GID,
|
||||
session *coredata.Session,
|
||||
) (coredata.UserAuthMethod, error) {
|
||||
// Load the user to check their email and SAML subject
|
||||
user := &coredata.User{}
|
||||
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||
return user.LoadByID(ctx, conn, userID)
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot load user: %w", err)
|
||||
}
|
||||
|
||||
// If user doesn't have a SAML subject, they only use password auth
|
||||
if user.SAMLSubject == nil || *user.SAMLSubject == "" {
|
||||
return coredata.UserAuthMethodPassword, nil
|
||||
}
|
||||
|
||||
// User has SAML subject - check if there's SAML config for this org + user's domain
|
||||
// Extract domain from user email
|
||||
emailParts := []byte(user.EmailAddress)
|
||||
atIndex := -1
|
||||
for i, b := range emailParts {
|
||||
if b == '@' {
|
||||
atIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if atIndex == -1 {
|
||||
return coredata.UserAuthMethodPassword, nil
|
||||
}
|
||||
domain := string(emailParts[atIndex+1:])
|
||||
|
||||
// Check if SAML is configured for this org + domain
|
||||
var samlConfig coredata.SAMLConfiguration
|
||||
orgScope := coredata.NewScope(organizationID.TenantID())
|
||||
err = s.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||
err := samlConfig.LoadByOrganizationIDAndEmailDomain(ctx, conn, orgScope, organizationID, domain)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil // No SAML config for this org+domain
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot check SAML configuration: %w", err)
|
||||
}
|
||||
|
||||
// If SAML config exists for this org+domain, user enrolled via SAML
|
||||
if samlConfig.ID != (gid.GID{}) {
|
||||
return coredata.UserAuthMethodSAML, nil
|
||||
}
|
||||
|
||||
// No SAML config for this org, user uses password
|
||||
return coredata.UserAuthMethodPassword, nil
|
||||
}
|
||||
|
||||
// Organization Access Control
|
||||
|
||||
type (
|
||||
// ErrSAMLAuthRequired indicates user must authenticate via SAML to access org
|
||||
ErrSAMLAuthRequired struct {
|
||||
ConfigID gid.GID
|
||||
OrganizationID gid.GID
|
||||
RedirectURL string // SAML IdP login URL
|
||||
}
|
||||
|
||||
// ErrPasswordAuthRequired indicates user must authenticate with password to access org
|
||||
ErrPasswordAuthRequired struct {
|
||||
OrganizationID gid.GID
|
||||
RedirectURL string // Password login page URL
|
||||
}
|
||||
)
|
||||
|
||||
func (e ErrSAMLAuthRequired) Error() string {
|
||||
return "SAML authentication required for this organization"
|
||||
}
|
||||
|
||||
func (e ErrPasswordAuthRequired) Error() string {
|
||||
return "password authentication required for this organization"
|
||||
}
|
||||
|
||||
// CheckOrganizationAccess determines if a user can access an organization
|
||||
// based on SAML configuration and session authentication state
|
||||
func (s Service) CheckOrganizationAccess(
|
||||
ctx context.Context,
|
||||
user *coredata.User,
|
||||
organizationID gid.GID,
|
||||
session *coredata.Session,
|
||||
) error {
|
||||
// Extract domain from user email
|
||||
emailParts := []byte(user.EmailAddress)
|
||||
atIndex := -1
|
||||
for i, b := range emailParts {
|
||||
if b == '@' {
|
||||
atIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if atIndex == -1 {
|
||||
return fmt.Errorf("invalid email address format")
|
||||
}
|
||||
domain := string(emailParts[atIndex+1:])
|
||||
|
||||
// Find SAML configuration for this organization and domain
|
||||
var samlConfig coredata.SAMLConfiguration
|
||||
scope := coredata.NewScope(organizationID.TenantID())
|
||||
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||
err := samlConfig.LoadByOrganizationIDAndEmailDomain(ctx, conn, scope, organizationID, domain)
|
||||
if err != nil {
|
||||
// If no SAML config found for this organization and domain, that's okay - not an error
|
||||
// Just means this organization doesn't have SAML configured for this domain
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot check SAML configuration: %w", err)
|
||||
}
|
||||
|
||||
// Check if SAML is configured and enabled for this domain and organization
|
||||
if samlConfig.ID != (gid.GID{}) && samlConfig.Enabled && samlConfig.DomainVerified {
|
||||
// SAML config exists for this org - check enforcement policy
|
||||
if samlConfig.EnforcementPolicy == coredata.SAMLEnforcementPolicyRequired {
|
||||
// SAML is REQUIRED - check if user has SAML-authenticated for this org
|
||||
authInfo, hasSAMLAuth := session.Data.SAMLAuthenticatedOrgs[organizationID.String()]
|
||||
if !hasSAMLAuth {
|
||||
// Build SAML login URL
|
||||
samlLoginURL := fmt.Sprintf("%s/auth/saml/login/%s", s.baseURL, samlConfig.ID)
|
||||
return ErrSAMLAuthRequired{
|
||||
ConfigID: samlConfig.ID,
|
||||
OrganizationID: organizationID,
|
||||
RedirectURL: samlLoginURL,
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: Check if SAML auth is still recent (not too old)
|
||||
// For now, we trust the session lifetime
|
||||
_ = authInfo
|
||||
} else {
|
||||
// SAML is OPTIONAL or OFF - allow either password OR SAML auth for this specific org
|
||||
hasSAMLAuth := false
|
||||
if _, ok := session.Data.SAMLAuthenticatedOrgs[organizationID.String()]; ok {
|
||||
hasSAMLAuth = true
|
||||
}
|
||||
|
||||
if !session.Data.PasswordAuthenticated && !hasSAMLAuth {
|
||||
// User needs to authenticate - offer SAML as option
|
||||
samlLoginURL := fmt.Sprintf("%s/auth/saml/login/%s", s.baseURL, samlConfig.ID)
|
||||
return ErrSAMLAuthRequired{
|
||||
ConfigID: samlConfig.ID,
|
||||
OrganizationID: organizationID,
|
||||
RedirectURL: samlLoginURL,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No SAML configuration for this org+domain combination
|
||||
// Require password authentication for password-only organizations
|
||||
if !session.Data.PasswordAuthenticated {
|
||||
// User hasn't authenticated with password - require password authentication
|
||||
loginURL := fmt.Sprintf("%s/authentication/login?method=password", s.baseURL)
|
||||
return ErrPasswordAuthRequired{
|
||||
OrganizationID: organizationID,
|
||||
RedirectURL: loginURL,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil // Access granted
|
||||
}
|
||||
|
||||
// InitiateDomainVerification creates a SAML configuration with unverified domain and generates verification token
|
||||
func (s Service) InitiateDomainVerification(
|
||||
ctx context.Context,
|
||||
tenantID gid.TenantID,
|
||||
organizationID gid.GID,
|
||||
emailDomain string,
|
||||
) (*coredata.SAMLConfiguration, error) {
|
||||
token, err := GenerateDomainVerificationToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate verification token: %w", err)
|
||||
}
|
||||
|
||||
var config *coredata.SAMLConfiguration
|
||||
|
||||
err = s.pg.WithTx(ctx, func(tx pg.Conn) error {
|
||||
now := time.Now()
|
||||
scope := coredata.NewScope(tenantID)
|
||||
|
||||
config = &coredata.SAMLConfiguration{
|
||||
ID: gid.New(tenantID, coredata.SAMLConfigurationEntityType),
|
||||
OrganizationID: organizationID,
|
||||
EmailDomain: emailDomain,
|
||||
Enabled: false,
|
||||
EnforcementPolicy: coredata.SAMLEnforcementPolicyOff,
|
||||
DomainVerified: false,
|
||||
DomainVerificationToken: &token,
|
||||
// Default IdP values (placeholders until configured)
|
||||
IdPEntityID: "not-configured",
|
||||
IdPSsoURL: "not-configured",
|
||||
IdPCertificate: "not-configured",
|
||||
// Default attribute mappings
|
||||
AttributeEmail: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
||||
AttributeFirstname: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
|
||||
AttributeLastname: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
|
||||
AttributeRole: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role",
|
||||
DefaultRole: "MEMBER",
|
||||
AutoSignupEnabled: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := config.Insert(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot insert SAML configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// VerifyDomain checks DNS TXT record and marks domain as verified if found
|
||||
func (s Service) VerifyDomain(
|
||||
ctx context.Context,
|
||||
tenantID gid.TenantID,
|
||||
configID gid.GID,
|
||||
) (*coredata.SAMLConfiguration, bool, error) {
|
||||
var config *coredata.SAMLConfiguration
|
||||
var verified bool
|
||||
|
||||
err := s.pg.WithTx(ctx, func(tx pg.Conn) error {
|
||||
scope := coredata.NewScope(tenantID)
|
||||
|
||||
// Load config
|
||||
config = &coredata.SAMLConfiguration{}
|
||||
if err := config.LoadByID(ctx, tx, scope, configID); err != nil {
|
||||
return fmt.Errorf("cannot load SAML configuration: %w", err)
|
||||
}
|
||||
|
||||
if config.DomainVerificationToken == nil {
|
||||
return fmt.Errorf("no verification token found for this configuration")
|
||||
}
|
||||
|
||||
if config.DomainVerified {
|
||||
verified = true
|
||||
return nil // Already verified
|
||||
}
|
||||
|
||||
// Check DNS TXT record
|
||||
isVerified, err := VerifyDomainOwnership(ctx, config.EmailDomain, *config.DomainVerificationToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot verify domain ownership: %w", err)
|
||||
}
|
||||
|
||||
verified = isVerified
|
||||
|
||||
if isVerified {
|
||||
now := time.Now()
|
||||
config.DomainVerified = true
|
||||
config.DomainVerifiedAt = &now
|
||||
config.UpdatedAt = now
|
||||
|
||||
if err := config.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("cannot update SAML configuration: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return config, verified, nil
|
||||
}
|
||||
|
||||
// Domain Verification Methods
|
||||
|
||||
// GenerateDomainVerificationToken generates a random 32-character hex token for domain verification
|
||||
func GenerateDomainVerificationToken() (string, error) {
|
||||
bytes := make([]byte, 16) // 16 bytes = 32 hex characters
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("cannot generate domain verification token: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// GetDomainVerificationRecord returns the DNS TXT record string that should be added to the domain
|
||||
func GetDomainVerificationRecord(token string) string {
|
||||
return fmt.Sprintf("probo-verification=%s", token)
|
||||
}
|
||||
|
||||
// VerifyDomainOwnership performs DNS lookup to verify domain ownership via TXT record
|
||||
func VerifyDomainOwnership(ctx context.Context, domain, expectedToken string) (bool, error) {
|
||||
// Use net package for DNS TXT record lookup
|
||||
var txtRecords []string
|
||||
var err error
|
||||
|
||||
// Create a DNS resolver with timeout from context
|
||||
resolver := &net.Resolver{
|
||||
PreferGo: true,
|
||||
}
|
||||
|
||||
txtRecords, err = resolver.LookupTXT(ctx, domain)
|
||||
if err != nil {
|
||||
// DNS lookup errors are expected if the domain doesn't exist or has no TXT records
|
||||
// We return false (not verified) but not an error, as this is a normal case
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check if any TXT record matches our verification token
|
||||
expectedRecord := GetDomainVerificationRecord(expectedToken)
|
||||
for _, record := range txtRecords {
|
||||
if record == expectedRecord {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Token not found in DNS records
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -261,6 +261,61 @@ func (s *Service) AcceptInvitationByID(
|
||||
return acceptedInvitation, nil
|
||||
}
|
||||
|
||||
// EnsureSAMLMembership creates or updates a user's membership in an organization.
|
||||
// This is used during SAML authentication to ensure the user has the correct role.
|
||||
// This method is on Service (not TenantAuthzService) because SAML authentication
|
||||
// happens before the user has tenant access.
|
||||
func (s *Service) EnsureSAMLMembership(
|
||||
ctx context.Context,
|
||||
tenantID gid.TenantID,
|
||||
userID gid.GID,
|
||||
organizationID gid.GID,
|
||||
role string,
|
||||
) error {
|
||||
scope := coredata.NewScope(tenantID)
|
||||
now := time.Now()
|
||||
|
||||
return s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
var membership coredata.Membership
|
||||
|
||||
// Try to load existing membership
|
||||
err := membership.LoadByUserAndOrg(ctx, tx, scope, userID, organizationID)
|
||||
if err != nil {
|
||||
// Membership doesn't exist, create it
|
||||
membershipID := gid.New(tenantID, coredata.MembershipEntityType)
|
||||
membership = coredata.Membership{
|
||||
ID: membershipID,
|
||||
UserID: userID,
|
||||
OrganizationID: organizationID,
|
||||
Role: role,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := membership.Create(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("failed to create membership: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Membership exists, update role if changed
|
||||
if membership.Role != role {
|
||||
membership.Role = role
|
||||
membership.UpdatedAt = now
|
||||
|
||||
if err := membership.Update(ctx, tx, scope); err != nil {
|
||||
return fmt.Errorf("failed to update membership role: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// This method is on Service (not TenantAuthzService) because the user viewing
|
||||
// their invitations doesn't have tenant access yet, and it operates across multiple tenants.
|
||||
func (s *Service) GetUserInvitations(
|
||||
|
||||
@@ -63,4 +63,5 @@ const (
|
||||
MembershipEntityType
|
||||
SlackMessageEntityType
|
||||
TrustCenterFileEntityType
|
||||
SAMLConfigurationEntityType
|
||||
)
|
||||
|
||||
@@ -132,22 +132,33 @@ func (m *Membership) LoadByID(
|
||||
membershipID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
WITH mbr AS (
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
organization_id,
|
||||
role,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
authz_memberships
|
||||
WHERE
|
||||
id = @membership_id
|
||||
AND %s
|
||||
)
|
||||
SELECT
|
||||
m.id,
|
||||
m.user_id,
|
||||
m.organization_id,
|
||||
m.role,
|
||||
mbr.id,
|
||||
mbr.user_id,
|
||||
mbr.organization_id,
|
||||
mbr.role,
|
||||
u.fullname as full_name,
|
||||
u.email_address,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
mbr.created_at,
|
||||
mbr.updated_at
|
||||
FROM
|
||||
authz_memberships m
|
||||
mbr
|
||||
JOIN
|
||||
users u ON m.user_id = u.id
|
||||
WHERE
|
||||
m.id = @membership_id
|
||||
AND %s
|
||||
users u ON mbr.user_id = u.id
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
@@ -182,23 +193,34 @@ func (m *Membership) LoadByUserAndOrg(
|
||||
orgID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
WITH mbr AS (
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
organization_id,
|
||||
role,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
authz_memberships
|
||||
WHERE
|
||||
user_id = @user_id
|
||||
AND organization_id = @organization_id
|
||||
AND %s
|
||||
)
|
||||
SELECT
|
||||
m.id,
|
||||
m.user_id,
|
||||
m.organization_id,
|
||||
m.role,
|
||||
mbr.id,
|
||||
mbr.user_id,
|
||||
mbr.organization_id,
|
||||
mbr.role,
|
||||
u.fullname as full_name,
|
||||
u.email_address,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
mbr.created_at,
|
||||
mbr.updated_at
|
||||
FROM
|
||||
authz_memberships m
|
||||
mbr
|
||||
JOIN
|
||||
users u ON m.user_id = u.id
|
||||
WHERE
|
||||
m.user_id = @user_id
|
||||
AND m.organization_id = @organization_id
|
||||
AND %s
|
||||
users u ON mbr.user_id = u.id
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
@@ -294,24 +316,35 @@ func (m *Memberships) LoadByUserID(
|
||||
userID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
WITH mbr AS (
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
organization_id,
|
||||
role,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
authz_memberships
|
||||
WHERE
|
||||
user_id = @user_id
|
||||
AND %s
|
||||
ORDER BY
|
||||
created_at DESC
|
||||
)
|
||||
SELECT
|
||||
m.id,
|
||||
m.user_id,
|
||||
m.organization_id,
|
||||
m.role,
|
||||
mbr.id,
|
||||
mbr.user_id,
|
||||
mbr.organization_id,
|
||||
mbr.role,
|
||||
u.fullname as full_name,
|
||||
u.email_address,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
mbr.created_at,
|
||||
mbr.updated_at
|
||||
FROM
|
||||
authz_memberships m
|
||||
mbr
|
||||
JOIN
|
||||
users u ON m.user_id = u.id
|
||||
WHERE
|
||||
m.user_id = @user_id
|
||||
AND %s
|
||||
ORDER BY
|
||||
m.created_at DESC
|
||||
users u ON mbr.user_id = u.id
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
@@ -343,23 +376,45 @@ func (m *Memberships) LoadByOrganizationID(
|
||||
cursor *page.Cursor[MembershipOrderField],
|
||||
) error {
|
||||
query := `
|
||||
WITH mbr AS (
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
organization_id,
|
||||
role,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
authz_memberships
|
||||
WHERE
|
||||
organization_id = @organization_id
|
||||
AND %s
|
||||
)
|
||||
SELECT
|
||||
m.id,
|
||||
m.user_id,
|
||||
m.organization_id,
|
||||
m.role,
|
||||
u.fullname as full_name,
|
||||
u.email_address,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM
|
||||
authz_memberships m
|
||||
JOIN
|
||||
users u ON m.user_id = u.id
|
||||
WHERE
|
||||
m.organization_id = @organization_id
|
||||
AND %s
|
||||
AND %s
|
||||
id,
|
||||
user_id,
|
||||
organization_id,
|
||||
role,
|
||||
full_name,
|
||||
email_address,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM (
|
||||
SELECT
|
||||
mbr.id,
|
||||
mbr.user_id,
|
||||
mbr.organization_id,
|
||||
mbr.role,
|
||||
u.fullname as full_name,
|
||||
u.email_address,
|
||||
mbr.created_at,
|
||||
mbr.updated_at
|
||||
FROM
|
||||
mbr
|
||||
JOIN
|
||||
users u ON mbr.user_id = u.id
|
||||
) AS membership_with_user
|
||||
WHERE %s
|
||||
`
|
||||
|
||||
query = fmt.Sprintf(query, scope.SQLFragment(), cursor.SQLFragment())
|
||||
@@ -411,3 +466,67 @@ WHERE
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func LoadUserIDsByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
) ([]gid.GID, error) {
|
||||
query := `
|
||||
SELECT user_id
|
||||
FROM authz_memberships
|
||||
WHERE organization_id = @organization_id AND %s
|
||||
`
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot query memberships: %w", err)
|
||||
}
|
||||
|
||||
var userIDs []gid.GID
|
||||
for rows.Next() {
|
||||
var userID gid.GID
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("cannot scan user_id: %w", err)
|
||||
}
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
return userIDs, nil
|
||||
}
|
||||
|
||||
func UpdateMembershipUserID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
oldUserID gid.GID,
|
||||
newUserID gid.GID,
|
||||
organizationID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
UPDATE authz_memberships
|
||||
SET user_id = @new_user_id, updated_at = @updated_at
|
||||
WHERE user_id = @old_user_id AND organization_id = @organization_id AND %s
|
||||
`
|
||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||
args := pgx.StrictNamedArgs{
|
||||
"new_user_id": newUserID,
|
||||
"old_user_id": oldUserID,
|
||||
"organization_id": organizationID,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update membership: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -28,13 +28,13 @@ const (
|
||||
func (p MembershipOrderField) Column() string {
|
||||
switch p {
|
||||
case MembershipOrderFieldFullName:
|
||||
return "u.fullname"
|
||||
return "full_name"
|
||||
case MembershipOrderFieldEmailAddress:
|
||||
return "u.email_address"
|
||||
return "email_address"
|
||||
case MembershipOrderFieldRole:
|
||||
return "m.role"
|
||||
return "role"
|
||||
case MembershipOrderFieldCreatedAt:
|
||||
return "m.created_at"
|
||||
return "created_at"
|
||||
}
|
||||
return string(p)
|
||||
}
|
||||
|
||||
155
pkg/coredata/migrations/20251018T194142Z.sql
Normal file
155
pkg/coredata/migrations/20251018T194142Z.sql
Normal file
@@ -0,0 +1,155 @@
|
||||
-- Add SAML authentication support
|
||||
-- This migration adds SAML SSO functionality including:
|
||||
-- - SAML configurations per organization
|
||||
-- - SAML request/assertion tracking for security
|
||||
-- - Domain verification
|
||||
-- - User SAML subject tracking
|
||||
|
||||
-- Create ENUM for SAML enforcement policies
|
||||
CREATE TYPE saml_enforcement_policy AS ENUM (
|
||||
'OFF', -- SAML disabled, must use password
|
||||
'OPTIONAL', -- SAML available but not required (default)
|
||||
'REQUIRED' -- Everyone must use SAML
|
||||
);
|
||||
|
||||
-- Create auth_saml_configurations table
|
||||
CREATE TABLE auth_saml_configurations (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL,
|
||||
email_domain TEXT NOT NULL,
|
||||
|
||||
-- SAML enabled flag
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
-- Enforcement policy for this SAML configuration
|
||||
enforcement_policy saml_enforcement_policy NOT NULL,
|
||||
|
||||
-- Identity Provider (IdP) configuration
|
||||
idp_entity_id TEXT NOT NULL,
|
||||
idp_sso_url TEXT NOT NULL,
|
||||
idp_certificate TEXT NOT NULL, -- X.509 certificate (PEM format)
|
||||
idp_metadata_url TEXT, -- Optional: for auto-refresh
|
||||
|
||||
-- Attribute mapping configuration (using WS-Federation Claims)
|
||||
attribute_email TEXT NOT NULL DEFAULT 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
|
||||
attribute_firstname TEXT NOT NULL DEFAULT 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname',
|
||||
attribute_lastname TEXT NOT NULL DEFAULT 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname',
|
||||
attribute_role TEXT NOT NULL DEFAULT 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role',
|
||||
|
||||
-- Default role if mapping fails or attribute missing
|
||||
default_role TEXT NOT NULL DEFAULT 'MEMBER',
|
||||
|
||||
-- Auto-signup settings
|
||||
auto_signup_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
-- Domain verification fields
|
||||
domain_verified BOOLEAN NOT NULL DEFAULT false,
|
||||
domain_verification_token TEXT,
|
||||
domain_verified_at TIMESTAMP,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
|
||||
CONSTRAINT fk_auth_saml_configurations_organization FOREIGN KEY (organization_id)
|
||||
REFERENCES organizations(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Index for fast organization lookup
|
||||
CREATE INDEX idx_auth_saml_configurations_organization_id
|
||||
ON auth_saml_configurations(organization_id);
|
||||
|
||||
-- Index for tenant scoping
|
||||
CREATE INDEX idx_auth_saml_configurations_tenant_id
|
||||
ON auth_saml_configurations(tenant_id);
|
||||
|
||||
-- Unique constraint scoped to organization
|
||||
-- This allows the same domain in different organizations
|
||||
-- while preventing duplicates within the same organization
|
||||
CREATE UNIQUE INDEX idx_saml_config_domain_org_unique
|
||||
ON auth_saml_configurations(organization_id, email_domain)
|
||||
WHERE enabled = true AND domain_verified = true;
|
||||
|
||||
-- Index for fast domain lookup (for email-based SAML discovery)
|
||||
CREATE INDEX idx_saml_config_email_domain
|
||||
ON auth_saml_configurations(email_domain)
|
||||
WHERE enabled = true AND domain_verified = true;
|
||||
|
||||
-- Add SAML subject to users table for tracking SAML NameID
|
||||
ALTER TABLE users ADD COLUMN saml_subject TEXT;
|
||||
|
||||
-- Unique constraint: one SAML subject globally
|
||||
CREATE UNIQUE INDEX idx_users_saml_subject
|
||||
ON users(saml_subject)
|
||||
WHERE saml_subject IS NOT NULL;
|
||||
|
||||
-- Make hashed_password nullable for SAML users
|
||||
-- SAML users authenticate via IdP and don't have passwords
|
||||
ALTER TABLE users ALTER COLUMN hashed_password DROP NOT NULL;
|
||||
|
||||
-- Create auth_saml_assertions table for replay attack prevention
|
||||
CREATE TABLE auth_saml_assertions (
|
||||
id TEXT PRIMARY KEY, -- SAML Assertion ID
|
||||
tenant_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL,
|
||||
used_at TIMESTAMP NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
-- Index for cleanup of expired assertions
|
||||
CREATE INDEX idx_auth_saml_assertions_expires_at
|
||||
ON auth_saml_assertions(expires_at);
|
||||
|
||||
-- Index for organization lookup
|
||||
CREATE INDEX idx_auth_saml_assertions_organization_id
|
||||
ON auth_saml_assertions(organization_id);
|
||||
|
||||
-- Index for tenant scoping
|
||||
CREATE INDEX idx_auth_saml_assertions_tenant_id
|
||||
ON auth_saml_assertions(tenant_id);
|
||||
|
||||
-- Create auth_saml_requests table for proper InResponseTo validation
|
||||
-- This prevents replay attacks and validates the SAML authentication flow
|
||||
CREATE TABLE auth_saml_requests (
|
||||
id TEXT PRIMARY KEY, -- SAML Request ID generated by SP
|
||||
organization_id TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
|
||||
CONSTRAINT fk_auth_saml_requests_organization FOREIGN KEY (organization_id)
|
||||
REFERENCES organizations(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Index for fast request ID lookup during SAML callback
|
||||
CREATE INDEX idx_auth_saml_requests_id_org ON auth_saml_requests(id, organization_id);
|
||||
|
||||
-- Index for cleanup of expired requests
|
||||
CREATE INDEX idx_auth_saml_requests_expires_at ON auth_saml_requests(expires_at);
|
||||
|
||||
-- Create auth_saml_relay_states table for secure RelayState management
|
||||
-- This prevents organization hijacking attacks
|
||||
CREATE TABLE auth_saml_relay_states (
|
||||
token TEXT PRIMARY KEY, -- Cryptographically secure random token
|
||||
tenant_id TEXT NOT NULL,
|
||||
organization_id TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL, -- Links to auth_saml_requests.id
|
||||
saml_config_id TEXT NOT NULL, -- Links to auth_saml_configurations.id
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
|
||||
CONSTRAINT fk_auth_saml_relay_states_organization FOREIGN KEY (organization_id)
|
||||
REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_auth_saml_relay_states_saml_config FOREIGN KEY (saml_config_id)
|
||||
REFERENCES auth_saml_configurations(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Index for fast token lookup during callback
|
||||
CREATE INDEX idx_auth_saml_relay_states_token ON auth_saml_relay_states(token);
|
||||
|
||||
-- Index for cleanup of expired relay states
|
||||
CREATE INDEX idx_auth_saml_relay_states_expires_at ON auth_saml_relay_states(expires_at);
|
||||
|
||||
-- Index for tenant scoping
|
||||
CREATE INDEX idx_auth_saml_relay_states_tenant_id ON auth_saml_relay_states(tenant_id);
|
||||
108
pkg/coredata/saml_assertion.go
Normal file
108
pkg/coredata/saml_assertion.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type SAMLAssertion struct {
|
||||
ID string `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
UsedAt time.Time `db:"used_at"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
}
|
||||
|
||||
type ErrAssertionAlreadyUsed struct {
|
||||
AssertionID string
|
||||
}
|
||||
|
||||
func (e ErrAssertionAlreadyUsed) Error() string {
|
||||
return fmt.Sprintf("assertion ID %q has already been used (replay attack)", e.AssertionID)
|
||||
}
|
||||
|
||||
func (s *SAMLAssertion) CheckExists(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
assertionID string,
|
||||
) (bool, error) {
|
||||
query := `
|
||||
SELECT id
|
||||
FROM auth_saml_assertions
|
||||
WHERE id = @id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
rows, err := conn.Query(ctx, query, pgx.NamedArgs{"id": assertionID})
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("cannot query saml_assertions: %w", err)
|
||||
}
|
||||
|
||||
_, err = pgx.CollectOneRow(rows, pgx.RowTo[string])
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if err == pgx.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("cannot collect saml_assertion: %w", err)
|
||||
}
|
||||
|
||||
func (s *SAMLAssertion) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
query := `
|
||||
INSERT INTO auth_saml_assertions (id, tenant_id, organization_id, used_at, expires_at)
|
||||
VALUES (@id, @tenant_id, @organization_id, @used_at, @expires_at)
|
||||
`
|
||||
|
||||
args := pgx.NamedArgs{
|
||||
"id": s.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": s.OrganizationID,
|
||||
"used_at": s.UsedAt,
|
||||
"expires_at": s.ExpiresAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert saml_assertion: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteExpiredSAMLAssertions(ctx context.Context, conn pg.Conn, now time.Time) (int64, error) {
|
||||
query := `
|
||||
DELETE FROM auth_saml_assertions
|
||||
WHERE expires_at < @now
|
||||
`
|
||||
|
||||
result, err := conn.Exec(ctx, query, pgx.NamedArgs{"now": now})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot delete expired saml_assertions: %w", err)
|
||||
}
|
||||
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
453
pkg/coredata/saml_configuration.go
Normal file
453
pkg/coredata/saml_configuration.go
Normal file
@@ -0,0 +1,453 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type SAMLConfiguration struct {
|
||||
ID gid.GID `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
EmailDomain string `db:"email_domain"`
|
||||
Enabled bool `db:"enabled"`
|
||||
EnforcementPolicy SAMLEnforcementPolicy `db:"enforcement_policy"`
|
||||
IdPEntityID string `db:"idp_entity_id"`
|
||||
IdPSsoURL string `db:"idp_sso_url"`
|
||||
IdPCertificate string `db:"idp_certificate"`
|
||||
IdPMetadataURL *string `db:"idp_metadata_url"`
|
||||
AttributeEmail string `db:"attribute_email"`
|
||||
AttributeFirstname string `db:"attribute_firstname"`
|
||||
AttributeLastname string `db:"attribute_lastname"`
|
||||
AttributeRole string `db:"attribute_role"`
|
||||
DefaultRole string `db:"default_role"`
|
||||
AutoSignupEnabled bool `db:"auto_signup_enabled"`
|
||||
DomainVerified bool `db:"domain_verified"`
|
||||
DomainVerificationToken *string `db:"domain_verification_token"`
|
||||
DomainVerifiedAt *time.Time `db:"domain_verified_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *SAMLConfiguration) LoadByOrganizationIDAndEmailDomain(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
emailDomain string,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
email_domain,
|
||||
enabled,
|
||||
enforcement_policy,
|
||||
idp_entity_id,
|
||||
idp_sso_url,
|
||||
idp_certificate,
|
||||
idp_metadata_url,
|
||||
attribute_email,
|
||||
attribute_firstname,
|
||||
attribute_lastname,
|
||||
attribute_role,
|
||||
default_role,
|
||||
auto_signup_enabled,
|
||||
domain_verified,
|
||||
domain_verification_token,
|
||||
domain_verified_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
auth_saml_configurations
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
AND email_domain = @email_domain
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"organization_id": organizationID,
|
||||
"email_domain": emailDomain,
|
||||
}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query auth_saml_configurations: %w", err)
|
||||
}
|
||||
|
||||
config, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[SAMLConfiguration])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect saml_configuration: %w", err)
|
||||
}
|
||||
|
||||
*s = config
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SAMLConfiguration) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
configID gid.GID,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
email_domain,
|
||||
enabled,
|
||||
enforcement_policy,
|
||||
idp_entity_id,
|
||||
idp_sso_url,
|
||||
idp_certificate,
|
||||
idp_metadata_url,
|
||||
attribute_email,
|
||||
attribute_firstname,
|
||||
attribute_lastname,
|
||||
attribute_role,
|
||||
default_role,
|
||||
auto_signup_enabled,
|
||||
domain_verified,
|
||||
domain_verification_token,
|
||||
domain_verified_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
auth_saml_configurations
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"id": configID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query auth_saml_configurations: %w", err)
|
||||
}
|
||||
|
||||
config, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[SAMLConfiguration])
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect saml_configuration: %w", err)
|
||||
}
|
||||
|
||||
*s = config
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SAMLConfiguration) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO auth_saml_configurations (
|
||||
id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
email_domain,
|
||||
enabled,
|
||||
enforcement_policy,
|
||||
idp_entity_id,
|
||||
idp_sso_url,
|
||||
idp_certificate,
|
||||
idp_metadata_url,
|
||||
attribute_email,
|
||||
attribute_firstname,
|
||||
attribute_lastname,
|
||||
attribute_role,
|
||||
default_role,
|
||||
auto_signup_enabled,
|
||||
domain_verified,
|
||||
domain_verification_token,
|
||||
domain_verified_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@id,
|
||||
@tenant_id,
|
||||
@organization_id,
|
||||
@email_domain,
|
||||
@enabled,
|
||||
@enforcement_policy,
|
||||
@idp_entity_id,
|
||||
@idp_sso_url,
|
||||
@idp_certificate,
|
||||
@idp_metadata_url,
|
||||
@attribute_email,
|
||||
@attribute_firstname,
|
||||
@attribute_lastname,
|
||||
@attribute_role,
|
||||
@default_role,
|
||||
@auto_signup_enabled,
|
||||
@domain_verified,
|
||||
@domain_verification_token,
|
||||
@domain_verified_at,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": s.ID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": s.OrganizationID,
|
||||
"email_domain": s.EmailDomain,
|
||||
"enabled": s.Enabled,
|
||||
"enforcement_policy": s.EnforcementPolicy,
|
||||
"idp_entity_id": s.IdPEntityID,
|
||||
"idp_sso_url": s.IdPSsoURL,
|
||||
"idp_certificate": s.IdPCertificate,
|
||||
"idp_metadata_url": s.IdPMetadataURL,
|
||||
"attribute_email": s.AttributeEmail,
|
||||
"attribute_firstname": s.AttributeFirstname,
|
||||
"attribute_lastname": s.AttributeLastname,
|
||||
"attribute_role": s.AttributeRole,
|
||||
"default_role": s.DefaultRole,
|
||||
"auto_signup_enabled": s.AutoSignupEnabled,
|
||||
"domain_verified": s.DomainVerified,
|
||||
"domain_verification_token": s.DomainVerificationToken,
|
||||
"domain_verified_at": s.DomainVerifiedAt,
|
||||
"created_at": s.CreatedAt,
|
||||
"updated_at": s.UpdatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert saml_configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SAMLConfiguration) Update(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
UPDATE auth_saml_configurations
|
||||
SET
|
||||
enabled = @enabled,
|
||||
enforcement_policy = @enforcement_policy,
|
||||
idp_entity_id = @idp_entity_id,
|
||||
idp_sso_url = @idp_sso_url,
|
||||
idp_certificate = @idp_certificate,
|
||||
idp_metadata_url = @idp_metadata_url,
|
||||
attribute_email = @attribute_email,
|
||||
attribute_firstname = @attribute_firstname,
|
||||
attribute_lastname = @attribute_lastname,
|
||||
attribute_role = @attribute_role,
|
||||
default_role = @default_role,
|
||||
auto_signup_enabled = @auto_signup_enabled,
|
||||
domain_verified = @domain_verified,
|
||||
domain_verification_token = @domain_verification_token,
|
||||
domain_verified_at = @domain_verified_at,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"id": s.ID,
|
||||
"enabled": s.Enabled,
|
||||
"enforcement_policy": s.EnforcementPolicy,
|
||||
"idp_entity_id": s.IdPEntityID,
|
||||
"idp_sso_url": s.IdPSsoURL,
|
||||
"idp_certificate": s.IdPCertificate,
|
||||
"idp_metadata_url": s.IdPMetadataURL,
|
||||
"attribute_email": s.AttributeEmail,
|
||||
"attribute_firstname": s.AttributeFirstname,
|
||||
"attribute_lastname": s.AttributeLastname,
|
||||
"attribute_role": s.AttributeRole,
|
||||
"default_role": s.DefaultRole,
|
||||
"auto_signup_enabled": s.AutoSignupEnabled,
|
||||
"domain_verified": s.DomainVerified,
|
||||
"domain_verification_token": s.DomainVerificationToken,
|
||||
"domain_verified_at": s.DomainVerifiedAt,
|
||||
"updated_at": s.UpdatedAt,
|
||||
}
|
||||
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update saml_configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SAMLConfiguration) Delete(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
DELETE FROM auth_saml_configurations
|
||||
WHERE
|
||||
%s
|
||||
AND id = @id
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"id": s.ID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete saml_configuration: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoadSAMLConfigurationsByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
) ([]*SAMLConfiguration, error) {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
email_domain,
|
||||
enabled,
|
||||
enforcement_policy,
|
||||
idp_entity_id,
|
||||
idp_sso_url,
|
||||
idp_certificate,
|
||||
idp_metadata_url,
|
||||
attribute_email,
|
||||
attribute_firstname,
|
||||
attribute_lastname,
|
||||
attribute_role,
|
||||
default_role,
|
||||
auto_signup_enabled,
|
||||
domain_verified,
|
||||
domain_verification_token,
|
||||
domain_verified_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
auth_saml_configurations
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
ORDER BY email_domain ASC;
|
||||
`
|
||||
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot query auth_saml_configurations: %w", err)
|
||||
}
|
||||
|
||||
configs, err := pgx.CollectRows(rows, pgx.RowToStructByName[SAMLConfiguration])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot collect saml_configurations: %w", err)
|
||||
}
|
||||
|
||||
result := make([]*SAMLConfiguration, len(configs))
|
||||
for i := range configs {
|
||||
result[i] = &configs[i]
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// LoadAllEnabledSAMLConfigurationsByEmailDomain loads all enabled SAML configurations for a given email domain
|
||||
// This is used for SSO login detection when multiple organizations may have SAML configured for the same domain
|
||||
func LoadAllEnabledSAMLConfigurationsByEmailDomain(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
emailDomain string,
|
||||
) ([]*SAMLConfiguration, error) {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
organization_id,
|
||||
email_domain,
|
||||
enabled,
|
||||
enforcement_policy,
|
||||
idp_entity_id,
|
||||
idp_sso_url,
|
||||
idp_certificate,
|
||||
idp_metadata_url,
|
||||
attribute_email,
|
||||
attribute_firstname,
|
||||
attribute_lastname,
|
||||
attribute_role,
|
||||
default_role,
|
||||
auto_signup_enabled,
|
||||
domain_verified,
|
||||
domain_verification_token,
|
||||
domain_verified_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
auth_saml_configurations
|
||||
WHERE
|
||||
email_domain = $1
|
||||
AND enabled = true
|
||||
AND domain_verified = true
|
||||
ORDER BY created_at ASC;
|
||||
`
|
||||
|
||||
rows, err := conn.Query(ctx, q, emailDomain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot query auth_saml_configurations: %w", err)
|
||||
}
|
||||
|
||||
configs, err := pgx.CollectRows(rows, pgx.RowToStructByName[SAMLConfiguration])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot collect saml_configurations: %w", err)
|
||||
}
|
||||
|
||||
result := make([]*SAMLConfiguration, len(configs))
|
||||
for i := range configs {
|
||||
result[i] = &configs[i]
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
60
pkg/coredata/saml_enforcement_policy.go
Normal file
60
pkg/coredata/saml_enforcement_policy.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type SAMLEnforcementPolicy string
|
||||
|
||||
const (
|
||||
SAMLEnforcementPolicyOff SAMLEnforcementPolicy = "OFF"
|
||||
SAMLEnforcementPolicyOptional SAMLEnforcementPolicy = "OPTIONAL"
|
||||
SAMLEnforcementPolicyRequired SAMLEnforcementPolicy = "REQUIRED"
|
||||
)
|
||||
|
||||
func (sep SAMLEnforcementPolicy) String() string {
|
||||
return string(sep)
|
||||
}
|
||||
|
||||
func (sep *SAMLEnforcementPolicy) Scan(value any) error {
|
||||
var s string
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
s = v
|
||||
case []byte:
|
||||
s = string(v)
|
||||
default:
|
||||
return fmt.Errorf("unsupported type for SAMLEnforcementPolicy: %T", value)
|
||||
}
|
||||
|
||||
switch s {
|
||||
case "OFF":
|
||||
*sep = SAMLEnforcementPolicyOff
|
||||
case "OPTIONAL":
|
||||
*sep = SAMLEnforcementPolicyOptional
|
||||
case "REQUIRED":
|
||||
*sep = SAMLEnforcementPolicyRequired
|
||||
default:
|
||||
return fmt.Errorf("invalid SAMLEnforcementPolicy value: %q", s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sep SAMLEnforcementPolicy) Value() (driver.Value, error) {
|
||||
return sep.String(), nil
|
||||
}
|
||||
156
pkg/coredata/saml_relay_state.go
Normal file
156
pkg/coredata/saml_relay_state.go
Normal file
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type SAMLRelayState struct {
|
||||
Token string `db:"token"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
SAMLConfigID gid.GID `db:"saml_config_id"`
|
||||
RequestID string `db:"request_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
}
|
||||
|
||||
type ErrRelayStateNotFound struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
func (e ErrRelayStateNotFound) Error() string {
|
||||
return "relay state token not found or invalid"
|
||||
}
|
||||
|
||||
type ErrRelayStateExpired struct {
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func (e ErrRelayStateExpired) Error() string {
|
||||
return fmt.Sprintf("relay state token expired at %v", e.ExpiresAt)
|
||||
}
|
||||
|
||||
func GenerateSecureToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot generate random token: %w", err)
|
||||
}
|
||||
|
||||
token := base64.URLEncoding.EncodeToString(b)
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *SAMLRelayState) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
query := `
|
||||
INSERT INTO auth_saml_relay_states (token, tenant_id, organization_id, saml_config_id, request_id, created_at, expires_at)
|
||||
VALUES (@token, @tenant_id, @organization_id, @saml_config_id, @request_id, @created_at, @expires_at)
|
||||
`
|
||||
|
||||
args := pgx.NamedArgs{
|
||||
"token": s.Token,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"organization_id": s.OrganizationID,
|
||||
"saml_config_id": s.SAMLConfigID,
|
||||
"request_id": s.RequestID,
|
||||
"created_at": s.CreatedAt,
|
||||
"expires_at": s.ExpiresAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert saml_relay_state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SAMLRelayState) Load(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
token string,
|
||||
) error {
|
||||
query := `
|
||||
SELECT token, organization_id, saml_config_id, request_id, created_at, expires_at
|
||||
FROM auth_saml_relay_states
|
||||
WHERE token = @token
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
rows, err := conn.Query(ctx, query, pgx.NamedArgs{"token": token})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query saml_relay_states: %w", err)
|
||||
}
|
||||
|
||||
state, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[SAMLRelayState])
|
||||
if err == pgx.ErrNoRows {
|
||||
return ErrRelayStateNotFound{Token: token}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect saml_relay_state: %w", err)
|
||||
}
|
||||
|
||||
*s = state
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SAMLRelayState) IsExpired(now time.Time) bool {
|
||||
return now.After(s.ExpiresAt) || now.Equal(s.ExpiresAt)
|
||||
}
|
||||
|
||||
func (s *SAMLRelayState) Delete(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
) error {
|
||||
query := `
|
||||
DELETE FROM auth_saml_relay_states
|
||||
WHERE token = @token
|
||||
`
|
||||
|
||||
_, err := conn.Exec(ctx, query, pgx.NamedArgs{"token": s.Token})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete saml_relay_state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteExpiredSAMLRelayStates(ctx context.Context, conn pg.Conn, now time.Time) (int64, error) {
|
||||
query := `
|
||||
DELETE FROM auth_saml_relay_states
|
||||
WHERE expires_at < @now
|
||||
`
|
||||
|
||||
result, err := conn.Exec(ctx, query, pgx.NamedArgs{"now": now})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot delete expired saml_relay_states: %w", err)
|
||||
}
|
||||
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
145
pkg/coredata/saml_request.go
Normal file
145
pkg/coredata/saml_request.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type SAMLRequest struct {
|
||||
ID string `db:"id"`
|
||||
OrganizationID gid.GID `db:"organization_id"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
ExpiresAt time.Time `db:"expires_at"`
|
||||
}
|
||||
|
||||
type ErrSAMLRequestNotFound struct {
|
||||
RequestID string
|
||||
}
|
||||
|
||||
func (e ErrSAMLRequestNotFound) Error() string {
|
||||
return fmt.Sprintf("SAML request ID %q not found", e.RequestID)
|
||||
}
|
||||
|
||||
type ErrSAMLRequestExpired struct {
|
||||
RequestID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func (e ErrSAMLRequestExpired) Error() string {
|
||||
return fmt.Sprintf("SAML request ID %q expired at %v", e.RequestID, e.ExpiresAt)
|
||||
}
|
||||
|
||||
func (s *SAMLRequest) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
query := `
|
||||
INSERT INTO auth_saml_requests (id, organization_id, tenant_id, created_at, expires_at)
|
||||
VALUES (@id, @organization_id, @tenant_id, @created_at, @expires_at)
|
||||
`
|
||||
|
||||
args := pgx.NamedArgs{
|
||||
"id": s.ID,
|
||||
"organization_id": s.OrganizationID,
|
||||
"tenant_id": scope.GetTenantID(),
|
||||
"created_at": s.CreatedAt,
|
||||
"expires_at": s.ExpiresAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot insert saml_request: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SAMLRequest) Load(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
requestID string,
|
||||
organizationID gid.GID,
|
||||
) error {
|
||||
query := `
|
||||
SELECT id, organization_id, created_at, expires_at
|
||||
FROM auth_saml_requests
|
||||
WHERE id = @id AND organization_id = @organization_id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
args := pgx.NamedArgs{
|
||||
"id": requestID,
|
||||
"organization_id": organizationID,
|
||||
}
|
||||
|
||||
rows, err := conn.Query(ctx, query, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query saml_requests: %w", err)
|
||||
}
|
||||
|
||||
req, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[SAMLRequest])
|
||||
if err == pgx.ErrNoRows {
|
||||
return ErrSAMLRequestNotFound{RequestID: requestID}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot collect saml_request: %w", err)
|
||||
}
|
||||
|
||||
*s = req
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SAMLRequest) IsExpired(now time.Time) bool {
|
||||
return now.After(s.ExpiresAt) || now.Equal(s.ExpiresAt)
|
||||
}
|
||||
|
||||
func (s *SAMLRequest) Delete(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
) error {
|
||||
query := `
|
||||
DELETE FROM auth_saml_requests
|
||||
WHERE id = @id
|
||||
`
|
||||
|
||||
_, err := conn.Exec(ctx, query, pgx.NamedArgs{"id": s.ID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot delete saml_request: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteExpiredSAMLRequests(ctx context.Context, conn pg.Conn, now time.Time) (int64, error) {
|
||||
query := `
|
||||
DELETE FROM auth_saml_requests
|
||||
WHERE expires_at < @now
|
||||
`
|
||||
|
||||
result, err := conn.Exec(ctx, query, pgx.NamedArgs{"now": now})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot delete expired saml_requests: %w", err)
|
||||
}
|
||||
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
@@ -35,7 +35,30 @@ type (
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
SessionData struct{}
|
||||
// SessionData stores authentication context for a user session
|
||||
// Stored as JSONB in database
|
||||
SessionData struct {
|
||||
// PasswordAuthenticated indicates if user authenticated with email/password
|
||||
// Required for accessing organizations without SAML
|
||||
PasswordAuthenticated bool `json:"password_authenticated"`
|
||||
|
||||
// SAMLAuthenticatedOrgs tracks which organizations user has SAML-authenticated for
|
||||
// Key: organization ID as string, Value: SAML authentication info
|
||||
// Required for accessing organizations with SAML enforcement
|
||||
SAMLAuthenticatedOrgs map[string]SAMLAuthInfo `json:"saml_authenticated_orgs,omitempty"`
|
||||
}
|
||||
|
||||
// SAMLAuthInfo stores SAML authentication details for an organization
|
||||
SAMLAuthInfo struct {
|
||||
// AuthenticatedAt is when the user SAML-
|
||||
AuthenticatedAt time.Time `json:"authenticated_at"`
|
||||
|
||||
// SAMLConfigID is the SAML configuration used for authentication
|
||||
SAMLConfigID gid.GID `json:"saml_config_id"`
|
||||
|
||||
// SAMLSubject is the NameID from the SAML assertion (email address)
|
||||
SAMLSubject string `json:"saml_subject"`
|
||||
}
|
||||
)
|
||||
|
||||
func (s Session) CursorKey(orderBy SessionOrderField) page.CursorKey {
|
||||
@@ -47,7 +70,6 @@ func (s Session) CursorKey(orderBy SessionOrderField) page.CursorKey {
|
||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||
}
|
||||
|
||||
// Tenant id scope is not applied because we want to access sessions across all tenants for authentication purposes.
|
||||
func (s *Session) LoadByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -36,6 +36,7 @@ type (
|
||||
HashedPassword []byte `db:"hashed_password"`
|
||||
FullName string `db:"fullname"`
|
||||
EmailAddressVerified bool `db:"email_address_verified"`
|
||||
SAMLSubject *string `db:"saml_subject"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
@@ -158,6 +159,7 @@ SELECT
|
||||
hashed_password,
|
||||
email_address_verified,
|
||||
fullname,
|
||||
saml_subject,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -201,6 +203,7 @@ SELECT
|
||||
hashed_password,
|
||||
email_address_verified,
|
||||
fullname,
|
||||
saml_subject,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
@@ -234,16 +237,18 @@ LIMIT 1;
|
||||
func (u *User) Insert(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
) error {
|
||||
q := `
|
||||
INSERT INTO
|
||||
users (id, email_address, hashed_password, email_address_verified, fullname, created_at, updated_at)
|
||||
users (id, email_address, hashed_password, email_address_verified, fullname, saml_subject, created_at, updated_at)
|
||||
VALUES (
|
||||
@user_id,
|
||||
@email_address,
|
||||
@hashed_password,
|
||||
@email_address_verified,
|
||||
@fullname,
|
||||
@saml_subject,
|
||||
@created_at,
|
||||
@updated_at
|
||||
)
|
||||
@@ -254,6 +259,7 @@ VALUES (
|
||||
"email_address": u.EmailAddress,
|
||||
"hashed_password": u.HashedPassword,
|
||||
"fullname": u.FullName,
|
||||
"saml_subject": u.SAMLSubject,
|
||||
"created_at": u.CreatedAt,
|
||||
"updated_at": u.UpdatedAt,
|
||||
"email_address_verified": u.EmailAddressVerified,
|
||||
@@ -341,3 +347,107 @@ WHERE
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *User) Update(ctx context.Context, conn pg.Conn) error {
|
||||
q := `
|
||||
UPDATE
|
||||
users
|
||||
SET
|
||||
email_address = @email_address,
|
||||
email_address_verified = @email_address_verified,
|
||||
saml_subject = @saml_subject,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
id = @user_id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{
|
||||
"user_id": u.ID,
|
||||
"email_address": u.EmailAddress,
|
||||
"email_address_verified": u.EmailAddressVerified,
|
||||
"saml_subject": u.SAMLSubject,
|
||||
"updated_at": u.UpdatedAt,
|
||||
}
|
||||
|
||||
_, err := conn.Exec(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update user: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadBySAMLSubject loads a user by their SAML subject (NameID)
|
||||
func (u *User) LoadBySAMLSubject(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
samlSubject string,
|
||||
) error {
|
||||
q := `
|
||||
SELECT
|
||||
id,
|
||||
email_address,
|
||||
hashed_password,
|
||||
email_address_verified,
|
||||
fullname,
|
||||
saml_subject,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
users
|
||||
WHERE
|
||||
saml_subject = @saml_subject
|
||||
LIMIT 1;
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{"saml_subject": samlSubject}
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot query user by SAML subject: %w", err)
|
||||
}
|
||||
|
||||
user, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[User])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return &ErrUserNotFound{Identifier: samlSubject}
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot collect user: %w", err)
|
||||
}
|
||||
|
||||
*u = user
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadByEmailAndTenant, LoadByEmailGlobal, and IsTenantUser methods removed
|
||||
// All users are now global (no tenant_id distinction)
|
||||
// Use LoadByEmail() for all email-based lookups
|
||||
|
||||
func (u *User) CountMemberships(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
) (int, error) {
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(*)
|
||||
FROM
|
||||
authz_memberships
|
||||
WHERE
|
||||
user_id = @user_id
|
||||
`
|
||||
|
||||
args := pgx.StrictNamedArgs{"user_id": u.ID}
|
||||
|
||||
var count int
|
||||
err := conn.QueryRow(ctx, q, args).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cannot count user memberships: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ConvertToTenantUser method removed
|
||||
// All users are now global (no tenant conversion needed)
|
||||
|
||||
22
pkg/coredata/user_auth_method.go
Normal file
22
pkg/coredata/user_auth_method.go
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package coredata
|
||||
|
||||
type UserAuthMethod string
|
||||
|
||||
const (
|
||||
UserAuthMethodPassword UserAuthMethod = "PASSWORD"
|
||||
UserAuthMethodSAML UserAuthMethod = "SAML"
|
||||
)
|
||||
@@ -25,6 +25,7 @@ type (
|
||||
Password passwordConfig `json:"password"`
|
||||
DisableSignup bool `json:"disable-signup"`
|
||||
InvitationConfirmationTokenValidity int `json:"invitation-confirmation-token-validity"`
|
||||
SAML samlConfig `json:"saml"`
|
||||
}
|
||||
|
||||
trustAuthConfig struct {
|
||||
|
||||
@@ -118,6 +118,10 @@ func New() *Implm {
|
||||
},
|
||||
DisableSignup: false,
|
||||
InvitationConfirmationTokenValidity: 3600,
|
||||
SAML: samlConfig{
|
||||
SessionDuration: 604800,
|
||||
CleanupIntervalSeconds: 86400,
|
||||
},
|
||||
},
|
||||
TrustAuth: trustAuthConfig{
|
||||
CookieName: "TCT",
|
||||
@@ -268,9 +272,11 @@ func (impl *Implm) Run(
|
||||
authService, err := auth.NewService(
|
||||
ctx,
|
||||
pgClient,
|
||||
impl.cfg.EncryptionKey,
|
||||
hp,
|
||||
impl.cfg.Auth.Cookie.Secret,
|
||||
impl.cfg.Hostname,
|
||||
fmt.Sprintf("https://%s", impl.cfg.Hostname),
|
||||
impl.cfg.Auth.DisableSignup,
|
||||
time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity)*time.Second,
|
||||
)
|
||||
@@ -291,6 +297,21 @@ func (impl *Implm) Run(
|
||||
|
||||
fileManagerService := filemanager.NewService(s3Client)
|
||||
|
||||
samlService, err := auth.NewSAMLService(
|
||||
pgClient,
|
||||
impl.cfg.EncryptionKey,
|
||||
fmt.Sprintf("https://%s", impl.cfg.Hostname),
|
||||
impl.cfg.Auth.SAML.SessionDurationTime(),
|
||||
impl.cfg.Auth.Cookie.Name,
|
||||
impl.cfg.Auth.Cookie.Secret,
|
||||
impl.cfg.Auth.SAML.Certificate,
|
||||
impl.cfg.Auth.SAML.PrivateKey,
|
||||
l.Named("saml"),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create SAML service: %w", err)
|
||||
}
|
||||
|
||||
var accountKey crypto.Signer
|
||||
if impl.cfg.CustomDomains.ACME.AccountKey != "" {
|
||||
accountKey, err = pem.DecodePrivateKey([]byte(impl.cfg.CustomDomains.ACME.AccountKey))
|
||||
@@ -368,10 +389,13 @@ func (impl *Implm) Run(
|
||||
Auth: authService,
|
||||
Authz: authzService,
|
||||
Trust: trustService,
|
||||
SAML: samlService,
|
||||
ConnectorRegistry: defaultConnectorRegistry,
|
||||
Agent: agent,
|
||||
SafeRedirect: &saferedirect.SafeRedirect{AllowedHost: impl.cfg.Hostname},
|
||||
CustomDomainCname: impl.cfg.CustomDomains.CnameTarget,
|
||||
FileManager: fileManagerService,
|
||||
PGClient: pgClient,
|
||||
Logger: l.Named("http.server"),
|
||||
ConsoleAuth: api.ConsoleAuthConfig{
|
||||
CookieName: impl.cfg.Auth.Cookie.Name,
|
||||
@@ -445,6 +469,20 @@ func (impl *Implm) Run(
|
||||
},
|
||||
)
|
||||
|
||||
samlCleanerCtx, stopSAMLCleaner := context.WithCancel(context.Background())
|
||||
samlCleaner := auth.NewCleaner(
|
||||
pgClient,
|
||||
impl.cfg.Auth.SAML.CleanupInterval(),
|
||||
l.Named("saml-cleaner"),
|
||||
)
|
||||
wg.Go(
|
||||
func() {
|
||||
if err := samlCleaner.Run(samlCleanerCtx); err != nil {
|
||||
cancel(fmt.Errorf("saml cleaner crashed: %w", err))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
trustCenterServerCtx, stopTrustCenterServer := context.WithCancel(context.Background())
|
||||
defer stopTrustCenterServer()
|
||||
wg.Go(
|
||||
@@ -460,6 +498,7 @@ func (impl *Implm) Run(
|
||||
stopMailer()
|
||||
stopSlackSender()
|
||||
stopExportJobExporter()
|
||||
stopSAMLCleaner()
|
||||
stopApiServer()
|
||||
stopTrustCenterServer()
|
||||
|
||||
|
||||
40
pkg/probod/saml_config.go
Normal file
40
pkg/probod/saml_config.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package probod
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type samlConfig struct {
|
||||
SessionDuration int `json:"session-duration"`
|
||||
CleanupIntervalSeconds int `json:"cleanup-interval-seconds"`
|
||||
Certificate string `json:"certificate"`
|
||||
PrivateKey string `json:"private-key"`
|
||||
}
|
||||
|
||||
func (c samlConfig) SessionDurationTime() time.Duration {
|
||||
if c.SessionDuration == 0 {
|
||||
return 7 * 24 * time.Hour
|
||||
}
|
||||
return time.Duration(c.SessionDuration) * time.Second
|
||||
}
|
||||
|
||||
func (c samlConfig) CleanupInterval() time.Duration {
|
||||
if c.CleanupIntervalSeconds == 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(c.CleanupIntervalSeconds) * time.Second
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func DefaultConfig(name, secret string) Config {
|
||||
MaxAge: 86400 * 30, // 30 days
|
||||
Secure: true,
|
||||
HTTPOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
SameSite: http.SameSiteNoneMode, // None mode required for SAML (cross-site POST from IdP)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ type (
|
||||
Auth *auth.Service
|
||||
Authz *authz.Service
|
||||
Trust *trust.Service
|
||||
SAML *auth.SAMLService
|
||||
ConsoleAuth ConsoleAuthConfig
|
||||
TrustAuth TrustAuthConfig
|
||||
ConnectorRegistry *connector.ConnectorRegistry
|
||||
@@ -194,6 +195,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.cfg.ConnectorRegistry,
|
||||
s.cfg.SafeRedirect,
|
||||
s.cfg.CustomDomainCname,
|
||||
s.cfg.SAML,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -60,11 +60,17 @@ type (
|
||||
proboSvc *probo.Service
|
||||
authSvc *auth.Service
|
||||
authzSvc *authz.Service
|
||||
samlSvc *auth.SAMLService
|
||||
authCfg AuthConfig
|
||||
customDomainCname string
|
||||
}
|
||||
|
||||
ctxKey struct{ name string }
|
||||
|
||||
userTenantAccess struct {
|
||||
tenantIDs []gid.TenantID
|
||||
authErrors map[gid.TenantID]error
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -92,6 +98,7 @@ func NewMux(
|
||||
connectorRegistry *connector.ConnectorRegistry,
|
||||
safeRedirect *saferedirect.SafeRedirect,
|
||||
customDomainCname string,
|
||||
samlSvc *auth.SAMLService,
|
||||
) *chi.Mux {
|
||||
r := chi.NewMux()
|
||||
|
||||
@@ -211,13 +218,6 @@ func NewMux(
|
||||
},
|
||||
)
|
||||
|
||||
r.Post("/auth/register", SignUpHandler(authSvc, authCfg))
|
||||
r.Post("/auth/login", SignInHandler(authSvc, authCfg))
|
||||
r.Delete("/auth/logout", SignOutHandler(authSvc, authCfg))
|
||||
r.Post("/auth/signup-from-invitation", SignupFromInvitationHandler(authSvc, authCfg))
|
||||
r.Post("/auth/forget-password", ForgetPasswordHandler(authSvc, authCfg))
|
||||
r.Post("/auth/reset-password", ResetPasswordHandler(authSvc, authCfg))
|
||||
|
||||
r.Get("/connectors/initiate", WithSession(authSvc, authzSvc, authCfg, func(w http.ResponseWriter, r *http.Request) {
|
||||
provider := r.URL.Query().Get("provider")
|
||||
if provider != "SLACK" {
|
||||
@@ -295,12 +295,12 @@ func NewMux(
|
||||
})
|
||||
|
||||
r.Get("/", playground.Handler("GraphQL", "/api/console/v1/query"))
|
||||
r.Post("/query", graphqlHandler(logger, proboSvc, authSvc, authzSvc, authCfg, customDomainCname))
|
||||
r.Post("/query", graphqlHandler(logger, proboSvc, authSvc, authzSvc, samlSvc, authCfg, customDomainCname))
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func graphqlHandler(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthConfig, customDomainCname string) http.HandlerFunc {
|
||||
func graphqlHandler(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.Service, authzSvc *authz.Service, samlSvc *auth.SAMLService, authCfg AuthConfig, customDomainCname string) http.HandlerFunc {
|
||||
var mb int64 = 1 << 20
|
||||
|
||||
es := schema.NewExecutableSchema(
|
||||
@@ -309,6 +309,7 @@ func graphqlHandler(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.S
|
||||
proboSvc: proboSvc,
|
||||
authSvc: authSvc,
|
||||
authzSvc: authzSvc,
|
||||
samlSvc: samlSvc,
|
||||
authCfg: authCfg,
|
||||
customDomainCname: customDomainCname,
|
||||
},
|
||||
@@ -387,7 +388,10 @@ func WithSession(authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthCon
|
||||
|
||||
ctx = context.WithValue(ctx, sessionContextKey, authResult.Session)
|
||||
ctx = context.WithValue(ctx, userContextKey, authResult.User)
|
||||
ctx = context.WithValue(ctx, userTenantContextKey, &authResult.TenantIDs)
|
||||
ctx = context.WithValue(ctx, userTenantContextKey, &userTenantAccess{
|
||||
tenantIDs: authResult.TenantIDs,
|
||||
authErrors: authResult.AuthErrors,
|
||||
})
|
||||
|
||||
next(w, r.WithContext(ctx))
|
||||
|
||||
@@ -425,13 +429,19 @@ func GetTenantAuthzService(ctx context.Context, authzSvc *authz.Service, tenantI
|
||||
}
|
||||
|
||||
func validateTenantAccess(ctx context.Context, tenantID gid.TenantID) {
|
||||
tenantIDs, _ := ctx.Value(userTenantContextKey).(*[]gid.TenantID)
|
||||
access, _ := ctx.Value(userTenantContextKey).(*userTenantAccess)
|
||||
|
||||
if tenantIDs == nil {
|
||||
if access == nil {
|
||||
panic(fmt.Errorf("tenant not found"))
|
||||
}
|
||||
|
||||
if !slices.Contains(*tenantIDs, tenantID) {
|
||||
panic(fmt.Errorf("tenant not found"))
|
||||
if !slices.Contains(access.tenantIDs, tenantID) {
|
||||
if access.authErrors != nil {
|
||||
if authErr := access.authErrors[tenantID]; authErr != nil {
|
||||
panic(authErr)
|
||||
}
|
||||
}
|
||||
|
||||
panic(fmt.Errorf("access denied to tenant"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,34 @@ enum AuditState
|
||||
@goEnum(value: "github.com/getprobo/probo/pkg/coredata.AuditStateOutdated")
|
||||
}
|
||||
|
||||
enum SAMLEnforcementPolicy
|
||||
@goModel(
|
||||
model: "github.com/getprobo/probo/pkg/coredata.SAMLEnforcementPolicy"
|
||||
) {
|
||||
OFF
|
||||
@goEnum(
|
||||
value: "github.com/getprobo/probo/pkg/coredata.SAMLEnforcementPolicyOff"
|
||||
)
|
||||
OPTIONAL
|
||||
@goEnum(
|
||||
value: "github.com/getprobo/probo/pkg/coredata.SAMLEnforcementPolicyOptional"
|
||||
)
|
||||
REQUIRED
|
||||
@goEnum(
|
||||
value: "github.com/getprobo/probo/pkg/coredata.SAMLEnforcementPolicyRequired"
|
||||
)
|
||||
}
|
||||
|
||||
enum UserAuthMethod
|
||||
@goModel(model: "github.com/getprobo/probo/pkg/coredata.UserAuthMethod") {
|
||||
PASSWORD
|
||||
@goEnum(
|
||||
value: "github.com/getprobo/probo/pkg/coredata.UserAuthMethodPassword"
|
||||
)
|
||||
SAML
|
||||
@goEnum(value: "github.com/getprobo/probo/pkg/coredata.UserAuthMethodSAML")
|
||||
}
|
||||
|
||||
enum TrustCenterVisibility
|
||||
@goModel(
|
||||
model: "github.com/getprobo/probo/pkg/coredata.TrustCenterVisibility"
|
||||
@@ -1791,6 +1819,8 @@ type Organization implements Node {
|
||||
|
||||
customDomain: CustomDomain @goField(forceResolver: true)
|
||||
|
||||
samlConfigurations: [SAMLConfiguration!]! @goField(forceResolver: true)
|
||||
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
}
|
||||
@@ -1810,6 +1840,7 @@ type Membership implements Node {
|
||||
role: String!
|
||||
fullName: String!
|
||||
emailAddress: String!
|
||||
authMethod: UserAuthMethod! @goField(forceResolver: true)
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
}
|
||||
@@ -2693,7 +2724,6 @@ type VendorServiceEdge {
|
||||
node: VendorService!
|
||||
}
|
||||
|
||||
|
||||
type VendorRiskAssessmentConnection {
|
||||
edges: [VendorRiskAssessmentEdge!]!
|
||||
pageInfo: PageInfo!
|
||||
@@ -3174,6 +3204,28 @@ type Mutation {
|
||||
deleteCustomDomain(
|
||||
input: DeleteCustomDomainInput!
|
||||
): DeleteCustomDomainPayload!
|
||||
|
||||
# SAML Configuration mutations (OWNER/ADMIN only)
|
||||
# Step 1: Initiate domain verification (creates SAML config with unverified domain)
|
||||
initiateDomainVerification(
|
||||
input: InitiateDomainVerificationInput!
|
||||
): InitiateDomainVerificationPayload!
|
||||
|
||||
# Step 2: Verify domain ownership via DNS TXT record
|
||||
verifyDomain(input: VerifyDomainInput!): VerifyDomainPayload!
|
||||
|
||||
# Step 3: Configure SAML (only allowed after domain is verified)
|
||||
createSAMLConfiguration(
|
||||
input: CreateSAMLConfigurationInput!
|
||||
): CreateSAMLConfigurationPayload!
|
||||
updateSAMLConfiguration(
|
||||
input: UpdateSAMLConfigurationInput!
|
||||
): UpdateSAMLConfigurationPayload!
|
||||
deleteSAMLConfiguration(
|
||||
input: DeleteSAMLConfigurationInput!
|
||||
): DeleteSAMLConfigurationPayload!
|
||||
enableSAML(input: EnableSAMLInput!): EnableSAMLPayload!
|
||||
disableSAML(input: DisableSAMLInput!): DisableSAMLPayload!
|
||||
}
|
||||
|
||||
# Input Types
|
||||
@@ -4794,3 +4846,167 @@ type CreateCustomDomainPayload {
|
||||
type DeleteCustomDomainPayload {
|
||||
deletedCustomDomainId: ID!
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# SAML Configuration Types
|
||||
# ============================================
|
||||
|
||||
type SAMLConfiguration implements Node {
|
||||
id: ID!
|
||||
organization: Organization! @goField(forceResolver: true)
|
||||
emailDomain: String!
|
||||
enabled: Boolean!
|
||||
enforcementPolicy: SAMLEnforcementPolicy!
|
||||
|
||||
# Domain verification (required before SAML can be configured)
|
||||
domainVerified: Boolean!
|
||||
domainVerificationToken: String
|
||||
domainVerifiedAt: Datetime
|
||||
|
||||
# Service Provider metadata (read-only, auto-generated)
|
||||
spEntityId: String!
|
||||
spAcsUrl: String!
|
||||
spMetadataUrl: String! @goField(forceResolver: true)
|
||||
|
||||
# Identity Provider configuration
|
||||
idpEntityId: String!
|
||||
idpSsoUrl: String!
|
||||
idpCertificate: String!
|
||||
idpMetadataUrl: String
|
||||
|
||||
# Attribute mapping
|
||||
attributeEmail: String!
|
||||
attributeFirstname: String!
|
||||
attributeLastname: String!
|
||||
attributeRole: String!
|
||||
|
||||
# Default role for users when role attribute is missing or invalid
|
||||
defaultRole: String!
|
||||
|
||||
# Auto-signup
|
||||
autoSignupEnabled: Boolean!
|
||||
|
||||
# Test login URL for this configuration
|
||||
testLoginUrl: String! @goField(forceResolver: true)
|
||||
|
||||
createdAt: Datetime!
|
||||
updatedAt: Datetime!
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# SAML Configuration Inputs
|
||||
# ============================================
|
||||
|
||||
input CreateSAMLConfigurationInput {
|
||||
organizationId: ID!
|
||||
|
||||
# Email domain this config applies to
|
||||
emailDomain: String!
|
||||
|
||||
# Enforcement policy for this SAML configuration
|
||||
enforcementPolicy: SAMLEnforcementPolicy!
|
||||
|
||||
# SP configuration (optional - auto-generated if not provided)
|
||||
spCertificate: String
|
||||
spPrivateKey: String
|
||||
|
||||
# IdP configuration - Option 1: Provide metadata XML (recommended for Google Workspace)
|
||||
# This will automatically extract entityId, ssoUrl, and certificate from the metadata
|
||||
idpMetadataXml: String
|
||||
|
||||
# IdP configuration - Option 2: Provide individual fields manually
|
||||
# Required if idpMetadataXml is not provided
|
||||
idpEntityId: String
|
||||
idpSsoUrl: String
|
||||
idpCertificate: String
|
||||
idpMetadataUrl: String
|
||||
|
||||
# Attribute mapping (optional, defaults provided)
|
||||
attributeEmail: String
|
||||
attributeFirstname: String
|
||||
attributeLastname: String
|
||||
attributeRole: String
|
||||
|
||||
defaultRole: String
|
||||
autoSignupEnabled: Boolean
|
||||
}
|
||||
|
||||
input UpdateSAMLConfigurationInput {
|
||||
id: ID!
|
||||
|
||||
enabled: Boolean
|
||||
enforcementPolicy: SAMLEnforcementPolicy
|
||||
spCertificate: String
|
||||
spPrivateKey: String
|
||||
idpEntityId: String
|
||||
idpSsoUrl: String
|
||||
idpCertificate: String
|
||||
idpMetadataUrl: String
|
||||
attributeEmail: String
|
||||
attributeFirstname: String
|
||||
attributeLastname: String
|
||||
attributeRole: String
|
||||
defaultRole: String
|
||||
autoSignupEnabled: Boolean
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# Domain Verification Inputs
|
||||
# ============================================
|
||||
|
||||
input InitiateDomainVerificationInput {
|
||||
organizationId: ID!
|
||||
emailDomain: String!
|
||||
}
|
||||
|
||||
input VerifyDomainInput {
|
||||
id: ID!
|
||||
}
|
||||
|
||||
input DeleteSAMLConfigurationInput {
|
||||
id: ID!
|
||||
}
|
||||
|
||||
input EnableSAMLInput {
|
||||
id: ID!
|
||||
}
|
||||
|
||||
input DisableSAMLInput {
|
||||
id: ID!
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# SAML Configuration Payloads
|
||||
# ============================================
|
||||
|
||||
type InitiateDomainVerificationPayload {
|
||||
samlConfiguration: SAMLConfiguration!
|
||||
# The TXT record value that needs to be added to DNS
|
||||
# Format: probo-verification={token}
|
||||
dnsRecord: String!
|
||||
}
|
||||
|
||||
type VerifyDomainPayload {
|
||||
samlConfiguration: SAMLConfiguration!
|
||||
verified: Boolean!
|
||||
}
|
||||
|
||||
type CreateSAMLConfigurationPayload {
|
||||
samlConfiguration: SAMLConfiguration!
|
||||
}
|
||||
|
||||
type UpdateSAMLConfigurationPayload {
|
||||
samlConfiguration: SAMLConfiguration!
|
||||
}
|
||||
|
||||
type DeleteSAMLConfigurationPayload {
|
||||
deletedSAMLConfigurationId: ID!
|
||||
}
|
||||
|
||||
type EnableSAMLPayload {
|
||||
samlConfiguration: SAMLConfiguration!
|
||||
}
|
||||
|
||||
type DisableSAMLPayload {
|
||||
samlConfiguration: SAMLConfiguration!
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
45
pkg/server/api/console/v1/types/saml_configuration.go
Normal file
45
pkg/server/api/console/v1/types/saml_configuration.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
)
|
||||
|
||||
func NewSAMLConfigurationWithURLs(c *coredata.SAMLConfiguration, spEntityID, spAcsURL string) *SAMLConfiguration {
|
||||
return &SAMLConfiguration{
|
||||
ID: c.ID,
|
||||
EmailDomain: c.EmailDomain,
|
||||
Enabled: c.Enabled,
|
||||
EnforcementPolicy: c.EnforcementPolicy,
|
||||
DomainVerified: c.DomainVerified,
|
||||
DomainVerificationToken: c.DomainVerificationToken,
|
||||
DomainVerifiedAt: c.DomainVerifiedAt,
|
||||
SpEntityID: spEntityID,
|
||||
SpAcsURL: spAcsURL,
|
||||
IdpEntityID: c.IdPEntityID,
|
||||
IdpSsoURL: c.IdPSsoURL,
|
||||
IdpCertificate: c.IdPCertificate,
|
||||
IdpMetadataURL: c.IdPMetadataURL,
|
||||
AttributeEmail: c.AttributeEmail,
|
||||
AttributeFirstname: c.AttributeFirstname,
|
||||
AttributeLastname: c.AttributeLastname,
|
||||
AttributeRole: c.AttributeRole,
|
||||
DefaultRole: c.DefaultRole,
|
||||
AutoSignupEnabled: c.AutoSignupEnabled,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -505,6 +505,29 @@ type CreateRiskPayload struct {
|
||||
RiskEdge *RiskEdge `json:"riskEdge"`
|
||||
}
|
||||
|
||||
type CreateSAMLConfigurationInput struct {
|
||||
OrganizationID gid.GID `json:"organizationId"`
|
||||
EmailDomain string `json:"emailDomain"`
|
||||
EnforcementPolicy coredata.SAMLEnforcementPolicy `json:"enforcementPolicy"`
|
||||
SpCertificate *string `json:"spCertificate,omitempty"`
|
||||
SpPrivateKey *string `json:"spPrivateKey,omitempty"`
|
||||
IdpMetadataXML *string `json:"idpMetadataXml,omitempty"`
|
||||
IdpEntityID *string `json:"idpEntityId,omitempty"`
|
||||
IdpSsoURL *string `json:"idpSsoUrl,omitempty"`
|
||||
IdpCertificate *string `json:"idpCertificate,omitempty"`
|
||||
IdpMetadataURL *string `json:"idpMetadataUrl,omitempty"`
|
||||
AttributeEmail *string `json:"attributeEmail,omitempty"`
|
||||
AttributeFirstname *string `json:"attributeFirstname,omitempty"`
|
||||
AttributeLastname *string `json:"attributeLastname,omitempty"`
|
||||
AttributeRole *string `json:"attributeRole,omitempty"`
|
||||
DefaultRole *string `json:"defaultRole,omitempty"`
|
||||
AutoSignupEnabled *bool `json:"autoSignupEnabled,omitempty"`
|
||||
}
|
||||
|
||||
type CreateSAMLConfigurationPayload struct {
|
||||
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
|
||||
}
|
||||
|
||||
type CreateSnapshotInput struct {
|
||||
OrganizationID gid.GID `json:"organizationId"`
|
||||
Name string `json:"name"`
|
||||
@@ -904,6 +927,14 @@ type DeleteRiskPayload struct {
|
||||
DeletedRiskID gid.GID `json:"deletedRiskId"`
|
||||
}
|
||||
|
||||
type DeleteSAMLConfigurationInput struct {
|
||||
ID gid.GID `json:"id"`
|
||||
}
|
||||
|
||||
type DeleteSAMLConfigurationPayload struct {
|
||||
DeletedSAMLConfigurationID gid.GID `json:"deletedSAMLConfigurationId"`
|
||||
}
|
||||
|
||||
type DeleteSnapshotInput struct {
|
||||
SnapshotID gid.GID `json:"snapshotId"`
|
||||
}
|
||||
@@ -1000,6 +1031,14 @@ type DeleteVendorServicePayload struct {
|
||||
DeletedVendorServiceID gid.GID `json:"deletedVendorServiceId"`
|
||||
}
|
||||
|
||||
type DisableSAMLInput struct {
|
||||
ID gid.GID `json:"id"`
|
||||
}
|
||||
|
||||
type DisableSAMLPayload struct {
|
||||
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
|
||||
}
|
||||
|
||||
type Document struct {
|
||||
ID gid.GID `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -1094,6 +1133,14 @@ type DocumentVersionSignatureOrder struct {
|
||||
Direction page.OrderDirection `json:"direction"`
|
||||
}
|
||||
|
||||
type EnableSAMLInput struct {
|
||||
ID gid.GID `json:"id"`
|
||||
}
|
||||
|
||||
type EnableSAMLPayload struct {
|
||||
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
|
||||
}
|
||||
|
||||
type Evidence struct {
|
||||
ID gid.GID `json:"id"`
|
||||
Size int `json:"size"`
|
||||
@@ -1216,6 +1263,16 @@ type ImportMeasurePayload struct {
|
||||
MeasureEdges []*MeasureEdge `json:"measureEdges"`
|
||||
}
|
||||
|
||||
type InitiateDomainVerificationInput struct {
|
||||
OrganizationID gid.GID `json:"organizationId"`
|
||||
EmailDomain string `json:"emailDomain"`
|
||||
}
|
||||
|
||||
type InitiateDomainVerificationPayload struct {
|
||||
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
|
||||
DNSRecord string `json:"dnsRecord"`
|
||||
}
|
||||
|
||||
type Invitation struct {
|
||||
ID gid.GID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
@@ -1284,14 +1341,15 @@ type MeasureFilter struct {
|
||||
}
|
||||
|
||||
type Membership struct {
|
||||
ID gid.GID `json:"id"`
|
||||
UserID gid.GID `json:"userID"`
|
||||
OrganizationID gid.GID `json:"organizationID"`
|
||||
Role string `json:"role"`
|
||||
FullName string `json:"fullName"`
|
||||
EmailAddress string `json:"emailAddress"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID gid.GID `json:"id"`
|
||||
UserID gid.GID `json:"userID"`
|
||||
OrganizationID gid.GID `json:"organizationID"`
|
||||
Role string `json:"role"`
|
||||
FullName string `json:"fullName"`
|
||||
EmailAddress string `json:"emailAddress"`
|
||||
AuthMethod coredata.UserAuthMethod `json:"authMethod"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (Membership) IsNode() {}
|
||||
@@ -1396,6 +1454,7 @@ type Organization struct {
|
||||
TrustCenterFiles *TrustCenterFileConnection `json:"trustCenterFiles"`
|
||||
TrustCenter *TrustCenter `json:"trustCenter,omitempty"`
|
||||
CustomDomain *CustomDomain `json:"customDomain,omitempty"`
|
||||
SamlConfigurations []*SAMLConfiguration `json:"samlConfigurations"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
@@ -1580,6 +1639,36 @@ type RiskFilter struct {
|
||||
SnapshotID *gid.GID `json:"snapshotId,omitempty"`
|
||||
}
|
||||
|
||||
type SAMLConfiguration struct {
|
||||
ID gid.GID `json:"id"`
|
||||
Organization *Organization `json:"organization"`
|
||||
EmailDomain string `json:"emailDomain"`
|
||||
Enabled bool `json:"enabled"`
|
||||
EnforcementPolicy coredata.SAMLEnforcementPolicy `json:"enforcementPolicy"`
|
||||
DomainVerified bool `json:"domainVerified"`
|
||||
DomainVerificationToken *string `json:"domainVerificationToken,omitempty"`
|
||||
DomainVerifiedAt *time.Time `json:"domainVerifiedAt,omitempty"`
|
||||
SpEntityID string `json:"spEntityId"`
|
||||
SpAcsURL string `json:"spAcsUrl"`
|
||||
SpMetadataURL string `json:"spMetadataUrl"`
|
||||
IdpEntityID string `json:"idpEntityId"`
|
||||
IdpSsoURL string `json:"idpSsoUrl"`
|
||||
IdpCertificate string `json:"idpCertificate"`
|
||||
IdpMetadataURL *string `json:"idpMetadataUrl,omitempty"`
|
||||
AttributeEmail string `json:"attributeEmail"`
|
||||
AttributeFirstname string `json:"attributeFirstname"`
|
||||
AttributeLastname string `json:"attributeLastname"`
|
||||
AttributeRole string `json:"attributeRole"`
|
||||
DefaultRole string `json:"defaultRole"`
|
||||
AutoSignupEnabled bool `json:"autoSignupEnabled"`
|
||||
TestLoginURL string `json:"testLoginUrl"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (SAMLConfiguration) IsNode() {}
|
||||
func (this SAMLConfiguration) GetID() gid.GID { return this.ID }
|
||||
|
||||
type SendSigningNotificationsInput struct {
|
||||
OrganizationID gid.GID `json:"organizationId"`
|
||||
}
|
||||
@@ -1973,6 +2062,28 @@ type UpdateRiskPayload struct {
|
||||
Risk *Risk `json:"risk"`
|
||||
}
|
||||
|
||||
type UpdateSAMLConfigurationInput struct {
|
||||
ID gid.GID `json:"id"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
EnforcementPolicy *coredata.SAMLEnforcementPolicy `json:"enforcementPolicy,omitempty"`
|
||||
SpCertificate *string `json:"spCertificate,omitempty"`
|
||||
SpPrivateKey *string `json:"spPrivateKey,omitempty"`
|
||||
IdpEntityID *string `json:"idpEntityId,omitempty"`
|
||||
IdpSsoURL *string `json:"idpSsoUrl,omitempty"`
|
||||
IdpCertificate *string `json:"idpCertificate,omitempty"`
|
||||
IdpMetadataURL *string `json:"idpMetadataUrl,omitempty"`
|
||||
AttributeEmail *string `json:"attributeEmail,omitempty"`
|
||||
AttributeFirstname *string `json:"attributeFirstname,omitempty"`
|
||||
AttributeLastname *string `json:"attributeLastname,omitempty"`
|
||||
AttributeRole *string `json:"attributeRole,omitempty"`
|
||||
DefaultRole *string `json:"defaultRole,omitempty"`
|
||||
AutoSignupEnabled *bool `json:"autoSignupEnabled,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateSAMLConfigurationPayload struct {
|
||||
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
|
||||
}
|
||||
|
||||
type UpdateTaskInput struct {
|
||||
TaskID gid.GID `json:"taskId"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
@@ -2359,6 +2470,15 @@ type VendorServiceEdge struct {
|
||||
Node *VendorService `json:"node"`
|
||||
}
|
||||
|
||||
type VerifyDomainInput struct {
|
||||
ID gid.GID `json:"id"`
|
||||
}
|
||||
|
||||
type VerifyDomainPayload struct {
|
||||
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
type Viewer struct {
|
||||
ID gid.GID `json:"id"`
|
||||
User *User `json:"user"`
|
||||
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
@@ -1081,6 +1083,20 @@ func (r *measureConnectionResolver) TotalCount(ctx context.Context, obj *types.M
|
||||
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
|
||||
}
|
||||
|
||||
// AuthMethod is the resolver for the authMethod field.
|
||||
func (r *membershipResolver) AuthMethod(ctx context.Context, obj *types.Membership) (coredata.UserAuthMethod, error) {
|
||||
session := SessionFromContext(ctx)
|
||||
if session == nil {
|
||||
return coredata.UserAuthMethodPassword, nil
|
||||
}
|
||||
|
||||
authMethod, err := r.authSvc.GetUserAuthMethod(ctx, coredata.NewScope(obj.UserID.TenantID()), obj.UserID, obj.OrganizationID, session)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot get user auth method: %w", err)
|
||||
}
|
||||
return authMethod, nil
|
||||
}
|
||||
|
||||
// TotalCount is the resolver for the totalCount field.
|
||||
func (r *membershipConnectionResolver) TotalCount(ctx context.Context, obj *types.MembershipConnection) (int, error) {
|
||||
switch obj.Resolver.(type) {
|
||||
@@ -1098,6 +1114,8 @@ func (r *membershipConnectionResolver) TotalCount(ctx context.Context, obj *type
|
||||
|
||||
// CreateOrganization is the resolver for the createOrganization field.
|
||||
func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.CreateOrganizationInput) (*types.CreateOrganizationPayload, error) {
|
||||
currentUser := UserFromContext(ctx)
|
||||
|
||||
prb := r.proboSvc.WithTenant(gid.NewTenantID())
|
||||
|
||||
organization, err := prb.Organizations.Create(
|
||||
@@ -1112,7 +1130,7 @@ func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.C
|
||||
|
||||
err = r.authzSvc.AddUserToOrganization(
|
||||
ctx,
|
||||
UserFromContext(ctx).ID,
|
||||
currentUser.ID,
|
||||
organization.ID,
|
||||
string(authz.RoleMember),
|
||||
)
|
||||
@@ -1129,8 +1147,8 @@ func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.C
|
||||
ctx,
|
||||
probo.CreatePeopleRequest{
|
||||
OrganizationID: organization.ID,
|
||||
FullName: UserFromContext(ctx).FullName,
|
||||
PrimaryEmailAddress: UserFromContext(ctx).EmailAddress,
|
||||
FullName: currentUser.FullName,
|
||||
PrimaryEmailAddress: currentUser.EmailAddress,
|
||||
AdditionalEmailAddresses: []string{},
|
||||
Kind: coredata.PeopleKindEmployee,
|
||||
},
|
||||
@@ -3537,6 +3555,260 @@ func (r *mutationResolver) DeleteCustomDomain(ctx context.Context, input types.D
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InitiateDomainVerification is the resolver for the initiateDomainVerification field.
|
||||
func (r *mutationResolver) InitiateDomainVerification(ctx context.Context, input types.InitiateDomainVerificationInput) (*types.InitiateDomainVerificationPayload, error) {
|
||||
user := UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("user not authenticated")
|
||||
}
|
||||
|
||||
organizationID := input.OrganizationID
|
||||
tenantID := organizationID.TenantID()
|
||||
|
||||
config, err := r.authSvc.InitiateDomainVerification(ctx, tenantID, organizationID, input.EmailDomain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initiate domain verification: %w", err)
|
||||
}
|
||||
|
||||
dnsRecord := auth.GetDomainVerificationRecord(*config.DomainVerificationToken)
|
||||
|
||||
return &types.InitiateDomainVerificationPayload{
|
||||
SamlConfiguration: types.NewSAMLConfigurationWithURLs(
|
||||
config,
|
||||
r.samlSvc.GetEntityID(),
|
||||
r.samlSvc.GetAcsURL(),
|
||||
),
|
||||
DNSRecord: dnsRecord,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyDomain is the resolver for the verifyDomain field.
|
||||
func (r *mutationResolver) VerifyDomain(ctx context.Context, input types.VerifyDomainInput) (*types.VerifyDomainPayload, error) {
|
||||
user := UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("user not authenticated")
|
||||
}
|
||||
|
||||
configID := input.ID
|
||||
tenantID := configID.TenantID()
|
||||
|
||||
config, verified, err := r.authSvc.VerifyDomain(ctx, tenantID, configID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to verify domain: %w", err)
|
||||
}
|
||||
|
||||
return &types.VerifyDomainPayload{
|
||||
SamlConfiguration: types.NewSAMLConfigurationWithURLs(
|
||||
config,
|
||||
r.samlSvc.GetEntityID(),
|
||||
r.samlSvc.GetAcsURL(),
|
||||
),
|
||||
Verified: verified,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateSAMLConfiguration is the resolver for the createSAMLConfiguration field.
|
||||
func (r *mutationResolver) CreateSAMLConfiguration(ctx context.Context, input types.CreateSAMLConfigurationInput) (*types.CreateSAMLConfigurationPayload, error) {
|
||||
user := UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("user not authenticated")
|
||||
}
|
||||
|
||||
organizationID := input.OrganizationID
|
||||
tenantID := organizationID.TenantID()
|
||||
|
||||
var idpEntityID, idpSsoURL, idpCertificate string
|
||||
var idpMetadataURL *string
|
||||
|
||||
if input.IdpMetadataXML != nil && *input.IdpMetadataXML != "" {
|
||||
metadata, err := auth.ParseIdPMetadata(*input.IdpMetadataXML)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse IdP metadata XML: %w", err)
|
||||
}
|
||||
idpEntityID = metadata.EntityID
|
||||
idpSsoURL = metadata.SsoURL
|
||||
idpCertificate = metadata.Certificate
|
||||
idpMetadataURL = metadata.MetadataURL
|
||||
} else {
|
||||
if input.IdpEntityID == nil || *input.IdpEntityID == "" {
|
||||
return nil, fmt.Errorf("either idpMetadataXml or idpEntityId must be provided")
|
||||
}
|
||||
if input.IdpSsoURL == nil || *input.IdpSsoURL == "" {
|
||||
return nil, fmt.Errorf("either idpMetadataXml or idpSsoUrl must be provided")
|
||||
}
|
||||
if input.IdpCertificate == nil || *input.IdpCertificate == "" {
|
||||
return nil, fmt.Errorf("either idpMetadataXml or idpCertificate must be provided")
|
||||
}
|
||||
idpEntityID = *input.IdpEntityID
|
||||
idpSsoURL = *input.IdpSsoURL
|
||||
idpCertificate = *input.IdpCertificate
|
||||
idpMetadataURL = input.IdpMetadataURL
|
||||
}
|
||||
|
||||
attributeEmail := "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
|
||||
if input.AttributeEmail != nil {
|
||||
attributeEmail = *input.AttributeEmail
|
||||
}
|
||||
|
||||
attributeFirstname := "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"
|
||||
if input.AttributeFirstname != nil {
|
||||
attributeFirstname = *input.AttributeFirstname
|
||||
}
|
||||
|
||||
attributeLastname := "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"
|
||||
if input.AttributeLastname != nil {
|
||||
attributeLastname = *input.AttributeLastname
|
||||
}
|
||||
|
||||
attributeRole := "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role"
|
||||
if input.AttributeRole != nil {
|
||||
attributeRole = *input.AttributeRole
|
||||
}
|
||||
|
||||
defaultRole := "MEMBER"
|
||||
if input.DefaultRole != nil {
|
||||
defaultRole = *input.DefaultRole
|
||||
}
|
||||
|
||||
autoSignupEnabled := false
|
||||
if input.AutoSignupEnabled != nil {
|
||||
autoSignupEnabled = *input.AutoSignupEnabled
|
||||
}
|
||||
|
||||
config, err := r.authSvc.WithTenant(tenantID).CreateSAMLConfiguration(ctx, auth.CreateSAMLConfigurationRequest{
|
||||
OrganizationID: organizationID,
|
||||
EmailDomain: input.EmailDomain,
|
||||
EnforcementPolicy: input.EnforcementPolicy,
|
||||
IdPEntityID: idpEntityID,
|
||||
IdPSsoURL: idpSsoURL,
|
||||
IdPCertificate: idpCertificate,
|
||||
IdPMetadataURL: idpMetadataURL,
|
||||
AttributeEmail: attributeEmail,
|
||||
AttributeFirstname: attributeFirstname,
|
||||
AttributeLastname: attributeLastname,
|
||||
AttributeRole: attributeRole,
|
||||
DefaultRole: defaultRole,
|
||||
AutoSignupEnabled: autoSignupEnabled,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SAML configuration: %w", err)
|
||||
}
|
||||
|
||||
return &types.CreateSAMLConfigurationPayload{
|
||||
SamlConfiguration: types.NewSAMLConfigurationWithURLs(
|
||||
config,
|
||||
r.samlSvc.GetEntityID(),
|
||||
r.samlSvc.GetAcsURL(),
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateSAMLConfiguration is the resolver for the updateSAMLConfiguration field.
|
||||
func (r *mutationResolver) UpdateSAMLConfiguration(ctx context.Context, input types.UpdateSAMLConfigurationInput) (*types.UpdateSAMLConfigurationPayload, error) {
|
||||
user := UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("user not authenticated")
|
||||
}
|
||||
|
||||
configID := input.ID
|
||||
tenantID := configID.TenantID()
|
||||
|
||||
updatedConfig, err := r.authSvc.WithTenant(tenantID).UpdateSAMLConfiguration(ctx, auth.UpdateSAMLConfigurationRequest{
|
||||
ID: configID,
|
||||
Enabled: input.Enabled,
|
||||
EnforcementPolicy: input.EnforcementPolicy,
|
||||
IdPEntityID: input.IdpEntityID,
|
||||
IdPSsoURL: input.IdpSsoURL,
|
||||
IdPCertificate: input.IdpCertificate,
|
||||
IdPMetadataURL: input.IdpMetadataURL,
|
||||
AttributeEmail: input.AttributeEmail,
|
||||
AttributeFirstname: input.AttributeFirstname,
|
||||
AttributeLastname: input.AttributeLastname,
|
||||
AttributeRole: input.AttributeRole,
|
||||
DefaultRole: input.DefaultRole,
|
||||
AutoSignupEnabled: input.AutoSignupEnabled,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update SAML configuration: %w", err)
|
||||
}
|
||||
|
||||
return &types.UpdateSAMLConfigurationPayload{
|
||||
SamlConfiguration: types.NewSAMLConfigurationWithURLs(
|
||||
updatedConfig,
|
||||
r.samlSvc.GetEntityID(),
|
||||
r.samlSvc.GetAcsURL(),
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteSAMLConfiguration is the resolver for the deleteSAMLConfiguration field.
|
||||
func (r *mutationResolver) DeleteSAMLConfiguration(ctx context.Context, input types.DeleteSAMLConfigurationInput) (*types.DeleteSAMLConfigurationPayload, error) {
|
||||
user := UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("user not authenticated")
|
||||
}
|
||||
|
||||
configID := input.ID
|
||||
tenantID := configID.TenantID()
|
||||
|
||||
err := r.authSvc.WithTenant(tenantID).DeleteSAMLConfiguration(ctx, configID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to delete SAML configuration: %w", err)
|
||||
}
|
||||
|
||||
return &types.DeleteSAMLConfigurationPayload{
|
||||
DeletedSAMLConfigurationID: configID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EnableSaml is the resolver for the enableSAML field.
|
||||
func (r *mutationResolver) EnableSaml(ctx context.Context, input types.EnableSAMLInput) (*types.EnableSAMLPayload, error) {
|
||||
user := UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("user not authenticated")
|
||||
}
|
||||
|
||||
configID := input.ID
|
||||
tenantID := configID.TenantID()
|
||||
|
||||
enabledConfig, err := r.authSvc.WithTenant(tenantID).EnableSAMLConfiguration(ctx, configID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to enable SAML: %w", err)
|
||||
}
|
||||
|
||||
return &types.EnableSAMLPayload{
|
||||
SamlConfiguration: types.NewSAMLConfigurationWithURLs(
|
||||
enabledConfig,
|
||||
r.samlSvc.GetEntityID(),
|
||||
r.samlSvc.GetAcsURL(),
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DisableSaml is the resolver for the disableSAML field.
|
||||
func (r *mutationResolver) DisableSaml(ctx context.Context, input types.DisableSAMLInput) (*types.DisableSAMLPayload, error) {
|
||||
user := UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("user not authenticated")
|
||||
}
|
||||
|
||||
configID := input.ID
|
||||
tenantID := configID.TenantID()
|
||||
|
||||
disabledConfig, err := r.authSvc.WithTenant(tenantID).DisableSAMLConfiguration(ctx, configID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to disable SAML: %w", err)
|
||||
}
|
||||
|
||||
return &types.DisableSAMLPayload{
|
||||
SamlConfiguration: types.NewSAMLConfigurationWithURLs(
|
||||
disabledConfig,
|
||||
r.samlSvc.GetEntityID(),
|
||||
r.samlSvc.GetAcsURL(),
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Organization is the resolver for the organization field.
|
||||
func (r *nonconformityResolver) Organization(ctx context.Context, obj *types.Nonconformity) (*types.Organization, error) {
|
||||
prb := r.ProboService(ctx, obj.ID.TenantID())
|
||||
@@ -4282,6 +4554,27 @@ func (r *organizationResolver) CustomDomain(ctx context.Context, obj *types.Orga
|
||||
return types.NewCustomDomain(domain, r.customDomainCname), nil
|
||||
}
|
||||
|
||||
// SamlConfigurations is the resolver for the samlConfigurations field.
|
||||
func (r *organizationResolver) SamlConfigurations(ctx context.Context, obj *types.Organization) ([]*types.SAMLConfiguration, error) {
|
||||
tenantID := obj.ID.TenantID()
|
||||
|
||||
configs, err := r.authSvc.WithTenant(tenantID).GetSAMLConfigurationsByOrganizationID(ctx, obj.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load SAML configurations: %w", err)
|
||||
}
|
||||
|
||||
result := make([]*types.SAMLConfiguration, len(configs))
|
||||
for i, config := range configs {
|
||||
result[i] = types.NewSAMLConfigurationWithURLs(
|
||||
config,
|
||||
r.samlSvc.GetEntityID(),
|
||||
r.samlSvc.GetAcsURL(),
|
||||
)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// TotalCount is the resolver for the totalCount field.
|
||||
func (r *peopleConnectionResolver) TotalCount(ctx context.Context, obj *types.PeopleConnection) (int, error) {
|
||||
prb := r.ProboService(ctx, obj.ParentID.TenantID())
|
||||
@@ -4727,6 +5020,41 @@ func (r *riskConnectionResolver) TotalCount(ctx context.Context, obj *types.Risk
|
||||
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
|
||||
}
|
||||
|
||||
// Organization is the resolver for the organization field.
|
||||
func (r *sAMLConfigurationResolver) Organization(ctx context.Context, obj *types.SAMLConfiguration) (*types.Organization, error) {
|
||||
tenantID := obj.ID.TenantID()
|
||||
prb := r.ProboService(ctx, tenantID)
|
||||
|
||||
config, err := r.authSvc.WithTenant(tenantID).GetSAMLConfigurationByID(ctx, obj.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load SAML configuration: %w", err)
|
||||
}
|
||||
|
||||
org, err := prb.Organizations.Get(ctx, config.OrganizationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load organization: %w", err)
|
||||
}
|
||||
|
||||
return types.NewOrganization(org), nil
|
||||
}
|
||||
|
||||
// SpMetadataURL is the resolver for the spMetadataUrl field.
|
||||
// Returns global Entity ID (same as spEntityId since metadata URL no longer needs config parameter)
|
||||
func (r *sAMLConfigurationResolver) SpMetadataURL(ctx context.Context, obj *types.SAMLConfiguration) (string, error) {
|
||||
return r.samlSvc.GetEntityID(), nil
|
||||
}
|
||||
|
||||
// TestLoginURL is the resolver for the testLoginUrl field.
|
||||
func (r *sAMLConfigurationResolver) TestLoginURL(ctx context.Context, obj *types.SAMLConfiguration) (string, error) {
|
||||
entityID := r.samlSvc.GetEntityID()
|
||||
parts := strings.Split(entityID, "/auth/saml/metadata")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid entity ID format")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/auth/saml/login/%s", parts[0], obj.ID), nil
|
||||
}
|
||||
|
||||
// Organization is the resolver for the organization field.
|
||||
func (r *snapshotResolver) Organization(ctx context.Context, obj *types.Snapshot) (*types.Organization, error) {
|
||||
prb := r.ProboService(ctx, obj.ID.TenantID())
|
||||
@@ -5520,6 +5848,8 @@ func (r *viewerResolver) Organizations(ctx context.Context, obj *types.Viewer, f
|
||||
panic(fmt.Errorf("failed to list organizations for user: %w", err))
|
||||
}
|
||||
|
||||
// Show all organizations the user is a member of
|
||||
// Authentication requirements will be enforced when switching to an organization
|
||||
page := page.NewPage(organizations, cursor)
|
||||
|
||||
return types.NewOrganizationConnection(page), nil
|
||||
@@ -5649,6 +5979,9 @@ func (r *Resolver) MeasureConnection() schema.MeasureConnectionResolver {
|
||||
return &measureConnectionResolver{r}
|
||||
}
|
||||
|
||||
// Membership returns schema.MembershipResolver implementation.
|
||||
func (r *Resolver) Membership() schema.MembershipResolver { return &membershipResolver{r} }
|
||||
|
||||
// MembershipConnection returns schema.MembershipConnectionResolver implementation.
|
||||
func (r *Resolver) MembershipConnection() schema.MembershipConnectionResolver {
|
||||
return &membershipConnectionResolver{r}
|
||||
@@ -5703,6 +6036,11 @@ func (r *Resolver) Risk() schema.RiskResolver { return &riskResolver{r} }
|
||||
// RiskConnection returns schema.RiskConnectionResolver implementation.
|
||||
func (r *Resolver) RiskConnection() schema.RiskConnectionResolver { return &riskConnectionResolver{r} }
|
||||
|
||||
// SAMLConfiguration returns schema.SAMLConfigurationResolver implementation.
|
||||
func (r *Resolver) SAMLConfiguration() schema.SAMLConfigurationResolver {
|
||||
return &sAMLConfigurationResolver{r}
|
||||
}
|
||||
|
||||
// Snapshot returns schema.SnapshotResolver implementation.
|
||||
func (r *Resolver) Snapshot() schema.SnapshotResolver { return &snapshotResolver{r} }
|
||||
|
||||
@@ -5818,6 +6156,7 @@ type invitationResolver struct{ *Resolver }
|
||||
type invitationConnectionResolver struct{ *Resolver }
|
||||
type measureResolver struct{ *Resolver }
|
||||
type measureConnectionResolver struct{ *Resolver }
|
||||
type membershipResolver struct{ *Resolver }
|
||||
type membershipConnectionResolver struct{ *Resolver }
|
||||
type mutationResolver struct{ *Resolver }
|
||||
type nonconformityResolver struct{ *Resolver }
|
||||
@@ -5832,6 +6171,7 @@ type queryResolver struct{ *Resolver }
|
||||
type reportResolver struct{ *Resolver }
|
||||
type riskResolver struct{ *Resolver }
|
||||
type riskConnectionResolver struct{ *Resolver }
|
||||
type sAMLConfigurationResolver struct{ *Resolver }
|
||||
type snapshotResolver struct{ *Resolver }
|
||||
type snapshotConnectionResolver struct{ *Resolver }
|
||||
type taskResolver struct{ *Resolver }
|
||||
|
||||
95
pkg/server/auth/accept_invitation_handler.go
Normal file
95
pkg/server/auth/accept_invitation_handler.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/server/session"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
)
|
||||
|
||||
type (
|
||||
AcceptInvitationRequest struct {
|
||||
InvitationID gid.GID `json:"invitationId"`
|
||||
}
|
||||
|
||||
AcceptInvitationResponse struct {
|
||||
InvitationID gid.GID `json:"invitationId"`
|
||||
}
|
||||
)
|
||||
|
||||
func AcceptInvitationHandler(authSvc *authsvc.Service, authzSvc *authz.Service, authCfg RoutesConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
sessionAuthCfg := session.AuthConfig{
|
||||
CookieName: authCfg.CookieName,
|
||||
CookieSecret: authCfg.CookieSecret,
|
||||
}
|
||||
|
||||
errorHandler := session.ErrorHandler{
|
||||
OnCookieError: func(err error) {
|
||||
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
|
||||
},
|
||||
OnParseError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
|
||||
session.ClearCookie(w, authCfg)
|
||||
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
|
||||
},
|
||||
OnSessionError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
|
||||
session.ClearCookie(w, authCfg)
|
||||
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("session expired"))
|
||||
},
|
||||
OnUserError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
|
||||
session.ClearCookie(w, authCfg)
|
||||
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("user not found"))
|
||||
},
|
||||
OnTenantError: func(err error) {
|
||||
panic(fmt.Errorf("failed to list tenants for user: %w", err))
|
||||
},
|
||||
}
|
||||
|
||||
authResult := session.TryAuth(ctx, w, r, authSvc, authzSvc, sessionAuthCfg, errorHandler)
|
||||
if authResult == nil {
|
||||
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("authentication required"))
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req AcceptInvitationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("invalid request body"))
|
||||
return
|
||||
}
|
||||
|
||||
// Accept the invitation
|
||||
_, err := authzSvc.AcceptInvitationByID(ctx, req.InvitationID, authResult.User.ID)
|
||||
if err != nil {
|
||||
httpserver.RenderError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
response := AcceptInvitationResponse{
|
||||
InvitationID: req.InvitationID,
|
||||
}
|
||||
|
||||
httpserver.RenderJSON(w, http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
72
pkg/server/auth/auth.go
Normal file
72
pkg/server/auth/auth.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/filemanager"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Auth *authsvc.Service
|
||||
Authz *authz.Service
|
||||
SAML *authsvc.SAMLService
|
||||
CookieName string
|
||||
CookieDomain string
|
||||
SessionDuration time.Duration
|
||||
CookieSecret string
|
||||
FileManager *filemanager.Service
|
||||
PGClient *pg.Client
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
router *chi.Mux
|
||||
}
|
||||
|
||||
func NewServer(cfg Config) (*Server, error) {
|
||||
router := chi.NewRouter()
|
||||
|
||||
MountRoutes(
|
||||
router,
|
||||
cfg.Auth,
|
||||
cfg.Authz,
|
||||
cfg.SAML,
|
||||
RoutesConfig{
|
||||
CookieName: cfg.CookieName,
|
||||
CookieDomain: cfg.CookieDomain,
|
||||
SessionDuration: cfg.SessionDuration,
|
||||
CookieSecret: cfg.CookieSecret,
|
||||
FileManager: cfg.FileManager,
|
||||
PGClient: cfg.PGClient,
|
||||
},
|
||||
cfg.Logger,
|
||||
)
|
||||
|
||||
return &Server{
|
||||
router: router,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.router.ServeHTTP(w, r)
|
||||
}
|
||||
@@ -12,14 +12,14 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package console_v1
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
func ForgetPasswordHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
func ForgetPasswordHandler(authSvc *authsvc.Service, authCfg RoutesConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req ForgetPasswordRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
203
pkg/server/auth/list_invitations_handler.go
Normal file
203
pkg/server/auth/list_invitations_handler.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/page"
|
||||
"github.com/getprobo/probo/pkg/server/session"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type (
|
||||
ListInvitationsResponse struct {
|
||||
Invitations []InvitationResponse `json:"invitations"`
|
||||
}
|
||||
|
||||
InvitationResponse struct {
|
||||
ID gid.GID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"fullName"`
|
||||
Role string `json:"role"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
AcceptedAt *string `json:"acceptedAt,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Organization OrganizationSummary `json:"organization"`
|
||||
}
|
||||
|
||||
OrganizationSummary struct {
|
||||
ID gid.GID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
)
|
||||
|
||||
// loadOrganizationByID loads an organization by ID without tenant scope
|
||||
func loadOrganizationByID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
orgID gid.GID,
|
||||
) (*coredata.Organization, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
tenant_id,
|
||||
name,
|
||||
logo_file_id,
|
||||
horizontal_logo_file_id,
|
||||
description,
|
||||
website_url,
|
||||
email,
|
||||
headquarter_address,
|
||||
custom_domain_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM
|
||||
authz_organizations
|
||||
WHERE
|
||||
id = $1
|
||||
`
|
||||
|
||||
row := conn.QueryRow(ctx, query, orgID)
|
||||
|
||||
var org coredata.Organization
|
||||
err := row.Scan(
|
||||
&org.ID,
|
||||
&org.TenantID,
|
||||
&org.Name,
|
||||
&org.LogoFileID,
|
||||
&org.HorizontalLogoFileID,
|
||||
&org.Description,
|
||||
&org.WebsiteURL,
|
||||
&org.Email,
|
||||
&org.HeadquarterAddress,
|
||||
&org.CustomDomainID,
|
||||
&org.CreatedAt,
|
||||
&org.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
return &org, nil
|
||||
}
|
||||
|
||||
func ListInvitationsHandler(authSvc *authsvc.Service, authzSvc *authz.Service, authCfg RoutesConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
sessionAuthCfg := session.AuthConfig{
|
||||
CookieName: authCfg.CookieName,
|
||||
CookieSecret: authCfg.CookieSecret,
|
||||
}
|
||||
|
||||
errorHandler := session.ErrorHandler{
|
||||
OnCookieError: func(err error) {
|
||||
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
|
||||
},
|
||||
OnParseError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
|
||||
session.ClearCookie(w, authCfg)
|
||||
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
|
||||
},
|
||||
OnSessionError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
|
||||
session.ClearCookie(w, authCfg)
|
||||
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("session expired"))
|
||||
},
|
||||
OnUserError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
|
||||
session.ClearCookie(w, authCfg)
|
||||
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("user not found"))
|
||||
},
|
||||
OnTenantError: func(err error) {
|
||||
panic(fmt.Errorf("failed to list tenants for user: %w", err))
|
||||
},
|
||||
}
|
||||
|
||||
authResult := session.TryAuth(ctx, w, r, authSvc, authzSvc, sessionAuthCfg, errorHandler)
|
||||
if authResult == nil {
|
||||
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("authentication required"))
|
||||
return
|
||||
}
|
||||
|
||||
// Get pending invitations for the user
|
||||
cursor := page.NewCursor(
|
||||
1000,
|
||||
nil,
|
||||
page.Head,
|
||||
page.OrderBy[coredata.InvitationOrderField]{
|
||||
Field: coredata.InvitationOrderFieldCreatedAt,
|
||||
Direction: page.OrderDirectionDesc,
|
||||
},
|
||||
)
|
||||
|
||||
invitationFilter := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
|
||||
|
||||
invitationsPage, err := authzSvc.GetUserInvitations(ctx, authResult.User.EmailAddress, cursor, invitationFilter)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to list invitations for user: %w", err))
|
||||
}
|
||||
|
||||
// Build response
|
||||
response := ListInvitationsResponse{
|
||||
Invitations: make([]InvitationResponse, 0, len(invitationsPage.Data)),
|
||||
}
|
||||
|
||||
// Load organization data for each invitation
|
||||
err = authCfg.PGClient.WithConn(ctx, func(conn pg.Conn) error {
|
||||
for _, invitation := range invitationsPage.Data {
|
||||
invitationResp := InvitationResponse{
|
||||
ID: invitation.ID,
|
||||
Email: invitation.Email,
|
||||
FullName: invitation.FullName,
|
||||
Role: invitation.Role,
|
||||
ExpiresAt: invitation.ExpiresAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
CreatedAt: invitation.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
|
||||
if invitation.AcceptedAt != nil {
|
||||
acceptedAtStr := invitation.AcceptedAt.Format("2006-01-02T15:04:05Z07:00")
|
||||
invitationResp.AcceptedAt = &acceptedAtStr
|
||||
}
|
||||
|
||||
// Load organization details
|
||||
org, err := loadOrganizationByID(ctx, conn, invitation.OrganizationID)
|
||||
if err != nil {
|
||||
// Log error but continue - organization might have been deleted
|
||||
return nil
|
||||
}
|
||||
|
||||
invitationResp.Organization = OrganizationSummary{
|
||||
ID: org.ID,
|
||||
Name: org.Name,
|
||||
}
|
||||
|
||||
response.Invitations = append(response.Invitations, invitationResp)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to load organization details: %w", err))
|
||||
}
|
||||
|
||||
httpserver.RenderJSON(w, http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
201
pkg/server/auth/list_organizations_handler.go
Normal file
201
pkg/server/auth/list_organizations_handler.go
Normal file
@@ -0,0 +1,201 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/filemanager"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/server/session"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type (
|
||||
AuthenticationStatus string
|
||||
|
||||
ListOrganizationsResponse struct {
|
||||
Organizations []OrganizationResponse `json:"organizations"`
|
||||
}
|
||||
|
||||
OrganizationResponse struct {
|
||||
ID gid.GID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
LogoURL *string `json:"logoUrl,omitempty"`
|
||||
AuthenticationMethod string `json:"authenticationMethod"` // "password", "saml", or "any"
|
||||
AuthStatus AuthenticationStatus `json:"authStatus"` // "authenticated", "unauthenticated", "expired"
|
||||
LoginURL string `json:"loginUrl"` // URL to login (SAML or password login page)
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
AuthStatusAuthenticated AuthenticationStatus = "authenticated"
|
||||
AuthStatusUnauthenticated AuthenticationStatus = "unauthenticated"
|
||||
AuthStatusExpired AuthenticationStatus = "expired"
|
||||
)
|
||||
|
||||
// generateLogoURL generates a presigned URL for an organization's logo
|
||||
func generateLogoURL(
|
||||
ctx context.Context,
|
||||
fileManager *filemanager.Service,
|
||||
conn pg.Conn,
|
||||
logoFileID *gid.GID,
|
||||
) (*string, error) {
|
||||
if logoFileID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var file coredata.File
|
||||
// Load file without scope since we're in auth context (cross-tenant)
|
||||
q := `SELECT bucket_name, file_key, file_name, mime_type, file_size FROM files WHERE id = $1`
|
||||
err := conn.QueryRow(ctx, q, logoFileID).Scan(
|
||||
&file.BucketName,
|
||||
&file.FileKey,
|
||||
&file.FileName,
|
||||
&file.MimeType,
|
||||
&file.FileSize,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot load file: %w", err)
|
||||
}
|
||||
|
||||
presignedURL, err := fileManager.GenerateFileUrl(ctx, &file, 1*time.Hour)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate file URL: %w", err)
|
||||
}
|
||||
|
||||
return &presignedURL, nil
|
||||
}
|
||||
|
||||
func ListOrganizationsHandler(authSvc *authsvc.Service, authzSvc *authz.Service, authCfg RoutesConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
sessionAuthCfg := session.AuthConfig{
|
||||
CookieName: authCfg.CookieName,
|
||||
CookieSecret: authCfg.CookieSecret,
|
||||
}
|
||||
|
||||
errorHandler := session.ErrorHandler{
|
||||
OnCookieError: func(err error) {
|
||||
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
|
||||
},
|
||||
OnParseError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
|
||||
session.ClearCookie(w, authCfg)
|
||||
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
|
||||
},
|
||||
OnSessionError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
|
||||
session.ClearCookie(w, authCfg)
|
||||
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("session expired"))
|
||||
},
|
||||
OnUserError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
|
||||
session.ClearCookie(w, authCfg)
|
||||
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("user not found"))
|
||||
},
|
||||
OnTenantError: func(err error) {
|
||||
panic(fmt.Errorf("failed to list tenants for user: %w", err))
|
||||
},
|
||||
}
|
||||
|
||||
authResult := session.TryAuth(ctx, w, r, authSvc, authzSvc, sessionAuthCfg, errorHandler)
|
||||
if authResult == nil {
|
||||
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("authentication required"))
|
||||
return
|
||||
}
|
||||
|
||||
// Get all organizations for the user (without filtering by authentication state)
|
||||
organizations, err := authzSvc.GetAllUserOrganizations(ctx, authResult.User.ID)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to list organizations for user: %w", err))
|
||||
}
|
||||
|
||||
// Build response with authentication requirements for each organization
|
||||
response := ListOrganizationsResponse{
|
||||
Organizations: make([]OrganizationResponse, 0, len(organizations)),
|
||||
}
|
||||
|
||||
for _, org := range organizations {
|
||||
orgResponse := OrganizationResponse{
|
||||
ID: org.ID,
|
||||
Name: org.Name,
|
||||
}
|
||||
|
||||
// Generate logo URL if available
|
||||
if authCfg.FileManager != nil && authCfg.PGClient != nil {
|
||||
err := authCfg.PGClient.WithConn(ctx, func(conn pg.Conn) error {
|
||||
logoURL, err := generateLogoURL(ctx, authCfg.FileManager, conn, org.LogoFileID)
|
||||
if err != nil {
|
||||
// Log error but don't fail the request
|
||||
return nil
|
||||
}
|
||||
orgResponse.LogoURL = logoURL
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// Log error but continue
|
||||
}
|
||||
}
|
||||
|
||||
// Check authentication requirements for this organization
|
||||
err := authSvc.CheckOrganizationAccess(ctx, authResult.User, org.ID, authResult.Session)
|
||||
if err != nil {
|
||||
// User needs additional authentication
|
||||
var errSAMLRequired authsvc.ErrSAMLAuthRequired
|
||||
if errors.As(err, &errSAMLRequired) {
|
||||
orgResponse.AuthenticationMethod = "saml"
|
||||
orgResponse.AuthStatus = AuthStatusUnauthenticated
|
||||
orgResponse.LoginURL = fmt.Sprintf("/auth/saml/login/%s", errSAMLRequired.ConfigID)
|
||||
} else {
|
||||
orgResponse.AuthenticationMethod = "password"
|
||||
orgResponse.AuthStatus = AuthStatusUnauthenticated
|
||||
orgResponse.LoginURL = "/authentication/login?method=password"
|
||||
}
|
||||
} else {
|
||||
// User has proper authentication
|
||||
orgResponse.AuthStatus = AuthStatusAuthenticated
|
||||
|
||||
// Determine which auth method they used
|
||||
if authResult.Session.Data.PasswordAuthenticated {
|
||||
orgResponse.AuthenticationMethod = "password"
|
||||
orgResponse.LoginURL = "/authentication/login?method=password"
|
||||
} else if len(authResult.Session.Data.SAMLAuthenticatedOrgs) > 0 {
|
||||
// Find SAML config for this org
|
||||
orgResponse.AuthenticationMethod = "saml"
|
||||
// Try to find the SAML config ID for login URL
|
||||
if samlInfo, ok := authResult.Session.Data.SAMLAuthenticatedOrgs[org.ID.String()]; ok {
|
||||
orgResponse.LoginURL = fmt.Sprintf("/auth/saml/login/%s", samlInfo.SAMLConfigID)
|
||||
} else {
|
||||
orgResponse.LoginURL = "/authentication/login?method=password"
|
||||
}
|
||||
} else {
|
||||
orgResponse.AuthenticationMethod = "any"
|
||||
orgResponse.LoginURL = "/authentication/login?method=password"
|
||||
}
|
||||
}
|
||||
|
||||
response.Organizations = append(response.Organizations, orgResponse)
|
||||
}
|
||||
|
||||
httpserver.RenderJSON(w, http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package console_v1
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
"errors"
|
||||
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
func ResetPasswordHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
func ResetPasswordHandler(authSvc *authsvc.Service, authCfg RoutesConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req ResetPasswordRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -46,8 +46,8 @@ func ResetPasswordHandler(authSvc *auth.Service, authCfg AuthConfig) http.Handle
|
||||
|
||||
err := authSvc.ResetPassword(r.Context(), req.Token, req.Password)
|
||||
if err != nil {
|
||||
var invalidPasswordErr *auth.ErrInvalidPassword
|
||||
var invalidTokenErr *auth.ErrInvalidTokenType
|
||||
var invalidPasswordErr *authsvc.ErrInvalidPassword
|
||||
var invalidTokenErr *authsvc.ErrInvalidTokenType
|
||||
|
||||
if errors.As(err, &invalidPasswordErr) {
|
||||
httpserver.RenderError(w, http.StatusBadRequest, err)
|
||||
60
pkg/server/auth/router.go
Normal file
60
pkg/server/auth/router.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/filemanager"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type RoutesConfig struct {
|
||||
CookieName string
|
||||
CookieDomain string
|
||||
SessionDuration time.Duration
|
||||
CookieSecret string
|
||||
FileManager *filemanager.Service
|
||||
PGClient *pg.Client
|
||||
}
|
||||
|
||||
func MountRoutes(
|
||||
r chi.Router,
|
||||
authSvc *authsvc.Service,
|
||||
authzSvc *authz.Service,
|
||||
samlSvc *authsvc.SAMLService,
|
||||
authCfg RoutesConfig,
|
||||
logger *log.Logger,
|
||||
) {
|
||||
r.Post("/register", SignUpHandler(authSvc, authCfg))
|
||||
r.Post("/login", SignInHandler(authSvc, authCfg))
|
||||
r.Delete("/logout", SignOutHandler(authSvc, authCfg))
|
||||
r.Post("/signup-from-invitation", SignupFromInvitationHandler(authSvc, authCfg))
|
||||
r.Post("/forget-password", ForgetPasswordHandler(authSvc, authCfg))
|
||||
r.Post("/reset-password", ResetPasswordHandler(authSvc, authCfg))
|
||||
r.Post("/check-sso", SAMLCheckSSOHandler(authSvc, logger))
|
||||
r.Get("/organizations", ListOrganizationsHandler(authSvc, authzSvc, authCfg))
|
||||
r.Get("/invitations", ListInvitationsHandler(authSvc, authzSvc, authCfg))
|
||||
r.Post("/invitations/accept", AcceptInvitationHandler(authSvc, authzSvc, authCfg))
|
||||
|
||||
// SAML routes
|
||||
r.Get("/saml/login/{samlConfigID}", SAMLLoginHandler(samlSvc, authSvc, logger))
|
||||
r.Post("/saml/consume", SAMLACSHandler(samlSvc, authSvc, authzSvc, authCfg, logger))
|
||||
r.Get("/saml/metadata", SAMLMetadataHandler(samlSvc))
|
||||
}
|
||||
131
pkg/server/auth/saml_acs_handler.go
Normal file
131
pkg/server/auth/saml_acs_handler.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/securecookie"
|
||||
"go.gearno.de/kit/log"
|
||||
)
|
||||
|
||||
func getSessionIDFromCookie(r *http.Request, authCfg RoutesConfig) (gid.GID, error) {
|
||||
cookieValue, err := securecookie.Get(r, securecookie.DefaultConfig(
|
||||
authCfg.CookieName,
|
||||
authCfg.CookieSecret,
|
||||
))
|
||||
if err != nil {
|
||||
return gid.GID{}, err
|
||||
}
|
||||
|
||||
return gid.ParseGID(cookieValue)
|
||||
}
|
||||
|
||||
func SAMLACSHandler(samlSvc *authsvc.SAMLService, authSvc *authsvc.Service, authzSvc *authz.Service, authCfg RoutesConfig, logger *log.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
logger.ErrorCtx(ctx, "failed to parse form", log.Error(err))
|
||||
http.Error(w, "failed to parse form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if r.FormValue("SAMLResponse") == "" {
|
||||
logger.WarnCtx(ctx, "missing SAMLResponse")
|
||||
http.Error(w, "missing SAMLResponse", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if r.FormValue("RelayState") == "" {
|
||||
logger.WarnCtx(ctx, "missing RelayState")
|
||||
http.Error(w, "missing RelayState", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
userInfo, err := samlSvc.HandleSAMLAssertion(ctx, r)
|
||||
if err != nil {
|
||||
logger.ErrorCtx(ctx, "SAML authentication failed", log.Error(err))
|
||||
http.Error(w, "SAML authentication failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := authSvc.CreateOrGetSAMLUser(ctx, userInfo.Email, userInfo.FullName, userInfo.SAMLSubject)
|
||||
if err != nil {
|
||||
logger.ErrorCtx(ctx, "cannot create or get SAML user", log.Error(err), log.String("email", userInfo.Email))
|
||||
http.Error(w, "failed to create user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = authzSvc.EnsureSAMLMembership(ctx, userInfo.TenantID, user.ID, userInfo.OrganizationID, userInfo.Role)
|
||||
if err != nil {
|
||||
logger.ErrorCtx(ctx, "cannot ensure membership", log.Error(err), log.String("user_id", user.ID.String()), log.String("org_id", userInfo.OrganizationID.String()))
|
||||
http.Error(w, "failed to create membership", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var session *coredata.Session
|
||||
if existingSessionID, err := getSessionIDFromCookie(r, authCfg); err == nil {
|
||||
if existingSession, err := authSvc.GetSession(ctx, existingSessionID); err == nil && existingSession.UserID == user.ID {
|
||||
session = existingSession
|
||||
}
|
||||
}
|
||||
|
||||
if session == nil {
|
||||
session, err = authSvc.CreateSessionForUser(ctx, user.ID, authCfg.SessionDuration)
|
||||
if err != nil {
|
||||
logger.ErrorCtx(ctx, "cannot create session", log.Error(err), log.String("user_id", user.ID.String()))
|
||||
http.Error(w, "failed to create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if session.Data.SAMLAuthenticatedOrgs == nil {
|
||||
session.Data.SAMLAuthenticatedOrgs = make(map[string]coredata.SAMLAuthInfo)
|
||||
}
|
||||
session.Data.SAMLAuthenticatedOrgs[userInfo.OrganizationID.String()] = coredata.SAMLAuthInfo{
|
||||
AuthenticatedAt: time.Now(),
|
||||
SAMLConfigID: userInfo.SAMLConfigID,
|
||||
SAMLSubject: userInfo.SAMLSubject,
|
||||
}
|
||||
|
||||
err = authSvc.UpdateSessionData(ctx, session.ID, session.Data)
|
||||
if err != nil {
|
||||
logger.ErrorCtx(ctx, "cannot update session data", log.Error(err), log.String("session_id", session.ID.String()))
|
||||
http.Error(w, "failed to update session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
securecookie.Set(
|
||||
w,
|
||||
securecookie.DefaultConfig(
|
||||
authCfg.CookieName,
|
||||
authCfg.CookieSecret,
|
||||
),
|
||||
session.ID.String(),
|
||||
)
|
||||
|
||||
logger.InfoCtx(ctx, "SAML login successful", log.String("user_id", user.ID.String()), log.String("org_id", userInfo.OrganizationID.String()))
|
||||
|
||||
redirectURL := fmt.Sprintf("/organizations/%s", userInfo.OrganizationID)
|
||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||
}
|
||||
}
|
||||
90
pkg/server/auth/saml_check_sso_handler.go
Normal file
90
pkg/server/auth/saml_check_sso_handler.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
"go.gearno.de/kit/log"
|
||||
)
|
||||
|
||||
type (
|
||||
CheckSSORequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
CheckSSOResponse struct {
|
||||
SSOAvailable bool `json:"ssoAvailable"`
|
||||
SAMLConfigID *string `json:"samlConfigId,omitempty"`
|
||||
OrganizationID *string `json:"organizationId,omitempty"`
|
||||
EnforcementPolicy *string `json:"enforcementPolicy,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
func SAMLCheckSSOHandler(authSvc *authsvc.Service, logger *log.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req CheckSSORequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot decode body: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Email == "" {
|
||||
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("email is required"))
|
||||
return
|
||||
}
|
||||
|
||||
configs, err := authSvc.CheckSSOAvailabilityByEmail(ctx, req.Email)
|
||||
if err != nil {
|
||||
logger.ErrorCtx(ctx, "cannot check SSO availability", log.Error(err), log.String("email", req.Email))
|
||||
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("cannot check SSO availability"))
|
||||
return
|
||||
}
|
||||
|
||||
// No SAML configs found for this domain
|
||||
if len(configs) == 0 {
|
||||
httpserver.RenderJSON(w, http.StatusOK, CheckSSOResponse{
|
||||
SSOAvailable: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Multiple SAML configs found - ambiguous, user must use organization-specific SSO URL
|
||||
if len(configs) > 1 {
|
||||
logger.WarnCtx(ctx, "multiple SAML configurations found for domain", log.String("email", req.Email), log.Int("count", len(configs)))
|
||||
httpserver.RenderError(w, http.StatusConflict, fmt.Errorf("multiple SSO configurations found for this domain. Please use your organization-specific SSO login URL"))
|
||||
return
|
||||
}
|
||||
|
||||
// Single SAML config found - return it
|
||||
config := configs[0]
|
||||
configIDStr := config.ID.String()
|
||||
orgIDStr := config.OrganizationID.String()
|
||||
enforcementPolicy := string(config.EnforcementPolicy)
|
||||
|
||||
httpserver.RenderJSON(w, http.StatusOK, CheckSSOResponse{
|
||||
SSOAvailable: true,
|
||||
SAMLConfigID: &configIDStr,
|
||||
OrganizationID: &orgIDStr,
|
||||
EnforcementPolicy: &enforcementPolicy,
|
||||
})
|
||||
}
|
||||
}
|
||||
65
pkg/server/auth/saml_login_handler.go
Normal file
65
pkg/server/auth/saml_login_handler.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.gearno.de/kit/log"
|
||||
)
|
||||
|
||||
func SAMLLoginHandler(samlSvc *authsvc.SAMLService, authSvc *authsvc.Service, logger *log.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
samlConfigIDStr := chi.URLParam(r, "samlConfigID")
|
||||
if samlConfigIDStr == "" {
|
||||
logger.WarnCtx(ctx, "missing SAML config ID in URL")
|
||||
http.Error(w, "missing SAML config ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
samlConfigID, err := gid.ParseGID(samlConfigIDStr)
|
||||
if err != nil {
|
||||
logger.ErrorCtx(ctx, "invalid SAML config ID", log.Error(err), log.String("saml_config_id", samlConfigIDStr))
|
||||
http.Error(w, "invalid SAML config ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tenantID := samlConfigID.TenantID()
|
||||
|
||||
config, err := authSvc.WithTenant(tenantID).GetSAMLConfigurationByID(ctx, samlConfigID)
|
||||
if err != nil {
|
||||
logger.ErrorCtx(ctx, "cannot load SAML configuration", log.Error(err), log.String("saml_config_id", samlConfigID.String()))
|
||||
http.Error(w, "SAML configuration not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL, err := samlSvc.InitiateSAMLLogin(ctx, config.OrganizationID, tenantID, config.EmailDomain)
|
||||
if err != nil {
|
||||
logger.ErrorCtx(ctx, "cannot initiate SAML login", log.Error(err), log.String("saml_config_id", samlConfigID.String()), log.String("org_id", config.OrganizationID.String()), log.String("email_domain", config.EmailDomain))
|
||||
http.Error(w, fmt.Sprintf("SAML login failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
logger.InfoCtx(ctx, "SAML login initiated", log.String("saml_config_id", samlConfigID.String()), log.String("org_id", config.OrganizationID.String()), log.String("email_domain", config.EmailDomain))
|
||||
|
||||
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||
}
|
||||
}
|
||||
38
pkg/server/auth/saml_metadata_handler.go
Normal file
38
pkg/server/auth/saml_metadata_handler.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||
)
|
||||
|
||||
// SAMLMetadataHandler returns an HTTP handler that serves the SAML Service Provider metadata XML
|
||||
// Uses global SP certificate configured at service startup
|
||||
func SAMLMetadataHandler(samlSvc *authsvc.SAMLService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
metadataXML, err := samlSvc.GenerateMetadata()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to generate metadata: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/samlmetadata+xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(metadataXML)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package console_v1
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -21,9 +21,10 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/securecookie"
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
)
|
||||
|
||||
@@ -46,7 +47,7 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
func SignInHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
func SignInHandler(authSvc *authsvc.Service, authCfg RoutesConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req SignInRequest
|
||||
@@ -55,9 +56,16 @@ func SignInHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
session, user, err := authSvc.SignIn(r.Context(), req.Email, req.Password)
|
||||
var existingSession *coredata.Session
|
||||
if existingSessionID, err := getSessionIDFromCookie(r, authCfg); err == nil {
|
||||
if session, err := authSvc.GetSession(r.Context(), existingSessionID); err == nil {
|
||||
existingSession = session
|
||||
}
|
||||
}
|
||||
|
||||
session, user, err := authSvc.SignInWithExistingSession(r.Context(), req.Email, req.Password, existingSession)
|
||||
if err != nil {
|
||||
var ErrInvalidCredentials *auth.ErrInvalidCredentials
|
||||
var ErrInvalidCredentials *authsvc.ErrInvalidCredentials
|
||||
if errors.As(err, &ErrInvalidCredentials) {
|
||||
httpserver.RenderError(w, http.StatusUnauthorized, err)
|
||||
return
|
||||
@@ -12,7 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package console_v1
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -20,11 +20,11 @@ import (
|
||||
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/securecookie"
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
)
|
||||
|
||||
func SignOutHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
func SignOutHandler(authSvc *authsvc.Service, authCfg RoutesConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sessionID, err := securecookie.Get(r, securecookie.DefaultConfig(
|
||||
@@ -12,7 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package console_v1
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/securecookie"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
)
|
||||
@@ -37,7 +37,7 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
func SignUpHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
func SignUpHandler(authSvc *authsvc.Service, authCfg RoutesConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req SignUpRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -52,13 +52,13 @@ func SignUpHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
req.FullName,
|
||||
)
|
||||
if err != nil {
|
||||
var errUserAlreadyExists *auth.ErrUserAlreadyExists
|
||||
var errUserAlreadyExists *authsvc.ErrUserAlreadyExists
|
||||
if errors.As(err, &errUserAlreadyExists) {
|
||||
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot register user: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
var errSignupDisabled *auth.ErrSignupDisabled
|
||||
var errSignupDisabled *authsvc.ErrSignupDisabled
|
||||
if errors.As(err, &errSignupDisabled) {
|
||||
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot register user: %w", err))
|
||||
return
|
||||
@@ -12,14 +12,14 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package console_v1
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/securecookie"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
)
|
||||
@@ -35,7 +35,7 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
func SignupFromInvitationHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
||||
func SignupFromInvitationHandler(authSvc *authsvc.Service, authCfg RoutesConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req SignupFromInvitationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -19,13 +19,53 @@ import (
|
||||
"errors"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/vektah/gqlparser/v2/gqlerror"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
"go.gearno.de/kit/log"
|
||||
)
|
||||
|
||||
func RecoverFunc(ctx context.Context, err any) error {
|
||||
if gqlErr, ok := err.(*gqlerror.Error); ok {
|
||||
return gqlErr
|
||||
}
|
||||
|
||||
var errSAMLRequired auth.ErrSAMLAuthRequired
|
||||
if errors.As(asError(err), &errSAMLRequired) {
|
||||
return &gqlerror.Error{
|
||||
Message: "Additional authentication required to access this organization",
|
||||
Extensions: map[string]any{
|
||||
"code": "AUTHENTICATION_REQUIRED",
|
||||
"requiresSaml": true,
|
||||
"redirectUrl": errSAMLRequired.RedirectURL,
|
||||
"samlConfigId": errSAMLRequired.ConfigID.String(),
|
||||
"organizationId": errSAMLRequired.OrganizationID.String(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var errPasswordRequired auth.ErrPasswordAuthRequired
|
||||
if errors.As(asError(err), &errPasswordRequired) {
|
||||
return &gqlerror.Error{
|
||||
Message: "Additional authentication required to access this organization",
|
||||
Extensions: map[string]any{
|
||||
"code": "AUTHENTICATION_REQUIRED",
|
||||
"requiresSaml": false,
|
||||
"redirectUrl": errPasswordRequired.RedirectURL,
|
||||
"organizationId": errPasswordRequired.OrganizationID.String(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
logger := httpserver.LoggerFromContext(ctx)
|
||||
logger.Error("resolver panic", log.Any("error", err), log.Any("stack", string(debug.Stack())))
|
||||
|
||||
return errors.New("internal server error")
|
||||
}
|
||||
|
||||
func asError(err any) error {
|
||||
if e, ok := err.(error); ok {
|
||||
return e
|
||||
}
|
||||
return errors.New("unknown panic")
|
||||
}
|
||||
|
||||
@@ -24,10 +24,12 @@ import (
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/connector"
|
||||
"github.com/getprobo/probo/pkg/filemanager"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/probo"
|
||||
"github.com/getprobo/probo/pkg/saferedirect"
|
||||
"github.com/getprobo/probo/pkg/server/api"
|
||||
auth_server "github.com/getprobo/probo/pkg/server/auth"
|
||||
trust_v1 "github.com/getprobo/probo/pkg/server/api/trust/v1"
|
||||
"github.com/getprobo/probo/pkg/server/trust"
|
||||
"github.com/getprobo/probo/pkg/server/web"
|
||||
@@ -35,6 +37,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.gearno.de/kit/httpserver"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -44,12 +47,15 @@ type Config struct {
|
||||
Auth *auth.Service
|
||||
Authz *authz.Service
|
||||
Trust *trust_pkg.Service
|
||||
SAML *auth.SAMLService
|
||||
ConsoleAuth api.ConsoleAuthConfig
|
||||
TrustAuth api.TrustAuthConfig
|
||||
ConnectorRegistry *connector.ConnectorRegistry
|
||||
Agent *agents.Agent
|
||||
SafeRedirect *saferedirect.SafeRedirect
|
||||
CustomDomainCname string
|
||||
FileManager *filemanager.Service
|
||||
PGClient *pg.Client
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
@@ -57,6 +63,7 @@ type Server struct {
|
||||
apiServer *api.Server
|
||||
webServer *web.Server
|
||||
trustServer *trust.Server
|
||||
authServer *auth_server.Server
|
||||
router *chi.Mux
|
||||
extraHeaderFields map[string]string
|
||||
proboService *probo.Service
|
||||
@@ -70,6 +77,7 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
Auth: cfg.Auth,
|
||||
Authz: cfg.Authz,
|
||||
Trust: cfg.Trust,
|
||||
SAML: cfg.SAML,
|
||||
ConsoleAuth: cfg.ConsoleAuth,
|
||||
TrustAuth: cfg.TrustAuth,
|
||||
ConnectorRegistry: cfg.ConnectorRegistry,
|
||||
@@ -92,12 +100,29 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authServer, err := auth_server.NewServer(auth_server.Config{
|
||||
Auth: cfg.Auth,
|
||||
Authz: cfg.Authz,
|
||||
SAML: cfg.SAML,
|
||||
CookieName: cfg.ConsoleAuth.CookieName,
|
||||
CookieDomain: cfg.ConsoleAuth.CookieDomain,
|
||||
SessionDuration: cfg.ConsoleAuth.SessionDuration,
|
||||
CookieSecret: cfg.ConsoleAuth.CookieSecret,
|
||||
FileManager: cfg.FileManager,
|
||||
PGClient: cfg.PGClient,
|
||||
Logger: cfg.Logger.Named("auth"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
|
||||
server := &Server{
|
||||
apiServer: apiServer,
|
||||
webServer: webServer,
|
||||
trustServer: trustServer,
|
||||
authServer: authServer,
|
||||
router: router,
|
||||
extraHeaderFields: cfg.ExtraHeaderFields,
|
||||
proboService: cfg.Probo,
|
||||
@@ -111,6 +136,7 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
|
||||
func (s *Server) setupRoutes() {
|
||||
s.router.Mount("/api", s.apiServer)
|
||||
s.router.Mount("/auth", s.authServer)
|
||||
|
||||
s.router.Route("/trust/{slugOrId}", func(r chi.Router) {
|
||||
r.Use(s.loadTrustCenterBySlugOrID)
|
||||
|
||||
@@ -32,9 +32,10 @@ type AuthConfig struct {
|
||||
}
|
||||
|
||||
type AuthResult struct {
|
||||
Session *coredata.Session
|
||||
User *coredata.User
|
||||
TenantIDs []gid.TenantID
|
||||
Session *coredata.Session
|
||||
User *coredata.User
|
||||
TenantIDs []gid.TenantID
|
||||
AuthErrors map[gid.TenantID]error // Maps tenant ID to authentication error
|
||||
}
|
||||
|
||||
type ErrorHandler struct {
|
||||
@@ -97,15 +98,28 @@ func TryAuth(
|
||||
return nil
|
||||
}
|
||||
|
||||
tenantIDs := make([]gid.TenantID, len(organizations))
|
||||
for i, org := range organizations {
|
||||
tenantIDs[i] = org.ID.TenantID()
|
||||
// Validate organization access based on authentication requirements
|
||||
// Only include organizations the user has proper authentication for
|
||||
allowedTenantIDs := make([]gid.TenantID, 0, len(organizations))
|
||||
authErrors := make(map[gid.TenantID]error)
|
||||
|
||||
for _, org := range organizations {
|
||||
// Check if user has the required authentication for this organization
|
||||
err := authSvc.CheckOrganizationAccess(ctx, user, org.ID, session)
|
||||
if err == nil {
|
||||
// User has proper authentication for this org
|
||||
allowedTenantIDs = append(allowedTenantIDs, org.ID.TenantID())
|
||||
} else {
|
||||
// Store the authentication error for later use
|
||||
authErrors[org.ID.TenantID()] = err
|
||||
}
|
||||
}
|
||||
|
||||
return &AuthResult{
|
||||
Session: session,
|
||||
User: user,
|
||||
TenantIDs: tenantIDs,
|
||||
TenantIDs: allowedTenantIDs,
|
||||
AuthErrors: authErrors,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user