feat(server): add ExtAuth logout URL configuration (#5074)

* feat(server): add ExtAuth logout URL configuration (#4467)

When external authentication (reverse proxy auth) is active, the Logout
button is hidden because authentication is managed externally. Many
external auth services (Authelia, Authentik, Keycloak) provide a logout
URL that can terminate the session.

Add `ExtAuth.LogoutURL` config option that, when set, shows the Logout
button in the UI and redirects the user to the external auth provider's
logout endpoint instead of the Navidrome login page.

* feat(server): add validation for ExtAuth logout URL configuration

* feat(server): refactor ExtAuth logout URL validation to a reusable function

* fix(configuration): rename URL validation functions for consistency

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(configuration): rename URL validation functions for consistency

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2026-02-23 20:28:38 -05:00
committed by GitHub
parent d1c5e6a2f2
commit 2bb13e5ff1
7 changed files with 81 additions and 1 deletions
+30
View File
@@ -250,6 +250,7 @@ type pluginsOptions struct {
type extAuthOptions struct { type extAuthOptions struct {
TrustedSources string TrustedSources string
UserHeader string UserHeader string
LogoutURL string
} }
type searchOptions struct { type searchOptions struct {
@@ -345,6 +346,7 @@ func Load(noConfigDump bool) {
validateBackupSchedule, validateBackupSchedule,
validatePlaylistsPath, validatePlaylistsPath,
validatePurgeMissingOption, validatePurgeMissingOption,
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
) )
if err != nil { if err != nil {
os.Exit(1) os.Exit(1)
@@ -548,6 +550,33 @@ func validateSchedule(schedule, field string) (string, error) {
return schedule, err return schedule, err
} }
// validateURL checks if the provided URL is valid and has either http or https scheme.
// It returns a function that can be used as a hook to validate URLs in the config.
func validateURL(optionName, optionURL string) func() error {
return func() error {
if optionURL == "" {
return nil
}
u, err := url.Parse(optionURL)
if err != nil {
log.Error(fmt.Sprintf("Invalid %s: it could not be parsed", optionName), "url", optionURL, "err", err)
return err
}
if u.Scheme != "http" && u.Scheme != "https" {
err := fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme)
log.Error(err.Error())
return err
}
// Require an absolute URL with a non-empty host and no opaque component.
if u.Host == "" || u.Opaque != "" {
err := fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL)
log.Error(err.Error())
return err
}
return nil
}
}
func normalizeSearchBackend(value string) string { func normalizeSearchBackend(value string) string {
v := strings.ToLower(strings.TrimSpace(value)) v := strings.ToLower(strings.TrimSpace(value))
switch v { switch v {
@@ -641,6 +670,7 @@ func setViperDefaults() {
viper.SetDefault("passwordencryptionkey", "") viper.SetDefault("passwordencryptionkey", "")
viper.SetDefault("extauth.userheader", "Remote-User") viper.SetDefault("extauth.userheader", "Remote-User")
viper.SetDefault("extauth.trustedsources", "") viper.SetDefault("extauth.trustedsources", "")
viper.SetDefault("extauth.logouturl", "")
viper.SetDefault("prometheus.enabled", false) viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath) viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
viper.SetDefault("prometheus.password", "") viper.SetDefault("prometheus.password", "")
+42
View File
@@ -52,6 +52,48 @@ var _ = Describe("Configuration", func() {
}) })
}) })
Describe("ValidateURL", func() {
It("accepts a valid http URL", func() {
fn := conf.ValidateURL("TestOption", "http://example.com/path")
Expect(fn()).To(Succeed())
})
It("accepts a valid https URL", func() {
fn := conf.ValidateURL("TestOption", "https://example.com/path")
Expect(fn()).To(Succeed())
})
It("rejects a URL with no scheme", func() {
fn := conf.ValidateURL("TestOption", "example.com/path")
Expect(fn()).To(MatchError(ContainSubstring("invalid scheme")))
})
It("rejects a URL with an unsupported scheme", func() {
fn := conf.ValidateURL("TestOption", "javascript://example.com/path")
Expect(fn()).To(MatchError(ContainSubstring("invalid scheme")))
})
It("accepts an empty URL (optional config)", func() {
fn := conf.ValidateURL("TestOption", "")
Expect(fn()).To(Succeed())
})
It("includes the option name in the error message", func() {
fn := conf.ValidateURL("MyOption", "ftp://example.com")
Expect(fn()).To(MatchError(ContainSubstring("MyOption")))
})
It("rejects a URL that cannot be parsed", func() {
fn := conf.ValidateURL("TestOption", "://invalid")
Expect(fn()).To(HaveOccurred())
})
It("rejects a URL without a host", func() {
fn := conf.ValidateURL("TestOption", "http:///path")
Expect(fn()).To(MatchError(ContainSubstring("non-empty host is required")))
})
})
DescribeTable("NormalizeSearchBackend", DescribeTable("NormalizeSearchBackend",
func(input, expected string) { func(input, expected string) {
Expect(conf.NormalizeSearchBackend(input)).To(Equal(expected)) Expect(conf.NormalizeSearchBackend(input)).To(Equal(expected))
+2
View File
@@ -8,4 +8,6 @@ var SetViperDefaults = setViperDefaults
var ParseLanguages = parseLanguages var ParseLanguages = parseLanguages
var ValidateURL = validateURL
var NormalizeSearchBackend = normalizeSearchBackend var NormalizeSearchBackend = normalizeSearchBackend
+1
View File
@@ -76,6 +76,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
"separator": string(os.PathSeparator), "separator": string(os.PathSeparator),
"enableInspect": conf.Server.Inspect.Enabled, "enableInspect": conf.Server.Inspect.Enabled,
"pluginsEnabled": conf.Server.Plugins.Enabled, "pluginsEnabled": conf.Server.Plugins.Enabled,
"extAuthLogoutURL": conf.Server.ExtAuth.LogoutURL,
} }
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") { if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
appConfig["loginBackgroundURL"] = path.Join(conf.Server.BasePath, conf.Server.UILoginBackgroundURL) appConfig["loginBackgroundURL"] = path.Join(conf.Server.BasePath, conf.Server.UILoginBackgroundURL)
+1
View File
@@ -104,6 +104,7 @@ var _ = Describe("serveIndex", func() {
Entry("enableUserEditing", func() { conf.Server.EnableUserEditing = false }, "enableUserEditing", false), Entry("enableUserEditing", func() { conf.Server.EnableUserEditing = false }, "enableUserEditing", false),
Entry("enableSharing", func() { conf.Server.EnableSharing = true }, "enableSharing", true), Entry("enableSharing", func() { conf.Server.EnableSharing = true }, "enableSharing", true),
Entry("devNewEventStream", func() { conf.Server.DevNewEventStream = true }, "devNewEventStream", true), Entry("devNewEventStream", func() { conf.Server.DevNewEventStream = true }, "devNewEventStream", true),
Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"),
) )
DescribeTable("sets other UI configuration values", DescribeTable("sets other UI configuration values",
+4
View File
@@ -66,6 +66,10 @@ const authProvider = {
logout: () => { logout: () => {
removeItems() removeItems()
if (config.extAuthLogoutURL) {
window.location.href = config.extAuthLogoutURL
return Promise.resolve(false)
}
return Promise.resolve() return Promise.resolve()
}, },
+1 -1
View File
@@ -122,7 +122,7 @@ const UserMenu = (props) => {
}) })
: null, : null,
)} )}
{!config.auth && logout} {(!config.auth || !!config.extAuthLogoutURL) && logout}
</MenuList> </MenuList>
</Popover> </Popover>
</div> </div>