diff --git a/.gitignore b/.gitignore index 172391fe..f66e8e65 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,4 @@ config.certify.yml /.deepsec # jetbrains -/.idea/ \ No newline at end of file +/.idea/ diff --git a/cmd/tinyauth/config.go b/cmd/tinyauth/config.go index 3c6be1b3..230c167b 100644 --- a/cmd/tinyauth/config.go +++ b/cmd/tinyauth/config.go @@ -1,8 +1,8 @@ package main import ( - "encoding/json" "fmt" + "strings" "github.com/tinyauthapp/paerser/cli" "github.com/tinyauthapp/tinyauth/internal/model" @@ -11,15 +11,21 @@ import ( func configCmd(tconfig *model.Config, loaders []cli.ResourceLoader) *cli.Command { return &cli.Command{ Name: "config", - Description: "Print the configuration of Tinyauth", + Description: "Dump the current configuration in YAML format, useful for debugging", Configuration: tconfig, Resources: loaders, Run: func(_ []string) error { - jsonBytes, err := json.MarshalIndent(tconfig, "", " ") + buf := strings.Builder{} + + fmt.Fprint(&buf, "Your current configuration in YAML is:\n\n") + + err := renderYamlToBuf(&buf, tconfig) + if err != nil { - return fmt.Errorf("failed to marshal configuration: %w", err) + return fmt.Errorf("failed to render yaml config: %w", err) } - fmt.Println(string(jsonBytes)) + + fmt.Print(buf.String()) return nil }, } diff --git a/cmd/tinyauth/create_oidc_client.go b/cmd/tinyauth/create_oidc_client.go index 3a7cb1e2..2ab4f084 100644 --- a/cmd/tinyauth/create_oidc_client.go +++ b/cmd/tinyauth/create_oidc_client.go @@ -7,8 +7,9 @@ import ( "strings" "github.com/google/uuid" - "github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/paerser/cli" + "github.com/tinyauthapp/tinyauth/internal/model" + "github.com/tinyauthapp/tinyauth/internal/utils" ) func createOidcClientCmd() *cli.Command { @@ -31,40 +32,84 @@ func createOidcClientCmd() *cli.Command { return errors.New("client name can only contain alphanumeric characters and hyphens") } - uuid := uuid.New() - clientId := uuid.String() + u := uuid.New() + clientId := u.String() clientSecret := "ta-" + utils.GenerateString(61) uclientName := strings.ToUpper(clientName) lclientName := strings.ToLower(clientName) - builder := strings.Builder{} + buf := strings.Builder{} // header - fmt.Fprintf(&builder, "Created credentials for client %s\n\n", clientName) + fmt.Fprintf(&buf, "Created '%s' OIDC client.\n\n", clientName) // credentials - fmt.Fprintf(&builder, "Client Name: %s\n", clientName) - fmt.Fprintf(&builder, "Client ID: %s\n", clientId) - fmt.Fprintf(&builder, "Client Secret: %s\n\n", clientSecret) + fmt.Fprintf(&buf, "Credentials:\n\n") + fmt.Fprintf(&buf, "Client Name: %s\n", clientName) + fmt.Fprintf(&buf, "Client ID: %s\n", clientId) + fmt.Fprintf(&buf, "Client Secret: %s\n\n", clientSecret) - // env variables - fmt.Fprint(&builder, "Environment variables:\n\n") - fmt.Fprintf(&builder, "TINYAUTH_OIDC_CLIENTS_%s_CLIENTID=%s\n", uclientName, clientId) - fmt.Fprintf(&builder, "TINYAUTH_OIDC_CLIENTS_%s_CLIENTSECRET=%s\n", uclientName, clientSecret) - fmt.Fprintf(&builder, "TINYAUTH_OIDC_CLIENTS_%s_NAME=%s\n\n", uclientName, utils.Capitalize(lclientName)) + // end variables + fmt.Fprintf(&buf, "Environment variables:\n\n") + renderToBuf(&buf, []kv{ + { + k: fmt.Sprintf("TINYAUTH_OIDC_CLIENTS_%s_CLIENTID", uclientName), + v: clientId, + }, + { + k: fmt.Sprintf("TINYAUTH_OIDC_CLIENTS_%s_CLIENTSECRET", uclientName), + v: clientSecret, + }, + { + k: fmt.Sprintf("TINYAUTH_OIDC_CLIENTS_%s_NAME", uclientName), + v: utils.Capitalize(lclientName), + }, + }, "=") + fmt.Fprintf(&buf, "\n") // cli flags - fmt.Fprint(&builder, "CLI flags:\n\n") - fmt.Fprintf(&builder, "--oidc.clients.%s.clientid=%s\n", lclientName, clientId) - fmt.Fprintf(&builder, "--oidc.clients.%s.clientsecret=%s\n", lclientName, clientSecret) - fmt.Fprintf(&builder, "--oidc.clients.%s.name=%s\n\n", lclientName, utils.Capitalize(lclientName)) + fmt.Fprintf(&buf, "CLI flags:\n\n") + renderToBuf(&buf, []kv{ + { + k: fmt.Sprintf("--oidc.clients.%s.clientid", lclientName), + v: clientId, + }, + { + k: fmt.Sprintf("--oidc.clients.%s.clientsecret", lclientName), + v: clientSecret, + }, + { + k: fmt.Sprintf("--oidc.clients.%s.name", lclientName), + v: utils.Capitalize(lclientName), + }, + }, "=") + fmt.Fprintf(&buf, "\n") + + // yaml config + fmt.Fprintf(&buf, "YAML config:\n\n") + + err = renderYamlToBuf(&buf, &model.OIDCConfig{ + Clients: map[string]model.OIDCClientConfig{ + lclientName: { + ClientID: clientId, + ClientSecret: clientSecret, + Name: utils.Capitalize(lclientName), + }, + }, + }) + + if err != nil { + return fmt.Errorf("failed to render yaml config: %w", err) + } + + buf.WriteString("\n") // footer - fmt.Fprintln(&builder, "You can use either option to configure your OIDC client. Make sure to save these credentials as there is no way to regenerate them.") + fmt.Fprintln(&buf, "You can use any of the above options to configure your OIDC client. Make sure to save these credentials as there is no way to regenerate them.") // print - out := builder.String() + out := buf.String() fmt.Print(out) return nil }, diff --git a/cmd/tinyauth/create_user.go b/cmd/tinyauth/create_user.go index d7e9f97e..d5459e99 100644 --- a/cmd/tinyauth/create_user.go +++ b/cmd/tinyauth/create_user.go @@ -3,11 +3,12 @@ package main import ( "errors" "fmt" + "os" "strings" "charm.land/huh/v2" "github.com/tinyauthapp/paerser/cli" - "github.com/tinyauthapp/tinyauth/internal/utils/logger" + "github.com/tinyauthapp/tinyauth/internal/model" "golang.org/x/crypto/bcrypt" ) @@ -34,62 +35,107 @@ func createUserCmd() *cli.Command { &cli.FlagLoader{}, } - return &cli.Command{ + cmd := &cli.Command{ Name: "create", Description: "Create a user", Configuration: tCfg, Resources: loaders, - Run: func(_ []string) error { - log := logger.NewLogger().WithSimpleConfig() - log.Init() - - if tCfg.Interactive { - form := huh.NewForm( - huh.NewGroup( - huh.NewInput().Title("Username").Value(&tCfg.Username).Validate((func(s string) error { - if s == "" { - return errors.New("username cannot be empty") - } - return nil - })), - huh.NewInput().Title("Password").Value(&tCfg.Password).Validate((func(s string) error { - if s == "" { - return errors.New("password cannot be empty") - } - return nil - })), - huh.NewSelect[bool]().Title("Format the output for Docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&tCfg.Docker), - ), - ) - - theme := new(themeBase) - err := form.WithTheme(theme).Run() - - if err != nil { - return fmt.Errorf("failed to run interactive prompt: %w", err) - } - } + } - if tCfg.Username == "" || tCfg.Password == "" { - return errors.New("username and password cannot be empty") + cmd.Run = func(_ []string) error { + if tCfg.Interactive { + form := huh.NewForm( + huh.NewGroup( + huh.NewInput().Title("Username").Value(&tCfg.Username).Validate(func(s string) error { + if s == "" { + return errors.New("username cannot be empty") + } + if strings.Contains(s, ":") { + return errors.New("username cannot contain ':'") + } + return nil + }), + huh.NewInput().Title("Password").Value(&tCfg.Password).Validate(func(s string) error { + if s == "" { + return errors.New("password cannot be empty") + } + return nil + }), + huh.NewSelect[bool]().Title("Format the output for Docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&tCfg.Docker), + ), + ) + + theme := new(themeBase) + + err := form.WithTheme(theme).Run() + if err != nil { + return fmt.Errorf("failed to run interactive prompt: %w", err) } + } - log.App.Info().Str("username", tCfg.Username).Msg("Creating user") + if tCfg.Username == "" || tCfg.Password == "" { + cmd.PrintHelp(os.Stdout) + return errors.New("username and password cannot be empty") + } - passwd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost) - if err != nil { - return fmt.Errorf("failed to hash password: %w", err) - } + if strings.Contains(tCfg.Username, ":") { + return errors.New("username cannot contain ':'") + } - // If docker format is enabled, escape the dollar sign - passwdStr := string(passwd) - if tCfg.Docker { - passwdStr = strings.ReplaceAll(passwdStr, "$", "$$") - } + passwd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + // Only the docker compose output needs $ escaped, the raw hash is correct everywhere else + passwdStr := string(passwd) + outputStr := passwdStr + + if tCfg.Docker { + outputStr = strings.ReplaceAll(passwdStr, "$", "$$") + } + + user := fmt.Sprintf("%s:%s", tCfg.Username, passwdStr) + escapedUser := fmt.Sprintf("%s:%s", tCfg.Username, outputStr) - log.App.Info().Str("user", fmt.Sprintf("%s:%s", tCfg.Username, passwdStr)).Msg("User created") + buf := strings.Builder{} - return nil - }, + // header + fmt.Fprintf(&buf, "Created user '%s'.\n\n", tCfg.Username) + + // environment variable + fmt.Fprint(&buf, "Environment variable:\n\n") + renderToBuf(&buf, []kv{ + {"TINYAUTH_AUTH_USERS", escapedUser}, + }, "=") + + // cli flags + fmt.Fprint(&buf, "\nCLI flags:\n\n") + renderToBuf(&buf, []kv{ + {"--auth.users", user}, + }, "=") + + // yaml config + fmt.Fprint(&buf, "\nYAML config:\n\n") + + err = renderYamlToBuf(&buf, &model.Config{ + Auth: model.AuthConfig{ + Users: []string{user}, + }, + }) + + if err != nil { + return fmt.Errorf("failed to render yaml config: %w", err) + } + + buf.WriteString("\n") + + // footer + fmt.Fprint(&buf, "Use your config option of choice to add the user to Tinyauth and then restart.") + + fmt.Println(buf.String()) + return nil } + + return cmd } diff --git a/cmd/tinyauth/generate_totp.go b/cmd/tinyauth/generate_totp.go index 8492f87b..696af69a 100644 --- a/cmd/tinyauth/generate_totp.go +++ b/cmd/tinyauth/generate_totp.go @@ -7,7 +7,6 @@ import ( "strings" "github.com/tinyauthapp/tinyauth/internal/utils" - "github.com/tinyauthapp/tinyauth/internal/utils/logger" "charm.land/huh/v2" "github.com/mdp/qrterminal/v3" @@ -34,85 +33,98 @@ func generateTotpCmd() *cli.Command { &cli.FlagLoader{}, } - return &cli.Command{ + cmd := &cli.Command{ Name: "generate", Description: "Generate a TOTP secret", Configuration: tCfg, Resources: loaders, - Run: func(_ []string) error { - log := logger.NewLogger().WithSimpleConfig() - log.Init() - - if tCfg.Interactive { - form := huh.NewForm( - huh.NewGroup( - huh.NewInput().Title("Current user (username:hash)").Value(&tCfg.User).Validate((func(s string) error { - if s == "" { - return errors.New("user cannot be empty") - } - return nil - })), - ), - ) - - theme := new(themeBase) - err := form.WithTheme(theme).Run() - - if err != nil { - return fmt.Errorf("failed to run interactive prompt: %w", err) - } - } + } + + cmd.Run = func(_ []string) error { + colors := getColors() + + if tCfg.Interactive { + form := huh.NewForm( + huh.NewGroup( + huh.NewInput().Title("Current user (username:hash)").Value(&tCfg.User).Validate((func(s string) error { + if s == "" { + return errors.New("user cannot be empty") + } + return nil + })), + ), + ) - user, err := utils.ParseUser(tCfg.User) + theme := new(themeBase) + err := form.WithTheme(theme).Run() if err != nil { - return fmt.Errorf("failed to parse user: %w", err) + return fmt.Errorf("failed to run interactive prompt: %w", err) } + } - docker := false - if strings.Contains(tCfg.User, "$$") { - docker = true - } + if tCfg.User == "" { + cmd.PrintHelp(os.Stdout) + return fmt.Errorf("user is required") + } - if user.TOTPSecret != "" { - return fmt.Errorf("user already has a TOTP secret") - } + user, err := utils.ParseUser(tCfg.User) - key, err := totp.Generate(totp.GenerateOpts{ - Issuer: "Tinyauth", - AccountName: user.Username, - }) + if err != nil { + return fmt.Errorf("failed to parse user: %w", err) + } - if err != nil { - return fmt.Errorf("failed to generate TOTP secret: %w", err) - } + docker := false + if strings.Contains(tCfg.User, "$$") { + docker = true + } - secret := key.Secret() + if user.TOTPSecret != "" { + return fmt.Errorf("user already has a TOTP secret") + } - log.App.Info().Str("secret", secret).Msg("Generated TOTP secret") + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: "Tinyauth", + AccountName: user.Username, + }) - log.App.Info().Msg("Generated QR code") + if err != nil { + return fmt.Errorf("failed to generate TOTP secret: %w", err) + } - config := qrterminal.Config{ - Level: qrterminal.L, - Writer: os.Stdout, - BlackChar: qrterminal.BLACK, - WhiteChar: qrterminal.WHITE, - QuietZone: 2, - } + secret := key.Secret() - qrterminal.GenerateWithConfig(key.URL(), config) + fmt.Printf("Scan the following QR code with your authenticator app (e.g., Google Authenticator, 2fauth, Microsoft Authenticator):\n\n") - user.TOTPSecret = secret + config := qrterminal.Config{ + Level: qrterminal.L, + Writer: os.Stdout, + BlackChar: qrterminal.BLACK, + WhiteChar: qrterminal.WHITE, + QuietZone: 2, + } - // If using docker escape re-escape it - if docker { - user.Password = strings.ReplaceAll(user.Password, "$", "$$") - } + qrterminal.GenerateWithConfig(key.URL(), config) + + user.TOTPSecret = secret + + // If using docker escape re-escape it + if docker { + user.Password = strings.ReplaceAll(user.Password, "$", "$$") + } - log.App.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TOTPSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.") + userStr := fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TOTPSecret) - return nil - }, + fmt.Print("\nOr add the following TOTP secret to your authenticator app: ") + fmt.Print(colors.green.Render(secret)) + fmt.Print("\n\n") + + fmt.Printf("Finally, add your user '%s' back to your configuration: ", user.Username) + fmt.Print(colors.green.Render(userStr)) + fmt.Print("\n") + + return nil } + + return cmd } diff --git a/cmd/tinyauth/tinyauth.go b/cmd/tinyauth/tinyauth.go index bf84bf4c..da8aece1 100644 --- a/cmd/tinyauth/tinyauth.go +++ b/cmd/tinyauth/tinyauth.go @@ -2,13 +2,16 @@ package main import ( "fmt" + "os" + "strings" "charm.land/huh/v2" + "charm.land/lipgloss/v2" "github.com/tinyauthapp/tinyauth/internal/bootstrap" "github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/utils/loaders" + "gopkg.in/yaml.v3" - "github.com/rs/zerolog/log" "github.com/tinyauthapp/paerser/cli" ) @@ -34,83 +37,104 @@ func main() { cmdUser := &cli.Command{ Name: "user", - Description: "Manage Tinyauth users", + Description: "Manage users", } cmdTotp := &cli.Command{ Name: "totp", - Description: "Manage Tinyauth TOTP users", + Description: "Manage TOTP users", } cmdOidc := &cli.Command{ Name: "oidc", - Description: "Manage Tinyauth OIDC clients", + Description: "Manage OIDC clients", } - err := cmdTinyauth.AddCommand(versionCmd()) + helpCmd := &cli.Command{ + Name: "help", + Description: "Show the help message", + Run: func(_ []string) error { + return cmdTinyauth.PrintHelp(os.Stdout) + }, + } + + err := cmdTinyauth.AddCommand(helpCmd) + + if err != nil { + fatalf(err, "Failed to add help command") + } + + err = cmdTinyauth.AddCommand(versionCmd()) if err != nil { - log.Fatal().Err(err).Msg("Failed to add version command") + fatalf(err, "Failed to add version command") } err = cmdTinyauth.AddCommand(configCmd(tConfig, loaders)) if err != nil { - log.Fatal().Err(err).Msg("Failed to add config command") + fatalf(err, "Failed to add config command") } err = cmdUser.AddCommand(verifyUserCmd()) if err != nil { - log.Fatal().Err(err).Msg("Failed to add verify command") + fatalf(err, "Failed to add user verify command") } err = cmdTinyauth.AddCommand(healthcheckCmd()) if err != nil { - log.Fatal().Err(err).Msg("Failed to add healthcheck command") + fatalf(err, "Failed to add healthcheck command") } err = cmdTotp.AddCommand(generateTotpCmd()) if err != nil { - log.Fatal().Err(err).Msg("Failed to add generate command") + fatalf(err, "Failed to add totp generate command") } err = cmdUser.AddCommand(createUserCmd()) if err != nil { - log.Fatal().Err(err).Msg("Failed to add create command") + fatalf(err, "Failed to add create user command") } err = cmdOidc.AddCommand(createOidcClientCmd()) if err != nil { - log.Fatal().Err(err).Msg("Failed to add create command") + fatalf(err, "Failed to add create oidc client command") } err = cmdTinyauth.AddCommand(cmdUser) if err != nil { - log.Fatal().Err(err).Msg("Failed to add user command") + fatalf(err, "Failed to add user command") } err = cmdTinyauth.AddCommand(cmdTotp) if err != nil { - log.Fatal().Err(err).Msg("Failed to add totp command") + fatalf(err, "Failed to add totp command") } err = cmdTinyauth.AddCommand(cmdOidc) if err != nil { - log.Fatal().Err(err).Msg("Failed to add oidc command") + fatalf(err, "Failed to add oidc command") } err = cli.Execute(cmdTinyauth) if err != nil { - log.Fatal().Err(err).Msg("Failed to execute command") + if strings.Contains(err.Error(), "command not found") { + fmt.Println("Command not found. Use 'tinyauth help' to see available commands.") + return + } + if strings.Contains(err.Error(), "is not runnable") { + return + } + fatalf(err, "Failed to execute command") } } @@ -131,3 +155,98 @@ type themeBase struct{} func (t *themeBase) Theme(isDark bool) *huh.Styles { return huh.ThemeBase(isDark) } + +type colors struct { + blue lipgloss.Style + gray lipgloss.Style + lightGray lipgloss.Style + green lipgloss.Style + yellow lipgloss.Style +} + +func getColors() colors { + noColor := os.Getenv("NO_COLOR") + forceColor := os.Getenv("FORCE_COLOR") + + colorOut := colors{ + green: lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(34)), + gray: lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(245)), + yellow: lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(214)), + blue: lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(75)), + lightGray: lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(250)), + } + + noColorOut := colors{ + green: lipgloss.NewStyle(), + gray: lipgloss.NewStyle(), + yellow: lipgloss.NewStyle(), + blue: lipgloss.NewStyle(), + lightGray: lipgloss.NewStyle(), + } + + useColors := true + + if noColor == "true" || noColor == "1" { + useColors = false + } + + if forceColor == "true" || forceColor == "1" { + useColors = true + } + + if !useColors { + return noColorOut + } + + return colorOut +} + +func fatalf(err error, msg string) { + fmt.Printf("%s: %v\n", msg, err) + os.Exit(1) +} + +type kv struct { + k string + v string +} + +func renderToBuf(buf *strings.Builder, kv []kv, sep string) { + colors := getColors() + for _, i := range kv { + buf.WriteString(colors.blue.Render(i.k)) + buf.WriteString(colors.gray.Render(sep)) + buf.WriteString(colors.lightGray.Render(i.v)) + buf.WriteString("\n") + } +} + +func renderYamlToBuf(buf *strings.Builder, i any) error { + colors := getColors() + + yout, err := yaml.Marshal(i) + + if err != nil { + return fmt.Errorf("failed to marshal yaml: %w", err) + } + + for l := range strings.SplitSeq(string(yout), "\n") { + if l == "" { + continue + } + if strings.HasPrefix(strings.TrimLeft(l, " "), "- ") { + buf.WriteString(colors.lightGray.Render(l)) + buf.WriteString("\n") + continue + } + lp := strings.SplitN(l, ":", 2) + buf.WriteString(colors.blue.Render(lp[0])) + buf.WriteString(colors.gray.Render(":")) + if len(lp) == 2 { + buf.WriteString(colors.lightGray.Render(lp[1])) + } + buf.WriteString("\n") + } + + return nil +} diff --git a/cmd/tinyauth/verify_user.go b/cmd/tinyauth/verify_user.go index b0347f6f..fe9ad0fe 100644 --- a/cmd/tinyauth/verify_user.go +++ b/cmd/tinyauth/verify_user.go @@ -3,9 +3,9 @@ package main import ( "errors" "fmt" + "os" "github.com/tinyauthapp/tinyauth/internal/utils" - "github.com/tinyauthapp/tinyauth/internal/utils/logger" "charm.land/huh/v2" "github.com/pquerna/otp/totp" @@ -38,81 +38,87 @@ func verifyUserCmd() *cli.Command { &cli.FlagLoader{}, } - return &cli.Command{ + cmd := &cli.Command{ Name: "verify", Description: "Verify a user is set up correctly", Configuration: tCfg, Resources: loaders, - Run: func(_ []string) error { - log := logger.NewLogger().WithSimpleConfig() - log.Init() - - if tCfg.Interactive { - form := huh.NewForm( - huh.NewGroup( - huh.NewInput().Title("User (username:hash:totp)").Value(&tCfg.User).Validate((func(s string) error { - if s == "" { - return errors.New("user cannot be empty") - } - return nil - })), - huh.NewInput().Title("Username").Value(&tCfg.Username).Validate((func(s string) error { - if s == "" { - return errors.New("username cannot be empty") - } - return nil - })), - huh.NewInput().Title("Password").Value(&tCfg.Password).Validate((func(s string) error { - if s == "" { - return errors.New("password cannot be empty") - } - return nil - })), - huh.NewInput().Title("TOTP Code (optional)").Value(&tCfg.Totp), - ), - ) - - theme := new(themeBase) - err := form.WithTheme(theme).Run() - - if err != nil { - return fmt.Errorf("failed to run interactive prompt: %w", err) - } - } + } - user, err := utils.ParseUser(tCfg.User) + cmd.Run = func(_ []string) error { + colors := getColors() + + if tCfg.Interactive { + form := huh.NewForm( + huh.NewGroup( + huh.NewInput().Title("User (username:hash:totp)").Value(&tCfg.User).Validate((func(s string) error { + if s == "" { + return errors.New("user cannot be empty") + } + return nil + })), + huh.NewInput().Title("Username").Value(&tCfg.Username).Validate((func(s string) error { + if s == "" { + return errors.New("username cannot be empty") + } + return nil + })), + huh.NewInput().Title("Password").Value(&tCfg.Password).Validate((func(s string) error { + if s == "" { + return errors.New("password cannot be empty") + } + return nil + })), + huh.NewInput().Title("TOTP Code (optional)").Value(&tCfg.Totp), + ), + ) + + theme := new(themeBase) + err := form.WithTheme(theme).Run() if err != nil { - return fmt.Errorf("failed to parse user: %w", err) + return fmt.Errorf("failed to run interactive prompt: %w", err) } + } - if user.Username != tCfg.Username { - return fmt.Errorf("username is incorrect") - } + if tCfg.User == "" || tCfg.Username == "" || tCfg.Password == "" { + cmd.PrintHelp(os.Stdout) + return fmt.Errorf("user, username, and password are required") + } - err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(tCfg.Password)) + user, err := utils.ParseUser(tCfg.User) - if err != nil { - return fmt.Errorf("password is incorrect: %w", err) - } + if err != nil { + return fmt.Errorf("failed to parse user: %w", err) + } - if user.TOTPSecret == "" { - if tCfg.Totp != "" { - log.App.Warn().Msg("User does not have TOTP secret") - } - log.App.Info().Msg("User verified") - return nil - } + if user.Username != tCfg.Username { + return fmt.Errorf("username is incorrect") + } - ok := totp.Validate(tCfg.Totp, user.TOTPSecret) + err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(tCfg.Password)) - if !ok { - return fmt.Errorf("TOTP code incorrect") + if err != nil { + return fmt.Errorf("password is incorrect: %w", err) + } + + if user.TOTPSecret == "" { + if tCfg.Totp != "" { + fmt.Println(colors.yellow.Render("⚠") + " TOTP code provided but user does not have TOTP enabled") } + fmt.Println(colors.green.Render("✓") + " User verified") + return nil + } - log.App.Info().Msg("User verified") + ok := totp.Validate(tCfg.Totp, user.TOTPSecret) + if !ok { + return fmt.Errorf("TOTP code incorrect") + } - return nil - }, + fmt.Println(colors.green.Render("✓") + " User verified") + + return nil } + + return cmd } diff --git a/cmd/tinyauth/version.go b/cmd/tinyauth/version.go index 4bd49924..2ced5dbb 100644 --- a/cmd/tinyauth/version.go +++ b/cmd/tinyauth/version.go @@ -14,9 +14,10 @@ func versionCmd() *cli.Command { Configuration: nil, Resources: nil, Run: func(_ []string) error { - fmt.Printf("Version: %s\n", model.Version) - fmt.Printf("Commit Hash: %s\n", model.CommitHash) - fmt.Printf("Build Timestamp: %s\n", model.BuildTimestamp) + colors := getColors() + fmt.Printf("Version: %s\n", colors.blue.Render(model.Version)) + fmt.Printf("Commit Hash: %s\n", colors.blue.Render(model.CommitHash)) + fmt.Printf("Build Timestamp: %s\n", colors.blue.Render(model.BuildTimestamp)) return nil }, } diff --git a/internal/model/config.go b/internal/model/config.go index 39bcb31d..b72c9656 100644 --- a/internal/model/config.go +++ b/internal/model/config.go @@ -102,140 +102,140 @@ func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config { } type Config struct { - AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl"` - Database DatabaseConfig `description:"Database configuration." yaml:"database"` - Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics"` - Resources ResourcesConfig `description:"Resources configuration." yaml:"resources"` - Server ServerConfig `description:"Server configuration." yaml:"server"` - Auth AuthConfig `description:"Authentication configuration." yaml:"auth"` - Apps map[string]App `description:"Application ACLs configuration." yaml:"apps"` - OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"` - OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc"` - UI UIConfig `description:"UI customization." yaml:"ui"` - LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap"` - Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"` - LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider"` - Log LogConfig `description:"Logging configuration." yaml:"log"` + AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl,omitempty"` + Database DatabaseConfig `description:"Database configuration." yaml:"database,omitempty"` + Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics,omitempty"` + Resources ResourcesConfig `description:"Resources configuration." yaml:"resources,omitempty"` + Server ServerConfig `description:"Server configuration." yaml:"server,omitempty"` + Auth AuthConfig `description:"Authentication configuration." yaml:"auth,omitempty"` + Apps map[string]App `description:"Application ACLs configuration." yaml:"apps,omitempty"` + OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth,omitempty"` + OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc,omitempty"` + UI UIConfig `description:"UI customization." yaml:"ui,omitempty"` + LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap,omitempty"` + Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental,omitempty"` + LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider,omitempty"` + Log LogConfig `description:"Logging configuration." yaml:"log,omitempty"` ConfigFile string `description:"Path to config file." yaml:"-"` } type DatabaseConfig struct { - Driver string `description:"The database driver to use. Valid values: sqlite, postgres, memory." yaml:"driver"` - Path string `description:"The path to the SQLite database file, or connection URL when driver is postgres." yaml:"path"` + Driver string `description:"The database driver to use. Valid values: sqlite, postgres, memory." yaml:"driver,omitempty"` + Path string `description:"The path to the SQLite database file, or connection URL when driver is postgres." yaml:"path,omitempty"` } type AnalyticsConfig struct { - Enabled bool `description:"Enable periodic version information collection." yaml:"enabled"` + Enabled bool `description:"Enable periodic version information collection." yaml:"enabled,omitempty"` } type ResourcesConfig struct { - Enabled bool `description:"Enable the resources server." yaml:"enabled"` - Path string `description:"The directory where resources are stored." yaml:"path"` + Enabled bool `description:"Enable the resources server." yaml:"enabled,omitempty"` + Path string `description:"The directory where resources are stored." yaml:"path,omitempty"` } type ServerConfig struct { - Port int `description:"The port on which the server listens." yaml:"port"` - Address string `description:"The address on which the server listens." yaml:"address"` - SocketPath string `description:"The path to the Unix socket." yaml:"socketPath"` + Port int `description:"The port on which the server listens." yaml:"port,omitempty"` + Address string `description:"The address on which the server listens." yaml:"address,omitempty"` + SocketPath string `description:"The path to the Unix socket." yaml:"socketPath,omitempty"` } type AuthConfig struct { - IP IPConfig `description:"IP whitelisting config options." yaml:"ip"` - Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"` - SubdomainsEnabled bool `description:"Enable subdomains support." yaml:"subdomainsEnabled"` - UserAttributes map[string]UserAttributes `description:"Map of per-user OIDC attributes (username -> attributes)." yaml:"userAttributes"` - UsersFile string `description:"Path to the users file." yaml:"usersFile"` - SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"` - SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"` - SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"` - LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"` - LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"` - LockdownEnabled bool `description:"Enable lockdown mode after maximum login retries. Lockdown mode limit is calculated automatically." yaml:"lockdownEnabled"` - TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"` - ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls"` + IP IPConfig `description:"IP whitelisting config options." yaml:"ip,omitempty"` + Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users,omitempty"` + SubdomainsEnabled bool `description:"Enable subdomains support." yaml:"subdomainsEnabled,omitempty"` + UserAttributes map[string]UserAttributes `description:"Map of per-user OIDC attributes (username -> attributes)." yaml:"userAttributes,omitempty"` + UsersFile string `description:"Path to the users file." yaml:"usersFile,omitempty"` + SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie,omitempty"` + SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry,omitempty"` + SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime,omitempty"` + LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout,omitempty"` + LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries,omitempty"` + LockdownEnabled bool `description:"Enable lockdown mode after maximum login retries. Lockdown mode limit is calculated automatically." yaml:"lockdownEnabled,omitempty"` + TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies,omitempty"` + ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls,omitempty"` } type UserAttributes struct { - Name string `description:"Full name of the user." yaml:"name"` - GivenName string `description:"Given (first) name of the user." yaml:"givenName"` - FamilyName string `description:"Family (last) name of the user." yaml:"familyName"` - MiddleName string `description:"Middle name of the user." yaml:"middleName"` - Nickname string `description:"Nickname of the user." yaml:"nickname"` - Profile string `description:"URL of the user's profile page." yaml:"profile"` - Picture string `description:"URL of the user's profile picture." yaml:"picture"` - Website string `description:"URL of the user's website." yaml:"website"` - Email string `description:"Email address of the user." yaml:"email"` - Gender string `description:"Gender of the user." yaml:"gender"` - Birthdate string `description:"Birthdate of the user (YYYY-MM-DD)." yaml:"birthdate"` - Zoneinfo string `description:"Time zone of the user (e.g. Europe/Athens)." yaml:"zoneinfo"` - Locale string `description:"Locale of the user (e.g. en-US)." yaml:"locale"` - PhoneNumber string `description:"Phone number of the user." yaml:"phoneNumber"` - Address AddressClaim `description:"Address of the user." yaml:"address"` + Name string `description:"Full name of the user." yaml:"name,omitempty"` + GivenName string `description:"Given (first) name of the user." yaml:"givenName,omitempty"` + FamilyName string `description:"Family (last) name of the user." yaml:"familyName,omitempty"` + MiddleName string `description:"Middle name of the user." yaml:"middleName,omitempty"` + Nickname string `description:"Nickname of the user." yaml:"nickname,omitempty"` + Profile string `description:"URL of the user's profile page." yaml:"profile,omitempty"` + Picture string `description:"URL of the user's profile picture." yaml:"picture,omitempty"` + Website string `description:"URL of the user's website." yaml:"website,omitempty"` + Email string `description:"Email address of the user." yaml:"email,omitempty"` + Gender string `description:"Gender of the user." yaml:"gender,omitempty"` + Birthdate string `description:"Birthdate of the user (YYYY-MM-DD)." yaml:"birthdate,omitempty"` + Zoneinfo string `description:"Time zone of the user (e.g. Europe/Athens)." yaml:"zoneinfo,omitempty"` + Locale string `description:"Locale of the user (e.g. en-US)." yaml:"locale,omitempty"` + PhoneNumber string `description:"Phone number of the user." yaml:"phoneNumber,omitempty"` + Address AddressClaim `description:"Address of the user." yaml:"address,omitempty"` } type AddressClaim struct { - Formatted string `description:"Full mailing address, formatted for display." yaml:"formatted" json:"formatted,omitempty"` - StreetAddress string `description:"Street address." yaml:"streetAddress" json:"street_address,omitempty"` - Locality string `description:"City or locality." yaml:"locality" json:"locality,omitempty"` - Region string `description:"State, province, or region." yaml:"region" json:"region,omitempty"` - PostalCode string `description:"Zip or postal code." yaml:"postalCode" json:"postal_code,omitempty"` - Country string `description:"Country." yaml:"country" json:"country,omitempty"` + Formatted string `description:"Full mailing address, formatted for display." yaml:"formatted,omitempty" json:"formatted,omitempty"` + StreetAddress string `description:"Street address." yaml:"streetAddress,omitempty" json:"street_address,omitempty"` + Locality string `description:"City or locality." yaml:"locality,omitempty" json:"locality,omitempty"` + Region string `description:"State, province, or region." yaml:"region,omitempty" json:"region,omitempty"` + PostalCode string `description:"Zip or postal code." yaml:"postalCode,omitempty" json:"postal_code,omitempty"` + Country string `description:"Country." yaml:"country,omitempty" json:"country,omitempty"` } type IPConfig struct { - Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"` - Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"` - Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication entirely." yaml:"bypass"` + Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow,omitempty"` + Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block,omitempty"` + Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication entirely." yaml:"bypass,omitempty"` } type OAuthConfig struct { - Whitelist []string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist"` - WhitelistFile string `description:"Path to the OAuth whitelist file." yaml:"whitelistFile"` - AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect"` - Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers"` + Whitelist []string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist,omitempty"` + WhitelistFile string `description:"Path to the OAuth whitelist file." yaml:"whitelistFile,omitempty"` + AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect,omitempty"` + Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers,omitempty"` } type OIDCConfig struct { - PrivateKeyPath string `description:"Path to the private key file, including file name." yaml:"privateKeyPath"` - PublicKeyPath string `description:"Path to the public key file, including file name." yaml:"publicKeyPath"` - Clients map[string]OIDCClientConfig `description:"OIDC clients configuration." yaml:"clients"` + PrivateKeyPath string `description:"Path to the private key file, including file name." yaml:"privateKeyPath,omitempty"` + PublicKeyPath string `description:"Path to the public key file, including file name." yaml:"publicKeyPath,omitempty"` + Clients map[string]OIDCClientConfig `description:"OIDC clients configuration." yaml:"clients,omitempty"` } type UIConfig struct { - Title string `description:"The title of the UI." yaml:"title"` - ForgotPasswordMessage string `description:"Message displayed on the forgot password page." yaml:"forgotPasswordMessage"` - BackgroundImage string `description:"Path to the background image." yaml:"backgroundImage"` - WarningsEnabled bool `description:"Enable UI warnings." yaml:"warningsEnabled"` + Title string `description:"The title of the UI." yaml:"title,omitempty"` + ForgotPasswordMessage string `description:"Message displayed on the forgot password page." yaml:"forgotPasswordMessage,omitempty"` + BackgroundImage string `description:"Path to the background image." yaml:"backgroundImage,omitempty"` + WarningsEnabled bool `description:"Enable UI warnings." yaml:"warningsEnabled,omitempty"` } type LDAPConfig struct { - Address string `description:"LDAP server address." yaml:"address"` - BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"` - BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"` - BindPasswordFile string `description:"Path to the Bind password." yaml:"bindPasswordFile"` - BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"` - Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"` - SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"` - AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert"` - AuthKey string `description:"Certificate key for mTLS authentication." yaml:"authKey"` - GroupCacheTTL int `description:"Cache duration for LDAP group membership in seconds." yaml:"groupCacheTTL"` + Address string `description:"LDAP server address." yaml:"address,omitempty"` + BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn,omitempty"` + BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword,omitempty"` + BindPasswordFile string `description:"Path to the Bind password." yaml:"bindPasswordFile,omitempty"` + BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn,omitempty"` + Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure,omitempty"` + SearchFilter string `description:"LDAP search filter." yaml:"searchFilter,omitempty"` + AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert,omitempty"` + AuthKey string `description:"Certificate key for mTLS authentication." yaml:"authKey,omitempty"` + GroupCacheTTL int `description:"Cache duration for LDAP group membership in seconds." yaml:"groupCacheTTL,omitempty"` } type LogConfig struct { - Level string `description:"Log level (trace, debug, info, warn, error)." yaml:"level"` - Json bool `description:"Enable JSON formatted logs." yaml:"json"` - Streams LogStreams `description:"Configuration for specific log streams." yaml:"streams"` + Level string `description:"Log level (trace, debug, info, warn, error)." yaml:"level,omitempty"` + Json bool `description:"Enable JSON formatted logs." yaml:"json,omitempty"` + Streams LogStreams `description:"Configuration for specific log streams." yaml:"streams,omitempty"` } type LogStreams struct { - HTTP LogStreamConfig `description:"HTTP request logging." yaml:"http"` - App LogStreamConfig `description:"Application logging." yaml:"app"` - Audit LogStreamConfig `description:"Audit logging." yaml:"audit"` + HTTP LogStreamConfig `description:"HTTP request logging." yaml:"http,omitempty"` + App LogStreamConfig `description:"Application logging." yaml:"app,omitempty"` + Audit LogStreamConfig `description:"Audit logging." yaml:"audit,omitempty"` } type LogStreamConfig struct { - Enabled bool `description:"Enable this log stream." yaml:"enabled"` - Level string `description:"Log level for this stream. Use global if empty." yaml:"level"` + Enabled bool `description:"Enable this log stream." yaml:"enabled,omitempty"` + Level string `description:"Log level for this stream. Use global if empty." yaml:"level,omitempty"` } type ExperimentalConfig struct { @@ -243,97 +243,97 @@ type ExperimentalConfig struct { } type TailscaleConfig struct { - Enabled bool `description:"Enable Tailscale integration." yaml:"enabled"` - Dir string `description:"Tailscale state directory." yaml:"dir"` - Hostname string `description:"Tailscale hostname." yaml:"hostname"` - AuthKey string `description:"Tailscale auth key." yaml:"authKey"` - Ephemeral bool `description:"Use ephemeral Tailscale node." yaml:"ephemeral"` - Funnel bool `description:"Enable Tailscale Funnel." yaml:"funnel"` - Listen bool `description:"Listen on the Tailscale address instead of standard address." yaml:"listen"` + Enabled bool `description:"Enable Tailscale integration." yaml:"enabled,omitempty"` + Dir string `description:"Tailscale state directory." yaml:"dir,omitempty"` + Hostname string `description:"Tailscale hostname." yaml:"hostname,omitempty"` + AuthKey string `description:"Tailscale auth key." yaml:"authKey,omitempty"` + Ephemeral bool `description:"Use ephemeral Tailscale node." yaml:"ephemeral,omitempty"` + Funnel bool `description:"Enable Tailscale Funnel." yaml:"funnel,omitempty"` + Listen bool `description:"Listen on the Tailscale address instead of standard address." yaml:"listen,omitempty"` } // OAuth/OIDC config type OAuthServiceConfig struct { - ClientID string `description:"OAuth client ID." yaml:"clientId"` - ClientSecret string `description:"OAuth client secret." yaml:"clientSecret"` - ClientSecretFile string `description:"Path to the file containing the OAuth client secret." yaml:"clientSecretFile"` - Whitelist []string `description:"Comma-separated list of allowed OAuth domains for this provider." yaml:"whitelist"` - WhitelistFile string `description:"Path to the OAuth whitelist file for this provider." yaml:"whitelistFile"` - Scopes []string `description:"OAuth scopes." yaml:"scopes"` - RedirectURL string `description:"OAuth redirect URL." yaml:"redirectUrl"` - AuthURL string `description:"OAuth authorization URL." yaml:"authUrl"` - TokenURL string `description:"OAuth token URL." yaml:"tokenUrl"` - UserinfoURL string `description:"OAuth userinfo URL." yaml:"userinfoUrl"` - Insecure bool `description:"Allow insecure OAuth connections." yaml:"insecure"` - Name string `description:"Provider name in UI." yaml:"name"` + ClientID string `description:"OAuth client ID." yaml:"clientId,omitempty"` + ClientSecret string `description:"OAuth client secret." yaml:"clientSecret,omitempty"` + ClientSecretFile string `description:"Path to the file containing the OAuth client secret." yaml:"clientSecretFile,omitempty"` + Whitelist []string `description:"Comma-separated list of allowed OAuth domains for this provider." yaml:"whitelist,omitempty"` + WhitelistFile string `description:"Path to the OAuth whitelist file for this provider." yaml:"whitelistFile,omitempty"` + Scopes []string `description:"OAuth scopes." yaml:"scopes,omitempty"` + RedirectURL string `description:"OAuth redirect URL." yaml:"redirectUrl,omitempty"` + AuthURL string `description:"OAuth authorization URL." yaml:"authUrl,omitempty"` + TokenURL string `description:"OAuth token URL." yaml:"tokenUrl,omitempty"` + UserinfoURL string `description:"OAuth userinfo URL." yaml:"userinfoUrl,omitempty"` + Insecure bool `description:"Allow insecure OAuth connections." yaml:"insecure,omitempty"` + Name string `description:"Provider name in UI." yaml:"name,omitempty"` } type OIDCClientConfig struct { ID string `description:"OIDC client ID." yaml:"-"` - ClientID string `description:"OIDC client ID." yaml:"clientId"` - ClientSecret string `description:"OIDC client secret." yaml:"clientSecret"` - ClientSecretFile string `description:"Path to the file containing the OIDC client secret." yaml:"clientSecretFile"` - TrustedRedirectURIs []string `description:"List of trusted redirect URIs." yaml:"trustedRedirectUris"` - Name string `description:"Client name in UI." yaml:"name"` + ClientID string `description:"OIDC client ID." yaml:"clientId,omitempty"` + ClientSecret string `description:"OIDC client secret." yaml:"clientSecret,omitempty"` + ClientSecretFile string `description:"Path to the file containing the OIDC client secret." yaml:"clientSecretFile,omitempty"` + TrustedRedirectURIs []string `description:"List of trusted redirect URIs." yaml:"trustedRedirectUris,omitempty"` + Name string `description:"Client name in UI." yaml:"name,omitempty"` } type ACLsConfig struct { - Policy string `description:"ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow." yaml:"policy"` + Policy string `description:"ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow." yaml:"policy,omitempty"` } // ACLs type Apps struct { - Apps map[string]App `description:"App ACLs configuration." yaml:"apps"` + Apps map[string]App `description:"App ACLs configuration." yaml:"apps,omitempty"` } type App struct { - Config AppConfig `description:"App configuration." yaml:"config"` - Users AppUsers `description:"User access configuration." yaml:"users"` - OAuth AppOAuth `description:"OAuth access configuration." yaml:"oauth"` - IP AppIP `description:"IP access configuration." yaml:"ip"` - Response AppResponse `description:"Response customization." yaml:"response"` - Path AppPath `description:"Path access configuration." yaml:"path"` - LDAP AppLDAP `description:"LDAP access configuration." yaml:"ldap"` + Config AppConfig `description:"App configuration." yaml:"config,omitempty"` + Users AppUsers `description:"User access configuration." yaml:"users,omitempty"` + OAuth AppOAuth `description:"OAuth access configuration." yaml:"oauth,omitempty"` + IP AppIP `description:"IP access configuration." yaml:"ip,omitempty"` + Response AppResponse `description:"Response customization." yaml:"response,omitempty"` + Path AppPath `description:"Path access configuration." yaml:"path,omitempty"` + LDAP AppLDAP `description:"LDAP access configuration." yaml:"ldap,omitempty"` } type AppConfig struct { - Domain string `description:"The domain of the app." yaml:"domain"` + Domain string `description:"The domain of the app." yaml:"domain,omitempty"` } type AppUsers struct { - Allow string `description:"Comma-separated list of allowed users." yaml:"allow"` - Block string `description:"Comma-separated list of blocked users." yaml:"block"` + Allow string `description:"Comma-separated list of allowed users." yaml:"allow,omitempty"` + Block string `description:"Comma-separated list of blocked users." yaml:"block,omitempty"` } type AppOAuth struct { - Whitelist string `description:"Comma-separated list of allowed OAuth groups." yaml:"whitelist"` - Groups string `description:"Comma-separated list of required OAuth groups." yaml:"groups"` + Whitelist string `description:"Comma-separated list of allowed OAuth groups." yaml:"whitelist,omitempty"` + Groups string `description:"Comma-separated list of required OAuth groups." yaml:"groups,omitempty"` } type AppLDAP struct { - Groups string `description:"Comma-separated list of required LDAP groups." yaml:"groups"` + Groups string `description:"Comma-separated list of required LDAP groups." yaml:"groups,omitempty"` } type AppIP struct { - Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"` - Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"` - Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication." yaml:"bypass"` + Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow,omitempty"` + Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block,omitempty"` + Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication." yaml:"bypass,omitempty"` } type AppResponse struct { - Headers []string `description:"Custom headers to add to the response." yaml:"headers"` - BasicAuth AppBasicAuth `description:"Basic authentication for the app." yaml:"basicAuth"` + Headers []string `description:"Custom headers to add to the response." yaml:"headers,omitempty"` + BasicAuth AppBasicAuth `description:"Basic authentication for the app." yaml:"basicAuth,omitempty"` } type AppBasicAuth struct { - Username string `description:"Basic auth username." yaml:"username"` - Password string `description:"Basic auth password." yaml:"password"` - PasswordFile string `description:"Path to the file containing the basic auth password." yaml:"passwordFile"` + Username string `description:"Basic auth username." yaml:"username,omitempty"` + Password string `description:"Basic auth password." yaml:"password,omitempty"` + PasswordFile string `description:"Path to the file containing the basic auth password." yaml:"passwordFile,omitempty"` } type AppPath struct { - Allow string `description:"Comma-separated list of allowed paths." yaml:"allow"` - Block string `description:"Comma-separated list of blocked paths." yaml:"block"` + Allow string `description:"Comma-separated list of allowed paths." yaml:"allow,omitempty"` + Block string `description:"Comma-separated list of blocked paths." yaml:"block,omitempty"` }