Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions cmd/tinyauth/tinyauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ func main() {
env := model.DetectRuntimeEnv()
tConfig := model.NewDefaultConfiguration(env)

envLoader := &loaders.EnvLoader{}

loaders := []cli.ResourceLoader{
&loaders.FileLoader{},
&loaders.FlagLoader{},
&loaders.EnvLoader{},
envLoader,
}

cmdTinyauth := &cli.Command{
Expand All @@ -37,7 +39,7 @@ func main() {
colors := getColors()
fmt.Println(colors.yellow.Render("⚠") + " Experimental features are enabled, use with caution. Experimental features may change with each release.")
}
return runCmd(*tConfig)
return runCmd(*tConfig, envLoader.Ignored)
},
}

Expand Down Expand Up @@ -144,8 +146,8 @@ func main() {
}
}

func runCmd(cfg model.Config) error {
app := bootstrap.NewBootstrapApp(cfg)
func runCmd(cfg model.Config, ignoredEnvVars []string) error {
app := bootstrap.NewBootstrapApp(cfg).WithIgnoredEnvVars(ignoredEnvVars)

err := app.Setup()

Expand Down
22 changes: 22 additions & 0 deletions internal/bootstrap/app_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ type BootstrapApp struct {
db *sql.DB
ding *ding.Ding
dig *dig.Container

ignoredEnvVars []string
}

func NewBootstrapApp(config model.Config) *BootstrapApp {
Expand All @@ -66,6 +68,12 @@ func NewBootstrapApp(config model.Config) *BootstrapApp {
}
}

// WithIgnoredEnvVars sets the unknown environment variables found while loading the configuration, they are logged on startup
func (app *BootstrapApp) WithIgnoredEnvVars(names []string) *BootstrapApp {
app.ignoredEnvVars = names
return app
}

func (app *BootstrapApp) Setup() error {
// create context
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
Expand All @@ -87,9 +95,23 @@ func (app *BootstrapApp) Setup() error {

app.log.App.Info().Msgf("Starting Tinyauth version: %s", model.Version)

if len(app.ignoredEnvVars) > 0 {
app.log.App.Warn().Strs("variables", app.ignoredEnvVars).Msg("Ignoring unknown environment variables, see https://tinyauth.app/docs/reference/configuration")
}

// get app url
appURL, err := utils.SafeParseAppURL(app.config.AppURL)

if errors.Is(err, utils.ErrEmptyURL) {
// v4 used APP_URL, a common leftover when migrating to v5
for _, legacy := range []string{"APP_URL", "TINYAUTH_APP_URL"} {
if os.Getenv(legacy) != "" {
return fmt.Errorf("%w, found %s which is not used since v5, rename it to TINYAUTH_APPURL", err, legacy)
}
}
return fmt.Errorf("%w, set it with TINYAUTH_APPURL, --appurl or appUrl in the config file (environment variables are ignored when a config file or CLI flags are used)", err)
}

if err != nil {
return fmt.Errorf("failed to parse app url: %w", err)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/utils/app_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
)

var (
ErrEmptyURL = fmt.Errorf("invalid url")
ErrEmptyURL = fmt.Errorf("app url is not set")
)

func SafeParseAppURL(str string) (string, error) {
Expand All @@ -28,7 +28,7 @@ func SafeParseAppURL(str string) (string, error) {
if u.Host == "" ||
(u.Scheme != "http" &&
u.Scheme != "https") {
return "", fmt.Errorf("invalid url, must be in format https(s)://host")
return "", fmt.Errorf("invalid url, must be in format http(s)://host")
}

hostname := strings.ToLower(u.Hostname())
Expand Down
68 changes: 65 additions & 3 deletions internal/utils/loaders/loader_env.go
Original file line number Diff line number Diff line change
@@ -1,25 +1,87 @@
package loaders

import (
"errors"
"fmt"
"os"
"reflect"
"regexp"
"slices"
"strings"

"github.com/tinyauthapp/paerser/cli"
"github.com/tinyauthapp/paerser/env"
"github.com/tinyauthapp/tinyauth/internal/model"
)

type EnvLoader struct{}
// kubernetesServiceEnvVar matches the variables Kubernetes injects for a service named tinyauth (service links)
var kubernetesServiceEnvVar = regexp.MustCompile(`^TINYAUTH_(SERVICE_HOST|SERVICE_PORT(_[A-Z0-9_]+)?|PORT(_[0-9]+_(TCP|UDP|SCTP)(_(ADDR|PORT|PROTO))?)?)$`)

type EnvLoader struct {
// Ignored holds the prefixed environment variables that matched no configuration option, they are logged on startup
Ignored []string
}

func (e *EnvLoader) Load(_ []string, cmd *cli.Command) (bool, error) {
vars := env.FindPrefixedEnvVars(os.Environ(), model.DefaultNamePrefix, cmd.Configuration)
environ := os.Environ()
vars := env.FindPrefixedEnvVars(environ, model.DefaultNamePrefix, cmd.Configuration)
e.Ignored = UnknownEnvVars(environ, vars)

if len(vars) == 0 {
return false, nil
}

if err := env.Decode(vars, model.DefaultNamePrefix, cmd.Configuration); err != nil {
return false, fmt.Errorf("failed to decode configuration from environment variables: %w", err)
// The decoder error can echo the offending value (e.g. a strconv parse error), which may be a
// secret pasted into the wrong variable, so it is not propagated. The rejected variable names
// are reported instead, which is what the operator needs to fix it.
if invalid := InvalidEnvVars(vars, cmd.Configuration); len(invalid) > 0 {
return false, fmt.Errorf("failed to decode configuration from environment variables, check %s", strings.Join(invalid, ", "))
}
return false, errors.New("failed to decode configuration from environment variables")
}

return true, nil
}

// UnknownEnvVars returns the names of prefixed environment variables that do not map to any configuration
// section and are therefore silently ignored by the decoder (e.g. TINYAUTH_APP_URL instead of TINYAUTH_APPURL).
func UnknownEnvVars(environ []string, matched []string) []string {
var unknown []string

for _, value := range environ {
if !strings.HasPrefix(value, model.DefaultNamePrefix) || slices.Contains(matched, value) {
continue
}
name, _, _ := strings.Cut(value, "=")
if kubernetesServiceEnvVar.MatchString(name) {
continue
}
unknown = append(unknown, name)
}

return unknown
}

// InvalidEnvVars decodes each variable on its own into a fresh configuration to find the names the decoder rejects,
// since the decoder error only contains the failing node (e.g. "databasepath") and not the variable name.
func InvalidEnvVars(vars []string, configuration any) []string {
var invalid []string

cfgType := reflect.TypeOf(configuration)
if cfgType == nil || cfgType.Kind() != reflect.Pointer {
return nil
}

for _, value := range vars {
fresh := reflect.New(cfgType.Elem()).Interface()
if err := env.Decode([]string{value}, model.DefaultNamePrefix, fresh); err != nil {
name, _, _ := strings.Cut(value, "=")
if !slices.Contains(invalid, name) {
invalid = append(invalid, name)
}
}
}

return invalid
}
74 changes: 74 additions & 0 deletions internal/utils/loaders/loader_env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package loaders_test

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/paerser/cli"
"github.com/tinyauthapp/paerser/env"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/loaders"
)

func TestLoadRedactsDecodeErrorValue(t *testing.T) {
secret := "super-secret-token-pasted-into-the-wrong-var"
t.Setenv("TINYAUTH_SERVER_PORT", secret) // port is an int, so this value fails to decode

loader := &loaders.EnvLoader{}
cfg := model.NewDefaultConfiguration(model.RuntimeEnvUnknown)

_, err := loader.Load(nil, &cli.Command{Configuration: cfg})

require.Error(t, err)
assert.Contains(t, err.Error(), "TINYAUTH_SERVER_PORT")
assert.NotContains(t, err.Error(), secret)
}

func TestUnknownEnvVars(t *testing.T) {
cfg := model.NewDefaultConfiguration(model.RuntimeEnvUnknown)

environ := []string{
"PATH=/usr/bin",
"APP_URL=https://tinyauth.example.com", // not prefixed, not ours
"TINYAUTH_APPURL=https://tinyauth.example.com",
"TINYAUTH_APP_URL=https://tinyauth.example.com",
"TINYAUTH_SERVER_PORT=3000",
"TINYAUTH_RESOURCESDIR=/data/resources", // matches the resources section, caught by InvalidEnvVars instead
// injected by Kubernetes for a service named tinyauth
"TINYAUTH_PORT=tcp://10.0.0.1:3000",
"TINYAUTH_PORT_3000_TCP=tcp://10.0.0.1:3000",
"TINYAUTH_PORT_3000_TCP_ADDR=10.0.0.1",
"TINYAUTH_PORT_3000_TCP_PORT=3000",
"TINYAUTH_PORT_3000_TCP_PROTO=tcp",
"TINYAUTH_SERVICE_HOST=10.0.0.1",
"TINYAUTH_SERVICE_PORT=3000",
"TINYAUTH_SERVICE_PORT_HTTP=3000",
}

vars := env.FindPrefixedEnvVars(environ, model.DefaultNamePrefix, cfg)

assert.Equal(t, []string{"TINYAUTH_APP_URL"}, loaders.UnknownEnvVars(environ, vars))
assert.Empty(t, loaders.UnknownEnvVars([]string{"TINYAUTH_APPURL=https://tinyauth.example.com"}, []string{"TINYAUTH_APPURL=https://tinyauth.example.com"}))
}

func TestInvalidEnvVars(t *testing.T) {
cfg := model.NewDefaultConfiguration(model.RuntimeEnvUnknown)

vars := []string{
"TINYAUTH_APPURL=https://tinyauth.example.com",
"TINYAUTH_DATABASEPATH=/data/tinyauth.db",
"TINYAUTH_DATABASEPATH=/data/tinyauth.db", // duplicates are reported once
"TINYAUTH_SERVER_PORT=3000",
"TINYAUTH_RESOURCESDIR=/data/resources",
"TINYAUTH_OAUTH_PROVIDERS_GOOGLE_CLIENT_ID=abc",
"TINYAUTH_OAUTH_PROVIDERS_GOOGLE_CLIENTID=abc",
}

assert.Equal(t, []string{"TINYAUTH_DATABASEPATH", "TINYAUTH_RESOURCESDIR", "TINYAUTH_OAUTH_PROVIDERS_GOOGLE_CLIENT_ID"}, loaders.InvalidEnvVars(vars, cfg))

// The configuration passed in must not be modified
assert.Equal(t, model.NewDefaultConfiguration(model.RuntimeEnvUnknown), cfg)

assert.Nil(t, loaders.InvalidEnvVars(vars, nil))
}