diff --git a/.scratch/headless-auth/diagnosis.md b/.scratch/headless-auth/diagnosis.md new file mode 100644 index 0000000..d83deb4 --- /dev/null +++ b/.scratch/headless-auth/diagnosis.md @@ -0,0 +1,128 @@ +# Login investigation — October 9, 2026 + +Status: Code 366 is explained; authentication now uses one browser workflow +with headless terminal prompts or a visible window. Real-account password and +MFA success remain unverified against live X. + +## Current account-login failure + +An instrumented run of the actual CLI with the supplied username reached +`POST /onboarding/web/actions/begin_login` with both `username_or_email` and a +browser-generated `$castle_token`. X returned HTTP 200 with an application +error, and its visible form displayed: + +> We’ve temporarily limited your login. Please try again later. + +The page never advanced to password entry. This is evidence of a temporary +login limit, not evidence that headless login requires a visible browser. +The response did not identify the scope of the limit or when it expires. +No further account submissions were made after identifying it. + +The CLI previously collapsed this text into `AUTHENTICATION_FAILED` with a +headed-login suggestion. It now reports `AUTH_RATE_LIMITED` and preserves +the instruction to try later. Generic page failures use +`AUTH_LOGIN_UNAVAILABLE`; hidden error text is ignored. Local Chromium +regressions exercise these rendered error states without contacting X. + +The earlier phone attempt also used the wrong entry path: the current +`username_or_email` field is labelled “Email or username,” while phone login +has a separate “Continue with phone” control. The terminal prompt now matches +that field. The separate phone-login route is not implemented by this driver. +Unknown form timeouts indicate a limitation in form recognition, not proof of +an X requirement to use a visible browser. + +The former experimental `auth login-api` implementation called the retired +classic onboarding flow. Updating its endpoint alone could not implement the +replacement: Jetfuel uses URL-encoded actions, binary responses, changing form +fields, and Castle device tokens. The implementation and public command have +been removed in favor of X's own web login. + +## Final implementation decision + +`twt auth login` defaults to a visible Chromium window for manual login. +`--headed` selects that default explicitly; `--headless` uses terminal prompts +for identity, password, and supported verification codes. Both +modes share one managed browser instance and application profile through +authentication and operation-contract capture. The chosen mode never changes +automatically, and Chromium is not relaunched between login and capture. + +This lets X's frontend generate device tokens and submit its current login +forms. The CLI does not maintain a parallel implementation of the private +login API. Passwords and verification codes are hidden during terminal entry, +are not accepted in flags or environment variables, and are never saved. +Unsupported challenges, including CAPTCHA and security keys, fail with +instructions to rerun `twt auth login --headed`. + +Contract refresh uses the existing profile headlessly. When authentication has +expired, it directs the user to log in explicitly rather than opening a +visible browser. After capture, the command saves authentication and candidate +contracts before validating the read operations. Successful validation promotes +the candidate contracts to active contracts. Validation failure preserves the +previous active contracts; the newly captured authentication is already saved. + +## Historical reproduction + +Before removal of the legacy implementation, +`go run ./.scratch/.sandbox/authprobe` called it without supplying account +credentials. The probe exited successfully only if the flow reached a +terminal input prompt. Repeated runs returned `AUTH_API_UNAVAILABLE` with X +code 366 before any prompt. The helper at +`.scratch/.sandbox/authprobe/main.go` was removed together with the legacy +`authapi` implementation. The command above records the historical experiment +and no longer runs in the current checkout. + +## Live observations + +- Direct HTTP `GET https://jf.x.com/onboarding/web?mode=login` returned HTTP 200. + The binary response exposed `username_or_email` and `begin_login`. +- Playwright with Chromium 156 loaded + `https://x.com/i/jf/onboarding/web?mode=login`. X rejected the default + `HeadlessChrome` user agent with HTTP 403; the corresponding standard Chrome + user agent loaded the page. A separate HTTP comparison reproduced this. +- One dummy identifier, `twt-login-probe@invalid.example`, was used throughout. + No real account identifier, password, email code, or session credentials were + provided. The dummy address uses the reserved `.example` domain. +- X's own frontend submitted + `POST https://jf.x.com/onboarding/web/actions/begin_login` with form fields + `username_or_email` and `$castle_token`. The generated token was about 5.9 KB; + its value was neither printed nor saved. +- A controlled comparison intercepted a fresh browser submission and replayed + it twice through the same HTTP client with the same headers and identifier. + Removing only `$castle_token` returned HTTP 200 with an 82-byte binary + “Something went wrong” response. Including the browser-generated token + returned HTTP 200 and X's normal “Get the app to finish signing up using email” + branch. HTTP success alone is therefore insufficient to validate login. + +This establishes the active endpoint and the device-token dependency at the +identifier step. It does not establish successful password login or MFA for a +real account, nor prove that every headless-generated token is accepted. +A short headless token-generation step was considered during the investigation. +The final decision keeps the whole login in the same browser as contract +capture. A reliable browser-free token source was not established. + +## Unified browser implementation probes + +The final browser workflow was exercised through the CLI with both managed +Chromium and current Chromium, using isolated `XDG_CONFIG_HOME` and +`XDG_STATE_HOME` directories. These probes used only a dummy identifier in the +reserved `.example` domain. No real account password or verification code was +supplied. + +The live identifier form contains an inert password input hidden by +`opacity: 0`. Login-field detection now filters that input so the CLI prompts +for the visible identifier. The current probe reaches that prompt and then +returns `AUTH_CHALLENGE_UNSUPPORTED`; it does not establish successful password +login or MFA. No specific post-identifier branch is claimed from this probe. + +## Current primary-source corroboration + +- [Twifork migration evidence, July 30](https://github.com/PawiX25/twifork/commit/3294c1ebf020761c95c877dc36ed06c24a944e88) +- [Mautrix Jetfuel route update, September 30](https://github.com/mautrix/twitter/pull/153) +- [Mautrix login implementation, October 6 revision](https://github.com/mautrix/twitter/blob/fea0bb26af1a5b7529588dee9ab4fa1f6e10be3b/pkg/twittermeow/login_jetfuel.go) +- [Mautrix's browser/webview token requirement](https://github.com/mautrix/twitter/blob/fea0bb26af1a5b7529588dee9ab4fa1f6e10be3b/pkg/twittermeow/castle_token.go) +- [Emusks login documentation and limitations](https://emusks.tiago.zip/jetfuel/login) + +Mautrix's current implementation carries browser/webview-generated Castle tokens +for Jetfuel form submissions. Emusks also describes a browser token dependency; +its documentation and shipped source differ on routes, and its statements about +initial stages should not be read as proof of full browser-free account login. diff --git a/CONTEXT.md b/CONTEXT.md index ce7f801..7ce0118 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -9,7 +9,7 @@ A CLI command that reads account or public X data. It requires valid authenticat _Avoid_: Browser command, API command **Authentication state**: -Locally persisted browser credentials established through interactive X login and loaded by later data commands. It changes independently from operation contracts. +Locally persisted X credentials established through a visible browser login or headless browser login with terminal prompts and loaded by later data commands. It changes independently from operation contracts. _Avoid_: Auth headers, copied cURL, session **Application profile**: diff --git a/README.md b/README.md index 4aa5b80..63b75d1 100644 --- a/README.md +++ b/README.md @@ -140,13 +140,35 @@ Then authenticate: twt auth login ``` -Authentication uses a dedicated Chromium profile owned by `twt`, not the -user's everyday browser profile. The command first checks that profile -headlessly. If X requires a login or interactive challenge, it opens a headed -Chromium window for the user to complete the flow. Once authenticated, it -captures the current Search, Bookmarks, and View request contracts, verifies that -the four read operations work, and activates the new local state only -after validation succeeds. +Authentication opens managed Chromium in a visible window for manual login. +Use `--headed` to select the default explicitly, or `--headless` to enter your +username, password, and supported verification codes through terminal prompts. +Passwords and codes are hidden during terminal entry and are never saved. +Credentials are not accepted through command flags or environment variables: + +```bash +twt auth login --headless +twt auth login --headed +``` + +Use a username or email for the initial headless prompt. X's separate phone +login route is not automated. If X temporarily limits login attempts, the CLI +reports `AUTH_RATE_LIMITED`; try again later. + +Both modes use one Chromium instance and the same dedicated application +profile throughout login and contract capture. The selected mode remains in +effect for the entire command. After authentication, `twt` captures the current +Search, Bookmarks, and View request contracts and saves the captured +authentication. It verifies that the four read operations work before replacing +the active operation contracts. If validation fails, the previous active +contracts remain and the newly captured authentication is already saved. + +Headless login supports terminal prompts for identity, password, and +verification codes. CAPTCHA, security-key, and other unsupported challenges +return an actionable failure; rerun `twt auth login --headed` to complete them +in the visible browser. Real-account password and MFA success have not yet +been verified against live X. The [login investigation](.scratch/headless-auth/diagnosis.md) +records the evidence behind using X's own web login in both modes. By default, sensitive and generated state is stored at: @@ -176,8 +198,10 @@ and then rerun the original command: twt contract refresh ``` -Refresh follows the same headless-first authentication behavior. Data commands -never refresh contracts, open Chromium, log in, or retry themselves. +Refresh uses the existing application profile headlessly. If X requires login, +it returns an authentication failure directing you to `twt auth login`; it +does not open a visible browser automatically. Data commands never refresh +contracts, open Chromium, log in, or retry themselves. ## Command documentation diff --git a/docs/commands.md b/docs/commands.md index c76e140..1e87267 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -126,13 +126,39 @@ revisions is offered separately and defaults to no. ## Authentication ```text -twt auth login +twt auth login [--headless | --headed] ``` -Checks the isolated application profile headlessly and opens managed Chromium -only when X requires interactive login or a challenge. After authentication, -the command captures and validates the Search, Bookmarks, and View contracts before -activating the new local authentication and contract state. +`login` defaults to a visible managed Chromium window for manual login. It +reuses the isolated application profile. `--headless` uses terminal prompts +when X requests an identity, password, or supported verification code. Passwords and verification codes are +hidden during terminal entry and are never saved or included in command +output. Account credentials are not accepted through flags or environment +variables. An already authenticated profile proceeds directly to contract +capture. + +The current initial form accepts a username or email. X's separate phone-login +route is not automated. `AUTH_RATE_LIMITED` means X temporarily limited login +attempts; try later. `AUTH_LOGIN_UNAVAILABLE` reports an X login-page error. +Neither failure implies that changing browser display mode will resolve it. + +`--headed` selects the default visible-browser mode explicitly. The flags are mutually exclusive. Both modes use one +browser instance and the same profile for login and capture; the command does +not switch modes or relaunch Chromium after authentication. + +After authentication, the command captures the Search, Bookmarks, and View +contracts, saves the captured authentication and candidate contracts, then +validates the four read operations. Only successful validation replaces the +active contracts. Successful output reports `status: "authenticated"`, +`authentication_path`, and `contract_path`. + +CAPTCHA, security-key, and other unsupported headless challenges return an +actionable failure suggesting `twt auth login --headed`. A failure before +capture leaves the saved authentication and active contract files unchanged. +If contract validation fails, the previous active contracts remain, but the +newly captured authentication has already been saved. +Real-account password and MFA success have not yet been verified against live +X; see the [login investigation](../.scratch/headless-auth/diagnosis.md). ## Contract maintenance @@ -144,10 +170,11 @@ twt contract refresh `status` validates the local authentication and contract files without opening Chromium or contacting X. It does not prove that X will still accept them. -`refresh` explicitly recaptures and validates current operation contracts. It -uses the existing application profile headlessly when possible and opens -Chromium only if X requires interactive authentication. Data commands never -refresh contracts or retry themselves. +`refresh` explicitly recaptures and validates current operation contracts using +the existing application profile headlessly. If X requires login, it returns +an authentication failure directing you to `twt auth login`. Refresh never +switches to a visible browser. Data commands never refresh contracts or retry +themselves. ## Output, warnings, and failures diff --git a/go.mod b/go.mod index f567ead..9294895 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/ach968/x-client-transaction-id-go v0.1.0 github.com/go-rod/rod v0.116.2 github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 + golang.org/x/term v0.43.0 ) require ( @@ -15,5 +16,6 @@ require ( github.com/ysmood/gson v0.7.3 // indirect github.com/ysmood/leakless v0.9.0 // indirect golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect ) diff --git a/go.sum b/go.sum index 855a1ef..67f1e56 100644 --- a/go.sum +++ b/go.sum @@ -22,5 +22,9 @@ github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= diff --git a/internal/app/browser/capture.go b/internal/app/browser/capture.go index 72a57b2..6a4a8dc 100644 --- a/internal/app/browser/capture.go +++ b/internal/app/browser/capture.go @@ -1,6 +1,7 @@ package browser import ( + "context" "encoding/json" "errors" "fmt" @@ -48,6 +49,9 @@ func (step ContractCaptureStep) CapturedOperations() []app.OperationName { } type ContractCaptureOptions struct { + Context context.Context + Login bool + LoginPrompt LoginPromptFunc ProfilePath string Headless bool URLs []string @@ -261,7 +265,10 @@ func CaptureOperationContracts(options ContractCaptureOptions) (result app.Captu if options.Timeout == 0 { options.Timeout = 10 * time.Second } - client, err := newBrowserClient(options.ProfilePath, options.Headless, options.Timeout) + if options.Login && options.Headless && options.LoginPrompt == nil { + return result, loginFailure("Headless login requires terminal input") + } + client, err := newBrowserClient(options.ProfilePath, options.Headless, 0) if err != nil { return result, err } @@ -270,6 +277,17 @@ func CaptureOperationContracts(options ContractCaptureOptions) (result app.Captu err = closeErr } }() + if options.Context == nil { + options.Context = context.Background() + } + client.page = client.page.Context(options.Context) + if options.Login { + if err := authenticateBrowser(client, options.LoginPrompt, loginURL); err != nil { + return result, err + } + } + client.page = client.page.Timeout(options.Timeout) + defer client.page.CancelTimeout() page := client.page var mutex sync.Mutex operations := map[app.OperationName]app.OperationContract{} @@ -331,6 +349,8 @@ func CaptureOperationContracts(options ContractCaptureOptions) (result app.Captu return nil } select { + case <-options.Context.Done(): + return options.Context.Err() case <-notify: case <-authenticationTicker.C: case <-timer.C: diff --git a/internal/app/browser/client.go b/internal/app/browser/client.go index 79e8c8d..582ca4c 100644 --- a/internal/app/browser/client.go +++ b/internal/app/browser/client.go @@ -3,6 +3,7 @@ package browser import ( "fmt" "os" + "strings" "time" "github.com/go-rod/rod" @@ -78,6 +79,17 @@ func newBrowserClient(profilePath string, headless bool, timeout time.Duration) _ = release() return nil, err } + // Use the installed browser's normal user agent in both display modes. + // X rejects the HeadlessChrome product name before rendering the login page. + version, err := launched.browser.Version() + if err == nil { + err = page.SetUserAgent(&proto.NetworkSetUserAgentOverride{UserAgent: strings.ReplaceAll(version.UserAgent, "HeadlessChrome/", "Chrome/"), AcceptLanguage: "en-US,en;q=0.9"}) + } + if err != nil { + _ = launched.Close() + _ = release() + return nil, err + } if timeout > 0 { page = page.Timeout(timeout) } diff --git a/internal/app/browser/login.go b/internal/app/browser/login.go new file mode 100644 index 0000000..903c2bf --- /dev/null +++ b/internal/app/browser/login.go @@ -0,0 +1,309 @@ +package browser + +import ( + "net/url" + "strings" + "time" + + app "github.com/ach968/x-twitter-cli/internal/app" + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/proto" +) + +const loginURL = "https://x.com/i/jf/onboarding/web?mode=login" + +type LoginPrompt struct { + Label string + Secret bool +} +type LoginPromptFunc func(LoginPrompt) (string, error) + +func loginFailure(message string) error { + return &app.OperationFailure{Code: "AUTHENTICATION_FAILED", Message: message} +} +func unsupportedLogin() error { + return &app.OperationFailure{Code: "AUTH_CHALLENGE_UNSUPPORTED", Message: "The CLI could not handle this X login step in headless mode; try twt auth login --headed"} +} + +// authenticateBrowser uses the same page and profile later used for capture. +// A nil prompt leaves input to the person interacting with the visible window. +func authenticateBrowser(client *browserClient, prompt LoginPromptFunc, startURL string) error { + page := client.page.Timeout(10 * time.Minute) + defer page.CancelTimeout() + origin, err := url.Parse(startURL) + if err != nil { + return loginFailure("Unable to open X login") + } + origin.Path, origin.RawQuery, origin.Fragment = "/", "", "" + if err := page.Navigate(origin.String() + "home"); err != nil { + return loginFailure("Unable to open X; check connectivity and try again") + } + if err := page.WaitLoad(); err != nil { + return loginFailure("Unable to load X login") + } + if complete, _ := browserAuthenticated(page, origin.String()); complete { + return nil + } + if err := page.Navigate(startURL); err != nil { + return loginFailure("Unable to open X login") + } + return completeBrowserLogin(page, prompt, origin.String()) +} + +func browserAuthenticated(page *rod.Page, origin string) (bool, error) { + info, err := page.Info() + if err != nil { + return false, err + } + parsed, err := url.Parse(info.URL) + expected, _ := url.Parse(origin) + if err != nil || parsed.Scheme != expected.Scheme || parsed.Host != expected.Host || authenticationRequiredURL(info.URL) { + return false, nil + } + cookies, err := (proto.NetworkGetCookies{Urls: []string{origin}}).Call(page) + if err != nil { + return false, err + } + for _, cookie := range cookies.Cookies { + if cookie.Name == "auth_token" && cookie.Value != "" { + // An expired cookie can remain while X renders its logged-out page. + // Wait for account navigation before treating the session as valid. + result, err := page.Eval(`() => {` + loginElementVisibility + ` + return [...document.querySelectorAll('[data-testid="SideNav_AccountSwitcher_Button"],[data-testid="AppTabBar_Profile_Link"]')].some(visible); +}`) + if err != nil { + // The execution context may be replaced during a redirect. + return false, nil + } + return result.Value.Bool(), nil + } + } + return false, nil +} + +type loginScreen struct { + Origin string `json:"origin"` + Signature string `json:"signature"` + Kind string `json:"kind"` + Identifier string `json:"identifier"` + Fields int `json:"fields"` + Submit bool `json:"submit"` + UsePassword bool `json:"usePassword"` + Failure string `json:"failure"` + Unsupported bool `json:"unsupported"` +} + +const loginElementVisibility = ` + const visible = e => { + if (!e || e.closest('[inert]')) return false; + const rect = e.getBoundingClientRect(); + if (!rect.width || !rect.height) return false; + for (let parent=e; parent; parent=parent.parentElement) { + const style=getComputedStyle(parent); + if (style.visibility==='hidden' || style.display==='none' || style.opacity==='0') return false; + } + return true; + }; +` + +// Inspect only the form shape, never return input values or raw page text. +const inspectLoginForm = `() => {` + loginElementVisibility + ` + const root = [...document.querySelectorAll('[role="dialog"],dialog')].find(visible) || document.body; + if (!root) return {origin:location.origin,signature:location.href,fields:0}; + const textNodes = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const visibleText = []; + while (textNodes.nextNode()) { + if (visible(textNodes.currentNode.parentElement)) visibleText.push(textNodes.currentNode.textContent); + } + const text = visibleText.join(' ').replace(/\s+/g,' ').toLowerCase(); + const inputs = [...root.querySelectorAll('input')].filter(e => visible(e) && !e.disabled && !e.readOnly && ['text','email','tel','password','number'].includes(e.type)); + let fields = inputs.filter(e => e.type === 'password'); + let kind = fields.length ? 'password' : ''; + let identifier = ''; + if (!fields.length) { + fields = inputs; + const description = inputs.map(e => [e.name,e.autocomplete,e.placeholder,e.getAttribute('aria-label')].join(' ')).join(' ').toLowerCase(); + if (/one-time-code|confirmation code|verification code|authentication code|enter (the |a |your )?code|backup code/.test(description+' '+text)) kind='code'; + else if (/username|email|phone|identifier/.test(description+' '+text)) kind='identifier'; + if (kind==='identifier' && fields.length===1 && fields[0].name==='username_or_email') identifier='username_or_email'; + } + document.querySelectorAll('[data-twt-login-field],[data-twt-login-submit],[data-twt-login-password]').forEach(e => { + e.removeAttribute('data-twt-login-field');e.removeAttribute('data-twt-login-submit');e.removeAttribute('data-twt-login-password'); + }); + fields.forEach((e,i) => e.setAttribute('data-twt-login-field',String(i))); + const buttons = [...root.querySelectorAll('button,[role="button"],input[type="submit"]')].filter(e => visible(e) && !e.disabled && e.getAttribute('aria-disabled') !== 'true'); + const label = e => (e.innerText || e.value || e.getAttribute('aria-label') || '').trim(); + const submit = buttons.find(e => /^(continue|next|log in|sign in|verify|confirm|submit)$/i.test(label(e))); + const usePassword = buttons.find(e => /^(use (a |your )?password|log in with password|sign in with password)$/i.test(label(e))); + if(submit) submit.setAttribute('data-twt-login-submit',''); + if(usePassword) usePassword.setAttribute('data-twt-login-password',''); + let failure = ''; + if (/temporarily limited|too many attempts/.test(text)) failure='rate_limited'; + else if (/incorrect password|wrong password|incorrect code|invalid code/.test(text)) failure='rejected'; + else if (/could not log you in|couldn.t log you in|something went wrong/.test(text)) failure='unavailable'; + const unsupported = !fields.length && (/captcha|security key|passkey|verify you are human|finish signing up|create your account/.test(text) || [...root.querySelectorAll('iframe')].some(e => visible(e) && /captcha|arkose/.test(e.src))); + const headings = [...root.querySelectorAll('h1,h2,h3,[role="heading"]')].map(e => e.innerText).join('|'); + return {origin:location.origin,signature:location.href+'|'+headings+'|'+fields.map(e=>[e.name,e.type,e.autocomplete,e.placeholder].join(':')).join('|'),kind,identifier,fields:fields.length,submit:!!submit,usePassword:!!usePassword,failure,unsupported}; +}` + +func readLoginScreen(page *rod.Page) (loginScreen, error) { + var screen loginScreen + result, err := page.Eval(inspectLoginForm) + if err != nil { + return screen, err + } + err = result.Value.Unmarshal(&screen) + return screen, err +} + +func completeBrowserLogin(page *rod.Page, prompt LoginPromptFunc, origin string) error { + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + lastSubmission := "" + lastProgress := time.Now() + passwordSent := false + prompts := 0 + for { + complete, err := browserAuthenticated(page, origin) + if err != nil { + return loginFailure("X login window was closed or interrupted") + } + if complete { + return nil + } + if err := page.GetContext().Err(); err != nil { + return loginFailure("X login was interrupted or timed out") + } + if prompt != nil { + screen, err := readLoginScreen(page) + if err != nil { + return loginFailure("Unable to read the X login page") + } + if screen.Origin != strings.TrimSuffix(origin, "/") { + return unsupportedLogin() + } + switch screen.Failure { + case "rate_limited": + return &app.OperationFailure{Code: "AUTH_RATE_LIMITED", Message: "X has temporarily limited login attempts; try again later"} + case "unavailable": + return &app.OperationFailure{Code: "AUTH_LOGIN_UNAVAILABLE", Message: "X displayed a login error; try again later"} + case "rejected": + return loginFailure("X rejected the password or verification code; check the entered value and try again") + } + if screen.Unsupported { + return unsupportedLogin() + } + if screen.Signature != lastSubmission && screen.UsePassword { + button, err := page.Element("[data-twt-login-password]") + if err != nil { + return unsupportedLogin() + } + if err := button.Click(proto.InputMouseButtonLeft, 1); err != nil { + return loginFailure("Unable to select password login") + } + lastSubmission, lastProgress = screen.Signature, time.Now() + } else if screen.Signature != lastSubmission && screen.Fields > 0 { + if screen.Kind == "" || (screen.Fields > 1 && screen.Kind != "code") { + return unsupportedLogin() + } + if prompts >= 10 { + return loginFailure("X login exceeded its step limit") + } + var question LoginPrompt + switch screen.Kind { + case "password": + if passwordSent { + return loginFailure("X requested the password again; login stopped") + } + question = LoginPrompt{Label: "X password: ", Secret: true} + passwordSent = true + case "code": + question = LoginPrompt{Label: "X verification code: ", Secret: true} + case "identifier": + question = LoginPrompt{Label: "X username, email, or phone: "} + if screen.Identifier == "username_or_email" { + question.Label = "X username or email: " + } + } + answer, err := prompt(question) + if err != nil || answer == "" { + return loginFailure("X login input was cancelled or empty") + } + if err := page.GetContext().Err(); err != nil { + return loginFailure("X login was interrupted or timed out") + } + prompts++ + if err := submitLoginAnswer(page, screen, answer); err != nil { + return err + } + lastSubmission, lastProgress = screen.Signature, time.Now() + } else if time.Since(lastProgress) > 30*time.Second { + return unsupportedLogin() + } + } + select { + case <-page.GetContext().Done(): + return loginFailure("X login was interrupted or timed out") + case <-ticker.C: + } + } +} + +func submitLoginAnswer(page *rod.Page, screen loginScreen, answer string) error { + current, err := readLoginScreen(page) + if err != nil || current.Origin != screen.Origin || current.Signature != screen.Signature { + return loginFailure("X login form changed while waiting for input; try again") + } + fields, err := page.Elements("[data-twt-login-field]") + if err != nil || len(fields) != screen.Fields { + return loginFailure("X login form changed while waiting for input; try again") + } + values := []string{answer} + if screen.Fields > 1 { + values = nil + for _, char := range answer { + values = append(values, string(char)) + } + if len(values) != len(fields) { + return loginFailure("Verification code length does not match the X form") + } + } + for i, field := range fields { + if err := field.SelectAllText(); err != nil { + return loginFailure("Unable to fill X login form") + } + if err := field.Input(values[i]); err != nil { + return loginFailure("Unable to fill X login form") + } + } + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + updated, err := readLoginScreen(page) + if err != nil { + return loginFailure("Unable to read X login form") + } + if updated.Origin != screen.Origin { + return unsupportedLogin() + } + if updated.Signature != screen.Signature { + // Some code forms submit automatically on the final digit. + return nil + } + if updated.Submit { + button, err := page.Element("[data-twt-login-submit]") + if err != nil { + return loginFailure("X login form changed before submission") + } + if err := button.Click(proto.InputMouseButtonLeft, 1); err != nil { + return loginFailure("Unable to submit X login form") + } + return nil + } + select { + case <-page.GetContext().Done(): + return loginFailure("X login was interrupted or timed out") + case <-time.After(100 * time.Millisecond): + } + } + return unsupportedLogin() +} diff --git a/internal/app/browser/login_browser_test.go b/internal/app/browser/login_browser_test.go new file mode 100644 index 0000000..6104c95 --- /dev/null +++ b/internal/app/browser/login_browser_test.go @@ -0,0 +1,507 @@ +//go:build browser + +package browser + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + app "github.com/ach968/x-twitter-cli/internal/app" +) + +const loginTestHome = `

