Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ AUTHULA_BASE_URL=
AUTHULA_SECRET=
# postgresql: "postgresql://username:password@localhost:5432/authula?sslmode=disable"
AUTHULA_DATABASE_URL=
# Cookie Domain attribute shared by all auth cookies (e.g. example.com). Leave empty for host-only cookies.
AUTHULA_COOKIE_DOMAIN=

# Email template overrides (optional) - Override default email templates via environment variables
# Each email type has three templates: SUBJECT, TEXT (plain text), HTML
Expand Down
53 changes: 53 additions & 0 deletions config/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package config

import (
"fmt"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"

"github.com/Authula/authula/env"
Expand Down Expand Up @@ -91,6 +94,16 @@ func NewConfig(options ...ConfigOption) *models.Config {
panic(fmt.Errorf("BasePath must start with '/', got: %q", config.BasePath))
}

// Validate cookie domain.
if envValue := os.Getenv(env.EnvCookieDomain); envValue != "" {
config.Session.Domain = envValue
}
domain, err := normalizeCookieDomain(config.Session.Domain, config.BaseURL)
if err != nil {
panic(fmt.Errorf("invalid session cookie domain: %w", err))
}
config.Session.Domain = domain

// Validate event bus configuration
if err := validateEventBusConfig(&config.EventBus); err != nil {
panic(fmt.Errorf("invalid event bus configuration: %w", err))
Expand Down Expand Up @@ -178,6 +191,11 @@ func WithSession(config models.SessionConfig) ConfigOption {
if config.CookieName != "" {
c.Session.CookieName = config.CookieName
}
if envValue := os.Getenv(env.EnvCookieDomain); envValue != "" {
c.Session.Domain = envValue
} else if config.Domain != "" {
c.Session.Domain = config.Domain
}
if config.ExpiresIn != 0 {
c.Session.ExpiresIn = config.ExpiresIn
}
Expand Down Expand Up @@ -308,6 +326,41 @@ func WithCoreServiceHooks(config *models.CoreServiceHooksConfig) ConfigOption {
}
}

// normalizeCookieDomain strips a leading dot from the cookie domain and checks that a browser
// would accept it for cookies set by baseURL: the domain must be a bare hostname (no scheme,
// port or path) and the BaseURL host must be that domain or one of its subdomains. Go's
// net/http silently drops an invalid Domain attribute, so this fails at startup instead.
func normalizeCookieDomain(domain, baseURL string) (string, error) {
if domain == "" {
return "", nil
}

domain = strings.ToLower(strings.TrimPrefix(domain, "."))
if domain == "" {
return "", fmt.Errorf("domain must not be empty after trimming the leading dot")
}
if strings.ContainsAny(domain, ":/?#@ \t") {
return "", fmt.Errorf("domain %q must be a bare hostname without scheme, port or path", domain)
}

// Browsers reject cookies with explicit Domain attributes set to IP addresses.
// For IP-based local dev (e.g. 127.0.0.1), domain must remain empty (host-only).
if ip := net.ParseIP(domain); ip != nil {
return "", fmt.Errorf("domain %q cannot be an IP address; browsers require IP cookies to be host-only (leave domain empty)", domain)
}

parsedBaseURL, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("could not parse BaseURL %q: %w", baseURL, err)
}
host := strings.ToLower(parsedBaseURL.Hostname())
if host != domain && !strings.HasSuffix(host, "."+domain) {
return "", fmt.Errorf("BaseURL host %q is not %q or one of its subdomains; browsers would reject the cookie", host, domain)
}

return domain, nil
}

// validateEventBusConfig validates that the event bus provider has the correct configuration
func validateEventBusConfig(config *models.EventBusConfig) error {
provider := config.Provider
Expand Down
179 changes: 179 additions & 0 deletions config/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"testing"
"time"

"github.com/Authula/authula/env"
"github.com/Authula/authula/models"
)

Expand Down Expand Up @@ -68,3 +69,181 @@ func TestWithSessionCookieMaxAge(t *testing.T) {
})
}
}

