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:
@@ -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", "")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -8,4 +8,6 @@ var SetViperDefaults = setViperDefaults
|
|||||||
|
|
||||||
var ParseLanguages = parseLanguages
|
var ParseLanguages = parseLanguages
|
||||||
|
|
||||||
|
var ValidateURL = validateURL
|
||||||
|
|
||||||
var NormalizeSearchBackend = normalizeSearchBackend
|
var NormalizeSearchBackend = normalizeSearchBackend
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ const UserMenu = (props) => {
|
|||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
)}
|
)}
|
||||||
{!config.auth && logout}
|
{(!config.auth || !!config.extAuthLogoutURL) && logout}
|
||||||
</MenuList>
|
</MenuList>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user