From f42bafd512b2d69a17b66bc8431a1a60e09f4f8d Mon Sep 17 00:00:00 2001
From: DTTerastar
Date: Mon, 20 Apr 2026 16:00:01 -0400
Subject: [PATCH] Fix token refresh: accept numeric userid from Withings
The /v2/oauth2 endpoint returns userid as a JSON string on the
initial authorization_code grant but as a JSON number on the
refresh_token grant. tokenResponse.UserID was typed as string,
so every refresh failed with:
cannot unmarshal number into Go struct field tokenResponse.userid
of type string
leaving users locked out after the ~3h access token expiry until
they re-ran auth login. Switch the field to json.Number, which
unmarshals from either form, and call .String() at the two assign
sites. TokenStore.UserID stays string on disk, so existing
auth.json files remain valid.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
internal/auth/auth.go | 18 +++++++++---------
internal/auth/auth_test.go | 30 ++++++++++++++++++++++++++++++
2 files changed, 39 insertions(+), 9 deletions(-)
create mode 100644 internal/auth/auth_test.go
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
index 4ae05b4..384112c 100644
--- a/internal/auth/auth.go
+++ b/internal/auth/auth.go
@@ -178,7 +178,7 @@ func exchangeCode(code, redirectURI, clientID, clientSecret string) error {
AccessToken: resp.AccessToken,
RefreshToken: resp.RefreshToken,
ExpiresAt: time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Add(-5 * time.Minute),
- UserID: resp.UserID,
+ UserID: resp.UserID.String(),
ClientID: clientID,
ClientSecret: clientSecret,
}
@@ -201,19 +201,19 @@ func refresh(store *TokenStore) error {
store.AccessToken = resp.AccessToken
store.RefreshToken = resp.RefreshToken
store.ExpiresAt = time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Add(-5 * time.Minute)
- if resp.UserID != "" {
- store.UserID = resp.UserID
+ if resp.UserID.String() != "" {
+ store.UserID = resp.UserID.String()
}
return save(store)
}
type tokenResponse struct {
- UserID string `json:"userid"`
- AccessToken string `json:"access_token"`
- RefreshToken string `json:"refresh_token"`
- ExpiresIn int `json:"expires_in"`
- Scope string `json:"scope"`
- TokenType string `json:"token_type"`
+ UserID json.Number `json:"userid"`
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn int `json:"expires_in"`
+ Scope string `json:"scope"`
+ TokenType string `json:"token_type"`
}
func postToken(form url.Values, out *tokenResponse) error {
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
new file mode 100644
index 0000000..d77a704
--- /dev/null
+++ b/internal/auth/auth_test.go
@@ -0,0 +1,30 @@
+package auth
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+// 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.
+func TestTokenResponse_UserIDUnmarshalsStringAndNumber(t *testing.T) {
+ cases := []struct {
+ name string
+ body string
+ }{
+ {"string form (initial login)", `{"userid":"12345","access_token":"a","refresh_token":"r","expires_in":10800,"scope":"s","token_type":"Bearer"}`},
+ {"number form (refresh)", `{"userid":12345,"access_token":"a","refresh_token":"r","expires_in":10800,"scope":"s","token_type":"Bearer"}`},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ var resp tokenResponse
+ if err := json.Unmarshal([]byte(tc.body), &resp); err != nil {
+ t.Fatalf("unmarshal failed: %v", err)
+ }
+ if resp.UserID.String() != "12345" {
+ t.Fatalf("UserID = %q, want %q", resp.UserID.String(), "12345")
+ }
+ })
+ }
+}