Add saml domain verification
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
5
pkg/coredata/migrations/20251219T220314Z.sql
Normal file
5
pkg/coredata/migrations/20251219T220314Z.sql
Normal 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);
|
||||
3
pkg/coredata/migrations/20251219T230719Z.sql
Normal file
3
pkg/coredata/migrations/20251219T230719Z.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
CREATE UNIQUE INDEX idx_saml_config_domain_verification_token_unique
|
||||
ON auth_saml_configurations(domain_verification_token);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
206
pkg/iam/saml_domain_verifier.go
Normal file
206
pkg/iam/saml_domain_verifier.go
Normal 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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user