Compare commits

...

6 Commits

Author SHA1 Message Date
binwiederhier
9e755a73f0 This works 2026-01-31 20:05:23 -05:00
binwiederhier
857f5742b9 Merge branch 'main' into user-header 2026-01-29 20:03:53 -05:00
binwiederhier
099cad02b8 Sw stuff 2026-01-24 15:54:57 -05:00
binwiederhier
9b1be517ea Remove auth_mode 2026-01-22 20:26:00 -05:00
binwiederhier
b67ffa4f5f Auth logout URL, auth proxy 2026-01-22 20:19:59 -05:00
binwiederhier
46cb9f2b41 User header 2026-01-21 20:14:45 -05:00
13 changed files with 281 additions and 41 deletions

View File

@@ -37,6 +37,7 @@ ADD go.mod go.sum main.go ./
ADD ./client ./client ADD ./client ./client
ADD ./cmd ./cmd ADD ./cmd ./cmd
ADD ./log ./log ADD ./log ./log
ADD ./payments ./payments
ADD ./server ./server ADD ./server ./server
ADD ./user ./user ADD ./user ./user
ADD ./util ./util ADD ./util ./util

View File

@@ -95,6 +95,8 @@ var flagsServe = append(
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}),
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-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: "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-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: "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)"}), 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)"}),
@@ -206,6 +208,8 @@ func execServe(c *cli.Context) error {
behindProxy := c.Bool("behind-proxy") behindProxy := c.Bool("behind-proxy")
proxyForwardedHeader := c.String("proxy-forwarded-header") proxyForwardedHeader := c.String("proxy-forwarded-header")
proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",") 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") stripeSecretKey := c.String("stripe-secret-key")
stripeWebhookKey := c.String("stripe-webhook-key") stripeWebhookKey := c.String("stripe-webhook-key")
billingContact := c.String("billing-contact") billingContact := c.String("billing-contact")
@@ -313,7 +317,8 @@ func execServe(c *cli.Context) error {
} else if u.Path != "" { } 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) 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://") return errors.New("if set, upstream-base-url must start with http:// or https://")
} else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") { } else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") {
return errors.New("if set, upstream-base-url must not end with a slash (/)") return errors.New("if set, upstream-base-url must not end with a slash (/)")
@@ -338,12 +343,21 @@ func execServe(c *cli.Context) error {
if messageSizeLimit > 5*1024*1024 { if messageSizeLimit > 5*1024*1024 {
return errors.New("message-size-limit cannot be higher than 5M") 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)") return errors.New("cannot enable WebPush, support is not available in this build (nowebpush)")
} else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration { } else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration {
return errors.New("web push expiry warning duration cannot be higher than web push expiry duration") return errors.New("web push expiry warning duration cannot be higher than web push expiry duration")
} else if behindProxy && proxyForwardedHeader == "" { } else if behindProxy && proxyForwardedHeader == "" {
return errors.New("if behind-proxy is set, proxy-forwarded-header must also be set") return errors.New("if behind-proxy is set, proxy-forwarded-header must also be set")
} else if authUserHeader != "" && !behindProxy {
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 { } else if visitorPrefixBitsIPv4 < 1 || visitorPrefixBitsIPv4 > 32 {
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32") return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 { } else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
@@ -412,6 +426,15 @@ func execServe(c *cli.Context) error {
payments.Setup(stripeSecretKey) 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 // Add default forbidden topics
disallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...) disallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...)
@@ -437,6 +460,8 @@ func execServe(c *cli.Context) error {
conf.AuthUsers = authUsers conf.AuthUsers = authUsers
conf.AuthAccess = authAccess conf.AuthAccess = authAccess
conf.AuthTokens = authTokens conf.AuthTokens = authTokens
conf.AuthUserHeader = authUserHeader
conf.AuthLogoutURL = authLogoutURL
conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentCacheDir = attachmentCacheDir
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
@@ -459,13 +484,7 @@ func execServe(c *cli.Context) error {
conf.TwilioAuthToken = twilioAuthToken conf.TwilioAuthToken = twilioAuthToken
conf.TwilioPhoneNumber = twilioPhoneNumber conf.TwilioPhoneNumber = twilioPhoneNumber
conf.TwilioVerifyService = twilioVerifyService conf.TwilioVerifyService = twilioVerifyService
if twilioCallFormat != "" { conf.TwilioCallFormat = twilioCallFormatTemplate
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.MessageSizeLimit = int(messageSizeLimit) conf.MessageSizeLimit = int(messageSizeLimit)
conf.MessageDelayMax = messageDelayLimit conf.MessageDelayMax = messageDelayLimit
conf.TotalTopicLimit = totalTopicLimit conf.TotalTopicLimit = totalTopicLimit

View File

@@ -166,6 +166,8 @@ type Config struct {
BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported) BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported)
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) 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 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 StripeSecretKey string
StripeWebhookKey string StripeWebhookKey string
StripePriceCacheDuration time.Duration StripePriceCacheDuration time.Duration
@@ -263,6 +265,8 @@ func NewConfig() *Config {
VisitorPrefixBitsIPv6: DefaultVisitorPrefixBitsIPv6, // Default: use /64 for IPv6 VisitorPrefixBitsIPv6: DefaultVisitorPrefixBitsIPv6, // Default: use /64 for IPv6
BehindProxy: false, // If true, the server will trust the proxy client IP header to determine the client IP address 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 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: "", StripeSecretKey: "",
StripeWebhookKey: "", StripeWebhookKey: "",
StripePriceCacheDuration: DefaultStripePriceCacheDuration, StripePriceCacheDuration: DefaultStripePriceCacheDuration,

View File

@@ -620,6 +620,10 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
} }
func (s *Server) configResponse() *apiConfigResponse { func (s *Server) configResponse() *apiConfigResponse {
authMode := ""
if s.config.AuthUserHeader != "" {
authMode = "proxy"
}
return &apiConfigResponse{ return &apiConfigResponse{
BaseURL: "", // Will translate to window.location.origin BaseURL: "", // Will translate to window.location.origin
AppRoot: s.config.WebRoot, AppRoot: s.config.WebRoot,
@@ -635,6 +639,8 @@ func (s *Server) configResponse() *apiConfigResponse {
WebPushPublicKey: s.config.WebPushPublicKey, WebPushPublicKey: s.config.WebPushPublicKey,
DisallowedTopics: s.config.DisallowedTopics, DisallowedTopics: s.config.DisallowedTopics,
ConfigHash: s.config.Hash(), ConfigHash: s.config.Hash(),
AuthMode: authMode,
AuthLogoutURL: s.config.AuthLogoutURL,
} }
} }
@@ -667,6 +673,11 @@ func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request, _ *visito
// handleStatic returns all static resources (excluding the docs), including the web app // handleStatic returns all static resources (excluding the docs), including the web app
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error { func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
r.URL.Path = webSiteDir + r.URL.Path r.URL.Path = webSiteDir + r.URL.Path
// Prevent caching of HTML files to ensure auth proxies can intercept unauthenticated requests.
// Static hashed assets (JS, CSS, images) can still be cached normally.
if strings.HasSuffix(r.URL.Path, ".html") {
w.Header().Set("Cache-Control", "no-store")
}
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r) util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
return nil return nil
} }
@@ -2140,6 +2151,24 @@ func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
if s.userManager == nil { if s.userManager == nil {
return vip, nil return vip, nil
} }
// Check for proxy-forwarded user header (requires behind-proxy and auth-user-header to be set)
if s.config.BehindProxy && s.config.AuthUserHeader != "" {
if username := strings.TrimSpace(r.Header.Get(s.config.AuthUserHeader)); username != "" {
u, err := s.userManager.User(username)
if err != nil {
logr(r).Err(err).Debug("User from auth-user-header not found")
return vip, errHTTPUnauthorized
}
if u.Deleted {
logr(r).Debug("User from auth-user-header is deleted")
return vip, errHTTPUnauthorized
}
logr(r).Debug("User from header found")
return s.visitor(ip, u), nil
}
// If auth-user-header is set, but no user was provided, return unauthorized
return vip, errHTTPUnauthorized
}
header, err := readAuthHeader(r) header, err := readAuthHeader(r)
if err != nil { if err != nil {
return vip, err return vip, err

View File

@@ -124,6 +124,27 @@
# proxy-forwarded-header: "X-Forwarded-For" # proxy-forwarded-header: "X-Forwarded-For"
# proxy-trusted-hosts: # proxy-trusted-hosts:
# If set (along with behind-proxy and auth-file), trust this header to contain the authenticated
# username. This is useful when running ntfy behind an authentication proxy like Authelia,
# Authentik, or Caddy Security that handles authentication and forwards the user identity.
#
# Common header names:
# - X-Forwarded-User (Authelia default)
# - Remote-User (common convention)
# - X-Remote-User
#
# IMPORTANT: Only enable this if you trust the proxy to authenticate users. The header value
# is trusted unconditionally when behind-proxy is also set. Users must be pre-provisioned in
# the ntfy database (via auth-file); they are not auto-created.
#
# 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 # If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments
# are "attachment-cache-dir" and "base-url". # are "attachment-cache-dir" and "base-url".
# #

View File

@@ -2398,6 +2398,102 @@ func TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) {
require.Equal(t, "2001:db8:3333::1", v.ip.String()) require.Equal(t, "2001:db8:3333::1", v.ip.String())
} }
func TestServer_AuthUserHeader_Success(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.BehindProxy = true
c.AuthUserHeader = "X-Forwarded-User"
s := newTestServer(t, c)
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil)
r.RemoteAddr = "1.2.3.4:1234"
r.Header.Set("X-Forwarded-User", "phil")
v, err := s.maybeAuthenticate(r)
require.Nil(t, err)
require.NotNil(t, v.User())
require.Equal(t, "phil", v.User().Name)
}
func TestServer_AuthUserHeader_UserNotFound(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.BehindProxy = true
c.AuthUserHeader = "X-Forwarded-User"
s := newTestServer(t, c)
r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil)
r.RemoteAddr = "1.2.3.4:1234"
r.Header.Set("X-Forwarded-User", "unknown-user")
_, err := s.maybeAuthenticate(r)
require.Equal(t, errHTTPUnauthorized, err)
}
func TestServer_AuthUserHeader_NoHeader_ReturnsUnauthorized(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.BehindProxy = true
c.AuthUserHeader = "X-Forwarded-User"
s := newTestServer(t, c)
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
// 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"))
_, err := s.maybeAuthenticate(r)
require.Equal(t, errHTTPUnauthorized, err)
}
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 -> 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"
_, err := s.maybeAuthenticate(r)
require.Equal(t, errHTTPUnauthorized, err)
}
func TestServer_AuthUserHeader_NotBehindProxy(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.BehindProxy = false // Auth user header should be ignored if not behind proxy
c.AuthUserHeader = "X-Forwarded-User"
s := newTestServer(t, c)
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
// Header is present but should be ignored since behind-proxy is false
r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil)
r.RemoteAddr = "1.2.3.4:1234"
r.Header.Set("X-Forwarded-User", "phil")
v, err := s.maybeAuthenticate(r)
require.Nil(t, err)
require.Nil(t, v.User()) // Should be anonymous since header is ignored
}
func TestServer_AuthUserHeader_RemoteUser(t *testing.T) {
c := newTestConfigWithAuthFile(t)
c.BehindProxy = true
c.AuthUserHeader = "Remote-User" // Common alternative header name
s := newTestServer(t, c)
require.Nil(t, s.userManager.AddUser("admin", "admin", user.RoleAdmin, false))
r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil)
r.RemoteAddr = "1.2.3.4:1234"
r.Header.Set("Remote-User", "admin")
v, err := s.maybeAuthenticate(r)
require.Nil(t, err)
require.NotNil(t, v.User())
require.Equal(t, "admin", v.User().Name)
require.True(t, v.User().IsAdmin())
}
func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) { func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
t.Parallel() t.Parallel()
count := 50000 count := 50000

View File

@@ -483,6 +483,8 @@ type apiConfigResponse struct {
WebPushPublicKey string `json:"web_push_public_key"` WebPushPublicKey string `json:"web_push_public_key"`
DisallowedTopics []string `json:"disallowed_topics"` DisallowedTopics []string `json:"disallowed_topics"`
ConfigHash string `json:"config_hash"` 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)
} }
type apiAccountBillingPrices struct { type apiAccountBillingPrices struct {

View File

@@ -20,4 +20,6 @@ var config = {
web_push_public_key: "", web_push_public_key: "",
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"], disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"],
config_hash: "dev", // Placeholder for development; actual value is generated server-side 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)
}; };

