Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,36 @@ 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

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. 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

### Measurements (scales, BP monitors, ECG)
Expand Down
18 changes: 17 additions & 1 deletion cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,28 @@ 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 {
return fmt.Errorf("not logged in — run: withings-export auth login")
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) {
Expand Down
61 changes: 61 additions & 0 deletions cmd/auth_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 4 additions & 1 deletion cmd/prime.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +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. 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.

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
71 changes: 66 additions & 5 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -42,11 +46,35 @@ func configPath() string {
return filepath.Join(home, ".config", "withings-export", "auth.json")
}

// GetToken returns a valid access token, refreshing if needed.
// 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 WITHINGS_REFRESH_TOKEN env var, then the saved token file
// (CONTRACT.md §5 precedence).
func GetToken() (string, error) {
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")
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 {
Expand All @@ -56,6 +84,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(refreshTokenEnvVar))
}

// 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 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 %s)", refreshTokenEnvVar)
}
id, secret := CredentialsFromEnv()
if id == "" || secret == "" {
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
}

// Login runs the OAuth2 authorization-code flow against a local callback server.
// clientID and clientSecret are the developer credentials from dev.withings.com.
//
Expand Down Expand Up @@ -206,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 {
Expand Down
Loading
Loading