Add saml domain verification

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-12-20 00:08:46 +01:00
parent 5433345a6e
commit 63349098fd
13 changed files with 394 additions and 391 deletions

View File

@@ -970,26 +970,26 @@ func (s OrganizationService) CreateSAMLConfiguration(
req *CreateSAMLConfigurationRequest,
) (*coredata.SAMLConfiguration, error) {
var (
now = time.Now()
scope = coredata.NewScopeFromObjectID(organizationID)
config = &coredata.SAMLConfiguration{
ID: gid.New(scope.GetTenantID(), coredata.SAMLConfigurationEntityType),
OrganizationID: organizationID,
EnforcementPolicy: coredata.SAMLEnforcementPolicyOff,
IdPEntityID: req.IdPEntityID,
IdPSsoURL: req.IdPSsoURL,
IdPCertificate: req.IdPCertificate,
EmailDomain: req.EmailDomain,
AutoSignupEnabled: req.AutoSignupEnabled,
AttributeEmail: DefaultAttributeEmail,
AttributeFirstname: DefaultAttributeFirstname,
AttributeLastname: DefaultAttributeLastname,
AttributeRole: DefaultAttributeRole,
CreatedAt: now,
UpdatedAt: now,
now = time.Now()
scope = coredata.NewScopeFromObjectID(organizationID)
domainVerificationToken = uuid.MustNewV4().String()
config = &coredata.SAMLConfiguration{
ID: gid.New(scope.GetTenantID(), coredata.SAMLConfigurationEntityType),
OrganizationID: organizationID,
EnforcementPolicy: coredata.SAMLEnforcementPolicyOff,
IdPEntityID: req.IdPEntityID,
IdPSsoURL: req.IdPSsoURL,
IdPCertificate: req.IdPCertificate,
DomainVerificationToken: &domainVerificationToken,
EmailDomain: req.EmailDomain,
AutoSignupEnabled: req.AutoSignupEnabled,
AttributeEmail: DefaultAttributeEmail,
AttributeFirstname: DefaultAttributeFirstname,
AttributeLastname: DefaultAttributeLastname,
AttributeRole: DefaultAttributeRole,
CreatedAt: now,
UpdatedAt: now,
}
// TODO create domain verification object
)
if req.AttributeEmail != nil {
@@ -1059,7 +1059,7 @@ func (s OrganizationService) UpdateSAMLConfiguration(
}
if req.EnforcementPolicy != nil {
if !config.DomainVerified {
if config.DomainVerifiedAt == nil {
return NewSAMLConfigurationDomainNotVerifiedError(configID)
}

View File

@@ -1,97 +0,0 @@
// 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 iam
import (
"crypto/x509"
"encoding/pem"
"fmt"
"net/url"
pemutil "go.probo.inc/probo/pkg/crypto/pem"
)
// 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,
) error {
// Validate IdP Entity ID
if idpEntityID == "" {
return fmt.Errorf("IdP Entity ID cannot be empty")
}
// Validate IdP SSO URL - accept both HTTP and HTTPS
if err := validateURL(idpSsoURL); err != nil {
return err
}
// Validate IdP certificate
if err := validateCertificate(idpCertificate); err != nil {
return err
}
return nil
}
func validateURL(urlStr string) error {
if urlStr == "" {
return fmt.Errorf("URL cannot be empty")
}
parsedURL, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid URL format: %w", err)
}
if parsedURL.Scheme == "" {
return fmt.Errorf("URL must have a scheme (http or https)")
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return fmt.Errorf("URL scheme must be http or https (found: %s)", parsedURL.Scheme)
}
if parsedURL.Host == "" {
return fmt.Errorf("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("cannot parse certificate PEM")
}
if block.Type != pemutil.BlockTypeCertificate {
return fmt.Errorf("PEM block type must be CERTIFICATE (found: %s)", block.Type)
}
_, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return fmt.Errorf("cannot parse X.509 certificate: %w", err)
}
return nil
}

View File

@@ -0,0 +1,206 @@
// 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 iam
import (
"context"
"fmt"
"strings"
"time"
"codeberg.org/miekg/dns"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.opentelemetry.io/otel/trace"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
type (
SAMLDomainVerifier struct {
pg *pg.Client
interval time.Duration
resolverAddr string
logger *log.Logger
tracer trace.Tracer
}
)
const (
txtRecordValuePrefix = "probo-verification="
)
func NewSAMLDomainVerifier(
pgClient *pg.Client,
logger *log.Logger,
tp trace.TracerProvider,
interval time.Duration,
resolverAddr string,
) *SAMLDomainVerifier {
return &SAMLDomainVerifier{
pg: pgClient,
interval: interval,
resolverAddr: resolverAddr,
logger: logger.Named("saml-domain-verifier"),
tracer: tp.Tracer("go.probo.inc/probo/pkg/iam/saml_domain_verifier"),
}
}
func (v *SAMLDomainVerifier) Run(ctx context.Context) error {
v.logger.InfoCtx(ctx, "starting", log.Duration("interval", v.interval))
for {
v.runOnce(ctx)
select {
case <-ctx.Done():
v.logger.InfoCtx(ctx, "shutting down")
return ctx.Err()
case <-time.After(v.interval):
}
}
}
func (v *SAMLDomainVerifier) runOnce(ctx context.Context) {
ctx, span := v.tracer.Start(ctx, "SAMLDomainVerifier.runOnce")
defer span.End()
if err := v.checkUnverifiedDomains(ctx); err != nil {
v.logger.ErrorCtx(ctx, "cannot check unverified domains", log.Error(err))
}
}
func (v *SAMLDomainVerifier) checkUnverifiedDomains(ctx context.Context) error {
var configs coredata.SAMLConfigurations
err := v.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := configs.LoadUnverified(ctx, conn)
if err != nil {
return fmt.Errorf("cannot load unverified SAML configurations: %w", err)
}
return nil
},
)
if err != nil {
return err
}
for _, config := range configs {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := v.tryVerifyDomain(ctx, config.ID); err != nil {
v.logger.ErrorCtx(ctx, "cannot verify domain",
log.String("config_id", config.ID.String()),
log.Error(err),
)
continue
}
}
return nil
}
func (v *SAMLDomainVerifier) tryVerifyDomain(ctx context.Context, configID gid.GID) error {
return v.pg.WithTx(
ctx,
func(tx pg.Conn) error {
config := &coredata.SAMLConfiguration{}
if err := config.LoadByIDForUpdateSkipLocked(ctx, tx, configID); err != nil {
if err == coredata.ErrResourceNotFound {
return nil
}
return fmt.Errorf("cannot load SAML configuration: %w", err)
}
if config.DomainVerifiedAt != nil {
return nil
}
if config.DomainVerificationToken == nil {
return fmt.Errorf("cannot verify domain %q: no verification token", config.EmailDomain)
}
expectedValue := txtRecordValuePrefix + *config.DomainVerificationToken
if err := v.checkDNSTXTRecord(config.EmailDomain, expectedValue); err != nil {
return err
}
v.logger.InfoCtx(ctx, "domain verified",
log.String("config_id", config.ID.String()),
)
now := time.Now()
config.DomainVerificationToken = nil
config.DomainVerifiedAt = &now
config.UpdatedAt = now
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
if err := config.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update SAML configuration: %w", err)
}
return nil
},
)
}
func (v *SAMLDomainVerifier) checkDNSTXTRecord(emailDomain string, expectedValue string) error {
fqdn := emailDomain
if !strings.HasSuffix(fqdn, ".") {
fqdn = fqdn + "."
}
msg := &dns.Msg{MsgHeader: dns.MsgHeader{ID: dns.ID(), RecursionDesired: true}}
msg.Question = []dns.RR{&dns.TXT{Hdr: dns.Header{Name: fqdn, Class: dns.ClassINET}}}
client := dns.NewClient()
resp, _, err := client.Exchange(context.Background(), msg, "udp", v.resolverAddr)
if err != nil {
return fmt.Errorf("cannot query TXT record for %q: %w", emailDomain, err)
}
if resp.Rcode != dns.RcodeSuccess {
return fmt.Errorf("cannot query TXT record for %q: %s", emailDomain, dns.RcodeToString[resp.Rcode])
}
if len(resp.Answer) == 0 {
return fmt.Errorf("cannot find TXT record for %q", emailDomain)
}
for _, answer := range resp.Answer {
txt, ok := answer.(*dns.TXT)
if !ok {
continue
}
value := strings.Join(txt.Txt, "")
if value == expectedValue {
return nil
}
}
return fmt.Errorf("cannot find matching TXT record for %q", emailDomain)
}

View File

@@ -9,12 +9,14 @@ import (
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.opentelemetry.io/otel/trace"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/crypto/cipher"
"go.probo.inc/probo/pkg/crypto/passwdhash"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam/saml"
"golang.org/x/sync/errgroup"
)
type (
@@ -42,21 +44,26 @@ type (
APIKeyService *APIKeyService
LegacyAccessManagementService *AccessManagementService
Authorizer *Authorizer
samlDomainVerifier *SAMLDomainVerifier
}
Config struct {
DisableSignup bool
InvitationTokenValidity time.Duration
PasswordResetTokenValidity time.Duration
SessionDuration time.Duration
Bucket string
TokenSecret string
BaseURL string
EncryptionKey cipher.EncryptionKey
Certificate *x509.Certificate
PrivateKey *rsa.PrivateKey
Logger *log.Logger
PolicySet *PolicySet
DisableSignup bool
InvitationTokenValidity time.Duration
PasswordResetTokenValidity time.Duration
SessionDuration time.Duration
Bucket string
TokenSecret string
BaseURL string
EncryptionKey cipher.EncryptionKey
Certificate *x509.Certificate
PrivateKey *rsa.PrivateKey
Logger *log.Logger
PolicySet *PolicySet
TracerProvider trace.TracerProvider
DomainVerificationInterval time.Duration
DomainVerificationResolverAddr string
}
)
@@ -122,31 +129,24 @@ func NewService(
}
svc.SAMLService = samlService
svc.samlDomainVerifier = NewSAMLDomainVerifier(
pgClient,
cfg.Logger,
cfg.TracerProvider,
cfg.DomainVerificationInterval,
cfg.DomainVerificationResolverAddr,
)
return svc, nil
}
func (s *Service) Run(ctx context.Context) error {
runCtx, stopAll := context.WithCancel(ctx)
defer stopAll()
g, ctx := errgroup.WithContext(ctx)
errCh := make(chan error, 1)
go func() {
errCh <- s.SAMLService.Run(runCtx)
}()
g.Go(func() error { return s.SAMLService.Run(ctx) })
g.Go(func() error { return s.samlDomainVerifier.Run(ctx) })
select {
case <-ctx.Done():
stopAll()
<-errCh
return ctx.Err()
case err := <-errCh:
if err != nil {
s.logger.ErrorCtx(ctx, "iam service failed", log.Error(err))
return err
}
return nil
}
return g.Wait()
}
func (s *Service) GetMembership(ctx context.Context, membershipID gid.GID) (*coredata.Membership, error) {