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