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
72 changes: 45 additions & 27 deletions internal/watcher/txadmin/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,39 +91,57 @@ func (c *Client) Login() error {
}
c.csrfToken = loginResp.CSRFToken

// Manually extract session cookie from Set-Cookie header
// txAdmin uses cookie names like "tx:abc123" which violate RFC 6265
// and are rejected by Go's cookie jar
setCookieHeaders := resp.Header["Set-Cookie"]
// Manually extract session cookie from Set-Cookie header.
// txAdmin cookie names contain ":" (legacy "tx:<profile>", current
// "txa:sess:<profileHash>"), which violate RFC 6265 and are rejected
// by Go's cookie jar.
cookieName, cookieValue, ok := sessionCookieFromSetCookie(resp.Header["Set-Cookie"])
if !ok {
return fmt.Errorf("no session cookie received from txAdmin")
}

c.sessionCookie = cookieName + "=" + cookieValue
c.session = &Session{
BaseURL: c.baseURL,
CookieName: cookieName,
Cookie: cookieValue,
CSRFToken: c.csrfToken,
ExpiresAt: time.Now().Add(23 * time.Hour),
}

return nil
}

// sessionCookieFromSetCookie finds the txAdmin session cookie.
// It ignores other txAdmin cookies such as "txa:theme".
func sessionCookieFromSetCookie(setCookieHeaders []string) (name, value string, ok bool) {
for _, setCookie := range setCookieHeaders {
// Format: "tx:abc123=value; path=/; ..."
// We need to extract "tx:abc123=value"
parts := strings.Split(setCookie, ";")
if len(parts) > 0 {
cookiePart := strings.TrimSpace(parts[0])
if strings.HasPrefix(cookiePart, "tx:") {
c.sessionCookie = cookiePart
// Also extract name and value for session caching
eqIdx := strings.Index(cookiePart, "=")
if eqIdx > 0 {
c.session = &Session{
BaseURL: c.baseURL,
CookieName: cookiePart[:eqIdx],
Cookie: cookiePart[eqIdx+1:],
CSRFToken: c.csrfToken,
ExpiresAt: time.Now().Add(23 * time.Hour),
}
}
break
}
if len(parts) == 0 {
continue
}
cookiePart := strings.TrimSpace(parts[0])
eqIdx := strings.Index(cookiePart, "=")
if eqIdx <= 0 {
continue
}
cookieName := cookiePart[:eqIdx]
cookieValue := cookiePart[eqIdx+1:]
if cookieValue == "" || !isTxAdminSessionCookie(cookieName) {
continue
}
return cookieName, cookieValue, true
}
return "", "", false
}

if c.sessionCookie == "" {
return fmt.Errorf("no session cookie received from txAdmin")
func isTxAdminSessionCookie(name string) bool {
// txAdmin < 8.1 used "tx:<profile>".
if strings.HasPrefix(name, "tx:") {
return true
}

return nil
// txAdmin >= 8.1 uses "txa:sess" or "txa:sess:<profilePathHash>".
return name == "txa:sess" || strings.HasPrefix(name, "txa:sess:")
}

// extractCSRFFromCookies tries to get CSRF token from cookies
Expand Down
90 changes: 90 additions & 0 deletions internal/watcher/txadmin/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package txadmin

import (
"net/http"
"net/http/httptest"
"testing"
)

func TestSessionCookieFromSetCookie(t *testing.T) {
tests := []struct {
name string
headers []string
want string
ok bool
}{
{
name: "current txAdmin session cookie",
headers: []string{"txa:sess:abcdef123456=11111111-1111-1111-1111-111111111111; Path=/; HttpOnly"},
want: "txa:sess:abcdef123456",
ok: true,
},
{
name: "theme cookie is ignored in favor of session",
headers: []string{
"txa:theme=dark; Path=/",
"txa:sess:abcdef123456=11111111-1111-1111-1111-111111111111; Path=/; HttpOnly",
},
want: "txa:sess:abcdef123456",
ok: true,
},
{
name: "legacy profile cookie",
headers: []string{"tx:default=legacy-session; Path=/"},
want: "tx:default",
ok: true,
},
{
name: "theme cookie alone is not a session",
headers: []string{"txa:theme=dark; Path=/"},
ok: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotName, _, ok := sessionCookieFromSetCookie(tt.headers)
if ok != tt.ok {
t.Fatalf("ok = %v, want %v", ok, tt.ok)
}
if gotName != tt.want {
t.Fatalf("cookie name = %q, want %q", gotName, tt.want)
}
})
}
}

func TestLoginAcceptsCurrentTxAdminSessionCookie(t *testing.T) {
const csrf = "csrf-token"
const sessID = "11111111-1111-1111-1111-111111111111"

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/auth/password" || r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
w.Header().Add("Set-Cookie", "txa:theme=dark; Path=/")
w.Header().Add("Set-Cookie", "txa:sess:abcdef123456="+sessID+"; Path=/; HttpOnly")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"csrfToken":"` + csrf + `"}`))
}))
t.Cleanup(srv.Close)

client, err := NewClient(srv.URL, "user", "pass")
if err != nil {
t.Fatal(err)
}
if err := client.Login(); err != nil {
t.Fatal(err)
}

if client.csrfToken != csrf {
t.Fatalf("csrf = %q, want %q", client.csrfToken, csrf)
}
if client.session == nil || client.session.CookieName != "txa:sess:abcdef123456" || client.session.Cookie != sessID {
t.Fatalf("session = %#v", client.session)
}
if client.sessionCookie != "txa:sess:abcdef123456="+sessID {
t.Fatalf("session cookie = %q", client.sessionCookie)
}
}
Loading