View File

@@ -1,7 +1,9 @@
/* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable import/no-extraneous-dependencies */
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from "workbox-precaching"; import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
import { NavigationRoute, registerRoute } from "workbox-routing"; import { registerRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies"; import { NetworkFirst, StaleWhileRevalidate } from "workbox-strategies";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
import { ExpirationPlugin } from "workbox-expiration";
import { clientsClaim } from "workbox-core"; import { clientsClaim } from "workbox-core";
import { dbAsync } from "../src/app/db"; import { dbAsync } from "../src/app/db";
import { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from "../src/app/notificationUtils"; import { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from "../src/app/notificationUtils";
@@ -337,27 +339,42 @@ clientsClaim();
cleanupOutdatedCaches(); cleanupOutdatedCaches();
if (!import.meta.env.DEV) { if (!import.meta.env.DEV) {
// we need the app_root setting, so we import the config.js file from the go server // Use NetworkFirst for navigation requests. This ensures that auth proxies (like Authelia)
// this does NOT include the same base_url as the web app running in a window, // can intercept unauthenticated requests, while still providing offline fallback.
// since we don't have access to `window` like in `src/app/config.js` // The 3-second timeout means if the network is slow/unavailable, cached HTML is served.
self.importScripts("/config.js");
// this is the fallback single-page-app route, matching vite.config.js PWA config,
// and is served by the go web server. It is needed for the single-page-app to work.
// https://developer.chrome.com/docs/workbox/modules/workbox-routing/#how-to-register-a-navigation-route
registerRoute( registerRoute(
new NavigationRoute(createHandlerBoundToURL("/app.html"), { ({ request }) => request.mode === "navigate",
allowlist: [ new NetworkFirst({
// the app root itself, could be /, or not cacheName: "html-cache",
new RegExp(`^${config.app_root}$`), networkTimeoutSeconds: 3,
plugins: [new CacheableResponsePlugin({ statuses: [200] }), new ExpirationPlugin({ maxEntries: 10, maxAgeSeconds: 60 })],
})
);
// Cache static assets (JS, CSS, images, fonts) with StaleWhileRevalidate for better performance.
// Serves cached version immediately while fetching fresh version in the background.
registerRoute(
({ request }) =>
request.destination === "script" ||
request.destination === "style" ||
request.destination === "image" ||
request.destination === "font",
new StaleWhileRevalidate({
cacheName: "assets-cache",
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 }),
], ],
}) })
); );
// the manifest excludes config.js (see vite.config.js) since the dist-file differs from the // Handle config.js with NetworkFirst. The manifest excludes it (see vite.config.js) since
// actual config served by the go server. this adds it back with `NetworkFirst`, so that the // the dist-file differs from the actual config served by the go server.
// most recent config from the go server is cached, but the app still works if the network registerRoute(
// is unavailable. this is important since there's no "refresh" button in the installed pwa ({ url }) => url.pathname === "/config.js",
// to force a reload. new NetworkFirst({
registerRoute(({ url }) => url.pathname === "/config.js", new NetworkFirst()); cacheName: "config-cache",
plugins: [new CacheableResponsePlugin({ statuses: [200] })],
})
);
} }

