From e7f5dc75ecbe4dcedc0b9d18c9f6fdb579aee1d5 Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Thu, 23 Jul 2026 21:18:53 -0400
Subject: [PATCH 1/3] auth: honor WITHINGS_REFRESH_TOKEN for headless use
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
README.md | 17 ++++++++++++++++
cmd/auth.go | 10 +++++++++-
cmd/prime.go | 1 +
internal/auth/auth.go | 36 +++++++++++++++++++++++++++++++--
internal/auth/auth_test.go | 41 ++++++++++++++++++++++++++++++++++++++
5 files changed, 102 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 27577dd..908d469 100644
--- a/README.md
+++ b/README.md
@@ -102,6 +102,23 @@ withings-export auth logout # Remove stored tokens
withings-export auth refresh # Force refresh and print status
```
+### Headless / CI
+
+Where there's no browser to run `auth login`, supply the refresh token via the
+environment instead of the token file (quantcli [contract](https://github.com/quantcli/common/blob/main/CONTRACT.md#5-auth) §5):
+
+```sh
+export WITHINGS_CLIENT_ID=...
+export WITHINGS_CLIENT_SECRET=...
+export WITHINGS_REFRESH_TOKEN=... # from a prior local `auth login` (see auth.json)
+withings-export measurements --since 30d
+```
+
+The CLI mints an access token from the refresh token on demand. **Withings
+rotates refresh tokens on each refresh**, so a static `WITHINGS_REFRESH_TOKEN`
+is single-use — a long-running job must capture the rotated token (written to
+`~/.config/withings-export/auth.json`) and feed it back for the next run.
+
## Usage
### Measurements (scales, BP monitors, ECG)
diff --git a/cmd/auth.go b/cmd/auth.go
index 68586e6..a2ed07f 100644
--- a/cmd/auth.go
+++ b/cmd/auth.go
@@ -94,7 +94,15 @@ https://github.com/quantcli/common/blob/main/CONTRACT.md#5-auth`,
RunE: func(cmd *cobra.Command, args []string) error {
store, err := auth.Load()
if err != nil {
- return fmt.Errorf("not logged in — run: withings-export auth login")
+ if auth.EnvRefreshToken() != "" {
+ id, secret := auth.CredentialsFromEnv()
+ if id == "" || secret == "" {
+ return fmt.Errorf("WITHINGS_REFRESH_TOKEN is set but WITHINGS_CLIENT_ID / WITHINGS_CLIENT_SECRET are missing")
+ }
+ fmt.Fprintln(cmd.OutOrStdout(), "using WITHINGS_REFRESH_TOKEN (env); access token minted on demand")
+ return nil
+ }
+ return fmt.Errorf("not logged in — run: withings-export auth login (or set WITHINGS_REFRESH_TOKEN)")
}
exp := store.ExpiresAt.Local().Format(time.RFC3339)
if time.Now().After(store.ExpiresAt) {
diff --git a/cmd/prime.go b/cmd/prime.go
index 23d6ab9..7ac95bf 100644
--- a/cmd/prime.go
+++ b/cmd/prime.go
@@ -23,6 +23,7 @@ AUTH
withings-export auth refresh|logout
Optional env: WITHINGS_CLIENT_ID, WITHINGS_CLIENT_SECRET, WITHINGS_CALLBACK_URL.
+ Headless (CI): set WITHINGS_REFRESH_TOKEN (+ WITHINGS_CLIENT_ID/SECRET) to skip auth login.
HTTPS-callback workaround: register https://redirectmeto.com/http://localhost:8128/oauth/authorize
(verbatim) and set WITHINGS_CALLBACK_URL to the same string.
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
index b494b6e..496a0e3 100644
--- a/internal/auth/auth.go
+++ b/internal/auth/auth.go
@@ -42,11 +42,17 @@ func configPath() string {
return filepath.Join(home, ".config", "withings-export", "auth.json")
}
-// GetToken returns a valid access token, refreshing if needed.
+// GetToken returns a valid access token, refreshing if needed. Auth resolves
+// in order: the saved token file, then the WITHINGS_REFRESH_TOKEN env var (the
+// headless path required by quantcli CONTRACT.md §5, for CI and containers with
+// no interactive `auth login`).
func GetToken() (string, error) {
store, err := load()
if err != nil {
- return "", fmt.Errorf("not logged in — run: withings-export auth login")
+ store, err = envStore()
+ if err != nil {
+ return "", err
+ }
}
if time.Now().After(store.ExpiresAt) {
if err := refresh(store); err != nil {
@@ -56,6 +62,32 @@ func GetToken() (string, error) {
return store.AccessToken, nil
}
+// EnvRefreshToken returns the refresh token supplied via WITHINGS_REFRESH_TOKEN,
+// or "" if unset. This is the headless auth path from CONTRACT.md §5.
+func EnvRefreshToken() string {
+ return strings.TrimSpace(os.Getenv("WITHINGS_REFRESH_TOKEN"))
+}
+
+// envStore builds a TokenStore from the headless env vars. The returned store
+// has a zero (already-elapsed) ExpiresAt, so GetToken mints an access token from
+// the refresh token before first use. Requires WITHINGS_REFRESH_TOKEN plus the
+// client credentials (WITHINGS_CLIENT_ID / WITHINGS_CLIENT_SECRET).
+//
+// Note: Withings rotates refresh tokens on each refresh, so a fresh token is
+// saved to ~/.config/withings-export/auth.json after minting. Long-running
+// headless callers must persist that rotated token back for the next run.
+func envStore() (*TokenStore, error) {
+ rt := EnvRefreshToken()
+ if rt == "" {
+ return nil, fmt.Errorf("not logged in — run: withings-export auth login (or set WITHINGS_REFRESH_TOKEN)")
+ }
+ id, secret := CredentialsFromEnv()
+ if id == "" || secret == "" {
+ return nil, fmt.Errorf("WITHINGS_REFRESH_TOKEN is set but WITHINGS_CLIENT_ID / WITHINGS_CLIENT_SECRET are missing")
+ }
+ return &TokenStore{RefreshToken: rt, ClientID: id, ClientSecret: secret}, nil
+}
+
// Login runs the OAuth2 authorization-code flow against a local callback server.
// clientID and clientSecret are the developer credentials from dev.withings.com.
//
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
index d77a704..a2154e4 100644
--- a/internal/auth/auth_test.go
+++ b/internal/auth/auth_test.go
@@ -3,8 +3,49 @@ package auth
import (
"encoding/json"
"testing"
+ "time"
)
+// envStore is the headless auth path (CONTRACT.md §5): build a TokenStore from
+// WITHINGS_REFRESH_TOKEN + client creds, with an already-elapsed ExpiresAt so
+// GetToken mints an access token before first use.
+func TestEnvStore(t *testing.T) {
+ t.Setenv("WITHINGS_REFRESH_TOKEN", " rt-abc ")
+ t.Setenv("WITHINGS_CLIENT_ID", "cid")
+ t.Setenv("WITHINGS_CLIENT_SECRET", "csecret")
+
+ if got := EnvRefreshToken(); got != "rt-abc" {
+ t.Fatalf("EnvRefreshToken = %q, want %q (trimmed)", got, "rt-abc")
+ }
+ s, err := envStore()
+ if err != nil {
+ t.Fatalf("envStore: %v", err)
+ }
+ if s.RefreshToken != "rt-abc" || s.ClientID != "cid" || s.ClientSecret != "csecret" {
+ t.Fatalf("envStore = %+v, want rt/cid/csecret", s)
+ }
+ if !time.Now().After(s.ExpiresAt) {
+ t.Fatal("ExpiresAt should be already-elapsed so GetToken refreshes on first use")
+ }
+}
+
+func TestEnvStore_Errors(t *testing.T) {
+ t.Run("no refresh token", func(t *testing.T) {
+ t.Setenv("WITHINGS_REFRESH_TOKEN", "")
+ if _, err := envStore(); err == nil {
+ t.Fatal("want error when WITHINGS_REFRESH_TOKEN unset")
+ }
+ })
+ t.Run("refresh token but no client creds", func(t *testing.T) {
+ t.Setenv("WITHINGS_REFRESH_TOKEN", "rt")
+ t.Setenv("WITHINGS_CLIENT_ID", "")
+ t.Setenv("WITHINGS_CLIENT_SECRET", "")
+ if _, err := envStore(); err == nil {
+ t.Fatal("want error when client creds missing")
+ }
+ })
+}
+
// Withings returns userid as a JSON string on the initial authorization_code
// grant and as a JSON number on the refresh_token grant. tokenResponse.UserID
// must unmarshal both without error — json.Number accepts either form.
From b2432b759d520dd3dba6e7deca857b0bb0e25887 Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Thu, 23 Jul 2026 21:32:49 -0400
Subject: [PATCH 2/3] auth: env wins over the saved token, and a failed cache
write is not fatal
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_012MvqxTC64Z9EEDewCUbNNo
---
README.md | 19 +++++--
cmd/auth.go | 24 ++++++---
cmd/auth_test.go | 61 ++++++++++++++++++++++
cmd/prime.go | 6 ++-
internal/auth/auth.go | 55 +++++++++++++++-----
internal/auth/auth_test.go | 100 +++++++++++++++++++++++++++++++++++++
6 files changed, 237 insertions(+), 28 deletions(-)
create mode 100644 cmd/auth_test.go
diff --git a/README.md b/README.md
index 908d469..1f5ab93 100644
--- a/README.md
+++ b/README.md
@@ -98,8 +98,8 @@ Tokens are stored at `~/.config/withings-export/auth.json` (mode `0600`). Access
```sh
withings-export auth login # OAuth2 in your browser
+withings-export auth status # One-line readiness check, no network call
withings-export auth logout # Remove stored tokens
-withings-export auth refresh # Force refresh and print status
```
### Headless / CI
@@ -114,10 +114,19 @@ export WITHINGS_REFRESH_TOKEN=... # from a prior local `auth login` (see auth.
withings-export measurements --since 30d
```
-The CLI mints an access token from the refresh token on demand. **Withings
-rotates refresh tokens on each refresh**, so a static `WITHINGS_REFRESH_TOKEN`
-is single-use — a long-running job must capture the rotated token (written to
-`~/.config/withings-export/auth.json`) and feed it back for the next run.
+The CLI mints an access token from the refresh token on demand. When
+`WITHINGS_REFRESH_TOKEN` is set it **takes precedence over a saved
+`auth.json`** — a container with a stale mounted config and a freshly injected
+secret uses the secret. `auth status` reports the env as the source and exits 0
+without a network call, so exit 0 there means "a token was supplied", not "the
+token works".
+
+**Withings rotates refresh tokens on each refresh**, so a static
+`WITHINGS_REFRESH_TOKEN` is single-use — a repeat job must capture the rotated
+token (written to `~/.config/withings-export/auth.json`) and feed it back for
+the next run. If that file can't be written (read-only rootfs, no `HOME`) the
+export still succeeds and prints a warning to stderr, but the rotated token is
+lost — mount a writable path for it on any job that runs more than once.
## Usage
diff --git a/cmd/auth.go b/cmd/auth.go
index a2ed07f..888e2a5 100644
--- a/cmd/auth.go
+++ b/cmd/auth.go
@@ -89,19 +89,27 @@ This is a local check — no network call and no refresh is attempted, even
when the saved token is expired. Any export subcommand will refresh
automatically when needed.
+If WITHINGS_REFRESH_TOKEN is set it wins over any saved token, and status
+reports that instead. Its validity is unknown without a network call, so
+exit 0 there means "a token was supplied", not "the token works".
+
Per the quantcli shared contract:
https://github.com/quantcli/common/blob/main/CONTRACT.md#5-auth`,
RunE: func(cmd *cobra.Command, args []string) error {
+ // Headless mode wins over the saved token file (CONTRACT.md §5), and
+ // has no expiry to report until a refresh happens — report the source
+ // and stop. The token file may not exist at all in a container.
+ if auth.EnvRefreshToken() != "" {
+ id, secret := auth.CredentialsFromEnv()
+ if id == "" || secret == "" {
+ return fmt.Errorf("WITHINGS_REFRESH_TOKEN is set but WITHINGS_CLIENT_ID / WITHINGS_CLIENT_SECRET are missing")
+ }
+ fmt.Fprintln(cmd.OutOrStdout(), "using WITHINGS_REFRESH_TOKEN (headless; no saved token consulted)")
+ return nil
+ }
+
store, err := auth.Load()
if err != nil {
- if auth.EnvRefreshToken() != "" {
- id, secret := auth.CredentialsFromEnv()
- if id == "" || secret == "" {
- return fmt.Errorf("WITHINGS_REFRESH_TOKEN is set but WITHINGS_CLIENT_ID / WITHINGS_CLIENT_SECRET are missing")
- }
- fmt.Fprintln(cmd.OutOrStdout(), "using WITHINGS_REFRESH_TOKEN (env); access token minted on demand")
- return nil
- }
return fmt.Errorf("not logged in — run: withings-export auth login (or set WITHINGS_REFRESH_TOKEN)")
}
exp := store.ExpiresAt.Local().Format(time.RFC3339)
diff --git a/cmd/auth_test.go b/cmd/auth_test.go
new file mode 100644
index 0000000..91ab8fa
--- /dev/null
+++ b/cmd/auth_test.go
@@ -0,0 +1,61 @@
+package cmd
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+// With WITHINGS_REFRESH_TOKEN set, 'auth status' reports the headless source
+// and exits 0 without consulting the saved token file — that file may not
+// exist at all in a container. Precedence is CONTRACT.md §5.
+func TestAuthStatus_HeadlessEnvWins(t *testing.T) {
+ t.Setenv("WITHINGS_REFRESH_TOKEN", "rt-from-env")
+ t.Setenv("WITHINGS_CLIENT_ID", "cid")
+ t.Setenv("WITHINGS_CLIENT_SECRET", "csecret")
+ t.Setenv("HOME", t.TempDir()) // no auth.json anywhere
+
+ var out bytes.Buffer
+ statusCmd.SetOut(&out)
+ t.Cleanup(func() { statusCmd.SetOut(nil) })
+
+ if err := statusCmd.RunE(statusCmd, nil); err != nil {
+ t.Fatalf("headless status should succeed with no saved token, got: %v", err)
+ }
+ if !strings.Contains(out.String(), "WITHINGS_REFRESH_TOKEN") {
+ t.Errorf("status should name the token source, got: %q", out.String())
+ }
+}
+
+// Withings needs client credentials to redeem a refresh token, so a refresh
+// token alone is not a usable headless setup — say so rather than exiting 0
+// and failing later at the first API call.
+func TestAuthStatus_HeadlessMissingClientCreds(t *testing.T) {
+ t.Setenv("WITHINGS_REFRESH_TOKEN", "rt-from-env")
+ t.Setenv("WITHINGS_CLIENT_ID", "")
+ t.Setenv("WITHINGS_CLIENT_SECRET", "")
+ t.Setenv("HOME", t.TempDir())
+
+ err := statusCmd.RunE(statusCmd, nil)
+ if err == nil {
+ t.Fatal("expected an error when client credentials are missing")
+ }
+ if !strings.Contains(err.Error(), "WITHINGS_CLIENT_ID") {
+ t.Errorf("error should name the missing vars, got: %v", err)
+ }
+}
+
+// Without the env var and without a saved token, status still fails and
+// points at both recovery paths.
+func TestAuthStatus_NoTokenFails(t *testing.T) {
+ t.Setenv("WITHINGS_REFRESH_TOKEN", "")
+ t.Setenv("HOME", t.TempDir())
+
+ err := statusCmd.RunE(statusCmd, nil)
+ if err == nil {
+ t.Fatal("expected an error when no token is available")
+ }
+ if !strings.Contains(err.Error(), "WITHINGS_REFRESH_TOKEN") {
+ t.Errorf("error should mention the headless option, got: %v", err)
+ }
+}
diff --git a/cmd/prime.go b/cmd/prime.go
index 7ac95bf..2a8d51e 100644
--- a/cmd/prime.go
+++ b/cmd/prime.go
@@ -20,10 +20,12 @@ I/O
AUTH
withings-export auth login OAuth2 in browser; tokens stored locally.
withings-export auth status Exit 0 if usable, 1 with reason. No network call.
- withings-export auth refresh|logout
+ withings-export auth logout Remove stored tokens.
Optional env: WITHINGS_CLIENT_ID, WITHINGS_CLIENT_SECRET, WITHINGS_CALLBACK_URL.
- Headless (CI): set WITHINGS_REFRESH_TOKEN (+ WITHINGS_CLIENT_ID/SECRET) to skip auth login.
+ Headless (CI): set WITHINGS_REFRESH_TOKEN (+ WITHINGS_CLIENT_ID/SECRET) to skip auth
+ login. It wins over any saved token. Withings rotates it on each use — the rotated
+ value lands in ~/.config/withings-export/auth.json; re-inject it on the next run.
HTTPS-callback workaround: register https://redirectmeto.com/http://localhost:8128/oauth/authorize
(verbatim) and set WITHINGS_CALLBACK_URL to the same string.
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
index 496a0e3..b88da00 100644
--- a/internal/auth/auth.go
+++ b/internal/auth/auth.go
@@ -18,9 +18,13 @@ import (
"time"
)
+// tokenURL is a var, not a const, so tests can point the token exchange at an
+// httptest server. Refreshing against live Withings rotates the caller's real
+// refresh token, so the refresh path is untestable without this seam.
+var tokenURL = "https://wbsapi.withings.net/v2/oauth2"
+
const (
- authURL = "https://account.withings.com/oauth2_user/authorize2"
- tokenURL = "https://wbsapi.withings.net/v2/oauth2"
+ authURL = "https://account.withings.com/oauth2_user/authorize2"
// user.activity covers activity, intraday, workouts, and sleep endpoints.
// user.metrics covers measurements and heart-rate endpoints.
// user.info covers device listing. user.sleepevents is webhook-only and unused here.
@@ -42,17 +46,35 @@ func configPath() string {
return filepath.Join(home, ".config", "withings-export", "auth.json")
}
+// refreshTokenEnvVar is the headless auth path required by CONTRACT.md §5: a
+// fresh container exports without an interactive login. When set it takes
+// precedence over the saved token file — a container with a stale mounted
+// config and a freshly injected secret must use the secret.
+const refreshTokenEnvVar = "WITHINGS_REFRESH_TOKEN"
+
// GetToken returns a valid access token, refreshing if needed. Auth resolves
-// in order: the saved token file, then the WITHINGS_REFRESH_TOKEN env var (the
-// headless path required by quantcli CONTRACT.md §5, for CI and containers with
-// no interactive `auth login`).
+// in order: the WITHINGS_REFRESH_TOKEN env var, then the saved token file
+// (CONTRACT.md §5 precedence).
func GetToken() (string, error) {
- store, err := load()
- if err != nil {
- store, err = envStore()
+ if EnvRefreshToken() != "" {
+ store, err := envStore()
if err != nil {
return "", err
}
+ // Unlike liftoff-export, the headless path here persists: Withings
+ // rotates the refresh token on every use, so the rotated value must
+ // reach disk or the injected secret is unrecoverable after one run.
+ // Trade-off: on a shared machine this overwrites the interactive
+ // user's saved token. The write is best-effort (see refresh).
+ if err := refresh(store); err != nil {
+ return "", fmt.Errorf("%s refresh failed: %w", refreshTokenEnvVar, err)
+ }
+ return store.AccessToken, nil
+ }
+
+ store, err := load()
+ if err != nil {
+ return "", fmt.Errorf("not logged in — run: withings-export auth login (or set %s)", refreshTokenEnvVar)
}
if time.Now().After(store.ExpiresAt) {
if err := refresh(store); err != nil {
@@ -65,7 +87,7 @@ func GetToken() (string, error) {
// EnvRefreshToken returns the refresh token supplied via WITHINGS_REFRESH_TOKEN,
// or "" if unset. This is the headless auth path from CONTRACT.md §5.
func EnvRefreshToken() string {
- return strings.TrimSpace(os.Getenv("WITHINGS_REFRESH_TOKEN"))
+ return strings.TrimSpace(os.Getenv(refreshTokenEnvVar))
}
// envStore builds a TokenStore from the headless env vars. The returned store
@@ -75,15 +97,15 @@ func EnvRefreshToken() string {
//
// Note: Withings rotates refresh tokens on each refresh, so a fresh token is
// saved to ~/.config/withings-export/auth.json after minting. Long-running
-// headless callers must persist that rotated token back for the next run.
+// headless callers must read that rotated token back and re-inject it.
func envStore() (*TokenStore, error) {
rt := EnvRefreshToken()
if rt == "" {
- return nil, fmt.Errorf("not logged in — run: withings-export auth login (or set WITHINGS_REFRESH_TOKEN)")
+ return nil, fmt.Errorf("not logged in — run: withings-export auth login (or set %s)", refreshTokenEnvVar)
}
id, secret := CredentialsFromEnv()
if id == "" || secret == "" {
- return nil, fmt.Errorf("WITHINGS_REFRESH_TOKEN is set but WITHINGS_CLIENT_ID / WITHINGS_CLIENT_SECRET are missing")
+ return nil, fmt.Errorf("%s is set but WITHINGS_CLIENT_ID / WITHINGS_CLIENT_SECRET are missing", refreshTokenEnvVar)
}
return &TokenStore{RefreshToken: rt, ClientID: id, ClientSecret: secret}, nil
}
@@ -238,7 +260,14 @@ func refresh(store *TokenStore) error {
if resp.UserID.String() != "" {
store.UserID = resp.UserID.String()
}
- return save(store)
+ // Best-effort persist, per CONTRACT.md §5: the access token just minted is
+ // usable whether or not the cache write lands, and a read-only rootfs (or a
+ // container with no HOME) is a normal shape for the headless path. A failed
+ // write is a stderr warning, not an error — §4 keeps stdout data-only.
+ if err := save(store); err != nil {
+ fmt.Fprintf(os.Stderr, "withings-export: could not persist rotated refresh token: %v\n", err)
+ }
+ return nil
}
type tokenResponse struct {
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
index a2154e4..ccb5435 100644
--- a/internal/auth/auth_test.go
+++ b/internal/auth/auth_test.go
@@ -2,10 +2,110 @@ package auth
import (
"encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
"testing"
"time"
)
+// stubTokenEndpoint points the token exchange at a local server returning a
+// well-formed Withings envelope, so refresh paths are testable without
+// rotating (and thereby invalidating) a real refresh token.
+func stubTokenEndpoint(t *testing.T) {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"status":0,"body":{"userid":"12345",
+ "access_token":"at-new","refresh_token":"rt-rotated","expires_in":10800}}`))
+ }))
+ t.Cleanup(srv.Close)
+
+ orig := tokenURL
+ tokenURL = srv.URL
+ t.Cleanup(func() { tokenURL = orig })
+}
+
+// A minted access token is usable whether or not the cache write lands, so an
+// unwritable token file must not fail the export — read-only rootfs and a
+// container with no writable HOME are the environments the headless path
+// exists to serve. CONTRACT.md §5; the warning goes to stderr per §4.
+func TestGetToken_HeadlessSurvivesUnwritableTokenFile(t *testing.T) {
+ stubTokenEndpoint(t)
+
+ home := t.TempDir()
+ // Create the config dir, then drop write permission: save()'s MkdirAll then
+ // succeeds on the existing dir and WriteFile is what fails — the same shape
+ // as a read-only rootfs.
+ cfg := filepath.Join(home, ".config", "withings-export")
+ if err := os.MkdirAll(cfg, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(cfg, 0o500); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.Chmod(cfg, 0o700) }) // let TempDir clean up
+ if err := os.WriteFile(filepath.Join(cfg, "probe"), nil, 0o600); err == nil {
+ t.Skip("filesystem permissions not enforced (running as root?)")
+ }
+ t.Setenv("HOME", home)
+
+ t.Setenv("WITHINGS_REFRESH_TOKEN", "rt-injected")
+ t.Setenv("WITHINGS_CLIENT_ID", "cid")
+ t.Setenv("WITHINGS_CLIENT_SECRET", "csecret")
+
+ tok, err := GetToken()
+ if err != nil {
+ t.Fatalf("headless GetToken must succeed when the token file is unwritable, got: %v", err)
+ }
+ if tok != "at-new" {
+ t.Errorf("GetToken = %q, want the freshly minted %q", tok, "at-new")
+ }
+}
+
+// The env var wins over a saved token file (CONTRACT.md §5): a container with
+// a stale mounted config and a freshly injected secret must use the secret.
+func TestGetToken_EnvWinsOverSavedToken(t *testing.T) {
+ stubTokenEndpoint(t)
+
+ t.Setenv("HOME", t.TempDir())
+ if err := save(&TokenStore{
+ AccessToken: "at-from-file",
+ RefreshToken: "rt-from-file",
+ ExpiresAt: time.Now().Add(time.Hour), // valid: would be used if the file won
+ ClientID: "cid",
+ ClientSecret: "csecret",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Setenv("WITHINGS_REFRESH_TOKEN", "rt-injected")
+ t.Setenv("WITHINGS_CLIENT_ID", "cid")
+ t.Setenv("WITHINGS_CLIENT_SECRET", "csecret")
+
+ tok, err := GetToken()
+ if err != nil {
+ t.Fatalf("GetToken: %v", err)
+ }
+ if tok == "at-from-file" {
+ t.Fatal("saved token was used; WITHINGS_REFRESH_TOKEN must take precedence")
+ }
+ if tok != "at-new" {
+ t.Errorf("GetToken = %q, want the env-minted %q", tok, "at-new")
+ }
+
+ // Withings rotates on every refresh, so the rotated value must reach disk
+ // for a repeat caller to re-inject it.
+ saved, err := load()
+ if err != nil {
+ t.Fatalf("load after refresh: %v", err)
+ }
+ if saved.RefreshToken != "rt-rotated" {
+ t.Errorf("persisted refresh token = %q, want the rotated %q", saved.RefreshToken, "rt-rotated")
+ }
+}
+
// envStore is the headless auth path (CONTRACT.md §5): build a TokenStore from
// WITHINGS_REFRESH_TOKEN + client creds, with an already-elapsed ExpiresAt so
// GetToken mints an access token before first use.
From c82de141f29ee533da95995ad22a5752df45b6c0 Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Thu, 23 Jul 2026 21:35:06 -0400
Subject: [PATCH 3/3] chore(security): bump go directive to 1.25.12
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
Claude-Session: https://claude.ai/code/session_012MvqxTC64Z9EEDewCUbNNo
---
go.mod | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/go.mod b/go.mod
index 028257b..dbb80f9 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/quantcli/withings-export-cli
-go 1.25.10
+go 1.25.12
require (
github.com/quantcli/common/compat v0.0.0-20260511001927-62c4c6634ff5