Compare commits

...

3 Commits

Author SHA1 Message Date
Deluan
4a4d7dc4d1 feat(server): refactor ExtAuth logout URL validation to a reusable function 2026-02-20 10:31:21 -05:00
Deluan
32cde243c5 feat(server): add validation for ExtAuth logout URL configuration 2026-02-20 10:11:01 -05:00
Deluan
c400167a55 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.
2026-02-20 09:39:25 -05:00
7 changed files with 70 additions and 1 deletions

View File

@@ -249,6 +249,7 @@ type pluginsOptions struct {
type extAuthOptions struct {
TrustedSources string
UserHeader string
LogoutURL string
}
var (
@@ -339,6 +340,7 @@ func Load(noConfigDump bool) {
validateBackupSchedule,
validatePlaylistsPath,
validatePurgeMissingOption,
validateUrl("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
)
if err != nil {
os.Exit(1)
@@ -539,6 +541,27 @@ func validateSchedule(schedule, field string) (string, error) {
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
}
return nil
}
}
// AddHook is used to register initialization code that should run as soon as the config is loaded
func AddHook(hook func()) {
hooks = append(hooks, hook)
@@ -619,6 +642,7 @@ func setViperDefaults() {
viper.SetDefault("passwordencryptionkey", "")
viper.SetDefault("extauth.userheader", "Remote-User")
viper.SetDefault("extauth.trustedsources", "")
viper.SetDefault("extauth.logouturl", "")
viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
viper.SetDefault("prometheus.password", "")

View File

@@ -52,6 +52,43 @@ 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())
})
})
DescribeTable("should load configuration from",
func(format string) {
filename := filepath.Join("testdata", "cfg."+format)

View File

@@ -7,3 +7,5 @@ func ResetConf() {
var SetViperDefaults = setViperDefaults
var ParseLanguages = parseLanguages
var ValidateUrl = validateUrl

View File

@@ -75,6 +75,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
"separator": string(os.PathSeparator),
"enableInspect": conf.Server.Inspect.Enabled,
"pluginsEnabled": conf.Server.Plugins.Enabled,
"extAuthLogoutUrl": conf.Server.ExtAuth.LogoutURL,
}
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
appConfig["loginBackgroundURL"] = path.Join(conf.Server.BasePath, conf.Server.UILoginBackgroundURL)

View File

@@ -103,6 +103,7 @@ var _ = Describe("serveIndex", func() {
Entry("enableUserEditing", func() { conf.Server.EnableUserEditing = false }, "enableUserEditing", false),
Entry("enableSharing", func() { conf.Server.EnableSharing = true }, "enableSharing", 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",

View File

@@ -66,6 +66,10 @@ const authProvider = {
logout: () => {
removeItems()
if (config.extAuthLogoutUrl) {
window.location.href = config.extAuthLogoutUrl
return Promise.resolve(false)
}
return Promise.resolve()
},

View File

@@ -122,7 +122,7 @@ const UserMenu = (props) => {
})
: null,
)}
{!config.auth && logout}
{(!config.auth || !!config.extAuthLogoutUrl) && logout}
</MenuList>
</Popover>
</div>