Files
pulse/internal/api/auth_helpers_test.go
T
rcourtman d890904ce6 Stop auto-escalating SameSite to None on proxied requests
getCookieSettings auto-set SameSite=None on the session and CSRF
cookies whenever a request arrived via a trusted proxy with
X-Forwarded-Proto: https. The original intent was "be more permissive
for proxied deployments," but in practice that disabled the
browser-side CSRF defence for every Pulse deployment behind a reverse
proxy — which is nearly all of them.

SameSite=None tells the browser to attach the cookie on arbitrary
cross-site requests. That is only required for cross-origin iframe
embedding scenarios Pulse does not document or support. The
top-level-navigation cases that actually matter (OIDC/SAML callback
landing, Cloudflare-tunnel access from a bookmark) all work under Lax,
which still attaches cookies on top-level navigations and only blocks
cross-site sub-resource requests.

Always return SameSiteLaxMode. The Secure flag still tracks the
actual connection state via isConnectionSecure(r). Together with the
earlier CSRF bypass fix (require token regardless of Authorization
header), the browser-side and server-side defences are now both in
place.

Test expectations updated: three cases that previously asserted
SameSiteNoneMode for the proxied-HTTPS / Cloudflare-tunnel / direct-
TLS-with-Forwarded paths now assert SameSiteLaxMode, with comments
flagging them as regression coverage.
2026-05-21 16:11:30 +01:00

1055 lines
29 KiB
Go

