diff --git a/internal/api/oauth.go b/internal/api/oauth.go index 488210e..ff25766 100644 --- a/internal/api/oauth.go +++ b/internal/api/oauth.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" ) @@ -264,12 +265,17 @@ func (c *OAuthClient) PollToken(ctx context.Context, auth *DeviceAuth, sleep fun // Refresh exchanges a refresh token for a new access token. The server may // rotate the refresh token; callers must persist tr.RefreshToken if non-empty. func (c *OAuthClient) Refresh(ctx context.Context, refreshToken string) (*TokenResponse, error) { - body, _ := json.Marshal(map[string]string{ - "grant_type": grantTypeRefreshToken, - "refresh_token": refreshToken, - "client_id": ClientID, - }) - status, raw, err := c.post(ctx, pathToken, body) + // The /token endpoint is the @node-oauth/oauth2-server token handler, which + // REQUIRES application/x-www-form-urlencoded (it rejects JSON with "content + // must be application/x-www-form-urlencoded"). Unlike the custom device-flow + // endpoints (/device, /device-token) which accept JSON via post(), the refresh + // grant must be form-encoded. + form := url.Values{ + "grant_type": {grantTypeRefreshToken}, + "refresh_token": {refreshToken}, + "client_id": {ClientID}, + } + status, raw, err := c.postForm(ctx, pathToken, form) if err != nil { return nil, err } @@ -308,6 +314,25 @@ func (c *OAuthClient) post(ctx context.Context, path string, body []byte) (int, return resp.StatusCode, raw, nil } +// postForm sends an application/x-www-form-urlencoded POST. Used for the OAuth +// /token endpoint (the refresh grant), whose @node-oauth/oauth2-server handler +// requires form encoding and rejects JSON. +func (c *OAuthClient) postForm(ctx context.Context, path string, form url.Values) (int, []byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, strings.NewReader(form.Encode())) + if err != nil { + return 0, nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := c.HTTP.Do(req) + if err != nil { + return 0, nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return resp.StatusCode, raw, nil +} + // oauthMsg extracts a human message from an OAuth error body. func oauthMsg(raw []byte) string { var oe oauthErr diff --git a/internal/api/oauth_test.go b/internal/api/oauth_test.go index 4a50d61..07a0c1d 100644 --- a/internal/api/oauth_test.go +++ b/internal/api/oauth_test.go @@ -152,10 +152,19 @@ func TestRefreshRotatesToken(t *testing.T) { if r.URL.Path != pathToken { t.Errorf("refresh hit wrong path %q", r.URL.Path) } - var body map[string]string - _ = json.NewDecoder(r.Body).Decode(&body) - if body["grant_type"] != grantTypeRefreshToken || body["refresh_token"] != "old-rt" { - t.Errorf("refresh body = %v", body) + // The /token route (the @node-oauth/oauth2-server token handler) REQUIRES + // application/x-www-form-urlencoded and rejects JSON with "content must be + // application/x-www-form-urlencoded". Assert the CLI sends form encoding — + // this is the regression guard for the bug where Refresh() posted JSON and + // the real server rejected every refresh after the 1h access-token TTL. + if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" { + t.Errorf("refresh Content-Type = %q, want application/x-www-form-urlencoded", ct) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + if r.PostFormValue("grant_type") != grantTypeRefreshToken || r.PostFormValue("refresh_token") != "old-rt" { + t.Errorf("refresh form = %v", r.PostForm) } _ = json.NewEncoder(w).Encode(TokenResponse{ AccessToken: "new-at", ExpiresIn: 3600, RefreshToken: "new-rt", Scope: DeviceScope,