diff --git a/pkg/config/auth.go b/pkg/config/auth.go index 9f428184c..12c7ecc79 100644 --- a/pkg/config/auth.go +++ b/pkg/config/auth.go @@ -189,6 +189,7 @@ func (a *auth) ToUpdateAuthConfigBody() v1API.UpdateAuthConfigBody { DisableSignup: cast.Ptr(!a.EnableSignup), ExternalAnonymousUsersEnabled: &a.EnableAnonymousSignIns, } + a.Sms.toAuthConfigBody(&body) return body } @@ -202,18 +203,152 @@ func (a *auth) fromRemoteAuthConfig(remoteConfig v1API.AuthConfigResponse) auth result.EnableManualLinking = cast.Val(remoteConfig.SecurityManualLinkingEnabled, false) result.EnableSignup = !cast.Val(remoteConfig.DisableSignup, false) result.EnableAnonymousSignIns = cast.Val(remoteConfig.ExternalAnonymousUsersEnabled, false) + result.Sms.fromAuthConfig(remoteConfig) return result } -func (a *auth) DiffWithRemote(remoteConfig v1API.AuthConfigResponse) ([]byte, error) { +func (s sms) toAuthConfigBody(body *v1API.UpdateAuthConfigBody) { + body.ExternalPhoneEnabled = &s.EnableSignup + body.SmsMaxFrequency = cast.Ptr(int(s.MaxFrequency.Seconds())) + body.SmsAutoconfirm = &s.EnableConfirmations + body.SmsTemplate = &s.Template + if otpString := mapToEnv(s.TestOTP); len(otpString) > 0 { + body.SmsTestOtp = &otpString + // Set a 10 year validity for test OTP + timestamp := time.Now().UTC().AddDate(10, 0, 0).Format(time.RFC3339) + body.SmsTestOtpValidUntil = ×tamp + } + // Api only overrides configs of enabled providers + switch { + case s.Twilio.Enabled: + body.SmsProvider = cast.Ptr("twilio") + body.SmsTwilioAuthToken = &s.Twilio.AuthToken + body.SmsTwilioAccountSid = &s.Twilio.AccountSid + body.SmsTwilioMessageServiceSid = &s.Twilio.MessageServiceSid + case s.TwilioVerify.Enabled: + body.SmsProvider = cast.Ptr("twilio_verify") + body.SmsTwilioVerifyAuthToken = &s.TwilioVerify.AuthToken + body.SmsTwilioVerifyAccountSid = &s.TwilioVerify.AccountSid + body.SmsTwilioVerifyMessageServiceSid = &s.TwilioVerify.MessageServiceSid + case s.Messagebird.Enabled: + body.SmsProvider = cast.Ptr("messagebird") + body.SmsMessagebirdAccessKey = &s.Messagebird.AccessKey + body.SmsMessagebirdOriginator = &s.Messagebird.Originator + case s.Textlocal.Enabled: + body.SmsProvider = cast.Ptr("textlocal") + body.SmsTextlocalApiKey = &s.Textlocal.ApiKey + body.SmsTextlocalSender = &s.Textlocal.Sender + case s.Vonage.Enabled: + body.SmsProvider = cast.Ptr("vonage") + body.SmsVonageApiSecret = &s.Vonage.ApiSecret + body.SmsVonageApiKey = &s.Vonage.ApiKey + body.SmsVonageFrom = &s.Vonage.From + } +} + +func (s *sms) fromAuthConfig(remoteConfig v1API.AuthConfigResponse) { + s.EnableSignup = cast.Val(remoteConfig.ExternalPhoneEnabled, false) + s.MaxFrequency = time.Duration(cast.Val(remoteConfig.SmsMaxFrequency, 0)) * time.Second + s.EnableConfirmations = cast.Val(remoteConfig.SmsAutoconfirm, false) + s.Template = cast.Val(remoteConfig.SmsTemplate, "") + s.TestOTP = envToMap(cast.Val(remoteConfig.SmsTestOtp, "")) + // We are only interested in the provider that's enabled locally + switch { + case s.Twilio.Enabled: + s.Twilio.AuthToken = hashPrefix + cast.Val(remoteConfig.SmsTwilioAuthToken, "") + s.Twilio.AccountSid = cast.Val(remoteConfig.SmsTwilioAccountSid, "") + s.Twilio.MessageServiceSid = cast.Val(remoteConfig.SmsTwilioMessageServiceSid, "") + case s.TwilioVerify.Enabled: + s.TwilioVerify.AuthToken = hashPrefix + cast.Val(remoteConfig.SmsTwilioVerifyAuthToken, "") + s.TwilioVerify.AccountSid = cast.Val(remoteConfig.SmsTwilioVerifyAccountSid, "") + s.TwilioVerify.MessageServiceSid = cast.Val(remoteConfig.SmsTwilioVerifyMessageServiceSid, "") + case s.Messagebird.Enabled: + s.Messagebird.AccessKey = hashPrefix + cast.Val(remoteConfig.SmsMessagebirdAccessKey, "") + s.Messagebird.Originator = cast.Val(remoteConfig.SmsMessagebirdOriginator, "") + case s.Textlocal.Enabled: + s.Textlocal.ApiKey = hashPrefix + cast.Val(remoteConfig.SmsTextlocalApiKey, "") + s.Textlocal.Sender = cast.Val(remoteConfig.SmsTextlocalSender, "") + case s.Vonage.Enabled: + s.Vonage.ApiSecret = hashPrefix + cast.Val(remoteConfig.SmsVonageApiSecret, "") + s.Vonage.ApiKey = cast.Val(remoteConfig.SmsVonageApiKey, "") + s.Vonage.From = cast.Val(remoteConfig.SmsVonageFrom, "") + case !s.EnableSignup: + // Nothing to do if both local and remote providers are disabled. + return + } + if provider := cast.Val(remoteConfig.SmsProvider, ""); len(provider) > 0 { + s.Twilio.Enabled = provider == "twilio" + s.TwilioVerify.Enabled = provider == "twilio_verify" + s.Messagebird.Enabled = provider == "messagebird" + s.Textlocal.Enabled = provider == "textlocal" + s.Vonage.Enabled = provider == "vonage" + } +} + +func (a *auth) DiffWithRemote(projectRef string, remoteConfig v1API.AuthConfigResponse) ([]byte, error) { + hashed := a.hashSecrets(projectRef) // Convert the config values into easily comparable remoteConfig values - currentValue, err := ToTomlBytes(a) + currentValue, err := ToTomlBytes(hashed) if err != nil { return nil, err } - remoteCompare, err := ToTomlBytes(a.fromRemoteAuthConfig(remoteConfig)) + remoteCompare, err := ToTomlBytes(hashed.fromRemoteAuthConfig(remoteConfig)) if err != nil { return nil, err } return diff.Diff("remote[auth]", remoteCompare, "local[auth]", currentValue), nil } + +const hashPrefix = "hash:" + +func (a *auth) hashSecrets(key string) auth { + hash := func(v string) string { + return hashPrefix + sha256Hmac(key, v) + } + result := *a + if len(result.Email.Smtp.Pass) > 0 { + result.Email.Smtp.Pass = hash(result.Email.Smtp.Pass) + } + // Only hash secrets for locally enabled providers because other envs won't be loaded + switch { + case result.Sms.Twilio.Enabled: + result.Sms.Twilio.AuthToken = hash(result.Sms.Twilio.AuthToken) + case result.Sms.TwilioVerify.Enabled: + result.Sms.TwilioVerify.AuthToken = hash(result.Sms.TwilioVerify.AuthToken) + case result.Sms.Messagebird.Enabled: + result.Sms.Messagebird.AccessKey = hash(result.Sms.Messagebird.AccessKey) + case result.Sms.Textlocal.Enabled: + result.Sms.Textlocal.ApiKey = hash(result.Sms.Textlocal.ApiKey) + case result.Sms.Vonage.Enabled: + result.Sms.Vonage.ApiSecret = hash(result.Sms.Vonage.ApiSecret) + } + if result.Hook.MFAVerificationAttempt.Enabled { + result.Hook.MFAVerificationAttempt.Secrets = hash(result.Hook.MFAVerificationAttempt.Secrets) + } + if result.Hook.PasswordVerificationAttempt.Enabled { + result.Hook.PasswordVerificationAttempt.Secrets = hash(result.Hook.PasswordVerificationAttempt.Secrets) + } + if result.Hook.CustomAccessToken.Enabled { + result.Hook.CustomAccessToken.Secrets = hash(result.Hook.CustomAccessToken.Secrets) + } + if result.Hook.SendSMS.Enabled { + result.Hook.SendSMS.Secrets = hash(result.Hook.SendSMS.Secrets) + } + if result.Hook.SendEmail.Enabled { + result.Hook.SendEmail.Secrets = hash(result.Hook.SendEmail.Secrets) + } + if size := len(a.External); size > 0 { + result.External = make(map[string]provider, size) + } + for name, provider := range a.External { + if provider.Enabled { + provider.Secret = hash(provider.Secret) + } + result.External[name] = provider + } + // Hide deprecated fields + delete(result.External, "slack") + delete(result.External, "linkedin") + // TODO: support SecurityCaptchaSecret in local config + return result +} diff --git a/pkg/config/auth_test.go b/pkg/config/auth_test.go new file mode 100644 index 000000000..548253bda --- /dev/null +++ b/pkg/config/auth_test.go @@ -0,0 +1,214 @@ +package config + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + v1API "github.com/supabase/cli/pkg/api" + "github.com/supabase/cli/pkg/cast" +) + +func TestSmsDiff(t *testing.T) { + t.Run("local enabled remote enabled", func(t *testing.T) { + c := auth{EnableSignup: true, Sms: sms{ + EnableSignup: true, + EnableConfirmations: true, + Template: "Your code is {{ .Code }}", + TestOTP: map[string]string{"123": "456"}, + MaxFrequency: time.Minute, + Twilio: twilioConfig{ + Enabled: true, + AccountSid: "test-account", + MessageServiceSid: "test-service", + AuthToken: "test-token", + }, + }} + // Run test + diff, err := c.DiffWithRemote("", v1API.AuthConfigResponse{ + ExternalPhoneEnabled: cast.Ptr(true), + SmsAutoconfirm: cast.Ptr(true), + SmsMaxFrequency: cast.Ptr(60), + SmsOtpExp: cast.Ptr(3600), + SmsOtpLength: 6, + SmsProvider: cast.Ptr("twilio"), + SmsTemplate: cast.Ptr("Your code is {{ .Code }}"), + SmsTestOtp: cast.Ptr("123=456"), + SmsTestOtpValidUntil: cast.Ptr("2050-01-01T01:00:00Z"), + SmsTwilioAccountSid: cast.Ptr("test-account"), + SmsTwilioAuthToken: cast.Ptr("c84443bc59b92caef8ec8500ff443584793756749523811eb333af2bbc74fc88"), + SmsTwilioContentSid: cast.Ptr("test-content"), + SmsTwilioMessageServiceSid: cast.Ptr("test-service"), + // Extra configs returned from api can be ignored + SmsMessagebirdAccessKey: cast.Ptr("test-messagebird-key"), + SmsMessagebirdOriginator: cast.Ptr("test-messagebird-originator"), + SmsTextlocalApiKey: cast.Ptr("test-textlocal-key"), + SmsTextlocalSender: cast.Ptr("test-textlocal-sencer"), + SmsTwilioVerifyAccountSid: cast.Ptr("test-verify-account"), + SmsTwilioVerifyAuthToken: cast.Ptr("test-verify-token"), + SmsTwilioVerifyMessageServiceSid: cast.Ptr("test-verify-service"), + SmsVonageApiKey: cast.Ptr("test-vonage-key"), + SmsVonageApiSecret: cast.Ptr("test-vonage-secret"), + SmsVonageFrom: cast.Ptr("test-vonage-from"), + }) + // Check error + assert.NoError(t, err) + assert.Empty(t, string(diff)) + }) + + t.Run("local disabled remote enabled", func(t *testing.T) { + c := auth{EnableSignup: true} + // Run test + diff, err := c.DiffWithRemote("", v1API.AuthConfigResponse{ + ExternalPhoneEnabled: cast.Ptr(true), + SmsAutoconfirm: cast.Ptr(true), + SmsMaxFrequency: cast.Ptr(60), + SmsOtpExp: cast.Ptr(3600), + SmsOtpLength: 6, + SmsProvider: cast.Ptr("twilio"), + SmsTemplate: cast.Ptr("Your code is {{ .Code }}"), + SmsTestOtp: cast.Ptr("123=456,456=123"), + SmsTestOtpValidUntil: cast.Ptr("2050-01-01T01:00:00Z"), + SmsTwilioAccountSid: cast.Ptr("test-account"), + SmsTwilioAuthToken: cast.Ptr("c84443bc59b92caef8ec8500ff443584793756749523811eb333af2bbc74fc88"), + SmsTwilioContentSid: cast.Ptr("test-content"), + SmsTwilioMessageServiceSid: cast.Ptr("test-service"), + }) + // Check error + assert.NoError(t, err) + assert.Contains(t, string(diff), `-enable_signup = true`) + assert.Contains(t, string(diff), `-enable_confirmations = true`) + assert.Contains(t, string(diff), `-template = "Your code is {{ .Code }}"`) + assert.Contains(t, string(diff), `-max_frequency = "1m0s"`) + + assert.Contains(t, string(diff), `+enable_signup = false`) + assert.Contains(t, string(diff), `+enable_confirmations = false`) + assert.Contains(t, string(diff), `+template = ""`) + assert.Contains(t, string(diff), `+max_frequency = "0s"`) + + assert.Contains(t, string(diff), `[sms.twilio]`) + assert.Contains(t, string(diff), `-enabled = true`) + assert.Contains(t, string(diff), `+enabled = false`) + + assert.Contains(t, string(diff), `-[sms.test_otp]`) + assert.Contains(t, string(diff), `-123 = "456"`) + assert.Contains(t, string(diff), `-456 = "123"`) + }) + + t.Run("local enabled remote disabled", func(t *testing.T) { + c := auth{EnableSignup: true, Sms: sms{ + EnableSignup: true, + EnableConfirmations: true, + Template: "Your code is {{ .Code }}", + TestOTP: map[string]string{"123": "456"}, + MaxFrequency: time.Minute, + Messagebird: messagebirdConfig{ + Enabled: true, + Originator: "test-originator", + AccessKey: "test-access-key", + }, + }} + // Run test + diff, err := c.DiffWithRemote("", v1API.AuthConfigResponse{ + ExternalPhoneEnabled: cast.Ptr(false), + SmsAutoconfirm: cast.Ptr(false), + SmsMaxFrequency: cast.Ptr(0), + SmsOtpExp: cast.Ptr(3600), + SmsOtpLength: 6, + SmsProvider: cast.Ptr("twilio"), + SmsTemplate: cast.Ptr(""), + SmsTwilioAccountSid: cast.Ptr("test-account"), + SmsTwilioAuthToken: cast.Ptr("c84443bc59b92caef8ec8500ff443584793756749523811eb333af2bbc74fc88"), + SmsTwilioContentSid: cast.Ptr("test-content"), + SmsTwilioMessageServiceSid: cast.Ptr("test-service"), + }) + // Check error + assert.NoError(t, err) + assert.Contains(t, string(diff), `-enable_signup = false`) + assert.Contains(t, string(diff), `-enable_confirmations = false`) + assert.Contains(t, string(diff), `-template = ""`) + assert.Contains(t, string(diff), `-max_frequency = "0s"`) + + assert.Contains(t, string(diff), `+enable_signup = true`) + assert.Contains(t, string(diff), `+enable_confirmations = true`) + assert.Contains(t, string(diff), `+template = "Your code is {{ .Code }}"`) + assert.Contains(t, string(diff), `+max_frequency = "1m0s"`) + + assert.Contains(t, string(diff), `[sms.twilio]`) + assert.Contains(t, string(diff), `-enabled = true`) + assert.Contains(t, string(diff), `+enabled = false`) + + assert.Contains(t, string(diff), `[sms.messagebird]`) + assert.Contains(t, string(diff), `-enabled = false`) + assert.Contains(t, string(diff), `-originator = ""`) + assert.Contains(t, string(diff), `-access_key = "hash:"`) + assert.Contains(t, string(diff), `+enabled = true`) + assert.Contains(t, string(diff), `+originator = "test-originator"`) + assert.Contains(t, string(diff), `+access_key = "hash:ab60d03fc809fb02dae838582f3ddc13d1d6cb32ffba77c4b969dd3caa496f13"`) + + assert.Contains(t, string(diff), `+[sms.test_otp]`) + assert.Contains(t, string(diff), `+123 = "456"`) + }) + + t.Run("local disabled remote disabled", func(t *testing.T) { + c := auth{EnableSignup: true, Sms: sms{ + EnableSignup: false, + EnableConfirmations: true, + Template: "Your code is {{ .Code }}", + TestOTP: map[string]string{"123": "456"}, + MaxFrequency: time.Minute, + }} + // Run test + diff, err := c.DiffWithRemote("", v1API.AuthConfigResponse{ + ExternalPhoneEnabled: cast.Ptr(false), + SmsAutoconfirm: cast.Ptr(true), + SmsMaxFrequency: cast.Ptr(60), + SmsOtpExp: cast.Ptr(3600), + SmsOtpLength: 6, + SmsTemplate: cast.Ptr("Your code is {{ .Code }}"), + SmsTestOtp: cast.Ptr("123=456"), + SmsTestOtpValidUntil: cast.Ptr("2050-01-01T01:00:00Z"), + SmsProvider: cast.Ptr("messagebird"), + SmsMessagebirdAccessKey: cast.Ptr("test-messagebird-key"), + SmsMessagebirdOriginator: cast.Ptr("test-messagebird-originator"), + }) + // Check error + assert.NoError(t, err) + assert.Empty(t, string(diff)) + }) + + t.Run("enable sign up without provider", func(t *testing.T) { + // This is not a valid config because platform requires a SMS provider. + // For consistency, we handle this in config.Load and emit a warning. + c := auth{EnableSignup: true, Sms: sms{ + EnableSignup: true, + }} + // Run test + diff, err := c.DiffWithRemote("", v1API.AuthConfigResponse{ + ExternalPhoneEnabled: cast.Ptr(false), + SmsProvider: cast.Ptr("twilio"), + }) + // Check error + assert.NoError(t, err) + assert.Contains(t, string(diff), `[sms]`) + assert.Contains(t, string(diff), `-enable_signup = false`) + assert.Contains(t, string(diff), `+enable_signup = true`) + }) + + t.Run("enable provider without sign up", func(t *testing.T) { + c := auth{EnableSignup: true, Sms: sms{ + Messagebird: messagebirdConfig{ + Enabled: true, + }, + }} + // Run test + diff, err := c.DiffWithRemote("", v1API.AuthConfigResponse{ + ExternalPhoneEnabled: cast.Ptr(false), + SmsProvider: cast.Ptr("messagebird"), + SmsMessagebirdAccessKey: cast.Ptr("b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad"), + }) + // Check error + assert.NoError(t, err) + assert.Empty(t, string(diff)) + }) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index b9056b095..5f167386b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -553,7 +553,7 @@ func (c *baseConfig) Validate(fsys fs.FS) error { if c.ProjectId == "" { return errors.New("Missing required field in config: project_id") } else if sanitized := sanitizeProjectId(c.ProjectId); sanitized != c.ProjectId { - fmt.Fprintln(os.Stderr, "WARNING:", "project_id field in config is invalid. Auto-fixing to", sanitized) + fmt.Fprintln(os.Stderr, "WARN: project_id field in config is invalid. Auto-fixing to", sanitized) c.ProjectId = sanitized } // Validate api config @@ -665,7 +665,8 @@ func (c *baseConfig) Validate(fsys fs.FS) error { return err } // Validate sms config - if c.Auth.Sms.Twilio.Enabled { + switch { + case c.Auth.Sms.Twilio.Enabled: if len(c.Auth.Sms.Twilio.AccountSid) == 0 { return errors.New("Missing required field in config: auth.sms.twilio.account_sid") } @@ -678,8 +679,7 @@ func (c *baseConfig) Validate(fsys fs.FS) error { if c.Auth.Sms.Twilio.AuthToken, err = maybeLoadEnv(c.Auth.Sms.Twilio.AuthToken); err != nil { return err } - } - if c.Auth.Sms.TwilioVerify.Enabled { + case c.Auth.Sms.TwilioVerify.Enabled: if len(c.Auth.Sms.TwilioVerify.AccountSid) == 0 { return errors.New("Missing required field in config: auth.sms.twilio_verify.account_sid") } @@ -692,8 +692,7 @@ func (c *baseConfig) Validate(fsys fs.FS) error { if c.Auth.Sms.TwilioVerify.AuthToken, err = maybeLoadEnv(c.Auth.Sms.TwilioVerify.AuthToken); err != nil { return err } - } - if c.Auth.Sms.Messagebird.Enabled { + case c.Auth.Sms.Messagebird.Enabled: if len(c.Auth.Sms.Messagebird.Originator) == 0 { return errors.New("Missing required field in config: auth.sms.messagebird.originator") } @@ -703,8 +702,7 @@ func (c *baseConfig) Validate(fsys fs.FS) error { if c.Auth.Sms.Messagebird.AccessKey, err = maybeLoadEnv(c.Auth.Sms.Messagebird.AccessKey); err != nil { return err } - } - if c.Auth.Sms.Textlocal.Enabled { + case c.Auth.Sms.Textlocal.Enabled: if len(c.Auth.Sms.Textlocal.Sender) == 0 { return errors.New("Missing required field in config: auth.sms.textlocal.sender") } @@ -714,8 +712,7 @@ func (c *baseConfig) Validate(fsys fs.FS) error { if c.Auth.Sms.Textlocal.ApiKey, err = maybeLoadEnv(c.Auth.Sms.Textlocal.ApiKey); err != nil { return err } - } - if c.Auth.Sms.Vonage.Enabled { + case c.Auth.Sms.Vonage.Enabled: if len(c.Auth.Sms.Vonage.From) == 0 { return errors.New("Missing required field in config: auth.sms.vonage.from") } @@ -731,6 +728,9 @@ func (c *baseConfig) Validate(fsys fs.FS) error { if c.Auth.Sms.Vonage.ApiSecret, err = maybeLoadEnv(c.Auth.Sms.Vonage.ApiSecret); err != nil { return err } + case c.Auth.Sms.EnableSignup: + c.Auth.Sms.EnableSignup = false + fmt.Fprintln(os.Stderr, "WARN: no SMS provider is enabled. Disabling phone login") } if err := c.Auth.Hook.MFAVerificationAttempt.HandleHook("mfa_verification_attempt"); err != nil { return err @@ -895,7 +895,7 @@ func (c *seed) loadSeedPaths(basePath string, fsys fs.FS) error { return errors.Errorf("failed to apply glob pattern: %w", err) } if len(matches) == 0 { - fmt.Fprintln(os.Stderr, "No seed files matched pattern:", pattern) + fmt.Fprintln(os.Stderr, "WARN: no seed files matched pattern:", pattern) } sort.Strings(matches) // Remove duplicates diff --git a/pkg/config/templates/config.toml b/pkg/config/templates/config.toml index e47487c21..8df89bea3 100644 --- a/pkg/config/templates/config.toml +++ b/pkg/config/templates/config.toml @@ -142,7 +142,7 @@ otp_expiry = 3600 [auth.sms] # Allow/disallow new user signups via SMS to your project. -enable_signup = true +enable_signup = false # If enabled, users need to confirm their phone number before signing in. enable_confirmations = false # Template for sending OTP to users diff --git a/pkg/config/updater.go b/pkg/config/updater.go index 8dfcaa66e..466b37c1b 100644 --- a/pkg/config/updater.go +++ b/pkg/config/updater.go @@ -108,7 +108,7 @@ func (u *ConfigUpdater) UpdateAuthConfig(ctx context.Context, projectRef string, } else if authConfig.JSON200 == nil { return errors.Errorf("unexpected status %d: %s", authConfig.StatusCode(), string(authConfig.Body)) } - authDiff, err := c.DiffWithRemote(*authConfig.JSON200) + authDiff, err := c.DiffWithRemote(projectRef, *authConfig.JSON200) if err != nil { return err } else if len(authDiff) == 0 { diff --git a/pkg/config/utils.go b/pkg/config/utils.go index 3101419eb..2dd9c1aae 100644 --- a/pkg/config/utils.go +++ b/pkg/config/utils.go @@ -1,6 +1,10 @@ package config import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" "path/filepath" "strings" ) @@ -87,3 +91,32 @@ func strToArr(v string) []string { } return strings.Split(v, ",") } + +func mapToEnv(input map[string]string) string { + var result []string + for k, v := range input { + kv := fmt.Sprintf("%s=%s", k, v) + result = append(result, kv) + } + return strings.Join(result, ",") +} + +func envToMap(input string) map[string]string { + env := strToArr(input) + if len(env) == 0 { + return nil + } + result := make(map[string]string, len(env)) + for _, kv := range env { + if parts := strings.Split(kv, "="); len(parts) > 1 { + result[parts[0]] = parts[1] + } + } + return result +} + +func sha256Hmac(key, value string) string { + h := hmac.New(sha256.New, []byte(key)) + h.Write([]byte(value)) + return hex.EncodeToString(h.Sum(nil)) +}