diff --git a/.env.example b/.env.example index 4d40c81..2c6c4fe 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,86 @@ -PORT=3000 -ADDRESS=0.0.0.0 -APP_URL=http://localhost:3000 -USERS=your_user_password_hash -USERS_FILE=users_file -SECURE_COOKIE=false -OAUTH_WHITELIST= -GENERIC_NAME=My OAuth -SESSION_EXPIRY=7200 -LOGIN_TIMEOUT=300 -LOGIN_MAX_RETRIES=5 -LOG_LEVEL=debug -APP_TITLE=Tinyauth SSO -FORGOT_PASSWORD_MESSAGE=Some message about resetting the password -OAUTH_AUTO_REDIRECT=none -BACKGROUND_IMAGE=some_image_url -GENERIC_SKIP_SSL=false -RESOURCES_DIR=/data/resources -DATABASE_PATH=/data/tinyauth.db -DISABLE_ANALYTICS=false -DISABLE_RESOURCES=false -TRUSTED_PROXIES= \ No newline at end of file +# Base Configuration + +# The base URL where Tinyauth is accessible +TINYAUTH_APPURL="https://auth.example.com" +# Log level: trace, debug, info, warn, error +TINYAUTH_LOGLEVEL="info" +# Directory for static resources +TINYAUTH_RESOURCESDIR="/data/resources" +# Path to SQLite database file +TINYAUTH_DATABASEPATH="/data/tinyauth.db" +# Disable version heartbeat +TINYAUTH_DISABLEANALYTICS="false" +# Disable static resource serving +TINYAUTH_DISABLERESOURCES="false" +# Disable UI warning messages +TINYAUTH_DISABLEUIWARNINGS="false" +# Enable JSON formatted logs +TINYAUTH_LOGJSON="false" + +# Server Configuration + +# Port to listen on +TINYAUTH_SERVER_PORT="3000" +# Interface to bind to (0.0.0.0 for all interfaces) +TINYAUTH_SERVER_ADDRESS="0.0.0.0" +# Unix socket path (optional, overrides port/address if set) +TINYAUTH_SERVER_SOCKETPATH="" +# Comma-separated list of trusted proxy IPs/CIDRs +TINYAUTH_SERVER_TRUSTEDPROXIES="" + +# Authentication Configuration + +# Format: username:bcrypt_hash (use bcrypt to generate hash) +TINYAUTH_AUTH_USERS="admin:$2a$10$example_bcrypt_hash_here" +# Path to external users file (optional) +TINYAUTH_USERSFILE="" +# Enable secure cookies (requires HTTPS) +TINYAUTH_SECURECOOKIE="true" +# Session expiry in seconds (7200 = 2 hours) +TINYAUTH_SESSIONEXPIRY="7200" +# Login timeout in seconds (300 = 5 minutes) +TINYAUTH_LOGINTIMEOUT="300" +# Maximum login retries before lockout +TINYAUTH_LOGINMAXRETRIES="5" + +# OAuth Configuration + +# Regex pattern for allowed email addresses (e.g., /@example\.com$/) +TINYAUTH_OAUTH_WHITELIST="" +# Provider ID to auto-redirect to (skips login page) +TINYAUTH_OAUTH_AUTOREDIRECT="" +# OAuth Provider Configuration (replace MYPROVIDER with your provider name) +TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_CLIENTID="your_client_id_here" +TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_CLIENTSECRET="your_client_secret_here" +TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_AUTHURL="https://provider.example.com/oauth/authorize" +TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_TOKENURL="https://provider.example.com/oauth/token" +TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_USERINFOURL="https://provider.example.com/oauth/userinfo" +TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_REDIRECTURL="https://auth.example.com/oauth/callback/myprovider" +TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_SCOPES="openid email profile" +TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_NAME="My OAuth Provider" +# Allow self-signed certificates +TINYAUTH_OAUTH_PROVIDERS_MYPROVIDER_INSECURE="false" + +# UI Customization + +# Custom title for login page +TINYAUTH_UI_TITLE="Tinyauth" +# Message shown on forgot password page +TINYAUTH_UI_FORGOTPASSWORDMESSAGE="Contact your administrator to reset your password" +# Background image URL for login page +TINYAUTH_UI_BACKGROUNDIMAGE="" + +# LDAP Configuration + +# LDAP server address +TINYAUTH_LDAP_ADDRESS="ldap://ldap.example.com:389" +# DN for binding to LDAP server +TINYAUTH_LDAP_BINDDN="cn=readonly,dc=example,dc=com" +# Password for bind DN +TINYAUTH_LDAP_BINDPASSWORD="your_bind_password" +# Base DN for user searches +TINYAUTH_LDAP_BASEDN="dc=example,dc=com" +# Search filter (%s will be replaced with username) +TINYAUTH_LDAP_SEARCHFILTER="(&(uid=%s)(memberOf=cn=users,ou=groups,dc=example,dc=com))" +# Allow insecure LDAP connections +TINYAUTH_LDAP_INSECURE="false" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ab4a990..5bc3e09 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -80,7 +80,7 @@ jobs: - name: Build run: | cp -r frontend/dist internal/assets/dist - go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 + go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 ./cmd/tinyauth env: CGO_ENABLED: 0 @@ -126,7 +126,7 @@ jobs: - name: Build run: | cp -r frontend/dist internal/assets/dist - go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 + go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 ./cmd/tinyauth env: CGO_ENABLED: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0cf3dde..c6896c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,7 +58,7 @@ jobs: - name: Build run: | cp -r frontend/dist internal/assets/dist - go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 + go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 ./cmd/tinyauth env: CGO_ENABLED: 0 @@ -101,7 +101,7 @@ jobs: - name: Build run: | cp -r frontend/dist internal/assets/dist - go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 + go build -ldflags "-s -w -X tinyauth/internal/config.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/config.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/config.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 ./cmd/tinyauth env: CGO_ENABLED: 0 diff --git a/.gitignore b/.gitignore index cb79b93..576aeee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,29 +1,36 @@ # dist -internal/assets/dist +/internal/assets/dist # binaries -tinyauth +/tinyauth # test docker compose -docker-compose.test* +/docker-compose.test* # users file -users.txt +/users.txt # secret test file -secret* +/secret* # apple stuff .DS_Store # env -.env +/.env # tmp directory -tmp +/tmp # version files -internal/assets/version +/internal/assets/version # data directory -data \ No newline at end of file +/data + +# config file +/config.yml + +# binary out +/tinyauth.db +/resources \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c30a056..41500ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,12 +33,11 @@ COPY go.sum ./ RUN go mod download -COPY ./main.go ./ COPY ./cmd ./cmd COPY ./internal ./internal COPY --from=frontend-builder /frontend/dist ./internal/assets/dist -RUN CGO_ENABLED=0 go build -ldflags "-s -w -X tinyauth/internal/config.Version=${VERSION} -X tinyauth/internal/config.CommitHash=${COMMIT_HASH} -X tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" +RUN CGO_ENABLED=0 go build -ldflags "-s -w -X tinyauth/internal/config.Version=${VERSION} -X tinyauth/internal/config.CommitHash=${COMMIT_HASH} -X tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth # Runner FROM alpine:3.23 AS runner @@ -53,6 +52,10 @@ EXPOSE 3000 VOLUME ["/data"] +ENV DATABASEPATH=/data/tinyauth.db + +ENV RESOURCESDIR=/data/resources + ENV GIN_MODE=release ENV PATH=$PATH:/tinyauth diff --git a/Dockerfile.dev b/Dockerfile.dev index a132ded..96ea9b9 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -12,9 +12,12 @@ RUN go install github.com/go-delve/delve/cmd/dlv@latest COPY ./cmd ./cmd COPY ./internal ./internal -COPY ./main.go ./ COPY ./air.toml ./ EXPOSE 3000 +ENV TINYAUTH_DATABASEPATH=/data/tinyauth.db + +ENV TINYAUTH_RESOURCESDIR=/data/resources + ENTRYPOINT ["air", "-c", "air.toml"] \ No newline at end of file diff --git a/Dockerfile.distroless b/Dockerfile.distroless index 75340d2..9806bea 100644 --- a/Dockerfile.distroless +++ b/Dockerfile.distroless @@ -33,14 +33,13 @@ COPY go.sum ./ RUN go mod download -COPY ./main.go ./ COPY ./cmd ./cmd COPY ./internal ./internal COPY --from=frontend-builder /frontend/dist ./internal/assets/dist RUN mkdir -p data -RUN CGO_ENABLED=0 go build -ldflags "-s -w -X tinyauth/internal/config.Version=${VERSION} -X tinyauth/internal/config.CommitHash=${COMMIT_HASH} -X tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" +RUN CGO_ENABLED=0 go build -ldflags "-s -w -X tinyauth/internal/config.Version=${VERSION} -X tinyauth/internal/config.CommitHash=${COMMIT_HASH} -X tinyauth/internal/config.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth # Runner FROM gcr.io/distroless/static-debian12:latest AS runner @@ -56,6 +55,10 @@ EXPOSE 3000 VOLUME ["/data"] +ENV TINYAUTH_DATABASEPATH=/data/tinyauth.db + +ENV TINYAUTH_RESOURCESDIR=/data/resources + ENV GIN_MODE=release ENV PATH=$PATH:/tinyauth diff --git a/air.toml b/air.toml index cddbc9d..5de0449 100644 --- a/air.toml +++ b/air.toml @@ -3,7 +3,7 @@ tmp_dir = "tmp" [build] pre_cmd = ["mkdir -p internal/assets/dist", "mkdir -p /data", "echo 'backend running' > internal/assets/dist/index.html"] -cmd = "CGO_ENABLED=0 go build -gcflags=\"all=-N -l\" -o tmp/tinyauth ." +cmd = "CGO_ENABLED=0 go build -gcflags=\"all=-N -l\" -o tmp/tinyauth ./cmd/tinyauth" bin = "tmp/tinyauth" full_bin = "dlv --listen :4000 --headless=true --api-version=2 --accept-multiclient --log=true exec tmp/tinyauth --continue --check-go-version=false" include_ext = ["go"] diff --git a/cmd/create.go b/cmd/create.go deleted file mode 100644 index 0abb3c7..0000000 --- a/cmd/create.go +++ /dev/null @@ -1,99 +0,0 @@ -package cmd - -import ( - "errors" - "fmt" - "strings" - - "github.com/charmbracelet/huh" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - "github.com/spf13/cobra" - "golang.org/x/crypto/bcrypt" -) - -type createUserCmd struct { - root *cobra.Command - cmd *cobra.Command - - interactive bool - docker bool - username string - password string -} - -func newCreateUserCmd(root *cobra.Command) *createUserCmd { - return &createUserCmd{ - root: root, - } -} - -func (c *createUserCmd) Register() { - c.cmd = &cobra.Command{ - Use: "create", - Short: "Create a user", - Long: `Create a user either interactively or by passing flags.`, - Run: c.run, - } - - c.cmd.Flags().BoolVarP(&c.interactive, "interactive", "i", false, "Create a user interactively") - c.cmd.Flags().BoolVar(&c.docker, "docker", false, "Format output for docker") - c.cmd.Flags().StringVar(&c.username, "username", "", "Username") - c.cmd.Flags().StringVar(&c.password, "password", "", "Password") - - if c.root != nil { - c.root.AddCommand(c.cmd) - } -} - -func (c *createUserCmd) GetCmd() *cobra.Command { - return c.cmd -} - -func (c *createUserCmd) run(cmd *cobra.Command, args []string) { - log.Logger = log.Level(zerolog.InfoLevel) - - if c.interactive { - form := huh.NewForm( - huh.NewGroup( - huh.NewInput().Title("Username").Value(&c.username).Validate((func(s string) error { - if s == "" { - return errors.New("username cannot be empty") - } - return nil - })), - huh.NewInput().Title("Password").Value(&c.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(&c.docker), - ), - ) - var baseTheme *huh.Theme = huh.ThemeBase() - err := form.WithTheme(baseTheme).Run() - if err != nil { - log.Fatal().Err(err).Msg("Form failed") - } - } - - if c.username == "" || c.password == "" { - log.Fatal().Err(errors.New("error invalid input")).Msg("Username and password cannot be empty") - } - - log.Info().Str("username", c.username).Msg("Creating user") - - passwd, err := bcrypt.GenerateFromPassword([]byte(c.password), bcrypt.DefaultCost) - if err != nil { - log.Fatal().Err(err).Msg("Failed to hash password") - } - - // If docker format is enabled, escape the dollar sign - passwdStr := string(passwd) - if c.docker { - passwdStr = strings.ReplaceAll(passwdStr, "$", "$$") - } - - log.Info().Str("user", fmt.Sprintf("%s:%s", c.username, passwdStr)).Msg("User created") -} diff --git a/cmd/generate.go b/cmd/generate.go deleted file mode 100644 index 005b473..0000000 --- a/cmd/generate.go +++ /dev/null @@ -1,120 +0,0 @@ -package cmd - -import ( - "errors" - "fmt" - "os" - "strings" - "tinyauth/internal/utils" - - "github.com/charmbracelet/huh" - "github.com/mdp/qrterminal/v3" - "github.com/pquerna/otp/totp" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - "github.com/spf13/cobra" -) - -type generateTotpCmd struct { - root *cobra.Command - cmd *cobra.Command - - interactive bool - user string -} - -func newGenerateTotpCmd(root *cobra.Command) *generateTotpCmd { - return &generateTotpCmd{ - root: root, - } -} - -func (c *generateTotpCmd) Register() { - c.cmd = &cobra.Command{ - Use: "generate", - Short: "Generate a totp secret", - Long: `Generate a totp secret for a user either interactively or by passing flags.`, - Run: c.run, - } - - c.cmd.Flags().BoolVarP(&c.interactive, "interactive", "i", false, "Run in interactive mode") - c.cmd.Flags().StringVar(&c.user, "user", "", "Your current user (username:hash)") - - if c.root != nil { - c.root.AddCommand(c.cmd) - } -} - -func (c *generateTotpCmd) GetCmd() *cobra.Command { - return c.cmd -} - -func (c *generateTotpCmd) run(cmd *cobra.Command, args []string) { - log.Logger = log.Level(zerolog.InfoLevel) - - if c.interactive { - form := huh.NewForm( - huh.NewGroup( - huh.NewInput().Title("Current user (username:hash)").Value(&c.user).Validate((func(s string) error { - if s == "" { - return errors.New("user cannot be empty") - } - return nil - })), - ), - ) - var baseTheme *huh.Theme = huh.ThemeBase() - err := form.WithTheme(baseTheme).Run() - if err != nil { - log.Fatal().Err(err).Msg("Form failed") - } - } - - user, err := utils.ParseUser(c.user) - if err != nil { - log.Fatal().Err(err).Msg("Failed to parse user") - } - - docker := false - if strings.Contains(c.user, "$$") { - docker = true - } - - if user.TotpSecret != "" { - log.Fatal().Msg("User already has a TOTP secret") - } - - key, err := totp.Generate(totp.GenerateOpts{ - Issuer: "Tinyauth", - AccountName: user.Username, - }) - - if err != nil { - log.Fatal().Err(err).Msg("Failed to generate TOTP secret") - } - - secret := key.Secret() - - log.Info().Str("secret", secret).Msg("Generated TOTP secret") - - log.Info().Msg("Generated QR code") - - config := qrterminal.Config{ - Level: qrterminal.L, - Writer: os.Stdout, - BlackChar: qrterminal.BLACK, - WhiteChar: qrterminal.WHITE, - QuietZone: 2, - } - - qrterminal.GenerateWithConfig(key.URL(), config) - - user.TotpSecret = secret - - // If using docker escape re-escape it - if docker { - user.Password = strings.ReplaceAll(user.Password, "$", "$$") - } - - log.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.") -} diff --git a/cmd/healthcheck.go b/cmd/healthcheck.go deleted file mode 100644 index ca2bd83..0000000 --- a/cmd/healthcheck.go +++ /dev/null @@ -1,112 +0,0 @@ -package cmd - -import ( - "encoding/json" - "errors" - "io" - "net/http" - - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -type healthzResponse struct { - Status string `json:"status"` - Message string `json:"message"` -} - -type healthcheckCmd struct { - root *cobra.Command - cmd *cobra.Command - - viper *viper.Viper -} - -func newHealthcheckCmd(root *cobra.Command) *healthcheckCmd { - return &healthcheckCmd{ - root: root, - viper: viper.New(), - } -} - -func (c *healthcheckCmd) Register() { - c.cmd = &cobra.Command{ - Use: "healthcheck [app-url]", - Short: "Perform a health check", - Long: `Use the health check endpoint to verify that Tinyauth is running and it's healthy.`, - Run: c.run, - } - - c.viper.AutomaticEnv() - - if c.root != nil { - c.root.AddCommand(c.cmd) - } -} - -func (c *healthcheckCmd) GetCmd() *cobra.Command { - return c.cmd -} - -func (c *healthcheckCmd) run(cmd *cobra.Command, args []string) { - log.Logger = log.Level(zerolog.InfoLevel) - - var appUrl string - - port := c.viper.GetString("PORT") - address := c.viper.GetString("ADDRESS") - - if port == "" { - port = "3000" - } - - if address == "" { - address = "127.0.0.1" - } - - appUrl = "http://" + address + ":" + port - - if len(args) > 0 { - appUrl = args[0] - } - - log.Info().Str("app_url", appUrl).Msg("Performing health check") - - client := http.Client{} - - req, err := http.NewRequest("GET", appUrl+"/api/healthz", nil) - - if err != nil { - log.Fatal().Err(err).Msg("Failed to create request") - } - - resp, err := client.Do(req) - - if err != nil { - log.Fatal().Err(err).Msg("Failed to perform request") - } - - if resp.StatusCode != http.StatusOK { - log.Fatal().Err(errors.New("service is not healthy")).Msgf("Service is not healthy. Status code: %d", resp.StatusCode) - } - - defer resp.Body.Close() - - var healthResp healthzResponse - - body, err := io.ReadAll(resp.Body) - - if err != nil { - log.Fatal().Err(err).Msg("Failed to read response") - } - - err = json.Unmarshal(body, &healthResp) - - if err != nil { - log.Fatal().Err(err).Msg("Failed to decode response") - } - - log.Info().Interface("response", healthResp).Msg("Tinyauth is healthy") -} diff --git a/cmd/root.go b/cmd/root.go deleted file mode 100644 index 3ac1548..0000000 --- a/cmd/root.go +++ /dev/null @@ -1,162 +0,0 @@ -package cmd - -import ( - "strings" - "tinyauth/internal/bootstrap" - "tinyauth/internal/config" - "tinyauth/internal/utils" - - "github.com/go-playground/validator/v10" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -type rootCmd struct { - root *cobra.Command - cmd *cobra.Command - - viper *viper.Viper -} - -func newRootCmd() *rootCmd { - return &rootCmd{ - viper: viper.New(), - } -} - -func (c *rootCmd) Register() { - c.cmd = &cobra.Command{ - Use: "tinyauth", - Short: "The simplest way to protect your apps with a login screen", - Long: `Tinyauth is a simple authentication middleware that adds a simple login screen or OAuth with Google, Github or any other provider to all of your docker apps.`, - Run: c.run, - } - - // Ignore unknown flags to allow --providers-* - c.cmd.FParseErrWhitelist.UnknownFlags = true - - c.viper.AutomaticEnv() - - configOptions := []struct { - name string - defaultVal any - description string - }{ - {"port", 3000, "Port to run the server on."}, - {"address", "0.0.0.0", "Address to bind the server to."}, - {"app-url", "", "The Tinyauth URL."}, - {"users", "", "Comma separated list of users in the format username:hash."}, - {"users-file", "", "Path to a file containing users in the format username:hash."}, - {"secure-cookie", false, "Send cookie over secure connection only."}, - {"oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth."}, - {"oauth-auto-redirect", "none", "Auto redirect to the specified OAuth provider if configured. (available providers: github, google, generic)"}, - {"session-expiry", 86400, "Session (cookie) expiration time in seconds."}, - {"login-timeout", 300, "Login timeout in seconds after max retries reached (0 to disable)."}, - {"login-max-retries", 5, "Maximum login attempts before timeout (0 to disable)."}, - {"log-level", "info", "Log level."}, - {"app-title", "Tinyauth", "Title of the app."}, - {"forgot-password-message", "", "Message to show on the forgot password page."}, - {"background-image", "/background.jpg", "Background image URL for the login page."}, - {"ldap-address", "", "LDAP server address (e.g. ldap://localhost:389)."}, - {"ldap-bind-dn", "", "LDAP bind DN (e.g. uid=user,dc=example,dc=com)."}, - {"ldap-bind-password", "", "LDAP bind password."}, - {"ldap-base-dn", "", "LDAP base DN (e.g. dc=example,dc=com)."}, - {"ldap-insecure", false, "Skip certificate verification for the LDAP server."}, - {"ldap-search-filter", "(uid=%s)", "LDAP search filter for user lookup."}, - {"resources-dir", "/data/resources", "Path to a directory containing custom resources (e.g. background image)."}, - {"database-path", "/data/tinyauth.db", "Path to the Sqlite database file. Directory will be created if it doesn't exist."}, - {"trusted-proxies", "", "Comma separated list of trusted proxies (IP addresses or CIDRs) for correct client IP detection."}, - {"disable-analytics", false, "Disable anonymous version collection."}, - {"disable-resources", false, "Disable the resources server."}, - {"socket-path", "", "Path to the Unix socket to bind the server to."}, - {"disable-ui-warnings", false, "Disable UI warnings about insecure configurations."}, - } - - for _, opt := range configOptions { - switch v := opt.defaultVal.(type) { - case bool: - c.cmd.Flags().Bool(opt.name, v, opt.description) - case int: - c.cmd.Flags().Int(opt.name, v, opt.description) - case string: - c.cmd.Flags().String(opt.name, v, opt.description) - } - - // Create uppercase env var name - envVar := strings.ReplaceAll(strings.ToUpper(opt.name), "-", "_") - c.viper.BindEnv(opt.name, envVar) - } - - c.viper.BindPFlags(c.cmd.Flags()) - - if c.root != nil { - c.root.AddCommand(c.cmd) - } -} - -func (c *rootCmd) GetCmd() *cobra.Command { - return c.cmd -} - -func (c *rootCmd) run(cmd *cobra.Command, args []string) { - var conf config.Config - - err := c.viper.Unmarshal(&conf) - if err != nil { - log.Fatal().Err(err).Msg("Failed to parse config") - } - - v := validator.New() - err = v.Struct(conf) - if err != nil { - log.Fatal().Err(err).Msg("Invalid config") - } - - log.Logger = log.Level(zerolog.Level(utils.GetLogLevel(conf.LogLevel))) - log.Info().Str("version", strings.TrimSpace(config.Version)).Msg("Starting Tinyauth") - - if log.Logger.GetLevel() == zerolog.TraceLevel { - log.Warn().Msg("Log level set to trace, this will log sensitive information!") - } - - app := bootstrap.NewBootstrapApp(conf) - - err = app.Setup() - if err != nil { - log.Fatal().Err(err).Msg("Failed to setup app") - } -} - -func Run() { - rootCmd := newRootCmd() - rootCmd.Register() - root := rootCmd.GetCmd() - - userCmd := &cobra.Command{ - Use: "user", - Short: "User utilities", - Long: `Utilities for creating and verifying tinyauth compatible users.`, - } - totpCmd := &cobra.Command{ - Use: "totp", - Short: "Totp utilities", - Long: `Utilities for creating and verifying totp codes.`, - } - - newCreateUserCmd(userCmd).Register() - newVerifyUserCmd(userCmd).Register() - newGenerateTotpCmd(totpCmd).Register() - newVersionCmd(root).Register() - newHealthcheckCmd(root).Register() - - root.AddCommand(userCmd) - root.AddCommand(totpCmd) - - err := root.Execute() - - if err != nil { - log.Fatal().Err(err).Msg("Failed to execute root command") - } -} diff --git a/cmd/tinyauth/create.go b/cmd/tinyauth/create.go new file mode 100644 index 0000000..6179f1d --- /dev/null +++ b/cmd/tinyauth/create.go @@ -0,0 +1,98 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/charmbracelet/huh" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/traefik/paerser/cli" + "golang.org/x/crypto/bcrypt" +) + +type CreateUserConfig struct { + Interactive bool `description:"Create a user interactively."` + Docker bool `description:"Format output for docker."` + Username string `description:"Username."` + Password string `description:"Password."` +} + +func NewCreateUserConfig() *CreateUserConfig { + return &CreateUserConfig{ + Interactive: false, + Docker: false, + Username: "", + Password: "", + } +} + +func createUserCmd() *cli.Command { + tCfg := NewCreateUserConfig() + + loaders := []cli.ResourceLoader{ + &cli.FlagLoader{}, + } + + return &cli.Command{ + Name: "create", + Description: "Create a user", + Configuration: tCfg, + Resources: loaders, + Run: func(_ []string) error { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Caller().Logger().Level(zerolog.InfoLevel) + + 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), + ), + ) + + var baseTheme *huh.Theme = huh.ThemeBase() + + err := form.WithTheme(baseTheme).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") + } + + log.Info().Str("username", tCfg.Username).Msg("Creating user") + + passwd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + // If docker format is enabled, escape the dollar sign + passwdStr := string(passwd) + if tCfg.Docker { + passwdStr = strings.ReplaceAll(passwdStr, "$", "$$") + } + + log.Info().Str("user", fmt.Sprintf("%s:%s", tCfg.Username, passwdStr)).Msg("User created") + + return nil + }, + } +} diff --git a/cmd/tinyauth/generate.go b/cmd/tinyauth/generate.go new file mode 100644 index 0000000..0c0755b --- /dev/null +++ b/cmd/tinyauth/generate.go @@ -0,0 +1,119 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + "tinyauth/internal/utils" + + "github.com/charmbracelet/huh" + "github.com/mdp/qrterminal/v3" + "github.com/pquerna/otp/totp" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/traefik/paerser/cli" +) + +type GenerateTotpConfig struct { + Interactive bool `description:"Generate a TOTP secret interactively."` + User string `description:"Your current user (username:hash)."` +} + +func NewGenerateTotpConfig() *GenerateTotpConfig { + return &GenerateTotpConfig{ + Interactive: false, + User: "", + } +} + +func generateTotpCmd() *cli.Command { + tCfg := NewGenerateTotpConfig() + + loaders := []cli.ResourceLoader{ + &cli.FlagLoader{}, + } + + return &cli.Command{ + Name: "generate", + Description: "Generate a TOTP secret", + Configuration: tCfg, + Resources: loaders, + Run: func(_ []string) error { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Caller().Logger().Level(zerolog.InfoLevel) + + 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 + })), + ), + ) + + var baseTheme *huh.Theme = huh.ThemeBase() + + err := form.WithTheme(baseTheme).Run() + + if err != nil { + return fmt.Errorf("failed to run interactive prompt: %w", err) + } + } + + user, err := utils.ParseUser(tCfg.User) + + if err != nil { + return fmt.Errorf("failed to parse user: %w", err) + } + + docker := false + if strings.Contains(tCfg.User, "$$") { + docker = true + } + + if user.TotpSecret != "" { + return fmt.Errorf("user already has a TOTP secret") + } + + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: "Tinyauth", + AccountName: user.Username, + }) + + if err != nil { + return fmt.Errorf("failed to generate TOTP secret: %w", err) + } + + secret := key.Secret() + + log.Info().Str("secret", secret).Msg("Generated TOTP secret") + + log.Info().Msg("Generated QR code") + + config := qrterminal.Config{ + Level: qrterminal.L, + Writer: os.Stdout, + BlackChar: qrterminal.BLACK, + WhiteChar: qrterminal.WHITE, + QuietZone: 2, + } + + qrterminal.GenerateWithConfig(key.URL(), config) + + user.TotpSecret = secret + + // If using docker escape re-escape it + if docker { + user.Password = strings.ReplaceAll(user.Password, "$", "$$") + } + + log.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.") + + return nil + }, + } +} diff --git a/cmd/tinyauth/healthcheck.go b/cmd/tinyauth/healthcheck.go new file mode 100644 index 0000000..7b2fdcb --- /dev/null +++ b/cmd/tinyauth/healthcheck.go @@ -0,0 +1,85 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/traefik/paerser/cli" +) + +type healthzResponse struct { + Status string `json:"status"` + Message string `json:"message"` +} + +func healthcheckCmd() *cli.Command { + return &cli.Command{ + Name: "healthcheck", + Description: "Perform a health check", + Configuration: nil, + Resources: nil, + AllowArg: true, + Run: func(args []string) error { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Caller().Logger().Level(zerolog.InfoLevel) + + appUrl := os.Getenv("TINYAUTH_APPURL") + + if len(args) > 0 { + appUrl = args[0] + } + + if appUrl == "" { + return errors.New("TINYAUTH_APPURL is not set and no argument was provided") + } + + log.Info().Str("app_url", appUrl).Msg("Performing health check") + + client := http.Client{ + Timeout: 30 * time.Second, + } + + req, err := http.NewRequest("GET", appUrl+"/api/healthz", nil) + + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(req) + + if err != nil { + return fmt.Errorf("failed to perform request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("service is not healthy, got: %s", resp.Status) + } + + defer resp.Body.Close() + + var healthResp healthzResponse + + body, err := io.ReadAll(resp.Body) + + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + err = json.Unmarshal(body, &healthResp) + + if err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + + log.Info().Interface("response", healthResp).Msg("Tinyauth is healthy") + + return nil + }, + } +} diff --git a/cmd/tinyauth/tinyauth.go b/cmd/tinyauth/tinyauth.go new file mode 100644 index 0000000..8642eed --- /dev/null +++ b/cmd/tinyauth/tinyauth.go @@ -0,0 +1,124 @@ +package main + +import ( + "fmt" + "os" + "strings" + "time" + "tinyauth/internal/bootstrap" + "tinyauth/internal/config" + "tinyauth/internal/utils/loaders" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/traefik/paerser/cli" +) + +func NewTinyauthCmdConfiguration() *config.Config { + return &config.Config{ + LogLevel: "info", + ResourcesDir: "./resources", + DatabasePath: "./tinyauth.db", + Server: config.ServerConfig{ + Port: 3000, + Address: "0.0.0.0", + }, + Auth: config.AuthConfig{ + SessionExpiry: 3600, + LoginTimeout: 300, + LoginMaxRetries: 3, + }, + UI: config.UIConfig{ + Title: "Tinyauth", + ForgotPasswordMessage: "You can change your password by changing the configuration.", + BackgroundImage: "/background.jpg", + }, + Experimental: config.ExperimentalConfig{ + ConfigFile: "", + }, + } +} + +func main() { + tConfig := NewTinyauthCmdConfiguration() + + loaders := []cli.ResourceLoader{ + &loaders.FileLoader{}, + &loaders.FlagLoader{}, + &loaders.EnvLoader{}, + } + + cmdTinyauth := &cli.Command{ + Name: "tinyauth", + Description: "The simplest way to protect your apps with a login screen.", + Configuration: tConfig, + Resources: loaders, + Run: func(_ []string) error { + return runCmd(*tConfig) + }, + } + + err := cmdTinyauth.AddCommand(versionCmd()) + + if err != nil { + log.Fatal().Err(err).Msg("Failed to add version command") + } + + err = cmdTinyauth.AddCommand(verifyUserCmd()) + + if err != nil { + log.Fatal().Err(err).Msg("Failed to add verify command") + } + + err = cmdTinyauth.AddCommand(healthcheckCmd()) + + if err != nil { + log.Fatal().Err(err).Msg("Failed to add healthcheck command") + } + + err = cmdTinyauth.AddCommand(generateTotpCmd()) + + if err != nil { + log.Fatal().Err(err).Msg("Failed to add generate command") + } + + err = cmdTinyauth.AddCommand(createUserCmd()) + + if err != nil { + log.Fatal().Err(err).Msg("Failed to add create command") + } + + err = cli.Execute(cmdTinyauth) + + if err != nil { + log.Fatal().Err(err).Msg("Failed to execute command") + } +} + +func runCmd(cfg config.Config) error { + logLevel, err := zerolog.ParseLevel(strings.ToLower(cfg.LogLevel)) + + if err != nil { + log.Error().Err(err).Msg("Invalid or missing log level, defaulting to info") + } else { + zerolog.SetGlobalLevel(logLevel) + } + + log.Logger = log.With().Caller().Logger() + + if !cfg.LogJSON { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}) + } + + log.Info().Str("version", config.Version).Msg("Starting tinyauth") + + app := bootstrap.NewBootstrapApp(cfg) + + err = app.Setup() + + if err != nil { + return fmt.Errorf("failed to bootstrap app: %w", err) + } + + return nil +} diff --git a/cmd/tinyauth/verify.go b/cmd/tinyauth/verify.go new file mode 100644 index 0000000..ddb114e --- /dev/null +++ b/cmd/tinyauth/verify.go @@ -0,0 +1,120 @@ +package main + +import ( + "errors" + "fmt" + "os" + "time" + "tinyauth/internal/utils" + + "github.com/charmbracelet/huh" + "github.com/pquerna/otp/totp" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/traefik/paerser/cli" + "golang.org/x/crypto/bcrypt" +) + +type VerifyUserConfig struct { + Interactive bool `description:"Validate a user interactively."` + Username string `description:"Username."` + Password string `description:"Password."` + Totp string `description:"TOTP code."` + User string `description:"Hash (username:hash:totp)."` +} + +func NewVerifyUserConfig() *VerifyUserConfig { + return &VerifyUserConfig{ + Interactive: false, + Username: "", + Password: "", + Totp: "", + User: "", + } +} + +func verifyUserCmd() *cli.Command { + tCfg := NewVerifyUserConfig() + + loaders := []cli.ResourceLoader{ + &cli.FlagLoader{}, + } + + return &cli.Command{ + Name: "verify", + Description: "Verify a user is set up correctly.", + Configuration: tCfg, + Resources: loaders, + Run: func(_ []string) error { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}).With().Caller().Logger().Level(zerolog.InfoLevel) + + 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), + ), + ) + + var baseTheme *huh.Theme = huh.ThemeBase() + + err := form.WithTheme(baseTheme).Run() + + if err != nil { + return fmt.Errorf("failed to run interactive prompt: %w", err) + } + } + + user, err := utils.ParseUser(tCfg.User) + + if err != nil { + return fmt.Errorf("failed to parse user: %w", err) + } + + if user.Username != tCfg.Username { + return fmt.Errorf("username is incorrect") + } + + err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(tCfg.Password)) + + if err != nil { + return fmt.Errorf("password is incorrect: %w", err) + } + + if user.TotpSecret == "" { + if tCfg.Totp != "" { + log.Warn().Msg("User does not have TOTP secret") + } + log.Info().Msg("User verified") + return nil + } + + ok := totp.Validate(tCfg.Totp, user.TotpSecret) + + if !ok { + return fmt.Errorf("TOTP code incorrect") + } + + log.Info().Msg("User verified") + + return nil + }, + } +} diff --git a/cmd/tinyauth/version.go b/cmd/tinyauth/version.go new file mode 100644 index 0000000..22aae14 --- /dev/null +++ b/cmd/tinyauth/version.go @@ -0,0 +1,23 @@ +package main + +import ( + "fmt" + "tinyauth/internal/config" + + "github.com/traefik/paerser/cli" +) + +func versionCmd() *cli.Command { + return &cli.Command{ + Name: "version", + Description: "Print the version number of Tinyauth.", + Configuration: nil, + Resources: nil, + Run: func(_ []string) error { + fmt.Printf("Version: %s\n", config.Version) + fmt.Printf("Commit Hash: %s\n", config.CommitHash) + fmt.Printf("Build Timestamp: %s\n", config.BuildTimestamp) + return nil + }, + } +} diff --git a/cmd/verify.go b/cmd/verify.go deleted file mode 100644 index 93b6a99..0000000 --- a/cmd/verify.go +++ /dev/null @@ -1,118 +0,0 @@ -package cmd - -import ( - "errors" - "tinyauth/internal/utils" - - "github.com/charmbracelet/huh" - "github.com/pquerna/otp/totp" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - "github.com/spf13/cobra" - "golang.org/x/crypto/bcrypt" -) - -type verifyUserCmd struct { - root *cobra.Command - cmd *cobra.Command - - interactive bool - username string - password string - totp string - user string -} - -func newVerifyUserCmd(root *cobra.Command) *verifyUserCmd { - return &verifyUserCmd{ - root: root, - } -} - -func (c *verifyUserCmd) Register() { - c.cmd = &cobra.Command{ - Use: "verify", - Short: "Verify a user is set up correctly", - Long: `Verify a user is set up correctly meaning that it has a correct username, password and TOTP code.`, - Run: c.run, - } - - c.cmd.Flags().BoolVarP(&c.interactive, "interactive", "i", false, "Validate a user interactively") - c.cmd.Flags().StringVar(&c.username, "username", "", "Username") - c.cmd.Flags().StringVar(&c.password, "password", "", "Password") - c.cmd.Flags().StringVar(&c.totp, "totp", "", "TOTP code") - c.cmd.Flags().StringVar(&c.user, "user", "", "Hash (username:hash:totp)") - - if c.root != nil { - c.root.AddCommand(c.cmd) - } -} - -func (c *verifyUserCmd) GetCmd() *cobra.Command { - return c.cmd -} - -func (c *verifyUserCmd) run(cmd *cobra.Command, args []string) { - log.Logger = log.Level(zerolog.InfoLevel) - - if c.interactive { - form := huh.NewForm( - huh.NewGroup( - huh.NewInput().Title("User (username:hash:totp)").Value(&c.user).Validate((func(s string) error { - if s == "" { - return errors.New("user cannot be empty") - } - return nil - })), - huh.NewInput().Title("Username").Value(&c.username).Validate((func(s string) error { - if s == "" { - return errors.New("username cannot be empty") - } - return nil - })), - huh.NewInput().Title("Password").Value(&c.password).Validate((func(s string) error { - if s == "" { - return errors.New("password cannot be empty") - } - return nil - })), - huh.NewInput().Title("TOTP Code (optional)").Value(&c.totp), - ), - ) - var baseTheme *huh.Theme = huh.ThemeBase() - err := form.WithTheme(baseTheme).Run() - if err != nil { - log.Fatal().Err(err).Msg("Form failed") - } - } - - user, err := utils.ParseUser(c.user) - if err != nil { - log.Fatal().Err(err).Msg("Failed to parse user") - } - - if user.Username != c.username { - log.Fatal().Msg("Username is incorrect") - } - - err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(c.password)) - if err != nil { - log.Fatal().Msg("Password is incorrect") - } - - if user.TotpSecret == "" { - if c.totp != "" { - log.Warn().Msg("User does not have TOTP secret") - } - log.Info().Msg("User verified") - return - } - - ok := totp.Validate(c.totp, user.TotpSecret) - if !ok { - log.Fatal().Msg("TOTP code incorrect") - - } - - log.Info().Msg("User verified") -} diff --git a/cmd/version.go b/cmd/version.go deleted file mode 100644 index 37eb14a..0000000 --- a/cmd/version.go +++ /dev/null @@ -1,42 +0,0 @@ -package cmd - -import ( - "fmt" - "tinyauth/internal/config" - - "github.com/spf13/cobra" -) - -type versionCmd struct { - root *cobra.Command - cmd *cobra.Command -} - -func newVersionCmd(root *cobra.Command) *versionCmd { - return &versionCmd{ - root: root, - } -} - -func (c *versionCmd) Register() { - c.cmd = &cobra.Command{ - Use: "version", - Short: "Print the version number of Tinyauth", - Long: `All software has versions. This is Tinyauth's.`, - Run: c.run, - } - - if c.root != nil { - c.root.AddCommand(c.cmd) - } -} - -func (c *versionCmd) GetCmd() *cobra.Command { - return c.cmd -} - -func (c *versionCmd) run(cmd *cobra.Command, args []string) { - fmt.Printf("Version: %s\n", config.Version) - fmt.Printf("Commit Hash: %s\n", config.CommitHash) - fmt.Printf("Build Timestamp: %s\n", config.BuildTimestamp) -} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..544bc83 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,88 @@ +# Tinyauth Example Configuration + +# The base URL where Tinyauth is accessible +appUrl: "https://auth.example.com" +# Log level: trace, debug, info, warn, error +logLevel: "info" +# Directory for static resources +resourcesDir: "./resources" +# Path to SQLite database file +databasePath: "./tinyauth.db" +# Disable usage analytics +disableAnalytics: false +# Disable static resource serving +disableResources: false +# Disable UI warning messages +disableUIWarnings: false +# Enable JSON formatted logs +logJSON: false + +# Server Configuration +server: + # Port to listen on + port: 3000 + # Interface to bind to (0.0.0.0 for all interfaces) + address: "0.0.0.0" + # Unix socket path (optional, overrides port/address if set) + socketPath: "" + # Comma-separated list of trusted proxy IPs/CIDRs + trustedProxies: "" + +# Authentication Configuration +auth: + # Format: username:bcrypt_hash (use bcrypt to generate hash) + users: "admin:$2a$10$example_bcrypt_hash_here" + # Path to external users file (optional) + usersFile: "" + # Enable secure cookies (requires HTTPS) + secureCookie: false + # Session expiry in seconds (3600 = 1 hour) + sessionExpiry: 3600 + # Login timeout in seconds (300 = 5 minutes) + loginTimeout: 300 + # Maximum login retries before lockout + loginMaxRetries: 3 + +# OAuth Configuration +oauth: + # Regex pattern for allowed email addresses (e.g., /@example\.com$/) + whitelist: "" + # Provider ID to auto-redirect to (skips login page) + autoRedirect: "" + # OAuth Provider Configuration (replace myprovider with your provider name) + providers: + myprovider: + clientId: "your_client_id_here" + clientSecret: "your_client_secret_here" + authUrl: "https://provider.example.com/oauth/authorize" + tokenUrl: "https://provider.example.com/oauth/token" + userInfoUrl: "https://provider.example.com/oauth/userinfo" + redirectUrl: "https://auth.example.com/api/oauth/callback/myprovider" + scopes: "openid email profile" + name: "My OAuth Provider" + # Allow insecure connections (self-signed certificates) + insecure: false + +# UI Customization +ui: + # Custom title for login page + title: "Tinyauth" + # Message shown on forgot password page + forgotPasswordMessage: "Contact your administrator to reset your password" + # Background image URL for login page + backgroundImage: "" + +# LDAP Configuration (optional) +ldap: + # LDAP server address + address: "ldap://ldap.example.com:389" + # DN for binding to LDAP server + bindDn: "cn=readonly,dc=example,dc=com" + # Password for bind DN + bindPassword: "your_bind_password" + # Base DN for user searches + baseDn: "dc=example,dc=com" + # Search filter (%s will be replaced with username) + searchFilter: "(&(uid=%s)(memberOf=cn=users,ou=groups,dc=example,dc=com))" + # Allow insecure LDAP connections + insecure: false diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 9cec4a5..d94221e 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -20,8 +20,8 @@ services: container_name: tinyauth image: ghcr.io/steveiliop56/tinyauth:v3 environment: - - APP_URL=https://tinyauth.example.com - - USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password + - TINYAUTH_APPURL=https://tinyauth.example.com + - TINYAUTH_AUTH_USERS=user:$$2a$$10$$UdLYoJ5lgPsC0RKqYH/jMua7zIn0g9kPqWmhYayJYLaZQ/FTmH2/u # user:password volumes: - ./data:/data labels: diff --git a/frontend/src/lib/i18n/i18n.ts b/frontend/src/lib/i18n/i18n.ts index 2810949..a14fdbf 100644 --- a/frontend/src/lib/i18n/i18n.ts +++ b/frontend/src/lib/i18n/i18n.ts @@ -2,7 +2,6 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import resourcesToBackend from "i18next-resources-to-backend"; -import { languages } from "./locales"; i18n .use(LanguageDetector) @@ -16,7 +15,6 @@ i18n fallbackLng: "en", debug: import.meta.env.MODE === "development", nonExplicitSupportedLngs: true, - supportedLngs: Object.keys(languages), load: "currentOnly", detection: { lookupLocalStorage: "tinyauth-lang", diff --git a/go.mod b/go.mod index 6f74e25..092a6b6 100644 --- a/go.mod +++ b/go.mod @@ -8,15 +8,11 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 github.com/gin-gonic/gin v1.11.0 github.com/glebarez/sqlite v1.11.0 - github.com/go-playground/validator/v10 v10.29.0 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/go-querystring v1.1.0 github.com/google/uuid v1.6.0 github.com/mdp/qrterminal/v3 v3.2.1 github.com/rs/zerolog v1.34.0 - github.com/spf13/cobra v1.10.2 - github.com/spf13/viper v1.21.0 - github.com/stoewer/go-strcase v1.3.1 github.com/traefik/paerser v0.2.2 github.com/weppos/publicsuffix-go v0.50.1 golang.org/x/crypto v0.46.0 @@ -27,6 +23,10 @@ require ( require ( github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect + github.com/BurntSushi/toml v1.4.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.2.1 // indirect + github.com/Masterminds/sprig/v3 v3.2.3 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -34,23 +34,28 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-playground/validator/v10 v10.28.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/huandu/xstrings v1.5.0 // indirect + github.com/imdario/mergo v0.3.11 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/term v0.38.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect @@ -81,8 +86,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.11 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-ldap/ldap/v3 v3.4.12 github.com/go-logr/logr v1.4.3 // indirect @@ -90,7 +94,6 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/goccy/go-json v0.10.4 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -112,12 +115,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pquerna/otp v1.5.0 github.com/rivo/uniseg v0.4.7 // indirect - github.com/sagikazarmark/locafero v0.11.0 // indirect - github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect - github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/subosito/gotenv v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect diff --git a/go.sum b/go.sum index dedf192..4a44d52 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,17 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= +github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= @@ -64,7 +73,6 @@ github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmC github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -87,10 +95,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= -github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= @@ -114,10 +120,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk= -github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= @@ -133,14 +137,18 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= @@ -186,8 +194,14 @@ github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuE github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= @@ -238,37 +252,25 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= -github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= -github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= -github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= -github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/traefik/paerser v0.2.2 h1:cpzW/ZrQrBh3mdwD/jnp6aXASiUFKOVr6ldP+keJTcQ= github.com/traefik/paerser v0.2.2/go.mod h1:7BBDd4FANoVgaTZG+yh26jI6CA2nds7D/4VTEdIsh24= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= @@ -279,6 +281,7 @@ github.com/weppos/publicsuffix-go v0.50.1 h1:elrBHeSkS/eIb169+DnLrknqmdP4AjT0Q0t github.com/weppos/publicsuffix-go v0.50.1/go.mod h1:znn0JVXjcR5hpUl9pbEogwH6I710rA1AX0QQPT0bf+k= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= @@ -301,36 +304,61 @@ go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c h1:AtEkQdl5b6zsybXcbz00j1LwNodDuH6hVifIaNqk7NQ= @@ -344,6 +372,9 @@ google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXn gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/bootstrap/app_bootstrap.go b/internal/bootstrap/app_bootstrap.go index 1862b72..a4d8024 100644 --- a/internal/bootstrap/app_bootstrap.go +++ b/internal/bootstrap/app_bootstrap.go @@ -43,7 +43,7 @@ func NewBootstrapApp(config config.Config) *BootstrapApp { func (app *BootstrapApp) Setup() error { // Parse users - users, err := utils.GetUsers(app.config.Users, app.config.UsersFile) + users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile) if err != nil { return err @@ -51,14 +51,35 @@ func (app *BootstrapApp) Setup() error { app.context.users = users - // Get OAuth configs - oauthProviders, err := utils.GetOAuthProvidersConfig(os.Environ(), os.Args, app.config.AppURL) + // Setup OAuth providers + app.context.oauthProviders = app.config.OAuth.Providers - if err != nil { - return err + for name, provider := range app.context.oauthProviders { + secret := utils.GetSecret(provider.ClientSecret, provider.ClientSecretFile) + provider.ClientSecret = secret + provider.ClientSecretFile = "" + app.context.oauthProviders[name] = provider } - app.context.oauthProviders = oauthProviders + for id := range config.OverrideProviders { + if provider, exists := app.context.oauthProviders[id]; exists { + if provider.RedirectURL == "" { + provider.RedirectURL = app.config.AppURL + "/api/oauth/callback/" + id + app.context.oauthProviders[id] = provider + } + } + } + + for id, provider := range app.context.oauthProviders { + if provider.Name == "" { + if name, ok := config.OverrideProviders[id]; ok { + provider.Name = name + } else { + provider.Name = utils.Capitalize(id) + } + } + app.context.oauthProviders[id] = provider + } // Get cookie domain cookieDomain, err := utils.GetCookieDomain(app.config.AppURL) @@ -98,7 +119,7 @@ func (app *BootstrapApp) Setup() error { // Configured providers configuredProviders := make([]controller.Provider, 0) - for id, provider := range oauthProviders { + for id, provider := range app.context.oauthProviders { configuredProviders = append(configuredProviders, controller.Provider{ Name: provider.Name, ID: id, @@ -144,17 +165,17 @@ func (app *BootstrapApp) Setup() error { } // If we have an socket path, bind to it - if app.config.SocketPath != "" { - if _, err := os.Stat(app.config.SocketPath); err == nil { - log.Info().Msgf("Removing existing socket file %s", app.config.SocketPath) - err := os.Remove(app.config.SocketPath) + if app.config.Server.SocketPath != "" { + if _, err := os.Stat(app.config.Server.SocketPath); err == nil { + log.Info().Msgf("Removing existing socket file %s", app.config.Server.SocketPath) + err := os.Remove(app.config.Server.SocketPath) if err != nil { return fmt.Errorf("failed to remove existing socket file: %w", err) } } - log.Info().Msgf("Starting server on unix socket %s", app.config.SocketPath) - if err := router.RunUnix(app.config.SocketPath); err != nil { + log.Info().Msgf("Starting server on unix socket %s", app.config.Server.SocketPath) + if err := router.RunUnix(app.config.Server.SocketPath); err != nil { log.Fatal().Err(err).Msg("Failed to start server") } @@ -162,7 +183,7 @@ func (app *BootstrapApp) Setup() error { } // Start server - address := fmt.Sprintf("%s:%d", app.config.Address, app.config.Port) + address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port) log.Info().Msgf("Starting server on %s", address) if err := router.Run(address); err != nil { log.Fatal().Err(err).Msg("Failed to start server") @@ -193,7 +214,7 @@ func (app *BootstrapApp) heartbeat() { } client := &http.Client{ - Timeout: time.Duration(10) * time.Second, // The server should never take more than 10 seconds to respond + Timeout: 30 * time.Second, // The server should never take more than 30 seconds to respond } heartbeatURL := config.ApiServer + "/v1/instances/heartbeat" diff --git a/internal/bootstrap/router_bootstrap.go b/internal/bootstrap/router_bootstrap.go index 554becb..9318561 100644 --- a/internal/bootstrap/router_bootstrap.go +++ b/internal/bootstrap/router_bootstrap.go @@ -13,8 +13,8 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) { engine := gin.New() engine.Use(gin.Recovery()) - if len(app.config.TrustedProxies) > 0 { - err := engine.SetTrustedProxies(strings.Split(app.config.TrustedProxies, ",")) + if len(app.config.Server.TrustedProxies) > 0 { + err := engine.SetTrustedProxies(strings.Split(app.config.Server.TrustedProxies, ",")) if err != nil { return nil, fmt.Errorf("failed to set trusted proxies: %w", err) @@ -57,12 +57,12 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) { contextController := controller.NewContextController(controller.ContextControllerConfig{ Providers: app.context.configuredProviders, - Title: app.config.Title, + Title: app.config.UI.Title, AppURL: app.config.AppURL, CookieDomain: app.context.cookieDomain, - ForgotPasswordMessage: app.config.ForgotPasswordMessage, - BackgroundImage: app.config.BackgroundImage, - OAuthAutoRedirect: app.config.OAuthAutoRedirect, + ForgotPasswordMessage: app.config.UI.ForgotPasswordMessage, + BackgroundImage: app.config.UI.BackgroundImage, + OAuthAutoRedirect: app.config.OAuth.AutoRedirect, DisableUIWarnings: app.config.DisableUIWarnings, }, apiRouter) @@ -70,7 +70,7 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) { oauthController := controller.NewOAuthController(controller.OAuthControllerConfig{ AppURL: app.config.AppURL, - SecureCookie: app.config.SecureCookie, + SecureCookie: app.config.Auth.SecureCookie, CSRFCookieName: app.context.csrfCookieName, RedirectCookieName: app.context.redirectCookieName, CookieDomain: app.context.cookieDomain, diff --git a/internal/bootstrap/service_bootstrap.go b/internal/bootstrap/service_bootstrap.go index 99d07de..e18d832 100644 --- a/internal/bootstrap/service_bootstrap.go +++ b/internal/bootstrap/service_bootstrap.go @@ -31,12 +31,12 @@ func (app *BootstrapApp) initServices() (Services, error) { services.databaseService = databaseService ldapService := service.NewLdapService(service.LdapServiceConfig{ - Address: app.config.LdapAddress, - BindDN: app.config.LdapBindDN, - BindPassword: app.config.LdapBindPassword, - BaseDN: app.config.LdapBaseDN, - Insecure: app.config.LdapInsecure, - SearchFilter: app.config.LdapSearchFilter, + Address: app.config.Ldap.Address, + BindDN: app.config.Ldap.BindDN, + BindPassword: app.config.Ldap.BindPassword, + BaseDN: app.config.Ldap.BaseDN, + Insecure: app.config.Ldap.Insecure, + SearchFilter: app.config.Ldap.SearchFilter, }) err = ldapService.Init() @@ -69,12 +69,12 @@ func (app *BootstrapApp) initServices() (Services, error) { authService := service.NewAuthService(service.AuthServiceConfig{ Users: app.context.users, - OauthWhitelist: app.config.OAuthWhitelist, - SessionExpiry: app.config.SessionExpiry, - SecureCookie: app.config.SecureCookie, + OauthWhitelist: app.config.OAuth.Whitelist, + SessionExpiry: app.config.Auth.SessionExpiry, + SecureCookie: app.config.Auth.SecureCookie, CookieDomain: app.context.cookieDomain, - LoginTimeout: app.config.LoginTimeout, - LoginMaxRetries: app.config.LoginMaxRetries, + LoginTimeout: app.config.Auth.LoginTimeout, + LoginMaxRetries: app.config.Auth.LoginMaxRetries, SessionCookieName: app.context.sessionCookieName, }, dockerService, ldapService, databaseService.GetDatabase()) diff --git a/internal/config/config.go b/internal/config/config.go index c53c4e7..e664c4d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,36 +15,67 @@ var RedirectCookieName = "tinyauth-redirect" // Main app config type Config struct { - Port int `mapstructure:"port" validate:"required"` - Address string `validate:"required,ip4_addr" mapstructure:"address"` - AppURL string `validate:"required,url" mapstructure:"app-url"` - Users string `mapstructure:"users"` - UsersFile string `mapstructure:"users-file"` - SecureCookie bool `mapstructure:"secure-cookie"` - OAuthWhitelist string `mapstructure:"oauth-whitelist"` - OAuthAutoRedirect string `mapstructure:"oauth-auto-redirect"` - SessionExpiry int `mapstructure:"session-expiry"` - LogLevel string `mapstructure:"log-level" validate:"oneof=trace debug info warn error fatal panic"` - Title string `mapstructure:"app-title"` - LoginTimeout int `mapstructure:"login-timeout"` - LoginMaxRetries int `mapstructure:"login-max-retries"` - ForgotPasswordMessage string `mapstructure:"forgot-password-message"` - BackgroundImage string `mapstructure:"background-image" validate:"required"` - LdapAddress string `mapstructure:"ldap-address"` - LdapBindDN string `mapstructure:"ldap-bind-dn"` - LdapBindPassword string `mapstructure:"ldap-bind-password"` - LdapBaseDN string `mapstructure:"ldap-base-dn"` - LdapInsecure bool `mapstructure:"ldap-insecure"` - LdapSearchFilter string `mapstructure:"ldap-search-filter"` - ResourcesDir string `mapstructure:"resources-dir"` - DatabasePath string `mapstructure:"database-path"` - TrustedProxies string `mapstructure:"trusted-proxies"` - DisableAnalytics bool `mapstructure:"disable-analytics"` - DisableResources bool `mapstructure:"disable-resources"` - DisableUIWarnings bool `mapstructure:"disable-ui-warnings"` - SocketPath string `mapstructure:"socket-path"` + AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl"` + LogLevel string `description:"Log level (trace, debug, info, warn, error)." yaml:"logLevel"` + ResourcesDir string `description:"The directory where resources are stored." yaml:"resourcesDir"` + DatabasePath string `description:"The path to the database file." yaml:"databasePath"` + DisableAnalytics bool `description:"Disable analytics." yaml:"disableAnalytics"` + DisableResources bool `description:"Disable resources server." yaml:"disableResources"` + DisableUIWarnings bool `description:"Disable UI warnings." yaml:"disableUIWarnings"` + LogJSON bool `description:"Enable JSON formatted logs." yaml:"logJSON"` + Server ServerConfig `description:"Server configuration." yaml:"server"` + Auth AuthConfig `description:"Authentication configuration." yaml:"auth"` + OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"` + UI UIConfig `description:"UI customization." yaml:"ui"` + Ldap LdapConfig `description:"LDAP configuration." yaml:"ldap"` + Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"` } +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"` + TrustedProxies string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"` +} + +type AuthConfig struct { + Users string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"` + 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"` + LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"` + LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"` +} + +type OAuthConfig struct { + Whitelist string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist"` + AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect"` + Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers"` +} + +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"` +} + +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"` + 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"` +} + +type ExperimentalConfig struct { + ConfigFile string `description:"Path to config file." yaml:"-"` +} + +// Config loader options + +const DefaultNamePrefix = "TINYAUTH_" + // OAuth/OIDC config type Claims struct { @@ -55,16 +86,16 @@ type Claims struct { } type OAuthServiceConfig struct { - ClientID string `field:"client-id"` - ClientSecret string - ClientSecretFile string - Scopes []string - RedirectURL string `field:"redirect-url"` - AuthURL string `field:"auth-url"` - TokenURL string `field:"token-url"` - UserinfoURL string `field:"user-info-url"` - InsecureSkipVerify bool - Name string + ClientID string `description:"OAuth client ID."` + ClientSecret string `description:"OAuth client secret."` + ClientSecretFile string `description:"Path to the file containing the OAuth client secret."` + Scopes []string `description:"OAuth scopes."` + RedirectURL string `description:"OAuth redirect URL."` + AuthURL string `description:"OAuth authorization URL."` + TokenURL string `description:"OAuth token URL."` + UserinfoURL string `description:"OAuth userinfo URL."` + Insecure bool `description:"Allow insecure OAuth connections."` + Name string `description:"Provider name in UI."` } var OverrideProviders = map[string]string{ diff --git a/internal/service/docker_service.go b/internal/service/docker_service.go index b0f977d..1c39778 100644 --- a/internal/service/docker_service.go +++ b/internal/service/docker_service.go @@ -82,7 +82,7 @@ func (docker *DockerService) GetLabels(appDomain string) (config.App, error) { return config.App{}, err } - labels, err := decoders.DecodeLabels(inspect.Config.Labels) + labels, err := decoders.DecodeLabels[config.Apps](inspect.Config.Labels, "apps") if err != nil { return config.App{}, err } diff --git a/internal/service/generic_oauth_service.go b/internal/service/generic_oauth_service.go index 49fa9bd..d68f8ae 100644 --- a/internal/service/generic_oauth_service.go +++ b/internal/service/generic_oauth_service.go @@ -38,7 +38,7 @@ func NewGenericOAuthService(config config.OAuthServiceConfig) *GenericOAuthServi TokenURL: config.TokenURL, }, }, - insecureSkipVerify: config.InsecureSkipVerify, + insecureSkipVerify: config.Insecure, userinfoUrl: config.UserinfoURL, name: config.Name, } @@ -54,6 +54,7 @@ func (generic *GenericOAuthService) Init() error { httpClient := &http.Client{ Transport: transport, + Timeout: 30 * time.Second, } ctx := context.Background() diff --git a/internal/service/github_oauth_service.go b/internal/service/github_oauth_service.go index 0d3d76f..11dcf61 100644 --- a/internal/service/github_oauth_service.go +++ b/internal/service/github_oauth_service.go @@ -50,7 +50,9 @@ func NewGithubOAuthService(config config.OAuthServiceConfig) *GithubOAuthService } func (github *GithubOAuthService) Init() error { - httpClient := &http.Client{} + httpClient := &http.Client{ + Timeout: 30 * time.Second, + } ctx := context.Background() ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) github.context = ctx diff --git a/internal/service/google_oauth_service.go b/internal/service/google_oauth_service.go index 474c285..0ece6f2 100644 --- a/internal/service/google_oauth_service.go +++ b/internal/service/google_oauth_service.go @@ -45,7 +45,9 @@ func NewGoogleOAuthService(config config.OAuthServiceConfig) *GoogleOAuthService } func (google *GoogleOAuthService) Init() error { - httpClient := &http.Client{} + httpClient := &http.Client{ + Timeout: 30 * time.Second, + } ctx := context.Background() ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) google.context = ctx diff --git a/internal/utils/app_utils.go b/internal/utils/app_utils.go index 40f48e8..a2b23c8 100644 --- a/internal/utils/app_utils.go +++ b/internal/utils/app_utils.go @@ -6,12 +6,8 @@ import ( "net/url" "strings" "tinyauth/internal/config" - "tinyauth/internal/utils/decoders" - - "maps" "github.com/gin-gonic/gin" - "github.com/rs/zerolog" "github.com/weppos/publicsuffix-go/publicsuffix" ) @@ -104,119 +100,3 @@ func IsRedirectSafe(redirectURL string, domain string) bool { return hostname == domain } - -func GetLogLevel(level string) zerolog.Level { - switch strings.ToLower(level) { - case "trace": - return zerolog.TraceLevel - case "debug": - return zerolog.DebugLevel - case "info": - return zerolog.InfoLevel - case "warn": - return zerolog.WarnLevel - case "error": - return zerolog.ErrorLevel - case "fatal": - return zerolog.FatalLevel - case "panic": - return zerolog.PanicLevel - default: - return zerolog.InfoLevel - } -} - -func GetOAuthProvidersConfig(env []string, args []string, appUrl string) (map[string]config.OAuthServiceConfig, error) { - providers := make(map[string]config.OAuthServiceConfig) - - // Get from environment variables - envMap := make(map[string]string) - - for _, e := range env { - pair := strings.SplitN(e, "=", 2) - if len(pair) == 2 { - envMap[pair[0]] = pair[1] - } - } - - envProviders, err := decoders.DecodeEnv[config.Providers, config.OAuthServiceConfig](envMap, "providers") - - if err != nil { - return nil, err - } - - maps.Copy(providers, envProviders.Providers) - - // Get from flags - flagsMap := make(map[string]string) - - for _, arg := range args[1:] { - if strings.HasPrefix(arg, "--") { - pair := strings.SplitN(arg[2:], "=", 2) - if len(pair) == 2 { - flagsMap[pair[0]] = pair[1] - } - } - } - - flagProviders, err := decoders.DecodeFlags[config.Providers, config.OAuthServiceConfig](flagsMap, "providers") - - if err != nil { - return nil, err - } - - maps.Copy(providers, flagProviders.Providers) - - // For every provider get correct secret from file if set - for name, provider := range providers { - secret := GetSecret(provider.ClientSecret, provider.ClientSecretFile) - provider.ClientSecret = secret - provider.ClientSecretFile = "" - providers[name] = provider - } - - // If we have google/github providers and no redirect URL then set a default - for id := range config.OverrideProviders { - if provider, exists := providers[id]; exists { - if provider.RedirectURL == "" { - provider.RedirectURL = appUrl + "/api/oauth/callback/" + id - providers[id] = provider - } - } - } - - // Set names - for id, provider := range providers { - if provider.Name == "" { - if name, ok := config.OverrideProviders[id]; ok { - provider.Name = name - } else { - provider.Name = Capitalize(id) - } - } - providers[id] = provider - } - - // Return combined providers - return providers, nil -} - -func ShoudLogJSON(environ []string, args []string) bool { - for _, e := range environ { - pair := strings.SplitN(e, "=", 2) - if len(pair) == 2 && pair[0] == "LOG_JSON" && strings.ToLower(pair[1]) == "true" { - return true - } - } - - for _, arg := range args[1:] { - if strings.HasPrefix(arg, "--log-json=") { - value := strings.SplitN(arg, "=", 2)[1] - if strings.ToLower(value) == "true" { - return true - } - } - } - - return false -} diff --git a/internal/utils/app_utils_test.go b/internal/utils/app_utils_test.go index 71c1aa0..55fde36 100644 --- a/internal/utils/app_utils_test.go +++ b/internal/utils/app_utils_test.go @@ -1,7 +1,6 @@ package utils_test import ( - "os" "testing" "tinyauth/internal/config" "tinyauth/internal/utils" @@ -206,93 +205,3 @@ func TestIsRedirectSafe(t *testing.T) { result = utils.IsRedirectSafe(redirectURL, domain) assert.Equal(t, false, result) } - -func TestGetOAuthProvidersConfig(t *testing.T) { - env := []string{"PROVIDERS_CLIENT1_CLIENT_ID=client1-id", "PROVIDERS_CLIENT1_CLIENT_SECRET=client1-secret"} - args := []string{"/tinyauth/tinyauth", "--providers-client2-client-id=client2-id", "--providers-client2-client-secret=client2-secret"} - - expected := map[string]config.OAuthServiceConfig{ - "client1": { - ClientID: "client1-id", - ClientSecret: "client1-secret", - Name: "Client1", - }, - "client2": { - ClientID: "client2-id", - ClientSecret: "client2-secret", - Name: "Client2", - }, - } - - result, err := utils.GetOAuthProvidersConfig(env, args, "") - assert.NilError(t, err) - assert.DeepEqual(t, expected, result) - - // Case with no providers - env = []string{} - args = []string{"/tinyauth/tinyauth"} - expected = map[string]config.OAuthServiceConfig{} - - result, err = utils.GetOAuthProvidersConfig(env, args, "") - assert.NilError(t, err) - assert.DeepEqual(t, expected, result) - - // Case with secret from file - file, err := os.Create("/tmp/tinyauth_test_file") - assert.NilError(t, err) - - _, err = file.WriteString("file content\n") - assert.NilError(t, err) - - err = file.Close() - assert.NilError(t, err) - defer os.Remove("/tmp/tinyauth_test_file") - - env = []string{"PROVIDERS_CLIENT1_CLIENT_ID=client1-id", "PROVIDERS_CLIENT1_CLIENT_SECRET_FILE=/tmp/tinyauth_test_file"} - args = []string{"/tinyauth/tinyauth"} - expected = map[string]config.OAuthServiceConfig{ - "client1": { - ClientID: "client1-id", - ClientSecret: "file content", - Name: "Client1", - }, - } - - result, err = utils.GetOAuthProvidersConfig(env, args, "") - assert.NilError(t, err) - assert.DeepEqual(t, expected, result) - - // Case with google provider and no redirect URL - env = []string{"PROVIDERS_GOOGLE_CLIENT_ID=google-id", "PROVIDERS_GOOGLE_CLIENT_SECRET=google-secret"} - args = []string{"/tinyauth/tinyauth"} - expected = map[string]config.OAuthServiceConfig{ - "google": { - ClientID: "google-id", - ClientSecret: "google-secret", - RedirectURL: "http://app.url/api/oauth/callback/google", - Name: "Google", - }, - } - - result, err = utils.GetOAuthProvidersConfig(env, args, "http://app.url") - assert.NilError(t, err) - assert.DeepEqual(t, expected, result) -} - -func TestShoudLogJSON(t *testing.T) { - // Test with no env or args - result := utils.ShoudLogJSON([]string{"FOO=bar"}, []string{"tinyauth", "--foo-bar=baz"}) - assert.Equal(t, false, result) - - // Test with env variable set - result = utils.ShoudLogJSON([]string{"LOG_JSON=true"}, []string{"tinyauth", "--foo-bar=baz"}) - assert.Equal(t, true, result) - - // Test with flag set - result = utils.ShoudLogJSON([]string{"FOO=bar"}, []string{"tinyauth", "--log-json=true"}) - assert.Equal(t, true, result) - - // Test with both env and flag set to false - result = utils.ShoudLogJSON([]string{"LOG_JSON=false"}, []string{"tinyauth", "--log-json=false"}) - assert.Equal(t, false, result) -} diff --git a/internal/utils/decoders/decoders.go b/internal/utils/decoders/decoders.go deleted file mode 100644 index 0c3d22d..0000000 --- a/internal/utils/decoders/decoders.go +++ /dev/null @@ -1,80 +0,0 @@ -package decoders - -import ( - "reflect" - "strings" - - "github.com/stoewer/go-strcase" -) - -func normalizeKeys[T any](input map[string]string, root string, sep string) map[string]string { - knownKeys := getKnownKeys[T]() - normalized := make(map[string]string) - - for k, v := range input { - parts := []string{"tinyauth"} - - key := strings.ToLower(k) - key = strings.ReplaceAll(key, sep, "-") - - if !strings.HasPrefix(key, root+"-") { - continue - } - - suffix := "" - - for _, known := range knownKeys { - if strings.HasSuffix(key, known) { - suffix = known - break - } - } - - if suffix == "" { - continue - } - - parts = append(parts, root) - - id := strings.TrimPrefix(key, root+"-") - id = strings.TrimSuffix(id, "-"+suffix) - - if id == "" { - continue - } - - parts = append(parts, id) - parts = append(parts, suffix) - - final := "" - - for i, part := range parts { - if i > 0 { - final += "." - } - final += strcase.LowerCamelCase(part) - } - - normalized[final] = v - } - - return normalized -} - -func getKnownKeys[T any]() []string { - var keys []string - var t T - - v := reflect.ValueOf(t) - typeOfT := v.Type() - - for field := range typeOfT.NumField() { - if typeOfT.Field(field).Tag.Get("field") != "" { - keys = append(keys, typeOfT.Field(field).Tag.Get("field")) - continue - } - keys = append(keys, strcase.KebabCase(typeOfT.Field(field).Name)) - } - - return keys -} diff --git a/internal/utils/decoders/env_decoder.go b/internal/utils/decoders/env_decoder.go deleted file mode 100644 index 532ec64..0000000 --- a/internal/utils/decoders/env_decoder.go +++ /dev/null @@ -1,19 +0,0 @@ -package decoders - -import ( - "github.com/traefik/paerser/parser" -) - -func DecodeEnv[T any, C any](env map[string]string, subName string) (T, error) { - var result T - - normalized := normalizeKeys[C](env, subName, "_") - - err := parser.Decode(normalized, &result, "tinyauth", "tinyauth."+subName) - - if err != nil { - return result, err - } - - return result, nil -} diff --git a/internal/utils/decoders/env_decoder_test.go b/internal/utils/decoders/env_decoder_test.go deleted file mode 100644 index da679f0..0000000 --- a/internal/utils/decoders/env_decoder_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package decoders_test - -import ( - "testing" - "tinyauth/internal/config" - "tinyauth/internal/utils/decoders" - - "gotest.tools/v3/assert" -) - -func TestDecodeEnv(t *testing.T) { - // Setup - env := map[string]string{ - "PROVIDERS_GOOGLE_CLIENT_ID": "google-client-id", - "PROVIDERS_GOOGLE_CLIENT_SECRET": "google-client-secret", - "PROVIDERS_MY_GITHUB_CLIENT_ID": "github-client-id", - "PROVIDERS_MY_GITHUB_CLIENT_SECRET": "github-client-secret", - } - - expected := config.Providers{ - Providers: map[string]config.OAuthServiceConfig{ - "google": { - ClientID: "google-client-id", - ClientSecret: "google-client-secret", - }, - "myGithub": { - ClientID: "github-client-id", - ClientSecret: "github-client-secret", - }, - }, - } - - // Execute - result, err := decoders.DecodeEnv[config.Providers, config.OAuthServiceConfig](env, "providers") - assert.NilError(t, err) - assert.DeepEqual(t, result, expected) -} diff --git a/internal/utils/decoders/flags_decoder.go b/internal/utils/decoders/flags_decoder.go deleted file mode 100644 index 0aae234..0000000 --- a/internal/utils/decoders/flags_decoder.go +++ /dev/null @@ -1,30 +0,0 @@ -package decoders - -import ( - "strings" - - "github.com/traefik/paerser/parser" -) - -func DecodeFlags[T any, C any](flags map[string]string, subName string) (T, error) { - var result T - - filtered := filterFlags(flags) - normalized := normalizeKeys[C](filtered, subName, "_") - - err := parser.Decode(normalized, &result, "tinyauth", "tinyauth."+subName) - - if err != nil { - return result, err - } - - return result, nil -} - -func filterFlags(flags map[string]string) map[string]string { - filtered := make(map[string]string) - for k, v := range flags { - filtered[strings.TrimPrefix(k, "--")] = v - } - return filtered -} diff --git a/internal/utils/decoders/flags_decoder_test.go b/internal/utils/decoders/flags_decoder_test.go deleted file mode 100644 index 935dea0..0000000 --- a/internal/utils/decoders/flags_decoder_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package decoders_test - -import ( - "testing" - "tinyauth/internal/config" - "tinyauth/internal/utils/decoders" - - "gotest.tools/v3/assert" -) - -func TestDecodeFlags(t *testing.T) { - // Setup - flags := map[string]string{ - "--providers-google-client-id": "google-client-id", - "--providers-google-client-secret": "google-client-secret", - "--providers-my-github-client-id": "github-client-id", - "--providers-my-github-client-secret": "github-client-secret", - } - - expected := config.Providers{ - Providers: map[string]config.OAuthServiceConfig{ - "google": { - ClientID: "google-client-id", - ClientSecret: "google-client-secret", - }, - "myGithub": { - ClientID: "github-client-id", - ClientSecret: "github-client-secret", - }, - }, - } - - // Execute - result, err := decoders.DecodeFlags[config.Providers, config.OAuthServiceConfig](flags, "providers") - assert.NilError(t, err) - assert.DeepEqual(t, result, expected) -} diff --git a/internal/utils/decoders/label_decoder.go b/internal/utils/decoders/label_decoder.go index e83e275..8a9862f 100644 --- a/internal/utils/decoders/label_decoder.go +++ b/internal/utils/decoders/label_decoder.go @@ -1,19 +1,17 @@ package decoders import ( - "tinyauth/internal/config" - "github.com/traefik/paerser/parser" ) -func DecodeLabels(labels map[string]string) (config.Apps, error) { - var appLabels config.Apps +func DecodeLabels[T any](labels map[string]string, root string) (T, error) { + var labelsDecoded T - err := parser.Decode(labels, &appLabels, "tinyauth", "tinyauth.apps") + err := parser.Decode(labels, &labelsDecoded, "tinyauth", "tinyauth."+root) if err != nil { - return config.Apps{}, err + return labelsDecoded, err } - return appLabels, nil + return labelsDecoded, nil } diff --git a/internal/utils/decoders/label_decoder_test.go b/internal/utils/decoders/label_decoder_test.go index 63189d1..c8874f4 100644 --- a/internal/utils/decoders/label_decoder_test.go +++ b/internal/utils/decoders/label_decoder_test.go @@ -62,7 +62,7 @@ func TestDecodeLabels(t *testing.T) { } // Test - result, err := decoders.DecodeLabels(test) + result, err := decoders.DecodeLabels[config.Apps](test, "apps") assert.NilError(t, err) assert.DeepEqual(t, expected, result) } diff --git a/internal/utils/loaders/loader_env.go b/internal/utils/loaders/loader_env.go new file mode 100644 index 0000000..9811974 --- /dev/null +++ b/internal/utils/loaders/loader_env.go @@ -0,0 +1,25 @@ +package loaders + +import ( + "fmt" + "os" + "tinyauth/internal/config" + + "github.com/traefik/paerser/cli" + "github.com/traefik/paerser/env" +) + +type EnvLoader struct{} + +func (e *EnvLoader) Load(_ []string, cmd *cli.Command) (bool, error) { + vars := env.FindPrefixedEnvVars(os.Environ(), config.DefaultNamePrefix, cmd.Configuration) + if len(vars) == 0 { + return false, nil + } + + if err := env.Decode(vars, config.DefaultNamePrefix, cmd.Configuration); err != nil { + return false, fmt.Errorf("failed to decode configuration from environment variables: %w", err) + } + + return true, nil +} diff --git a/internal/utils/loaders/loader_file.go b/internal/utils/loaders/loader_file.go new file mode 100644 index 0000000..7242791 --- /dev/null +++ b/internal/utils/loaders/loader_file.go @@ -0,0 +1,35 @@ +package loaders + +import ( + "github.com/rs/zerolog/log" + "github.com/traefik/paerser/cli" + "github.com/traefik/paerser/file" + "github.com/traefik/paerser/flag" +) + +type FileLoader struct{} + +func (f *FileLoader) Load(args []string, cmd *cli.Command) (bool, error) { + flags, err := flag.Parse(args, cmd.Configuration) + + if err != nil { + return false, err + } + + // I guess we are using traefik as the root name + configFileFlag := "traefik.experimental.configFile" + + if _, ok := flags[configFileFlag]; !ok { + return false, nil + } + + log.Warn().Msg("Using experimental file config loader, this feature is experimental and may change or be removed in future releases") + + err = file.Decode(flags[configFileFlag], cmd.Configuration) + + if err != nil { + return false, err + } + + return true, nil +} diff --git a/internal/utils/loaders/loader_flag.go b/internal/utils/loaders/loader_flag.go new file mode 100644 index 0000000..7887e8c --- /dev/null +++ b/internal/utils/loaders/loader_flag.go @@ -0,0 +1,22 @@ +package loaders + +import ( + "fmt" + + "github.com/traefik/paerser/cli" + "github.com/traefik/paerser/flag" +) + +type FlagLoader struct{} + +func (*FlagLoader) Load(args []string, cmd *cli.Command) (bool, error) { + if len(args) == 0 { + return false, nil + } + + if err := flag.Decode(args, cmd.Configuration); err != nil { + return false, fmt.Errorf("failed to decode configuration from flags: %w", err) + } + + return true, nil +} diff --git a/main.go b/main.go deleted file mode 100644 index 94aefe2..0000000 --- a/main.go +++ /dev/null @@ -1,19 +0,0 @@ -package main - -import ( - "os" - "time" - "tinyauth/cmd" - "tinyauth/internal/utils" - - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" -) - -func main() { - log.Logger = log.Logger.With().Caller().Logger() - if !utils.ShoudLogJSON(os.Environ(), os.Args) { - log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}) - } - cmd.Run() -}