Skip to content

auth: honor WITHINGS_REFRESH_TOKEN for headless use#43

Merged
DTTerastar merged 3 commits into
mainfrom
headless-refresh-token
Jul 24, 2026
Merged

auth: honor WITHINGS_REFRESH_TOKEN for headless use#43
DTTerastar merged 3 commits into
mainfrom
headless-refresh-token

Conversation

@DTTerastar

@DTTerastar DTTerastar commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Blocked on quantcli/common#28, which writes the precedence rule this PR now implements into CONTRACT.md §5. Merge that first.

Problem

GetToken() only read ~/.config/withings-export/auth.json, so headless runs (CI, containers) with no interactive auth login failed with not logged in — run: withings-export auth login even when WITHINGS_REFRESH_TOKEN was set. CONTRACT.md §5 already advertises that var as the headless path, and crono/liftoff honor it — withings was the outlier. (Found it failing in the quantified dashboard's data-refresh CI.)

Change

  • GetToken(): resolve WITHINGS_REFRESH_TOKEN before the token file, building a store from it plus WITHINGS_CLIENT_ID/SECRET and minting an access token through the existing refresh() path.
  • auth status: same order — reports using WITHINGS_REFRESH_TOKEN (headless; no saved token consulted), still no network call.
  • refresh(): the token-file write is now best-effort.
  • prime + README document the headless var and its precedence.
  • Unit tests for both fixes plus the three auth status branches.

Precedence: env wins

The first draft of this PR had the file win and the env var act as a fallback. That was wrong on consistency grounds: liftoff already checks LIFTOFF_REFRESH_TOKEN before the token file (internal/auth/auth.go:84), so the same variable pattern would have resolved in opposite directions across two CLIs — WITHINGS_REFRESH_TOKEN=… withings-export … silently ignoring the variable on a machine with a saved login, while the identical liftoff invocation honored it. quantcli/common#28 rules the environment wins; this now matches.

A failed cache write is no longer fatal

refresh() ended with return save(store), so a successfully minted access token still surfaced as token refresh failed: … whenever the token file could not be written — read-only rootfs, distroless, or no HOME (configPath() swallows the UserHomeDir error and writes a relative .config/withings-export/auth.json into CWD). That is exactly the environment this PR exists to serve. The write is now best-effort: warning on stderr, exit 0, per §4/§5.

Rotation

Unlike liftoff, the headless path here still persists, because Withings rotates the refresh token on every use — without the write-back the injected secret is unrecoverable after one run. Two consequences, both documented rather than solved:

  • On a shared machine, using the env var overwrites the interactive user's auth.json. Noted at the call site.
  • If the write fails, the export still succeeds but the rotated token is lost. The README tells repeat jobs to mount a writable path.

A static WITHINGS_REFRESH_TOKEN remains single-use; the quantified workflow captures the rotated value and rewrites the secret.

Drive-by

auth refresh is gone from README and primeinit() only ever registered login/logout/status, so the documented command fell through to help.

Test

gofmt / go build ./... / go vet ./... / go test ./... green, and the -tags=compat conformance suite still passes against a fresh build.

The refresh path was previously untestable without rotating my own real token, so tokenURL becomes a var an httptest server can replace. That buys hermetic coverage of both fixes:

  • TestGetToken_EnvWinsOverSavedToken — saved token is valid and unexpired, so it would be used if the file won; also asserts the rotated value reaches disk.
  • TestGetToken_HeadlessSurvivesUnwritableTokenFile — config dir chmod 0500, mint succeeds, warning on stderr, no error. Skips if permissions aren't enforced (root).
  • cmd/auth_test.go — the three auth status branches, mirroring liftoff's.

Precedence also verified end-to-end against my real (expired) auth.json, no network:

$ ./withings-export auth status
Error: token expired 2026-04-25T23:28:01-04:00 — run: withings-export auth login   # exit 1

$ WITHINGS_REFRESH_TOKEN=bogus WITHINGS_CLIENT_ID=cid WITHINGS_CLIENT_SECRET=sec ./withings-export auth status
using WITHINGS_REFRESH_TOKEN (headless; no saved token consulted)                  # exit 0

Live E2E is still the quantified CI run once released — deliberately not run here, since a live refresh would rotate and invalidate my own local token.

🤖 Generated with Claude Code

https://claude.ai/code/session_012MvqxTC64Z9EEDewCUbNNo

GetToken only ever read ~/.config/withings-export/auth.json, so a CI or
container run with no interactive 'auth login' failed with 'not logged in'
even when WITHINGS_REFRESH_TOKEN was set — the contract (§5) already
advertises that var, and crono/liftoff honor it.

Add an env fallback: when no token file is present, build the store from
WITHINGS_REFRESH_TOKEN + WITHINGS_CLIENT_ID/SECRET and mint an access token
via the existing refresh path. 'auth status' reports the env path; prime and
README document it. Note the Withings refresh-token rotation caveat for
long-running headless callers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DTTerastar and others added 2 commits July 23, 2026 21:32
…fatal

Two fixes to the headless path, both from reviewing it against
liftoff-export-cli, which shipped this feature first.

Precedence was inverted. liftoff checks LIFTOFF_REFRESH_TOKEN before the
token file (internal/auth/auth.go:84); this checked the file first and used
the env var only as a fallback. Same variable pattern, opposite resolution:
on a machine with a saved login, WITHINGS_REFRESH_TOKEN=… was silently
ignored here while the identical liftoff invocation honored it. quantcli/
common#28 rules the environment wins; GetToken and `auth status` now do.

A failed token-file write was fatal, in the exact environment this feature
targets. refresh() ended with `return save(store)`, so a successfully minted
access token still surfaced as "token refresh failed: …" whenever the cache
could not be written — read-only rootfs, distroless, or no HOME (configPath
swallows the UserHomeDir error and writes a *relative* path into CWD). The
write is now best-effort: a warning on stderr, exit 0, per CONTRACT.md §4/§5.

Unlike liftoff the headless path still persists, because Withings rotates the
refresh token on every use — without the write-back the injected secret is
unrecoverable after one run. The trade-off (on a shared machine this
overwrites the interactive user's token) is noted at the call site, and the
README now says that losing the write costs you the rotated token.

Tests: the refresh path was previously untestable without rotating the
caller's real token, so tokenURL becomes a var an httptest server can
replace. That buys hermetic coverage of both fixes — env-wins-over-a-valid-
saved-token (asserting the rotated value reaches disk) and
survives-an-unwritable-token-file — plus cmd/auth_test.go for the three
`auth status` branches, mirroring liftoff's.

Also drops `auth refresh` from README and prime: init() only ever registered
login/logout/status, so the documented command fell through to help.

Refs quantcli/common#28

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MvqxTC64Z9EEDewCUbNNo
govulncheck fails on the 1.25.10 standard library: GO-2026-5037
(crypto/x509 hostname parsing), GO-2026-5039 (net/textproto error
escaping), and GO-2026-5856 (crypto/tls), the last fixed only in
1.25.12. All are reached through pre-existing call paths (client.Call,
auth.Login) and are unrelated to the auth change on this branch — main
last ran green on 2026-05-25, before these were published, so it would
fail there too today.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MvqxTC64Z9EEDewCUbNNo
@DTTerastar
DTTerastar force-pushed the headless-refresh-token branch from 94e2ce7 to c82de14 Compare July 24, 2026 01:37
@DTTerastar
DTTerastar merged commit 535b4d1 into main Jul 24, 2026
6 checks passed
@DTTerastar
DTTerastar deleted the headless-refresh-token branch July 24, 2026 01:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant