Compare commits

..

3 Commits

Author SHA1 Message Date
Stavros f8fb7d678b fix: fix conflicts 2026-05-07 16:17:11 +03:00
Stavros 0c31fb0600 Merge branch 'main' into feat/oauth-whitelist-file 2026-05-07 16:17:01 +03:00
djedditt 6b5a6bd982 feat: add support for oauth whitelist file (#817) 2026-04-29 03:19:11 +02:00
12 changed files with 187 additions and 32 deletions
+2
View File
@@ -91,6 +91,8 @@ TINYAUTH_APPS_name_LDAP_GROUPS=
# Comma-separated list of allowed OAuth domains.
TINYAUTH_OAUTH_WHITELIST=
# Path to the OAuth whitelist file.
TINYAUTH_OAUTH_WHITELISTFILE=
# The OAuth provider to use for automatic redirection.
TINYAUTH_OAUTH_AUTOREDIRECT=
# OAuth client ID.
+15 -1
View File
@@ -31,6 +31,7 @@ type BootstrapApp struct {
oauthSessionCookieName string
localUsers *[]model.LocalUser
oauthProviders map[string]model.OAuthServiceConfig
oauthWhitelist []string
configuredProviders []controller.Provider
oidcClients []model.OIDCClientConfig
}
@@ -71,6 +72,13 @@ func (app *BootstrapApp) Setup() error {
app.context.localUsers = users
oauthWhitelist, err := utils.GetStringList(app.config.OAuth.Whitelist, app.config.OAuth.WhitelistFile)
if err != nil {
return err
}
app.context.oauthWhitelist = oauthWhitelist
// Setup OAuth providers
app.context.oauthProviders = app.config.OAuth.Providers
@@ -104,7 +112,13 @@ func (app *BootstrapApp) Setup() error {
}
// Get cookie domain
cookieDomain, err := utils.GetCookieDomain(app.context.appUrl)
cookieDomainResolver := utils.GetCookieDomain
if !app.config.Auth.SubdomainsEnabled {
tlog.App.Info().Msg("Subdomains disabled, automatic authentication for proxied apps will not work")
cookieDomainResolver = utils.GetStandaloneCookieDomain
}
cookieDomain, err := cookieDomainResolver(app.context.appUrl)
if err != nil {
return err
+1
View File
@@ -84,6 +84,7 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
RedirectCookieName: app.context.redirectCookieName,
CookieDomain: app.context.cookieDomain,
OAuthSessionCookieName: app.context.oauthSessionCookieName,
SubdomainsEnabled: app.config.Auth.SubdomainsEnabled,
}, apiRouter, app.services.authService)
oauthController.SetupRoutes()
+2 -1
View File
@@ -90,7 +90,7 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
authService := service.NewAuthService(service.AuthServiceConfig{
LocalUsers: app.context.localUsers,
OauthWhitelist: app.config.OAuth.Whitelist,
OauthWhitelist: app.context.oauthWhitelist,
SessionExpiry: app.config.Auth.SessionExpiry,
SessionMaxLifetime: app.config.Auth.SessionMaxLifetime,
SecureCookie: app.config.Auth.SecureCookie,
@@ -100,6 +100,7 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er
SessionCookieName: app.context.sessionCookieName,
IP: app.config.Auth.IP,
LDAPGroupsCacheTTL: app.config.LDAP.GroupCacheTTL,
SubdomainsEnabled: app.config.Auth.SubdomainsEnabled,
}, services.ldapService, queries, services.oauthBrokerService)
err = authService.Init()
+10 -2
View File
@@ -26,6 +26,7 @@ type OAuthControllerConfig struct {
SecureCookie bool
AppURL string
CookieDomain string
SubdomainsEnabled bool
}
type OAuthController struct {
@@ -105,7 +106,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
return
}
c.SetCookie(controller.config.OAuthSessionCookieName, sessionId, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true)
c.SetCookie(controller.config.OAuthSessionCookieName, sessionId, int(time.Hour.Seconds()), "/", controller.getCookieDomain(), controller.config.SecureCookie, true)
c.JSON(200, gin.H{
"status": 200,
@@ -135,7 +136,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
return
}
c.SetCookie(controller.config.OAuthSessionCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true)
c.SetCookie(controller.config.OAuthSessionCookieName, "", -1, "/", controller.getCookieDomain(), controller.config.SecureCookie, true)
oauthPendingSession, err := controller.auth.GetOAuthPendingSession(sessionIdCookie)
@@ -283,3 +284,10 @@ func (controller *OAuthController) isOidcRequest(params service.OAuthURLParams)
params.ClientID != "" &&
params.RedirectURI != ""
}
func (controller *OAuthController) getCookieDomain() string {
if controller.config.SubdomainsEnabled {
return "." + controller.config.CookieDomain
}
return controller.config.CookieDomain
}
+3
View File
@@ -18,6 +18,7 @@ func NewDefaultConfiguration() *Config {
Address: "0.0.0.0",
},
Auth: AuthConfig{
SubdomainsEnabled: true,
SessionExpiry: 86400, // 1 day
SessionMaxLifetime: 0, // disabled
LoginTimeout: 300, // 5 minutes
@@ -102,6 +103,7 @@ type ServerConfig struct {
type AuthConfig struct {
IP IPConfig `description:"IP whitelisting config options." yaml:"ip"`
Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"`
SubdomainsEnabled bool `description:"Enable subdomains support." yaml:"subdomainsEnabled"`
UserAttributes map[string]UserAttributes `description:"Map of per-user OIDC attributes (username -> attributes)." yaml:"userAttributes"`
UsersFile string `description:"Path to the users file." yaml:"usersFile"`
SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"`
@@ -146,6 +148,7 @@ type IPConfig struct {
type OAuthConfig struct {
Whitelist []string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist"`
WhitelistFile string `description:"Path to the OAuth whitelist file." yaml:"whitelistFile"`
AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect"`
Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers"`
}
+14
View File
@@ -84,6 +84,7 @@ type AuthServiceConfig struct {
SessionCookieName string
IP model.IPConfig
LDAPGroupsCacheTTL int
SubdomainsEnabled bool
}
type AuthService struct {
@@ -397,6 +398,12 @@ func (auth *AuthService) DeleteSession(ctx context.Context, uuid string) (*http.
tlog.App.Warn().Err(err).Msg("Failed to delete session from database, proceeding to clear cookie anyway")
}
err = auth.queries.DeleteSession(ctx, uuid)
if err != nil {
return nil, err
}
return &http.Cookie{
Name: auth.config.SessionCookieName,
Value: "",
@@ -838,3 +845,10 @@ func (auth *AuthService) ClearRateLimitsTestingOnly() {
}
auth.loginMutex.Unlock()
}
func (auth *AuthService) getCookieDomain() string {
if auth.config.SubdomainsEnabled {
return "." + auth.config.CookieDomain
}
return auth.config.CookieDomain
}
+22 -1
View File
@@ -22,7 +22,7 @@ func GetCookieDomain(u string) (string, error) {
host := parsed.Hostname()
if netIP := net.ParseIP(host); netIP != nil {
return "", errors.New("IP addresses not allowed")
return "", errors.New("ip addresses not allowed")
}
parts := strings.Split(host, ".")
@@ -47,6 +47,27 @@ func GetCookieDomain(u string) (string, error) {
return domain, nil
}
func GetStandaloneCookieDomain(u string) (string, error) {
parsed, err := url.Parse(u)
if err != nil {
return "", err
}
host := parsed.Hostname()
if netIP := net.ParseIP(host); netIP != nil {
return "", errors.New("ip addresses not allowed")
}
parts := strings.Split(host, ".")
if len(parts) < 2 {
return "", errors.New("invalid app url")
}
return host, nil
}
func ParseFileToLine(content string) string {
lines := strings.Split(content, "\n")
users := make([]string, 0)
+46 -1
View File
@@ -30,7 +30,7 @@ func TestGetRootDomain(t *testing.T) {
// IP address
domain = "http://10.10.10.10"
_, err = utils.GetCookieDomain(domain)
assert.ErrorContains(t, err, "IP addresses not allowed")
assert.ErrorContains(t, err, "ip addresses not allowed")
// Invalid URL
domain = "http://[::1]:namedport"
@@ -180,3 +180,48 @@ func TestIsRedirectSafe(t *testing.T) {
result = utils.IsRedirectSafe(redirectURL, domain)
assert.False(t, result)
}
func TestGetStandaloneCookieDomain(t *testing.T) {
// Normal case
domain := "http://tinyauth.app"
expected := "tinyauth.app"
result, err := utils.GetStandaloneCookieDomain(domain)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// URL with subdomain (full hostname is returned, no subdomain stripping)
domain = "http://sub.tinyauth.app"
expected = "sub.tinyauth.app"
result, err = utils.GetStandaloneCookieDomain(domain)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// URL with port (port should be stripped)
domain = "http://tinyauth.app:8080"
expected = "tinyauth.app"
result, err = utils.GetStandaloneCookieDomain(domain)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// URL with path
domain = "https://tinyauth.app/some/path"
expected = "tinyauth.app"
result, err = utils.GetStandaloneCookieDomain(domain)
assert.NoError(t, err)
assert.Equal(t, expected, result)
// IP address
domain = "http://10.10.10.10"
_, err = utils.GetStandaloneCookieDomain(domain)
assert.ErrorContains(t, err, "ip addresses not allowed")
// Invalid domain (only TLD)
domain = "com"
_, err = utils.GetStandaloneCookieDomain(domain)
assert.ErrorContains(t, err, "invalid app url")
// Invalid URL
domain = "http://[::1]:namedport"
_, err = utils.GetStandaloneCookieDomain(domain)
assert.ErrorContains(t, err, "parse \"http://[::1]:namedport\": invalid port \":namedport\" after host")
}
+38
View File
@@ -28,3 +28,41 @@ func CoalesceToString(value any) string {
return ""
}
}
func ParseNonEmptyLines(contents string) []string {
lines := make([]string, 0)
for line := range strings.SplitSeq(contents, "\n") {
lineTrimmed := strings.TrimSpace(line)
if lineTrimmed == "" {
continue
}
lines = append(lines, lineTrimmed)
}
return lines
}
func GetStringList(valuesCfg []string, valuesPath string) ([]string, error) {
values := make([]string, 0, len(valuesCfg))
for _, value := range valuesCfg {
valueTrimmed := strings.TrimSpace(value)
if valueTrimmed == "" {
continue
}
values = append(values, valueTrimmed)
}
if valuesPath == "" {
return values, nil
}
contents, err := ReadFile(valuesPath)
if err != nil {
return []string{}, err
}
values = append(values, ParseNonEmptyLines(contents)...)
return values, nil
}
+31
View File
@@ -1,6 +1,7 @@
package utils_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
@@ -56,3 +57,33 @@ func TestCompileUserEmail(t *testing.T) {
// Test with invalid email
assert.Equal(t, "user@example.com", utils.CompileUserEmail("user", "example.com"))
}
func TestParseNonEmptyLines(t *testing.T) {
lines := utils.ParseNonEmptyLines(" first@example.com \n\n second@example.com \n \n")
assert.DeepEqual(t, []string{"first@example.com", "second@example.com"}, lines)
}
func TestGetStringList(t *testing.T) {
file, err := os.Create("/tmp/tinyauth_list_test_file")
assert.NilError(t, err)
_, err = file.WriteString(" third@example.com \n\n fourth@example.com \n")
assert.NilError(t, err)
err = file.Close()
assert.NilError(t, err)
defer os.Remove("/tmp/tinyauth_list_test_file")
values, err := utils.GetStringList([]string{" first@example.com ", "", "second@example.com"}, "/tmp/tinyauth_list_test_file")
assert.NilError(t, err)
assert.DeepEqual(t, []string{"first@example.com", "second@example.com", "third@example.com", "fourth@example.com"}, values)
values, err = utils.GetStringList(nil, "")
assert.NilError(t, err)
assert.DeepEqual(t, []string{}, values)
values, err = utils.GetStringList(nil, "/tmp/non_existing_list_file")
assert.ErrorContains(t, err, "no such file or directory")
assert.DeepEqual(t, []string{}, values)
}
+3 -26
View File
@@ -34,32 +34,9 @@ func ParseUsers(usersStr []string, userAttributes map[string]model.UserAttribute
}
func GetUsers(usersCfg []string, usersPath string, userAttributes map[string]model.UserAttributes) (*[]model.LocalUser, error) {
var usersStr []string
if len(usersCfg) == 0 && usersPath == "" {
return nil, nil
}
if len(usersCfg) > 0 {
usersStr = append(usersStr, usersCfg...)
}
if usersPath != "" {
contents, err := ReadFile(usersPath)
if err != nil {
return nil, err
}
lines := strings.SplitSeq(contents, "\n")
for line := range lines {
lineTrimmed := strings.TrimSpace(line)
if lineTrimmed == "" {
continue
}
usersStr = append(usersStr, lineTrimmed)
}
usersStr, err := GetStringList(usersCfg, usersPath)
if err != nil {
return nil, err
}
return ParseUsers(usersStr, userAttributes)