diff --git a/cmd/serve.go b/cmd/serve.go
index eb30bbd6..f7034ca1 100644
--- a/cmd/serve.go
+++ b/cmd/serve.go
@@ -96,6 +96,7 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-user-header", Aliases: []string{"auth_user_header"}, EnvVars: []string{"NTFY_AUTH_USER_HEADER"}, Value: "", Usage: "if set (along with behind-proxy and auth-file), trust this header to contain the authenticated username (e.g. X-Forwarded-User, Remote-User)"}),
+ altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-logout-url", Aliases: []string{"auth_logout_url"}, EnvVars: []string{"NTFY_AUTH_LOGOUT_URL"}, Value: "", Usage: "URL to redirect to when logging out in proxy auth mode (e.g. https://auth.example.com/logout)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "billing-contact", Aliases: []string{"billing_contact"}, EnvVars: []string{"NTFY_BILLING_CONTACT"}, Value: "", Usage: "e-mail or website to display in upgrade dialog (only if payments are enabled)"}),
@@ -208,6 +209,7 @@ func execServe(c *cli.Context) error {
proxyForwardedHeader := c.String("proxy-forwarded-header")
proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",")
authUserHeader := c.String("auth-user-header")
+ authLogoutURL := c.String("auth-logout-url")
stripeSecretKey := c.String("stripe-secret-key")
stripeWebhookKey := c.String("stripe-webhook-key")
billingContact := c.String("billing-contact")
@@ -315,7 +317,8 @@ func execServe(c *cli.Context) error {
} else if u.Path != "" {
return fmt.Errorf("if set, base-url must not have a path (%s), as hosting ntfy on a sub-path is not supported, e.g. https://ntfy.mydomain.com", u.Path)
}
- } else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
+ }
+ if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
return errors.New("if set, upstream-base-url must start with http:// or https://")
} else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") {
return errors.New("if set, upstream-base-url must not end with a slash (/)")
@@ -340,7 +343,8 @@ func execServe(c *cli.Context) error {
if messageSizeLimit > 5*1024*1024 {
return errors.New("message-size-limit cannot be higher than 5M")
}
- } else if !server.WebPushAvailable && (webPushPrivateKey != "" || webPushPublicKey != "" || webPushFile != "") {
+ }
+ if !server.WebPushAvailable && (webPushPrivateKey != "" || webPushPublicKey != "" || webPushFile != "") {
return errors.New("cannot enable WebPush, support is not available in this build (nowebpush)")
} else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration {
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
@@ -350,6 +354,10 @@ func execServe(c *cli.Context) error {
return errors.New("auth-user-header requires behind-proxy to be set")
} else if authUserHeader != "" && authFile == "" {
return errors.New("auth-user-header requires auth-file to be set")
+ } else if authUserHeader != "" && enableLogin {
+ return errors.New("auth-user-header cannot be used with enable-login")
+ } else if authUserHeader != "" && enableSignup {
+ return errors.New("auth-user-header cannot be used with enable-signup")
} else if visitorPrefixBitsIPv4 < 1 || visitorPrefixBitsIPv4 > 32 {
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
@@ -418,6 +426,15 @@ func execServe(c *cli.Context) error {
payments.Setup(stripeSecretKey)
}
+ // Parse Twilio call format template
+ var twilioCallFormatTemplate *template.Template
+ if twilioCallFormat != "" {
+ twilioCallFormatTemplate, err = template.New("").Parse(twilioCallFormat)
+ if err != nil {
+ return fmt.Errorf("failed to parse twilio-call-format template: %w", err)
+ }
+ }
+
// Add default forbidden topics
disallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...)
@@ -443,6 +460,8 @@ func execServe(c *cli.Context) error {
conf.AuthUsers = authUsers
conf.AuthAccess = authAccess
conf.AuthTokens = authTokens
+ conf.AuthUserHeader = authUserHeader
+ conf.AuthLogoutURL = authLogoutURL
conf.AttachmentCacheDir = attachmentCacheDir
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
@@ -465,13 +484,7 @@ func execServe(c *cli.Context) error {
conf.TwilioAuthToken = twilioAuthToken
conf.TwilioPhoneNumber = twilioPhoneNumber
conf.TwilioVerifyService = twilioVerifyService
- if twilioCallFormat != "" {
- tmpl, err := template.New("twiml").Parse(twilioCallFormat)
- if err != nil {
- return fmt.Errorf("failed to parse twilio-call-format template: %w", err)
- }
- conf.TwilioCallFormat = tmpl
- }
+ conf.TwilioCallFormat = twilioCallFormatTemplate
conf.MessageSizeLimit = int(messageSizeLimit)
conf.MessageDelayMax = messageDelayLimit
conf.TotalTopicLimit = totalTopicLimit
@@ -490,7 +503,6 @@ func execServe(c *cli.Context) error {
conf.BehindProxy = behindProxy
conf.ProxyForwardedHeader = proxyForwardedHeader
conf.ProxyTrustedPrefixes = trustedProxyPrefixes
- conf.AuthUserHeader = authUserHeader
conf.StripeSecretKey = stripeSecretKey
conf.StripeWebhookKey = stripeWebhookKey
conf.BillingContact = billingContact
diff --git a/server/config.go b/server/config.go
index 4410c1bc..8824fe91 100644
--- a/server/config.go
+++ b/server/config.go
@@ -167,6 +167,7 @@ type Config struct {
ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported)
ProxyTrustedPrefixes []netip.Prefix // List of trusted proxy networks (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true
AuthUserHeader string // Header to read the authenticated user from, if BehindProxy is true (e.g. X-Forwarded-User, Remote-User)
+ AuthLogoutURL string // URL to redirect to when logging out in proxy auth mode (e.g. https://auth.example.com/logout)
StripeSecretKey string
StripeWebhookKey string
StripePriceCacheDuration time.Duration
@@ -265,6 +266,7 @@ func NewConfig() *Config {
BehindProxy: false, // If true, the server will trust the proxy client IP header to determine the client IP address
ProxyForwardedHeader: "X-Forwarded-For", // Default header for reverse proxy client IPs
AuthUserHeader: "", // Header to read the authenticated user from (requires behind-proxy and auth-file)
+ AuthLogoutURL: "", // URL to redirect to when logging out in proxy auth mode
StripeSecretKey: "",
StripeWebhookKey: "",
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
diff --git a/server/server.go b/server/server.go
index 9dcae651..db690479 100644
--- a/server/server.go
+++ b/server/server.go
@@ -603,13 +603,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor
return s.writeJSON(w, response)
}
-func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
+func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, v *visitor) error {
w.Header().Set("Cache-Control", "no-cache")
- return s.writeJSON(w, s.configResponse())
+ return s.writeJSON(w, s.configResponse(v))
}
-func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
- b, err := json.MarshalIndent(s.configResponse(), "", " ")
+func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, v *visitor) error {
+ b, err := json.MarshalIndent(s.configResponse(v), "", " ")
if err != nil {
return err
}
@@ -619,7 +619,15 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
return err
}
-func (s *Server) configResponse() *apiConfigResponse {
+func (s *Server) configResponse(v *visitor) *apiConfigResponse {
+ authMode := ""
+ username := ""
+ if s.config.AuthUserHeader != "" {
+ authMode = "proxy"
+ if v != nil && v.User() != nil {
+ username = v.User().Name
+ }
+ }
return &apiConfigResponse{
BaseURL: "", // Will translate to window.location.origin
AppRoot: s.config.WebRoot,
@@ -635,6 +643,9 @@ func (s *Server) configResponse() *apiConfigResponse {
WebPushPublicKey: s.config.WebPushPublicKey,
DisallowedTopics: s.config.DisallowedTopics,
ConfigHash: s.config.Hash(),
+ AuthMode: authMode,
+ AuthLogoutURL: s.config.AuthLogoutURL,
+ Username: username,
}
}
diff --git a/server/server.yml b/server/server.yml
index 7356cb0e..484327fe 100644
--- a/server/server.yml
+++ b/server/server.yml
@@ -139,6 +139,12 @@
#
# auth-user-header:
+# If auth-user-header is set, this is the URL to redirect users to when they click logout.
+# This is typically the logout URL of your authentication proxy (e.g. Authelia, Authentik).
+# If not set, the logout button will be hidden in the web UI when using proxy auth.
+#
+# auth-logout-url:
+
# If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments
# are "attachment-cache-dir" and "base-url".
#
diff --git a/server/server_test.go b/server/server_test.go
index 3ce6b33f..3cb58e97 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -2428,7 +2428,7 @@ func TestServer_AuthUserHeader_UserNotFound(t *testing.T) {
require.Equal(t, errHTTPUnauthorized, err)
}
-func TestServer_AuthUserHeader_NoHeader_FallbackToStandardAuth(t *testing.T) {
+func TestServer_AuthUserHeader_NoHeader_ReturnsUnauthorized(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.BehindProxy = true
c.AuthUserHeader = "X-Forwarded-User"
@@ -2436,28 +2436,27 @@ func TestServer_AuthUserHeader_NoHeader_FallbackToStandardAuth(t *testing.T) {
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
- // No X-Forwarded-User header, but with Authorization header
+ // No X-Forwarded-User header, even with Authorization header -> unauthorized
+ // When auth-user-header is configured, the header MUST be present
r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil)
r.RemoteAddr = "1.2.3.4:1234"
r.Header.Set("Authorization", util.BasicAuth("phil", "phil"))
- v, err := s.maybeAuthenticate(r)
- require.Nil(t, err)
- require.NotNil(t, v.User())
- require.Equal(t, "phil", v.User().Name)
+ _, err := s.maybeAuthenticate(r)
+ require.Equal(t, errHTTPUnauthorized, err)
}
-func TestServer_AuthUserHeader_NoHeader_AnonymousAllowed(t *testing.T) {
+func TestServer_AuthUserHeader_NoHeader_NoAuthReturnsUnauthorized(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.BehindProxy = true
c.AuthUserHeader = "X-Forwarded-User"
s := newTestServer(t, c)
- // No X-Forwarded-User header and no Authorization header -> anonymous
+ // No X-Forwarded-User header and no Authorization header -> unauthorized
+ // When auth-user-header is configured, the header MUST be present
r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil)
r.RemoteAddr = "1.2.3.4:1234"
- v, err := s.maybeAuthenticate(r)
- require.Nil(t, err)
- require.Nil(t, v.User())
+ _, err := s.maybeAuthenticate(r)
+ require.Equal(t, errHTTPUnauthorized, err)
}
func TestServer_AuthUserHeader_NotBehindProxy(t *testing.T) {
diff --git a/server/types.go b/server/types.go
index fae44e5b..4b2e3b11 100644
--- a/server/types.go
+++ b/server/types.go
@@ -483,6 +483,9 @@ type apiConfigResponse struct {
WebPushPublicKey string `json:"web_push_public_key"`
DisallowedTopics []string `json:"disallowed_topics"`
ConfigHash string `json:"config_hash"`
+ AuthMode string `json:"auth_mode,omitempty"` // "proxy" if auth-user-header is set, empty otherwise
+ AuthLogoutURL string `json:"auth_logout_url,omitempty"` // URL to redirect to on logout (only for proxy auth)
+ Username string `json:"username,omitempty"` // Authenticated username (for proxy auth)
}
type apiAccountBillingPrices struct {
diff --git a/web/public/config.js b/web/public/config.js
index 62f49ed4..0e2ab8a8 100644
--- a/web/public/config.js
+++ b/web/public/config.js
@@ -20,4 +20,7 @@ var config = {
web_push_public_key: "",
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"],
config_hash: "dev", // Placeholder for development; actual value is generated server-side
+ auth_mode: "", // "proxy" if auth-user-header is set, empty otherwise
+ auth_logout_url: "", // URL to redirect to on logout (only for proxy auth)
+ username: "", // Authenticated username (for proxy auth)
};
diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js
index d9380438..f3c48948 100644
--- a/web/src/app/AccountApi.js
+++ b/web/src/app/AccountApi.js
@@ -16,6 +16,7 @@ import {
withBasicAuth,
withBearerAuth,
} from "./utils";
+import config from "./config";
import session from "./Session";
import subscriptionManager from "./SubscriptionManager";
import prefs from "./Prefs";
@@ -341,7 +342,12 @@ class AccountApi {
async sync() {
try {
- if (!session.token()) {
+ // For proxy auth, store the username from config if not already in session
+ if (config.auth_mode === "proxy" && config.username && !session.exists()) {
+ console.log(`[AccountApi] Proxy auth: storing session for user ${config.username}`);
+ await session.store(config.username, ""); // Empty token for proxy auth
+ }
+ if (!session.exists()) {
return null;
}
console.log(`[AccountApi] Syncing account`);
diff --git a/web/src/app/Session.js b/web/src/app/Session.js
index 7464150c..7502a851 100644
--- a/web/src/app/Session.js
+++ b/web/src/app/Session.js
@@ -3,6 +3,10 @@ import Dexie from "dexie";
/**
* Manages the logged-in user's session and access token.
* The session replica is stored in IndexedDB so that the service worker can access it.
+ *
+ * For proxy authentication (when config.auth_mode === "proxy"), the token will be empty
+ * since authentication is handled by the proxy. In this case, store(username, "") is called
+ * with an empty token, and exists() returns true based on the username alone.
*/
class Session {
constructor() {
@@ -53,7 +57,7 @@ class Session {
}
exists() {
- return this.username() && this.token();
+ return !!this.username();
}
username() {
diff --git a/web/src/components/ActionBar.jsx b/web/src/components/ActionBar.jsx
index 1f41aac0..54cd9f21 100644
--- a/web/src/components/ActionBar.jsx
+++ b/web/src/components/ActionBar.jsx
@@ -139,6 +139,17 @@ const ProfileIcon = () => {
};
const handleLogout = async () => {
+ // For proxy auth, redirect to the logout URL if configured
+ if (config.auth_mode === "proxy") {
+ if (config.auth_logout_url) {
+ await db().delete();
+ localStorage.removeItem("user");
+ localStorage.removeItem("token");
+ window.location.href = config.auth_logout_url;
+ }
+ return;
+ }
+ // Standard logout
try {
await accountApi.logout();
await db().delete();
@@ -147,6 +158,9 @@ const ProfileIcon = () => {
}
};
+ // Determine if logout button should be shown
+ const showLogout = config.auth_mode !== "proxy" || config.auth_logout_url;
+
return (
<>
{session.exists() && (
@@ -178,12 +192,14 @@ const ProfileIcon = () => {
{t("action_bar_profile_settings")}
-
+ {showLogout && (
+
+ )}
>
);
diff --git a/web/src/components/Login.jsx b/web/src/components/Login.jsx
index 5c1af249..15006e3c 100644
--- a/web/src/components/Login.jsx
+++ b/web/src/components/Login.jsx
@@ -1,5 +1,5 @@
import * as React from "react";
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "@mui/material";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { NavLink } from "react-router-dom";
@@ -18,6 +18,13 @@ const Login = () => {
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
+ // Redirect to app if using proxy authentication
+ useEffect(() => {
+ if (config.auth_mode === "proxy") {
+ window.location.href = routes.app;
+ }
+ }, []);
+
const handleSubmit = async (event) => {
event.preventDefault();
const user = { username, password };