diff --git a/go.mod b/go.mod index 68e70d1f..cfc1b512 100644 --- a/go.mod +++ b/go.mod @@ -19,11 +19,12 @@ require ( github.com/rs/zerolog v1.35.1 github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.19.0 - go.mau.fi/util v0.9.10 - golang.org/x/crypto v0.53.0 - golang.org/x/sync v0.21.0 + go.mau.fi/util v0.9.11 + golang.org/x/crypto v0.54.0 + golang.org/x/net v0.57.0 + golang.org/x/sync v0.22.0 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.28.1 + maunium.net/go/mautrix v0.29.1-0.20260727181456-bf941f51d27a ) require ( @@ -38,8 +39,8 @@ require ( github.com/lib/pq v1.12.3 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.45 // indirect - github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect + github.com/mattn/go-sqlite3 v1.14.48 // indirect + github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.56.0 // indirect @@ -50,13 +51,12 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/x448/float16 v0.8.4 // indirect - github.com/yuin/goldmark v1.8.2 // indirect + github.com/yuin/goldmark v1.8.4 // indirect go.mau.fi/zeroconfig v0.2.0 // indirect - golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect maunium.net/go/mauflag v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index 584e6232..fdd68b00 100644 --- a/go.sum +++ b/go.sum @@ -44,12 +44,12 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk= -github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs= +github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/openziti/secretstream v0.1.51 h1:j/rMfIzBNqZD5a1EKV8J4Z5QeaoSK56s/zxvvB08eSA= github.com/openziti/secretstream v0.1.51/go.mod h1:YapZv2c/SyZyohn6Q0MJkl8SUuBbs9Q6XWSiboBc1jA= -github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= -github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca h1:GHSUVE4yOgX4E7kTRzpxCPbCOYkd3Kj8Dgdod30OI1E= +github.com/petermattis/goid v0.0.0-20260713124913-97594f28f5ca/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= @@ -83,10 +83,10 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= -github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.10 h1:wzvz5iDHyqDXB8vgisD4d3SzucLXNM3iNY+1O1RoHtg= -go.mau.fi/util v0.9.10/go.mod h1:YQOxySn+ZE3qSYqNxvyX7Yi3suA8YK17PS6QqBREW7A= +github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= +github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.mau.fi/util v0.9.11 h1:Cus1Lu/t7d3OG6VF4aYWvlUUS0Q4O1/lcpPNJZ0jsw0= +go.mau.fi/util v0.9.11/go.mod h1:xunp/oIQfFD68HHcNHfG0pOiHkvEtDhTweeIwKJ//+Q= go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU= go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= @@ -97,17 +97,17 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= -golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 h1:qLvzZeaANDgyVOA8pyHCOStGlXn0rseXma+GQjeuv2g= +golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -117,8 +117,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -126,8 +126,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -140,8 +140,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -160,8 +160,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -182,5 +182,5 @@ gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= -maunium.net/go/mautrix v0.28.1 h1:Hic3oDMPbLbQu1fhboTRAKZcORMjzzkjxsa+SGk60b0= -maunium.net/go/mautrix v0.28.1/go.mod h1:mWXQNmOlrq4VTDU9f1HO03BSIswdUIyyY4wUKHqwzzY= +maunium.net/go/mautrix v0.29.1-0.20260727181456-bf941f51d27a h1:/aHmbVZi5ZEy+T5sNZLeAxlH53cbQjAVF9DLR94rdbg= +maunium.net/go/mautrix v0.29.1-0.20260727181456-bf941f51d27a/go.mod h1:LynuVr8N9nWsE1N4WAE+vItRACDB1pt9M3gN4SIBpeY= diff --git a/pkg/connector/castle_token.js b/pkg/connector/castle_token.js new file mode 100644 index 00000000..e4d66cac --- /dev/null +++ b/pkg/connector/castle_token.js @@ -0,0 +1,445 @@ +(async ({ + scriptURL, + publicKey, + cookieNames: cookieNameList, + contextURL, + identifier, + castleTokenBatchSize, +}) => { + const cookieNames = new Set(cookieNameList); + const castleStorageKey = "fi.mau.twitter.castle_token"; + + let browserStatusText; + function showBrowserLoginStatus(message) { + try { + document.title = "Signing in to X"; + if (!browserStatusText || !browserStatusText.isConnected) { + const body = document.body || document.documentElement.appendChild(document.createElement("body")); + const container = document.createElement("main"); + container.id = "mautrix-twitter-login-status"; + container.setAttribute("role", "status"); + container.setAttribute("aria-live", "polite"); + + const title = document.createElement("h1"); + title.textContent = "Signing in to X"; + Object.assign(title.style, { + margin: "0", + color: "#eff3f4", + fontSize: "28px", + fontWeight: "700", + lineHeight: "1.2", + }); + + const progress = document.createElement("progress"); + progress.setAttribute("aria-label", "Signing in"); + Object.assign(progress.style, { + width: "220px", + height: "6px", + margin: "28px 0 22px", + accentColor: "#1d9bf0", + }); + + browserStatusText = document.createElement("p"); + Object.assign(browserStatusText.style, { + margin: "0", + color: "#8b98a5", + fontSize: "15px", + lineHeight: "1.5", + }); + + container.append(title, progress, browserStatusText); + Object.assign(container.style, { + width: "min(420px, calc(100% - 48px))", + textAlign: "center", + }); + Object.assign(document.documentElement.style, { + minHeight: "100%", + background: "#000000", + colorScheme: "dark", + }); + Object.assign(body.style, { + minHeight: "100vh", + margin: "0", + display: "grid", + placeItems: "center", + background: "#000000", + fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", + }); + body.replaceChildren(container); + } + browserStatusText.textContent = message; + } catch (_) {} + } + showBrowserLoginStatus("Preparing secure login..."); + + const browserLog = message => { + try { + console.log("[BrowserAuth] mautrix-twitter Castle: " + message); + } catch (_) {} + }; + function getLocalStorage() { + try { + return window.localStorage; + } catch (_) {} + return null; + } + function castleTokenResultKey(index) { + return index === 1 ? "castle_token" : "castle_token_" + index; + } + function castleTokenStorageKeyForIndex(index) { + return index === 1 ? castleStorageKey : castleStorageKey + "_" + index; + } + const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + async function waitFor(predicate, message, timeout = 30000) { + const deadlineAt = Date.now() + timeout; + while (Date.now() < deadlineAt) { + const value = predicate(); + if (value) { + return value; + } + await sleep(100); + } + throw new Error(message); + } + function resetStoredResult() { + try { + const storage = getLocalStorage(); + if (!storage) { + return; + } + storage.removeItem(castleStorageKey); + for (let index = 2; index <= castleTokenBatchSize; index++) { + storage.removeItem(castleTokenStorageKeyForIndex(index)); + } + for (const name of cookieNames) { + storage.removeItem("fi.mau.twitter.cookie." + name); + } + } catch (_) {} + } + function storeBrowserAuthResult(result) { + try { + globalThis.__MAUTRIX_TWITTER_CASTLE_RESULT__ = result; + globalThis.__MAUTRIX_TWITTER_CASTLE_IN_PROGRESS__ = false; + globalThis.__BEEP_BEEP_AUTH_RESULTS__ = result; + if (typeof window !== "undefined") { + window.__BEEP_BEEP_AUTH_RESULTS__ = result; + } + } catch (_) {} + try { + const storage = getLocalStorage(); + if (!storage) { + return; + } + storage.setItem(castleStorageKey, result.castle_token || ""); + for (let index = 2; index <= castleTokenBatchSize; index++) { + const key = castleTokenResultKey(index); + if (result[key]) { + storage.setItem(castleTokenStorageKeyForIndex(index), result[key]); + } + } + for (const name of cookieNames) { + if (result[name]) { + storage.setItem("fi.mau.twitter.cookie." + name, result[name]); + } + } + } catch (_) {} + } + function storedBrowserAuthResult() { + try { + const result = globalThis.__MAUTRIX_TWITTER_CASTLE_RESULT__; + if (result && result.castle_token) { + return result; + } + } catch (_) {} + try { + const storage = getLocalStorage(); + if (!storage) { + return null; + } + const token = storage.getItem(castleStorageKey); + if (!token) { + return null; + } + const result = { castle_token: token }; + for (let index = 2; index <= castleTokenBatchSize; index++) { + const token = storage.getItem(castleTokenStorageKeyForIndex(index)); + if (token) { + result[castleTokenResultKey(index)] = token; + } + } + for (const name of cookieNames) { + const value = storage.getItem("fi.mau.twitter.cookie." + name); + if (value) { + result[name] = value; + } + } + return result; + } catch (_) { + return null; + } + } + const existingResult = storedBrowserAuthResult(); + if (existingResult) { + browserLog("returning stored result"); + return existingResult; + } + if (globalThis.__MAUTRIX_TWITTER_CASTLE_IN_PROGRESS__) { + browserLog("waiting for in-flight result"); + return await waitFor(() => storedBrowserAuthResult(), "X Castle token generation did not finish", 30000); + } + globalThis.__MAUTRIX_TWITTER_CASTLE_IN_PROGRESS__ = true; + resetStoredResult(); + + function addModules(entry, modules) { + if (!entry || typeof entry !== "object") { + return; + } + const defs = entry[1]; + if (!defs || typeof defs !== "object") { + return; + } + for (const id of Object.keys(defs)) { + modules[id] = defs[id]; + } + } + + function loadScript(doc, url, timeout = 10000) { + return new Promise((resolve, reject) => { + const script = doc.createElement("script"); + const timer = setTimeout(() => { + try { + script.remove(); + } catch (_) {} + reject(new Error("Timed out loading X Castle script")); + }, timeout); + script.src = url; + script.async = true; + script.onload = () => { + clearTimeout(timer); + resolve(); + }; + script.onerror = () => { + clearTimeout(timer); + reject(new Error("Failed to load X Castle script")); + }; + (doc.head || doc.documentElement).appendChild(script); + }); + } + + function installModuleCapture(win) { + const chunk = win.webpackChunk_twitter_responsive_web = win.webpackChunk_twitter_responsive_web || []; + const modules = {}; + for (const entry of chunk) { + addModules(entry, modules); + } + if (!chunk.__mautrixCastleCaptured) { + const nativePush = chunk.push.bind(chunk); + chunk.push = (...entries) => { + for (const entry of entries) { + addModules(entry, modules); + } + return nativePush(...entries); + }; + Object.defineProperty(chunk, "__mautrixCastleCaptured", { value: true }); + } + return modules; + } + + async function createTokenInContext(win, doc) { + const modules = installModuleCapture(win); + await loadScript(doc, scriptURL); + await waitFor(() => Object.keys(modules).length > 0, "X Castle script did not register modules", 15000); + + const cache = {}; + function req(id) { + const key = String(id); + if (cache[key]) { + return cache[key].exports; + } + const fn = modules[key]; + if (typeof fn !== "function") { + throw new Error("Missing X Castle module " + key); + } + const module = { exports: {} }; + cache[key] = module; + fn(module, module.exports, req); + return module.exports; + } + req.d = (exports, definition) => { + for (const key of Object.keys(definition)) { + if (!Object.prototype.hasOwnProperty.call(exports, key)) { + Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); + } + } + }; + req.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop); + req.r = exports => { + if (typeof win.Symbol !== "undefined" && win.Symbol.toStringTag) { + Object.defineProperty(exports, win.Symbol.toStringTag, { value: "Module" }); + } + Object.defineProperty(exports, "__esModule", { value: true }); + }; + + let configure; + for (const id of Object.keys(modules)) { + try { + const exports = req(id); + if (exports && typeof exports.configure === "function") { + configure = exports.configure; + break; + } + } catch (_) {} + } + if (typeof configure !== "function") { + throw new Error("X Castle module is unavailable"); + } + + const castle = configure({ pk: publicKey }); + if (!castle || typeof castle.createRequestToken !== "function") { + throw new Error("X Castle token generator is unavailable"); + } + const tokens = []; + for (let index = 0; index < castleTokenBatchSize; index++) { + const token = await castle.createRequestToken(); + if (token) { + tokens.push(token); + } + await sleep(10); + } + return tokens; + } + + async function synthesizeCastleActivity(win, doc) { + const target = doc.body || doc.documentElement; + if (!target) { + return; + } + const input = doc.createElement("input"); + input.type = "text"; + input.autocomplete = "username"; + input.style.cssText = "position:fixed;left:24px;top:24px;width:240px;height:32px;opacity:0;pointer-events:none;"; + target.appendChild(input); + input.focus(); + const points = [[52, 48], [96, 52], [148, 60], [212, 68]]; + for (const [x, y] of points) { + const eventInit = { bubbles: true, cancelable: true, clientX: x, clientY: y, screenX: x + 16, screenY: y + 88, pointerType: "mouse", isPrimary: true, buttons: 0 }; + if (typeof win.PointerEvent === "function") { + target.dispatchEvent(new win.PointerEvent("pointermove", eventInit)); + } else { + target.dispatchEvent(new win.MouseEvent("mousemove", eventInit)); + } + await sleep(20); + } + const downInit = { bubbles: true, cancelable: true, clientX: 96, clientY: 52, screenX: 112, screenY: 140, pointerType: "mouse", isPrimary: true, buttons: 1 }; + if (typeof win.PointerEvent === "function") { + input.dispatchEvent(new win.PointerEvent("pointerdown", downInit)); + input.dispatchEvent(new win.PointerEvent("pointerup", { ...downInit, buttons: 0 })); + } + input.dispatchEvent(new win.MouseEvent("mousedown", downInit)); + input.dispatchEvent(new win.MouseEvent("mouseup", { ...downInit, buttons: 0 })); + input.dispatchEvent(new win.MouseEvent("click", { ...downInit, buttons: 0 })); + const chars = identifier || "x"; + const valueSetter = Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value").set; + for (const ch of chars.slice(0, 16)) { + input.dispatchEvent(new win.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: ch })); + valueSetter.call(input, input.value + ch); + input.dispatchEvent(new win.InputEvent("input", { bubbles: true, inputType: "insertText", data: ch })); + input.dispatchEvent(new win.KeyboardEvent("keyup", { bubbles: true, cancelable: true, key: ch })); + await sleep(15); + } + input.dispatchEvent(new win.Event("change", { bubbles: true })); + await sleep(100); + } + + async function loadFetchedContextFrame() { + const resp = await fetch(contextURL, { credentials: "include" }); + if (!resp.ok) { + throw new Error("Failed to fetch X login context: HTTP " + resp.status); + } + const html = await resp.text(); + const iframe = document.createElement("iframe"); + iframe.tabIndex = -1; + iframe.style.cssText = "position:fixed;left:-10000px;top:-10000px;width:1024px;height:768px;border:0;opacity:0;pointer-events:none;"; + document.documentElement.appendChild(iframe); + const win = iframe.contentWindow; + const doc = iframe.contentDocument; + if (!win || !doc) { + throw new Error("Synthetic X login context is not accessible"); + } + doc.open(); + doc.write(html); + doc.close(); + try { + win.history.replaceState(null, "", contextURL); + } catch (_) {} + await waitFor(() => doc.readyState === "interactive" || doc.readyState === "complete", "Synthetic X login context did not become ready", 15000); + return { win, doc }; + } + + function copyCookies(result, cookieText) { + for (const part of cookieText.split(";")) { + const idx = part.indexOf("="); + if (idx <= 0) { + continue; + } + const name = part.slice(0, idx).trim(); + if (cookieNames.has(name)) { + result[name] = part.slice(idx + 1).trim(); + } + } + } + + function quoteClientHint(value) { + return '"' + String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; + } + + function captureBrowserHeaders() { + const headers = { + browser_user_agent: String(navigator.userAgent || ""), + }; + const userAgentData = navigator.userAgentData; + if (!userAgentData) { + return headers; + } + const brands = Array.from(userAgentData.brands || []); + if (brands.length > 0) { + headers.browser_sec_ch_ua = brands.map(item => + quoteClientHint(item.brand) + ";v=" + quoteClientHint(item.version) + ).join(", "); + } + if (userAgentData.platform) { + headers.browser_sec_ch_ua_platform = quoteClientHint(userAgentData.platform); + } + headers.browser_sec_ch_ua_mobile = userAgentData.mobile ? "?1" : "?0"; + return headers; + } + + try { + browserLog("loading X context"); + const context = await loadFetchedContextFrame(); + browserLog("context ready"); + browserLog("creating module token"); + showBrowserLoginStatus("Completing sign-in..."); + await synthesizeCastleActivity(context.win, context.doc); + const castleTokens = await createTokenInContext(context.win, context.doc); + const castleToken = castleTokens[0] || ""; + browserLog("module tokens ready, count " + castleTokens.length + ", first length " + String(castleToken || "").length); + const result = { castle_token: castleToken }; + Object.assign(result, captureBrowserHeaders()); + for (let index = 1; index < castleTokens.length; index++) { + result[castleTokenResultKey(index + 1)] = castleTokens[index]; + } + copyCookies(result, document.cookie); + copyCookies(result, context.doc.cookie); + storeBrowserAuthResult(result); + browserLog("result stored"); + showBrowserLoginStatus("Finishing..."); + return result; + } catch (err) { + showBrowserLoginStatus("Unable to finish signing in."); + try { + globalThis.__MAUTRIX_TWITTER_CASTLE_IN_PROGRESS__ = false; + } catch (_) {} + throw err; + } +})(__MAUTRIX_TWITTER_CASTLE_CONFIG__) diff --git a/pkg/connector/castle_token_webview.go b/pkg/connector/castle_token_webview.go new file mode 100644 index 00000000..29e40af3 --- /dev/null +++ b/pkg/connector/castle_token_webview.go @@ -0,0 +1,44 @@ +package connector + +import ( + _ "embed" + "encoding/json" + "fmt" + "strings" + + "go.mau.fi/mautrix-twitter/pkg/twittermeow" +) + +const castleTokenJSConfigPlaceholder = "__MAUTRIX_TWITTER_CASTLE_CONFIG__" + +//go:embed castle_token.js +var castleTokenExtractJSSource string + +type castleTokenJSConfig struct { + ScriptURL string `json:"scriptURL"` + PublicKey string `json:"publicKey"` + CookieNames []string `json:"cookieNames"` + ContextURL string `json:"contextURL"` + Identifier string `json:"identifier"` + BatchSize int `json:"castleTokenBatchSize"` +} + +func castleTokenExtractJS(info twittermeow.JetfuelCastleTokenInfo, identifier string) string { + config, err := json.Marshal(castleTokenJSConfig{ + ScriptURL: info.ScriptURL, + PublicKey: info.PublicKey, + CookieNames: castleTokenCookieNames, + ContextURL: castleTokenContextURL, + Identifier: identifier, + BatchSize: castleTokenBatchSize, + }) + if err != nil { + panic(fmt.Errorf("marshal Castle token extraction config: %w", err)) + } + + script := strings.TrimRight(castleTokenExtractJSSource, "\r\n") + if strings.Count(script, castleTokenJSConfigPlaceholder) != 1 { + panic("Castle token extraction script must contain exactly one config placeholder") + } + return strings.Replace(script, castleTokenJSConfigPlaceholder, string(config), 1) +} diff --git a/pkg/connector/client.go b/pkg/connector/client.go index e7d122a4..9360d225 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -56,6 +56,10 @@ type TwitterClient struct { ensurePortalLocks sync.Map pollingChatResyncLast sync.Map + + connectLock sync.Mutex + connectCancel context.CancelFunc + connectRunLock sync.Mutex } var _ bridgev2.NetworkAPI = (*TwitterClient)(nil) @@ -98,6 +102,9 @@ func (tc *TwitterConnector) LoadUserLogin(ctx context.Context, login *bridgev2.U c := cookies.NewCookiesFromString(meta.Cookies) log := login.Log.With().Str("component", "twitter_client").Logger() client := twittermeow.NewClient(c, newUserLoginKeyStore(login, tc), log) + if meta.BrowserHeaders != nil { + client.SetBrowserHeaders(*meta.BrowserHeaders) + } client.SetCurrentUserID(ParseUserLoginID(login.ID)) login.Client = NewTwitterClient(login, tc, client) return nil @@ -109,6 +116,42 @@ const ( ) func (tc *TwitterClient) Connect(ctx context.Context) { + // Bridge startup waits for every NetworkAPI.Connect call, while inbox import may take minutes. + tc.startConnect(ctx, tc.connect) +} + +func (tc *TwitterClient) startConnect(ctx context.Context, connect func(context.Context)) { + connectCtx, cancel := context.WithCancel(ctx) + tc.connectLock.Lock() + previousCancel := tc.connectCancel + tc.connectCancel = cancel + tc.connectLock.Unlock() + if previousCancel != nil { + previousCancel() + } + go func() { + tc.connectRunLock.Lock() + defer tc.connectRunLock.Unlock() + if connectCtx.Err() == nil { + connect(connectCtx) + } + }() +} + +func (tc *TwitterClient) cancelConnect() { + tc.connectLock.Lock() + cancel := tc.connectCancel + tc.connectCancel = nil + tc.connectLock.Unlock() + if cancel != nil { + cancel() + } +} + +func (tc *TwitterClient) connect(ctx context.Context) { + if ctx.Err() != nil { + return + } log := zerolog.Ctx(ctx) if tc.client == nil { @@ -163,6 +206,9 @@ func (tc *TwitterClient) Connect(ctx context.Context) { // Load messages page to initialize session (populates cookies, tokens, etc.) _, err := tc.client.LoadMessagesPage(ctx) if err != nil { + if ctx.Err() != nil { + return + } log.Err(err).Msg("Failed to load messages page") if twittermeow.IsAuthError(err) { tc.userLogin.BridgeState.Send(status.BridgeState{ @@ -316,6 +362,9 @@ func (tc *TwitterClient) Connect(ctx context.Context) { initialResp, err := tc.client.GetInitialXChatPage(ctx, vars) if err != nil { + if ctx.Err() != nil { + return + } fetchLog.Err(err). Msg("Failed to fetch initial XChat inbox page") tc.userLogin.BridgeState.Send(status.BridgeState{ @@ -338,6 +387,9 @@ func (tc *TwitterClient) Connect(ctx context.Context) { updatePageState(page) processPage(page) + if ctx.Err() != nil { + return + } cursor = nextXChatInboxCursor(page) tc.saveXChatInboxCheckpoint(ctx, cursor, getMaxSeqID(), msgPullVersion) @@ -361,6 +413,9 @@ func (tc *TwitterClient) Connect(ctx context.Context) { inboxVars := payload.NewInboxPageRequestQueryVariables(cursor) resp, err := tc.client.GetInboxPageRequest(ctx, inboxVars) if err != nil { + if ctx.Err() != nil { + return + } fetchLog.Err(err). Msg("Failed to fetch XChat inbox page") tc.userLogin.BridgeState.Send(status.BridgeState{ @@ -383,6 +438,9 @@ func (tc *TwitterClient) Connect(ctx context.Context) { updatePageState(page) processPage(page) + if ctx.Err() != nil { + return + } nextCursor := nextXChatInboxCursor(page) if nextCursor != nil && nextCursor.CursorId == cursor.CursorId { @@ -418,9 +476,15 @@ func (tc *TwitterClient) Connect(ctx context.Context) { log.Warn().Err(err).Msg("Failed to fetch some missing users") } } + if ctx.Err() != nil { + return + } // Start XChat websocket for real-time events after initial sync if err := tc.client.StartXChatWebsocket(ctx); err != nil { + if ctx.Err() != nil { + return + } log.Err(err).Msg("Failed to start XChat websocket") } @@ -428,6 +492,9 @@ func (tc *TwitterClient) Connect(ctx context.Context) { Int("conversations", int(totalItems.Load())). Msg("Finished fetching XChat inbox") + if ctx.Err() != nil { + return + } tc.userLogin.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnected}) // Update remote profile from cached user data @@ -603,6 +670,7 @@ func (tc *TwitterClient) saveUserLoginState(ctx context.Context) error { } func (tc *TwitterClient) Disconnect() { + tc.cancelConnect() tc.client.Disconnect() } @@ -663,6 +731,12 @@ func (tc *TwitterClient) HandleConversationDataRefresh(ctx context.Context, conv if item == nil { return } + if tc.connector.br.BackgroundCtx != nil { + ctx = tc.userLogin.Log.WithContext(tc.connector.br.BackgroundCtx) + } + if ctx.Err() != nil { + return + } log := zerolog.Ctx(ctx).With(). Str("conversation_id", conversationID). diff --git a/pkg/connector/client_connect_test.go b/pkg/connector/client_connect_test.go new file mode 100644 index 00000000..fdbde745 --- /dev/null +++ b/pkg/connector/client_connect_test.go @@ -0,0 +1,62 @@ +package connector + +import ( + "context" + "testing" + "time" +) + +func waitForConnectSignal(t *testing.T, signal <-chan struct{}, name string) { + t.Helper() + select { + case <-signal: + case <-time.After(time.Second): + t.Fatalf("timed out waiting for %s", name) + } +} + +func TestStartConnectRunsInBackgroundAndCancelsPreviousWorker(t *testing.T) { + tc := &TwitterClient{} + firstStarted := make(chan struct{}) + firstCanceled := make(chan struct{}) + firstStartReturned := make(chan struct{}) + go func() { + tc.startConnect(context.Background(), func(ctx context.Context) { + close(firstStarted) + <-ctx.Done() + close(firstCanceled) + }) + close(firstStartReturned) + }() + waitForConnectSignal(t, firstStartReturned, "first startConnect call to return") + waitForConnectSignal(t, firstStarted, "first connect worker to start") + + secondStarted := make(chan struct{}) + secondCanceled := make(chan struct{}) + tc.startConnect(context.Background(), func(ctx context.Context) { + close(secondStarted) + <-ctx.Done() + close(secondCanceled) + }) + waitForConnectSignal(t, firstCanceled, "first connect worker to be canceled") + waitForConnectSignal(t, secondStarted, "second connect worker to start") + + tc.cancelConnect() + waitForConnectSignal(t, secondCanceled, "second connect worker to be canceled") +} + +func TestStartConnectWorkerStopsWithParentContext(t *testing.T) { + tc := &TwitterClient{} + parentCtx, cancelParent := context.WithCancel(context.Background()) + workerStarted := make(chan struct{}) + workerCanceled := make(chan struct{}) + tc.startConnect(parentCtx, func(ctx context.Context) { + close(workerStarted) + <-ctx.Done() + close(workerCanceled) + }) + waitForConnectSignal(t, workerStarted, "connect worker to start") + + cancelParent() + waitForConnectSignal(t, workerCanceled, "connect worker to observe parent cancellation") +} diff --git a/pkg/connector/client_http_step.go b/pkg/connector/client_http_step.go new file mode 100644 index 00000000..67b01215 --- /dev/null +++ b/pkg/connector/client_http_step.go @@ -0,0 +1,28 @@ +package connector + +import ( + "bytes" + "fmt" + + "maunium.net/go/mautrix/bridgev2" + + "go.mau.fi/mautrix-twitter/pkg/twittermeow" +) + +func makeClientHTTPRequestStep(request *twittermeow.ClientHTTPRequest) (*bridgev2.LoginStep, error) { + if request == nil { + return nil, fmt.Errorf("client HTTP request is missing") + } + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeClientHTTP, + StepID: LoginStepIDClientHTTPRequest, + Instructions: "Sending an X sign-in request from this device. Client HTTP is in beta.", + ClientHTTPParams: &bridgev2.LoginClientHTTPParams{ + RequestID: request.ID, + Method: request.Method, + URL: request.URL, + Headers: request.Headers.Clone(), + Body: bytes.Clone(request.Body), + }, + }, nil +} diff --git a/pkg/connector/config.go b/pkg/connector/config.go index fa2f75ac..8335c5fa 100644 --- a/pkg/connector/config.go +++ b/pkg/connector/config.go @@ -2,6 +2,7 @@ package connector import ( _ "embed" + "fmt" "strings" "text/template" @@ -12,13 +13,22 @@ import ( //go:embed example-config.yaml var ExampleConfig string +type LoginFlow string + +const ( + LoginFlowWebView LoginFlow = "webview" + LoginFlowNative LoginFlow = "native" + LoginFlowClientHTTP LoginFlow = "client_http" +) + type Config struct { Proxy string `yaml:"proxy"` GetProxyURL string `yaml:"get_proxy_url"` - DisplaynameTemplate string `yaml:"displayname_template"` - ConversationSyncLimit int `yaml:"conversation_sync_limit"` - CacheSession bool `yaml:"cache_session"` + DisplaynameTemplate string `yaml:"displayname_template"` + ConversationSyncLimit int `yaml:"conversation_sync_limit"` + CacheSession bool `yaml:"cache_session"` + LoginFlow LoginFlow `yaml:"login_flow"` X bool `yaml:"x"` @@ -36,17 +46,39 @@ func (c *Config) UnmarshalYAML(node *yaml.Node) error { } func (c *Config) PostProcess() error { + if c.LoginFlow == "" { + c.LoginFlow = LoginFlowWebView + } + switch c.LoginFlow { + case LoginFlowWebView, LoginFlowNative, LoginFlowClientHTTP: + default: + return fmt.Errorf("invalid login_flow %q", c.LoginFlow) + } var err error c.displaynameTemplate, err = template.New("displayname").Parse(c.DisplaynameTemplate) return err } +func (c *Config) EffectiveLoginFlow() LoginFlow { + if c == nil || c.LoginFlow == "" { + return LoginFlowWebView + } + return c.LoginFlow +} + func upgradeConfig(helper up.Helper) { helper.Copy(up.Str|up.Null, "proxy") helper.Copy(up.Str|up.Null, "get_proxy_url") helper.Copy(up.Str, "displayname_template") helper.Copy(up.Int, "conversation_sync_limit") helper.Copy(up.Bool, "cache_session") + if _, ok := helper.Get(up.Str, "login_flow"); ok { + helper.Copy(up.Str, "login_flow") + } else if clientHTTPLogin, ok := helper.Get(up.Bool, "client_http_login"); ok && clientHTTPLogin == "true" { + helper.Set(up.Str, string(LoginFlowClientHTTP), "login_flow") + } else if nativeLogin, ok := helper.Get(up.Bool, "native_login"); ok && nativeLogin == "true" { + helper.Set(up.Str, string(LoginFlowNative), "login_flow") + } helper.Copy(up.Bool, "x") } diff --git a/pkg/connector/config_test.go b/pkg/connector/config_test.go new file mode 100644 index 00000000..5dd90005 --- /dev/null +++ b/pkg/connector/config_test.go @@ -0,0 +1,93 @@ +package connector + +import ( + "os" + "path/filepath" + "testing" + + up "go.mau.fi/util/configupgrade" + "gopkg.in/yaml.v3" +) + +func TestExampleConfigUsesWebViewLoginByDefault(t *testing.T) { + var raw map[string]any + if err := yaml.Unmarshal([]byte(ExampleConfig), &raw); err != nil { + t.Fatalf("failed to parse example config: %v", err) + } + value, ok := raw["login_flow"] + if !ok { + t.Fatal("example config is missing login_flow") + } + if value != string(LoginFlowWebView) { + t.Fatalf("login_flow = %#v, want %q", value, LoginFlowWebView) + } + + var config Config + if err := yaml.Unmarshal([]byte(ExampleConfig), &config); err != nil { + t.Fatalf("failed to unmarshal example config: %v", err) + } + if config.LoginFlow != LoginFlowWebView { + t.Fatalf("Config.LoginFlow = %q, want %q", config.LoginFlow, LoginFlowWebView) + } +} + +func TestConfigAcceptsLoginFlows(t *testing.T) { + for _, flow := range []LoginFlow{LoginFlowWebView, LoginFlowNative, LoginFlowClientHTTP} { + t.Run(string(flow), func(t *testing.T) { + var config Config + if err := yaml.Unmarshal([]byte("login_flow: "+string(flow)+"\n"), &config); err != nil { + t.Fatalf("failed to unmarshal config: %v", err) + } + if config.LoginFlow != flow { + t.Fatalf("Config.LoginFlow = %q, want %q", config.LoginFlow, flow) + } + }) + } + + var config Config + if err := yaml.Unmarshal([]byte("login_flow: proxy_magic\n"), &config); err == nil { + t.Fatal("invalid login_flow was accepted") + } +} + +func TestConfigUpgradeHandlesLoginFlow(t *testing.T) { + tests := []struct { + name string + data string + want LoginFlow + }{ + {name: "missing defaults to webview", data: "x: true\n", want: LoginFlowWebView}, + {name: "webview is preserved", data: "login_flow: webview\n", want: LoginFlowWebView}, + {name: "native is preserved", data: "login_flow: native\n", want: LoginFlowNative}, + {name: "client HTTP is preserved", data: "login_flow: client_http\n", want: LoginFlowClientHTTP}, + {name: "legacy client HTTP true migrates", data: "client_http_login: true\n", want: LoginFlowClientHTTP}, + {name: "legacy client HTTP takes precedence", data: "client_http_login: true\nnative_login: true\n", want: LoginFlowClientHTTP}, + {name: "legacy native true migrates", data: "native_login: true\n", want: LoginFlowNative}, + {name: "legacy native false stays webview", data: "native_login: false\n", want: LoginFlowWebView}, + {name: "legacy client HTTP false stays webview", data: "client_http_login: false\n", want: LoginFlowWebView}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(configPath, []byte(test.data), 0o600); err != nil { + t.Fatalf("failed to write source config: %v", err) + } + upgrader := &up.StructUpgrader{ + SimpleUpgrader: up.SimpleUpgrader(upgradeConfig), + Base: ExampleConfig, + } + output, _, err := up.Do(configPath, false, upgrader) + if err != nil { + t.Fatalf("failed to upgrade config: %v", err) + } + + var config Config + if err = yaml.Unmarshal(output, &config); err != nil { + t.Fatalf("failed to unmarshal upgraded config: %v", err) + } + if config.LoginFlow != test.want { + t.Fatalf("Config.LoginFlow = %q, want %q", config.LoginFlow, test.want) + } + }) + } +} diff --git a/pkg/connector/dbmeta.go b/pkg/connector/dbmeta.go index 8e9419c4..c8984e01 100644 --- a/pkg/connector/dbmeta.go +++ b/pkg/connector/dbmeta.go @@ -67,11 +67,12 @@ func (m *PortalMetadata) CanBackfillXChat() bool { } type UserLoginMetadata struct { - Cookies string `json:"cookies"` - SecretKey string `json:"secret_key,omitempty"` - SigningKey string `json:"signing_key,omitempty"` - SigningKeyVersion string `json:"signing_key_version,omitempty"` - PushKeys *PushKeys `json:"push_keys,omitempty"` + Cookies string `json:"cookies"` + SecretKey string `json:"secret_key,omitempty"` + SigningKey string `json:"signing_key,omitempty"` + SigningKeyVersion string `json:"signing_key_version,omitempty"` + PushKeys *PushKeys `json:"push_keys,omitempty"` + BrowserHeaders *twittermeow.BrowserHeaders `json:"browser_headers,omitempty"` Session *twittermeow.CachedSession `json:"session,omitempty"` MaxUserSequenceID string `json:"max_user_sequence_id,omitempty"` // Last processed sequence ID for incremental inbox fetching diff --git a/pkg/connector/example-config.yaml b/pkg/connector/example-config.yaml index 82fcfe2c..2d843739 100644 --- a/pkg/connector/example-config.yaml +++ b/pkg/connector/example-config.yaml @@ -14,6 +14,12 @@ conversation_sync_limit: 20 # Should the bridge cache sessions instead of resyncing chats on every restart? cache_session: true +# Login implementation to offer: +# - webview: sign in through X's visible login page. +# - native: build and send login requests from the bridge. +# - client_http: build requests on the bridge, but send them directly from the Beeper client. +login_flow: webview + # Should the bridge use "X" instead of "Twitter" in certain places, # such as the management room welcome message and MSC2346 bridge info? x: false diff --git a/pkg/connector/login.go b/pkg/connector/login.go index a426f60c..b7661a9e 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -18,6 +18,7 @@ package connector import ( "context" + "encoding/base64" "errors" "fmt" "net/http" @@ -25,7 +26,9 @@ import ( "strconv" "strings" "time" + "unicode" + "github.com/rs/zerolog" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/status" @@ -49,17 +52,221 @@ type TwitterLogin struct { tc *TwitterConnector isMigration bool // True if upgrading from main branch (had cookies but no encryption keys) needsPINSetup bool - - client *twittermeow.Client - profile twittermeow.CurrentUserProfile + useCookieLogin bool + + useClientHTTPLogin bool + + client *twittermeow.Client + webLogin *twittermeow.WebLoginSession + webLoginIdentifier string + webLoginPassword string + webLoginCastleStage string + webLoginAuthMethod string + webLoginText string + webLoginChallenge *twittermeow.WebLoginChallenge + webLoginMethods []twittermeow.WebLoginAuthMethod + browserHeaders twittermeow.BrowserHeaders + profile twittermeow.CurrentUserProfile + clientHTTPTransport *twittermeow.ClientHTTPTransport + clientHTTPStage string } var ( - LoginStepIDCookies = "fi.mau.twitter.login.enter_cookies" - LoginStepJuiceboxPIN = "fi.mau.twitter.login.juicebox_pin" - LoginStepIDComplete = "fi.mau.twitter.login.complete" + LoginFlowIDPassword = "password" + LoginFlowIDCookies = "cookies" + LoginStepIDCredentials = "fi.mau.twitter.login.enter_credentials" + LoginStepIDCastleToken = "fi.mau.twitter.login.castle_token" + LoginStepIDVerification = "fi.mau.twitter.login.enter_verification" + LoginStepIDAuthMethod = "fi.mau.twitter.login.select_auth_method" + LoginStepIDCookies = "fi.mau.twitter.login.enter_cookies" + LoginStepJuiceboxPIN = "fi.mau.twitter.login.juicebox_pin" + LoginStepIDComplete = "fi.mau.twitter.login.complete" + loginFieldIdentifier = "identifier" + loginFieldPassword = "password" + loginFieldCastleToken = "castle_token" + loginFieldBrowserUserAgent = "browser_user_agent" + loginFieldBrowserSecCHUA = "browser_sec_ch_ua" + loginFieldBrowserPlatform = "browser_sec_ch_ua_platform" + loginFieldBrowserMobile = "browser_sec_ch_ua_mobile" + loginFieldVerificationCode = "verification_code" + loginFieldAuthMethod = "auth_method" +) + +var ( + LoginFlowIDClientHTTP = "client-http" + LoginStepIDClientHTTPRequest = "fi.mau.twitter.login.client_http" +) + +const ( + webLoginCastleStageIdentifier = "identifier" + webLoginCastleStagePassword = "password" + webLoginCastleStageCombined = "combined" + webLoginCastleStageBeginTwoFactor = "begin_two_factor" + webLoginCastleStageAuthMethod = "auth_method" + webLoginCastleStageText = "text" + castleTokenWebviewURL = "https://x.com/robots.txt" + castleTokenContextURL = "https://x.com/i/jf/onboarding/web?mode=login" + castleTokenHeaderPrefix = "text/plain, application/x-mautrix-twitter-castle;v=" + castleTokenBatchSize = 8 ) +var castleTokenCookieNames = []string{ + "__cf_bm", + "__cuid", + "gt", + "guest_id", + "guest_id_ads", + "guest_id_marketing", + "personalization_id", +} + +type browserHeaderField struct { + ID string + HeaderName string + Required bool + Pattern string +} + +var browserHeaderFields = []browserHeaderField{ + { + ID: loginFieldBrowserUserAgent, + HeaderName: "user-agent", + Required: true, + Pattern: `^[^\r\n]{1,1024}$`, + }, + { + ID: loginFieldBrowserSecCHUA, + HeaderName: "sec-ch-ua", + Pattern: `^[^\r\n]{1,1024}$`, + }, + { + ID: loginFieldBrowserPlatform, + HeaderName: "sec-ch-ua-platform", + Pattern: `^[^\r\n]{1,1024}$`, + }, + { + ID: loginFieldBrowserMobile, + HeaderName: "sec-ch-ua-mobile", + Pattern: `^\?[01]$`, + }, +} + +func decodeCastleTokenInput(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", nil + } + if idx := strings.Index(value, castleTokenHeaderPrefix); idx >= 0 { + encoded := removeCastleTokenWhitespace(value[idx+len(castleTokenHeaderPrefix):]) + decoded, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return "", fmt.Errorf("decode Castle header token: %w", err) + } + value = string(decoded) + } + return removeCastleTokenWhitespace(value), nil +} + +func removeCastleTokenWhitespace(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return -1 + } + return r + }, value) +} + +func castleTokenFieldID(index int) string { + if index <= 1 { + return loginFieldCastleToken + } + return fmt.Sprintf("%s_%d", loginFieldCastleToken, index) +} + +func castleTokenCookieFields() []bridgev2.LoginCookieField { + fields := []bridgev2.LoginCookieField{ + { + ID: loginFieldCastleToken, + Required: true, + Pattern: `^[\s\S]{128,}$`, + Sources: []bridgev2.LoginCookieFieldSource{ + { + Type: bridgev2.LoginCookieTypeSpecial, + Name: "fi.mau.twitter.castle_token", + }, + { + Type: bridgev2.LoginCookieTypeLocalStorage, + Name: "fi.mau.twitter.castle_token", + }, + }, + }, + } + for index := 2; index <= castleTokenBatchSize; index++ { + fieldID := castleTokenFieldID(index) + storageKey := fmt.Sprintf("fi.mau.twitter.castle_token_%d", index) + fields = append(fields, bridgev2.LoginCookieField{ + ID: fieldID, + Required: false, + Pattern: `^[\s\S]{128,}$`, + Sources: []bridgev2.LoginCookieFieldSource{ + { + Type: bridgev2.LoginCookieTypeSpecial, + Name: storageKey, + }, + { + Type: bridgev2.LoginCookieTypeLocalStorage, + Name: storageKey, + }, + }, + }) + } + for _, field := range browserHeaderFields { + fields = append(fields, bridgev2.LoginCookieField{ + ID: field.ID, + Required: field.Required, + Pattern: field.Pattern, + Sources: []bridgev2.LoginCookieFieldSource{ + { + Type: bridgev2.LoginCookieTypeRequestHeader, + Name: field.HeaderName, + RequestURLRegex: `^https://x\.com/`, + }, + { + Type: bridgev2.LoginCookieTypeSpecial, + Name: field.ID, + }, + }, + }) + } + for _, name := range castleTokenCookieNames { + fields = append(fields, bridgev2.LoginCookieField{ + ID: name, + Required: false, + Sources: []bridgev2.LoginCookieFieldSource{ + { + Type: bridgev2.LoginCookieTypeCookie, + Name: name, + CookieDomain: "x.com", + }, + { + Type: bridgev2.LoginCookieTypeCookie, + Name: name, + CookieDomain: ".x.com", + }, + { + Type: bridgev2.LoginCookieTypeSpecial, + Name: "fi.mau.twitter.cookie." + name, + }, + { + Type: bridgev2.LoginCookieTypeLocalStorage, + Name: "fi.mau.twitter.cookie." + name, + }, + }, + }) + } + return fields +} + var _ bridgev2.LoginProcessCookies = (*TwitterLogin)(nil) var _ bridgev2.LoginProcessUserInput = (*TwitterLogin)(nil) var _ bridgev2.LoginProcessWithOverride = (*TwitterLogin)(nil) @@ -106,33 +313,73 @@ var ( Err: "Couldn't read your X account ID. Please try again.", StatusCode: http.StatusInternalServerError, } + ErrMissingLoginInput = bridgev2.RespError{ + ErrCode: "FI.MAU.TWITTER.MISSING_LOGIN_INPUT", + Err: "Missing required login input.", + StatusCode: http.StatusBadRequest, + } + ErrWebLoginFailed = bridgev2.RespError{ + ErrCode: "FI.MAU.TWITTER.LOGIN_FAILED", + Err: "X login failed.", + StatusCode: http.StatusBadGateway, + } ) func (tc *TwitterConnector) GetLoginFlows() []bridgev2.LoginFlow { - return []bridgev2.LoginFlow{ - { + switch tc.Config.EffectiveLoginFlow() { + case LoginFlowClientHTTP: + return []bridgev2.LoginFlow{{ + Name: "Client HTTP (Beta)", + Description: "Log in with your X username, email, or phone number and run sign-in requests on this device", + ID: LoginFlowIDClientHTTP, + }} + case LoginFlowNative: + return []bridgev2.LoginFlow{{ + Name: "Username/password", + Description: "Log in with your X username, email, or phone number and password", + ID: LoginFlowIDPassword, + }} + default: + return []bridgev2.LoginFlow{{ Name: "Cookies", Description: "Log in with your X account using your cookies", - ID: "cookies", - }, + ID: LoginFlowIDCookies, + }} } } func (tc *TwitterConnector) CreateLogin(_ context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { - if flowID != "cookies" { + configuredFlowID := LoginFlowIDCookies + switch tc.Config.EffectiveLoginFlow() { + case LoginFlowNative: + configuredFlowID = LoginFlowIDPassword + case LoginFlowClientHTTP: + configuredFlowID = LoginFlowIDClientHTTP + } + if flowID == "" { + flowID = configuredFlowID + } + if flowID != configuredFlowID { return nil, bridgev2.ErrInvalidLoginFlowID } - return &TwitterLogin{User: user, tc: tc}, nil + return &TwitterLogin{ + User: user, + tc: tc, + useCookieLogin: flowID == LoginFlowIDCookies, + useClientHTTPLogin: flowID == LoginFlowIDClientHTTP, + }, nil } func (t *TwitterLogin) Start(_ context.Context) (*bridgev2.LoginStep, error) { + if !t.useCookieLogin { + return makeCredentialsStep(""), nil + } return &bridgev2.LoginStep{ Type: bridgev2.LoginStepTypeCookies, StepID: LoginStepIDCookies, Instructions: "Open the Login URL in an Incognito/Private browsing mode. Then, extract the cookies as a JSON object/cURL command copied from the Network tab of your browser's DevTools. After that, close the browser **before** pasting the cookies.\n\nFor example: `{\"ct0\":\"123466-...\",\"auth_token\":\"abcde-...\"}`", CookiesParams: &bridgev2.LoginCookiesParams{ - URL: "https://x.com/i/flow/login", - UserAgent: "", + URL: "https://x.com/i/flow/login", Fields: []bridgev2.LoginCookieField{ { ID: "ct0", @@ -153,7 +400,155 @@ func (t *TwitterLogin) Start(_ context.Context) (*bridgev2.LoginStep, error) { }, nil } -func (t *TwitterLogin) Cancel() {} +func (t *TwitterLogin) Cancel() { + t.stopClientHTTPLogin() +} + +func makeCredentialsStep(errorLine string) *bridgev2.LoginStep { + instructions := "Enter your X username, email, or phone number and password." + if errorLine != "" { + instructions = fmt.Sprintf("%s\n\n%s", errorLine, instructions) + } + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: LoginStepIDCredentials, + Instructions: instructions, + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{ + { + Type: bridgev2.LoginInputFieldTypeUsername, + ID: loginFieldIdentifier, + Name: "Username, email, or phone", + Description: "The identifier you use to sign in to X.", + }, + { + Type: bridgev2.LoginInputFieldTypePassword, + ID: loginFieldPassword, + Name: "Password", + }, + }, + }, + } +} + +func makeCastleTokenStep(info twittermeow.JetfuelCastleTokenInfo, identifier, errorLine string) *bridgev2.LoginStep { + instructions := "Generating an X browser token for this login." + if errorLine != "" { + instructions = fmt.Sprintf("%s\n\n%s", errorLine, instructions) + } + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeCookies, + StepID: LoginStepIDCastleToken, + Instructions: instructions, + CookiesParams: &bridgev2.LoginCookiesParams{ + URL: castleTokenWebviewURL, + ExtractJS: castleTokenExtractJS(info, identifier), + WaitForURLPattern: `^https://x\.com/robots\.txt$`, + Fields: castleTokenCookieFields(), + Hidden: true, + }, + } +} + +func (t *TwitterLogin) makeWebLoginCastleTokenStep(errorLine string) *bridgev2.LoginStep { + if t.webLogin == nil || t.webLogin.Client() == nil { + t.webLoginCastleStage = "" + return makeCredentialsStep("The X login session expired. Enter your X login details again.") + } + info := t.webLogin.Client().JetfuelCastleTokenInfo() + if !info.IsValid() { + t.webLoginCastleStage = "" + t.User.Log.Warn().Msg("X Castle web metadata missing from login page bootstrap") + return makeCredentialsStep("X did not provide the browser-token metadata needed for native login. Try again.") + } + return makeCastleTokenStep(info, t.webLoginIdentifier, errorLine) +} + +func makeVerificationStep(challenge *twittermeow.WebLoginChallenge, errorLine string) *bridgev2.LoginStep { + instructions := "X needs additional verification for this login." + fieldName := "Verification" + fieldType := bridgev2.LoginInputFieldTypeToken + if challenge != nil { + if challenge.Description != "" { + instructions = challenge.Description + } else if challenge.Hint != "" { + instructions = challenge.Hint + } + switch challenge.InputKind { + case twittermeow.WebLoginChallengeInputKindPhoneNumber: + fieldName = "Phone number" + fieldType = bridgev2.LoginInputFieldTypePhoneNumber + if instructions == "" { + instructions = "Enter the phone number associated with your X account." + } + case twittermeow.WebLoginChallengeInputKindCode: + fieldName = "Verification code" + fieldType = bridgev2.LoginInputFieldType2FACode + if instructions == "" { + instructions = "Enter the verification code from X." + } + default: + if challenge.IsTwoFactor { + fieldName = "Verification code" + fieldType = bridgev2.LoginInputFieldType2FACode + if instructions == "" { + instructions = "Enter the verification code from X." + } + } + } + } + if errorLine != "" { + instructions = fmt.Sprintf("%s\n\n%s", errorLine, instructions) + } + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: LoginStepIDVerification, + Instructions: instructions, + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{ + { + Type: fieldType, + ID: loginFieldVerificationCode, + Name: fieldName, + }, + }, + }, + } +} + +func makeAuthMethodStep(methods []twittermeow.WebLoginAuthMethod, errorLine string) *bridgev2.LoginStep { + instructions := "Choose how to verify this X login." + if errorLine != "" { + instructions = fmt.Sprintf("%s\n\n%s", errorLine, instructions) + } + options := make([]string, 0, len(methods)) + for _, method := range methods { + if !method.Supported { + continue + } + if method.Name == "" { + options = append(options, method.ID) + } else { + options = append(options, method.Name) + } + } + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: LoginStepIDAuthMethod, + Instructions: instructions, + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{ + { + Type: bridgev2.LoginInputFieldTypeSelect, + ID: loginFieldAuthMethod, + Name: "Verification method", + Description: "Choose the method X should use for this login.", + Options: options, + }, + }, + }, + } +} func makePINStep(errorLine string, isSetup bool) *bridgev2.LoginStep { instructions := passcodeBodyRecover @@ -192,6 +587,9 @@ func (t *TwitterLogin) StartWithOverride(ctx context.Context, override *bridgev2 // Migration case: validate existing cookies and skip to passcode t.Cookies = meta.Cookies + if meta.BrowserHeaders != nil { + t.browserHeaders = *meta.BrowserHeaders + } if err := t.ensureClientForPIN(ctx); err != nil { // Cookies expired, fall back to normal flow t.User.Log.Warn().Err(err).Msg("Migration: cookies invalid, falling back to full login") @@ -207,6 +605,9 @@ func (t *TwitterLogin) StartWithOverride(ctx context.Context, override *bridgev2 } func (t *TwitterLogin) SubmitCookies(ctx context.Context, cookies map[string]string) (*bridgev2.LoginStep, error) { + if t.isWaitingForWebLoginCastleToken() { + return t.submitWebCastleTokenInput(ctx, cookies) + } cookieStruct := twitCookies.NewCookies(cookies) t.Cookies = cookieStruct.String() @@ -247,6 +648,9 @@ func (t *TwitterLogin) ensureClientForPIN(ctx context.Context) error { } cookieStruct := twitCookies.NewCookiesFromString(t.Cookies) t.client = twittermeow.NewClient(cookieStruct, nil, t.User.Log.With().Str("component", "login_twitter_client").Logger()) + if t.browserHeaders.UserAgent != "" { + t.client.SetBrowserHeaders(t.browserHeaders) + } profile, err := t.client.LoadMessagesPage(ctx) if err != nil { return fmt.Errorf("failed to load messages page: %w", err) @@ -452,9 +856,7 @@ func (t *TwitterLogin) bootstrapJuiceboxPIN(ctx context.Context, pin string) (*K juiceboxLogger := t.User.Log.With().Str("component", "juicebox").Logger() juiceboxLogger.Debug(). - Str("juicebox_config", juiceboxConfigJSON). Int("juicebox_config_len", len(juiceboxConfigJSON)). - Any("auth_tokens", authTokens). Int("auth_tokens_count", len(authTokens)). Int("max_guess_count", addResp.Data.UserAddPublicKey.TokenMap.MaxGuessCount). Msg("Juicebox bootstrap parameters") @@ -494,9 +896,7 @@ func (t *TwitterLogin) recoverJuiceboxPIN( juiceboxLogger := t.User.Log.With().Str("component", "juicebox").Logger() juiceboxLogger.Debug(). - Str("juicebox_config", juiceboxConfigJSON). Int("juicebox_config_len", len(juiceboxConfigJSON)). - Any("auth_tokens", authTokens). Int("auth_tokens_count", len(authTokens)). Msg("Juicebox recovery parameters") @@ -519,6 +919,521 @@ func (t *TwitterLogin) recoverJuiceboxPIN( } func (t *TwitterLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { + if _, ok := input["pin"]; ok { + return t.submitPINInput(ctx, input) + } + if _, ok := input[loginFieldAuthMethod]; ok { + return t.submitWebAuthMethodInput(ctx, input) + } + if _, ok := input[loginFieldVerificationCode]; ok || t.webLoginChallenge != nil { + return t.submitWebVerificationInput(ctx, input) + } + if _, ok := input[loginFieldIdentifier]; ok || input[loginFieldPassword] != "" { + return t.submitCredentialsInput(ctx, input) + } + return nil, ErrMissingLoginInput +} + +func (t *TwitterLogin) submitCredentialsInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { + identifier := strings.TrimSpace(input[loginFieldIdentifier]) + password := input[loginFieldPassword] + if identifier == "" || password == "" { + return nil, ErrMissingLoginInput + } + + t.stopClientHTTPLogin() + client := twittermeow.NewClient(twitCookies.NewCookies(nil), nil, t.User.Log.With().Str("component", "login_twitter_client").Logger()) + t.webLogin = twittermeow.NewWebLoginSession(client) + t.webLoginIdentifier = identifier + t.webLoginPassword = password + t.webLoginCastleStage = "" + t.webLoginAuthMethod = "" + t.webLoginText = "" + t.webLoginChallenge = nil + t.webLoginMethods = nil + if t.useClientHTTPLogin { + t.clientHTTPTransport = client.EnableClientHTTP() + t.clientHTTPStage = clientHTTPStageStart + return t.continueClientHTTPLogin(ctx) + } + + result, err := t.webLogin.Start(ctx) + if err != nil { + return nil, webLoginFailureError(err) + } + return t.continueStartedCredentialsLogin(ctx, result) +} + +func (t *TwitterLogin) continueStartedCredentialsLogin(ctx context.Context, result *twittermeow.WebLoginResult) (*bridgev2.LoginStep, error) { + if result.Status != twittermeow.WebLoginStatusNeedsIdentifier { + return t.handleWebLoginResult(ctx, result) + } + if t.webLogin.UsesJetfuel() { + return t.startWebCastleLogin(), nil + } + + result, err := t.webLogin.SubmitCredentials(ctx, t.webLoginIdentifier, t.webLoginPassword) + if err != nil { + return handleWebLoginCredentialsError(err) + } + return t.handleWebLoginResult(ctx, result) +} + +func (t *TwitterLogin) startWebCastleLogin() *bridgev2.LoginStep { + // The browser sends username and password together in begin_login. Starting with the + // combined form avoids advancing the same Jetfuel session with an identifier-only POST. + t.webLoginCastleStage = webLoginCastleStageCombined + return t.makeWebLoginCastleTokenStep("") +} + +func (t *TwitterLogin) isWaitingForWebLoginCastleToken() bool { + return t.webLogin != nil && t.webLoginCastleStage != "" && t.webLoginIdentifier != "" && t.webLoginPassword != "" +} + +func (t *TwitterLogin) submitWebCastleTokenInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { + if !t.isWaitingForWebLoginCastleToken() { + t.webLoginCastleStage = "" + return makeCredentialsStep("The X login session expired. Enter your X login details again."), nil + } + castleTokens, err := decodeCastleTokenBatchInput(input) + if err != nil { + return t.makeWebLoginCastleTokenStep("The X webview returned an invalid browser token."), nil + } + if len(castleTokens) == 0 { + return t.makeWebLoginCastleTokenStep("The X webview did not return a browser token."), nil + } + client := t.webLogin.Client() + if !client.SetBrowserHeaders(browserHeadersFromInput(input)) { + return t.makeWebLoginCastleTokenStep("The X webview did not return a valid browser fingerprint."), nil + } + t.browserHeaders = client.GetBrowserHeaders() + client.SetCookies(castleWebviewCookies(input)) + client.SetNextJetfuelCastleTokens(castleTokens) + if t.useClientHTTPLogin { + t.webLoginCastleStage = "" + return t.continueClientHTTPLogin(ctx) + } + return t.continueWebCastleLogin(ctx) +} + +func browserHeadersFromInput(input map[string]string) twittermeow.BrowserHeaders { + return twittermeow.BrowserHeaders{ + UserAgent: input[loginFieldBrowserUserAgent], + SecCHUserAgent: input[loginFieldBrowserSecCHUA], + SecCHPlatform: input[loginFieldBrowserPlatform], + SecCHMobile: input[loginFieldBrowserMobile], + } +} + +func decodeCastleTokenBatchInput(input map[string]string) ([]string, error) { + tokens := make([]string, 0, castleTokenBatchSize) + seen := make(map[string]struct{}, castleTokenBatchSize) + for index := 1; index <= castleTokenBatchSize; index++ { + token, err := decodeCastleTokenInput(input[castleTokenFieldID(index)]) + if err != nil { + return nil, err + } + if token == "" { + continue + } + if len(token) < 128 || len(token) > 20000 { + return nil, fmt.Errorf("invalid Castle token length") + } + if _, ok := seen[token]; ok { + continue + } + seen[token] = struct{}{} + tokens = append(tokens, token) + } + return tokens, nil +} + +func (t *TwitterLogin) continueWebCastleLogin(ctx context.Context) (*bridgev2.LoginStep, error) { + for attempts := 0; attempts < castleTokenBatchSize; attempts++ { + stage := t.webLoginCastleStage + zerolog.Ctx(ctx).Debug(). + Str("stage", stage). + Bool("castle_token_available", t.webLogin.Client().HasNextJetfuelCastleToken()). + Msg("Processing X Jetfuel Castle stage") + + var ( + result *twittermeow.WebLoginResult + err error + ) + switch stage { + case webLoginCastleStageIdentifier: + result, err = t.webLogin.SubmitIdentifier(ctx, t.webLoginIdentifier) + if err != nil && twittermeow.IsWebLoginPrePasswordParityError(err) { + t.webLoginCastleStage = webLoginCastleStageCombined + continue + } + case webLoginCastleStagePassword: + result, err = t.webLogin.SubmitPassword(ctx, t.webLoginPassword) + case webLoginCastleStageCombined: + result, err = t.webLogin.SubmitCombinedCredentials(ctx, t.webLoginIdentifier, t.webLoginPassword) + case webLoginCastleStageBeginTwoFactor: + result, err = t.webLogin.SubmitPendingTwoFactor(ctx) + case webLoginCastleStageAuthMethod: + methodID := t.webLoginAuthMethod + if methodID == "" { + t.webLoginCastleStage = "" + return makeAuthMethodStep(t.webLoginMethods, "Choose a verification method."), nil + } + result, err = t.webLogin.SubmitAuthMethod(ctx, methodID) + case webLoginCastleStageText: + text := t.webLoginText + if text == "" { + t.webLoginCastleStage = "" + return makeVerificationStep(t.webLoginChallenge, "Enter the X verification code."), nil + } + result, err = t.webLogin.SubmitText(ctx, text) + default: + t.webLoginCastleStage = "" + return makeCredentialsStep("The X login session expired. Enter your X login details again."), nil + } + if err != nil { + if errors.Is(err, twittermeow.ErrJetfuelCastleTokenRequired) { + if stage == webLoginCastleStagePassword || stage == webLoginCastleStageText { + t.webLoginCastleStage = webLoginCastleStageBeginTwoFactor + continue + } + return t.makeWebLoginCastleTokenStep("X needs a fresh browser token to continue this login."), nil + } + logWebCastleFailure(ctx, stage, err) + t.webLoginCastleStage = "" + return handleWebCastleStageError(stage, t.webLoginChallenge, t.webLoginMethods, err) + } + if stage == webLoginCastleStageAuthMethod { + t.webLoginAuthMethod = "" + } + if stage == webLoginCastleStageText { + t.webLoginText = "" + } + if result != nil && result.Status == twittermeow.WebLoginStatusNeedsPassword && t.webLoginPassword != "" { + t.webLoginCastleStage = webLoginCastleStagePassword + if !t.webLogin.Client().HasNextJetfuelCastleToken() { + return t.makeWebLoginCastleTokenStep("X needs a fresh browser token to continue this login."), nil + } + continue + } + t.webLoginCastleStage = "" + return t.handleWebLoginResult(ctx, result) + } + return t.makeWebLoginCastleTokenStep("X needs a fresh browser token to continue this login."), nil +} + +func logWebCastleFailure(ctx context.Context, stage string, err error) { + event := zerolog.Ctx(ctx).Debug().Str("stage", stage) + var webErr *twittermeow.WebLoginError + switch { + case errors.As(err, &webErr): + event = event.Str("error_kind", "x_response").Int("error_code", webErr.Code) + case errors.Is(err, twittermeow.ErrWebLoginUnexpectedSubtask): + event = event.Str("error_kind", "unsupported_response") + default: + event = event.Str("error_kind", "request") + } + event.Msg("X Jetfuel Castle stage failed") +} + +func handleWebCastleStageError(stage string, challenge *twittermeow.WebLoginChallenge, methods []twittermeow.WebLoginAuthMethod, err error) (*bridgev2.LoginStep, error) { + switch stage { + case webLoginCastleStageIdentifier, webLoginCastleStagePassword, webLoginCastleStageCombined: + return handleWebLoginCredentialsError(err) + case webLoginCastleStageText: + return handleWebLoginVerificationError(challenge, err) + case webLoginCastleStageAuthMethod: + return handleWebLoginAuthMethodError(methods, err) + default: + return nil, webLoginFailureError(err) + } +} + +func castleWebviewCookies(input map[string]string) map[string]string { + out := make(map[string]string) + for _, name := range castleTokenCookieNames { + if value := strings.TrimSpace(input[name]); value != "" { + out[name] = value + } + } + return out +} + +func (t *TwitterLogin) submitWebAuthMethodInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { + if t.webLogin == nil { + t.webLoginMethods = nil + return makeCredentialsStep("The X login session expired. Enter your X login details again."), nil + } + methodID := strings.TrimSpace(input[loginFieldAuthMethod]) + if methodID == "" { + return nil, ErrMissingLoginInput + } + if method, ok := findWebLoginAuthMethod(t.webLoginMethods, methodID); ok { + methodID = method.ID + if methodID == "" { + methodID = method.Name + } + } + if t.useClientHTTPLogin { + t.webLoginAuthMethod = methodID + t.clientHTTPStage = webLoginCastleStageAuthMethod + return t.continueClientHTTPLogin(ctx) + } + if t.webLogin.UsesJetfuel() { + t.webLoginAuthMethod = methodID + t.webLoginCastleStage = webLoginCastleStageAuthMethod + if t.webLogin.Client().HasNextJetfuelCastleToken() { + return t.continueWebCastleLogin(ctx) + } + return t.makeWebLoginCastleTokenStep(""), nil + } + result, err := t.webLogin.SubmitAuthMethod(ctx, methodID) + if err != nil { + return nil, webLoginFailureError(err) + } + return t.handleWebLoginResult(ctx, result) +} + +func findWebLoginAuthMethod(methods []twittermeow.WebLoginAuthMethod, selected string) (twittermeow.WebLoginAuthMethod, bool) { + selected = normalizeLoginChoice(selected) + for _, method := range methods { + if normalizeLoginChoice(method.ID) == selected || normalizeLoginChoice(method.Name) == selected { + return method, true + } + } + return twittermeow.WebLoginAuthMethod{}, false +} + +func normalizeLoginChoice(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + value = strings.ReplaceAll(value, " ", "") + value = strings.ReplaceAll(value, "_", "") + value = strings.ReplaceAll(value, "-", "") + value = strings.ReplaceAll(value, ".", "") + return value +} + +func (t *TwitterLogin) submitWebVerificationInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { + if t.webLogin == nil { + t.webLoginChallenge = nil + t.webLoginPassword = "" + t.webLoginMethods = nil + return makeCredentialsStep("The X login session expired. Enter your X login details again."), nil + } + text := strings.TrimSpace(input[loginFieldVerificationCode]) + if text == "" { + return nil, ErrMissingLoginInput + } + if t.useClientHTTPLogin { + t.webLoginText = text + t.clientHTTPStage = webLoginCastleStageText + return t.continueClientHTTPLogin(ctx) + } + if t.webLogin.UsesJetfuel() { + t.webLoginText = text + t.webLoginCastleStage = webLoginCastleStageText + if t.webLogin.Client().HasNextJetfuelCastleToken() { + return t.continueWebCastleLogin(ctx) + } + return t.makeWebLoginCastleTokenStep(""), nil + } + result, err := t.webLogin.SubmitText(ctx, text) + if err != nil { + return handleWebLoginVerificationError(t.webLoginChallenge, err) + } + if result.Status == twittermeow.WebLoginStatusNeedsPassword && t.webLoginPassword != "" { + if t.webLogin.UsesJetfuel() { + t.webLoginCastleStage = webLoginCastleStagePassword + return t.makeWebLoginCastleTokenStep(""), nil + } + result, err = t.webLogin.SubmitPassword(ctx, t.webLoginPassword) + if err != nil { + return handleWebLoginCredentialsError(err) + } + } + return t.handleWebLoginResult(ctx, result) +} + +func (t *TwitterLogin) handleWebLoginResult(ctx context.Context, result *twittermeow.WebLoginResult) (*bridgev2.LoginStep, error) { + if result == nil { + return makeCredentialsStep("X did not return a login step. Try again."), nil + } + switch result.Status { + case twittermeow.WebLoginStatusComplete: + return t.completeWebLogin(ctx) + case twittermeow.WebLoginStatusNeedsAuthMethod: + if len(result.AuthMethods) == 0 { + return makeCredentialsStep("X returned a verification method chooser without any methods. Try again."), nil + } + t.webLoginChallenge = nil + t.webLoginMethods = result.AuthMethods + return makeAuthMethodStep(result.AuthMethods, ""), nil + case twittermeow.WebLoginStatusNeedsText: + t.webLoginChallenge = result.Challenge + t.webLoginMethods = nil + return makeVerificationStep(result.Challenge, ""), nil + case twittermeow.WebLoginStatusNeedsPassword: + if t.webLogin != nil && t.webLoginPassword != "" { + if t.webLogin.UsesJetfuel() { + t.webLoginCastleStage = webLoginCastleStagePassword + return t.makeWebLoginCastleTokenStep(""), nil + } + next, err := t.webLogin.SubmitPassword(ctx, t.webLoginPassword) + if err != nil { + return handleWebLoginCredentialsError(err) + } + if next != nil && next.Status != twittermeow.WebLoginStatusNeedsPassword { + return t.handleWebLoginResult(ctx, next) + } + } + return makeCredentialsStep("X still needs your password. Enter your login details again."), nil + case twittermeow.WebLoginStatusNeedsIdentifier: + return makeCredentialsStep("X still needs your username, email, or phone. Enter your login details again."), nil + default: + t.User.Log.Warn(). + Str("subtask_id", result.CurrentSubtaskID). + Str("status", string(result.Status)). + Msg("X returned unsupported login subtask") + return makeCredentialsStep(webLoginUnsupportedInstructions(result)), nil + } +} + +func webLoginUnsupportedInstructions(result *twittermeow.WebLoginResult) string { + if result != nil && result.Challenge != nil { + description := strings.TrimSpace(result.Challenge.Description) + if description != "" { + return description + } + } + return "X returned a login challenge this bridge does not support yet." +} + +func handleWebLoginCredentialsError(err error) (*bridgev2.LoginStep, error) { + if isWebLoginCredentialsInputError(err) { + return makeCredentialsStep(webLoginErrorInstructions(err)), nil + } + return nil, webLoginFailureError(err) +} + +func handleWebLoginVerificationError(challenge *twittermeow.WebLoginChallenge, err error) (*bridgev2.LoginStep, error) { + if isWebLoginVerificationInputError(err) { + return makeVerificationStep(challenge, webLoginErrorInstructions(err)), nil + } + return nil, webLoginFailureError(err) +} + +func handleWebLoginAuthMethodError(methods []twittermeow.WebLoginAuthMethod, err error) (*bridgev2.LoginStep, error) { + if errors.Is(err, twittermeow.ErrWebLoginUnsupportedAuthMethod) { + return makeAuthMethodStep(methods, webLoginErrorInstructions(err)), nil + } + if errors.Is(err, twittermeow.ErrWebLoginMissingAuthMethodState) { + return makeCredentialsStep(webLoginErrorInstructions(err)), nil + } + return nil, webLoginFailureError(err) +} + +func isWebLoginCredentialsInputError(err error) bool { + var webErr *twittermeow.WebLoginError + if !errors.As(err, &webErr) { + return false + } + msg := strings.ToLower(strings.TrimSpace(webErr.Message)) + if webErr.Code != 32 { + return false + } + return strings.Contains(msg, "wrong password") || + strings.Contains(msg, "incorrect password") || + strings.Contains(msg, "invalid password") || + strings.Contains(msg, "password you entered") || + strings.Contains(msg, "password is incorrect") || + strings.Contains(msg, "username and password") && strings.Contains(msg, "did not match") || + strings.Contains(msg, "invalid username or password") || + strings.Contains(msg, "invalid credentials") || + strings.Contains(msg, "missing_account") || + strings.Contains(msg, "not registered") +} + +func isWebLoginVerificationInputError(err error) bool { + var webErr *twittermeow.WebLoginError + if !errors.As(err, &webErr) { + return false + } + msg := strings.ToLower(strings.TrimSpace(webErr.Message)) + return strings.Contains(msg, "wrong code") || + strings.Contains(msg, "incorrect code") || + strings.Contains(msg, "invalid code") || + strings.Contains(msg, "code is incorrect") || + strings.Contains(msg, "verification code") && strings.Contains(msg, "incorrect") || + strings.Contains(msg, "authentication code") && strings.Contains(msg, "incorrect") +} + +func webLoginFailureError(err error) error { + if err == nil { + return ErrWebLoginFailed + } + return ErrWebLoginFailed.WithMessage(webLoginErrorInstructions(err)) +} + +func (t *TwitterLogin) completeWebLogin(ctx context.Context) (*bridgev2.LoginStep, error) { + if t.webLogin == nil || t.webLogin.Client() == nil { + return makeCredentialsStep("The X login session expired. Enter your X login details again."), nil + } + client := t.webLogin.Client() + profile, err := client.LoadMessagesPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load authenticated X messages page after login: %w", err) + } + t.client = client + t.profile = profile + t.persistClientCookiesAndUserID() + if t.useClientHTTPLogin { + needsPINSetup, detectErr := t.detectPINSetupNeeded(ctx) + if errors.Is(detectErr, twittermeow.ErrClientHTTPRequestPending) { + return nil, detectErr + } else if detectErr != nil { + t.User.Log.Warn().Err(detectErr).Msg("Failed to determine PIN setup state after client HTTP login, using recovery prompt") + } else { + t.needsPINSetup = needsPINSetup + } + } else { + t.refreshPINSetupState(ctx, "Failed to determine PIN setup state after native login, using recovery prompt") + } + t.webLoginIdentifier = "" + t.webLoginPassword = "" + t.webLoginCastleStage = "" + t.webLoginAuthMethod = "" + t.webLoginText = "" + t.webLoginChallenge = nil + t.webLoginMethods = nil + + return makePINStep("", t.needsPINSetup), nil +} + +func webLoginErrorInstructions(err error) string { + if err == nil { + return "X rejected this login. Please check the details and try again." + } + var webErr *twittermeow.WebLoginError + if errors.As(err, &webErr) { + return webErr.UserMessage() + } + if errors.Is(err, twittermeow.ErrWebLoginUnexpectedSubtask) { + return "X returned a login challenge this bridge does not support yet." + } + if errors.Is(err, twittermeow.ErrWebLoginUnsupportedAuthMethod) { + return "That X verification method is not available for this login." + } + if errors.Is(err, twittermeow.ErrWebLoginMissingAuthMethodState) { + return "The X verification method selection expired. Enter your X login details again." + } + msg := strings.TrimSpace(err.Error()) + if msg == "" { + return "X rejected this login. Please check the details and try again." + } + return fmt.Sprintf("X login failed: %s", msg) +} + +func (t *TwitterLogin) submitPINInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { pin, err := parsePINInput(input) if err != nil { return nil, err @@ -584,6 +1499,9 @@ func (t *TwitterLogin) SubmitUserInput(ctx context.Context, input map[string]str SigningKey: t.SigningKey, SigningKeyVersion: t.SigningKeyVersion, } + if browserHeaders := t.client.GetBrowserHeaders(); browserHeaders.UserAgent != "" { + meta.BrowserHeaders = &browserHeaders + } // If this is a migration, mark it and flag for full encrypted room sync if t.isMigration { diff --git a/pkg/connector/login_client_http.go b/pkg/connector/login_client_http.go new file mode 100644 index 00000000..ee636698 --- /dev/null +++ b/pkg/connector/login_client_http.go @@ -0,0 +1,187 @@ +package connector + +import ( + "context" + "errors" + + "maunium.net/go/mautrix/bridgev2" + + "go.mau.fi/mautrix-twitter/pkg/twittermeow" +) + +const ( + clientHTTPStageStart = "start" + clientHTTPMaxImmediateActions = 16 +) + +var _ bridgev2.LoginProcessClientHTTP = (*TwitterLogin)(nil) + +func (t *TwitterLogin) isWaitingForClientHTTPRequest() bool { + return t.useClientHTTPLogin && + t.webLogin != nil && + t.webLogin.Client() != nil && + t.clientHTTPTransport != nil && + t.clientHTTPTransport.PendingRequest() != nil && + t.clientHTTPStage != "" +} + +func (t *TwitterLogin) SubmitClientHTTPResponse( + ctx context.Context, + response *bridgev2.LoginClientHTTPResponse, +) (*bridgev2.LoginStep, error) { + if !t.isWaitingForClientHTTPRequest() { + t.stopClientHTTPLogin() + return makeCredentialsStep("The X login session expired. Enter your X login details again."), nil + } + if response == nil { + return t.failClientHTTPRequest("The client returned an invalid HTTP response. Please try again.") + } + if response.Error != "" { + return t.failClientHTTPRequest("The request did not complete on this device. Please try again.") + } + err := t.clientHTTPTransport.SubmitResponse(twittermeow.ClientHTTPResponse{ + RequestID: response.RequestID, + Status: response.StatusCode, + Headers: response.Headers, + Body: response.Body, + FinalURL: response.FinalURL, + }) + if err != nil { + return t.failClientHTTPRequest("The client HTTP response did not match this login. Please try again.") + } + return t.continueClientHTTPLogin(ctx) +} + +func (t *TwitterLogin) failClientHTTPRequest(message string) (*bridgev2.LoginStep, error) { + t.stopClientHTTPLogin() + t.webLogin = nil + t.webLoginIdentifier = "" + t.webLoginPassword = "" + t.webLoginCastleStage = "" + t.webLoginAuthMethod = "" + t.webLoginText = "" + t.webLoginChallenge = nil + t.webLoginMethods = nil + return makeCredentialsStep(message), nil +} + +func (t *TwitterLogin) continueClientHTTPLogin(ctx context.Context) (*bridgev2.LoginStep, error) { + if t.webLogin == nil || t.webLogin.Client() == nil || t.clientHTTPTransport == nil || t.clientHTTPStage == "" { + t.stopClientHTTPLogin() + return makeCredentialsStep("The X login session expired. Enter your X login details again."), nil + } + + for range clientHTTPMaxImmediateActions { + stage := t.clientHTTPStage + if err := t.clientHTTPTransport.BeginOperation(stage); err != nil { + t.stopClientHTTPLogin() + return nil, webLoginFailureError(err) + } + + result, err := t.runClientHTTPStage(ctx, stage) + if errors.Is(err, twittermeow.ErrClientHTTPRequestPending) { + return makeClientHTTPRequestStep(t.clientHTTPTransport.PendingRequest()) + } + if errors.Is(err, twittermeow.ErrJetfuelCastleTokenRequired) { + t.webLoginCastleStage = stage + return t.makeWebLoginCastleTokenStep(""), nil + } + if err != nil { + logWebCastleFailure(ctx, stage, err) + step, handledErr := handleClientHTTPStageError(stage, t.webLoginChallenge, t.webLoginMethods, err) + t.clientHTTPStage = "" + if handledErr != nil { + t.stopClientHTTPLogin() + } else { + t.clientHTTPTransport.ResetOperation() + } + return step, handledErr + } + switch { + case stage == clientHTTPStageStart && result != nil && result.Status == twittermeow.WebLoginStatusNeedsIdentifier: + if err = t.clientHTTPTransport.EndOperation(); err != nil { + t.stopClientHTTPLogin() + return nil, webLoginFailureError(err) + } + t.clientHTTPStage = webLoginCastleStageCombined + continue + case result != nil && result.Status == twittermeow.WebLoginStatusNeedsPassword && t.webLoginPassword != "": + if err = t.clientHTTPTransport.EndOperation(); err != nil { + t.stopClientHTTPLogin() + return nil, webLoginFailureError(err) + } + t.clientHTTPStage = webLoginCastleStagePassword + continue + } + + step, err := t.handleWebLoginResult(ctx, result) + if errors.Is(err, twittermeow.ErrClientHTTPRequestPending) { + return makeClientHTTPRequestStep(t.clientHTTPTransport.PendingRequest()) + } + if errors.Is(err, twittermeow.ErrJetfuelCastleTokenRequired) { + t.webLoginCastleStage = stage + return t.makeWebLoginCastleTokenStep(""), nil + } + if err != nil { + t.stopClientHTTPLogin() + return nil, err + } + if err = t.clientHTTPTransport.EndOperation(); err != nil { + t.stopClientHTTPLogin() + return nil, webLoginFailureError(err) + } + if stage == webLoginCastleStageAuthMethod { + t.webLoginAuthMethod = "" + } + if stage == webLoginCastleStageText { + t.webLoginText = "" + } + t.clientHTTPStage = "" + if result != nil && result.Status == twittermeow.WebLoginStatusComplete { + t.stopClientHTTPLogin() + } + return step, nil + } + + t.stopClientHTTPLogin() + return nil, webLoginFailureError(errors.New("client HTTP login exceeded the immediate action limit")) +} + +func (t *TwitterLogin) runClientHTTPStage(ctx context.Context, stage string) (*twittermeow.WebLoginResult, error) { + switch stage { + case clientHTTPStageStart: + return t.webLogin.Start(ctx) + case webLoginCastleStagePassword: + return t.webLogin.SubmitPassword(ctx, t.webLoginPassword) + case webLoginCastleStageCombined: + return t.webLogin.SubmitCombinedCredentials(ctx, t.webLoginIdentifier, t.webLoginPassword) + case webLoginCastleStageAuthMethod: + return t.webLogin.SubmitAuthMethod(ctx, t.webLoginAuthMethod) + case webLoginCastleStageText: + return t.webLogin.SubmitText(ctx, t.webLoginText) + default: + return nil, errors.New("unknown client HTTP login stage") + } +} + +func handleClientHTTPStageError( + stage string, + challenge *twittermeow.WebLoginChallenge, + methods []twittermeow.WebLoginAuthMethod, + err error, +) (*bridgev2.LoginStep, error) { + if stage == clientHTTPStageStart { + return nil, webLoginFailureError(err) + } + return handleWebCastleStageError(stage, challenge, methods, err) +} + +func (t *TwitterLogin) stopClientHTTPLogin() { + if t.webLogin != nil && t.webLogin.Client() != nil { + t.webLogin.Client().DisableClientHTTP() + } else if t.clientHTTPTransport != nil { + t.clientHTTPTransport.ResetOperation() + } + t.clientHTTPTransport = nil + t.clientHTTPStage = "" +} diff --git a/pkg/connector/login_client_http_test.go b/pkg/connector/login_client_http_test.go new file mode 100644 index 00000000..7d2f6966 --- /dev/null +++ b/pkg/connector/login_client_http_test.go @@ -0,0 +1,418 @@ +package connector + +import ( + "bytes" + "context" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + + "go.mau.fi/mautrix-twitter/pkg/twittermeow" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/endpoints" +) + +const clientHTTPTestMainPage = `
` + +func TestClientHTTPLoginUsesCastleWebviewThenCapturesLocalRequest(t *testing.T) { + t.Setenv("TWITTER_JETFUEL_VIEWER_CONTEXT", "0") + login := &TwitterLogin{ + User: &bridgev2.User{Log: zerolog.Nop()}, + useClientHTTPLogin: true, + } + + step, err := login.submitCredentialsInput(context.Background(), map[string]string{ + loginFieldIdentifier: "test-user", + loginFieldPassword: "test-password", + }) + if err != nil { + t.Fatal(err) + } + if step.Type != bridgev2.LoginStepTypeClientHTTP || step.StepID != LoginStepIDClientHTTPRequest || + step.ClientHTTPParams == nil { + t.Fatalf("first step = %#v, want typed client HTTP request", step) + } + + for range 20 { + if step.StepID == LoginStepIDCastleToken { + break + } + pending := login.clientHTTPTransport.PendingRequest() + if pending == nil { + t.Fatal("client HTTP request is not pending") + } + var body string + switch { + case pending.URL == endpoints.JETFUEL_LOGIN_REFERER_URL: + body = clientHTTPTestMainPage + case strings.Contains(pending.URL, "/i/jfapi"+endpoints.JETFUEL_LANDING_PATH): + body = "landing" + case strings.Contains(pending.URL, "/i/jfapi/onboarding/web?mode=login"): + body = endpoints.JETFUEL_BEGIN_LOGIN_PATH + "\x00username_or_email" + default: + body = "{}" + } + input := clientHTTPTestResponseInput(pending, body) + step, err = login.SubmitClientHTTPResponse(context.Background(), input) + if err != nil { + t.Fatalf("submit response for %s: %v", pending.URL, err) + } + if step.StepID != LoginStepIDClientHTTPRequest && step.StepID != LoginStepIDCastleToken { + t.Fatalf("step after %s = %#v", pending.URL, step) + } + } + + if step.StepID != LoginStepIDCastleToken || step.CookiesParams == nil || !step.CookiesParams.Hidden { + t.Fatalf("step = %#v, want native hidden Castle token step", step) + } + step, err = login.submitWebCastleTokenInput(context.Background(), clientHTTPTestCastleInput()) + if err != nil { + t.Fatal(err) + } + pending := login.clientHTTPTransport.PendingRequest() + if step.StepID != LoginStepIDClientHTTPRequest || pending == nil { + t.Fatalf("step = %#v pending = %#v, want direct client request", step, pending) + } + form, err := url.ParseQuery(string(pending.Body)) + if err != nil { + t.Fatal(err) + } + if form.Get("username_or_email") != "test-user" || form.Get("password") != "test-password" { + t.Fatalf("combined form did not preserve credentials") + } + if form.Get("$castle_token") != clientHTTPTestCastleToken(1) { + t.Fatalf("Castle form value = %q", form.Get("$castle_token")) + } + if step.ClientHTTPParams == nil || !bytes.Equal(step.ClientHTTPParams.Body, pending.Body) { + t.Fatal("client HTTP step did not carry the exact request body") + } + if step.CookiesParams != nil { + t.Fatal("client HTTP request unexpectedly used a cookie/WebView step") + } + + input := clientHTTPTestResponseInput(pending, "login accepted") + clientHTTPTestAuthenticate(input) + step, err = login.SubmitClientHTTPResponse(context.Background(), input) + if err != nil { + t.Fatal(err) + } + pending = login.clientHTTPTransport.PendingRequest() + if step.StepID != LoginStepIDClientHTTPRequest || pending == nil || + pending.URL != endpoints.BASE_MESSAGES_URL { + t.Fatalf("post-login step = %#v pending = %#v, want client-side messages request", step, pending) + } + + for range 30 { + if step.StepID == LoginStepIDCastleToken { + step, err = login.submitWebCastleTokenInput(context.Background(), clientHTTPTestCastleInput()) + if err != nil { + t.Fatal(err) + } + } + if step.StepID != LoginStepIDClientHTTPRequest { + break + } + pending = login.clientHTTPTransport.PendingRequest() + if pending == nil { + break + } + body := "{}" + if pending.URL == endpoints.BASE_MESSAGES_URL { + body = clientHTTPTestMainPage + } + input = clientHTTPTestResponseInput(pending, body) + clientHTTPTestAuthenticate(input) + step, err = login.SubmitClientHTTPResponse(context.Background(), input) + if err != nil { + t.Fatalf("submit post-login response for %s: %v", pending.URL, err) + } + if step.StepID == LoginStepIDCastleToken { + continue + } + if step.StepID != LoginStepIDClientHTTPRequest { + break + } + } + if step.StepID != LoginStepJuiceboxPIN { + t.Fatalf("final step = %#v, want PIN step", step) + } + if login.clientHTTPTransport != nil || login.webLogin.Client().IsClientHTTPEnabled() { + t.Fatal("client HTTP transport remained enabled after login completion") + } +} + +func TestClientHTTPLoginPreservesVerificationCodeDuringPostLoginRequests(t *testing.T) { + t.Setenv("TWITTER_JETFUEL_VIEWER_CONTEXT", "0") + login := &TwitterLogin{ + User: &bridgev2.User{Log: zerolog.Nop()}, + useClientHTTPLogin: true, + } + ctx := context.Background() + + step, err := login.submitCredentialsInput(ctx, map[string]string{ + loginFieldIdentifier: "test-user", + loginFieldPassword: "test-password", + }) + if err != nil { + t.Fatal(err) + } + + for range 20 { + if step.StepID == LoginStepIDCastleToken { + break + } + pending := login.clientHTTPTransport.PendingRequest() + if pending == nil { + t.Fatal("client HTTP request is not pending") + } + body := "{}" + switch { + case pending.URL == endpoints.JETFUEL_LOGIN_REFERER_URL: + body = clientHTTPTestMainPage + case strings.Contains(pending.URL, "/i/jfapi"+endpoints.JETFUEL_LANDING_PATH): + body = "landing" + case strings.Contains(pending.URL, "/i/jfapi/onboarding/web?mode=login"): + body = endpoints.JETFUEL_BEGIN_LOGIN_PATH + "\x00username_or_email" + } + input := clientHTTPTestResponseInput(pending, body) + step, err = login.SubmitClientHTTPResponse(ctx, input) + if err != nil { + t.Fatalf("submit bootstrap response for %s: %v", pending.URL, err) + } + } + + if step.StepID != LoginStepIDCastleToken { + t.Fatalf("step = %#v, want native Castle token step", step) + } + step, err = login.submitWebCastleTokenInput(ctx, clientHTTPTestCastleInput()) + if err != nil { + t.Fatal(err) + } + pending := login.clientHTTPTransport.PendingRequest() + if step.StepID != LoginStepIDClientHTTPRequest || pending == nil { + t.Fatalf("step = %#v pending = %#v, want credentials request", step, pending) + } + chooserBody := "Select a method to authenticate\x00Choose the method you prefer to use for 2-step verification.\x00" + + "two_factor_method\x00Totp\x00BackupCode\x00U2fSecurityKey\x00" + + "user_id\x001127993589949243392\x00" + + "session_token\x0012345678-1234-1234-1234-123456789abc\x00" + + "prelude_dispatch_id\x00abcdefab-1234-1234-1234-abcdefabcdef\x00" + + "begin_two_factor_auth" + input := clientHTTPTestResponseInput(pending, chooserBody) + step, err = login.SubmitClientHTTPResponse(ctx, input) + if err != nil { + t.Fatal(err) + } + if step.StepID != LoginStepIDAuthMethod { + t.Fatalf("credentials response step = %#v, want auth method", step) + } + + step, err = login.submitWebAuthMethodInput(ctx, map[string]string{ + loginFieldAuthMethod: "Totp", + }) + if err != nil { + t.Fatal(err) + } + pending = login.clientHTTPTransport.PendingRequest() + if step.StepID != LoginStepIDClientHTTPRequest || pending == nil { + t.Fatalf("auth method submit step = %#v pending = %#v", step, pending) + } + challengeBody := "Enter the code from your authentication app.\x00challenge_response\x00" + + endpoints.JETFUEL_FINISH_TWO_FACTOR_AUTH_PATH + "\x00" + + "session_token\x0012345678-1234-1234-1234-123456789abc" + input = clientHTTPTestResponseInput(pending, challengeBody) + step, err = login.SubmitClientHTTPResponse(ctx, input) + if err != nil { + t.Fatal(err) + } + if step.StepID != LoginStepIDVerification { + t.Fatalf("auth method response step = %#v, want verification", step) + } + + const verificationCode = "123456" + step, err = login.submitWebVerificationInput(ctx, map[string]string{ + loginFieldVerificationCode: verificationCode, + }) + if err != nil { + t.Fatal(err) + } + pending = login.clientHTTPTransport.PendingRequest() + if step.StepID != LoginStepIDClientHTTPRequest || pending == nil { + t.Fatalf("verification submit step = %#v pending = %#v", step, pending) + } + input = clientHTTPTestResponseInput(pending, "/home") + clientHTTPTestAuthenticate(input) + step, err = login.SubmitClientHTTPResponse(ctx, input) + if err != nil { + t.Fatal(err) + } + pending = login.clientHTTPTransport.PendingRequest() + if step.StepID != LoginStepIDClientHTTPRequest || pending == nil || + pending.URL != endpoints.BASE_MESSAGES_URL { + t.Fatalf("post-verification step = %#v pending = %#v, want messages request", step, pending) + } + if login.webLoginText != verificationCode { + t.Fatalf("verification code was cleared while its client HTTP operation is still pending") + } + + for range 30 { + if step.StepID == LoginStepIDCastleToken { + step, err = login.submitWebCastleTokenInput(ctx, clientHTTPTestCastleInput()) + if err != nil { + t.Fatal(err) + } + } + if step.StepID != LoginStepIDClientHTTPRequest { + break + } + pending = login.clientHTTPTransport.PendingRequest() + if pending == nil { + break + } + body := "{}" + if pending.URL == endpoints.BASE_MESSAGES_URL { + body = clientHTTPTestMainPage + } + input = clientHTTPTestResponseInput(pending, body) + clientHTTPTestAuthenticate(input) + step, err = login.SubmitClientHTTPResponse(ctx, input) + if err != nil { + t.Fatalf("submit post-login response for %s: %v", pending.URL, err) + } + if step.StepID == LoginStepIDCastleToken { + continue + } + if step.StepID != LoginStepIDClientHTTPRequest { + break + } + } + if step.StepID != LoginStepJuiceboxPIN { + t.Fatalf("final step = %#v, want PIN step", step) + } + if login.webLoginText != "" { + t.Fatal("verification code remained after its client HTTP operation completed") + } +} + +func TestClientHTTPRequestFailureStopsAutomaticRetry(t *testing.T) { + login := &TwitterLogin{ + User: &bridgev2.User{Log: zerolog.Nop()}, + useClientHTTPLogin: true, + } + step, err := login.submitCredentialsInput(context.Background(), map[string]string{ + loginFieldIdentifier: "test-user", + loginFieldPassword: "test-password", + }) + if err != nil { + t.Fatal(err) + } + if step.StepID != LoginStepIDClientHTTPRequest { + t.Fatalf("initial step = %#v", step) + } + + step, err = login.SubmitClientHTTPResponse(context.Background(), &bridgev2.LoginClientHTTPResponse{ + RequestID: step.ClientHTTPParams.RequestID, + Error: "Failed to fetch", + }) + if err != nil { + t.Fatal(err) + } + if step.StepID != LoginStepIDCredentials { + t.Fatalf("failure step = %#v, want credentials step", step) + } + if !strings.Contains(step.Instructions, "Please try again") { + t.Fatalf("failure instructions = %q", step.Instructions) + } + if login.clientHTTPTransport != nil || login.webLogin != nil || + login.webLoginIdentifier != "" || login.webLoginPassword != "" { + t.Fatal("client HTTP failure retained retryable login state") + } +} + +func TestClientHTTPRequestStepContract(t *testing.T) { + request := &twittermeow.ClientHTTPRequest{ + ID: "client-http-7", + Method: http.MethodPost, + URL: "https://x.com/i/jfapi/onboarding/web/actions/begin_login", + Headers: http.Header{ + "Authorization": {"Bearer public"}, + "Cookie": {"guest_id=v1%3A123; ct0=csrf"}, + "Referer": {endpoints.JETFUEL_LOGIN_REFERER_URL}, + "X-Multi": {"one", "two"}, + }, + Body: []byte("password=secret"), + } + step, err := makeClientHTTPRequestStep(request) + if err != nil { + t.Fatal(err) + } + if step.Type != bridgev2.LoginStepTypeClientHTTP || step.StepID != LoginStepIDClientHTTPRequest || + step.ClientHTTPParams == nil || step.CookiesParams != nil { + t.Fatalf("step = %#v", step) + } + params := step.ClientHTTPParams + if params.RequestID != request.ID || params.Method != request.Method || params.URL != request.URL { + t.Fatalf("client HTTP params = %#v", params) + } + if !bytes.Equal(params.Body, request.Body) { + t.Fatalf("client HTTP body = %q", params.Body) + } + if got := params.Headers.Values("X-Multi"); len(got) != 2 || got[0] != "one" || got[1] != "two" { + t.Fatalf("multi-value headers = %#v", params.Headers) + } + if params.Headers.Get("Cookie") != request.Headers.Get("Cookie") || + params.Headers.Get("Referer") != request.Headers.Get("Referer") { + t.Fatalf("cookies or referrer were not preserved: %#v", params.Headers) + } + request.Headers.Set("Cookie", "changed") + request.Body[0] = 'X' + if params.Headers.Get("Cookie") == "changed" || params.Body[0] == 'X' { + t.Fatal("client HTTP step did not clone mutable request data") + } +} + +func clientHTTPTestResponseInput( + request *twittermeow.ClientHTTPRequest, + body string, +) *bridgev2.LoginClientHTTPResponse { + return &bridgev2.LoginClientHTTPResponse{ + RequestID: request.ID, + StatusCode: http.StatusOK, + FinalURL: request.URL, + Headers: http.Header{ + "Content-Type": {"application/octet-stream"}, + "Set-Cookie": { + "guest_id=v1%3A123456789; Domain=.x.com; Path=/", + "gt=123456789; Domain=.x.com; Path=/", + }, + }, + Body: []byte(body), + } +} + +func clientHTTPTestAuthenticate(response *bridgev2.LoginClientHTTPResponse) { + response.Headers.Add("Set-Cookie", "auth_token=authenticated-cookie; Domain=.x.com; Path=/") + response.Headers.Add("Set-Cookie", "ct0=csrf-cookie; Domain=.x.com; Path=/") + response.Headers.Add("Set-Cookie", "twid=u%3D123456789; Domain=.x.com; Path=/") +} + +func clientHTTPTestCastleToken(index int) string { + return strings.Repeat("client-http-castle-"+string(rune('0'+index))+"-", 12) +} + +func clientHTTPTestCastleInput() map[string]string { + input := map[string]string{ + loginFieldBrowserUserAgent: "Mozilla/5.0 test client", + } + for index := 1; index <= castleTokenBatchSize; index++ { + input[castleTokenFieldID(index)] = clientHTTPTestCastleToken(index) + } + return input +} diff --git a/pkg/connector/login_live_probe_test.go b/pkg/connector/login_live_probe_test.go new file mode 100644 index 00000000..b6ada764 --- /dev/null +++ b/pkg/connector/login_live_probe_test.go @@ -0,0 +1,207 @@ +//go:build liveprobe + +package connector + +import ( + "context" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/id" + + "go.mau.fi/mautrix-twitter/pkg/twittermeow" + twitCookies "go.mau.fi/mautrix-twitter/pkg/twittermeow/cookies" +) + +func TestLiveNativeLoginFlowProbe(t *testing.T) { + identifier := strings.TrimSpace(os.Getenv("TWITTER_LIVE_IDENTIFIER")) + password := os.Getenv("TWITTER_LIVE_PASSWORD") + if identifier == "" || password == "" { + t.Skip("TWITTER_LIVE_IDENTIFIER and TWITTER_LIVE_PASSWORD are required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + tc := &TwitterConnector{Config: Config{LoginFlow: LoginFlowNative}} + user := &bridgev2.User{ + User: &database.User{ + MXID: id.UserID("@highest:beeper.com"), + }, + Log: zerolog.Nop(), + } + process, err := tc.CreateLogin(ctx, user, LoginFlowIDPassword) + if err != nil { + t.Fatalf("CreateLogin() failed: %v", err) + } + defer process.Cancel() + + first, err := process.Start(ctx) + if err != nil { + t.Fatalf("Start() failed: %v", err) + } + if first == nil { + t.Fatal("Start() returned nil step") + } + t.Logf("first step: type=%s id=%s", first.Type, first.StepID) + if first.Type != bridgev2.LoginStepTypeUserInput || first.StepID != LoginStepIDCredentials { + t.Fatalf("unexpected first step: type=%s id=%s", first.Type, first.StepID) + } + + next, err := process.(bridgev2.LoginProcessUserInput).SubmitUserInput(ctx, map[string]string{ + loginFieldIdentifier: identifier, + loginFieldPassword: password, + }) + if err != nil { + t.Fatalf("SubmitUserInput(credentials) failed: %v", err) + } + if next == nil { + t.Fatal("SubmitUserInput(credentials) returned nil step") + } + t.Logf("next step: type=%s id=%s instructions=%q", next.Type, next.StepID, next.Instructions) + + switch next.StepID { + case LoginStepIDCastleToken: + t.Log("native credentials reached the client BrowserAuth Castle step") + return + case LoginStepJuiceboxPIN, LoginStepIDVerification, LoginStepIDComplete: + verificationCode := strings.TrimSpace(os.Getenv("TWITTER_LIVE_VERIFICATION_CODE")) + if next.StepID == LoginStepIDVerification { + if verificationCode == "" { + return + } + next, err = process.(bridgev2.LoginProcessUserInput).SubmitUserInput(ctx, map[string]string{ + loginFieldVerificationCode: verificationCode, + }) + if err != nil { + t.Fatalf("SubmitUserInput(verification) failed: %v", err) + } + if next == nil { + t.Fatal("SubmitUserInput(verification) returned nil step") + } + t.Logf("after verification step: type=%s id=%s instructions=%q", next.Type, next.StepID, next.Instructions) + } + if verificationCode != "" && next.StepID == LoginStepIDVerification { + t.Fatalf("verification code was provided but flow stayed on verification: %s", next.Instructions) + } + if next.StepID != LoginStepJuiceboxPIN { + return + } + case LoginStepIDCredentials: + if strings.Contains(next.Instructions, "Wait a bit") || + strings.Contains(next.Instructions, "cannot log") || + strings.Contains(next.Instructions, "could not log") || + strings.Contains(next.Instructions, "returned a login challenge") { + t.Fatalf("native credential submission returned retry/error step: %s", next.Instructions) + } + t.Fatalf("native credential submission did not advance past credentials: %s", next.Instructions) + default: + t.Fatalf("unexpected next step after credentials: type=%s id=%s instructions=%q", next.Type, next.StepID, next.Instructions) + } + + pin := strings.TrimSpace(os.Getenv("TWITTER_LIVE_PIN")) + if pin == "" { + return + } + if next.StepID != LoginStepJuiceboxPIN { + t.Fatalf("PIN was provided but flow did not reach PIN step: type=%s id=%s instructions=%q", next.Type, next.StepID, next.Instructions) + } + final, err := process.(bridgev2.LoginProcessUserInput).SubmitUserInput(ctx, map[string]string{ + "pin": pin, + }) + if err != nil { + t.Fatalf("SubmitUserInput(pin) failed: %v", err) + } + if final == nil { + t.Fatal("SubmitUserInput(pin) returned nil step") + } + t.Logf("final step: type=%s id=%s", final.Type, final.StepID) + if final.Type != bridgev2.LoginStepTypeComplete || final.StepID != LoginStepIDComplete { + t.Fatalf("unexpected final step after pin: type=%s id=%s instructions=%q", final.Type, final.StepID, final.Instructions) + } +} + +func TestLiveNativeLoginStageProbe(t *testing.T) { + identifier := strings.TrimSpace(os.Getenv("TWITTER_LIVE_IDENTIFIER")) + password := os.Getenv("TWITTER_LIVE_PASSWORD") + if identifier == "" || password == "" { + t.Skip("TWITTER_LIVE_IDENTIFIER and TWITTER_LIVE_PASSWORD are required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + client := twittermeow.NewClient(twitCookies.NewCookies(nil), nil, zerolog.Nop()) + webLogin := twittermeow.NewWebLoginSession(client) + + result, err := webLogin.Start(ctx) + logStage(t, "start", result, err) + if err != nil { + t.Fatalf("Start() failed: %v", err) + } + + result, err = webLogin.SubmitCredentials(ctx, identifier, password) + logStage(t, "credentials", result, err) + if errors.Is(err, twittermeow.ErrJetfuelCastleTokenRequired) { + t.Log("native login reached the client BrowserAuth Castle boundary") + return + } + if err != nil { + t.Fatalf("SubmitCredentials() failed: %v", err) + } + switch result.Status { + case twittermeow.WebLoginStatusComplete, twittermeow.WebLoginStatusNeedsText, twittermeow.WebLoginStatusNeedsPassword: + default: + t.Fatalf("SubmitCredentials() returned status %s, want complete, password, or text challenge", result.Status) + } +} + +func TestLiveIdentifierOnlyProbe(t *testing.T) { + identifier := strings.TrimSpace(os.Getenv("TWITTER_IDENTIFIER_PROBE")) + if identifier == "" { + t.Skip("TWITTER_IDENTIFIER_PROBE is required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + client := twittermeow.NewClient(twitCookies.NewCookies(nil), nil, zerolog.Nop()) + webLogin := twittermeow.NewWebLoginSession(client) + + result, err := webLogin.Start(ctx) + logStage(t, "start", result, err) + if err != nil { + t.Fatalf("Start() failed: %v", err) + } + + result, err = webLogin.SubmitIdentifier(ctx, identifier) + logStage(t, "identifier", result, err) + if errors.Is(err, twittermeow.ErrJetfuelCastleTokenRequired) { + t.Log("identifier submission reached the client BrowserAuth Castle boundary") + return + } + if err == nil && result.Status != twittermeow.WebLoginStatusNeedsPassword { + t.Fatalf("SubmitIdentifier() status = %s, want password step or a login error", result.Status) + } +} + +func logStage(t *testing.T, stage string, result *twittermeow.WebLoginResult, err error) { + t.Helper() + if result != nil { + t.Logf("%s result: status=%s subtask=%s", stage, result.Status, result.CurrentSubtaskID) + } + if err != nil { + var webErr *twittermeow.WebLoginError + if errors.As(err, &webErr) { + t.Logf("%s error: code=%d message=%q", stage, webErr.Code, webErr.Message) + return + } + t.Logf("%s error: %T", stage, err) + } +} diff --git a/pkg/connector/login_test.go b/pkg/connector/login_test.go new file mode 100644 index 00000000..7007d985 --- /dev/null +++ b/pkg/connector/login_test.go @@ -0,0 +1,832 @@ +package connector + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + + "go.mau.fi/mautrix-twitter/pkg/twittermeow" + twitCookies "go.mau.fi/mautrix-twitter/pkg/twittermeow/cookies" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/endpoints" +) + +type connectorRoundTripFunc func(*http.Request) (*http.Response, error) + +func (rtf connectorRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return rtf(req) +} + +func connectorTestHTTPResponse(body string) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func TestSubmitUserInputRejectsMissingRequiredCredentialFields(t *testing.T) { + login := &TwitterLogin{} + tests := []map[string]string{ + {}, + {loginFieldIdentifier: "alice"}, + {loginFieldPassword: "secret"}, + {loginFieldIdentifier: " ", loginFieldPassword: "secret"}, + {loginFieldIdentifier: "alice", loginFieldPassword: ""}, + } + + for _, input := range tests { + step, err := login.SubmitUserInput(context.Background(), input) + if step != nil { + t.Fatalf("SubmitUserInput(%#v) step = %#v, want nil", input, step) + } + if !errors.Is(err, ErrMissingLoginInput) { + t.Fatalf("SubmitUserInput(%#v) error = %v, want ErrMissingLoginInput", input, err) + } + } +} + +func TestHandleWebLoginCredentialsErrorRetriesOnlyCredentialErrors(t *testing.T) { + step, err := handleWebLoginCredentialsError(&twittermeow.WebLoginError{ + Code: 32, + Message: "Wrong password", + }) + if err != nil { + t.Fatalf("handleWebLoginCredentialsError(wrong password) error = %v", err) + } + if step == nil || step.StepID != LoginStepIDCredentials { + t.Fatalf("handleWebLoginCredentialsError(wrong password) step = %#v, want credentials step", step) + } + + step, err = handleWebLoginCredentialsError(&twittermeow.WebLoginError{ + Code: 399, + Message: "We've temporarily limited your login. Please try again later.", + }) + if step != nil { + t.Fatalf("handleWebLoginCredentialsError(temporary limit) step = %#v, want nil", step) + } + var respErr bridgev2.RespError + if !errors.As(err, &respErr) || respErr.ErrCode != ErrWebLoginFailed.ErrCode { + t.Fatalf("handleWebLoginCredentialsError(temporary limit) error = %#v, want ErrWebLoginFailed response", err) + } +} + +func TestHandleWebLoginVerificationErrorRetriesOnlyCodeErrors(t *testing.T) { + challenge := &twittermeow.WebLoginChallenge{Description: "Enter the verification code from X."} + step, err := handleWebLoginVerificationError(challenge, &twittermeow.WebLoginError{ + Code: 32, + Message: "The verification code is incorrect", + }) + if err != nil { + t.Fatalf("handleWebLoginVerificationError(wrong code) error = %v", err) + } + if step == nil || step.StepID != LoginStepIDVerification { + t.Fatalf("handleWebLoginVerificationError(wrong code) step = %#v, want verification step", step) + } + + step, err = handleWebLoginVerificationError(challenge, &twittermeow.WebLoginError{ + Code: 399, + Message: "We've temporarily limited your login. Please try again later.", + }) + if step != nil { + t.Fatalf("handleWebLoginVerificationError(temporary limit) step = %#v, want nil", step) + } + var respErr bridgev2.RespError + if !errors.As(err, &respErr) || respErr.ErrCode != ErrWebLoginFailed.ErrCode { + t.Fatalf("handleWebLoginVerificationError(temporary limit) error = %#v, want ErrWebLoginFailed", err) + } +} + +func TestHandleWebLoginAuthMethodErrorRetriesOnlyUnsupportedSelection(t *testing.T) { + methods := []twittermeow.WebLoginAuthMethod{{ID: "Totp", Name: "Authenticator App", Supported: true}} + step, err := handleWebLoginAuthMethodError(methods, fmt.Errorf("%w: Security Key", twittermeow.ErrWebLoginUnsupportedAuthMethod)) + if err != nil { + t.Fatalf("handleWebLoginAuthMethodError(unsupported method) error = %v", err) + } + if step == nil || step.StepID != LoginStepIDAuthMethod { + t.Fatalf("handleWebLoginAuthMethodError(unsupported method) step = %#v, want auth method step", step) + } + + step, err = handleWebLoginAuthMethodError(methods, &twittermeow.WebLoginError{ + Code: 399, + Message: "We've temporarily limited your login. Please try again later.", + }) + if step != nil { + t.Fatalf("handleWebLoginAuthMethodError(temporary limit) step = %#v, want nil", step) + } + var respErr bridgev2.RespError + if !errors.As(err, &respErr) || respErr.ErrCode != ErrWebLoginFailed.ErrCode { + t.Fatalf("handleWebLoginAuthMethodError(temporary limit) error = %#v, want ErrWebLoginFailed", err) + } + + step, err = handleWebLoginAuthMethodError(methods, twittermeow.ErrWebLoginMissingAuthMethodState) + if err != nil { + t.Fatalf("handleWebLoginAuthMethodError(missing state) error = %v", err) + } + if step == nil || step.StepID != LoginStepIDCredentials { + t.Fatalf("handleWebLoginAuthMethodError(missing state) step = %#v, want credentials step", step) + } +} + +func TestHandleWebCastleStageErrorMakesPreludeFailureTerminal(t *testing.T) { + step, err := handleWebCastleStageError( + webLoginCastleStageBeginTwoFactor, + nil, + nil, + fmt.Errorf("%w: two-factor prelude response", twittermeow.ErrWebLoginUnexpectedSubtask), + ) + if step != nil { + t.Fatalf("handleWebCastleStageError() step = %#v, want nil", step) + } + var respErr bridgev2.RespError + if !errors.As(err, &respErr) || respErr.ErrCode != ErrWebLoginFailed.ErrCode { + t.Fatalf("handleWebCastleStageError() error = %#v, want ErrWebLoginFailed", err) + } +} + +func TestGetLoginFlowsUsesConfiguredFlow(t *testing.T) { + tests := []struct { + name string + configFlow LoginFlow + wantID string + wantBeta bool + }{ + {name: "zero value uses webview", wantID: LoginFlowIDCookies}, + {name: "webview", configFlow: LoginFlowWebView, wantID: LoginFlowIDCookies}, + {name: "native", configFlow: LoginFlowNative, wantID: LoginFlowIDPassword}, + {name: "client HTTP", configFlow: LoginFlowClientHTTP, wantID: LoginFlowIDClientHTTP, wantBeta: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tc := TwitterConnector{Config: Config{LoginFlow: test.configFlow}} + flows := tc.GetLoginFlows() + if len(flows) != 1 { + t.Fatalf("len(flows) = %d, want 1", len(flows)) + } + if flows[0].ID != test.wantID { + t.Fatalf("flow ID = %s, want %s", flows[0].ID, test.wantID) + } + if strings.Contains(strings.ToLower(flows[0].Name), "beta") != test.wantBeta { + t.Fatalf("flow name = %q, want beta=%t", flows[0].Name, test.wantBeta) + } + }) + } +} + +func TestCreateLoginRespectsLoginFlowConfig(t *testing.T) { + tests := []struct { + name string + configFlow LoginFlow + flowID string + wantType bridgev2.LoginStepType + wantStepID string + wantInvalid bool + }{ + { + name: "zero value uses webview", + wantType: bridgev2.LoginStepTypeCookies, + wantStepID: LoginStepIDCookies, + }, + { + name: "explicit webview flow", + configFlow: LoginFlowWebView, + flowID: LoginFlowIDCookies, + wantType: bridgev2.LoginStepTypeCookies, + wantStepID: LoginStepIDCookies, + }, + { + name: "native flow disabled by webview config", + configFlow: LoginFlowWebView, + flowID: LoginFlowIDPassword, + wantInvalid: true, + }, + { + name: "default flow uses native", + configFlow: LoginFlowNative, + wantType: bridgev2.LoginStepTypeUserInput, + wantStepID: LoginStepIDCredentials, + }, + { + name: "explicit native flow", + configFlow: LoginFlowNative, + flowID: LoginFlowIDPassword, + wantType: bridgev2.LoginStepTypeUserInput, + wantStepID: LoginStepIDCredentials, + }, + { + name: "webview flow disabled by native config", + configFlow: LoginFlowNative, + flowID: LoginFlowIDCookies, + wantInvalid: true, + }, + { + name: "default flow uses client HTTP", + configFlow: LoginFlowClientHTTP, + wantType: bridgev2.LoginStepTypeUserInput, + wantStepID: LoginStepIDCredentials, + }, + { + name: "explicit client HTTP flow", + configFlow: LoginFlowClientHTTP, + flowID: LoginFlowIDClientHTTP, + wantType: bridgev2.LoginStepTypeUserInput, + wantStepID: LoginStepIDCredentials, + }, + { + name: "native flow disabled by client HTTP config", + configFlow: LoginFlowClientHTTP, + flowID: LoginFlowIDPassword, + wantInvalid: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + tc := TwitterConnector{Config: Config{LoginFlow: test.configFlow}} + process, err := tc.CreateLogin(context.Background(), nil, test.flowID) + if test.wantInvalid { + if !errors.Is(err, bridgev2.ErrInvalidLoginFlowID) { + t.Fatalf("CreateLogin() error = %v, want ErrInvalidLoginFlowID", err) + } + return + } + if err != nil { + t.Fatalf("CreateLogin() error = %v", err) + } + + step, err := process.Start(context.Background()) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + if step.Type != test.wantType || step.StepID != test.wantStepID { + t.Fatalf("Start() step = (%s, %s), want (%s, %s)", step.Type, step.StepID, test.wantType, test.wantStepID) + } + if test.wantType == bridgev2.LoginStepTypeCookies { + if step.CookiesParams == nil || step.UserInputParams != nil { + t.Fatalf("Start() params = cookies %#v, user input %#v", step.CookiesParams, step.UserInputParams) + } + } else if step.UserInputParams == nil || step.CookiesParams != nil { + t.Fatalf("Start() params = user input %#v, cookies %#v", step.UserInputParams, step.CookiesParams) + } + }) + } +} + +func TestWebCastleLoginStartsCombinedAndReturnsCode399AsFailure(t *testing.T) { + t.Setenv("TWITTER_JETFUEL_VIEWER_CONTEXT", "0") + const mainPageHTML = `` + + client := twittermeow.NewClient(twitCookies.NewCookies(nil), nil, zerolog.Nop()) + combinedRequestCount := 0 + client.HTTP = &http.Client{Transport: connectorRoundTripFunc(func(req *http.Request) (*http.Response, error) { + switch { + case req.Method == http.MethodGet && req.URL.Path == "/i/jf/onboarding/web": + resp := connectorTestHTTPResponse(mainPageHTML) + resp.Header.Add("Set-Cookie", "guest_id=v1%3A123456789; Path=/; Secure") + return resp, nil + case req.Method == http.MethodGet && req.URL.Path == "/i/jfapi"+endpoints.JETFUEL_LANDING_PATH: + return connectorTestHTTPResponse("landing"), nil + case req.Method == http.MethodGet && req.URL.Path == "/i/jfapi/onboarding/web" && req.URL.Query().Get("mode") == "login": + return connectorTestHTTPResponse(endpoints.JETFUEL_BEGIN_LOGIN_PATH + "\x00username_or_email"), nil + case req.Method == http.MethodPost && req.URL.Path == "/i/jfapi"+endpoints.JETFUEL_BEGIN_LOGIN_PATH: + combinedRequestCount++ + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("ReadAll(combined request) error = %v", err) + } + for _, value := range []string{"username_or_email=test-user", "password=test-password", "%24castle_token=combined-token"} { + if !strings.Contains(string(body), value) { + t.Fatalf("combined request body missing %q", value) + } + } + return connectorTestHTTPResponse("We've temporarily limited your login. Please try again later."), nil + default: + t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String()) + return nil, nil + } + })} + session := twittermeow.NewWebLoginSession(client) + result, err := session.Start(context.Background()) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + if !session.UsesJetfuel() || result == nil || result.Status != twittermeow.WebLoginStatusNeedsIdentifier { + t.Fatalf("Start() result = %#v, UsesJetfuel = %t", result, session.UsesJetfuel()) + } + + login := &TwitterLogin{ + User: &bridgev2.User{Log: zerolog.Nop()}, + webLogin: session, + webLoginIdentifier: "test-user", + webLoginPassword: "test-password", + } + step, err := login.continueStartedCredentialsLogin(context.Background(), result) + if err != nil { + t.Fatalf("continueStartedCredentialsLogin() error = %v", err) + } + if step == nil || step.StepID != LoginStepIDCastleToken { + t.Fatalf("continueStartedCredentialsLogin() step = %#v, want Castle token step", step) + } + if login.webLoginCastleStage != webLoginCastleStageCombined { + t.Fatalf("initial Castle stage = %q, want combined", login.webLoginCastleStage) + } + + client.SetNextJetfuelCastleTokens([]string{"combined-token", "unused-token"}) + var logs bytes.Buffer + logger := zerolog.New(&logs).With().Str("login_id", "test-login").Logger() + ctx := logger.WithContext(context.Background()) + step, err = login.continueWebCastleLogin(ctx) + if step != nil { + t.Fatalf("continueWebCastleLogin() step = %#v, want nil", step) + } + var respErr bridgev2.RespError + if !errors.As(err, &respErr) || respErr.ErrCode != ErrWebLoginFailed.ErrCode { + t.Fatalf("continueWebCastleLogin() error = %#v, want ErrWebLoginFailed", err) + } + if login.webLoginCastleStage != "" { + t.Fatalf("Castle stage = %q, want cleared", login.webLoginCastleStage) + } + if combinedRequestCount != 1 { + t.Fatalf("combined request count = %d, want 1", combinedRequestCount) + } + if !client.HasNextJetfuelCastleToken() { + t.Fatal("unused Castle token was consumed after code 399") + } + logged := logs.String() + for _, field := range []string{"\"login_id\":\"test-login\"", "\"stage\":\"combined\"", "\"error_kind\":\"x_response\"", "\"error_code\":399"} { + if !strings.Contains(logged, field) { + t.Fatalf("safe Castle failure log missing %q: %s", field, logged) + } + } + if strings.Contains(logged, "temporarily limited") || strings.Contains(logged, "test-password") || strings.Contains(logged, "combined-token") { + t.Fatalf("Castle failure log leaked response or submitted values: %s", logged) + } +} + +func TestContinueWebCastleLoginRequestsFreshTokenForActionlessPasswordReplay(t *testing.T) { + t.Setenv("TWITTER_JETFUEL_VIEWER_CONTEXT", "0") + const mainPageHTML = `` + + client := twittermeow.NewClient(twitCookies.NewCookies(nil), nil, zerolog.Nop()) + passwordRequestCount := 0 + client.HTTP = &http.Client{Transport: connectorRoundTripFunc(func(req *http.Request) (*http.Response, error) { + switch { + case req.Method == http.MethodGet && req.URL.Path == "/i/jf/onboarding/web": + resp := connectorTestHTTPResponse(mainPageHTML) + resp.Header.Add("Set-Cookie", "guest_id=v1%3A123456789; Path=/; Secure") + return resp, nil + case req.Method == http.MethodGet && req.URL.Path == "/i/jfapi"+endpoints.JETFUEL_LANDING_PATH: + return connectorTestHTTPResponse("landing"), nil + case req.Method == http.MethodGet && req.URL.Path == "/i/jfapi/onboarding/web" && req.URL.Query().Get("mode") == "login": + return connectorTestHTTPResponse(endpoints.JETFUEL_BEGIN_LOGIN_PATH + "\x00username_or_email"), nil + case req.Method == http.MethodPost && req.URL.Path == "/i/jfapi"+endpoints.JETFUEL_BEGIN_LOGIN_PATH: + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("ReadAll(identifier request) error = %v", err) + } + if !strings.Contains(string(body), "%24castle_token=identifier-token") { + t.Fatalf("identifier request body = %q", body) + } + return connectorTestHTTPResponse(endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH + "\x00password"), nil + case req.Method == http.MethodPost && req.URL.Path == "/i/jfapi"+endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH: + passwordRequestCount++ + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("ReadAll(password request) error = %v", err) + } + wantToken := "first-password-token" + if passwordRequestCount == 2 { + wantToken = "replay-password-token" + } + if !strings.Contains(string(body), "%24castle_token="+wantToken) { + t.Fatalf("password request %d body = %q, want token %q", passwordRequestCount, body, wantToken) + } + if passwordRequestCount == 1 { + return connectorTestHTTPResponse("/onboarding/web/actions/persist_login_state\x00opaque_field"), nil + } + return connectorTestHTTPResponse(endpoints.JETFUEL_FINISH_TWO_FACTOR_AUTH_PATH + "\x00challenge_response\x00Enter your verification code"), nil + default: + t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String()) + return nil, nil + } + })} + session := twittermeow.NewWebLoginSession(client) + result, err := session.Start(context.Background()) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + if !session.UsesJetfuel() || result == nil || result.Status != twittermeow.WebLoginStatusNeedsIdentifier { + t.Fatalf("Start() result = %#v, UsesJetfuel = %t", result, session.UsesJetfuel()) + } + + client.SetNextJetfuelCastleTokens([]string{"identifier-token"}) + result, err = session.SubmitIdentifier(context.Background(), "test-user") + if err != nil || result == nil || result.Status != twittermeow.WebLoginStatusNeedsPassword { + t.Fatalf("SubmitIdentifier() result = %#v, error = %v", result, err) + } + + client.SetNextJetfuelCastleTokens([]string{"first-password-token"}) + login := &TwitterLogin{ + User: &bridgev2.User{Log: zerolog.Nop()}, + webLogin: session, + webLoginIdentifier: "test-user", + webLoginPassword: "test-password", + webLoginCastleStage: webLoginCastleStagePassword, + } + step, err := login.continueWebCastleLogin(context.Background()) + if err != nil { + t.Fatalf("first continueWebCastleLogin() error = %v", err) + } + if step == nil || step.StepID != LoginStepIDCastleToken { + t.Fatalf("first continueWebCastleLogin() step = %#v, want Castle token step", step) + } + if login.webLoginCastleStage != webLoginCastleStagePassword { + t.Fatalf("Castle stage = %q, want password", login.webLoginCastleStage) + } + if passwordRequestCount != 1 { + t.Fatalf("password request count = %d, want 1", passwordRequestCount) + } + + client.SetNextJetfuelCastleTokens([]string{"replay-password-token"}) + step, err = login.continueWebCastleLogin(context.Background()) + if err != nil { + t.Fatalf("second continueWebCastleLogin() error = %v", err) + } + if step == nil || step.StepID != LoginStepIDVerification { + t.Fatalf("second continueWebCastleLogin() step = %#v, want verification step", step) + } + if passwordRequestCount != 2 { + t.Fatalf("password request count = %d, want 2", passwordRequestCount) + } +} + +func TestCookieLoginRemainsVisible(t *testing.T) { + login := &TwitterLogin{useCookieLogin: true} + step, err := login.Start(context.Background()) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + if step.CookiesParams == nil { + t.Fatal("CookiesParams = nil") + } + if step.CookiesParams.Hidden { + t.Fatal("CookiesParams.Hidden = true, want user-driven cookie login to remain visible") + } +} + +func TestMakeAuthMethodStepUsesNativeSelect(t *testing.T) { + methods := []twittermeow.WebLoginAuthMethod{ + {ID: "Totp", Name: "Authenticator App", Supported: true}, + {ID: "Sms", Name: "Text Message", Supported: false}, + {ID: "BackupCode", Name: "Backup Code", Supported: true}, + {ID: "U2fSecurityKey", Name: "Security Key PC", Supported: false}, + } + step := makeAuthMethodStep(methods, "") + + if step.Type != bridgev2.LoginStepTypeUserInput { + t.Fatalf("Type = %s, want user input", step.Type) + } + if step.StepID != LoginStepIDAuthMethod { + t.Fatalf("StepID = %s, want %s", step.StepID, LoginStepIDAuthMethod) + } + if step.UserInputParams == nil || len(step.UserInputParams.Fields) != 1 { + t.Fatalf("UserInputParams = %#v, want one field", step.UserInputParams) + } + field := step.UserInputParams.Fields[0] + if field.Type != bridgev2.LoginInputFieldTypeSelect { + t.Fatalf("field.Type = %s, want select", field.Type) + } + if field.ID != loginFieldAuthMethod { + t.Fatalf("field.ID = %s, want %s", field.ID, loginFieldAuthMethod) + } + if strings.Join(field.Options, ",") != "Authenticator App,Backup Code" { + t.Fatalf("field.Options = %#v", field.Options) + } + if strings.Contains(step.Instructions, "not supported") { + t.Fatalf("Instructions = %q, want no unsupported caveat", step.Instructions) + } +} + +func TestWebLoginUnsupportedInstructionsUsesChallengeDescription(t *testing.T) { + result := &twittermeow.WebLoginResult{ + Status: twittermeow.WebLoginStatusUnsupported, + Challenge: &twittermeow.WebLoginChallenge{ + Description: "Text message verification is coming soon.", + }, + } + + if got := webLoginUnsupportedInstructions(result); got != "Text message verification is coming soon." { + t.Fatalf("webLoginUnsupportedInstructions() = %q", got) + } +} + +func TestMakeCastleTokenStepUsesClientWebviewExtraction(t *testing.T) { + if castleTokenBatchSize < 6 { + t.Fatalf("castleTokenBatchSize = %d, want enough tokens for the six-request 2FA login path", castleTokenBatchSize) + } + info := twittermeow.JetfuelCastleTokenInfo{ + ScriptURL: "https://abs.twimg.com/responsive-web/client-web/ondemand.castle.1ff15ffa.js", + PublicKey: "castle-public-key", + } + step := makeCastleTokenStep(info, "test-user", "") + + if step.Type != bridgev2.LoginStepTypeCookies { + t.Fatalf("Type = %s, want cookies webview step", step.Type) + } + if step.StepID != LoginStepIDCastleToken { + t.Fatalf("StepID = %s, want %s", step.StepID, LoginStepIDCastleToken) + } + if step.CookiesParams == nil { + t.Fatal("CookiesParams = nil") + } + if !step.CookiesParams.Hidden { + t.Fatal("CookiesParams.Hidden = false, want Castle token acquisition to run in a hidden webview") + } + if step.CookiesParams.UserAgent != "" { + t.Fatalf("UserAgent = %q, want the webview's native user agent", step.CookiesParams.UserAgent) + } + if step.CookiesParams.URL != castleTokenWebviewURL { + t.Fatalf("URL = %q", step.CookiesParams.URL) + } + if strings.Contains(step.CookiesParams.URL, "/i/flow/login") { + t.Fatalf("URL = %q, want neutral webview page", step.CookiesParams.URL) + } + if !strings.Contains(step.CookiesParams.WaitForURLPattern, "robots") { + t.Fatalf("WaitForURLPattern = %q, want neutral X URL", step.CookiesParams.WaitForURLPattern) + } + if !strings.Contains(step.CookiesParams.ExtractJS, info.ScriptURL) || + !strings.Contains(step.CookiesParams.ExtractJS, "createRequestToken") { + t.Fatalf("ExtractJS does not load X Castle token generator") + } + if strings.Contains(step.CookiesParams.ExtractJS, castleTokenJSConfigPlaceholder) { + t.Fatal("ExtractJS still contains the embedded script config placeholder") + } + if !strings.Contains(step.CookiesParams.ExtractJS, castleTokenContextURL) { + t.Fatalf("ExtractJS does not include the X login context") + } + if !strings.Contains(step.CookiesParams.ExtractJS, "showBrowserLoginStatus") || + !strings.Contains(step.CookiesParams.ExtractJS, "Signing in to X") || + !strings.Contains(step.CookiesParams.ExtractJS, "mautrix-twitter-login-status") || + !strings.Contains(step.CookiesParams.ExtractJS, "body.replaceChildren(container)") { + t.Fatalf("ExtractJS does not replace robots.txt with the visible X login status") + } + if !strings.Contains(step.CookiesParams.ExtractJS, "__BEEP_BEEP_AUTH_RESULTS__") { + t.Fatalf("ExtractJS does not store the BrowserAuth result for Desktop polling") + } + if !strings.Contains(step.CookiesParams.ExtractJS, "__MAUTRIX_TWITTER_CASTLE_IN_PROGRESS__") { + t.Fatalf("ExtractJS does not guard against repeated BrowserAuth navigation runs") + } + if !strings.Contains(step.CookiesParams.ExtractJS, "castleTokenBatchSize") { + t.Fatalf("ExtractJS does not generate a Castle token batch") + } + fields := map[string]bridgev2.LoginCookieField{} + for _, field := range step.CookiesParams.Fields { + fields[field.ID] = field + } + if field, ok := fields[loginFieldCastleToken]; !ok || !field.Required { + t.Fatalf("Fields = %#v, want required Castle token field", step.CookiesParams.Fields) + } else if !hasCookieFieldSource(field, bridgev2.LoginCookieTypeLocalStorage, "fi.mau.twitter.castle_token") { + t.Fatalf("Castle token field sources = %#v, want local_storage fallback", field.Sources) + } + for index := 2; index <= castleTokenBatchSize; index++ { + fieldID := castleTokenFieldID(index) + field, ok := fields[fieldID] + if !ok { + t.Fatalf("Fields missing optional Castle token batch field %q", fieldID) + } + if field.Required { + t.Fatalf("Castle token batch field %q is required", fieldID) + } + if !hasCookieFieldSource(field, bridgev2.LoginCookieTypeLocalStorage, "fi.mau.twitter.castle_token_"+strconv.Itoa(index)) { + t.Fatalf("Castle token batch field %q sources = %#v, want local_storage fallback", fieldID, field.Sources) + } + } + for _, expected := range browserHeaderFields { + field, ok := fields[expected.ID] + if !ok { + t.Fatalf("Fields missing browser header %q", expected.ID) + } + if field.Required != expected.Required { + t.Fatalf("browser header %q required = %t, want %t", expected.ID, field.Required, expected.Required) + } + if !hasCookieFieldSource(field, bridgev2.LoginCookieTypeRequestHeader, expected.HeaderName) { + t.Fatalf("browser header %q sources = %#v, want request header %q", expected.ID, field.Sources, expected.HeaderName) + } + if field.Sources[0].RequestURLRegex == "" || !hasCookieFieldSource(field, bridgev2.LoginCookieTypeSpecial, expected.ID) { + t.Fatalf("browser header %q sources = %#v, want x.com request extraction and webview JS fallback", expected.ID, field.Sources) + } + } + if !strings.Contains(step.CookiesParams.ExtractJS, "navigator.userAgent") || + !strings.Contains(step.CookiesParams.ExtractJS, "navigator.userAgentData") { + t.Fatal("ExtractJS does not include the webview browser-header fallback") + } + for _, name := range castleTokenCookieNames { + field, ok := fields[name] + if !ok { + t.Fatalf("Fields missing optional browser cookie %q", name) + } + if field.Required { + t.Fatalf("browser cookie field %q is required", name) + } + if !strings.Contains(step.CookiesParams.ExtractJS, name) { + t.Fatalf("ExtractJS does not include browser cookie %q", name) + } + if !hasCookieFieldSource(field, bridgev2.LoginCookieTypeLocalStorage, "fi.mau.twitter.cookie."+name) { + t.Fatalf("browser cookie field %q sources = %#v, want local_storage fallback", name, field.Sources) + } + } + for _, name := range []string{"auth_token", "ct0", "twid", "kdt"} { + if _, ok := fields[name]; ok { + t.Fatalf("Fields include auth cookie %q", name) + } + } +} + +func TestBrowserHeadersFromInput(t *testing.T) { + input := map[string]string{ + loginFieldBrowserUserAgent: "test user agent", + loginFieldBrowserSecCHUA: `"Chromium";v="150"`, + loginFieldBrowserPlatform: `"Android"`, + loginFieldBrowserMobile: "?1", + } + got := browserHeadersFromInput(input) + if got.UserAgent != input[loginFieldBrowserUserAgent] || + got.SecCHUserAgent != input[loginFieldBrowserSecCHUA] || + got.SecCHPlatform != input[loginFieldBrowserPlatform] || + got.SecCHMobile != input[loginFieldBrowserMobile] { + t.Fatalf("browserHeadersFromInput() = %#v", got) + } +} + +func TestUserLoginMetadataPersistsBrowserHeaders(t *testing.T) { + meta := UserLoginMetadata{ + Cookies: "ct0=test", + BrowserHeaders: &twittermeow.BrowserHeaders{ + UserAgent: "test user agent", + SecCHUserAgent: `"Chromium";v="150"`, + SecCHPlatform: `"Windows"`, + SecCHMobile: "?0", + }, + } + encoded, err := json.Marshal(meta) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + var decoded UserLoginMetadata + if err = json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if decoded.BrowserHeaders == nil || *decoded.BrowserHeaders != *meta.BrowserHeaders { + t.Fatalf("decoded BrowserHeaders = %#v, want %#v", decoded.BrowserHeaders, meta.BrowserHeaders) + } +} + +func hasCookieFieldSource(field bridgev2.LoginCookieField, sourceType bridgev2.LoginCookieFieldSourceType, name string) bool { + for _, source := range field.Sources { + if source.Type == sourceType && source.Name == name { + return true + } + } + return false +} + +func TestDecodeCastleTokenInputAcceptsHeaderCaptureValue(t *testing.T) { + token := strings.Repeat("castle-token-", 64) + encoded := base64.RawURLEncoding.EncodeToString([]byte(token)) + got, err := decodeCastleTokenInput(castleTokenHeaderPrefix + encoded) + if err != nil { + t.Fatalf("decodeCastleTokenInput() failed: %v", err) + } + if got != token { + t.Fatalf("decodeCastleTokenInput() = %q, want original token", got) + } + + got, err = decodeCastleTokenInput(token) + if err != nil { + t.Fatalf("decodeCastleTokenInput(raw) failed: %v", err) + } + if got != token { + t.Fatalf("decodeCastleTokenInput(raw) = %q, want original token", got) + } +} + +func TestDecodeCastleTokenInputStripsTransportWhitespace(t *testing.T) { + token := strings.Repeat("castle-token-", 64) + wrapped := token[:80] + "\r\n" + token[80:160] + "\n\t " + token[160:] + got, err := decodeCastleTokenInput(wrapped) + if err != nil { + t.Fatalf("decodeCastleTokenInput(wrapped) failed: %v", err) + } + if got != token { + t.Fatalf("decodeCastleTokenInput(wrapped) = %q, want original token", got) + } + + encoded := base64.RawURLEncoding.EncodeToString([]byte(token)) + wrappedEncoded := encoded[:80] + "\n" + encoded[80:] + got, err = decodeCastleTokenInput(castleTokenHeaderPrefix + wrappedEncoded) + if err != nil { + t.Fatalf("decodeCastleTokenInput(wrapped header) failed: %v", err) + } + if got != token { + t.Fatalf("decodeCastleTokenInput(wrapped header) = %q, want original token", got) + } +} + +func TestDecodeCastleTokenBatchInput(t *testing.T) { + tokensByIndex := make([]string, castleTokenBatchSize) + input := make(map[string]string, castleTokenBatchSize) + for index := 1; index <= castleTokenBatchSize; index++ { + token := strings.Repeat(fmt.Sprintf("castle-%d-", index), 64) + tokensByIndex[index-1] = token + input[castleTokenFieldID(index)] = token + } + input[loginFieldCastleToken] = tokensByIndex[0][:80] + "\n" + tokensByIndex[0][80:] + + tokens, err := decodeCastleTokenBatchInput(input) + if err != nil { + t.Fatalf("decodeCastleTokenBatchInput() error = %v", err) + } + if len(tokens) != castleTokenBatchSize { + t.Fatalf("len(tokens) = %d, want %d", len(tokens), castleTokenBatchSize) + } + for index := range tokens { + if tokens[index] != tokensByIndex[index] { + t.Fatalf("tokens[%d] = %q, want token %d", index, tokens[index], index+1) + } + } +} + +func TestCastleTokenFieldPatternAcceptsWrappedTransportValue(t *testing.T) { + fields := castleTokenCookieFields() + if len(fields) == 0 || fields[0].ID != loginFieldCastleToken { + t.Fatalf("first field = %#v, want Castle token field", fields) + } + token := strings.Repeat("castle-token-", 64) + wrapped := token[:80] + "\n" + token[80:] + if !regexp.MustCompile(fields[0].Pattern).MatchString(wrapped) { + t.Fatalf("Castle token field pattern %q rejected wrapped token transport", fields[0].Pattern) + } + if strings.Contains(fields[0].Pattern, "(?s)") { + t.Fatalf("Castle token field pattern %q must be JavaScript RegExp compatible", fields[0].Pattern) + } + if !strings.Contains(fields[0].Pattern, `[\s\S]`) { + t.Fatalf("Castle token field pattern %q should match newlines without JS-only-invalid flags", fields[0].Pattern) + } +} + +func TestFindWebLoginAuthMethodMatchesNameOrID(t *testing.T) { + methods := []twittermeow.WebLoginAuthMethod{ + {ID: "Totp", Name: "Authenticator App", Supported: true}, + {ID: "Sms", Name: "Text Message", Supported: true}, + {ID: "BackupCode", Name: "Backup Code", Supported: true}, + } + if method, ok := findWebLoginAuthMethod(methods, "Authenticator App"); !ok || method.ID != "Totp" { + t.Fatalf("find by label = %#v %t, want Totp", method, ok) + } + if method, ok := findWebLoginAuthMethod(methods, "backup_code"); !ok || method.ID != "BackupCode" { + t.Fatalf("find by normalized ID = %#v %t, want BackupCode", method, ok) + } + if method, ok := findWebLoginAuthMethod(methods, "text_message"); !ok || method.ID != "Sms" { + t.Fatalf("find by normalized ID = %#v %t, want Sms", method, ok) + } +} + +func TestMakeVerificationStepUsesPhoneNumberInput(t *testing.T) { + step := makeVerificationStep(&twittermeow.WebLoginChallenge{ + Description: "Enter the phone number associated with your X account.", + InputKind: twittermeow.WebLoginChallengeInputKindPhoneNumber, + }, "") + + if step.UserInputParams == nil || len(step.UserInputParams.Fields) != 1 { + t.Fatalf("UserInputParams = %#v, want one field", step.UserInputParams) + } + field := step.UserInputParams.Fields[0] + if field.Type != bridgev2.LoginInputFieldTypePhoneNumber { + t.Fatalf("field.Type = %s, want phone_number", field.Type) + } + if field.Name != "Phone number" { + t.Fatalf("field.Name = %q, want Phone number", field.Name) + } + if !strings.Contains(step.Instructions, "phone number") { + t.Fatalf("Instructions = %q, want phone number prompt", step.Instructions) + } +} diff --git a/pkg/connector/msgconv.go b/pkg/connector/msgconv.go index ede9b34e..6352bd54 100644 --- a/pkg/connector/msgconv.go +++ b/pkg/connector/msgconv.go @@ -654,11 +654,11 @@ func downloadFile(ctx context.Context, cli *twittermeow.Client, url string) (*ht if isPublicCDNURL(url) { // Public CDN URLs use minimal headers - no auth, no Origin/Referer - req.Header.Set("User-Agent", twittermeow.UserAgent) + req.Header.Set("User-Agent", cli.GetUserAgent()) req.Header.Set("Accept", "*/*") } else { // Authenticated requests use full headers with cookies - headers := twittermeow.BaseHeaders.Clone() + headers := cli.GetBaseHeaders() headers.Set("Cookie", cli.GetCookieString()) req.Header = headers } diff --git a/pkg/twittermeow/castle_token.go b/pkg/twittermeow/castle_token.go new file mode 100644 index 00000000..9c2bb9bc --- /dev/null +++ b/pkg/twittermeow/castle_token.go @@ -0,0 +1,65 @@ +package twittermeow + +import ( + "errors" + "net/url" + "strings" +) + +var ErrJetfuelCastleTokenRequired = errors.New("x login needs a Castle token from the client webview") + +type JetfuelCastleTokenInfo struct { + ScriptURL string + PublicKey string +} + +func (info JetfuelCastleTokenInfo) IsValid() bool { + return strings.TrimSpace(info.ScriptURL) != "" && strings.TrimSpace(info.PublicKey) != "" +} + +func (c *Client) JetfuelCastleTokenInfo() JetfuelCastleTokenInfo { + return c.jetfuelCastleInfo +} + +// SetNextJetfuelCastleTokens provides browser-generated Castle tokens for the +// next Jetfuel form submissions. Tokens are consumed in order and only once. +func (c *Client) SetNextJetfuelCastleTokens(tokens []string) { + c.jetfuelCastleTokenMu.Lock() + defer c.jetfuelCastleTokenMu.Unlock() + c.jetfuelCastleTokens = c.jetfuelCastleTokens[:0] + for _, token := range tokens { + if token = strings.TrimSpace(token); token != "" { + c.jetfuelCastleTokens = append(c.jetfuelCastleTokens, token) + } + } +} + +func (c *Client) takeNextJetfuelCastleToken() string { + c.jetfuelCastleTokenMu.Lock() + defer c.jetfuelCastleTokenMu.Unlock() + if len(c.jetfuelCastleTokens) == 0 { + return "" + } + token := c.jetfuelCastleTokens[0] + copy(c.jetfuelCastleTokens, c.jetfuelCastleTokens[1:]) + c.jetfuelCastleTokens = c.jetfuelCastleTokens[:len(c.jetfuelCastleTokens)-1] + return token +} + +func (c *Client) HasNextJetfuelCastleToken() bool { + c.jetfuelCastleTokenMu.Lock() + defer c.jetfuelCastleTokenMu.Unlock() + return len(c.jetfuelCastleTokens) > 0 +} + +func (c *Client) addJetfuelCastleTokenToForm(form url.Values) error { + if form.Get("$castle_token") != "" { + return nil + } + token := c.takeNextJetfuelCastleToken() + if token == "" { + return ErrJetfuelCastleTokenRequired + } + form.Set("$castle_token", token) + return nil +} diff --git a/pkg/twittermeow/client.go b/pkg/twittermeow/client.go index 54c13aad..3ddfe674 100644 --- a/pkg/twittermeow/client.go +++ b/pkg/twittermeow/client.go @@ -3,8 +3,10 @@ package twittermeow import ( "context" "encoding/base64" + "errors" "fmt" "net/http" + "net/url" "regexp" "slices" "strconv" @@ -48,6 +50,8 @@ type Client struct { session *CachedSession HTTP *http.Client + clientHTTPTransport *ClientHTTPTransport + eventHandler EventHandler streamEventHandler StreamEventHandler xchatEventHandler XChatEventHandler @@ -62,8 +66,12 @@ type Client struct { xchatProcessor *XChatEventProcessor keyManager *crypto.KeyManager - xchatToken *cachedXChatToken - xchatTokenMu sync.Mutex + jetfuelCastleTokens []string + jetfuelCastleInfo JetfuelCastleTokenInfo + browserHeaders BrowserHeaders + jetfuelCastleTokenMu sync.Mutex + xchatToken *cachedXChatToken + xchatTokenMu sync.Mutex } func NewClient(cookies *cookies.Cookies, store crypto.KeyStore, logger zerolog.Logger) *Client { @@ -93,6 +101,17 @@ func (c *Client) GetCookieString() string { return c.cookies.String() } +func (c *Client) SetCookies(values map[string]string) { + for name, value := range values { + name = strings.TrimSpace(name) + value = strings.TrimSpace(value) + if name == "" || value == "" { + continue + } + c.cookies.Set(cookies.XCookieName(name), value) + } +} + func (c *Client) SetSession(sess *CachedSession) { c.session = sess } @@ -211,6 +230,9 @@ func (c *Client) LoadMessagesPage(ctx context.Context) (CurrentUserProfile, erro profile, err := c.GetCurrentUserProfile(ctx) if err != nil { + if errors.Is(err, ErrClientHTTPRequestPending) { + return CurrentUserProfile{}, err + } if IsAuthError(err) { return CurrentUserProfile{}, err } @@ -277,11 +299,54 @@ func (c *Client) fetchScript(ctx context.Context, url string) ([]byte, error) { return scriptRespBody, err } -func (c *Client) fetchAndParseMainScript(ctx context.Context, scriptURL string) string { +func (c *Client) fetchCloudflareJSD(ctx context.Context, pageURL *url.URL, mainPageHTML string) error { + scriptURL := methods.ParseCloudflareJSDURL(mainPageHTML) + if scriptURL == "" { + return nil + } + parsedScriptURL, err := pageURL.Parse(scriptURL) + if err != nil { + return err + } + extraHeaders := map[string]string{ + "accept": "*/*", + "sec-fetch-dest": "script", + "sec-fetch-mode": "no-cors", + "sec-fetch-site": "same-origin", + } + originalCheckRedirect := c.HTTP.CheckRedirect + c.disableRedirects() + defer func() { + c.HTTP.CheckRedirect = originalCheckRedirect + }() + for i := 0; i < 4; i++ { + resp, _, err := c.MakeRequest(ctx, parsedScriptURL.String(), http.MethodGet, c.buildHeaders(HeaderOpts{ + Extra: extraHeaders, + Referer: pageURL.String(), + WithCookies: true, + }), nil, types.ContentTypeNone) + if resp != nil { + c.cookies.UpdateFromResponse(resp) + } + if !errors.Is(err, ErrRedirectAttempted) { + return err + } + location := resp.Header.Get("Location") + if location == "" { + return err + } + parsedScriptURL, err = parsedScriptURL.Parse(location) + if err != nil { + return err + } + } + return ErrMaxRetriesReached +} + +func (c *Client) fetchAndParseMainScript(ctx context.Context, scriptURL string) (string, error) { scriptRespBody, err := c.fetchScript(ctx, scriptURL) if err != nil { - zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to fetch main script") - return "" + return "", err } authTokenBytes := methods.ParseBearerToken(scriptRespBody) authTokens := exslices.CastFunc(authTokenBytes, func(from []byte) string { @@ -299,7 +364,7 @@ func (c *Client) fetchAndParseMainScript(ctx context.Context, scriptURL string) Msg("Hardcoded token doesn't match fetched one") c.session.bearerToken = authTokens[0] } - return methods.ParseOndemandSURLFromScript(scriptRespBody) + return methods.ParseOndemandSURLFromScript(scriptRespBody), nil } func (c *Client) fetchAndParseSScript(ctx context.Context, scriptURL string) (*[4]int, error) { @@ -381,9 +446,31 @@ func (c *Client) parseMainPageHTML(ctx context.Context, mainPageResp *http.Respo Msg("Found loading animations and verification token") } + for name, value := range methods.ParseDocumentCookieAssignments(mainPageHTML) { + c.cookies.Set(cookies.XCookieName(name), value) + } + if mainPageResp.Request != nil && mainPageResp.Request.URL != nil { + if err := c.fetchCloudflareJSD(ctx, mainPageResp.Request.URL, mainPageHTML); err != nil { + if errors.Is(err, ErrClientHTTPRequestPending) { + return err + } + c.Logger.Debug().Err(err).Msg("Failed to fetch Cloudflare JSD bootstrap") + } + } + c.session.Country = country c.session.VerificationToken = verificationToken c.session.loadingAnims = loadingAnims + c.jetfuelCastleInfo = JetfuelCastleTokenInfo{ + ScriptURL: methods.ParseOndemandCastleURLFromScript([]byte(mainPageHTML)), + PublicKey: methods.ParseResponsiveWebCastlePublicKey(mainPageHTML), + } + if !c.jetfuelCastleInfo.IsValid() { + c.Logger.Debug(). + Bool("has_script_url", c.jetfuelCastleInfo.ScriptURL != ""). + Bool("has_public_key", c.jetfuelCastleInfo.PublicKey != ""). + Msg("X Castle web metadata not found in main page HTML") + } guestToken := methods.ParseGuestToken(mainPageHTML) if guestToken == "" { @@ -400,12 +487,21 @@ func (c *Client) parseMainPageHTML(ctx context.Context, mainPageResp *http.Respo if mainScriptURL == "" { zerolog.Ctx(ctx).Warn().Int("status_code", mainPageResp.StatusCode).Msg("Main script URL not found in main page HTML") } else if ondemandSURL == "" { - ondemandSURL = c.fetchAndParseMainScript(ctx, mainScriptURL) + var fetchErr error + ondemandSURL, fetchErr = c.fetchAndParseMainScript(ctx, mainScriptURL) + if errors.Is(fetchErr, ErrClientHTTPRequestPending) { + return fetchErr + } else if fetchErr != nil { + zerolog.Ctx(ctx).Warn().Err(fetchErr).Msg("Failed to fetch main script") + } } if ondemandSURL == "" { c.Logger.Warn().Msg("ondemand.s URL not found in bootstrap sources") } else if indexes, err := c.fetchAndParseSScript(ctx, ondemandSURL); err != nil { + if errors.Is(err, ErrClientHTTPRequestPending) { + return err + } c.Logger.Warn().Err(err).Msg("Failed to fetch and parse s script") } else { c.session.variableIndexes = indexes diff --git a/pkg/twittermeow/client_http.go b/pkg/twittermeow/client_http.go new file mode 100644 index 00000000..86dae031 --- /dev/null +++ b/pkg/twittermeow/client_http.go @@ -0,0 +1,349 @@ +package twittermeow + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + + "golang.org/x/net/http/httpguts" +) + +const ( + ClientHTTPMaxResponseBodySize = 8 << 20 + ClientHTTPMaxResponseHeadersSize = 64 << 10 + clientHTTPMaxRequestBodySize = 4 << 20 + clientHTTPMaxExchanges = 64 +) + +var ( + ErrClientHTTPRequestPending = errors.New("client HTTP request is pending") + errClientHTTPOperationChanged = errors.New("client HTTP operation changed before completion") + errClientHTTPReplayMismatch = errors.New("client HTTP request did not match the recorded operation") +) + +type ClientHTTPRequest struct { + ID string + Method string + URL string + Headers http.Header + Body []byte +} + +func (req *ClientHTTPRequest) clone() *ClientHTTPRequest { + if req == nil { + return nil + } + cloned := *req + cloned.Headers = req.Headers.Clone() + cloned.Body = bytes.Clone(req.Body) + return &cloned +} + +type ClientHTTPResponse struct { + RequestID string + Status int + Headers http.Header + Body []byte + FinalURL string +} + +type clientHTTPExchange struct { + method string + url string + response ClientHTTPResponse +} + +// ClientHTTPTransport turns each outgoing HTTP request into a client login step. +// +// A connector starts an operation, runs the normal twittermeow method, submits +// the client response, and reruns the same operation. Previously submitted +// responses are replayed in order until the next real request is reached. This +// keeps request construction and response parsing in Go while the network I/O +// happens in the Beeper client's network stack. +type ClientHTTPTransport struct { + mu sync.Mutex + + operation string + exchanges []clientHTTPExchange + cursor int + pending *ClientHTTPRequest + nextID uint64 + baseTransport http.RoundTripper + baseCheckRedirect func(*http.Request, []*http.Request) error +} + +func newClientHTTPTransport() *ClientHTTPTransport { + return &ClientHTTPTransport{} +} + +func (t *ClientHTTPTransport) BeginOperation(operation string) error { + operation = strings.TrimSpace(operation) + if operation == "" { + return fmt.Errorf("client HTTP operation name is required") + } + + t.mu.Lock() + defer t.mu.Unlock() + if t.operation == "" { + t.operation = operation + } else if t.operation != operation { + return fmt.Errorf("%w: have %q, got %q", errClientHTTPOperationChanged, t.operation, operation) + } + t.cursor = 0 + return nil +} + +func (t *ClientHTTPTransport) EndOperation() error { + t.mu.Lock() + defer t.mu.Unlock() + if t.pending != nil { + return ErrClientHTTPRequestPending + } + if t.cursor != len(t.exchanges) { + return fmt.Errorf("%w: consumed %d of %d responses", errClientHTTPReplayMismatch, t.cursor, len(t.exchanges)) + } + t.resetOperationLocked() + return nil +} + +func (t *ClientHTTPTransport) ResetOperation() { + t.mu.Lock() + defer t.mu.Unlock() + t.resetOperationLocked() +} + +func (t *ClientHTTPTransport) resetOperationLocked() { + t.operation = "" + t.exchanges = nil + t.cursor = 0 + t.pending = nil +} + +func (t *ClientHTTPTransport) PendingRequest() *ClientHTTPRequest { + t.mu.Lock() + defer t.mu.Unlock() + return t.pending.clone() +} + +func (t *ClientHTTPTransport) SubmitResponse(response ClientHTTPResponse) error { + t.mu.Lock() + defer t.mu.Unlock() + if t.pending == nil { + return fmt.Errorf("no client HTTP request is pending") + } + if response.RequestID != t.pending.ID { + return fmt.Errorf("client HTTP response request ID does not match") + } + if response.Status < 100 || response.Status > 599 { + return fmt.Errorf("invalid client HTTP response status %d", response.Status) + } + if len(response.Body) > ClientHTTPMaxResponseBodySize { + return fmt.Errorf("client HTTP response body is too large") + } + if len(t.exchanges) >= clientHTTPMaxExchanges { + return fmt.Errorf("client HTTP operation has too many requests") + } + headerSize := 0 + for name, values := range response.Headers { + if !httpguts.ValidHeaderFieldName(name) { + return fmt.Errorf("client HTTP response header name is invalid") + } + headerSize += len(name) + for _, value := range values { + if !httpguts.ValidHeaderFieldValue(value) { + return fmt.Errorf("client HTTP response header value is invalid") + } + headerSize += len(value) + } + } + if headerSize > ClientHTTPMaxResponseHeadersSize { + return fmt.Errorf("client HTTP response headers are too large") + } + if response.FinalURL != "" { + finalURL, err := url.Parse(response.FinalURL) + if err != nil || !isAllowedClientHTTPURL(finalURL) { + return fmt.Errorf("client HTTP response URL is not allowed") + } + } + response.Headers = response.Headers.Clone() + response.Body = bytes.Clone(response.Body) + t.exchanges = append(t.exchanges, clientHTTPExchange{ + method: t.pending.Method, + url: t.pending.URL, + response: response, + }) + t.pending = nil + return nil +} + +func (t *ClientHTTPTransport) RoundTrip(request *http.Request) (*http.Response, error) { + body, err := readClientHTTPRequestBody(request) + if err != nil { + return nil, err + } + + t.mu.Lock() + defer t.mu.Unlock() + if t.operation == "" { + return nil, fmt.Errorf("client HTTP request made outside an operation") + } + if t.cursor < len(t.exchanges) { + exchange := t.exchanges[t.cursor] + if request.Method != exchange.method || request.URL.String() != exchange.url { + return nil, fmt.Errorf( + "%w: response %d expected %s %s, got %s %s", + errClientHTTPReplayMismatch, + t.cursor, + exchange.method, + exchange.url, + request.Method, + request.URL.String(), + ) + } + t.cursor++ + return replayClientHTTPResponse(request, exchange.response), nil + } + if t.pending != nil { + if request.Method != t.pending.Method || request.URL.String() != t.pending.URL { + return nil, fmt.Errorf("%w: a different request arrived while waiting for the client", errClientHTTPReplayMismatch) + } + return nil, ErrClientHTTPRequestPending + } + if err = validateClientHTTPRequest(request, body); err != nil { + return nil, err + } + + t.nextID++ + t.pending = &ClientHTTPRequest{ + ID: "client-http-" + strconv.FormatUint(t.nextID, 10), + Method: request.Method, + URL: request.URL.String(), + Headers: clientHTTPFetchHeaders(request.Header), + Body: bytes.Clone(body), + } + return nil, ErrClientHTTPRequestPending +} + +func readClientHTTPRequestBody(request *http.Request) ([]byte, error) { + if request.Body == nil { + return nil, nil + } + body, err := io.ReadAll(io.LimitReader(request.Body, clientHTTPMaxRequestBodySize+1)) + if err != nil { + return nil, fmt.Errorf("read client HTTP request body: %w", err) + } + request.Body = io.NopCloser(bytes.NewReader(body)) + if len(body) > clientHTTPMaxRequestBodySize { + return nil, fmt.Errorf("client HTTP request body is too large") + } + return body, nil +} + +func validateClientHTTPRequest(request *http.Request, body []byte) error { + if request.Method != http.MethodGet && request.Method != http.MethodPost { + return fmt.Errorf("client HTTP method %s is not allowed", request.Method) + } + if !isAllowedClientHTTPURL(request.URL) { + return fmt.Errorf("client HTTP URL is not allowed") + } + if len(body) > clientHTTPMaxRequestBodySize { + return fmt.Errorf("client HTTP request body is too large") + } + return nil +} + +func isAllowedClientHTTPURL(parsed *url.URL) bool { + if parsed == nil || parsed.Scheme != "https" || parsed.User != nil || parsed.Port() != "" { + return false + } + switch strings.ToLower(parsed.Hostname()) { + case "x.com", "api.x.com", "twitter.com", "abs.twimg.com": + return true + default: + return false + } +} + +func clientHTTPFetchHeaders(headers http.Header) http.Header { + out := make(http.Header) + for key, values := range headers { + key = strings.TrimSpace(key) + if key == "" || isClientManagedHeader(strings.ToLower(key)) { + continue + } + for _, value := range values { + if value == "" || strings.ContainsAny(value, "\r\n") { + continue + } + out.Add(key, value) + } + } + return out +} + +func isClientManagedHeader(name string) bool { + switch name { + case "accept-encoding", "connection", "content-length", "expect", + "host", "keep-alive", "te", "trailer", "transfer-encoding", "upgrade", "via": + return true + } + return strings.HasPrefix(name, "proxy-") +} + +func replayClientHTTPResponse(request *http.Request, response ClientHTTPResponse) *http.Response { + statusText := http.StatusText(response.Status) + status := strconv.Itoa(response.Status) + if statusText != "" { + status += " " + statusText + } + finalRequest := request + if response.FinalURL != "" && response.FinalURL != request.URL.String() { + if finalURL, err := url.Parse(response.FinalURL); err == nil { + cloned := request.Clone(request.Context()) + cloned.URL = finalURL + finalRequest = cloned + } + } + return &http.Response{ + Status: status, + StatusCode: response.Status, + Header: response.Headers.Clone(), + Body: io.NopCloser(bytes.NewReader(response.Body)), + ContentLength: int64(len(response.Body)), + Request: finalRequest, + } +} + +func (c *Client) EnableClientHTTP() *ClientHTTPTransport { + if c.clientHTTPTransport != nil { + return c.clientHTTPTransport + } + transport := newClientHTTPTransport() + transport.baseTransport = c.HTTP.Transport + transport.baseCheckRedirect = c.HTTP.CheckRedirect + c.HTTP.Transport = transport + c.HTTP.CheckRedirect = nil + c.clientHTTPTransport = transport + return transport +} + +func (c *Client) DisableClientHTTP() { + if c.clientHTTPTransport == nil { + return + } + transport := c.clientHTTPTransport + transport.ResetOperation() + c.HTTP.Transport = transport.baseTransport + c.HTTP.CheckRedirect = transport.baseCheckRedirect + c.clientHTTPTransport = nil +} + +func (c *Client) IsClientHTTPEnabled() bool { + return c.clientHTTPTransport != nil +} diff --git a/pkg/twittermeow/client_http_test.go b/pkg/twittermeow/client_http_test.go new file mode 100644 index 00000000..28c17b2b --- /dev/null +++ b/pkg/twittermeow/client_http_test.go @@ -0,0 +1,240 @@ +package twittermeow + +import ( + "context" + "errors" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + + "go.mau.fi/mautrix-twitter/pkg/twittermeow/cookies" +) + +func TestClientHTTPTransportCapturesAndReplaysRequest(t *testing.T) { + transport := newClientHTTPTransport() + if err := transport.BeginOperation("credentials"); err != nil { + t.Fatal(err) + } + + body := "$castle_token=" + url.QueryEscape("castle-from-webview") + "&password=secret" + request, err := http.NewRequest(http.MethodPost, "https://x.com/i/jfapi/onboarding/web/actions/begin_login", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("authorization", "Bearer public") + request.Header.Set("cookie", "auth_token=secret") + request.Header.Set("origin", "https://x.com") + request.Header.Set("referer", "https://x.com/i/jf/onboarding/web?mode=login") + request.Header.Set("sec-fetch-site", "same-origin") + request.Header.Set("user-agent", "bridge user agent") + request.Header.Add("x-multi", "one") + request.Header.Add("x-multi", "two") + + _, err = transport.RoundTrip(request) + if !errors.Is(err, ErrClientHTTPRequestPending) { + t.Fatalf("RoundTrip() error = %v, want pending", err) + } + pending := transport.PendingRequest() + if pending == nil || pending.Method != http.MethodPost || pending.URL != request.URL.String() { + t.Fatalf("PendingRequest() = %#v", pending) + } + if pending.Headers.Get("cookie") != "auth_token=secret" { + t.Fatalf("Cookie header = %q", pending.Headers.Get("cookie")) + } + if pending.Headers.Get("authorization") != "Bearer public" { + t.Fatalf("authorization header missing: %#v", pending.Headers) + } + for _, forwarded := range []string{"cookie", "origin", "referer", "sec-fetch-site", "user-agent"} { + if pending.Headers.Get(forwarded) == "" { + t.Fatalf("end-to-end header %q was not forwarded", forwarded) + } + } + if got := pending.Headers.Values("x-multi"); len(got) != 2 || got[0] != "one" || got[1] != "two" { + t.Fatalf("multi-value header was not preserved: %#v", pending.Headers) + } + + err = transport.SubmitResponse(ClientHTTPResponse{ + RequestID: pending.ID, + Status: http.StatusOK, + Headers: http.Header{ + "Content-Type": {"application/octet-stream"}, + "Set-Cookie": {"first=1; Path=/", "second=2; Path=/"}, + }, + Body: []byte("response body"), + FinalURL: "https://x.com/home", + }) + if err != nil { + t.Fatal(err) + } + if err = transport.BeginOperation("credentials"); err != nil { + t.Fatal(err) + } + replayRequest := request.Clone(context.Background()) + replayRequest.Body = io.NopCloser(strings.NewReader(body)) + response, err := transport.RoundTrip(replayRequest) + if err != nil { + t.Fatal(err) + } + replayedBody, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK || string(replayedBody) != "response body" { + t.Fatalf("replayed response = %d %q", response.StatusCode, replayedBody) + } + if response.Request.URL.String() != "https://x.com/home" { + t.Fatalf("replayed final URL = %q", response.Request.URL) + } + if got := response.Header.Values("Set-Cookie"); len(got) != 2 { + t.Fatalf("replayed Set-Cookie headers = %#v", got) + } + if err = transport.EndOperation(); err != nil { + t.Fatal(err) + } +} + +func TestClientHTTPTransportRejectsUnsafeRequestsAndResponses(t *testing.T) { + tests := []struct { + name string + method string + url string + }{ + {name: "plain HTTP", method: http.MethodGet, url: "http://x.com/login"}, + {name: "credentials in URL", method: http.MethodGet, url: "https://user:pass@x.com/login"}, + {name: "unknown host", method: http.MethodGet, url: "https://example.com/login"}, + {name: "explicit port", method: http.MethodGet, url: "https://x.com:443/login"}, + {name: "unsupported method", method: http.MethodDelete, url: "https://x.com/login"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + transport := newClientHTTPTransport() + if err := transport.BeginOperation("test"); err != nil { + t.Fatal(err) + } + request, err := http.NewRequest(test.method, test.url, nil) + if err != nil { + t.Fatal(err) + } + if _, err = transport.RoundTrip(request); err == nil || errors.Is(err, ErrClientHTTPRequestPending) { + t.Fatalf("RoundTrip() error = %v, want safety rejection", err) + } + }) + } + + transport := newClientHTTPTransport() + if err := transport.BeginOperation("test"); err != nil { + t.Fatal(err) + } + request, _ := http.NewRequest(http.MethodGet, "https://x.com/login", nil) + _, _ = transport.RoundTrip(request) + pending := transport.PendingRequest() + for _, response := range []ClientHTTPResponse{ + {RequestID: "wrong", Status: http.StatusOK}, + {RequestID: pending.ID, Status: 99}, + {RequestID: pending.ID, Status: http.StatusOK, FinalURL: "https://example.com/"}, + {RequestID: pending.ID, Status: http.StatusOK, Body: make([]byte, ClientHTTPMaxResponseBodySize+1)}, + {RequestID: pending.ID, Status: http.StatusOK, Headers: http.Header{"Bad Header": {"value"}}}, + {RequestID: pending.ID, Status: http.StatusOK, Headers: http.Header{"X-Test": {"bad\rvalue"}}}, + {RequestID: pending.ID, Status: http.StatusOK, Headers: http.Header{ + "X-Test": {strings.Repeat("a", ClientHTTPMaxResponseHeadersSize+1)}, + }}, + } { + if err := transport.SubmitResponse(response); err == nil { + t.Fatalf("SubmitResponse(%#v) succeeded, want rejection", response) + } + } + + largeBodyTransport := newClientHTTPTransport() + if err := largeBodyTransport.BeginOperation("test"); err != nil { + t.Fatal(err) + } + largeRequest, _ := http.NewRequest( + http.MethodPost, + "https://x.com/login", + strings.NewReader(strings.Repeat("a", clientHTTPMaxRequestBodySize+1)), + ) + if _, err := largeBodyTransport.RoundTrip(largeRequest); err == nil { + t.Fatal("oversized client HTTP request body was accepted") + } +} + +func TestClientHTTPTransportOperationAndReplayGuards(t *testing.T) { + transport := newClientHTTPTransport() + if err := transport.BeginOperation("first"); err != nil { + t.Fatal(err) + } + if err := transport.BeginOperation("second"); !errors.Is(err, errClientHTTPOperationChanged) { + t.Fatalf("BeginOperation() error = %v", err) + } + request, _ := http.NewRequest(http.MethodGet, "https://x.com/login", nil) + _, _ = transport.RoundTrip(request) + if err := transport.EndOperation(); !errors.Is(err, ErrClientHTTPRequestPending) { + t.Fatalf("EndOperation() error = %v", err) + } + pending := transport.PendingRequest() + if err := transport.SubmitResponse(ClientHTTPResponse{RequestID: pending.ID, Status: http.StatusOK}); err != nil { + t.Fatal(err) + } + if err := transport.SubmitResponse(ClientHTTPResponse{RequestID: pending.ID, Status: http.StatusOK}); err == nil { + t.Fatal("duplicate client HTTP response was accepted") + } + if err := transport.BeginOperation("first"); err != nil { + t.Fatal(err) + } + different, _ := http.NewRequest(http.MethodGet, "https://x.com/messages", nil) + if _, err := transport.RoundTrip(different); !errors.Is(err, errClientHTTPReplayMismatch) { + t.Fatalf("RoundTrip() error = %v", err) + } +} + +func TestClientHTTPModeUsesNativeCastleTokensAndRestoresTransport(t *testing.T) { + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + originalTransport := client.HTTP.Transport + transport := client.EnableClientHTTP() + if transport == nil || !client.IsClientHTTPEnabled() || client.HTTP.Transport != transport { + t.Fatal("client HTTP mode was not enabled") + } + form := url.Values{} + if err := client.addJetfuelCastleTokenToForm(form); !errors.Is(err, ErrJetfuelCastleTokenRequired) { + t.Fatalf("addJetfuelCastleTokenToForm() error = %v, want Castle token request", err) + } + client.SetNextJetfuelCastleTokens([]string{"castle-from-webview"}) + if err := client.addJetfuelCastleTokenToForm(form); err != nil { + t.Fatal(err) + } + if got := form.Get("$castle_token"); got != "castle-from-webview" { + t.Fatalf("$castle_token = %q", got) + } + client.DisableClientHTTP() + if client.IsClientHTTPEnabled() || client.HTTP.Transport != originalTransport { + t.Fatal("client HTTP mode did not restore the original transport") + } +} + +func TestMakeRequestReturnsClientHTTPPendingWithoutRetry(t *testing.T) { + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + transport := client.EnableClientHTTP() + if err := transport.BeginOperation("test"); err != nil { + t.Fatal(err) + } + started := time.Now() + _, _, err := client.MakeRequest( + context.Background(), + "https://x.com/login", + http.MethodGet, + http.Header{}, + nil, + "", + ) + if !errors.Is(err, ErrClientHTTPRequestPending) { + t.Fatalf("MakeRequest() error = %v", err) + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("MakeRequest() took %s, likely retried pending request", elapsed) + } +} diff --git a/pkg/twittermeow/cookies/cookies.go b/pkg/twittermeow/cookies/cookies.go index 7e195264..72b06385 100644 --- a/pkg/twittermeow/cookies/cookies.go +++ b/pkg/twittermeow/cookies/cookies.go @@ -21,6 +21,8 @@ const ( XLang XCookieName = "lang" XAtt XCookieName = "att" XPersonalizationID XCookieName = "personalization_id" + XDtabLocal XCookieName = "dtab_local" + XGuestIDAds XCookieName = "guest_id_ads" XGuestIDMarketing XCookieName = "guest_id_marketing" ) @@ -41,23 +43,33 @@ func NewCookies(store map[string]string) *Cookies { func NewCookiesFromString(cookieStr string) *Cookies { c := NewCookies(nil) - cookieStrings := strings.Split(cookieStr, ";") - fakeHeader := http.Header{} - for _, cookieStr := range cookieStrings { - trimmedCookieStr := strings.TrimSpace(cookieStr) - if trimmedCookieStr != "" { - fakeHeader.Add("Set-Cookie", trimmedCookieStr) - } - } - fakeResponse := &http.Response{Header: fakeHeader} - - for _, cookie := range fakeResponse.Cookies() { + for _, cookie := range parseCookiesFromString(cookieStr) { c.store[cookie.Name] = cookie.Value } return c } +func parseCookiesFromString(cookieStr string) []*http.Cookie { + parsedCookies, err := http.ParseCookie(cookieStr) + if err == nil { + return parsedCookies + } + setCookie, err := http.ParseSetCookie(cookieStr) + if err == nil { + return []*http.Cookie{setCookie} + } + cookieStrings := strings.Split(cookieStr, ";") + cookies := make([]*http.Cookie, 0, len(cookieStrings)) + for _, cookieStr := range cookieStrings { + cookie, err := http.ParseSetCookie(strings.TrimSpace(cookieStr)) + if err == nil { + cookies = append(cookies, cookie) + } + } + return cookies +} + func (c *Cookies) String() string { c.lock.RLock() defer c.lock.RUnlock() @@ -88,7 +100,7 @@ func (c *Cookies) UpdateFromResponse(r *http.Response) { c.lock.Lock() defer c.lock.Unlock() for _, cookie := range r.Cookies() { - if cookie.MaxAge == 0 || cookie.Expires.Before(time.Now()) { + if cookie.MaxAge < 0 || (!cookie.Expires.IsZero() && cookie.Expires.Before(time.Now())) { delete(c.store, cookie.Name) } else { //log.Println(fmt.Sprintf("updated cookie %s to value %s", cookie.Name, cookie.Value)) diff --git a/pkg/twittermeow/cookies/cookies_test.go b/pkg/twittermeow/cookies/cookies_test.go new file mode 100644 index 00000000..3a3c2a5d --- /dev/null +++ b/pkg/twittermeow/cookies/cookies_test.go @@ -0,0 +1,44 @@ +package cookies + +import ( + "net/http" + "testing" +) + +func TestNewCookiesFromStringParsesCookieHeader(t *testing.T) { + store := NewCookiesFromString("auth_token=auth-value; ct0=csrf-value") + + if got := store.Get(XAuthToken); got != "auth-value" { + t.Fatalf("auth_token = %q, want auth-value", got) + } + if got := store.Get(XCt0); got != "csrf-value" { + t.Fatalf("ct0 = %q, want csrf-value", got) + } +} + +func TestNewCookiesFromStringParsesSingleSetCookieHeader(t *testing.T) { + store := NewCookiesFromString("ct0=csrf-value; Path=/; Secure; HttpOnly") + + if got := store.Get(XCt0); got != "csrf-value" { + t.Fatalf("ct0 = %q, want csrf-value", got) + } + if got := store.Get(XCookieName("Path")); got != "" { + t.Fatalf("Path pseudo-cookie = %q, want omitted", got) + } +} + +func TestUpdateFromResponseKeepsSessionCookies(t *testing.T) { + store := NewCookies(map[string]string{"deleted": "old"}) + resp := &http.Response{Header: http.Header{}} + resp.Header.Add("Set-Cookie", "__cf_bm=session-value; Path=/; Secure; HttpOnly") + resp.Header.Add("Set-Cookie", "deleted=gone; Max-Age=0; Path=/") + + store.UpdateFromResponse(resp) + + if got := store.Get(XCookieName("__cf_bm")); got != "session-value" { + t.Fatalf("__cf_bm = %q, want session-value", got) + } + if got := store.Get(XCookieName("deleted")); got != "" { + t.Fatalf("deleted cookie still present: %q", got) + } +} diff --git a/pkg/twittermeow/data/endpoints/endpoints.go b/pkg/twittermeow/data/endpoints/endpoints.go index 87f33459..2ae1b32f 100644 --- a/pkg/twittermeow/data/endpoints/endpoints.go +++ b/pkg/twittermeow/data/endpoints/endpoints.go @@ -9,9 +9,12 @@ const ( BASE_HOST = "x.com" BASE_URL = "https://" + BASE_HOST BASE_LOGIN_URL = BASE_URL + "/login" + BASE_FLOW_LOGIN_URL = BASE_URL + "/i/flow/login" BASE_MESSAGES_URL = BASE_URL + "/messages" BASE_LOGOUT_URL = BASE_URL + "/logout" BASE_NOTIFICATION_SETTINGS_URL = BASE_URL + "/settings/push_notifications" + JETFUEL_BASE_URL = BASE_URL + "/i/jfapi" + JETFUEL_LOGIN_REFERER_URL = BASE_URL + "/i/jf/onboarding/web?mode=login" API_BASE_HOST = "api.x.com" API_BASE_URL = "https://" + API_BASE_HOST @@ -59,6 +62,17 @@ const ( JOT_CLIENT_EVENT_URL = API_BASE_URL + "/1.1/jot/client_event.json" JOT_CES_P2_URL = API_BASE_URL + "/1.1/jot/ces/p2" + VIEWER_CONTEXT_URL = API_BASE_URL + "/1.1/graphql/viewer_context.json" + + GUEST_ACTIVATE_URL = API_BASE_URL + "/1.1/guest/activate.json" + ONBOARDING_TASK_URL = API_BASE_URL + "/1.1/onboarding/task.json" + ONBOARDING_LOGIN_TASK_URL = ONBOARDING_TASK_URL + "?flow_name=login" + JETFUEL_LANDING_PATH = "/onboarding/web/landing" + JETFUEL_LOGIN_PATH = "/onboarding/web?mode=login" + JETFUEL_BEGIN_LOGIN_PATH = "/onboarding/web/actions/begin_login" + JETFUEL_LOGIN_ENTER_PASSWORD_PATH = "/onboarding/web/actions/login_enter_password" + JETFUEL_BEGIN_TWO_FACTOR_AUTH_PATH = "/onboarding/web/actions/begin_two_factor_auth" + JETFUEL_FINISH_TWO_FACTOR_AUTH_PATH = "/onboarding/web/actions/finish_two_factor_auth" PIPELINE_EVENTS_URL = API_BASE_URL + "/live_pipeline/events" PIPELINE_UPDATE_URL = API_BASE_URL + "/1.1/live_pipeline/update_subscriptions" diff --git a/pkg/twittermeow/headers.go b/pkg/twittermeow/headers.go index 81cde678..daafc545 100644 --- a/pkg/twittermeow/headers.go +++ b/pkg/twittermeow/headers.go @@ -2,11 +2,14 @@ package twittermeow import ( "net/http" + "strings" "go.mau.fi/mautrix-twitter/pkg/twittermeow/cookies" "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/endpoints" ) +// These defaults bootstrap requests made before BrowserAuth captures the +// client webview's actual HTTP headers. const BrowserName = "Chrome" const ChromeVersion = "141" const UserAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/" + ChromeVersion + ".0.0.0 Safari/537.36" @@ -17,9 +20,18 @@ const SecCHMobile = "?0" const UDID = OSName + "/" + BrowserName +// BrowserHeaders contains the browser fingerprint captured from the client +// webview. Empty client-hint values are intentional for non-Chromium browsers. +type BrowserHeaders struct { + UserAgent string `json:"user_agent"` + SecCHUserAgent string `json:"sec_ch_ua,omitempty"` + SecCHPlatform string `json:"sec_ch_ua_platform,omitempty"` + SecCHMobile string `json:"sec_ch_ua_mobile,omitempty"` +} + var BaseHeaders = http.Header{ "Accept": []string{"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"}, - "Accept-Language": []string{"en-US,en;q=0.9"}, + "Accept-Language": []string{"en"}, "User-Agent": []string{UserAgent}, "Sec-Ch-Ua": []string{SecCHUserAgent}, "Sec-Ch-Ua-Platform": []string{SecCHPlatform}, @@ -48,7 +60,7 @@ func (c *Client) buildHeaders(opts HeaderOpts) http.Header { opts.Extra = make(map[string]string) } - headers := BaseHeaders.Clone() + headers := c.GetBaseHeaders() if opts.WithCookies { opts.Extra["cookie"] = c.cookies.String() } @@ -93,3 +105,73 @@ func (c *Client) buildHeaders(opts HeaderOpts) http.Header { return headers } + +func normalizeBrowserHeader(value string) string { + value = strings.TrimSpace(value) + if value == "" || len(value) > 1024 { + return "" + } + for _, char := range value { + if char < 0x20 || char == 0x7f { + return "" + } + } + return value +} + +func (headers BrowserHeaders) normalized() BrowserHeaders { + headers.UserAgent = normalizeBrowserHeader(headers.UserAgent) + headers.SecCHUserAgent = normalizeBrowserHeader(headers.SecCHUserAgent) + headers.SecCHPlatform = normalizeBrowserHeader(headers.SecCHPlatform) + headers.SecCHMobile = normalizeBrowserHeader(headers.SecCHMobile) + if headers.SecCHMobile != "?0" && headers.SecCHMobile != "?1" { + headers.SecCHMobile = "" + } + if headers.UserAgent == "" { + return BrowserHeaders{} + } + return headers +} + +// SetBrowserHeaders installs a webview-captured browser fingerprint. It returns +// false when the required User-Agent is missing or unsafe to use as a header. +func (c *Client) SetBrowserHeaders(headers BrowserHeaders) bool { + headers = headers.normalized() + if headers.UserAgent == "" { + return false + } + c.browserHeaders = headers + return true +} + +func (c *Client) GetBrowserHeaders() BrowserHeaders { + return c.browserHeaders +} + +func (c *Client) GetBaseHeaders() http.Header { + headers := BaseHeaders.Clone() + browser := c.browserHeaders + if browser.UserAgent == "" { + return headers + } + headers.Set("User-Agent", browser.UserAgent) + setOptionalHeader(headers, "Sec-Ch-Ua", browser.SecCHUserAgent) + setOptionalHeader(headers, "Sec-Ch-Ua-Platform", browser.SecCHPlatform) + setOptionalHeader(headers, "Sec-Ch-Ua-Mobile", browser.SecCHMobile) + return headers +} + +func setOptionalHeader(headers http.Header, name, value string) { + if value == "" { + headers.Del(name) + } else { + headers.Set(name, value) + } +} + +func (c *Client) GetUserAgent() string { + if c.browserHeaders.UserAgent != "" { + return c.browserHeaders.UserAgent + } + return UserAgent +} diff --git a/pkg/twittermeow/headers_test.go b/pkg/twittermeow/headers_test.go new file mode 100644 index 00000000..1973530d --- /dev/null +++ b/pkg/twittermeow/headers_test.go @@ -0,0 +1,67 @@ +package twittermeow + +import ( + "testing" + + "github.com/rs/zerolog" + + "go.mau.fi/mautrix-twitter/pkg/twittermeow/cookies" +) + +func TestClientUsesCapturedBrowserHeaders(t *testing.T) { + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + captured := BrowserHeaders{ + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/150.0.0.0 Safari/537.36", + SecCHUserAgent: `"Chromium";v="150", "Google Chrome";v="150", "Not_A Brand";v="99"`, + SecCHPlatform: `"Windows"`, + SecCHMobile: "?0", + } + if !client.SetBrowserHeaders(captured) { + t.Fatal("SetBrowserHeaders() rejected valid captured headers") + } + + headers := client.GetBaseHeaders() + if got := headers.Get("User-Agent"); got != captured.UserAgent { + t.Fatalf("User-Agent = %q, want %q", got, captured.UserAgent) + } + if got := headers.Get("Sec-Ch-Ua"); got != captured.SecCHUserAgent { + t.Fatalf("Sec-Ch-Ua = %q, want %q", got, captured.SecCHUserAgent) + } + if got := headers.Get("Sec-Ch-Ua-Platform"); got != captured.SecCHPlatform { + t.Fatalf("Sec-Ch-Ua-Platform = %q, want %q", got, captured.SecCHPlatform) + } + if got := headers.Get("Sec-Ch-Ua-Mobile"); got != captured.SecCHMobile { + t.Fatalf("Sec-Ch-Ua-Mobile = %q, want %q", got, captured.SecCHMobile) + } + if got := BaseHeaders.Get("User-Agent"); got != UserAgent { + t.Fatalf("captured fingerprint mutated fallback headers: %q", got) + } +} + +func TestClientOmitsUnavailableClientHints(t *testing.T) { + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + userAgent := "Mozilla/5.0 (Android 15; Mobile; rv:141.0) Gecko/141.0 Firefox/141.0" + if !client.SetBrowserHeaders(BrowserHeaders{UserAgent: userAgent}) { + t.Fatal("SetBrowserHeaders() rejected Firefox user agent") + } + + headers := client.GetBaseHeaders() + if got := headers.Get("User-Agent"); got != userAgent { + t.Fatalf("User-Agent = %q, want %q", got, userAgent) + } + for _, name := range []string{"Sec-Ch-Ua", "Sec-Ch-Ua-Platform", "Sec-Ch-Ua-Mobile"} { + if got := headers.Get(name); got != "" { + t.Fatalf("%s = %q, want omitted", name, got) + } + } +} + +func TestClientRejectsUnsafeCapturedBrowserHeaders(t *testing.T) { + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + if client.SetBrowserHeaders(BrowserHeaders{UserAgent: "browser\r\nX-Injected: true"}) { + t.Fatal("SetBrowserHeaders() accepted a header injection") + } + if got := client.GetUserAgent(); got != UserAgent { + t.Fatalf("GetUserAgent() = %q, want fallback %q", got, UserAgent) + } +} diff --git a/pkg/twittermeow/http.go b/pkg/twittermeow/http.go index 71a0af95..8af1a16c 100644 --- a/pkg/twittermeow/http.go +++ b/pkg/twittermeow/http.go @@ -27,12 +27,14 @@ var ( ) func (c *Client) MakeRequest(ctx context.Context, url string, method string, headers http.Header, payload []byte, contentType types.ContentType) (*http.Response, []byte, error) { - log := zerolog.Ctx(ctx).With(). + logContext := zerolog.Ctx(ctx).With(). Str("url", url). Str("method", method). - Str("function", "MakeRequest"). - Str("request_body", string(payload)). - Logger() + Str("function", "MakeRequest") + if !c.IsClientHTTPEnabled() { + logContext = logContext.Str("request_body", string(payload)) + } + log := logContext.Logger() var attempts int for { attempts++ @@ -49,6 +51,8 @@ func (c *Client) MakeRequest(ctx context.Context, url string, method string, hea Dur("duration", dur). Msg("Request successful") return resp, respDat, nil + } else if errors.Is(err, ErrClientHTTPRequestPending) { + return resp, respDat, err } else if resp != nil && resp.StatusCode >= 400 && resp.StatusCode < 500 { log.Error(). Err(err). @@ -63,11 +67,13 @@ func (c *Client) MakeRequest(ctx context.Context, url string, method string, hea return nil, nil, fmt.Errorf("%w: %w", ErrMaxRetriesReached, err) } else if errors.Is(err, ErrRedirectAttempted) { location := resp.Header.Get("Location") - c.Logger.Err(err). + redirectLog := c.Logger.Err(err). Str("location", location). - Dur("duration", dur). - Str("request_body", string(payload)). - Msg("Redirect attempted") + Dur("duration", dur) + if !c.IsClientHTTPEnabled() { + redirectLog = redirectLog.Str("request_body", string(payload)) + } + redirectLog.Msg("Redirect attempted") return resp, nil, err } else if ctx.Err() != nil { return resp, nil, ctx.Err() @@ -109,10 +115,13 @@ func (c *Client) makeRequestDirect(ctx context.Context, url string, method strin if err != nil { return nil, nil, fmt.Errorf("%w: %w", ErrResponseReadFailed, err) } - c.Logger.Trace(). - Int("status_code", response.StatusCode). - Str("response_body", string(responseBody)). - Msg("Raw HTTP response") + responseLog := c.Logger.Trace().Int("status_code", response.StatusCode) + if c.IsClientHTTPEnabled() { + responseLog = responseLog.Int("response_bytes", len(responseBody)) + } else { + responseLog = responseLog.Str("response_body", string(responseBody)) + } + responseLog.Msg("Raw HTTP response") if response.StatusCode >= 400 { var respErr TwitterErrors if json.Unmarshal(responseBody, &respErr) == nil { diff --git a/pkg/twittermeow/login_jetfuel.go b/pkg/twittermeow/login_jetfuel.go new file mode 100644 index 00000000..ba9bf1f7 --- /dev/null +++ b/pkg/twittermeow/login_jetfuel.go @@ -0,0 +1,1343 @@ +package twittermeow + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "regexp" + "slices" + "strings" + "time" + "unicode/utf8" + + "go.mau.fi/mautrix-twitter/pkg/twittermeow/cookies" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/crypto" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/endpoints" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" +) + +const ( + webLoginBackendOCF webLoginBackend = "ocf" + webLoginBackendJetfuel webLoginBackend = "jetfuel" + + jetfuelHeaderVersion = "JP-5" + jetfuelHeaderTheme = "dark" +) + +var ( + jetfuelActionPathRegex = regexp.MustCompile(`/onboarding/web/actions/[A-Za-z0-9_./-]+`) + jetfuelFieldRegex = regexp.MustCompile(`^[A-Za-z_$][A-Za-z0-9_$-]{1,80}$`) + jetfuelNumericIDRegex = regexp.MustCompile(`\b[0-9]{5,30}\b`) + jetfuelUUIDRegex = regexp.MustCompile(`(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) + jetfuelActionAliases = map[string]string{ + "begin_login": endpoints.JETFUEL_BEGIN_LOGIN_PATH, + "login_enter_password": endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH, + "begin_two_factor_auth": endpoints.JETFUEL_BEGIN_TWO_FACTOR_AUTH_PATH, + "finish_two_factor_auth": endpoints.JETFUEL_FINISH_TWO_FACTOR_AUTH_PATH, + "two_factor_code": "/onboarding/web/actions/two_factor_code", + } +) + +type webLoginBackend string + +type jetfuelLoginState struct { + identifier string + passwordAction string + verificationAction string + verificationFields []string + twoFactorAction string + twoFactorMethods []WebLoginAuthMethod + sessionToken string + preludeDispatchID string + userID string + passwordReplayUsed bool +} + +type jetfuelLoginResponse struct { + strings []string + paths []string + fields []string + raw []byte +} + +func (wls *WebLoginSession) startJetfuel(ctx context.Context) (*WebLoginResult, error) { + if err := wls.client.loadPage(ctx, endpoints.JETFUEL_LOGIN_REFERER_URL); err != nil { + return nil, fmt.Errorf("failed to load X login page: %w", err) + } + if _, err := wls.client.jetfuelGet(ctx, endpoints.JETFUEL_LANDING_PATH); err != nil { + if errors.Is(err, ErrClientHTTPRequestPending) { + return nil, err + } + wls.client.Logger.Debug().Err(err).Msg("Jetfuel login landing preflight failed") + } + body, err := wls.client.jetfuelGet(ctx, endpoints.JETFUEL_LOGIN_PATH) + if err != nil { + return nil, err + } + parsed := parseJetfuelLoginResponse(body) + if !parsed.hasPath(endpoints.JETFUEL_BEGIN_LOGIN_PATH) && !parsed.hasField("username_or_email") { + return nil, fmt.Errorf("%w: jetfuel login page did not expose a username action", ErrWebLoginUnexpectedSubtask) + } + wls.backend = webLoginBackendJetfuel + wls.jetfuel = &jetfuelLoginState{} + return &WebLoginResult{ + Status: WebLoginStatusNeedsIdentifier, + CurrentSubtaskID: "JetfuelBeginLogin", + Challenge: &WebLoginChallenge{ + SubtaskID: "JetfuelBeginLogin", + Hint: "Phone, email, or username", + }, + }, nil +} + +func (wls *WebLoginSession) submitJetfuelIdentifier(ctx context.Context, identifier string) (*WebLoginResult, error) { + identifier = strings.TrimSpace(identifier) + if identifier == "" { + return nil, fmt.Errorf("x username, email, or phone is required") + } + if shouldSendJetfuelViewerContextEvent() { + if err := wls.client.sendJetfuelViewerContextEvent(ctx); err != nil { + if errors.Is(err, ErrClientHTTPRequestPending) { + return nil, err + } + wls.client.Logger.Debug().Err(err).Msg("Jetfuel viewer-context preflight failed") + } + } + body, err := wls.client.jetfuelPostForm(ctx, endpoints.JETFUEL_BEGIN_LOGIN_PATH, url.Values{ + "username_or_email": {identifier}, + }) + if err != nil { + return nil, err + } + parsed := parseJetfuelLoginResponse(body) + if err := parsed.loginError(); err != nil { + return nil, err + } + wls.updateJetfuelState(parsed) + wls.jetfuel.identifier = identifier + if wls.client.IsLoggedIn() { + return &WebLoginResult{Status: WebLoginStatusComplete}, nil + } + if action := parsed.passwordAction(); action != "" { + wls.jetfuel.passwordAction = action + return &WebLoginResult{ + Status: WebLoginStatusNeedsPassword, + CurrentSubtaskID: "JetfuelPassword", + Challenge: &WebLoginChallenge{ + SubtaskID: "JetfuelPassword", + Hint: "Password", + }, + }, nil + } + if action := parsed.verificationAction(); action != "" { + wls.jetfuel.verificationAction = action + wls.jetfuel.verificationFields = parsed.verificationCodeFields() + return &WebLoginResult{ + Status: WebLoginStatusNeedsText, + CurrentSubtaskID: "JetfuelVerification", + Challenge: parsed.verificationChallenge(), + }, nil + } + wls.logUnsupportedJetfuelResponse("identifier", parsed) + return nil, ErrJetfuelIdentifierNoSupportedAction +} + +func (wls *WebLoginSession) submitJetfuelCredentials(ctx context.Context, identifier, password string) (*WebLoginResult, error) { + identifier = strings.TrimSpace(identifier) + if identifier == "" { + return nil, fmt.Errorf("x username, email, or phone is required") + } + if password == "" { + return nil, fmt.Errorf("x password is required") + } + result, err := wls.submitJetfuelIdentifier(ctx, identifier) + if err != nil { + if !isJetfuelPrePasswordParityError(err) { + return nil, err + } + wls.client.Logger.Debug().Err(err).Msg("Jetfuel sequential identifier submit failed, trying combined credentials submit") + return wls.submitJetfuelCombinedCredentials(ctx, identifier, password) + } + if result.Status != WebLoginStatusNeedsPassword { + return result, nil + } + return wls.submitJetfuelPassword(ctx, password) +} + +func isJetfuelPrePasswordParityError(err error) bool { + if errors.Is(err, ErrJetfuelIdentifierNoSupportedAction) { + return true + } + var webErr *WebLoginError + if !errors.As(err, &webErr) { + return false + } + text := strings.ToLower(webErr.Message) + return webErr.Code == 399 && (strings.Contains(text, "temporarily limited") || + strings.Contains(text, "official x apps") || strings.Contains(text, "use x.com")) +} + +func IsWebLoginPrePasswordParityError(err error) bool { + return isJetfuelPrePasswordParityError(err) +} + +func (wls *WebLoginSession) submitJetfuelCombinedCredentials(ctx context.Context, identifier, password string) (*WebLoginResult, error) { + body, err := wls.client.jetfuelPostForm(ctx, endpoints.JETFUEL_BEGIN_LOGIN_PATH, url.Values{ + "username_or_email": {identifier}, + "password": {password}, + }) + if err != nil { + return nil, err + } + parsed := parseJetfuelLoginResponse(body) + if err := parsed.loginError(); err != nil { + return nil, err + } + wls.updateJetfuelState(parsed) + if wls.jetfuel != nil { + wls.jetfuel.identifier = identifier + } + if wls.client.IsLoggedIn() || parsed.isComplete() { + return &WebLoginResult{Status: WebLoginStatusComplete}, nil + } + if result := wls.jetfuelAuthMethodChoiceResult(parsed); result != nil { + return result, nil + } + if action := parsed.verificationAction(); action != "" { + wls.jetfuel.verificationAction = action + wls.jetfuel.verificationFields = parsed.verificationCodeFields() + return &WebLoginResult{ + Status: WebLoginStatusNeedsText, + CurrentSubtaskID: "JetfuelVerification", + Challenge: parsed.verificationChallenge(), + }, nil + } + if action := parsed.passwordAction(); action != "" { + wls.jetfuel.passwordAction = action + return &WebLoginResult{ + Status: WebLoginStatusNeedsPassword, + CurrentSubtaskID: "JetfuelPassword", + Challenge: &WebLoginChallenge{ + SubtaskID: "JetfuelPassword", + Hint: "Password", + }, + }, nil + } + wls.logUnsupportedJetfuelResponse("combined_credentials", parsed) + return nil, fmt.Errorf("%w: jetfuel credentials response did not complete or expose a supported challenge", ErrWebLoginUnexpectedSubtask) +} + +func (wls *WebLoginSession) submitJetfuelPassword(ctx context.Context, password string) (*WebLoginResult, error) { + if password == "" { + return nil, fmt.Errorf("x password is required") + } + if wls.jetfuel == nil || wls.jetfuel.passwordAction == "" { + return nil, fmt.Errorf("%w: jetfuel password action is missing", ErrWebLoginUnexpectedSubtask) + } + form := url.Values{ + "password": {password}, + } + if wls.jetfuel.identifier != "" { + form.Set("username", wls.jetfuel.identifier) + } + if wls.jetfuel.sessionToken != "" { + form.Set("session_token", wls.jetfuel.sessionToken) + } + body, err := wls.client.jetfuelPostForm(ctx, wls.jetfuel.passwordAction, form) + if err != nil { + return nil, err + } + parsed := parseJetfuelLoginResponse(body) + if err := parsed.loginError(); err != nil { + return nil, err + } + wls.updateJetfuelState(parsed) + if wls.client.IsLoggedIn() || parsed.isComplete() { + return &WebLoginResult{Status: WebLoginStatusComplete}, nil + } + if result := wls.jetfuelAuthMethodChoiceResult(parsed); result != nil { + return result, nil + } + if action := parsed.beginTwoFactorAction(); action != "" { + return wls.submitJetfuelBeginTwoFactor(ctx, action) + } + if action := parsed.verificationAction(); action != "" { + wls.jetfuel.verificationAction = action + wls.jetfuel.verificationFields = parsed.verificationCodeFields() + return &WebLoginResult{ + Status: WebLoginStatusNeedsText, + CurrentSubtaskID: "JetfuelVerification", + Challenge: parsed.verificationChallenge(), + }, nil + } + if action := parsed.passwordAction(); action != "" { + wls.jetfuel.passwordAction = action + if !wls.jetfuel.passwordReplayUsed { + wls.jetfuel.passwordReplayUsed = true + return &WebLoginResult{ + Status: WebLoginStatusNeedsPassword, + CurrentSubtaskID: "JetfuelPassword", + Challenge: &WebLoginChallenge{ + SubtaskID: "JetfuelPassword", + Hint: "Password", + }, + }, nil + } + wls.logUnsupportedJetfuelResponse("password_replay_limit", parsed) + return nil, fmt.Errorf("%w: jetfuel password action repeated after the allowed replay", ErrWebLoginUnexpectedSubtask) + } + if parsed.canReplayPasswordWithoutAction() { + if !wls.jetfuel.passwordReplayUsed { + wls.jetfuel.passwordReplayUsed = true + wls.logUnsupportedJetfuelResponse("password_actionless_replay", parsed) + return &WebLoginResult{ + Status: WebLoginStatusNeedsPassword, + CurrentSubtaskID: "JetfuelPassword", + Challenge: &WebLoginChallenge{ + SubtaskID: "JetfuelPassword", + Hint: "Password", + }, + }, nil + } + wls.logUnsupportedJetfuelResponse("password_replay_limit", parsed) + return nil, fmt.Errorf("%w: jetfuel password response remained actionless after the allowed replay", ErrWebLoginUnexpectedSubtask) + } + wls.logUnsupportedJetfuelResponse("password", parsed) + return nil, fmt.Errorf("%w: jetfuel password response did not complete or expose a supported challenge", ErrWebLoginUnexpectedSubtask) +} + +func (wls *WebLoginSession) logUnsupportedJetfuelResponse(stage string, parsed jetfuelLoginResponse) { + if wls == nil || wls.client == nil { + return + } + wls.client.Logger.Debug(). + Str("stage", stage). + Int("response_bytes", len(parsed.raw)). + Int("string_count", len(parsed.strings)). + Int("path_count", len(parsed.paths)). + Int("field_count", len(parsed.fields)). + Msg("Jetfuel response did not expose a supported next action") +} + +func (wls *WebLoginSession) submitJetfuelBeginTwoFactor(ctx context.Context, action string) (*WebLoginResult, error) { + if wls.jetfuel == nil { + return nil, fmt.Errorf("%w: jetfuel session state is missing", ErrWebLoginUnexpectedSubtask) + } + form := url.Values{} + if wls.jetfuel.preludeDispatchID != "" { + form.Set("prelude_dispatch_id", wls.jetfuel.preludeDispatchID) + } + if wls.jetfuel.sessionToken != "" { + form.Set("session_token", wls.jetfuel.sessionToken) + } + body, err := wls.client.jetfuelPostForm(ctx, action, form) + if err != nil { + return nil, err + } + parsed := parseJetfuelLoginResponse(body) + if err := parsed.loginError(); err != nil { + return nil, err + } + wls.updateJetfuelState(parsed) + if wls.client.IsLoggedIn() || parsed.isComplete() { + return &WebLoginResult{Status: WebLoginStatusComplete}, nil + } + if result := wls.jetfuelAuthMethodChoiceResult(parsed); result != nil { + return result, nil + } + if action := parsed.verificationAction(); action != "" { + wls.jetfuel.verificationAction = action + wls.jetfuel.verificationFields = parsed.verificationCodeFields() + return &WebLoginResult{ + Status: WebLoginStatusNeedsText, + CurrentSubtaskID: "JetfuelVerification", + Challenge: parsed.verificationChallenge(), + }, nil + } + return nil, fmt.Errorf("%w: jetfuel two-factor prelude did not expose a verification challenge", ErrWebLoginUnexpectedSubtask) +} + +func (wls *WebLoginSession) submitJetfuelText(ctx context.Context, text string) (*WebLoginResult, error) { + text = strings.TrimSpace(text) + if text == "" { + return nil, fmt.Errorf("x verification code is required") + } + if wls.jetfuel == nil || wls.jetfuel.verificationAction == "" { + return nil, fmt.Errorf("%w: jetfuel verification action is missing", ErrWebLoginUnexpectedSubtask) + } + form := url.Values{} + for _, field := range wls.jetfuelVerificationFields() { + form.Set(field, text) + } + if wls.jetfuel.sessionToken != "" { + form.Set("session_token", wls.jetfuel.sessionToken) + } + if wls.jetfuel.preludeDispatchID != "" { + form.Set("prelude_dispatch_id", wls.jetfuel.preludeDispatchID) + } + body, err := wls.client.jetfuelPostForm(ctx, wls.jetfuel.verificationAction, form) + if err != nil { + return nil, err + } + parsed := parseJetfuelLoginResponse(body) + if err := parsed.loginError(); err != nil { + return nil, err + } + wls.updateJetfuelState(parsed) + if wls.client.IsLoggedIn() || parsed.isComplete() { + return &WebLoginResult{Status: WebLoginStatusComplete}, nil + } + if result := wls.jetfuelAuthMethodChoiceResult(parsed); result != nil { + return result, nil + } + if action := parsed.passwordAction(); action != "" { + wls.jetfuel.passwordAction = action + return &WebLoginResult{ + Status: WebLoginStatusNeedsPassword, + CurrentSubtaskID: "JetfuelPassword", + Challenge: &WebLoginChallenge{ + SubtaskID: "JetfuelPassword", + Hint: "Password", + }, + }, nil + } + if action := parsed.beginTwoFactorAction(); action != "" { + return wls.submitJetfuelBeginTwoFactor(ctx, action) + } + if action := parsed.verificationAction(); action != "" { + wls.jetfuel.verificationAction = action + wls.jetfuel.verificationFields = parsed.verificationCodeFields() + return &WebLoginResult{ + Status: WebLoginStatusNeedsText, + CurrentSubtaskID: "JetfuelVerification", + Challenge: parsed.verificationChallenge(), + }, nil + } + return nil, fmt.Errorf("%w: jetfuel verification response did not complete login", ErrWebLoginUnexpectedSubtask) +} + +func (wls *WebLoginSession) updateJetfuelState(parsed jetfuelLoginResponse) { + if wls.jetfuel == nil { + wls.jetfuel = &jetfuelLoginState{} + } + if token := parsed.uuidValue("session_token"); token != "" { + wls.jetfuel.sessionToken = token + } + if id := parsed.uuidValue("prelude_dispatch_id"); id != "" { + wls.jetfuel.preludeDispatchID = id + } + if id := parsed.numericValue("user_id"); id != "" { + wls.jetfuel.userID = id + } + if action := parsed.passwordAction(); action != "" { + wls.jetfuel.passwordAction = action + } + if action := parsed.beginTwoFactorAction(); action != "" { + wls.jetfuel.twoFactorAction = action + } + if action := parsed.verificationAction(); action != "" { + wls.jetfuel.verificationAction = action + wls.jetfuel.verificationFields = parsed.verificationCodeFields() + } + if methods := parsed.authMethods(); len(methods) > 0 { + wls.jetfuel.twoFactorMethods = methods + } +} + +func (wls *WebLoginSession) jetfuelVerificationFields() []string { + if wls.jetfuel != nil && len(wls.jetfuel.verificationFields) > 0 { + return wls.jetfuel.verificationFields + } + return defaultJetfuelVerificationFields() +} + +func (wls *WebLoginSession) jetfuelAuthMethodChoiceResult(parsed jetfuelLoginResponse) *WebLoginResult { + methods := parsed.authMethods() + if len(methods) == 0 && wls.jetfuel != nil { + methods = wls.jetfuel.twoFactorMethods + } + if len(methods) == 0 || !parsed.isAuthMethodChoice() { + return nil + } + if wls.jetfuel != nil { + wls.jetfuel.twoFactorMethods = methods + } + supportedMethods := supportedWebLoginAuthMethods(methods) + if len(supportedMethods) == 0 { + return &WebLoginResult{ + Status: WebLoginStatusUnsupported, + Challenge: unsupportedWebLoginAuthMethodChallenge(methods), + CurrentSubtaskID: "JetfuelTwoFactorMethod", + } + } + return &WebLoginResult{ + Status: WebLoginStatusNeedsAuthMethod, + CurrentSubtaskID: "JetfuelTwoFactorMethod", + Challenge: &WebLoginChallenge{ + SubtaskID: "JetfuelTwoFactorMethod", + Hint: "Verification method", + Description: "Choose how to verify this X login.", + IsTwoFactor: true, + }, + AuthMethods: supportedMethods, + } +} + +func unsupportedWebLoginAuthMethodChallenge(methods []WebLoginAuthMethod) *WebLoginChallenge { + description := "X returned a login challenge this bridge does not support yet." + if len(methods) == 1 && methods[0].Description != "" { + description = methods[0].Description + } + return &WebLoginChallenge{ + SubtaskID: "JetfuelTwoFactorMethod", + Hint: "Verification method", + Description: description, + IsTwoFactor: true, + } +} + +func supportedWebLoginAuthMethods(methods []WebLoginAuthMethod) []WebLoginAuthMethod { + supported := make([]WebLoginAuthMethod, 0, len(methods)) + for _, method := range methods { + if method.Supported { + supported = append(supported, method) + } + } + return supported +} + +func (wls *WebLoginSession) submitJetfuelAuthMethod(ctx context.Context, methodID string) (*WebLoginResult, error) { + if wls.jetfuel == nil || len(wls.jetfuel.twoFactorMethods) == 0 { + return nil, ErrWebLoginMissingAuthMethodState + } + method, ok := wls.jetfuel.findAuthMethod(methodID) + if !ok { + return nil, fmt.Errorf("%w: %s", ErrWebLoginUnsupportedAuthMethod, strings.TrimSpace(methodID)) + } + if !method.Supported { + return nil, fmt.Errorf("%w: %s", ErrWebLoginUnsupportedAuthMethod, method.Name) + } + action := wls.jetfuel.twoFactorAction + if action == "" { + action = endpoints.JETFUEL_BEGIN_TWO_FACTOR_AUTH_PATH + } + wls.client.Logger.Debug(). + Str("method_id", method.ID). + Str("method_submit_id", method.SubmitID). + Str("method_kind", string(method.Kind)). + Int("method_index", method.Index). + Str("action", action). + Msg("Submitting Jetfuel auth method") + body, err := wls.client.jetfuelPostForm(ctx, action, wls.jetfuel.authMethodForm(method)) + if err != nil { + return nil, err + } + parsed := parseJetfuelLoginResponse(body) + if err := parsed.loginError(); err != nil { + return nil, err + } + wls.updateJetfuelState(parsed) + if wls.client.IsLoggedIn() || parsed.isComplete() { + return &WebLoginResult{Status: WebLoginStatusComplete}, nil + } + if action := parsed.verificationActionForMethod(method); action != "" { + wls.jetfuel.verificationAction = action + wls.jetfuel.verificationFields = parsed.verificationCodeFields() + return &WebLoginResult{ + Status: WebLoginStatusNeedsText, + CurrentSubtaskID: "JetfuelVerification", + Challenge: parsed.verificationChallengeForMethod(method), + }, nil + } + if result := wls.jetfuelAuthMethodChoiceResult(parsed); result != nil { + return result, nil + } + wls.client.Logger.Debug(). + Str("method_id", method.ID). + Str("method_submit_id", method.SubmitID). + Str("method_kind", string(method.Kind)). + Strs("paths", parsed.paths). + Strs("fields", parsed.fields). + Bool("has_verification_challenge", parsed.hasVerificationChallengeForMethod(method)). + Msg("Jetfuel auth method response did not expose a supported verification challenge") + return nil, fmt.Errorf("%w: jetfuel auth method response did not expose a verification challenge", ErrWebLoginUnexpectedSubtask) +} + +func (jls *jetfuelLoginState) findAuthMethod(methodID string) (WebLoginAuthMethod, bool) { + methodID = normalizeJetfuelMethodID(methodID) + for _, method := range jls.twoFactorMethods { + if normalizeJetfuelMethodID(method.ID) == methodID || + normalizeJetfuelMethodID(method.SubmitID) == methodID || + normalizeJetfuelMethodID(method.Name) == methodID { + return method, true + } + } + return WebLoginAuthMethod{}, false +} + +func (jls *jetfuelLoginState) authMethodForm(method WebLoginAuthMethod) url.Values { + methodID := method.ID + if method.SubmitID != "" { + methodID = method.SubmitID + } + form := url.Values{ + "two_factor_auth_method_type": {methodID}, + "_selected_method_idx": {fmt.Sprintf("%d", method.Index)}, + } + if jls.userID != "" { + form.Set("user_id", jls.userID) + } + if jls.sessionToken != "" { + form.Set("session_token", jls.sessionToken) + } + return form +} + +func (c *Client) jetfuelGet(ctx context.Context, path string) ([]byte, error) { + return c.jetfuelRequest(ctx, path, http.MethodGet, nil) +} + +func (c *Client) jetfuelPostForm(ctx context.Context, path string, form url.Values) ([]byte, error) { + if err := c.addJetfuelCastleTokenToForm(form); err != nil { + c.Logger.Trace().Err(err).Msg("Failed to attach Castle request token") + return nil, err + } + return c.jetfuelRequest(ctx, path, http.MethodPost, []byte(form.Encode())) +} + +func (c *Client) jetfuelRequest(ctx context.Context, path, method string, body []byte) ([]byte, error) { + fullURL := endpoints.JETFUEL_BASE_URL + ensureLeadingSlash(path) + txID, err := crypto.SignTransaction(c.session.AnimationToken, c.session.VerificationToken, fullURL, method) + if err != nil { + c.Logger.Trace().Err(err).Msg("Failed to create X Jetfuel client transaction ID") + txID = "e:" + } + extra := map[string]string{ + "accept": "*/*", + "origin": endpoints.BASE_URL, + "priority": "u=1, i", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "timezone": jetfuelTimezone(), + "x-client-transaction-id": txID, + "x-jf-client-theme": jetfuelHeaderTheme, + "x-jf-v": jetfuelHeaderVersion, + "x-twitter-active-user": "yes", + } + if csrfToken := c.cookies.Get(cookies.XCt0); csrfToken != "" { + extra["x-csrf-token"] = csrfToken + } + headers := c.buildHeaders(HeaderOpts{ + WithNonAuthBearer: true, + WithCookies: true, + WithXGuestToken: true, + Referer: endpoints.JETFUEL_LOGIN_REFERER_URL, + Extra: extra, + }) + headers.Del("x-twitter-client-language") + contentType := types.ContentTypeNone + if method == http.MethodPost { + contentType = types.ContentTypeForm + } + resp, respBody, err := c.makeRequestDirect(ctx, fullURL, method, headers, body, contentType) + if resp != nil { + c.cookies.UpdateFromResponse(resp) + } + if err != nil { + return respBody, err + } + return respBody, nil +} + +type jetfuelViewerContextEvent struct { + Category string `json:"_category_"` + FormatVersion int `json:"format_version"` + TriggeredOn int64 `json:"triggered_on"` + Items []any `json:"items"` + EventNamespace jetfuelViewerEventNamespace `json:"event_namespace"` + ClientEventSequenceStartTimestamp int64 `json:"client_event_sequence_start_timestamp"` + ClientEventSequenceNumber int `json:"client_event_sequence_number"` + ClientAppID string `json:"client_app_id"` +} + +type jetfuelViewerEventNamespace struct { + Page string `json:"page"` + Action string `json:"action"` + Element string `json:"element"` + Client string `json:"client"` +} + +func (c *Client) sendJetfuelViewerContextEvent(ctx context.Context) error { + now := time.Now().UnixMilli() + event := jetfuelViewerContextEvent{ + Category: "client_event", + FormatVersion: 2, + TriggeredOn: now, + Items: []any{}, + EventNamespace: jetfuelViewerEventNamespace{Page: "front", Action: "click", Element: "continue", Client: "m5"}, + ClientEventSequenceStartTimestamp: now, + ClientEventSequenceNumber: 1, + ClientAppID: "3033300", + } + logPayload, err := json.Marshal([]jetfuelViewerContextEvent{event}) + if err != nil { + return err + } + form := url.Values{ + "debug": {"true"}, + "log": {string(logPayload)}, + } + txID, err := crypto.SignTransaction(c.session.AnimationToken, c.session.VerificationToken, endpoints.VIEWER_CONTEXT_URL, http.MethodPost) + if err != nil { + c.Logger.Trace().Err(err).Msg("Failed to create X viewer-context client transaction ID") + txID = "e:" + } + headers := c.buildHeaders(HeaderOpts{ + WithNonAuthBearer: true, + WithCookies: true, + WithXGuestToken: true, + WithXTwitterHeaders: true, + Origin: endpoints.BASE_URL, + Referer: endpoints.BASE_URL + "/", + Extra: map[string]string{ + "accept": "*/*", + "priority": "u=1, i", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-site", + "x-client-transaction-id": txID, + }, + }) + resp, _, err := c.makeRequestDirect(ctx, endpoints.VIEWER_CONTEXT_URL, http.MethodPost, headers, []byte(form.Encode()), types.ContentTypeForm) + if resp != nil { + c.cookies.UpdateFromResponse(resp) + } + return err +} + +func jetfuelTimezone() string { + if timezone := strings.TrimSpace(os.Getenv("TWITTER_JETFUEL_TIMEZONE")); timezone != "" { + return timezone + } + if local := time.Local.String(); strings.Contains(local, "/") { + return local + } + _, offset := time.Now().Zone() + switch offset { + case -10 * 60 * 60: + return "Pacific/Honolulu" + case -9 * 60 * 60: + return "America/Anchorage" + case -8 * 60 * 60: + return "America/Los_Angeles" + case -7 * 60 * 60: + return "America/Denver" + case -6 * 60 * 60, -5 * 60 * 60: + return "America/Chicago" + case -4 * 60 * 60: + return "America/New_York" + default: + return "UTC" + } +} + +func shouldSendJetfuelViewerContextEvent() bool { + return strings.TrimSpace(os.Getenv("TWITTER_JETFUEL_VIEWER_CONTEXT")) == "1" +} + +func ensureLeadingSlash(path string) string { + if strings.HasPrefix(path, "/") { + return path + } + return "/" + path +} + +func parseJetfuelLoginResponse(body []byte) jetfuelLoginResponse { + strs := extractJetfuelStrings(body) + paths := make([]string, 0) + fields := make([]string, 0) + for _, str := range strs { + for _, path := range jetfuelActionPathRegex.FindAllString(str, -1) { + paths = appendJetfuelPath(paths, path) + } + if path := canonicalJetfuelActionPath(str); path != "" { + paths = appendJetfuelPath(paths, path) + } + if jetfuelFieldRegex.MatchString(str) && !slices.Contains(fields, str) { + fields = append(fields, str) + } + } + return jetfuelLoginResponse{strings: strs, paths: paths, fields: fields, raw: body} +} + +func canonicalJetfuelActionPath(value string) string { + value = strings.TrimSpace(value) + if strings.HasPrefix(value, "/onboarding/web/actions/") { + return value + } + return jetfuelActionAliases[value] +} + +func appendJetfuelPath(paths []string, path string) []string { + if path == "" || slices.Contains(paths, path) { + return paths + } + return append(paths, path) +} + +func extractJetfuelStrings(body []byte) []string { + var out []string + seen := make(map[string]struct{}) + start := -1 + for i := 0; i < len(body); { + r, size := utf8.DecodeRune(body[i:]) + if r == utf8.RuneError && size == 1 { + if start >= 0 { + addJetfuelString(body[start:i], seen, &out) + start = -1 + } + i++ + continue + } + if isJetfuelStringRune(r) { + if start < 0 { + start = i + } + } else if start >= 0 { + addJetfuelString(body[start:i], seen, &out) + start = -1 + } + i += size + } + if start >= 0 { + addJetfuelString(body[start:], seen, &out) + } + return out +} + +func isJetfuelStringRune(r rune) bool { + return r == '\n' || r == '\r' || r == '\t' || r >= 0x20 && r != utf8.RuneError +} + +func addJetfuelString(raw []byte, seen map[string]struct{}, out *[]string) { + str := strings.TrimSpace(string(bytes.Trim(raw, "\x00"))) + if len(str) < 3 { + return + } + if _, ok := seen[str]; ok { + return + } + seen[str] = struct{}{} + *out = append(*out, str) +} + +func (jfr jetfuelLoginResponse) text() string { + return strings.ToLower(strings.Join(jfr.strings, "\n")) +} + +func (jfr jetfuelLoginResponse) hasPath(path string) bool { + return slices.Contains(jfr.paths, path) +} + +func (jfr jetfuelLoginResponse) hasField(field string) bool { + return slices.Contains(jfr.fields, field) +} + +// X may require one fresh-token replay without repeating the password action in the response. +// Require parsed structure so an empty or opaque transport response does not trigger another password POST. +func (jfr jetfuelLoginResponse) canReplayPasswordWithoutAction() bool { + return len(jfr.raw) > 0 && len(jfr.paths) > 0 && len(jfr.fields) > 0 +} + +func (jfr jetfuelLoginResponse) passwordAction() string { + for _, path := range jfr.paths { + lower := strings.ToLower(path) + if strings.Contains(lower, "password") { + return path + } + } + if jfr.hasField("password") || strings.Contains(jfr.text(), "password") { + return endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH + } + return "" +} + +func (jfr jetfuelLoginResponse) beginTwoFactorAction() string { + for _, path := range jfr.paths { + if strings.Contains(strings.ToLower(path), "begin_two_factor_auth") { + return path + } + } + return "" +} + +func (jfr jetfuelLoginResponse) verificationAction() string { + for _, path := range jfr.paths { + lower := strings.ToLower(path) + if strings.Contains(lower, "begin_two_factor_auth") { + continue + } + if strings.Contains(lower, "two_factor") || strings.Contains(lower, "2fa") || + strings.Contains(lower, "challenge") || strings.Contains(lower, "verification") { + return path + } + } + return "" +} + +func (jfr jetfuelLoginResponse) verificationActionForMethod(method WebLoginAuthMethod) string { + if action := jfr.verificationAction(); action != "" { + return action + } + if action := jfr.methodVerificationAction(method); action != "" { + return action + } + if jfr.hasVerificationChallengeForMethod(method) { + return endpoints.JETFUEL_FINISH_TWO_FACTOR_AUTH_PATH + } + return "" +} + +func (jfr jetfuelLoginResponse) methodVerificationAction(method WebLoginAuthMethod) string { + if method.Kind != WebLoginAuthMethodKindSMS { + return "" + } + isPhoneNumberChallenge := jfr.isPhoneNumberChallenge() + for _, path := range jfr.paths { + lower := strings.ToLower(path) + if strings.Contains(lower, "begin_two_factor_auth") || strings.Contains(lower, "resend") { + continue + } + if isPhoneNumberChallenge && + (strings.Contains(lower, "phone") || strings.Contains(lower, "sms") || + strings.Contains(lower, "text") || strings.Contains(lower, "send")) { + return path + } + if !isPhoneNumberChallenge && (strings.Contains(lower, "code") || strings.Contains(lower, "pin")) { + return path + } + } + return "" +} + +func (jfr jetfuelLoginResponse) hasVerificationChallengeForMethod(method WebLoginAuthMethod) bool { + text := jfr.text() + if strings.Contains(text, "verification code") || + strings.Contains(text, "authentication code") || + strings.Contains(text, "two-factor code") || + strings.Contains(text, "two factor code") || + strings.Contains(text, "backup code") { + return true + } + if method.Kind == WebLoginAuthMethodKindSMS && + (strings.Contains(text, "text message") || strings.Contains(text, "sms") || + isJetfuelPhoneNumberChallengeText(text) || jfr.hasExplicitPhoneNumberField()) { + return true + } + return jfr.hasExplicitVerificationField() +} + +func (jfr jetfuelLoginResponse) hasExplicitPhoneNumberField() bool { + for _, field := range jfr.fields { + lower := strings.ToLower(field) + if lower == "phone_number" || lower == "phone" || lower == "loginacid" || lower == "login_acid" || + strings.Contains(lower, "phone") || strings.Contains(lower, "acid") { + return true + } + } + return false +} + +func (jfr jetfuelLoginResponse) hasExplicitVerificationField() bool { + for _, field := range jfr.fields { + lower := strings.ToLower(field) + if lower == "session_token" || lower == "prelude_dispatch_id" || + strings.Contains(lower, "csrf") || strings.Contains(lower, "oauth") || + strings.Contains(lower, "castle") { + continue + } + if lower == "challenge_response" || lower == "verification_code" || + lower == "two_factor_code" || lower == "backup_code" || + lower == "code" || lower == "response" || lower == "token" || + strings.Contains(lower, "otp") || strings.Contains(lower, "phone") || + strings.Contains(lower, "acid") { + return true + } + } + return false +} + +func (jfr jetfuelLoginResponse) verificationCodeFields() []string { + if jfr.isPhoneNumberChallenge() { + if fields := jfr.phoneNumberVerificationFields(); len(fields) > 0 { + return fields + } + } + fields := make([]string, 0, 8) + text := jfr.text() + preferred := []string{ + "challenge_response", + "verification_code", + "two_factor_code", + "code", + "token", + } + if strings.Contains(text, "backup code") { + preferred = append([]string{"backup_code"}, preferred...) + } + for _, field := range preferred { + if jfr.hasField(field) { + fields = appendJetfuelVerificationField(fields, field) + } + } + for _, field := range jfr.fields { + lower := strings.ToLower(field) + if field != lower { + continue + } + if lower == "session_token" || lower == "prelude_dispatch_id" || + strings.Contains(lower, "csrf") || strings.Contains(lower, "oauth") || + strings.Contains(lower, "castle") { + continue + } + if strings.Contains(lower, "code") || strings.Contains(lower, "otp") || + strings.Contains(lower, "challenge") || strings.Contains(lower, "response") || + strings.Contains(lower, "token") { + fields = appendJetfuelVerificationField(fields, field) + } + } + if len(fields) == 0 { + return defaultJetfuelVerificationFields() + } + return fields +} + +func (jfr jetfuelLoginResponse) phoneNumberVerificationFields() []string { + fields := make([]string, 0, 4) + for _, field := range []string{"phone_number", "phone", "LoginAcid", "login_acid"} { + if jfr.hasField(field) { + fields = appendJetfuelVerificationField(fields, field) + } + } + if len(fields) > 0 { + return fields + } + for _, field := range jfr.fields { + lower := strings.ToLower(field) + if lower == "session_token" || lower == "prelude_dispatch_id" || + strings.Contains(lower, "csrf") || strings.Contains(lower, "oauth") || + strings.Contains(lower, "castle") { + continue + } + if strings.Contains(lower, "phone") || strings.Contains(lower, "acid") { + fields = appendJetfuelVerificationField(fields, field) + } + } + if len(fields) > 0 { + return fields + } + for _, field := range []string{"challenge_response", "response"} { + if jfr.hasField(field) { + fields = appendJetfuelVerificationField(fields, field) + } + } + return fields +} + +func appendJetfuelVerificationField(fields []string, field string) []string { + field = strings.TrimSpace(field) + if field == "" || slices.Contains(fields, field) { + return fields + } + return append(fields, field) +} + +func defaultJetfuelVerificationFields() []string { + return []string{"challenge_response", "verification_code", "two_factor_code", "backup_code", "code"} +} + +func (jfr jetfuelLoginResponse) uuidValue(field string) string { + field = strings.ToLower(field) + for i, str := range jfr.strings { + if !strings.Contains(strings.ToLower(str), field) { + continue + } + if uuid := firstJetfuelUUID(str); uuid != "" { + return uuid + } + for next := i + 1; next < len(jfr.strings) && next <= i+6; next++ { + if uuid := firstJetfuelUUID(jfr.strings[next]); uuid != "" { + return uuid + } + } + } + return "" +} + +func (jfr jetfuelLoginResponse) numericValue(field string) string { + field = strings.ToLower(field) + for i, str := range jfr.strings { + if !strings.Contains(strings.ToLower(str), field) { + continue + } + if id := firstJetfuelNumericID(str); id != "" { + return id + } + for next := i + 1; next < len(jfr.strings) && next <= i+6; next++ { + if id := firstJetfuelNumericID(jfr.strings[next]); id != "" { + return id + } + } + } + return "" +} + +func firstJetfuelUUID(value string) string { + return jetfuelUUIDRegex.FindString(value) +} + +func firstJetfuelNumericID(value string) string { + return jetfuelNumericIDRegex.FindString(value) +} + +func (jfr jetfuelLoginResponse) isAuthMethodChoice() bool { + text := jfr.text() + if strings.Contains(text, "select a method") || + strings.Contains(text, "choose the method") || + strings.Contains(text, "two_factor_method") || + strings.Contains(text, "two factor method") { + return true + } + if strings.Contains(text, "verification code") || strings.Contains(text, "authentication code") || + strings.Contains(text, "backup code") || strings.Contains(text, "two-factor code") || + strings.Contains(text, "two factor code") { + return false + } + return jfr.beginTwoFactorAction() != "" && jfr.hasAuthMethodToken() +} + +func (jfr jetfuelLoginResponse) hasAuthMethodToken() bool { + return slices.ContainsFunc(jfr.strings, func(str string) bool { + _, ok := classifyJetfuelAuthMethod(str) + return ok + }) +} + +func (jfr jetfuelLoginResponse) authMethods() []WebLoginAuthMethod { + if !jfr.isAuthMethodChoice() { + return nil + } + methods := make([]WebLoginAuthMethod, 0, 3) + methodIndex := 0 + for _, str := range jfr.strings { + method, ok := classifyJetfuelAuthMethod(str) + if !ok { + continue + } + if updated := updateJetfuelAuthMethod(methods, method); updated { + continue + } + method.Index = methodIndex + methodIndex++ + methods = append(methods, method) + } + return methods +} + +func classifyJetfuelAuthMethod(value string) (WebLoginAuthMethod, bool) { + normalized := normalizeJetfuelMethodID(value) + switch normalized { + case "totp", "authenticatorapp", "authenticationapp": + return WebLoginAuthMethod{ + ID: "Totp", + Name: "Authenticator App", + Description: "Use the code from your authentication app.", + Kind: WebLoginAuthMethodKindCode, + Supported: true, + }, true + case "backupcode": + return WebLoginAuthMethod{ + ID: "BackupCode", + Name: "Backup Code", + Description: "Use a backup code from your X account settings.", + Kind: WebLoginAuthMethodKindBackupCode, + Supported: true, + }, true + case "sms", "smscode", "smsverification", "smsauth", "smsauthentication", "text", "textmessage", "textmessagecode", "textmessageauth", "textmessageauthentication", "phone", "phonecode", "phonenumber", "phoneverification", "phoneauth", "phoneauthentication", "mobile", "mobilephone": + return WebLoginAuthMethod{ + ID: "Sms", + SubmitID: jetfuelAuthMethodSubmitID("Sms", value), + Name: "Text Message", + Description: "Text message verification is coming soon.", + Kind: WebLoginAuthMethodKindSMS, + Supported: false, + }, true + case "u2fsecuritykey", "securitykey", "securitykeypc", "passkey": + return WebLoginAuthMethod{ + ID: "U2fSecurityKey", + Name: "Security Key PC", + Description: "Requires passkey/WebAuthn client support.", + Kind: WebLoginAuthMethodKindUnknown, + Supported: false, + }, true + default: + return WebLoginAuthMethod{}, false + } +} + +func jetfuelAuthMethodSubmitID(canonicalID, raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" || normalizeJetfuelMethodID(raw) == normalizeJetfuelMethodID(canonicalID) { + return "" + } + if strings.ContainsAny(raw, " \t\r\n") { + return "" + } + return raw +} + +func updateJetfuelAuthMethod(methods []WebLoginAuthMethod, method WebLoginAuthMethod) bool { + normalized := normalizeJetfuelMethodID(method.ID) + for i := range methods { + if normalizeJetfuelMethodID(methods[i].ID) != normalized { + continue + } + if methods[i].SubmitID == "" && method.SubmitID != "" { + method.Index = methods[i].Index + methods[i] = method + } + return true + } + return false +} + +func normalizeJetfuelMethodID(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + value = strings.ReplaceAll(value, " ", "") + value = strings.ReplaceAll(value, "_", "") + value = strings.ReplaceAll(value, "-", "") + value = strings.ReplaceAll(value, ".", "") + return value +} + +func (jfr jetfuelLoginResponse) verificationChallenge() *WebLoginChallenge { + text := jfr.text() + isPhoneNumber := jfr.isPhoneNumberChallenge() + hint := "Verification code" + inputKind := WebLoginChallengeInputKindCode + if isPhoneNumber { + hint = "Phone number" + inputKind = WebLoginChallengeInputKindPhoneNumber + } + return &WebLoginChallenge{ + SubtaskID: "JetfuelVerification", + Hint: hint, + Description: jetfuelChallengeDescription(text), + IsTwoFactor: !isPhoneNumber && (strings.Contains(text, "two-factor") || strings.Contains(text, "two factor") || + strings.Contains(text, "authentication code") || strings.Contains(text, "verification code") || + strings.Contains(text, "backup code") || strings.Contains(text, "totp")), + InputKind: inputKind, + } +} + +func (jfr jetfuelLoginResponse) verificationChallengeForMethod(method WebLoginAuthMethod) *WebLoginChallenge { + challenge := jfr.verificationChallenge() + switch method.Kind { + case WebLoginAuthMethodKindCode: + challenge.Description = "Enter the code from your authentication app." + case WebLoginAuthMethodKindBackupCode: + challenge.Hint = "Backup code" + challenge.Description = "Enter a backup code from X." + case WebLoginAuthMethodKindSMS: + if challenge.InputKind == WebLoginChallengeInputKindPhoneNumber { + challenge.Description = "Enter the phone number associated with your X account." + } else { + challenge.Description = "Enter the code sent to your phone number." + } + } + challenge.IsTwoFactor = challenge.InputKind != WebLoginChallengeInputKindPhoneNumber + return challenge +} + +func jetfuelChallengeDescription(text string) string { + switch { + case isJetfuelPhoneNumberChallengeText(text): + return "Enter the phone number associated with your X account." + case strings.Contains(text, "backup code"): + return "Enter the code from your authentication app." + case strings.Contains(text, "text message") || strings.Contains(text, "phone") || strings.Contains(text, "sms"): + return "Enter the code sent to your phone number." + case strings.Contains(text, "authentication code"): + return "Enter the authentication code from X." + case strings.Contains(text, "verification code"): + return "Enter the verification code from X." + default: + return "X needs additional verification for this login." + } +} + +func (jfr jetfuelLoginResponse) isPhoneNumberChallenge() bool { + text := jfr.text() + if strings.Contains(text, "verification code") || strings.Contains(text, "authentication code") || + strings.Contains(text, "backup code") || strings.Contains(text, "two-factor code") || + strings.Contains(text, "two factor code") || strings.Contains(text, "text message") || + strings.Contains(text, "sms") { + return false + } + if isJetfuelPhoneNumberChallengeText(text) { + return true + } + return jfr.hasField("phone_number") || jfr.hasField("phone") || + jfr.hasField("LoginAcid") || jfr.hasField("login_acid") +} + +func isJetfuelPhoneNumberChallengeText(text string) bool { + if !strings.Contains(text, "phone") { + return false + } + return strings.Contains(text, "enter") || strings.Contains(text, "confirm") || + strings.Contains(text, "verify") || strings.Contains(text, "provide") || + strings.Contains(text, "associated") +} + +func (jfr jetfuelLoginResponse) isComplete() bool { + text := jfr.text() + return strings.Contains(text, "/home") || strings.Contains(text, "open_account") +} + +func (jfr jetfuelLoginResponse) loginError() error { + text := jfr.text() + switch { + case strings.Contains(text, "official x apps") || strings.Contains(text, "use x.com"): + return &WebLoginError{Code: 399, Message: "Please use X.com or official X apps to proceed with log in/sign up."} + case strings.Contains(text, "temporarily limited") || strings.Contains(text, "try again later"): + return &WebLoginError{Code: 399, Message: "We've temporarily limited your login. Please try again later."} + case strings.Contains(text, "too many attempts") || strings.Contains(text, "try again in a few minutes"): + return &WebLoginError{Code: 399, Message: "Too many attempts. Try again in a few minutes."} + case strings.Contains(text, "missing_account") || strings.Contains(text, "not registered"): + return &WebLoginError{Code: 32, Message: "This email or username is not registered yet."} + case isJetfuelBadCredentialsText(text): + return &WebLoginError{Code: 32, Message: "Wrong password"} + case strings.Contains(text, "could not log you in") || strings.Contains(text, "couldn't log you in"): + return &WebLoginError{Code: 399, Message: "Could not log you in now. Please try again later."} + default: + return nil + } +} + +func isJetfuelBadCredentialsText(text string) bool { + return strings.Contains(text, "wrong password") || + strings.Contains(text, "incorrect password") || + strings.Contains(text, "invalid password") || + strings.Contains(text, "password you entered") || + strings.Contains(text, "password is incorrect") || + strings.Contains(text, "username and password") && strings.Contains(text, "did not match") || + strings.Contains(text, "invalid username or password") || + strings.Contains(text, "invalid credentials") +} diff --git a/pkg/twittermeow/login_jetfuel_live_probe_test.go b/pkg/twittermeow/login_jetfuel_live_probe_test.go new file mode 100644 index 00000000..bac9ff31 --- /dev/null +++ b/pkg/twittermeow/login_jetfuel_live_probe_test.go @@ -0,0 +1,562 @@ +//go:build liveprobe + +package twittermeow + +import ( + "context" + "errors" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + + "go.mau.fi/mautrix-twitter/pkg/twittermeow/cookies" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/endpoints" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/types" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/methods" +) + +func TestLiveJetfuelLoginLandingProbe(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + if err := client.loadPage(ctx, jetfuelProbePageURL()); err != nil { + t.Fatalf("loadPage() failed: %v", err) + } + if os.Getenv("TWITTER_DNT_PROBE") == "1" { + client.cookies.Set(cookies.XCookieName("dnt"), "1") + t.Logf("using injected dnt cookie: true") + } + if cuid := strings.TrimSpace(os.Getenv("TWITTER_CUID_PROBE")); cuid != "" { + client.cookies.Set(cookies.XCookieName("__cuid"), cuid) + t.Logf("using injected __cuid cookie: true") + } + t.Logf("transaction token state after login page: verification=%t animation=%t", + client.session.VerificationToken != "", + client.session.AnimationToken != "", + ) + t.Logf("cookie presence after login page: cf_bm=%t cuid=%t dnt=%t guest_id=%t guest_id_ads=%t guest_id_marketing=%t personalization_id=%t gt=%t ct0=%t att=%t", + !client.cookies.IsCookieEmpty(cookies.XCookieName("__cf_bm")), + !client.cookies.IsCookieEmpty(cookies.XCookieName("__cuid")), + !client.cookies.IsCookieEmpty(cookies.XCookieName("dnt")), + !client.cookies.IsCookieEmpty(cookies.XGuestID), + !client.cookies.IsCookieEmpty(cookies.XGuestIDAds), + !client.cookies.IsCookieEmpty(cookies.XGuestIDMarketing), + !client.cookies.IsCookieEmpty(cookies.XPersonalizationID), + !client.cookies.IsCookieEmpty(cookies.XGuestToken), + !client.cookies.IsCookieEmpty(cookies.XCt0), + !client.cookies.IsCookieEmpty(cookies.XAtt), + ) + if _, err := client.jetfuelGet(ctx, endpoints.JETFUEL_LANDING_PATH); err != nil { + t.Logf("landing preflight failed: %v", err) + } + t.Logf("cookie presence after Jetfuel landing: cf_bm=%t cuid=%t dnt=%t guest_id=%t guest_id_ads=%t guest_id_marketing=%t personalization_id=%t gt=%t ct0=%t att=%t", + !client.cookies.IsCookieEmpty(cookies.XCookieName("__cf_bm")), + !client.cookies.IsCookieEmpty(cookies.XCookieName("__cuid")), + !client.cookies.IsCookieEmpty(cookies.XCookieName("dnt")), + !client.cookies.IsCookieEmpty(cookies.XGuestID), + !client.cookies.IsCookieEmpty(cookies.XGuestIDAds), + !client.cookies.IsCookieEmpty(cookies.XGuestIDMarketing), + !client.cookies.IsCookieEmpty(cookies.XPersonalizationID), + !client.cookies.IsCookieEmpty(cookies.XGuestToken), + !client.cookies.IsCookieEmpty(cookies.XCt0), + !client.cookies.IsCookieEmpty(cookies.XAtt), + ) + + body, err := client.jetfuelGet(ctx, endpoints.JETFUEL_LOGIN_PATH) + if err != nil { + t.Fatalf("jetfuelGet() failed: %v", err) + } + parsed := parseJetfuelLoginResponse(body) + t.Logf("Jetfuel landing strings=%d paths=%v fields=%v", len(parsed.strings), parsed.paths, parsed.fields) + if !parsed.hasPath(endpoints.JETFUEL_BEGIN_LOGIN_PATH) && !parsed.hasField("username_or_email") { + t.Fatalf("Jetfuel landing did not expose begin_login or username_or_email") + } +} + +func TestLiveJetfuelIdentifierMetadataProbe(t *testing.T) { + identifier := strings.TrimSpace(os.Getenv("TWITTER_IDENTIFIER_PROBE")) + if identifier == "" { + t.Skip("TWITTER_IDENTIFIER_PROBE is required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + if err := client.loadPage(ctx, jetfuelProbePageURL()); err != nil { + t.Fatalf("loadPage() failed: %v", err) + } + if os.Getenv("TWITTER_DNT_PROBE") == "1" { + client.cookies.Set(cookies.XCookieName("dnt"), "1") + t.Logf("using injected dnt cookie: true") + } + if cuid := strings.TrimSpace(os.Getenv("TWITTER_CUID_PROBE")); cuid != "" { + client.cookies.Set(cookies.XCookieName("__cuid"), cuid) + t.Logf("using injected __cuid cookie: true") + } + t.Logf("transaction token state after login page: verification=%t animation=%t", + client.session.VerificationToken != "", + client.session.AnimationToken != "", + ) + if _, err := client.jetfuelGet(ctx, endpoints.JETFUEL_LANDING_PATH); err != nil { + t.Logf("landing preflight failed: %v", err) + } + if _, err := client.jetfuelGet(ctx, endpoints.JETFUEL_LOGIN_PATH); err != nil { + t.Fatalf("login graph GET failed: %v", err) + } + + form := url.Values{ + "username_or_email": {identifier}, + } + if password := os.Getenv("TWITTER_PASSWORD_PROBE"); password != "" { + form.Set("password", password) + t.Logf("using injected password field: true") + } + if castleToken := strings.TrimSpace(os.Getenv("TWITTER_CASTLE_TOKEN_PROBE")); castleToken != "" { + form.Set("$castle_token", castleToken) + t.Logf("using injected castle token: true") + } else { + t.Skip("TWITTER_CASTLE_TOKEN_PROBE is required; Castle tokens are now generated by the client webview") + } + body, err := client.jetfuelPostForm(ctx, endpoints.JETFUEL_BEGIN_LOGIN_PATH, form) + if err != nil { + t.Fatalf("begin_login POST failed: %v", err) + } + parsed := parseJetfuelLoginResponse(body) + t.Logf("begin_login strings=%d paths=%v fields=%v", len(parsed.strings), parsed.paths, redactJetfuelDebugList(parsed.fields, identifier)) + for _, line := range filteredJetfuelDebugStrings(parsed, identifier) { + t.Logf("begin_login string: %s", line) + } +} + +func TestLiveJetfuelVerificationResponseProbe(t *testing.T) { + identifier := strings.TrimSpace(os.Getenv("TWITTER_LIVE_IDENTIFIER")) + password := os.Getenv("TWITTER_LIVE_PASSWORD") + code := strings.TrimSpace(os.Getenv("TWITTER_LIVE_VERIFICATION_CODE")) + if identifier == "" || password == "" || code == "" { + t.Skip("TWITTER_LIVE_IDENTIFIER, TWITTER_LIVE_PASSWORD, and TWITTER_LIVE_VERIFICATION_CODE are required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + session := NewWebLoginSession(client) + result, err := session.Start(ctx) + logWebLoginStage(t, "start", result, err) + if err != nil { + t.Fatalf("Start() failed: %v", err) + } + result, err = session.SubmitCredentials(ctx, identifier, password) + logWebLoginStage(t, "credentials", result, err) + if err != nil { + t.Fatalf("SubmitCredentials() failed: %v", err) + } + if result.Status == WebLoginStatusNeedsPassword { + result, err = session.SubmitPassword(ctx, password) + logWebLoginStage(t, "password", result, err) + if err != nil { + t.Fatalf("SubmitPassword() failed: %v", err) + } + } + if result.Status != WebLoginStatusNeedsText { + t.Fatalf("SubmitCredentials() status = %s, want %s", result.Status, WebLoginStatusNeedsText) + } + t.Logf("verification action present=%t fields=%v", session.jetfuel != nil && session.jetfuel.verificationAction != "", session.jetfuelVerificationFields()) + + form := url.Values{} + for _, field := range session.jetfuelVerificationFields() { + form.Set(field, code) + } + if session.jetfuel.sessionToken != "" { + form.Set("session_token", session.jetfuel.sessionToken) + } + if session.jetfuel.preludeDispatchID != "" { + form.Set("prelude_dispatch_id", session.jetfuel.preludeDispatchID) + } + body, err := client.jetfuelPostForm(ctx, session.jetfuel.verificationAction, form) + if err != nil { + t.Fatalf("verification POST failed: %v", err) + } + parsed := parseJetfuelLoginResponse(body) + t.Logf("verification response: logged_in=%t complete=%t strings=%d paths=%v fields=%v password_action=%q begin_2fa_action=%q verification_action=%q", + client.IsLoggedIn(), + parsed.isComplete(), + len(parsed.strings), + redactJetfuelDebugList(parsed.paths, identifier, password, code), + redactJetfuelDebugList(parsed.fields, identifier, password, code), + parsed.passwordAction(), + parsed.beginTwoFactorAction(), + parsed.verificationAction(), + ) + if err := parsed.loginError(); err != nil { + var webErr *WebLoginError + if errors.As(err, &webErr) { + t.Logf("verification response login error: code=%d message=%q", webErr.Code, redactJetfuelDebugString(webErr.Message, identifier, password, code)) + } else { + t.Logf("verification response login error: %T", err) + } + } + for _, line := range filteredJetfuelDebugStrings(parsed, identifier, password, code) { + t.Logf("verification response string: %s", line) + } +} + +func TestLiveJetfuelPasswordResponseProbe(t *testing.T) { + identifier := strings.TrimSpace(os.Getenv("TWITTER_LIVE_IDENTIFIER")) + password := os.Getenv("TWITTER_LIVE_PASSWORD") + if identifier == "" || password == "" { + t.Skip("TWITTER_LIVE_IDENTIFIER and TWITTER_LIVE_PASSWORD are required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + session := NewWebLoginSession(client) + result, err := session.Start(ctx) + logWebLoginStage(t, "start", result, err) + if err != nil { + t.Fatalf("Start() failed: %v", err) + } + result, err = session.SubmitIdentifier(ctx, identifier) + logWebLoginStage(t, "identifier", result, err) + if err != nil { + t.Fatalf("SubmitIdentifier() failed: %v", err) + } + if result.Status != WebLoginStatusNeedsPassword { + t.Fatalf("SubmitIdentifier() status = %s, want %s", result.Status, WebLoginStatusNeedsPassword) + } + + form := url.Values{"password": {password}} + if session.jetfuel.identifier != "" { + form.Set("username", session.jetfuel.identifier) + } + if session.jetfuel.sessionToken != "" { + form.Set("session_token", session.jetfuel.sessionToken) + } + body, err := client.jetfuelPostForm(ctx, session.jetfuel.passwordAction, form) + if err != nil { + t.Fatalf("password POST failed: %v", err) + } + parsed := parseJetfuelLoginResponse(body) + t.Logf("password response: logged_in=%t complete=%t strings=%d paths=%v fields=%v password_action=%q begin_2fa_action=%q verification_action=%q", + client.IsLoggedIn(), + parsed.isComplete(), + len(parsed.strings), + redactJetfuelDebugList(parsed.paths, identifier, password), + redactJetfuelDebugList(parsed.fields, identifier, password), + parsed.passwordAction(), + parsed.beginTwoFactorAction(), + parsed.verificationAction(), + ) + if err := parsed.loginError(); err != nil { + var webErr *WebLoginError + if errors.As(err, &webErr) { + t.Logf("password response login error: code=%d message=%q", webErr.Code, redactJetfuelDebugString(webErr.Message, identifier, password)) + } else { + t.Logf("password response login error: %T", err) + } + } + for _, line := range filteredJetfuelDebugStrings(parsed, identifier, password) { + t.Logf("password response string: %s", line) + } +} + +func TestLiveJetfuelAuthMethodSelectionProbe(t *testing.T) { + identifier := strings.TrimSpace(os.Getenv("TWITTER_LIVE_IDENTIFIER")) + password := os.Getenv("TWITTER_LIVE_PASSWORD") + methodID := strings.TrimSpace(os.Getenv("TWITTER_LIVE_AUTH_METHOD")) + if identifier == "" || password == "" || methodID == "" { + t.Skip("TWITTER_LIVE_IDENTIFIER, TWITTER_LIVE_PASSWORD, and TWITTER_LIVE_AUTH_METHOD are required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + session := NewWebLoginSession(client) + result, err := session.Start(ctx) + logWebLoginStage(t, "start", result, err) + if err != nil { + t.Fatalf("Start() failed: %v", err) + } + result, err = session.SubmitCredentials(ctx, identifier, password) + logWebLoginStage(t, "credentials", result, err) + if err != nil { + t.Fatalf("SubmitCredentials() failed: %v", err) + } + if result.Status != WebLoginStatusNeedsAuthMethod { + t.Fatalf("SubmitCredentials() status = %s, want %s", result.Status, WebLoginStatusNeedsAuthMethod) + } + + method, ok := session.jetfuel.findAuthMethod(methodID) + if !ok { + t.Fatalf("auth method %q not found in %#v", methodID, result.AuthMethods) + } + action := session.jetfuel.twoFactorAction + if action == "" { + action = endpoints.JETFUEL_BEGIN_TWO_FACTOR_AUTH_PATH + } + body, err := client.jetfuelPostForm(ctx, action, session.jetfuel.authMethodForm(method)) + if err != nil { + t.Fatalf("auth method POST failed: %v", err) + } + parsed := parseJetfuelLoginResponse(body) + t.Logf("auth method response: method=%s kind=%s supported=%t logged_in=%t complete=%t strings=%d paths=%v fields=%v begin_2fa_action=%q verification_action=%q", + method.ID, + method.Kind, + method.Supported, + client.IsLoggedIn(), + parsed.isComplete(), + len(parsed.strings), + redactJetfuelDebugList(parsed.paths, identifier, password), + redactJetfuelDebugList(parsed.fields, identifier, password), + parsed.beginTwoFactorAction(), + parsed.verificationAction(), + ) + if err := parsed.loginError(); err != nil { + var webErr *WebLoginError + if errors.As(err, &webErr) { + t.Logf("auth method response login error: code=%d message=%q", webErr.Code, redactJetfuelDebugString(webErr.Message, identifier, password)) + } else { + t.Logf("auth method response login error: %T", err) + } + } + for _, line := range filteredJetfuelDebugStrings(parsed, identifier, password) { + t.Logf("auth method response string: %s", line) + } +} + +func jetfuelProbePageURL() string { + if os.Getenv("TWITTER_ROOT_PAGE_PROBE") == "1" { + return endpoints.BASE_URL + "/" + } + return endpoints.BASE_FLOW_LOGIN_URL +} + +func TestLiveCloudflareJSDProbe(t *testing.T) { + if os.Getenv("TWITTER_CLOUDFLARE_JSD_PROBE") != "1" { + t.Skip("TWITTER_CLOUDFLARE_JSD_PROBE=1 is required") + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + extraHeaders := map[string]string{ + "upgrade-insecure-requests": "1", + "sec-fetch-site": "none", + "sec-fetch-user": "?1", + "sec-fetch-dest": "document", + } + resp, body, err := client.MakeRequest(ctx, endpoints.BASE_FLOW_LOGIN_URL, http.MethodGet, client.buildHeaders(HeaderOpts{Extra: extraHeaders, WithCookies: true}), nil, types.ContentTypeNone) + if resp != nil { + client.cookies.UpdateFromResponse(resp) + } + if err != nil { + t.Fatalf("login page request failed: %v", err) + } + pageURL := resp.Request.URL + scriptURL := methods.ParseCloudflareJSDURL(string(body)) + t.Logf("cloudflare jsd script present=%t", scriptURL != "") + if scriptURL == "" { + return + } + parsedScriptURL, err := pageURL.Parse(scriptURL) + if err != nil { + t.Fatalf("parse script URL failed: %v", err) + } + originalCheckRedirect := client.HTTP.CheckRedirect + client.disableRedirects() + defer func() { client.HTTP.CheckRedirect = originalCheckRedirect }() + for i := 0; i < 4; i++ { + resp, _, err = client.MakeRequest(ctx, parsedScriptURL.String(), http.MethodGet, client.buildHeaders(HeaderOpts{ + Extra: map[string]string{ + "accept": "*/*", + "sec-fetch-dest": "script", + "sec-fetch-mode": "no-cors", + "sec-fetch-site": "same-origin", + }, + Referer: pageURL.String(), + WithCookies: true, + }), nil, types.ContentTypeNone) + names := setCookieNames(resp) + if resp != nil { + client.cookies.UpdateFromResponse(resp) + t.Logf("jsd hop=%d status=%d set_cookie_names=%v cf_bm_present=%t", i, resp.StatusCode, names, !client.cookies.IsCookieEmpty(cookies.XCookieName("__cf_bm"))) + } + if !errors.Is(err, ErrRedirectAttempted) { + if err != nil { + t.Fatalf("jsd request failed: %v", err) + } + return + } + location := resp.Header.Get("Location") + if location == "" { + t.Fatalf("redirect without location") + } + parsedScriptURL, err = parsedScriptURL.Parse(location) + if err != nil { + t.Fatalf("parse redirect URL failed: %v", err) + } + } +} + +func setCookieNames(resp *http.Response) []string { + if resp == nil { + return nil + } + names := make([]string, 0) + for _, cookie := range resp.Cookies() { + names = append(names, cookie.Name) + } + return names +} + +func TestLiveJetfuelFakeCredentialsProbe(t *testing.T) { + if os.Getenv("TWITTER_FAKE_CREDENTIALS_PROBE") != "1" { + t.Skip("TWITTER_FAKE_CREDENTIALS_PROBE=1 is required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + session := NewWebLoginSession(client) + result, err := session.Start(ctx) + if err != nil { + t.Fatalf("Start() failed: %v", err) + } + if result.Status != WebLoginStatusNeedsIdentifier { + t.Fatalf("Start() status = %s, want %s", result.Status, WebLoginStatusNeedsIdentifier) + } + result, err = session.SubmitCredentials(ctx, "codex_probe_20260625_noacct", "not-a-real-password") + if err == nil { + t.Fatalf("SubmitCredentials() returned result %#v, want missing-account error", result) + } + var webErr *WebLoginError + if !errors.As(err, &webErr) { + t.Fatalf("SubmitCredentials() error = %T, want *WebLoginError: %v", err, err) + } + if webErr.Code != 32 { + t.Fatalf("WebLoginError.Code = %d, want missing-account/credential code 32 (%s)", webErr.Code, webErr.Message) + } + if strings.Contains(strings.ToLower(webErr.Message), "temporarily limited") { + t.Fatalf("SubmitCredentials() hit temporary-limit branch: %s", webErr.Message) + } +} + +func TestLiveOCFFakeCredentialsProbe(t *testing.T) { + if os.Getenv("TWITTER_OCF_FAKE_CREDENTIALS_PROBE") != "1" { + t.Skip("TWITTER_OCF_FAKE_CREDENTIALS_PROBE=1 is required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + session := NewWebLoginSession(client) + result, err := session.startOCF(ctx) + if err != nil { + t.Fatalf("startOCF() failed: %v", err) + } + if result.Status != WebLoginStatusNeedsIdentifier { + t.Fatalf("startOCF() status = %s, want %s", result.Status, WebLoginStatusNeedsIdentifier) + } + result, err = session.SubmitCredentials(ctx, "codex_probe_20260625_noacct", "not-a-real-password") + if err == nil { + t.Fatalf("SubmitCredentials() returned result %#v, want credential/missing-account error", result) + } + var webErr *WebLoginError + if !errors.As(err, &webErr) { + t.Fatalf("SubmitCredentials() error = %T, want *WebLoginError: %v", err, err) + } + if strings.Contains(strings.ToLower(webErr.Message), "temporarily limited") { + t.Fatalf("OCF SubmitCredentials() hit temporary-limit branch: %s", webErr.Message) + } + t.Logf("OCF fake credentials error: code=%d message=%q", webErr.Code, webErr.Message) +} + +func filteredJetfuelDebugStrings(parsed jetfuelLoginResponse, secrets ...string) []string { + var out []string + needles := []string{ + "password", + "login", + "verification", + "challenge", + "two", + "factor", + "error", + "message", + "limited", + "temporarily", + "castle", + "action", + "flow", + "next", + } + for _, str := range parsed.strings { + lower := strings.ToLower(str) + matched := false + for _, needle := range needles { + if strings.Contains(lower, needle) { + matched = true + break + } + } + if !matched { + continue + } + redacted := redactJetfuelDebugString(str, secrets...) + if len(redacted) > 300 { + redacted = redacted[:300] + "..." + } + out = append(out, redacted) + if len(out) >= 30 { + break + } + } + return out +} + +func redactJetfuelDebugList(values []string, secrets ...string) []string { + out := make([]string, len(values)) + for i, value := range values { + out[i] = redactJetfuelDebugString(value, secrets...) + } + return out +} + +func redactJetfuelDebugString(value string, secrets ...string) string { + for _, secret := range secrets { + if secret != "" { + value = strings.ReplaceAll(value, secret, "