func TestWithSessionCookieDomain(t *testing.T) {
tests := []struct {
name string
baseURL string
domain string
wantDomain string
wantPanic bool
}{
{
name: "empty domain keeps cookies host-only",
baseURL: "http://localhost:8080",
domain: "",
wantDomain: "",
},
{
name: "parent domain of the base url host is accepted",
baseURL: "https://api.example.com",
domain: "example.com",
wantDomain: "example.com",
},
{
name: "domain equal to the base url host is accepted",
baseURL: "https://example.com",
domain: "example.com",
wantDomain: "example.com",
},
{
name: "leading dot is stripped",
baseURL: "https://api.example.com",
domain: ".example.com",
wantDomain: "example.com",
},
{
name: "domain is lower-cased",
baseURL: "https://api.example.com:8443",
domain: "Example.COM",
wantDomain: "example.com",
},
{
name: "base url host outside the domain panics",
baseURL: "http://localhost:8080",
domain: "example.com",
wantPanic: true,
},
{
name: "look-alike suffix without a dot boundary panics",
baseURL: "https://notexample.com",
domain: "example.com",
wantPanic: true,
},
{
name: "domain with a scheme panics",
baseURL: "https://api.example.com",
domain: "https://example.com",
wantPanic: true,
},
{
name: "domain with a port panics",
baseURL: "https://api.example.com",
domain: "example.com:8080",
wantPanic: true,
},
{
name: "bare dot panics",
baseURL: "https://api.example.com",
domain: ".",
wantPanic: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

defer func() {
recovered := recover()
if tt.wantPanic && recovered == nil {
t.Fatal("expected NewConfig to panic")
}
if !tt.wantPanic && recovered != nil {
t.Fatalf("expected NewConfig not to panic, got %v", recovered)
}
}()

config := NewConfig(
WithBaseURL(tt.baseURL),
WithSession(models.SessionConfig{Domain: tt.domain}),
)

if config.Session.Domain != tt.wantDomain {
t.Fatalf("expected cookie domain %q, got %q", tt.wantDomain, config.Session.Domain)
}
})
}
}

// Uses t.Setenv, so it must not run in parallel with the other config tests.
func TestWithSessionCookieDomainFromEnv(t *testing.T) {
tests := []struct {
name string
envDomain string
configDomain string
baseURL string
wantDomain string
wantPanic bool
}{
{
name: "env var sets the domain when config leaves it empty",
envDomain: "example.com",
baseURL: "https://api.example.com",
wantDomain: "example.com",
},
{
name: "env var overrides the domain from config",
envDomain: "example.com",
configDomain: "other.com",
baseURL: "https://api.example.com",
wantDomain: "example.com",
},
{
name: "config domain is used when env var is unset",
envDomain: "",
configDomain: "example.com",
baseURL: "https://api.example.com",
wantDomain: "example.com",
},
{
name: "env var is normalized like config",
envDomain: ".Example.COM",
baseURL: "https://api.example.com",
wantDomain: "example.com",
},
{
name: "env var is validated against base url",
envDomain: "example.com",
baseURL: "http://localhost:8080",
wantPanic: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(env.EnvCookieDomain, tt.envDomain)

defer func() {
recovered := recover()
if tt.wantPanic && recovered == nil {
t.Fatal("expected NewConfig to panic")
}
if !tt.wantPanic && recovered != nil {
t.Fatalf("expected NewConfig not to panic, got %v", recovered)
}
}()

config := NewConfig(
WithBaseURL(tt.baseURL),
WithSession(models.SessionConfig{Domain: tt.configDomain}),
)

if config.Session.Domain != tt.wantDomain {
t.Fatalf("expected cookie domain %q, got %q", tt.wantDomain, config.Session.Domain)
}
})
}
}