View File

@@ -16,6 +16,7 @@ import {
withBasicAuth, withBasicAuth,
withBearerAuth, withBearerAuth,
} from "./utils"; } from "./utils";
import config from "./config";
import session from "./Session"; import session from "./Session";
import subscriptionManager from "./SubscriptionManager"; import subscriptionManager from "./SubscriptionManager";
import prefs from "./Prefs"; import prefs from "./Prefs";
@@ -341,7 +342,18 @@ class AccountApi {
async sync() { async sync() {
try { try {
if (!session.token()) { // For proxy auth, detect user from /v1/account if no session exists
if (config.auth_mode === AuthMode.PROXY && !session.exists()) {
console.log(`[AccountApi] Proxy auth mode, detecting user from /v1/account`);
const account = await this.get();
// Never store "*" (anonymous) as username
if (account.username && account.username !== "*") {
console.log(`[AccountApi] Proxy auth: storing session for ${account.username}`);
await session.store(account.username, ""); // Empty token for proxy auth
}
return account;
}
if (!session.exists()) {
return null; return null;
} }
console.log(`[AccountApi] Syncing account`); console.log(`[AccountApi] Syncing account`);
@@ -367,6 +379,11 @@ class AccountApi {
} catch (e) { } catch (e) {
console.log(`[AccountApi] Error fetching account`, e); console.log(`[AccountApi] Error fetching account`, e);
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
// For proxy auth, hard refresh to get fresh auth from proxy
if (config.auth_mode === AuthMode.PROXY) {
window.location.reload();
return undefined;
}
await session.resetAndRedirect(routes.login); await session.resetAndRedirect(routes.login);
} }
return undefined; return undefined;
@@ -431,5 +448,10 @@ export const Permission = {
DENY_ALL: "deny-all", DENY_ALL: "deny-all",
}; };
// Maps to apiConfigResponse.AuthMode in server/types.go
export const AuthMode = {
PROXY: "proxy",
};
const accountApi = new AccountApi(); const accountApi = new AccountApi();
export default accountApi; export default accountApi;

View File

@@ -3,6 +3,10 @@ import Dexie from "dexie";
/** /**
* Manages the logged-in user's session and access token. * 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. * 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 { class Session {
constructor() { constructor() {
@@ -53,7 +57,7 @@ class Session {
} }
exists() { exists() {
return this.username() && this.token(); return !!this.username();
} }
username() { username() {

View File

@@ -16,7 +16,7 @@ import routes from "./routes";
import db from "../app/db"; import db from "../app/db";
import { topicDisplayName } from "../app/utils"; import { topicDisplayName } from "../app/utils";
import Navigation from "./Navigation"; import Navigation from "./Navigation";
import accountApi from "../app/AccountApi"; import accountApi, { AuthMode } from "../app/AccountApi";
import PopupMenu from "./PopupMenu"; import PopupMenu from "./PopupMenu";
import { SubscriptionPopup } from "./SubscriptionPopup"; import { SubscriptionPopup } from "./SubscriptionPopup";
import { useIsLaunchedPWA } from "./hooks"; import { useIsLaunchedPWA } from "./hooks";
@@ -139,6 +139,17 @@ const ProfileIcon = () => {
}; };
const handleLogout = async () => { const handleLogout = async () => {
// For proxy auth, redirect to the logout URL if configured
if (config.auth_mode === AuthMode.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 { try {
await accountApi.logout(); await accountApi.logout();
await db().delete(); await db().delete();
@@ -147,6 +158,9 @@ const ProfileIcon = () => {
} }
}; };
// Determine if logout button should be shown (hide if proxy auth without logout URL)
const showLogout = config.auth_mode !== AuthMode.PROXY || config.auth_logout_url;
return ( return (
<> <>
{session.exists() && ( {session.exists() && (
@@ -178,12 +192,14 @@ const ProfileIcon = () => {
</ListItemIcon> </ListItemIcon>
{t("action_bar_profile_settings")} {t("action_bar_profile_settings")}
</MenuItem> </MenuItem>
<MenuItem onClick={handleLogout}> {showLogout && (
<ListItemIcon> <MenuItem onClick={handleLogout}>
<Logout fontSize="small" /> <ListItemIcon>
</ListItemIcon> <Logout fontSize="small" />
{t("action_bar_profile_logout")} </ListItemIcon>
</MenuItem> {t("action_bar_profile_logout")}
</MenuItem>
)}
</PopupMenu> </PopupMenu>
</> </>
); );

View File

@@ -1,11 +1,11 @@
import * as React from "react"; 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 { Typography, TextField, Button, Box, IconButton, InputAdornment } from "@mui/material";
import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Visibility, VisibilityOff } from "@mui/icons-material"; import { Visibility, VisibilityOff } from "@mui/icons-material";
import accountApi from "../app/AccountApi"; import accountApi, { AuthMode } from "../app/AccountApi";
import AvatarBox from "./AvatarBox"; import AvatarBox from "./AvatarBox";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes"; import routes from "./routes";
@@ -18,6 +18,13 @@ const Login = () => {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
// Redirect to app if using proxy authentication
useEffect(() => {
if (config.auth_mode === AuthMode.PROXY) {
window.location.href = routes.app;
}
}, []);
const handleSubmit = async (event) => { const handleSubmit = async (event) => {
event.preventDefault(); event.preventDefault();
const user = { username, password }; const user = { username, password };