package api
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestDetectProxy(t *testing.T) {
// Set up trusted proxy CIDRs so that 127.0.0.1 (default httptest RemoteAddr) is trusted.
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "127.0.0.1/32,192.0.2.1/32")
ResetTrustedProxyConfigForTests()
tests := []struct {
name string
headers map[string]string
want bool
}{
{
name: "no proxy headers",
headers: map[string]string{},
want: false,
},
{
name: "X-Forwarded-For present",
headers: map[string]string{"X-Forwarded-For": "192.168.1.1"},
want: true,
},
{
name: "X-Real-IP present",
headers: map[string]string{"X-Real-IP": "10.0.0.1"},
want: true,
},
{
name: "X-Forwarded-Proto present",
headers: map[string]string{"X-Forwarded-Proto": "https"},
want: true,
},
{
name: "X-Forwarded-Host present",
headers: map[string]string{"X-Forwarded-Host": "example.com"},
want: true,
},
{
name: "Forwarded header present (RFC 7239)",
headers: map[string]string{"Forwarded": "for=192.168.1.1;proto=https"},
want: true,
},
{
name: "Cloudflare CF-Ray present",
headers: map[string]string{"CF-Ray": "abc123"},
want: true,
},
{
name: "Cloudflare CF-Connecting-IP present",
headers: map[string]string{"CF-Connecting-IP": "1.2.3.4"},
want: true,
},
{
name: "X-Forwarded-Server present",
headers: map[string]string{"X-Forwarded-Server": "proxy.example.com"},
want: true,
},
{
name: "X-Forwarded-Port present",
headers: map[string]string{"X-Forwarded-Port": "443"},
want: true,
},
{
name: "multiple proxy headers",
headers: map[string]string{
"X-Forwarded-For": "192.168.1.1",
"X-Forwarded-Proto": "https",
"CF-Ray": "abc123",
},
want: true,
},
{
name: "unrelated headers only",
headers: map[string]string{"Content-Type": "application/json", "User-Agent": "test"},
want: false,
},
{
name: "empty X-Forwarded-For",
headers: map[string]string{"X-Forwarded-For": ""},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
for key, value := range tt.headers {
req.Header.Set(key, value)
}
got := detectProxy(req)
if got != tt.want {
t.Errorf("detectProxy() = %v, want %v", got, tt.want)
}
})
}
// Verify untrusted peer cannot inject proxy headers
t.Run("untrusted peer with proxy headers", func(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "10.0.0.0/8")
ResetTrustedProxyConfigForTests()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "203.0.113.1:12345" // not in trusted CIDRs
req.Header.Set("X-Forwarded-For", "192.168.1.1")
if detectProxy(req) {
t.Error("detectProxy() should return false for untrusted peer")
}
})
}
func TestIsConnectionSecure(t *testing.T) {
// Trust the default httptest RemoteAddr (192.0.2.1) for proxy header tests
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "192.0.2.1/32")
ResetTrustedProxyConfigForTests()
tests := []struct {
name string
useTLS bool
headers map[string]string
want bool
}{
{
name: "plain HTTP no headers",
useTLS: false,
headers: map[string]string{},
want: false,
},
{
name: "TLS connection",
useTLS: true,
headers: map[string]string{},
want: true,
},
{
name: "X-Forwarded-Proto https (trusted proxy)",
useTLS: false,
headers: map[string]string{"X-Forwarded-Proto": "https"},
want: true,
},
{
name: "X-Forwarded-Proto http",
useTLS: false,
headers: map[string]string{"X-Forwarded-Proto": "http"},
want: false,
},
{
name: "X-Forwarded-Proto HTTPS uppercase",
useTLS: false,
headers: map[string]string{"X-Forwarded-Proto": "HTTPS"},
want: false, // strict comparison
},
{
name: "Forwarded header with proto=https (trusted proxy)",
useTLS: false,
headers: map[string]string{"Forwarded": "for=192.168.1.1;proto=https"},
want: true,
},
{
name: "Forwarded header with proto=http",
useTLS: false,
headers: map[string]string{"Forwarded": "for=192.168.1.1;proto=http"},
want: false,
},
{
name: "TLS with X-Forwarded-Proto http (TLS takes precedence)",
useTLS: true,
headers: map[string]string{"X-Forwarded-Proto": "http"},
want: true,
},
{
name: "both X-Forwarded-Proto and Forwarded present",
useTLS: false,
headers: map[string]string{
"X-Forwarded-Proto": "https",
"Forwarded": "proto=http",
},
want: true, // X-Forwarded-Proto is checked first
},
{
name: "empty X-Forwarded-Proto",
useTLS: false,
headers: map[string]string{"X-Forwarded-Proto": ""},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
for key, value := range tt.headers {
req.Header.Set(key, value)
}
if tt.useTLS {
req.TLS = &tls.ConnectionState{}
}
got := isConnectionSecure(req)
if got != tt.want {
t.Errorf("isConnectionSecure() = %v, want %v", got, tt.want)
}
})
}
// Verify untrusted peer's X-Forwarded-Proto is ignored
t.Run("untrusted peer X-Forwarded-Proto ignored", func(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "10.0.0.0/8")
ResetTrustedProxyConfigForTests()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "203.0.113.1:12345"
req.Header.Set("X-Forwarded-Proto", "https")
if isConnectionSecure(req) {
t.Error("isConnectionSecure() should return false for untrusted peer")
}
})
}
func TestGetCookieSettings(t *testing.T) {
// Trust the default httptest RemoteAddr (192.0.2.1) for proxy tests
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "192.0.2.1/32")
ResetTrustedProxyConfigForTests()
tests := []struct {
name string
useTLS bool
headers map[string]string
wantSecure bool
wantSameSite http.SameSite
}{
{
name: "plain HTTP no proxy",
useTLS: false,
headers: map[string]string{},
wantSecure: false,
wantSameSite: http.SameSiteLaxMode,
},
{
name: "HTTPS direct connection",
useTLS: true,
headers: map[string]string{},
wantSecure: true,
wantSameSite: http.SameSiteLaxMode,
},
{
// Regression: previously auto-escalated SameSite to None here,
// which disabled the browser-side CSRF defence for every
// proxied-HTTPS deployment. SameSite must stay Lax; Secure
// still tracks the proxied protocol.
name: "HTTPS through proxy",
useTLS: false,
headers: map[string]string{
"X-Forwarded-For": "192.168.1.1",
"X-Forwarded-Proto": "https",
},
wantSecure: true,
wantSameSite: http.SameSiteLaxMode,
},
{
name: "HTTP through proxy",
useTLS: false,
headers: map[string]string{
"X-Forwarded-For": "192.168.1.1",
"X-Forwarded-Proto": "http",
},
wantSecure: false,
wantSameSite: http.SameSiteLaxMode,
},
{
// Regression: same pattern as the HTTPS-through-proxy case via
// Cloudflare's CF-* headers — must NOT escalate to SameSite=None.
name: "Cloudflare tunnel HTTPS",
useTLS: false,
headers: map[string]string{
"CF-Ray": "abc123",
"CF-Connecting-IP": "1.2.3.4",
"X-Forwarded-Proto": "https",
},
wantSecure: true,
wantSameSite: http.SameSiteLaxMode,
},
{
name: "proxy detected but no proto header",
useTLS: false,
headers: map[string]string{
"X-Forwarded-For": "192.168.1.1",
},
wantSecure: false,
wantSameSite: http.SameSiteLaxMode,
},
{
// Regression: previously SameSite=None when TLS + trusted-proxy
// Forwarded header. Must stay Lax.
name: "direct TLS with Forwarded header (trusted proxy)",
useTLS: true,
headers: map[string]string{
"Forwarded": "for=192.168.1.1;proto=https",
},
wantSecure: true,
wantSameSite: http.SameSiteLaxMode,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
for key, value := range tt.headers {
req.Header.Set(key, value)
}
if tt.useTLS {
req.TLS = &tls.ConnectionState{}
}
gotSecure, gotSameSite := getCookieSettings(req)
if gotSecure != tt.wantSecure {
t.Errorf("getCookieSettings() secure = %v, want %v", gotSecure, tt.wantSecure)
}
if gotSameSite != tt.wantSameSite {
t.Errorf("getCookieSettings() sameSite = %v, want %v", gotSameSite, tt.wantSameSite)
}
})
}
}
func TestGenerateSessionToken(t *testing.T) {
// Test that tokens are generated
token := generateSessionToken()
if token == "" {
t.Error("generateSessionToken() returned empty string")
}
// Test token length (32 bytes = 64 hex characters)
if len(token) != 64 {
t.Errorf("generateSessionToken() length = %d, want 64", len(token))
}
// Test that tokens are unique
tokens := make(map[string]bool)
for i := 0; i < 100; i++ {
tok := generateSessionToken()
if tokens[tok] {
t.Errorf("generateSessionToken() generated duplicate token: %s", tok)
}
tokens[tok] = true
}
// Test that token is valid hex
for _, c := range token {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
t.Errorf("generateSessionToken() contains non-hex character: %c", c)
}
}
}
func TestValidateSession_NonExistentToken(t *testing.T) {
// Ensure session store is initialized
InitSessionStore(t.TempDir())
// A random token that doesn't exist should return false
result := ValidateSession("nonexistent-token-12345")
if result {
t.Error("ValidateSession should return false for non-existent token")
}
}
func TestValidateSession_EmptyToken(t *testing.T) {
InitSessionStore(t.TempDir())
result := ValidateSession("")
if result {
t.Error("ValidateSession should return false for empty token")
}
}
func TestValidateSession_ValidToken(t *testing.T) {
dir := t.TempDir()
InitSessionStore(dir)
// Create a valid session with a generated token
store := GetSessionStore()
token := generateSessionToken()
store.CreateSession(token, 24*time.Hour, "test-agent", "127.0.0.1", "testuser")
result := ValidateSession(token)
if !result {
t.Error("ValidateSession should return true for valid token")
}
}
func TestValidateSession_ExpiredToken(t *testing.T) {
dir := t.TempDir()
InitSessionStore(dir)
// Create a session and manually expire it
store := GetSessionStore()
token := generateSessionToken()
store.CreateSession(token, 24*time.Hour, "test-agent", "127.0.0.1", "testuser")
// Manually expire the session by modifying the store
store.mu.Lock()
hash := sessionHash(token)
if session, exists := store.sessions[hash]; exists {
session.ExpiresAt = time.Now().Add(-1 * time.Hour) // Set to past
store.sessions[hash] = session
}
store.mu.Unlock()
result := ValidateSession(token)
if result {
t.Error("ValidateSession should return false for expired token")
}
}
// CheckProxyAuth tests
func TestCheckProxyAuth_NotConfigured(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "", // Not configured
}
req := httptest.NewRequest("GET", "/", nil)
valid, username, isAdmin := CheckProxyAuth(cfg, req)
if valid {
t.Error("CheckProxyAuth should return false when not configured")
}
if username != "" {
t.Errorf("username should be empty, got %q", username)
}
if isAdmin {
t.Error("isAdmin should be false when not authenticated")
}
}
func TestCheckProxyAuth_InvalidSecret(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "correct-secret",
}
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Proxy-Secret", "wrong-secret")
valid, username, isAdmin := CheckProxyAuth(cfg, req)
if valid {
t.Error("CheckProxyAuth should return false for invalid secret")
}
if username != "" {
t.Errorf("username should be empty, got %q", username)
}
if isAdmin {
t.Error("isAdmin should be false when not authenticated")
}
}
func TestCheckProxyAuth_MissingSecret(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "correct-secret",
}
req := httptest.NewRequest("GET", "/", nil)
// No X-Proxy-Secret header
valid, username, isAdmin := CheckProxyAuth(cfg, req)
if valid {
t.Error("CheckProxyAuth should return false when secret header is missing")
}
if username != "" {
t.Errorf("username should be empty, got %q", username)
}
if isAdmin {
t.Error("isAdmin should be false when not authenticated")
}
}
func TestCheckProxyAuth_ValidSecretNoUserHeader(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "correct-secret",
// No ProxyAuthUserHeader configured
}
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Proxy-Secret", "correct-secret")
valid, username, isAdmin := CheckProxyAuth(cfg, req)
if !valid {
t.Error("CheckProxyAuth should return true for valid secret")
}
if username != "" {
t.Errorf("username should be empty when user header not configured, got %q", username)
}
if !isAdmin {
t.Error("isAdmin should be true by default when no role checking")
}
}
func TestCheckProxyAuth_MissingUserHeader(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "correct-secret",
ProxyAuthUserHeader: "X-Remote-User",
}
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Proxy-Secret", "correct-secret")
// Missing X-Remote-User header
valid, username, isAdmin := CheckProxyAuth(cfg, req)
if valid {
t.Error("CheckProxyAuth should return false when user header is missing")
}
if username != "" {
t.Errorf("username should be empty, got %q", username)
}
if isAdmin {
t.Error("isAdmin should be false when not authenticated")
}
}
func TestCheckProxyAuth_ValidWithUsername(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "correct-secret",
ProxyAuthUserHeader: "X-Remote-User",
}
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Proxy-Secret", "correct-secret")
req.Header.Set("X-Remote-User", "testuser")
valid, username, isAdmin := CheckProxyAuth(cfg, req)
if !valid {
t.Error("CheckProxyAuth should return true for valid auth")
}
if username != "testuser" {
t.Errorf("username should be 'testuser', got %q", username)
}
if !isAdmin {
t.Error("isAdmin should be true by default when no role checking")
}
}
func TestCheckProxyAuth_RoleCheckingEmptyRolesHeader(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "correct-secret",
ProxyAuthUserHeader: "X-Remote-User",
ProxyAuthRoleHeader: "X-Remote-Roles",
ProxyAuthAdminRole: "admin",
}
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Proxy-Secret", "correct-secret")
req.Header.Set("X-Remote-User", "testuser")
// No X-Remote-Roles header
valid, username, isAdmin := CheckProxyAuth(cfg, req)
if !valid {
t.Error("CheckProxyAuth should return true for valid auth")
}
if username != "testuser" {
t.Errorf("username should be 'testuser', got %q", username)
}
// When role header is empty, isAdmin stays true (default)
if !isAdmin {
t.Error("isAdmin should be true when roles header is empty")
}
}
func TestCheckProxyAuth_RoleCheckingWithAdminRole(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "correct-secret",
ProxyAuthUserHeader: "X-Remote-User",
ProxyAuthRoleHeader: "X-Remote-Roles",
ProxyAuthAdminRole: "admin",
}
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Proxy-Secret", "correct-secret")
req.Header.Set("X-Remote-User", "adminuser")
req.Header.Set("X-Remote-Roles", "user|admin|viewer")
valid, username, isAdmin := CheckProxyAuth(cfg, req)
if !valid {
t.Error("CheckProxyAuth should return true for valid auth")
}
if username != "adminuser" {
t.Errorf("username should be 'adminuser', got %q", username)
}
if !isAdmin {
t.Error("isAdmin should be true when user has admin role")
}
}
func TestCheckProxyAuth_RoleCheckingWithoutAdminRole(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "correct-secret",
ProxyAuthUserHeader: "X-Remote-User",
ProxyAuthRoleHeader: "X-Remote-Roles",
ProxyAuthAdminRole: "admin",
}
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Proxy-Secret", "correct-secret")
req.Header.Set("X-Remote-User", "regularuser")
req.Header.Set("X-Remote-Roles", "user|viewer")
valid, username, isAdmin := CheckProxyAuth(cfg, req)
if !valid {
t.Error("CheckProxyAuth should return true for valid auth")
}
if username != "regularuser" {
t.Errorf("username should be 'regularuser', got %q", username)
}
if isAdmin {
t.Error("isAdmin should be false when user lacks admin role")
}
}
func TestCheckProxyAuth_CustomRoleSeparator(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "correct-secret",
ProxyAuthUserHeader: "X-Remote-User",
ProxyAuthRoleHeader: "X-Remote-Roles",
ProxyAuthAdminRole: "administrator",
ProxyAuthRoleSeparator: ",",
}
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Proxy-Secret", "correct-secret")
req.Header.Set("X-Remote-User", "adminuser")
req.Header.Set("X-Remote-Roles", "user,administrator,viewer")
valid, _, isAdmin := CheckProxyAuth(cfg, req)
if !valid {
t.Error("CheckProxyAuth should return true for valid auth")
}
if !isAdmin {
t.Error("isAdmin should be true with custom separator")
}
}
func TestCheckProxyAuth_RoleWithWhitespace(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "correct-secret",
ProxyAuthUserHeader: "X-Remote-User",
ProxyAuthRoleHeader: "X-Remote-Roles",
ProxyAuthAdminRole: "admin",
}
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Proxy-Secret", "correct-secret")
req.Header.Set("X-Remote-User", "adminuser")
req.Header.Set("X-Remote-Roles", "user| admin |viewer") // Whitespace around admin
valid, _, isAdmin := CheckProxyAuth(cfg, req)
if !valid {
t.Error("CheckProxyAuth should return true for valid auth")
}
if !isAdmin {
t.Error("isAdmin should be true when role matches after trimming whitespace")
}
}
// CheckAuth tests
func TestCheckAuth_ProxyAuthSetsHeaders(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "secret123",
ProxyAuthUserHeader: "X-Remote-User",
}
req := httptest.NewRequest("GET", "/api/test", nil)
req.Header.Set("X-Proxy-Secret", "secret123")
req.Header.Set("X-Remote-User", "proxyuser")
w := httptest.NewRecorder()
result := CheckAuth(cfg, w, req)
if !result {
t.Error("CheckAuth should return true for valid proxy auth")
}
if w.Header().Get("X-Authenticated-User") != "proxyuser" {
t.Errorf("X-Authenticated-User header = %q, want 'proxyuser'", w.Header().Get("X-Authenticated-User"))
}
if w.Header().Get("X-Auth-Method") != "proxy" {
t.Errorf("X-Auth-Method header = %q, want 'proxy'", w.Header().Get("X-Auth-Method"))
}
}
func TestCheckAuth_ProxyAuthNoUsernameHeader(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "secret123",
// No ProxyAuthUserHeader configured
}
req := httptest.NewRequest("GET", "/api/test", nil)
req.Header.Set("X-Proxy-Secret", "secret123")
w := httptest.NewRecorder()
result := CheckAuth(cfg, w, req)
if !result {
t.Error("CheckAuth should return true for valid proxy auth without user header")
}
// X-Authenticated-User should not be set when username is empty
if w.Header().Get("X-Authenticated-User") != "" {
t.Errorf("X-Authenticated-User should be empty, got %q", w.Header().Get("X-Authenticated-User"))
}
if w.Header().Get("X-Auth-Method") != "proxy" {
t.Errorf("X-Auth-Method header = %q, want 'proxy'", w.Header().Get("X-Auth-Method"))
}
}
func TestCheckAuth_OIDCEnabledWithoutCredentials(t *testing.T) {
dataDir := t.TempDir()
cfg := &config.Config{
DataPath: dataDir,
ConfigPath: dataDir,
// No AuthUser, AuthPass, or APITokens
}
setSSOAuthSnapshot(cfg, &config.SSOConfig{
Providers: []config.SSOProvider{
{
ID: "test-oidc",
Name: "Test OIDC",
Type: config.SSOProviderTypeOIDC,
Enabled: true,
OIDC: &config.OIDCProviderConfig{
IssuerURL: "https://issuer.example.com",
ClientID: "client-id",
},
},
},
})
req := httptest.NewRequest("GET", "/api/test", nil)
w := httptest.NewRecorder()
result := CheckAuth(cfg, w, req)
// Should return false - OIDC enabled requires authentication
if result {
t.Error("CheckAuth should return false when OIDC is enabled without credentials")
}
}
func TestCheckAuth_OIDCSessionValid(t *testing.T) {
dir := t.TempDir()
InitSessionStore(dir)
// Create a session and track it for a user
store := GetSessionStore()
sessionToken := generateSessionToken()
store.CreateOIDCSession(sessionToken, 24*time.Hour, "test-agent", "127.0.0.1", "oidcuser", &OIDCTokenInfo{
Issuer: "https://issuer.example.com",
ClientID: "client-id",
})
cfg := &config.Config{
DataPath: dir,
ConfigPath: dir,
}
setSSOAuthSnapshot(cfg, &config.SSOConfig{
Providers: []config.SSOProvider{
{
ID: "test-oidc",
Name: "Test OIDC",
Type: config.SSOProviderTypeOIDC,
Enabled: true,
OIDC: &config.OIDCProviderConfig{
IssuerURL: "https://issuer.example.com",
ClientID: "client-id",
},
},
},
})
req := httptest.NewRequest("GET", "/api/test", nil)
req.AddCookie(&http.Cookie{
Name: "pulse_session",
Value: sessionToken,
})
w := httptest.NewRecorder()
result := CheckAuth(cfg, w, req)
if !result {
t.Error("CheckAuth should return true for valid OIDC session")
}
if w.Header().Get("X-Authenticated-User") != "oidcuser" {
t.Errorf("X-Authenticated-User = %q, want 'oidcuser'", w.Header().Get("X-Authenticated-User"))
}
if w.Header().Get("X-Auth-Method") != "oidc" {
t.Errorf("X-Auth-Method = %q, want 'oidc'", w.Header().Get("X-Auth-Method"))
}
}
func TestCheckAuth_OIDCSessionInvalid(t *testing.T) {
dir := t.TempDir()
InitSessionStore(dir)
cfg := &config.Config{
DataPath: dir,
ConfigPath: dir,
}
setSSOAuthSnapshot(cfg, &config.SSOConfig{
Providers: []config.SSOProvider{
{
ID: "test-oidc",
Name: "Test OIDC",
Type: config.SSOProviderTypeOIDC,
Enabled: true,
OIDC: &config.OIDCProviderConfig{
IssuerURL: "https://issuer.example.com",
ClientID: "client-id",
},
},
},
})
req := httptest.NewRequest("GET", "/api/test", nil)
req.AddCookie(&http.Cookie{
Name: "pulse_session",
Value: "invalid-session-token",
})
w := httptest.NewRecorder()
result := CheckAuth(cfg, w, req)
// Should return false - invalid session
if result {
t.Error("CheckAuth should return false for invalid OIDC session")
}
}
func TestCheckAuth_SessionCookieValid(t *testing.T) {
dir := t.TempDir()
InitSessionStore(dir)
// Create a session
store := GetSessionStore()
sessionToken := generateSessionToken()
store.CreateSession(sessionToken, 24*time.Hour, "test-agent", "127.0.0.1", "testuser")
cfg := &config.Config{
AuthUser: "testuser",
AuthPass: "$2a$10$dummy", // Has auth configured but using session
}
req := httptest.NewRequest("GET", "/api/test", nil)
req.AddCookie(&http.Cookie{
Name: "pulse_session",
Value: sessionToken,
})
w := httptest.NewRecorder()
result := CheckAuth(cfg, w, req)
if !result {
t.Error("CheckAuth should return true for valid session cookie")
}
}
func TestCheckAuth_SessionCookieExpired(t *testing.T) {
dir := t.TempDir()
InitSessionStore(dir)
// Create and expire a session
store := GetSessionStore()
sessionToken := generateSessionToken()
store.CreateSession(sessionToken, 24*time.Hour, "test-agent", "127.0.0.1", "testuser")
// Manually expire the session
store.mu.Lock()
hash := sessionHash(sessionToken)
if session, exists := store.sessions[hash]; exists {
session.ExpiresAt = time.Now().Add(-1 * time.Hour)
store.sessions[hash] = session
}
store.mu.Unlock()
cfg := &config.Config{
AuthUser: "testuser",
AuthPass: "$2a$10$dummy",
}
req := httptest.NewRequest("GET", "/api/test", nil)
req.AddCookie(&http.Cookie{
Name: "pulse_session",
Value: sessionToken,
})
w := httptest.NewRecorder()
result := CheckAuth(cfg, w, req)
// Should return false - expired session, no valid basic auth
if result {
t.Error("CheckAuth should return false for expired session with invalid basic auth")
}
}
func TestCheckAuth_NoSessionCookie(t *testing.T) {
cfg := &config.Config{
AuthUser: "testuser",
AuthPass: "$2a$10$dummy",
}
req := httptest.NewRequest("GET", "/api/test", nil)
// No session cookie
w := httptest.NewRecorder()
result := CheckAuth(cfg, w, req)
// Should return false - no session and no valid auth header
if result {
t.Error("CheckAuth should return false without session or valid auth")
}
}
func TestCheckAuth_ValidSessionWithSlidingExpiration(t *testing.T) {
dir := t.TempDir()
InitSessionStore(dir)
store := GetSessionStore()
sessionToken := generateSessionToken()
store.CreateSession(sessionToken, 24*time.Hour, "test-agent", "127.0.0.1", "testuser")
cfg := &config.Config{
// No auth configured, so session alone should work
}
req := httptest.NewRequest("GET", "/api/test", nil)
req.AddCookie(&http.Cookie{
Name: "pulse_session",
Value: sessionToken,
})
w := httptest.NewRecorder()
result := CheckAuth(cfg, w, req)
if !result {
t.Error("CheckAuth should return true for valid session with sliding expiration")
}
if got := w.Header().Get("X-Authenticated-User"); got != "testuser" {
t.Fatalf("X-Authenticated-User = %q, want testuser", got)
}
if got := w.Header().Get("X-Auth-Method"); got != "session" {
t.Fatalf("X-Auth-Method = %q, want session", got)
}
}
func TestReadSessionCookie_SecureRequestRequiresHostPrefix(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "https://pulse.example.test/api/test", nil)
req.TLS = &tls.ConnectionState{}
req.AddCookie(&http.Cookie{
Name: cookieNameSession,
Value: "legacy-session",
})
if _, err := readSessionCookie(req); err == nil {
t.Fatalf("expected secure request to reject legacy session cookie")
}
}
func TestReadSessionCookie_SecureRequestAcceptsHostPrefix(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "https://pulse.example.test/api/test", nil)
req.TLS = &tls.ConnectionState{}
req.AddCookie(&http.Cookie{
Name: cookieNameSessionSecure,
Value: "secure-session",
})
cookie, err := readSessionCookie(req)
if err != nil {
t.Fatalf("expected secure request to accept __Host- session cookie: %v", err)
}
if cookie.Value != "secure-session" {
t.Fatalf("unexpected cookie value %q", cookie.Value)
}
}
func TestValidateFreshBrowserSession_AllowsRecentBoundSession(t *testing.T) {
dir := t.TempDir()
InitSessionStore(dir)
sessionToken := generateSessionToken()
GetSessionStore().CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", "alice")
req := httptest.NewRequest(http.MethodPost, "/api/orgs/acme/members", nil)
req.AddCookie(&http.Cookie{Name: cookieNameSession, Value: sessionToken})
if authErr := validateFreshBrowserSession(req, "alice", "transfer ownership", privilegedBrowserSessionMaxAge); authErr != nil {
t.Fatalf("expected recent session to pass, got %#v", authErr)
}
}
func TestValidateFreshBrowserSession_RejectsStaleSession(t *testing.T) {
dir := t.TempDir()
InitSessionStore(dir)
sessionToken := generateSessionToken()
store := GetSessionStore()
store.CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", "alice")
store.mu.Lock()
store.sessions[sessionHash(sessionToken)].CreatedAt = time.Now().Add(-privilegedBrowserSessionMaxAge - time.Minute)
store.mu.Unlock()
req := httptest.NewRequest(http.MethodPost, "/api/orgs/acme/members", nil)
req.AddCookie(&http.Cookie{Name: cookieNameSession, Value: sessionToken})
authErr := validateFreshBrowserSession(req, "alice", "transfer ownership", privilegedBrowserSessionMaxAge)
if authErr == nil {
t.Fatal("expected stale session to be rejected")
}
if authErr.Status != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", authErr.Status, http.StatusUnauthorized)
}
if authErr.Code != "fresh_session_required" {
t.Fatalf("code = %q, want fresh_session_required", authErr.Code)
}
}
func TestValidateFreshBrowserSession_RejectsUsernameMismatch(t *testing.T) {
dir := t.TempDir()
InitSessionStore(dir)
sessionToken := generateSessionToken()
GetSessionStore().CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", "alice")
req := httptest.NewRequest(http.MethodPost, "/api/orgs/acme/members", nil)
req.AddCookie(&http.Cookie{Name: cookieNameSession, Value: sessionToken})
authErr := validateFreshBrowserSession(req, "bob", "transfer ownership", privilegedBrowserSessionMaxAge)
if authErr == nil {
t.Fatal("expected mismatched session identity to be rejected")
}
if authErr.Code != "fresh_session_required" {
t.Fatalf("code = %q, want fresh_session_required", authErr.Code)
}
}
func TestCheckAuth_NoAuthConfigured(t *testing.T) {
cfg := &config.Config{
// No auth configured at all
}
req := httptest.NewRequest("GET", "/api/test", nil)
req.RemoteAddr = "127.0.0.1:12345"
w := httptest.NewRecorder()
result := CheckAuth(cfg, w, req)
// Should return true for direct loopback when no auth is configured
if !result {
t.Error("CheckAuth should return true for loopback when no auth is configured")
}
}
func TestCheckAuth_NoAuthConfiguredRejectsRemote(t *testing.T) {
cfg := &config.Config{}
req := httptest.NewRequest("GET", "/api/test", nil)
req.RemoteAddr = "198.51.100.10:12345"
w := httptest.NewRecorder()
if CheckAuth(cfg, w, req) {
t.Fatal("CheckAuth should reject remote access before auth is configured")
}
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected explicit unauthorized response, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "Authentication required") {
t.Fatalf("expected authentication-required body, got %q", w.Body.String())
}
}