Home

` + +func loginTestClient(t *testing.T, profile string) *browserClient { + t.Helper() + client, err := newBrowserClient(profile, true, 15*time.Second) + if err != nil { + t.Fatalf("launch browser: %v", err) + } + t.Cleanup(func() { _ = client.Close() }) + return client +} + +func loginTestForm(heading, input, button string) string { + return fmt.Sprintf(`

%s

%s
`, heading, input, button) +} + +func loginTestCookies(w http.ResponseWriter) { + for _, name := range []string{"auth_token", "ct0"} { + http.SetCookie(w, &http.Cookie{Name: name, Value: "synthetic-" + name, Path: "/", MaxAge: 3600}) + } +} + +func assertLoginFailure(t *testing.T, err error, code string, secrets ...string) { + t.Helper() + var failure *app.OperationFailure + if !errors.As(err, &failure) || failure.Code != code { + t.Fatalf("expected %s, got %v", code, err) + } + for _, secret := range secrets { + if secret != "" && strings.Contains(err.Error(), secret) { + t.Fatal("login error exposed a credential or page detail") + } + } +} + +func TestBrowserLoginUsesOneBrowserForCredentialsAndSession(t *testing.T) { + const username = "synthetic-user" + const password = " synthetic password\t " + const verification = "012345" + var mu sync.Mutex + var submitted []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if r.URL.Path == "/home" { + fmt.Fprint(w, loginTestHome) + return + } + if r.Method == http.MethodGet { + fmt.Fprint(w, loginTestForm("Sign in to X", ``, "Continue")) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + mu.Lock() + defer mu.Unlock() + switch { + case r.Form.Has("username"): + submitted = append(submitted, r.Form.Get("username")) + fmt.Fprint(w, loginTestForm("Enter your password", ``, "Log in")) + case r.Form.Has("password"): + submitted = append(submitted, r.Form.Get("password")) + fmt.Fprint(w, loginTestForm("Enter your verification code", ``, "Continue")) + case r.Form.Has("verification"): + submitted = append(submitted, r.Form.Get("verification")) + loginTestCookies(w) + http.Redirect(w, r, "/home", http.StatusSeeOther) + } + })) + t.Cleanup(server.Close) + profile := t.TempDir() + t.Run("new session", func(t *testing.T) { + client := loginTestClient(t, profile) + target, pid := client.page.TargetID, client.launched.launcher.PID() + answers := []string{username, password, verification} + var questions []LoginPrompt + err := authenticateBrowser(client, func(question LoginPrompt) (string, error) { + questions = append(questions, question) + if len(questions) > len(answers) { + return "", errors.New("unexpected extra prompt") + } + return answers[len(questions)-1], nil + }, server.URL+"/login") + if err != nil { + t.Fatalf("authenticate: %v", err) + } + if len(questions) != 3 || questions[0].Secret || !questions[1].Secret || !questions[2].Secret { + t.Fatalf("wrong credential prompt sequence: %+v", questions) + } + mu.Lock() + got := append([]string(nil), submitted...) + mu.Unlock() + if len(got) != len(answers) { + t.Fatalf("expected %d submitted steps, got %d", len(answers), len(got)) + } + for i := range answers { + if got[i] != answers[i] { + t.Errorf("step %d did not preserve its supplied credential", i) + } + } + if client.page.TargetID != target || client.launched.launcher.PID() != pid { + t.Fatal("login replaced the page or browser process") + } + info, err := client.page.Info() + if err != nil || info.URL != server.URL+"/home" { + t.Fatalf("expected authenticated home page, got %+v (%v)", info, err) + } + }) + reused := loginTestClient(t, profile) + err := authenticateBrowser(reused, func(LoginPrompt) (string, error) { + t.Error("persisted authenticated profile prompted for credentials") + return "", errors.New("unexpected prompt") + }, server.URL+"/login") + if err != nil { + t.Fatalf("reuse authenticated profile: %v", err) + } +} + +func TestBrowserLoginManualModeUsesSameSession(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if r.URL.Path == "/home" { + fmt.Fprint(w, loginTestHome) + return + } + if r.Method == http.MethodPost { + loginTestCookies(w) + http.Redirect(w, r, "/home", http.StatusSeeOther) + return + } + fmt.Fprint(w, loginTestForm("Sign in", ``, "Continue")) + fmt.Fprint(w, ``) + })) + t.Cleanup(server.Close) + client := loginTestClient(t, t.TempDir()) + target, pid := client.page.TargetID, client.launched.launcher.PID() + if err := authenticateBrowser(client, nil, server.URL+"/login"); err != nil { + t.Fatalf("manual authentication: %v", err) + } + if client.page.TargetID != target || client.launched.launcher.PID() != pid { + t.Fatal("manual login replaced the page or browser process") + } +} + +func TestBrowserLoginRejectedPasswordDoesNotReprompt(t *testing.T) { + const password = "synthetic-rejected-password" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + heading := "Enter your password" + if r.Method == http.MethodPost { + heading = "Wrong password: " + password + } + fmt.Fprint(w, loginTestForm(heading, ``, "Log in")) + })) + t.Cleanup(server.Close) + client := loginTestClient(t, t.TempDir()) + prompts := 0 + err := authenticateBrowser(client, func(LoginPrompt) (string, error) { + prompts++ + return password, nil + }, server.URL+"/login") + assertLoginFailure(t, err, "AUTHENTICATION_FAILED", password) + if prompts != 1 { + t.Fatalf("expected one password prompt, got %d", prompts) + } +} + +func TestBrowserLoginReportsUpstreamFailuresWithoutReprompt(t *testing.T) { + for _, scenario := range []struct { + name string + page string + code string + message string + }{ + { + name: "login temporarily limited", + page: "We’ve temporarily limited your login. Please try again later.", + code: "AUTH_RATE_LIMITED", + message: "X has temporarily limited login attempts; try again later", + }, + { + name: "generic login page error", + page: "Something went wrong", + code: "AUTH_LOGIN_UNAVAILABLE", + message: "X displayed a login error; try again later", + }, + } { + t.Run(scenario.name, func(t *testing.T) { + const identifier = "synthetic-login-identifier" + submitted := make(chan string, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if r.Method == http.MethodPost { + submitted <- r.FormValue("username_or_email") + fmt.Fprintf(w, "

%s

%s

", scenario.page, identifier) + return + } + fmt.Fprint(w, loginTestForm("Sign in to X", ``, "Continue")) + })) + t.Cleanup(server.Close) + client := loginTestClient(t, t.TempDir()) + prompts := 0 + err := authenticateBrowser(client, func(question LoginPrompt) (string, error) { + prompts++ + if question.Secret { + t.Error("login failure prompted for a password or code") + } + return identifier, nil + }, server.URL+"/login") + assertLoginFailure(t, err, scenario.code, identifier) + var failure *app.OperationFailure + if !errors.As(err, &failure) || failure.Message != scenario.message { + t.Fatalf("expected precise static failure message, got %v", err) + } + if prompts != 1 || len(submitted) != 1 { + t.Fatalf("expected one prompt and submission, got %d prompts and %d submissions", prompts, len(submitted)) + } + }) + } +} + +func TestBrowserLoginUsernameOrEmailPromptIgnoresHiddenErrors(t *testing.T) { + const identifier = "1234567890" + submitted := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if r.URL.Path == "/home" { + fmt.Fprint(w, loginTestHome) + return + } + if r.Method == http.MethodPost { + submitted <- r.FormValue("username_or_email") + loginTestCookies(w) + http.Redirect(w, r, "/home", http.StatusSeeOther) + return + } + fmt.Fprint(w, loginTestForm("Sign in to X", ``, "Continue")) + fmt.Fprint(w, `
Something went wrong
Too many attempts
`) + })) + t.Cleanup(server.Close) + client := loginTestClient(t, t.TempDir()) + prompts := 0 + err := authenticateBrowser(client, func(question LoginPrompt) (string, error) { + prompts++ + if question.Label != "X username or email: " || question.Secret { + t.Fatalf("expected username/email prompt, got %+v", question) + } + return identifier, nil + }, server.URL+"/login") + if err != nil { + t.Fatalf("authenticate despite hidden errors: %v", err) + } + if prompts != 1 { + t.Fatalf("expected one username/email prompt, got %d", prompts) + } + select { + case got := <-submitted: + if got != identifier { + t.Fatal("numeric identifier was not preserved") + } + default: + t.Fatal("numeric identifier was not submitted") + } +} + +func TestBrowserLoginUnsupportedChallengeStopsBeforePrompt(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "

Verify you are human

CAPTCHA required

") + })) + t.Cleanup(server.Close) + client := loginTestClient(t, t.TempDir()) + err := authenticateBrowser(client, func(LoginPrompt) (string, error) { + t.Error("unsupported challenge prompted for credentials") + return "", nil + }, server.URL+"/login") + assertLoginFailure(t, err, "AUTH_CHALLENGE_UNSUPPORTED") +} + +func TestBrowserLoginDoesNotExposePromptErrors(t *testing.T) { + const secret = "synthetic-secret-from-terminal" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, loginTestForm("Enter your password", ``, "Log in")) + })) + t.Cleanup(server.Close) + client := loginTestClient(t, t.TempDir()) + err := authenticateBrowser(client, func(LoginPrompt) (string, error) { + return secret, errors.New("terminal failed with " + secret) + }, server.URL+"/login") + assertLoginFailure(t, err, "AUTHENTICATION_FAILED", secret) +} + +func TestBrowserLoginRejectsOriginChangeWhilePrompting(t *testing.T) { + const password = "synthetic-secret-for-trusted-origin" + form := loginTestForm("Enter your password", ``, "Log in") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, form) + })) + t.Cleanup(server.Close) + untrusted := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, form) + })) + t.Cleanup(untrusted.Close) + client := loginTestClient(t, t.TempDir()) + err := authenticateBrowser(client, func(LoginPrompt) (string, error) { + if err := client.page.Navigate(untrusted.URL + "/login"); err != nil { + return "", err + } + if err := client.page.WaitLoad(); err != nil { + return "", err + } + return password, nil + }, server.URL+"/login") + assertLoginFailure(t, err, "AUTHENTICATION_FAILED", password) + value, err := client.page.Eval(`() => document.querySelector('input').value`) + if err != nil { + t.Fatalf("inspect untrusted form: %v", err) + } + if value.Value.Str() != "" { + t.Fatal("credential was entered into the untrusted origin") + } +} + +func TestBrowserLoginStaleCookieDoesNotSkipPrompt(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if r.URL.Path == "/home" { + if cookie, err := r.Cookie("auth_token"); err == nil && cookie.Value == "synthetic-auth_token" { + fmt.Fprint(w, loginTestHome) + return + } + http.SetCookie(w, &http.Cookie{Name: "auth_token", Value: "stale-session", Path: "/"}) + fmt.Fprint(w, "

Welcome to X

Sign in") + return + } + if r.Method == http.MethodPost { + loginTestCookies(w) + http.Redirect(w, r, "/home", http.StatusSeeOther) + return + } + fmt.Fprint(w, loginTestForm("Sign in to X", ``, "Continue")) + })) + t.Cleanup(server.Close) + client := loginTestClient(t, t.TempDir()) + prompts := 0 + err := authenticateBrowser(client, func(LoginPrompt) (string, error) { + prompts++ + return "synthetic-user", nil + }, server.URL+"/login") + if err != nil { + t.Fatalf("authenticate with a stale session: %v", err) + } + if prompts != 1 { + t.Fatalf("stale session should prompt once, got %d prompts", prompts) + } +} + +func TestBrowserLoginIgnoresInvisiblePasswordFields(t *testing.T) { + for _, hidden := range []struct { + name string + field string + }{ + {name: "offscreen inert", field: ``}, + {name: "transparent", field: ``}, + {name: "transparent ancestor", field: `
`}, + {name: "inert ancestor", field: `
`}, + } { + t.Run(hidden.name, func(t *testing.T) { + submitted := make(chan [2]string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if r.URL.Path == "/home" { + fmt.Fprint(w, loginTestHome) + return + } + if r.Method == http.MethodPost { + submitted <- [2]string{r.FormValue("username"), r.FormValue("password")} + loginTestCookies(w) + http.Redirect(w, r, "/home", http.StatusSeeOther) + return + } + fields := `` + hidden.field + fmt.Fprint(w, loginTestForm("Sign in to X", fields, "Continue")) + })) + t.Cleanup(server.Close) + client := loginTestClient(t, t.TempDir()) + prompts := 0 + err := authenticateBrowser(client, func(question LoginPrompt) (string, error) { + prompts++ + if question.Secret { + t.Error("invisible password input caused a secret prompt before username") + return "", errors.New("unexpected password prompt") + } + return "synthetic-user", nil + }, server.URL+"/login") + if err != nil { + t.Fatalf("authenticate: %v", err) + } + if prompts != 1 { + t.Fatalf("expected one username prompt, got %d", prompts) + } + select { + case got := <-submitted: + if got[0] != "synthetic-user" || got[1] != "" { + t.Fatal("username was not entered exclusively into the visible input") + } + default: + t.Fatal("username form was not submitted") + } + }) + } +} + +func TestBrowserLoginSupportsCodeAutoSubmission(t *testing.T) { + const verification = "012345" + submitted := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + switch r.URL.Path { + case "/home": + fmt.Fprint(w, loginTestHome) + case "/verify": + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "bad code", http.StatusBadRequest) + return + } + submitted <- string(body) + loginTestCookies(w) + fmt.Fprint(w, "verified") + default: + fmt.Fprint(w, `

