fix(auth): set SameSite=None for viewer session cookie over HTTPS and update docs

This commit is contained in:
zwitschi committed 2026-08-16 09:45:58 +02:00
1 parent 85454610f4
commit 4fe09a0a3d
5 files changed
+69 -16

No files matched your search

+27 -8
View File
@@ -88,29 +88,48 @@ func CookieSecure(r *http.Request) bool {
return r.TLS != nil
}
// SetCookie writes the admin session cookie.
func SetCookie(w http.ResponseWriter, r *http.Request, cookieName, token string, expiresAt time.Time) {
// SameSiteForUserCookie returns the SameSite policy for the viewer session cookie.
// Over HTTPS it returns SameSite=None so the cookie is sent cross-site from the
// Workers-served SPA; over plain HTTP (local dev) it falls back to Lax.
func SameSiteForUserCookie(r *http.Request) http.SameSite {
if CookieSecure(r) {
return http.SameSiteNoneMode
}
return http.SameSiteLaxMode
}
// SetCookie writes a session cookie with the given SameSite policy.
// SameSite=None is only valid together with Secure, which is enforced here.
func SetCookie(w http.ResponseWriter, r *http.Request, cookieName, token string, expiresAt time.Time, sameSite http.SameSite) {
secure := CookieSecure(r)
if sameSite == http.SameSiteNoneMode {
secure = true
}
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: CookieSecure(r),
SameSite: sameSite,
Secure: secure,
Expires: expiresAt,
MaxAge: int(time.Until(expiresAt).Seconds()),
})
}
// ClearCookie removes the admin session cookie.
func ClearCookie(w http.ResponseWriter, r *http.Request, cookieName string) {
// ClearCookie removes a session cookie with the given SameSite policy.
func ClearCookie(w http.ResponseWriter, r *http.Request, cookieName string, sameSite http.SameSite) {
secure := CookieSecure(r)
if sameSite == http.SameSiteNoneMode {
secure = true
}
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: CookieSecure(r),
SameSite: sameSite,
Secure: secure,
Expires: time.Unix(0, 0),
MaxAge: -1,
})
+34 -2
View File
@@ -134,7 +134,7 @@ func TestSetCookie(t *testing.T) {
w := httptest.NewRecorder()
expiresAt := time.Now().Add(1 * time.Hour)
SetCookie(w, req, "test_cookie", "abc123", expiresAt)
SetCookie(w, req, "test_cookie", "abc123", expiresAt, http.SameSiteStrictMode)
cookies := w.Result().Cookies()
if len(cookies) != 1 {
@@ -165,7 +165,7 @@ func TestClearCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
ClearCookie(w, req, "test_cookie")
ClearCookie(w, req, "test_cookie", http.SameSiteStrictMode)
cookies := w.Result().Cookies()
if len(cookies) != 1 {
@@ -183,6 +183,38 @@ func TestClearCookie(t *testing.T) {
}
}
func TestSameSiteForUserCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Forwarded-Proto", "https")
if got := SameSiteForUserCookie(req); got != http.SameSiteNoneMode {
t.Errorf("SameSiteForUserCookie(https) = %v, want None", got)
}
req2 := httptest.NewRequest("GET", "/", nil)
if got := SameSiteForUserCookie(req2); got != http.SameSiteLaxMode {
t.Errorf("SameSiteForUserCookie(http) = %v, want Lax", got)
}
}
func TestSetCookie_NoneForcesSecure(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
expiresAt := time.Now().Add(1 * time.Hour)
SetCookie(w, req, "test_cookie", "abc123", expiresAt, http.SameSiteNoneMode)
cookies := w.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
c := cookies[0]
if c.SameSite != http.SameSiteNoneMode {
t.Errorf("cookie SameSite = %v, want None", c.SameSite)
}
if !c.Secure {
t.Error("cookie Secure = false, want true (SameSite=None requires Secure)")
}
}
func TestGetTokenFromCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: "token123"})
+4 -4
View File
@@ -291,7 +291,7 @@ func HandleAdminLogin(deps *Dependencies) http.HandlerFunc {
expiresAt := time.Now().Add(config.AdminSessionTTL())
deps.SessionStore.Add(token, expiresAt)
auth.SetCookie(w, r, config.AdminSessionCookieName, token, expiresAt)
auth.SetCookie(w, r, config.AdminSessionCookieName, token, expiresAt, http.SameSiteStrictMode)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: config.AdminUsername()})
@@ -309,7 +309,7 @@ func HandleAdminLogout(deps *Dependencies) http.HandlerFunc {
if token, ok := auth.GetTokenFromCookie(r, config.AdminSessionCookieName); ok {
deps.SessionStore.Remove(token)
}
auth.ClearCookie(w, r, config.AdminSessionCookieName)
auth.ClearCookie(w, r, config.AdminSessionCookieName, http.SameSiteStrictMode)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: false})
@@ -368,7 +368,7 @@ func startUserSession(deps *Dependencies, w http.ResponseWriter, r *http.Request
}
expiresAt := time.Now().Add(config.UserSessionTTL())
deps.UserSessions.Add(token, username, expiresAt)
auth.SetCookie(w, r, userSessionCookieName, token, expiresAt)
auth.SetCookie(w, r, userSessionCookieName, token, expiresAt, auth.SameSiteForUserCookie(r))
}
// HandleUserRegister creates a new chat user and signs them in.
@@ -474,7 +474,7 @@ func HandleUserLogout(deps *Dependencies) http.HandlerFunc {
if token, ok := auth.GetTokenFromCookie(r, userSessionCookieName); ok {
deps.UserSessions.Remove(token)
}
auth.ClearCookie(w, r, userSessionCookieName)
auth.ClearCookie(w, r, userSessionCookieName, auth.SameSiteForUserCookie(r))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}