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
19 changes: 13 additions & 6 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"os"
"strings"
"tinyauth/internal/bootstrap"
"tinyauth/internal/config"
Expand All @@ -14,15 +15,16 @@ import (
)

type rootCmd struct {
root *cobra.Command
cmd *cobra.Command

viper *viper.Viper
root *cobra.Command
cmd *cobra.Command
viper *viper.Viper
aclFlags map[string]string
}

func newRootCmd() *rootCmd {
return &rootCmd{
viper: viper.New(),
viper: viper.New(),
aclFlags: make(map[string]string),
}
}

Expand All @@ -32,6 +34,9 @@ func (c *rootCmd) Register() {
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,
FParseErrWhitelist: cobra.FParseErrWhitelist{
UnknownFlags: true,
},
}

c.viper.AutomaticEnv()
Expand Down Expand Up @@ -116,7 +121,7 @@ func (c *rootCmd) run(cmd *cobra.Command, args []string) {
log.Warn().Msg("Log level set to trace, this will log sensitive information!")
}

app := bootstrap.NewBootstrapApp(conf)
app := bootstrap.NewBootstrapApp(conf, c.aclFlags)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flags can be parsed directly here avoiding the need to store them in the rootCmd struct. Since we are fetching directly from os.Args, cobra will not touch them so nothing to worry about.


