auth: LIFTOFF_REFRESH_TOKEN headless path (#55)#56
Conversation
fb71ffa to
10b9838
Compare
Google Sign-In accounts have no password, so 'auth login' — which POSTs
email+password to user.signIn — has no credential pair to send and the
backend rejects password reset for them outright. Those accounts had no
working path to any authenticated subcommand.
Also closes a standing gap against quantcli/common CONTRACT §5, which
requires every CLI to accept {SERVICE}_* env vars so a fresh container
can export without an interactive login. liftoff-export had no headless
path at all.
Set LIFTOFF_REFRESH_TOKEN and the saved token file is bypassed entirely,
neither read nor written — writing would clobber the tokens of whoever
is logged in on the same machine. Costs one refresh call per invocation,
which is the right trade for a CLI that runs once per export.
Splits the unexported refresh() out of Refresh() so the headless path can
exchange tokens without persisting. Refresh() keeps its save-on-success
behavior for the interactive path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013JJnPtEtP97to9Rv2QMoer
10b9838 to
684ef8f
Compare
Try this PR without building itReview binaries for commit
Unzip, then on macOS/Linux Links are resolved by nightly.link, a third-party redirector for GitHub Actions artifacts, because GitHub has no anonymous artifact URL of its own. Signed-in users can also browse the run directly. Artifacts expire 14 days after the run. Updated on each push to this PR. |
Please test @KushH305 |
Addresses the first half of #55.
Problem
auth loginPOSTs email + password touser.signIn. Accounts created via Google Sign-In have no password on the backend, and the backend explicitly refuses to provision one ("Your account was created with Google, simply login with Google!"). Those accounts have no working path toauth login,auth refresh,auth status, or any export subcommand.Separately, liftoff-export had no headless auth path at all, which is a standing gap against
commonCONTRACT §5: "every CLI accepts environment variables that let a fresh container run without an interactive login. The variable names are{SERVICE}_*." HenceLIFTOFF_REFRESH_TOKEN, matchingWITHINGS_REFRESH_TOKEN.Change
Set
LIFTOFF_REFRESH_TOKENand it takes precedence over the saved token file, which is then neither read nor written. Not persisting is deliberate: writing would clobber the tokens of whoever is interactively logged in on the same machine. It costs one refresh call per invocation, which is the right trade for a CLI that runs once per export.The refresh token is the right thing to inject rather than an access token —
user.refreshTokenneeds no password, so one injection keeps working instead of expiring in an hour.Mechanically:
refresh()is split out ofRefresh()so the headless path can exchange tokens without persisting.Refresh()keeps save-on-success for the interactive path.auth statusreports the headless source, and the "not logged in" error now names both recovery paths.Verification
Exercised against a real account with
HOMEpointed at a nonexistent directory, so no token file could be read:The logged-in user's
auth.jsonwas byte-identical (sha1) before and after. Also checked:LIFTOFF_REFRESH_TOKEN=fake auth status→using LIFTOFF_REFRESH_TOKEN (headless; no saved token consulted), exit 0LIFTOFF_REFRESH_TOKEN=bogus workouts list→Error: LIFTOFF_REFRESH_TOKEN refresh failed: refresh failed: Session not found, exit 1logged in (token expires ...)Two tests cover the
auth statusfork withHOMEredirected to a temp dir.go vet ./...andgo test ./...pass.What this does NOT fix
This does not by itself unblock the reporter of #55. An env var only helps someone who already has a refresh token, and a Google-only account still has no way to obtain one short of a mitmproxy capture against the phone app — a heavy ask for someone on Windows running a downloaded zip.
The actual fix is an OAuth path. There's a promising thread:
Login()hardcodes"provider": "gymbros"in theuser.signInpayload, which strongly implies the backend accepts"google"with some credential — plausibly a Google ID token we could mint via a loopback flow, since installed-app client IDs carry no secret. That's blocked on knowing the exact payload shape for a Google sign-in, which needs a capture from someone who has such an account.So this PR is worth landing on its own merits (contract compliance, CI/container use, and an escape hatch for anyone who can capture a token), but #55 should stay open.
🤖 Generated with Claude Code
https://claude.ai/code/session_013JJnPtEtP97to9Rv2QMoer