// Standalone mode builds the config without WithSession, so the env var must still apply.
func TestNewConfigCookieDomainFromEnvWithoutSessionOption(t *testing.T) {
t.Setenv(env.EnvCookieDomain, "example.com")
t.Setenv(env.EnvBaseURL, "https://api.example.com")

config := NewConfig()

if config.Session.Domain != "example.com" {
t.Fatalf("expected cookie domain %q, got %q", "example.com", config.Session.Domain)
}
}
1 change: 1 addition & 0 deletions env/env_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const (
EnvBaseURL = "AUTHULA_BASE_URL"
EnvSecret = "AUTHULA_SECRET"
EnvDatabaseURL = "AUTHULA_DATABASE_URL"
EnvCookieDomain = "AUTHULA_COOKIE_DOMAIN"

// ENVIRONMENT

Expand Down
7 changes: 6 additions & 1 deletion models/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@ type LoggerConfig struct {
}

type SessionConfig struct {
CookieName string `json:"cookie_name" toml:"cookie_name"`
CookieName string `json:"cookie_name" toml:"cookie_name"`
// Domain is the Domain attribute applied to every auth cookie Authula issues (session, CSRF,
// TOTP, impersonation). Empty means host-only: the cookie is bound to the exact host that set
// it. Set it to the parent domain (e.g. "example.com") to share cookies between subdomains such
// as api.example.com and app.example.com. BaseURL must be that domain or one of its subdomains.
Domain string `json:"domain" toml:"domain"`
ExpiresIn time.Duration `json:"expires_in" toml:"expires_in"` // Sliding window per activity
UpdateAge time.Duration `json:"update_age" toml:"update_age"` // How often to check/update
CookieMaxAge time.Duration `json:"cookie_max_age" toml:"cookie_max_age"` // Absolute max age of the cookie
Expand Down
20 changes: 5 additions & 15 deletions plugins/admin/handlers/impersonation_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,14 @@ func (h *StartImpersonationHandler) Handler() http.HandlerFunc {
}

sessionConfig := h.globalConfig.Session
sameSite := sameSiteFromSessionConfig(&sessionConfig)
sameSite := util.ParseSameSite(sessionConfig.SameSite)

if result.OriginalCookieToken != "" {
http.SetCookie(w, &http.Cookie{
Name: sessionConfig.CookieName + constants.OriginalSessionCookieSuffix,
Value: result.OriginalCookieToken,
Path: "/",
Domain: sessionConfig.Domain,
HttpOnly: sessionConfig.HttpOnly,
Secure: sessionConfig.Secure,
SameSite: sameSite,
Expand Down Expand Up @@ -217,12 +218,13 @@ func (h *StopImpersonationHandler) Handler() http.HandlerFunc {
}

sessionConfig := h.globalConfig.Session
sameSite := sameSiteFromSessionConfig(&sessionConfig)
sameSite := util.ParseSameSite(sessionConfig.SameSite)

http.SetCookie(w, &http.Cookie{
Name: sessionConfig.CookieName,
Value: result.OriginalSessionToken,
Path: "/",
Domain: sessionConfig.Domain,
HttpOnly: sessionConfig.HttpOnly,
Secure: sessionConfig.Secure,
SameSite: sameSite,
Expand All @@ -233,6 +235,7 @@ func (h *StopImpersonationHandler) Handler() http.HandlerFunc {
Name: sessionConfig.CookieName + constants.OriginalSessionCookieSuffix,
Value: "",
Path: "/",
Domain: sessionConfig.Domain,
HttpOnly: sessionConfig.HttpOnly,
Secure: sessionConfig.Secure,
SameSite: sameSite,
Expand Down Expand Up @@ -270,16 +273,3 @@ func respondImpersonationError(reqCtx *models.RequestContext, err error) {
func mapImpersonationErrorStatus(err error) int {
return mapAdminHttpErrorStatus(err)
}

func sameSiteFromSessionConfig(sessionConfig *models.SessionConfig) http.SameSite {
switch sessionConfig.SameSite {
case "strict":
return http.SameSiteStrictMode
case "none":
return http.SameSiteNoneMode
case "lax":
return http.SameSiteLaxMode
default:
return http.SameSiteLaxMode
}
}
Loading