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

@@ -0,0 +1,5 @@
ALTER TABLE auth_saml_configurations DROP COLUMN enabled;
ALTER TABLE auth_saml_configurations DROP COLUMN domain_verified;
CREATE UNIQUE INDEX idx_saml_config_domain_org_unique
ON auth_saml_configurations(organization_id, email_domain);

View File

@@ -0,0 +1,3 @@
CREATE UNIQUE INDEX idx_saml_config_domain_verification_token_unique
ON auth_saml_configurations(domain_verification_token);

View File

@@ -43,7 +43,6 @@ type (
AttributeLastname string `db:"attribute_lastname"`
AttributeRole string `db:"attribute_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"`
@@ -88,7 +87,6 @@ SELECT
id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
@@ -99,7 +97,6 @@ SELECT
attribute_lastname,
attribute_role,
auto_signup_enabled,
domain_verified,
domain_verification_token,
domain_verified_at,
created_at,
@@ -147,7 +144,6 @@ SELECT
id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
@@ -158,7 +154,6 @@ SELECT
attribute_lastname,
attribute_role,
auto_signup_enabled,
domain_verified,
domain_verification_token,
domain_verified_at,
created_at,
@@ -195,6 +190,58 @@ LIMIT 1;
return nil
}
func (s *SAMLConfiguration) LoadByIDForUpdateSkipLocked(
ctx context.Context,
conn pg.Conn,
configID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
email_domain,
enforcement_policy,
idp_entity_id,
idp_sso_url,
idp_certificate,
idp_metadata_url,
attribute_email,
attribute_firstname,
attribute_lastname,
attribute_role,
auto_signup_enabled,
domain_verification_token,
domain_verified_at,
created_at,
updated_at
FROM
auth_saml_configurations
WHERE
id = @id
FOR UPDATE SKIP LOCKED;
`
args := pgx.StrictNamedArgs{"id": configID}
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 {
if err == pgx.ErrNoRows {
return ErrResourceNotFound
}
return fmt.Errorf("cannot collect saml_configuration: %w", err)
}
*s = config
return nil
}
func (s *SAMLConfiguration) Insert(
ctx context.Context,
conn pg.Conn,
@@ -216,7 +263,6 @@ INSERT INTO auth_saml_configurations (
attribute_lastname,
attribute_role,
auto_signup_enabled,
domain_verified,
domain_verification_token,
domain_verified_at,
created_at,
@@ -236,7 +282,6 @@ INSERT INTO auth_saml_configurations (
@attribute_lastname,
@attribute_role,
@auto_signup_enabled,
@domain_verified,
@domain_verification_token,
@domain_verified_at,
@created_at,
@@ -259,7 +304,6 @@ INSERT INTO auth_saml_configurations (
"attribute_lastname": s.AttributeLastname,
"attribute_role": s.AttributeRole,
"auto_signup_enabled": s.AutoSignupEnabled,
"domain_verified": s.DomainVerified,
"domain_verification_token": s.DomainVerificationToken,
"domain_verified_at": s.DomainVerifiedAt,
"created_at": s.CreatedAt,
@@ -292,7 +336,6 @@ SET
attribute_lastname = @attribute_lastname,
attribute_role = @attribute_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
@@ -315,7 +358,6 @@ WHERE
"attribute_lastname": s.AttributeLastname,
"attribute_role": s.AttributeRole,
"auto_signup_enabled": s.AutoSignupEnabled,
"domain_verified": s.DomainVerified,
"domain_verification_token": s.DomainVerificationToken,
"domain_verified_at": s.DomainVerifiedAt,
"updated_at": s.UpdatedAt,
@@ -377,7 +419,6 @@ SELECT
attribute_lastname,
attribute_role,
auto_signup_enabled,
domain_verified,
domain_verification_token,
domain_verified_at,
created_at,
@@ -410,122 +451,6 @@ ORDER BY email_domain ASC;
return 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,
enforcement_policy,
idp_entity_id,
idp_sso_url,
idp_certificate,
idp_metadata_url,
attribute_email,
attribute_firstname,
attribute_lastname,
attribute_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
}
// LoadSAMLConfigurationsByOrganizationIDsAndEmailDomain loads SAML configurations for multiple organizations
// and a given email domain in a single query. This is used to avoid N+1 queries.
func LoadSAMLConfigurationsByOrganizationIDsAndEmailDomain(
ctx context.Context,
conn pg.Conn,
organizationIDs []gid.GID,
emailDomain string,
) (map[gid.GID]*SAMLConfiguration, error) {
if len(organizationIDs) == 0 {
return make(map[gid.GID]*SAMLConfiguration), nil
}
q := `
SELECT
id,
organization_id,
email_domain,
enforcement_policy,
idp_entity_id,
idp_sso_url,
idp_certificate,
idp_metadata_url,
attribute_email,
attribute_firstname,
attribute_lastname,
attribute_role,
auto_signup_enabled,
domain_verified,
domain_verification_token,
domain_verified_at,
created_at,
updated_at
FROM
auth_saml_configurations
WHERE
organization_id = ANY(@organization_ids)
AND email_domain = @email_domain
`
args := pgx.StrictNamedArgs{
"organization_ids": organizationIDs,
"email_domain": emailDomain,
}
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(map[gid.GID]*SAMLConfiguration, len(configs))
for i := range configs {
result[configs[i].OrganizationID] = &configs[i]
}
return result, nil
}
func (s *SAMLConfigurations) CountByOrganizationID(
ctx context.Context,
conn pg.Conn,
@@ -560,3 +485,50 @@ WHERE
return count, nil
}
func (s *SAMLConfigurations) LoadUnverified(
ctx context.Context,
conn pg.Conn,
) error {
q := `
SELECT
id,
organization_id,
email_domain,
enforcement_policy,
idp_entity_id,
idp_sso_url,
idp_certificate,
idp_metadata_url,
attribute_email,
attribute_firstname,
attribute_lastname,
attribute_role,
auto_signup_enabled,
domain_verification_token,
domain_verified_at,
created_at,
updated_at
FROM
auth_saml_configurations
WHERE
domain_verified_at IS NULL
AND domain_verification_token IS NOT NULL
ORDER BY created_at ASC
LIMIT 100;
`
rows, err := conn.Query(ctx, q)
if err != nil {
return fmt.Errorf("cannot query unverified auth_saml_configurations: %w", err)
}
configs, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[SAMLConfiguration])
if err != nil {
return fmt.Errorf("cannot collect unverified saml_configurations: %w", err)
}
*s = configs
return nil
}

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) {

View File

@@ -126,8 +126,10 @@ func New() *Implm {
InvitationConfirmationTokenValidity: 3600,
PasswordResetTokenValidity: 3600,
SAML: samlConfig{
SessionDuration: 604800,
CleanupIntervalSeconds: 86400,
SessionDuration: 604800,
CleanupIntervalSeconds: 86400,
DomainVerificationIntervalSeconds: 60,
DomainVerificationResolverAddr: "8.8.8.8:53",
},
},
TrustAuth: trustAuthConfig{
@@ -313,17 +315,20 @@ func (impl *Implm) Run(
fileManagerService,
hp,
iam.Config{
DisableSignup: impl.cfg.Auth.DisableSignup,
InvitationTokenValidity: time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity) * time.Second,
PasswordResetTokenValidity: time.Duration(impl.cfg.Auth.PasswordResetTokenValidity) * time.Second,
SessionDuration: time.Duration(impl.cfg.Auth.Cookie.Duration) * time.Hour,
Bucket: impl.cfg.AWS.Bucket,
TokenSecret: impl.cfg.Auth.Cookie.Secret,
BaseURL: impl.cfg.BaseURL.String(),
EncryptionKey: impl.cfg.EncryptionKey,
Certificate: samlCert,
PrivateKey: samlKey,
Logger: l.Named("iam"),
DisableSignup: impl.cfg.Auth.DisableSignup,
InvitationTokenValidity: time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity) * time.Second,
PasswordResetTokenValidity: time.Duration(impl.cfg.Auth.PasswordResetTokenValidity) * time.Second,
SessionDuration: time.Duration(impl.cfg.Auth.Cookie.Duration) * time.Hour,
Bucket: impl.cfg.AWS.Bucket,
TokenSecret: impl.cfg.Auth.Cookie.Secret,
BaseURL: impl.cfg.BaseURL.String(),
EncryptionKey: impl.cfg.EncryptionKey,
Certificate: samlCert,
PrivateKey: samlKey,
Logger: l.Named("iam"),
TracerProvider: tp,
DomainVerificationInterval: impl.cfg.Auth.SAML.DomainVerificationInterval(),
DomainVerificationResolverAddr: impl.cfg.Auth.SAML.DomainVerificationResolverAddr,
},
)
if err != nil {
@@ -494,11 +499,9 @@ func (impl *Implm) Run(
iamServiceCtx, stopIAMService := context.WithCancel(context.Background())
wg.Go(
func() {
if err := iamService.Run(iamServiceCtx); err != nil {
cancel(fmt.Errorf("iam service crashed: %w", err))
}
},
)

View File

@@ -19,10 +19,12 @@ import (
)
type samlConfig struct {
SessionDuration int `json:"session-duration"`
CleanupIntervalSeconds int `json:"cleanup-interval-seconds"`
Certificate string `json:"certificate"`
PrivateKey string `json:"private-key"`
SessionDuration int `json:"session-duration"`
CleanupIntervalSeconds int `json:"cleanup-interval-seconds"`
Certificate string `json:"certificate"`
PrivateKey string `json:"private-key"`
DomainVerificationIntervalSeconds int `json:"domain-verification-interval-seconds"`
DomainVerificationResolverAddr string `json:"domain-verification-resolver-addr"`
}
func (c samlConfig) SessionDurationTime() time.Duration {
@@ -36,5 +38,10 @@ func (c samlConfig) CleanupInterval() time.Duration {
if c.CleanupIntervalSeconds == 0 {
return 0
}
return time.Duration(c.CleanupIntervalSeconds) * time.Second
}
func (c samlConfig) DomainVerificationInterval() time.Duration {
return time.Duration(c.DomainVerificationIntervalSeconds) * time.Second
}

View File

@@ -318,9 +318,7 @@ type SessionPolicy {
type SAMLConfiguration implements Node {
id: ID!
emailDomain: String!
enabled: Boolean!
enforcementPolicy: SAMLEnforcementPolicy!
domainVerified: Boolean!
domainVerifiedAt: Datetime
domainVerificationToken: String
idpEntityId: String!

View File

@@ -355,10 +355,8 @@ type ComplexityRoot struct {
CreatedAt func(childComplexity int) int
DefaultPermissions func(childComplexity int) int
DomainVerificationToken func(childComplexity int) int
DomainVerified func(childComplexity int) int
DomainVerifiedAt func(childComplexity int) int
EmailDomain func(childComplexity int) int
Enabled func(childComplexity int) int
EnforcementPolicy func(childComplexity int) int
ID func(childComplexity int) int
IdpCertificate func(childComplexity int) int
@@ -1733,12 +1731,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
}
return e.complexity.SAMLConfiguration.DomainVerificationToken(childComplexity), true
case "SAMLConfiguration.domainVerified":
if e.complexity.SAMLConfiguration.DomainVerified == nil {
break
}
return e.complexity.SAMLConfiguration.DomainVerified(childComplexity), true
case "SAMLConfiguration.domainVerifiedAt":
if e.complexity.SAMLConfiguration.DomainVerifiedAt == nil {
break
@@ -1751,12 +1743,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
}
return e.complexity.SAMLConfiguration.EmailDomain(childComplexity), true
case "SAMLConfiguration.enabled":
if e.complexity.SAMLConfiguration.Enabled == nil {
break
}
return e.complexity.SAMLConfiguration.Enabled(childComplexity), true
case "SAMLConfiguration.enforcementPolicy":
if e.complexity.SAMLConfiguration.EnforcementPolicy == nil {
break
@@ -2484,9 +2470,7 @@ type SessionPolicy {
type SAMLConfiguration implements Node {
id: ID!
emailDomain: String!
enabled: Boolean!
enforcementPolicy: SAMLEnforcementPolicy!
domainVerified: Boolean!
domainVerifiedAt: Datetime
domainVerificationToken: String
idpEntityId: String!
@@ -10090,35 +10074,6 @@ func (ec *executionContext) fieldContext_SAMLConfiguration_emailDomain(_ context
return fc, nil
}
func (ec *executionContext) _SAMLConfiguration_enabled(ctx context.Context, field graphql.CollectedField, obj *types.SAMLConfiguration) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_SAMLConfiguration_enabled,
func(ctx context.Context) (any, error) {
return obj.Enabled, nil
},
nil,
ec.marshalNBoolean2bool,
true,
true,
)
}
func (ec *executionContext) fieldContext_SAMLConfiguration_enabled(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "SAMLConfiguration",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Boolean does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _SAMLConfiguration_enforcementPolicy(ctx context.Context, field graphql.CollectedField, obj *types.SAMLConfiguration) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
@@ -10148,35 +10103,6 @@ func (ec *executionContext) fieldContext_SAMLConfiguration_enforcementPolicy(_ c
return fc, nil
}
func (ec *executionContext) _SAMLConfiguration_domainVerified(ctx context.Context, field graphql.CollectedField, obj *types.SAMLConfiguration) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_SAMLConfiguration_domainVerified,
func(ctx context.Context) (any, error) {
return obj.DomainVerified, nil
},
nil,
ec.marshalNBoolean2bool,
true,
true,
)
}
func (ec *executionContext) fieldContext_SAMLConfiguration_domainVerified(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "SAMLConfiguration",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Boolean does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _SAMLConfiguration_domainVerifiedAt(ctx context.Context, field graphql.CollectedField, obj *types.SAMLConfiguration) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
@@ -10672,12 +10598,8 @@ func (ec *executionContext) fieldContext_SAMLConfigurationEdge_node(_ context.Co
return ec.fieldContext_SAMLConfiguration_id(ctx, field)
case "emailDomain":
return ec.fieldContext_SAMLConfiguration_emailDomain(ctx, field)
case "enabled":
return ec.fieldContext_SAMLConfiguration_enabled(ctx, field)
case "enforcementPolicy":
return ec.fieldContext_SAMLConfiguration_enforcementPolicy(ctx, field)
case "domainVerified":
return ec.fieldContext_SAMLConfiguration_domainVerified(ctx, field)
case "domainVerifiedAt":
return ec.fieldContext_SAMLConfiguration_domainVerifiedAt(ctx, field)
case "domainVerificationToken":
@@ -11739,12 +11661,8 @@ func (ec *executionContext) fieldContext_UpdateSAMLConfigurationPayload_samlConf
return ec.fieldContext_SAMLConfiguration_id(ctx, field)
case "emailDomain":
return ec.fieldContext_SAMLConfiguration_emailDomain(ctx, field)
case "enabled":
return ec.fieldContext_SAMLConfiguration_enabled(ctx, field)
case "enforcementPolicy":
return ec.fieldContext_SAMLConfiguration_enforcementPolicy(ctx, field)
case "domainVerified":
return ec.fieldContext_SAMLConfiguration_domainVerified(ctx, field)
case "domainVerifiedAt":
return ec.fieldContext_SAMLConfiguration_domainVerifiedAt(ctx, field)
case "domainVerificationToken":
@@ -17312,21 +17230,11 @@ func (ec *executionContext) _SAMLConfiguration(ctx context.Context, sel ast.Sele
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "enabled":
out.Values[i] = ec._SAMLConfiguration_enabled(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "enforcementPolicy":
out.Values[i] = ec._SAMLConfiguration_enforcementPolicy(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "domainVerified":
out.Values[i] = ec._SAMLConfiguration_domainVerified(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "domainVerifiedAt":
out.Values[i] = ec._SAMLConfiguration_domainVerifiedAt(ctx, field, obj)
case "domainVerificationToken":

View File

@@ -64,12 +64,12 @@ func NewSAMLConfiguration(samlConfiguration *coredata.SAMLConfiguration) *SAMLCo
ID: samlConfiguration.ID,
EmailDomain: samlConfiguration.EmailDomain,
EnforcementPolicy: samlConfiguration.EnforcementPolicy,
DomainVerified: samlConfiguration.DomainVerified,
DomainVerifiedAt: samlConfiguration.DomainVerifiedAt,
DomainVerificationToken: samlConfiguration.DomainVerificationToken,
IdpEntityID: samlConfiguration.IdPEntityID,
IdpSsoURL: samlConfiguration.IdPSsoURL,
IdpCertificate: samlConfiguration.IdPCertificate,
AutoSignupEnabled: samlConfiguration.AutoSignupEnabled,
CreatedAt: samlConfiguration.CreatedAt,
UpdatedAt: samlConfiguration.UpdatedAt,
AttributeMappings: &SAMLAttributeMappings{

View File

@@ -400,9 +400,7 @@ func (SAMLAuthenticationRequired) IsAssumeOrganizationSessionResult() {}
type SAMLConfiguration struct {
ID gid.GID `json:"id"`
EmailDomain string `json:"emailDomain"`
Enabled bool `json:"enabled"`
EnforcementPolicy coredata.SAMLEnforcementPolicy `json:"enforcementPolicy"`
DomainVerified bool `json:"domainVerified"`
DomainVerifiedAt *time.Time `json:"domainVerifiedAt,omitempty"`
DomainVerificationToken *string `json:"domainVerificationToken,omitempty"`
IdpEntityID string `json:"idpEntityId"`