Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions pkg/messagix/bloks/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,27 @@ var selectedWhatsAppNumber = flag.String("whatsapp-number", "", "Pick a WhatsApp
var cancelPasskey = flag.Bool("cancel-passkey", false, "Tap the 'try another way' passkey button")
var isAndroid = flag.Bool("android", false, "Use Android instead of iOS")
var doPageFromAction = flag.Bool("new-page", false, "Act on new page rendered by action")
var exchangeToken = flag.String("exchange-token", "", "Exchange an access token for session cookies (auth/create_session_for_app)")
var machineID = flag.String("machine-id", "", "Machine ID to send with -exchange-token")
var sessionEndpoint = flag.String("session-endpoint", "", "Override the create_session_for_app endpoint")
var newAppID = flag.String("new-app-id", "", "Request the session for this app instead of our own")
var appAuth = flag.Bool("app-auth", false, "Send the first-party app authorization header with -exchange-token")
var sweepAppIDs = flag.Bool("sweep-app-ids", false, "Try -exchange-token against every known first-party app ID")

// Known first-party app IDs, for finding one that the session exchange accepts.
var knownAppIDs = []struct{ ID, Name string }{
{"", "ourselves"},
{"350685531728", "Facebook Android"},
{"6628568379", "Facebook iPhone"},
{"275254692598279", "Facebook Lite"},
{"256002347743983", "Messenger Android"},
{"237759909591655", "Messenger iPhone"},
{"200424423651082", "Messenger Lite"},
{"437626316973788", "Messenger Lite iOS"},
{"165907476854626", "Pages Manager iOS"},
{"121876164619130", "Pages Manager Android"},
{"124024574287414", "Instagram"},
}

func main() {
err := mainE()
Expand Down Expand Up @@ -125,6 +146,42 @@ func mainE() error {
fmt.Println(enc)
return nil
}
if *exchangeToken != "" {
cl := messagix.NewClient(&cookies.Cookies{
Platform: plat,
}, log, &messagix.Config{
ClientSettings: exhttp.SensibleClientSettings,
})
if *deviceID != "" {
cl.MessengerLite.SetDeviceIdentifiers(uuid.MustParse(*deviceID))
}
cl.MessengerLite.SetMachineID(*machineID)

apps := []struct{ ID, Name string }{{*newAppID, "requested app"}}
if *sweepAppIDs {
apps = knownAppIDs
}
for _, app := range apps {
resp, err := cl.MessengerLite.GetSessionForApp(ctx, *exchangeToken, messagix.SessionForAppOptions{
Endpoint: *sessionEndpoint,
NewAppID: app.ID,
AppAuth: *appAuth,
})
if err != nil {
fmt.Printf("%s %s: %v\n", app.ID, app.Name, err)
continue
}
cookieNames := make([]string, len(resp.SessionCookies))
for i, cookie := range resp.SessionCookies {
cookieNames[i] = cookie.Name
}
fmt.Printf("%s %s: uid=%s cookies=[%s]\n", app.ID, app.Name, resp.UID, strings.Join(cookieNames, " "))
// The token may well be single use, so don't keep going once
// something has worked.
break
}
return nil
}
if *doRPC != "" {
if *rpcParams == "" {
return fmt.Errorf("-rpc-params is mandatory when using -rpc")
Expand Down
1 change: 1 addition & 0 deletions pkg/messagix/endpoints/facebook.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ func makeMessengerLiteEndpoints(host string, graph string, doubleSlash bool) map
endpoints := makeFacebookEndpoints(host)
endpoints["graph_graphql"] = fmt.Sprintf("https://%s/graphql", graph)
endpoints["pwd_key"] = fmt.Sprintf("https://%s%spwd_key_fetch", graph, slash)
endpoints["session_for_app"] = fmt.Sprintf("https://%s/auth/create_session_for_app", graph)
endpoints["v2.10"] = "https://graph.facebook.com/v2.10"
endpoints["cat"] = "https://web.facebook.com/messaging/lightspeed/cat"
endpoints["icdc_fetch"] = "https://v.whatsapp.net/v2/fb_icdc_fetch"
Expand Down
7 changes: 4 additions & 3 deletions pkg/messagix/httpclient/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -406,14 +406,14 @@ func (c *HTTPClient) makeRequest(
Str("method", method).
Dur("duration", dur).
Msg("Request failed, giving up")
return resp, nil, fmt.Errorf("%w: %w", ErrMaxRetriesReached, err)
return resp, respDat, fmt.Errorf("%w: %w", ErrMaxRetriesReached, err)
} else if IsPermanentRequestError(err) || (resp != nil && resp.StatusCode < 500 && resp.StatusCode != 429) || ctx.Err() != nil {
logContext(c.log.Err(err)).
Str("url", url).
Str("method", method).
Dur("duration", dur).
Msg("Request failed, cannot be retried")
return resp, nil, err
return resp, respDat, err
}
backoff := time.Duration(attempts) * 3 * time.Second
if resp != nil && resp.StatusCode == 429 {
Expand Down Expand Up @@ -462,7 +462,8 @@ func (c *HTTPClient) makeRequestDirect(ctx context.Context, url string, method s
}

if response.StatusCode >= 400 {
return response, nil, fmt.Errorf("%w %d", ErrUnexpectedError, response.StatusCode)
// Return the body as well: some APIs only explain what went wrong there.
return response, responseBody, fmt.Errorf("%w %d", ErrUnexpectedError, response.StatusCode)
}

return response, responseBody, nil
Expand Down
206 changes: 179 additions & 27 deletions pkg/messagix/messengerlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"context"
"encoding/json"
"fmt"
"maps"
"net/http"
"net/url"
"slices"
"strings"

"github.com/google/uuid"
Expand All @@ -21,6 +23,7 @@ import (

var (
ErrLoginMissingCookies = bridgev2.RespError{ErrCode: "FI.MAU.META_MISSING_COOKIES", Err: "Meta returned incomplete credentials after login. If you don't have MFA enabled, please turn it on for your Facebook account. Otherwise, it may help to try again, log in from the official app/website first, or change the MFA settings for your Facebook account"}
ErrLoginTokenExchange = bridgev2.RespError{ErrCode: "FI.MAU.META_TOKEN_EXCHANGE_FAILED", Err: "Meta returned a temporary credential after login which could not be exchanged for a usable session. It may help to try again, or to log in from the official app/website first"}
)

type MessengerLiteMethods struct {
Expand Down Expand Up @@ -64,25 +67,42 @@ func (fb *MessengerLiteMethods) getBrowserConfig() *bloks.BrowserConfig {
}
}

func (c *Client) FetchLightspeedKey(ctx context.Context) (*LightspeedKeyResponse, error) {
endpoint := c.GetEndpoint("pwd_key")
// messengerLiteApp is the first-party app that we pretend to be when making
// non-Bloks messenger lite requests.
type messengerLiteApp struct {
AppID string
AccessToken string
UserAgent string
}

var accessToken, appID, userAgent string
func (c *Client) getMessengerLiteApp() (*messengerLiteApp, error) {
switch c.Platform {
case types.MessengerLiteIOS:
accessToken = useragent.MessengerLiteIOSAccessToken
appID = useragent.MessengerLiteIOSAppID
userAgent = useragent.MessengerLiteIOSUserAgent
return &messengerLiteApp{
AppID: useragent.MessengerLiteIOSAppID,
AccessToken: useragent.MessengerLiteIOSAccessToken,
UserAgent: useragent.MessengerLiteIOSUserAgent,
}, nil
case types.MessengerLiteAndroid:
accessToken = useragent.MessengerLiteAndroidAccessToken
appID = useragent.MessengerLiteAndroidAppID
userAgent = useragent.MessengerLiteAndroidUserAgent
return &messengerLiteApp{
AppID: useragent.MessengerLiteAndroidAppID,
AccessToken: useragent.MessengerLiteAndroidAccessToken,
UserAgent: useragent.MessengerLiteAndroidUserAgent,
}, nil
default:
return nil, fmt.Errorf("platform %s does not support lightspeed key fetch", c.Platform.String())
return nil, fmt.Errorf("platform %s does not support messenger lite auth requests", c.Platform.String())
}
}

func (c *Client) FetchLightspeedKey(ctx context.Context) (*LightspeedKeyResponse, error) {
endpoint := c.GetEndpoint("pwd_key")

app, err := c.getMessengerLiteApp()
if err != nil {
return nil, err
}
params := map[string]any{
"access_token": accessToken,
"access_token": app.AccessToken,
"device_id": strings.ToUpper(c.MessengerLite.deviceID.String()),
"machine_id": c.MessengerLite.machineID,
"version": "3",
Expand All @@ -94,16 +114,16 @@ func (c *Client) FetchLightspeedKey(ctx context.Context) (*LightspeedKeyResponse
}
fullURL := endpoint + "?" + query.Encode()

analyticsTags, err := httpclient.MakeRequestAnalyticsHeader(appID)
analyticsTags, err := httpclient.MakeRequestAnalyticsHeader(app.AppID)
if err != nil {
return nil, err
}

headers := map[string]string{
"accept": "*/*",
"x-fb-appid": appID,
"x-fb-appid": app.AppID,
"x-fb-request-analytics-tags": analyticsTags,
"user-agent": userAgent,
"user-agent": app.UserAgent,
"accept-language": "en-US,en;q=0.9",
"request_token": uuid.New().String(),
}
Expand Down Expand Up @@ -165,20 +185,144 @@ type BloksLoginActionResponsePayload struct {
SessionCookies []RawCookie `json:"session_cookies"`
}

func (m *MessengerLiteMethods) convertCookies(payload *BloksLoginActionResponsePayload) *cookies.Cookies {
func (m *MessengerLiteMethods) convertCookies(rawCookies []RawCookie) *cookies.Cookies {
newCookies := &cookies.Cookies{Platform: m.client.Platform}
newCookies.UpdateValues(make(map[cookies.MetaCookieName]string))
for _, raw := range payload.SessionCookies {
for _, raw := range rawCookies {
newCookies.Set(cookies.MetaCookieName(raw.Name), raw.Value)
}
return newCookies
}

// SessionForAppOptions overrides the parameters of the create_session_for_app
// request. The zero value makes the same request the official app does.
type SessionForAppOptions struct {
Endpoint string
NewAppID string
AppAuth bool
}

type SessionForAppResponse struct {
AccessToken string `json:"access_token"`
SessionKey string `json:"session_key"`
Secret string `json:"secret"`
MachineID string `json:"machine_id"`
AnalyticsClaim string `json:"analytics_claim"`
UID json.Number `json:"uid"`
SessionCookies []RawCookie `json:"session_cookies"`

// The legacy REST API returns errors in the body with a 200 status
ErrorCode int `json:"error_code"`
ErrorSubcode int `json:"error_subcode"`
ErrorMsg string `json:"error_msg"`
// ...while the graph API uses the usual graph error object
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
Code int `json:"code"`
Subcode int `json:"error_subcode"`
} `json:"error"`
}

func (resp *SessionForAppResponse) Err() error {
if resp.Error != nil {
return fmt.Errorf("session-for-app error %d/%d: %s", resp.Error.Code, resp.Error.Subcode, resp.Error.Message)
} else if resp.ErrorCode != 0 {
return fmt.Errorf("session-for-app error %d/%d: %s", resp.ErrorCode, resp.ErrorSubcode, resp.ErrorMsg)
}
return nil
}

func (m *MessengerLiteMethods) GetSessionForApp(ctx context.Context, accessToken string, opts SessionForAppOptions) (*SessionForAppResponse, error) {
app, err := m.client.getMessengerLiteApp()
if err != nil {
return nil, err
}

endpoint := opts.Endpoint
if endpoint == "" {
endpoint = m.client.GetEndpoint("session_for_app")
}
newAppID := opts.NewAppID
if newAppID == "" {
newAppID = app.AppID
}

params := url.Values{}
params.Set("format", "json")
params.Set("access_token", accessToken)
params.Set("new_app_id", newAppID)
params.Set("generate_session_cookies", "1")
params.Set("generate_analytics_claim", "1")
if m.deviceID != uuid.Nil {
params.Set("device_id", strings.ToUpper(m.deviceID.String()))
}
if m.machineID != "" {
params.Set("machine_id", m.machineID)
} else {
params.Set("generate_machine_id", "1")
}

analyticsTags, err := httpclient.MakeRequestAnalyticsHeader(app.AppID)
if err != nil {
return nil, err
}

headers := http.Header{}
for k, v := range map[string]string{
"accept": "*/*",
"x-fb-appid": app.AppID,
"x-fb-request-analytics-tags": analyticsTags,
"user-agent": app.UserAgent,
"accept-language": "en-US,en;q=0.9",
"request_token": uuid.New().String(),
} {
headers.Set(k, v)
}
if opts.AppAuth {
headers.Set("authorization", "OAuth "+app.AccessToken)
}

// The legacy REST host answers with a 200 and the error in the body, while
// the graph hosts use a normal HTTP error status, so parse the body first
// either way and only fall back to the HTTP error if it isn't usable.
_, responseBytes, reqErr := m.client.http.MakeRequest(ctx, endpoint, "POST", headers, []byte(params.Encode()), types.FORM)

var response SessionForAppResponse
err = json.Unmarshal(responseBytes, &response)
switch {
case err == nil && response.Err() != nil:
return nil, response.Err()
case reqErr != nil:
return nil, fmt.Errorf("requesting session for app: %w", reqErr)
case err != nil:
return nil, fmt.Errorf("parsing session-for-app response: %w", err)
}
return &response, nil
}

func (m *MessengerLiteMethods) ExchangeTransientToken(ctx context.Context, accessToken string) (*cookies.Cookies, error) {
resp, err := m.GetSessionForApp(ctx, accessToken, SessionForAppOptions{})
if err != nil {
return nil, err
}
newCookies := m.convertCookies(resp.SessionCookies)
if !newCookies.IsLoggedIn() {
return nil, fmt.Errorf("session-for-app response is missing cookies: %v", newCookies.GetMissingCookieNames())
}
return newCookies, nil
}

// For testing only
func (m *MessengerLiteMethods) SetDeviceIdentifiers(deviceID uuid.UUID) {
m.deviceID = deviceID
}

// For testing only
func (m *MessengerLiteMethods) SetMachineID(machineID string) {
m.machineID = machineID
}

func (m *MessengerLiteMethods) DoLoginSteps(ctx context.Context, userInput map[string]string) (*bridgev2.LoginStep, *cookies.Cookies, error) {
if m.browser == nil {
b, err := bloks.NewBrowser(m.getBrowserConfig())
Expand Down Expand Up @@ -216,18 +360,26 @@ func (m *MessengerLiteMethods) DoLoginSteps(ctx context.Context, userInput map[s
return nil, nil, fmt.Errorf("parsing login response data: %w", err)
}

if loginRespPayload.CredentialType == "transient_token" {
// There is an extra step for getting cookies when the credential_type
// field is transient_token, which the bridge does not currently
// implement. Until then, add a warning log. Normally the credential_type
// is two_factor. It depends on the account, not on the MFA method
// selected.
m.client.Logger.Warn().Msg("Got credential_type transient_token, login will fail")
}

if len(loginRespPayload.SessionCookies) == 0 {
return nil, nil, ErrLoginMissingCookies
if loginRespPayload.AccessToken == "" {
return nil, nil, ErrLoginMissingCookies
}
m.client.Logger.Debug().
Str("credential_type", loginRespPayload.CredentialType).
Msg("Login response didn't include session cookies, exchanging access token for a session")
newCookies, err := m.ExchangeTransientToken(ctx, loginRespPayload.AccessToken)
if err != nil {
m.client.Logger.Warn().Err(err).
Str("credential_type", loginRespPayload.CredentialType).
Msg("Failed to exchange access token for session cookies")
return nil, nil, ErrLoginTokenExchange
}
m.client.Logger.Debug().
Str("credential_type", loginRespPayload.CredentialType).
Any("cookie_names", slices.Collect(maps.Keys(newCookies.GetAll()))).
Msg("Exchanged access token for session cookies")
return nil, newCookies, nil
}

return nil, m.convertCookies(&loginRespPayload), nil
return nil, m.convertCookies(loginRespPayload.SessionCookies), nil
}
Loading