Enter your verification code

`) + } + })) + t.Cleanup(server.Close) + client := loginTestClient(t, t.TempDir()) + prompts := 0 + err := authenticateBrowser(client, func(question LoginPrompt) (string, error) { + prompts++ + if !question.Secret { + t.Error("verification code prompt was not secret") + } + return verification, nil + }, server.URL+"/login") + if err != nil { + t.Fatalf("authenticate with auto-submitted verification: %v", err) + } + if prompts != 1 { + t.Fatalf("expected one verification prompt, got %d", prompts) + } + select { + case got := <-submitted: + if got != verification { + t.Fatal("auto-submitted code did not preserve the input") + } + default: + t.Fatal("verification code was not automatically submitted") + } +} + +func TestBrowserLoginManualModeStopsWhenPageCloses(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, loginTestForm("Sign in", ``, "Continue")) + })) + t.Cleanup(server.Close) + client := loginTestClient(t, t.TempDir()) + page := client.page.Timeout(3 * time.Second) + defer page.CancelTimeout() + if err := page.Navigate(server.URL + "/login"); err != nil { + t.Fatalf("open login page: %v", err) + } + if err := page.WaitLoad(); err != nil { + t.Fatalf("load login page: %v", err) + } + closed := make(chan error, 1) + go func() { + time.Sleep(200 * time.Millisecond) + closed <- page.Close() + }() + started := time.Now() + err := completeBrowserLogin(page, nil, server.URL+"/") + assertLoginFailure(t, err, "AUTHENTICATION_FAILED") + if time.Since(started) > 2500*time.Millisecond { + t.Error("manual login waited for its timeout after the page closed") + } + if err := <-closed; err != nil { + t.Fatalf("close login page: %v", err) + } +} diff --git a/internal/app/cli/application_test.go b/internal/app/cli/application_test.go index 766680b..faac4d3 100644 --- a/internal/app/cli/application_test.go +++ b/internal/app/cli/application_test.go @@ -35,7 +35,7 @@ func (service *controlledManagement) Setup(context.Context, management.ConfirmBr return management.SetupResult{Status: "ready", ExecutablePath: "/managed/chromium"}, nil } -func (service *controlledManagement) Login(context.Context, management.ConfirmBrowserSetup) (management.StateChangeResult, error) { +func (service *controlledManagement) Login(context.Context, management.ConfirmBrowserSetup, management.LoginOptions) (management.StateChangeResult, error) { service.calls = append(service.calls, "auth login") return management.StateChangeResult{Status: "authenticated", AuthenticationPath: "/config/authentication.json", ContractPath: "/config/contracts.json"}, nil } diff --git a/internal/app/cli/dependencies/commands/management/management.go b/internal/app/cli/dependencies/commands/management/management.go index 8636055..2c1a504 100644 --- a/internal/app/cli/dependencies/commands/management/management.go +++ b/internal/app/cli/dependencies/commands/management/management.go @@ -3,12 +3,17 @@ package management import ( "bufio" "context" + "errors" "fmt" "io" + "os" "strings" + app "github.com/ach968/x-twitter-cli/internal/app" + "github.com/ach968/x-twitter-cli/internal/app/browser" "github.com/ach968/x-twitter-cli/internal/app/cli/dependencies/commands" managementservice "github.com/ach968/x-twitter-cli/internal/app/management" + "golang.org/x/term" ) func NewSetup(service managementservice.Service) commands.Command { @@ -35,7 +40,7 @@ func runSetup(service managementservice.Service) commands.Handler { if service == nil { return commands.WriteFailure(stderr, "SETUP_FAILED", "Unable to set up managed Chromium") } - result, err := service.Setup(ctx, browserSetupConfirmation(stdin, stderr)) + result, err := service.Setup(ctx, browserSetupConfirmation(terminalLoginPrompt(stdin, stderr))) if err != nil { return commands.WriteFailure(stderr, "SETUP_FAILED", "Unable to set up managed Chromium") } @@ -53,20 +58,47 @@ func runAuth(service managementservice.Service) commands.Handler { WriteAuthHelp(stdout) return commands.ExitSuccess } - if len(arguments) != 1 || arguments[0] != "login" { - return commands.WriteFailure(stderr, "INVALID_ARGUMENT", "Usage: twt auth login") + headless, valid := loginMode(arguments) + if !valid { + return commands.WriteFailure(stderr, "INVALID_ARGUMENT", "Usage: twt auth login [--headless|--headed]") } if service == nil { return commands.WriteFailure(stderr, "AUTHENTICATION_FAILED", "Unable to authenticate with X") } - result, err := service.Login(ctx, browserSetupConfirmation(stdin, stderr)) + prompt := terminalLoginPrompt(stdin, stderr) + options := managementservice.LoginOptions{Headless: headless} + if headless { + options.Prompt = prompt + } + result, err := service.Login(ctx, browserSetupConfirmation(prompt), options) if err != nil { - return commands.WriteFailure(stderr, "AUTHENTICATION_FAILED", "Unable to authenticate with X") + return writeManagementFailure(stderr, err, "AUTHENTICATION_FAILED", "Unable to authenticate with X") } return commands.WriteJSON(stdout, result, commands.ExitSuccess) } } +func loginMode(arguments []string) (headless, valid bool) { + if len(arguments) == 0 || arguments[0] != "login" || len(arguments) > 2 { + return false, false + } + if len(arguments) == 1 || arguments[1] == "--headed" { + return false, true + } + return arguments[1] == "--headless", arguments[1] == "--headless" +} + +func writeManagementFailure(output io.Writer, err error, code, message string) int { + var failure *app.OperationFailure + if errors.As(err, &failure) { + switch failure.Code { + case "AUTHENTICATION_FAILED", "AUTH_CHALLENGE_UNSUPPORTED", "AUTHENTICATION_REQUIRED", "AUTH_RATE_LIMITED", "AUTH_LOGIN_UNAVAILABLE": + return commands.WriteFailure(output, failure.Code, failure.Message) + } + } + return commands.WriteFailure(output, code, message) +} + func runContract(service managementservice.Service) commands.Handler { return func(ctx context.Context, arguments []string, _ io.Reader, stdout, stderr io.Writer) int { if helpRequested(arguments) || subcommandHelpRequested(arguments, "refresh") || subcommandHelpRequested(arguments, "status") { @@ -83,7 +115,7 @@ func runContract(service managementservice.Service) commands.Handler { case "refresh": result, err := service.RefreshContracts(ctx) if err != nil { - return commands.WriteFailure(stderr, "CONTRACT_REFRESH_FAILED", "Unable to refresh operation contracts") + return writeManagementFailure(stderr, err, "CONTRACT_REFRESH_FAILED", "Unable to refresh operation contracts") } return commands.WriteJSON(stdout, result, commands.ExitSuccess) case "status": @@ -94,32 +126,22 @@ func runContract(service managementservice.Service) commands.Handler { } } -func browserSetupConfirmation(input io.Reader, output io.Writer) managementservice.ConfirmBrowserSetup { - reader := bufio.NewReader(input) - return func(prompt managementservice.BrowserSetupPrompt) (bool, error) { - if prompt.Action == "cleanup" { - _, _ = fmt.Fprintf(output, "New managed Chromium revision %s is ready. Remove older managed revisions %s? Warning: older twt builds may still require them. [y/N] ", prompt.RequiredRevision, strings.Join(prompt.InstalledRevisions, ", ")) - return readConfirmation(reader) - } - action := prompt.Action - if action == "" { - action = "install" - } - if len(prompt.InstalledRevisions) > 0 { - _, _ = fmt.Fprintf(output, "Managed Chromium revisions %s are installed; revision %s is required. %s now? [y/N] ", strings.Join(prompt.InstalledRevisions, ", "), prompt.RequiredRevision, displayAction(action)) +func browserSetupConfirmation(prompt browser.LoginPromptFunc) managementservice.ConfirmBrowserSetup { + return func(setup managementservice.BrowserSetupPrompt) (bool, error) { + var label string + if setup.Action == "cleanup" { + label = fmt.Sprintf("New managed Chromium revision %s is ready. Remove older managed revisions %s? Warning: older twt builds may still require them. [y/N] ", setup.RequiredRevision, strings.Join(setup.InstalledRevisions, ", ")) + } else if len(setup.InstalledRevisions) > 0 { + label = fmt.Sprintf("Managed Chromium revisions %s are installed; revision %s is required. %s now? [y/N] ", strings.Join(setup.InstalledRevisions, ", "), setup.RequiredRevision, displayAction(setup.Action)) } else { - _, _ = fmt.Fprintf(output, "Managed Chromium revision %s is required. %s now? [y/N] ", prompt.RequiredRevision, displayAction(action)) + label = fmt.Sprintf("Managed Chromium revision %s is required. %s now? [y/N] ", setup.RequiredRevision, displayAction(setup.Action)) } - return readConfirmation(reader) - } -} - -func readConfirmation(reader *bufio.Reader) (bool, error) { - answer, err := reader.ReadString('\n') - if err != nil && len(answer) == 0 && err != io.EOF { - return false, err + answer, err := prompt(browser.LoginPrompt{Label: label}) + if err != nil && err != io.EOF { + return false, err + } + return strings.EqualFold(strings.TrimSpace(answer), "y") || strings.EqualFold(strings.TrimSpace(answer), "yes"), nil } - return strings.EqualFold(strings.TrimSpace(answer), "y") || strings.EqualFold(strings.TrimSpace(answer), "yes"), nil } func displayAction(action string) string { @@ -142,9 +164,41 @@ func WriteSetupHelp(output io.Writer) { } func WriteAuthHelp(output io.Writer) { - _, _ = io.WriteString(output, "Usage: twt auth login\n\nCheck the application profile headlessly, open it headed only when interactive X login is required, then capture and activate operation contracts.\n") + _, _ = io.WriteString(output, "Usage: twt auth login [--headless|--headed]\n\nLog in with the application Chromium profile, then capture and activate operation contracts. Headed login is the default and opens a visible browser window. Use --headless to enter credentials and verification codes through terminal prompts.\n") } func WriteContractHelp(output io.Writer) { _, _ = io.WriteString(output, "Usage: twt contract refresh|status\n\nRefresh operation contracts explicitly, or inspect local authentication and contract state without launching Chromium.\n") } + +func terminalLoginPrompt(input io.Reader, output io.Writer) browser.LoginPromptFunc { + reader := bufio.NewReader(input) + file, isFile := input.(*os.File) + isTerminal := isFile && term.IsTerminal(int(file.Fd())) + terminal := term.NewTerminal(struct { + io.Reader + io.Writer + }{input, output}, "") + return func(prompt browser.LoginPrompt) (string, error) { + if isTerminal { + state, err := term.MakeRaw(int(file.Fd())) + if err != nil { + return "", err + } + defer term.Restore(int(file.Fd()), state) + if prompt.Secret { + return terminal.ReadPassword(prompt.Label) + } + terminal.SetPrompt(prompt.Label) + return terminal.ReadLine() + } + if _, err := io.WriteString(output, prompt.Label); err != nil { + return "", err + } + value, err := reader.ReadString('\n') + if err != nil && !(err == io.EOF && len(value) > 0) { + return "", err + } + return strings.TrimSuffix(strings.TrimSuffix(value, "\n"), "\r"), nil + } +} diff --git a/internal/app/cli/dependencies/commands/management/management_test.go b/internal/app/cli/dependencies/commands/management/management_test.go index 0acb710..02403d3 100644 --- a/internal/app/cli/dependencies/commands/management/management_test.go +++ b/internal/app/cli/dependencies/commands/management/management_test.go @@ -2,15 +2,20 @@ package management import ( "bytes" + "context" + "errors" + "fmt" "strings" "testing" + app "github.com/ach968/x-twitter-cli/internal/app" + "github.com/ach968/x-twitter-cli/internal/app/browser" managementservice "github.com/ach968/x-twitter-cli/internal/app/management" ) func TestBrowserSetupConfirmationWarnsBeforeCleanup(t *testing.T) { var output bytes.Buffer - confirm := browserSetupConfirmation(strings.NewReader("yes\n"), &output) + confirm := browserSetupConfirmation(terminalLoginPrompt(strings.NewReader("yes\n"), &output)) accepted, err := confirm(managementservice.BrowserSetupPrompt{ Action: "cleanup", RequiredRevision: "1234", @@ -27,7 +32,7 @@ func TestBrowserSetupConfirmationWarnsBeforeCleanup(t *testing.T) { func TestBrowserSetupCleanupDefaultsToKeepingOldRevisions(t *testing.T) { var output bytes.Buffer - confirm := browserSetupConfirmation(strings.NewReader("\n"), &output) + confirm := browserSetupConfirmation(terminalLoginPrompt(strings.NewReader("\n"), &output)) accepted, err := confirm(managementservice.BrowserSetupPrompt{ Action: "cleanup", RequiredRevision: "1234", @@ -37,3 +42,155 @@ func TestBrowserSetupCleanupDefaultsToKeepingOldRevisions(t *testing.T) { t.Fatalf("accepted=%v err=%v", accepted, err) } } + +func TestTerminalLoginInputIsNotEchoedOrTrimmed(t *testing.T) { + var output bytes.Buffer + prompt := terminalLoginPrompt(strings.NewReader("user\n password with spaces \r\n012345\n"), &output) + for _, tc := range []struct { + label, want string + secret bool + }{ + {"Username: ", "user", false}, {"Password: ", " password with spaces ", true}, {"Code: ", "012345", true}, + } { + value, err := prompt(browser.LoginPrompt{Label: tc.label, Secret: tc.secret}) + if err != nil || value != tc.want { + t.Fatalf("value=%q err=%v", value, err) + } + } + if output.String() != "Username: Password: Code: " { + t.Fatal("input echoed") + } + if _, err := prompt(browser.LoginPrompt{Label: "Code: ", Secret: true}); err == nil { + t.Fatal("EOF accepted") + } +} + +type managementStub struct { + login func(managementservice.ConfirmBrowserSetup, managementservice.LoginOptions) (managementservice.StateChangeResult, error) + refresh func() (managementservice.StateChangeResult, error) +} + +func (*managementStub) Setup(context.Context, managementservice.ConfirmBrowserSetup) (managementservice.SetupResult, error) { + return managementservice.SetupResult{}, nil +} + +func (stub *managementStub) Login(_ context.Context, confirm managementservice.ConfirmBrowserSetup, options managementservice.LoginOptions) (managementservice.StateChangeResult, error) { + return stub.login(confirm, options) +} + +func (stub *managementStub) RefreshContracts(context.Context) (managementservice.StateChangeResult, error) { + return stub.refresh() +} + +func (*managementStub) ContractStatus(context.Context) managementservice.ContractStatus { + return managementservice.ContractStatus{} +} + +func TestLoginModeSelection(t *testing.T) { + for _, tc := range []struct { + arguments []string + headless bool + }{ + {[]string{"login"}, false}, + {[]string{"login", "--headless"}, true}, + {[]string{"login", "--headed"}, false}, + } { + t.Run(strings.Join(tc.arguments, " "), func(t *testing.T) { + calls := 0 + service := &managementStub{login: func(_ managementservice.ConfirmBrowserSetup, options managementservice.LoginOptions) (managementservice.StateChangeResult, error) { + calls++ + if options.Headless != tc.headless || (options.Prompt != nil) != tc.headless { + t.Fatalf("login options=%#v", options) + } + return managementservice.StateChangeResult{Status: "authenticated"}, nil + }} + var stdout, stderr bytes.Buffer + code := runAuth(service)(context.Background(), tc.arguments, strings.NewReader(""), &stdout, &stderr) + if code != 0 || calls != 1 || stderr.Len() != 0 || stdout.String() != "{\"status\":\"authenticated\",\"contract_path\":\"\"}\n" { + t.Fatalf("code=%d calls=%d stdout=%q stderr=%q", code, calls, stdout.String(), stderr.String()) + } + }) + } +} + +func TestLoginRejectsUnsupportedArguments(t *testing.T) { + for _, arguments := range [][]string{ + nil, {"login-api"}, {"login-api", "--help"}, {"login", "--headless", "--headed"}, + {"login", "--headless", "--headless"}, {"login", "--headed", "--headed"}, + {"login", "--headless=true"}, {"login", "--username=user"}, {"login", "unexpected"}, + } { + var stdout, stderr bytes.Buffer + code := runAuth(nil)(context.Background(), arguments, strings.NewReader(""), &stdout, &stderr) + if code == 0 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "INVALID_ARGUMENT") { + t.Fatalf("args=%v code=%d stdout=%q stderr=%q", arguments, code, stdout.String(), stderr.String()) + } + } +} + +func TestLoginSetupAndCredentialsShareInputWithoutEchoingSecrets(t *testing.T) { + service := &managementStub{login: func(confirm managementservice.ConfirmBrowserSetup, options managementservice.LoginOptions) (managementservice.StateChangeResult, error) { + accepted, err := confirm(managementservice.BrowserSetupPrompt{Action: "install", RequiredRevision: "1234"}) + if err != nil || !accepted { + t.Fatalf("accepted=%v error=%v", accepted, err) + } + for _, tc := range []struct { + label, want string + secret bool + }{ + {"Username: ", "account", false}, + {"Password: ", " my password ", true}, + {"Code: ", "012345", true}, + } { + answer, err := options.Prompt(browser.LoginPrompt{Label: tc.label, Secret: tc.secret}) + if err != nil || answer != tc.want { + t.Fatalf("answer=%q error=%v", answer, err) + } + } + return managementservice.StateChangeResult{Status: "authenticated"}, nil + }} + var stdout, stderr bytes.Buffer + code := runAuth(service)(context.Background(), []string{"login", "--headless"}, strings.NewReader("yes\naccount\n my password \n012345\n"), &stdout, &stderr) + if code != 0 { + t.Fatalf("code=%d stderr=%q", code, stderr.String()) + } + if strings.Contains(stderr.String(), "my password") || strings.Contains(stderr.String(), "012345") || strings.Contains(stdout.String(), "account") { + t.Fatal("login input leaked to command output") + } +} + +func TestManagementFailuresPreserveSafeAuthGuidanceAndMaskOtherErrors(t *testing.T) { + for _, tc := range []struct { + name string + err error + expected string + }{ + {"unknown", errors.New("password=secret;cookie=secret"), "Unable to authenticate with X"}, + {"challenge", &app.OperationFailure{Code: "AUTH_CHALLENGE_UNSUPPORTED", Message: "Complete this challenge with twt auth login --headed"}, "Complete this challenge with twt auth login --headed"}, + {"required", &app.OperationFailure{Code: "AUTHENTICATION_REQUIRED", Message: "Run twt auth login"}, "Run twt auth login"}, + {"rate limited", &app.OperationFailure{Code: "AUTH_RATE_LIMITED", Message: "X has temporarily limited login attempts; try again later"}, "X has temporarily limited login attempts; try again later"}, + {"login page error", &app.OperationFailure{Code: "AUTH_LOGIN_UNAVAILABLE", Message: "X could not load the next login step; try again later"}, "X could not load the next login step; try again later"}, + {"other typed error", &app.OperationFailure{Code: "TRANSPORT_ERROR", Message: "password=secret"}, "Unable to authenticate with X"}, + } { + t.Run(tc.name, func(t *testing.T) { + service := &managementStub{login: func(managementservice.ConfirmBrowserSetup, managementservice.LoginOptions) (managementservice.StateChangeResult, error) { + return managementservice.StateChangeResult{}, fmt.Errorf("wrapped: %w", tc.err) + }} + var stdout, stderr bytes.Buffer + code := runAuth(service)(context.Background(), []string{"login"}, strings.NewReader(""), &stdout, &stderr) + if code == 0 || stdout.Len() != 0 || !strings.Contains(stderr.String(), tc.expected) || strings.Contains(stderr.String(), "secret") { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + }) + } +} + +func TestContractRefreshShowsAuthenticationRecovery(t *testing.T) { + service := &managementStub{refresh: func() (managementservice.StateChangeResult, error) { + return managementservice.StateChangeResult{}, &app.OperationFailure{Code: "AUTHENTICATION_REQUIRED", Message: "Run twt auth login or twt auth login --headed"} + }} + var stdout, stderr bytes.Buffer + code := runContract(service)(context.Background(), []string{"refresh"}, strings.NewReader(""), &stdout, &stderr) + if code == 0 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "AUTHENTICATION_REQUIRED") || !strings.Contains(stderr.String(), "twt auth login --headed") { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } +} diff --git a/internal/app/management/management.go b/internal/app/management/management.go index 2a979f7..94d1b1d 100644 --- a/internal/app/management/management.go +++ b/internal/app/management/management.go @@ -70,7 +70,7 @@ func (manager *Manager) Setup(_ context.Context, confirm ConfirmBrowserSetup) (S }, nil } -func (manager *Manager) Login(ctx context.Context, confirm ConfirmBrowserSetup) (StateChangeResult, error) { +func (manager *Manager) Login(ctx context.Context, confirm ConfirmBrowserSetup, options LoginOptions) (StateChangeResult, error) { setup, err := manager.ensureChromium(confirm) if err != nil { return StateChangeResult{}, err @@ -78,7 +78,7 @@ func (manager *Manager) Login(ctx context.Context, confirm ConfirmBrowserSetup) if setup.Status != "ready" { return StateChangeResult{}, fmt.Errorf("managed Chromium %s was declined; run %s", setup.Action, setup.InstallCommand) } - capture, err := manager.captureWithAuthenticationFallback() + capture, err := manager.capture(ctx, options.Headless, true, options.Prompt) if err != nil { return StateChangeResult{}, err } @@ -96,7 +96,7 @@ func (manager *Manager) Login(ctx context.Context, confirm ConfirmBrowserSetup) } func (manager *Manager) RefreshContracts(ctx context.Context) (StateChangeResult, error) { - capture, err := manager.captureWithAuthenticationFallback() + capture, err := manager.capture(ctx, true, false, nil) if err != nil { return StateChangeResult{}, err } @@ -147,28 +147,28 @@ func (manager *Manager) ensureChromium(confirm ConfirmBrowserSetup) (browser.Chr }) } -func (manager *Manager) capture(headless bool, timeout time.Duration) (app.CapturedState, error) { - return manager.options.Capture(browser.ContractCaptureOptions{ +func (manager *Manager) capture(ctx context.Context, headless, login bool, prompt browser.LoginPromptFunc) (app.CapturedState, error) { + capture, err := manager.options.Capture(browser.ContractCaptureOptions{ + Context: ctx, ProfilePath: manager.options.Paths.ProfilePath, Headless: headless, - Timeout: timeout, + Login: login, + LoginPrompt: prompt, + Timeout: time.Minute, Steps: []browser.ContractCaptureStep{ {URL: "https://x.com/search?q=x&src=typed_query", WaitFor: []app.OperationName{app.SearchTimeline}, TriggerPostView: true}, {URL: "https://x.com/i/bookmarks", WaitFor: []app.OperationName{app.Bookmarks}, TriggerBookmarkSearch: true}, }, }) -} - -func (manager *Manager) captureWithAuthenticationFallback() (app.CapturedState, error) { - capture, err := manager.capture(true, time.Minute) - if err == nil { - return capture, nil - } var authenticationRequired *browser.AuthenticationRequiredError - if !errors.As(err, &authenticationRequired) { - return app.CapturedState{}, err + if errors.As(err, &authenticationRequired) { + return app.CapturedState{}, &app.OperationFailure{ + Code: "AUTHENTICATION_REQUIRED", + Message: "X authentication is required; run twt auth login or twt auth login --headed", + RecoveryCommand: "twt auth login", + } } - return manager.capture(false, 10*time.Minute) + return capture, err } func (manager *Manager) persistAndActivate(ctx context.Context, capture app.CapturedState, generator app.TransactionIDGenerator) error { diff --git a/internal/app/management/management_test.go b/internal/app/management/management_test.go index 52deec6..bf2bfc7 100644 --- a/internal/app/management/management_test.go +++ b/internal/app/management/management_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "testing" + "time" app "github.com/ach968/x-twitter-cli/internal/app" "github.com/ach968/x-twitter-cli/internal/app/browser" @@ -96,7 +97,7 @@ func TestLoginCapturesPersistsAndActivatesState(t *testing.T) { options := serviceOptions(paths) options.Capture = func(options browser.ContractCaptureOptions) (app.CapturedState, error) { captureCalls++ - if !options.Headless || options.ProfilePath != paths.ProfilePath { + if !options.Headless || !options.Login || options.ProfilePath != paths.ProfilePath || options.Context == nil || options.Timeout != time.Minute { t.Fatalf("capture options = %#v", options) } if len(options.Steps) != 2 || options.Steps[1].URL != "https://x.com/i/bookmarks" || !options.Steps[1].TriggerBookmarkSearch { @@ -109,7 +110,7 @@ func TestLoginCapturesPersistsAndActivatesState(t *testing.T) { } service := management.New(options) - result, err := service.Login(context.Background(), func(management.BrowserSetupPrompt) (bool, error) { return true, nil }) + result, err := service.Login(context.Background(), func(management.BrowserSetupPrompt) (bool, error) { return true, nil }, management.LoginOptions{Headless: true}) if err != nil || result.Status != "authenticated" || result.AuthenticationPath != paths.AuthenticationPath || result.ContractPath != paths.ActiveContractPath { t.Fatalf("result=%#v err=%v", result, err) } @@ -130,38 +131,59 @@ func TestLoginCapturesPersistsAndActivatesState(t *testing.T) { } } -func TestLoginUsesHeadedBrowserOnlyWhenAuthenticationIsRequired(t *testing.T) { - paths := testPaths(t) - options := serviceOptions(paths) - var headlessValues []bool - options.Capture = func(input browser.ContractCaptureOptions) (app.CapturedState, error) { - headlessValues = append(headlessValues, input.Headless) - if input.Headless { - return app.CapturedState{}, &browser.AuthenticationRequiredError{URL: "https://x.com/i/flow/login"} - } - return capturedState(), nil - } - service := management.New(options) - result, err := service.Login(context.Background(), func(management.BrowserSetupPrompt) (bool, error) { return true, nil }) - if err != nil || result.Status != "authenticated" { - t.Fatalf("result=%#v err=%v", result, err) - } - if len(headlessValues) != 2 || !headlessValues[0] || headlessValues[1] { - t.Fatalf("headless sequence=%v", headlessValues) +func TestLoginUsesSelectedBrowserOnce(t *testing.T) { + for _, headless := range []bool{true, false} { + t.Run(map[bool]string{true: "headless", false: "headed"}[headless], func(t *testing.T) { + options := serviceOptions(testPaths(t)) + ctx := context.WithValue(context.Background(), struct{}{}, "test") + captureCalls := 0 + promptCalls := 0 + prompt := func(browser.LoginPrompt) (string, error) { promptCalls++; return "user", nil } + options.Capture = func(input browser.ContractCaptureOptions) (app.CapturedState, error) { + captureCalls++ + if input.Headless != headless || !input.Login || input.Context != ctx { + t.Fatalf("capture options=%#v", input) + } + if value, err := input.LoginPrompt(browser.LoginPrompt{Label: "Username: "}); err != nil || value != "user" { + t.Fatalf("prompt value=%q error=%v", value, err) + } + return capturedState(), nil + } + result, err := management.New(options).Login(ctx, func(management.BrowserSetupPrompt) (bool, error) { return true, nil }, management.LoginOptions{Headless: headless, Prompt: prompt}) + if err != nil || result.Status != "authenticated" || captureCalls != 1 || promptCalls != 1 { + t.Fatalf("result=%#v err=%v captures=%d prompts=%d", result, err, captureCalls, promptCalls) + } + }) } +} - unknownFailure := errors.New("capture transport failed") - headlessValues = nil - options.Capture = func(input browser.ContractCaptureOptions) (app.CapturedState, error) { - headlessValues = append(headlessValues, input.Headless) - return app.CapturedState{}, unknownFailure - } - service = management.New(options) - if _, err := service.Login(context.Background(), func(management.BrowserSetupPrompt) (bool, error) { return true, nil }); !errors.Is(err, unknownFailure) { - t.Fatalf("error=%v", err) - } - if len(headlessValues) != 1 || !headlessValues[0] { - t.Fatalf("unknown failure unexpectedly opened headed browser: %v", headlessValues) +func TestLoginFailureDoesNotChangeBrowserModeOrStoredState(t *testing.T) { + for _, failure := range []error{errors.New("capture transport failed"), &browser.AuthenticationRequiredError{URL: "https://x.com/i/flow/login"}} { + paths := testPaths(t) + if err := state.SaveCaptured(paths.ActiveContractPath, paths.AuthenticationPath, capturedState()); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(paths.AuthenticationPath) + if err != nil { + t.Fatal(err) + } + options := serviceOptions(paths) + captureCalls := 0 + options.Capture = func(input browser.ContractCaptureOptions) (app.CapturedState, error) { + captureCalls++ + if !input.Headless { + t.Fatal("failure opened a headed browser") + } + return app.CapturedState{}, failure + } + _, err = management.New(options).Login(context.Background(), func(management.BrowserSetupPrompt) (bool, error) { return true, nil }, management.LoginOptions{Headless: true}) + if err == nil || captureCalls != 1 { + t.Fatalf("error=%v captures=%d", err, captureCalls) + } + after, err := os.ReadFile(paths.AuthenticationPath) + if err != nil || string(after) != string(before) { + t.Fatal("failed login changed authentication") + } } } @@ -191,38 +213,23 @@ func TestSetupReportsManagedChromiumOutcome(t *testing.T) { } } -func TestContractRefreshUsesHeadedBrowserOnlyWhenAuthenticationIsRequired(t *testing.T) { - paths := testPaths(t) - options := serviceOptions(paths) - var headlessValues []bool +func TestContractRefreshRequiresExplicitLoginAndNeverFallsBack(t *testing.T) { + options := serviceOptions(testPaths(t)) + captureCalls := 0 options.Capture = func(input browser.ContractCaptureOptions) (app.CapturedState, error) { - headlessValues = append(headlessValues, input.Headless) - if input.Headless { - return app.CapturedState{}, &browser.AuthenticationRequiredError{URL: "https://x.com/i/flow/login"} + captureCalls++ + if !input.Headless || input.Login || input.LoginPrompt != nil { + t.Fatalf("refresh login options=%#v", input) } - return capturedState(), nil + return app.CapturedState{}, &browser.AuthenticationRequiredError{URL: "https://x.com/i/flow/login"} } - service := management.New(options) - result, err := service.RefreshContracts(context.Background()) - if err != nil || result.Status != "refreshed" || result.ContractPath != paths.ActiveContractPath { - t.Fatalf("result=%#v err=%v", result, err) - } - if len(headlessValues) != 2 || !headlessValues[0] || headlessValues[1] { - t.Fatalf("headless sequence=%v", headlessValues) - } - - unknownFailure := errors.New("capture transport failed") - headlessValues = nil - options.Capture = func(input browser.ContractCaptureOptions) (app.CapturedState, error) { - headlessValues = append(headlessValues, input.Headless) - return app.CapturedState{}, unknownFailure - } - service = management.New(options) - if _, err := service.RefreshContracts(context.Background()); !errors.Is(err, unknownFailure) { + _, err := management.New(options).RefreshContracts(context.Background()) + var failure *app.OperationFailure + if !errors.As(err, &failure) || failure.Code != "AUTHENTICATION_REQUIRED" || failure.RecoveryCommand != "twt auth login" { t.Fatalf("error=%v", err) } - if len(headlessValues) != 1 || !headlessValues[0] { - t.Fatalf("unknown failure unexpectedly opened headed browser: %v", headlessValues) + if captureCalls != 1 { + t.Fatalf("capture calls=%d", captureCalls) } } diff --git a/internal/app/management/types.go b/internal/app/management/types.go index d88e8cb..ab8725e 100644 --- a/internal/app/management/types.go +++ b/internal/app/management/types.go @@ -2,7 +2,11 @@ // operation-contract maintenance. Data commands do not call this package. package management -import "context" +import ( + "context" + + "github.com/ach968/x-twitter-cli/internal/app/browser" +) type BrowserSetupPrompt struct { Action string @@ -12,6 +16,11 @@ type BrowserSetupPrompt struct { type ConfirmBrowserSetup func(BrowserSetupPrompt) (bool, error) +type LoginOptions struct { + Headless bool + Prompt browser.LoginPromptFunc +} + type SetupResult struct { Status string `json:"status"` ExecutablePath string `json:"executable_path,omitempty"` @@ -38,7 +47,7 @@ type ContractStatus struct { type Service interface { Setup(context.Context, ConfirmBrowserSetup) (SetupResult, error) - Login(context.Context, ConfirmBrowserSetup) (StateChangeResult, error) + Login(context.Context, ConfirmBrowserSetup, LoginOptions) (StateChangeResult, error) RefreshContracts(context.Context) (StateChangeResult, error) ContractStatus(context.Context) ContractStatus }