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, "") + } + } + value = uuidDebugRegex.ReplaceAllString(value, "") + return value +} + +func logWebLoginStage(t *testing.T, stage string, result *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 *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) + } +} + +var uuidDebugRegex = regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`) diff --git a/pkg/twittermeow/login_web.go b/pkg/twittermeow/login_web.go new file mode 100644 index 00000000..63b7f33a --- /dev/null +++ b/pkg/twittermeow/login_web.go @@ -0,0 +1,739 @@ +package twittermeow + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "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 ( + webLoginSubtaskJSInstrumentation = "LoginJsInstrumentationSubtask" + webLoginSubtaskIdentifier = "LoginEnterUserIdentifierSSO" + webLoginSubtaskTwoFactor = "LoginTwoFactorAuthChallenge" + + webLoginLinkNext = "next_link" +) + +var ( + ErrWebLoginUnexpectedSubtask = errors.New("unexpected X login subtask") + ErrJetfuelIdentifierNoSupportedAction = fmt.Errorf("%w: jetfuel identifier response did not expose a supported next action", ErrWebLoginUnexpectedSubtask) + ErrWebLoginMissingFlowToken = errors.New("x login response did not include a flow token") + ErrWebLoginMissingGuestToken = errors.New("x guest activation response did not include a guest token") + ErrWebLoginUnsupportedAuthMethod = errors.New("unsupported X login verification method") + ErrWebLoginMissingAuthMethodState = errors.New("x login verification method state is missing") +) + +type WebLoginStatus string + +const ( + WebLoginStatusNeedsIdentifier WebLoginStatus = "needs_identifier" + WebLoginStatusNeedsPassword WebLoginStatus = "needs_password" + WebLoginStatusNeedsText WebLoginStatus = "needs_text" + WebLoginStatusNeedsAuthMethod WebLoginStatus = "needs_auth_method" + WebLoginStatusComplete WebLoginStatus = "complete" + WebLoginStatusUnsupported WebLoginStatus = "unsupported" +) + +type WebLoginAuthMethodKind string + +const ( + WebLoginAuthMethodKindCode WebLoginAuthMethodKind = "code" + WebLoginAuthMethodKindBackupCode WebLoginAuthMethodKind = "backup_code" + WebLoginAuthMethodKindSMS WebLoginAuthMethodKind = "sms" + WebLoginAuthMethodKindUnknown WebLoginAuthMethodKind = "unknown" +) + +type WebLoginChallengeInputKind string + +const ( + WebLoginChallengeInputKindCode WebLoginChallengeInputKind = "code" + WebLoginChallengeInputKindPhoneNumber WebLoginChallengeInputKind = "phone_number" +) + +type WebLoginError struct { + Code int + Message string +} + +func (wle *WebLoginError) Error() string { + if wle == nil { + return "" + } + if wle.Code != 0 { + return fmt.Sprintf("X login failed (%d): %s", wle.Code, wle.Message) + } + return fmt.Sprintf("X login failed: %s", wle.Message) +} + +func (wle *WebLoginError) UserMessage() string { + if wle == nil { + return "X rejected this login. Please check the details and try again." + } + switch wle.Code { + case 32, 64, 89, 99: + return "X rejected the username or password. Check the details and try again." + case 88, 226, 326, 399: + return "X rejected this login attempt. Wait a bit, then try again." + default: + msg := strings.TrimSpace(wle.Message) + if msg == "" { + return "X rejected this login. Please check the details and try again." + } + return fmt.Sprintf("X rejected this login: %s", msg) + } +} + +type WebLoginChallenge struct { + SubtaskID string + Hint string + Description string + IsTwoFactor bool + InputKind WebLoginChallengeInputKind +} + +type WebLoginAuthMethod struct { + ID string + SubmitID string + Name string + Description string + Kind WebLoginAuthMethodKind + Supported bool + Index int +} + +type WebLoginResult struct { + Status WebLoginStatus + Challenge *WebLoginChallenge + AuthMethods []WebLoginAuthMethod + CurrentSubtaskID string +} + +type WebLoginSession struct { + client *Client + flowToken string + subtasks []onboardingSubtask + backend webLoginBackend + jetfuel *jetfuelLoginState +} + +func NewWebLoginSession(client *Client) *WebLoginSession { + return &WebLoginSession{client: client} +} + +func (wls *WebLoginSession) Client() *Client { + return wls.client +} + +func (wls *WebLoginSession) UsesJetfuel() bool { + return wls != nil && wls.backend == webLoginBackendJetfuel +} + +func (wls *WebLoginSession) Start(ctx context.Context) (*WebLoginResult, error) { + if result, err := wls.startJetfuel(ctx); err == nil { + return result, nil + } else if errors.Is(err, ErrClientHTTPRequestPending) || errors.Is(err, ErrJetfuelCastleTokenRequired) { + return nil, err + } else { + wls.client.Logger.Warn().Err(err).Msg("Jetfuel login start failed, falling back to OCF login") + } + return wls.startOCF(ctx) +} + +func (wls *WebLoginSession) startOCF(ctx context.Context) (*WebLoginResult, error) { + wls.backend = webLoginBackendOCF + if err := wls.client.loadPage(ctx, endpoints.BASE_FLOW_LOGIN_URL); err != nil { + return nil, fmt.Errorf("failed to load x login page: %w", err) + } + if err := wls.client.activateGuest(ctx); err != nil { + return nil, err + } + + startPayload := newWebLoginStartPayload(wls.client.session.Country) + resp, err := wls.client.sendOnboardingTask(ctx, endpoints.ONBOARDING_LOGIN_TASK_URL, startPayload) + if err != nil { + return nil, err + } + if err := wls.update(resp); err != nil { + return nil, err + } + return wls.advanceJSInstrumentation(ctx) +} + +func newWebLoginStartPayload(countryCode string) onboardingTaskRequest { + return onboardingTaskRequest{ + InputFlowData: &onboardingInputFlowData{ + FlowContext: onboardingFlowContext{ + DebugOverrides: map[string]any{}, + StartLocation: map[string]string{"location": "manual_link"}, + }, + CountryCode: countryCode, + }, + SubtaskVersions: webLoginSubtaskVersions(), + } +} + +func (wls *WebLoginSession) SubmitIdentifier(ctx context.Context, identifier string) (*WebLoginResult, error) { + if wls.backend == webLoginBackendJetfuel { + return wls.submitJetfuelIdentifier(ctx, identifier) + } + identifier = strings.TrimSpace(identifier) + if identifier == "" { + return nil, fmt.Errorf("x username, email, or phone is required") + } + st := wls.currentSubtask() + if st == nil || st.SettingsList == nil { + return nil, fmt.Errorf("%w: expected identifier settings_list, got %s", ErrWebLoginUnexpectedSubtask, subtaskName(st)) + } + link := st.SettingsList.nextLinkID() + payload := onboardingTaskRequest{ + FlowToken: wls.flowToken, + SubtaskInputs: []onboardingSubtaskInput{{ + SubtaskID: st.SubtaskID, + SettingsList: &settingsListInput{ + SettingResponses: []settingResponseInput{{ + Key: "user_identifier", + ResponseData: map[string]resultInput{ + "text_data": {Result: identifier}, + }, + }}, + Link: link, + }, + }}, + } + resp, err := wls.client.sendOnboardingTask(ctx, endpoints.ONBOARDING_TASK_URL, payload) + if err != nil { + return nil, err + } + if err := wls.update(resp); err != nil { + return nil, err + } + return wls.advanceJSInstrumentation(ctx) +} + +func (wls *WebLoginSession) SubmitCredentials(ctx context.Context, identifier, password string) (*WebLoginResult, error) { + if wls.backend == webLoginBackendJetfuel { + return wls.submitJetfuelCredentials(ctx, identifier, password) + } + result, err := wls.SubmitIdentifier(ctx, identifier) + if err != nil { + return nil, err + } + if result.Status != WebLoginStatusNeedsPassword { + return result, nil + } + return wls.SubmitPassword(ctx, password) +} + +func (wls *WebLoginSession) SubmitCombinedCredentials(ctx context.Context, identifier, password string) (*WebLoginResult, error) { + if wls.backend == webLoginBackendJetfuel { + return wls.submitJetfuelCombinedCredentials(ctx, identifier, password) + } + return wls.SubmitCredentials(ctx, identifier, password) +} + +func (wls *WebLoginSession) SubmitPassword(ctx context.Context, password string) (*WebLoginResult, error) { + if wls.backend == webLoginBackendJetfuel { + return wls.submitJetfuelPassword(ctx, password) + } + if password == "" { + return nil, fmt.Errorf("x password is required") + } + st := wls.currentSubtask() + if st == nil || st.EnterPassword == nil { + return nil, fmt.Errorf("%w: expected enter_password, got %s", ErrWebLoginUnexpectedSubtask, subtaskName(st)) + } + link := st.EnterPassword.nextLinkID() + payload := onboardingTaskRequest{ + FlowToken: wls.flowToken, + SubtaskInputs: []onboardingSubtaskInput{{ + SubtaskID: st.SubtaskID, + EnterPassword: &enterPasswordInput{ + Password: password, + Link: link, + }, + }}, + } + resp, err := wls.client.sendOnboardingTask(ctx, endpoints.ONBOARDING_TASK_URL, payload) + if err != nil { + return nil, err + } + if err := wls.update(resp); err != nil { + return nil, err + } + return wls.advanceJSInstrumentation(ctx) +} + +func (wls *WebLoginSession) SubmitAuthMethod(ctx context.Context, methodID string) (*WebLoginResult, error) { + if wls.backend == webLoginBackendJetfuel { + return wls.submitJetfuelAuthMethod(ctx, methodID) + } + return nil, fmt.Errorf("%w: auth method selection is unsupported for %s login", ErrWebLoginUnexpectedSubtask, wls.backend) +} + +func (wls *WebLoginSession) SubmitPendingTwoFactor(ctx context.Context) (*WebLoginResult, error) { + if wls.backend != webLoginBackendJetfuel { + return nil, fmt.Errorf("%w: two-factor prelude is unsupported for %s login", ErrWebLoginUnexpectedSubtask, wls.backend) + } + if wls.jetfuel == nil || wls.jetfuel.twoFactorAction == "" { + return nil, fmt.Errorf("%w: jetfuel two-factor action is missing", ErrWebLoginUnexpectedSubtask) + } + return wls.submitJetfuelBeginTwoFactor(ctx, wls.jetfuel.twoFactorAction) +} + +func (wls *WebLoginSession) SubmitText(ctx context.Context, text string) (*WebLoginResult, error) { + if wls.backend == webLoginBackendJetfuel { + return wls.submitJetfuelText(ctx, text) + } + text = strings.TrimSpace(text) + if text == "" { + return nil, fmt.Errorf("x verification code is required") + } + st := wls.currentSubtask() + if st == nil || st.EnterText == nil { + return nil, fmt.Errorf("%w: expected enter_text, got %s", ErrWebLoginUnexpectedSubtask, subtaskName(st)) + } + link := st.EnterText.nextLinkID() + payload := onboardingTaskRequest{ + FlowToken: wls.flowToken, + SubtaskInputs: []onboardingSubtaskInput{{ + SubtaskID: st.SubtaskID, + EnterText: &enterTextInput{ + Text: text, + Link: link, + }, + }}, + } + resp, err := wls.client.sendOnboardingTask(ctx, endpoints.ONBOARDING_TASK_URL, payload) + if err != nil { + return nil, err + } + if err := wls.update(resp); err != nil { + return nil, err + } + return wls.advanceJSInstrumentation(ctx) +} + +func (wls *WebLoginSession) advanceJSInstrumentation(ctx context.Context) (*WebLoginResult, error) { + for range 3 { + st := wls.currentSubtask() + if st == nil || st.JSInstrumentation == nil { + return wls.result(), nil + } + metrics := "{}" + payload := onboardingTaskRequest{ + FlowToken: wls.flowToken, + SubtaskInputs: []onboardingSubtaskInput{{ + SubtaskID: st.SubtaskID, + JSInstrumentation: &jsInstrumentationInput{ + Response: metrics, + Link: webLoginLinkNext, + }, + }}, + } + resp, err := wls.client.sendOnboardingTask(ctx, endpoints.ONBOARDING_TASK_URL, payload) + if err != nil { + return nil, err + } + if err := wls.update(resp); err != nil { + return nil, err + } + } + return nil, fmt.Errorf("%w: JS instrumentation loop did not settle", ErrWebLoginUnexpectedSubtask) +} + +func (wls *WebLoginSession) update(resp *onboardingTaskResponse) error { + if resp == nil { + return fmt.Errorf("x login response was empty") + } + if resp.FlowToken == "" && !wls.client.IsLoggedIn() { + return ErrWebLoginMissingFlowToken + } + if resp.FlowToken != "" { + wls.flowToken = resp.FlowToken + } + wls.subtasks = resp.Subtasks + return nil +} + +func (wls *WebLoginSession) result() *WebLoginResult { + if wls.client != nil && wls.client.IsLoggedIn() { + return &WebLoginResult{Status: WebLoginStatusComplete} + } + st := wls.currentSubtask() + if st == nil { + return &WebLoginResult{Status: WebLoginStatusUnsupported} + } + result := &WebLoginResult{CurrentSubtaskID: st.SubtaskID} + switch { + case st.JSInstrumentation != nil: + result.Status = WebLoginStatusUnsupported + case st.SettingsList != nil: + result.Status = WebLoginStatusNeedsIdentifier + result.Challenge = &WebLoginChallenge{ + SubtaskID: st.SubtaskID, + Hint: st.SettingsList.identifierHint(), + Description: richTextText(st.SettingsList.DetailText), + } + case st.EnterPassword != nil: + result.Status = WebLoginStatusNeedsPassword + result.Challenge = &WebLoginChallenge{ + SubtaskID: st.SubtaskID, + Hint: st.EnterPassword.hint(), + Description: richTextText(st.EnterPassword.SecondaryText), + } + case st.EnterText != nil: + result.Status = WebLoginStatusNeedsText + result.Challenge = &WebLoginChallenge{ + SubtaskID: st.SubtaskID, + Hint: st.EnterText.hint(), + Description: richTextText(st.EnterText.DetailText), + IsTwoFactor: st.SubtaskID == webLoginSubtaskTwoFactor, + } + case st.OpenAccount != nil || st.OpenHomeTimeline != nil || st.EndFlow != nil: + result.Status = WebLoginStatusComplete + default: + result.Status = WebLoginStatusUnsupported + } + return result +} + +func (wls *WebLoginSession) currentSubtask() *onboardingSubtask { + for i := range wls.subtasks { + st := &wls.subtasks[i] + if st.JSInstrumentation != nil || st.SettingsList != nil || st.EnterPassword != nil || + st.EnterText != nil || st.OpenAccount != nil || st.OpenHomeTimeline != nil || st.EndFlow != nil { + return st + } + } + if len(wls.subtasks) == 0 { + return nil + } + return &wls.subtasks[0] +} + +func subtaskName(st *onboardingSubtask) string { + if st == nil { + return "" + } + if st.SubtaskID == "" { + return "" + } + return st.SubtaskID +} + +func (c *Client) activateGuest(ctx context.Context) error { + resp, respBody, err := c.MakeRequest(ctx, endpoints.GUEST_ACTIVATE_URL, http.MethodPost, c.buildHeaders(HeaderOpts{ + WithNonAuthBearer: true, + WithCookies: true, + WithXTwitterHeaders: true, + Origin: endpoints.BASE_URL, + Referer: endpoints.BASE_FLOW_LOGIN_URL, + }), []byte(`{}`), types.ContentTypeJSON) + if resp != nil { + c.cookies.UpdateFromResponse(resp) + } + if err != nil { + return fmt.Errorf("failed to activate X guest session: %w", err) + } + var guest struct { + GuestToken string `json:"guest_token"` + } + if err = json.Unmarshal(respBody, &guest); err != nil { + return fmt.Errorf("failed to parse X guest activation response: %w", err) + } + if guest.GuestToken == "" { + return ErrWebLoginMissingGuestToken + } + c.cookies.Set(cookies.XGuestToken, guest.GuestToken) + return nil +} + +func (c *Client) sendOnboardingTask(ctx context.Context, url string, payload onboardingTaskRequest) (*onboardingTaskResponse, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to encode X login task: %w", err) + } + txID, err := crypto.SignTransaction(c.session.AnimationToken, c.session.VerificationToken, url, http.MethodPost) + if err != nil { + c.Logger.Trace().Err(err).Msg("Failed to create X login client transaction ID") + txID = "e:" + } + resp, respBody, err := c.makeRequestDirect(ctx, url, http.MethodPost, c.buildHeaders(HeaderOpts{ + WithNonAuthBearer: true, + WithCookies: true, + WithXTwitterHeaders: true, + WithXGuestToken: true, + Origin: endpoints.BASE_URL, + Referer: endpoints.BASE_FLOW_LOGIN_URL, + Extra: map[string]string{ + "x-client-transaction-id": txID, + "accept": "*/*", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-site", + }, + }), body, types.ContentTypeJSON) + if resp != nil { + c.cookies.UpdateFromResponse(resp) + } + taskResp := &onboardingTaskResponse{} + if len(respBody) > 0 { + if unmarshalErr := json.Unmarshal(respBody, taskResp); unmarshalErr != nil && err == nil { + return nil, fmt.Errorf("failed to parse X login task response: %w", unmarshalErr) + } + } + if len(taskResp.Errors) > 0 { + return taskResp, &WebLoginError{ + Code: taskResp.Errors[0].Code, + Message: taskResp.Errors[0].Message, + } + } + if err != nil { + return taskResp, err + } + return taskResp, nil +} + +type onboardingTaskRequest struct { + FlowToken string `json:"flow_token,omitempty"` + InputFlowData *onboardingInputFlowData `json:"input_flow_data,omitempty"` + SubtaskVersions map[string]int `json:"subtask_versions,omitempty"` + SubtaskInputs []onboardingSubtaskInput `json:"subtask_inputs,omitempty"` +} + +type onboardingInputFlowData struct { + FlowContext onboardingFlowContext `json:"flow_context"` + CountryCode string `json:"country_code,omitempty"` +} + +type onboardingFlowContext struct { + DebugOverrides map[string]any `json:"debug_overrides"` + StartLocation map[string]string `json:"start_location"` +} + +type onboardingSubtaskInput struct { + SubtaskID string `json:"subtask_id"` + JSInstrumentation *jsInstrumentationInput `json:"js_instrumentation,omitempty"` + SettingsList *settingsListInput `json:"settings_list,omitempty"` + EnterPassword *enterPasswordInput `json:"enter_password,omitempty"` + EnterText *enterTextInput `json:"enter_text,omitempty"` +} + +type jsInstrumentationInput struct { + Response string `json:"response"` + Link string `json:"link"` +} + +type settingsListInput struct { + SettingResponses []settingResponseInput `json:"setting_responses"` + Link string `json:"link"` + CastleToken string `json:"castle_token,omitempty"` +} + +type settingResponseInput struct { + Key string `json:"key"` + ResponseData map[string]resultInput `json:"response_data"` +} + +type resultInput struct { + Result any `json:"result"` +} + +type enterPasswordInput struct { + Password string `json:"password"` + Link string `json:"link"` + CastleToken string `json:"castle_token,omitempty"` +} + +type enterTextInput struct { + Text string `json:"text"` + Link string `json:"link"` + CastleToken string `json:"castle_token,omitempty"` +} + +type onboardingTaskResponse struct { + FlowToken string `json:"flow_token"` + Subtasks []onboardingSubtask `json:"subtasks"` + Errors []TwitterError `json:"errors"` +} + +type onboardingSubtask struct { + SubtaskID string `json:"subtask_id"` + JSInstrumentation *jsInstrumentationSubtask `json:"js_instrumentation,omitempty"` + SettingsList *settingsListSubtask `json:"settings_list,omitempty"` + EnterPassword *enterPasswordSubtask `json:"enter_password,omitempty"` + EnterText *enterTextSubtask `json:"enter_text,omitempty"` + OpenAccount *struct{} `json:"open_account,omitempty"` + OpenHomeTimeline *struct{} `json:"open_home_timeline,omitempty"` + EndFlow *struct{} `json:"end_flow,omitempty"` +} + +type jsInstrumentationSubtask struct { + URL string `json:"url"` +} + +type settingsListSubtask struct { + Settings []settingsListSetting `json:"settings"` + NextLink *navigationLink `json:"next_link,omitempty"` + DetailText *richText `json:"detail_text,omitempty"` +} + +type settingsListSetting struct { + ValueIdentifier string `json:"value_identifier"` + ValueType string `json:"value_type"` + ValueData *settingValueData `json:"value_data,omitempty"` +} + +type settingValueData struct { + TextField *textFieldData `json:"text_field,omitempty"` + Button *buttonData `json:"button,omitempty"` +} + +type textFieldData struct { + HintText string `json:"hint_text"` +} + +type buttonData struct { + NavigationLink *navigationLink `json:"navigation_link,omitempty"` +} + +type enterPasswordSubtask struct { + Hint string `json:"hint"` + PasswordField *textFieldData `json:"password_field,omitempty"` + NextLink *navigationLink `json:"next_link,omitempty"` + SecondaryText *richText `json:"secondary_text,omitempty"` +} + +type enterTextSubtask struct { + HintText string `json:"hint_text"` + DetailText *richText `json:"detail_text,omitempty"` + NextLink *navigationLink `json:"next_link,omitempty"` +} + +type navigationLink struct { + LinkID string `json:"link_id"` +} + +type richText struct { + Text string `json:"text"` +} + +func (sls *settingsListSubtask) nextLinkID() string { + if sls == nil { + return webLoginLinkNext + } + if sls.NextLink != nil && sls.NextLink.LinkID != "" { + return sls.NextLink.LinkID + } + for _, setting := range sls.Settings { + if setting.ValueIdentifier == "next_button" && setting.ValueData != nil && + setting.ValueData.Button != nil && setting.ValueData.Button.NavigationLink != nil && + setting.ValueData.Button.NavigationLink.LinkID != "" { + return setting.ValueData.Button.NavigationLink.LinkID + } + } + return webLoginLinkNext +} + +func (sls *settingsListSubtask) identifierHint() string { + if sls == nil { + return "" + } + for _, setting := range sls.Settings { + if setting.ValueIdentifier == "user_identifier" && setting.ValueData != nil && setting.ValueData.TextField != nil { + return setting.ValueData.TextField.HintText + } + } + return "" +} + +func (eps *enterPasswordSubtask) nextLinkID() string { + if eps != nil && eps.NextLink != nil && eps.NextLink.LinkID != "" { + return eps.NextLink.LinkID + } + return webLoginLinkNext +} + +func (eps *enterPasswordSubtask) hint() string { + if eps == nil { + return "" + } + if eps.PasswordField != nil && eps.PasswordField.HintText != "" { + return eps.PasswordField.HintText + } + return eps.Hint +} + +func (ets *enterTextSubtask) nextLinkID() string { + if ets != nil && ets.NextLink != nil && ets.NextLink.LinkID != "" { + return ets.NextLink.LinkID + } + return webLoginLinkNext +} + +func (ets *enterTextSubtask) hint() string { + if ets == nil { + return "" + } + return ets.HintText +} + +func richTextText(rt *richText) string { + if rt == nil { + return "" + } + return strings.TrimSpace(rt.Text) +} + +func webLoginSubtaskVersions() map[string]int { + return map[string]int{ + "action_list": 2, + "alert_dialog": 1, + "app_download_cta": 1, + "check_logged_in_account": 1, + "choice_selection": 3, + "contacts_live_sync_permission_prompt": 0, + "cta": 7, + "email_verification": 2, + "end_flow": 1, + "enter_date": 1, + "enter_email": 2, + "enter_password": 5, + "enter_phone": 2, + "enter_recaptcha": 1, + "enter_text": 5, + "enter_username": 2, + "generic_urt": 3, + "in_app_notification": 1, + "interest_picker": 3, + "js_instrumentation": 1, + "menu_dialog": 1, + "notifications_permission_prompt": 2, + "open_account": 2, + "open_home_timeline": 1, + "open_link": 1, + "phone_verification": 4, + "privacy_options": 1, + "select_avatar": 4, + "select_banner": 2, + "settings_list": 7, + "show_code": 1, + "sign_up": 2, + "sign_up_review": 4, + "tweet_selection_urt": 1, + "update_users": 1, + "upload_media": 1, + "user_recommendations_list": 4, + "user_recommendations_urt": 1, + "wait_spinner": 3, + "web_modal": 1, + } +} diff --git a/pkg/twittermeow/login_web_test.go b/pkg/twittermeow/login_web_test.go new file mode 100644 index 00000000..aac43df2 --- /dev/null +++ b/pkg/twittermeow/login_web_test.go @@ -0,0 +1,1299 @@ +package twittermeow + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/rs/zerolog" + + "go.mau.fi/mautrix-twitter/pkg/twittermeow/cookies" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/endpoints" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (rtf roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return rtf(req) +} + +func readJetfuelTestForm(t *testing.T, req *http.Request) url.Values { + t.Helper() + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("ReadAll(request body) error = %v", err) + } + form, err := url.ParseQuery(string(body)) + if err != nil { + t.Fatalf("ParseQuery(request body) error = %v", err) + } + return form +} + +func jetfuelTestResponse(body string) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func TestSettingsListIdentifierPayloadShape(t *testing.T) { + payload := onboardingTaskRequest{ + FlowToken: "flow-token", + SubtaskInputs: []onboardingSubtaskInput{{ + SubtaskID: webLoginSubtaskIdentifier, + SettingsList: &settingsListInput{ + SettingResponses: []settingResponseInput{{ + Key: "user_identifier", + ResponseData: map[string]resultInput{ + "text_data": {Result: "example"}, + }, + }}, + Link: webLoginLinkNext, + }, + }}, + } + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + var got map[string]any + if err = json.Unmarshal(body, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + inputs := got["subtask_inputs"].([]any) + settingsList := inputs[0].(map[string]any)["settings_list"].(map[string]any) + responses := settingsList["setting_responses"].([]any) + firstResponse := responses[0].(map[string]any) + responseData := firstResponse["response_data"].(map[string]any) + textData := responseData["text_data"].(map[string]any) + + if firstResponse["key"] != "user_identifier" { + t.Fatalf("setting response key = %v, want user_identifier", firstResponse["key"]) + } + if textData["result"] != "example" { + t.Fatalf("text_data.result = %v, want example", textData["result"]) + } + if settingsList["link"] != webLoginLinkNext { + t.Fatalf("settings_list.link = %v, want %s", settingsList["link"], webLoginLinkNext) + } + if _, ok := settingsList["castle_token"]; ok { + t.Fatalf("castle_token should be omitted when empty") + } +} + +func TestWebLoginStartPayloadShape(t *testing.T) { + payload := newWebLoginStartPayload("US") + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + var got map[string]any + if err = json.Unmarshal(body, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + inputFlowData := got["input_flow_data"].(map[string]any) + flowContext := inputFlowData["flow_context"].(map[string]any) + startLocation := flowContext["start_location"].(map[string]any) + subtaskVersions := got["subtask_versions"].(map[string]any) + + if inputFlowData["country_code"] != "US" { + t.Fatalf("country_code = %v, want US", inputFlowData["country_code"]) + } + if startLocation["location"] != "manual_link" { + t.Fatalf("start_location.location = %v, want manual_link", startLocation["location"]) + } + if subtaskVersions["settings_list"] != float64(7) { + t.Fatalf("settings_list version = %v, want 7", subtaskVersions["settings_list"]) + } +} + +func TestWebLoginResultClassifiesTwoFactor(t *testing.T) { + session := &WebLoginSession{ + subtasks: []onboardingSubtask{{ + SubtaskID: webLoginSubtaskTwoFactor, + EnterText: &enterTextSubtask{ + HintText: "Enter your code", + NextLink: &navigationLink{LinkID: webLoginLinkNext}, + }, + }}, + } + result := session.result() + if result.Status != WebLoginStatusNeedsText { + t.Fatalf("Status = %s, want %s", result.Status, WebLoginStatusNeedsText) + } + if result.Challenge == nil || !result.Challenge.IsTwoFactor { + t.Fatalf("Challenge = %#v, want two-factor challenge", result.Challenge) + } + if result.Challenge.Hint != "Enter your code" { + t.Fatalf("Challenge.Hint = %q", result.Challenge.Hint) + } +} + +func TestParseJetfuelLoginResponseFindsActionsAndFields(t *testing.T) { + body := []byte{ + 0x03, 0x00, 0xff, + } + body = append(body, []byte("username_or_email\x00/onboarding/web/actions/begin_login\x00password\x00/onboarding/web/actions/login_enter_password\x00session_token\x0012345678-1234-1234-1234-123456789abc")...) + + parsed := parseJetfuelLoginResponse(body) + if !parsed.hasField("username_or_email") { + t.Fatalf("username_or_email field not found in %#v", parsed.fields) + } + if !parsed.hasPath("/onboarding/web/actions/begin_login") { + t.Fatalf("begin_login path not found in %#v", parsed.paths) + } + if action := parsed.passwordAction(); action != endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH { + t.Fatalf("passwordAction() = %q", action) + } + if token := parsed.uuidValue("session_token"); token != "12345678-1234-1234-1234-123456789abc" { + t.Fatalf("uuidValue(session_token) = %q", token) + } +} + +func TestJetfuelLoginResponseSeparatesTwoFactorPreludeFromCodeAction(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte("prelude_dispatch_id\x00aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\x00/onboarding/web/actions/begin_two_factor_auth\x00/onboarding/web/actions/two_factor_code")) + + if action := parsed.beginTwoFactorAction(); action != endpoints.JETFUEL_BEGIN_TWO_FACTOR_AUTH_PATH { + t.Fatalf("beginTwoFactorAction() = %q", action) + } + if action := parsed.verificationAction(); action != "/onboarding/web/actions/two_factor_code" { + t.Fatalf("verificationAction() = %q", action) + } + if id := parsed.uuidValue("prelude_dispatch_id"); id != "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" { + t.Fatalf("uuidValue(prelude_dispatch_id) = %q", id) + } +} + +func TestJetfuelLoginResponseExpandsBareTwoFactorActions(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte("prelude_dispatch_id\x00aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\x00begin_two_factor_auth\x00finish_two_factor_auth\x00challenge_response")) + + if action := parsed.beginTwoFactorAction(); action != endpoints.JETFUEL_BEGIN_TWO_FACTOR_AUTH_PATH { + t.Fatalf("beginTwoFactorAction() = %q", action) + } + if action := parsed.verificationAction(); action != "/onboarding/web/actions/finish_two_factor_auth" { + t.Fatalf("verificationAction() = %q", action) + } +} + +func TestJetfuelLoginResponseFindsSMSPhoneAction(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte( + "Confirm the phone number associated with your X account.\x00phone_number\x00/onboarding/web/actions/send_sms_code\x00session_token", + )) + + method := WebLoginAuthMethod{Kind: WebLoginAuthMethodKindSMS} + if action := parsed.verificationActionForMethod(method); action != "/onboarding/web/actions/send_sms_code" { + t.Fatalf("verificationActionForMethod(SMS) = %q, want send_sms_code action", action) + } +} + +func TestJetfuelLoginResponseFindsSMSCodeAction(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte( + "We sent a text message with a verification code to your phone.\x00challenge_response\x00/onboarding/web/actions/enter_sms_pin\x00session_token", + )) + + method := WebLoginAuthMethod{Kind: WebLoginAuthMethodKindSMS} + if action := parsed.verificationActionForMethod(method); action != "/onboarding/web/actions/enter_sms_pin" { + t.Fatalf("verificationActionForMethod(SMS) = %q, want enter_sms_pin action", action) + } +} + +func TestJetfuelLoginResponseFindsTwoFactorCodeFields(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte("authentication code\x00session_token\x00challenge_response\x00verification_code\x00two_factor_code\x00prelude_dispatch_id")) + fields := parsed.verificationCodeFields() + want := []string{"challenge_response", "verification_code", "two_factor_code"} + if strings.Join(fields, ",") != strings.Join(want, ",") { + t.Fatalf("verificationCodeFields() = %#v, want %#v", fields, want) + } +} + +func TestJetfuelLoginResponseFindsBackupCodeFields(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte("Enter a backup code from X.\x00backup_code\x00challenge_response\x00session_token\x00castle_token")) + fields := parsed.verificationCodeFields() + want := []string{"backup_code", "challenge_response"} + if strings.Join(fields, ",") != strings.Join(want, ",") { + t.Fatalf("verificationCodeFields() = %#v, want %#v", fields, want) + } +} + +func TestJetfuelLoginResponseFindsPhoneNumberFields(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte("Enter the phone number associated with your X account.\x00phone_number\x00challenge_response\x00session_token")) + fields := parsed.verificationCodeFields() + want := []string{"phone_number"} + if strings.Join(fields, ",") != strings.Join(want, ",") { + t.Fatalf("verificationCodeFields() = %#v, want %#v", fields, want) + } +} + +func TestJetfuelLoginResponseBuildsPhoneNumberChallenge(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte("Confirm the phone number associated with your X account.\x00phone_number\x00session_token")) + challenge := parsed.verificationChallenge() + if challenge.Description != "Enter the phone number associated with your X account." { + t.Fatalf("verificationChallenge().Description = %q", challenge.Description) + } + if challenge.Hint != "Phone number" { + t.Fatalf("verificationChallenge().Hint = %q", challenge.Hint) + } + if challenge.InputKind != WebLoginChallengeInputKindPhoneNumber { + t.Fatalf("verificationChallenge().InputKind = %q, want phone_number", challenge.InputKind) + } + if challenge.IsTwoFactor { + t.Fatal("verificationChallenge().IsTwoFactor = true, want false for phone number input") + } +} + +func TestJetfuelLoginResponseKeepsSMSCodeAsTwoFactorCode(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte("We sent a text message with a verification code to your phone.\x00challenge_response\x00session_token")) + challenge := parsed.verificationChallenge() + if challenge.InputKind != WebLoginChallengeInputKindCode { + t.Fatalf("verificationChallenge().InputKind = %q, want code", challenge.InputKind) + } + if challenge.Description != "Enter the code sent to your phone number." { + t.Fatalf("verificationChallenge().Description = %q", challenge.Description) + } + if !challenge.IsTwoFactor { + t.Fatal("verificationChallenge().IsTwoFactor = false, want true") + } +} + +func TestJetfuelLoginResponseUsesAuthenticatorAppCopyForBackupCodePrompt(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte("Enter a backup code from X.\x00challenge_response\x00Totp\x00BackupCode\x00session_token")) + challenge := parsed.verificationChallenge() + if challenge.Description != "Enter the code from your authentication app." { + t.Fatalf("verificationChallenge().Description = %q", challenge.Description) + } + if !challenge.IsTwoFactor { + t.Fatal("verificationChallenge().IsTwoFactor = false, want true") + } +} + +func TestJetfuelLoginResponseDoesNotTreatChallengeModesAsFields(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte("Enter a backup code from X.\x00challenge_response\x00Totp\x00BackupCode\x00session_token")) + fields := parsed.verificationCodeFields() + want := []string{"challenge_response"} + if strings.Join(fields, ",") != strings.Join(want, ",") { + t.Fatalf("verificationCodeFields() = %#v, want %#v", fields, want) + } +} + +func TestJetfuelLoginResponseFindsAuthMethodChoice(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte( + "Select a method to authenticate\x00Choose the method you prefer to use for 2-step verification.\x00two_factor_method\x00" + + "Totp\x00Sms\x00BackupCode\x00U2fSecurityKey\x00user_id\x001127993589949243392\x00session_token\x0012345678-1234-1234-1234-123456789abc\x00begin_two_factor_auth", + )) + methods := parsed.authMethods() + if len(methods) != 4 { + t.Fatalf("authMethods() length = %d, want 4: %#v", len(methods), methods) + } + wantIDs := []string{"Totp", "Sms", "BackupCode", "U2fSecurityKey"} + for i, want := range wantIDs { + if methods[i].ID != want || methods[i].Index != i { + t.Fatalf("method[%d] = %#v, want ID %s index %d", i, methods[i], want, i) + } + } + if !methods[0].Supported || methods[1].Supported || !methods[2].Supported { + t.Fatalf("only authenticator app and backup code should be supported: %#v", methods) + } + if methods[1].Kind != WebLoginAuthMethodKindSMS { + t.Fatalf("sms method = %#v, want SMS method", methods[1]) + } + if methods[3].Supported || methods[3].Kind != WebLoginAuthMethodKindUnknown { + t.Fatalf("security key method = %#v, want known unsupported method", methods[3]) + } + supported := supportedWebLoginAuthMethods(methods) + if len(supported) != 2 { + t.Fatalf("supportedWebLoginAuthMethods() length = %d, want 2: %#v", len(supported), supported) + } + if got := parsed.numericValue("user_id"); got != "1127993589949243392" { + t.Fatalf("numericValue(user_id) = %q", got) + } + if action := parsed.beginTwoFactorAction(); action != endpoints.JETFUEL_BEGIN_TWO_FACTOR_AUTH_PATH { + t.Fatalf("beginTwoFactorAction() = %q", action) + } +} + +func TestJetfuelLoginResponseFindsBareAuthMethodChoice(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte( + "Totp\x00Text\x00BackupCode\x00U2fSecurityKey\x00user_id\x001127993589949243392\x00begin_two_factor_auth", + )) + session := &WebLoginSession{jetfuel: &jetfuelLoginState{}} + session.updateJetfuelState(parsed) + + result := session.jetfuelAuthMethodChoiceResult(parsed) + if result == nil { + t.Fatal("jetfuelAuthMethodChoiceResult() returned nil") + } + if result.Status != WebLoginStatusNeedsAuthMethod { + t.Fatalf("Status = %s, want %s", result.Status, WebLoginStatusNeedsAuthMethod) + } + if len(result.AuthMethods) != 2 { + t.Fatalf("AuthMethods length = %d, want 2 supported methods: %#v", len(result.AuthMethods), result.AuthMethods) + } + wantIDs := []string{"Totp", "BackupCode"} + for i, want := range wantIDs { + if result.AuthMethods[i].ID != want { + t.Fatalf("AuthMethods[%d].ID = %q, want %q", i, result.AuthMethods[i].ID, want) + } + } + if session.jetfuel.twoFactorAction != endpoints.JETFUEL_BEGIN_TWO_FACTOR_AUTH_PATH { + t.Fatalf("twoFactorAction = %q, want begin_two_factor_auth", session.jetfuel.twoFactorAction) + } +} + +func TestJetfuelLoginResponsePrefersSMSSubmitToken(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte( + "Select a method to authenticate\x00two_factor_method\x00Text Message\x00Totp\x00Text\x00BackupCode\x00begin_two_factor_auth", + )) + methods := parsed.authMethods() + if len(methods) != 3 { + t.Fatalf("authMethods() length = %d, want 3: %#v", len(methods), methods) + } + if methods[0].ID != "Sms" { + t.Fatalf("authMethods()[0].ID = %q, want Sms", methods[0].ID) + } + if methods[0].SubmitID != "Text" { + t.Fatalf("authMethods()[0].SubmitID = %q, want Text", methods[0].SubmitID) + } + if methods[0].Index != 0 { + t.Fatalf("authMethods()[0].Index = %d, want 0", methods[0].Index) + } +} + +func TestJetfuelLoginResponseDoesNotTreatCodePromptAsAuthMethodChoice(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte("Enter your two factor code\x00Use your authenticator app to generate the code.\x00challenge_response\x00Totp\x00BackupCode\x00session_token")) + if methods := parsed.authMethods(); len(methods) != 0 { + t.Fatalf("authMethods() = %#v, want none for code prompt", methods) + } +} + +func TestJetfuelLoginResponseClassifiesPhoneAuthMethodAliases(t *testing.T) { + tests := []struct { + raw string + submitID string + }{ + {raw: "Sms"}, + {raw: "SMS"}, + {raw: "Text", submitID: "Text"}, + {raw: "Text message"}, + {raw: "Phone number"}, + {raw: "TextMessage", submitID: "TextMessage"}, + {raw: "PhoneNumber", submitID: "PhoneNumber"}, + } + for _, test := range tests { + method, ok := classifyJetfuelAuthMethod(test.raw) + if !ok { + t.Fatalf("classifyJetfuelAuthMethod(%q) returned false", test.raw) + } + if method.ID != "Sms" || method.Kind != WebLoginAuthMethodKindSMS || method.Supported { + t.Fatalf("classifyJetfuelAuthMethod(%q) = %#v, want coming-soon SMS method", test.raw, method) + } + if method.SubmitID != test.submitID { + t.Fatalf("classifyJetfuelAuthMethod(%q).SubmitID = %q, want %q", test.raw, method.SubmitID, test.submitID) + } + } +} + +func TestJetfuelAuthMethodFormShape(t *testing.T) { + state := &jetfuelLoginState{ + sessionToken: "session-token", + preludeDispatchID: "prelude-id", + userID: "1127993589949243392", + } + form := state.authMethodForm(WebLoginAuthMethod{ID: "Totp", Index: 0}) + + if got := form.Get("two_factor_auth_method_type"); got != "Totp" { + t.Fatalf("two_factor_auth_method_type = %q", got) + } + if got := form.Get("_selected_method_idx"); got != "0" { + t.Fatalf("_selected_method_idx = %q", got) + } + if got := form.Get("user_id"); got != "1127993589949243392" { + t.Fatalf("user_id = %q", got) + } + if got := form.Get("session_token"); got != "session-token" { + t.Fatalf("session_token = %q", got) + } + if got := form.Get("prelude_dispatch_id"); got != "" { + t.Fatalf("prelude_dispatch_id = %q, want omitted", got) + } +} + +func TestJetfuelAuthMethodFormUsesSubmitID(t *testing.T) { + state := &jetfuelLoginState{} + form := state.authMethodForm(WebLoginAuthMethod{ID: "Sms", SubmitID: "TextMessage", Index: 1}) + + if got := form.Get("two_factor_auth_method_type"); got != "TextMessage" { + t.Fatalf("two_factor_auth_method_type = %q, want TextMessage", got) + } + if got := form.Get("_selected_method_idx"); got != "1" { + t.Fatalf("_selected_method_idx = %q, want 1", got) + } +} + +func TestJetfuelCastleTokenUsesClientProvidedOneShotToken(t *testing.T) { + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + form := make(url.Values) + + if err := client.addJetfuelCastleTokenToForm(form); !errors.Is(err, ErrJetfuelCastleTokenRequired) { + t.Fatalf("addJetfuelCastleTokenToForm() error = %v, want ErrJetfuelCastleTokenRequired", err) + } + + client.SetNextJetfuelCastleTokens([]string{" castle-from-webview "}) + if err := client.addJetfuelCastleTokenToForm(form); err != nil { + t.Fatalf("addJetfuelCastleTokenToForm() error = %v", err) + } + if got := form.Get("$castle_token"); got != "castle-from-webview" { + t.Fatalf("$castle_token = %q, want webview token", got) + } + if err := client.addJetfuelCastleTokenToForm(make(url.Values)); !errors.Is(err, ErrJetfuelCastleTokenRequired) { + t.Fatalf("second addJetfuelCastleTokenToForm() error = %v, want one-shot token to be consumed", err) + } +} + +func TestJetfuelCastleTokenConsumesQueuedTokensInOrder(t *testing.T) { + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + client.SetNextJetfuelCastleTokens([]string{" first-token ", "", "second-token"}) + + firstForm := make(url.Values) + if err := client.addJetfuelCastleTokenToForm(firstForm); err != nil { + t.Fatalf("first addJetfuelCastleTokenToForm() error = %v", err) + } + if got := firstForm.Get("$castle_token"); got != "first-token" { + t.Fatalf("first $castle_token = %q, want first-token", got) + } + if !client.HasNextJetfuelCastleToken() { + t.Fatal("HasNextJetfuelCastleToken() = false, want queued second token") + } + + secondForm := make(url.Values) + if err := client.addJetfuelCastleTokenToForm(secondForm); err != nil { + t.Fatalf("second addJetfuelCastleTokenToForm() error = %v", err) + } + if got := secondForm.Get("$castle_token"); got != "second-token" { + t.Fatalf("second $castle_token = %q, want second-token", got) + } + if client.HasNextJetfuelCastleToken() { + t.Fatal("HasNextJetfuelCastleToken() = true, want queue exhausted") + } +} + +func TestSetCookiesPreservesExistingLoginCookies(t *testing.T) { + client := NewClient(cookies.NewCookies(map[string]string{ + "att": "native-login-cookie", + "guest_id": "old-guest-id", + }), nil, zerolog.Nop()) + + client.SetCookies(map[string]string{ + "guest_id": "browser-guest-id", + "__cf_bm": "browser-cf-cookie", + }) + + if got := client.cookies.Get(cookies.XAtt); got != "native-login-cookie" { + t.Fatalf("att cookie = %q, want native login cookie to be preserved", got) + } + if got := client.cookies.Get(cookies.XGuestID); got != "browser-guest-id" { + t.Fatalf("guest_id cookie = %q, want webview cookie to update existing value", got) + } + if got := client.cookies.Get(cookies.XCookieName("__cf_bm")); got != "browser-cf-cookie" { + t.Fatalf("__cf_bm cookie = %q, want webview cookie", got) + } +} + +func TestJetfuelIdentifierNoSupportedActionClassification(t *testing.T) { + if !errors.Is(ErrJetfuelIdentifierNoSupportedAction, ErrWebLoginUnexpectedSubtask) { + t.Fatalf("ErrJetfuelIdentifierNoSupportedAction must wrap ErrWebLoginUnexpectedSubtask") + } + if !isJetfuelPrePasswordParityError(ErrJetfuelIdentifierNoSupportedAction) { + t.Fatal("identifier no-action error must enable the combined-credentials fallback") + } +} + +func TestSubmitJetfuelCredentialsFallsBackToCombinedAfterIdentifierNoAction(t *testing.T) { + t.Setenv("TWITTER_JETFUEL_VIEWER_CONTEXT", "0") + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + client.SetNextJetfuelCastleTokens([]string{"identifier-token", "combined-token"}) + + requestCount := 0 + client.HTTP = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path != "/i/jfapi"+endpoints.JETFUEL_BEGIN_LOGIN_PATH { + t.Fatalf("request path = %s, want begin_login", req.URL.Path) + } + requestCount++ + form := readJetfuelTestForm(t, req) + switch requestCount { + case 1: + if got := form.Get("username_or_email"); got != "test-user" { + t.Fatalf("identifier username_or_email = %q", got) + } + if _, ok := form["password"]; ok { + t.Fatalf("identifier form unexpectedly contains password: %#v", form) + } + if got := form.Get("$castle_token"); got != "identifier-token" { + t.Fatalf("identifier Castle token = %q", got) + } + return jetfuelTestResponse("identifier accepted"), nil + case 2: + if got := form.Get("username_or_email"); got != "test-user" { + t.Fatalf("combined username_or_email = %q", got) + } + if got := form.Get("password"); got != "test-password" { + t.Fatalf("combined password = %q", got) + } + if got := form.Get("$castle_token"); got != "combined-token" { + t.Fatalf("combined Castle token = %q", got) + } + return jetfuelTestResponse("/home"), nil + default: + t.Fatalf("unexpected request %d", requestCount) + return nil, nil + } + })} + session := NewWebLoginSession(client) + session.backend = webLoginBackendJetfuel + session.jetfuel = &jetfuelLoginState{} + + result, err := session.SubmitCredentials(context.Background(), "test-user", "test-password") + if err != nil { + t.Fatalf("SubmitCredentials() error = %v", err) + } + if result == nil || result.Status != WebLoginStatusComplete { + t.Fatalf("SubmitCredentials() result = %#v, want complete", result) + } + if requestCount != 2 { + t.Fatalf("request count = %d, want 2", requestCount) + } +} + +func TestUnsupportedJetfuelResponseLoggingOmitsResponseValues(t *testing.T) { + t.Setenv("TWITTER_JETFUEL_VIEWER_CONTEXT", "0") + var logs bytes.Buffer + client := NewClient(cookies.NewCookies(nil), nil, zerolog.New(&logs).Level(zerolog.DebugLevel)) + client.SetNextJetfuelCastleTokens([]string{"castle-secret-marker"}) + client.HTTP = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return jetfuelTestResponse("response-secret-marker\x00opaque_field"), nil + })} + session := NewWebLoginSession(client) + session.backend = webLoginBackendJetfuel + session.jetfuel = &jetfuelLoginState{} + + result, err := session.SubmitIdentifier(context.Background(), "identifier-secret-marker") + if result != nil { + t.Fatalf("SubmitIdentifier() result = %#v, want nil", result) + } + if !errors.Is(err, ErrJetfuelIdentifierNoSupportedAction) { + t.Fatalf("SubmitIdentifier() error = %v, want ErrJetfuelIdentifierNoSupportedAction", err) + } + logged := logs.String() + for _, secret := range []string{"response-secret-marker", "opaque_field", "identifier-secret-marker", "castle-secret-marker"} { + if strings.Contains(logged, secret) { + t.Fatalf("sanitized diagnostics contain %q: %s", secret, logged) + } + } + for _, field := range []string{"\"stage\":\"identifier\"", "\"response_bytes\":", "\"string_count\":", "\"path_count\":", "\"field_count\":"} { + if !strings.Contains(logged, field) { + t.Fatalf("sanitized diagnostics missing %q: %s", field, logged) + } + } +} + +func TestSubmitJetfuelCombinedCredentialsReturnsPasswordAction(t *testing.T) { + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + client.SetNextJetfuelCastleTokens([]string{"combined-token"}) + client.HTTP = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path != "/i/jfapi"+endpoints.JETFUEL_BEGIN_LOGIN_PATH { + t.Fatalf("request path = %s, want begin_login", req.URL.Path) + } + form := readJetfuelTestForm(t, req) + if form.Get("username_or_email") != "test-user" || form.Get("password") != "test-password" { + t.Fatalf("combined form = %#v", form) + } + return jetfuelTestResponse(endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH + "\x00password"), nil + })} + session := NewWebLoginSession(client) + session.backend = webLoginBackendJetfuel + session.jetfuel = &jetfuelLoginState{} + + result, err := session.SubmitCombinedCredentials(context.Background(), "test-user", "test-password") + if err != nil { + t.Fatalf("SubmitCombinedCredentials() error = %v", err) + } + if result == nil || result.Status != WebLoginStatusNeedsPassword { + t.Fatalf("SubmitCombinedCredentials() result = %#v, want needs password", result) + } + if got := session.jetfuel.passwordAction; got != endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH { + t.Fatalf("password action = %q", got) + } +} + +func TestSubmitJetfuelPasswordAllowsOneBoundedReplay(t *testing.T) { + structuredActionlessBody := "/onboarding/web/actions/persist_login_state\x00opaque_field" + tests := []struct { + name string + replayBody string + terminalBody string + wantStatus WebLoginStatus + }{ + { + name: "explicit password action then complete", + replayBody: endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH + "\x00password", + terminalBody: "/home", + wantStatus: WebLoginStatusComplete, + }, + { + name: "explicit password action then verification challenge", + replayBody: endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH + "\x00password", + terminalBody: endpoints.JETFUEL_FINISH_TWO_FACTOR_AUTH_PATH + "\x00challenge_response\x00Enter your verification code", + wantStatus: WebLoginStatusNeedsText, + }, + { + name: "structured actionless response then complete", + replayBody: structuredActionlessBody, + terminalBody: "/home", + wantStatus: WebLoginStatusComplete, + }, + { + name: "structured actionless response then verification challenge", + replayBody: structuredActionlessBody, + terminalBody: endpoints.JETFUEL_FINISH_TWO_FACTOR_AUTH_PATH + "\x00challenge_response\x00Enter your verification code", + wantStatus: WebLoginStatusNeedsText, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + client.SetNextJetfuelCastleTokens([]string{"combined-token", "first-password-token", "replay-password-token"}) + + passwordRequestCount := 0 + client.HTTP = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + form := readJetfuelTestForm(t, req) + if req.URL.Path == "/i/jfapi"+endpoints.JETFUEL_BEGIN_LOGIN_PATH { + if form.Get("username_or_email") != "test-user" || form.Get("password") != "test-password" { + t.Fatalf("combined form = %#v", form) + } + if got := form.Get("$castle_token"); got != "combined-token" { + t.Fatalf("combined Castle token = %q", got) + } + return jetfuelTestResponse(endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH + "\x00password"), nil + } + if req.URL.Path != "/i/jfapi"+endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH { + t.Fatalf("request path = %s, want login_enter_password", req.URL.Path) + } + passwordRequestCount++ + if got := form.Get("password"); got != "test-password" { + t.Fatalf("password = %q", got) + } + wantToken := "first-password-token" + if passwordRequestCount == 2 { + wantToken = "replay-password-token" + } + if got := form.Get("$castle_token"); got != wantToken { + t.Fatalf("password request %d Castle token = %q, want %q", passwordRequestCount, got, wantToken) + } + if passwordRequestCount == 1 { + return jetfuelTestResponse(tc.replayBody), nil + } + return jetfuelTestResponse(tc.terminalBody), nil + })} + session := NewWebLoginSession(client) + session.backend = webLoginBackendJetfuel + session.jetfuel = &jetfuelLoginState{} + + result, err := session.SubmitCombinedCredentials(context.Background(), "test-user", "test-password") + if err != nil { + t.Fatalf("SubmitCombinedCredentials() error = %v", err) + } + if result == nil || result.Status != WebLoginStatusNeedsPassword { + t.Fatalf("SubmitCombinedCredentials() result = %#v, want needs password", result) + } + if session.jetfuel.passwordReplayUsed { + t.Fatal("combined password action incorrectly consumed the response-driven replay") + } + + result, err = session.SubmitPassword(context.Background(), "test-password") + if err != nil { + t.Fatalf("first SubmitPassword() error = %v", err) + } + if result == nil || result.Status != WebLoginStatusNeedsPassword { + t.Fatalf("first SubmitPassword() result = %#v, want needs password", result) + } + + result, err = session.SubmitPassword(context.Background(), "test-password") + if err != nil { + t.Fatalf("replay SubmitPassword() error = %v", err) + } + if result == nil || result.Status != tc.wantStatus { + t.Fatalf("replay SubmitPassword() result = %#v, want status %s", result, tc.wantStatus) + } + if passwordRequestCount != 2 { + t.Fatalf("password request count = %d, want 2", passwordRequestCount) + } + }) + } +} + +func TestSubmitJetfuelPasswordRejectsSecondResponseDrivenReplay(t *testing.T) { + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + client.SetNextJetfuelCastleTokens([]string{"combined-token", "first-password-token", "replay-password-token", "unused-token"}) + + passwordRequestCount := 0 + client.HTTP = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path == "/i/jfapi"+endpoints.JETFUEL_BEGIN_LOGIN_PATH { + return jetfuelTestResponse(endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH + "\x00password"), nil + } + if req.URL.Path != "/i/jfapi"+endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH { + t.Fatalf("request path = %s, want login_enter_password", req.URL.Path) + } + passwordRequestCount++ + return jetfuelTestResponse(endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH + "\x00password"), nil + })} + session := NewWebLoginSession(client) + session.backend = webLoginBackendJetfuel + session.jetfuel = &jetfuelLoginState{} + + result, err := session.SubmitCombinedCredentials(context.Background(), "test-user", "test-password") + if err != nil || result == nil || result.Status != WebLoginStatusNeedsPassword { + t.Fatalf("SubmitCombinedCredentials() result = %#v, error = %v", result, err) + } + + result, err = session.SubmitPassword(context.Background(), "test-password") + if err != nil || result == nil || result.Status != WebLoginStatusNeedsPassword { + t.Fatalf("first SubmitPassword() result = %#v, error = %v", result, err) + } + result, err = session.SubmitPassword(context.Background(), "test-password") + if result != nil { + t.Fatalf("replay SubmitPassword() result = %#v, want nil", result) + } + if !errors.Is(err, ErrWebLoginUnexpectedSubtask) { + t.Fatalf("replay SubmitPassword() error = %v, want ErrWebLoginUnexpectedSubtask", err) + } + if passwordRequestCount != 2 { + t.Fatalf("password request count = %d, want 2", passwordRequestCount) + } + if !client.HasNextJetfuelCastleToken() { + t.Fatal("third Castle token was consumed; password replay was not bounded") + } +} + +func TestSubmitJetfuelPasswordRejectsSecondStructuredActionlessResponse(t *testing.T) { + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + client.SetNextJetfuelCastleTokens([]string{"combined-token", "first-password-token", "replay-password-token", "unused-token"}) + + passwordRequestCount := 0 + client.HTTP = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path == "/i/jfapi"+endpoints.JETFUEL_BEGIN_LOGIN_PATH { + return jetfuelTestResponse(endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH + "\x00password"), nil + } + if req.URL.Path != "/i/jfapi"+endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH { + t.Fatalf("request path = %s, want login_enter_password", req.URL.Path) + } + passwordRequestCount++ + return jetfuelTestResponse("/onboarding/web/actions/persist_login_state\x00opaque_field"), nil + })} + session := NewWebLoginSession(client) + session.backend = webLoginBackendJetfuel + session.jetfuel = &jetfuelLoginState{} + + result, err := session.SubmitCombinedCredentials(context.Background(), "test-user", "test-password") + if err != nil || result == nil || result.Status != WebLoginStatusNeedsPassword { + t.Fatalf("SubmitCombinedCredentials() result = %#v, error = %v", result, err) + } + + result, err = session.SubmitPassword(context.Background(), "test-password") + if err != nil || result == nil || result.Status != WebLoginStatusNeedsPassword { + t.Fatalf("first SubmitPassword() result = %#v, error = %v", result, err) + } + result, err = session.SubmitPassword(context.Background(), "test-password") + if result != nil { + t.Fatalf("replay SubmitPassword() result = %#v, want nil", result) + } + if !errors.Is(err, ErrWebLoginUnexpectedSubtask) { + t.Fatalf("replay SubmitPassword() error = %v, want ErrWebLoginUnexpectedSubtask", err) + } + if passwordRequestCount != 2 { + t.Fatalf("password request count = %d, want 2", passwordRequestCount) + } + if !client.HasNextJetfuelCastleToken() { + t.Fatal("third Castle token was consumed; actionless password replay was not bounded") + } +} + +func TestSubmitJetfuelPasswordDoesNotReplayUnstructuredResponse(t *testing.T) { + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + client.SetNextJetfuelCastleTokens([]string{"password-token", "unused-token"}) + + passwordRequestCount := 0 + client.HTTP = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path != "/i/jfapi"+endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH { + t.Fatalf("request path = %s, want login_enter_password", req.URL.Path) + } + passwordRequestCount++ + return jetfuelTestResponse("opaque response with no action path"), nil + })} + session := NewWebLoginSession(client) + session.backend = webLoginBackendJetfuel + session.jetfuel = &jetfuelLoginState{ + identifier: "test-user", + passwordAction: endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH, + } + + result, err := session.SubmitPassword(context.Background(), "test-password") + if result != nil { + t.Fatalf("SubmitPassword() result = %#v, want nil", result) + } + if !errors.Is(err, ErrWebLoginUnexpectedSubtask) { + t.Fatalf("SubmitPassword() error = %v, want ErrWebLoginUnexpectedSubtask", err) + } + if session.jetfuel.passwordReplayUsed { + t.Fatal("unstructured response incorrectly consumed the password replay") + } + if passwordRequestCount != 1 { + t.Fatalf("password request count = %d, want 1", passwordRequestCount) + } + if !client.HasNextJetfuelCastleToken() { + t.Fatal("unused Castle token was consumed after an unstructured response") + } +} + +func TestSubmitJetfuelPasswordDefersTwoFactorPreludeUntilNextCastleToken(t *testing.T) { + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + client.SetNextJetfuelCastleTokens([]string{"password-castle-token"}) + + var paths []string + client.HTTP = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("ReadAll(request body) error = %v", err) + } + paths = append(paths, req.URL.Path) + form := string(body) + switch req.URL.Path { + case "/i/jfapi" + endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH: + if !strings.Contains(form, "%24castle_token=password-castle-token") { + t.Fatalf("password request body = %q, want password Castle token", form) + } + responseBody := "begin_two_factor_auth\x00session_token\x0012345678-1234-1234-1234-123456789abc\x00prelude_dispatch_id\x00abcdefab-1234-1234-1234-abcdefabcdef" + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + case "/i/jfapi" + endpoints.JETFUEL_BEGIN_TWO_FACTOR_AUTH_PATH: + if !strings.Contains(form, "%24castle_token=twofactor-castle-token") { + t.Fatalf("two-factor request body = %q, want second Castle token", form) + } + if !strings.Contains(form, "session_token=12345678-1234-1234-1234-123456789abc") { + t.Fatalf("two-factor request body = %q, want session token", form) + } + responseBody := "Select a method to authenticate\x00two_factor_method\x00Totp\x00BackupCode" + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + default: + t.Fatalf("unexpected request path: %s", req.URL.Path) + return nil, nil + } + })} + session := NewWebLoginSession(client) + session.backend = webLoginBackendJetfuel + session.jetfuel = &jetfuelLoginState{ + identifier: "test-user", + passwordAction: endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH, + } + + result, err := session.SubmitPassword(context.Background(), "password") + if !errors.Is(err, ErrJetfuelCastleTokenRequired) { + t.Fatalf("SubmitPassword() error = %v, want ErrJetfuelCastleTokenRequired", err) + } + if result != nil { + t.Fatalf("SubmitPassword() result = %#v, want nil while waiting for next Castle token", result) + } + if got := session.jetfuel.twoFactorAction; got != endpoints.JETFUEL_BEGIN_TWO_FACTOR_AUTH_PATH { + t.Fatalf("twoFactorAction = %q, want begin two-factor action", got) + } + if len(paths) != 1 || paths[0] != "/i/jfapi"+endpoints.JETFUEL_LOGIN_ENTER_PASSWORD_PATH { + t.Fatalf("paths after password = %#v, want only password request", paths) + } + + client.SetNextJetfuelCastleTokens([]string{"twofactor-castle-token"}) + result, err = session.SubmitPendingTwoFactor(context.Background()) + if err != nil { + t.Fatalf("SubmitPendingTwoFactor() error = %v", err) + } + if result == nil || result.Status != WebLoginStatusNeedsAuthMethod { + t.Fatalf("SubmitPendingTwoFactor() result = %#v, want auth method chooser", result) + } + if len(paths) != 2 || paths[1] != "/i/jfapi"+endpoints.JETFUEL_BEGIN_TWO_FACTOR_AUTH_PATH { + t.Fatalf("paths after pending two-factor = %#v", paths) + } +} + +func TestSubmitJetfuelAuthMethodPrefersVerificationChallenge(t *testing.T) { + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + client.SetNextJetfuelCastleTokens([]string{"castle-from-webview"}) + client.HTTP = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path != "/i/jfapi"+endpoints.JETFUEL_BEGIN_TWO_FACTOR_AUTH_PATH { + t.Fatalf("request path = %s", req.URL.Path) + } + if got := req.Header.Get("x-jf-client-theme"); got != jetfuelHeaderTheme { + t.Fatalf("x-jf-client-theme = %q, want %q", got, jetfuelHeaderTheme) + } + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("ReadAll(request body) error = %v", err) + } + form := string(body) + if !strings.Contains(form, "two_factor_auth_method_type=Totp") { + t.Fatalf("request body = %q, want Totp method", form) + } + if !strings.Contains(form, "%24castle_token=castle-from-webview") { + t.Fatalf("request body = %q, want webview Castle token", form) + } + responseBody := "Select a method to authenticate\x00two_factor_method\x00Totp\x00BackupCode\x00U2fSecurityKey\x00" + + "Enter the code from your authentication app.\x00challenge_response\x00finish_two_factor_auth\x00session_token" + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + })} + session := NewWebLoginSession(client) + session.backend = webLoginBackendJetfuel + session.jetfuel = &jetfuelLoginState{ + sessionToken: "session-token", + twoFactorAction: endpoints.JETFUEL_BEGIN_TWO_FACTOR_AUTH_PATH, + twoFactorMethods: []WebLoginAuthMethod{ + {ID: "Totp", Name: "Authenticator App", Kind: WebLoginAuthMethodKindCode, Supported: true}, + {ID: "BackupCode", Name: "Backup Code", Kind: WebLoginAuthMethodKindBackupCode, Supported: true, Index: 1}, + {ID: "U2fSecurityKey", Name: "Security Key PC", Kind: WebLoginAuthMethodKindUnknown, Supported: false, Index: 2}, + }, + } + + result, err := session.SubmitAuthMethod(context.Background(), "Authenticator App") + if err != nil { + t.Fatalf("SubmitAuthMethod() error = %v", err) + } + if result.Status != WebLoginStatusNeedsText { + t.Fatalf("SubmitAuthMethod() status = %s, want %s", result.Status, WebLoginStatusNeedsText) + } + if result.Challenge == nil || result.Challenge.Description != "Enter the code from your authentication app." { + t.Fatalf("Challenge = %#v, want authenticator app code prompt", result.Challenge) + } + if session.jetfuel.verificationAction != endpoints.JETFUEL_FINISH_TWO_FACTOR_AUTH_PATH { + t.Fatalf("verificationAction = %q", session.jetfuel.verificationAction) + } +} + +func TestSubmitJetfuelSMSAuthMethodReturnsPhoneChallenge(t *testing.T) { + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + client.SetNextJetfuelCastleTokens([]string{"sms-method-castle-token"}) + client.HTTP = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path != "/i/jfapi"+endpoints.JETFUEL_BEGIN_TWO_FACTOR_AUTH_PATH { + t.Fatalf("request path = %s", req.URL.Path) + } + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("ReadAll(request body) error = %v", err) + } + form, err := url.ParseQuery(string(body)) + if err != nil { + t.Fatalf("ParseQuery(request body) error = %v", err) + } + if got := form.Get("two_factor_auth_method_type"); got != "Sms" { + t.Fatalf("two_factor_auth_method_type = %q, want Sms", got) + } + if got := form.Get("_selected_method_idx"); got != "1" { + t.Fatalf("_selected_method_idx = %q, want 1", got) + } + responseBody := "We sent a text message with a verification code to your phone.\x00challenge_response\x00finish_two_factor_auth\x00session_token" + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + })} + session := NewWebLoginSession(client) + session.backend = webLoginBackendJetfuel + session.jetfuel = &jetfuelLoginState{ + sessionToken: "session-token", + twoFactorAction: endpoints.JETFUEL_BEGIN_TWO_FACTOR_AUTH_PATH, + twoFactorMethods: []WebLoginAuthMethod{ + {ID: "Totp", Name: "Authenticator App", Kind: WebLoginAuthMethodKindCode, Supported: true}, + {ID: "Sms", Name: "Text Message", Kind: WebLoginAuthMethodKindSMS, Supported: true, Index: 1}, + {ID: "BackupCode", Name: "Backup Code", Kind: WebLoginAuthMethodKindBackupCode, Supported: true, Index: 2}, + }, + } + + result, err := session.SubmitAuthMethod(context.Background(), "Text Message") + if err != nil { + t.Fatalf("SubmitAuthMethod() error = %v", err) + } + if result.Status != WebLoginStatusNeedsText { + t.Fatalf("SubmitAuthMethod() status = %s, want %s", result.Status, WebLoginStatusNeedsText) + } + if result.Challenge == nil || result.Challenge.Description != "Enter the code sent to your phone number." { + t.Fatalf("Challenge = %#v, want phone code prompt", result.Challenge) + } + if session.jetfuel.verificationAction != endpoints.JETFUEL_FINISH_TWO_FACTOR_AUTH_PATH { + t.Fatalf("verificationAction = %q", session.jetfuel.verificationAction) + } +} + +func TestSubmitJetfuelSMSAuthMethodDefaultsActionForPhoneChallenge(t *testing.T) { + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + client.SetNextJetfuelCastleTokens([]string{"sms-phone-challenge-castle-token"}) + client.HTTP = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path != "/i/jfapi"+endpoints.JETFUEL_BEGIN_TWO_FACTOR_AUTH_PATH { + t.Fatalf("request path = %s", req.URL.Path) + } + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("ReadAll(request body) error = %v", err) + } + form, err := url.ParseQuery(string(body)) + if err != nil { + t.Fatalf("ParseQuery(request body) error = %v", err) + } + if got := form.Get("two_factor_auth_method_type"); got != "Text" { + t.Fatalf("two_factor_auth_method_type = %q, want Text", got) + } + responseBody := "Confirm the phone number associated with your X account.\x00phone_number\x00session_token" + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(responseBody)), + }, nil + })} + session := NewWebLoginSession(client) + session.backend = webLoginBackendJetfuel + session.jetfuel = &jetfuelLoginState{ + sessionToken: "session-token", + twoFactorAction: endpoints.JETFUEL_BEGIN_TWO_FACTOR_AUTH_PATH, + twoFactorMethods: []WebLoginAuthMethod{ + {ID: "Totp", Name: "Authenticator App", Kind: WebLoginAuthMethodKindCode, Supported: true}, + {ID: "Sms", SubmitID: "Text", Name: "Text Message", Kind: WebLoginAuthMethodKindSMS, Supported: true, Index: 1}, + {ID: "BackupCode", Name: "Backup Code", Kind: WebLoginAuthMethodKindBackupCode, Supported: true, Index: 2}, + }, + } + + result, err := session.SubmitAuthMethod(context.Background(), "Text Message") + if err != nil { + t.Fatalf("SubmitAuthMethod() error = %v", err) + } + if result.Status != WebLoginStatusNeedsText { + t.Fatalf("SubmitAuthMethod() status = %s, want %s", result.Status, WebLoginStatusNeedsText) + } + if result.Challenge == nil || result.Challenge.InputKind != WebLoginChallengeInputKindPhoneNumber { + t.Fatalf("Challenge = %#v, want phone-number prompt", result.Challenge) + } + if session.jetfuel.verificationAction != endpoints.JETFUEL_FINISH_TWO_FACTOR_AUTH_PATH { + t.Fatalf("verificationAction = %q", session.jetfuel.verificationAction) + } +} + +func TestSubmitJetfuelPhoneNumberVerificationPostsPhoneField(t *testing.T) { + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + client.SetNextJetfuelCastleTokens([]string{"phone-number-castle-token"}) + client.HTTP = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path != "/i/jfapi"+endpoints.JETFUEL_FINISH_TWO_FACTOR_AUTH_PATH { + t.Fatalf("request path = %s", req.URL.Path) + } + body, err := io.ReadAll(req.Body) + if err != nil { + t.Fatalf("ReadAll(request body) error = %v", err) + } + form, err := url.ParseQuery(string(body)) + if err != nil { + t.Fatalf("ParseQuery(request body) error = %v", err) + } + if got := form.Get("phone_number"); got != "+15551234567" { + t.Fatalf("phone_number = %q, want test phone number", got) + } + if got := form.Get("challenge_response"); got != "" { + t.Fatalf("challenge_response = %q, want omitted when phone_number is known", got) + } + if got := form.Get("verification_code"); got != "" { + t.Fatalf("verification_code = %q, want omitted when phone_number is known", got) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("/home")), + }, nil + })} + session := NewWebLoginSession(client) + session.backend = webLoginBackendJetfuel + session.jetfuel = &jetfuelLoginState{ + verificationAction: endpoints.JETFUEL_FINISH_TWO_FACTOR_AUTH_PATH, + verificationFields: []string{"phone_number"}, + } + + result, err := session.SubmitText(context.Background(), "+15551234567") + if err != nil { + t.Fatalf("SubmitText() error = %v", err) + } + if result.Status != WebLoginStatusComplete { + t.Fatalf("SubmitText() status = %s, want %s", result.Status, WebLoginStatusComplete) + } +} + +func TestJetfuelAuthMethodChoiceWithOnlyUnsupportedMethod(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte("Select a method to authenticate\x00two_factor_method\x00U2fSecurityKey\x00begin_two_factor_auth")) + session := &WebLoginSession{jetfuel: &jetfuelLoginState{}} + + result := session.jetfuelAuthMethodChoiceResult(parsed) + if result == nil { + t.Fatal("jetfuelAuthMethodChoiceResult() returned nil") + } + if result.Status != WebLoginStatusUnsupported { + t.Fatalf("Status = %s, want %s", result.Status, WebLoginStatusUnsupported) + } + if len(session.jetfuel.twoFactorMethods) != 1 || session.jetfuel.twoFactorMethods[0].Supported { + t.Fatalf("twoFactorMethods = %#v, want one unsupported method", session.jetfuel.twoFactorMethods) + } +} + +func TestJetfuelAuthMethodChoiceWithOnlySMSIsComingSoon(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte("Select a method to authenticate\x00two_factor_method\x00Text\x00begin_two_factor_auth")) + session := &WebLoginSession{jetfuel: &jetfuelLoginState{}} + + result := session.jetfuelAuthMethodChoiceResult(parsed) + if result == nil { + t.Fatal("jetfuelAuthMethodChoiceResult() returned nil") + } + if result.Status != WebLoginStatusUnsupported { + t.Fatalf("Status = %s, want %s", result.Status, WebLoginStatusUnsupported) + } + if len(session.jetfuel.twoFactorMethods) != 1 { + t.Fatalf("twoFactorMethods length = %d, want 1: %#v", len(session.jetfuel.twoFactorMethods), session.jetfuel.twoFactorMethods) + } + method := session.jetfuel.twoFactorMethods[0] + if method.ID != "Sms" || method.Supported || method.Description != "Text message verification is coming soon." { + t.Fatalf("SMS method = %#v, want coming-soon unsupported method", method) + } + if result.Challenge == nil || result.Challenge.Description != "Text message verification is coming soon." { + t.Fatalf("Challenge = %#v, want coming-soon description", result.Challenge) + } +} + +func TestSubmitJetfuelAuthMethodRejectsUnsupportedMethod(t *testing.T) { + session := &WebLoginSession{ + backend: webLoginBackendJetfuel, + jetfuel: &jetfuelLoginState{ + twoFactorMethods: []WebLoginAuthMethod{{ + ID: "U2fSecurityKey", + Name: "Security Key PC", + Supported: false, + }}, + }, + } + + _, err := session.SubmitAuthMethod(context.Background(), "U2fSecurityKey") + if !errors.Is(err, ErrWebLoginUnsupportedAuthMethod) { + t.Fatalf("SubmitAuthMethod() error = %v, want ErrWebLoginUnsupportedAuthMethod", err) + } +} + +func TestJetfuelLoginResponseClassifiesTemporaryLimit(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte("We've temporarily limited your login. Please try again later.")) + err := parsed.loginError() + if err == nil { + t.Fatal("loginError() returned nil") + } + webErr, ok := err.(*WebLoginError) + if !ok { + t.Fatalf("loginError() = %T, want *WebLoginError", err) + } + if webErr.Code != 399 { + t.Fatalf("WebLoginError.Code = %d, want 399", webErr.Code) + } +} + +func TestJetfuelLoginResponseClassifiesTooManyAttempts(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte("errors.Too many attempts. Try again in a few minutes.\x00message.Too many attempts. Try again in a few minutes.")) + err := parsed.loginError() + if err == nil { + t.Fatal("loginError() returned nil") + } + webErr, ok := err.(*WebLoginError) + if !ok { + t.Fatalf("loginError() = %T, want *WebLoginError", err) + } + if webErr.Code != 399 { + t.Fatalf("WebLoginError.Code = %d, want 399", webErr.Code) + } +} + +func TestJetfuelLoginResponseClassifiesOfficialClientError(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte("Please use X.com or official X apps to proceed with log in/sign up.")) + err := parsed.loginError() + if err == nil { + t.Fatal("loginError() returned nil") + } + webErr, ok := err.(*WebLoginError) + if !ok { + t.Fatalf("loginError() = %T, want *WebLoginError", err) + } + if webErr.Code != 399 { + t.Fatalf("WebLoginError.Code = %d, want 399", webErr.Code) + } + if !strings.Contains(webErr.Message, "official X apps") { + t.Fatalf("WebLoginError.Message = %q", webErr.Message) + } +} + +func TestJetfuelLoginResponseClassifiesMissingAccount(t *testing.T) { + parsed := parseJetfuelLoginResponse([]byte("missing_account This email or username is not registered yet")) + err := parsed.loginError() + if err == nil { + t.Fatal("loginError() returned nil") + } + webErr, ok := err.(*WebLoginError) + if !ok { + t.Fatalf("loginError() = %T, want *WebLoginError", err) + } + if webErr.Code != 32 { + t.Fatalf("WebLoginError.Code = %d, want 32", webErr.Code) + } +} + +func TestJetfuelLoginResponseClassifiesBadCredentials(t *testing.T) { + tests := []string{ + "Wrong password", + "The password you entered is incorrect.", + "The username and password you entered did not match our records.", + "Invalid username or password", + "Invalid credentials", + } + for _, body := range tests { + parsed := parseJetfuelLoginResponse([]byte(body)) + err := parsed.loginError() + if err == nil { + t.Fatalf("loginError(%q) returned nil", body) + } + webErr, ok := err.(*WebLoginError) + if !ok { + t.Fatalf("loginError(%q) = %T, want *WebLoginError", body, err) + } + if webErr.Code != 32 { + t.Fatalf("WebLoginError.Code for %q = %d, want 32", body, webErr.Code) + } + } +} +func TestJetfuelTimezoneEnvOverride(t *testing.T) { + t.Setenv("TWITTER_JETFUEL_TIMEZONE", "Europe/Paris") + if got := jetfuelTimezone(); got != "Europe/Paris" { + t.Fatalf("jetfuelTimezone() = %q, want Europe/Paris", got) + } +} diff --git a/pkg/twittermeow/methods/html.go b/pkg/twittermeow/methods/html.go index d6e88b2a..29b07a38 100644 --- a/pkg/twittermeow/methods/html.go +++ b/pkg/twittermeow/methods/html.go @@ -1,7 +1,9 @@ package methods import ( + "net/http" "regexp" + "strconv" "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/payload" ) @@ -11,10 +13,14 @@ var ( migrateFormDataRegex = regexp.MustCompile(`]* action="([^"]+)"[^>]*>[\s\S]*?]* name="tok" value="([^"]+)"[^>]*>[\s\S]*?]* name="data" value="([^"]+)"[^>]*>`) mainScriptURLRegex = regexp.MustCompile(`https:\/\/(?:[A-Za-z0-9.-]+)\/responsive-web\/client-web\/main\.[0-9A-Za-z]+\.js`) bearerTokenRegex = regexp.MustCompile(`Bearer\s[A-Za-z0-9%]{16,}`) + documentCookieRegex = regexp.MustCompile(`document\.cookie\s*=\s*("(?:\\.|[^"\\])*")`) + cloudflareJSDRegex = regexp.MustCompile(`]+src=["']([^"']*/cdn-cgi/challenge-platform/[^"']*api\.js[^"']*)["']`) guestTokenRegex = regexp.MustCompile(`gt=([0-9]+)`) verificationTokenRegex = regexp.MustCompile(`meta name="twitter-site-verification" content="([^"]+)"`) countryCodeRegex = regexp.MustCompile(`"country":\s*"([A-Z]{2})"`) ondemandSChunkIDRegex = regexp.MustCompile(`(\d+):"ondemand\.s"`) + ondemandCastleIDRegex = regexp.MustCompile(`(\d+):"ondemand\.castle"`) + castlePublicKeyRegex = regexp.MustCompile(`"responsive_web_castle_public_key"\s*:\s*\{\s*"value"\s*:\s*"([^"]+)"`) variableIndexesRegex = regexp.MustCompile(`\[.+?\(\w{1,2}\[(\d{1,2})],16\).+?\(\w{1,2}\[(\d{1,2})],16\).+?\(\w{1,2}\[(\d{1,2})],16\).+?\(\w{1,2}\[(\d{1,2})],16\)`) ) @@ -59,6 +65,36 @@ func ParseGuestToken(html string) string { return match[1] } +func ParseDocumentCookieAssignments(html string) map[string]string { + matches := documentCookieRegex.FindAllStringSubmatch(html, -1) + if len(matches) == 0 { + return nil + } + out := make(map[string]string) + for _, match := range matches { + if len(match) < 2 { + continue + } + cookie, err := strconv.Unquote(match[1]) + if err != nil || cookie == "" { + continue + } + parsedCookie, err := http.ParseSetCookie(cookie) + if err == nil { + out[parsedCookie.Name] = parsedCookie.Value + } + } + return out +} + +func ParseCloudflareJSDURL(html string) string { + match := cloudflareJSDRegex.FindStringSubmatch(html) + if len(match) < 2 { + return "" + } + return match[1] +} + func ParseVerificationToken(html string) string { match := verificationTokenRegex.FindStringSubmatch(html) if len(match) < 1 { @@ -76,7 +112,15 @@ func ParseCountry(html string) string { } func ParseOndemandSURLFromScript(js []byte) string { - chunkIDMatch := ondemandSChunkIDRegex.FindSubmatchIndex(js) + return parseOndemandChunkURLFromScript(js, "ondemand.s", ondemandSChunkIDRegex) +} + +func ParseOndemandCastleURLFromScript(js []byte) string { + return parseOndemandChunkURLFromScript(js, "ondemand.castle", ondemandCastleIDRegex) +} + +func parseOndemandChunkURLFromScript(js []byte, chunkName string, chunkIDRegex *regexp.Regexp) string { + chunkIDMatch := chunkIDRegex.FindSubmatchIndex(js) if len(chunkIDMatch) < 4 { return "" } @@ -90,5 +134,13 @@ func ParseOndemandSURLFromScript(js []byte) string { } hash := string(jsAfterNameMap[hashMatch[2]:hashMatch[3]]) - return "https://abs.twimg.com/responsive-web/client-web/ondemand.s." + hash + "a.js" + return "https://abs.twimg.com/responsive-web/client-web/" + chunkName + "." + hash + "a.js" +} + +func ParseResponsiveWebCastlePublicKey(html string) string { + match := castlePublicKeyRegex.FindStringSubmatch(html) + if len(match) < 2 { + return "" + } + return match[1] } diff --git a/pkg/twittermeow/methods/html_test.go b/pkg/twittermeow/methods/html_test.go index 8385d34a..65b68648 100644 --- a/pkg/twittermeow/methods/html_test.go +++ b/pkg/twittermeow/methods/html_test.go @@ -3,10 +3,46 @@ package methods import ( "io" "net/http" + "strings" "testing" "time" ) +func TestParseDocumentCookieAssignments(t *testing.T) { + html := `` + + cookies := ParseDocumentCookieAssignments(html) + if got := cookies["ct0"]; got != "csrf-value" { + t.Fatalf("ct0 = %q, want csrf-value", got) + } + if got := cookies["guest_id"]; got != "guest-value" { + t.Fatalf("guest_id = %q, want guest-value", got) + } + if got := cookies["Path"]; got != "" { + t.Fatalf("Path pseudo-cookie = %q, want omitted", got) + } +} + +func TestParseCastleBootstrapInfo(t *testing.T) { + html := `{"responsive_web_castle_public_key":{"value":"castle-public-key"},"lang":"en"}` + if got := ParseResponsiveWebCastlePublicKey(html); got != "castle-public-key" { + t.Fatalf("ParseResponsiveWebCastlePublicKey() = %q", got) + } + + js := []byte(`{100:"bundle.Home",15793:"ondemand.castle",16000:"other"};{100:"abc1234",15793:"1ff15ff",16000:"def5678"}`) + gotURL := ParseOndemandCastleURLFromScript(js) + wantURL := "https://abs.twimg.com/responsive-web/client-web/ondemand.castle.1ff15ffa.js" + if gotURL != wantURL { + t.Fatalf("ParseOndemandCastleURLFromScript() = %q, want %q", gotURL, wantURL) + } + if strings.Contains(gotURL, "ondemand.s") { + t.Fatalf("ParseOndemandCastleURLFromScript() = %q, want Castle chunk URL", gotURL) + } +} + func TestParseOndemandSURLFromScript(t *testing.T) { client := &http.Client{Timeout: 20 * time.Second} req, err := http.NewRequest(http.MethodGet, "https://x.com/", nil) diff --git a/pkg/twittermeow/xchat_send.go b/pkg/twittermeow/xchat_send.go index 198d9b03..3f8d94bd 100644 --- a/pkg/twittermeow/xchat_send.go +++ b/pkg/twittermeow/xchat_send.go @@ -324,13 +324,20 @@ func (c *Client) RefreshConversationKeys(ctx context.Context, conversationID str } // Notify callback to sync room data (members, name, avatar, etc.) - if c.onConversationDataRefresh != nil { - c.onConversationDataRefresh(ctx, conversationID, &item) - } + c.notifyConversationDataRefresh(ctx, conversationID, item) return nil } +func (c *Client) notifyConversationDataRefresh(ctx context.Context, conversationID string, item response.XChatInboxItem) { + callback := c.onConversationDataRefresh + if callback == nil { + return + } + // Room refresh may queue on the portal currently converting this message, so never wait inline. + go callback(ctx, conversationID, &item) +} + func (c *Client) processKeyChangeEventsFromItem(ctx context.Context, conversationID string, item *response.XChatInboxItem) error { if item == nil || len(item.LatestConversationKeyChangeEvents) == 0 { return nil diff --git a/pkg/twittermeow/xchat_send_test.go b/pkg/twittermeow/xchat_send_test.go new file mode 100644 index 00000000..79370b30 --- /dev/null +++ b/pkg/twittermeow/xchat_send_test.go @@ -0,0 +1,61 @@ +package twittermeow + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + + "go.mau.fi/mautrix-twitter/pkg/twittermeow/cookies" + "go.mau.fi/mautrix-twitter/pkg/twittermeow/data/response" +) + +func TestRefreshConversationKeysDoesNotBlockOnConversationDataCallback(t *testing.T) { + client := NewClient(cookies.NewCookies(nil), nil, zerolog.Nop()) + client.HTTP = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `{"data":{"get_inbox_page_conversation_data":{"data":{"conversation_detail":{"conversation_id":"1:2"}}}}}`, + )), + }, nil + })} + + callbackStarted := make(chan struct{}) + callbackRelease := make(chan struct{}) + client.SetConversationDataCallback(func(context.Context, string, *response.XChatInboxItem) { + close(callbackStarted) + <-callbackRelease + }) + + refreshDone := make(chan error, 1) + go func() { + refreshDone <- client.RefreshConversationKeys(context.Background(), "1:2") + }() + + select { + case <-callbackStarted: + case <-time.After(time.Second): + t.Fatal("conversation data callback was not invoked") + } + + select { + case err := <-refreshDone: + if err != nil { + t.Fatalf("RefreshConversationKeys() error = %v", err) + } + close(callbackRelease) + case <-time.After(250 * time.Millisecond): + close(callbackRelease) + err := <-refreshDone + if err != nil { + t.Fatalf("RefreshConversationKeys() error after releasing callback = %v", err) + } + t.Fatal("RefreshConversationKeys blocked on the conversation data callback") + } +}