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") + } + }) + } +}