From 19c0294c6d45d296ad05e507881f2da809c2d84e Mon Sep 17 00:00:00 2001 From: djedditt Date: Sun, 24 May 2026 23:37:48 +0200 Subject: [PATCH] feat: add support for app-level oauth whitelist file (#817) --- .env.example | 4 +- internal/model/config.go | 5 +- internal/service/access_controls_service.go | 24 ++++++++- .../service/access_controls_service_test.go | 52 +++++++++++++++++++ internal/utils/decoders/label_decoder_test.go | 6 ++- 5 files changed, 84 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 5fd3ae19..e059055d 100644 --- a/.env.example +++ b/.env.example @@ -62,8 +62,10 @@ TINYAUTH_APPS_name_CONFIG_DOMAIN= TINYAUTH_APPS_name_USERS_ALLOW= # Comma-separated list of blocked users. TINYAUTH_APPS_name_USERS_BLOCK= -# Comma-separated list of allowed OAuth groups. +# Comma-separated list of allowed OAuth emails. TINYAUTH_APPS_name_OAUTH_WHITELIST= +# Path to the OAuth whitelist file for this app. +TINYAUTH_APPS_name_OAUTH_WHITELISTFILE= # Comma-separated list of required OAuth groups. TINYAUTH_APPS_name_OAUTH_GROUPS= # List of allowed IPs or CIDR ranges. diff --git a/internal/model/config.go b/internal/model/config.go index 07c9a4f5..936d240c 100644 --- a/internal/model/config.go +++ b/internal/model/config.go @@ -276,8 +276,9 @@ type AppUsers struct { } type AppOAuth struct { - Whitelist string `description:"Comma-separated list of allowed OAuth groups." yaml:"whitelist"` - Groups string `description:"Comma-separated list of required OAuth groups." yaml:"groups"` + Whitelist string `description:"Comma-separated list of allowed OAuth emails." yaml:"whitelist"` + WhitelistFile string `description:"Path to the OAuth whitelist file for this app." yaml:"whitelistFile"` + Groups string `description:"Comma-separated list of required OAuth groups." yaml:"groups"` } type AppLDAP struct { diff --git a/internal/service/access_controls_service.go b/internal/service/access_controls_service.go index 64c4d6fc..825d07ec 100644 --- a/internal/service/access_controls_service.go +++ b/internal/service/access_controls_service.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/tinyauthapp/tinyauth/internal/model" + "github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils/logger" ) @@ -53,14 +54,33 @@ func (service *AccessControlsService) GetAccessControls(domain string) (*model.A if app != nil { service.log.App.Debug().Msg("Using static ACLs for app") - return app, nil + return service.resolveAppOAuthWhitelist(app) } // If we have a label provider configured, try to get ACLs from it if service.labelProvider != nil && *service.labelProvider != nil { - return (*service.labelProvider).GetLabels(domain) + app, err := (*service.labelProvider).GetLabels(domain) + if err != nil { + return nil, err + } + return service.resolveAppOAuthWhitelist(app) } // no labels return nil, nil } + +func (service *AccessControlsService) resolveAppOAuthWhitelist(app *model.App) (*model.App, error) { + if app == nil || app.OAuth.WhitelistFile == "" { + return app, nil + } + + values, err := utils.GetStringList([]string{app.OAuth.Whitelist}, app.OAuth.WhitelistFile) + if err != nil { + return nil, err + } + + resolved := *app + resolved.OAuth.Whitelist = strings.Join(values, ",") + return &resolved, nil +} diff --git a/internal/service/access_controls_service_test.go b/internal/service/access_controls_service_test.go index e3d32eb6..32a4b58b 100644 --- a/internal/service/access_controls_service_test.go +++ b/internal/service/access_controls_service_test.go @@ -2,6 +2,7 @@ package service import ( "errors" + "os" "testing" "github.com/stretchr/testify/assert" @@ -162,6 +163,38 @@ func TestGetAccessControls(t *testing.T) { assert.Equal(t, 1, mock.callCount) }) + t.Run("loads app oauth whitelist from file for static config", func(t *testing.T) { + tmpDir := t.TempDir() + whitelistPath := tmpDir + "/oauth-whitelist.txt" + file, err := os.Create(whitelistPath) + require.NoError(t, err) + + _, err = file.WriteString("second@example.com\nthird@example.com\n") + require.NoError(t, err) + + err = file.Close() + require.NoError(t, err) + + config := model.Config{ + Apps: map[string]model.App{ + "foo": { + Config: model.AppConfig{Domain: "foo.example.com"}, + OAuth: model.AppOAuth{ + Whitelist: "first@example.com", + WhitelistFile: whitelistPath, + }, + }, + }, + } + svc := NewAccessControlsService(log, config, nil) + + got, err := svc.GetAccessControls("foo.example.com") + + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "first@example.com,second@example.com,third@example.com", got.OAuth.Whitelist) + }) + t.Run("does not call label provider when static match found", func(t *testing.T) { mock := &mockLabelProvider{} var provider LabelProvider = mock @@ -196,4 +229,23 @@ func TestGetAccessControls(t *testing.T) { assert.ErrorIs(t, err, providerErr) assert.Equal(t, 1, mock.callCount) }) + + t.Run("returns whitelist file errors from static config", func(t *testing.T) { + config := model.Config{ + Apps: map[string]model.App{ + "foo": { + Config: model.AppConfig{Domain: "foo.example.com"}, + OAuth: model.AppOAuth{ + WhitelistFile: t.TempDir() + "/missing.txt", + }, + }, + }, + } + svc := NewAccessControlsService(log, config, nil) + + got, err := svc.GetAccessControls("foo.example.com") + + assert.Nil(t, got) + assert.ErrorContains(t, err, "no such file or directory") + }) } diff --git a/internal/utils/decoders/label_decoder_test.go b/internal/utils/decoders/label_decoder_test.go index 9048e7bc..b5bc7a62 100644 --- a/internal/utils/decoders/label_decoder_test.go +++ b/internal/utils/decoders/label_decoder_test.go @@ -21,8 +21,9 @@ func TestDecodeLabels(t *testing.T) { Block: "user3", }, OAuth: model.AppOAuth{ - Whitelist: "somebody@example.com", - Groups: "group3", + Whitelist: "somebody@example.com", + WhitelistFile: "/path/to/whitelistfile", + Groups: "group3", }, IP: model.AppIP{ Allow: []string{"10.71.0.1/24", "10.71.0.2"}, @@ -49,6 +50,7 @@ func TestDecodeLabels(t *testing.T) { "tinyauth.apps.foo.users.allow": "user1,user2", "tinyauth.apps.foo.users.block": "user3", "tinyauth.apps.foo.oauth.whitelist": "somebody@example.com", + "tinyauth.apps.foo.oauth.whitelistfile": "/path/to/whitelistfile", "tinyauth.apps.foo.oauth.groups": "group3", "tinyauth.apps.foo.ip.allow": "10.71.0.1/24,10.71.0.2", "tinyauth.apps.foo.ip.block": "10.10.10.10,10.0.0.0/24",