From 91048c16f86b66a5418ef651cdf8a4c6b2b50aec Mon Sep 17 00:00:00 2001 From: Stavros Date: Thu, 11 Sep 2025 23:10:36 +0300 Subject: [PATCH 01/12] feat: add flag decoder (candidate) --- internal/config/config.go | 10 ++++ internal/utils/decoders/flags_decoder.go | 55 +++++++++++++++++ internal/utils/decoders/flags_decoder_test.go | 60 +++++++++++++++++++ internal/utils/decoders/label_decoder_test.go | 13 ++-- 4 files changed, 129 insertions(+), 9 deletions(-) create mode 100644 internal/utils/decoders/flags_decoder.go create mode 100644 internal/utils/decoders/flags_decoder_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 7ccedd3b..6b143bfb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -174,3 +174,13 @@ type AppPath struct { Allow string Block string } + +// Flags + +type Providers struct { + Providers map[string]ProviderConfig +} + +type ProviderConfig struct { + Config OAuthServiceConfig +} diff --git a/internal/utils/decoders/flags_decoder.go b/internal/utils/decoders/flags_decoder.go new file mode 100644 index 00000000..4154157f --- /dev/null +++ b/internal/utils/decoders/flags_decoder.go @@ -0,0 +1,55 @@ +package decoders + +import ( + "strings" + "tinyauth/internal/config" + "tinyauth/internal/utils" + + "github.com/traefik/paerser/parser" +) + +func DecodeFlags(flags map[string]string) (config.Providers, error) { + // Normalize flags (sorry to whoever has to read this) + // --providers-client1-client-id -> tinyauth.providers.client1.clientId + normalized := make(map[string]string) + for k, v := range flags { + newKey := "" + + nk := strings.TrimPrefix(k, "--") + parts := strings.SplitN(nk, "-", 4) + + for i, part := range parts { + if i == 3 { + subParts := strings.Split(part, "-") + for j, subPart := range subParts { + if j == 0 { + newKey += "." + subPart + } else { + newKey += utils.Capitalize(subPart) + } + } + continue + } + if i == 0 { + newKey += part + continue + } + newKey += "." + part + } + + newKey = "tinyauth." + newKey + normalized[newKey] = v + + } + + // Decode + var providers config.Providers + + err := parser.Decode(normalized, &providers, "tinyauth", "tinyauth.providers") + + if err != nil { + return config.Providers{}, err + } + + return providers, nil +} diff --git a/internal/utils/decoders/flags_decoder_test.go b/internal/utils/decoders/flags_decoder_test.go new file mode 100644 index 00000000..a10760a5 --- /dev/null +++ b/internal/utils/decoders/flags_decoder_test.go @@ -0,0 +1,60 @@ +package decoders_test + +import ( + "testing" + "tinyauth/internal/config" + "tinyauth/internal/utils/decoders" + + "gotest.tools/v3/assert" +) + +func TestDecodeFlags(t *testing.T) { + // Variables + expected := config.Providers{ + Providers: map[string]config.ProviderConfig{ + "client1": { + Config: config.OAuthServiceConfig{ + ClientID: "client1-id", + ClientSecret: "client1-secret", + Scopes: []string{"client1-scope1", "client1-scope2"}, + RedirectURL: "client1-redirect-url", + AuthURL: "client1-auth-url", + UserinfoURL: "client1-user-info-url", + InsecureSkipVerify: false, + }, + }, + "client2": { + Config: config.OAuthServiceConfig{ + ClientID: "client2-id", + ClientSecret: "client2-secret", + Scopes: []string{"client2-scope1", "client2-scope2"}, + RedirectURL: "client2-redirect-url", + AuthURL: "client2-auth-url", + UserinfoURL: "client2-user-info-url", + InsecureSkipVerify: false, + }, + }, + }, + } + test := map[string]string{ + "--providers-client1-config-client-id": "client1-id", + "--providers-client1-config-client-secret": "client1-secret", + "--providers-client1-config-scopes": "client1-scope1,client1-scope2", + "--providers-client1-config-redirect-url": "client1-redirect-url", + "--providers-client1-config-auth-url": "client1-auth-url", + "--providers-client1-config-user-info-url": "client1-user-info-url", + "--providers-client1-config-insecure-skip-verify": "false", + "--providers-client2-config-client-id": "client2-id", + "--providers-client2-config-client-secret": "client2-secret", + "--providers-client2-config-scopes": "client2-scope1,client2-scope2", + "--providers-client2-config-redirect-url": "client2-redirect-url", + "--providers-client2-config-auth-url": "client2-auth-url", + "--providers-client2-config-user-info-url": "client2-user-info-url", + "--providers-client2-config-insecure-skip-verify": "false", + } + + // Test + res, err := decoders.DecodeFlags(test) + assert.NilError(t, err) + assert.DeepEqual(t, expected, res) +} diff --git a/internal/utils/decoders/label_decoder_test.go b/internal/utils/decoders/label_decoder_test.go index 1df885cd..63189d19 100644 --- a/internal/utils/decoders/label_decoder_test.go +++ b/internal/utils/decoders/label_decoder_test.go @@ -1,10 +1,11 @@ package decoders_test import ( - "reflect" "testing" "tinyauth/internal/config" "tinyauth/internal/utils/decoders" + + "gotest.tools/v3/assert" ) func TestDecodeLabels(t *testing.T) { @@ -62,12 +63,6 @@ func TestDecodeLabels(t *testing.T) { // Test result, err := decoders.DecodeLabels(test) - - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - if reflect.DeepEqual(expected, result) == false { - t.Fatalf("Expected %v but got %v", expected, result) - } + assert.NilError(t, err) + assert.DeepEqual(t, expected, result) } From b30b908de3c36eb67d91ff780a52c9aed63cd4c5 Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 12 Sep 2025 12:47:58 +0300 Subject: [PATCH 02/12] refactor: finalize flags decoder --- internal/config/config.go | 7 +- internal/utils/decoders/flags_decoder.go | 143 ++++++++++++++---- internal/utils/decoders/flags_decoder_test.go | 66 ++++---- 3 files changed, 147 insertions(+), 69 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 6b143bfb..f925b0c8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -74,6 +74,7 @@ type OAuthServiceConfig struct { TokenURL string UserinfoURL string InsecureSkipVerify bool + Name string } // User/session related stuff @@ -178,9 +179,5 @@ type AppPath struct { // Flags type Providers struct { - Providers map[string]ProviderConfig -} - -type ProviderConfig struct { - Config OAuthServiceConfig + Providers map[string]OAuthServiceConfig } diff --git a/internal/utils/decoders/flags_decoder.go b/internal/utils/decoders/flags_decoder.go index 4154157f..6a29d3a7 100644 --- a/internal/utils/decoders/flags_decoder.go +++ b/internal/utils/decoders/flags_decoder.go @@ -1,6 +1,9 @@ package decoders import ( + "fmt" + "slices" + "sort" "strings" "tinyauth/internal/config" "tinyauth/internal/utils" @@ -9,43 +12,25 @@ import ( ) func DecodeFlags(flags map[string]string) (config.Providers, error) { - // Normalize flags (sorry to whoever has to read this) - // --providers-client1-client-id -> tinyauth.providers.client1.clientId - normalized := make(map[string]string) - for k, v := range flags { - newKey := "" - - nk := strings.TrimPrefix(k, "--") - parts := strings.SplitN(nk, "-", 4) - - for i, part := range parts { - if i == 3 { - subParts := strings.Split(part, "-") - for j, subPart := range subParts { - if j == 0 { - newKey += "." + subPart - } else { - newKey += utils.Capitalize(subPart) - } - } - continue - } - if i == 0 { - newKey += part - continue - } - newKey += "." + part - } + normalized := normalizeFlags(flags, "tinyauth") - newKey = "tinyauth." + newKey - normalized[newKey] = v + node, err := decodeFlagsToNode(normalized, "tinyauth", "tinyauth_providers") + if err != nil { + return config.Providers{}, err } - // Decode var providers config.Providers - err := parser.Decode(normalized, &providers, "tinyauth", "tinyauth.providers") + metaOpts := parser.MetadataOpts{TagName: "flag", AllowSliceAsStruct: true} + + err = parser.AddMetadata(&providers, node, metaOpts) + + if err != nil { + return config.Providers{}, err + } + + err = parser.Fill(&providers, node, parser.FillerOpts{AllowSliceAsStruct: true}) if err != nil { return config.Providers{}, err @@ -53,3 +38,99 @@ func DecodeFlags(flags map[string]string) (config.Providers, error) { return providers, nil } + +func decodeFlagsToNode(flags map[string]string, rootName string, filters ...string) (*parser.Node, error) { + sorted := sortFlagKeys(flags, filters) + + var node *parser.Node + + for i, k := range sorted { + split := strings.SplitN(k, "_", 4) + + if split[0] != rootName { + return nil, fmt.Errorf("invalid flag root %s", split[0]) + } + + if slices.Contains(split, "") { + return nil, fmt.Errorf("invalid element: %s", k) + } + + if i == 0 { + node = &parser.Node{} + } + + decodeFlagToNode(node, split, flags[k]) + } + + return node, nil +} + +func decodeFlagToNode(root *parser.Node, path []string, value string) { + if len(root.Name) == 0 { + root.Name = path[0] + } + + if !(len(path) > 1) { + root.Value = value + return + } + + if n := containsFlagNode(root.Children, path[1]); n != nil { + decodeFlagToNode(n, path[1:], value) + return + } + + child := &parser.Node{Name: path[1]} + decodeFlagToNode(child, path[1:], value) + root.Children = append(root.Children, child) +} + +func containsFlagNode(node []*parser.Node, name string) *parser.Node { + for _, n := range node { + if strings.EqualFold(n.Name, name) { + return n + } + } + return nil +} + +func sortFlagKeys(flags map[string]string, filters []string) []string { + var sorted []string + + for k := range flags { + if len(filters) == 0 { + sorted = append(sorted, k) + continue + } + + for _, f := range filters { + if strings.HasPrefix(k, f) { + sorted = append(sorted, k) + break + } + } + } + + sort.Strings(sorted) + return sorted +} + +// normalizeFlags converts flags from --providers-client-client-id to tinyauth_providers_client_clientId +func normalizeFlags(flags map[string]string, rootName string) map[string]string { + n := make(map[string]string) + for k, v := range flags { + fk := strings.TrimPrefix(k, "--") + fks := strings.SplitN(fk, "-", 3) + fkb := "" + for i, s := range strings.Split(fks[len(fks)-1], "-") { + if i == 0 { + fkb += s + continue + } + fkb += utils.Capitalize(s) + } + fk = rootName + "_" + strings.Join(fks[:len(fks)-1], "_") + "_" + fkb + n[fk] = v + } + return n +} diff --git a/internal/utils/decoders/flags_decoder_test.go b/internal/utils/decoders/flags_decoder_test.go index a10760a5..356b4ae7 100644 --- a/internal/utils/decoders/flags_decoder_test.go +++ b/internal/utils/decoders/flags_decoder_test.go @@ -11,46 +11,46 @@ import ( func TestDecodeFlags(t *testing.T) { // Variables expected := config.Providers{ - Providers: map[string]config.ProviderConfig{ + Providers: map[string]config.OAuthServiceConfig{ "client1": { - Config: config.OAuthServiceConfig{ - ClientID: "client1-id", - ClientSecret: "client1-secret", - Scopes: []string{"client1-scope1", "client1-scope2"}, - RedirectURL: "client1-redirect-url", - AuthURL: "client1-auth-url", - UserinfoURL: "client1-user-info-url", - InsecureSkipVerify: false, - }, + ClientID: "client1-id", + ClientSecret: "client1-secret", + Scopes: []string{"client1-scope1", "client1-scope2"}, + RedirectURL: "client1-redirect-url", + AuthURL: "client1-auth-url", + UserinfoURL: "client1-user-info-url", + Name: "Client1", + InsecureSkipVerify: false, }, "client2": { - Config: config.OAuthServiceConfig{ - ClientID: "client2-id", - ClientSecret: "client2-secret", - Scopes: []string{"client2-scope1", "client2-scope2"}, - RedirectURL: "client2-redirect-url", - AuthURL: "client2-auth-url", - UserinfoURL: "client2-user-info-url", - InsecureSkipVerify: false, - }, + ClientID: "client2-id", + ClientSecret: "client2-secret", + Scopes: []string{"client2-scope1", "client2-scope2"}, + RedirectURL: "client2-redirect-url", + AuthURL: "client2-auth-url", + UserinfoURL: "client2-user-info-url", + Name: "My Awesome Client2", + InsecureSkipVerify: false, }, }, } test := map[string]string{ - "--providers-client1-config-client-id": "client1-id", - "--providers-client1-config-client-secret": "client1-secret", - "--providers-client1-config-scopes": "client1-scope1,client1-scope2", - "--providers-client1-config-redirect-url": "client1-redirect-url", - "--providers-client1-config-auth-url": "client1-auth-url", - "--providers-client1-config-user-info-url": "client1-user-info-url", - "--providers-client1-config-insecure-skip-verify": "false", - "--providers-client2-config-client-id": "client2-id", - "--providers-client2-config-client-secret": "client2-secret", - "--providers-client2-config-scopes": "client2-scope1,client2-scope2", - "--providers-client2-config-redirect-url": "client2-redirect-url", - "--providers-client2-config-auth-url": "client2-auth-url", - "--providers-client2-config-user-info-url": "client2-user-info-url", - "--providers-client2-config-insecure-skip-verify": "false", + "--providers-client1-client-id": "client1-id", + "--providers-client1-client-secret": "client1-secret", + "--providers-client1-scopes": "client1-scope1,client1-scope2", + "--providers-client1-redirect-url": "client1-redirect-url", + "--providers-client1-auth-url": "client1-auth-url", + "--providers-client1-user-info-url": "client1-user-info-url", + "--providers-client1-name": "Client1", + "--providers-client1-insecure-skip-verify": "false", + "--providers-client2-client-id": "client2-id", + "--providers-client2-client-secret": "client2-secret", + "--providers-client2-scopes": "client2-scope1,client2-scope2", + "--providers-client2-redirect-url": "client2-redirect-url", + "--providers-client2-auth-url": "client2-auth-url", + "--providers-client2-user-info-url": "client2-user-info-url", + "--providers-client2-name": "My Awesome Client2", + "--providers-client2-insecure-skip-verify": "false", } // Test From 68fd5ac24c6641f6d75eb179b2202bf5b9f00473 Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 12 Sep 2025 12:54:44 +0300 Subject: [PATCH 03/12] feat: add env decoder --- internal/utils/decoders/env_decoder.go | 136 ++++++++++++++++++++ internal/utils/decoders/env_decoder_test.go | 60 +++++++++ 2 files changed, 196 insertions(+) create mode 100644 internal/utils/decoders/env_decoder.go create mode 100644 internal/utils/decoders/env_decoder_test.go diff --git a/internal/utils/decoders/env_decoder.go b/internal/utils/decoders/env_decoder.go new file mode 100644 index 00000000..467c68b5 --- /dev/null +++ b/internal/utils/decoders/env_decoder.go @@ -0,0 +1,136 @@ +package decoders + +import ( + "fmt" + "slices" + "sort" + "strings" + "tinyauth/internal/config" + "tinyauth/internal/utils" + + "github.com/traefik/paerser/parser" +) + +func DecodeEnv(env map[string]string) (config.Providers, error) { + normalized := normalizeEnv(env, "tinyauth") + + node, err := decodeEnvsToNode(normalized, "tinyauth", "tinyauth_providers") + + if err != nil { + return config.Providers{}, err + } + + var providers config.Providers + + metaOpts := parser.MetadataOpts{TagName: "env", AllowSliceAsStruct: true} + + err = parser.AddMetadata(&providers, node, metaOpts) + + if err != nil { + return config.Providers{}, err + } + + err = parser.Fill(&providers, node, parser.FillerOpts{AllowSliceAsStruct: true}) + + if err != nil { + return config.Providers{}, err + } + + return providers, nil +} + +func decodeEnvsToNode(env map[string]string, rootName string, filters ...string) (*parser.Node, error) { + sorted := sortEnvKeys(env, filters) + + var node *parser.Node + + for i, k := range sorted { + split := strings.SplitN(k, "_", 4) + + if split[0] != rootName { + return nil, fmt.Errorf("invalid env root %s", split[0]) + } + + if slices.Contains(split, "") { + return nil, fmt.Errorf("invalid element: %s", k) + } + + if i == 0 { + node = &parser.Node{} + } + + decodeEnvToNode(node, split, env[k]) + } + + return node, nil +} + +func decodeEnvToNode(root *parser.Node, path []string, value string) { + if len(root.Name) == 0 { + root.Name = path[0] + } + + if !(len(path) > 1) { + root.Value = value + return + } + + if n := containsEnvNode(root.Children, path[1]); n != nil { + decodeEnvToNode(n, path[1:], value) + return + } + + child := &parser.Node{Name: path[1]} + decodeEnvToNode(child, path[1:], value) + root.Children = append(root.Children, child) +} + +func containsEnvNode(node []*parser.Node, name string) *parser.Node { + for _, n := range node { + if strings.EqualFold(n.Name, name) { + return n + } + } + return nil +} + +func sortEnvKeys(env map[string]string, filters []string) []string { + var sorted []string + + for k := range env { + if len(filters) == 0 { + sorted = append(sorted, k) + continue + } + + for _, f := range filters { + if strings.HasPrefix(k, f) { + sorted = append(sorted, k) + break + } + } + } + + sort.Strings(sorted) + return sorted +} + +// normalizeEnv converts env vars from PROVIDERS_CLIENT1_CLIENT_ID to tinyauth_providers_client_clientId +func normalizeEnv(env map[string]string, rootName string) map[string]string { + n := make(map[string]string) + for k, v := range env { + fk := strings.ToLower(k) + fks := strings.SplitN(fk, "_", 3) + fkb := "" + for i, s := range strings.Split(fks[len(fks)-1], "_") { + if i == 0 { + fkb += s + continue + } + fkb += utils.Capitalize(s) + } + fk = rootName + "_" + strings.Join(fks[:len(fks)-1], "_") + "_" + fkb + n[fk] = v + } + return n +} diff --git a/internal/utils/decoders/env_decoder_test.go b/internal/utils/decoders/env_decoder_test.go new file mode 100644 index 00000000..2233241f --- /dev/null +++ b/internal/utils/decoders/env_decoder_test.go @@ -0,0 +1,60 @@ +package decoders_test + +import ( + "testing" + "tinyauth/internal/config" + "tinyauth/internal/utils/decoders" + + "gotest.tools/v3/assert" +) + +func TestDecodeEnv(t *testing.T) { + // Variables + expected := config.Providers{ + Providers: map[string]config.OAuthServiceConfig{ + "client1": { + ClientID: "client1-id", + ClientSecret: "client1-secret", + Scopes: []string{"client1-scope1", "client1-scope2"}, + RedirectURL: "client1-redirect-url", + AuthURL: "client1-auth-url", + UserinfoURL: "client1-user-info-url", + Name: "Client1", + InsecureSkipVerify: false, + }, + "client2": { + ClientID: "client2-id", + ClientSecret: "client2-secret", + Scopes: []string{"client2-scope1", "client2-scope2"}, + RedirectURL: "client2-redirect-url", + AuthURL: "client2-auth-url", + UserinfoURL: "client2-user-info-url", + Name: "My Awesome Client2", + InsecureSkipVerify: false, + }, + }, + } + test := map[string]string{ + "PROVIDERS_CLIENT1_CLIENT_ID": "client1-id", + "PROVIDERS_CLIENT1_CLIENT_SECRET": "client1-secret", + "PROVIDERS_CLIENT1_SCOPES": "client1-scope1,client1-scope2", + "PROVIDERS_CLIENT1_REDIRECT_URL": "client1-redirect-url", + "PROVIDERS_CLIENT1_AUTH_URL": "client1-auth-url", + "PROVIDERS_CLIENT1_USER_INFO_URL": "client1-user-info-url", + "PROVIDERS_CLIENT1_NAME": "Client1", + "PROVIDERS_CLIENT1_INSECURE_SKIP_VERIFY": "false", + "PROVIDERS_CLIENT2_CLIENT_ID": "client2-id", + "PROVIDERS_CLIENT2_CLIENT_SECRET": "client2-secret", + "PROVIDERS_CLIENT2_SCOPES": "client2-scope1,client2-scope2", + "PROVIDERS_CLIENT2_REDIRECT_URL": "client2-redirect-url", + "PROVIDERS_CLIENT2_AUTH_URL": "client2-auth-url", + "PROVIDERS_CLIENT2_USER_INFO_URL": "client2-user-info-url", + "PROVIDERS_CLIENT2_NAME": "My Awesome Client2", + "PROVIDERS_CLIENT2_INSECURE_SKIP_VERIFY": "false", + } + + // Test + res, err := decoders.DecodeEnv(test) + assert.NilError(t, err) + assert.DeepEqual(t, expected, res) +} From 5fcc50d5fd4fc33d9c3928582b4acdbacf208e00 Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 12 Sep 2025 13:16:45 +0300 Subject: [PATCH 04/12] feat: add oauth config parsing logic --- internal/config/config.go | 1 + internal/utils/app_utils.go | 54 ++++++++++++++++++++++++ internal/utils/app_utils_test.go | 54 ++++++++++++++++++++++++ internal/utils/decoders/env_decoder.go | 3 +- internal/utils/decoders/flags_decoder.go | 3 +- 5 files changed, 111 insertions(+), 4 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index f925b0c8..880d6638 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -68,6 +68,7 @@ type Claims struct { type OAuthServiceConfig struct { ClientID string ClientSecret string + ClientSecretFile string Scopes []string RedirectURL string AuthURL string diff --git a/internal/utils/app_utils.go b/internal/utils/app_utils.go index c4b98c6b..ed067465 100644 --- a/internal/utils/app_utils.go +++ b/internal/utils/app_utils.go @@ -6,6 +6,9 @@ import ( "net/url" "strings" "tinyauth/internal/config" + "tinyauth/internal/utils/decoders" + + "maps" "github.com/gin-gonic/gin" "github.com/rs/zerolog" @@ -130,3 +133,54 @@ func GetLogLevel(level string) zerolog.Level { return zerolog.InfoLevel } } + +func GetOAuthProvidersConfig(env []string, args []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) + envMap[pair[0]] = pair[1] + } + + envProviders, err := decoders.DecodeEnv(envMap) + + 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(flagsMap) + + 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 + } + + // Return combined providers + return providers, nil +} diff --git a/internal/utils/app_utils_test.go b/internal/utils/app_utils_test.go index c35db3d7..48bb915f 100644 --- a/internal/utils/app_utils_test.go +++ b/internal/utils/app_utils_test.go @@ -1,6 +1,7 @@ package utils_test import ( + "os" "testing" "tinyauth/internal/config" "tinyauth/internal/utils" @@ -200,3 +201,56 @@ 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", + }, + "client2": { + ClientID: "client2-id", + ClientSecret: "client2-secret", + }, + } + + 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", + }, + } + + result, err = utils.GetOAuthProvidersConfig(env, args) + assert.NilError(t, err) + assert.DeepEqual(t, expected, result) +} diff --git a/internal/utils/decoders/env_decoder.go b/internal/utils/decoders/env_decoder.go index 467c68b5..fc909452 100644 --- a/internal/utils/decoders/env_decoder.go +++ b/internal/utils/decoders/env_decoder.go @@ -6,7 +6,6 @@ import ( "sort" "strings" "tinyauth/internal/config" - "tinyauth/internal/utils" "github.com/traefik/paerser/parser" ) @@ -127,7 +126,7 @@ func normalizeEnv(env map[string]string, rootName string) map[string]string { fkb += s continue } - fkb += utils.Capitalize(s) + fkb += strings.ToUpper(string([]rune(s)[0])) + string([]rune(s)[1:]) } fk = rootName + "_" + strings.Join(fks[:len(fks)-1], "_") + "_" + fkb n[fk] = v diff --git a/internal/utils/decoders/flags_decoder.go b/internal/utils/decoders/flags_decoder.go index 6a29d3a7..97aac72e 100644 --- a/internal/utils/decoders/flags_decoder.go +++ b/internal/utils/decoders/flags_decoder.go @@ -6,7 +6,6 @@ import ( "sort" "strings" "tinyauth/internal/config" - "tinyauth/internal/utils" "github.com/traefik/paerser/parser" ) @@ -127,7 +126,7 @@ func normalizeFlags(flags map[string]string, rootName string) map[string]string fkb += s continue } - fkb += utils.Capitalize(s) + fkb += strings.ToUpper(string([]rune(s)[0])) + string([]rune(s)[1:]) } fk = rootName + "_" + strings.Join(fks[:len(fks)-1], "_") + "_" + fkb n[fk] = v From fbf584359255edae8a0346d99137b5ffb17b29a6 Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 12 Sep 2025 13:46:07 +0300 Subject: [PATCH 05/12] feat: implement backend logic for multiple oauth providers --- cmd/root.go | 20 ------ internal/bootstrap/app_bootstrap.go | 72 ++++++++++--------- internal/config/config.go | 63 +++++++--------- internal/controller/context_controller.go | 31 ++++---- .../controller/context_controller_test.go | 17 +++-- internal/utils/app_utils.go | 14 +++- internal/utils/app_utils_test.go | 21 +++++- 7 files changed, 124 insertions(+), 114 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 155ccd2f..d72cd344 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,11 +27,6 @@ var rootCmd = &cobra.Command{ log.Fatal().Err(err).Msg("Failed to parse config") } - // Check if secrets have a file associated with them - conf.GithubClientSecret = utils.GetSecret(conf.GithubClientSecret, conf.GithubClientSecretFile) - conf.GoogleClientSecret = utils.GetSecret(conf.GoogleClientSecret, conf.GoogleClientSecretFile) - conf.GenericClientSecret = utils.GetSecret(conf.GenericClientSecret, conf.GenericClientSecretFile) - // Validate config v := validator.New() @@ -80,21 +75,6 @@ func init() { {"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."}, - {"github-client-id", "", "Github OAuth client ID."}, - {"github-client-secret", "", "Github OAuth client secret."}, - {"github-client-secret-file", "", "Github OAuth client secret file."}, - {"google-client-id", "", "Google OAuth client ID."}, - {"google-client-secret", "", "Google OAuth client secret."}, - {"google-client-secret-file", "", "Google OAuth client secret file."}, - {"generic-client-id", "", "Generic OAuth client ID."}, - {"generic-client-secret", "", "Generic OAuth client secret."}, - {"generic-client-secret-file", "", "Generic OAuth client secret file."}, - {"generic-scopes", "", "Generic OAuth scopes."}, - {"generic-auth-url", "", "Generic OAuth auth URL."}, - {"generic-token-url", "", "Generic OAuth token URL."}, - {"generic-user-url", "", "Generic OAuth user info URL."}, - {"generic-name", "Generic", "Generic OAuth provider name."}, - {"generic-skip-ssl", false, "Skip SSL verification for the generic OAuth provider."}, {"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."}, diff --git a/internal/bootstrap/app_bootstrap.go b/internal/bootstrap/app_bootstrap.go index db2e564c..684e4bd6 100644 --- a/internal/bootstrap/app_bootstrap.go +++ b/internal/bootstrap/app_bootstrap.go @@ -3,6 +3,7 @@ package bootstrap import ( "fmt" "net/url" + "os" "strings" "tinyauth/internal/config" "tinyauth/internal/controller" @@ -45,6 +46,13 @@ func (app *BootstrapApp) Setup() error { return err } + // Get OAuth configs + oauthProviders, err := utils.GetOAuthProvidersConfig(os.Environ(), os.Args, app.Config.AppURL) + + if err != nil { + return err + } + // Get cookie domain cookieDomain, err := utils.GetCookieDomain(app.Config.AppURL) @@ -112,7 +120,7 @@ func (app *BootstrapApp) Setup() error { // Create services dockerService := service.NewDockerService() authService := service.NewAuthService(authConfig, dockerService, ldapService, database) - oauthBrokerService := service.NewOAuthBrokerService(app.getOAuthBrokerConfig()) + oauthBrokerService := service.NewOAuthBrokerService(oauthProviders) // Initialize services services := []Service{ @@ -132,13 +140,39 @@ func (app *BootstrapApp) Setup() error { } // Configured providers - var configuredProviders []string + babysit := map[string]string{ + "google": "Google", + "github": "GitHub", + } + configuredProviders := make([]controller.Provider, 0) + + for id, provider := range oauthProviders { + if id == "" { + continue + } + + if provider.Name == "" && babysit[id] != "" { + provider.Name = babysit[id] + } else { + provider.Name = utils.Capitalize(id) + } + + configuredProviders = append(configuredProviders, controller.Provider{ + Name: provider.Name, + ID: id, + OAuth: true, + }) + } if authService.UserAuthConfigured() || ldapService != nil { - configuredProviders = append(configuredProviders, "username") + configuredProviders = append(configuredProviders, controller.Provider{ + Name: "Username", + ID: "username", + OAuth: false, + }) } - configuredProviders = append(configuredProviders, oauthBrokerService.GetConfiguredServices()...) + log.Debug().Interface("providers", configuredProviders).Msg("Authentication providers") if len(configuredProviders) == 0 { return fmt.Errorf("no authentication providers configured") @@ -179,9 +213,8 @@ func (app *BootstrapApp) Setup() error { // Create controllers contextController := controller.NewContextController(controller.ContextControllerConfig{ - ConfiguredProviders: configuredProviders, + Providers: configuredProviders, Title: app.Config.Title, - GenericName: app.Config.GenericName, AppURL: app.Config.AppURL, CookieDomain: cookieDomain, ForgotPasswordMessage: app.Config.ForgotPasswordMessage, @@ -235,30 +268,3 @@ func (app *BootstrapApp) Setup() error { return nil } - -// Temporary -func (app *BootstrapApp) getOAuthBrokerConfig() map[string]config.OAuthServiceConfig { - return map[string]config.OAuthServiceConfig{ - "google": { - ClientID: app.Config.GoogleClientId, - ClientSecret: app.Config.GoogleClientSecret, - RedirectURL: fmt.Sprintf("%s/api/oauth/callback/google", app.Config.AppURL), - }, - "github": { - ClientID: app.Config.GithubClientId, - ClientSecret: app.Config.GithubClientSecret, - RedirectURL: fmt.Sprintf("%s/api/oauth/callback/github", app.Config.AppURL), - }, - "generic": { - ClientID: app.Config.GenericClientId, - ClientSecret: app.Config.GenericClientSecret, - RedirectURL: fmt.Sprintf("%s/api/oauth/callback/generic", app.Config.AppURL), - Scopes: strings.Split(app.Config.GenericScopes, ","), - AuthURL: app.Config.GenericAuthURL, - TokenURL: app.Config.GenericTokenURL, - UserinfoURL: app.Config.GenericUserURL, - InsecureSkipVerify: app.Config.GenericSkipSSL, - }, - } - -} diff --git a/internal/config/config.go b/internal/config/config.go index 880d6638..cdb02aec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,45 +15,30 @@ 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"` - GithubClientId string `mapstructure:"github-client-id"` - GithubClientSecret string `mapstructure:"github-client-secret"` - GithubClientSecretFile string `mapstructure:"github-client-secret-file"` - GoogleClientId string `mapstructure:"google-client-id"` - GoogleClientSecret string `mapstructure:"google-client-secret"` - GoogleClientSecretFile string `mapstructure:"google-client-secret-file"` - GenericClientId string `mapstructure:"generic-client-id"` - GenericClientSecret string `mapstructure:"generic-client-secret"` - GenericClientSecretFile string `mapstructure:"generic-client-secret-file"` - GenericScopes string `mapstructure:"generic-scopes"` - GenericAuthURL string `mapstructure:"generic-auth-url"` - GenericTokenURL string `mapstructure:"generic-token-url"` - GenericUserURL string `mapstructure:"generic-user-url"` - GenericName string `mapstructure:"generic-name"` - GenericSkipSSL bool `mapstructure:"generic-skip-ssl"` - OAuthWhitelist string `mapstructure:"oauth-whitelist"` - OAuthAutoRedirect string `mapstructure:"oauth-auto-redirect" validate:"oneof=none github google generic"` - 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" validate:"required"` - TrustedProxies string `mapstructure:"trusted-proxies"` + 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" validate:"oneof=none github google generic"` + 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" validate:"required"` + TrustedProxies string `mapstructure:"trusted-proxies"` } // OAuth/OIDC config diff --git a/internal/controller/context_controller.go b/internal/controller/context_controller.go index ee3eec69..148bc1cc 100644 --- a/internal/controller/context_controller.go +++ b/internal/controller/context_controller.go @@ -22,22 +22,26 @@ type UserContextResponse struct { } type AppContextResponse struct { - Status int `json:"status"` - Message string `json:"message"` - ConfiguredProviders []string `json:"configuredProviders"` - Title string `json:"title"` - GenericName string `json:"genericName"` - AppURL string `json:"appUrl"` - CookieDomain string `json:"cookieDomain"` - ForgotPasswordMessage string `json:"forgotPasswordMessage"` - BackgroundImage string `json:"backgroundImage"` - OAuthAutoRedirect string `json:"oauthAutoRedirect"` + Status int `json:"status"` + Message string `json:"message"` + Providers []Provider `json:"providers"` + Title string `json:"title"` + AppURL string `json:"appUrl"` + CookieDomain string `json:"cookieDomain"` + ForgotPasswordMessage string `json:"forgotPasswordMessage"` + BackgroundImage string `json:"backgroundImage"` + OAuthAutoRedirect string `json:"oauthAutoRedirect"` +} + +type Provider struct { + Name string `json:"name"` + ID string `json:"id"` + OAuth bool `json:"oauth"` } type ContextControllerConfig struct { - ConfiguredProviders []string + Providers []Provider Title string - GenericName string AppURL string CookieDomain string ForgotPasswordMessage string @@ -96,9 +100,8 @@ func (controller *ContextController) appContextHandler(c *gin.Context) { c.JSON(200, AppContextResponse{ Status: 200, Message: "Success", - ConfiguredProviders: controller.config.ConfiguredProviders, + Providers: controller.config.Providers, Title: controller.config.Title, - GenericName: controller.config.GenericName, AppURL: fmt.Sprintf("%s://%s", appUrl.Scheme, appUrl.Host), CookieDomain: controller.config.CookieDomain, ForgotPasswordMessage: controller.config.ForgotPasswordMessage, diff --git a/internal/controller/context_controller_test.go b/internal/controller/context_controller_test.go index 44f77a17..85be0b52 100644 --- a/internal/controller/context_controller_test.go +++ b/internal/controller/context_controller_test.go @@ -12,9 +12,19 @@ import ( ) var controllerCfg = controller.ContextControllerConfig{ - ConfiguredProviders: []string{"github", "google", "generic"}, + Providers: []controller.Provider{ + { + Name: "Username", + ID: "username", + OAuth: false, + }, + { + Name: "Google", + ID: "google", + OAuth: true, + }, + }, Title: "Test App", - GenericName: "Generic", AppURL: "http://localhost:8080", CookieDomain: "localhost", ForgotPasswordMessage: "Contact admin to reset your password.", @@ -58,9 +68,8 @@ func TestAppContextHandler(t *testing.T) { expectedRes := controller.AppContextResponse{ Status: 200, Message: "Success", - ConfiguredProviders: controllerCfg.ConfiguredProviders, + Providers: controllerCfg.Providers, Title: controllerCfg.Title, - GenericName: controllerCfg.GenericName, AppURL: controllerCfg.AppURL, CookieDomain: controllerCfg.CookieDomain, ForgotPasswordMessage: controllerCfg.ForgotPasswordMessage, diff --git a/internal/utils/app_utils.go b/internal/utils/app_utils.go index ed067465..bf2a1762 100644 --- a/internal/utils/app_utils.go +++ b/internal/utils/app_utils.go @@ -134,7 +134,7 @@ func GetLogLevel(level string) zerolog.Level { } } -func GetOAuthProvidersConfig(env []string, args []string) (map[string]config.OAuthServiceConfig, error) { +func GetOAuthProvidersConfig(env []string, args []string, appUrl string) (map[string]config.OAuthServiceConfig, error) { providers := make(map[string]config.OAuthServiceConfig) // Get from environment variables @@ -181,6 +181,18 @@ func GetOAuthProvidersConfig(env []string, args []string) (map[string]config.OAu providers[name] = provider } + // If we have google/github providers and no redirect URL babysit them + babysitProviders := []string{"google", "github"} + + for _, name := range babysitProviders { + if provider, exists := providers[name]; exists { + if provider.RedirectURL == "" { + provider.RedirectURL = appUrl + "/api/oauth/callback/" + name + providers[name] = provider + } + } + } + // Return combined providers return providers, nil } diff --git a/internal/utils/app_utils_test.go b/internal/utils/app_utils_test.go index 48bb915f..a7f09fe6 100644 --- a/internal/utils/app_utils_test.go +++ b/internal/utils/app_utils_test.go @@ -217,7 +217,7 @@ func TestGetOAuthProvidersConfig(t *testing.T) { }, } - result, err := utils.GetOAuthProvidersConfig(env, args) + result, err := utils.GetOAuthProvidersConfig(env, args, "") assert.NilError(t, err) assert.DeepEqual(t, expected, result) @@ -226,7 +226,7 @@ func TestGetOAuthProvidersConfig(t *testing.T) { args = []string{"/tinyauth/tinyauth"} expected = map[string]config.OAuthServiceConfig{} - result, err = utils.GetOAuthProvidersConfig(env, args) + result, err = utils.GetOAuthProvidersConfig(env, args, "") assert.NilError(t, err) assert.DeepEqual(t, expected, result) @@ -250,7 +250,22 @@ func TestGetOAuthProvidersConfig(t *testing.T) { }, } - result, err = utils.GetOAuthProvidersConfig(env, args) + 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", + }, + } + + result, err = utils.GetOAuthProvidersConfig(env, args, "http://app.url") assert.NilError(t, err) assert.DeepEqual(t, expected, result) } From e5ecf6336ff171c75adcb2d0e6ae61e34b757146 Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 12 Sep 2025 14:38:06 +0300 Subject: [PATCH 06/12] feat: implement multiple oauth providers in the frontend --- .../icons/{generic.tsx => oauth.tsx} | 2 +- frontend/src/pages/login-page.tsx | 70 ++++++------------- frontend/src/pages/logout-page.tsx | 8 +-- frontend/src/schemas/app-context-schema.ts | 11 ++- frontend/src/schemas/user-context-schema.ts | 1 + .../migrations/000002_oauth_name.down.sql | 1 + .../migrations/000002_oauth_name.up.sql | 8 +++ internal/bootstrap/app_bootstrap.go | 10 +-- internal/config/config.go | 2 + internal/controller/context_controller.go | 2 + internal/controller/oauth_controller.go | 1 + internal/middleware/context_middleware.go | 1 + internal/model/session_model.go | 1 + internal/service/auth_service.go | 2 + internal/service/generic_oauth_service.go | 6 ++ internal/service/github_oauth_service.go | 6 ++ internal/service/google_oauth_service.go | 6 ++ internal/service/oauth_broker_service.go | 1 + 18 files changed, 77 insertions(+), 62 deletions(-) rename frontend/src/components/icons/{generic.tsx => oauth.tsx} (91%) create mode 100644 internal/assets/migrations/000002_oauth_name.down.sql create mode 100644 internal/assets/migrations/000002_oauth_name.up.sql diff --git a/frontend/src/components/icons/generic.tsx b/frontend/src/components/icons/oauth.tsx similarity index 91% rename from frontend/src/components/icons/generic.tsx rename to frontend/src/components/icons/oauth.tsx index 6be8289c..3ca531d6 100644 --- a/frontend/src/components/icons/generic.tsx +++ b/frontend/src/components/icons/oauth.tsx @@ -1,6 +1,6 @@ import type { SVGProps } from "react"; -export function GenericIcon(props: SVGProps) { +export function OAuthIcon(props: SVGProps) { return ( { const { isLoggedIn } = useUserContext(); - const { configuredProviders, title, oauthAutoRedirect, genericName } = - useAppContext(); + const { providers, title, oauthAutoRedirect } = useAppContext(); const { search } = useLocation(); const { t } = useTranslation(); const isMounted = useIsMounted(); @@ -35,10 +32,11 @@ export const LoginPage = () => { const searchParams = new URLSearchParams(search); const redirectUri = searchParams.get("redirect_uri"); - const oauthConfigured = - configuredProviders.filter((provider) => provider !== "username").length > - 0; - const userAuthConfigured = configuredProviders.includes("username"); + const oauthProviders = providers.filter( + (provider) => provider.id !== "username", + ); + const userAuthConfigured = + providers.find((provider) => provider.id === "username") !== undefined; const oauthMutation = useMutation({ mutationFn: (provider: string) => @@ -96,8 +94,8 @@ export const LoginPage = () => { useEffect(() => { if (isMounted()) { if ( - oauthConfigured && - configuredProviders.includes(oauthAutoRedirect) && + oauthProviders.length !== 0 && + providers.find((provider) => provider.id === oauthAutoRedirect) && !isLoggedIn && redirectUri ) { @@ -130,57 +128,33 @@ export const LoginPage = () => { {title} - {configuredProviders.length > 0 && ( + {providers.length > 0 && ( - {oauthConfigured ? t("loginTitle") : t("loginTitleSimple")} + {oauthProviders.length !== 0 + ? t("loginTitle") + : t("loginTitleSimple")} )} - {oauthConfigured && ( + {oauthProviders.length !== 0 && (
- {configuredProviders.includes("google") && ( - } - className="w-full" - onClick={() => oauthMutation.mutate("google")} - loading={ - oauthMutation.isPending && - oauthMutation.variables === "google" - } - disabled={oauthMutation.isPending || loginMutation.isPending} - /> - )} - {configuredProviders.includes("github") && ( - } - className="w-full" - onClick={() => oauthMutation.mutate("github")} - loading={ - oauthMutation.isPending && - oauthMutation.variables === "github" - } - disabled={oauthMutation.isPending || loginMutation.isPending} - /> - )} - {configuredProviders.includes("generic") && ( + {oauthProviders.map((provider) => ( } + title={provider.name} + icon={} className="w-full" - onClick={() => oauthMutation.mutate("generic")} + onClick={() => oauthMutation.mutate(provider.id)} loading={ oauthMutation.isPending && - oauthMutation.variables === "generic" + oauthMutation.variables === provider.id } disabled={oauthMutation.isPending || loginMutation.isPending} /> - )} + ))}
)} - {userAuthConfigured && oauthConfigured && ( + {userAuthConfigured && oauthProviders.length !== 0 && ( {t("loginDivider")} )} {userAuthConfigured && ( @@ -189,7 +163,7 @@ export const LoginPage = () => { loading={loginMutation.isPending || oauthMutation.isPending} /> )} - {configuredProviders.length == 0 && ( + {providers.length == 0 && (

{t("failedToFetchProvidersTitle")}

diff --git a/frontend/src/pages/logout-page.tsx b/frontend/src/pages/logout-page.tsx index 17693bb6..480d8ae5 100644 --- a/frontend/src/pages/logout-page.tsx +++ b/frontend/src/pages/logout-page.tsx @@ -6,9 +6,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { useAppContext } from "@/context/app-context"; import { useUserContext } from "@/context/user-context"; -import { capitalize } from "@/lib/utils"; import { useMutation } from "@tanstack/react-query"; import axios from "axios"; import { useEffect, useRef } from "react"; @@ -17,8 +15,7 @@ import { Navigate } from "react-router"; import { toast } from "sonner"; export const LogoutPage = () => { - const { provider, username, isLoggedIn, email } = useUserContext(); - const { genericName } = useAppContext(); + const { provider, username, isLoggedIn, email, oauthName } = useUserContext(); const { t } = useTranslation(); const redirectTimer = useRef(null); @@ -67,8 +64,7 @@ export const LogoutPage = () => { }} values={{ username: email, - provider: - provider === "generic" ? genericName : capitalize(provider), + provider: oauthName, }} /> ) : ( diff --git a/frontend/src/schemas/app-context-schema.ts b/frontend/src/schemas/app-context-schema.ts index 8931be1b..ec766ee0 100644 --- a/frontend/src/schemas/app-context-schema.ts +++ b/frontend/src/schemas/app-context-schema.ts @@ -1,14 +1,19 @@ import { z } from "zod"; +export const providerSchema = z.object({ + id: z.string(), + name: z.string(), + oauth: z.boolean(), +}); + export const appContextSchema = z.object({ - configuredProviders: z.array(z.string()), + providers: z.array(providerSchema), title: z.string(), - genericName: z.string(), appUrl: z.string(), cookieDomain: z.string(), forgotPasswordMessage: z.string(), - oauthAutoRedirect: z.enum(["none", "github", "google", "generic"]), backgroundImage: z.string(), + oauthAutoRedirect: z.string(), }); export type AppContextSchema = z.infer; diff --git a/frontend/src/schemas/user-context-schema.ts b/frontend/src/schemas/user-context-schema.ts index ee6682cf..e7e057ac 100644 --- a/frontend/src/schemas/user-context-schema.ts +++ b/frontend/src/schemas/user-context-schema.ts @@ -8,6 +8,7 @@ export const userContextSchema = z.object({ provider: z.string(), oauth: z.boolean(), totpPending: z.boolean(), + oauthName: z.string(), }); export type UserContextSchema = z.infer; diff --git a/internal/assets/migrations/000002_oauth_name.down.sql b/internal/assets/migrations/000002_oauth_name.down.sql new file mode 100644 index 00000000..75ce3b06 --- /dev/null +++ b/internal/assets/migrations/000002_oauth_name.down.sql @@ -0,0 +1 @@ +ALTER TABLE "sessions" DROP COLUMN "oauth_name"; \ No newline at end of file diff --git a/internal/assets/migrations/000002_oauth_name.up.sql b/internal/assets/migrations/000002_oauth_name.up.sql new file mode 100644 index 00000000..91ff9dcd --- /dev/null +++ b/internal/assets/migrations/000002_oauth_name.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE "sessions" ADD COLUMN "oauth_name" TEXT; + +UPDATE + "sessions" +SET + "oauth_name" = "Generic" +WHERE + "oauth_name" IS NULL AND "provider" IS NOT NULL; diff --git a/internal/bootstrap/app_bootstrap.go b/internal/bootstrap/app_bootstrap.go index 684e4bd6..5301a768 100644 --- a/internal/bootstrap/app_bootstrap.go +++ b/internal/bootstrap/app_bootstrap.go @@ -151,10 +151,12 @@ func (app *BootstrapApp) Setup() error { continue } - if provider.Name == "" && babysit[id] != "" { - provider.Name = babysit[id] - } else { - provider.Name = utils.Capitalize(id) + if provider.Name == "" { + if name, ok := babysit[id]; ok { + provider.Name = name + } else { + provider.Name = utils.Capitalize(id) + } } configuredProviders = append(configuredProviders, controller.Provider{ diff --git a/internal/config/config.go b/internal/config/config.go index cdb02aec..4721ffa4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -84,6 +84,7 @@ type SessionCookie struct { Provider string TotpPending bool OAuthGroups string + OAuthName string } type UserContext struct { @@ -96,6 +97,7 @@ type UserContext struct { TotpPending bool OAuthGroups string TotpEnabled bool + OAuthName string } // API responses and queries diff --git a/internal/controller/context_controller.go b/internal/controller/context_controller.go index 148bc1cc..80ec61a7 100644 --- a/internal/controller/context_controller.go +++ b/internal/controller/context_controller.go @@ -19,6 +19,7 @@ type UserContextResponse struct { Provider string `json:"provider"` OAuth bool `json:"oauth"` TotpPending bool `json:"totpPending"` + OAuthName string `json:"oauthName"` } type AppContextResponse struct { @@ -80,6 +81,7 @@ func (controller *ContextController) userContextHandler(c *gin.Context) { Provider: context.Provider, OAuth: context.OAuth, TotpPending: context.TotpPending, + OAuthName: context.OAuthName, } if err != nil { diff --git a/internal/controller/oauth_controller.go b/internal/controller/oauth_controller.go index a65b53a0..bf50ff91 100644 --- a/internal/controller/oauth_controller.go +++ b/internal/controller/oauth_controller.go @@ -186,6 +186,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { Email: user.Email, Provider: req.Provider, OAuthGroups: utils.CoalesceToString(user.Groups), + OAuthName: service.GetName(), }) if err != nil { diff --git a/internal/middleware/context_middleware.go b/internal/middleware/context_middleware.go index 30fa6230..2c903be3 100644 --- a/internal/middleware/context_middleware.go +++ b/internal/middleware/context_middleware.go @@ -95,6 +95,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { Email: cookie.Email, Provider: cookie.Provider, OAuthGroups: cookie.OAuthGroups, + OAuthName: cookie.OAuthName, IsLoggedIn: true, OAuth: true, }) diff --git a/internal/model/session_model.go b/internal/model/session_model.go index 45e60659..0fdb6c37 100644 --- a/internal/model/session_model.go +++ b/internal/model/session_model.go @@ -9,4 +9,5 @@ type Session struct { TOTPPending bool `gorm:"column:totp_pending"` OAuthGroups string `gorm:"column:oauth_groups"` Expiry int64 `gorm:"column:expiry"` + OAuthName string `gorm:"column:oauth_name"` } diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index a3f8ed01..8925e491 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -210,6 +210,7 @@ func (auth *AuthService) CreateSessionCookie(c *gin.Context, data *config.Sessio TOTPPending: data.TotpPending, OAuthGroups: data.OAuthGroups, Expiry: time.Now().Add(time.Duration(expiry) * time.Second).Unix(), + OAuthName: data.OAuthName, } err = auth.database.Create(&session).Error @@ -278,6 +279,7 @@ func (auth *AuthService) GetSessionCookie(c *gin.Context) (config.SessionCookie, Provider: session.Provider, TotpPending: session.TOTPPending, OAuthGroups: session.OAuthGroups, + OAuthName: session.OAuthName, }, nil } diff --git a/internal/service/generic_oauth_service.go b/internal/service/generic_oauth_service.go index 72c23572..aae89c46 100644 --- a/internal/service/generic_oauth_service.go +++ b/internal/service/generic_oauth_service.go @@ -22,6 +22,7 @@ type GenericOAuthService struct { verifier string insecureSkipVerify bool userinfoUrl string + name string } func NewGenericOAuthService(config config.OAuthServiceConfig) *GenericOAuthService { @@ -38,6 +39,7 @@ func NewGenericOAuthService(config config.OAuthServiceConfig) *GenericOAuthServi }, insecureSkipVerify: config.InsecureSkipVerify, userinfoUrl: config.UserinfoURL, + name: config.Name, } } @@ -115,3 +117,7 @@ func (generic *GenericOAuthService) Userinfo() (config.Claims, error) { return user, nil } + +func (generic *GenericOAuthService) GetName() string { + return generic.name +} diff --git a/internal/service/github_oauth_service.go b/internal/service/github_oauth_service.go index 26d73b13..163c2c88 100644 --- a/internal/service/github_oauth_service.go +++ b/internal/service/github_oauth_service.go @@ -33,6 +33,7 @@ type GithubOAuthService struct { context context.Context token *oauth2.Token verifier string + name string } func NewGithubOAuthService(config config.OAuthServiceConfig) *GithubOAuthService { @@ -44,6 +45,7 @@ func NewGithubOAuthService(config config.OAuthServiceConfig) *GithubOAuthService Scopes: GithubOAuthScopes, Endpoint: endpoints.GitHub, }, + name: config.Name, } } @@ -167,3 +169,7 @@ func (github *GithubOAuthService) Userinfo() (config.Claims, error) { return user, nil } + +func (github *GithubOAuthService) GetName() string { + return github.name +} diff --git a/internal/service/google_oauth_service.go b/internal/service/google_oauth_service.go index 0f8c7eb8..ab0597d3 100644 --- a/internal/service/google_oauth_service.go +++ b/internal/service/google_oauth_service.go @@ -28,6 +28,7 @@ type GoogleOAuthService struct { context context.Context token *oauth2.Token verifier string + name string } func NewGoogleOAuthService(config config.OAuthServiceConfig) *GoogleOAuthService { @@ -39,6 +40,7 @@ func NewGoogleOAuthService(config config.OAuthServiceConfig) *GoogleOAuthService Scopes: GoogleOAuthScopes, Endpoint: endpoints.Google, }, + name: config.Name, } } @@ -111,3 +113,7 @@ func (google *GoogleOAuthService) Userinfo() (config.Claims, error) { return user, nil } + +func (google *GoogleOAuthService) GetName() string { + return google.name +} diff --git a/internal/service/oauth_broker_service.go b/internal/service/oauth_broker_service.go index f9df4f87..e6c6ddb3 100644 --- a/internal/service/oauth_broker_service.go +++ b/internal/service/oauth_broker_service.go @@ -14,6 +14,7 @@ type OAuthService interface { GetAuthURL(state string) string VerifyCode(code string) error Userinfo() (config.Claims, error) + GetName() string } type OAuthBrokerService struct { From 9f97a4ddd5c14e10926d99df2016ece587ed52b1 Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 12 Sep 2025 14:57:26 +0300 Subject: [PATCH 07/12] feat: add some default icons --- frontend/src/components/icons/microsoft.tsx | 18 ++++++++++++++++ frontend/src/components/icons/pocket-id.tsx | 20 +++++++++++++++++ frontend/src/components/icons/tailscale.tsx | 24 +++++++++++++++++++++ frontend/src/pages/login-page.tsx | 16 +++++++++++++- 4 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/icons/microsoft.tsx create mode 100644 frontend/src/components/icons/pocket-id.tsx create mode 100644 frontend/src/components/icons/tailscale.tsx diff --git a/frontend/src/components/icons/microsoft.tsx b/frontend/src/components/icons/microsoft.tsx new file mode 100644 index 00000000..58d470c5 --- /dev/null +++ b/frontend/src/components/icons/microsoft.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from "react"; + +export function MicrosoftIcon(props: SVGProps) { + return ( + + + + + + + ); +} diff --git a/frontend/src/components/icons/pocket-id.tsx b/frontend/src/components/icons/pocket-id.tsx new file mode 100644 index 00000000..cd0d017d --- /dev/null +++ b/frontend/src/components/icons/pocket-id.tsx @@ -0,0 +1,20 @@ +import type { SVGProps } from "react"; + +export function PocketIDIcon(props: SVGProps) { + return ( + + + + + ); +} diff --git a/frontend/src/components/icons/tailscale.tsx b/frontend/src/components/icons/tailscale.tsx new file mode 100644 index 00000000..3f7a15af --- /dev/null +++ b/frontend/src/components/icons/tailscale.tsx @@ -0,0 +1,24 @@ +import type { SVGProps } from "react"; + +export function TailscaleIcon(props: SVGProps) { + return ( + + + + + + ); +} diff --git a/frontend/src/pages/login-page.tsx b/frontend/src/pages/login-page.tsx index 9db10874..3354b2da 100644 --- a/frontend/src/pages/login-page.tsx +++ b/frontend/src/pages/login-page.tsx @@ -1,5 +1,10 @@ import { LoginForm } from "@/components/auth/login-form"; +import { GithubIcon } from "@/components/icons/github"; +import { GoogleIcon } from "@/components/icons/google"; +import { MicrosoftIcon } from "@/components/icons/microsoft"; import { OAuthIcon } from "@/components/icons/oauth"; +import { PocketIDIcon } from "@/components/icons/pocket-id"; +import { TailscaleIcon } from "@/components/icons/tailscale"; import { Card, CardHeader, @@ -20,6 +25,14 @@ import { useTranslation } from "react-i18next"; import { Navigate, useLocation } from "react-router"; import { toast } from "sonner"; +const iconMap: Record = { + google: , + github: , + tailscale: , + microsoft: , + pocketid: , +}; + export const LoginPage = () => { const { isLoggedIn } = useUserContext(); const { providers, title, oauthAutoRedirect } = useAppContext(); @@ -141,8 +154,9 @@ export const LoginPage = () => {
{oauthProviders.map((provider) => ( } + icon={iconMap[provider.id] ?? } className="w-full" onClick={() => oauthMutation.mutate(provider.id)} loading={ From e001f63eb577733220f7b0256dfec1cb328a1498 Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 12 Sep 2025 15:11:40 +0300 Subject: [PATCH 08/12] chore: add credits for parser --- internal/utils/decoders/env_decoder.go | 6 ++++-- internal/utils/decoders/flags_decoder.go | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/utils/decoders/env_decoder.go b/internal/utils/decoders/env_decoder.go index fc909452..938685a6 100644 --- a/internal/utils/decoders/env_decoder.go +++ b/internal/utils/decoders/env_decoder.go @@ -10,6 +10,8 @@ import ( "github.com/traefik/paerser/parser" ) +// Based on https://github.com/traefik/paerser/blob/master/parser/labels_decode.go + func DecodeEnv(env map[string]string) (config.Providers, error) { normalized := normalizeEnv(env, "tinyauth") @@ -44,7 +46,7 @@ func decodeEnvsToNode(env map[string]string, rootName string, filters ...string) var node *parser.Node for i, k := range sorted { - split := strings.SplitN(k, "_", 4) + split := strings.SplitN(k, "_", 4) // Something like PROVIDERS_MY_AWESOME_CLIENT is not supported because it will confuse the parser if split[0] != rootName { return nil, fmt.Errorf("invalid env root %s", split[0]) @@ -69,7 +71,7 @@ func decodeEnvToNode(root *parser.Node, path []string, value string) { root.Name = path[0] } - if !(len(path) > 1) { + if len(path) <= 1 { root.Value = value return } diff --git a/internal/utils/decoders/flags_decoder.go b/internal/utils/decoders/flags_decoder.go index 97aac72e..3b5550b7 100644 --- a/internal/utils/decoders/flags_decoder.go +++ b/internal/utils/decoders/flags_decoder.go @@ -10,6 +10,8 @@ import ( "github.com/traefik/paerser/parser" ) +// Based on https://github.com/traefik/paerser/blob/master/parser/labels_decode.go + func DecodeFlags(flags map[string]string) (config.Providers, error) { normalized := normalizeFlags(flags, "tinyauth") @@ -44,7 +46,7 @@ func decodeFlagsToNode(flags map[string]string, rootName string, filters ...stri var node *parser.Node for i, k := range sorted { - split := strings.SplitN(k, "_", 4) + split := strings.SplitN(k, "_", 4) // Something like --providers-my-awesome-client is not supported because it will confuse the parser if split[0] != rootName { return nil, fmt.Errorf("invalid flag root %s", split[0]) @@ -69,7 +71,7 @@ func decodeFlagToNode(root *parser.Node, path []string, value string) { root.Name = path[0] } - if !(len(path) > 1) { + if len(path) <= 1 { root.Value = value return } From 060e20e578cfa94c27e1de2bfd809f5ce0b9288d Mon Sep 17 00:00:00 2001 From: Stavros Date: Fri, 12 Sep 2025 17:30:30 +0300 Subject: [PATCH 09/12] feat: style oauth auto redirect screen --- cmd/root.go | 3 +- frontend/src/lib/i18n/locales/en-US.json | 3 ++ frontend/src/lib/i18n/locales/en.json | 3 ++ frontend/src/pages/continue-page.tsx | 2 +- frontend/src/pages/login-page.tsx | 40 +++++++++++++++++++++++- internal/config/config.go | 2 +- 6 files changed, 49 insertions(+), 4 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index d72cd344..aeb96a59 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -52,6 +52,7 @@ var rootCmd = &cobra.Command{ } func Execute() { + rootCmd.FParseErrWhitelist.UnknownFlags = true err := rootCmd.Execute() if err != nil { log.Fatal().Err(err).Msg("Failed to execute command") @@ -92,7 +93,7 @@ func init() { {"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."}, - {"trusted-proxies", "", "Comma separated list of trusted proxies (IP addresses) for correct client IP detection and for header ACLs."}, + {"trusted-proxies", "", "Comma separated list of trusted proxies (IP addresses or CIDRs) for correct client IP detection."}, } for _, opt := range configOptions { diff --git a/frontend/src/lib/i18n/locales/en-US.json b/frontend/src/lib/i18n/locales/en-US.json index 6338a884..43004285 100644 --- a/frontend/src/lib/i18n/locales/en-US.json +++ b/frontend/src/lib/i18n/locales/en-US.json @@ -14,6 +14,9 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", "continueTitle": "Continue", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 6338a884..43004285 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -14,6 +14,9 @@ "loginOauthFailSubtitle": "Failed to get OAuth URL", "loginOauthSuccessTitle": "Redirecting", "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider", + "loginOauthAutoRedirectTitle": "OAuth Auto Redirect", + "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", + "loginOauthAutoRedirectButton": "Redirect now", "continueTitle": "Continue", "continueRedirectingTitle": "Redirecting...", "continueRedirectingSubtitle": "You should be redirected to the app soon", diff --git a/frontend/src/pages/continue-page.tsx b/frontend/src/pages/continue-page.tsx index f17bd975..dd03a4c1 100644 --- a/frontend/src/pages/continue-page.tsx +++ b/frontend/src/pages/continue-page.tsx @@ -70,7 +70,7 @@ export const ContinuePage = () => { const reveal = setTimeout(() => { setLoading(false); setShowRedirectButton(true); - }, 1000); + }, 5000); return () => { clearTimeout(auto); diff --git a/frontend/src/pages/login-page.tsx b/frontend/src/pages/login-page.tsx index 3354b2da..2f3bc99f 100644 --- a/frontend/src/pages/login-page.tsx +++ b/frontend/src/pages/login-page.tsx @@ -5,12 +5,14 @@ import { MicrosoftIcon } from "@/components/icons/microsoft"; import { OAuthIcon } from "@/components/icons/oauth"; import { PocketIDIcon } from "@/components/icons/pocket-id"; import { TailscaleIcon } from "@/components/icons/tailscale"; +import { Button } from "@/components/ui/button"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, + CardFooter, } from "@/components/ui/card"; import { OAuthButton } from "@/components/ui/oauth-button"; import { SeperatorWithChildren } from "@/components/ui/separator"; @@ -20,7 +22,7 @@ import { useIsMounted } from "@/lib/hooks/use-is-mounted"; import { LoginSchema } from "@/schemas/login-schema"; import { useMutation } from "@tanstack/react-query"; import axios, { AxiosError } from "axios"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Navigate, useLocation } from "react-router"; import { toast } from "sonner"; @@ -39,8 +41,12 @@ export const LoginPage = () => { const { search } = useLocation(); const { t } = useTranslation(); const isMounted = useIsMounted(); + const [oauthAutoRedirectHandover, setOauthAutoRedirectHandover] = + useState(false); + const [showRedirectButton, setShowRedirectButton] = useState(false); const redirectTimer = useRef(null); + const redirectButtonTimer = useRef(null); const searchParams = new URLSearchParams(search); const redirectUri = searchParams.get("redirect_uri"); @@ -67,6 +73,7 @@ export const LoginPage = () => { }, 500); }, onError: () => { + setOauthAutoRedirectHandover(false); toast.error(t("loginOauthFailTitle"), { description: t("loginOauthFailSubtitle"), }); @@ -112,7 +119,11 @@ export const LoginPage = () => { !isLoggedIn && redirectUri ) { + setOauthAutoRedirectHandover(true); oauthMutation.mutate(oauthAutoRedirect); + redirectButtonTimer.current = window.setTimeout(() => { + setShowRedirectButton(true); + }, 5000); } } }, []); @@ -120,6 +131,8 @@ export const LoginPage = () => { useEffect( () => () => { if (redirectTimer.current) clearTimeout(redirectTimer.current); + if (redirectButtonTimer.current) + clearTimeout(redirectButtonTimer.current); }, [], ); @@ -137,6 +150,31 @@ export const LoginPage = () => { return ; } + if (oauthAutoRedirectHandover) { + return ( + + + + {t("loginOauthAutoRedirectTitle")} + + + {t("loginOauthAutoRedirectSubtitle")} + + + {showRedirectButton && ( + + + + )} + + ); + } return ( diff --git a/internal/config/config.go b/internal/config/config.go index 4721ffa4..1ebf0286 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,7 +22,7 @@ type Config struct { UsersFile string `mapstructure:"users-file"` SecureCookie bool `mapstructure:"secure-cookie"` OAuthWhitelist string `mapstructure:"oauth-whitelist"` - OAuthAutoRedirect string `mapstructure:"oauth-auto-redirect" validate:"oneof=none github google generic"` + 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"` From 120ae2c79d579b61c42ddc21a490aa02b009b7b8 Mon Sep 17 00:00:00 2001 From: Stavros Date: Mon, 15 Sep 2025 13:37:08 +0300 Subject: [PATCH 10/12] fix: bot suggestions --- frontend/src/components/icons/tailscale.tsx | 6 ++++-- internal/assets/migrations/000002_oauth_name.up.sql | 2 +- internal/utils/app_utils.go | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/icons/tailscale.tsx b/frontend/src/components/icons/tailscale.tsx index 3f7a15af..9381b5cd 100644 --- a/frontend/src/components/icons/tailscale.tsx +++ b/frontend/src/components/icons/tailscale.tsx @@ -11,13 +11,15 @@ export function TailscaleIcon(props: SVGProps) { {...props} > ); diff --git a/internal/assets/migrations/000002_oauth_name.up.sql b/internal/assets/migrations/000002_oauth_name.up.sql index 91ff9dcd..297ed2ff 100644 --- a/internal/assets/migrations/000002_oauth_name.up.sql +++ b/internal/assets/migrations/000002_oauth_name.up.sql @@ -3,6 +3,6 @@ ALTER TABLE "sessions" ADD COLUMN "oauth_name" TEXT; UPDATE "sessions" SET - "oauth_name" = "Generic" + "oauth_name" = 'Generic' WHERE "oauth_name" IS NULL AND "provider" IS NOT NULL; diff --git a/internal/utils/app_utils.go b/internal/utils/app_utils.go index bf2a1762..643c9cf0 100644 --- a/internal/utils/app_utils.go +++ b/internal/utils/app_utils.go @@ -142,7 +142,9 @@ func GetOAuthProvidersConfig(env []string, args []string, appUrl string) (map[st for _, e := range env { pair := strings.SplitN(e, "=", 2) - envMap[pair[0]] = pair[1] + if len(pair) == 2 { + envMap[pair[0]] = pair[1] + } } envProviders, err := decoders.DecodeEnv(envMap) From cebce1a92c1856d7b04113a5425e1a6a811fe77a Mon Sep 17 00:00:00 2001 From: Stavros Date: Mon, 15 Sep 2025 14:46:27 +0300 Subject: [PATCH 11/12] refactor: rework decoders using simpler and more efficient pattern --- internal/config/config.go | 20 ++-- internal/utils/decoders/decoders.go | 81 +++++++++++++++ internal/utils/decoders/decoders_test.go | 44 +++++++++ internal/utils/decoders/env_decoder.go | 121 +---------------------- internal/utils/decoders/flags_decoder.go | 121 ++--------------------- 5 files changed, 144 insertions(+), 243 deletions(-) create mode 100644 internal/utils/decoders/decoders.go create mode 100644 internal/utils/decoders/decoders_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 1ebf0286..4fc66fcd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -51,16 +51,16 @@ type Claims struct { } type OAuthServiceConfig struct { - ClientID string - ClientSecret string - ClientSecretFile string - Scopes []string - RedirectURL string - AuthURL string - TokenURL string - UserinfoURL string - InsecureSkipVerify bool - Name string + ClientID string `key:"client-id"` + ClientSecret string `key:"client-secret"` + ClientSecretFile string `key:"client-secret-file"` + Scopes []string `key:"scopes"` + RedirectURL string `key:"redirect-url"` + AuthURL string `key:"auth-url"` + TokenURL string `key:"token-url"` + UserinfoURL string `key:"user-info-url"` + InsecureSkipVerify bool `key:"insecure-skip-verify"` + Name string `key:"name"` } // User/session related stuff diff --git a/internal/utils/decoders/decoders.go b/internal/utils/decoders/decoders.go new file mode 100644 index 00000000..72a11d5b --- /dev/null +++ b/internal/utils/decoders/decoders.go @@ -0,0 +1,81 @@ +package decoders + +import ( + "reflect" + "strings" + "tinyauth/internal/config" +) + +func NormalizeKeys(keys map[string]string, rootName string, sep string) map[string]string { + normalized := make(map[string]string) + knownKeys := getKnownKeys() + + for k, v := range keys { + var finalKey []string + var suffix string + var camelClientName string + var camelField string + + finalKey = append(finalKey, rootName) + finalKey = append(finalKey, "providers") + cebabKey := strings.ToLower(k) + + for _, known := range knownKeys { + if strings.HasSuffix(cebabKey, strings.ReplaceAll(known, "-", sep)) { + suffix = known + break + } + } + + if suffix == "" { + continue + } + + clientNameParts := strings.Split(strings.TrimPrefix(strings.TrimSuffix(cebabKey, sep+strings.ReplaceAll(suffix, "-", sep)), "providers"+sep), sep) + + for i, p := range clientNameParts { + if i == 0 { + camelClientName += p + continue + } + if p == "" { + continue + } + camelClientName += strings.ToUpper(string([]rune(p)[0])) + string([]rune(p)[1:]) + } + + finalKey = append(finalKey, camelClientName) + + filedParts := strings.Split(suffix, "-") + + for i, p := range filedParts { + if i == 0 { + camelField += p + continue + } + if p == "" { + continue + } + camelField += strings.ToUpper(string([]rune(p)[0])) + string([]rune(p)[1:]) + } + + finalKey = append(finalKey, camelField) + normalized[strings.Join(finalKey, ".")] = v + } + + return normalized +} + +func getKnownKeys() []string { + var known []string + + p := config.OAuthServiceConfig{} + v := reflect.ValueOf(p) + typeOfP := v.Type() + + for field := range typeOfP.NumField() { + known = append(known, typeOfP.Field(field).Tag.Get("key")) + } + + return known +} diff --git a/internal/utils/decoders/decoders_test.go b/internal/utils/decoders/decoders_test.go new file mode 100644 index 00000000..285760c9 --- /dev/null +++ b/internal/utils/decoders/decoders_test.go @@ -0,0 +1,44 @@ +package decoders_test + +import ( + "testing" + "tinyauth/internal/utils/decoders" + + "gotest.tools/v3/assert" +) + +func TestNormalizeKeys(t *testing.T) { + // Test with env + test := map[string]string{ + "PROVIDERS_CLIENT1_CLIENT_ID": "my-client-id", + "PROVIDERS_CLIENT1_CLIENT_SECRET": "my-client-secret", + "PROVIDERS_MY_AWESOME_CLIENT_CLIENT_ID": "my-awesome-client-id", + "PROVIDERS_MY_AWESOME_CLIENT_CLIENT_SECRET_FILE": "/path/to/secret", + } + expected := map[string]string{ + "tinyauth.providers.client1.clientId": "my-client-id", + "tinyauth.providers.client1.clientSecret": "my-client-secret", + "tinyauth.providers.myAwesomeClient.clientId": "my-awesome-client-id", + "tinyauth.providers.myAwesomeClient.clientSecretFile": "/path/to/secret", + } + + normalized := decoders.NormalizeKeys(test, "tinyauth", "_") + assert.DeepEqual(t, normalized, expected) + + // Test with flags (assume -- is already stripped) + test = map[string]string{ + "providers-client1-client-id": "my-client-id", + "providers-client1-client-secret": "my-client-secret", + "providers-my-awesome-client-client-id": "my-awesome-client-id", + "providers-my-awesome-client-client-secret-file": "/path/to/secret", + } + expected = map[string]string{ + "tinyauth.providers.client1.clientId": "my-client-id", + "tinyauth.providers.client1.clientSecret": "my-client-secret", + "tinyauth.providers.myAwesomeClient.clientId": "my-awesome-client-id", + "tinyauth.providers.myAwesomeClient.clientSecretFile": "/path/to/secret", + } + + normalized = decoders.NormalizeKeys(test, "tinyauth", "-") + assert.DeepEqual(t, normalized, expected) +} diff --git a/internal/utils/decoders/env_decoder.go b/internal/utils/decoders/env_decoder.go index 938685a6..4164aa55 100644 --- a/internal/utils/decoders/env_decoder.go +++ b/internal/utils/decoders/env_decoder.go @@ -1,37 +1,16 @@ package decoders import ( - "fmt" - "slices" - "sort" - "strings" "tinyauth/internal/config" "github.com/traefik/paerser/parser" ) -// Based on https://github.com/traefik/paerser/blob/master/parser/labels_decode.go - func DecodeEnv(env map[string]string) (config.Providers, error) { - normalized := normalizeEnv(env, "tinyauth") - - node, err := decodeEnvsToNode(normalized, "tinyauth", "tinyauth_providers") - - if err != nil { - return config.Providers{}, err - } - + normalized := NormalizeKeys(env, "tinyauth", "_") var providers config.Providers - metaOpts := parser.MetadataOpts{TagName: "env", AllowSliceAsStruct: true} - - err = parser.AddMetadata(&providers, node, metaOpts) - - if err != nil { - return config.Providers{}, err - } - - err = parser.Fill(&providers, node, parser.FillerOpts{AllowSliceAsStruct: true}) + err := parser.Decode(normalized, &providers, "tinyauth", "tinyauth.providers") if err != nil { return config.Providers{}, err @@ -39,99 +18,3 @@ func DecodeEnv(env map[string]string) (config.Providers, error) { return providers, nil } - -func decodeEnvsToNode(env map[string]string, rootName string, filters ...string) (*parser.Node, error) { - sorted := sortEnvKeys(env, filters) - - var node *parser.Node - - for i, k := range sorted { - split := strings.SplitN(k, "_", 4) // Something like PROVIDERS_MY_AWESOME_CLIENT is not supported because it will confuse the parser - - if split[0] != rootName { - return nil, fmt.Errorf("invalid env root %s", split[0]) - } - - if slices.Contains(split, "") { - return nil, fmt.Errorf("invalid element: %s", k) - } - - if i == 0 { - node = &parser.Node{} - } - - decodeEnvToNode(node, split, env[k]) - } - - return node, nil -} - -func decodeEnvToNode(root *parser.Node, path []string, value string) { - if len(root.Name) == 0 { - root.Name = path[0] - } - - if len(path) <= 1 { - root.Value = value - return - } - - if n := containsEnvNode(root.Children, path[1]); n != nil { - decodeEnvToNode(n, path[1:], value) - return - } - - child := &parser.Node{Name: path[1]} - decodeEnvToNode(child, path[1:], value) - root.Children = append(root.Children, child) -} - -func containsEnvNode(node []*parser.Node, name string) *parser.Node { - for _, n := range node { - if strings.EqualFold(n.Name, name) { - return n - } - } - return nil -} - -func sortEnvKeys(env map[string]string, filters []string) []string { - var sorted []string - - for k := range env { - if len(filters) == 0 { - sorted = append(sorted, k) - continue - } - - for _, f := range filters { - if strings.HasPrefix(k, f) { - sorted = append(sorted, k) - break - } - } - } - - sort.Strings(sorted) - return sorted -} - -// normalizeEnv converts env vars from PROVIDERS_CLIENT1_CLIENT_ID to tinyauth_providers_client_clientId -func normalizeEnv(env map[string]string, rootName string) map[string]string { - n := make(map[string]string) - for k, v := range env { - fk := strings.ToLower(k) - fks := strings.SplitN(fk, "_", 3) - fkb := "" - for i, s := range strings.Split(fks[len(fks)-1], "_") { - if i == 0 { - fkb += s - continue - } - fkb += strings.ToUpper(string([]rune(s)[0])) + string([]rune(s)[1:]) - } - fk = rootName + "_" + strings.Join(fks[:len(fks)-1], "_") + "_" + fkb - n[fk] = v - } - return n -} diff --git a/internal/utils/decoders/flags_decoder.go b/internal/utils/decoders/flags_decoder.go index 3b5550b7..d973d299 100644 --- a/internal/utils/decoders/flags_decoder.go +++ b/internal/utils/decoders/flags_decoder.go @@ -1,37 +1,18 @@ package decoders import ( - "fmt" - "slices" - "sort" "strings" "tinyauth/internal/config" "github.com/traefik/paerser/parser" ) -// Based on https://github.com/traefik/paerser/blob/master/parser/labels_decode.go - func DecodeFlags(flags map[string]string) (config.Providers, error) { - normalized := normalizeFlags(flags, "tinyauth") - - node, err := decodeFlagsToNode(normalized, "tinyauth", "tinyauth_providers") - - if err != nil { - return config.Providers{}, err - } - + filtered := filterFlags(flags) + normalized := NormalizeKeys(filtered, "tinyauth", "-") var providers config.Providers - metaOpts := parser.MetadataOpts{TagName: "flag", AllowSliceAsStruct: true} - - err = parser.AddMetadata(&providers, node, metaOpts) - - if err != nil { - return config.Providers{}, err - } - - err = parser.Fill(&providers, node, parser.FillerOpts{AllowSliceAsStruct: true}) + err := parser.Decode(normalized, &providers, "tinyauth", "tinyauth.providers") if err != nil { return config.Providers{}, err @@ -40,98 +21,10 @@ func DecodeFlags(flags map[string]string) (config.Providers, error) { return providers, nil } -func decodeFlagsToNode(flags map[string]string, rootName string, filters ...string) (*parser.Node, error) { - sorted := sortFlagKeys(flags, filters) - - var node *parser.Node - - for i, k := range sorted { - split := strings.SplitN(k, "_", 4) // Something like --providers-my-awesome-client is not supported because it will confuse the parser - - if split[0] != rootName { - return nil, fmt.Errorf("invalid flag root %s", split[0]) - } - - if slices.Contains(split, "") { - return nil, fmt.Errorf("invalid element: %s", k) - } - - if i == 0 { - node = &parser.Node{} - } - - decodeFlagToNode(node, split, flags[k]) - } - - return node, nil -} - -func decodeFlagToNode(root *parser.Node, path []string, value string) { - if len(root.Name) == 0 { - root.Name = path[0] - } - - if len(path) <= 1 { - root.Value = value - return - } - - if n := containsFlagNode(root.Children, path[1]); n != nil { - decodeFlagToNode(n, path[1:], value) - return - } - - child := &parser.Node{Name: path[1]} - decodeFlagToNode(child, path[1:], value) - root.Children = append(root.Children, child) -} - -func containsFlagNode(node []*parser.Node, name string) *parser.Node { - for _, n := range node { - if strings.EqualFold(n.Name, name) { - return n - } - } - return nil -} - -func sortFlagKeys(flags map[string]string, filters []string) []string { - var sorted []string - - for k := range flags { - if len(filters) == 0 { - sorted = append(sorted, k) - continue - } - - for _, f := range filters { - if strings.HasPrefix(k, f) { - sorted = append(sorted, k) - break - } - } - } - - sort.Strings(sorted) - return sorted -} - -// normalizeFlags converts flags from --providers-client-client-id to tinyauth_providers_client_clientId -func normalizeFlags(flags map[string]string, rootName string) map[string]string { - n := make(map[string]string) +func filterFlags(flags map[string]string) map[string]string { + filtered := make(map[string]string) for k, v := range flags { - fk := strings.TrimPrefix(k, "--") - fks := strings.SplitN(fk, "-", 3) - fkb := "" - for i, s := range strings.Split(fks[len(fks)-1], "-") { - if i == 0 { - fkb += s - continue - } - fkb += strings.ToUpper(string([]rune(s)[0])) + string([]rune(s)[1:]) - } - fk = rootName + "_" + strings.Join(fks[:len(fks)-1], "_") + "_" + fkb - n[fk] = v + filtered[strings.TrimPrefix(k, "--")] = v } - return n + return filtered } From 7795a989cd22b05584cf32715569af59350fc761 Mon Sep 17 00:00:00 2001 From: Stavros Date: Mon, 15 Sep 2025 15:06:39 +0300 Subject: [PATCH 12/12] refactor: rework oauth name database migration --- .../assets/migrations/000002_oauth_name.up.sql | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/assets/migrations/000002_oauth_name.up.sql b/internal/assets/migrations/000002_oauth_name.up.sql index 297ed2ff..416bd295 100644 --- a/internal/assets/migrations/000002_oauth_name.up.sql +++ b/internal/assets/migrations/000002_oauth_name.up.sql @@ -1,8 +1,10 @@ ALTER TABLE "sessions" ADD COLUMN "oauth_name" TEXT; -UPDATE - "sessions" -SET - "oauth_name" = 'Generic' -WHERE - "oauth_name" IS NULL AND "provider" IS NOT NULL; +UPDATE "sessions" +SET "oauth_name" = CASE + WHEN LOWER("provider") = 'github' THEN 'GitHub' + WHEN LOWER("provider") = 'google' THEN 'Google' + ELSE UPPER(SUBSTR("provider", 1, 1)) || SUBSTR("provider", 2) +END +WHERE "oauth_name" IS NULL AND "provider" IS NOT NULL; +