err = app.Setup()
if err != nil {
Expand All @@ -126,6 +131,8 @@ func (c *rootCmd) run(cmd *cobra.Command, args []string) {

func Run() {
rootCmd := newRootCmd()
rootCmd.aclFlags = utils.ExtractACLFlags(os.Args[1:])

rootCmd.Register()
root := rootCmd.GetCmd()

Expand Down
11 changes: 7 additions & 4 deletions internal/bootstrap/app_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@ type Service interface {
}

type BootstrapApp struct {
config config.Config
uuid string
config config.Config
aclFlags map[string]string
uuid string
}

func NewBootstrapApp(config config.Config) *BootstrapApp {
func NewBootstrapApp(config config.Config, aclFlags map[string]string) *BootstrapApp {
return &BootstrapApp{
config: config,
config: config,
aclFlags: aclFlags,
}
}

Expand Down Expand Up @@ -140,6 +142,7 @@ func (app *BootstrapApp) Setup() error {
// Create services
dockerService := service.NewDockerService()
aclsService := service.NewAccessControlsService(dockerService)
aclsService.SetACLFlags(app.aclFlags)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use a function and not pass them in the initialization function?

authService := service.NewAuthService(authConfig, dockerService, ldapService, database)
oauthBrokerService := service.NewOAuthBrokerService(oauthProviders)

Expand Down
55 changes: 12 additions & 43 deletions internal/service/access_controls_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,70 +4,39 @@ import (
"os"
"strings"
"tinyauth/internal/config"
"tinyauth/internal/utils/decoders"
"tinyauth/internal/utils"

"github.com/rs/zerolog/log"
)

type AccessControlsService struct {
docker *DockerService
envACLs config.Apps
docker *DockerService
envACLs config.Apps
aclFlags map[string]string
}

func NewAccessControlsService(docker *DockerService) *AccessControlsService {
return &AccessControlsService{
docker: docker,
docker: docker,
aclFlags: make(map[string]string),
}
}

func (acls *AccessControlsService) Init() error {
acls.envACLs = config.Apps{}
env := os.Environ()
appEnvVars := []string{}

for _, e := range env {
if strings.HasPrefix(e, "TINYAUTH_APPS_") {
appEnvVars = append(appEnvVars, e)
}
}

err := acls.loadEnvACLs(appEnvVars)

if err != nil {
return err
}

return nil
func (acls *AccessControlsService) SetACLFlags(flags map[string]string) {
acls.aclFlags = flags
}

func (acls *AccessControlsService) loadEnvACLs(appEnvVars []string) error {
if len(appEnvVars) == 0 {
return nil
}

envAcls := map[string]string{}

for _, e := range appEnvVars {
parts := strings.SplitN(e, "=", 2)
if len(parts) != 2 {
continue
}

// Normalize key, this should use the same normalization logic as in utils/decoders/decoders.go
key := parts[0]
key = strings.ToLower(key)
key = strings.ReplaceAll(key, "_", ".")
value := parts[1]
envAcls[key] = value
}
func (acls *AccessControlsService) Init() error {
env := os.Environ()

apps, err := decoders.DecodeLabels(envAcls)
apps, err := utils.GetACLsConfig(env, acls.aclFlags)

if err != nil {
return err
}

acls.envACLs = apps

return nil
}

Expand Down
50 changes: 50 additions & 0 deletions internal/utils/app_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,53 @@ func GetOAuthProvidersConfig(env []string, args []string, appUrl string) (map[st
// Return combined providers
return providers, nil
}

func GetACLsConfig(env []string, flagsMap map[string]string) (config.Apps, error) {
apps := config.Apps{Apps: make(map[string]config.App)}

envMap := make(map[string]string)

for _, e := range env {
pair := strings.SplitN(e, "=", 2)
if len(pair) == 2 {
envMap[pair[0]] = pair[1]
}
}

envApps, err := decoders.DecodeACLEnv[config.Apps](envMap, "apps")

if err != nil {
return config.Apps{}, err
}

if envApps.Apps != nil {
maps.Copy(apps.Apps, envApps.Apps)
}

flagApps, err := decoders.DecodeACLFlags[config.Apps](flagsMap, "apps")

if err != nil {
return config.Apps{}, err
}

if flagApps.Apps != nil {
maps.Copy(apps.Apps, flagApps.Apps)
}

return apps, nil
}

func ExtractACLFlags(args []string) map[string]string {
aclFlags := make(map[string]string)

for _, arg := range args {
if strings.HasPrefix(arg, "--apps-") || strings.HasPrefix(arg, "--tinyauth-apps-") {
pair := strings.SplitN(arg[2:], "=", 2)
if len(pair) == 2 {
aclFlags[pair[0]] = pair[1]
}
}
}

return aclFlags
}
119 changes: 119 additions & 0 deletions internal/utils/decoders/decoders.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,71 @@ import (
"github.com/stoewer/go-strcase"
)

func ParsePath(parts []string, idx int, t reflect.Type) []string {
if idx >= len(parts) {
return []string{}
}

if t.Kind() == reflect.Map {

if idx >= len(parts) {
return []string{}
}

elemType := t.Elem()
keyEndIdx := idx + 1

if elemType.Kind() == reflect.Struct {
for i := idx + 1; i < len(parts); i++ {
found := false

for j := 0; j < elemType.NumField(); j++ {
Comment on lines +25 to +28
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These loops can probably be replaced with:

for j := range elemType.NumField() {
  ...
}

Also applies to lines 52 and 62.

field := elemType.Field(j)
if strings.EqualFold(parts[i], field.Name) {
keyEndIdx = i
found = true
break
}
}

if found {
break
}
}
}

keyParts := parts[idx:keyEndIdx]
keyName := strings.ToLower(strings.Join(keyParts, "_"))
Comment on lines +43 to +44
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Validate input parts to prevent empty string segments.

If parts contains empty strings (e.g., from splitting "apps--field" by "-"), then keyName at line 44 can become an empty string when keyParts contains only empty elements. This empty string propagates into the result array and later causes malformed keys like "tinyauth.acl..field" in normalizeACLKeys.

While the past review suggested filtering in normalizeACLKeys, it's more robust to prevent empty strings at the source.

Consider one of these approaches:

Option 1: Filter empty strings from keyParts before joining:

 	keyParts := parts[idx:keyEndIdx]
+	filtered := make([]string, 0, len(keyParts))
+	for _, part := range keyParts {
+		if part != "" {
+			filtered = append(filtered, part)
+		}
+	}
+	if len(filtered) == 0 {
+		return []string{}
+	}
-	keyName := strings.ToLower(strings.Join(keyParts, "_"))
+	keyName := strings.ToLower(strings.Join(filtered, "_"))

Option 2: Validate at entry that parts contains no empty strings (add after line 11):

 	if idx >= len(parts) {
 		return []string{}
 	}
+	// Skip empty parts
+	for idx < len(parts) && parts[idx] == "" {
+		idx++
+	}
+	if idx >= len(parts) {
+		return []string{}
+	}

Run this script to check if there are existing test cases or usage patterns that might produce empty string segments:


🏁 Script executed:

#!/bin/bash
# Search for Split calls that might produce empty strings
rg -nP --type=go 'strings\.Split.*["\047]-["\047]' 

# Search for how parts are constructed before calling ParsePath
rg -nP --type=go -B5 'ParsePath\(' | grep -E 'Split|parts.*='

Length of output: 79


🏁 Script executed:

#!/bin/bash
# Examine the decoders.go file around lines 43-44
echo "=== Content of decoders.go lines 35-55 ==="
sed -n '35,55p' internal/utils/decoders/decoders.go

# Show the full ParsePath function signature and initial logic
echo -e "\n=== ParsePath function signature and initial lines ==="
sed -n '1,100p' internal/utils/decoders/decoders.go | tail -50

# Find how ParsePath is called
echo -e "\n=== All ParsePath call sites ==="
rg -nP --type=go 'ParsePath\(' -B3 -A1

Length of output: 3425


🏁 Script executed:

#!/bin/bash
# Get the full normalizeKeys function to see how parts array is built
echo "=== normalizeKeys function (lines 88-175) ==="
sed -n '88,175p' internal/utils/decoders/decoders.go

# Also check for any test cases that might show expected behavior
echo -e "\n=== Test files for decoders ==="
find . -name '*decoder*test*.go' -o -name '*test*decoder*.go' | head -10

# Look for test cases that call ParsePath or normalizeKeys
echo -e "\n=== Test content ==="
fd -e 'go' -path '*/test*' | xargs grep -l 'ParsePath\|normalizeKeys' 2>/dev/null | head -5

Length of output: 2080


🏁 Script executed:

#!/bin/bash
# Look at test files to see if edge cases with empty strings or double delimiters are tested
echo "=== Test file list ==="
ls -la internal/utils/decoders/*test.go

echo -e "\n=== Content of env_decoder_test.go ==="
cat internal/utils/decoders/env_decoder_test.go | head -100

echo -e "\n=== Search for test cases with double delimiters or consecutive separators ==="
grep -rn '\-\-\|__' internal/utils/decoders/*test.go

echo -e "\n=== Search for any validation of parts array in ParsePath ==="
sed -n '10,50p' internal/utils/decoders/decoders.go

Length of output: 2657


Filter empty string segments from keyParts before joining at line 44.

The concern is valid. Go's strings.Split() produces empty strings when consecutive delimiters exist (e.g., splitting "tinyauth__apps" by "_" yields ["tinyauth", "", "apps"]). This empty string can propagate into keyParts at line 43, causing strings.Join(keyParts, "_") to produce malformed keys like "_field" or "". The current code has no validation preventing this.

Implement Option 1 from the original suggestion—filter empty strings before joining:

 	keyParts := parts[idx:keyEndIdx]
+	filtered := make([]string, 0, len(keyParts))
+	for _, part := range keyParts {
+		if part != "" {
+			filtered = append(filtered, part)
+		}
+	}
+	if len(filtered) == 0 {
+		return []string{}
+	}
-	keyName := strings.ToLower(strings.Join(keyParts, "_"))
+	keyName := strings.ToLower(strings.Join(filtered, "_"))

This handles the issue at the source where keys are constructed, preventing malformed output downstream.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In internal/utils/decoders/decoders.go around lines 43 to 44, keyParts may
contain empty strings from strings.Split which leads to malformed keys when
joined; filter out any empty string elements from keyParts before calling
strings.Join so the resulting keyName contains only non-empty segments (e.g.,
build a new slice, append only parts with len>0, then join with "_"), ensuring
empty segments are removed at the source before creating keyName.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not a bad suggestion @nicotsx.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!


rest := ParsePath(parts, keyEndIdx, elemType)
result := append([]string{keyName}, rest...)
return result
}

if t.Kind() == reflect.Struct {
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.Type.Kind() == reflect.Map {
rest := ParsePath(parts, idx, field.Type)
if len(rest) > 0 {
return rest
}
}
}

for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if strings.EqualFold(parts[idx], field.Name) {
rest := ParsePath(parts, idx+1, field.Type)
result := append([]string{strings.ToLower(field.Name)}, rest...)
return result
}
}
}

return []string{}
}

func normalizeKeys[T any](input map[string]string, root string, sep string) map[string]string {
knownKeys := getKnownKeys[T]()
normalized := make(map[string]string)
Expand Down Expand Up @@ -74,3 +139,57 @@ func getKnownKeys[T any]() []string {

return keys
}

func normalizeACLKeys[T any](input map[string]string, root string, sep string) map[string]string {
normalized := make(map[string]string)
var t T
rootType := reflect.TypeOf(t)

for k, v := range input {
parts := strings.Split(strings.ToLower(k), sep)

if len(parts) < 2 {
continue
}

// Two cases:
// 1. Keys starting with "tinyauth" (env vars): tinyauth_apps_...
// 2. Keys starting with root directly (flags): apps-...
startIdx := 0
if parts[0] == "tinyauth" {
if len(parts) < 3 {
continue
}
if parts[1] != root {
continue
}
startIdx = 2 // Skip "tinyauth" and root
} else if parts[0] == root {
startIdx = 1 // Skip root only
} else {
continue
}

if startIdx < len(parts) {
parsedParts := ParsePath(parts[startIdx:], 0, rootType)

if len(parsedParts) == 0 {
continue
}

final := "tinyauth." + root

for _, part := range parsedParts {
if strings.Contains(part, "_") {
final += "." + part
} else {
final += "." + strcase.LowerCamelCase(part)
}
}

normalized[final] = v
}
}

return normalized
}
14 changes: 14 additions & 0 deletions internal/utils/decoders/env_decoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,17 @@ func DecodeEnv[T any, C any](env map[string]string, subName string) (T, error) {

return result, nil
}

func DecodeACLEnv[T any](env map[string]string, subName string) (T, error) {
var result T

normalized := normalizeACLKeys[T](env, subName, "_")

err := parser.Decode(normalized, &result, "tinyauth", "tinyauth."+subName)

if err != nil {
return result, err
}

return result, nil
}
15 changes: 15 additions & 0 deletions internal/utils/decoders/flags_decoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ func DecodeFlags[T any, C any](flags map[string]string, subName string) (T, erro
return result, nil
}

func DecodeACLFlags[T any](flags map[string]string, subName string) (T, error) {
var result T

filtered := filterFlags(flags)
normalized := normalizeACLKeys[T](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 {
Expand Down
Loading